Practical Binary Analysis - Chapter 5 - CTF walkthrough level 6
Thumbnail: logo

Practical Binary Analysis - Chapter 5 - CTF walkthrough level 6


During the last month I have been busy reading this awesome book by Dennis Andriesse, which quickly became one of my favourite reads on the subject: it does an excellent job on covering the foundation about Linux’s binary analysis and also going above and beyond by providing the reader with all the necessary techniques to be highly proficient and effective when dealing with such alchemical matter.

Chapter 5 has the purpose of illustrating all these different tools of the trade which culminates with an intriguing CTF, whose goal is to challenge the reader to put in practice all the skills&tricks gained up to this point.

The CTF comprises 8 (or even more?) different levels and I have just cleared level 6. Sifting through search engines, I could not find any other walk-through about this level, hence the reason behind this post.

So, let the party begin by starting with the previous level’s hint:

$ ./oracle 0fa355cbec64a05f7a5d050e836b1a1f -h
Find out what I expect, then trace me for a hint

It seems to suggest that before running ltrace or strace we should find some other parameter.

Let’s first start with no parameters, so we can have a baseline. The binary is printing the first twenty-five prime numbers:

$ ./lvl6
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97

However, when it comes to the first tracing tool, strace is giving nothing valuable:

$ strace ./lvl6

