Skip to content

Media protection: the contract, and how to re-seal a modified package

A patched package can be re-sealed

The unit refuses modified firmware because it validates the media against a signed contract. That contract is fully decoded, and tools/patch_contract.py regenerates it from the files in your package.

Workflow:

uv run tools/patch_smeg.py     --src SMEG_PLUS_UPG --out SMEG_PLUS_UPG_mod --copy-package
uv run tools/patch_contract.py --package SMEG_PLUS_UPG_mod

--copy-package matters: without it patch_smeg.py writes only the changed files, and the re-seal fails because there is no contract.dat in the output. build_package.py does all of this in the right order.

Verified on a real package: all 115 records recompute to exactly the values the original contract holds, and after patching the application image the regenerated contract decrypts cleanly and matches every file.

Confirmed on hardware. A re-sealed patched package was accepted by a real unit — string 2099 did not appear, and the update went on to write the application image. See Hardware verification.

The symptom

Patching AppBin/f_BigQuick.bin and flashing the result gives string id 2099:

The update file is protected and cannot be copied.

That is not a copy failure. It comes from the contract check.

flowchart TD
    A["USB inserted<br/>'Identification of media inserted…' (2083)"] --> B["C_BCM_UPGRADE::CheckTrustedSource()<br/>lives in the APPLICATION IMAGE"]
    B --> C["path = m_media_path + '/contract.dat'<br/>read from the MEDIA, not the unit"]
    C --> D["RsaHeaderDecrypt(buf, 0x100)<br/>RSA-OAEP (SHA-1) → 152-byte header"]
    D --> E["RsaReadDataBlockDecrypt()<br/>115 × 212-byte records"]
    E --> F{"every record vs the file it names<br/>size · crc32 · spot-check"}
    F -->|"all match"| G["MSG_BCM_UPGRADE_KNOWN_KEY_INSERTED"]
    F -->|"any mismatch"| H["MSG_BCM_UPGRADE_ILLEGAL_MEDIA"]
    G --> I["copy to the unit and flash"]
    H --> J["popup 2099 — 'The update file is<br/>protected and cannot be copied.'"]

    style J fill:#ffe0e0,stroke:#c62828,stroke-width:2px
    style I fill:#e6f4ea,stroke:#2e7d32,stroke-width:2px
    style G fill:#e6f4ea,stroke:#2e7d32
    style H fill:#ffe0e0,stroke:#c62828

The chart is simplified in two ways (read). Before the check, the unit has already loaded and called UpgPlugin.out from the stick. After KNOWN_KEY_INSERTED come three more stages before anything is flashed: the plugin's compatibility check, the user's confirmation, and the plugin's media check. The update flow has the whole sequence.

Where the check lives

C_BCM_UPGRADE::CheckTrustedSource(C_BCM_UPGRADE*)      @ 0x018778b4   (NAV 5.43.A.R2)

0x0187775c is most likely the same function in the AUDIO_BT image (inferred).

The messages it logs name the mechanism:

(C_BCM_UPGRADE) CheckTrustedSourcetype +
(C_BCM_UPGRADE) CheckTrustedSourcetype : File path is '%s'
(C_BCM_UPGRADE) CheckTrustedSourcetype : Offset is %lu
(C_BCM_UPGRADE) CheckTrustedSourcetype : Size is %lu
(C_BCM_UPGRADE) CheckTrustedSourcetype : value_to_check is %lu
(C_BCM_UPGRADE) CheckTrustedSourcetype open Crypto file Succed
(C_BCM_UPGRADE) CheckTrustedSourcetype : l_array_contract_data is null
(C_BCM_UPGRADE) CheckTrustedSourcetype : Send message MSG_BCM_UPGRADE_KNOWN_KEY_INSERTED
(C_BCM_UPGRADE) CheckTrustedSourcetype : Send message MSG_BCM_UPGRADE_ILLEGAL_MEDIA

The flow:

