ropvm

Description

This isn’t your usual machine.

Think differently and precisely. Then the flag is yours.

nc chal.h4c.cddc2025.xyz 31123

943KB
archive
Open

Solution

Sadly, I only managed to solve this after the CTF ended.

TL;DR

  • Reverse the binary and the vm to understand the program

  • Enter the correct password to bypass the password checker

  • Exploit buffer overflow in VM region to ROP

  • ROP to printf to leak libc, heap, and stack addresses

  • ROP to read into VM region so that you can write your own instructions for the VM to execute

  • Make instructions read to reg1 to overwrite with offset in stack region, then perform normal ret2libc

Analysis

Understanding The VM

Running checksec, we can see that it has all possible protections enabled.

checksec --file ropvm

Opening ropvm in IDA, we can tell that it reads of bytes of program.bin and the function at sub_3A70 executes code according to the bytes read, which is essentially the VM functionality.

Using LLM to decode the VM functionalities in the sub_3A70 function, we can understand what each variable and the different instructions it could process.

We discover that each instruction processed is of 16 bytes each, where each instruction contains:

We also see what each opcode does:

Opcode
Mnemonic
Purpose (executed in 32bit)

0x01 (1)

LOAD

reg[op1] = memory[op2]

0x02 (2)

STORE

memory[op2] = reg[op1]

0x03 (3)

ADD

reg[op1] = reg[op2] + reg[op3]

0x04 (4)

SUB

reg[op1] = reg[op2] - reg[op3)

0x05 (5)

MUL

reg[op1] = reg[op2] * reg[op3]

0x06 (6)

DIV

reg[op1] = reg[op2] / reg[op3]

0x07 (7)

JMP

EIP = memory[op1]

0x08 (8)

JEQ

if reg[op1] == reg[op2] then PC = memory[op3]

0x09 (9)

JNE

if reg[op1] != reg[op2] then PC = memory[op3]

0x0a (10)

JLT

if reg[op1] < reg[op2] then PC = memory[op3]

0x0b (11)

JGE

if reg[op1] >= reg[op2] then PC = memory[op3]

0x0c (12)

CALL

PUSH ret addr, jmp memory[op1]

0x0d (13)

RET

POP EIP

0x0e (14)

HALT

Stop execution

0x0f (15)

PUSH

PUSH reg[op1]

0x10 (16)

POP

POP reg[op1]

0x11 (17)

MOV

reg[op1] = reg[op2]

0x12 (18)

CMP

reg[op1] = (reg[op2] == reg[op2])

0x13 (19)

XOR

reg[op1] = reg[op2] ^ reg[op3]

0x14 (20)

INC

reg[op1]++

0x15 (21)

SYSCALL

reg[0] = syscall number syscall numbers: 0=read, 1=printf, 3=exit

0x16 (22)

LOADI

reg[op1] = op2

0x17 (23)

LOADR

reg[op1] = memory[reg[op2]]

0x18 (24)

AND

reg[op1] = reg[op2] & op3

There are 3 syscalls implemented:

  1. reg[0] == 0: read(stdin, memory + reg[1], reg[2])

  2. reg[0] == 1: printf(memory + reg[1], memory + reg[2])

  3. reg[0] == 3: exit()

Analyzing program.bin

This is a rough disassembly of the some parts of program.bin:

We can see that the function at 0x1300 is the one that prompts and checks for the password, we can ask Claude to help us figure out the password, which is V3ry53cretP4ass .

We can also recognize that reg[13] = ebp and reg[14] = esp through recognizing the function prologue and epilogue in typical functions. This shows that it reads to esp-32, but it reads in 256 bytes, which shows there is a clear "buffer overflow".

Buffer Overflow in VM

Sending 50 "A"s returns a "Access violation" error, which shows that some kind of overflow is happening. This message however, is printed out by the VM interpreter itself, which has certain checks in place. Thankfully, we can perform ROP using the VM's instructions, since we have control of the VM's "EIP".

VM ROP

To achieve a shell, we need a way to leak addresses first, which we can do so as we can control the VM's register values and we can ROP to syscall to printf with reg[0] pointing to out custom string with a bunch of "%p"s. Unfortunately, we cannot use printf to write to addresses as FORTIFY protection is enabled, but we have address leaks.

We will craft our payload by POPing 0 into reg[0] (read) and an arbitrary address we want to write to into reg[1]. We wrote to a part of the VM with a bunch of nulls (0x2000). We will then we return to 0xa70, which just calls syscall and returns:

We craft our first payload like so:

When we send this payload, we can break when the RET is executed to determine where it returns to. We broke at +0x3eaf and view the value in RDX, which should contain the return address.

In this case, we see RDX is part of our cyclic value sent in the second payload, at an offset of 4. We can control this so that it returns to our payload we just wrote to, which is at 0x2008! We can then write our own VM shellcode.

VM Shellcoding (address leak)

We first write a shellcode to use printf to leak addresses, where the "%p"s will be placed in the first payload and pop the offset to it is in reg[1].

I added this to my first payload, and calculated that the offset to the "%p"s were at memory+0xe021:

Afterwards, we need to continue the payload so we create another read syscall. However, we now overwrite the instructions at 0xa80 and we then jump to 0xa60, which is right before the syscall. This makes it so that my next payload would be executed as VM instructions right after the read syscall is done. (I had to do this as I did not think of overwriting the size at that time, so had size restrictions and had to keep jumping around)

Now we can see that we have a bunch of addresses leaked, in which case we can parse them to get the heap address, stack address, and libc address.

After we have the address leaks, we can write a final payload to overwrite RIP on the stack with actual libc ROP gadgets and perform ret2libc. This would require to get the read syscall to read to stack addresses. There are also no checks performed on the offset that it reads to, which makes this vulnerable.

However, we can't simply just pop an offset to libc into reg[1] as each POP only takes in 32 bits and the offset between libc and the heap base is way more than 32 bits. So with this, we only have a partial arbitrary read, where we can read to addresses in the heap and lower addresses.

VM Shellcoding (read to stack)

We can simply bypass this 32 bit restriction by not using the VM's POP instructions, but to just use the partial arbitrary read we have to read into the address where reg[1] resides, which contains the offset, then we can write a 64 bit offset to libc to reg[1]!

Breaking at the read right after modifying reg[1], we can see the next read is reading to any address we control and its a 64 bit address, so we have a full arbitrary read!

Now we just set this read address to read to the stack and overwrite RIP during the read syscall, then use libc ROP gadgets to perform ret2libc and get a shell.

Classic ret2libc

Nothing special about this part, just create a ROP chain to get execve("/bin/sh",0,0) and overwrite at the address where RIP is found on the stack.

Final Exploit

local flag :')

Last updated

Was this helpful?