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¶
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.datkeeps 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.mdfor the trademark and no-firmware statements.