$ cat writeup.md…
$ cat writeup.md…
d3c2026
Task: A minimal iOS Pac-Man IPA hides a flag behind gameplay telemetry and a Mach-port-backed authenticated VM. Solution: Emulate the original ARM64 verifier and workers under Unicorn, substitute synchronous transport, and verify the final RC4 decryption.
Let's play Pac-Man.
The supplied pacman.ipa is a minimal iOS application. The goal is to reverse the game and recover the value displayed only after its hidden verification path succeeds.
Extract the IPA and identify its only native executable:
unzip -q pacman.ipa -d extracted file extracted/Payload/MachActorVM.app/MachActorVM shasum -a 256 extracted/Payload/MachActorVM.app/MachActorVM
The bundle contains only Info.plist and an ARM64 Mach-O executable. The executable's SHA-256 is:
06478236457c0f1a6f4fc4cefcf834d0c8c43a75ba08bd661e8c75d05c75221a
There are no images, audio files, or other resources in which to hide the result, and ordinary string extraction does not reveal it. The solution therefore requires reversing the native code.
-[ViewController stepGame:] at 0x100005148 delegates game updates to 0x100007a68. That routine moves Pac-Man through a 25-by-13 maze, updates score and telemetry, relocates ten beans, and sets an unlock byte when:
(score >> 4) >= 0x271
This is equivalent to score >= 10000. The renderer then overlays VM UNLOCKED, but this is not the secret. -[ViewController updateWithFrame:] at 0x1000051b0 obtains a 40-byte telemetry object and invokes the verifier at 0x100005c7c.
The verifier first calls a telemetry validator at 0x100006668, initializes four Mach-port workers, and performs 288 authenticated state transitions. Its important components are:
| Component | Address / offset | Purpose |
|---|---|---|
| Verifier | 0x100005c7c | Drives all transitions and accepts the terminal state |
| Request packer | 0x100006384 | Serializes VM state and a record into a Mach message |
| Worker dispatcher | 0x100006d2c | Selects and executes the original worker formulas |
| RC4 wrapper | 0x100006a1c | Decrypts the embedded 40-byte ciphertext |
| Record table | file 0xa800 | 72 records, each encoded as <HBBIQQ> |
| Ciphertext | file 0xb3a0 | 40 encrypted bytes |
The records use several magic values and reference tables near 0xaec0. SplitMix64-style mixing repeatedly uses the constants 0x9e3779b97f4a7c15, 0xbf58476d1ce4e5b9, and 0x94d049bb133111eb. The final state after all authenticated transitions becomes the eight-byte RC4 key.
Directly treating record fields, table entries, or their SplitMix/endian variants as keys fails. Those values are only inputs to the 288-step state evolution.
Reimplementing every undocumented Mach request field and worker formula would be error-prone. A more faithful approach is to map the original Mach-O under Unicorn ARM64 and let the challenge binary execute its own packer, dispatcher, transition checks, and verifier.
emulate_vm.py maps the Mach-O at its preferred base and creates isolated stack and heap regions:
def fresh(): u = Uc(UC_ARCH_ARM64, UC_MODE_ARM) u.mem_map(BASE, 0x20000) u.mem_write(BASE, RAW) u.mem_map(STACK, 0x200000) u.mem_map(HEAP, 0x200000) u.mem_map(STOP, 0x1000) # Imported data pointers and four nonzero fake receive rights are installed. return u
The harness stubs imported functions such as mutex operations, port allocation, and mach_absolute_time. A fixed time value makes execution reproducible.
When the original request packer calls mach_msg to send, the hook captures the serialized message. On its receive call, the harness starts a fresh emulator at the original worker dispatcher, supplies that request, captures the worker response, and copies it into the client's receive buffer:
if opt == 1: # client send transport["request"] = bytes(uc.mem_read(buf, send_size)) ret(uc, 0) elif opt == 2: # client receive response = dispatch_worker( transport["request"], worker_index(magic) ) uc.mem_write(buf, response + bytes(max(0, recv_size - len(response)))) ret(uc, 0)
This substitution preserves the binary's exact request layout and all worker arithmetic while avoiding unavailable iOS Mach-port infrastructure.
The harness executes 0x100005c7c with deterministic telemetry bytes. It bypasses only the external pre-VM telemetry validator by forcing its return value to one. Calls to the request packer are redirected through the synchronous transport described above; all 288 transitions and their authentication checks still execute in the original ARM64 code.
At the RC4 wrapper, the verifier supplies these final key bytes:
a7 40 41 a0 71 5b ad 5e
Interpreted as a little-endian integer, the state is 0x5ead5b71a04140a7.
The harness also performs an independent Python RC4 decryption over exactly 40 bytes from file offset 0xb3a0:
def rc4(key, data): s = list(range(256)) j = 0 for i in range(256): j = (j + s[i] + key[i & 7]) & 0xff s[i], s[j] = s[j], s[i] out = bytearray() i = j = 0 for c in data: i = (i + 1) & 0xff j = (j + s[i]) & 0xff s[i], s[j] = s[j], s[i] out.append(c ^ s[(s[i] + s[j]) & 0xff]) return bytes(out)
Run the authoritative harness from the task directory:
python3 emulate_vm.py
The verifier returns ok=1, reports the expected eight-byte key, and prints plaintext=b'd3ctf{REDACTED}'. This confirms both the original verifier path and the independent RC4 implementation.
notes.md: complete analysis chronology and addresses.solve.py: record parser, SplitMix64 helpers, RC4 implementation, and rejected direct-key experiments.emulate_vm.py: complete reproducible Unicorn harness and final verifier.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar