Post

LeHACK 2025 - Hidden

Solve for the easiest LeHACK 2025 challenge... and even a lil bit more

LeHACK 2025 - Hidden

Intro

LeHACK is a hacking conference held every year in Paris. At the end of the event, a CTF named “Wargame” is organized and lasts all night long.

During the 2025 LeHACK Wargame, the goal of the RE challenge named “Hidden” was to understand a cypher function stored in a dynamically-linked library.

The Write-up

The binary is a 64-bits stripped ELF named hidden.bin.

To begin the investigation, I launched the automatics analysis with radare2 and then disassembled the function identified as the main. Some call instructions retained my attention.

1
2
3
4
0x000013f4      ff5510         call qword [rbp + 0x10]
0x000013f7      4989c4         mov r12, rax
0x000013fa      4889c7         mov rdi, rax
0x000013fd      ff5508         call qword [rbp + 8]

I simply set a breakpoint at one of these call instructions, and when I jumped in the function, GDB printed the name and the path of a dynamically-linked libray used by hidden.bin.

1
2
3
4
5
6
7
(gdb) b* 0x1000013fd
Breakpoint 1 at 0x1000013fd
(gdb) r
...
Breakpoint 1, 0x00000001000013fd in ?? ()
(gdb) si
0x00007ffff7fba160 in check () from /tmp/hidden.so

After copying hidden.so in my current working directory, I started the investigation.

In the terminal

1
user_one@machine:hidden $ r2 -AA ./hidden.so

In radare2

1
2
3
4
5
6
7
8
9
10
11
12
13
[0x000010a0]> afl
0x00001030    1      6 sym.imp.strncpy
0x00001040    1      6 sym.imp.strlen
0x00001050    1      6 sym.imp.strchr
0x00001060    1      6 sym.imp.memcmp
0x00001070    1      6 sym.imp.fgets
0x00001080    1      6 sym.imp.fwrite
0x00001090    1      6 sym.imp.__cxa_finalize
0x000010a0    4     34 entry0
0x000013a0    3    106 sym.input
0x00001160   27    538 sym.check
0x00001150    5     56 entry.init0
0x00001110    5     50 entry.fini0

Obviously, the check function is what I investigated first. To do it, I exported the CFG representation of this function in GraphViz DOT before generating a PNG file from it.

In radare2

1
2
[0x000010a0]> s sym.check 
[0x00001160]> agfd > check_cfg.dot

In the terminal

1
user_one@machine:hidden $ dot check_cfg.dot -T png -o check_cfg.png

The swap

Firstly we can identify a switch case implemented as an if-cascade by the compiler.

switch case cfg

Once reversed we can conclude that this switch case swaps some ASCII characters following this “swap table”:

Character ACharacter B
_/
?!
#@

To be honest, when I reversed all of of this, I firstly believed that the swap was only operated on a copy of the user input, which made me lost a lot of time (HOURS).

The XOR loop

After, we can see that several registers are initialized with some arbitrary values and the datas are then passed through a loop.

XOR cfg

At each round, a character of the user input (pointed by rcx) is xored by the value in al. Then rcx is incremented, which means it now point to the next character of the user input. rsi is then set to 1-&userInput+(&userInput+i) where i is the number of rounds. This can be simplified to rsi = 1+i.

The basic block starting at the address 0x12a8 contains a tricky way to perfom a modulo with the length of the key.

I still don’t fully understand the whole trick. Furthermore, I’m not able to say if it’s the result of an obfuscation technique or any form of compiler optimization. If you are able to explain all the details, feel free to rise an issue.

To sum it up, this loop simply XOR all the datas by hiDd3n, which is the key.

Preparation of the stack for comparison

last block CFG

The last basic block seems a bit complicated to analyse because of the use of the xmm0 register for example. But in fact, this register act as a normal register in our case. The whole block simply puts values on the stack:

  
rsp0xa11062e41462b1d462c51574b132600 ( =xmm0)
rsp+0x100xc1c370d215f4177 ( =rax)
rsp+0x180x4b2f2627
rsp+0x1c0x12

You can check this by setting up a breakpoint at the first instruction of the last basic block and then analyse the stack as follows:

