Skip to content

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Windows PE Malware Static Analyzer 🦠🔍

Automated, portable static analysis tool, built for production environments (SOC / Blue Team / DFIR). This Python script dissects Windows binaries without ever executing them, then offers an assisted reverse engineering phase with an interactive console.

Three PE families are handled with their own specifics: executables (.exe), libraries (.dll, .ocx, .cpl, .ax — imports and exports, sideloading) including .NET assemblies, and kernel drivers (.sys — WDM/KMDF capabilities, BYOVD cross-check against known vulnerable drivers).

Zero new dependency: only lief and r2pipe (+ rizin/radare2 on the system).


🔀 The two-phase pipeline

This is v7's structural change: static analysis and reverse engineering are now two distinct steps, with two separate JSON reports.

                    ┌─────────────────────┐
   binary.exe   ───▶ │  PHASE 1 : STATIC   │ ──▶ report_<name>_<hash8>_static.json
                    └─────────────────────┘     (+ .yar if --yara)
                              │
                    [?] Launch reverse? ──── no ──▶ clean stop (code 0)
                              │ yes
                    ┌─────────────────────┐
                    │  PHASE 2 : REVERSE  │
                    │  auto → interactive │ ──▶ report_<name>_<hash8>_reverse.json
                    └─────────────────────┘     (+ .yar generated in session)

Important points:

  • The static report is written to disk BEFORE the reverse phase. If rizin crashes, if you hit Ctrl+C, or if you answer no, the static analysis work is already saved.
  • The two JSON files are distinguishable by their _static / _reverse suffix, in addition to the first 8 characters of the SHA256, which avoids collisions between samples sharing a name.
  • The reverse report references the static report (linked_static_report) and recomputes the suspicion score by folding in the reverse phase's signals (suspicion_delta gives the before/after score).
  • On a Ctrl+C during a long rizin analysis, the partial reverse report is still written ("interrupted": true, return code 130).

⚙️ What the tool does

Phase 1 — Static analysis

  1. Metadata & IOCs

    • MD5, SHA256, global entropy (packing detection).
    • ASCII / UTF-16 strings, with automatic Base64 decoding and single-byte XOR brute-force (bounded window).
    • IOCs: IPv4, IPv6, URLs, domains (filtered of false positives like kernel32.dll), emails, crypto wallets (BTC/ETH), registry keys, suspicious Windows paths, PowerShell/CMD commands.
    • extraction_stats block indicating whether any caps were hit (truncated) — the IOC list is never silently incomplete.
  2. Structural parsing (LIEF)

    • PE header: architecture, entry point, ImpHash, Rich Header fingerprint.
    • Compiled-in protections (ASLR, DEP/NX, CFG, SEH).
    • Regular and delay-load imports, cross-referenced against the behavioral capability table.
    • TLS callbacks (anti-sandbox), overlay, resources (version info, manifest, icon fingerprint).
    • Recomputed PE checksum compared against the declared field — algorithm reimplemented from public documentation, to be validated on a real sample before operational use (see warning in the code).
    • Best-effort Authenticode check (depends on the LIEF version).
    • exe / dll / driver classification, plus .NET assembly detection.
    • For a binary whose import table is silent (.NET, destroyed imports, dynamically-resolving driver), capabilities are recovered from strings and weighted down accordingly.
    • Managed .NET capabilities (DOTNET_MANAGED_CAPABILITIES): a .NET malware needs no P/Invoke at all to exfiltrate over HTTP, persist at startup, or encrypt files — it all goes through the BCL, whose type/method names live in plain sight in the metadata.
    • Signer identity extracted from the certificate (subject, issuer, validity) — a forged or stolen certificate is a major signal.
    • Export analysis (DLL/EXE): names, unnamed exports (ordinals), forwarded exports, fingerprint to group variants together. An executable exposing exports is itself abnormal (pattern of a DLL disguised as a .exe — found on an actual sample in the test set) and counts against it.
    • A DLL's execution surface: the exports Windows hosts call — DllRegisterServer (regsvr32), ServiceMain (svchost), CPlApplet (control.exe), rundll32 conventions, ReflectiveLoader.
    • Proxy DLL detection: when the majority of exports are forwarded to another library, that's the signature of DLL sideloading.
    • Packer detection via section signatures and a cluster of structural clues.
    • Heuristic suspicion score (0-100) — a triage aid, not a substitute for manual analysis.
  3. Kernel drivers (.sys)

    • Reliable classification via import of a library loadable only in kernel mode (ntoskrnl.exe, hal.dll, fltmgr.sys...) — far more robust than the on-disk extension or the subsystem alone, both of which are imitable. The IMAGE_FILE_DLL header flag is required as corroboration: a single forged import isn't enough to get a binary (mis)classified as a driver.
    • Dedicated kernel capabilities: notify routine tampering (PsSetCreateProcessNotifyRoutine and its removal — the signature of a kernel-mode "EDR killer"), handle protection callbacks (ObRegisterCallbacks), arbitrary kernel memory access (MmMapIoSpace, ZwMapViewOfSection...).
    • BYOVD cross-check: the binary's name is compared against a list of signed but vulnerable drivers, actually abused in attacks (cross-referenced with the LOLDrivers project). Checked first against internal metadata (OriginalFilename/InternalName from VERSIONINFO, PDB path) — which renaming the file on disk doesn't change.
    • nt_device_paths IOC: \Device\.../\DosDevices\... paths on the kernel side, \\.\Name form on the usermode side — this is how a loader talks to the vulnerable driver it just loaded.

