forbytten blogs

Writing on the Wall Writeup - Cyber Apocalypse 2024

Last update:

1 Introduction

This writeup covers the Writing on the Wall Pwn challenge from the Hack The Box Cyber Apocalypse 2024 CTF, which was rated as having a ‘very easy’ difficulty. The challenge involved the identification and exploitation of a stack-based buffer overflow.

The description of the challenge is shown below.

Writing on the Wall challenge description

2 Key Techniques

The key techniques employed in this writeup are:

3 Artifacts Summary

The downloaded artifact had the following hash:

$ shasum -a256

The zip file contained a writing_on_the_wall binary, a fake flag file and glibc libraries:

$ unzip
   creating: challenge/
   creating: challenge/glibc/
  inflating: challenge/glibc/
  inflating: challenge/glibc/
  inflating: challenge/writing_on_the_wall
 extracting: challenge/flag.txt

$ shasum -a256 $(find challenge -type f)
bc1a1b62cb2b8d8c8d73e62848016d5c1caa22208081f07a4f639533efee1e4a  challenge/glibc/
4d2657934fc7442f86bd1258a7c6440aeab584add04f0c3dae6c6f4610c612f4  challenge/glibc/
6bc354ab8c3a1bed2bc6880cce7b26147bea5e6e7c4b7b07b47cb6a7a5c6838e  challenge/writing_on_the_wall
1d5bc96de556b62162db68870aa29581f152c172cf5e73cf74f381cf42c07b84  challenge/flag.txt

4 Static analysis

4.1 Basic file identification

The file command was used to identify the binary. For the purposes of the challenge, the key properties identified were:

$ file writing_on_the_wall
writing_on_the_wall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./glibc/, BuildID[sha1]=e1865b228b26ed7b4714423d70d822f6f188e63c, for GNU/Linux 3.2.0, not stripped

4.2 Ghidra

The binary was imported into Ghidra using default settings and the main function located.

4.2.1 main function calls open_door

The main function tests if the strings pointed to by the local_18 and local_1e stack variables are equal by calling the strcmp function. If they are equal, it calls the open_door function.

The breakdown of the disassembled instructions is as follows. The address of stack variable local_18 is loaded into register RDX and the address of stack variable local_1e is loaded into register RAX. RDX is then moved into RSI and RAX is moved into into RDI. In other words, RDI is now a pointer to local_1e and RSI is now a pointer to local_18.

In the x86-64 architecture, the calling convention for function calls dictates that RDI and RSI are the first and second parameters, respectively. Therefore, when the strcmp function is called, it is passed pointers to local_1e and local_18, which matches the strcmp function signature. If the strings are equal and hence the result of strcmp is 0 (stored in EAX), the open_door function is called.

main function calls open_door if local_18 equals local_1e

4.2.2 open_door function reads the flag

Taking a peak at the open_door function, there are two key blocks.

  1. The flag file is opened and the file descriptor stored in the local_14 stack variable.

    Breaking this down in the assembly below, 0x0 is moved into register ESI, which is the lower 32 bits of register RSI. A pointer to the string literal ./flag.txt is loaded into register RAX, then RAX is moved into RDI. RDI and ESI become the first and second parameters, respectively, when the open system call function is called.

    From man 2 open1, the signature of open is int open(const char *pathname, int flags). The flags parameter being an int, which is typically 32 bits wide, explains why ESI is used as the second parameter instead of RSI. A value of 0x0 corresponds to the O_RDONLY flag to open the file read only:

    $ grep -H O_RDONLY $(locate fcntl.h) | grep asm-generic
    /usr/include/asm-generic/fcntl.h:#define O_RDONLY       00000000

    Finally, the return value, which is also a 32 bit int, is moved from EAX into the stack variable local_14. The result, assuming the open call succeeded, is that local_14 is now a file descriptor for the flag file.

    open_door function opens the flag file, with the file descriptor stored in local_14
  2. Each character from the flag file is read using the read system call, then printed out by a call to fputc.

    Breaking down the assembly below is more complicated due to the presence of a loop. An unconditional jump is first made to label LAB_00101436. At this label:

    1. The address of stack variable local_15 is loaded into RCX.
    2. Stack variable local_14, which is the ./flag.txt file descriptor, is moved to EAX.
    3. 0x1 is moved to EDX.


    1. RCX is moved to RSI, so that RSI now contains a pointer to local_15.
    2. EAX is moved to EDI so that EDI now contains the ./flag.txt file descriptor.

    Similar to before, EDI and RSI are the first and second parameters passed to read, whereas EDX is the third parameter. The signature of the read system call is ssize_t read(int fildes, void *buf, size_t nbyte) so EDX, which is 0x1, is set to read 1 byte from the flag file. Thus, 1 byte is read from the flag file into local_15.

    Finally, if > 0 bytes are read, a jump is made to the LAB_0010141e label. The instructions here call fputc to print the read character and are similar to the instructions for the other function calls, except for MOVZX and MOVSX. The former ensures the high order bits of RAX are zeroed out when the character stored in local_15 is assigned to EAX. The latter ensures the sign bit of the character is preserved by moving the low order 8 bits from AL into EAX with sign extension.

    open_door function prints each character read from the flag file

4.2.3 Identifying the conditions under which the main function calls open_door

The last remaining piece is to determine under what conditions the main function calls open_door. In the main function, prior to the call being made:

  1. The 8 bytes 0x2073736170743377 are moved into stack variable local_18 but due to the x86-64 architecture being little-endian, the actual order of bytes on the stack will be the reverse, 0x7733747061737320, which is w3tpass:

    $ echo -n '7733747061737320' |xxd -r -p |xxd
    00000000: 7733 7470 6173 7320                      w3tpass
    8 bytes 0x2073736170743377 are moved into local_18
  2. Seven bytes of user input are read into the local_1e stack variable:

    Seven bytes of user input are read into local_1e

From the earlier look at the main function, local_18 must equal local_1e before the open_door function will be called. Since the stack grows from high to low addresses and given Ghidra’s naming convention, local_1e is at a lower address than local_18. The stack can be illustrated from low to high addresses:

offset address byte char
0 local_1e TBD TBD
1 local_1d TBD TBD
2 local_1c TBD TBD
3 local_1b TBD TBD
4 local_1a TBD TBD
5 local_19 TBD TBD
6 local_18 0x77 ‘w’
7 local_17 0x33 ‘3’
8 local_16 0x74 ‘t’
9 local_15 0x70 ‘p’
10 local_14 0x61 ‘a’
11 local_13 0x73 ‘s’
12 local_12 0x73 ‘s’
13 local_11 0x20 ’ ’

Notably, reading 7 bytes into local_1e will overlap 1 byte of local_18, which is an instance of the common weakness CWE-121: Stack-based Buffer Overflow. Since strings are terminated by a null byte 0x00, we can choose any input bytes that result in writing 0x00 to the first byte of local_1e and 0x00 to the first byte of local_18, causing both of them to be empty strings and hence equal. One such payload is 0x00414141414100.

5 Obtaining the flag

The payload was delivered using Python piped into netcat, resulting in the flag being obtained:

$ python3 -c 'print(str(b"\x00\x41\x41\x41\x41\x41\x00","utf-8"),end="")'|nc -n -v 44705
(UNKNOWN) [] 44705 (?) open
〰③ ╤ ℙ Å ⅀ ₷

The writing on the wall seems unreadable, can you figure it out?

>> You managed to open the door! Here is the password for the next one: HTB{3v3ryth1ng_15_r34d4bl3}

6 Conclusion

The flag was submitted and the challenge was marked as pwned

Submission of the flag marked the challenge as pwned