1
2
3
4
5
(gdb) x/29x $rsp
0x7fffffffeb50: 0x00    0x26    0x13    0x4b    0x57    0x51    0x2c    0x46
0x7fffffffeb58: 0x1d    0x2b    0x46    0x41    0x2e    0x06    0x11    0x0a
0x7fffffffeb60: 0x77    0x41    0x5f    0x21    0x0d    0x37    0x1c    0x0c
0x7fffffffeb68: 0x27    0x26    0x2f    0x4b    0x12

The same opeartion can be done with the x/29bx $rsi command if you break at the memcmp.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(gdb) x/10i $rip
=> 0x7ffff7fba30d <check+429>:  call   0x7ffff7fba060 <memcmp@plt>
   0x7ffff7fba312 <check+434>:  add    rsp,0x120
   0x7ffff7fba319 <check+441>:  pop    rbx
   0x7ffff7fba31a <check+442>:  pop    rbp
   0x7ffff7fba31b <check+443>:  pop    r12
   0x7ffff7fba31d <check+445>:  ret
   0x7ffff7fba31e <check+446>:  xchg   ax,ax
   0x7ffff7fba320 <check+448>:  mov    BYTE PTR [rax],0x2f
   0x7ffff7fba323 <check+451>:  add    rax,0x1
   0x7ffff7fba327 <check+455>:  cmp    rax,rdi
(gdb) x/29bx $rsi
0x7fffffffeb50: 0x00    0x26    0x13    0x4b    0x57    0x51    0x2c    0x46
0x7fffffffeb58: 0x1d    0x2b    0x46    0x41    0x2e    0x06    0x11    0x0a
0x7fffffffeb60: 0x77    0x41    0x5f    0x21    0x0d    0x37    0x1c    0x0c
0x7fffffffeb68: 0x27    0x26    0x2f    0x4b    0x12

The last part with xmm0and the complicated instructions was reversed for me by Ghidra. I only understood this part completely once at home.

After having extracted these values, you can the reverse the cypher.

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
    #include <stdio.h>

    int main(){

    unsigned char ciphered[0x1d] = {
        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};

    unsigned char key[7] = "hiDd3n";

        for(int i=0; i<0x1d; i++){
            ciphered[i] = ciphered[i]^key[i%6];
            switch (ciphered[i]) {
                case '_':
                    ciphered[i] = '/';
                    break;
                case '/':
                    ciphered[i] = '_';
                    break;
                case '+':
                    ciphered[i] = '-';
                    break;
                case '-':
                    ciphered[i] = '+';
                    break;
                case '?':
                    ciphered[i] = '!';
                    break;
                case '!':
                    ciphered[i] = '?';
                    break;
                case '#':
                    ciphered[i] = '@';
                    break;
                case '@':
                    ciphered[i] = '#';
                    break;
            }
        }

        printf("%s\n",ciphered);

        return 0;
    }

Once this code compiled and executed you obtain hOW_d!D_YOu_FoUnD_7HIS_bOOk_?, the complete flag is leHACK{hOW_d!D_YOu_FoUnD_7HIS_bOOk_?}.

But how the hell this libray appeared in my system ??

With @remsflems, we were surprised that a library had appeared in our system and asked ourselves how it could happen. My theory was that one of the functions called before main had been modified.

First let’s try to find the first hidden.so appearance during the execution.

Command

1
2
strace -o strace.out ./hidden.bin
cat strace.out | grep hidden.so

Output

1
2
3
...
unlink("/tmp/hidden.so")
...

Then I looked to the syscalls executed around:

Command

1
cat strace.out | grep -B 3 -A 3 ./hidden.so

Output

