Skip to content

aoughwl/obfuscate

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

obfuscate

An obfuscator for Nimony, built as a test article: a program-analysis that claims to understand code by its execution structure rather than its identifier names (or its source shape) should behave identically before and after obfuscation. This produces the adversarial input for that test.

It works entirely on the compiler's own IR — parsed or typed NIF/AIF — and never on source text. Because it rides on the token tree rather than characters, it inherently cannot corrupt runtime data: strings, char literals and comments are Str/Char tokens, not identifiers, so they are never rewritten. (The old source-text name-mangler has been removed; this subsumes it.)

obfnif — a NIF/AIF → NIF/AIF pass

obfnif reads a .nif/.aif, rewrites it as a balanced token tree, and writes it back in place — a standalone filter in the same family as nimony's own nifler/hexer passes, i.e. literally a compiler-pipeline plugin. It auto-detects which of the two layers a file is and adapts every rewrite to that dialect:

  • Parsed (.p.nif / .nif, dialect nim-parsed) — the untyped tree nifler/aowlparser emit from source. Identifiers are plain Ident tokens, so renaming is by spelling: every name the modules declare (proc/type/param/var/let/const/enum-field …) is mapped to an opaque token under one shared map and every matching occurrence is rewritten. Foreign names (stdlib, keywords, imports) are left alone. The result re-feeds nimsem and behaves identically.

  • Typed (.s.nif / .s.aif) — the sem-checked tree the interpreter runs. Identifiers are Symbols carrying base.disamb.module, so renaming is symbol-precise: a definition and every cross-module use collapse to one canonical key and get the same opaque base, while re-homed generic instantiations, lifetime hooks, operators and locals are provably untouched.

Obfuscation techniques

On top of renaming it weaves in behaviour-preserving control flow and destroys provenance. Each technique is dialect-aware — the emitted node is valid in whichever layer the file is:

flag effect
(default) / --no-rename rename declared identifiers/symbols to opaque tokens
--wrap-opaque wrap a statement as if TRUE: S (typed) / if TRUE: S else: S (parsed)
--dead-else give the opaque if a dead else that never runs (typed)
--dead-guard precede a statement with a never-firing if FALSE: branch
--opaque-pred make the injected TRUE/FALSE computed constants, e.g. and(true, not false), so a naive "is-it-literally-true?" scan is defeated
--junk-discard inject side-effect-free discard <opaque-bool> no-op statements
--nest wrap a statement in redundant stmts nesting
--strip-info zero every token's line-info — annihilates source shape and provenance at the IR level
--wrap-rate:N apply the per-statement injections on every Nth statement
--name-style:opaque|confuse o0,o1,… (default) or a visually-confusable l/I/1/i/j soup
--all enable the full recommended set (rename + every technique)
--parsed / --typed force the layer instead of auto-detecting it

Control flow is only ever injected around genuine executable statements — never around declarations or directives — so moving a statement into an injected scope can never hide an import, a let, or a type. Every injected branch takes the same arm on every run and every discard is a no-op, so execution structure — the set of decisions that actually vary — is preserved exactly. A sound structure-based analysis behaves identically before and after; names and shape give it zero help.

Usage

# build (needs nimony's NIF libs on the path)
nim c -p:/home/savant/nimony/src/lib obfnif.nim

# parsed layer — obfuscate the untyped tree, then let nimsem re-check it
nifler p mod.nim mod.p.nif
obfnif --all keep.txt mod.p.nif

# typed layer — obfuscate the sem-checked tree the interpreter/backends consume
obfnif --wrap-opaque --dead-else --dead-guard --opaque-pred --strip-info keep.txt mod.s.nif

Files are rewritten in place and share one rename map, so a call in one module still resolves to the renamed definition in another — pass every module of a program together. The keep-list is one identifier per line (# starts a comment): put keywords, stdlib names, imported symbols and the public API you ground against there.

Verification

Behaviour preservation is checked end-to-end against nimony's own aowli-interp: for both layers and every technique combination, the obfuscated IR (re-nimsem'd in the parsed case) produces byte-for-byte identical program output, including across a multi-module program whose exported symbols are renamed under the shared map.

📖 Full docs → aoughwl.github.io/docs/obfuscate

About

Nim/Nimony obfuscators as adversarial test articles: obfuscate.nim renames every defined identifier to opaque tokens (source layer); obfnif.nim is a typed .s.nif to .s.nif compiler pass that renames defs and injects behaviour-preserving control flow. Output stays well-typed and runs byte-for-byte identically.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages