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