Skip to content

Obfuscatorx - #215

Draft
echo094 wants to merge 18 commits into
mainfrom
obfuscatorx
Draft

Obfuscatorx#215
echo094 wants to merge 18 commits into
mainfrom
obfuscatorx

Conversation

@echo094

@echo094 echo094 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

No description provided.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68.44% 4520 / 6604
🔵 Statements 67.5% 4735 / 7014
🔵 Functions 80.7% 552 / 684
🔵 Branches 70.05% 3890 / 5553
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/main.js 0% 0% 0% 0% 14-84
src/plugin/obfuscatorx.js 86.48% 66.66% 100% 87.87% 70-73, 99-103
src/utility/logger.js 66.66% 75% 66.66% 66.66% 29-33, 38
src/visitor/lint-if-statement.js 100% 100% 100% 100%
src/visitor/prune-if-branch.js 93.1% 96% 100% 92.85% 34-35
src/visitor/split-assignment.js 100% 87.87% 100% 100%
src/visitor/split-sequence.js 68.96% 56.25% 100% 67.85% 23-32, 36, 43
src/visitor/split-variable-declaration.js 93.75% 87.5% 100% 93.33% 16
src/visitor/atomic/collapse-property-shorthand.js 84.61% 85.71% 100% 84.61% 36, 49
src/visitor/atomic/convert-conditional-assign.js 100% 100% 100% 100%
src/visitor/atomic/lint-conditional-if.js 100% 100% 100% 100%
src/visitor/atomic/lint-logical-if.js 100% 100% 100% 100%
src/visitor/atomic/split-if-test-sequence.js 90.9% 83.33% 100% 90.9% 39
src/visitor/atomic/uncompute-member.js 91.66% 75% 100% 91.66% 36
src/visitor/atomic/uncompute-property-key.js 100% 100% 100% 100%
src/visitor/jsconfuser/variable-masking.js 87.77% 83.56% 100% 87.83% 109-113, 117-122, 130, 134-137, 140, 203-205, 241, 257, 263-264, 268-273, 283, 294, 401, 424, 492, 519, 523, 526, 561, 567, 607-608, 627, 656, 660, 663, 685, 781, 789, 797-802, 808, 844, 848, 905, 909, 923, 926, 942, 969, 973, 995, 1002, 1037
src/visitor/obfuscator/detect.js 92.53% 84.66% 100% 92.46% 58, 62, 98, 104, 108-110, 138, 197, 292-296, 354, 500, 511, 516, 582, 633, 641, 657, 745, 835-836, 838-841, 1004-1008
src/visitor/obfuscator/normalize-converting.js 96.15% 85.71% 100% 96.15% 117
src/visitor/obfuscator/normalize-statements.js 92% 71.42% 80% 91.66% 79, 101
src/visitor/obfuscator/report.js 95.65% 97.29% 100% 95% 143-148
src/visitor/obfuscator/string-array.js 86.63% 76.52% 100% 86.43% 203, 216, 270, 277-283, 355-359, 380-384, 394-397, 461-465, 468-472, 516-517, 520-521, 600, 603, 613
src/visitor/obfuscator/unflatten-switch-dispatch.js 83.33% 77.22% 100% 98.78% 52, 53, 56, 141, 149, 150, 152, 159, 160, 174, 176, 177, 183, 188, 202, 205, 213, 215, 227, 228
src/visitor/obfuscator/unlock-env.js 87.82% 85.13% 100% 92.5% 70-71, 183, 186, 188, 190, 203, 205, 246, 248, 254-255, 287, 299-305
Generated in workflow #123 for commit 1a956cb by the Vitest Coverage Report Action

echo094 added 18 commits August 16, 2026 17:44
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant