fix(core/xtask): pad a combined image's erased regions as erased
What changed, and why it matters
This commit fixes a build-time tooling bug in Trezor's firmware build helper. When creating a combined firmware image, the tool was filling unused gaps between sections with 0x00 bytes. One specific gap (the UCB region) is erased by the bootloader on first boot. Because erased flash reads as 0xFF, the device would no longer match the original image after its first boot, causing factory verification to fail. The fix makes the tool fill that gap with 0xFF instead, so the image stays identical after the device boots. This is a manufacturing/verification bug, not a security vulnerability that can be exploited by an attacker.
No urgent security action required. Ensure build pipelines pick up the fixed `xtask` binary so factory-flashed combined images are byte-identical after first boot. Treat as a build-quality/manufacturing reliability fix rather than a vulnerability patch.
Security signals we found
Boot chain behavior mismatch between host-generated image and device-erased flash
Factory verification failure due to byte-level image divergence after first boot
Host-side build tooling correction with no firmware/bootloader runtime change
Region identified from linker script symbols (BOOTUCB_START / BOOTUCB_MAXSIZE)
Evidence from the diff
The xtask combine command in core/embed/xtask/src/combine.rs previously padded gaps between firmware sections with SECTION_PADDING (0x00). For the BOOTUCB region, which lies between boardloader and bootloader, this is incorrect because boot_ucb_erase() erases that region on first boot. Post-erase flash reads as 0xFF, so a device flashed with the old combined image would diverge from the source image immediately after first boot. The patch adds ERASED_PADDING (0xFF) and a new erase_boot_ucb() helper that reads BOOTUCB_START/BOOTUCB_MAXSIZE from the model’s memory.ld and overwrites that gap with 0xFF. Models without a declared UCB region are untouched. Unit tests cover both cases. There is no runtime code change in firmware or bootloader; only the host-side image generator is affected.
Changed components
core/embed/xtask/src/combine.rsdocs/core/build/xtask.mdTrezor combined firmware image generator (host build tool)Inspect captured patch +84 / −1
### core/embed/xtask/src/combine.rs
@@ -1,4 +1,5 @@
use std::fs;
+use std::path::Path;
use anyhow::{Context, Result, ensure};
@@ -11,6 +12,15 @@ const COMBINED_PREFIX: &str = "combined-";
/// `combine_firmware.py`, which padded with zero bytes.
const SECTION_PADDING: u8 = 0x00;
+/// Byte used to pad a region the boot chain ERASES on first boot.
+///
+/// Flash reads as 0xFF when erased, so padding such a region with anything else
+/// makes the device stop matching the image the moment it boots -- and a
+/// factory line that verifies by reading flash back would fail. Padded with the
+/// erased value, the erase is a no-op (`boot_ucb_erase` even skips it) and the
+/// image stays byte-identical.
+const ERASED_PADDING: u8 = 0xFF;
+
fn load_binary(model: Model, project: Project) -> Result<Vec<u8>> {
let path = helpers::artifacts_dir(model)?.join(format!("{}.bin", project.binary_name()));
println!("Loading `{}`", path.display());
@@ -19,6 +29,33 @@ fn load_binary(model: Model, project: Project) -> Result<Vec<u8>> {
Ok(data)
}
+/// Overwrite the UCB region with the erased byte, if this model has one.
+///
+/// The region falls in the gap between the boardloader and the bootloader, so
+/// it is already padding; this only corrects the value. See [`ERASED_PADDING`].
+fn erase_boot_ucb(binary: &mut [u8], memory_ld: &Path, base: u32) -> Result<()> {
+ let content = fs::read_to_string(memory_ld)
+ .with_context(|| format!("Failed to read `{}`", memory_ld.display()))?;
+ let Ok(start) = helpers::read_symbol_from_content(&content, "BOOTUCB_START") else {
+ return Ok(());
+ };
+ let size = helpers::read_symbol_from_content(&content, "BOOTUCB_MAXSIZE")?;
+
+ let from = (start - base) as usize;
+ let to = from + size as usize;
+ ensure!(
+ to <= binary.len(),
+ "the UCB region (0x{start:X}..0x{:X}) runs past the combined image",
+ start + size,
+ );
+ binary[from..to].fill(ERASED_PADDING);
+ println!(
+ "Padding the UCB region 0x{start:X}..0x{:X} with 0x{ERASED_PADDING:02X} (erased state)",
+ start + size
+ );
+ Ok(())
+}
+
/// Places `data` at `offset` within `binary`, padding any preceding gap with
/// [`SECTION_PADDING`]. Each section must start at or after the current end of
/// the image, otherwise the sections would overlap.
@@ -108,6 +145,8 @@ pub fn combine(args: CombineArgs) -> Result<()> {
),
}
+ erase_boot_ucb(&mut binary, &memory_ld, base)?;
+
// Save the combined binary to the artifacts directory
let artifact_dir = helpers::artifacts_dir(args.model)?;
helpers::ensure_directory(&artifact_dir)?;
@@ -141,7 +180,7 @@ pub fn combine(args: CombineArgs) -> Result<()> {
#[cfg(test)]
mod tests {
- use super::{SECTION_PADDING, place_section};
+ use super::{ERASED_PADDING, SECTION_PADDING, erase_boot_ucb, place_section};
#[test]
fn places_sections_at_their_offsets_and_pads_gaps() {
@@ -167,6 +206,41 @@ mod tests {
);
}
+ /// The UCB region must end up 0xFF even though the gap around it is 0x00:
+ /// the boot chain erases it, and the image has to stay byte-identical.
+ #[test]
+ fn pads_the_ucb_region_with_the_erased_byte() {
+ let dir = tempfile::tempdir().unwrap();
+ let memory_ld = dir.path().join("memory.ld");
+ std::fs::write(
+ &memory_ld,
+ "BOOTUCB_START = 0xc01c000;\nBOOTUCB_MAXSIZE = 0x4;\n",
+ )
+ .unwrap();
+
+ let base = 0xc01_b000;
+ let mut binary = vec![SECTION_PADDING; 0x2000];
+ binary[0] = 0xAA;
+ erase_boot_ucb(&mut binary, &memory_ld, base).unwrap();
+
+ assert_eq!(binary[0], 0xAA, "sections outside the region are untouched");
+ assert_eq!(&binary[0x1000..0x1004], &[ERASED_PADDING; 4]);
+ assert_eq!(binary[0x1004], SECTION_PADDING, "and nothing beyond it");
+ }
+
+ /// A model without a UCB region is left alone.
+ #[test]
+ fn leaves_images_without_a_ucb_region_alone() {
+ let dir = tempfile::tempdir().unwrap();
+ let memory_ld = dir.path().join("memory.ld");
+ std::fs::write(&memory_ld, "BOOTLOADER_START = 0x8020000;\n").unwrap();
+
+ let mut binary = vec![SECTION_PADDING; 16];
+ erase_boot_ucb(&mut binary, &memory_ld, 0x800_0000).unwrap();
+
+ assert_eq!(binary, vec![SECTION_PADDING; 16]);
+ }
+
#[test]
fn rejects_overlapping_sections() {
let mut binary = Vec::new();
### docs/core/build/xtask.md
@@ -253,6 +253,15 @@ Dependency builds (kernel, secmon when built as part of firmware) collect the
ELF, map and compile_commands but **not** the `.bin`. `xtask combine` writes
`combined-<project>.bin` here.
+A combined image pads the gaps between its sections with `0x00`, with one
+exception: a region the **boot chain erases on first boot** is padded with
+`0xFF`, the erased state. The bootloader erases the UCB region
+(`boot_ucb_erase`), so padding it with anything else would make the device stop
+matching the image it was flashed from the moment it boots, and a factory line
+that verifies by reading flash back would fail. Padded erased, the erase is a
+no-op and the image stays byte-identical. Models whose `memory.ld` declares no
+such region are unaffected.
+
Files are copied only if newer, so rebuilding one project doesn't clobber
others. The `latest` symlink always points at the model directory most recently
built.Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.