From a3f3df7dedcdbb78278feefa00bc7047b7fd4a8a Mon Sep 17 00:00:00 2001 From: Josh <16180970+deadp@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:02:47 +1300 Subject: [PATCH] Bound hostile-input allocations; decode PDF name escapes EWF2: cap the chunk count against the image size. Registry log: reject a hive size beyond the log. AFF4: inflate with a max_length and refuse members or chunks that inflate past their declared size or are truncated. PDF: decode #HH escapes in names before matching active content. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++++++++ engine/aff4.py | 16 +++++++++++++-- engine/ewf2.py | 13 ++++++++++++ engine/pdfdoc.py | 37 +++++++++++++++-------------------- engine/reglog.py | 9 +++++++++ tests/test_aff4.py | 24 +++++++++++++++++++++++ tests/test_ewf2.py | 24 +++++++++++++++++++++++ tests/test_pdfdoc.py | 27 +++++++++++++++++++++++++ tests/test_registry.py | 13 ++++++++++++ web/strings/en-GB/engine.json | 1 + 10 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 tests/test_pdfdoc.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d43c9e..cc58ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ records, not how the code changed. ## [Unreleased] +### Security + +- **Crafted evidence can no longer make Strata exhaust memory**: an EWF2 + image with an inflated chunk count, a registry transaction log claiming a + huge hive size, or an AFF4 member or chunk that inflates past its declared + size is now refused with a finding. A truncated AFF4 deflate stream is + reported instead of being read as short data. +- **PDF active content written with `#HH` name escapes (for example + `/Java#53cript`) is now recognised and stripped** like the plain spelling. + ## [0.9.0] - 2026-10-02 ### Added diff --git a/engine/aff4.py b/engine/aff4.py index 8c458df..4071823 100644 --- a/engine/aff4.py +++ b/engine/aff4.py @@ -432,7 +432,13 @@ def read_member(self, name, max_bytes=None): return raw if e.method == 8: try: - return zlib.decompress(raw, -15) + d = zlib.decompressobj(-15) + out = d.decompress(raw, e.size + 1) + if len(out) > e.size: + raise zlib.error("member inflates past its declared size") + if not d.eof: + raise zlib.error("member is truncated") + return out except zlib.error: raise Aff4Error(_t("aff4.member_decompress_failed") % name) raise Aff4Error(_t("aff4.member_compression_unsupported") % e.method) @@ -516,7 +522,13 @@ def _read_chunk(self, chunk_id): if length == self.chunk_size: return raw try: - return zlib.decompress(raw) + d = zlib.decompressobj() + out = d.decompress(raw, self.chunk_size + 1) + if len(out) > self.chunk_size: + raise zlib.error("chunk inflates past the chunk size") + if not d.eof: + raise zlib.error("chunk is truncated") + return out except zlib.error: self.findings.append( _t("aff4.chunk_decompress_failed") % (chunk_id, self.urn)) diff --git a/engine/ewf2.py b/engine/ewf2.py index 62acf01..eac8fad 100644 --- a/engine/ewf2.py +++ b/engine/ewf2.py @@ -57,6 +57,7 @@ # damaged or hostile descriptor claims -- same discipline as engine/ewf.py. MAX_OBJECT_STRING = 4 << 20 MAX_CHUNK_SIZE = 64 << 20 +MAX_UNSIZED_CHUNKS = 1 << 22 MAX_TABLE_ENTRIES = 1 << 20 SECTOR_SIZES = (512, 1024, 2048, 4096) @@ -208,6 +209,18 @@ def _parse(self): if not chunk_by_index: raise Ewf2Error(_t("ewf2.chunk_table_found_evidence")) + if self.sector_count and self.bytes_per_sector and self.chunk_size: + total = -(-self.sector_count * self.bytes_per_sector + // self.chunk_size) + else: + total = MAX_UNSIZED_CHUNKS + if max(chunk_by_index) >= total: + self.findings.append(_t("ewf2.chunks_beyond_image_size") % total) + chunk_by_index = {i: c for i, c in chunk_by_index.items() + if i < total} + if not chunk_by_index: + raise Ewf2Error(_t("ewf2.chunk_table_found_evidence")) + last = max(chunk_by_index) missing = 0 self.chunks = [] diff --git a/engine/pdfdoc.py b/engine/pdfdoc.py index 84b6f38..0f308f5 100644 --- a/engine/pdfdoc.py +++ b/engine/pdfdoc.py @@ -32,26 +32,23 @@ def looks_like_pdf(head): return head[:5] == MAGIC -def _same_length_replacement(name): - body = name[1:] - return b"/X" + b"x" * (len(body) - 1) +def _same_length_replacement(length): + return b"/X" + b"x" * (length - 2) -_NAME_CHAR = re.compile(rb"[0-9A-Za-z\-_.#]") +_NAME = re.compile(rb"/[0-9A-Za-z\-_.#]*") +_HEX_ESCAPE = re.compile(rb"#([0-9A-Fa-f]{2})") + +def _decoded(name): + return _HEX_ESCAPE.sub(lambda m: bytes([int(m.group(1), 16)]), name) + +def _name_spans(buf, key): + """(start, end) of every name in buf equal to key once #HH escapes are + decoded, so /Java#53cript is found as /JavaScript.""" + return [m.span() for m in _NAME.finditer(buf) + if _decoded(m.group()) == key] def _name_positions(buf, key): - out = [] - start = 0 - n = len(key) - while True: - i = buf.find(key, start) - if i < 0: - return out - nxt = buf[i + n:i + n + 1] - if nxt and _NAME_CHAR.match(nxt): - start = i + 1 - continue - out.append(i) - start = i + n + return [i for i, _ in _name_spans(buf, key)] def _streams(data): out = [] @@ -160,10 +157,8 @@ def sanitised(self): if self._clean is None: out = bytearray(self.data) for key in DANGEROUS: - rep = _same_length_replacement(key) - assert len(rep) == len(key) - for i in _name_positions(bytes(out), key): - out[i:i + len(key)] = rep + for i, j in _name_spans(bytes(out), key): + out[i:j] = _same_length_replacement(j - i) self._clean = bytes(out) left = [k.decode() for k in DANGEROUS if _name_positions(self._clean, k)] diff --git a/engine/reglog.py b/engine/reglog.py index a1bac84..c4acce6 100644 --- a/engine/reglog.py +++ b/engine/reglog.py @@ -203,6 +203,15 @@ def recover(hive, logs): last_seq = None for entry, raw in ordered: need = HIVE_BASE_BLOCK + entry["hbins_size"] + # A hive cannot sensibly grow by more than the log that grew it. + limit = len(hive) + len(raw) + if need > limit or any(HIVE_BASE_BLOCK + off + length > limit + for off, length, _ in entry["pages"]): + report["findings"].append( + "A log entry declares a hive size (%d bytes) out of " + "proportion to the hive and its log; the log was not " + "replayed." % need) + return None, report if need > len(out): out.extend(bytes(need - len(out))) for off, length, at in entry["pages"]: diff --git a/tests/test_aff4.py b/tests/test_aff4.py index 31b113f..9c2fa74 100644 --- a/tests/test_aff4.py +++ b/tests/test_aff4.py @@ -10,6 +10,7 @@ import sys import tempfile import unittest +import zlib sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -317,5 +318,28 @@ def test_malformed_bevy_index_zero_fills_that_bevy(self): img.close() +class InflateBounds(unittest.TestCase): + + def test_chunk_inflating_past_chunk_size_reads_as_zeros(self): + volume_urn = "aff4://77777777-7777-7777-7777-777777777777" + stream_urn = "%s/image.dd" % volume_urn + bomb = zlib.compress(b"\x01" * (1 << 20)) + turtle = build._turtle([(stream_urn, [ + ("a", ["aff4:ImageStream"]), + ("aff4:chunkSize", ["512"]), + ("aff4:chunksInSegment", ["1"]), + ("aff4:size", ["512"]), + ])]) + raw = build.build_zip64( + [("image.dd/00000000", bomb), + ("image.dd/00000000.index", struct.pack(">\nendobj\n") + pdf = pdfdoc.Pdf(data) + self.assertIn("/JavaScript", [a["key"] for a in pdf.active]) + clean = pdf.sanitised() + self.assertIsNotNone(clean) + self.assertEqual(len(clean), len(data)) + self.assertNotIn(b"Java#53cript", clean) + self.assertFalse(pdfdoc._name_positions(clean, b"/JavaScript")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_registry.py b/tests/test_registry.py index c531b06..b227e6c 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -383,5 +383,18 @@ def test_random_corruption_never_raises(self): h.carve_deleted() +class OversizedLog(unittest.TestCase): + + def test_hbins_size_far_beyond_hive_and_log_is_not_replayed(self): + stale, log, fresh_bins = build.build_dirty_pair() + evil = build.build_log( + [build.log_entry(10, 0x7FFFF000, [(0, fresh_bins)])], 10) + new, report = reglog.recover(stale, [evil]) + self.assertIsNone(new) + self.assertFalse(report["recovered"]) + self.assertTrue(any("out of proportion" in f + for f in report["findings"])) + + if __name__ == "__main__": unittest.main() diff --git a/web/strings/en-GB/engine.json b/web/strings/en-GB/engine.json index bec8582..e28e70f 100644 --- a/web/strings/en-GB/engine.json +++ b/web/strings/en-GB/engine.json @@ -99,6 +99,7 @@ "ewf2.chunk_inflates_past_chunk_size": "Chunk %d inflates past the %d-byte chunk size this image declares; it was cut off there.", "ewf2.chunk_incomplete": "Chunk %d is incomplete: its compressed data %s and gave %d of %d bytes; the rest reads as zeros.", "ewf2.chunk_table_found_evidence": "No chunk table found; evidence set may be incomplete.", + "ewf2.chunks_beyond_image_size": "The chunk table has entries past the %d chunks this image declares; they were ignored.", "ewf2.chunk_table_has_gaps": "The chunk table has %d gap(s); the missing chunks read as zeros.", "ewf2.compression_method_unknown": "This image's segment file header declares compression method %d, which Strata does not recognise.", "ewf2.encrypted_not_supported": "This EWF2 volume is encrypted. Ex01 encryption is not disclosed by Guidance Software and not documented by libewf either, so it cannot be decrypted here.",