execve("./lvl6", ["./lvl6"], [/* 32 vars */]) = 0
brk(NULL)                               = 0x15bc000
access("/etc/", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/", R_OK)      = -1 ENOENT (No such file or directory)
open("/etc/", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=98537, ...}) = 0
mmap(NULL, 98537, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7fc3b5fb3000
close(3)                                = 0
access("/etc/", F_OK)      = -1 ENOENT (No such file or directory)
open("/lib/x86_64-linux-gnu/", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\t\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=1868984, ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fc3b5fb2000
mmap(NULL, 3971488, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7fc3b59dd000
mprotect(0x7fc3b5b9d000, 2097152, PROT_NONE) = 0
mmap(0x7fc3b5d9d000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1c0000) = 0x7fc3b5d9d000
mmap(0x7fc3b5da3000, 14752, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7fc3b5da3000
close(3)                                = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fc3b5fb1000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fc3b5fb0000
arch_prctl(ARCH_SET_FS, 0x7fc3b5fb1700) = 0
mprotect(0x7fc3b5d9d000, 16384, PROT_READ) = 0
mprotect(0x600000, 4096, PROT_READ)     = 0
mprotect(0x7fc3b5fcc000, 4096, PROT_READ) = 0
munmap(0x7fc3b5fb3000, 98537)           = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
brk(NULL)                               = 0x15bc000
brk(0x15dd000)                          = 0x15dd000
write(1, "2 3 5 7 11 13 17 19 23 29 31 37 "..., 722 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97
) = 72
exit_group(0)                           = ?
+++ exited with 0 +++

While ltrace just restates the obvious by listing each prime’s printfs:

binary@binary-VirtualBox:~/code/chapter5/level6$ ltrace ./lvl6
__libc_start_main(0x4005f0, 1, 0x7ffd949f3ef8, 0x400890 <unfinished ...>
__printf_chk(1, 0x400947, 2, 100)                                                                          = 2
__printf_chk(1, 0x400947, 3, 0x7ffffffe)                                                                   = 2
__printf_chk(1, 0x400947, 5, 0x7ffffffe)                                                                   = 2
__printf_chk(1, 0x400947, 7, 0x7ffffffe)                                                                   = 2
__printf_chk(1, 0x400947, 97, 0x7ffffffd)                                                                  = 3
putchar(10, 3, 0, 0x7ffffffd2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97
)                                                                              = 10
+++ exited (status 0) +++

Aiming for our first part of the hint, the command line argument, we can try giving string a run to see if anything fancy pops up.

$ strings lvl6
DEBUG: argv[1] = %s
GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609

Apart from symbols and section names, there are a few interesting strings that stand out. The ‘debug’ string is clearly telling us the expected argv[1] format, our first argument, which is indeed a string. Following along, the next value is ‘get_data_addr’ which might be a viable candidate. Let’s give it a go.

$ ltrace ./lvl6 get_data_addr
__libc_start_main(0x4005f0, 2, 0x7ffd56d2bba8, 0x400890 <unfinished ...>
strcmp("get_data_addr", "get_data_addr")                                                                   = 0
__sprintf_chk(0x7ffd56d2b6a0, 1, 1024, 0x400937)                                                           = 8
setenv("DATA_ADDR", "0x4006c1", 1)                                                                         = 0
__printf_chk(1, 0x400947, 2, 100)                                                                          = 2
__printf_chk(1, 0x400947, 3, 0x7ffffffe)

Now we have started unveiling the mask!
strcmp is expecting ‘get_data_addr’ as a right match, so it looks like we have completed our first milestone by finding the correct argument.

A longer way to accomplish the same goal (which I took) is via GDB: since we know that the binary is expecting a parameter, we can also try it out with the debugger and see what happens.

$gdb --args lvl6 testparameter
gdb-peda$ info file
	Entry point: 0x400790

We have found the entry point of the stripped binary and we can dump all the instructions starting from the address, to verify when main gets called.

gdb-peda$ x/32i 0x400790
     0x4007ad:	mov    rdi,0x4005f0
     0x4007b4:	call   0x4005a0 <__libc_start_main@plt>

The address of main passed to libc_start_main is 0x4005f0, so we are able to dump the instructions starting from that point.

gdb-peda$ x/32i 0x4005f0
   0x4005f0:	push   rbp
   0x4005f1:	push   rbx
   0x4005f2:	mov    ebp,edi
   0x4005f4:	mov    rbx,rsi
   0x4005f7:	sub    rsp,0x5a8
   0x4005fe:	mov    edx,DWORD PTR [rip+0x200a60]        # 0x601064
   0x400604:	mov    rax,QWORD PTR fs:0x28
   0x40060d:	mov    QWORD PTR [rsp+0x598],rax
   0x400615:	xor    eax,eax
   0x400617:	test   edx,edx
   0x400619:	jne    0x400732
   0x40061f:	cmp    ebp,0x1
   0x400622:	mov    QWORD PTR [rip+0x200a3b],0x4005b0        # 0x601068
   0x40062d:	jle    0x400645
   0x40062f:	mov    rdi,QWORD PTR [rbx+0x8]
   0x400633:	mov    esi,0x400929
   0x400638:	call   0x4005b0 <strcmp@plt>

Hey! Do you spot the call to ‘strcmp’ here? Let’s see if we can dump the matching string at runtime. In this code snippet, the expected string is being stored into the esi/rsi register.

gdb-peda$ b *0x400638

gdb-peda$ run
Breakpoint 1, 0x0000000000400638 in ?? ()

gdb-peda$ x/s $rsi
0x400929:	"get_data_addr"

So now it’s time to review the second suggestion from the oracle: ‘…then trace me for a hint’. If we pay close attention to the latest ltrace we ran (the one provided with the correct first argument) we can spot a new function being called.

setenv("DATA_ADDR", "0x4006c1", 1)

This function is supposed to set an environment variable called “DATA_ADDR” with a value of “0x4006c1”. That value seems to reside in our binary address space, so it is wise to have a look at it with the debugger. After some trying, it is clear that GDB is not willing to execute any instruction near that address, and if we dump them, they clearly look like gibberish:

gdb-peda$ x/16i 0x4006c1
   0x4006c1:	cs sub esi,eax
   0x4006c4:	rex.WX lsl rsp,WORD PTR [rsi+0x7f302aee]
   0x4006cc:	in     al,dx
   0x4006cd:	enter  0xffc3,0x42
   0x4006d1:	lea    rbp,[rsp+0x190]
   0x4006d9:	jmp    0x4006e9
   0x4006db:	nop    DWORD PTR [rax+rax*1+0x0]
   0x4006e0:	add    rbx,0x4

Until we get to the well-known ‘lea’, all the previous instructions are not common ones, so this region of memory does not contain code, but data instead. If we calculate the distance between the start and the end of the garbage instructions, we will know the amount of data we have to dump.

gdb-peda$ print/d (0x4006d1-0x4006c1)
$1 = 16

We have 16 bytes, let’s dump them

gdb-peda$ x/16bx 0x4006c1
0x4006c1:	0x2e	0x29	0xc6	0x4a	0x0f	0x03	0xa6	0xee
0x4006c9:	0x2a	0x30	0x7f	0xec	0xc8	0xc3	0xff	0x42

Coincidentally, these 16 bytes also are formed by 32 characters, which matches the expected flag length.
We could give it a try and see if our intrepidness was worth the journey.

binary@binary-VirtualBox:~/code/chapter5$ ./oracle 2e29c64a0f03a6ee2a307fecc8c3ff42
| Level 6 completed, unlocked lvl7         |
Run oracle with -h to show a hint

level completed! :)

binary analysis, ctf, debugging

© 2013-2019 Matteo Malvica. Illustrations by Sergio Kalisiak.