Dissecting the R4 DEMON timebomb
I used an R4 card almost every day as a child without really thinking about what its kernel did until I returned to Nintendo hardware last year and the same cartridge stopped booting after an arbitrary date. The blog post by Kynex covers a similar restriction in another R4 kernel but the code and integrity metadata in my build were different enough that I needed to trace its expiry check myself. I wanted to understand what actually stopped the menu from loading and make a permanent bypass without bringing along the unrelated changes in an existing community build.
The problem
The cartridge boots with an older system date but refuses to open the menu after 2 September 2024 and goes straight back to working once the clock is set to an accepted date. Although the warning describes expired firmware, the restriction is actually in the menu executable that reads the DS real time clock while the cartridge firmware and microSD data remain intact. You can get around it by changing the date but that also leaves other applications using the wrong date and writing incorrect timestamps until the console clock is restored.
The failure screen reports a clock error and directs you to the old vendor website before the handler clears ARM9 memory from 0x02000000 and destroys the menu that is currently running. That sounds worse than it is because this path never reaches the routines that write the microSD or cartridge flash and a restart with an accepted date simply reloads the untouched executable.
Finding the problem
A normal string search does not find the warning because the kernel stores important text as transformed words of 16 bits rather than plain ASCII and those messages need decoding before they become useful. The decoder starts by permuting every bit in the encoded length and then applies the same permutation to each character before subtracting bytes selected from an embedded CRC table. Once I recovered that transform I could read 42 printable strings including the expiry messages and paths used by the loader and reset stages, then follow their references to the error handler at ARM9 address 0x02034B14.
The date comparison is not in the handler itself because the decision has already been made by its caller in the startup state machine at 0x02035A14 before menu initialisation begins. ARM9 takes the year, month and day fields already normalised from the RTC and combines them into a weighted integer that does not correspond to a Gregorian day count or Unix timestamp. Writing that expression out in C makes it easier to see how the individual RTC fields contribute to the value that eventually decides whether startup continues:
uint32_t serial =
rtc->month * 0x57
+ rtc->year_since_2000 * 0x2AFB
+ rtc->day
+ 0xA121;
if (serial > 0x4ABBA)
expired_firmware();
At LAB_02035FE8, the decompiler compares the weighted RTC value with 0x4ABBA and transfers control directly into FUN_02034B14 when the threshold is exceeded:
local_34 = 0;
thunk_FUN_0205aa52(&local_34,0x2000000,0x5004000);
LAB_02035fe8:
if (0x4abba < (uint)*(byte *)(iVar23 + 2) * 0x57 +
(uint)*(byte *)(iVar23 + 1) * 0x2afb + ...)
FUN_02034b14();
Because the year field counts from 2000 rather than storing the full calendar year, September 2024 supplies 24 and 9 and the expression produces exactly 0x4ABBA on 2 September. Equality still passes this check but advancing the clock by one day raises the result to 0x4ABBB and sends the same startup path into the expiry handler:
2 September 2024 24 * 11003 + 9 * 87 + 2 + 41249 = 306106 = 0x4ABBA
3 September 2024 24 * 11003 + 9 * 87 + 3 + 41249 = 306107 = 0x4ABBB
Reverse engineering it
The NDS executable R4.dat places ARM9 at file offset 0x200 and runtime base 0x02000000, which lets a runtime instruction address be mapped back to its position in the container. Subtracting that base from 0x02037928 and adding the file offset gives 0x37B28, where four bytes encode the unconditional ARM bl that calls the expiry handler. The call is reached from 0x0203604C, and its resolved target matches the handler identified through the decoded warning strings in the following disassembly:
02037910 02 00 80 e2 add r0,r0,#0x2
02037914 70 eb ff eb bl FUN_020326dc
02037918 00 50 a0 e1 mov r5,r0
0203791c c8 fa ff ea b LAB_02036444
LAB_02037920
02037920 41 6f ff eb bl FUN_0201362c
02037924 ae fa ff ea b LAB_020363e4
LAB_02037928
02037928 79 f4 ff eb bl FUN_02034b14
0203792c c7 f9 ff ea b LAB_02036050
Replacing the call with an ARM NOP is enough to bypass expiry without disturbing registers or startup state but it is not enough to get the patched menu through the later integrity check. ARM7 also checks the ARM9 image against metadata in the secret area at ARM9 address 0x0206B4E4 (file offset 0x6B6E4), which holds encoded ARM9, ARM7 and loader checksums followed by a dependent area value.
The checksum looks like reflected CRC16 starting at 0xFFFF until you reach table entry 246 and find 0x4681 where the standard table would contain 0x4680, so using a standard implementation would miss that difference. Its ARM9 coverage also skips the secret area and most of the final image region before the result goes through the same permutation of 16 bits used by the string decoder. Calculating the area value then means encrypting the encoded ARM9 and ARM7 fields with two embedded DES keys of 56 bits and adjusting the first output word using the loader checksum.
Borrowing the metadata from the community build would not work here because its changes to clone checks, hardware policy and interface behaviour leave it describing a different ARM9 image from the one I wanted to produce. Changing only the expiry call gives raw ARM9 checksum 0x7512 and encoded checksum 0xA493 with area value 0xDE637BBC, while the untouched ARM7 and loader inputs retain checksums 0xEFD0 and 0xDBD7.
Bypassing it
Start with an untouched R4.dat from this exact build, whose header places ARM9 at container offset 0x200, loads it at 0x02000000 and declares 0x91D6C bytes. The call at 0x02037928 therefore lies at image offset 0x37928 and container offset 0x37B28, where the original bytes must be 79 F4 FF EB before these constants can be applied.
Replace the call with mov r0,r0, which occupies the same four bytes and preserves registers, flags and stack state before falling through to the existing branch at 0x0203792C. Its encoding is 0xE1A00000, stored as 00 00 A0 E1 in little endian byte order, so the patched disassembly retains the branch into normal initialisation:
02037920 41 6f ff eb bl FUN_0201362c
02037924 ae fa ff ea b LAB_020363e4
02037928 00 00 a0 e1 mov r0,r0
0203792c c7 f9 ff ea b LAB_02036050
At this point the expiry call is gone but ARM7 will still reject the image unless the checksum over the modified ARM9 regions is recalculated as 0x7512 and stored in its encoded form 0xA493 at 0x0206B4E8. The dependent area value becomes 0xDE637BBC at 0x0206B4EE and there is no reason to alter the ARM7 checksum 0xEFD0 or loader checksum 0xDBD7 because neither of those inputs has changed.
Apply the three replacements below to the extracted ARM9 image only after checking each original byte sequence, keeping the image offsets distinct from container offsets that include the header of 0x200 bytes.
| Runtime address | ARM9 image offset | R4.dat offset | Original bytes | Replacement bytes |
|---|---|---|---|---|
0x02037928 | 0x37928 | 0x37B28 | 79 F4 FF EB | 00 00 A0 E1 |
0x0206B4E8 | 0x6B4E8 | 0x6B6E8 | 59 5F | 93 A4 |
0x0206B4EE | 0x6B4EE | 0x6B6EE | 88 22 24 91 | BC 7B 63 DE |
Before repair, the encoded ARM9 checksum occupies 59 5F at 0x0206B4E8 and the dependent area value occupies 88 22 24 91 at 0x0206B4EE, surrounded by the remaining secret area bytes:
0206b4e4 3f 2f 23 40
0206b4e8 59 5f
0206b4ea 3d a7
0206b4ec fe b3
0206b4ee 88 22 24 91
0206b4f2 94 e1 04 00
After recalculation, those same locations contain encoded checksum 93 A4 and area value BC 7B 63 DE, with the adjacent ARM7 and loader fields left untouched:
0206b4e4 3f 2f 23 40
0206b4e8 93 a4
0206b4ea 3d a7
0206b4ec fe b3
0206b4ee bc 7b 63 de
0206b4f2 94 e1 04 00
The patched ARM9 image must remain exactly 0x91D6C bytes because inserting an image of a different length would shift ARM7, filesystem data or padding within the container. Copying it over the ARM9 range in a duplicate of the original preserves the header of 512 bytes and every other region, leaving exactly 10 changed bytes when the replacements above are complete. The following PowerShell rebuild checks the image length before copying it into the container and writes the result to a separate file so the original remains available:
$container = [System.IO.File]::ReadAllBytes('.\R4.dat')
$arm9 = [System.IO.File]::ReadAllBytes('.\r4_arm9_patched.bin')
if ($arm9.Length -ne 0x91D6C) {
throw 'The patched ARM9 image has the wrong length.'
}
[Array]::Copy($arm9, 0, $container, 0x200, $arm9.Length)
[System.IO.File]::WriteAllBytes('.\R4-notimebomb.dat', $container)
Keep a separate backup of the untouched kernel before renaming the patched output to R4.dat and placing it at the microSD root for the cartridge to load. Dates after 2 September 2024 now follow normal startup and the repaired metadata passes the ARM7 integrity check, while cartridge authentication, game loading, saves, cheats and reset support retain their original routines.