Skip to content

multipart parser truncates binary uploads ending in a whitespace byte → bricked flashes (Image hash failed) #9

Description

@HansStrupat

Bug: multipart parser truncates binary uploads ending in a whitespace byte → bricked flashes (Image hash failed)

Summary

The custom multipart/form-data parser in pi/portal.py calls part.strip() on each raw
binary part. bytes.strip() removes trailing ASCII‑whitespace bytes (0x09 0x0a 0x0b 0x0c 0x0d 0x20), so any uploaded binary whose final byte is one of those is silently truncated. The
truncated image is flashed faithfully (esptool even reports Hash of data verified), but the
device then boot‑loops with:

E (xxx) esp_image: Image hash failed - image is corrupt
E (xxx) boot: Factory app partition is not bootable

because the image's appended SHA‑256 no longer matches. It affects roughly 2–3 % of builds at
random
(whatever the final byte happens to be) and is independent of chip, board, baud, and
esptool version — which makes it extremely confusing to diagnose (it looks board/chip‑specific).

Affected code

pi/portal.py, in both multipart handlers:

  • /api/flash — line 2715
  • /api/firmware/upload — line 2634
parts_raw = body.split(b"--" + boundary_bytes)
...
for part in parts_raw:
    part = part.strip()          # <-- BUG: strips whitespace bytes from BINARY content
    if not part or part == b"--":
        continue
    if b"\r\n\r\n" in part:
        header_section, content = part.split(b"\r\n\r\n", 1)
    ...
    if content.endswith(b"\r\n"):
        content = content[:-2]   # this already removes the framing CRLF correctly
    ...
    files[filename] = content

The part.strip() is both redundant (the framing CRLF is already removed by
if content.endswith(b"\r\n")) and destructive (it eats real trailing bytes of the payload).

(Note: the part = part.strip() calls at lines 2617 and 2695 are inside
for part in content_type.split(";") — those parse the Content‑Type header text and are fine.)

Reproduction

Flash any app image whose last byte is 0x09/0a/0b/0c/0d/20. Clean A/B on one ESP32, same
board/portal, only the app's trailing byte differs:

App image last byte via /api/flash
original app.bin 0x20 ❌ boot‑loops, Image hash failed
app.bin + one trailing 0xFF (ignored by the ROM, after the SHA‑256) 0xFF ✅ boots

Direct esptool on the Pi against the same /dev/ttyUSB0 (using the original file) flashes and
boots fine at any baud, stub or --no-stub — confirming the corruption is introduced by the
parser, not esptool/serial.

Fix

Strip only the leading CRLF framing; never .strip() the part body. The existing
content.endswith(b"\r\n") check already removes the trailing framing CRLF.

 for part in parts_raw:
-    part = part.strip()
-    if not part or part == b"--":
+    # strip only the leading CRLF framing; do NOT .strip() the part,
+    # which silently drops trailing whitespace bytes (09 0a 0b 0c 0d 20)
+    # from binary uploads -> truncated image -> bad SHA-256 -> bricked flash
+    if part.startswith(b"\r\n"):
+        part = part[2:]
+    elif part.startswith(b"\n"):
+        part = part[1:]
+    if not part or part in (b"--", b"--\r\n", b"--\n"):
         continue

Apply the same change at both line 2715 and line 2634.

(A more robust alternative would be to replace the hand‑rolled parser with
email.parser/multipart, but the one‑line‑equivalent fix above is sufficient and minimal.)

Verified

Applied this patch to a running workbench (/usr/local/bin/rfc2217-portal) and re‑tested: the
exact image that previously bricked on every /api/flash (final byte 0x20) now boots, with no
other changes. Environment: Raspberry Pi workbench, esptool v5.2.0, classic ESP32‑D0WD over a
CP2102 (/dev/ttyUSB0).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions