Lehack2025 - Writup Reverse
Writups of the reverse challenges for the WarGame CTF at LeHack 2025.
LeHack2025 WarGame Reverse Writups.
- Easy: Hidden
- Easy: Z80 Explorer
- Medium: Singularity
- Medium: The Prodigy
Note: I like to sleep and left pretty early (midnight). I don’t know if any other challenges were posted during the night. If so, please send them to me, I might add them to this writeup.
I will push the challs and solves on my github page (once i’ve finished this article).
EASY | Hidden
Description: Welcome to the Singularity Library! Within these digital walls lies a wealth of knowledge and hidden secrets. Your task is to navigate through the intricate data and uncover what you seek. Can you decipher the mysteries that await you?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
➜ md5sum 01_hidden.bin
2881ce64b47162dd87519be2f253de5e 01_hidden.bin
➜ file 01_hidden.bin
01_hidden.bin: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=be185d75e898d9c8b2980317eaea848d9417d03c, for GNU/Linux 3.2.0, stripped
➜ ls -lah 01_hidden.bin
-rwxrwxr-x 1 klcium klcium 29K juin 28 22:42 01_hidden.bin
./01_hidden.bin
.__=\__ .__==__,
jf' ~~=\, _=/~' `\,
._jZ' `\q, /=~ `\__
j5(/ `\./ V\\,
.Z))' _____ | .____, \)/\
j5(K=~~ ~~~~\=_, | _/=~~~~' `~~+K\\,
.Z)\/ `~=L | _=/~ t\ZL
j5(_/.__/===========\__ ~q |j/ .__============___/\J(N,
4L#XXXL_________________XGm, \P .mXL_________________JXXXW8L
~~~~~~~~~~~~~~~~~~~~~~~~~YKWmmWmmW@~~~~~~~~~~~~~~~~~~~~~~~~~~
Welcome to the Singularity Library! Will you be able
to find what you are looking for ?
[INFO] Loading Singularity libary
[INFO] Libary loaded!
> hello
[ERROR] Invalid password!
So far the binary is an ELF x86 executable which requires a password.
As it is an easy challenge the strings
check may save us some time:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
➜ strings 01_hidden.bin
/lib64/ld-linux-x86-64.so.2
_ITM_deregisterTMCloneTable
__gmon_start__
_ITM_registerTMCloneTable
dlclose
dlsym
[...]
hidden.so
check
input
[INFO] Loading Singularity libary
[ERROR] Fail to load Singularity library
[INFO] Libary loaded!
[INFO] Congratulations! You can validate with leHACK\{\%s\}
[ERROR] Invalid password!
[...]
From this, we observe three things:
- The password is not there
- There is a “hidden.so” shared object. Which correlates with the welcoming message.
I could not find it in the import libs. Which is peculiar. As shown below, a ltrace could quickly disclose that the hidden.so
file is created in the temporary folder of the host filesystem.
From this, all we have to do is execute the original binary and copy the /tmp/hidden.so
file before trying a password.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
➜ readelf -d 01_hidden.bin
Dynamic section at offset 0x2dd8 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libdl.so.2]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x1000
➜ ltrace ./01_hidden.bin aze
getenv("LD_LIBRARY_PATH") = nil
remove("/tmp/hidden.so") = -1
open("/tmp/hidden.so", 66, 0777) = 3
write(3, "\177ELF\002\001\001", 14296) = 14296
close(3) = 0
readlink(0x7ffd9bc0b8e1, 0x7ffd9bc0b930, 4095, 0x7ffd9bc0b8f0) s= 61
execle(0x7ffd9bc0b930, 0x7ffd9bc0b930, 0, 0x7ffd9bc0b900 <no return ...>
--- Called exec() ---
[...]
The flag is probably stored in the hidden.so
shared object and exports the check
and input
functions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
➜ nm -D hidden.so
0000000000001160 T check
w __cxa_finalize@GLIBC_2.2.5
U fgets@GLIBC_2.2.5
U fwrite@GLIBC_2.2.5
w __gmon_start__
00000000000013a0 T input
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
U memcmp@GLIBC_2.2.5
U stdin@GLIBC_2.2.5
U stdout@GLIBC_2.2.5
U strchr@GLIBC_2.2.5
U strlen@GLIBC_2.2.5
U strncpy@GLIBC_2.2.5
When disassembling and decompiling in ghidra we find the following (very approximative and partially untrue) C code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
void check(char *param_1)
{
[...BORING STUFF...]
strlen = ::strlen(param_1);
strlen_ = 0x100;
if ((int)(uint)strlen < 0x101) {
strlen_ = (uint)strlen;
}
strlen = (size_t)(int)strlen_;
__s1 = (byte *)strncpy(usr_input_bfr[0],param_1,strlen);
if (strlen != 0) {
strlen__ = __s1 + strlen;
local_RAX_192 = (char *)__s1;
do {
while (loopchar = *local_RAX_192, loopchar == '_') {
*local_RAX_192 = '/';
loop:
local_RAX_192 = local_RAX_192 + 1;
if ((byte *)local_RAX_192 == strlen__) goto exito;
}
if (loopchar == '/') {
*local_RAX_192 = '_';
goto loop;
}
if (loopchar == '+') {
*local_RAX_192 = '-';
goto loop;
}
if (loopchar == '-') {
*local_RAX_192 = '+';
goto loop;
}
if (loopchar == '!') {
*local_RAX_192 = '?';
goto loop;
}
if (loopchar == '?') {
*local_RAX_192 = '!';
goto loop;
}
if (loopchar == '#') {
*local_RAX_192 = '@';
goto loop;
}
if (loopchar != '@') goto loop;
*local_RAX_192 = '#';
local_RAX_192 = local_RAX_192 + 1;
} while ((byte *)local_RAX_192 != strlen__);
exito:
key = 'h';
char_ = __s1;
while( true ) {
*char_ = *char_ ^ key;
pbVar1 = char_ + (1 - (long)__s1);
char_ = char_ + 1;
if (char_ == strlen__) break;
key = "hiDd3n"[(ulong)pbVar1 % 6];
}
if (0x1d < strlen_) {
strlen = 0x1d;
}
}
flag1[0] = '\0';
flag1[1] = '&';
[...MORE CHARS...]
flag1[0x16] = '\x1c';
flag1[0x17] = '\f';
memcmp(__s1,flag1,strlen);
return;
}
So what can we see ?
- The user input is copies to an initialized buffer
- Some characters are swapped:
/
with_
,+
with-
,!
with?
,#
with@
and vice versa. - The input is then xored with
hiDd3n
- Flag is set in memory
- Input compared with the flag
If you’re paying attention, you will notice there’s a bug in the string length verification: The compared memory is equal to the size of the input string so you could get the flag by bruteforcing character by character.
To solve this chall the expected way
, I set a breakpoint in GDB and copy pasted the compared buffer. You can also get it from Ghidra but I don’t like that.
1
2
3
4
► 0x7ffff7fb930d <check+429> call memcmp@plt <memcmp@plt>
s1: 0x7fffffffeb60 ◂— 0x25052829
s2: 0x7fffffffeb40 ◂— 0x462c51574b132600
n: 4
1
2
3
4
5
6
pwndbg> x/100x 0x7fffffffeb40
0x7fffffffeb40: 0x00 0x26 0x13 0x4b 0x57 0x51 0x2c 0x46
0x7fffffffeb48: 0x1d 0x2b 0x46 0x41 0x2e 0x06 0x11 0x0a
0x7fffffffeb50: 0x77 0x41 0x5f 0x21 0x0d 0x37 0x1c 0x0c
0x7fffffffeb58: 0x27 0x26 0x2f 0x4b 0x12 0x7f 0x00 0x00
[...]
I then wrote a quick script to XOR the buffer with our hiDd3n
string and swapped back the characters.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
flag = [0x00, 0x26, 0x13, 0x4b, 0x57, 0x51, 0x2c, 0x46,
0x1d, 0x2b, 0x46, 0x41, 0x2e, 0x06, 0x11, 0x0a,
0x77, 0x41, 0x5f, 0x21, 0x0d, 0x37, 0x1c, 0x0c,
0x27, 0x26, 0x2f, 0x4b, 0x12, 0x7f]
key = "hiDd3n"
def swap(i):
match chr(i):
case "_":
return ord("/")
case "/":
return ord("_")
case "+":
return ord("-")
case "-":
return ord("+")
case "!":
return ord("?")
case "?":
return ord("!")
case "#":
return ord("@")
case "@":
return ord("#")
return i
for i,v in enumerate(flag):
v = v ^ ord(key[i%6])
flag[i] = chr(swap(v))
print(''.join(flag))
1
2
➜ python3 ./solves/01_hidden.py
hOW_d!D_YOu_FoUnD_7HIS_bOOk_?
And voila, I spent too much time on this easy chall !
EASY | Z80 Explorer
Description:This game is a demo but it holds in the code something else.
The file given is a Game Boy ROM image :
1
z80_explorer.gb: Game Boy ROM image (Rev.00) [ROM ONLY], ROM: 256Kbit
Game Boy ROM images are actually pretty common in crackmes as they come with their own instruction sets and lead to creative challs.
You may find the manual reference on Root-me.org
1
2
3
4
5
6
7
8
9
10
11
12
13
➜ strings z80_explorer.gb
88l|
88l|
l|8>
fw8>
fg<?
[JUNK I CANT PRINT]
66IIIIII
uuRRrr
<<BB<<BB<<
<<BBBBBB<<
,-.*0123
)45+
If you emulate it, you won’t see anything but a cuteee pixelized pirate skull :
Anyway, let’s open it in ghidra. I do have the instruction set in Ghidra but I don’t remember if I did import it or not. So you may have to do it in order to decompile it.
At this point the code looks pretty much empty besides three small functions that don’t to much.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
FUN_0172
0172 11 c1 01 LD DE,0x1c1
0175 21 00 90 LD HL,0x9000
0178 01 60 03 LD BC,0x360
017b cd b7 01 CALL FUN_01b7
017e 11 21 05 LD DE,0x521
0181 21 00 98 LD HL,0x9800
0184 01 40 02 LD BC,0x240
0187 cd b7 01 CALL FUN_01b7
018a c9 RET
018b 11 ?? 11h
018c c1 ?? C1h
018d 01 ?? 01h
018e 21 ?? 21h !
018f 00 ?? 00h
0190 90 ?? 90h
0191 01 ?? 01h
0192 60 ?? 60h `
0193 03 ?? 03h
0194 cd ?? CDh
0195 b7 ?? B7h
0196 01 ?? 01h
0197 11 ?? 11h
0198 61 ?? 61h a
0199 07 ?? 07h
019a 21 ?? 21h !
019b 00 ?? 00h
019c 98 ?? 98h
019d 01 ?? 01h
019e 40 ?? 40h @
019f 02 ?? 02h
01a0 cd ?? CDh
01a1 b7 ?? B7h
01a2 01 ?? 01h
01a3 c9 ?? C9h
But something that may catch your attention is that there is a lot of unreferenced code which we will force decompile in Ghidra.
When we declare this as code we end up with some valid code
1
2
3
4
5
6
7
8
9
018b 11 c1 01 LD DE,0x1c1
018e 21 00 90 LD HL,0x9000
0191 01 60 03 LD BC,0x360
0194 cd b7 01 CALL FUN_01b7
0197 11 61 07 LD DE,0x761
019a 21 00 98 LD HL,0x9800
019d 01 40 02 LD BC,0x240
01a0 cd b7 01 CALL FUN_01b7
01a3 c9 RET
So i’m guessing our objective here is to edit the ROM image so we can replace the ret
instruction by a nop
. From the aforementionned manual, we can see that the nop
opcode are just zeros. So all we have to do is replace c9 by 00 at the right position with hexedit.
MEDIUM | Singularity
Description: In a not-so-distant future, a rogue AI known as ‘Singularity’ has taken control of a secure vault containing invaluable data. To access this vault, one must bypass a sophisticated password protection system designed by the AI itself. The only way to unlock the vault is to decipher the password that Singularity has cleverly concealed.
This chall is a python file
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
➜ python3 ./singularity.py
.andAHHAbnn.
.aAHHHAAUUAAHHHAn.
dHP^~" "~^THb.
. .AHF YHA. .
| .AHHb. .dHHA. |
| HHAUAAHAbn adAHAAUAHA |
I HF~"_____ ____ ]HHH I
HHI HAPK""~^YUHb dAHHHHHHHHHH IHH
HHI HHHD> .andHH HHUUP^~YHHHH IHH Welcome back to the singularity !
YUI ]HHP "~Y P~" THH[ IUP
" `HK ]HH' " Can you find my password ?
THAn. .d.aAAn.b. .dHHP
]HHHHAAUP" ~~ "YUAAHHHH[
`HHP^~" .annn. "~^YHH'
YHb ~" "" "~ dHF
"YAb..abdHHbndbndAP"
THHAAb. .adAHHF
"UHHHHHHHHHHU"
]HHUUHHHHHH[
.adHHb "HHHHHbn.
..andAAHHHHHHb.AHHHHHHHAAbnn..
.ndAAHHHHHHUUHHHHHHHHHHUP^~"~^YUHHHAAbn.
"~^YUHHP" "~^YUHHUP" "^YUP^"
"" "~~"
Enter the password>
You can validate with leHACK{}
tldr: you can also here again bruteforce your flag out of the way.
There is this function in the python file:
1
2
3
4
5
def check_password(password):
singularity = lambda t: (ord(t[1])+t[0])^0x42 == bytes.fromhex(
base64.b64decode('MDExMjMyN2IzNDdiMzgxODI1MjA3OGMyMjkxMTNmYzYzYzM3MzMzZDNiMTljMDE1MWQ=').decode()
)[t[0]]
return all(map(singularity, enumerate(password)))
The tiny trick to know to solve this chall is to understand that map(singularity, enumerate(password))
is equivalent to run singularity(enumerate(password))
.
All we have to do then is to simplify the lambda function to then solve it.
In its essence, the function checks if the following equation is true:
1
2
# I removed the string comparison for clarity
(ord(i), v)^0x42 == naughty_string[i]
All there is left to do now is to reverse the equation to get our value in one side to get the flag:
1
2
3
4
5
6
7
8
9
10
11
import base64
b64 = "MDExMjMyN2IzNDdiMzgxODI1MjA3OGMyMjkxMTNmYzYzYzM3MzMzZDNiMTljMDE1MWQ="
array = bytes.fromhex(base64.b64decode(b64).decode())
flag = ""
for i in range(len(array)):
flag+= chr((array[i]^0x42) - i)
print(flag)
And voila, actually this one was much faster to solve than the first one.
1
2
python3 ./solve_singularity.py
COn6r4tS_Y0u_Found_leFl@G
MEDIUM | The Prodigy
I forgot to download the challenge before leaving.