rackyard.lol

R4 DEMON: Dissecting the timebomb

The DS hardware I remember most clearly from childhood was the R4 card I used almost every day, although I never understood what its kernel actually did. Returning to Nintendo hardware last year made that question interesting again, especially after the same cartridge stopped booting because the console clock had crossed an arbitrary date. Kynex's writeup covers a similar restriction in another R4 kernel, but this implementation uses different code and integrity metadata. I wanted to identify the exact predicate, follow its failure path and produce the smallest permanent bypass without inheriting unrelated changes from an existing patched build.

1.The problem

The cartridge starts normally with an older system date, yet displays an expiry warning and refuses to construct the menu when the clock moves beyond 2 September 2024. Rolling the clock backward restores operation immediately, which shows that the card hardware, microSD data and persistent cartridge firmware haven't failed. The restriction lives entirely inside the menu executable and uses the Nintendo DS real-time clock as a commercial policy input. That makes the temporary workaround obvious, although changing the console date breaks timestamps and leaves every other date-sensitive application with incorrect time.

The failure screen combines a clock error with the expiry condition, then tells the user to download another kernel from the vendor's old website. After drawing those messages, the handler clears ARM9 memory beginning at 0x02000000, destroying the running menu so execution can't continue past the warning. It doesn't erase the microSD, rewrite cartridge flash or damage the console because every persistent storage routine remains outside this control flow. The apparent bricking is therefore a volatile software lockout rather than a hardware failure, and restarting with an accepted date reloads the untouched executable.

2.Finding the problem

A normal string search doesn't find the warning because the kernel stores important text as transformed 16-bit words instead of plain ASCII. The decoder first permutes every bit in the encoded length, then applies the same permutation to each character before subtracting bytes selected from an embedded CRC table. Recovering that transform exposed 42 printable strings, including the expiry messages and several paths used by the loader and reset stages. Cross-references from those decoded buffers led directly to the error handler at ARM9 address 0x02034B14.

The handler itself contains no date comparison, so the useful reference is its caller inside the large startup state machine at 0x02035A14. Immediately before normal menu initialization, ARM9 reads year, month and day fields which have already been normalized from the console's RTC representation. The predicate reduces to a weighted integer expression rather than a Unix timestamp or a proper Gregorian day calculation. Written as ordinary C, the complete policy check is straightforward:

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:

The 0x4ABBA comparison and call to FUN_02034B14, annotated with direct pointers.

The year field counts from 2000, so September 2024 supplies 24 for the year and 9 for the month. On 2 September the expression equals 0x4ABBA, which passes because the branch tests strictly greater rather than greater than or equal. Increasing only the day produces 0x4ABBB on 3 September and transfers execution into the expiry handler. The exact boundary becomes clear when both dates are evaluated:

2024-09-02: 24 * 11003 + 9 * 87 + 2 + 41249 = 306106 = 0x4ABBA2024-09-03: 24 * 11003 + 9 * 87 + 3 + 41249 = 306107 = 0x4ABBB

3.Reverse engineering it

R4.dat is an NDS executable despite its extension, and its cartridge header places ARM9 at file offset 0x200 with runtime base 0x02000000. Subtracting those values maps runtime address 0x02037928 back to file offset 0x37B28, where the startup routine calls the expiry handler. The 4 bytes there decode as an unconditional ARM bl, while the following instruction branches back into ordinary menu initialization. The relevant sequence is small enough to describe the entire policy hand-off:

02037920  ...02037924  b     0x020363E402037928  bl    0x02034B14    ; display expiry warning and clear ARM9 memory0203792C  b     0x02036050    ; continue normal initialization

The call at 0x02037928 is reached from 0x0203604C, and its resolved target matches the handler identified through the decoded warning strings:

The original BL instruction, its caller cross reference and expiry-handler target, annotated with direct pointers.

Replacing the call with an ARM NOP removes the date failure path while preserving registers and the surrounding startup state. This kernel also checks its ARM9 image from ARM7, however, so changing those 4 instruction bytes invalidates metadata stored in a compact secret area. That structure begins at ARM9 address 0x0206B4E4, corresponding to file offset 0x6B6E4, and contains encoded ARM9, ARM7 and loader checksums followed by an area value. Ignoring those dependent fields makes the first patch look correct in a disassembler but causes the menu to stop later when both processors compare different integrity results.