Phase 2 — Reverse engineering (Rizin + Ghidra)

Automatic pass:

  • Xrefs to sensitive APIs (including via the IAT).
  • Decompilation of the entry point and of the functions referencing the most distinct sensitive APIs, with the ranking exposed in priority_functions (selection_reason: "api_xref").
  • Complexity-based safety net: on a packed or obfuscated sample — the majority of the real test set —, the import table is nearly empty and the API signal barely picks out any function; the analyst previously received only the entry point, almost always just the unpacking stub. With remaining budget, the largest functions (by basic block count) fill out the selection (selection_reason: "complexity_fallback"), size-capped so as not to pick a function doomed to fail (see below).
  • Lightweight CFG (blocks + edges) for these functions.
  • Time-bounded decompilation, independent of the underlying engine: on an actual Go binary from the test set, a function with 550+ basic blocks (massive Go runtime inlining) had pdg running for over 90s without ever finishing. r2ghidra.timeout — the native mechanism meant for this role — proved unreliable inside a container, making decompilation go silent altogether once set. An independent Python guard kills the underlying rizin process if a command exceeds its deadline (pdg, agfj, and aaa itself).

Interactive console — the rizin session stays open, so the aaa analysis is never replayed: this is where the real time savings happen on a large sample.

Command Effect
l [filter] [n] List functions (sorted by size)
top Recall the function ranking from the auto pass
exp [filter] List exports, execution surface listed first
d <func|0xADDR> Decompile and add to the report
s <func|0xADDR> [n] Disassemble n instructions (default 80)
g <func|0xADDR> Extract the CFG and add it to the report
x <API> Cross-references to an API
str [n] Strings from the data sections
str all [n] Strings from the whole file (noisy: headers, code)
pick <text> Mark a string as a signal for the YARA rule
picks Show marked strings
note <text> Timestamped analyst note
y Generate a YARA rule (marked strings take priority)
sum Session summary
? / q Help / quit and write the JSON

Everything you do in the console is logged in interactive_session: commands, UTC timestamps, decompilations, CFGs, xrefs, notes. Free analysis traceability.

Optional

  • YARA rule (--yara) from extracted IOCs/strings — to be manually refined. Validated during calibration with the official yara engine (yara rule.yar sample) against their source sample: the tool isn't required for analysis, useful for anyone wanting to reproduce that check.
  • VirusTotal hash lookup, disabled by default (an OPSEC choice). Prefer the VT_API_KEY environment variable: a key passed as an argument is readable by any user on the machine via ps aux.
  • Multi-file/folder batch mode with parallelization (--recursive, --workers).

🛠️ Requirements & Installation

Unchanged.

Step 1: Python and Git

sudo apt update
sudo apt install python3 python3-venv git

Step 2: Radare2 (from source)

git clone https://github.com/radareorg/radare2
radare2/sys/install.sh

Step 3: Ghidra decompiler (via r2pm)

