Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: 2

updates:
# No pip ecosystem entry by design: Strata is standard-library-only in
# the engine and in the tests (see CONTRIBUTING.md), so there is no
# requirements.txt / pyproject.toml to keep current. Only the CI
# tooling itself -- the GitHub Actions used in the workflows -- has
# versions worth Dependabot's attention.
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
groups:
actions:
patterns:
- "*"
56 changes: 56 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: CodeQL

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Weekly as well as on push: CodeQL's query packs are updated
# independently of this repo, so new checks reach unchanged code.
- cron: '17 3 * * 1'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
actions: read
strategy:
fail-fast: false
matrix:
# engine/ parses untrusted forensic input (images, filesystems,
# registry hives, archives, ...) byte by byte, and web/ is the
# local HTTP UI that talks to it -- both are exactly the kind of
# code CodeQL is worth running against. No compiled languages
# here, so build-mode is always "none".
include:
- { language: python, build-mode: none }
- { language: javascript-typescript, build-mode: none }
steps:
- uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended
paths-ignore: |
docs/**
**/*.md

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
with:
category: '/language:${{ matrix.language }}'
60 changes: 60 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Fuzz

# Mutation fuzzing of the evidence parsers (tests/fuzz.py). Kept out of the
# main CI workflow on purpose: it runs against a time budget rather than to
# completion, and a finding is something to triage, not a reason to block an
# unrelated PR. Findings are uploaded as an artifact with their inputs, and
# each can be replayed locally with `python3 tests/fuzz.py --replay <file>`.

on:
pull_request:
paths:
- 'engine/**'
- 'tests/fuzz.py'
- 'tests/imagebuild_*.py'
schedule:
- cron: '41 4 * * 2'
workflow_dispatch:
inputs:
minutes:
description: 'Time budget in minutes'
default: '20'
target:
description: 'Target (all, fat16, ntfs, ext4, hive, ewf, ...)'
default: 'all'

permissions:
contents: read

concurrency:
group: fuzz-${{ github.ref }}
cancel-in-progress: true

jobs:
fuzz:
name: Fuzz parsers
runs-on: ubuntu-latest
# A PR gets a short pass over whatever it touched; the weekly run and a
# manual dispatch get long enough to find the less shallow problems.
continue-on-error: ${{ github.event_name == 'pull_request' }}
timeout-minutes: 75
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Run the fuzzer
env:
MINUTES: ${{ github.event_name == 'pull_request' && '3' || github.event.inputs.minutes || '45' }}
TARGET: ${{ github.event.inputs.target || 'all' }}
run: |
python tests/fuzz.py --minutes "$MINUTES" --target "$TARGET" \
--seed "${{ github.run_id }}" --out fuzz-findings
- name: Upload findings
if: always()
uses: actions/upload-artifact@v4
with:
name: fuzz-findings
path: fuzz-findings/
if-no-files-found: ignore
retention-days: 30
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
/exports/
/reports/
*-report.html
/fuzz-findings/

# Python
__pycache__/
Expand Down
124 changes: 93 additions & 31 deletions engine/ewf.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
SECTION_DESC = 76
FILE_HEADER = 13

# Every size below comes from the segment file itself, so each read is bounded
# by what the format can legitimately hold rather than by what a damaged or
# hostile descriptor claims. Real metadata sections are a few KB; EnCase's
# largest chunk is 32768 sectors.
MAX_METADATA_SECTION = 1 << 20
MAX_HEADER_SECTION = 16 << 20
MAX_CHUNK_SIZE = 64 << 20
MAX_TABLE_ENTRIES = 1 << 20
SECTOR_SIZES = (512, 1024, 2048, 4096)

class EwfError(Exception):
pass

Expand Down Expand Up @@ -93,7 +103,14 @@ def __init__(self, path, cache_chunks=64):
self._cache_max = cache_chunks
self._io_lock = threading.Lock()
self._pos = 0
self._parse()
self._seg_sizes = {}
try:
self._parse()
except BaseException:
# Otherwise a failed open leaves the evidence files held open,
# which on Windows keeps them locked.
self.close()
raise