CheckTrustedSource()
  path = m_media_path + "/contract.dat"          # read from the MEDIA, not the unit
  RsaHeaderDecrypt(buf, 0x100)   -> 152 bytes (0x98) into this+0x22c
  RsaReadDataBlockDecrypt(...)   -> 212-byte (0xd4) records into this+0x2c4
  compare each record against the file it names
  pass -> MSG_BCM_UPGRADE_KNOWN_KEY_INSERTED
  fail -> MSG_BCM_UPGRADE_ILLEGAL_MEDIA          # the user sees 2099

There is also an abort flag, m_abort_contract_checking (this+0x1d3). Setting it makes the check reject the media, not trust it. (read) CheckTrustedSource clears it on entry, and every path taken while it is set ends in MSG_BCM_UPGRADE_ILLEGAL_MEDIA. It is set unconditionally when the stick is removed, and on a backup-state message. See The update flow.

The phase-state gate on 0x4651 in the same HandlePrivateMessage case belongs to UnLoadUpgradePlugin, not to this check (read). UpgPlugin.out from the stick is loaded and called before this check runs (read); see The update flow.

The format

contract.dat is a multiple of 256-byte RSA blocks. Each block is RSA-OAEP (SHA-1) encrypted, then raw-RSA'd with the private exponent.

block 0        header, 152 bytes
                 [0..8]   "19/09/2017"
                 [10..]   manifest version, "1.1.0.0"
                 [44..47] u32 BE record count (115) - the only field the unit reads
                 [48..51] 0x35cf08ae, meaning not known
                 [52..59] magic: deadbeef badef00d

blocks 1..N    one 212-byte record per checked file
                 [0..59]   path, NUL padded (e.g. "/SMEG_PLUS_UPG/NAV/smeg.inf")
                 [60..63]  CheckType, u32 BE
                 [64..67]  uint32 BE
                 [68..71]  uint32 BE
                 [72..]    payload

The layout above comes from decrypting the stock contract with patch_contract.py's own functions (executed). CheckTrustedSource reads the record count into this+0x258 and loops on it; the date, version and magic are never compared (read). CheckType is read as a u32 at +0x3c, so a path must fit in 60 bytes. patch_contract.py re-encrypts the original header unchanged and writes one record per original record, so the count always stays correct.

flowchart LR
    P["[0..59]<br/>path, NUL padded<br/>'/SMEG_PLUS_UPG/NAV/smeg.inf'"]
    T["[60..63]<br/>CheckType, u32"]
    A["[64..67]<br/>u32 BE"]
    B["[68..71]<br/>u32 BE"]
    C["[72..211]<br/>payload"]

    P --- T --- A --- B --- C

    style T fill:#fff3cd,stroke:#b8860b,stroke-width:2px
    subgraph NOTE["one 212-byte record — fields are adjacent, not sequential"]
        direction TB
        EMPTY[" "]
    end
    style NOTE fill:none,stroke:none
    style EMPTY fill:none,stroke:none,color:none
CheckType @64 @68 @72 verified
1 0xfffefffe 0xfffefffe file size, u32 BE ✅ 13/13
2 0xfffefffe 0xfffefffe file crc32, u32 BE ✅ 84/84
3 length offset raw bytes read from the file ✅ 18/18

0xfffefffe is the "field unused" sentinel. All 115 records in a real contract were checked against the files on disk — 84 CRC32s and 18 spot-checks matched exactly, which is what confirms the reading.

The paths are rooted at /SMEG_PLUS_UPG/ (the folder name on the stick), so /SMEG_PLUS_UPG/NAV/smeg.inf maps onto <package>/NAV/smeg.inf.

Why re-sealing works

The unit decrypts with a private exponent, so the contract was encrypted with the public key. Encrypting with a public key needs no secret — and both key pairs are embedded in the firmware image as plain decimal literals:

n  2048-bit modulus          (617 decimal digits)
e  65537
d  ~2048-bit private exponent (616 digits; 2045 bits, hence not exactly 2048)
p, q  1024-bit primes

n == p*q verified;  d*e == 1 mod lambda(n) verified

Key material is not published here