r2pm -U
r2pm -ci r2ghidra

Step 4: Python virtual environment

cd /path/to/the/project/folder
python3 -m venv env
source env/bin/activate

Step 5: Python dependencies

pip install -r requirements.txt

🚀 Usage

Golden rule, unchanged: activate the venv before every run (source env/bin/activate).

Simple case — static, then you're asked

python3 win_malware_analyzer.py la_meuh.exe

Static only (quick triage)

python3 win_malware_analyzer.py la_meuh.exe --reverse skip

Chain everything with no prompt and no console (CI, scripts)

python3 win_malware_analyzer.py la_meuh.exe --reverse auto --no-interactive

An entire folder (batch mode, parallelized)

python3 win_malware_analyzer.py /path/to/samples_folder --recursive

Options

Option Effect
-o, --output-dir DIR Output folder for reports (default: current folder)
-r, --recursive Walks subfolders in batch mode
--reverse ask|auto|skip ask (default): prompts after the static phase — auto: chains automatically — skip: static only
--skip-reverse Legacy alias for --reverse skip
--no-interactive Automatic reverse without opening the console
--max-functions N Functions decompiled in addition to the entry point (default 15)
--no-cfg Disables lightweight CFG extraction
--yara Generates a .yar rule alongside the static report
--vt-key KEY VirusTotal lookup (default: VT_API_KEY)
--timeout S rizin / network timeout (default 30s)
--workers N Parallel processes in batch mode
-v / -vv Verbosity (INFO / DEBUG)

Return codes

Code Meaning
0 Success
1 Target not found / no file to analyze
2 Failure on at least one file
130 Reverse interrupted from the keyboard (partial report is written)

Non-interactive behavior

The prompt never shows if stdin isn't a TTY (cron, pipe, CI) nor in batch mode: ask then degrades to auto, and no worker touches stdin. Your existing scripted usages don't change.

Batch mode is determined by the nature of the target (a folder), not by the number of files found: a folder containing a single sample is still processed as a batch.


🔒 Security notes

The tool handles inherently hostile binaries; several safeguards are in place:

  • Symlinks refused. The target isn't followed if it's a link — resolve it before passing it as an argument. The refusal is applied upstream, so it also covers LIEF and rizin, which open the file by its path.
  • FIFOs, devices, sockets and directories refused (opened with O_NONBLOCK then S_ISREG checked).
  • rizin command injection. Symbol names come from the analyzed binary, i.e. from the attacker. Known functions are targeted by numeric address, never by name; manually entered targets are filtered through a strict allowlist that rejects rizin metacharacters (@ temporary seek, $ variables, ;, backticks, pipes...). Accepted consequence: a genuinely mangled symbol (MSVC, stdcall _foo@8) isn't reachable by name — use its address instead (d 0x401500).
  • TOCTOU. If the file changes between the static and reverse phases, the reverse phase is aborted: the report's hashes would no longer be trustworthy.
  • Atomic report writing (temp file + os.replace): no half-written JSON on a Ctrl+C, a full disk, or two batch workers landing on duplicate samples.
  • Anti-DoS bounds: caps on the number of strings analyzed, IOCs per category, session artifacts, and a time budget on IOC extraction. When a cap is hit, it's stated plainly in extraction_stats.truncated.
  • Driver classification not spoofable by a single isolated signal. A forged import to ntoskrnl.exe (never actually resolved) is no longer enough to pass an ordinary binary off as a kernel driver and thereby disable the scoring rules that apply to executables/DLLs — the IMAGE_FILE_DLL header flag, which a real driver always carries, is required as corroboration.
  • Domain filters by boundary, not by substring. "Boilerplate" exclusions (PKI infrastructure, .NET XML namespaces, Go/Rust/Node package registries) check a label boundary rather than a plain in: a C2 named evil-w3.org-c2.net is no longer wrongly filtered out, and tempuri.org.attacker.net is no longer treated as harmless noise.
  • Analysis run in an isolated environment (recommended): although the tool never executes the sample, it does have LIEF and rizin parse its bytes — two parsers themselves exposed to hostile input. This project's real test set is systematically analyzed inside a disposable Docker container, network disabled.

📊 Calibration

v8.4 batch — 26 real samples (MalwareBazaar)

