The Hash That Started With a Zero Byte — Python Bug Hunt

Modelled on the Nintendo Wii "Trucha" signing bug, found by the homebrew community around 2008.

  • Language: Python
  • Layer: Backend
  • Difficulty: Medium
  • Concepts: Security, Crypto, Hashing
  • Modelled on: Nintendo Wii · Trucha bug 2008
  • Visible tests: the genuine hash is accepted; a hash that differs in the first byte is rejected; a zero-led forgery is rejected
  • Reward: 50 XP for a complete fix

Briefing

Modelled on the Nintendo Wii "Trucha" signing bug, found by the homebrew community around 2008. The Wii's system software checked a signature by recovering the signed SHA-1 hash and comparing it with the content's hash using strncmp — a string comparison that stops at the first zero byte. A forged signature that recovered to a hash beginning with 0x00, paired with content tweaked until its own hash also began with 0x00, compared "equal" after zero bytes, and unsigned software ran as if Nintendo had signed it.

This reconstruction's verify.py compares the two hashes the same way: byte by byte, stopping at the first zero.

Fix hashes_match so it compares every byte of both hashes, in constant time.

Bug report

BUG-TRUCHA · Priority: Critical · Reported by: security

hashes_match(computed, recovered) — both are bytes objects:

  • True only when both have the same length AND every byte is equal
  • a zero byte is an ordinary byte value, not a terminator
  • the comparison must not return early on a mismatch (use a constant-time comparison such as hmac.compare_digest)

accept(content, recovered) == hashes_match(sha1(content), recovered).

Observed: content whose SHA-1 starts with 0x00 is accepted with a signature that recovers to twenty zero bytes.

Logs

[ios] title 00010001 sig check: hash[0]=00 recovered[0]=00 -> OK
[ios] launched unsigned title 00010001

The code as shipped

src/ios/verify.py (editable)

digest = bug_require("./digest.py")


def hashes_match(computed, recovered):
    for i in range(len(computed)):
        a = computed[i]
        b = recovered[i] if i < len(recovered) else 0
        if a != b:
            return False
        if a == 0:
            return True
    return True


def accept(content, recovered):
    return hashes_match(digest.sha1(content), recovered)

Read-only context: src/ios/digest.py.

Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More Python bug hunts.