The values are deliberately not reproduced in this repository, and patch_contract.py does not hardcode them either — it extracts the pair from the f_BigQuick.bin of the package you point it at, i.e. from firmware you already own, and uses it for that package only. Nothing is written out.

Publishing these values would be publishing a vendor signing key, which would enable forgery against any device sharing it. Keeping the tool self-contained avoids that.

flowchart LR
    K["key pair embedded in the firmware image<br/>n · e = 65537 · d · p · q"]
    K -->|"public exponent e"| ENC["ENCRYPT contract.dat<br/><b>anyone can do this</b>"]
    K -->|"private exponent d"| DEC["DECRYPT on the unit<br/>CheckTrustedSource()"]
    ENC --> F["contract.dat on the stick"]
    F --> DEC

    style ENC fill:#e6f4ea,stroke:#2e7d32,stroke-width:2px
    style DEC fill:#e3f2fd,stroke:#1565c0,stroke-width:2px

So the seal is: recompute every record from the files on disk, rebuild the blocks, and re-encrypt with the public key. Because OAEP is randomised, the ciphertext differs each run — the unit only cares that it decrypts to the right plaintext.

Workflow

flowchart LR
    S["SMEG_PLUS_UPG<br/>stock package"] --> P1["tools/patch_smeg.py<br/>patch the app image"]
    P1 --> M["SMEG_PLUS_UPG_mod<br/>f_BigQuick.bin patched<br/>CRC cascade rebuilt"]
    M --> P2["tools/patch_contract.py<br/>recompute every record"]
    M --> P3["tools/patch_media.py<br/>optional: ring tones, menu"]
    P3 --> P2
    P2 --> R["contract.dat re-sealed"]
    R --> U["USB stick → car"]

    style P2 fill:#fff3cd,stroke:#b8860b,stroke-width:2px
    style R fill:#e6f4ea,stroke:#2e7d32,stroke-width:2px

The re-seal step is not optional

Any change to a file the contract covers invalidates it. Skipping patch_contract.py means the unit shows 2099 and copies nothing.

patch_smeg.py edits the application image, which moves six records in a NAV package:

path type before after
ctrl.bin 2 crc32 3ad598f2 d135ef35
NAV_ctrl.bin 2 crc32 20a63c9c a0a0ac2d
NAV/smeg.inf 2 crc32 ee006c38 d54b9090
NAV/AppBin/f_BigQuick.bin 1 size 00f9cfa0 00be2087
NAV/AppBin/f_BigQuick.bin 2 crc32 7763afe0 7c310f0f
NAV/AppBin/f_BigQuick.bin 3 spot 140@5159103 — re-read from the file
NAV/AppBin/f_BigQuick.bin.inf 2 crc32 eb883558 3b663d37
flowchart TD
    IMG["NAV/AppBin/f_BigQuick.bin<br/>4 bytes changed"]

    IMG --> A["size · type 1"]
    IMG --> B["crc32 · type 2"]
    IMG --> C["spot-check · type 3"]
    IMG --> D[".inf — crc32 · type 2"]
    D --> E["smeg.inf — crc32 · type 2"]
    E --> F["NAV_ctrl.bin — crc32 · type 2"]
    F --> G["ctrl.bin — crc32 · type 2"]

    style IMG fill:#e3f2fd,stroke:#1565c0,stroke-width:2px

A four-byte patch therefore moves six records — three on the image itself, then the three manifest layers above it.

Note the root ctrl.bin is covered too, so a media-partition edit needs the same treatment.

For type 3 the length and offset are left alone and the bytes are simply re-read from the patched file — the same window the unit will compare, so no knowledge of why that offset was chosen is needed.

Limits

  • The tool re-seals; it does not validate that a package is otherwise sane. A wrong patch is still a wrong patch.
  • It needs the module's application image present (that is where the key lives).
  • contract.dat keeps its size; the record count is not changed, so this only works for files the contract already lists.
  • Only ever used on firmware you own. See the project NOTICE.md for the trademark and no-firmware statements.