Web Challenge IRIS CTF 2025 - Political
A detailed walkthrough of the Political web challenge from IRIS CTF 2025, covering token validation, admin cookie exploitation, and URL encoding techniques.
SOURCE CODE REVIEW
When accessing the site https://political-web.chal.irisc.tf/
, a random hex token is generated by the /token
endpoint:
1
2
3
4
5
6
7
8
9
@app.route("/token")
def tok():
token = secrets.token_hex(16)
valid_tokens[token] = False
return token
- The token generated is initially not valid because
valid_tokens[token] = False
.
GETTING PATHWAYS FOR THE FLAG
The /redeem
endpoint handles flag retrieval:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@app.route("/redeem", methods=["POST"])
def redeem():
if "token" not in request.form:
return "Give me token"
token = request.form["token"]
if token not in valid_tokens or valid_tokens[token] != True:
return "Nice try."
return FLAG
To retrieve the flag:
- The token must be supplied via the request form (
if "token" not in request.form
). - The token must exist in the
valid_tokens
dictionary and must beTrue
.
If these conditions are met, the FLAG
is returned. Otherwise, the function exits with one of the failure messages.
INSERT TOKEN INTO VALID_TOKENS
The goal is to make the token valid. The /giveflag
function allows this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@app.route("/giveflag")
def hello_world():
if "token" not in request.args or "admin" not in request.cookies:
return "Who are you?"
token = request.args["token"]
admin = request.cookies["admin"]
if token not in valid_tokens or admin != ADMIN:
return "Why are you?"
valid_tokens[token] = True
return "GG"
Key requirements:
- Provide the token as a query parameter and include the
admin
cookie (if "token" not in request.args or "admin" not in request.cookies
). - The token must exist in
valid_tokens
, and the cookie must matchADMIN
(if token not in valid_tokens or admin != ADMIN
).
If successful, the token is added to valid_tokens
and set to True
. This allows access to /redeem
to get the flag.
GETTING VISITED WITH ADMIN COOKIE
The bot reads the admin
cookie from the file system:
1
let cookie = JSON.parse(fs.readFileSync('/home/user/cookie'));
cookie structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "admin",
"value": "redacted",
"domain": "localhost:5000",
"url": "http://localhost:5000/",
"path": "/",
"httpOnly": true,
"secure": true
}
- infers as admin cookie
The Puppeteer bot:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
const puppeteer = require('puppeteer');
const fs = require('fs');
const net = require('net');
const BOT_TIMEOUT = process.env.BOT_TIMEOUT || 2*1000;
const puppeter_args = {};
(async function(){
const browser = await puppeteer.launch(puppeter_args);
function ask_for_url(socket) {
socket.state = 'URL';
socket.write('Please send me a URL to open.\n');
}
async function load_url(socket, data) {
let url = data.toString().trim();
console.log(`checking url: ${url}`);
// replace with your server as needed
if (!url.startsWith('http://localhost:1337/') && !url.startsWith('https://localhost:1337/')) {
socket.state = 'ERROR';
socket.write('Invalid URL (must start with http:// or https://).\n');
socket.destroy();
return;
}
socket.state = 'LOADED';
let cookie = JSON.parse(fs.readFileSync('/home/user/cookie'));
const context = await browser.createBrowserContext();
const page = await context.newPage();
await page.setJavaScriptEnabled(false);
await page.setCookie(cookie);
socket.write(`Loading page ${url}.\n`);
setTimeout(()=>{
try {
context.close();
socket.write('timeout\n');
socket.destroy();
} catch (err) {
console.log(`err: ${err}`);
}
}, BOT_TIMEOUT);
await page.goto(url);
}
var server = net.createServer();
server.listen(1338);
console.log('listening on port 1338');
server.on('connection', socket=>{
socket.on('data', data=>{
try {
if (socket.state == 'URL') {
load_url(socket, data);
}
} catch (err) {
console.log(`err: ${err}`);
}
});
try {
ask_for_url(socket);
} catch (err) {
console.log(`err: ${err}`);
}
});
})();
A Rough Bot Work Flow :
- Prompts the user to provide a URL. (line 14)
- Validates the input URL to ensure it starts with https://political-web.chal.irisc.tf/. (line 21)
- Opens a new browser context. (line 33)
- Sets the admin cookie from the file
/home/user/cookie
. (line 33) - Loads the provided URL in a page with JavaScript disabled. (line 44)
URL CONSTRUCTION
The input URL must:
- Start with
https://political-web.chal.irisc.tf/
. - Satisfy Chrome’s URL policy (
policy.json
).
Construct the URL:
https://political-web.chal.irisc.tf/giveflag?token=<generated token via /token>
CHROME POLICY AND URL ENCODING
Due to Chrome’s URL blocklist behavior, encoded characters in the URL are treated literally. This means:
Chrome’s URL blocklist typically operates on the raw URL string without normalizing or decoding it. This means that encoded characters are treated as literal parts of the URL rather than their decoded equivalents.
- URL encoding the constructed URL bypasses the policy:
https://political-web.chal.irisc.tf/giv%65flag?tok%65n=ff71b78003353e5c769e68cb310a13eb
When the bot processes this URL:
- It visits the
/giveflag
endpoint with theadmin
cookie. - The
valid_tokens[token]
is set toTrue
.
1
2
3
4
5
6
└─$ nc political-bot.chal.irisc.tf 1337
== proof-of-work: disabled ==
Please send me a URL to open.
https://political-web.chal.irisc.tf/giv%65flag?tok%65n=ff71b78003353e5c769e68cb310a13eb
Loading page https://political-web.chal.irisc.tf/giv%65flag?tok%65n=ff71b78003353e5c769e68cb310a13eb.
timeout
The timeout is not an issue because the time taken to render the webpage exceeds the timeout duration specified in the environment variable (
BOT_TIMEOUT
) . but the bot visits with the admin cookie and token is insterted into valid cookie
FLAG RETRIEVAL
Finally, access the /redeem
endpoint with the token to retrieve the flag.
1
irisctf{flag_blocked_by_admin}