mirror of
https://github.com/praetorian-inc/reduce-golang-detections-skill
synced 2026-06-21 14:05:11 +00:00
Initial open-source release
Skill, PE structural analyzer, and reference docs for systematically reducing VirusTotal/EDR detection rates on compiled Go binaries via structural analysis and disciplined A/B testing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
# Local Claude Code state — never publish
|
||||
.claude/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# OS / editor cruft
|
||||
.DS_Store
|
||||
*.swp
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Scratch artifacts
|
||||
*.log
|
||||
tmp/
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Praetorian Security, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,45 @@
|
||||
# reduce-golang-detections-skill
|
||||
|
||||
A [Claude Code](https://claude.com/claude-code) skill for systematically reducing VirusTotal and EDR detection rates on compiled Go binaries through structural analysis, iterative A/B testing, and ML feature vector optimization.
|
||||
|
||||
> A companion blog post will be linked here once published.
|
||||
|
||||
## What this is
|
||||
|
||||
Modern EDR detections on Go binaries are dominated by statistical ML classifiers (e.g. Microsoft `Wacatac.B!ml`, `ML.Attribute.HighConfidence`, `MalwareX-gen`, `Evo-gen`) rather than fixed-byte YARA rules. Defeating an ML classifier is a fundamentally different problem from defeating a signature — renaming strings and swapping imports often makes detection *worse*, because the resulting binary diverges further from the vanilla toolchain baseline the classifier has learned as "normal."
|
||||
|
||||
This skill packages a disciplined methodology for that problem:
|
||||
|
||||
- **Triage detection type before remediation.** Label every hit as YARA-style or ML-style. The fixes are different and not interchangeable.
|
||||
- **Change one variable per experiment.** 10–20 samples per arm, control and variant built in the same VT upload window — Wacatac retrains on roughly a daily cadence, so a control batch from yesterday is not a valid A/B test.
|
||||
- **Measure with a comprehensive PE structural analyzer** (included) before and after each change, and compare deltas against a vanilla binary from the same toolchain.
|
||||
- **Camouflage, not concealment.** Give the classifier a believable answer to "what is this binary?" — mimicking the gopclntab symbol fingerprint of a single coherent large Go project consistently outperforms stripping, padding, or obfuscation.
|
||||
- **Recognize the irreducible floor.** Once detection drops to ~15–25% on VirusTotal (the stochastic floor near the ML threshold), further structural optimization rarely pays back, and VT is not ground truth for real endpoints.
|
||||
|
||||
## What's in the box
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `SKILL.md` | The skill itself — methodology, prerequisites, phased workflow, and the core principles above. Load this into Claude Code. |
|
||||
| `pe_structural_analyzer.py` | Standalone Python analyzer that extracts the full PE structural feature vector (sections, imports, exports, resources, gopclntab, entropy, etc.) and produces a baseline/delta JSON report. |
|
||||
| `references/pe-structural-analyzer.md` | How to run the analyzer and interpret its output. |
|
||||
| `references/pe-structural-features.md` | Catalog of structural features observed across vanilla vs. modified Go builds, with which features actually correlate with detection. |
|
||||
| `references/experiment-categories.md` | Catalog of experiment categories that have and have not worked in practice, including dead ends to avoid. |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3 with `pefile` and `lief` (a venv is recommended; on PEP 668 systems use `--break-system-packages` if you must install system-wide).
|
||||
- A VirusTotal API key.
|
||||
- A vanilla binary from the same toolchain as your target (e.g. `GOOS=windows GOARCH=amd64 go build`) for delta comparison.
|
||||
|
||||
## Using the skill with Claude Code
|
||||
|
||||
Drop `SKILL.md`, `pe_structural_analyzer.py`, and the `references/` directory into a location Claude Code can read as a skill, then invoke the workflow when you have a high-detection binary you need to bring down. The skill will walk through baseline collection, structural analysis, hypothesis selection, and per-experiment A/B testing.
|
||||
|
||||
## Scope and intent
|
||||
|
||||
This is a defensive-research and authorized-engagement tool published by Praetorian to share methodology for understanding how modern ML-based EDR classifiers respond to changes in compiled binaries. It is intended for use on binaries you are authorized to test, in the context of red-team engagements, detection-engineering research, and toolchain hardening.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
name: reduce-edr-detections
|
||||
description: Use when reducing VirusTotal/EDR detection rates on compiled binaries — systematic A/B testing methodology with comprehensive PE structural analysis for identifying and eliminating ML classifier signals
|
||||
allowed-tools: Read, Bash, Grep, Glob, Write, Edit, Agent, AskUserQuestion
|
||||
---
|
||||
|
||||
# Reduce EDR Detections
|
||||
|
||||
**Systematic methodology for reducing VirusTotal and EDR detection rates on compiled binaries through comprehensive structural analysis, iterative A/B testing, and ML feature vector optimization.**
|
||||
|
||||
## When to Use
|
||||
|
||||
- VT detection rate is too high on compiled binaries
|
||||
- ML classifiers (Wacatac, MalwareX-gen, ML.Attribute, Evo-gen, etc.) are flagging output
|
||||
- Need to identify which binary attributes trigger detection
|
||||
- After changes that may alter the PE/ELF structure
|
||||
- Preparing binaries for deployment
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- VT API key at `/path/to/vt_apikey`
|
||||
- `pefile` and `lief` Python libraries — preferably in a venv (`python3 -m venv .venv && . .venv/bin/activate && pip install pefile lief`). On PEP 668 systems where you can't use a venv, add `--break-system-packages` to a system-wide `pip install`.
|
||||
- PE Structural Analyzer script: see `references/pe-structural-analyzer.md`
|
||||
- A vanilla binary from the same toolchain as your target (e.g., `GOOS=windows GOARCH=amd64 go build`)
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Triage detection type before choosing remediation.** A YARA-style verdict (`Trojan/Win.Sliver.R774471`) and an ML verdict (`Wacatac.B!ml`, `ML.Attribute.HighConfidence`) require fundamentally different fixes. YARA matches fixed bytes — rename strings, swap imports, restructure sections. ML is a statistical classifier — renaming strings cannot defeat it, and trying often makes things worse. Label every hit before starting.
|
||||
2. **Change ONE variable per experiment. 10–20 samples per test. Build control and variant in the same VT upload window.** ML models (especially Microsoft Wacatac.B!ml) retrain on approximately a daily cadence. A control batch built today and a variant built tomorrow is not a valid A/B test — half the observed delta will be model drift.
|
||||
3. **Don't fight the toolchain identity.** Making a Go binary look less like Go creates inconsistencies that _increase_ detection — including renaming natural internal type names (e.g. `Allocator`, `Preamble`) that show up slightly over-represented in detected samples. That's likely `strings -n 6` extraction noise, not a real signal. See `references/experiment-categories.md`.
|
||||
4. **Validate `strings -n 6` tokens against source before acting on them.** The `strings` tool glues adjacent in-memory strings together, producing tokens that look like meaningful symbols but are two unrelated strings concatenated across a buffer boundary. Grep the actual source tree to confirm before making a suspicious token a hypothesis.
|
||||
5. **Camouflage, not concealment.** The goal is to give the classifier a believable answer to "what is this binary?" Mimicking the gopclntab symbol fingerprint of a single large real Go project (ghost profiling) consistently outperforms stripping, obfuscating, or padding. One coherent project; blending multiple produces a binary that matches no known software.
|
||||
6. **VT is not ground truth, and there is an irreducible floor.** Microsoft's cloud ML is substantially more aggressive than the local Defender engine. A binary at 100% Wacatac on VirusTotal can be clean on a real endpoint. Once detection drops to roughly 15–25% (the stochastic floor near the ML threshold), further structural optimization rarely pays back.
|
||||
7. **Measure everything.** Use the full PE structural analyzer before and after each change. Features you don't measure can't be correlated with detections.
|
||||
8. **Compare against vanilla.** Always compare your binary against a clean vanilla binary from the same toolchain. The delta between them is your ML signal.
|
||||
9. **ML classifiers use feature vectors, not individual features.** Compound anomalies accumulate — fix the ones that diverge most from the vanilla baseline.
|
||||
|
||||
## Phase 1: Establish Baseline
|
||||
|
||||
Build 10–20 identical-purpose samples. Upload all to VT and record per-engine results.
|
||||
|
||||
```bash
|
||||
VT_KEY=$(cat /path/to/vt_apikey)
|
||||
UPLOAD_URL=$(curl -s 'https://www.virustotal.com/api/v3/files/upload_url' \
|
||||
-H "x-apikey: $VT_KEY" | python3 -c "import sys,json; print(json.load(sys.stdin)['data'])")
|
||||
|
||||
for f in /tmp/samples/*.exe; do
|
||||
curl -s --request POST --url "$UPLOAD_URL" \
|
||||
--header "x-apikey: $VT_KEY" --form "file=@$f" > /dev/null
|
||||
sleep 16 # free-tier rate limit
|
||||
done
|
||||
sleep 300 # wait for analysis
|
||||
sha256() { command -v sha256sum >/dev/null && sha256sum "$1" | cut -d' ' -f1 || shasum -a 256 "$1" | cut -d' ' -f1; }
|
||||
for f in /tmp/samples/*.exe; do
|
||||
sha=$(sha256 "$f")
|
||||
curl -s "https://www.virustotal.com/api/v3/files/$sha" -H "x-apikey: $VT_KEY" | \
|
||||
python3 -c "import sys,json; d=json.load(sys.stdin); r=d['data']['attributes']['last_analysis_results']; dets={k:v for k,v in r.items() if v['category']=='malicious'}; print(f'{len(dets)}: {list(dets.keys())}')"
|
||||
sleep 4
|
||||
done
|
||||
```
|
||||
|
||||
Create a detection label file for the analyzer:
|
||||
|
||||
```json
|
||||
{ "sample-01.exe": "clean", "sample-02.exe": "detected", ... }
|
||||
```
|
||||
|
||||
## Phase 2: Comprehensive Structural Analysis
|
||||
|
||||
**Use the full PE Structural Analyzer** (see `references/pe-structural-analyzer.md`). It extracts 13 ML-relevant feature categories in one pass:
|
||||
|
||||
```bash
|
||||
python3 pe_structural_analyzer.py /tmp/samples/ \
|
||||
--baseline /tmp/vanilla_go.exe \
|
||||
--detections /tmp/detections.json
|
||||
```
|
||||
|
||||
Outputs: `/tmp/pe_analysis.json` (full), `/tmp/pe_analysis.csv` (flat), console clean-vs-detected comparison.
|
||||
|
||||
### Build the Vanilla Baseline
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/vanilla && cd /tmp/vanilla && go mod init hello
|
||||
printf 'package main\nimport ("fmt";"os")\nfunc main(){fmt.Println("hello");os.Exit(0)}' > main.go
|
||||
GOOS=windows GOARCH=amd64 go build -o /tmp/vanilla_go.exe .
|
||||
```
|
||||
|
||||
### Read the Baseline Delta
|
||||
|
||||
The analyzer prints every feature where your binary diverges from the vanilla baseline. Focus on:
|
||||
|
||||
- **Features ADDED** by your modifications (phantom sections, extra DLLs, resources)
|
||||
- **Features AMPLIFIED** (BSS ratio from 6x to 208x, debug sections from 27% to 50%)
|
||||
- **Features that CONTRADICT the toolchain identity** (wrong stack reserve for Go, linker version mismatch)
|
||||
|
||||
For the full taxonomy of what ML classifiers measure per feature category, see `references/pe-structural-features.md`.
|
||||
|
||||
### Clean vs. Detected Statistical Comparison
|
||||
|
||||
The analyzer outputs Cohen's d between clean and detected groups automatically. Guidance:
|
||||
|
||||
| Effect Size | Action |
|
||||
|-------------|--------|
|
||||
| d > 0.8 | Investigate immediately |
|
||||
| d > 0.5 | Worth testing |
|
||||
| d > 0.3 | Low priority |
|
||||
| d < 0.3 | Noise — skip |
|
||||
|
||||
**With fewer than 5 clean samples, Cohen's d is unreliable.** When all samples are structurally identical and detection is stochastic, you need to shift the _entire_ feature vector, not individual features.
|
||||
|
||||
## Phase 3: Hypothesis Testing
|
||||
|
||||
For each signal from Phase 2:
|
||||
|
||||
1. **Form hypothesis**: "Removing X will move feature Y toward vanilla baseline"
|
||||
2. **One change only**
|
||||
3. **Build 10–20 samples**
|
||||
4. **Run the analyzer** — verify the feature actually shifted before uploading
|
||||
5. **Upload to VT**, wait, pull results
|
||||
6. **Compare**: clean rate, anomaly score, vanilla delta
|
||||
7. **Decision**: improved → keep, same/worse → revert
|
||||
|
||||
See `references/experiment-categories.md` for the safe vs. dangerous change taxonomy, and the experiment tracking table template.
|
||||
|
||||
### Testing Anti-patterns
|
||||
|
||||
- Don't overindex on small samples — n<5 correlations are noise
|
||||
- Don't change multiple variables simultaneously
|
||||
- Don't assume causation from correlation
|
||||
- Don't fight the toolchain — a Go binary should look like Go, not MSVC
|
||||
|
||||
## Phase 4: Iterate
|
||||
|
||||
Repeat Phases 2–3 until: clean rate plateaus, remaining detections show no structural pattern, or feature vector matches vanilla baseline as closely as possible.
|
||||
|
||||
## Phase 5: Validate at Scale
|
||||
|
||||
Final validation with 20–30 samples of the real payload. Record as the release benchmark.
|
||||
|
||||
## Tracking Results
|
||||
|
||||
```
|
||||
| Experiment | Change | Samples | Clean% | Anomaly Score | Vanilla Delta | Keep? |
|
||||
|-----------|-----------------|---------|--------|---------------|---------------|-------|
|
||||
| Baseline | — | 20 | 15% | 12 | 12 anomalies | — |
|
||||
| Exp 1 | Remove .edata | 20 | 20% | 11 | 11 anomalies | Yes |
|
||||
| Exp 2 | Patch linker v | 20 | 5% | 11 (incons.) | 13 anomalies | No |
|
||||
```
|
||||
|
||||
## Known Ineffective / Counterproductive Approaches
|
||||
|
||||
### Proven Counterproductive (WORSE detection)
|
||||
|
||||
- Patching linker version (Go 3.0 → MSVC 14.x) — toolchain inconsistency
|
||||
- Adding fake Rich header to Go binary — inconsistent with Go's section layout
|
||||
- XOR padding .text section — raises entropy to 6.99, triggers Bkav/Trapmine
|
||||
- Block-shuffle padding .text — ML detects .text SIZE anomaly
|
||||
- Stripping .symtab — Go always has it; absence is inconsistent with toolchain
|
||||
|
||||
### Proven Neutral (no effect)
|
||||
|
||||
- Code-level polymorphism (struct reorder, opaque predicates, AST transforms)
|
||||
- Per-build filename randomization
|
||||
|
||||
### Proven Effective
|
||||
|
||||
- DLL pool pruning (remove netapi32, ole32, winhttp — unused import heuristic)
|
||||
- Product name pruning (remove enterprise-sounding names that appear in malware datasets)
|
||||
- Signing identity diversification — per-build random pick from a pool of plausible company names, description strings, and issuer suffixes eliminates stable YARA targets in the Authenticode blob
|
||||
- Capping enriched DLLs at ≤3
|
||||
- YARA signature kill (per-build renaming of wazero/WASM package paths and exported names)
|
||||
- Ghost profiling — replacing a binary's gopclntab with harvested function/package names from a large real Go project. The classifier sees "legitimate infrastructure software" instead of a minimal Go runtime. Larger, coherent single-project profiles produce reliably lower detection rates; blending multiple projects typically makes detection worse.
|
||||
|
||||
## References
|
||||
|
||||
- [pe-structural-analyzer.md](references/pe-structural-analyzer.md) — Analyzer installation, usage, output format
|
||||
- [pe-structural-features.md](references/pe-structural-features.md) — 13-category ML feature taxonomy
|
||||
- [experiment-categories.md](references/experiment-categories.md) — Safe/dangerous change taxonomy, experiment tracking template
|
||||
|
||||
## Integration
|
||||
|
||||
### Called By
|
||||
- Manual invocation when detection rates increase
|
||||
- After ML model updates (re-test existing samples to detect regression)
|
||||
- Before deploying new binary versions
|
||||
@@ -0,0 +1,698 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PE Structural Analyzer — Comprehensive feature extraction for ML detection research.
|
||||
|
||||
Extracts all features that AV ML classifiers use, organized by detection vector.
|
||||
Outputs JSON per-sample and CSV summary for correlation analysis.
|
||||
|
||||
Feature categories:
|
||||
1. Header fields (EMBER HeaderFileInfo equivalent)
|
||||
2. Section table (EMBER SectionInfo equivalent + Go-specific)
|
||||
3. Import table (EMBER ImportsInfo equivalent)
|
||||
4. Byte statistics (EMBER ByteHistogram + ByteEntropyHistogram equivalent)
|
||||
5. String features (EMBER StringExtractor equivalent)
|
||||
6. Data directories (EMBER DataDirectories equivalent)
|
||||
7. General file info (EMBER GeneralFileInfo equivalent)
|
||||
8. Go-specific features (custom: BSS ratio, symtab, linker fingerprint)
|
||||
9. Certificate/Authenticode features
|
||||
10. Resource features
|
||||
11. Anomaly scores (composite)
|
||||
|
||||
Usage:
|
||||
python3 pe_structural_analyzer.py /path/to/samples/ [--baseline /path/to/vanilla.exe]
|
||||
"""
|
||||
|
||||
import pefile
|
||||
import lief
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
import hashlib
|
||||
import struct
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def byte_histogram(data):
|
||||
"""256-bin byte frequency histogram (EMBER ByteHistogram)."""
|
||||
counts = [0] * 256
|
||||
for b in data:
|
||||
counts[b] += 1
|
||||
total = len(data)
|
||||
return [c / total for c in counts] if total > 0 else counts
|
||||
|
||||
|
||||
def byte_entropy_histogram(data, window=2048):
|
||||
"""Joint byte-value/entropy 2D histogram (EMBER ByteEntropyHistogram).
|
||||
Maps each byte to (byte_value_bin, entropy_bin) where:
|
||||
- byte_value: 16 bins (0-15, 16-31, ..., 240-255)
|
||||
- entropy: 16 bins (0.0-0.5, 0.5-1.0, ..., 7.5-8.0)
|
||||
Returns 256 values (16x16 flattened).
|
||||
"""
|
||||
if len(data) < window:
|
||||
return [0.0] * 256
|
||||
|
||||
hist2d = [[0] * 16 for _ in range(16)]
|
||||
total_pairs = 0
|
||||
|
||||
# Sliding window entropy calculation (approximate with stride)
|
||||
stride = max(1, window // 4)
|
||||
for offset in range(0, len(data) - window, stride):
|
||||
chunk = data[offset:offset + window]
|
||||
# Compute entropy of this window
|
||||
counts = Counter(chunk)
|
||||
ent = 0.0
|
||||
for c in counts.values():
|
||||
p = c / window
|
||||
if p > 0:
|
||||
ent -= p * math.log2(p)
|
||||
|
||||
ent_bin = min(15, int(ent * 2)) # 0-8 -> 0-15
|
||||
|
||||
# Map center byte to histogram
|
||||
center = data[offset + window // 2]
|
||||
val_bin = center >> 4 # 0-255 -> 0-15
|
||||
hist2d[val_bin][ent_bin] += 1
|
||||
total_pairs += 1
|
||||
|
||||
# Flatten and normalize
|
||||
result = []
|
||||
for row in hist2d:
|
||||
for val in row:
|
||||
result.append(val / total_pairs if total_pairs > 0 else 0.0)
|
||||
return result
|
||||
|
||||
|
||||
def section_entropy(section_data):
|
||||
"""Shannon entropy of a byte sequence."""
|
||||
if len(section_data) == 0:
|
||||
return 0.0
|
||||
counts = Counter(section_data)
|
||||
total = len(section_data)
|
||||
ent = 0.0
|
||||
for c in counts.values():
|
||||
p = c / total
|
||||
if p > 0:
|
||||
ent -= p * math.log2(p)
|
||||
return ent
|
||||
|
||||
|
||||
def string_features(data):
|
||||
"""Extract string statistics (EMBER StringExtractor equivalent)."""
|
||||
# Find printable ASCII strings of length >= 5
|
||||
strings = []
|
||||
current = []
|
||||
for b in data:
|
||||
if 32 <= b <= 126:
|
||||
current.append(chr(b))
|
||||
else:
|
||||
if len(current) >= 5:
|
||||
strings.append(''.join(current))
|
||||
current = []
|
||||
if len(current) >= 5:
|
||||
strings.append(''.join(current))
|
||||
|
||||
if not strings:
|
||||
return {
|
||||
'num_strings': 0, 'avg_len': 0, 'max_len': 0,
|
||||
'num_urls': 0, 'num_paths': 0, 'num_registry': 0,
|
||||
'num_mz_headers': 0, 'printable_ratio': 0.0,
|
||||
'string_entropy': 0.0
|
||||
}
|
||||
|
||||
lengths = [len(s) for s in strings]
|
||||
all_text = ' '.join(strings)
|
||||
|
||||
return {
|
||||
'num_strings': len(strings),
|
||||
'avg_len': sum(lengths) / len(lengths),
|
||||
'max_len': max(lengths),
|
||||
'num_urls': sum(1 for s in strings if 'http' in s.lower() or 'ftp' in s.lower()),
|
||||
'num_paths': sum(1 for s in strings if '\\' in s or '/' in s),
|
||||
'num_registry': sum(1 for s in strings if 'HKEY_' in s or 'Software\\' in s),
|
||||
'num_mz_headers': sum(1 for s in strings if s.startswith('MZ') or 'This program' in s),
|
||||
'printable_ratio': sum(1 for b in data if 32 <= b <= 126) / len(data),
|
||||
'string_entropy': section_entropy(all_text.encode()) if all_text else 0.0
|
||||
}
|
||||
|
||||
|
||||
def analyze_pe(filepath, label=""):
|
||||
"""Extract comprehensive PE features from a single binary."""
|
||||
data = open(filepath, 'rb').read()
|
||||
pe = pefile.PE(data=data)
|
||||
binary = lief.parse(list(data))
|
||||
|
||||
total_size = len(data)
|
||||
oh = pe.OPTIONAL_HEADER
|
||||
fh = pe.FILE_HEADER
|
||||
|
||||
features = {'file': os.path.basename(filepath), 'label': label, 'size': total_size}
|
||||
|
||||
# ===== 1. HEADER FIELDS =====
|
||||
features['header'] = {
|
||||
'machine': fh.Machine,
|
||||
'num_sections': fh.NumberOfSections,
|
||||
'timestamp': fh.TimeDateStamp,
|
||||
'timestamp_is_zero': fh.TimeDateStamp == 0,
|
||||
'pointer_to_symbol_table': fh.PointerToSymbolTable,
|
||||
'num_symbols': fh.NumberOfSymbols,
|
||||
'sizeof_optional_header': fh.SizeOfOptionalHeader,
|
||||
'characteristics': fh.Characteristics,
|
||||
'magic': oh.Magic,
|
||||
'major_linker_version': oh.MajorLinkerVersion,
|
||||
'minor_linker_version': oh.MinorLinkerVersion,
|
||||
'sizeof_code': oh.SizeOfCode,
|
||||
'sizeof_initialized_data': oh.SizeOfInitializedData,
|
||||
'sizeof_uninitialized_data': oh.SizeOfUninitializedData,
|
||||
'entry_point': oh.AddressOfEntryPoint,
|
||||
'base_of_code': oh.BaseOfCode,
|
||||
'image_base': oh.ImageBase,
|
||||
'section_alignment': oh.SectionAlignment,
|
||||
'file_alignment': oh.FileAlignment,
|
||||
'major_os_version': oh.MajorOperatingSystemVersion,
|
||||
'minor_os_version': oh.MinorOperatingSystemVersion,
|
||||
'major_image_version': oh.MajorImageVersion,
|
||||
'minor_image_version': oh.MinorImageVersion,
|
||||
'major_subsystem_version': oh.MajorSubsystemVersion,
|
||||
'minor_subsystem_version': oh.MinorSubsystemVersion,
|
||||
'sizeof_image': oh.SizeOfImage,
|
||||
'sizeof_headers': oh.SizeOfHeaders,
|
||||
'checksum': oh.CheckSum,
|
||||
'checksum_is_valid': oh.CheckSum != 0,
|
||||
'subsystem': oh.Subsystem,
|
||||
'dll_characteristics': oh.DllCharacteristics,
|
||||
'sizeof_stack_reserve': oh.SizeOfStackReserve,
|
||||
'sizeof_stack_commit': oh.SizeOfStackCommit,
|
||||
'sizeof_heap_reserve': oh.SizeOfHeapReserve,
|
||||
'sizeof_heap_commit': oh.SizeOfHeapCommit,
|
||||
'num_rva_and_sizes': oh.NumberOfRvaAndSizes,
|
||||
}
|
||||
|
||||
# Derived header features
|
||||
features['header']['code_to_init_data_ratio'] = (
|
||||
oh.SizeOfCode / oh.SizeOfInitializedData
|
||||
if oh.SizeOfInitializedData > 0 else 999
|
||||
)
|
||||
features['header']['code_to_file_ratio'] = oh.SizeOfCode / total_size
|
||||
|
||||
# ===== 2. RICH HEADER =====
|
||||
features['rich_header'] = {
|
||||
'present': pe.RICH_HEADER is not None,
|
||||
'num_entries': len(pe.RICH_HEADER.values) // 2 if pe.RICH_HEADER else 0,
|
||||
}
|
||||
|
||||
# ===== 3. SECTIONS =====
|
||||
sections = []
|
||||
section_entropies = []
|
||||
section_raw_sizes = []
|
||||
section_virt_sizes = []
|
||||
max_entropy = 0
|
||||
max_entropy_section_pct = 0
|
||||
high_entropy_sections = 0 # entropy > 7.0
|
||||
max_entropy_sections = 0 # entropy > 7.9
|
||||
discardable_sections = 0
|
||||
discardable_size = 0
|
||||
code_sections_size = 0
|
||||
data_sections_size = 0
|
||||
debug_like_size = 0
|
||||
numeric_section_names = 0
|
||||
symtab_size = 0
|
||||
has_edata = False
|
||||
has_rsrc = False
|
||||
bss_ratio = 0.0 # .data virt/raw
|
||||
|
||||
for s in pe.sections:
|
||||
name = s.Name.decode('utf-8', errors='replace').rstrip('\x00')
|
||||
raw = s.SizeOfRawData
|
||||
virt = s.Misc_VirtualSize
|
||||
ent = s.get_entropy()
|
||||
flags = s.Characteristics
|
||||
pct_of_file = raw / total_size if total_size > 0 else 0
|
||||
|
||||
sec_info = {
|
||||
'name': name,
|
||||
'raw_size': raw,
|
||||
'virtual_size': virt,
|
||||
'entropy': round(ent, 4),
|
||||
'characteristics': flags,
|
||||
'pct_of_file': round(pct_of_file, 4),
|
||||
'virt_raw_ratio': round(virt / raw, 2) if raw > 0 else 0,
|
||||
'is_executable': bool(flags & 0x20000000),
|
||||
'is_writable': bool(flags & 0x80000000),
|
||||
'is_discardable': bool(flags & 0x02000000),
|
||||
}
|
||||
sections.append(sec_info)
|
||||
section_entropies.append(ent)
|
||||
section_raw_sizes.append(raw)
|
||||
section_virt_sizes.append(virt)
|
||||
|
||||
if ent > max_entropy:
|
||||
max_entropy = ent
|
||||
max_entropy_section_pct = pct_of_file
|
||||
if ent > 7.0:
|
||||
high_entropy_sections += 1
|
||||
if ent > 7.9:
|
||||
max_entropy_sections += 1
|
||||
if flags & 0x02000000:
|
||||
discardable_sections += 1
|
||||
discardable_size += raw
|
||||
if flags & 0x20000000: # executable
|
||||
code_sections_size += raw
|
||||
if name.startswith('/') and name[1:].isdigit():
|
||||
numeric_section_names += 1
|
||||
debug_like_size += raw
|
||||
if name == '.symtab':
|
||||
symtab_size = raw
|
||||
if name == '.edata':
|
||||
has_edata = True
|
||||
if name == '.rsrc':
|
||||
has_rsrc = True
|
||||
if name == '.data' and raw > 0:
|
||||
bss_ratio = virt / raw
|
||||
|
||||
# Section aggregates
|
||||
mean_entropy = sum(section_entropies) / len(section_entropies) if section_entropies else 0
|
||||
std_entropy = (sum((e - mean_entropy)**2 for e in section_entropies) / len(section_entropies))**0.5 if len(section_entropies) > 1 else 0
|
||||
|
||||
features['sections'] = {
|
||||
'details': sections,
|
||||
'count': len(sections),
|
||||
'mean_entropy': round(mean_entropy, 4),
|
||||
'std_entropy': round(std_entropy, 4),
|
||||
'max_entropy': round(max_entropy, 4),
|
||||
'min_entropy': round(min(section_entropies) if section_entropies else 0, 4),
|
||||
'high_entropy_count': high_entropy_sections,
|
||||
'max_entropy_count': max_entropy_sections,
|
||||
'max_entropy_section_pct': round(max_entropy_section_pct, 4),
|
||||
'discardable_count': discardable_sections,
|
||||
'discardable_pct': round(discardable_size / total_size, 4) if total_size > 0 else 0,
|
||||
'numeric_name_count': numeric_section_names,
|
||||
'debug_like_pct': round(debug_like_size / total_size, 4) if total_size > 0 else 0,
|
||||
'code_pct': round(code_sections_size / total_size, 4) if total_size > 0 else 0,
|
||||
'symtab_size': symtab_size,
|
||||
'symtab_pct': round(symtab_size / total_size, 4) if total_size > 0 else 0,
|
||||
'has_edata': has_edata,
|
||||
'has_rsrc': has_rsrc,
|
||||
'bss_ratio': round(bss_ratio, 2),
|
||||
'largest_section_pct': round(max(section_raw_sizes) / total_size, 4) if section_raw_sizes else 0,
|
||||
'smallest_section_size': min(section_raw_sizes) if section_raw_sizes else 0,
|
||||
}
|
||||
|
||||
# ===== 4. IMPORTS =====
|
||||
import_dlls = {}
|
||||
total_imports = 0
|
||||
has_get_proc_address = False
|
||||
has_load_library = False
|
||||
has_virtual_alloc = False
|
||||
has_create_thread = False
|
||||
has_write_process_memory = False
|
||||
|
||||
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
|
||||
for entry in pe.DIRECTORY_ENTRY_IMPORT:
|
||||
dll = entry.dll.decode().lower()
|
||||
funcs = []
|
||||
for imp in entry.imports:
|
||||
if imp.name:
|
||||
fname = imp.name.decode()
|
||||
funcs.append(fname)
|
||||
fl = fname.lower()
|
||||
if 'getprocaddress' in fl: has_get_proc_address = True
|
||||
if 'loadlibrary' in fl: has_load_library = True
|
||||
if 'virtualalloc' in fl: has_virtual_alloc = True
|
||||
if 'createthread' in fl: has_create_thread = True
|
||||
if 'writeprocessmemory' in fl: has_write_process_memory = True
|
||||
import_dlls[dll] = funcs
|
||||
total_imports += len(funcs)
|
||||
|
||||
features['imports'] = {
|
||||
'num_dlls': len(import_dlls),
|
||||
'total_functions': total_imports,
|
||||
'dlls': {dll: len(funcs) for dll, funcs in import_dlls.items()},
|
||||
'dll_names': sorted(import_dlls.keys()),
|
||||
'imphash': pe.get_imphash(),
|
||||
'has_get_proc_address': has_get_proc_address,
|
||||
'has_load_library': has_load_library,
|
||||
'has_virtual_alloc': has_virtual_alloc,
|
||||
'has_create_thread': has_create_thread,
|
||||
'has_write_process_memory': has_write_process_memory,
|
||||
'suspicious_api_count': sum([has_get_proc_address, has_load_library,
|
||||
has_virtual_alloc, has_create_thread,
|
||||
has_write_process_memory]),
|
||||
}
|
||||
|
||||
# ===== 5. DATA DIRECTORIES =====
|
||||
dd_names = ['EXPORT','IMPORT','RESOURCE','EXCEPTION','SECURITY','BASERELOC',
|
||||
'DEBUG','ARCHITECTURE','GLOBALPTR','TLS','LOAD_CONFIG',
|
||||
'BOUND_IMPORT','IAT','DELAY_IMPORT','CLR_RUNTIME','RESERVED']
|
||||
data_dirs = {}
|
||||
for i, d in enumerate(oh.DATA_DIRECTORY):
|
||||
n = dd_names[i] if i < len(dd_names) else f'DIR_{i}'
|
||||
data_dirs[n] = {'rva': d.VirtualAddress, 'size': d.Size}
|
||||
|
||||
features['data_directories'] = {
|
||||
'present': {n: info['size'] > 0 for n, info in data_dirs.items()},
|
||||
'sizes': {n: info['size'] for n, info in data_dirs.items() if info['size'] > 0},
|
||||
'has_debug_dir': data_dirs.get('DEBUG', {}).get('size', 0) > 0,
|
||||
'has_tls': data_dirs.get('TLS', {}).get('size', 0) > 0,
|
||||
'has_load_config': data_dirs.get('LOAD_CONFIG', {}).get('size', 0) > 0,
|
||||
'has_security': data_dirs.get('SECURITY', {}).get('size', 0) > 0,
|
||||
'has_exception': data_dirs.get('EXCEPTION', {}).get('size', 0) > 0,
|
||||
'has_reloc': data_dirs.get('BASERELOC', {}).get('size', 0) > 0,
|
||||
'security_size': data_dirs.get('SECURITY', {}).get('size', 0),
|
||||
}
|
||||
|
||||
# ===== 6. STRINGS =====
|
||||
features['strings'] = string_features(data)
|
||||
|
||||
# ===== 7. BYTE STATISTICS =====
|
||||
histogram = byte_histogram(data)
|
||||
features['byte_stats'] = {
|
||||
'file_entropy': section_entropy(data),
|
||||
'byte_histogram_std': (sum((h - 1/256)**2 for h in histogram) / 256)**0.5,
|
||||
'byte_histogram_skew': sum((h - 1/256)**3 for h in histogram) / (256 * ((sum((h - 1/256)**2 for h in histogram) / 256)**0.5)**3) if sum((h - 1/256)**2 for h in histogram) > 0 else 0,
|
||||
'null_byte_ratio': histogram[0],
|
||||
'ff_byte_ratio': histogram[255],
|
||||
'ascii_byte_ratio': sum(histogram[32:127]),
|
||||
}
|
||||
|
||||
# ===== 8. GO-SPECIFIC FEATURES =====
|
||||
features['go_specific'] = {
|
||||
'is_go_binary': oh.MajorLinkerVersion == 3 and oh.MinorLinkerVersion == 0,
|
||||
'linker_version_go': oh.MajorLinkerVersion == 3,
|
||||
'has_symtab': symtab_size > 0,
|
||||
'symtab_size': symtab_size,
|
||||
'has_coff_long_names': numeric_section_names > 0,
|
||||
'coff_long_name_count': numeric_section_names,
|
||||
'bss_ratio': round(bss_ratio, 2),
|
||||
'stack_reserve': oh.SizeOfStackReserve,
|
||||
'stack_reserve_is_go_default': oh.SizeOfStackReserve == 0x200000,
|
||||
'has_go_build_id': any(b'Go build ID' in data[i:i+20] for i in range(0, min(len(data), 0x1000), 1)),
|
||||
}
|
||||
|
||||
# ===== 9. AUTHENTICODE =====
|
||||
sec_dir = oh.DATA_DIRECTORY[4]
|
||||
features['authenticode'] = {
|
||||
'signed': sec_dir.Size > 0,
|
||||
'cert_size': sec_dir.Size,
|
||||
'cert_pct': round(sec_dir.Size / total_size, 6) if total_size > 0 else 0,
|
||||
}
|
||||
|
||||
# Parse certificate if present
|
||||
if sec_dir.Size > 0 and sec_dir.VirtualAddress > 0:
|
||||
try:
|
||||
cert_data = data[sec_dir.VirtualAddress:sec_dir.VirtualAddress + sec_dir.Size]
|
||||
if len(cert_data) >= 8:
|
||||
cert_len, cert_rev, cert_type = struct.unpack('<IHH', cert_data[:8])
|
||||
features['authenticode']['cert_revision'] = cert_rev
|
||||
features['authenticode']['cert_type'] = cert_type
|
||||
except:
|
||||
pass
|
||||
|
||||
# ===== 10. VERSIONINFO =====
|
||||
version_info = {}
|
||||
if hasattr(pe, 'VS_VERSIONINFO'):
|
||||
for finfo in pe.FileInfo:
|
||||
for entry in finfo:
|
||||
if hasattr(entry, 'StringTable'):
|
||||
for st in entry.StringTable:
|
||||
for k, v in st.entries.items():
|
||||
version_info[k.decode()] = v.decode()
|
||||
|
||||
features['version_info'] = {
|
||||
'present': len(version_info) > 0,
|
||||
'fields': version_info,
|
||||
'field_count': len(version_info),
|
||||
'has_company': 'CompanyName' in version_info,
|
||||
'has_product': 'ProductName' in version_info,
|
||||
'has_description': 'FileDescription' in version_info,
|
||||
'has_original_filename': 'OriginalFilename' in version_info,
|
||||
}
|
||||
|
||||
# ===== 11. OVERLAY =====
|
||||
overlay_offset = pe.get_overlay_data_start_offset()
|
||||
if overlay_offset:
|
||||
overlay_size = total_size - overlay_offset
|
||||
features['overlay'] = {
|
||||
'present': True,
|
||||
'offset': overlay_offset,
|
||||
'size': overlay_size,
|
||||
'pct': round(overlay_size / total_size, 4),
|
||||
}
|
||||
else:
|
||||
features['overlay'] = {'present': False, 'offset': 0, 'size': 0, 'pct': 0}
|
||||
|
||||
# ===== 12. HASHES =====
|
||||
features['hashes'] = {
|
||||
'md5': hashlib.md5(data).hexdigest(),
|
||||
'sha256': hashlib.sha256(data).hexdigest(),
|
||||
'imphash': pe.get_imphash(),
|
||||
}
|
||||
|
||||
# ===== 13. ANOMALY SCORES (composite) =====
|
||||
anomalies = 0
|
||||
anomaly_details = []
|
||||
|
||||
if fh.TimeDateStamp == 0:
|
||||
anomalies += 1
|
||||
anomaly_details.append('timestamp_zero')
|
||||
if pe.RICH_HEADER is None:
|
||||
anomalies += 1
|
||||
anomaly_details.append('no_rich_header')
|
||||
if oh.MajorLinkerVersion == 3:
|
||||
anomalies += 1
|
||||
anomaly_details.append('linker_v3_go')
|
||||
if bss_ratio > 50:
|
||||
anomalies += 1
|
||||
anomaly_details.append(f'extreme_bss_ratio_{bss_ratio:.0f}')
|
||||
if high_entropy_sections >= 5:
|
||||
anomalies += 1
|
||||
anomaly_details.append(f'many_high_entropy_sections_{high_entropy_sections}')
|
||||
if numeric_section_names >= 3:
|
||||
anomalies += 1
|
||||
anomaly_details.append(f'numeric_section_names_{numeric_section_names}')
|
||||
if symtab_size > 0:
|
||||
anomalies += 1
|
||||
anomaly_details.append(f'has_coff_symtab_{symtab_size}')
|
||||
if has_edata and data_dirs.get('EXPORT', {}).get('size', 0) == 0:
|
||||
anomalies += 1
|
||||
anomaly_details.append('phantom_edata')
|
||||
if not data_dirs.get('DEBUG', {}).get('size', 0) > 0:
|
||||
anomalies += 1
|
||||
anomaly_details.append('no_debug_directory')
|
||||
if not data_dirs.get('LOAD_CONFIG', {}).get('size', 0) > 0:
|
||||
anomalies += 1
|
||||
anomaly_details.append('no_load_config')
|
||||
if debug_like_size / total_size > 0.4:
|
||||
anomalies += 1
|
||||
anomaly_details.append(f'debug_sections_over_40pct')
|
||||
if has_get_proc_address and has_load_library:
|
||||
anomalies += 1
|
||||
anomaly_details.append('dynamic_api_resolution')
|
||||
|
||||
features['anomaly_score'] = {
|
||||
'total': anomalies,
|
||||
'details': anomaly_details,
|
||||
}
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def flat_features_for_csv(features):
|
||||
"""Flatten features dict into a single-level dict for CSV output."""
|
||||
flat = {
|
||||
'file': features['file'],
|
||||
'label': features['label'],
|
||||
'size': features['size'],
|
||||
}
|
||||
|
||||
# Header
|
||||
for k, v in features['header'].items():
|
||||
flat[f'hdr_{k}'] = v
|
||||
|
||||
# Rich header
|
||||
flat['rich_present'] = features['rich_header']['present']
|
||||
|
||||
# Sections aggregate
|
||||
for k, v in features['sections'].items():
|
||||
if k != 'details':
|
||||
flat[f'sec_{k}'] = v
|
||||
|
||||
# Imports
|
||||
for k, v in features['imports'].items():
|
||||
if k not in ('dlls', 'dll_names'):
|
||||
flat[f'imp_{k}'] = v
|
||||
|
||||
# Data directories
|
||||
for k, v in features['data_directories'].items():
|
||||
if isinstance(v, dict):
|
||||
for kk, vv in v.items():
|
||||
flat[f'dd_{k}_{kk}'] = vv
|
||||
else:
|
||||
flat[f'dd_{k}'] = v
|
||||
|
||||
# Strings
|
||||
for k, v in features['strings'].items():
|
||||
flat[f'str_{k}'] = v
|
||||
|
||||
# Byte stats
|
||||
for k, v in features['byte_stats'].items():
|
||||
flat[f'byte_{k}'] = v
|
||||
|
||||
# Go specific
|
||||
for k, v in features['go_specific'].items():
|
||||
flat[f'go_{k}'] = v
|
||||
|
||||
# Authenticode
|
||||
for k, v in features['authenticode'].items():
|
||||
flat[f'auth_{k}'] = v
|
||||
|
||||
# Version info
|
||||
flat['vi_present'] = features['version_info']['present']
|
||||
flat['vi_field_count'] = features['version_info']['field_count']
|
||||
for k, v in features['version_info'].get('fields', {}).items():
|
||||
flat[f'vi_{k}'] = v
|
||||
|
||||
# Overlay
|
||||
flat['overlay_present'] = features['overlay']['present']
|
||||
flat['overlay_size'] = features['overlay']['size']
|
||||
flat['overlay_pct'] = features['overlay']['pct']
|
||||
|
||||
# Anomaly
|
||||
flat['anomaly_score'] = features['anomaly_score']['total']
|
||||
flat['anomaly_details'] = '|'.join(features['anomaly_score']['details'])
|
||||
|
||||
return flat
|
||||
|
||||
|
||||
def main():
|
||||
import csv
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <sample_dir_or_file> [--baseline <vanilla.exe>] [--detections <json_file>]")
|
||||
sys.exit(1)
|
||||
|
||||
target = sys.argv[1]
|
||||
baseline_path = None
|
||||
detections_path = None
|
||||
|
||||
for i, arg in enumerate(sys.argv[2:], 2):
|
||||
if arg == '--baseline' and i + 1 < len(sys.argv):
|
||||
baseline_path = sys.argv[i + 1]
|
||||
if arg == '--detections' and i + 1 < len(sys.argv):
|
||||
detections_path = sys.argv[i + 1]
|
||||
|
||||
# Load detection labels if provided
|
||||
detections = {}
|
||||
if detections_path and os.path.exists(detections_path):
|
||||
with open(detections_path) as f:
|
||||
detections = json.load(f)
|
||||
|
||||
# Collect files
|
||||
if os.path.isdir(target):
|
||||
files = sorted([os.path.join(target, f) for f in os.listdir(target)
|
||||
if f.endswith('.exe') or f.endswith('.dll')])
|
||||
else:
|
||||
files = [target]
|
||||
|
||||
# Analyze baseline if provided
|
||||
baseline_features = None
|
||||
if baseline_path and os.path.exists(baseline_path):
|
||||
print(f"Analyzing baseline: {baseline_path}", file=sys.stderr)
|
||||
baseline_features = analyze_pe(baseline_path, "baseline")
|
||||
|
||||
# Analyze all samples
|
||||
all_features = []
|
||||
all_flat = []
|
||||
|
||||
for filepath in files:
|
||||
fname = os.path.basename(filepath)
|
||||
label = detections.get(fname, "unknown")
|
||||
print(f"Analyzing: {fname} (label: {label})", file=sys.stderr)
|
||||
features = analyze_pe(filepath, label)
|
||||
all_features.append(features)
|
||||
all_flat.append(flat_features_for_csv(features))
|
||||
|
||||
# Output full JSON
|
||||
output = {
|
||||
'samples': all_features,
|
||||
'baseline': baseline_features,
|
||||
'summary': {
|
||||
'total_samples': len(all_features),
|
||||
'feature_count': len(all_flat[0]) if all_flat else 0,
|
||||
}
|
||||
}
|
||||
|
||||
json_path = '/tmp/pe_analysis.json'
|
||||
with open(json_path, 'w') as f:
|
||||
json.dump(output, f, indent=2, default=str)
|
||||
print(f"Full analysis: {json_path}", file=sys.stderr)
|
||||
|
||||
# Output CSV
|
||||
if all_flat:
|
||||
csv_path = '/tmp/pe_analysis.csv'
|
||||
keys = list(all_flat[0].keys())
|
||||
with open(csv_path, 'w', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=keys)
|
||||
writer.writeheader()
|
||||
for row in all_flat:
|
||||
writer.writerow(row)
|
||||
print(f"CSV summary: {csv_path}", file=sys.stderr)
|
||||
|
||||
# Print summary comparison if we have detection labels
|
||||
labeled = [f for f in all_features if f['label'] in ('clean', 'detected')]
|
||||
if len(labeled) > 0:
|
||||
clean = [f for f in labeled if f['label'] == 'clean']
|
||||
detected = [f for f in labeled if f['label'] == 'detected']
|
||||
print(f"\n=== CLEAN ({len(clean)}) vs DETECTED ({len(detected)}) ===")
|
||||
|
||||
# Compare key metrics
|
||||
if clean and detected:
|
||||
compare_metrics = [
|
||||
('Section count', lambda f: f['sections']['count']),
|
||||
('Mean entropy', lambda f: f['sections']['mean_entropy']),
|
||||
('Max entropy', lambda f: f['sections']['max_entropy']),
|
||||
('High entropy sections', lambda f: f['sections']['high_entropy_count']),
|
||||
('Debug-like %', lambda f: f['sections']['debug_like_pct']),
|
||||
('BSS ratio', lambda f: f['sections']['bss_ratio']),
|
||||
('Code %', lambda f: f['sections']['code_pct']),
|
||||
('Symtab size', lambda f: f['sections']['symtab_size']),
|
||||
('Import DLLs', lambda f: f['imports']['num_dlls']),
|
||||
('Total imports', lambda f: f['imports']['total_functions']),
|
||||
('Suspicious APIs', lambda f: f['imports']['suspicious_api_count']),
|
||||
('File entropy', lambda f: f['byte_stats']['file_entropy']),
|
||||
('Null byte ratio', lambda f: f['byte_stats']['null_byte_ratio']),
|
||||
('String count', lambda f: f['strings']['num_strings']),
|
||||
('Printable ratio', lambda f: f['strings']['printable_ratio']),
|
||||
('Cert size', lambda f: f['authenticode']['cert_size']),
|
||||
('Anomaly score', lambda f: f['anomaly_score']['total']),
|
||||
('File size', lambda f: f['size']),
|
||||
]
|
||||
|
||||
from statistics import mean, stdev
|
||||
print(f"{'Metric':<25s} {'Clean mean':>12s} {'Det mean':>12s} {'Delta':>10s} {'Cohen d':>10s}")
|
||||
print("-" * 75)
|
||||
for name, extractor in compare_metrics:
|
||||
clean_vals = [extractor(f) for f in clean]
|
||||
det_vals = [extractor(f) for f in detected]
|
||||
if not clean_vals or not det_vals:
|
||||
continue
|
||||
cmean = mean(clean_vals)
|
||||
dmean = mean(det_vals)
|
||||
delta = dmean - cmean
|
||||
|
||||
# Cohen's d
|
||||
if len(clean_vals) > 1 and len(det_vals) > 1:
|
||||
cstd = stdev(clean_vals)
|
||||
dstd = stdev(det_vals)
|
||||
pooled = ((cstd**2 + dstd**2) / 2)**0.5
|
||||
d = abs(delta) / pooled if pooled > 0 else 0
|
||||
else:
|
||||
d = 0.0
|
||||
|
||||
flag = " ***" if d > 0.8 else " **" if d > 0.5 else " *" if d > 0.3 else ""
|
||||
print(f"{name:<25s} {cmean:>12.4f} {dmean:>12.4f} {delta:>+10.4f} {d:>10.3f}{flag}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,137 @@
|
||||
# Experiment Categories & Tracking Template
|
||||
|
||||
## Change Category Taxonomy
|
||||
|
||||
Before running any experiment, classify the proposed change. Category determines risk.
|
||||
|
||||
### Category A — Move Toward Toolchain Baseline (PREFERRED)
|
||||
|
||||
Changes that make the binary more consistent with a vanilla binary from the same toolchain.
|
||||
These reduce anomaly count without creating toolchain inconsistencies.
|
||||
|
||||
| Change | Target Feature | Expected Direction |
|
||||
|--------|---------------|-------------------|
|
||||
| Remove phantom .edata section | phantom_edata anomaly | -1 anomaly |
|
||||
| Revert stack reserve to Go default (0x200000) | stack_reserve inconsistency | -1 anomaly |
|
||||
| Prune enriched DLLs to ≤3 | unused_import heuristic | -1 engine (AVG/Avast) |
|
||||
| Remove risky DLL names (netapi32, ole32, winhttp) | AVG/Avast Evo-gen | Engine-specific |
|
||||
| Prune risky product names from VERSIONINFO pool | product name corpus | Engine-specific |
|
||||
| Prune risky file versions | version ML feature | Engine-specific |
|
||||
|
||||
**Expected behavior**: Lowers anomaly score, moves toward vanilla baseline delta.
|
||||
**Risk**: Low — making things look more like vanilla Go is safe.
|
||||
|
||||
### Category B — Structural Ratio Changes (MEASURE CAREFULLY)
|
||||
|
||||
Changes that affect key ratios ML models weight heavily. These require careful measurement
|
||||
because some ratio changes that look better on paper trigger different classifiers.
|
||||
|
||||
| Change | Target Feature | Risk |
|
||||
|--------|---------------|------|
|
||||
| Compress/strip debug sections | debug_like_pct | Medium — may expose other signals |
|
||||
| Split .data section to reduce BSS ratio | bss_ratio (208x → lower) | High — complex PE surgery |
|
||||
| Embed WASM in .text instead of debug sections | entropy distribution | Very High |
|
||||
| Reduce total file size | file entropy, size | Medium |
|
||||
|
||||
**Required measurement**: Run analyzer before and after to verify the ratio actually changed.
|
||||
Upload to VT only after confirming the feature shifted.
|
||||
|
||||
**The .text inflation anti-pattern**: Do NOT pad .text to improve code% ratio.
|
||||
ML detects .text SIZE anomaly (5.3MB vs typical 3.6MB), not just entropy.
|
||||
XOR padding also raises entropy from 6.12 to 6.99 — triggers Bkav/Trapmine.
|
||||
|
||||
### Category C — Fight Toolchain Identity (DANGEROUS — AVOID)
|
||||
|
||||
Changes that make the binary inconsistent with its actual toolchain. These create
|
||||
contradictory signals that ML classifiers interpret as evasion attempts.
|
||||
|
||||
| Change | Why It Fails |
|
||||
|--------|-------------|
|
||||
| Patch linker version 3.0 → 14.38 | Section layout, import style, strings still scream Go |
|
||||
| Add fake Rich header | Inconsistent with Go section naming and structure |
|
||||
| Change timestamp from 0 to a date | Go binaries naturally have timestamp=0; changing it is anomalous |
|
||||
| Strip .symtab | Go always has it; absence is inconsistent with the toolchain |
|
||||
| Strip numeric section names (/4, /19…) | These ARE the debug section names; removing breaks the binary |
|
||||
| Add fake DEBUG directory | Inconsistent with Go's lack of PDB path |
|
||||
| Add fake LOAD_CONFIG | Go binaries don't generate this; fake content is detectable |
|
||||
|
||||
**The consistency principle**: ML classifiers see a feature vector spanning dozens of
|
||||
dimensions. If 40 features say "this is Go" and 3 features say "this is MSVC", the
|
||||
inconsistency score rises — not falls. Every Category C change ADDS to the detection score.
|
||||
|
||||
## Experiment Tracking Template
|
||||
|
||||
Copy this table for each experiment batch. Update after receiving VT results.
|
||||
|
||||
```
|
||||
Batch: [name] Date: [date] Base Clean Rate: [X%] Samples: [N]
|
||||
|
||||
| Exp | Change | N | Clean | Rate | Delta | Anomaly | Vanilla Δ | Keep? |
|
||||
|-----|------------------------|----|-------|------|-------|---------|-----------|-------|
|
||||
| B | — baseline | 20 | 3/20 | 15% | — | 12 | 12 | — |
|
||||
| 1 | [change description] | 20 | ?/20 | ?% | ?% | ? | ? | ? |
|
||||
| 2 | [change description] | 20 | ?/20 | ?% | ?% | ? | ? | ? |
|
||||
```
|
||||
|
||||
**Minimum required columns**: Change, N, Clean, Rate, Delta. Anomaly score and Vanilla Δ
|
||||
come from the PE structural analyzer and are mandatory for structural experiments.
|
||||
|
||||
## A-Tier Validation Results (2026-04-08, n=10 per experiment)
|
||||
|
||||
Post-processing patches applied to existing builds. Baseline: 15% clean, 80% AhnLab, 60% Symantec.
|
||||
|
||||
| Experiment | Category | Clean% | AhnLab | Symantec | New Engines | Verdict |
|
||||
|-----------|----------|--------|--------|----------|-------------|---------|
|
||||
| **Baseline** (original 20) | — | 15% | 80% | 60% | — | — |
|
||||
| **remove-edata** | A (toward Go) | **0%** | 90% | **0%** | **+Bkav 90%** | WORSE — traded Symantec for Bkav |
|
||||
| **revert-stack** (→0x200000) | A (toward Go) | **0%** | 90% | **80%** | +Microsoft | WORSE — Symantec up, 0% clean |
|
||||
| **patch-linker** (→14.38) | C (fight identity) | 20% | 70% | 50% | — | Neutral (within noise) |
|
||||
| **set-timestamp** (→recent) | C (fight identity) | **0%** | 70% | 30% | **+AVG/Avast 50%, +Bkav 40%** | MUCH WORSE |
|
||||
|
||||
**Key findings:**
|
||||
- Every A-tier change made things **equal or worse** — confirms header tweaks don't help
|
||||
- Zeroing sections creates PE anomalies that trigger new engines (Bkav)
|
||||
- Adding timestamps to a Go binary (which naturally has timestamp=0) is catastrophic
|
||||
- Patching linker version was surprisingly neutral — AhnLab/Symantec weight structure over header
|
||||
- **Root cause is structural ratios** (BSS 208x, 50% high-entropy data, file entropy 7.44), not headers
|
||||
|
||||
## Proven Effective Changes (Historical)
|
||||
|
||||
| Change | Clean Rate Before | Clean Rate After | Engine Eliminated |
|
||||
|--------|-----------------|-----------------|------------------|
|
||||
| Cap enriched DLLs at 3 (was 3-5) | 56% | 96% | AVG/Avast (Win64:Evo-gen) |
|
||||
| Prune netapi32/ole32/winhttp | 25% | ~15% (signing needed) | AVG/Avast |
|
||||
| HashiCorp self-signing | 15% | 67% | CrowdStrike |
|
||||
| Product name pruning | 67% | 87% | Cynet, remaining AVG |
|
||||
| YARA kill (wazero type renaming) | n/a | n/a | YARA-based engines |
|
||||
|
||||
## VT API Helpers
|
||||
|
||||
```bash
|
||||
# Upload with rate limiting (16s gap for free tier)
|
||||
VT_KEY=$(cat /path/to/vt_apikey)
|
||||
upload_sample() {
|
||||
local file="$1"
|
||||
UPLOAD_URL=$(curl -s 'https://www.virustotal.com/api/v3/files/upload_url' \
|
||||
-H "x-apikey: $VT_KEY" | python3 -c "import sys,json; print(json.load(sys.stdin)['data'])")
|
||||
curl -s --request POST --url "$UPLOAD_URL" \
|
||||
--header "x-apikey: $VT_KEY" --form "file=@$file" > /dev/null
|
||||
sleep 16
|
||||
}
|
||||
|
||||
# Pull results by SHA256
|
||||
sha256() { command -v sha256sum >/dev/null && sha256sum "$1" | cut -d' ' -f1 || shasum -a 256 "$1" | cut -d' ' -f1; }
|
||||
get_result() {
|
||||
local file="$1"
|
||||
local sha=$(sha256 "$file")
|
||||
curl -s "https://www.virustotal.com/api/v3/files/$sha" -H "x-apikey: $VT_KEY" | \
|
||||
python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
r=d.get('data',{}).get('attributes',{}).get('last_analysis_results',{})
|
||||
dets={k:v for k,v in r.items() if v.get('category')=='malicious'}
|
||||
print(f'{len(dets)} dets: {sorted(dets.keys())}')
|
||||
"
|
||||
sleep 4
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
# PE Structural Analyzer
|
||||
|
||||
Comprehensive 13-category PE feature extractor for ML detection research.
|
||||
Covers everything EMBER uses plus Go-specific and Authenticode features.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Preferred: virtualenv
|
||||
python3 -m venv .venv && . .venv/bin/activate && pip install pefile lief
|
||||
|
||||
# Alternative on PEP 668 systems without a venv
|
||||
python3 -m pip install --break-system-packages pefile lief
|
||||
```
|
||||
|
||||
## Script Location
|
||||
|
||||
```
|
||||
./pe_structural_analyzer.py
|
||||
```
|
||||
|
||||
(Bundled alongside `SKILL.md` in this skill directory.)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Analyze directory of samples with vanilla baseline and detection labels
|
||||
python3 pe_structural_analyzer.py /tmp/samples/ \
|
||||
--baseline /tmp/vanilla_go.exe \
|
||||
--detections /tmp/detections.json
|
||||
|
||||
# Single file analysis
|
||||
python3 pe_structural_analyzer.py /tmp/sample.exe
|
||||
|
||||
# Outputs
|
||||
# /tmp/pe_analysis.json — full per-sample features (raw)
|
||||
# /tmp/pe_analysis.csv — flattened for spreadsheet analysis
|
||||
# stdout — clean vs. detected comparison table
|
||||
```
|
||||
|
||||
## Detection Labels File Format
|
||||
|
||||
```json
|
||||
{
|
||||
"sample-01.exe": "clean",
|
||||
"sample-02.exe": "detected",
|
||||
"sample-03.exe": "detected"
|
||||
}
|
||||
```
|
||||
|
||||
## Output: Clean vs. Detected Comparison Table
|
||||
|
||||
The analyzer prints Cohen's d for every numeric feature:
|
||||
|
||||
```
|
||||
Metric Clean mean Det mean Delta Cohen d
|
||||
---------------------------------------------------------------------------
|
||||
BSS ratio 208.3600 208.3600 +0.0000 0.000
|
||||
Mean entropy 5.7167 5.6953 -0.0214 0.973 ***
|
||||
Total imports 55.3333 53.9412 -1.3922 1.032 ***
|
||||
```
|
||||
|
||||
Stars: `***` d>0.8, `**` d>0.5, `*` d>0.3
|
||||
|
||||
## Output: Vanilla Baseline Delta
|
||||
|
||||
When `--baseline` is provided, the analyzer prints every feature where the sample
|
||||
differs from the vanilla binary:
|
||||
|
||||
```
|
||||
--- SECTIONS ---
|
||||
bss_ratio: GO=6.42 MOD=208.36
|
||||
debug_like_pct: GO=0.2722 MOD=0.4979
|
||||
has_edata: GO=False MOD=True
|
||||
|
||||
--- GO-SPECIFIC ---
|
||||
stack_reserve_is_go_default: GO=True MOD=False
|
||||
|
||||
--- ANOMALY SCORE ---
|
||||
GO: 9 anomalies
|
||||
MOD: 12 anomalies = [..., extreme_bss_ratio_208, phantom_edata, debug_sections_over_40pct]
|
||||
```
|
||||
|
||||
## Feature Categories Extracted
|
||||
|
||||
1. Header fields (linker version, timestamps, stack sizes, DLL characteristics)
|
||||
2. Rich header (presence, entry count)
|
||||
3. Section table (count, entropy stats, size ratios, flags, naming patterns)
|
||||
4. Import table (DLL count, function count, suspicious API flags, imphash)
|
||||
5. Data directories (which directories present, sizes)
|
||||
6. String features (count, lengths, URL/path/registry patterns, printable ratio)
|
||||
7. Byte statistics (file entropy, histogram distribution, null/FF ratios)
|
||||
8. Go-specific features (BSS ratio, symtab, linker fingerprint, stack reserve)
|
||||
9. Authenticode (signed, cert size, cert details)
|
||||
10. Resource features (presence, section details)
|
||||
11. Overlay (presence, size)
|
||||
12. Version info (fields present, content)
|
||||
13. Anomaly scores (composite count of structural deviations from toolchain norms)
|
||||
|
||||
## Building a Vanilla Baseline
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/vanilla && cd /tmp/vanilla && go mod init hello
|
||||
printf 'package main\nimport ("fmt";"os")\nfunc main(){fmt.Println("hello");os.Exit(0)}' > main.go
|
||||
GOOS=windows GOARCH=amd64 go build -o /tmp/vanilla_go.exe .
|
||||
```
|
||||
|
||||
Use a vanilla binary from the exact same Go version as your target binary.
|
||||
@@ -0,0 +1,120 @@
|
||||
# PE Structural Features: ML Classifier Taxonomy
|
||||
|
||||
What AV ML engines measure, organized by feature category. Use this to understand
|
||||
which features your changes affect and what the classifier sees.
|
||||
|
||||
## How ML Classifiers Work
|
||||
|
||||
Engines like Symantec ML.Attribute.HighConfidence and AhnLab MalwareX-gen extract a
|
||||
~200-2000 dimensional feature vector from each PE binary, then run a trained classifier.
|
||||
**No single feature triggers detection — the full vector is scored together.**
|
||||
|
||||
This means:
|
||||
- Individual "anomalies" may not matter if everything else looks clean
|
||||
- Multiple small anomalies that point in the same direction are detected with high confidence
|
||||
- Fighting the toolchain identity adds inconsistency signals that raise the score
|
||||
|
||||
## Feature Category Reference
|
||||
|
||||
### 1. Header Fields (High ML Weight)
|
||||
|
||||
| Feature | What ML Sees | Notes |
|
||||
|---------|-------------|-------|
|
||||
| `MajorLinkerVersion` | 3 = Go, 14 = MSVC, 2 = MinGW | Strong toolchain fingerprint |
|
||||
| `TimeDateStamp` | 0 = Go/reproducible builds | Epoch-zero is an anomaly but consistent with Go |
|
||||
| `SizeOfStackReserve` | Go default: 0x200000 | Changing from default adds inconsistency |
|
||||
| `DllCharacteristics` | 0x8160 = Go standard | Fine |
|
||||
| `CheckSum` | 0 = no checksum, nonzero = computed | We compute it — slightly inconsistent with vanilla Go |
|
||||
| Rich header | Absent in Go/Rust/MinGW | Absence correlates with non-MSVC toolchains |
|
||||
|
||||
**Key principle**: Do NOT patch header fields to look like a different toolchain. The section
|
||||
layout, import style, and string patterns still scream Go. Inconsistency = higher score.
|
||||
|
||||
### 2. Section Table (Highest ML Weight)
|
||||
|
||||
| Feature | Vanilla Go | Modified Build | ML Impact |
|
||||
|---------|-----------|-----------------|-----------|
|
||||
| Section count | 16 | 18 (+.rsrc, .edata) | Medium |
|
||||
| .data BSS ratio (virt/raw) | 6.4x | 208x | **Very High** |
|
||||
| Debug-like sections % | 27% | 50% | **High** |
|
||||
| Max section entropy | 7.998 | 7.999 | Low (both near max) |
|
||||
| High-entropy section count | 6 | 7 | Low |
|
||||
| Numeric section names (/4, /19…) | 8 | 8 (same) | Low |
|
||||
| .symtab present | Yes | Yes (4x larger) | Medium |
|
||||
| Phantom .edata (section + no export dir) | No | Yes | Medium |
|
||||
|
||||
**The 208x BSS ratio is the single largest structural anomaly.** It comes from Sliver's
|
||||
global variable allocation. Vanilla Go hello-world has 6.4x. This is load-bearing — cannot
|
||||
be easily changed without modifying the linker.
|
||||
|
||||
### 3. Import Table (High ML Weight)
|
||||
|
||||
| Feature | Vanilla Go | Modified Build |
|
||||
|---------|-----------|-----------------|
|
||||
| DLL count | 1 | 4 |
|
||||
| Total imports | 47 | 53 |
|
||||
| GetProcAddress present | Yes | Yes |
|
||||
| LoadLibraryExW present | Yes | Yes (duplicated) |
|
||||
|
||||
**Unused import heuristic** (AVG/Avast Win64:Evo-gen): DLLs with only 1-2 functions that
|
||||
are never called in the code trigger detection. Cap at 3 enriched DLLs max.
|
||||
|
||||
**Import hash (imphash)**: Each randomized build has a unique imphash — no hash-based blocking.
|
||||
|
||||
### 4. Byte-Level Features (Used by Symantec, AhnLab)
|
||||
|
||||
| Feature | Vanilla Go | Modified Build |
|
||||
|---------|-----------|-----------------|
|
||||
| File entropy | 6.85 | **7.44** |
|
||||
| Byte histogram std | 0.0114 | 0.0068 (more uniform) |
|
||||
| Null byte ratio | 17.6% | 10.6% |
|
||||
| Printable ratio | 33.0% | 35.5% |
|
||||
|
||||
Higher file entropy (7.44 vs 6.85) comes from the WASM payload in debug sections. The byte
|
||||
distribution is more uniform (lower std) — consistent with encrypted/compressed content.
|
||||
|
||||
### 5. String Features (Used by Most Engines)
|
||||
|
||||
| Feature | Vanilla Go | Modified Build |
|
||||
|---------|-----------|-----------------|
|
||||
| String count | 11,638 | 61,919 |
|
||||
| Path strings | 1,759 | 14,468 |
|
||||
| URL strings | 6 | 33 |
|
||||
| MZ header strings | 2 | 5 |
|
||||
|
||||
High string count and path count come from Sliver's large codebase. Hard to change.
|
||||
|
||||
### 6. Data Directory Completeness
|
||||
|
||||
Both vanilla Go and the modified build are missing:
|
||||
- `DEBUG` directory — legitimate software often has a PDB path here
|
||||
- `LOAD_CONFIG` — security cookies, guard flags, etc.
|
||||
- `TLS` — thread-local storage
|
||||
|
||||
These absences are shared with vanilla Go, so they don't add additional detection signal
|
||||
_relative to baseline_. Do not add fake directories — they create inconsistencies.
|
||||
|
||||
### 7. Go-Specific Anomaly Composite
|
||||
|
||||
The analyzer's anomaly score tracks deviations from standard PE norms. The modified build in
|
||||
the example below adds 3 anomalies beyond vanilla Go:
|
||||
- `extreme_bss_ratio_208` — .data 34MB virtual / 163KB raw
|
||||
- `phantom_edata` — .edata section exists but export directory is empty
|
||||
- `debug_sections_over_40pct` — 50% of file is high-entropy compressed data
|
||||
|
||||
Vanilla Go already has: timestamp_zero, no_rich_header, linker_v3_go, numeric_section_names,
|
||||
has_coff_symtab, no_debug_directory, no_load_config, dynamic_api_resolution, many_high_entropy_sections.
|
||||
|
||||
**Fixing the 3 extras is the highest-leverage structural target.**
|
||||
|
||||
## What Makes a Binary "Normal" to ML
|
||||
|
||||
ML classifiers are trained on millions of real Windows binaries. "Normal" means:
|
||||
- Consistent toolchain signals (same linker version, section layout, import style throughout)
|
||||
- Code+data sections dominate file size (not 50% compressed data)
|
||||
- BSS/virtual size ratio typical for the toolchain
|
||||
- Import DLLs actually used (no phantom imports)
|
||||
- No phantom sections (section exists but directory entry is missing/empty)
|
||||
|
||||
**The goal is not to look like a generic Windows binary. The goal is to look like a
|
||||
large, legitimate Go program.** Large Go programs exist and are clean on VT.
|
||||
Reference in New Issue
Block a user