NepCTF 2026 T.A.S P.W.N Writeup
Turning Paper Mario “bluescreen%” into calc.exe
Challenge summary
The challenge asks us to submit a BizHawk movie (*.bk2) for the Japanese version of Paper Mario:
1 | Mario Story (J) [!].n64 |
The judge restores a Windows 10 Pro 22H2 VM, opens the movie in BizHawk 2.9.1, and lets it play for at most 20 minutes. We win if the movie escapes the N64 emulator and executes:
1 | calc.exe |
The supplied clue is a Bilibili video ID:
1 | BV1rt421n7tY |
Our development machine was an Apple Silicon Mac, so the Windows-only exploit could not initially be tested end-to-end. The approach was therefore:
- identify the movie referenced by the clue;
- recover its exploit stages from controller input;
- preserve the known-working BizHawk escape;
- replace only the terminal Windows payload;
- validate every intermediate transformation statically;
- finally replay the generated movie in the real Windows environment.
The final movie worked. It launched the challenge’s calc.exe replacement after the original 55,345-frame playthrough, approximately 15 minutes 22.42 seconds after power-on.
Final artifacts
| File | Purpose |
|---|---|
calc.bk2 |
Final working TAS movie |
craft_calc_tas.py |
Reproducible downloader, patcher, encoder, and verifier |
payload_calc_x64.s |
Source for the native Windows x64 payload |
Important hashes:
| Artifact | SHA-256 |
|---|---|
calc.bk2 |
6d361be21cabe93283e199bed63d57b446855fcd048b507abfb6c220f72f8506 |
Original bluescreen.bk2 |
a446baef914426842940c5a8dbc568a6bd231e410982c6c3fbe8b4acfd679cad |
| Original 612-byte MIPS stage | a593e1bfa53d2dd08dd8a95b56239baf5ea3a2d40fcd8e7d0d4580bc382be0b6 |
| Patched 612-byte MIPS stage | 85041c5508fc038e9ec1ab2a6402d42dda54c1383ee51a687c3d6b54aeb56cfc |
| 129-byte x64 payload | 6ecd14f1d6d72f01bea575f59b0f71c6ba34f984abf988cbd39f98e6a4f36433 |
Safety warning: both the original movie and
calc.bk2deliberately execute native host code through a vulnerable emulator. Only open them in an authorized, disposable VM that can be restored afterward.
1. Following the video clue
Observation. The description says that someone downloaded a TAS movie, played it, and got a blue screen. It then gives the video ID BV1rt421n7tY.
Looking up that ID identifies:
1 | 【TAS】[N64]《纸片马里奥》 "蓝屏%" in 15:22.42 |
The video description links TASVideos submission #8982, “bluescreen%”.
Inference. This is not merely a similar proof of concept. Its metadata matches every important challenge constant:
- Mario Story (Japan);
- ROM SHA-1
B9CCA3FF260B9FF427D981626B82F96DE73586D3; - BizHawk 2.9.1;
- Mupen64Plus core;
- power-on movie;
- 55,345 frames;
- runtime 15:22.42;
- a Windows blue-screen payload.
Next step. Download the original movie and use it as the known-working escape scaffold instead of rebuilding a complete emulator exploit from scratch.
The public submission download is:
1 | https://tasvideos.org/8982S?handler=Download |
The download is an outer ZIP containing bluescreen.bk2:
1 | curl -fL 'https://tasvideos.org/8982S?handler=Download' \ |
Expected hashes:
1 | submission8982.zip |
2. Understanding the BK2 container
Observation. A BK2 movie is itself a ZIP archive. Listing the reference movie gives:
1 | Header.txt |
There is no savestate. The movie starts from power-on and derives its entire state from the ROM and controller inputs.
Useful inspection commands are:
1 | unzip -t bluescreen.bk2 |
The header confirms the target:
1 | emuVersion Version 2.9.1 |
Inference. Whatever native-code exploit exists must be encoded into ordinary-looking controller records in Input Log.txt.
Next step. Inspect the official disclosure and vulnerable emulator source, then look for structured or high-entropy input near the end of the movie.
3. Confirming the emulator vulnerability
BizHawk’s maintainers documented this exact movie in issue #3929. The issue states that malicious N64 software running in the Mupen64Plus core can write to host executable memory and execute arbitrary host code. It affects BizHawk 2.9.1 and earlier; Ares64 is not affected.
The official fix commit adds missing masks and bounds checks to several PI, SP, SI, FlashRAM, and controller paths. The corresponding upstream report is mupen64plus-core issue #1081.
Observation. In BizHawk 2.9.1’s bundled Mupen source, the FlashRAM DMA code uses the raw PI DRAM address to index the host-side RDRAM allocation. The relevant reads and writes are not restricted to the emulated 8 MiB region.
Inference. By selecting out-of-range PI DMA addresses, guest MIPS code can read and write relative to the host RDRAM allocation. The original movie can use that primitive to leak ASLR-dependent pointers, patch dynarec state, and write executable x64 bytes.
Next step. Decode the final controller records into the MIPS program that drives this primitive.
4. Finding the payload in the input log
The final input records look unlike normal gameplay:
- P3 begins carrying repetitive non-gameplay values near frame 55,207;
- all four controllers are active on frames 55,294 through 55,344;
- frame 55,345 terminates the pattern.
BizHawk 2.9.1 packs each N64 controller as four bytes:
1 | [button_low, button_high, signed X, signed Y] |
The implementation is visible in BizHawk’s N64Input.cs.
The public Paper Mario decompilation gives another clue. In the Japanese build, four six-byte OSContPad structures immediately precede gCameras at 0x800B1D60. Their addresses are therefore:
1 | P1: 0x800B1D48 |
Observation. Decoding frame 55,294 produces bytes that begin with valid big-endian MIPS instructions. For example, P1 earlier supplies 3C 10 80 80, which disassembles as:
1 | lui s0, 0x8080 |
P3 repeatedly supplies a jump into the controller-data region. This is a bootstrap that executes instructions directly from controller state and builds a more capable loader at 0x807FFFB8.
Inference. The 51 all-controller frames are not 16 unrestricted bytes per frame. The bootstrap deliberately selects 12 usable bytes from the four controller structures.
The exact per-frame permutation is:
| Output stage bytes | Source controller bytes |
|---|---|
| 0, 1 | P4 X, P4 Y |
| 2, 3 | P2 X, P2 Y |
| 4 | P3 button-low |
| 5 | P2 button-low |
| 6, 7 | P3 X, P3 Y |
| 8 | P1 button-low |
| 9 | P4 button-low |
| 10, 11 | P1 X, P1 Y |
P4’s button-high byte is reserved as a frame marker. It alternates:
1 | 3, 2, 3, 2, ... |
Next step. Apply that permutation to frames 55,294–55,344 and concatenate the results.
This recovers:
1 | 51 frames × 12 bytes = 612 bytes |
5. Accounting for the terminating frame
The bootstrap starts its destination pointer at 0x80800000. Each accepted marker causes one 12-byte copy followed by:
1 | addiu s0, s0, 0xC |
Frame 55,345 changes the P4 marker to zero. A subtle but important detail is that the loader still copies that frame’s twelve zero bytes and executes the increment in the branch delay slot before exiting.
Therefore the final pointer is:
1 | 0x80800000 + (51 data frames + 1 terminator) × 12 |
Why this clue mattered. Several later addresses are calculated relative to s0. Using 0x80800264 instead would shift the inferred payload boundary by twelve bytes and make valid MIPS/data look inexplicable.
With the correct s0 = 0x80800270, the stage layout becomes consistent.
6. What the final MIPS stage does
The 612-byte program has three broad jobs:
- use unchecked FlashRAM/PI DMA for host-relative reads and writes;
- leak pointers and reconfigure Mupen’s old dynarec state;
- emit and execute an x64 payload in a host RWX buffer.
The stage explicitly accesses the PI register block:
1 | lui t0, 0xA460 |
It drives FlashRAM through the sequence represented by commands such as 0xB400, 0xD200, and 0xF000, switching write/read modes and copying between RDRAM and FlashRAM. Because the PI DRAM address is not masked in the vulnerable release, these operations escape the guest RDRAM allocation.
Static analysis of the exact BizHawk 2.9.1 Windows DLL found:
1 | RDRAM DLL RVA: 0x658D0 |
The stage keeps these version-specific pieces unchanged. Reusing them is much safer than designing a new host escape without a Windows test machine.
The final guest jump is:
1 | 0x803536AC |
It dispatches the compiled host block at offset zero in Mupen’s dynamically allocated PAGE_EXECUTE_READWRITE buffer.
7. Recovering the encrypted original x64 payload
At stage offset 0x140 we find the ASCII bytes:
1 | HIMITSU! |
This translates to “secret,” matching the challenge description’s wording about discovering the secret that controls the swarm.
The MIPS decryption loop at offsets 0x78..0x93 is:
1 | dmult s4, s3 |
The initial state and multiplier are:
1 | s4 seed: 0x2ED763B30C0D4DAB |
The first multiplication explains the marker:
1 | 0x2ED763B30C0D4DAB × 0x6E463 mod 2^64 |
For each of 24 big-endian qwords:
1 | key = (key * 0x6E463) & 0xFFFFFFFFFFFFFFFF |
This decrypts stage offsets 0x140..0x1FF.
At first, the decrypted bytes still did not look like forward x64 code. That became the most important debugging pivot in the solve.
8. Debugging pivot: the host payload is written backward
Initial, incorrect assumption
The first theory was:
- NOP the MIPS decrypt loop;
- place ordinary plaintext x64 bytes starting at stage offset
0x140; - let the later loop copy them directly into host executable memory.
This was attractive because our calc payload easily fit in the apparent 188-byte region. However, closer inspection showed two problems:
- offset
0xC0executessd s2, ..., overwriting stage offsets0x140..0x147with a leaked host pointer; - the writer loop sets Mupen’s
code_lengthfrom 187 down to zero while reading stage bytes in ascending order.
Source-level evidence
The host-writer loop begins with:
1 | addiu t8, s0, -0xBB |
and reads:
1 | lb t0, -0x4075(t8) |
while incrementing t8 until it reaches s0 inclusively. With s0 = 0x80800270, the source addresses are exactly:
1 | first: 0x807FC140 |
Mupen’s put8() writes directly to:
1 | (*inst_pointer)[code_length] |
Because code_length runs from 187 down to zero, the final host buffer is:
1 | reverse(stage[0x140:0x1FC]) |
There is no extra selector or opcode transformation—only reversal.
Proving the correction
Decrypting the original 192-byte region and reversing the copied 188 bytes produces a valid x64 program beginning:
1 | 48 83 EC 38 48 8D 0D 4B ... |
It cleanly disassembles and contains:
1 | RtlAdjustPrivilege |
Its SHA-256 is:
1 | 7e6be1484c6abe9db5bc6e253a1e6da1e62c99def9419944111a28d8548f7833 |
This explained every previously confusing byte.
Correct patch layout
Stage offsets 0x140..0x147 are runtime scratch and are replaced with the leaked pointer. After reversal they become the final eight host bytes. Our actual controllable code capacity is therefore:
1 | stage[0x148:0x1FC] = 180 bytes |
The correct patch is:
- replace the seven decrypt-loop instructions with MIPS NOPs;
- create
host = calc_shellcode || 0x90 paddingto exactly 180 bytes; - write
reverse(host)into stage offsets0x148..0x1FB; - preserve
HIMITSU!at0x140..0x147as runtime scratch; - preserve offsets
0x1FC..0x1FF, which are outside the copied range; - leave the remaining exploit/bootstrap unchanged.
This correction changed 19 final movie frames:
1 | 55304-55306 |
9. Building a compact calc.exe payload
The native payload must be:
- Windows x64;
- position-independent because of ASLR;
- no more than 180 bytes;
- free of link-time relocations;
- able to find an API without fixed addresses;
- terminal, because resuming the compromised emulator is unnecessary.
The final payload is 129 bytes. Its source is payload_calc_x64.s.
Its logic is:
- obtain the PEB from
GS:[0x60]; - walk
PEB->Ldrmodule entries; - parse each module’s PE32+ export directory;
- look for the exact name
WinExec\0; - resolve the name ordinal and function RVA;
- place
calc.exe\0on the stack; - set
RCXto the string andRDX = 1(SW_SHOWNORMAL); - align the stack and reserve Windows x64 shadow space;
- call
WinExec; - loop forever if it returns.
The core assembly is:
1 | xor ecx, ecx |
The terminal loop means the remaining 51 NOP bytes and the final runtime pointer can never be executed.
10. Validating the Windows payload without Windows
The assembly was built as a Windows x64 object using LLVM:
1 | llvm-mc -triple=x86_64-pc-windows-msvc \ |
Expected result:
1 | size: 129 bytes |
For stronger validation, the exact byte sequence was run under Unicorn with:
- a synthetic x64 TEB, PEB, and loader list;
- an authentic Windows 10 22H2
kernel32.dllmapped as a PE image; - the target module placed at loader positions 2 through 8;
- dummy preceding modules with no exports or no
WinExec; - every possible initial low nibble of
RSP.
All 112 test cases reached the real WinExec export with:
1 | RCX -> "calc.exe\0" |
Representative Windows 10 build 19041 versions from .1889 through .5915 were also checked. In each sample, WinExec was real executable code rather than a forwarded export.
11. Re-encoding the stage into controller inputs
The patcher performs the inverse of the 12-byte controller permutation.
For each 12-byte stage chunk it updates only:
- each controller’s low button byte where used;
- signed X and Y axis values;
- no high button byte used by the loader marker.
It preserves the alternating P4 marker sequence and the zero terminator. The rewritten input lines stay exactly 128 characters long.
The resulting movie has:
1 | 55,345 frames |
The final patched stage hash is:
1 | 85041c5508fc038e9ec1ab2a6402d42dda54c1383ee51a687c3d6b54aeb56cfc |
12. Reproducing calc.bk2
The builder uses only the Python standard library. It can download and verify the source movie itself:
1 | python3 craft_calc_tas.py --output calc.bk2 |
Or it can use a previously downloaded submission ZIP/BK2:
1 | python3 craft_calc_tas.py \ |
Then verify the completed movie:
1 | python3 craft_calc_tas.py --verify-only calc.bk2 |
Expected verification output includes:
1 | movie_sha256: 6d361be21cabe93283e199bed63d57b446855fcd048b507abfb6c220f72f8506 |
The builder refuses an unknown source hash and checks all of the following:
- exact reference BK2 and original stage hashes;
- game, ROM, emulator, and core header values;
- frame count;
- controller decoding and inverse encoding;
- P4 marker alternation and zero terminator;
- original decrypt-loop instructions;
- recovery of the original
NtRaiseHardErrorpayload; - patched stage hash;
- preserved scratch and uncopied key bytes;
- reversed calc payload and NOP padding;
- final BK2 integrity.
Conclusion
The shortest path was not to create a new N64-to-Windows exploit. The video clue led to a public movie that already contained a complete, version-matched BizHawk escape. The task became a careful data-recovery problem:
1 | Bilibili clue |
The most important debugging lesson was the writer direction. NOPing the decryptor was valid, but storing forward shellcode at 0x140 was not. The runtime pointer overwrite and descending code_length revealed that only 0x148..0x1FB was controllable and that the native bytes had to be stored in reverse. Decrypting and disassembling the original payload provided the proof; the successful Windows replay confirmed it.
