Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions engine/aff4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
13 changes: 13 additions & 0 deletions engine/ewf2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 = []
Expand Down
37 changes: 16 additions & 21 deletions engine/pdfdoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)]
Expand Down
9 changes: 9 additions & 0 deletions engine/reglog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
24 changes: 24 additions & 0 deletions tests/test_aff4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__))))

Expand Down Expand Up @@ -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("<III", 0, 0, len(bomb))),
("information.turtle", turtle)],
comment=volume_urn.encode("ascii"))
img = _open(raw, "bomb.aff4")
self.assertEqual(img.read_at(0, 512), bytes(512))
self.assertTrue(img.findings)
img.close()


if __name__ == "__main__":
unittest.main()
24 changes: 24 additions & 0 deletions tests/test_ewf2.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,5 +324,29 @@ def table_section(first_chunk, entry):
img.close()


class ChunkIndexBound(TempDir):

def test_chunk_index_past_declared_size_is_ignored(self):
data = b"A" * 2048
seg = build.SegmentBuilder()
seg.add_object_section(build.SECTION_DEVICE, {"bp": 512, "ts": 4})
seg.add_object_section(build.SECTION_CASE, {"sb": 4, "tb": 1})
seg.add_section(build.SECTION_SECTORS, data)
base = seg.length - len(data) - build.SECTION_DESC_SIZE
for first in (0, 1 << 40):
header = struct.pack("<QII", first, 1, 0)
header += struct.pack("<I", zlib.adler32(header) & 0xFFFFFFFF)
header += bytes(12)
body = struct.pack("<QII", base, len(data), 0)
footer = struct.pack("<I", zlib.adler32(body) & 0xFFFFFFFF)
footer += bytes(12)
seg.add_section(build.SECTION_TABLE, header + body + footer)
img = ewf2.Ewf2Image(self.write("a.Ex01", seg.finish()))
self.assertEqual(len(img.chunks), 1)
self.assertEqual(img.read_at(0, img.size), data)
self.assertTrue(any("past" in f for f in img.findings))
img.close()


if __name__ == "__main__":
unittest.main()
27 changes: 27 additions & 0 deletions tests/test_pdfdoc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Unit tests for engine.pdfdoc active-content detection."""

import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from engine import pdfdoc # noqa: E402


class NameEscapes(unittest.TestCase):

def test_hex_escaped_name_is_still_detected_and_neutralised(self):
data = (b"%PDF-1.4\n1 0 obj\n"
b"<< /Type /Page /S /Java#53cript >>\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()
13 changes: 13 additions & 0 deletions tests/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
1 change: 1 addition & 0 deletions web/strings/en-GB/engine.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading