GRANT · WANGsoftware developer
JUN 2026 · AUG 2025 - OCT 2025 & MAY 2026 - JUN 2026· Solo projectLive

I hid your control flow behind an open conjecture

Most Python obfuscators rename your variables and base64 your strings. This one rewrites your loops into Collatz-like state machines you can't read without stepping through them by hand. The loop always halts. The only way to find out when is to run it.

ObfuscationCompilersOpaque predicatesASTPython
Line art of an Archimedean spiral winding inward to its center, then a line breaking outward across the spiral rings at 45 degrees — a Collatz-like loop that always terminates, but won't say when.
13 min read · 2,830 words

Most code obfuscators play dress-up. They rename your variables to l1lI1l, base64-encode your strings, minify the whitespace, maybe wrap the whole mess in an exec, and call it a day. Run a decompiler or just read carefully, and the logic is sitting right there — it was never actually hidden, only inconvenienced.

Pyobfuscate rewrites the logic itself, at the AST level. The most interesting thing it does is to your loops, so I'll start there.

A plain counting loop goes in:

python
for i in range(n):
body(i)

and comes out as a state machine walking a Collatz-like trajectory1:

python
num = target
idx = 0
while num != seed:
i = idx
body(i)
if num % 2 == 0:
num = num // 2
else:
num = a * num + b
idx += 1

The loop still runs exactly n times and i still takes exactly the values it used to. But the termination condition is now num != seed, where num follows a generalized Collatz recurrence — halve when it's even, a*num + b when it's odd. Proving this loop halts is trivial: step through it and watch it land on seed. The catch is that stepping through it is the only way to find out. There's no closed form that reads off the iteration count, and no shortcut a static analyzer can take — in general, settling termination for these maps is provably out of reach.2 The loop will tell you when it stops. It just won't tell you ahead of time.

§ 01What it produces

Pyobfuscate takes Python in and emits functionally identical Python out. You decide what happens in between: pick which transforms to run, configure each one, reorder them, run some twice and skip others entirely, or drop in a strategy of your own and have it picked up automatically. The built-ins — nine today:

StageWhat it does
Loop obfuscationrewrites range loops into Collatz-like state machines (below)
Number obscuringscrambles integer literals through a Feistel cipher, then an XOR-string pass
String obscuringencodes string literals as XOR byte sequences with an injected decoder, or chr() chains
Identity wrappingburies expressions in identity transforms ((lambda x: x)(expr), (expr,)[0], and others)
Conditional injectionwraps statements in opaque always-true / always-false branches
Junk injectionthreads dead arithmetic and bitwise noise between live statements
Bogus functionsinjects never-called functions to bulk up the symbol table
Import obfuscationrewrites import foo as foo = __import__('foo')
Renamingreplaces every user-defined name with a random 8-character identifier

Every run is reproducible from a seed, and the same logic runs four ways: a CLI; a FastAPI backend server; the web frontend running everything client-side in your browser via Pyodide, so your source never leaves your machine; and that same frontend offloading server-side to a Lambda, for anyone who'd rather not run it locally.

Most of what follows is about the loop engine — the piece I had the most fun breaking my head on.

§ 02The naive version, and why it wasn't good enough

The first version of the loop engine used probabilistic backstepping. Pick a seed, then build a Collatz-like sequence by repeatedly choosing — semi-randomly — to either divide out the ax+ba\cdot x + b branch or double, recording where you land. Emit a loop that retraces that path. It worked. It produced loops that ran the right number of times and terminated correctly.

It also produced loops that were boring, and worse, legibly boring. Short loops were the problem. When the random walk took the doubling branch most of the time — which, for short sequences, it usually did3 — the resulting trajectory was just target=seed2k\text{target} = \text{seed}\cdot 2^{k}. An analyst glancing at the loop sees a value halving its way down to the seed and clocks it instantly; some numbers just look divisible (16, 128, but also 48, 72, 144), and no amount of costume helps. The opacity I was paying for in runtime overhead simply wasn't there.

So the question became: how do I stop leaving the opening to chance?

That meant actually doing the math on these recurrences rather than sampling them. I generalized off classic Collatz (3x+13x + 1) to a family of ax+ba\cdot x + b maps with a{3,5,7}a \in \{3, 5, 7\} and b{1,1,3,5,7,11}b \in \{-1, 1, 3, 5, 7, 11\}, bab \neq a, worked out the parity constraints that make a given sequence of branches realizable, and rebuilt seed-finding around a hybrid: brute-force an interesting prefix first, then let the tail wander.

A trajectory is a binary string — 1 for the ax+ba\cdot x + b branch, 0 for the halving branch. The engine:

  1. Generates a random prefix pattern (up to 10 steps)4, forbidding two consecutive 1s because the parity math makes that branch sequence unrealizable.
  2. Solves for a seed whose forward trajectory follows that exact pattern. This is the part the naive version couldn't do — it pins down a deliberately chosen opening instead of whatever the dice produced.
  3. For loops longer than the prefix, continues with the old probabilistic walk from the prefix endpoint. Short loops are fully determined by the solved prefix — which is exactly where the old approach was weakest.

