$ cat writeup.md…
$ cat writeup.md…
asisctf2026
Task: a hex-encoded Win32 shellcode blob hides the flag behind manual PEB walking, export resolution, and anti-analysis control flow. Solution: emulate it in Unicorn with a fake PEB/kernel32, skip the dead-loop trap, trace stack writes, and force the gated byte mutations that reveal the real flag.
No separate organizer description was preserved in the local task files.
The archive only contained Lesh/lesh.hex. Converting it to raw bytes produced a 7182-byte 32-bit Win32 PIC shellcode sample, so the real task was to emulate and understand the shellcode rather than run a normal executable.
The first useful step was turning the hex file into lesh.bin and recognizing the structure as position-independent x86 shellcode. Early code walks the PEB through fs:[0x30], traverses loader structures, and resolves Sleep from kernel32 exports manually.
That immediately suggested a local emulator was enough. A fake PEB, fake loader list, and minimal fake kernel32 export directory were sufficient for Unicorn to execute the blob and follow its API resolution logic.
After the first resolved call returned, execution reliably reached a deliberate anti-analysis trap at offset 0x2A4, where the shellcode executes an infinite EB FE loop. Skipping that trap let execution continue into the part that constructs printable text on the stack and later resolves FatalAppExitA.
Simple string extraction was misleading. The shellcode emits obvious decoys, and the interesting ASIS{...} text exists only transiently while bytes are being assembled and mutated on the stack. The decisive observation came from tracing stack writes near the string buffer and correlating them with nearby arithmetic byte edits.
There were five targeted stack-byte mutations that changed specific positions in the temporary candidate. Two of them were gated by runtime state, so plain emulation exposed only an intermediate decoy. For analysis, forcing the branch conditions at the responsible comparison sites made all observed text mutations execute and produced the scoreboard-valid final string.
The archive did not ship a PE file or a runner. Converting lesh.hex to raw bytes yielded a 7182-byte shellcode blob, which matched the task wording much better than any “broken executable” hypothesis.
The shellcode expects Windows process structures, not a normal Linux userspace. I emulated it in 32-bit Unicorn and mapped:
kernel32 image with just enough export metadata to resolve Sleep and later FatalAppExitA.Using UTF-16LE module names in the fake loader list was necessary because the shellcode compares wide-character DLL names during the PEB walk.
Once Sleep was resolved and hooked, the shellcode always reached offset 0x2A4, an intentional jmp $ trap. Treating that as anti-analysis noise and advancing EIP to the next instruction opened the real flag-building path.
Hooking stack writes revealed that printable ASIS{...} text appears only briefly while the shellcode is still editing it. The final visible message path was a decoy, so static strings and late snapshots were not enough.
The key step was to map arithmetic byte instructions to concrete positions in the stack-built string. Five edits mattered:
0x1259: sub byte [esp-5], 0x2a0x125f: sub byte [esp-4], 0x440x135d: add byte [esp-3], 0x200x141f: sub byte [esp-2], 0x3f0x15c7: add byte [esp+6], 0x3eTwo branches guarded part of that mutation set. Forcing eax == 0x3526d3 at 0x1252 and ebx == -14 at 0x1358 caused all five observed edits to fire, which transformed the transient candidate into the real final flag.
The following script reproduces the successful local emulation setup and forced-branch recovery path without embedding the secret in the body:
#!/usr/bin/env python3 from unicorn import Uc, UC_ARCH_X86, UC_MODE_32, UC_HOOK_CODE from unicorn.x86_const import * from pathlib import Path import struct import re BASE = 0x100000 SIZE = 0x40000 ZERO = 0 STACK = 0x300000 STACK_SIZE = 0x10000 PEB = 0x400000 K32 = 0x500000 SLEEP = K32 + 0x3000 FATALAPP = K32 + 0x3010 def p32(x): return struct.pack("<I", x) def write_cstr(uc, addr, s): uc.mem_write(addr, s + b"\x00") def write_wstr(uc, addr, s): uc.mem_write(addr, s.decode().encode("utf-16le") + b"\x00\x00") def setup(uc): uc.mem_map(ZERO, 0x1000) uc.mem_map(BASE, SIZE) uc.mem_map(STACK, STACK_SIZE) uc.mem_map(PEB, 0x10000) uc.mem_map(K32, 0x40000) uc.mem_write(BASE, Path("lesh.bin").read_bytes()) peb = PEB + 0x100 ldr = PEB + 0x200 mod1 = PEB + 0x300 mod2 = PEB + 0x380 uc.mem_write(0x30, p32(peb)) uc.mem_write(peb + 0x0C, p32(ldr)) uc.mem_write(ldr + 0x1C, p32(mod1)) uc.mem_write(mod1 + 0x00, p32(mod2)) uc.mem_write(mod1 + 0x08, p32(0x700000)) uc.mem_write(mod1 + 0x20, p32(PEB + 0x500)) uc.mem_write(mod2 + 0x00, p32(mod2)) uc.mem_write(mod2 + 0x08, p32(K32)) uc.mem_write(mod2 + 0x20, p32(PEB + 0x520)) write_wstr(uc, PEB + 0x500, b"ntdll.dll") write_wstr(uc, PEB + 0x520, b"kernel32.dll") export_rva = 0x200 exp = K32 + export_rva uc.mem_write(K32 + 0x3C, p32(0x100)) uc.mem_write(K32 + 0x100 + 0x78, p32(export_rva)) uc.mem_write(exp + 0x14, p32(2)) uc.mem_write(exp + 0x18, p32(2)) uc.mem_write(exp + 0x1C, p32(0x300)) uc.mem_write(exp + 0x20, p32(0x400)) uc.mem_write(exp + 0x24, p32(0x500 - 2)) uc.mem_write(K32 + 0x300, p32(SLEEP - K32) + p32(FATALAPP - K32)) uc.mem_write(K32 + 0x400, p32(0x600) + p32(0x608)) uc.mem_write(K32 + 0x500, struct.pack("<HH", 1, 2)) write_cstr(uc, K32 + 0x600, b"Sleep") write_cstr(uc, K32 + 0x608, b"FatalAppExitA") uc.reg_write(UC_X86_REG_ESP, STACK + STACK_SIZE // 2) def main(): uc = Uc(UC_ARCH_X86, UC_MODE_32) setup(uc) def code(uc, address, size, user_data): if address == BASE + 0x2A4: uc.reg_write(UC_X86_REG_EIP, BASE + 0x2A6) return if address == SLEEP or address == FATALAPP: esp = uc.reg_read(UC_X86_REG_ESP) ret = struct.unpack("<I", uc.mem_read(esp, 4))[0] pop = 8 if address == SLEEP else 12 uc.reg_write(UC_X86_REG_ESP, esp + pop) uc.reg_write(UC_X86_REG_EIP, ret) return if address == BASE + 0x1252: uc.reg_write(UC_X86_REG_EAX, 0x3526D3) if address == BASE + 0x1358: uc.reg_write(UC_X86_REG_EBX, 0xFFFFFFF2) uc.hook_add(UC_HOOK_CODE, code) uc.emu_start(BASE, BASE + len(Path("lesh.bin").read_bytes()), count=5_000_000) mem = bytes(uc.mem_read(STACK, STACK_SIZE)) for m in re.finditer(rb"ASIS\{[^\x00]{1,80}?\}", mem): print(m.group().decode("ascii", "ignore").replace("ASIS{", "ASIS{REDACTED")) if __name__ == "__main__": main()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar