Post

Lehack2025 - Writup Reverse

Writups of the reverse challenges for the WarGame CTF at LeHack 2025.

Lehack2025 - Writup Reverse

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 ?

  1. The user input is copies to an initialized buffer
  2. Some characters are swapped: / with _, + with -, ! with ?, # with @ and vice versa.
  3. The input is then xored with hiDd3n
  4. Flag is set in memory
  5. 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.

meme

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 : screen from mgba

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.

fixed hexbin

And voila, another flag : GBA FLAG

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&gt; .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.

i am stupid

This post is licensed under CC BY 4.0 by the author.

Trending Tags