def _fh(self, seg):
h = self._handles.get(seg)
Expand All @@ -102,6 +119,24 @@ def _fh(self, seg):
self._handles[seg] = h
return h

def _seg_size(self, seg):
size = self._seg_sizes.get(seg)
if size is None:
size = os.fstat(self._fh(seg).fileno()).st_size
self._seg_sizes[seg] = size
return size

def _read_bounded(self, seg, offset, size, cap):
"""Read `size` bytes at `offset`, but never past the end of the
segment and never more than `cap`. Returns (data, clipped)."""
end = self._seg_size(seg)
if offset < 0 or offset >= end:
return b"", size > 0
want = min(size, end - offset, cap)
fh = self._fh(seg)
fh.seek(offset)
return fh.read(want), want < size

def close(self):
for h in self._handles.values():
h.close()
Expand Down Expand Up @@ -135,27 +170,27 @@ def _parse(self):
self.findings.append("Section chain in segment %d exceeded "
"sane length; stopped." % seg_index)
break
sec = self._read_section(fh, offset)
sec = self._read_section(seg_index, offset)
if sec is None:
break
self.sections.append((seg_index, sec))

if sec.type == "sectors":
sectors_extent = (sec.data_start, sec.start + sec.size)
elif sec.type in ("volume", "disk"):
self._parse_volume(fh, sec)
self._parse_volume(seg_index, sec)
elif sec.type == "header2":
self._parse_header(fh, sec, utf16=True)
self._parse_header(seg_index, sec, utf16=True)
elif sec.type == "header":
if not self.header:
self._parse_header(fh, sec, utf16=False)
self._parse_header(seg_index, sec, utf16=False)
elif sec.type == "table":
pending_table = sec
self._parse_table(fh, sec, seg_index, sectors_extent)
self._parse_table(sec, seg_index, sectors_extent)
elif sec.type == "digest":
self._parse_digest(fh, sec)
self._parse_digest(seg_index, sec)
elif sec.type == "hash":
self._parse_hash(fh, sec)
self._parse_hash(seg_index, sec)

if sec.type in ("next", "done"):
break
Expand All @@ -174,10 +209,13 @@ def _parse(self):
if self.size == 0:
self.size = len(self.chunks) * self.chunk_size

def _read_section(self, fh, offset):
fh.seek(offset)
raw = fh.read(SECTION_DESC)
def _read_section(self, seg, offset):
raw, _ = self._read_bounded(seg, offset, SECTION_DESC, SECTION_DESC)
if len(raw) < SECTION_DESC:
if offset >= self._seg_size(seg):
self.findings.append(
"Section pointer %d is past the end of segment %d; "
"stopped." % (offset, seg))
return None
type_ = raw[0:16].split(b"\x00")[0].decode("ascii", "replace")
next_offset, size = struct.unpack("<QQ", raw[16:32])
Expand All @@ -190,9 +228,17 @@ def _read_section(self, fh, offset):
return None
return Section(type_, offset, next_offset, size)

def _parse_volume(self, fh, sec):
fh.seek(sec.data_start)
data = fh.read(sec.data_size)
def _section_data(self, seg, sec, cap):
data, clipped = self._read_bounded(seg, sec.data_start, sec.data_size,
cap)
if clipped:
self.findings.append(
"Section '%s' at %d declares %d bytes; only %d were read."
% (sec.type, sec.start, sec.data_size, len(data)))
return data

def _parse_volume(self, seg, sec):
data = self._section_data(seg, sec, MAX_METADATA_SECTION)
if len(data) < 52:
self.findings.append("Volume section too short to parse.")
return
Expand All @@ -203,12 +249,21 @@ def _parse_volume(self, fh, sec):
self.sector_count = struct.unpack("<Q", data[16:24])[0]
self.media_flags = data[36]
self.compression_level = data[52] if len(data) > 52 else 0
if self.bytes_per_sector not in SECTOR_SIZES:
self.findings.append(
"Volume section declares %d bytes per sector; using 512."
% self.bytes_per_sector)
self.bytes_per_sector = 512
if self.sectors_per_chunk * self.bytes_per_sector > MAX_CHUNK_SIZE:
self.findings.append(
"Volume section declares %d sectors per chunk; using 64."
% self.sectors_per_chunk)
self.sectors_per_chunk = 64
self.chunk_size = self.sectors_per_chunk * self.bytes_per_sector
self._declared_chunks = chunk_count

