Writing on the Wall Writeup - Cyber Apocalypse 2024
→ 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.
→ 2 Key Techniques
The key techniques employed in this writeup are:
- Static analysis of an x86-64 ELF binary using Ghidra
→ 3 Artifacts Summary
The downloaded artifact had the following hash:
$ shasum -a256 pwn_writing_on_the_wall.zip
15f59255896c50d21c6c341704f0ad6a8c9d422dc3d7cb17e1ba3bfbc3e1172d pwn_writing_on_the_wall.zip
The zip file contained a writing_on_the_wall
binary, a
fake flag file and glibc libraries:
$ unzip pwn_writing_on_the_wall.zip
Archive: pwn_writing_on_the_wall.zip
creating: challenge/
creating: challenge/glibc/
inflating: challenge/glibc/ld-linux-x86-64.so.2
inflating: challenge/glibc/libc.so.6
inflating: challenge/writing_on_the_wall
extracting: challenge/flag.txt
$ shasum -a256 $(find challenge -type f)
bc1a1b62cb2b8d8c8d73e62848016d5c1caa22208081f07a4f639533efee1e4a challenge/glibc/libc.so.6
4d2657934fc7442f86bd1258a7c6440aeab584add04f0c3dae6c6f4610c612f4 challenge/glibc/ld-linux-x86-64.so.2
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:
- ELF: The binary is in ELF format, commonly used on Linux systems.
- x86-64: The binary is compiled for x86-64 bit machines.
- not stripped: Symbols have not been stripped so they will be visible when disassembling the binary.
$ file writing_on_the_wall
writing_on_the_wall: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./glibc/ld-linux-x86-64.so.2, 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.
→ 4.2.2 open_door function reads the flag
Taking a peak at the open_door
function, there are two
key blocks.
-
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 registerESI
, which is the lower 32 bits of registerRSI
. A pointer to the string literal./flag.txt
is loaded into registerRAX
, thenRAX
is moved intoRDI
.RDI
andESI
become the first and second parameters, respectively, when theopen
system call function is called.From
man 2 open
1, the signature ofopen
isint open(const char *pathname, int flags)
. Theflags
parameter being an int, which is typically 32 bits wide, explains whyESI
is used as the second parameter instead ofRSI
. A value of0x0
corresponds to theO_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 variablelocal_14
. The result, assuming the open call succeeded, is thatlocal_14
is now a file descriptor for the flag file. -
Each character from the flag file is read using the
read
system call, then printed out by a call tofputc
.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:-
The address of stack variable
local_15
is loaded intoRCX
. -
Stack variable
local_14
, which is the./flag.txt
file descriptor, is moved toEAX
. -
0x1
is moved toEDX
.
Then:
-
RCX
is moved toRSI
, so thatRSI
now contains a pointer tolocal_15
. -
EAX
is moved toEDI
so thatEDI
now contains the./flag.txt
file descriptor.
Similar to before,
EDI
andRSI
are the first and second parameters passed toread
, whereasEDX
is the third parameter. The signature of the read system call isssize_t read(int fildes, void *buf, size_t nbyte)
soEDX
, which is0x1
, is set to read 1 byte from the flag file. Thus, 1 byte is read from the flag file intolocal_15
.Finally, if > 0 bytes are read, a jump is made to the
LAB_0010141e
label. The instructions here callfputc
to print the read character and are similar to the instructions for the other function calls, except forMOVZX
andMOVSX
. The former ensures the high order bits ofRAX
are zeroed out when the character stored inlocal_15
is assigned toEAX
. The latter ensures the sign bit of the character is preserved by moving the low order 8 bits fromAL
intoEAX
with sign extension. -
The address of stack variable
→ 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:
-
The 8 bytes
0x2073736170743377
are moved into stack variablelocal_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 isw3tpass
:$ echo -n '7733747061737320' |xxd -r -p |xxd 00000000: 7733 7470 6173 7320 w3tpass
-
Seven bytes of user input are read into the
local_1e
stack variable:
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 94.237.58.148 44705
(UNKNOWN) [94.237.58.148] 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