Appearance
obfuscate — Nimony obfuscators (name + NIF control-flow)
Two obfuscators for Nimony, built as test articles: a program-analysis that claims to understand code by its execution structure rather than its identifier names — or its source shape — should behave identically whether the input is readable or obfuscated. These produce the adversarial input for that test, at two layers.
obfuscate.nim— a source-level name obfuscator. Rewrites a set of.nimfiles so every identifier they define (proc / type / enum-constant names) becomes an opaque token (o0, o1, …) under a single shared map, skipping strings / chars / comments. Compiles and behaves byte-for-byte identically.obfnif.nim— a.s.nif → .s.nifpass that works one layer down, on the compiler's typed NIF (the representation the interpreter actually runs). It renames the modules' own definitions and injects behaviour-preserving control flow — a standalone NIF filter in the same family as nimony'snifler/ hexer passes, i.e. literally a compiler-pipeline plugin.
- TOC
obfuscate (source layer) — what it does, and what it deliberately does not
A char-level scan skips string literals, char literals, and comments, so runtime data and documentation are never rewritten — only identifiers in normal code. The source obfuscator injects no control flow into real procs; the only thing it adds is a handful of never-called, side-effect-free decoy procs so the output looks busier. Execution structure is preserved exactly; only names change. (Control- flow injection is the job of the NIF pass, obfnif, below.)
Identifiers are matched at whole-word boundaries. Only names that appear in a definition position are collected — module-level var/let/const and type fields are left alone (fields routinely share names with kept imports).
Usage
obfuscate <keep-list-file> <file1.nim> [file2.nim ...]- The files are obfuscated together, sharing one rename map.
- The keep-list file is one identifier per line (
#starts a comment, blanks ignored): put keywords, stdlib names, imported symbols, and any public API you want to preserve there. - The rename map is written to
<file1>.map(original -> opaque, one per line); the decoy procs are appended to the first file.
nim
# keep.txt holds: keywords, stdlib, imported module exports, and the public
# API you want to remain callable (the "grounding anchors").
obfuscate keep.txt validator.nim vds.nimWhy it exists
It is a test article. A tool that claims to understand a program by its execution structure rather than its identifier names should behave identically whether the source is readable or fully obfuscated. obfuscate produces the adversarial input for that test: run the analysis on the original, run it again on the name-stripped copy, and compare.
In its first use, a CSS validator was obfuscated (111 identifiers renamed, matchOne → o68, opKw → o93, …) and a rule-extractor re-run on it: the extracted rule set came out structurally identical — same rules, same firing counts — with every enum-derived label turned to garbage. The only labels that survived were positional (else, elif#N, generated by the interpreter, never names) and the grammar data the obfuscator was told to leave alone. Names gave zero help — which was the point.
obfnif — obfuscating the typed NIF, and injecting control flow
Names are the easy thing to strip. The stronger test is whether an analysis survives a change to the program's control-flow shape. obfnif is a standalone .s.nif → .s.nif filter — the same shape as nimony's own NIF tools — that reads the compiler's typed intermediate, rewrites it, and writes valid NIF (index regenerated) back out. It does two things:
Rename, robustly. Working on the typed representation, it renames every symbol the given modules define under one shared map, so a call in one module still resolves to the renamed definition in another. It deliberately leaves alone everything the runtime binds by name or convention — lifetime hooks (=destroy/=copy/…), operators, and re-homed generic instantiations of natives (inc, Table, newSeqUninit) — which is exactly the machinery a source-text renamer can't safely see. A keep-list preserves the public anchors you ground against.
Inject behaviour-preserving control flow, configurably:
| flag | injects |
|---|---|
--wrap-opaque | wrap a statement S as (if (elif (true) S)) — always takes the real arm |
--dead-else | give that if a dead (else …) that never runs |
--dead-guard | precede S with (if (elif (false) …)) — a branch that never fires |
--nest | wrap S in redundant (stmts …) nesting |
Every injected branch chooses the same arm on every firing. On the CSS validator, --wrap-opaque --dead-else --dead-guard --nest blew the if-node count from 242 to 4862 (20×) and doubled the file — and it still ran byte-for-byte identically.
obfnif [flags] <keep-list-file> <mod1.s.nif> [mod2.s.nif ...]Why the injected control flow is invisible to a sound analysis
A rule-dispatch — the thing worth extracting — fires different arms across a run. An injected if true fires the same arm every time: it decides nothing. So the sound criterion is a runtime one — did this branch site's choice ever vary? — never a guess from the label or the source shape. A branch that never varied carries zero information and is dropped before any behaviour is fingerprinted.
The one subtlety that makes this actually sound: a branch site must be the distinct source construct, not its line number. An injected if wrapping a case shares the case's line, so keying sites on lines lets the (varying) case drag the (constant) wrapper along with it. Keying instead on the branch node's own identity separates them cleanly.
With that in place, the numbers line up exactly. On the CSS validator the rule-extractor yields the same 46 rule-ideas / 63 grounded claims on all three inputs — original, names-obfuscated, and names-plus-control-flow-obfuscated — and the central matchOne dispatch reads out its eight combinator rules with identical firing counts (622 / 72 / 36 / 34 / 10 / 4 / 2 / 1) every time; only the labels change (opKw → o1, opRef → o5, …). Names gave zero help; shape gave zero help. The analysis rides on the decisions that actually varied, and nothing else — which is what "sound" has to mean.

