Obfuscatorx - #215
Draft
echo094 wants to merge 18 commits into
Draft
Conversation
Contributor
…line runner The oracle goes in before the fixes it makes findable, which inverts the usual order and is right only where a defect is invisible to output. A pass can rewrite the tree correctly and leave the derived state beside it inconsistent; the emitted text is then perfect byte for byte while every later pass consulting that state decides against a program that no longer exists. The cheap checks do not see it. A stale reference reports `removed === false` and its cached parent chain still reaches a Program, so only asking whether the node is reachable from the live tree works. `helper.test.js` exists to prove the detector can fail - a check that only ever reports zero is indistinguishable from one whose population is empty. `getPipelineResult` runs several passes on ONE AST because the other two helpers cannot reach the failure class a real pipeline has: a fixture built by running earlier passes and writing the result to disk is certified across a re-parse, which rebuilds every path from text and repairs exactly the state a pipeline carries forward. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Four shared visitors left `binding.references` inflated. Babel's insertion and replacement family - `replaceWith`, `insertBefore`, `insertAfter`, `unshiftContainer`, `pushContainer` - records a reference per call rather than per node, so a rewrite that re-homes a subtree books its references twice. The tree is correct and the printed output is byte-identical; what is wrong is the derived state every later pass consults. The consequence was not hypothetical. `parse-control-flow-storage` refuses to remove a declaration unless every reference resolved, and it was handed a list inflated past the live nodes - so the gate was satisfied while one live reference went unhandled, and the decoded program threw a ReferenceError. Each now crawls once on the way out, program-scoped and gated on whether it rewrote anything. Repairing at the producer rather than at the consumer is the point: a consumer-side repair has a placement question, and that question is an artifact of the wrong level. `split-variable-declaration` is the odd one - it has no `replaceWith` at all and was simply crawling the wrong scope. The same root cause was diagnosed and fixed in `split-assignment` alone eight months ago; these siblings carried it unfixed because a workaround repeated per consumer never gets priced as one defect. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The pass re-homes the whole assignment into an inserted statement and then handed `path.node.left` back to `replaceWith` - but that node is already live inside the statement just inserted, so the tree ended up holding **one node reachable at two positions**. Measured as two such nodes on one real sample. This is not the duplicate-reference class the siblings have, and a crawl cannot repair it: the tree genuinely holds the node twice, so the bookkeeping is an accurate description of a wrong tree. The hazard is a later pass resolving both occurrences - the second finds its parent slot already rewritten, resyncs to a null key, and throws inside Babel's validator. `t.cloneNode(node, true)` fixes it with output unchanged. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
… split It crawled the program scope after every split. The crawl is only needed once the traversal is done, so this gates it on whether anything was split and moves it to `Program.exit`, matching the shape the sibling visitors use. Safe because nothing in this pass reads scope state between splits: `getInsertPath` walks `parentPath` and tests node types and keys, never a binding. Output is byte-identical. Measured on one sample alongside the clone fix before it: 35ms to 16ms per run. Its reference count is legitimately two above the baseline afterwards - the rewrite really does create two new references - which is why the audit has to distinguish duplicate entries from entry count, or this reads as residue. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Unrelated to the javascript-obfuscator work this branch is otherwise about, and given its own commit for that reason: it was found in passing while auditing the insertion-and-replacement family across the repository, and bundling it into a feature's commit would mean neither could be reverted without the other. The pass inlined a cached value node directly at each use site, so one node object ended up reachable from every site that read it. Cloning per use is the fix. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The suite had one case for this visitor and it was a decline, so nothing pinned what the pass does when it succeeds - and its completeness gate is what a later defect turned out to hinge on. Three cases now cover one wrapper kind each: binary, call and logical. Each is real encoder output verified to run identically to its input, so a decode that resolved the wrong entry fails rather than merely looking different. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Three of the four visitors repaired earlier had no test at all, which is why the defect survived: the oracle was adequate and simply unreachable. These run through `getVisitorResult`, so they inherit the reference-state check rather than re-implementing it. Every rewriting fixture keeps a reference to an outer binding inside the re-homed subtree, and that is the property that makes the check bite. A fixture whose re-homed subtree references nothing pins the text and reads clean on the state - which is precisely how the one visitor that *did* have coverage still missed fourteen duplicates. Verified to fail for the right reason: against the pre-fix visitors every rewriting case fails on the reference-state assertion, and the declining cases pass, since a visitor that does not mutate cannot duplicate. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…overage Four plugins compose it and nothing pinned it. The cases cover both folding directions, the conditional-expression form, and the dead-branch shapes dead-code injection emits. Two are about what it must *not* do. `lexical-branch-kept` pins that a surviving branch owning a `let`/`const` keeps its block, since splicing it into the parent list would move a block-scoped binding. `outer-reference-in-dead-branch` pins the postcondition this pass owns: detaching a branch leaves every other binding's references pointing into it, so it crawls on the way out rather than leaving each consumer to notice. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`debugLog` is per-pass tracing and is rightly off by default, but a plugin that declines needs to say why on a channel the user is already reading. A refusal nobody can read is the silent fallthrough it was meant to replace. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Seven single-rewrite visitors, plugin-agnostic and re-runnable to a fixpoint, composed by a pipeline rather than run alone. Four reverse operator-packed control flow - a statement-level `&&`, a conditional in statement or return position, a sequence in an `if` test, and an assignment distributed into a conditional's branches. Three restore property spelling - member reads, keys, and shorthand. **The seam is the point.** Ten of the eleven reversals the encoder's Converting stage needs turned out to be encoder-agnostic, so they belong on the shared side and only the scheduling is specific to one obfuscator. Ask which side a rewrite belongs on before writing it into a plugin's own folder; the answer here went the surprising way. Each declines rather than stopping: a site failing the gate is skipped and traversal continues, because `path.stop()` halts the whole traversal and on obfuscated input the declined sites outnumber the matched ones roughly two to one. The `-invalid` fixtures are the pass working, not gaps - a gate that rejects more than it accepts is the expected shape when value-position constructs outnumber statement-position ones. `collapse-property-shorthand` is correct and, against this one encoder, dead: the shorthand is expanded so a renamer has two nodes, and the renamer then runs. It is kept because the layer is plugin-agnostic and an encoder that does not rename leaves the shape intact. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…e-first
Detection is separated from decoding, and that separation is what makes a
refusal mean something: fused, "no string array present" is indistinguishable
from "one I could not read", and only the second is worth declining on.
**The era is an output of matching, not an input to it.** The rejected design
was an era-keyed dispatch registry - `detect(ast) -> { era }` then
`strategies[era].decode(ast)`. This matches the union of known shapes and
reports which one hit, so the registry can gain rows without the matcher
changing and there is nothing for a dispatch table to key.
Three outcomes rather than two: resolved, evidence-present-but-unresolvable,
and absent. The legacy `V0`/`V2`/`V3` labels have no mapping here - they are
one incumbent's branch names, not shapes.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Evaluate, do not model. The encoder chooses the per-item encoding, the index arithmetic, the rotation amount and the rotator's era; re-implementing any of that only ever covers the version that was read. So the machinery is run in a **fresh isolate per decode** - a module-scope one is shared by every decode in the process, and the second sample then evaluates into a context still holding the first's bindings. Four outcomes, and three of them are not failures: `decoded`, `absent` (built with the option off), `unowned` (a layer this does not own - success plus residue) and `unreadable`, which is the only one worth refusing on. Two guards catch the failure mode an evaluating reversal has and a static one does not. Miss one component of the machinery and every call site returns a real string, just the wrong one - output parses, runs, and drives every residue axis to zero. A decoded string in computed-member-key position must be a valid identifier, because the encoder put a real property name there; and self-validating machinery cannot terminate on an incomplete extraction, which is why the timeout is mandatory rather than defensive. Fixtures are the encoder's own spec cases rather than shapes invented here, including one damaged input per refusal path - each asserting the note it expects, since every refusal reports the same status and a case landing on the wrong guard would otherwise read as passing. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Schedules the four atomic operator-to-statement visitors, plus statement and declaration splitting, to a fixpoint. It runs first in the pipeline because every matcher below navigates by statement boundaries. **The fixpoint is required rather than tidy**, and the committed case pins it: four conditionals in four positions, one of which is unreachable until an `&&` has been reversed. One round is not enough, and the round count is the nesting depth rather than a constant. Declaration merging had no reversal at all until upstream's own spec fixtures were mined as a *measurement* rather than as a source of cases - encoding every fixture for a stage and comparing the decoded structure against its source, which is what surfaced a reversal nobody had noticed was missing. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Ten of the encoder's eleven Converting transformers rewrite one node in place, so ten of the reversals are single-node rewrites and none is specific to this obfuscator - they live in the atomic layer, and this file holds only the scheduling. What is obfuscator-specific is knowing that these reversals **unlock each other**, in which order, and that the group must run to a fixpoint: with `splitStrings` on a property name arrives as a `+` chain and is not a string literal at all until it has been folded. **One deliberate improvement over the incumbent, and it is a real semantic defect avoided.** That plugin un-computes any string key unguarded. Three keys change meaning when they lose their brackets - `["__proto__"]` becomes the prototype setter, `["constructor"]` becomes the class constructor, `static ["prototype"]` becomes a runtime error - and the repository's shared guard already refuses all three, so this calls it rather than reimplementing the list. Termination compares the tree between rounds rather than counting reported rewrites: two of the six passes are shared visitors with no change signal to offer, and counting only what can report would exit a round early whenever those two were the only ones to fire. A text-level residue assertion does not work on this decoder's output - the first version of `literals-and-members` proved it, since `/0x[0-9a-f]/i` fails on a correct decode when renaming leaves `_0x185301` everywhere. Assert on the shape the census reads. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…lattening
A fork of the shared `remove-control-flow-ob`, not an import, and the four
behaviours that forced the fork are why: the shared visitor accepts any
prefix unary as the loop test and so deletes an ordinary `while (!done)`
loop; it walks forward into a fallthrough case and appends it twice; it reads
the discriminant's `property.argument.name` unguarded and throws, aborting
the decode; and it resolves the control declarations by binding, which
happily accepts a conditionally-initialised controller. Three of those are
worse than declining. Narrowing a shared visitor is not an option when two
other plugins consume it.
**The reversal is a permutation read, in one step.** The controller string
holds, for each original position, the index of the case now carrying it, so
reading it left to right and indexing the case list recovers the order
directly - no scanning, no walking forward, no state. Every invariant is
checked before any mutation, so a decline is safe: there is no point at which
the pass has half-applied itself and then found a reason to stop.
Declarations are resolved by scanning previous siblings rather than through
`scope.getBinding()`, for two reasons both paid for. `var` hoists, so a
binding lookup resolves `if (x) { var C = …; }` while the *initialisation* is
conditional. And a binding records the path as of the last crawl, so it can
point at a detached node that still prints identically - a binding-based
version accepted every hand-built case and rejected all 108 live corpus
blocks, because only the hand-built trees had never been rewritten.
Five of the seven cases pin a decline, which is unusual and is the point: the
shared visitor passes both rewrite cases and mishandles every decline case,
so a suite covering only the happy path would not distinguish the two
implementations at all.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Self-defending, debug protection and console disabling share one removal path because they share the encoder's calls-controller indirection, so the only per-protection code is a classifier table. **The guard is not dormant and never was.** Parse a raw sample and print it back with zero passes applied and the program hangs, at every era in range, while the untouched sample runs - so anything that regenerates without stripping will hang, which is the trap that first looked like a slow corpus. The two self-defending eras are **not equally strippable**, and the newer one fails in the silent direction. The older is classified by `new RegExp`, whose callee no encoding touches, so it matches on an undecoded tree. The newer is classified by `.search` member calls, and that property name is a string-array call until the array resolves - so with the string array unresolved the classifier misses, the guard survives, and it spins forever. A second test file exists because the strip's dependency on an earlier pass is only observable on a composition: run through the shared pipeline helper on one AST, since a fixture built by writing an intermediate to disk is certified across a re-parse that repairs the state a real pipeline carries. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
An **additive second entry** for javascript-obfuscator, not a patch. The existing one is widely depended on, so changing it in place risks breaking people relying on its behaviour; the two are expected to disagree, and that is the point of the split rather than a problem to reconcile. No version in the name, deliberately: coverage is non-contiguous and growing, so any single version would misdescribe it. **Refusal is narrow and means one thing: a layer that is mine, which I could not read.** A falsy return is the only signal the interface has, so it is spent on the case where output would otherwise be silently half-decoded. `absent` and `unowned` both fall through - a foreign residual layer is success plus residue, and declining on it would discard a completed peel and leave no intermediate to chain from, which is how the recorded field workflow actually runs. The cost is stated because the log is the only place it shows: a truthy return no longer implies fully decoded. Exposing a machine-readable verdict was rejected as a wider blast radius than an additive entry intends. **The pipeline is era-invariant** - same passes, same order, every era - with one fixpoint group in the middle because the dependency is a cycle: storage inlining re-opens Converting work that has already reported clean. Three placements were settled by measurement rather than argued, and `prune-if-branch` is load-bearing three times over, so anyone reorganising the group must move it knowing all three roles. The report derives a version *range* by intersecting per-component verdicts and never names a version. It never gates: an unrecognised signature yields `unknown`, an empty intersection is a not-stock diagnostic, and `rotate=none` contributes **no evidence** rather than an era - collapsing that into unknown would invent an era for a sample built with rotation off. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Those eras were measured only by a corpus that can be rebuilt, so nothing committed held them. Four cases cover the four distinct era combinations below `2.16.0` - every combination the seven columns down there carry between them, since the wrapper moves twice and the rotator once. A fifth would pin a shape one of these already covers. Pipeline-level rather than entry-level: running the passes directly is what lets a case assert the composition, and writing an intermediate to disk would certify it across a re-parse that repairs exactly the state a real pipeline carries forward. **Three of the four goldens are byte-identical apart from one renamed identifier**, which `renameIdentifiers` makes irreversible by design. Four encoder eras decoding to the same program is the collapse claim as an artifact rather than an argument. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.