The prefix is where the randomness earns its keep. Its ax+ba\cdot x + b steps blow the values up early, so the opening reads as nothing in particular rather than a power-of-two slide. Nothing here is guaranteed — a guarantee would just be a pattern someone could math back out, which defeats the whole exercise — and that's deliberate. Every trajectory opens on a freshly-rolled sequence.

§ 03Making the search fast: CRT narrowing

Step 2 — "solve for a seed whose trajectory follows this pattern" — is the expensive one if you do it by brute force, scanning candidate seeds one at a time and simulating each. So I don't. Brute force falls apart fast: every 0 in the pattern is a doubling, so a halving-heavy pattern points at an enormous seed. A plain range(50) that lands on a near-all-0 pattern wants a seed on the order of 2502^{50} — start the scan, take a vacation, come home to nothing.

Each 1-step in the pattern imposes two constraints on a valid seed nn: a divisibility constraint (the branch is only legal when the current value is b(moda)\equiv b \pmod a) and a parity constraint (the result has to be odd, or the next branch won't route correctly). Worked backward through the pattern, each of these is a congruence nri(modmi)n \equiv r_i \pmod{m_i}. The Chinese Remainder Theorem merges them into a single residue class nr(modM)n \equiv r \pmod{M}, so instead of scanning every integer, the engine only checks r,r+M,r+2M,r,\, r+M,\, r+2M,\, \dots — a handful of candidates.

The merge is real general CRT — extended-Euclidean modular inverse, solvability check via gcd\gcd, the works — not a parity-bit shortcut. The wrinkle worth naming is that the moduli here aren't arbitrary coprimes: they're powers of aa (3, 5, or 7) crossed with 2, so they always share structure. The general CRT path handles them correctly regardless, which is what lets the same code work when I swap aa. Seed-finding ends up linear in the prefix length rather than in the size of the seed space.

§ 04Correctness: the constraint the generalization created

Generalizing past classic Collatz bought the opacity, but it also created an obligation. Classic 3x+13x + 1 has famously well-behaved cycle structure; an arbitrary ax+ba\cdot x + b map does not, and a generated trajectory can wander into a cycle. It can't run forever — these maps are deterministic, so the runtime loop always resolves — but it can terminate early, which is just as fatal. Say a 12-step pattern produces 51684214214215 \to 16 \to 8 \to 4 \to 2 \to 1 \to 4 \to 2 \to 1 \to 4 \to 2 \to 1: the loop was meant to run 12 times, but once it drops into the 4-2-1 cycle it hits its stop value after 6. Wrong count, wrong output — and silently changing the answer is the one failure an obfuscator never gets to ship.

So every candidate is validated by simulate_inverse, which walks the full trajectory and throws it out on any of three conditions: a step whose parity constraint fails, a value dropping below 1, or — the one that matters here — any value repeating. A repeat means a cycle, a cycle means early termination, and it never reaches emitted code. The recurrence is free to look like it might run forever. It is never allowed to actually run wrong.

§ 05Constant-multiple overhead

An obfuscated program that runs noticeably slower advertises that it's been obfuscated, and turning an O(n)O(n) loop into an O(n2)O(n^2) one is a non-starter for anything real. The Collatz state machine drives termination, but it can't also be the loop index without re-deriving its position every iteration — and that's the quadratic trap.

So the state machine does one job and a plain counter does the other. num walks the Collatz trajectory and owns the termination test; a separate idx owns the actual index, advancing by the loop's step size each iteration so arbitrary range(start, stop, step) shapes come out right. The one place I don't take the cheap path is the counter's starting value: writing it down as a literal would hand an analyst a free landmark, so instead a small injected helper recovers it by walking the Collatz sequence itself. Even knowing where the loop begins means running the recurrence. Reading the index mid-loop is O(1)O(1), the Collatz step is O(1)O(1), and the whole thing comes out O(n)O(n) — same complexity class as the original, just a constant fatter.5

§ 06The system around it

A clever loop transform isn't a project on its own, so the engine sits inside a pipeline that carries its own weight — every stage is meant to cost a reader something, not pad a feature list. The Feistel number transform, like the loop, hides its constants behind work you have to redo by hand: no literal to read, just a cipher to step through.

Stages auto-register through a base-class hook — every obfuscation strategy subclasses its family's base and lands in a registry keyed by class name — so adding a transform is subclassing one class, and the browser UI can list and load custom strategies by name without any wiring. The pipeline is fully reorderable: stages can be toggled, dropped, or run more than once, in any order. On the developer side, everything is declared in a single manifest, with strategies autodiscovered from it to make extension easy.

Randomness is threaded explicitly. Each run gets its own isolated random.Random instance seeded from the request, rather than touching global RNG state. That makes output reproducible when you pass a seed, keeps concurrent API requests from interfering with each other — I replaced an earlier coarse threading lock with this per-run instance, which is both faster and cleaner — and it keeps the test suite deterministic. The same run_pipeline core backs all four entry points — the CLI, the FastAPI backend, and the frontend whether it runs client-side or offloads to a Lambda — so every path runs the identical transformation logic.

§ 07The renamer picks a fight

Renaming is the one stage that gets to think about who's reading. It's two composable layers — a base generator that invents the identifiers, and any number of modifiers stacked on top — so the output can be aimed at a particular kind of reader.

The bases run from boring to hostile. The default is plain 8-character random gibberish. HomoglyphAscii builds names out of the characters that make things annoying to read — l, I, 1, O, 0 — so the symbol table turns to soup. ForeignLanguage uses reversed Russian words, which come out word-shaped and meaningless (переменная → яаннемерп) in order to confuse modern ai-based cracking systems. And the one I had the most fun with, AntiChatbot, renames your variables into prompt-injection payloads that happen to be valid Python identifiers — ignore_all_previous_instructions_and, you_are_now_in_developer_mode_do, and friends. The code runs exactly as before; the payload sits dormant until someone feeds the source to an LLM to figure it out. New models are very good at regurgitating what they read, but one trip up and that cascades into a useless output.

On top of any base you can stack modifiers. HomoglyphUnicode swaps Latin letters with Cyrillic lookalikes that are visually identical and bytewise different. DiacriticChaos piles on combining diacritical marks until a name looks like it went through a blender — and the marks are chosen specifically to survive Python's NFKC identifier normalization, so they don't quietly collapse back to clean ASCII at parse time. Bases and modifiers compose freely, and renaming itself is now just another stage in the pipeline — reorderable, droppable, runnable more than once — rather than the special case pinned to the end that it used to be.

§ 08How I worked with AI on this

The math and the design here are mine — the generalization to ax+ba\cdot x + b maps, the parity constraints, the CRT formulation, the hybrid prefix. Where AI earned its place was in how I built around those ideas, and that changed with the problem.

The genuinely hard pieces got parallel exploration. Back when the tooling was just a chat window, the Feistel number transform and the initial CRT work each had me running several conversations at once, trying different approaches against each other and keeping whichever held up. For the v2 work I switched to writing plan files — short design notes I'd drop as I went, recording the next improvement before I lost the thread — which kept each session focused on one thing and let me move fast without carrying the whole project in my head. Heavier algorithm work I did on desktop, where I had tighter control and wasn't spending API budget on exploratory math; integration was its own separate pass.

§ 09What it is, and what it isn't

Pyobfuscate is a practical deterrent, not a security guarantee, and I want to be precise about where the line is.

Against static analysis, good luck. Recovering the control flow means resolving Collatz-like termination, and there's no closed form to resolve it with — you're single-stepping this loop by hand, one parity check at a time. That's the same wall symbolic execution hits, and it's the direction recent academic work on opaque predicates has gone too: building conditions whose resolution resists dynamic symbolic execution — which I read after landing on the approach myself.6

Against an analyst who simply runs your code, no obfuscator wins, including this one. Execute-and-watch reveals what the program did on the inputs they tried. What it never reveals is how the program works — and raising the cost of that "how" from "read it" to "instrument it, trace it, and reconstruct the trajectory by hand" is the whole job. Obfuscation buys you that gap. It is not a replacement for access controls, encryption, or keeping your secrets off the client. It's a deterrent, and a good one, scoped to exactly what a deterrent can do.

Notes

  1. This is an oversimplification of the actual process that happens.

  2. More precisely, deciding whether an arbitrary generalized Collatz map halts is undecidable in general — Conway showed in 1972 that these iterations can encode arbitrary computation. The plain 3x+13x+1 case isn't known to be undecidable, but whether it always reaches 1 is the open Collatz conjecture. Either way, there's no general shortcut.

  3. There's a structural reason short walks skew toward halving. Every integer has a doubling predecessor, but only one in 2a2a integers has an ax+ba\cdot x + b predecessor — it has to land in the right class mod aa, and the resulting predecessor has to have the right parity, which for 3x+13x+1 works out to 13×12\tfrac{1}{3}\times\tfrac{1}{2}. Halving is simply far more available — the same lopsidedness behind the fact that, after all these decades, nobody has found a Collatz start value that escapes the 4-2-1 cycle.

  4. The 10-step cap is sized for the hosted Lambda's time budget. In-browser Pyodide can comfortably take it to around 100 and still come in under a 300 ms render budget, and run locally — where the CRT keeps the search nearly free no matter how long the prefix — you can push it to the point where computing the numbers themselves become the problem.

  5. And the constant is small. A Collatz step is a parity test plus a shift or a multiply-add — the kind of thing CPython's eval loop chews through without noticing — so the measurable overhead is mostly the extra bookkeeping, not the arithmetic.

  6. Cao, Y., Zhou, Z., & Zhuang, Y. (2025). Advancing code obfuscation: Novel opaque predicate techniques to counter dynamic symbolic execution. Computers, Materials & Continua, 84(1), 1545–1565. And Xu, H., Zhou, Y., Kang, Y., Tu, F., & Lyu, M. R. (2018). Manufacturing resilient bi-opaque predicates against symbolic execution. 2018 48th Annual IEEE/IFIP DSN, 666–677.