The checksum recurrence resembles reflected CRC16 with an initial value of 0xFFFF, although table entry 246 contains 0x4681 instead of the standard 0x4680. ARM9 coverage skips the secret area itself and excludes most of the final image region, preventing mutable metadata from feeding directly into its own result. The resulting checksum then passes through the same 16-bit permutation used by the string encoding before being stored in the executable. A final area value encrypts the encoded ARM9 and ARM7 fields with 2 embedded 56-bit DES keys, then adjusts the first output word using the loader checksum.

I compared the original image with the broader community build, but that version also changes clone checks, hardware policy and interface behavior. Its replacement metadata therefore belongs to a different ARM9 byte sequence and can't be reused for a minimal timebomb patch. Recalculating the protected fields after changing only the expiry call produces raw ARM9 checksum 0x7512, stored encoded value 0xA493, and area value 0xDE637BBC. ARM7 checksum 0xEFD0 and loader checksum 0xDBD7 remain unchanged because neither protected input was modified.

4.Bypassing it

Start with an untouched R4.dat from the same build, because every offset and expected byte below belongs to that exact revision. Its NDS header places ARM9 at container offset 0x200, loads it at 0x02000000 and declares a length of 0x91D6C bytes. Runtime address 0x02037928 therefore maps to ARM9 offset 0x37928 and container offset 0x37B28. Verify the original 4 bytes are 79 F4 FF EB before changing anything, since a mismatch means the kernel is a different revision and these constants shouldn't be applied.

Replace that call with mov r0,r0, which occupies the same 4-byte ARM instruction slot without changing registers, flags, stack state or the following control flow. Execution then reaches the existing branch at 0x0203792C exactly as it does when the date predicate accepts the clock. The instruction encodes as word 0xE1A00000, stored little-endian as 00 00 A0 E1. The complete instruction change is:

; original02037928  79 F4 FF EB    bl  0x02034B14; patched02037928  00 00 A0 E1    mov r0,r0

The replacement bytes at 0x02037928 decode as the canonical ARM no operation, while the branch at 0x0203792C continues into the normal initialization path unchanged:

The bypassed expiry call and retained normal branch, annotated with direct pointers.

The altered instruction changes the protected ARM9 result, so a 4-byte patch alone reaches the later ARM7 integrity comparison and stops before the menu. Recalculating the recovered recurrence over the modified ARM9 regions produces raw checksum 0x7512, which the firmware's 16-bit permutation stores as 0xA493. That encoded halfword belongs at runtime address 0x0206B4E8, while the dependent area calculation produces 0xDE637BBC for 0x0206B4EE. ARM7 checksum 0xEFD0 and loader checksum 0xDBD7 remain untouched because their covered inputs haven't changed.

Apply exactly 3 replacements to the extracted ARM9 image, checking every original byte before writing its replacement. The first removes the expiry call, the second repairs the encoded ARM9 checksum and the third updates the encrypted area value derived from it. Container offsets include the 0x200 byte NDS header, whereas image offsets begin at the first ARM9 instruction. The complete map is:

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 is 59 5F at 0x0206B4E8, while the dependent area value is 88 22 24 91 at 0x0206B4EE:

The original checksum and area-value byte ranges, annotated with exact values and direct pointers.

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:

The repaired checksum and area-value byte ranges, annotated with exact values and direct pointers.

The raw ARM9 image must remain exactly 0x91D6C bytes after patching, otherwise reinserting it would shift ARM7, the filesystem data or trailing padding. Copy the modified image over the ARM9 range inside a duplicate of the original container, leaving its 512-byte header and every other region in place. This avoids rebuilding the NDS header and makes the resulting difference exactly 10 bytes. A short PowerShell rebuild with a length guard is:

$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)

The rebuilt container must produce SHA-256 digest fe302cf5fc5adee785f5e542abb6799a1cfd0892fb6c66fa23021ab08cdfacd4, which identifies the expected patched image and confirms that the complete 10-byte modification was applied correctly. Keep the untouched kernel in a separate backup location before renaming the verified output to R4.dat and placing it at the microSD root. With that file installed, RTC dates after 2 September 2024 follow the normal startup path because execution can no longer enter the expiry handler. The repaired integrity values also satisfy ARM7 without touching the routines responsible for cartridge authentication or game loading alongside saves, cheats and reset support.