All projects

MalWhere

A local agent that triages Windows executables. A language model picks analysis tools one at a time, reads each result, and writes a verdict with its reasoning visible.

Context
Exploratory personal project, 2026
Role
Design and implementation, sole author
Stack
Python, FastAPI, llama.cpp server, LightGBM (EMBER), YARA, SQLite, React
Scope
Static analysis only; no sample is executed
Diagram of MalWhere's loop: an uploaded file is hashed and checked against a SQLite cache; on a miss the local language model asks for one of nine tools at a time, each trimmed result goes back to it, and after at most ten turns it writes a report that streams to the dashboard.

A language model drives the triage.

I wanted to see what happens when a language model drives malware triage instead of a fixed script. You upload a Windows .exe or .dll. MalWhere hashes it, checks a cache, and then lets a local model call analysis tools one at a time.

Each result goes back to the model, which decides what to ask for next and finally writes a verdict, a confidence score, the MITRE ATT&CK techniques it saw and the indicators of compromise. Nothing ever runs the sample: every tool reads its bytes.

The project in 30 seconds. The trace and the report are the real output for one benign sample.

One investigation, step by step.

MalWhere: one upload, nine tools, at most ten turns An uploaded file is hashed and checked against a SQLite cache. On a miss, the language model asks for one of nine tools at a time; the app runs the tool, trims the result and adds it to the conversation. After at most ten turns the model writes a report, which streams to the dashboard with every step and is stored under the file's hash. One upload, nine tools, ten turns MalWhere reads a Windows executable without ever running it. Upload .exe or .dll, up to 10 MB SHA-256 + cache SQLite, keyed by file hash miss hit: stored report, no model call ReAct loop, at most 10 turns Local LLM llama.cpp server Run the tool read, never execute Trim result 200 chars, 10 items tool + reason back to the LLM final_report verdict, confidence, ATT&CK, IOCs done, or forced at turn 10 Nine tools On the file, read-only get_pe_info analyze_imports scan_section_entropy extract_strings scan_yara ml_risk_score visualize_pe headers 16 risky APIs packing URLs, IPs, keys community rules EMBER LightGBM byte image Threat feeds, over the network hash_lookup VirusTotal, MalwareBazaar only if it found detections threat_intel_lookup OTX, ThreatFox Dashboard one card per step as it happens, then the verdict, ATT&CK tags, IOCs and the byte image
Every claim in this diagram maps to the code: the API, the loop and its ten-turn cap, the prompt's tool order, and the nine tool modules.
  1. Hash and check the cache

    The API reads the upload, takes its SHA-256 and looks it up in SQLite. A hit returns the stored report with no model call.

  2. Ask the model

    On a miss the loop starts. The model replies with one JSON object: a tool name, its arguments and a one-sentence reason.

  3. Run the tool and stream the step

    The app runs that tool and pushes the step to the dashboard straight away, as a server-sent event.

  4. Trim the result for the model

    A trimmed copy goes back into the conversation: long strings cut to 200 characters, lists to 10 items, and the byte image left out. The dashboard keeps the full result.

  5. Repeat, ten turns at most

    If the model has not finished by turn ten, it is told to write its report from what it has.

  6. Store the report

    The final report is saved in SQLite under the file's hash, so the same file never costs a second run.

Nine tools. Seven read the file, two ask the network.

Every tool returns plain JSON. The seven local ones parse the file with pefile, regular expressions, YARA and a LightGBM model; none of them executes it.

get_pe_info
Hashes, sections, compile time and entry point.
analyze_imports
The import table, with 16 risky Windows APIs mapped to ATT&CK techniques.
scan_section_entropy
Shannon entropy per section. Above 7.0 is flagged as likely packed, above 7.5 as encrypted or compressed.
extract_strings
Printable strings sorted into URLs, IP addresses, paths, registry keys and suspicious keywords.
scan_yara
Community YARA rules, compiled one file at a time so a broken rule does not stop the scan.
ml_risk_score
EMBER features through a pre-trained LightGBM model, scored from 0 to 1.
visualize_pe
The file's bytes as a 512-pixel-wide grayscale image, for the person reading the report.
hash_lookup
VirusTotal and MalwareBazaar. A hash counts as known malware only if MalwareBazaar has it, or VirusTotal shows at least 5 detections and at least 10% of engines.
threat_intel_lookup
AlienVault OTX and ThreatFox, called only after the hash lookup found detections.

Where the model decides, and where it doesn't.

The system prompt sets most of the order: file metadata first, then the hash lookup, then every static tool, then the EMBER score. The one real branch is the deeper threat-intel lookup, which depends on what the hash lookup found.

Within that, the model chooses each call, passes the arguments, explains every step in a sentence, and weighs all of the evidence into a verdict. Giving the loop more freedom is the obvious next experiment.

I have not measured detection accuracy, so I make no claim that the verdicts are right. The reports are model output, and the trace is there so a person can check them.

Keeping a small local model usable.

  • Local models wrap or mangle their JSON. The loop strips tool-call tokens and code fences before parsing, and asks again when parsing still fails. Each failed turn counts toward the ten.
  • An unknown tool name gets a reply listing the valid tools. A tool that crashes becomes an error result the model can read, and the loop carries on.
  • EMBER has no fallback. If the model file or package is missing, the tool returns an error rather than a made-up score. Getting the old EMBER package to run on current libraries took small shims for NumPy, LIEF and scikit-learn.
  • The VirusTotal gate exists because one or two detections on a clean file are common.

What the analyst sees.

The dashboard is a React app reading the event stream. Steps appear as they finish, so a slow threat-feed call is visible as a slow step rather than a frozen page; if nothing arrives for a minute, the server sends a heartbeat.

When the report arrives it shows the verdict and confidence, the model's summary, ATT&CK tags, indicators you can copy with a click, and the byte image.

The model never sees that image. It is there for the analyst, who can spot packed or encrypted regions in it at a glance.

Limits.

  • Static only. Nothing runs the sample and there is no sandbox. Parsing an untrusted file can still carry risk, so the backend belongs in a container or a virtual machine.
  • No accuracy numbers. I have not built a labelled test set, so the verdicts are unmeasured.
  • It runs locally. The dashboard talks to a backend on localhost, and the threat feeds need API keys; without them a lookup reports "skipped".
  • Only .exe and .dll files, up to 10 MB.