fix: add zip bomb protection and QR part limit enforcement (#843) (#848)
What changed, and why it matters
This commit fixes two denial-of-service weaknesses in Krux, a Bitcoin hardware-wallet firmware. First, it caps how much data can come out of compressed (deflated) QR codes and encrypted backups, preventing a maliciously crafted 'zip bomb' from exhausting the device's memory. Second, it rejects QR-code part counts that are zero or absurdly high, preventing an attacker from tricking the wallet into reserving unbounded memory while scanning multi-part QR codes.
Treat this as a security hardening fix and include it in the next release. Users building from source should update past this commit. Because the C-level change is in firmware/MaixPy, ensure the compiled firmware is rebuilt and re-flashed; simulator-only updates are insufficient for real devices.
Security signals we found
zip-bomb / decompression-bomb protection
denial-of-service (OOM) mitigation
input validation on multi-part QR part counts
deflate decompressed-size cap enforced at C level
ValueError from DeflateIO now propagated instead of swallowed
Evidence from the diff
The patch adds a 100 KB maximum decompressed-size limit to DeflateIO (enforced in the C module moddeflate.c and mirrored in the simulator/test mocks). Callers in src/krux/bbqr.py and src/krux/kef.py now let the underlying ValueError propagate instead of masking it. It also hardens parse_pmofn_qr_part() in src/krux/qr.py to require 1 <= part_total <= 99 and 1 <= part_index <= part_total, and parse_bbqr() in src/krux/bbqr.py to require part_total >= 1. Tests are added for each limit.
Changed components
firmware/MaixPy (moddeflate.c C implementation)src/krux/bbqr.py (BBQR deflate_decompress and parse_bbqr)src/krux/kef.py (KEF _reinflate)src/krux/qr.py (parse_pmofn_qr_part)simulator/kruxsim/mocks/deflate.pytests/shared_mocks.pyInspect captured patch +102 / −6
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 856e413..b4099ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
- Warn user before signing raw hashes in message signing
- Fix ZeroDivisionError in fee calculation for zero-value output PSBTs
- Validate multisig quorum: reject m=0 and m>n in key-value wallet files
+- DeflateIO enforces 100KB max decompressed size, preventing zip bomb OOM via BBQR encoding "Z" or KEF decryption
+- Enforce part_total limits in pMofN (1–99) and BBQR (≥1) QR parsers, preventing OOM via unbounded part accumulation
# Changelog 26.03.0 - March 2025
diff --git a/simulator/kruxsim/mocks/deflate.py b/simulator/kruxsim/mocks/deflate.py
index 59cb9f0..05c6aa4 100644
--- a/simulator/kruxsim/mocks/deflate.py
+++ b/simulator/kruxsim/mocks/deflate.py
@@ -3,14 +3,25 @@ from unittest import mock
import zlib
from io import BytesIO
+# Must match DEFLATEIO_MAX_DECOMPRESSED_SIZE in moddeflate.c
+MAX_DECOMPRESSED_SIZE = 100 * 1024 # 100 KB
+
class DeflateIO:
def __init__(self, stream) -> None:
self.stream = stream
self.data = stream.read()
-
- def read(self):
- return zlib.decompress(self.data, wbits=-10)
+ self._total_out = 0
+
+ def read(self, size=-1):
+ if not hasattr(self, "_decompressed"):
+ self._decompressed = BytesIO(zlib.decompress(self.data, wbits=-10))
+ chunk = self._decompressed.read() if size == -1 else self._decompressed.read(size)
+ if chunk:
+ self._total_out += len(chunk)
+ if self._total_out > MAX_DECOMPRESSED_SIZE:
+ raise ValueError("decompressed data exceeds size limit")
+ return chunk
def write(self, input_data):
compressor = zlib.compressobj(wbits=-10)
diff --git a/src/krux/bbqr.py b/src/krux/bbqr.py
index a4aa552..71918ca 100644
--- a/src/krux/bbqr.py
+++ b/src/krux/bbqr.py
@@ -73,6 +73,8 @@ def parse_bbqr(data):
except ValueError:
raise ValueError("Invalid BBQR format")
+ if part_total < 1:
+ raise ValueError("Invalid part total")
if part_index >= part_total:
raise ValueError("Invalid part index")
@@ -101,6 +103,8 @@ def deflate_decompress(data):
with deflate.DeflateIO(BytesIO(data)) as d:
return d.read()
+ except ValueError:
+ raise
except:
raise ValueError("Error decompressing BBQR")
diff --git a/src/krux/kef.py b/src/krux/kef.py
index d91f0ae..6010a97 100644
--- a/src/krux/kef.py
+++ b/src/krux/kef.py
@@ -559,5 +559,7 @@ def _reinflate(data):
try:
with deflate.DeflateIO(io.BytesIO(data)) as d:
return d.read()
+ except ValueError:
+ raise
except:
raise ValueError("Error decompressing")
diff --git a/src/krux/qr.py b/src/krux/qr.py
index 4d8e01b..d59cb5c 100644
--- a/src/krux/qr.py
+++ b/src/krux/qr.py
@@ -369,6 +369,9 @@ def find_min_num_parts(data, max_width, qr_format):
return num_parts, part_size
+PMOFN_MAX_PARTS = 99 # Matches the 2-digit generator limit
+
+
def parse_pmofn_qr_part(data):
"""Parses the QR as a P M-of-N part, extracting the part's content, index, and total"""
data = data.decode() if isinstance(data, bytes) else data
@@ -376,6 +379,10 @@ def parse_pmofn_qr_part(data):
space_index = data.index(" ")
part_index = int(data[1:of_index])
part_total = int(data[of_index + 2 : space_index])
+ if part_total < 1 or part_total > PMOFN_MAX_PARTS:
+ raise ValueError("Invalid pMofN part total: %d" % part_total)
+ if part_index < 1 or part_index > part_total:
+ raise ValueError("Invalid pMofN part index: %d" % part_index)
return data[space_index + 1 :], part_index, part_total
diff --git a/tests/shared_mocks.py b/tests/shared_mocks.py
index 74dd3a6..ee6a709 100644
--- a/tests/shared_mocks.py
+++ b/tests/shared_mocks.py
@@ -89,13 +89,29 @@ def mock_open(mock_file):
return _open
+# Must match DEFLATEIO_MAX_DECOMPRESSED_SIZE in moddeflate.c
+MAX_DECOMPRESSED_SIZE = 100 * 1024 # 100 KB
+
+
class DeflateIO:
def __init__(self, stream) -> None:
self.stream = stream
self.data = stream.read()
+ self._total_out = 0
- def read(self):
- return zlib.decompress(self.data, wbits=-10)
+ def read(self, size=-1):
+ if not hasattr(self, "_decompressed"):
+ from io import BytesIO
+
+ self._decompressed = BytesIO(zlib.decompress(self.data, wbits=-10))
+ chunk = (
+ self._decompressed.read() if size == -1 else self._decompressed.read(size)
+ )
+ if chunk:
+ self._total_out += len(chunk)
+ if self._total_out > MAX_DECOMPRESSED_SIZE:
+ raise ValueError("decompressed data exceeds size limit")
+ return chunk
def write(self, input_data):
compressor = zlib.compressobj(wbits=-10)
diff --git a/tests/test_bbqr.py b/tests/test_bbqr.py
index 04832d2..9a97b02 100644
--- a/tests/test_bbqr.py
+++ b/tests/test_bbqr.py
@@ -293,6 +293,29 @@ def test_deflate_decompress_invalid_data(m5stickv):
deflate_decompress("non binary string")
+def test_deflate_decompress_size_limit(m5stickv):
+ """C4: deflate decompression must reject data exceeding DeflateIO size limit"""
+ import zlib
+ from krux.bbqr import deflate_decompress
+ from .shared_mocks import MAX_DECOMPRESSED_SIZE
+
+ # Create data larger than the limit
+ big_data = b"\x00" * (MAX_DECOMPRESSED_SIZE + 1)
+ compressed = zlib.compress(big_data, wbits=-10)
+
+ with pytest.raises(ValueError, match="exceeds size limit"):
+ deflate_decompress(compressed)
+
+
+def test_parse_bbqr_rejects_zero_part_total(m5stickv):
+ """C5: BBQR parser must reject part_total < 1"""
+ from krux.bbqr import parse_bbqr
+
+ # "B:" = BBQR prefix, "Z" = encoding, "P" = file type, "00" = total 0 (base36), "00" = index
+ with pytest.raises(ValueError, match="Invalid part total"):
+ parse_bbqr("B:ZP0000data")
+
+
def test_decode_bbqr_descriptors(m5stickv):
from krux.qr import detect_format
from krux.bbqr import decode_bbqr, parse_bbqr
diff --git a/tests/test_kef.py b/tests/test_kef.py
index a44bbf6..b33f572 100644
--- a/tests/test_kef.py
+++ b/tests/test_kef.py
@@ -1258,6 +1258,15 @@ def test_deflate_compression(m5stickv):
with pytest.raises(ValueError, match="Error decompressing"):
kef._reinflate(compressed[:-1])
+ # Reinflate must reject data exceeding DeflateIO size limit
+ import zlib
+ from tests.shared_mocks import MAX_DECOMPRESSED_SIZE
+
+ big_data = b"\x00" * (MAX_DECOMPRESSED_SIZE + 1)
+ compressed = zlib.compress(big_data, wbits=-10)
+ with pytest.raises(ValueError, match="exceeds size limit"):
+ kef._reinflate(compressed)
+
def kef_self_document(version, label=None, iterations=None, limit=None):
"""This is NOT a unit-test, it's a way for KEF encoding to document itself"""
diff --git a/tests/test_qr.py b/tests/test_qr.py
index d69fb03..f9d5381 100644
--- a/tests/test_qr.py
+++ b/tests/test_qr.py
@@ -222,3 +222,25 @@ def test_find_min_num_parts(m5stickv):
assert raised_ex.type is ValueError
assert raised_ex.value.args[0] == "Invalid format type"
+
+
+def test_parse_pmofn_rejects_excessive_parts(m5stickv):
+ """C5: pMofN parser must reject part_total exceeding the 99-part limit"""
+ from krux.qr import parse_pmofn_qr_part
+
+ with pytest.raises(ValueError, match="Invalid pMofN part total"):
+ parse_pmofn_qr_part("p1of100 data")
+
+ with pytest.raises(ValueError, match="Invalid pMofN part total"):
+ parse_pmofn_qr_part("p1of0 data")
+
+
+def test_parse_pmofn_rejects_invalid_index(m5stickv):
+ """C5: pMofN parser must reject part_index out of range"""
+ from krux.qr import parse_pmofn_qr_part
+
+ with pytest.raises(ValueError, match="Invalid pMofN part index"):
+ parse_pmofn_qr_part("p0of3 data")
+
+ with pytest.raises(ValueError, match="Invalid pMofN part index"):
+ parse_pmofn_qr_part("p4of3 data")
Why this scored 66/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.