def _parse_header(self, fh, sec, utf16):
fh.seek(sec.data_start)
blob = fh.read(sec.data_size)
def _parse_header(self, seg, sec, utf16):
blob = self._section_data(seg, sec, MAX_HEADER_SECTION)
text, over = inflate_capped(blob, 1 << 20)
if over:
self.findings.append("Header section inflates to more than 1 MB; "
Expand Down Expand Up @@ -237,9 +292,8 @@ def _parse_header(self, fh, sec, utf16):
self.header[names.get(k, k)] = v
break

def _parse_table(self, fh, sec, seg_index, sectors_extent):
fh.seek(sec.data_start)
head = fh.read(24)
def _parse_table(self, sec, seg_index, sectors_extent):
head, _ = self._read_bounded(seg_index, sec.data_start, 24, 24)
if len(head) < 24:
self.findings.append("Table section header truncated.")
return
Expand All @@ -248,11 +302,12 @@ def _parse_table(self, fh, sec, seg_index, sectors_extent):
stored = struct.unpack("<I", head[20:24])[0]
if zlib.adler32(head[:20]) & 0xFFFFFFFF != stored:
self.findings.append("Table header checksum mismatch at %d." % sec.start)
if entry_count == 0 or entry_count > 0x0FFFFFFF:
if entry_count == 0 or entry_count > MAX_TABLE_ENTRIES:
self.findings.append("Table entry count %d is implausible." % entry_count)
return

raw = fh.read(entry_count * 4)
raw, _ = self._read_bounded(seg_index, sec.data_start + 24,
entry_count * 4, entry_count * 4)
if len(raw) < entry_count * 4:
self.findings.append("Table entries truncated at %d." % sec.start)
entry_count = len(raw) // 4
Expand All @@ -276,19 +331,17 @@ def _parse_table(self, fh, sec, seg_index, sectors_extent):
length = self.chunk_size + 4
self.chunks.append(Chunk(seg_index, off, length, compressed))

def _parse_digest(self, fh, sec):
fh.seek(sec.data_start)
d = fh.read(sec.data_size)
def _parse_digest(self, seg, sec):
d = self._section_data(seg, sec, MAX_METADATA_SECTION)
if len(d) >= 36:
md5, sha1 = d[0:16], d[16:36]
if any(md5):
self.stored_md5 = md5.hex()
if any(sha1):
self.stored_sha1 = sha1.hex()

def _parse_hash(self, fh, sec):
fh.seek(sec.data_start)
d = fh.read(sec.data_size)
def _parse_hash(self, seg, sec):
d = self._section_data(seg, sec, MAX_METADATA_SECTION)
if len(d) >= 16 and any(d[0:16]) and not self.stored_md5:
self.stored_md5 = d[0:16].hex()

Expand All @@ -300,10 +353,19 @@ def _chunk(self, index):
if index < 0 or index >= len(self.chunks):
return b""
c = self.chunks[index]
# A stored chunk is at most the chunk plus its checksum; a compressed
# one can exceed that only by zlib's small worst-case expansion.
cap = self.chunk_size + self.chunk_size // 64 + 64
with self._io_lock:
fh = self._fh(c.seg)
fh.seek(c.offset)
raw = fh.read(c.length)
raw, _ = self._read_bounded(c.seg, c.offset, c.length, cap)
# Running into the end of the segment is not itself remarkable (a
# final chunk sized from the nominal chunk size does); a length past
# what any chunk can be, or an offset outside the file, is.
if c.length > cap or c.offset >= self._seg_size(c.seg):
self.findings.append(
"Chunk %d declares %d bytes at offset %d in segment %d; "
"only %d could be read." % (index, c.length, c.offset, c.seg,
len(raw)))
if c.compressed:
data, over = inflate_capped(raw, self.chunk_size)
if over:
Expand Down
Loading
Loading