Logo flocto's blog
LakeCTF Finals 2025 Writeups

LakeCTF Finals 2025 Writeups

May 12, 2025
79 min read
Table of Contents
index

Recently we (.;,;.) got to travel to Switzerland and participate in the 2025 LakeCTF Finals at EPFL Lausanne. It was a great experience and we all had a ton of fun traveling and competing.

Unfortunately, we didn’t manage to win, only getting 4th place. Still, I really enjoyed the challenges, so here’s writeups for the challenges I solved, which were mostly rev with one misc.

misc - Craft The Flag

Oh wait CTF doesn’t mean Craft The Flag

nc chall.polygl0ts.ch 9098

Points: 110 | Solves: 10

challenge_fakeflag.sh
#!/bin/bash
20 collapsed lines
# Set colors for a more dynamic experience
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
RESET='\033[0m'
# ASCII Minecraft-style art (for the intro)
echo -e "${CYAN}---------------------------------------------------"
echo -e " ${GREEN}Welcome to my new 1.8.8 Minecraft Server!${RESET}"
echo -e " ${YELLOW}Log in and come play with us!${RESET}"
echo -e "---------------------------------------------------"
echo -e ""
echo -e "${YELLOW}Type your username to get started!${RESET}"
echo -e ""
# Sleep for dramatic effect :dramatic:
sleep 1
while true; do
echo -e "${CYAN}----------------------------${RESET}"
echo -e "${GREEN}${##} - Log in${RESET}"
echo -e "${RED}$((${##}<<${##})) - Exit${RESET}"
echo -e "${CYAN}----------------------------${RESET}"
echo -e "Choose an option: "
read choice
if [ "$choice" -eq 1 ]; then
echo -e "${YELLOW}Enter your Minecraft username:${RESET}"
read username
echo -e ""
if [ ! -f whitelist.json ]; then
echo -e "${RED}Error: whitelist.json not found! Please contact the admin.${RESET}"
continue
fi
if jq -e --arg name "$username" '.[] | select(. == $name)' whitelist.json > /dev/null; then
echo -e "${GREEN}Access granted! Welcome, ${CYAN}$username${GREEN}!${RESET}"
echo -e ""
echo -e "${CYAN}Enjoy your stay on our Minecraft server!${RESET}"
echo -e "flag : EPFL{FAKE}"
else
echo -e "${RED}Access denied! You are not on the whitelist.${RESET}"
echo -e ""
echo -e "${CYAN}Try again later!${RESET}"
fi
elif [ "$choice" -eq 2 ]; then
echo "$ "
read -r input
if [[ "$input" =~ [[:alnum:]] || ${#input} -gt 110 ]]; then
echo "Error: Rejected." >&2
continue
else
eval "bash -c \"$input\""
fi
else
echo "Invalid input. Please enter 1 or 2."
fi
echo
done
Dockerfile
11 collapsed lines
# Stage 1: Build the minimal environment
FROM ubuntu:20.04 AS base
# Install only what’s necessary
RUN apt-get update && \
apt-get install -y --no-install-recommends \
bash \
jq \
coreutils && \
rm -rf /var/lib/apt/lists/*
# Create restricted bin
RUN mkdir -p /restricted-bin && \
cp /bin/ls /restricted-bin/ && \
cp /bin/cat /restricted-bin/
9 collapsed lines
# Create user and home
RUN useradd -ms /bin/rbash ctfuser
WORKDIR /home/ctfuser
# Copy the challenge files
COPY challenge.sh /home/ctfuser/challenge_script.sh
COPY whitelist.json /app/whitelist.json
# Permissions and restricted profile
RUN echo 'export PATH=/restricted-bin' >> /home/ctfuser/.bash_profile && \
chmod +x /home/ctfuser/challenge_script.sh
17 collapsed lines
# Stage 2: Final jail
FROM pwn.red/jail
# Copy the entire restricted environment
COPY --from=base / /srv
# redpwn/jail expects a /srv/app/run entrypoint
COPY --from=base /home/ctfuser/challenge_script.sh /srv/app/run
# Make sure /srv/app/run is executable
RUN chmod +x /srv/app/run
ENV JAIL_TIME 120
ENV JAIL_CONNS_PER_IP 5
ENV JAIL_MEM 20M
ENV JAIL_TMP_SIZE 20M

With a quick parse-through of the challenge, we can see a pretty clear hole in the script.

Terminal window
elif [ "$choice" -eq 2 ]; then
echo "$ "
read -r input
if [[ "$input" =~ [[:alnum:]] || ${#input} -gt 110 ]]; then
echo "Error: Rejected." >&2
continue
else
eval "bash -c \"$input\""
fi

As long as we can produce some input that does not contain alphanumeric characters, we can run arbitrary bash commands.

One special feature of bash that fits perfectly here is using wildcards to glob files. This allows us to execute files even if we can’t spell them out by name.

Checking the Dockerfile, we see that there are helpful cat and ls binaries in /restricted-bin. This lets us run cat by using /*-*/??? instead, since after expansion, the only valid path will end up being /restricted-bin/cat.

From here, we can simply allow it to print every file in the directory, which should print the original sh script and give us the flag.

Terminal window
$ nc chall.polygl0ts.ch 9098
---------------------------------------------------
Welcome to my new 1.8.8 Minecraft Server!
Log in and come play with us!
---------------------------------------------------
Type your username to get started!
----------------------------
1 - Log in
2 - Exit
----------------------------
Choose an option:
2
$
/*-*/??? *
... [truncated]
echo -e "${GREEN}Access granted! Welcome, ${CYAN}$username${GREEN}!${RESET}"
echo -e ""
echo -e "${CYAN}Enjoy your stay on our Minecraft server!${RESET}"
echo -e "flag : EPFL{LooKs_l1k3_4lph4numeric_filter_1s_n0t_enough}"
... [truncated]

rev - Fastprocess

Classic example of a bad rev challenge. File ‘solutions.txt’ is only an example. On the remote, it is regenerated every run.

nc chall.polygl0ts.ch 9070

Points: 116 | Solves: 9

main | solutions.txt

solutions.txt
100 collapsed lines
419039093524866021 5253480278082363392
129512260564226652 13124866128201383936
749069362794463437 1232252044832186368
335140905473764197 14871695962828439552
335574193081417235 10650717383824457728
256966611085784470 7817403002779959296
788172764199840145 5481958494201839616
964226096677086814 5756354812978974720
75846918916011634 3688607283484295168
846608871809048473 4253718553447359808
218703394089169872 8196101361308802880
499013370692702107 1846328187118682112
945012864030497780 7768503082148691968
19502795608119922 17475123775309332480
109309650614386697 5731122308704003712
455169381196390377 8505588835330692002
382060665718823014 10367968462000619520
991515285034548022 5762003957672520380
694955152716075095 9606573056292028416
784309398688803593 9316358259134693376
139655098495424036 1783217031878017024
460276414731333146 9975190514690949120
69316359781098132 2199409486861434880
804346961375328652 8051030820439195648
402975832422002478 6777181155091152896
742590227786291609 1974932880912147277
1208462624441848 16013984677819842560
51168125059044772 8116719871330353151
929563989532142684 212405186500921600
276624970662116728 13317267436710395904
266852951274819606 7308905345671168000
485870317150999840 11966129992063801344
609992127017103343 5519573816147029760
366828048311940032 2386754815771279360
735390599405822397 18301429462144450560
95456812816183194 7196581779833094144
496099120990257678 1456528131174297936
640864335947465031 9339274452638648448
946781867878999462 12570039778120302592
333748393004210653 13753056054296900864
841709238117981116 6184007288911560704
789079934945951395 10512370723316072448
886656151836613348 16483705204223508480
594464156819792349 15008800528433938432
869721049525515399 7826697540430957360
420908831683767374 13527922726904529168
305387935205720779 17864174920631320576
737481728539219474 8273258489095127040
904350040485185493 16096235376292970496
762734017514446383 2948572329451454464
725857737885243305 13931915872785399808
870452166195405054 14310294361223462912
926823472953286272 4118453577117073408
479271339778015133 7242270112502438400
581754946937570551 13965509329168138240
432591374554499103 5894715894748296192
710856070764706301 13619125978106691584
301403928406009131 6030266247160004608
204406966792247251 14305335043993407488
148567668923502407 14801270184706310144
492369546879655204 2161754450586410168
414535906265309725 17347344655712256000
751055965195743066 11472746865968021504
83688570794639996 5328798925300170752
214720901554107603 11661058181345306624
715917706375823303 10635904883700498432
646555488927200753 5797261115931263133
872802759508878029 15551643886630731776
171646073839858716 8546590612034420736
695076175609310027 17939441421819346816
877972691643807577 32973253714761536
550881926790469596 14615008417420031024
780883385074613093 5208897595304312832
716570344611606290 5176580041879257088
532890293218718689 5135002756768584384
71071369011023634 7358546950200231800
441019070814670325 6891596181668901269
362106324076817263 6669060028491628545
174420813453635649 15438673172462829568
125529397384125327 7581700810262708224
190794716479498585 9657169680132997120
861091775888149933 11942476880893968384
862633121306157000 4274744957882859520
402269847520941040 11890917606686720
996191617875277478 12197551110348603392
374798632869251072 16108188243685539840
247590527292312805 3858717815305618432
174095638187604635 4137925613572849664
380993258275196171 12311526125789249536
763804216531940678 11488916199449296896
639999400553165313 1981606515406733312
80647357003050471 2206418746080231424
882352911550804187 316527982324249929
95364623845355664 4425153587307347968
413263032078564973 3803940977817681920
328457983191576238 10286860838186778624
507383938963953011 1205976210190893056
736679251241197382 17828010906227408896
111032714677491468 8059636854560784384
114201152382051745 7250045961327476736

We get just a binary and a text file with a list of pairs of numbers. Running the binary, it prompts us to answer the output for a function and then almost instantly tells us that we were too slow. Obviously, this means we must find some efficient way to compute this answer.

Opening up the binary, we can see the main function here. I’ve renamed some of the functions and variables to make it easier to read.

main.c
int32_t main(int32_t argc, char** argv, char** envp) {
void* fsbase;
int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
srand(time(nullptr));
int32_t res = 1;
int64_t n = (int64_t)(rand() % 29 + 32);
int64_t x = f(n);
res = do_level("Easy", n, x);
if (res) {
n = (int64_t)(rand() % 0x15f90 + 0x2710);
x = f(n);
res = do_level("Medium", n, x);
}
if (!res)
goto label_401d56;
int32_t result;
if (parse_solutions_txt(&n, &x)) {
res = do_level("Hard", n, x);
label_401d56:
if (!res) {
puts("\nYou failed a level.");
result = 1;
} else {
puts("\nCongratulations! All levels pa…");
void var_118;
if (!print_flag(&var_118, 0x100)) {
fwrite("Passed, but couldn't read flag f…", 1, 0x25, stderr);
result = 1;
} else {
printf("Flag: %s\n", &var_118);
result = 0;
}
}
else {
fprintf(stderr, "\nHard level setup failed. Check…", "solutions.txt",
"\nHard level setup failed. Check…");
result = 1;
}
*(uint64_t*)((char*)fsbase + 0x28);
if (rax == *(uint64_t*)((char*)fsbase + 0x28))
return result;
__stack_chk_fail();
/* no return */
}
}

To get the flag, we have to parse through 3 levels of verification. The first two levels are fairly simple, the binary generates a random small number (between 32 and 61) and a random medium number (between 10k and 100k). It then calls the function f with these arguments, and then calls do_level, which expects us to input the correct value of f(n) given n in 1 second.

Here is the actual function f:

main.c
int64_t f(int64_t n) {
void* fsbase;
int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
int64_t result;
if (n <= 0x1e)
result = 0;
else if (n != 0x1f) {
uint64_t arr[0x20];
__builtin_memset(&arr, 0, 0x100);
arr[0x1f] = 1;
int64_t j = 1;
uint64_t i = 0;
for (int64_t _ = 0x20; n >= _; _ += 1) {
int64_t tmp = j - arr[i];
arr[i] = j;
j += tmp;
i = (uint64_t)(i + 1) & 0x1f;
}
result = arr[(uint64_t)(i + 0x1f) & 0x1f];
} else
result = 1;
*(uint64_t*)((char*)fsbase + 0x28);
if (rax == *(uint64_t*)((char*)fsbase + 0x28))
return result;
__stack_chk_fail();
/* no return */
}

The function reserves a 32 length array, then successively computes values starting from the 32th iteration. If the input n is less than or equal to 32, the answer is hard-coded.

With a quick Python re-implementation, we can see that it seems to follow a strict pattern at first, but then suddenly starts to diverge.

def f(n):
if n <= 30:
return 0
elif n == 31:
return 1
else:
arr = [0] * 32
arr[31] = 1
j = 1
i = 0
for _ in range(32, n + 1):
tmp = j - arr[i]
arr[i] = j
j += tmp
i = (i + 1) & 0x1f
return arr[(i + 31) & 0x1f]
for i in range(32, 100):
print(i, f(i))
32 1
27 collapsed lines
33 2
34 4
35 8
36 16
37 32
38 64
39 128
40 256
41 512
42 1024
43 2048
44 4096
45 8192
46 16384
47 32768
48 65536
49 131072
50 262144
51 524288
52 1048576
53 2097152
54 4194304
55 8388608
56 16777216
57 33554432
58 67108864
59 134217728
60 268435456
61 536870912
62 1073741824
63 2147483648
64 4294967295
65 8589934589
66 17179869176
67 34359738348
68 68719476688
69 137438953360
70 274877906688

At first, it seems to follow a pattern of 232n2^{32-n}, but after 64 that turns out to be incorrect. Additionally, since the datatypes are 64-bit, at some point it would overflow and wrap around as well.

Let’s try to represent the formula mathematically. For now, we assume that n32n \geq 32. Instead of considering ff as a function, we can consider it as a sequence of numbers. Thus, instead of finding f(n)f(n), we can find the nnth number in the sequence, or FnF_n.

for _ in range(32, n + 1):
tmp = j - arr[i]
arr[i] = j # arr[i] = F_n = j
# update for F_{n+1}
j += tmp # tmp = j - arr[i]
i = (i + 1) & 0x1f
return arr[(i + 31) & 0x1f] # return F_n

When calculating FnF_n, we can see that we are essentially just returning the previous value of jj before it is updated, meaning arr[i]=Fn=jarr[i] = F_n = j. Then, when we update jj, we are increasing it by tmp, which is the difference between the current value of jj and the previous value of arr[i]arr[i].

Fn=jFn+1=jnxttmp=jarr[i]jnxt=j+tmpjnxt=j+(jarr[i])Fn+1=2Fnarr[i]\begin{aligned} F_n &= j \\ F_{n+1} &= j_{nxt} \\ tmp &= j - arr[i] \\\\ j_{nxt} &= j + tmp \\ j_{nxt} &= j + (j - arr[i]) \\\\ F_{n+1} &= 2F_n - arr[i] \end{aligned}

Finally, arr[i] is the value that was previously assigned when we were last in this position in arr. Looping through the entire buffer to reach the same spot again takes 32 iterations, so the previous value of arr[i] must be the 32th previous value in the sequence, or Fn32F_{n-32}.

This gives us Fn+1=2FnFn32F_{n+1} = 2F_n - F_{n-32}.

Thus, we have a recurrence relation of the form Fn=2Fn1Fn33F_n = 2F_{n-1} - F_{n-33}, which we can use to compute the value of FnF_n for any nn. Our base case(s) can be the simple powers of two pattern when 32n<6432 \leq n < 64.

There are plenty of methods to calculate a recurrence relation quickly, but the easiest one is to use matrix exponentiation. We can represent the relation as a matrix multiplication, which allows us to compute it in O(log(n))O(log(n)) time.

Here is my final solve script. I’ve done some small optimizations like starting from 63 as the base instead of 32, and I ended up having to use sage due to the fact that we are using 64-bit integers, or working in modulo 2642^{64}.

solve.py
from pwn import *
import ctypes
from sage.all import *
from functools import cache
# r = process("./main")
# nc chall.polygl0ts.ch 9070
r = remote("chall.polygl0ts.ch", 9070)
@cache
def f(n):
18 collapsed lines
if n < 0x20:
return 0
arr = [0] * 0x20
arr[0x1f] = 1
j = 1
i = 0
# all uint64s
for itr in range(0x20, n + 1):
tmp = j - arr[i]
tmp = ctypes.c_uint64(tmp).value
arr[i] = j
arr[i] = ctypes.c_uint64(arr[i]).value
j += tmp
j = ctypes.c_uint64(j).value
i = (i + 1) & 0x1f
return arr[(i + 0x1f) & 0x1f]
@cache
def fast_f(n):
25 collapsed lines
if n < 0x40:
return f(n)
p = 2**64
P = Integers(p)
k = 33
# Transition matrix M: a_n = a_{n-1} + a_{n-k}
M = Matrix(P, k, k)
# Fill in shift: M[i][i+1] = 1 for i = 0..k-2
for i in range(k - 1):
M[i, i+1] = 1
# F_n = 2F_{n-1} - F_{n-k}
M[k-1, 0] = -1
M[k-1, k-1] = 2
vec = [1, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 4194304, 8388608, 16777216, 33554432, 67108864, 134217728, 268435456, 536870912, 1073741824, 2147483648]
v0 = vector(P, vec)
if n < k:
return v0[n]
Mn = M**(n - 63)
return (Mn * v0)[k - 1]
line = r.recvline_contains(b'What is f(')
n = int(line.split(b'(')[1].split(b')')[0])
x = 1 << (n - 32)
print(f"f({n}) = {x}")
r.sendlineafter(b'Your answer: ', str(x).encode())
line = r.recvline_contains(b'What is f(')
n = int(line.split(b'(')[1].split(b')')[0])
x = fast_f(n)
print(f"f({n}) = {x}")
r.sendlineafter(b'Your answer: ', str(x).encode())
line = r.recvline_contains(b'What is f(')
n = int(line.split(b'(')[1].split(b')')[0])
x = fast_f(n)
print(f"f({n}) = {x}")
r.sendlineafter(b'Your answer: ', str(x).encode())
r.interactive()

Running this gives us our flag: EPFL{are_you_serious_fibonacci_again}

Terminal window
$ python3 solve.py
[+] Opening connection to chall.polygl0ts.ch on port 9070: Done
f(54) = 4194304
f(13895) = 14979957406756292168
f(379402583153428697) = 889007270209280
[*] Switching to interactive mode
Received: 889007270209280 (took 0.867 s)
Correct! Proceeding...
Congratulations! All levels passed!
Flag: EPFL{are_you_serious_fibonacci_again}
[*] Got EOF while reading in interactive
[*] Closed connection to chall.polygl0ts.ch port 9070

rev - Jump Around

Jump Around from House of Pain: https://www.youtube.com/watch?v=MdxCfAb-ROM

Points: 116 | Solves: 9

out

This challenge mainly revolved around an obfuscation technique where each assembly instruction was contained inside its own function and after executing one instruction, it would jump or call a function corresponding to the next instruction. This made it very difficult to follow the control flow of the program, as it was not linear at all.

Most of the work on this challenge was done by my teammate kroot, who vibe coded created a gdb script to dump what should be the actual instructions. As I don’t have access to it or how it was created, I’ll simply go over the part that I did, which was taking the output of that script and analyzing it.

Here’s what the first portion of the dump looked like:

f208: mov rbp,rsp
faa0: push rbx
f5f1: sub rsp,0x148
f742: mov rax,QWORD PTR fs:0x28
f708: mov QWORD PTR [rbp-0x18],rax
f2ae: xor eax,eax
f8db: mov rax,rsp
f4ad: mov rbx,rax
f85f: mov DWORD PTR [rbp-0x144],0x30
fab8: mov eax,DWORD PTR [rbp-0x144]
f8f5: movsxd rdx,eax
f826: sub rdx,0x1
f97f: mov QWORD PTR [rbp-0x140],rdx
f7ba: cdqe
f880: mov edx,0x10
fad5: sub rdx,0x1
f530: add rax,rdx
f400: mov esi,0x10
f949: mov edx,0x0
f359: div rsi
f99d: imul rax,rax,0x10
f965: sub rsp,rax
f4e1: mov rax,rsp
f258: mov QWORD PTR [rbp-0x138],rax
f89c: mov rax,QWORD PTR [rbp-0x138]
f4c7: mov rsi,rax
f762: lea rax,[rip+0xae7] # 0x555555560250
f564: mov rdi,rax
fa84: mov eax,0x0
f697: call 0x555555555080 <__isoc99_scanf@plt>
f79b: mov QWORD PTR [rbp-0x70],0x0
f723: mov QWORD PTR [rbp-0x68],0x0
f6b3: movabs rax,0xa6d2ae2816157e2b
f31e: movabs rdx,0x3c4fcf098815f7ab
f660: mov QWORD PTR [rbp-0x60],rax
f645: mov QWORD PTR [rbp-0x58],rdx
f458: movabs rax,0xff2bf601bd7f2cc4
f8ba: movabs rdx,0xa19d6771c2a58e2
f780: mov QWORD PTR [rbp-0x50],rax
f479: mov QWORD PTR [rbp-0x48],rdx
f9f6: movabs rax,0x4e4eaf7ead01a0a8
f373: movabs rdx,0xda67f3d74d336f23
fa4d: mov QWORD PTR [rbp-0x40],rax
f7ed: mov QWORD PTR [rbp-0x38],rdx
f2c7: movabs rax,0xc1c8da839ec6d06e
f9b8: movabs rdx,0x6f0e62d284fdefad
f3e5: mov QWORD PTR [rbp-0x30],rax
f3ca: mov QWORD PTR [rbp-0x28],rdx
f515: lea rdx,[rbp-0x70]
faf0: lea rcx,[rbp-0x60]
f808: lea rax,[rbp-0x130]
f62b: mov rsi,rcx
f54a: mov rdi,rax
fa31: call 0x5555555568c0

If you’ve looked at normal CTF crackme binaries before, this code should look pretty familiar. At the top, we have function initialization, where it reserves some stack space and also stores a stack canary. Then, there’s a call for user input, followed by some constants that are loaded onto the stack (highlighted above).

Funnily enough, searching the first constant results in some pages about AES, with the first 16 bytes of these constants being a commonly used AES key.

https://www.google.com/search?q=2b7e151628aed2a6

Peeking at the readonly data, we also have what appears to be the standard AES S-box, inverse S-Box, and Rcon tables.

AES S-Box, Inverse S-Box, and Rcon tables

From here, we can do one test with ltrace just to see what happens, which appears to be very little:

Terminal window
$ ltrace ./out
__isoc99_scanf(0x5627e5d3e250, 0x7ffe045c5140, 15, 0x5627e5d3fdd0AAAA
) = 1
memcmp(0x7ffe045c5270, 0x7ffe045c5140, 48, 0x7ffe045c5140) = 0xffffffc7
puts("FAILURE!"FAILURE!
) = 9
+++ exited (status 0) +++

However, given the context we’ve seen, it’s safe to guess that there’s AES encryption going on with our input, which is then compared to some other data in the memcmp.

Writing a quick script in Python, we can see that our guess is pretty close.

test.py
from Crypto.Cipher import AES
key = (0xa6d2ae2816157e2b).to_bytes(8, 'little') + (0x3c4fcf098815f7ab).to_bytes(8, 'little')
enc = [
0xff2bf601bd7f2cc4,
0xa19d6771c2a58e2,
0x4e4eaf7ead01a0a8,
0xda67f3d74d336f23,
0xc1c8da839ec6d06e,
0x6f0e62d284fdefad,
]
enc = b''.join([i.to_bytes(8, 'little') for i in enc])
cipher = AES.new(key, AES.MODE_ECB)
print(cipher.decrypt(enc))
Terminal window
$ python3 test.py
b'EPFL{mom_please_\xb7X\x10\xcd^\x9bN\x8c\x911D{(\xbb`U\xc1\xcer\xd9\x0c\xda-:J\x00]>\x88\xac\x1a\xda'

However, it looks like only our first block is valid. Thankfully, one small change is enough to make it work, we simply use CBC mode instead with an IV of all null bytes, which gives us the flag.

cipher = AES.new(key, AES.MODE_ECB)
cipher = AES.new(key, AES.MODE_CBC, iv=b'\x00' * 16)
Terminal window
$ python3 test.py
b'EPFL{mom_please_stop_messing_my_instructions__}\x00'

This was a pretty funny challenge where trusting your gut and simply guessing some aspects of the challenge could save a lot of time (I didn’t even bother opening the binary in a decompiler).

rev - Hygiene

I know not to roll my own crypto and I have used a public and audited cryptographic library for my newest cli encryption program. Surely nothing could go wrong…

nc chall.polygl0ts.ch 9034

Points: 140 | Solves: 7

chal

This challenge was a bit less reversing and more program analysis. The binary came with symbols so most of the functions looked pretty nice.

main.c
int32_t main(int32_t argc, char** argv, char** envp)
27 collapsed lines
setbuf(fp: stdin, buf: nullptr)
setbuf(fp: __TMC_END__, buf: nullptr)
setbuf(fp: stderr, buf: nullptr)
read(fd: open(file: "flag.txt", oflag: 0), buf: &flag, nbytes: 0x100)
if (argc == 1)
int32_t fd_1 = open(file: "/dev/urandom", oflag: 0)
if (fd_1 < 0)
exit(status: 1)
if (read(fd: fd_1, buf: &instance_key, nbytes: 0x20) != 0x20)
exit(status: 1)
else
int32_t fd = open(file: argv[1], oflag: 0)
if (fd < 0)
__printf_chk(flag: 1, format: "Bad keyfile")
exit(status: 1)
if (read(fd, buf: &instance_key, nbytes: 0x20) != 0x20)
exit(status: 1)
__builtin_memset(s: &iv, c: 0, n: 0x18)
initial_setup()
chall()
return 0
int64_t initial_setup()
15 collapsed lines
void* fsbase
int64_t rax = *(fsbase + 0x28)
char s[0x108]
__sprintf_chk(&s, flag: 1, slen: 0x100, format: "Hi. If you have been able to dec…", &flag)
int128_t var_158 = instance_key[0].o
int128_t var_148 = instance_key[0x10][0].o
int128_t var_138 = iv[0].o
int64_t var_128 = iv[0x10][0].q
chacha20_encrypt_and_destroy(dst: "ciphertext", src: &s, len: 0x100, block: 0)
if (rax == *(fsbase + 0x28))
return 0
__stack_chk_fail()
int64_t chall()
46 collapsed lines
void* fsbase
int64_t rax = *(fsbase + 0x28)
int32_t rax_2
while (true)
puts(str: "Menu:\n1. Encrypt data\n2. Print…")
puts(str: "What do you want to do?")
char buf[0x180]
rax_2 = get_num(&buf, n: 5)
if (rax_2 != 1)
break
puts(str: "How many characters do you want …")
int32_t len_1 = get_num(&buf, n: 0xa)
int64_t len = sx.q(len_1)
puts(str: "Which block do you want to encry…")
int32_t block = get_num(&buf, n: 0xa)
if (sx.q(block) - 3 > 99997)
__printf_chk(flag: 1, format: "Nope, not giving you another enc…")
else if (len u> 379)
__printf_chk(flag: 1, format: "Maximum number of characters is …", 0x17b)
else
puts(str: "Input the text you want to encry…")
if (&buf == __fgets_chk(&buf, size: 384, n: len_1 + 2, fp: stdin))
if (buf[1 + len] != 0)
__printf_chk(flag: 1, format: "YOU LIED TO ME ABOUT THE LENGHT!…")
exit(status: 1)
int128_t var_208_1 = instance_key[0].o
int128_t var_1f8_1 = instance_key[0x10][0].o
int128_t var_1e8_1 = iv[0].o
int64_t var_1d8_1 = iv[0x10][0].q
chacha20_encrypt_and_destroy(dst: &buf, src: &buf, len, block)
puts(str: "Here is your encrypted text:")
printhex(&buf, len)
if (rax_2 == 2)
printhex("ciphertext", 0x100)
exit(status: 0)
if (rax == *(fsbase + 0x28))
return 0
__stack_chk_fail()

The binary first generates a key to be used for ChaCha20 encryption, either from /dev/urandom or from a specified file. Then, it encrypts a message containing the flag with this key and stores it at the location of ciphertext.

From here, we get a standard encryption oracle, where we can query the oracle with a block number and a message to encrypt. The block number is used to determine which block of the ChaCha keystream is used to encrypt the message. We can also get the encrypted flag message and exit the program.

We can assume that the ChaCha20 implementation is correct, given the description, so let’s focus on the rest of the program.

no block reuse

If you have some crypto experience, perhaps you might try to encrypt some text with the same keystream as the one that encrypted the flag. This would let us recover the keystream as we would have a known plaintext / ciphertext pair, which we could then use to decrypt the encrypted flag message.

However, unfortunately there is no way to use the same blocks as the ones used to encrypt the flag, as the program will only allow us to use blocks between 3 and 100000.

int32_t block = get_num(&buf, n: 0xa)
if (sx.q(block) - 3 > 99997)
__printf_chk(flag: 1, format: "Nope, not giving you another enc…")

The sign extension on block means that even if we pass in a block below 3, it would underflow through subtraction and cause the if statement to still be true.

maybe this is useful

Looking at the other parameter we can input, there actually is a small bug here

else if (len u> 379)
__printf_chk(flag: 1, format: "Maximum number of characters is …", 0x17b)
else
puts(str: "Input the text you want to encry…")
if (&buf == __fgets_chk(&buf, size: 384, n: len_1 + 2, fp: stdin))
if (buf[1 + len] != 0)
__printf_chk(flag: 1, format: "YOU LIED TO ME ABOUT THE LENGHT!…")
exit(status: 1)
int128_t var_208_1 = instance_key[0].o
int128_t var_1f8_1 = instance_key[0x10][0].o
int128_t var_1e8_1 = iv[0].o
int64_t var_1d8_1 = iv[0x10][0].q
chacha20_encrypt_and_destroy(dst: &buf, src: &buf, len, block)
puts(str: "Here is your encrypted text:")
printhex(&buf, len)

Notice that the program doesn’t check that we input as many characters as the length we specified, it only checks that the character at buf[1 + len] is null. This means that we could potentially have the encrypted plaintext be left over stack data from previous calls by simply not inputing as many bytes as we claim.

Running through GDB quickly with a breakpoint just before the fgets call, we see that the ChaCha key itself just so happens to be on the stack in our input buffer, uncleared:

$rbx+ 0x7fffffffd420|+0x0000|+000: 0x2f2f2f000a000a33 [buf]
0x7fffffffd428|+0x0008|+001: 0x66416b941a777300 <- canary
0x7fffffffd430|+0x0010|+002: 0x00005555555581c0 <flag> -> 0x0000000000000000
0x7fffffffd438|+0x0018|+003: 0x00007fffffffd4d0 -> 0x0706050403020100
0x7fffffffd440|+0x0020|+004: 0x0000000000000100
0x7fffffffd448|+0x0028|+005: 0x0000555555558080 <encrypt_header> -> 0x59fab6808cca4ac9
0x7fffffffd450|+0x0030|+006: 0x0000555555555553 <main> -> 0xfb8908ec83485355
0x7fffffffd458|+0x0038|+007: 0x0000555555557dd8 -> 0x0000555555555180 -> 0x301d3d80fa1e0ff3
0x7fffffffd460|+0x0040|+008: 0x00007ffff7ffd040 <_rtld_global> -> 0x00007ffff7ffe2e0 -> 0x0000555555554000 -> ...
0x7fffffffd468|+0x0048|+009: 0x00005555555551ff <chacha20_encrypt_and_destroy+0x1f> -> 0xed85481874dc3949
0x7fffffffd470|+0x0050|+010: 0x00007fffffffd4d0 -> 0x0706050403020100
0x7fffffffd478|+0x0058|+011: 0x00007fffffffd718 -> 0x00007fffffffd9b9 -> 0x552f632f746e6d2f '/mnt/c/Users/flocto/Documents/Cybersecurity/2025/LakeCTFFinals/h'...
0x7fffffffd480|+0x0060|+012: 0x00007fffffffd718 -> 0x00007fffffffd9b9 -> 0x552f632f746e6d2f '/mnt/c/Users/flocto/Documents/Cybersecurity/2025/LakeCTFFinals/h'...
0x7fffffffd488|+0x0068|+013: 0x000055555555552a <initial_setup+0x8d> -> 0x0824848b48dc8948
0x7fffffffd490|+0x0070|+014: 0x4242424241414141 'AAAABBBBCCCCDDDD0000111122223333' [ChaCha key]
0x7fffffffd498|+0x0078|+015: 0x4444444443434343 'CCCCDDDD0000111122223333'
0x7fffffffd4a0|+0x0080|+016: 0x3131313130303030 '0000111122223333'
0x7fffffffd4a8|+0x0088|+017: 0x3333333332323232 '22223333'
0x7fffffffd4b0|+0x0090|+018: 0x0000000000000000
0x7fffffffd4b8|+0x0098|+019: 0x0000000000000000
0x7fffffffd4c0|+0x00a0|+020: 0x0000000000000000

This means we can specify a length just beyond that of the ChaCha key, input in no characters, then the program will happily spit out the encrypted key. Wait… that isn’t too useful!

Thankfully, since ChaCha is a stream cipher, simply repeating the encryption again will decrypt the key and spit it out to use in a useful format. All that’s left is to script this all together and we have our flag!

solve.py
from pwn import *
from Crypto.Cipher import ChaCha20
key = open('keyfile', 'rb').read()
iv = b'\x00' * 0x8
cipher = ChaCha20.new(key=key, nonce=iv)
BLOCK_SIZE = 0x40
# r = process(['./chal', 'keyfile'])
# nc chall.polygl0ts.ch 9034
r = remote('chall.polygl0ts.ch', 9034)
def enc(block, pt, len):
r.sendlineafter(b'to do?\n', b'1')
r.sendlineafter(b'to encrypt?\n', str(len).encode())
r.sendlineafter(b'to encrypt?\n', str(block).encode())
r.sendlineafter(b'to encrypt:\n', pt)
# r.sendlineafter(b'to encrypt:\n', b'B' * 380)
r.recvuntil(b' text:\n')
return r.recvline().strip()
enc(3, b'', 150)
dec = enc(3, b'', 150)
dec = bytes.fromhex(dec.decode())
key = dec[112:144]
print(f'key: {key.hex()}')
# r.interactive()
r.sendlineafter(b'to do?\n', b'2')
ct = bytes.fromhex(r.recvline().strip().decode())
cipher = ChaCha20.new(key=key, nonce=iv)
pt = cipher.decrypt(ct)
print(pt)
Terminal window
$ python3 solve.py
[+] Opening connection to chall.polygl0ts.ch on port 9034: Done
key: ff2a9d3e4f6a08e5406947061369dd68e1022cb62f3a112e998b011bb00e63df
b'Hi. If you have been able to decrypt this text, you deserve the flag for this chall: EPFL{remember_to_clean_your_keys_and_beware_of_stack_parameters}\n\n\x00\xa0\xb6-L\xf3\x7f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf5\xe3\x14L\xf3\x7f\x00\x00\x0e\x00\x00\x00\x00\x00\x00\x00\xa0\xb6-L\xf3\x7f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xe9H\x07\x00k\xfal\x00v-L\xf3\x7f\x00\x00\xad\xa5\x14L\xf3\x7f\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x7f\x15\x14L\xf3\x7f\x00\x00\x00@>\xbf\xfd\x7f\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'
[*] Closed connection to chall.polygl0ts.ch port 9034

rev - one vs all

I’ve proven myself impossible to beat at this but you can take a try.

nc chall.polygl0ts.ch 9076

Points: 500 | Solves: 0

main | program.bpf

chal.py
#!/usr/bin/env -S python3 -u
import subprocess
with open("/tmp/moves.txt", "w") as f:
f.write("221\n")
while True:
move = input("Enter your move: ")
if move == 'EOF':
break
f.write(move + "\n")
print(move)
result = subprocess.run(["stdbuf", "-i0", "-e0", "-o0", "/app/main"], capture_output=True, text=True)
print(result.stdout)

Finally, the last unblooded rev. Although I didn’t manage to solve this one during the CTF, I took a look at it afterward and manage to upsolve it first.

The main gimmick of the challenge was the fact that it was a compiled Lean binary, which then had a BPF program interpreter that ran the provided BPF program. Both of these were non-standard and made it hard to get a good overview of the program.

The decompilation of the binary was really really bad to look at, since Lean has a lot of consistency checks that add bloat to the real program logic. Feel free to open it in your own decompiler to check yourself.

Anyway, after a bit of digging around, we find the real _lean_main function. It uses some pre-initialized constants from initialize_Main, which I’ve put below.

initialize_Main
int64_t* rax_365 = lean_mk_string_unchecked("program.bpf", 0xb, 0xb)
program.bpf = rax_365
lean_mark_persistent(rax_365)
int64_t* rax_366 = lean_mk_string_unchecked("/tmp/moves.txt", 0xe, 0xe)
_/tmp/moves.txt = rax_366
lean_mark_persistent(rax_366)
int64_t* rax_367 = lean_mk_string_unchecked("No moves found!", 0xf, 0xf)
No moves found! = rax_367
lean_mark_persistent(rax_367)
int64_t* rax_368 = lean_alloc_small(0x18, 2)
*rax_368 = -0xaffffffffffffff
rax_368[1] = l_main___lambda__1___boxed
rax_368[2].d = 1
l_main___closed__4 = rax_368
lean_mark_persistent(rax_368)
int64_t* rax_369 = lean_mk_string_unchecked("Unsuccessful", 0xc, 0xc)
Unsuccessful = rax_369
lean_mark_persistent(rax_369)
int64_t* rax_370 = lean_mk_string_unchecked("Successful run!", 0xf, 0xf)
Successful run! = rax_370
lean_mark_persistent(rax_370)
int64_t* rax_371 = lean_mk_string_unchecked("flag.txt", 8, 8)
flag.txt = rax_371
lean_mark_persistent(rax_371)

Note: this code is really long, don’t expand unless you want to see the whole thing, a lot of it is just error checks and reference counting.

_lean_main
int64_t* c = l_IO_FS_readFile(program.bpf)
45 collapsed lines
if ((c.b & 1) == 0)
if (zx.d(*(c + 7)) != 0)
goto label_4fab8b
goto label_4faad3
if ((c u>> 1).d != 0)
label_4fab8b:
if (*c != 1)
label_4fab92:
int32_t* rbx_2 = c[1]
int32_t* r14_2 = c[2]
if ((r14_2.b & 1) == 0)
int32_t rax_17 = *r14_2
if (rax_17 s> 0)
*r14_2 = rax_17 + 1
else if (rax_17 != 0)
lean_inc_ref_cold(r14_2)
if ((rbx_2.b & 1) == 0)
int32_t rax_19 = *rbx_2
if (rax_19 s> 0)
*rbx_2 = rax_19 + 1
else if (rax_19 != 0)
lean_inc_ref_cold(rbx_2)
if ((c.b & 1) == 0)
int32_t rax_21 = *c
if (rax_21 s>= 2)
*c = rax_21 - 1
else if (rax_21 != 0)
lean_dec_ref_cold(c)
c = lean_alloc_small(0x18, 2)
*c = 0x102000000000001
c[1] = rbx_2
c[2] = r14_2
else
label_4faad3:
int32_t* rbx_1 = c[1]
27 collapsed lines
if ((rbx_1.b & 1) == 0)
int32_t rax_4 = *rbx_1
if (rax_4 s> 0)
*rbx_1 = rax_4 + 1
else if (rax_4 != 0)
lean_inc_ref_cold(rbx_1)
int32_t* r14_1 = c[2]
if ((r14_1.b & 1) == 0)
int32_t rax_6 = *r14_1
if (rax_6 s> 0)
*r14_1 = rax_6 + 1
else if (rax_6 != 0)
lean_inc_ref_cold(r14_1)
if ((c.b & 1) == 0)
int32_t rax_8 = *c
if (rax_8 s>= 2)
*c = rax_8 - 1
else if (rax_8 != 0)
lean_dec_ref_cold(c)
int32_t* rax_10 = l_String_splitAux___at_main___spec__2(rbx_1, 1, 1, 1)
9 collapsed lines
if ((rbx_1.b & 1) == 0)
int32_t rax_11 = *rbx_1
if (rax_11 s>= 2)
*rbx_1 = rax_11 - 1
else if (rax_11 != 0)
lean_dec_ref_cold(rbx_1)
c = l_IO_FS_readFile(_/tmp/moves.txt)
23 collapsed lines
if ((c.b & 1) == 0)
if (zx.d(*(c + 7)) == 0)
goto label_4fac24
goto label_4fab74
if ((c u>> 1).d != 0)
label_4fab74:
if ((rax_10.b & 1) != 0)
goto label_4fab8b
int32_t rax_15 = *rax_10
if (rax_15 s>= 2)
*rax_10 = rax_15 - 1
else if (rax_15 != 0)
lean_dec_ref_cold(rax_10)
goto label_4fab8b
label_4fac24:
int32_t* r12_1 = c[1]
27 collapsed lines
if ((r12_1.b & 1) == 0)
int32_t rax_27 = *r12_1
if (rax_27 s> 0)
*r12_1 = rax_27 + 1
else if (rax_27 != 0)
lean_inc_ref_cold(r12_1)
int32_t* r15_1 = c[2]
if ((r15_1.b & 1) == 0)
int32_t rax_29 = *r15_1
if (rax_29 s> 0)
*r15_1 = rax_29 + 1
else if (rax_29 != 0)
lean_inc_ref_cold(r15_1)
if ((c.b & 1) == 0)
int32_t rax_31 = *c
if (rax_31 s>= 2)
*c = rax_31 - 1
else if (rax_31 != 0)
lean_dec_ref_cold(c)
int32_t* rax_33 = l_String_splitAux___at_main___spec__2(r12_1, 1, 1, 1)
9 collapsed lines
if ((r12_1.b & 1) == 0)
int32_t rax_34 = *r12_1
if (rax_34 s>= 2)
*r12_1 = rax_34 - 1
else if (rax_34 != 0)
lean_dec_ref_cold(r12_1)
int32_t* rax_36 = l_List_mapTR_loop___at_main___spec__3(rax_33, 1)
19 collapsed lines
if ((rax_36.b & 1) == 0)
if (zx.d(*(rax_36 + 7)) == 0)
goto label_4fae91
goto label_4faccb
if ((rax_36 u>> 1).d != 0)
label_4faccb:
int32_t* r12_2 = *(rax_36 + 8)
if ((r12_2.b & 1) == 0)
int32_t rax_38 = *r12_2
if (rax_38 s> 0)
*r12_2 = rax_38 + 1
else if (rax_38 != 0)
lean_inc_ref_cold(r12_2)
int32_t* r13 = *(rax_36 + 0x10)
17 collapsed lines
if ((r13.b & 1) == 0)
int32_t rax_40 = *r13
if (rax_40 s> 0)
*r13 = rax_40 + 1
else if (rax_40 != 0)
lean_inc_ref_cold(r13)
if ((rax_36.b & 1) == 0)
int32_t rax_42 = *rax_36
if (rax_42 s>= 2)
*rax_36 = rax_42 - 1
else if (rax_42 != 0)
lean_dec_ref_cold(rax_36)
int64_t* rax_44 = lean_alloc_small(0x20, 3)
*rax_44 = -0xaffffffffffffff
rax_44[1] = l_main___lambda__2___boxed
rax_44[2].d = 0x10002
rax_44[3] = r12_2
int64_t* rax_45 = lean_alloc_small(0x20, 3)
*rax_45 = -0xaffffffffffffff
rax_45[1] = l_main___lambda__3___boxed
rax_45[2].d = 0x10002
rax_45[3] = r13
uint64_t l_main___closed__4_1 = l_main___closed__4
int32_t* rbp_2 = lean_alloc_small(0x28, 4)
*rbp_2 = 0x4000000000001
*(rbp_2 + 8) = l_main___closed__4_1
*(rbp_2 + 0x10) = rax_44
*(rbp_2 + 0x18) = rax_45
*(rbp_2 + 0x20) = 1
int32_t* rax_47 = l_List_mapTR_loop___at_runEbpfFromList___spec__1(rax_10, 1)
int32_t* rax_48
int128_t zmm0_1
rax_48, zmm0_1 = l_runEbpfFromList_loop(rax_47, rbp_2)
9 collapsed lines
if ((rax_47.b & 1) == 0)
int32_t rax_49 = *rax_47
if (rax_49 s>= 2)
*rax_47 = rax_49 - 1
else if (rax_49 != 0)
zmm0_1 = lean_dec_ref_cold(rax_47)
int32_t* rbx_7 = *(rax_48 + 0x10)
17 collapsed lines
if ((rbx_7.b & 1) == 0)
int32_t rax_51 = *rbx_7
if (rax_51 s> 0)
*rbx_7 = rax_51 + 1
else if (rax_51 != 0)
lean_inc_ref_cold(rbx_7)
if ((rax_48.b & 1) == 0)
int32_t rax_53 = *rax_48
if (rax_53 s>= 2)
*rax_48 = rax_53 - 1
else if (rax_53 != 0)
zmm0_1 = lean_dec_ref_cold(rax_48)
int32_t* rax_55 = lean_apply_1(rbx_7, 0x3ff, zmm0_1)
int64_t rbx_8 = *(rax_55 + 8)
9 collapsed lines
if ((rax_55.b & 1) == 0)
int32_t rcx_1 = *rax_55
if (rcx_1 s>= 2)
*rax_55 = rcx_1 - 1
else if (rcx_1 != 0)
lean_dec_ref_cold(rax_55)
if (rbx_8 != 0x1337)
return l_IO_print___at_IO_println___spec__1(lean_string_push(Unsuccessful, 0xa))
__tailcall
c = l_IO_print___at_IO_println___spec__1(lean_string_push(Successful run!, 0xa))
26 collapsed lines
uint32_t rax_58
if ((c.b & 1) != 0)
rax_58 = (c u>> 1).d
else
rax_58 = zx.d(*(c + 7))
if (rax_58 == 0)
int32_t* rbx_9 = c[2]
if ((rbx_9.b & 1) == 0)
int32_t rax_70 = *rbx_9
if (rax_70 s> 0)
*rbx_9 = rax_70 + 1
else if (rax_70 != 0)
lean_inc_ref_cold(rbx_9)
if ((c.b & 1) == 0)
int32_t rax_72 = *c
if (rax_72 s>= 2)
*c = rax_72 - 1
else if (rax_72 != 0)
lean_dec_ref_cold(c)
c = l_IO_FS_readFile(flag.txt)
36 collapsed lines
uint32_t rax_75
if ((c.b & 1) != 0)
rax_75 = (c u>> 1).d
else
rax_75 = zx.d(*(c + 7))
if (rax_75 == 0)
int32_t* r15_3 = c[1]
if ((r15_3.b & 1) == 0)
int32_t rax_84 = *r15_3
if (rax_84 s> 0)
*r15_3 = rax_84 + 1
else if (rax_84 != 0)
lean_inc_ref_cold(r15_3)
int32_t* r14_4 = c[2]
if ((r14_4.b & 1) == 0)
int32_t rax_86 = *r14_4
if (rax_86 s> 0)
*r14_4 = rax_86 + 1
else if (rax_86 != 0)
lean_inc_ref_cold(r14_4)
if ((c.b & 1) == 0)
int32_t rax_88 = *c
if (rax_88 s>= 2)
*c = rax_88 - 1
else if (rax_88 != 0)
lean_dec_ref_cold(c)
int32_t* l_main___closed__8_1 = l_main___closed__8
int64_t* rax_90 = lean_string_append(l_main___closed__8_1, r15_3)
9 collapsed lines
if ((r15_3.b & 1) == 0)
int32_t rax_91 = *r15_3
if (rax_91 s>= 2)
*r15_3 = rax_91 - 1
else if (rax_91 != 0)
lean_dec_ref_cold(r15_3)
return l_IO_print___at_IO_println___spec__1(lean_string_push(
lean_string_append(rax_90, l_main___closed__8_1), 0xa)) __tailcall
13 collapsed lines
goto label_4fab8b
label_4fae91:
if ((rax_10.b & 1) == 0)
int32_t rax_61 = *rax_10
if (rax_61 s>= 2)
*rax_10 = rax_61 - 1
else if (rax_61 != 0)
lean_dec_ref_cold(rax_10)
c = l_IO_print___at_IO_println___spec__1(lean_string_push(No moves found!, 0xa))
53 collapsed lines
uint32_t rax_65
if ((c.b & 1) != 0)
rax_65 = (c u>> 1).d
else
rax_65 = zx.d(*(c + 7))
int32_t rcx_4 = *c
if (rax_65 == 0)
if (rcx_4 != 1)
int32_t* rbx_10 = c[2]
if ((rbx_10.b & 1) == 0)
int32_t rax_78 = *rbx_10
if (rax_78 s> 0)
*rbx_10 = rax_78 + 1
else if (rax_78 != 0)
lean_inc_ref_cold(rbx_10)
if ((c.b & 1) == 0)
int32_t rax_80 = *c
if (rax_80 s>= 2)
*c = rax_80 - 1
else if (rax_80 != 0)
lean_dec_ref_cold(c)
c = lean_alloc_small(0x18, 2)
*c = 0x2000000000001
c[1] = 1
c[2] = rbx_10
else
int32_t* rdi_17 = c[1]
if ((rdi_17.b & 1) != 0)
c[1] = 1
else
int32_t rax_76 = *rdi_17
if (rax_76 s< 2)
if (rax_76 != 0)
lean_dec_ref_cold(rdi_17)
c[1] = 1
else
*rdi_17 = rax_76 - 1
c[1] = 1
else if (rcx_4 != 1)
goto label_4fab92
return c

Essentially, the program reads in a EBPF program from program.bpf as well as a list of moves from /tmp/moves.txt. It then runs the EBPF program with the moves as input, and if the program returns 0x1337, it print the flag.

This means our goal is to figure out what the EBPF program does, and how to get it to return 0x1337.

Without going into too much detail, the l_List_mapTR_loop___at_runEbpfFromList___spec__1 and l_runEbpfFromList_loop functions take in the list of integers from program.bpf, convert them from Lean’s native natural number representation to a uint64, extract the EBPF program from the numbers, then finally executes it in a loop, continually interpreting the current EBPF instruction and executing it until the program terminates.

thanks gemini

Unfortunately, if we try to convert the EBPF program ourselves and run standard disassembly tools on it, we immediately get an error. Instead of trying to chase down this rabbit hole, I instead had Gemini create a Python disassembly script for me:

disasm.py
#!/usr/bin/env python3
import struct
from enum import Enum, auto
class BPFClass(Enum):
13 collapsed lines
LD = 0x00
LDX = 0x01
ST = 0x02
STX = 0x03
ALU = 0x04
JMP = 0x05
RET = 0x06
ALU64 = 0x07
# These are BPF_JMP32, BPF_ALU32 in more recent kernels
UNUSED1 = 0x08
UNUSED2 = 0x09
# BPF extensions
MISC = 0xF0
class BPFSize(Enum):
4 collapsed lines
W = 0x00 # 32-bit
H = 0x08 # 16-bit
B = 0x10 # 8-bit
DW = 0x18 # 64-bit
class BPFMode(Enum):
7 collapsed lines
IMM = 0x00
ABS = 0x20
IND = 0x40
MEM = 0x60
LEN = 0x80
MSH = 0xa0
XADD = 0xc0
class BPFALU(Enum):
14 collapsed lines
ADD = 0x00
SUB = 0x10
MUL = 0x20
DIV = 0x30
OR = 0x40
AND = 0x50
LSH = 0x60
RSH = 0x70
NEG = 0x80
MOD = 0x90
XOR = 0xa0
MOV = 0xb0
ARSH = 0xc0
END = 0xd0
class BPFSource(Enum):
2 collapsed lines
K = 0x00 # 32-bit immediate
X = 0x08 # Index register
class BPFJump(Enum):
14 collapsed lines
JA = 0x00
JEQ = 0x10
JGT = 0x20
JGE = 0x30
JSET = 0x40
JNE = 0x50
JSGT = 0x60
JSGE = 0x70
CALL = 0x80
EXIT = 0x90
JLT = 0xa0
JLE = 0xb0
JSLT = 0xc0
JSLE = 0xd0
def format_immediate(imm):
"""Format immediate value with ASCII representation if printable."""
3 collapsed lines
if 32 <= imm <= 126: # Printable ASCII range
return f"{imm} ('{chr(imm)}')"
return f"{imm}"
def decode_instruction(i, instruction):
"""Decode a BPF instruction from a 64-bit integer."""
106 collapsed lines
# eBPF instruction format:
# op:8, dst:4, src:4, off:16, imm:32
# Extract fields from the instruction
op = (instruction & 0xFF)
dst_reg = ((instruction >> 8) & 0xF)
src_reg = ((instruction >> 12) & 0xF)
offset = struct.unpack('h', struct.pack('H', (instruction >> 16) & 0xFFFF))[0] # Signed 16-bit
imm = struct.unpack('i', struct.pack('I', (instruction >> 32) & 0xFFFFFFFF))[0] # Signed 32-bit
# Decode opcode
opcode = op & 0x07 # class
src_type = op & 0x08 # src
# Extract instruction class
try:
bpf_class = BPFClass(opcode)
except ValueError:
return f"Unknown instruction class: {opcode:x}"
# Decode instruction based on class
if bpf_class == BPFClass.ALU or bpf_class == BPFClass.ALU64:
alu_op = op & 0xf0
src_type = op & 0x08
try:
alu_operation = BPFALU(alu_op)
src_name = "X" if src_type else "K"
class_name = "ALU64" if bpf_class == BPFClass.ALU64 else "ALU"
if alu_operation == BPFALU.NEG:
return f"{alu_operation.name} r{dst_reg}"
else:
if src_type:
src_val = f"r{src_reg}"
else:
src_val = format_immediate(imm)
return f"{alu_operation.name} r{dst_reg} {src_val}"
except ValueError:
return f"Unknown ALU operation: {alu_op:x}"
elif bpf_class == BPFClass.JMP:
jmp_op = op & 0xf0
src_type = op & 0x08
try:
jump_operation = BPFJump(jmp_op)
if jump_operation == BPFJump.CALL:
return f"CALL {imm}"
elif jump_operation == BPFJump.EXIT:
return "EXIT"
elif jump_operation == BPFJump.JA:
return f"JA {offset} ({i + offset})"
else:
if src_type:
src_val = f"r{src_reg}"
else:
src_val = format_immediate(imm)
return f"{jump_operation.name} r{dst_reg}, {src_val}, {offset}"
except ValueError:
return f"Unknown jump operation: {jmp_op:x}"
elif bpf_class == BPFClass.LD or bpf_class == BPFClass.LDX:
size = op & 0x18
mode = op & 0xe0
try:
size_name = BPFSize(size).name
mode_name = BPFMode(mode).name
class_name = "LD" if bpf_class == BPFClass.LD else "LDX"
if mode == BPFMode.IMM.value:
# print(src_type, src_reg)
return f"LD {size_name}/IMM r{dst_reg}, r{src_reg}"
elif mode == BPFMode.MEM.value:
if bpf_class == BPFClass.LDX:
src_desc = f"r{src_reg}"
else:
src_desc = format_immediate(imm)
return f"{size_name}/MEM r{dst_reg}, [r{src_reg}{'+'+str(offset) if offset else ''}]"
else:
val = offset if offset else imm
return f"{size_name}/{mode_name} r{dst_reg}, {format_immediate(val)}"
except ValueError:
return f"Unknown LD/LDX mode or size: mode={mode:x}, size={size:x}"
elif bpf_class == BPFClass.ST or bpf_class == BPFClass.STX:
size = op & 0x18
mode = op & 0xe0
try:
size_name = BPFSize(size).name
mode_name = BPFMode(mode).name
class_name = "ST" if bpf_class == BPFClass.ST else "STX"
if mode == BPFMode.MEM.value:
if bpf_class == BPFClass.ST:
value = format_immediate(imm)
else:
value = f"r{src_reg}"
return f"{size_name}/MEM [r{dst_reg}{'+'+str(offset) if offset else ''}], {value}"
elif mode == BPFMode.XADD.value:
return f"XADD [r{dst_reg}{'+'+str(offset) if offset else ''}], r{src_reg}"
else:
val = offset if offset else imm
return f"ST {size_name}/{mode_name} [r{dst_reg}], {format_immediate(val)}"
except ValueError:
return f"Unknown ST/STX mode or size: mode={mode:x}, size={size:x}"
else:
return f"Unhandled instruction class: {bpf_class.name}, op={op:x}, dst={dst_reg}, src={src_reg}, off={offset}, imm={imm}"
def main():
with open('program.bpf', 'r') as f:
instructions = [int(line.strip()) for line in f.readlines() if line.strip()]
print("Disassembled BPF program:")
print("-------------------------")
for i, instr in enumerate(instructions):
decoded = decode_instruction(i, instr)
print(f"{i:4d}: {instr:16x} {decoded}")
if __name__ == "__main__":
main()

If we run this with our given program, we get the following output

Disassembled BPF program:
-------------------------
140 collapsed lines
0: 3000000004 ADD r0 48 ('0')
1: 3800001004 ADD r0 56 ('8')
2: 5000000002 ST W/IMM [r0], 80 ('P')
3: 100000004 ADD r0 1
4: fffe015d JNE r1, r0, -2
5: 5200000002 ST W/IMM [r0], 82 ('R')
6: 100000004 ADD r0 1
7: 4b00000002 ST W/IMM [r0], 75 ('K')
8: 100000004 ADD r0 1
9: 4200000002 ST W/IMM [r0], 66 ('B')
10: 100000004 ADD r0 1
11: 5100000002 ST W/IMM [r0], 81 ('Q')
12: 100000004 ADD r0 1
13: 5700000002 ST W/IMM [r0], 87 ('W')
14: 100000004 ADD r0 1
15: 4200000002 ST W/IMM [r0], 66 ('B')
16: 100000004 ADD r0 1
17: 4b00000002 ST W/IMM [r0], 75 ('K')
18: 100000004 ADD r0 1
19: 5200000002 ST W/IMM [r0], 82 ('R')
20: a01 LD W/IMM r10, r0
21: 15 JEQ r0, 0, 0
22: 10bc MOV r0 r1
23: ff00001054 AND r0 255
24: 800000074 RSH r0 8
25: 20bc MOV r0 r2
26: ff00002054 AND r0 255
27: 800000074 RSH r0 8
28: 30bc MOV r0 r3
29: ff00003054 AND r0 255
30: 800000074 RSH r0 8
31: 100001014 SUB r0 1
32: 4100002014 SUB r0 65 ('A')
33: 800001024 MUL r0 8
34: 41bc MOV r1 r4
35: 420c ADD r2 r4
36: 50bc MOV r0 r5
37: ff00005054 AND r0 255
38: 800000074 RSH r0 8
39: 60bc MOV r0 r6
40: ff00006054 AND r0 255
41: 800000074 RSH r0 8
42: 70bc MOV r0 r7
43: ff00007054 AND r0 255
44: 800000074 RSH r0 8
45: 100005014 SUB r0 1
46: 4100006014 SUB r0 65 ('A')
47: 800005024 MUL r0 8
48: 85bc MOV r5 r8
49: 860c ADD r6 r8
50: 375d JNE r7, r3, 0
51: 401 LD W/IMM r4, r0
52: 35d JNE r3, r0, 0
53: 5000083055 JNE r0, 80 ('P'), 8
54: 48bd JLE r8, r4, 0
55: 80a5 JLT r0, 0, 0
56: 4000008035 JGE r0, 64 ('@'), 0
57: 4bc MOV r4 r0
58: 81c SUB r8 r0
59: 800000055 JNE r0, 8, 0
60: 470005 JA 71 (131)
61: 5200043055 JNE r0, 82 ('R'), 4
62: 2151d JEQ r5, r1, 2
63: 265d JNE r6, r2, 0
64: 430005 JA 67 (131)
65: 4b00103055 JNE r0, 75 ('K'), 16
66: 215ad JLT r5, r1, 2
67: 3152d JGT r5, r1, 3
68: 511c SUB r1 r5
69: 30005 JA 3 (72)
70: 151c SUB r5 r1
71: 51bc MOV r1 r5
72: 3262d JGT r6, r2, 3
73: 621c SUB r2 r6
74: 30005 JA 3 (77)
75: 261c SUB r6 r2
76: 62bc MOV r2 r6
77: 200025055 JNE r0, 2, 2
78: 100006055 JNE r0, 1, 0
79: 200006055 JNE r0, 2, 0
80: 330005 JA 51 (131)
81: 4200103055 JNE r0, 66 ('B'), 16
82: 80a5 JLT r0, 0, 0
83: 4000008035 JGE r0, 64 ('@'), 0
84: 3152d JGT r5, r1, 3
85: 511c SUB r1 r5
86: 30005 JA 3 (89)
87: 151c SUB r5 r1
88: 51bc MOV r1 r5
89: 300005074 RSH r0 3
90: 3262d JGT r6, r2, 3
91: 621c SUB r2 r6
92: 30005 JA 3 (95)
93: 261c SUB r6 r2
94: 62bc MOV r2 r6
95: 565d JNE r6, r5, 0
96: 230005 JA 35 (131)
97: 5100123055 JNE r0, 81 ('Q'), 18
98: 80a5 JLT r0, 0, 0
99: 4000008035 JGE r0, 64 ('@'), 0
100: e151d JEQ r5, r1, 14
101: d261d JEQ r6, r2, 13
102: 3152d JGT r5, r1, 3
103: 511c SUB r1 r5
104: 30005 JA 3 (107)
105: 151c SUB r5 r1
106: 51bc MOV r1 r5
107: 300005074 RSH r0 3
108: 3262d JGT r6, r2, 3
109: 621c SUB r2 r6
110: 30005 JA 3 (113)
111: 261c SUB r6 r2
112: 62bc MOV r2 r6
113: 565d JNE r6, r5, 0
114: 110005 JA 17 (131)
115: 5700003055 JNE r0, 87 ('W'), 0
116: 80a5 JLT r0, 0, 0
117: 4000008035 JGE r0, 64 ('@'), 0
118: 3152d JGT r5, r1, 3
119: 511c SUB r1 r5
120: 30005 JA 3 (123)
121: 151c SUB r5 r1
122: 51bc MOV r1 r5
123: 300005074 RSH r0 3
124: 3262d JGT r6, r2, 3
125: 621c SUB r2 r6
126: 30005 JA 3 (129)
127: 261c SUB r6 r2
128: 62bc MOV r2 r6
129: 560c ADD r6 r5
130: 100005055 JNE r0, 1, 0
131: 1002 ST W/IMM [r0], 0
132: 801 LD W/IMM r8, r0
133: 5a00050015 JEQ r0, 90 ('Z'), 5
134: 4002 ST W/IMM [r0], 0
135: 8303 ST W/IMM [r3], 0
136: 10000a004 ADD r0 1
137: ff8b0005 JA -117 (20)
138: 1ff00009004 ADD r0 511
139: 133700009002 ST W/IMM [r0], 4919

While it looks pretty reasonable, there a few bits that seem off.

For example, the chunk

20: a01 LD W/IMM r10, r0
21: 15 JEQ r0, 0, 0
22: 10bc MOV r0 r1
23: ff00001054 AND r0 255
24: 800000074 RSH r0 8
25: 20bc MOV r0 r2
26: ff00002054 AND r0 255
27: 800000074 RSH r0 8
28: 30bc MOV r0 r3
29: ff00003054 AND r0 255
30: 800000074 RSH r0 8
31: 100001014 SUB r0 1
32: 4100002014 SUB r0 65 ('A')

Seems like it should be extracting the bytes of some given number that was loaded in, but it’s moves values into r0 from registers that weren’t previously set. This is pretty odd behavior, and would make a lot more sense if it were the other way around.

This part, along with some other parts, led me to believe that the source and destination registers were swapped in the program or interpreter itself.

With a bit of debugging and trial and error, I confirmed this to be the case, so with a quick edit to Gemini’s output, we can finally disassemble the program correctly:

disasm.py
def decode_instruction(i, instruction):
"""Decode a BPF instruction from a 64-bit integer."""
# eBPF instruction format:
# op:8, dst:4, src:4, off:16, imm:32
# Extract fields from the instruction
op = (instruction & 0xFF)
dst_reg = ((instruction >> 8) & 0xF)
src_reg = ((instruction >> 12) & 0xF)
src_reg = ((instruction >> 8) & 0xF)
dst_reg = ((instruction >> 12) & 0xF)
offset = struct.unpack('h', struct.pack('H', (instruction >> 16) & 0xFFFF))[0] # Signed 16-bit
imm = struct.unpack('i', struct.pack('I', (instruction >> 32) & 0xFFFFFFFF))[0] # Signed 32-bit

Here’s the final correct to the best of my knowledge disassembly:

Disassembled BPF program:
-------------------------
140 collapsed lines
0: 3000000004 ADD r0 48 ('0')
1: 3800001004 ADD r1 56 ('8')
2: 5000000002 ST W/IMM [r0], 80 ('P')
3: 100000004 ADD r0 1
4: fffe015d JNE r0, r1, -2
5: 5200000002 ST W/IMM [r0], 82 ('R')
6: 100000004 ADD r0 1
7: 4b00000002 ST W/IMM [r0], 75 ('K')
8: 100000004 ADD r0 1
9: 4200000002 ST W/IMM [r0], 66 ('B')
10: 100000004 ADD r0 1
11: 5100000002 ST W/IMM [r0], 81 ('Q')
12: 100000004 ADD r0 1
13: 5700000002 ST W/IMM [r0], 87 ('W')
14: 100000004 ADD r0 1
15: 4200000002 ST W/IMM [r0], 66 ('B')
16: 100000004 ADD r0 1
17: 4b00000002 ST W/IMM [r0], 75 ('K')
18: 100000004 ADD r0 1
19: 5200000002 ST W/IMM [r0], 82 ('R')
20: a01 LD W/IMM r0, r10
21: 15 JEQ r0, 0, 0
22: 10bc MOV r1 r0
23: ff00001054 AND r1 255
24: 800000074 RSH r0 8
25: 20bc MOV r2 r0
26: ff00002054 AND r2 255
27: 800000074 RSH r0 8
28: 30bc MOV r3 r0
29: ff00003054 AND r3 255
30: 800000074 RSH r0 8
31: 100001014 SUB r1 1
32: 4100002014 SUB r2 65 ('A')
33: 800001024 MUL r1 8
34: 41bc MOV r4 r1
35: 420c ADD r4 r2
36: 50bc MOV r5 r0
37: ff00005054 AND r5 255
38: 800000074 RSH r0 8
39: 60bc MOV r6 r0
40: ff00006054 AND r6 255
41: 800000074 RSH r0 8
42: 70bc MOV r7 r0
43: ff00007054 AND r7 255
44: 800000074 RSH r0 8
45: 100005014 SUB r5 1
46: 4100006014 SUB r6 65 ('A')
47: 800005024 MUL r5 8
48: 85bc MOV r8 r5
49: 860c ADD r8 r6
50: 375d JNE r3, r7, 0
51: 401 LD W/IMM r0, r4
52: 35d JNE r0, r3, 0
53: 5000083055 JNE r3, 80 ('P'), 8
54: 48bd JLE r4, r8, 0
55: 80a5 JLT r8, 0, 0
56: 4000008035 JGE r8, 64 ('@'), 0
57: 4bc MOV r0 r4
58: 81c SUB r0 r8
59: 800000055 JNE r0, 8, 0
60: 470005 JA 71 (131)
61: 5200043055 JNE r3, 82 ('R'), 4
62: 2151d JEQ r1, r5, 2
63: 265d JNE r2, r6, 0
64: 430005 JA 67 (131)
65: 4b00103055 JNE r3, 75 ('K'), 16
66: 215ad JLT r1, r5, 2
67: 3152d JGT r1, r5, 3
68: 511c SUB r5 r1
69: 30005 JA 3 (72)
70: 151c SUB r1 r5
71: 51bc MOV r5 r1
72: 3262d JGT r2, r6, 3
73: 621c SUB r6 r2
74: 30005 JA 3 (77)
75: 261c SUB r2 r6
76: 62bc MOV r6 r2
77: 200025055 JNE r5, 2, 2
78: 100006055 JNE r6, 1, 0
79: 200006055 JNE r6, 2, 0
80: 330005 JA 51 (131)
81: 4200103055 JNE r3, 66 ('B'), 16
82: 80a5 JLT r8, 0, 0
83: 4000008035 JGE r8, 64 ('@'), 0
84: 3152d JGT r1, r5, 3
85: 511c SUB r5 r1
86: 30005 JA 3 (89)
87: 151c SUB r1 r5
88: 51bc MOV r5 r1
89: 300005074 RSH r5 3
90: 3262d JGT r2, r6, 3
91: 621c SUB r6 r2
92: 30005 JA 3 (95)
93: 261c SUB r2 r6
94: 62bc MOV r6 r2
95: 565d JNE r5, r6, 0
96: 230005 JA 35 (131)
97: 5100123055 JNE r3, 81 ('Q'), 18
98: 80a5 JLT r8, 0, 0
99: 4000008035 JGE r8, 64 ('@'), 0
100: e151d JEQ r1, r5, 14
101: d261d JEQ r2, r6, 13
102: 3152d JGT r1, r5, 3
103: 511c SUB r5 r1
104: 30005 JA 3 (107)
105: 151c SUB r1 r5
106: 51bc MOV r5 r1
107: 300005074 RSH r5 3
108: 3262d JGT r2, r6, 3
109: 621c SUB r6 r2
110: 30005 JA 3 (113)
111: 261c SUB r2 r6
112: 62bc MOV r6 r2
113: 565d JNE r5, r6, 0
114: 110005 JA 17 (131)
115: 5700003055 JNE r3, 87 ('W'), 0
116: 80a5 JLT r8, 0, 0
117: 4000008035 JGE r8, 64 ('@'), 0
118: 3152d JGT r1, r5, 3
119: 511c SUB r5 r1
120: 30005 JA 3 (123)
121: 151c SUB r1 r5
122: 51bc MOV r5 r1
123: 300005074 RSH r5 3
124: 3262d JGT r2, r6, 3
125: 621c SUB r6 r2
126: 30005 JA 3 (129)
127: 261c SUB r2 r6
128: 62bc MOV r6 r2
129: 560c ADD r5 r6
130: 100005055 JNE r5, 1, 0
131: 1002 ST W/IMM [r1], 0
132: 801 LD W/IMM r0, r8
133: 5a00050015 JEQ r0, 90 ('Z'), 5
134: 4002 ST W/IMM [r4], 0
135: 8303 ST W/IMM [r8], 0
136: 10000a004 ADD r10 1
137: ff8b0005 JA -117 (20)
138: 1ff00009004 ADD r9 511
139: 133700009002 ST W/IMM [r9], 4919

ebpf chess fun

If you haven’t already guessed, this emulates a sort of chess program. We have P for the pawns, then R, K, B, Q, and W for the rooks, knights, bishops, queens, and kings respectively.

We can split up this program into a few seperate sections.

First, the initial section sets up the board by placing a row of 8 pawns, then the back rank of what we can assume is our pieces. It places these spanning positions 48-63 in memory, which is 2 rows of 8 pieces.

0: 3000000004 ADD r0 48 ('0')
1: 3800001004 ADD r1 56 ('8')
2: 5000000002 ST W/IMM [r0], 80 ('P')
3: 100000004 ADD r0 1
4: fffe015d JNE r0, r1, -2
5: 5200000002 ST W/IMM [r0], 82 ('R')
6: 100000004 ADD r0 1
7: 4b00000002 ST W/IMM [r0], 75 ('K')
8: 100000004 ADD r0 1
9: 4200000002 ST W/IMM [r0], 66 ('B')
10: 100000004 ADD r0 1
11: 5100000002 ST W/IMM [r0], 81 ('Q')
12: 100000004 ADD r0 1
13: 5700000002 ST W/IMM [r0], 87 ('W')
14: 100000004 ADD r0 1
15: 4200000002 ST W/IMM [r0], 66 ('B')
16: 100000004 ADD r0 1
17: 4b00000002 ST W/IMM [r0], 75 ('K')
18: 100000004 ADD r0 1
19: 5200000002 ST W/IMM [r0], 82 ('R')

Next, we have a section that loads in a number from r10, exits if its 0, otherwise breaks it up byte by byte into r1 through r7, with each register storing a different part of the number.

A look back at the binary shows that loading from r10 actually loads from the list of integers passed in from /tmp/moves.txt, so this is the part that parses the move input into the actual moves.

20: a01 LD W/IMM r0, r10
21: 15 JEQ r0, 0, 0
22: 10bc MOV r1 r0
23: ff00001054 AND r1 255
24: 800000074 RSH r0 8
25: 20bc MOV r2 r0
26: ff00002054 AND r2 255
27: 800000074 RSH r0 8
28: 30bc MOV r3 r0
29: ff00003054 AND r3 255
30: 800000074 RSH r0 8
31: 100001014 SUB r1 1
32: 4100002014 SUB r2 65 ('A')
33: 800001024 MUL r1 8
34: 41bc MOV r4 r1
35: 420c ADD r4 r2
36: 50bc MOV r5 r0
37: ff00005054 AND r5 255
38: 800000074 RSH r0 8
39: 60bc MOV r6 r0
40: ff00006054 AND r6 255
41: 800000074 RSH r0 8
42: 70bc MOV r7 r0
43: ff00007054 AND r7 255
44: 800000074 RSH r0 8
45: 100005014 SUB r5 1
46: 4100006014 SUB r6 65 ('A')
47: 800005024 MUL r5 8
48: 85bc MOV r8 r5
49: 860c ADD r8 r6
50: 375d JNE r3, r7, 0

A move consists of 6 bytes, the first 2 denoting the start position, the 3rd and last being the piece that is to be moved, and the 4th and 5th being the end position.

The positions are encoded with the first byte being the rank, and the second being the file, which is represented by the first byte being subtracted by 1, then multiplied by 8, and the second byte being subtracted by A. These are then added together to get the final position in memory.

Next, the program loads in the piece currently stored at the position on the board, then checks that it is equal to the provided piece in the move. After this, there are move checks for the various pieces to ensure they follow their normal movement patterns, which is done in a if-else like fashion, with comparisons for each piece having its own block.

51: 401 LD W/IMM r0, r4
52: 35d JNE r0, r3, 0
53: 5000083055 JNE r3, 80 ('P'), 8
7 collapsed lines
54: 48bd JLE r4, r8, 0
55: 80a5 JLT r8, 0, 0
56: 4000008035 JGE r8, 64 ('@'), 0
57: 4bc MOV r0 r4
58: 81c SUB r0 r8
59: 800000055 JNE r0, 8, 0
60: 470005 JA 71 (131)
61: 5200043055 JNE r3, 82 ('R'), 4
3 collapsed lines
62: 2151d JEQ r1, r5, 2
63: 265d JNE r2, r6, 0
64: 430005 JA 67 (131)
65: 4b00103055 JNE r3, 75 ('K'), 16
15 collapsed lines
66: 215ad JLT r1, r5, 2
67: 3152d JGT r1, r5, 3
68: 511c SUB r5 r1
69: 30005 JA 3 (72)
70: 151c SUB r1 r5
71: 51bc MOV r5 r1
72: 3262d JGT r2, r6, 3
73: 621c SUB r6 r2
74: 30005 JA 3 (77)
75: 261c SUB r2 r6
76: 62bc MOV r6 r2
77: 200025055 JNE r5, 2, 2
78: 100006055 JNE r6, 1, 0
79: 200006055 JNE r6, 2, 0
80: 330005 JA 51 (131)
81: 4200103055 JNE r3, 66 ('B'), 16
15 collapsed lines
82: 80a5 JLT r8, 0, 0
83: 4000008035 JGE r8, 64 ('@'), 0
84: 3152d JGT r1, r5, 3
85: 511c SUB r5 r1
86: 30005 JA 3 (89)
87: 151c SUB r1 r5
88: 51bc MOV r5 r1
89: 300005074 RSH r5 3
90: 3262d JGT r2, r6, 3
91: 621c SUB r6 r2
92: 30005 JA 3 (95)
93: 261c SUB r2 r6
94: 62bc MOV r6 r2
95: 565d JNE r5, r6, 0
96: 230005 JA 35 (131)
97: 5100123055 JNE r3, 81 ('Q'), 18
17 collapsed lines
98: 80a5 JLT r8, 0, 0
99: 4000008035 JGE r8, 64 ('@'), 0
100: e151d JEQ r1, r5, 14
101: d261d JEQ r2, r6, 13
102: 3152d JGT r1, r5, 3
103: 511c SUB r5 r1
104: 30005 JA 3 (107)
105: 151c SUB r1 r5
106: 51bc MOV r5 r1
107: 300005074 RSH r5 3
108: 3262d JGT r2, r6, 3
109: 621c SUB r6 r2
110: 30005 JA 3 (113)
111: 261c SUB r2 r6
112: 62bc MOV r6 r2
113: 565d JNE r5, r6, 0
114: 110005 JA 17 (131)
115: 5700003055 JNE r3, 87 ('W'), 0
15 collapsed lines
116: 80a5 JLT r8, 0, 0
117: 4000008035 JGE r8, 64 ('@'), 0
118: 3152d JGT r1, r5, 3
119: 511c SUB r5 r1
120: 30005 JA 3 (123)
121: 151c SUB r1 r5
122: 51bc MOV r5 r1
123: 300005074 RSH r5 3
124: 3262d JGT r2, r6, 3
125: 621c SUB r6 r2
126: 30005 JA 3 (129)
127: 261c SUB r2 r6
128: 62bc MOV r6 r2
129: 560c ADD r5 r6
130: 100005055 JNE r5, 1, 0

Notice that all the move checks besides the rook R and knight K check have this clause:

80a5 JLT r8, 0, 0
4000008035 JGE r8, 64 ('@'), 0

This is a check to ensure that the end position is within the bounds of the board. The rook and knight checks don’t have this, so they have the ability to move off the board, which will be useful later.

Finally, if the move was valid, the program will check if the end position currently stores a 0x5a, or Z. If this is the case, the program will store 0x1337 at position 511 in memory, which is eventually what is returned. Otherwise, it will set the end position to the piece being moved, set the start position to 0, then loop back to check the next move.

131: 1002 ST W/IMM [r1], 0
132: 801 LD W/IMM r0, r8
133: 5a00050015 JEQ r0, 90 ('Z'), 5
134: 4002 ST W/IMM [r4], 0
135: 8303 ST W/IMM [r8], 0
136: 10000a004 ADD r10 1
137: ff8b0005 JA -117 (20)
138: 1ff00009004 ADD r9 511
139: 133700009002 ST W/IMM [r9], 4919

wheres the z???

Given the last part of the program, we can see that our goal is somehow to move a piece onto a Z. However, there is no part of the program that actually writes a Z anywhere, so how could we possibly move on a Z?

Going back to the binary, I did a quick search for 0x5a and found an interesting function:

int64_t* l_main___lambda__2___boxed(int32_t* arg1, int32_t* arg2)
uint64_t rbx_1 = zx.q(arg1.d) & 1
uint64_t r14
if ((rbx_1.d & arg2.d) == 0)
r14 = zx.q(lean_nat_big_eq(arg2, arg1))
8 collapsed lines
if ((arg2.b & 1) == 0)
label_4fb317:
int32_t rax_1 = *arg2
if (rax_1 s>= 2)
*arg2 = rax_1 - 1
else if (rax_1 != 0)
lean_dec_ref_cold(arg2)
else
r14.b = arg2 == arg1
10 collapsed lines
if ((arg2.b & 1) == 0)
goto label_4fb317
if (rbx_1 == 0)
int32_t rax_3 = *arg1
if (rax_3 s>= 2)
*arg1 = rax_3 - 1
else if (rax_3 != 0)
lean_dec_ref_cold(arg1)
int64_t rbx_2 = 0x5a
if (r14.b == 0)
rbx_2 = 0
int64_t* result = lean_alloc_small(0x10, 1)
*result = 1
result[1] = rbx_2
return result

In one of the lambdas that gets applied to the list of moves, we have some equality check that if true, will cause 0x5a to be returned instead of 0.

With some debugging, I found that the two values that were being compared against were the 221 that was written to /tmp/moves.txt at the start of /chal.py, as well as the end position for every move in /tmp/moves.txt.

Terminal window
[ Legend: Modified register | Code | Heap | Stack | Writable | ReadOnly | None | RWX | String ]
---- registers ----
$rax : 0x000055555557e600 -> 0x0045474c004543fa
$rbx : 0x0000000000000001
$rcx : 0x00005555559d2d4c <lean_apply_1+0x40c> -> 0x01c7f640187f8b49
$rdx : 0x0000000000000050
$rsp : 0x00007fffffffbb30 -> 0x0000000000000001
$rbp : 0x0000000000000001
$rsi : 0x0000000000000001
$rdi : 0x00000000000001bb
$rip : 0x000055555564f30a <l_main___lambda__2___boxed+0x1a> -> 0x40c6940f41fd394c
$r8 : 0x0000000000000030
$r9 : 0x0000000000000000
$r10 : 0x00007ffff78c0b78 -> 0x00007ffff78c0b68 -> 0x00007ffff78c0b58 -> 0x00007ffff78c0b48 -> ...
$r11 : 0x0000000000000001
$r12 : 0x00007ffff78c0b78 -> 0x00007ffff78c0b68 -> 0x00007ffff78c0b58 -> 0x00007ffff78c0b48 -> ...
$r13 : 0x0000000000000001
$r14 : 0x0000000000000001
$r15 : 0x00000000000001bb
$eflags: 0x202 [ident align vx86 resume nested overflow direction INTERRUPT trap sign zero adjust parity carry] [Ring=3]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
---- stack ----
$rsp 0x7fffffffbb30|+0x0000|+000: 0x0000000000000001
0x7fffffffbb38|+0x0008|+001: 0x0000000000000008
0x7fffffffbb40|+0x0010|+002: 0x0000000000000001
0x7fffffffbb48|+0x0018|+003: 0x00007ffff78e3138 -> 0xf500000000000002
0x7fffffffbb50|+0x0020|+004: 0x00007fffffffbb90 -> 0x00007fffffffbbd0 -> 0x00007fffffffbc10 -> ...
0x7fffffffbb58|+0x0028|+005: 0x00005555559d2d6b <lean_apply_1+0x42b> -> 0xf883078b41c38948 <- retaddr[1]
0x7fffffffbb60|+0x0030|+006: 0x0000000000000002
0x7fffffffbb68|+0x0038|+007: 0x0000000000000008
---- code:x86:64 ----
0x55555564f303 83e301 <l_main___lambda__2___boxed+0x13> and ebx, 0x1
0x55555564f306 85eb <l_main___lambda__2___boxed+0x16> test ebx, ebp
0x55555564f308 745f <l_main___lambda__2___boxed+0x18> je 0x55555564f369 <l_main___lambda__2___boxed+0x79>
*-> 0x55555564f30a 4c39fd <l_main___lambda__2___boxed+0x1a> cmp rbp, r15
0x55555564f30d 410f94c6 <l_main___lambda__2___boxed+0x1d> sete r14b
0x55555564f311 40f6c501 <l_main___lambda__2___boxed+0x21> test bpl, 0x1
0x55555564f315 750d <l_main___lambda__2___boxed+0x25> jne 0x55555564f324 <l_main___lambda__2___boxed+0x34>
0x55555564f317 8b4500 <l_main___lambda__2___boxed+0x27> mov eax, DWORD PTR [rbp + 0x0]
0x55555564f31a 83f802 <l_main___lambda__2___boxed+0x2a> cmp eax, 0x2
---- threads ----
[Thread Id:1, tid:118598] Name: "main", stopped at 0x55555564f30a <l_main___lambda__2___boxed+0x1a>, reason: BREAKPOINT
[Thread Id:2, tid:118601] Name: "main", stopped at 0x7ffff7da0b68 <epoll_pwait+0x78>, reason: BREAKPOINT
---- trace ----
-> [#0] 0x55555564f30a <l_main___lambda__2___boxed+0x1a>
[#1] 0x5555559d2d6b <lean_apply_1+0x42b>
[#2] 0x5555559d2d6b <lean_apply_1+0x42b>
[#3] 0x5555559d2d6b <lean_apply_1+0x42b>
[#4] 0x5555559d2e24 <lean_apply_1+0x4e4>
[#5] 0x5555559d2d6b <lean_apply_1+0x42b>
[#6] 0x5555559d2d6b <lean_apply_1+0x42b>
[#7] 0x5555559d2e24 <lean_apply_1+0x4e4>
[#8] 0x5555559d2d6b <lean_apply_1+0x42b>
[#9] 0x5555559d2d6b <lean_apply_1+0x42b>
-----------------------
gef>

This means the Z only actually ever gets written (returned) if there is a move whose end position is equal to that memory address, as that lambda is checking equality on the value of the end position and the Z intended position.

In this case, the intended position of the Z is 221. Since this is not within the bounds of the board, we need to either use the rook or knight to move onto this space.

The reason gdb shows a value of 0x1bb is because of how Lean encodes natural numbers, where it uses the bottom-most bit to denote if the number is a pointer, as Lean supports arbitrary precision integers. Thus, 0x1bb, which is equal to 443, is actually the value (443 - 1) / 2 = 221. You can see this in the l_main___lambda__2___boxed function as well, where it checks the bottom-most bit to determine if it should perform a simple == check or call a function to check equality.

putting it together

Now since we know where the Z is supposed to be, we can simply have any rook move there in 2 moves. The rooks start at A8 or H8, so I simply chose the A8 one for convenience.

solve.py
k = 221
with open("/tmp/moves.txt", "w") as f:
f.write(f"{k}\n")
row = (k // 8) + 1
col = (k % 8) + 65
moves = [
# b'\x08AR\x1bAR',
# b'\x1bAR\x1bNR',
# move from A8 to A<row>
b'\x08AR' + bytes([row]) + b'AR',
# move from A<row> to <col><row>
bytes([row]) + b'AR' + bytes([row, col]) + b'R',
]
for move in moves:
move = int.from_bytes(move, byteorder='little')
f.write(f"{move}\n")
from pwn import process
r = process("./main")
r.interactive()

Since I didn’t manage the solve this during the original CTF, I don’t have the flag (or any remote interaction). However, running the script will show that the run was successful.

Terminal window
$ python3 chal.py
[+] Starting local process './main': pid 136290
[*] Switching to interactive mode
Successful run!
flag{testing_flag}
[*] Got EOF while reading in interactive
$
[*] Interrupted
[*] Process './main' stopped with exit code 0 (pid 136290)

food tier list

hi esenin

no esenin

By demand of the higher ups, here is a food tier list of the things I ate during the trip to LakeCTF. No explanations are provided.

  1. italian lunch in geneva (ts so fire 🔥)
  2. fancy dinner dessert
  3. geneva center gelato
  4. shawarma wrap thing
  5. fancy dinner entrees: pork, asparagus, fish, potatoes, appetizer thing
  6. sushi and burger apero @ lakectf
  7. geneva ramen dinner
  8. pizza and beer apero @ lakectf
  9. bread and pastries breakfast @ lakectf
  10. mcdonald’s cheeseburger and 4 nuggets (only 5 francs)
  11. sandwiches lunch @ lakectf
  12. chicken wrap and smoothie from geneva airport
  13. small pizza bread thing from random bakery