Capabilities over signatures: the design behind a .blend inspector, and the provenance layer that sidesteps the fight
The technical companion to The weight of a hyphen. Two questions you can ask of a file that runs itself, what will it do and who made it, decompose into a detection problem you can only draw and a provenance problem you can win. The capability model that keeps a static inspector from drowning in false positives, the four-move evasion arms race and the four answers, and the signing math that makes a pirated copy fail by arithmetic. Architecture and decisions, not source.
This is the technical companion to The weight of a hyphen. That post was the incident: a friend's Blender community bleeding accounts, a lookalike domain one character off, and a file format that runs code the moment you open it. This one is the architecture of the two tools that came out of it, and more usefully, the decisions behind them, because the decisions are the part you can actually reuse.
Start with the only framing that matters. There are two questions you can ask of a file that is capable of executing itself. The first is what will this do when I open it? The second is is this genuinely the file its author made, unchanged? They sound adjacent. They are not. The first is a detection problem, undecidable in the general case and an arms race in every specific one, a fight whose best outcome is a draw. The second is a provenance problem, and provenance is a fight you win with arithmetic. Almost every good decision in this project came from keeping those two questions apart and refusing to let the easy one masquerade as the hard one.
Where code hides in a file that isn't code
A .blend is not a document with an unfortunate macro feature bolted on. Executable Python is woven through the format by design, for legitimate reasons, and that is exactly what makes it hard to reason about. Before you can inspect anything you have to enumerate the places code can live and the conditions under which each one runs.
There are four that matter. Text datablocks can be flagged to register on load, which runs them as modules the moment the file opens. Driver expressions are Python, evaluated whenever the dependency graph updates, which is to say constantly and early. Application handlers attach functions to lifecycle events like load-post, and those functions come from the registered text blocks. And OSL script nodes carry shader code, a narrower surface but not a non-existent one. Four vectors, four sets of trigger conditions, and a single global preference, "Auto Run Python Scripts," that gates whether the first three fire automatically.
The design decision that falls out of this is the one most people get wrong: you inspect statically, and you treat "statically" as a hard constraint, not a convenience. The inspector never executes the thing it is inspecting, ever, not in a sandbox, not behind a flag, not "just the safe parts." This is not purism. The campaign this came from specifically targets physical machines with real GPUs and is built to notice when it is running somewhere disposable. The only safe way to read a hostile .blend is to never let it run, which means parsing data structures and reasoning about code as text, and giving up entirely on the one thing dynamic analysis is good at, which is watching what actually happens. You trade certainty for safety. For a tool that runs on an artist's working machine, on a file they are about to open anyway, that trade is not close.
Why not signatures
The obvious way to build a .blend scanner, and the way it has been built before, more than once, is a list of known-bad strings. Match discord.com/api/webhooks, match subprocess, match the byte pattern of last month's loader, raise a flag. It is easy, it ships in a weekend, and it is a treadmill you lose in slow motion. The people writing the malware get a vote, their vote is obfuscation, and a signature scanner hands them a precise specification of exactly what they need to rewrite. The first variant that splits a string defeats it.
So the first real decision was to not build that, and to be honest about why the alternative is harder. The problem with matching strings is not only that attackers evade it. It is that it conflates two completely different things: a token appearing in a file, and a capability being exercised by the code. import os appears in a staggering fraction of perfectly innocent Blender add-ons, because os.path is how you build a file path. A scanner that treats the presence of os as suspicious is not a security tool, it is a false-positive generator with a security theme, and false positives are the thing that actually kills a defensive tool. Nobody uninstalls a scanner for missing malware they never knew about. They uninstall it the third time it screams at a file they know is fine.
Capabilities, not tokens
The model the engine settled on is to score capabilities, not strings, and to weight the verb rather than the noun. Importing os is nothing. Calling os.system is something. Holding the word subprocess is nothing. Calling subprocess.Popen is something. Reaching a URL is context; reaching a URL that is a Discord webhook is, on its own, damning, because legitimate rigging code has no reason to talk to a chat platform's message API.
Concretely, every signal is sorted into one of a few classes. Context signals are things that are common in benign code and meaningful only in combination: a file write, a hardcoded URL, the presence of a decode call. Strong signals are actual dangerous capabilities: dynamic execution, a spawned process, a network send, native memory access through ctypes. Critical signals are the handful of things that are damning essentially on sight: a webhook exfiltration endpoint, a credential-store path, a known loader pattern. And then the rule that does most of the false-positive work: a context signal can never, by itself, push a verdict above informational. A file that writes to disk and contains a URL is an add-on. A file that writes to disk, contains a URL, and spawns a shell is a problem. The severity is not a sum of weights, which is how you get a benign file with six mild signals scored as malicious. It is a count of distinct strong capabilities, gated so that the weak signals can color a verdict but never carry it.
The measure of whether this works is not how much malware it catches. It is how little benign code it flags, because that number is the one that determines whether anyone keeps it installed. The engineering target was a high catch rate on a corpus of evasion samples and a false-positive rate at or near zero on a corpus of real, gnarly, legitimate add-ons that do genuinely alarming-looking things for good reasons. You do not get to claim the first number until you have earned the second.
The arms race, in four moves
The interesting work is all in the gap between what a payload is and what it looks like, because that gap is where the attacker lives. There are four moves they make, and the engine answers each one with a deliberately boring, compounding mechanism. None of the four is clever. Cleverness is not what wins here. Coverage is.
Move one: break up the words. A payload does not write subprocess, it writes "sub" + "process", or it builds the string with chr() arithmetic, or it reverses it at runtime. The answer is a normalization pass that runs before anything else looks at the text: collapse adjacent string concatenation, resolve hex and octal and unicode escape sequences into the characters they denote, and then scan both the original and the normalized form. You are not trying to execute the construction, you are trying to flatten the cheapest disguises so the later passes see through them.
Move two: encode the payload. Base64 a blob, then base64 it again, or stack base85 on hex on zlib, and decode it at runtime with exec. The answer is a recursive decoder that tries the whole family of encodings, base64 and its url-safe and 85 and 32 variants, hex, rot13, zlib, gzip, decodes a layer, and re-runs the entire analysis on what falls out. If the decoded thing contains a dangerous capability, the original is marked critical by transitivity. This is the one place you have to be careful about your own safety, because a recursive decoder pointed at attacker-controlled input is a decompression bomb waiting to happen, so the recursion is bounded in depth and the total decoded volume is capped. The defense against the payload and the defense against the payload weaponizing your own decoder are the same line of code.
Move three: hide the blob where you can't decode it. Sometimes you cannot decode the string, because the key is fetched at runtime or the scheme is custom. You are not beaten, because encoded payloads share a property that plaintext does not: they look random. A long string literal with high Shannon entropy and a base64-ish or hex-ish alphabet is, overwhelmingly, not English and not a file path. The engine scores entropy on the candidate strings it cannot open, and a high-entropy opaque blob raises its hand even though nobody knows what it says. You flag the shape of concealment when you cannot reach the thing concealed.
Move four: lean on formatting. Whitespace, comments, a token sitting inside a string literal so a regex matches it in a place where it is inert. The answer is to stop trusting regexes for the verdict and parse the code into a syntax tree. An AST pass confirms that exec is actually being called, that os.system is a call and not a substring in a docstring, that a getattr is reaching into __import__. It both catches things the text scan misses and, just as importantly, lets a real call outweigh the same word appearing somewhere harmless. Structure beats string.
Drivers get their own stricter track on top of all of this, because the honest baseline for a driver is different. A slider that drives a bone is allowed to do arithmetic and nothing else. There is no legitimate reason for a driver expression to import a module, call a shell, or reach into builtins, so in driver context those are not weighed against mitigating factors, they are simply disqualifying.
And then the part I insist on stating in every version of this, because leaving it out is its own kind of lie: none of this wins outright. An attacker who constructs the entire payload at runtime, assembling the malicious logic only after execution has already begun, walks past every static inspector that has ever been written, including this one. Static analysis cannot decide what a program will do; that is not a limitation of effort, it is a theorem. What the engine can do is make the commodity ninety-five percent loud, make evasion expensive enough to filter out everyone who is not specifically targeting this ecosystem, and, above all, stop the default behavior's lie by omission, where you are warned that a file wants to run code and never shown what the code is. The goal was never an oracle. It was to make the trust decision an informed one and to keep the floor from being a trapdoor.
Tests are the product
A detection engine that has not been measured is a vibe. The discipline that made this one real was a pair of corpora and a refusal to trust my own judgment about whether a change helped.
One corpus is evasion: every trick above, rendered as a concrete sample, each one a payload that is dangerous in shape but inert in effect, so the suite is safe to run anywhere. The other is benign: real add-on patterns that do scary-looking things for innocent reasons, file exporters, update checkers, rigs full of registered Python, mesh scripts with long integer arrays that look just enough like an encoded blob to be a trap. Every change gets scored against both. The first hardening pass took the evasion corpus from a third of samples rated dangerous to all of them, and the benign false positives from one to zero, and I only believed those numbers because the harness printed them every time.
I will confess the least heroic hour, because the unglamorous half is the half worth writing down. For a stretch I was convinced a major rewrite of the ruleset had changed nothing, staring at output byte-identical to the version before it. The engine was fine. The test runner was loading a stale compiled cache of the previous version and had never executed the new code at all. The hardest bug in a cryptography-and-parsing project was a filesystem quietly serving yesterday's bytecode. Measure the thing you think you are measuring.
The disk path, and the discipline of admitting you can't read
There are two ways to inspect a file: while it is open in the application, where you have a fully parsed object graph to walk, and on disk before you ever open it, which is the more useful and the harder one. The naive on-disk approach is to treat the file as a blob of bytes and scan the whole thing, which works and also drowns you in noise, because a .blend is mostly binary scene data and only incidentally text.
The better approach is to parse the container. A .blend is a header followed by a sequence of typed blocks, and the script text lives in specific block types. Walking that structure lets you pull the text out of the blocks that hold it and ignore the megabytes of mesh data that do not, which both cuts false positives and makes the scan tractable on large files. The parser is defensive, because it is reading hostile input, and it falls back to a whole-file text extraction when the structure is malformed or unfamiliar rather than giving up.
The decision in this section I am most attached to is the smallest one. Modern .blend files are usually compressed, and the on-disk scanner cannot always decompress them without a library it deliberately does not bundle. The wrong behavior, the tempting behavior, is to scan what you can and report clean. The right behavior is to report that you could not fully read the file and say so in those words. A security tool that returns "clean" when it means "I couldn't look" is worse than no tool, because it manufactures false confidence at exactly the moment confidence is unwarranted. Never let "I didn't see anything" render as "there is nothing there."
The pivot: from what to who
Everything to this point is the floor. It is worth building, and it is, structurally, a fight you can only draw, because the attacker always gets the last move. At some point you have to stop trying to out-clever the obfuscation and ask the better question, the second of the two from the top: not is this code bad, but is this genuinely the file its author made, unchanged. You will never reliably answer the first. The second is a solved problem in cryptography wearing a Blender costume.
Signing, and why it is the part that actually wins
The mechanism is public-key signatures, and the whole reason it works is an asymmetry. A keypair has a private half that can produce a signature over some data and a public half that can verify the signature but cannot produce one and cannot be worked backward into the private half. So a valid signature proves two things simultaneously: the data was vouched for by whoever holds the private key, and the data has not changed by a single byte since, because any change invalidates the signature.
A few design decisions make that primitive into something a course author can actually use.
You do not sign the files. You sign a manifest of their hashes. Each file gets reduced to a SHA-256 digest, a fingerprint with the avalanche property, flip one bit anywhere in the file and the digest changes completely and unpredictably. The manifest is the small list of those digests, and the author signs the manifest once. That single signature now covers an entire release by reference: to alter any file is to change its digest, which changes the manifest, which breaks the signature. One cheap operation, transitive integrity over arbitrarily many large files.
The primitive is Ed25519, and the choice is deliberate. It has small keys and signatures, it is fast, and it is deterministic, which matters more than it sounds, because a deterministic signature scheme is testable: the same input always yields the same output, so you can check an implementation against a known answer. It also sidesteps the two classic ways to hold a signature scheme wrong, RSA's padding choices and ECDSA's catastrophic sensitivity to nonce reuse, by simply not having those knobs. Fewer ways to be wrong is a security property.
The implementation is dependency-free, on purpose, because it has to run inside an application's bundled Python with no ability to install anything, and because a signing tool that drags in a pile of native dependencies is a supply-chain liability wearing a supply-chain-security costume. Writing your own cryptography is, correctly, a thing people warn against, so the discipline that makes it acceptable is to refuse to trust it: the implementation is validated by making a mature reference library agree with it byte for byte, in both directions, same keys, same signatures, on top of the standard test vectors. I do not trust my own crypto. I made the reference library certify it, and that is the only reason it ships.
The trust model is explicit, and that is a feature. There is no central authority, no certificate hierarchy, no automatic trust of anything. An author publishes their public key over a channel their audience already trusts, the course site over HTTPS, a pinned message in the community, and each member adds that key once, deliberately, the way you save a number from a person you actually know. Verification then requires three independent things to all hold: the signature is valid for the attached key, that key is one you chose to trust, and the file in front of you has a digest that appears in the signed manifest. Pass all three and the file is genuine. The failure modes are not error noise, they are the diagnosis: a digest that is not in the manifest means the file was modified after signing, which is precisely what a pirated, recompiled, or trojanized copy is, and it fails by arithmetic without anyone having to inspect it. A valid signature from a key you never trusted means a stranger, not your teacher.
And then the boundary, stated as plainly as the detection ceiling, because a security tool that oversells itself is a liability. Signing proves provenance and integrity. It does not prove safety. If an author's private key leaks, or their own machine is compromised and they unknowingly sign a malicious file, the signature verifies perfectly, because the system is working exactly as designed, it just has nothing true to say about intent. Provenance answers "is this really theirs, unchanged," not "is this harmless." Which is exactly why it complements detection instead of replacing it: you verify what you can, you inspect everything else, and you can still inspect even the things that verify. The trust anchor is the distribution of the public key, so the one operation the whole edifice rests on is getting the right key to the right people, which is a human problem, not a math one, and the human problems are always the load-bearing ones.
That signing layer is its own tool. I am calling it BlendSeal, and the decision to keep it separate from the free inspector is deliberate, not incidental. Detection wants to be free, broad, open source, and installed by everyone, because its value is in coverage. Provenance wants to be a smaller, sharper thing aimed at the people who publish, with the verification side available to everyone who consumes. Two audiences, two value propositions, two licenses. Welding them into one tool would have made both worse.
The shape of the lesson
The two questions stay separate to the end. Detection is a fight you draw, and the engineering is all in the compounding, unglamorous parts: the normalization that flattens the cheap disguises, the decode caps that keep your own tool from being the bomb, the entropy heuristic that flags concealment you cannot pierce, the AST pass that trusts structure over string, and the brutal honesty of a benign corpus that tells you how often you cry wolf. Provenance is a fight you win, and the engineering is in the restraint: sign the hashes not the files, pick the primitive with the fewest footguns, refuse to trust your own crypto until a reference library certifies it, and state the boundary out loud so nobody mistakes "genuinely theirs" for "safe."
Neither tool is clever. That was the goal. The clever part of the original attack was nothing, a hyphen and a default and a loop, and the answer to unclever attacks that work is not cleverness, it is coverage, calibration, and the discipline to say "I could not read this file" instead of "this file is clean." Most of security is boring decisions made carefully before anyone needed them. This was a few of them, written down.
A narrative companion to this piece — the actual incident that produced both tools, the lookalike-domain investigation, and why the Discord-token loop kept eating β's community no matter how many times the moderators cleaned up — is in The weight of a hyphen.