From 597edeebd83721c18d79f11f122bfa1322c8d79f Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 18 May 2026 20:28:40 -0700 Subject: [PATCH 1/4] fix: guard DC writes in JPEGDEC MCU_SKIP path EIGHT_BIT_GRAYSCALE decode of a 3-component progressive JPEG calls JPEGDecodeMCU_P with MCU_SKIP for Cb and Cr after every Y MCU. The existing safe-pMCU patch redirects the wild pointer to &sMCUs[0] but leaves the DC store unguarded, so each chroma skip overwrites the just-decoded Y DC with the chroma DC predictor. Output reads sMCUs[0], gets the trailing Cr DC (~0), and renders an all-black image. Add `if (iMCU >= 0)` guards to the two pMCU[0] writes (main DC store and successive-approximation update). The pointer redirect stays as the AC wild-pointer defence; the new guards stop the silent corruption at sMCUs[0]. The two fixes are independent and both required. --- scripts/patch_jpegdec.py | 95 ++++++++++++++++++++++++++++++++-------- 1 file changed, 76 insertions(+), 19 deletions(-) diff --git a/scripts/patch_jpegdec.py b/scripts/patch_jpegdec.py index 015b1761..89169419 100644 --- a/scripts/patch_jpegdec.py +++ b/scripts/patch_jpegdec.py @@ -1,23 +1,29 @@ """ -PlatformIO pre-build script: patch JPEGDEC for MCU_SKIP wild pointer crash. +PlatformIO pre-build script: patch JPEGDEC for safe MCU_SKIP handling. -Problem: - JPEGDecodeMCU_P computes pMCU = &sMCUs[iMCU & 0xffffff]. When iMCU is - MCU_SKIP (-8), the bitmask produces index 0xFFFFF8 (16 777 208), creating a - pointer ~33 MB past the 392-entry sMCUs array. If the progressive JPEG's - first scan includes AC coefficients (iScanEnd > 0), the AC decode loop writes - through this wild pointer and crashes with a store-access fault. +When iMCU is MCU_SKIP (-8), JPEGDecodeMCU_P computes pMCU as +&sMCUs[iMCU & 0xffffff] = &sMCUs[0xFFFFF8], a wild pointer ~33 MB past +the array. EIGHT_BIT_GRAYSCALE decoding of a 3-component progressive +JPEG calls JPEGDecodeMCU_P with MCU_SKIP twice per Y MCU (Cb then Cr), +so every MCU exercises the wild pointer. - Upstream commit 8628297 guarded the DC coefficient write (pMCU[0]) but not the - AC coefficient writes at indices 1-63. +Two patches, both required: -Fix: - Redirect pMCU to sMCUs[0] when MCU_SKIP is active. Writes to sMCUs[1..63] - are harmless: for JPEG_SCALE_EIGHTH only sMCUs[0] is read for output, and - the DC write at sMCUs[0] is already guarded by the existing `if (iMCU >= 0)` - check. +1. Redirect pMCU to &sMCUs[0] when iMCU < 0. Without this, the AC + decode loop (`pMCU[iIndex] = ...`) store-faults on any progressive + JPEG whose first scan carries AC coefficients (iScanEnd > 0). -Applied idempotently — safe to run on every build. +2. Guard the two pMCU[0] DC writes with `if (iMCU >= 0)`. Without + this, patch 1 just relocates the corruption: chroma-skip DC writes + land at sMCUs[0] and clobber the freshly-decoded Y DC, producing + all-black output for progressive JPEGs at JPEG_SCALE_EIGHTH grayscale. + +The AC loop body (`pMCU[iIndex] = ...` writes and matching reads) is +not separately guarded. It is unreachable on the JPEG_SCALE_EIGHTH +DC-only first-scan path, and guarding the writes without also skipping +bit consumption would desync the bitstream for the next MCU. + +Both patches are idempotent. """ Import("env") @@ -32,6 +38,7 @@ def patch_jpegdec(env): jpeg_inl = os.path.join(libdeps_dir, env_dir, "JPEGDEC", "src", "jpeg.inl") if os.path.isfile(jpeg_inl): _apply_mcu_skip_pointer_fix(jpeg_inl) + _apply_dc_write_guards(jpeg_inl) def _apply_mcu_skip_pointer_fix(filepath): @@ -40,9 +47,8 @@ def _apply_mcu_skip_pointer_fix(filepath): content = f.read() if MARKER in content: - return # already patched + return - # The wild-pointer line in JPEGDecodeMCU_P: OLD = " signed short *pMCU = &pJPEG->sMCUs[iMCU & 0xffffff];" NEW = ( @@ -54,7 +60,7 @@ def _apply_mcu_skip_pointer_fix(filepath): if OLD not in content: print( "WARNING: JPEGDEC MCU_SKIP pointer patch target not found in %s " - "— library may have been updated" % filepath + "-- library may have been updated" % filepath ) return @@ -64,5 +70,56 @@ def _apply_mcu_skip_pointer_fix(filepath): print("Patched JPEGDEC: safe pMCU for MCU_SKIP in JPEGDecodeMCU_P: %s" % filepath) -# Run immediately at script import time (before compilation). +def _apply_dc_write_guards(filepath): + MARKER = "// CrossPoint patch: guard pMCU DC writes for MCU_SKIP" + with open(filepath, "r") as f: + content = f.read() + + if MARKER in content: + return + + OLD_DC = """\ + pMCU[0] = (short)*iDCPredictor; // store in MCU[0] + } + // Now get the other 63 AC coefficients""" + + NEW_DC = """\ + """ + MARKER + """ + if (iMCU >= 0) + pMCU[0] = (short)*iDCPredictor; // store in MCU[0] + } + // Now get the other 63 AC coefficients""" + + OLD_SA = """\ + pMCU[0] |= iPositive; + } + goto mcu_done; // that's it""" + + NEW_SA = """\ + if (iMCU >= 0) + pMCU[0] |= iPositive; + } + goto mcu_done; // that's it""" + + if OLD_DC not in content: + print( + "WARNING: JPEGDEC DC write guard target not found in %s " + "-- library may have been updated" % filepath + ) + return + + content = content.replace(OLD_DC, NEW_DC, 1) + if OLD_SA in content: + content = content.replace(OLD_SA, NEW_SA, 1) + else: + print( + "WARNING: JPEGDEC successive-approximation DC guard target not found in %s " + "-- continuing without that half of the patch" % filepath + ) + + with open(filepath, "w") as f: + f.write(content) + print("Patched JPEGDEC: guard pMCU[0] DC writes for MCU_SKIP in JPEGDecodeMCU_P: %s" % filepath) + + patch_jpegdec(env) From 9e68ced046e0241b212790b11cc459dbbe911de7 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 18 May 2026 21:02:17 -0700 Subject: [PATCH 2/4] refactor: apply JPEGDEC patches via git apply, not string replace The previous patch_jpegdec.py used in-place string replacement with a single shared marker for two distinct DC writes (main store and successive-approximation update). If only one of the two anchors matched, the file was written half-patched and the shared marker locked the partial state in for every subsequent run. Generate the fixes as `git format-patch` artifacts under scripts/jpegdec_patches/, then apply them in lexical order via `git apply`. Idempotency is decided by git itself: `--check --reverse` succeeds means already applied; `--check` succeeds means appliable; neither aborts the build rather than leaving a half-patched file. No behaviour change to the patched JPEGDEC source: same redirect, same DC guards, same intent. Just stops the pre-build script from doing something it has no business doing. --- .../0001-redirect-pmcu-on-mcu-skip.patch | 28 ++++ .../0002-guard-dc-writes-on-mcu-skip.patch | 41 +++++ scripts/patch_jpegdec.py | 158 +++++++----------- 3 files changed, 128 insertions(+), 99 deletions(-) create mode 100644 scripts/jpegdec_patches/0001-redirect-pmcu-on-mcu-skip.patch create mode 100644 scripts/jpegdec_patches/0002-guard-dc-writes-on-mcu-skip.patch diff --git a/scripts/jpegdec_patches/0001-redirect-pmcu-on-mcu-skip.patch b/scripts/jpegdec_patches/0001-redirect-pmcu-on-mcu-skip.patch new file mode 100644 index 00000000..5f046627 --- /dev/null +++ b/scripts/jpegdec_patches/0001-redirect-pmcu-on-mcu-skip.patch @@ -0,0 +1,28 @@ +From 5dff5afab0c68d0d0c4385d72e6f0c030b613960 Mon Sep 17 00:00:00 2001 +From: patch +Date: Mon, 18 May 2026 20:57:54 -0700 +Subject: [PATCH 1/2] Redirect pMCU to sMCUs[0] when iMCU < 0 (MCU_SKIP) + +--- + src/jpeg.inl | 5 ++++- + 1 file changed, 4 insertions(+), 1 deletion(-) + +diff --git a/src/jpeg.inl b/src/jpeg.inl +index a60b548..26bcf6f 100644 +--- a/src/jpeg.inl ++++ b/src/jpeg.inl +@@ -1824,7 +1824,10 @@ static int JPEGDecodeMCU_P(JPEGIMAGE *pJPEG, int iMCU, int *iDCPredictor) + unsigned short *pFast; + uint32_t usHuff; // this prevents an unnecessary & 65535 for shorts + signed int iPositive, iNegative, iCoeff; +- signed short *pMCU = &pJPEG->sMCUs[iMCU & 0xffffff]; ++ // CrossPoint patch: redirect pMCU to sMCUs[0] when MCU_SKIP to avoid ++ // a wild pointer (~33 MB past sMCUs) that store-faults on AC writes. ++ signed short *pMCU = (iMCU < 0) ? pJPEG->sMCUs ++ : &pJPEG->sMCUs[iMCU & 0xffffff]; + uint32_t ulBitOff; + my_ulong ulCode, ulBits, ulTemp; // local copies to allow compiler to use register vars + uint8_t *pBuf; +-- +2.50.1 (Apple Git-155) + diff --git a/scripts/jpegdec_patches/0002-guard-dc-writes-on-mcu-skip.patch b/scripts/jpegdec_patches/0002-guard-dc-writes-on-mcu-skip.patch new file mode 100644 index 00000000..82925c64 --- /dev/null +++ b/scripts/jpegdec_patches/0002-guard-dc-writes-on-mcu-skip.patch @@ -0,0 +1,41 @@ +From f6238b54c2de34c8e29b0d371a7e902d9cf579bf Mon Sep 17 00:00:00 2001 +From: patch +Date: Mon, 18 May 2026 20:58:22 -0700 +Subject: [PATCH 2/2] Guard pMCU[0] DC writes against MCU_SKIP + +--- + src/jpeg.inl | 11 +++++++++-- + 1 file changed, 9 insertions(+), 2 deletions(-) + +diff --git a/src/jpeg.inl b/src/jpeg.inl +index 26bcf6f..1bd38b2 100644 +--- a/src/jpeg.inl ++++ b/src/jpeg.inl +@@ -1855,7 +1855,11 @@ static int JPEGDecodeMCU_P(JPEGIMAGE *pJPEG, int iMCU, int *iDCPredictor) + { + // (*iDCPredictor) |= iPositive; // in case the scan is run more than once + // pMCU[0] = *iDCPredictor; // store in MCU[0] +- pMCU[0] |= iPositive; ++ // CrossPoint patch: guard against MCU_SKIP. The pMCU ++ // redirect makes &sMCUs[0] safe to dereference, but ++ // writing here would clobber the just-decoded Y DC. ++ if (iMCU >= 0) ++ pMCU[0] |= iPositive; + } + goto mcu_done; // that's it + } +@@ -1887,7 +1891,10 @@ static int JPEGDecodeMCU_P(JPEGIMAGE *pJPEG, int iMCU, int *iDCPredictor) + ulCode <<= pJPEG->cApproxBitsLow; // successive approximation shift value + (*iDCPredictor) += ulCode; + } +- pMCU[0] = (short)*iDCPredictor; // store in MCU[0] ++ // CrossPoint patch: guard against MCU_SKIP. See note on the ++ // matching SA write above. ++ if (iMCU >= 0) ++ pMCU[0] = (short)*iDCPredictor; // store in MCU[0] + } + // Now get the other 63 AC coefficients + pFast = &pJPEG->usHuffAC[pJPEG->ucACTable * HUFF11SIZE]; +-- +2.50.1 (Apple Git-155) + diff --git a/scripts/patch_jpegdec.py b/scripts/patch_jpegdec.py index 89169419..cd54231d 100644 --- a/scripts/patch_jpegdec.py +++ b/scripts/patch_jpegdec.py @@ -1,125 +1,85 @@ """ -PlatformIO pre-build script: patch JPEGDEC for safe MCU_SKIP handling. +PlatformIO pre-build script: apply CrossPoint's JPEGDEC patches via `git apply`. -When iMCU is MCU_SKIP (-8), JPEGDecodeMCU_P computes pMCU as -&sMCUs[iMCU & 0xffffff] = &sMCUs[0xFFFFF8], a wild pointer ~33 MB past -the array. EIGHT_BIT_GRAYSCALE decoding of a 3-component progressive -JPEG calls JPEGDecodeMCU_P with MCU_SKIP twice per Y MCU (Cb then Cr), -so every MCU exercises the wild pointer. +The upstream JPEGDEC pin still has the wild-pointer + DC-write bugs in +JPEGDecodeMCU_P that surface when EIGHT_BIT_GRAYSCALE decodes a 3-component +progressive JPEG (each Y MCU drags two MCU_SKIP calls behind it for Cb/Cr). +The patches in `scripts/jpegdec_patches/` carry the fix; this script applies +each one against the libdep working tree. -Two patches, both required: +Each patch's idempotency is decided by git itself: + * `git apply --check --reverse` succeeds -> already applied, skip + * `git apply --check` succeeds -> apply + * neither succeeds -> abort the build -1. Redirect pMCU to &sMCUs[0] when iMCU < 0. Without this, the AC - decode loop (`pMCU[iIndex] = ...`) store-faults on any progressive - JPEG whose first scan carries AC coefficients (iScanEnd > 0). - -2. Guard the two pMCU[0] DC writes with `if (iMCU >= 0)`. Without - this, patch 1 just relocates the corruption: chroma-skip DC writes - land at sMCUs[0] and clobber the freshly-decoded Y DC, producing - all-black output for progressive JPEGs at JPEG_SCALE_EIGHTH grayscale. - -The AC loop body (`pMCU[iIndex] = ...` writes and matching reads) is -not separately guarded. It is unreachable on the JPEG_SCALE_EIGHTH -DC-only first-scan path, and guarding the writes without also skipping -bit consumption would desync the bitstream for the next MCU. - -Both patches are idempotent. +Patches live in `scripts/jpegdec_patches/` as one-commit-per-fix files +(see the file headers for context). Applied in lexical order. """ Import("env") import os +import subprocess +import sys + + +PATCH_DIR = os.path.join(env["PROJECT_DIR"], "scripts", "jpegdec_patches") def patch_jpegdec(env): libdeps_dir = os.path.join(env["PROJECT_DIR"], ".pio", "libdeps") if not os.path.isdir(libdeps_dir): return - for env_dir in os.listdir(libdeps_dir): - jpeg_inl = os.path.join(libdeps_dir, env_dir, "JPEGDEC", "src", "jpeg.inl") - if os.path.isfile(jpeg_inl): - _apply_mcu_skip_pointer_fix(jpeg_inl) - _apply_dc_write_guards(jpeg_inl) - - -def _apply_mcu_skip_pointer_fix(filepath): - MARKER = "// CrossPoint patch: safe pMCU for MCU_SKIP" - with open(filepath, "r") as f: - content = f.read() - - if MARKER in content: + patches = _patch_files() + if not patches: return + for env_dir in os.listdir(libdeps_dir): + jpeg_dir = os.path.join(libdeps_dir, env_dir, "JPEGDEC") + if not os.path.isdir(os.path.join(jpeg_dir, ".git")): + continue + for patch in patches: + _apply_one(jpeg_dir, patch) - OLD = " signed short *pMCU = &pJPEG->sMCUs[iMCU & 0xffffff];" - NEW = ( - " " + MARKER + "\n" - " signed short *pMCU = (iMCU < 0) ? pJPEG->sMCUs\n" - " : &pJPEG->sMCUs[iMCU & 0xffffff];" +def _patch_files(): + if not os.path.isdir(PATCH_DIR): + return [] + return sorted( + os.path.join(PATCH_DIR, name) + for name in os.listdir(PATCH_DIR) + if name.endswith(".patch") ) - if OLD not in content: - print( - "WARNING: JPEGDEC MCU_SKIP pointer patch target not found in %s " - "-- library may have been updated" % filepath - ) + +def _apply_one(jpeg_dir, patch_path): + name = os.path.basename(patch_path) + if _git_apply_succeeds(jpeg_dir, patch_path, reverse=True): return - - content = content.replace(OLD, NEW, 1) - with open(filepath, "w") as f: - f.write(content) - print("Patched JPEGDEC: safe pMCU for MCU_SKIP in JPEGDecodeMCU_P: %s" % filepath) - - -def _apply_dc_write_guards(filepath): - MARKER = "// CrossPoint patch: guard pMCU DC writes for MCU_SKIP" - with open(filepath, "r") as f: - content = f.read() - - if MARKER in content: - return - - OLD_DC = """\ - pMCU[0] = (short)*iDCPredictor; // store in MCU[0] - } - // Now get the other 63 AC coefficients""" - - NEW_DC = """\ - """ + MARKER + """ - if (iMCU >= 0) - pMCU[0] = (short)*iDCPredictor; // store in MCU[0] - } - // Now get the other 63 AC coefficients""" - - OLD_SA = """\ - pMCU[0] |= iPositive; - } - goto mcu_done; // that's it""" - - NEW_SA = """\ - if (iMCU >= 0) - pMCU[0] |= iPositive; - } - goto mcu_done; // that's it""" - - if OLD_DC not in content: - print( - "WARNING: JPEGDEC DC write guard target not found in %s " - "-- library may have been updated" % filepath + if not _git_apply_succeeds(jpeg_dir, patch_path, reverse=False): + # Not applied, not appliable -- the libdep source has diverged from + # what the patch expects. Don't write a half-patched file. + result = subprocess.run( + ["git", "apply", "--check", patch_path], + cwd=jpeg_dir, + capture_output=True, + text=True, ) - return - - content = content.replace(OLD_DC, NEW_DC, 1) - if OLD_SA in content: - content = content.replace(OLD_SA, NEW_SA, 1) - else: - print( - "WARNING: JPEGDEC successive-approximation DC guard target not found in %s " - "-- continuing without that half of the patch" % filepath + sys.stderr.write( + "ERROR: JPEGDEC patch %s does not apply cleanly:\n%s%s\n" + % (name, result.stdout, result.stderr) ) + raise SystemExit(1) + subprocess.run(["git", "apply", patch_path], cwd=jpeg_dir, check=True) + print("Applied JPEGDEC patch: %s" % name) - with open(filepath, "w") as f: - f.write(content) - print("Patched JPEGDEC: guard pMCU[0] DC writes for MCU_SKIP in JPEGDecodeMCU_P: %s" % filepath) + +def _git_apply_succeeds(jpeg_dir, patch_path, *, reverse): + cmd = ["git", "apply", "--check"] + if reverse: + cmd.append("--reverse") + cmd.append(patch_path) + return subprocess.run( + cmd, cwd=jpeg_dir, capture_output=True, text=True + ).returncode == 0 patch_jpegdec(env) From e9178342a08d0261104aef559e9a670a2d19bc69 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 18 May 2026 21:09:13 -0700 Subject: [PATCH 3/4] chore: suppress ruff F821 on SCons-injected globals in patch_jpegdec --- scripts/patch_jpegdec.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/patch_jpegdec.py b/scripts/patch_jpegdec.py index cd54231d..fb446b9d 100644 --- a/scripts/patch_jpegdec.py +++ b/scripts/patch_jpegdec.py @@ -16,13 +16,13 @@ Patches live in `scripts/jpegdec_patches/` as one-commit-per-fix files (see the file headers for context). Applied in lexical order. """ -Import("env") +Import("env") # noqa: F821 (SCons-injected global) import os import subprocess import sys -PATCH_DIR = os.path.join(env["PROJECT_DIR"], "scripts", "jpegdec_patches") +PATCH_DIR = os.path.join(env["PROJECT_DIR"], "scripts", "jpegdec_patches") # noqa: F821 def patch_jpegdec(env): @@ -82,4 +82,4 @@ def _git_apply_succeeds(jpeg_dir, patch_path, *, reverse): ).returncode == 0 -patch_jpegdec(env) +patch_jpegdec(env) # noqa: F821 From 7058cf1c91fdf273320d9c9eb7f5161f77d18f6c Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 18 May 2026 21:22:26 -0700 Subject: [PATCH 4/4] fix: hard-fail JPEGDEC patch loader when patches are missing Silently returning when scripts/jpegdec_patches/ is missing or empty would let the build succeed shipping an unpatched JPEGDEC, which re-introduces the wild-pointer + black-image bugs without warning. Raise RuntimeError in _patch_files() in both cases. --- scripts/patch_jpegdec.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/patch_jpegdec.py b/scripts/patch_jpegdec.py index fb446b9d..8f2d38b8 100644 --- a/scripts/patch_jpegdec.py +++ b/scripts/patch_jpegdec.py @@ -30,8 +30,6 @@ def patch_jpegdec(env): if not os.path.isdir(libdeps_dir): return patches = _patch_files() - if not patches: - return for env_dir in os.listdir(libdeps_dir): jpeg_dir = os.path.join(libdeps_dir, env_dir, "JPEGDEC") if not os.path.isdir(os.path.join(jpeg_dir, ".git")): @@ -42,12 +40,21 @@ def patch_jpegdec(env): def _patch_files(): if not os.path.isdir(PATCH_DIR): - return [] - return sorted( + raise RuntimeError( + "JPEGDEC patches missing -- aborting build (expected directory %s)" + % PATCH_DIR + ) + patches = sorted( os.path.join(PATCH_DIR, name) for name in os.listdir(PATCH_DIR) if name.endswith(".patch") ) + if not patches: + raise RuntimeError( + "JPEGDEC patches missing -- aborting build (no .patch files in %s)" + % PATCH_DIR + ) + return patches def _apply_one(jpeg_dir, patch_path):