ropvm
Description
This isnβt your usual machine.
Think differently and precisely. Then the flag is yours.
nc chal.h4c.cddc2025.xyz 31123
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.

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:
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:
reg[0] == 0:
read(stdin, memory + reg[1], reg[2])reg[0] == 1:
printf(memory + reg[1], memory + reg[2])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".

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:
Note that we have to +0x10 for each POP gadget in order to continue controlling the RIP on the stack after each RET
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

Last updated
Was this helpful?
