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
3 changes: 3 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Sponsor button for this repository (GitHub Sponsors).
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: warpedatom
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@
.DS_Store
Thumbs.db

# Keep Cargo.lock this crate builds a binary, so the lock is committed.
# Keep Cargo.lock - this crate builds a binary, so the lock is committed.
56 changes: 35 additions & 21 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,39 @@
All notable changes to OffsetScan are documented in this file.
The project follows semantic versioning.

## [Unreleased]
## [0.4.0] - 2026-07-27

### Planned
### Added

- `offsetscan rich` — PE Rich-header parsing and rich hash (build-toolchain fingerprint for
clustering / attribution), the fast-follow to 0.3.0's TLSH similarity clustering.
- **`offsetscan rich` - PE Rich-header parsing and a build-toolchain fingerprint hash.** Parses
the undocumented Rich header the Microsoft linker writes between the DOS stub and the PE
header, reporting its `@comp.id` records (`ProductId`/`BuildId`/`Count`) and a `RichHash`.
`RichHash` is the MD5 of the decrypted Rich array (the `DanS` marker, its padding, and every
comp.id/count record - pefile's `RICH_HEADER.clear_data`), deliberately excluding the per-file
XOR key and the `Rich`/checksum trailer, so it is invariant across binaries built with the same
toolchain - a clustering / attribution primitive that complements imphash and TLSH.
- **Verified byte-identical to pefile:** `md5(pe.RICH_HEADER.clear_data)` matched OffsetScan's
`RichHash` on 118/118 System32 DLLs carrying a Rich header - 0 mismatches, 0 presence
disagreements.
- One record per file - `File`/`FileSize`/`HasRichHeader`/`RichHash`/`XorKey`/`EntryCount`/
`Entries`/`Warnings`. A missing Rich header (non-MSVC or stripped binaries) is reported
cleanly with `HasRichHeader: false`, not as an error; a coincidental `Rich` marker with no
valid `DanS` behind it is skipped in favour of the genuine header.
- Per-file and streaming (`--ndjson`); no new dependencies (reuses `md-5`). OffsetScan-only -
no OffsetInspect equivalent, so it sits outside the JSON parity contract.

## [0.3.0] - 2026-07-27

### Added

- **`offsetscan cluster` TLSH similarity hashing and corpus clustering.** Computes a
- **`offsetscan cluster` - TLSH similarity hashing and corpus clustering.** Computes a
canonical TLSH (`T1...`) locality-sensitive digest per file and groups files whose pairwise
TLSH distance is within `--threshold` (default 70; lower = stricter, 0 = identical) into
single-linkage clusters. Emits one record per file `File`/`FileSize`/`SHA256`/`Tlsh`/
`ClusterId`/`Warnings` where `ClusterId` is shared across near-duplicates and null for
single-linkage clusters. Emits one record per file - `File`/`FileSize`/`SHA256`/`Tlsh`/
`ClusterId`/`Warnings` - where `ClusterId` is shared across near-duplicates and null for
singletons. This is the first OffsetScan capability with no OffsetInspect equivalent: it is
inherently corpus-scale, answering "which of these thousands of samples are variants of one
another?" (repacks, malware families, near-duplicates) variants that a different SHA-256
another?" (repacks, malware families, near-duplicates) - variants that a different SHA-256
hides but TLSH still groups. Verified on real PEs: a modified copy (different SHA-256) still
clusters with its original, while an unrelated system DLL does not.
- Files too small/uniform for TLSH, or unreadable, are reported with a warning and left
Expand All @@ -30,8 +44,8 @@ The project follows semantic versioning.
by each cluster's smallest member path), independent of input order or thread scheduling.
- Digests carry the `T1` version prefix, so they are directly comparable with other canonical
TLSH tooling.
- Depends on the pure-Rust `tlsh2` crate (`diff` feature) no C toolchain or `libclang`
required, unlike the optional `yara-scan` feature so `cargo install offsetscan` keeps
- Depends on the pure-Rust `tlsh2` crate (`diff` feature) - no C toolchain or `libclang`
required, unlike the optional `yara-scan` feature - so `cargo install offsetscan` keeps
working with no system prerequisites.

### Notes
Expand All @@ -46,7 +60,7 @@ The project follows semantic versioning.
### Documentation

- Correct the crate description's schema reference. It previously claimed compatibility with
`OffsetInspect.Result`/`ThreatScanResult` objects produced by `Invoke-OffsetInspect` and
`OffsetInspect.Result`/`ThreatScanResult` - objects produced by `Invoke-OffsetInspect` and
`Invoke-OffsetThreatScan`, which OffsetScan does not emit. It is schema-compatible with the
static-triage output (`Get-OffsetPEInfo`/`Get-OffsetEntropy`/`Get-OffsetString`/`Get-OffsetIOC`).
No code changes.
Expand Down Expand Up @@ -77,8 +91,8 @@ The project follows semantic versioning.
### Added

- **`offsetscan yara` subcommand** (feature-gated behind `yara-scan`, off by default).
Matches files against YARA rule files and emits one record per matched string
`File`/`Rule`/`StringId`/`Offset`/`OffsetHex`/`Data` **verified byte-identical to
Matches files against YARA rule files and emits one record per matched string -
`File`/`Rule`/`StringId`/`Offset`/`OffsetHex`/`Data` - **verified byte-identical to
OffsetInspect's `Invoke-OffsetYaraScan`** on the same rules and sample. Supports multiple
`--rules` files, `--recurse`, a `--timeout`, and `--ndjson`. A default build (no feature)
exposes the subcommand but returns a clear rebuild instruction. Schema field names are
Expand All @@ -98,7 +112,7 @@ The project follows semantic versioning.
### Fixed

- The strict PE path computed the overlay boundary **without** filtering out zero-raw-size
sections, unlike OffsetInspect's `Get-OIPEOverlayRange` and the lenient path a latent
sections, unlike OffsetInspect's `Get-OIPEOverlayRange` and the lenient path - a latent
inconsistency that could misplace the overlay for PEs with a zero-raw-size section
(e.g. some packers' virtual sections). Both paths now use the filtered computation.

Expand All @@ -107,12 +121,12 @@ The project follows semantic versioning.
### Fixed

- **Structurally-damaged PEs are no longer rejected outright.** goblin does strict
full-structure validation, so a truncated or carved sample headers intact but section
data cut off, common with partial downloads and file-carving was reported as
full-structure validation, so a truncated or carved sample - headers intact but section
data cut off, common with partial downloads and file-carving - was reported as
`IsPE: false` with every PE field null, losing all triage value. `parse_pe` now falls
back to a lenient header-salvage parse (mirroring OffsetInspect's `ConvertTo-OIPEImage`)
when goblin rejects a file, recovering machine, sections, entry point, overlay, and any
reachable imports/imphash. Valid PEs are unaffected they always take the goblin path,
reachable imports/imphash. Valid PEs are unaffected - they always take the goblin path,
verified unchanged across a 150-file corpus vs pefile. Across a 34-file malformed corpus
(truncations, bit-flips, corrupted directories, bogus section counts, pure garbage),
`IsPE` now agrees with OffsetInspect on all 34 (was 22/34), and neither engine crashes nor
Expand All @@ -130,8 +144,8 @@ The project follows semantic versioning.

- **imphash now resolves ordinal imports from `ws2_32`/`wsock32`/`oleaut32` to their real
function names, matching pefile and VirusTotal.** Previously these rendered as `ord<N>`,
so any binary importing those libraries by ordinal a common malware networking
pattern produced an imphash that did not match the value on VirusTotal, defeating the
so any binary importing those libraries by ordinal - a common malware networking
pattern - produced an imphash that did not match the value on VirusTotal, defeating the
imphash's purpose of correlating a sample against threat intel. Verified against pefile
across a 150-file corpus (44 exercising the special-ordinal path): 0 mismatches, and 15
binaries that previously diverged now agree. Other ordinal imports still render `ord<N>`,
Expand Down Expand Up @@ -170,9 +184,9 @@ The project follows semantic versioning.
units and silently skipped any UTF-16LE string beginning at an odd offset. It now
resynchronizes one byte at a time after a non-match, mirroring `Get-OIByteString`.
This under-reported `PrintableStringCount` in the `ioc` panel and omitted hits from
`strings` on `user32.dll`, 36 of 366 UTF-16LE strings were missing. Verified after
`strings` - on `user32.dll`, 36 of 366 UTF-16LE strings were missing. Verified after
the fix: `offsetscan strings` and `Get-OffsetString` return set-identical results
(offset, encoding, and value) for `ntdll.dll` 32,506 strings, zero differences on
(offset, encoding, and value) for `ntdll.dll` - 32,506 strings, zero differences on
either side.
- The UTF-16LE printability test now explicitly requires a zero high byte rather than
range-checking the assembled `u16`, matching the reference implementation.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "offsetscan"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
authors = ["DreadHost Research"]
description = "Standalone native corpus-scale engine for PE parsing, entropy, string extraction, imphash, and IOC panels (JSON schema-compatible with OffsetInspect's Get-OffsetPEInfo/Get-OffsetEntropy/Get-OffsetString/Get-OffsetIOC), plus TLSH similarity clustering for grouping related samples across a corpus."
Expand All @@ -26,7 +26,7 @@ serde_json = "1"
md-5 = "0.10"
sha1 = "0.10"
sha2 = "0.10"
# Parallel corpus scanning (thousands of files) the actual perf payoff over PowerShell
# Parallel corpus scanning (thousands of files) - the actual perf payoff over PowerShell
rayon = "1"
# Directory/wildcard expansion, matching Invoke-OffsetThreatScanBatch's file-discovery semantics
walkdir = "2"
Expand Down
72 changes: 52 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# OffsetScan

<p align="center">
<img src="assets/offsetscan-banner.png" alt="OffsetScan native corpus-scale static-triage engine" width="640">
<img src="assets/offsetscan-banner.png" alt="OffsetScan - native corpus-scale static-triage engine" width="640">
</p>

<p align="center">
Expand All @@ -23,7 +23,7 @@ great for single-file, interactive analysis. OffsetScan exists for the other
end of the workload: **thousands of files**, where PowerShell's per-file
overhead adds up and a parallel, no-GC native core pays for itself.

OffsetScan does not touch AMSI or Microsoft Defender that stays exactly
OffsetScan does not touch AMSI or Microsoft Defender - that stays exactly
where it belongs, as Windows-only functionality in OffsetInspect. OffsetScan
only does the read-only, cross-platform static-analysis layer: PE parsing,
entropy, string extraction, hashing, and (optionally) YARA matching.
Expand All @@ -45,17 +45,18 @@ interchangeable at the JSON layer:
The struct definitions have been verified field-for-field against the
authoritative `docs/OUTPUT-SCHEMA.md` and the real OffsetInspect 3.x objects,
and cross-checked at runtime: `offsetscan ioc` and `Get-OffsetIOC` produce
identical panels for the same file **including the imphash**. The
identical panels for the same file - **including the imphash**. The
serialized field names (`MD5`/`SHA1`/`SHA256`/`IsPE`/`IsPE32Plus`) are locked
by unit tests so the interchange contract can't silently drift.

One subcommand deliberately sits **outside** this contract: `offsetscan cluster`
(TLSH similarity clustering) has no OffsetInspect equivalent — it is inherently
corpus-scale, so it lives only here. See [Similarity clustering](#similarity-clustering).
Two subcommands deliberately sit **outside** this contract - neither has an OffsetInspect
equivalent, so they live only here: `offsetscan cluster` (TLSH similarity clustering, inherently
corpus-scale) and `offsetscan rich` (PE Rich-header build-toolchain fingerprint). See
[Similarity clustering](#similarity-clustering) and [Rich header](#rich-header).

### Verified parity

Both engines produce the same IOC panel for a file captured at runtime against the same
Both engines produce the same IOC panel for a file - captured at runtime against the same
Windows system DLL.

`offsetscan ioc C:\Windows\System32\kernel32.dll`:
Expand Down Expand Up @@ -109,13 +110,13 @@ while `ConvertTo-Json` emits a bare object with Windows `\` paths.

String extraction is compared as a set, not just a count. For `ntdll.dll`
(2,517,928 bytes), `offsetscan strings` and `Get-OffsetString` both return
**32,506** hits that agree exactly on offset, encoding, and value zero entries
**32,506** hits that agree exactly on offset, encoding, and value - zero entries
unique to either engine.

One caveat when reproducing this: `Get-OffsetString` reads in 1 MiB windows by
default and a string straddling a window seam is split in two, which inflates
PowerShell's count by one per affected seam on files larger than the window.
That is an artifact of the windowed read, not a disagreement about content
That is an artifact of the windowed read, not a disagreement about content -
pass a `-WindowSize` large enough to hold the file to compare like for like.
OffsetScan reads the whole file and is unaffected.

Expand All @@ -139,24 +140,28 @@ offsetscan ioc ./sample.exe
# Corpus mode (any subcommand):
offsetscan ioc ./samples --recurse

# Streaming output for large corpora one compact JSON object per line,
# Streaming output for large corpora - one compact JSON object per line,
# emitted as each file finishes so peak memory stays flat:
offsetscan ioc ./samples --recurse --ndjson

# Flat CSV for spreadsheets/SIEM (ioc only; columns match the JSON field names):
offsetscan ioc ./samples --recurse --csv > iocs.csv

# Similarity clustering group variants/repacks/near-duplicates across a corpus:
# Similarity clustering - group variants/repacks/near-duplicates across a corpus:
offsetscan cluster ./samples --recurse
offsetscan cluster ./samples --recurse --threshold 40 # stricter (fewer, tighter clusters)

# Rich-header build-toolchain fingerprint (RichHash + comp.id records):
offsetscan rich ./sample.exe
offsetscan rich ./samples --recurse --ndjson
```

By default all commands emit a pretty-printed JSON array to stdout, matching
OffsetInspect's JSON-mode convention (always an array, even for one result).
Add `--ndjson` for newline-delimited JSON pipe-friendly and constant-memory
Add `--ndjson` for newline-delimited JSON - pipe-friendly and constant-memory
over hundreds of thousands of files. Add `--csv` (the `ioc` subcommand only, as
the others produce nested data) for a flat header + one-row-per-file table whose
columns match the JSON field names ready for Excel or a SIEM import.
columns match the JSON field names - ready for Excel or a SIEM import.

## Consuming from PowerShell

Expand All @@ -177,7 +182,7 @@ that shape accepts OffsetScan's output as a drop-in, faster-at-scale alternative
locality-sensitive digest for each file and groups files whose pairwise TLSH distance is
within `--threshold` (default 70; lower = stricter, `0` = identical) into single-linkage
clusters. It answers the question single-file inspection can't: *which of these thousands of
samples are variants of one another?* repacks, malware families, near-duplicates. A modified
samples are variants of one another?* - repacks, malware families, near-duplicates. A modified
copy has a different SHA-256, so exact-hash dedup misses it; TLSH still places it beside its
original.

Expand All @@ -197,22 +202,49 @@ One record per file:
`ClusterId` is shared across near-duplicates and `null` for a singleton. Files too small or too
uniform for TLSH (and unreadable files) come back with `Tlsh: null` and a `Warnings` entry
rather than failing the run. Digests carry the `T1` version prefix, so they compare directly
with other canonical TLSH tooling. Clustering is deterministic cluster numbering is ordered by
with other canonical TLSH tooling. Clustering is deterministic - cluster numbering is ordered by
each cluster's smallest member path, so output doesn't depend on input order or thread timing.

Notes: `cluster` is a whole-corpus operation (every file is hashed before ids are assigned), so
unlike the other subcommands it isn't streamed as files finish; `--ndjson` still emits one record
per line, and `--csv` isn't supported for it. Pairwise comparison is `O(n²)` sized for the
per line, and `--csv` isn't supported for it. Pairwise comparison is `O(n²)` - sized for the
few-thousand-file corpora this targets. It depends only on the pure-Rust `tlsh2` crate, so
`cargo install offsetscan` needs no system prerequisites (unlike the `yara-scan` feature).

## Rich header

`offsetscan rich` parses the undocumented **Rich header** the Microsoft linker writes between
the DOS stub and the PE header, and reports a build-toolchain fingerprint. The header lists
`@comp.id` records - which build tool (`ProductId`), which build (`BuildId`), and how many
objects it contributed (`Count`) - so two samples compiled in the same environment share it even
when little else lines up. Useful for clustering and attribution alongside imphash and TLSH.

```json
[
{ "File": "sample.exe", "FileSize": 836232, "HasRichHeader": true,
"RichHash": "1f67254359760775d87ef504d2f80fb1", "XorKey": "0x54B34DDD",
"EntryCount": 10,
"Entries": [ { "ProductId": 257, "BuildId": 33145, "Count": 4 }, … ],
"Warnings": [] }
]
```

`RichHash` is the MD5 of the **decrypted** Rich array (the `DanS` marker, its padding, and every
comp.id/count record - pefile's `RICH_HEADER.clear_data`). It deliberately excludes the per-file
XOR key and the `Rich`/checksum trailer, so it is **invariant across binaries built with the same
toolchain**, which is what makes it a clustering key. It is **verified byte-identical to pefile**:
`md5(pe.RICH_HEADER.clear_data)` matched OffsetScan's `RichHash` on 118/118 System32 DLLs that
carry a Rich header. A binary without one (non-MSVC or stripped) comes back with
`HasRichHeader: false` and no error; a stray `Rich` byte sequence with no valid `DanS` behind it
is skipped in favour of the genuine header.

## What's intentionally NOT here

- AMSI / Microsoft Defender scanning stays in OffsetInspect (Windows-only,
- AMSI / Microsoft Defender scanning - stays in OffsetInspect (Windows-only,
needs the actual providers).
- Detection-boundary bisection search that's a stateful, provider-driven
- Detection-boundary bisection search - that's a stateful, provider-driven
workflow (`Invoke-OffsetThreatScan`), not a stateless corpus pass.
- ClamAV integration `clamscan` process-spawning has no real parallel-corpus
- ClamAV integration - `clamscan` process-spawning has no real parallel-corpus
benefit from a Rust rewrite; left in PowerShell.

## Status
Expand All @@ -225,7 +257,7 @@ from the PE resource data directory.
The `yara-scan` feature adds an `offsetscan yara` subcommand whose records
(`File`/`Rule`/`StringId`/`Offset`/`OffsetHex`/`Data`) are verified identical to
`Invoke-OffsetYaraScan`'s on the same rules and sample. It is **feature-gated and
off by default** building it needs a C toolchain and `libclang` (the `yara` crate
off by default** - building it needs a C toolchain and `libclang` (the `yara` crate
compiles a vendored `libyara` via bindgen), so the default binary (and everything on
crates.io / the release page) does not include it. A CI job builds and tests the
feature on Linux (installing `libclang` for bindgen). Non-printable match bytes are
Expand Down
Loading