1
2
3
4
5
6
7
8
9
10
...
mprotect(0x681ad7526000, 8192, PROT_READ) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=16384*1024, rlim_max=RLIM64_INFINITY}) = 0
munmap(0x681ad74b3000, 226179)          = 0
unlink("/tmp/hidden.so")                = -1 ENOENT (Aucun fichier ou dossier de ce nom)
openat(AT_FDCWD, "/tmp/hidden.so", O_RDWR|O_CREAT, 0777) = 3
write(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\240\20\0\0\0\0\0\0"..., 14296) = 14296
close(3)                                = 0
readlink("/proc/self/exe", "/home/user_one/ctf/lehack/wargam"..., 4095) = 55
...

My job now will be to find the functions that are susceptible to make these syscalls in one of the functions called before main. In radare2 we can see entry.init0 and entry.init1.

1
2
3
4
5
[0x00001570]> afl
...
0x00001560    5     56 entry.init0
0x000011b0    9    464 entry.init1
...

As you can see the entry.init0 function is really short and, believe me, there is nothing interesting in it. Unlikely, entry.init1 seems REAAAALLY interesting once disassembled.

I do not put the entire disassembly here because it would be too long.

We can for example see calls to the open, write, close and readlink functions. Something else that is really interesting is that before a large amount of these calls, a custom function (labelled fcn.00001570 by radare2) is called.

Example

1
2
3
4
call fcn.00001570
mov rdi, rbp
mov rsi, rax
call sym.imp.strcmp

Here you can see the whole disassembly of this function:

1
2
3
4
5
6
7
8
9
10
11
12
13
    0x00001570      4989f8         mov r8, rdi
    0x00001573      85f6           test esi, esi
┌─< 0x00001575      7e1c           jle 0x1593
│   0x00001577      8d4eff         lea ecx, [rsi - 1]
│   0x0000157a      4889f8         mov rax, rdi
│   0x0000157d      488d4c0f01     lea rcx, [rdi + rcx + 1]
|   0x00001582      660f1f440000   nop word [rax + rax]
│┌> 0x00001588      3010           xor byte [rax], dl
│╎  0x0000158a      4883c001       add rax, 1
│╎  0x0000158e      4839c8         cmp rax, rcx
│└< 0x00001591      75f5           jne 0x1588
└─> 0x00001593      4c89c0         mov rax, r8
    0x00001596      c3             ret

Once reversed, I conclude that it is a simple xor_by_x function whose prototype in C could be:

1
    char* xor_by_x(char* data, int dataSize, int x);

Let’s check the theory by extracting the bytes xored before the write function call that seems to write the hidden.so file in the system.

1
2
3
4
5
6
7
8
9
10
...
lea rdi, str.oUV
mov edx, 0x10
mov esi, 0x37d8
call fcn.00001570
mov edi, ebp
mov edx, 0x37d8
mov rsi, rax
call sym.imp.write
...

We can see that it takes 0x37d8 values stored at a place named str.oUV by radare2, and then XOR them by 0x10. Now, I’ve to find the offset where values pointed by str.oUV are in hidden.bin:

In radare2

1
2
3
[0x000011b0]> px 10 @ str.oUV 
- offset -  C0C1 C2C3 C4C5 C6C7 C8C9 CACB CCCD CECF  0123456789ABCDEF
0x000043c0  6f55 5c56 1211 1110 1010                 oU\V......

In the terminal

1
2
user_one@machine:hidden $ xxd ./hidden.bin | grep 6f55
000033c0: 6f55 5c56 1211 1110 1010 1010 1010 1010  oU\V............

Lets now write a little bit of code in order to extracts and XOR everything.

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
    #include <stdio.h>
    #include <stdlib.h>

    #define LIBOFFSET 0x33c0
    #define LIBSIZE 0x37d8

    int main(){
        FILE *inFile = fopen("./hidden.bin", "rb"); 
        FILE *outFile = fopen("./extracted_lib.so", "wb"); 

        fseek(inFile, LIBOFFSET, SEEK_SET);
        
        unsigned char *libBytes = malloc(LIBSIZE);
        
        fread(libBytes, 1, LIBSIZE, inFile);

        for(int i=0; i<LIBSIZE; i++){
            libBytes[i] = libBytes[i] ^ 0x10;
        }

        fwrite(libBytes, 1, LIBSIZE, outFile);

        free(libBytes);
        fclose(inFile);
        fclose(outFile);
        return 0;
   }

Once the code compiled and executed, we can see that what we extracted is the lib given by the hidden.bin during the challenge.

1
2
3
4
user_one@machine:hidden $ sha512sum hidden.so 
9fd6ba37925801f4abd45dcc2396c534e094ec7a6d3da33b13bc58c967a7ae94e02caf781ad82f6c5d2ccd039840670362c2e369cf2b10d9bc9a8cda84e8393e  hidden.so
user_one@machine:hidden $ sha512sum extracted_lib.so
9fd6ba37925801f4abd45dcc2396c534e094ec7a6d3da33b13bc58c967a7ae94e02caf781ad82f6c5d2ccd039840670362c2e369cf2b10d9bc9a8cda84e8393e  extracted_lib.so

Solved by W0nda

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

Trending Tags