A deliberately heterogeneous and difficult set: 6 real kernel drivers (.sys), one Go binary, several binaries signed with a valid certificate (signature-abuse technique), one Lazarus Group DLL, and the rest ordinary EXE/DLL (droppers, loaders, agents). Analyzed inside a disposable Docker container, network disabled, archive password infected.

Score Count
minimal (< 15) 1 / 26 see "what the score isn't" below
low (15-34) 7 / 26 including one sample fully packed, zero resolved imports
medium (35-59) 12 / 26
high (≥ 60) 6 / 26 including the 4 drivers with the richest kernel capabilities
  • All 6 drivers in the set are signed and correctly classified driver (scores 17 to 100/100 depending on exposed kernel capabilities). Two files carrying the .sys extension but structurally ordinary GUI DLLs (no kernel-only import) remain correctly classified executable — non-regression verified after the anti-spoofing hardening of the classification.
  • Two confirmed false negatives, fixed mid-loop: a managed .NET malware with not a single P/Invoke (9/100 → DOTNET_MANAGED_CAPABILITIES added) and a DLL disguised as a .exeDllMain dispatcher visible under reverse, two non-standard exports (7/100 → 17/100 after adding the "executable exposing exports" rule).
  • Auto-generated YARA rules validated by the official yara engine against their source sample: 26/26, across three rounds of fixes.
  • The only minimal sample (6/100) is a .NET application whose strings reveal no offensive capability in the strict sense (name, resources and control flow of an ordinary utility): consistent with the documented limitation below — a payload whose behavior is entirely resolved elsewhere (a remote second stage, for instance) escapes static analysis by construction.

v8.2 batch — 25 real binaries (EXE/DLL, historical)

22 malicious (ransomware, droppers, loaders, RATs, signed Go payloads, packed agents, injection DLLs, .NET AMSI patchers) and 3 clean, including a signed Microsoft installer and a utility wrongly flagged by about twenty antivirus engines.

Result
Malicious, "high" 3 / 21
Malicious, "medium" or above 18 / 21
Clean binaries 0, 9 and 29 / 100

Only one malicious sample scores below the highest-scoring clean binary. The remaining false negatives are a 7-Zip self-extractor, a Delphi packer, and a 37 MB Electron application whose only visible capability is BCryptEncrypt — the latter remains out of reach of purely static analysis.

What the score isn't. A triage-aid heuristic, not a verdict. A high score calls for manual analysis; a low score clears nothing. Every point is justified by a line in suspicion.reasons, precisely so the analyst can contest the reasoning.

⚠️ Known limitations

  • Format: PE only (.exe, .dll, .ocx, .cpl, .sys). An MSI, an MSIX, or an archive comes back with score: null and level: non_applicable — the tool doesn't claim to analyze them.
  • The score depends on what the binary exposes. A payload whose capabilities are entirely resolved at runtime (or delivered by a remote second stage) can't be characterized statically; the tool then flags the packing rather than the behavior, or flags nothing at all if the binary itself shows nothing.
  • Data read from the binary is attacker-controlled — section names, export names, certificate subject, declared import table. Heuristics relying on it are cross-checked (certificate issuer, redirection targets, addresses rather than symbol names, header flag corroborating an import for driver classification), but that cross-checking has its limits.
  • Calibration reflects its test set. 25, then 26 binaries is a solid basis, not a guarantee. Families not yet covered — Rust infostealer, userland rootkit, MSIX container — haven't been tested.
  • The known-vulnerable-driver list (BYOVD) is a curated list, not the full LOLDrivers database: a name match is a strong clue, its absence doesn't clear a driver unknown to the list.
  • Authenticode signature is checked via LIEF, without cross-referencing the system's certificate store: a self-signed chain can be internally consistent. This is why the issuer is checked against a list of recognized authorities.

📄 License and usage

Static analysis tool: no sample is ever executed at any point. Reports are written with 0600 permissions — they contain code extracted from malicious binaries. VirusTotal lookup is disabled by default: enabling it sends the sample's fingerprint to a third party.

About

A Python script that automates static analysis, yara analysis and reverse-engineering on Windows (exe, sys, dll) binaries!

Topics

Resources

Stars

8 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages