Skip to content

Obfuscatorx - #215

Draft
echo094 wants to merge 13 commits into
mainfrom
obfuscatorx
Draft

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

Conversation

@echo094

@echo094 echo094 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

No description provided.

echo094 and others added 13 commits August 9, 2026 13:41
Obfuscators pack a branch onto one line by rewriting it into an operator -
`if (c) f()` becomes `c && f()`, a two-branch `if` becomes a ternary. That is
semantics-preserving, but it destroys the statement boundary that every
statement-level matcher navigates by, so a decoder has to put it back before
anything else can read shape.

Each visitor does one rewrite and carries its own position gate, which is the
whole safety argument: the node must be the entire statement, never a value.
Measured over 432 samples of javascript-obfuscator output, that gate matches
435 sites and declines 1090, of which 895 are conditionals in value position -
an ungated pass is wrong on every one of them.

They decline rather than stop. Halting on an unexpected parent aborts the
entire Babel traversal, not the subtree, so on this input the first declined
site would end the pass before it had normalized anything.

Cases follow the existing -valid/-invalid convention, with the invalid half
asserting byte-identical output: the `if`/`while`/`for` test positions, an
arrow concise body, declarator, argument, operand and property positions, and
`||`, which has no single-branch `if` form and is deliberately unhandled.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Schedules the atomic visitors to reverse javascript-obfuscator's Simplifying
stage. Holds no rewrites of its own - what is obfuscator-specific is which
visitors run, in what order, and that they run to a fixpoint.

The fixpoint is required rather than tidy, because the rewrites unlock each
other. The encoder nests its own outputs: a source `if` inside an `if` is
emitted as `t && (x(), c ? a() : b());`, where the conditional sits in a
sequence inside a `&&`. Nothing can reach it until the `&&` is reversed, which
creates a statement; re-bracing then gives that statement a block; only then
can the sequence split; only then is the conditional in statement position.
The round count is the nesting depth, not a constant.

Verified against the frozen corpus rather than by construction: applied to the
existing obfuscator plugin's decoded output over the 2.19.0 column, it takes
the normalization census to zero on all eight axes, and all 54 cells still
reproduce their fixture's output exactly.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Shape-first: it matches the union of emitted shapes and resolves the
entrypoint, reading the era off whichever matched rather than consulting
one to decide what to match. Reports three outcomes rather than a
boolean, because collapsing absence into failure is what makes a nested
sample undiagnosable.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Detection resolved the entrypoint, but two of the handles it returned were
the node a shape was *read* from rather than the node a remover has to act
on, and both differences are removal-safety:

- the plain-declaration holder came back as the VariableDeclaration. Removing
  it would take any sibling declarator with it, and `var a = 1, ARR = ['..']`
  is a legal spelling. It is now matched and returned as the declarator.
- the rotator came back only as the statement it sits in. Adjacent-statement
  merging fuses the IIFE with the statement after it, so removing that
  statement takes the fused-in effect - a setInterval, on a sample enabling a
  timer - with it. The call is returned alongside, and it is also what now
  defines "inside the machinery", since a wrapper reference in a fused-in
  effect is a real use site rather than machinery.

The undeclared-alias list is returned as well as noted, because a consumer has
to gate on it rather than log it: an alias with no binding has call sites
nothing can enumerate, so deleting the machinery after missing one breaks the
program where declining would have left countable residue.

Wrapper resolution now yields the wrapper node instead of a boolean. With
several encodings configured there are several root wrappers, and which one a
site reached decides which decode body evaluates it - resolution already has
that answer.

No change to what is matched: over the frozen corpus every cell reports the
same status and the same signature as before, one per encoder version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Replaces every root-wrapper call with the string it returns and deletes the
machinery that produced it. The strings are recovered by running the encoder's
own code - the subsystem's source is captured, evaluated in an isolate built
per decode, and each call site decoded by evaluating that call - rather than by
reimplementing rotation, the index arithmetic and the none/base64/rc4
encodings.

The reason is generality over three axes that are shape-only: the encodings,
the eras (the holder, wrapper and rotator shapes move independently while their
semantics never change, which is why extraction is era-dependent and decoding
is era-invariant), and modified variants, which still run but no longer match a
model keyed on the stock algorithm.

One run decodes exactly one layer; there is no internal loop. The dangerous
failure on a re-obfuscated sample is a pass mutating the layer beneath it, so
the layer boundary is left observable, and layers need not come from the same
encoder anyway.

Four outcomes rather than two, because three of them are not failures:
`absent` is what a terminating peel round reports, and `unowned` keeps a sample
merely awaiting a later unit distinguishable from a matcher failure. Everything
short of a complete decode leaves the tree untouched - a half-resolved string
array manufactures two entities out of one for every matcher downstream.

Two guards catch the failure a residue census cannot see, and both work with no
expected output: a compare-loop rotator cannot terminate on an array it was not
written for, so the eval timeout reads an incomplete extraction; and a decoded
string in computed-member-key position must be a valid identifier, which is the
general check since rotation is optional. Four gates run before anything is
touched - an undeclared alias, a foreign scope wrapper, a rotator that does not
rotate this holder, and a read of the array from outside the machinery.

Verified against the frozen corpus rather than by construction. Over every
encoder version the outcome partition is identical; the six-axis residue census
reads zero on every cell reported decoded, against a non-zero control on the
same inputs, while the cells reported unowned read exactly their control. The
decoded output reproduces each fixture's own reported lines, excluding the
anti-tamper cells that hang on reformatted output until a later unit strips
them. Each refusal path was reached by a damaged input and landed on the guard
it was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…derived cases

The claims come from javascript-obfuscator's own StringArrayTransformer.spec.ts
and StringArrayRotateFunctionTransformer.spec.ts rather than from cases invented
here: a fixture pins a claim, and one written to match what the pass already does
pins the pass to itself.

Mining that list turned up coverage the frozen corpus cannot reach.
`stringArrayIndexShift` and `stringArrayIndexesType` appear in exactly two option
sets, and both are blind — the maximal profile's array is starved by its own
`splitStringsChunkLength` so it has no live call site, and its raised-chunk
sibling is function-form and therefore unowned. So the index shift and the
numeric-string index spelling had never been decoded with a live use site.

Three of the four outcomes are not failures and have no obvious golden, so
`absent`, `unowned` and `unreadable` assert a status plus a tree that came
through byte-identical, measured against a normalize-only run of the same bytes
rather than against the raw input. Each of the five refusal cases also asserts
the note, because all five report `unreadable` and a case can otherwise land on
another guard and still look right.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…eclarator

Babel's own removal hook deletes the parent VariableDeclaration when the removed
declarator is the only one, so the declaration is either already gone or still
holds at least one declarator - the empty-parent branch could never run. Left as
a comment saying why, because the guard read as load-bearing and the next reader
would either trust it or move it.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…pers

A scope wrapper is a wrapper around the root wrapper, so it is one more piece
of machinery to extract rather than a new kind of reversal: the detector now
resolves them into handles instead of counting them as blockers, and they join
the prelude, the call-site set and the removal.

Membership is a fixpoint grown outward from the root wrappers, because the
shape alone does not identify one - a plain forwarding function is a legal
thing for a program to contain, and what makes it ours is that its chain ends
at a root wrapper. Each is lifted under a synthetic name, since mangled output
reuses short names across sibling scopes and they all land in one isolate
scope.

Numeric-string operands are folded where the operator coerces, which
hexadecimal-numeric-string indexes need and which nothing before this reached:
with the variable form the index stays positive and arrives as a bare string
literal, so only a scope wrapper's negative offset produces the spelling.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…eam's cases

Seven cases, each naming the describe() in the encoder's own
StringArrayScopeCallsWrapperTransformer.spec.ts that asserts the shape. Four
reach shapes the corpus cannot: mangled names, which is what the synthetic
lifted names exist for; a three-level chain; a const program; and a prohibited
scope. The mangled case independently reproduces the value upstream's own spec
asserts.

wrappers-function moves from unowned to decoded, and its reporter is corrected:
it called a string, so both sides threw. That went unseen while the case was
unowned, because the runtime oracle only runs for decoded - the builder refused
the moment U3 decoded it, which is what the refuse-until-verified contract is
for.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…ing for

The gate has no input below javascript-obfuscator 3.2.0 because a 2.x use site
is constant by construction, so from inside the decoder it reads as dead code.
It is not: stringArrayCallsTransform rewrites a site's literal arguments into a
control-flow storage member read, which is exactly what isEvaluableArg refuses.
Saying so where someone would otherwise delete it.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Three single-rewrite visitors for the shapes an obfuscator produces when
it moves a static property name into a computed string so a string-
concealing pass can reach it: member un-computing, property-key
un-computing, and shorthand collapse.

None is specific to any obfuscator, so all three live in visitor/atomic/
and any plugin may compose them.

Key un-computing delegates its dangerous half to safeFunc's
uncomputeStringKey rather than reimplementing the exclusion list. Three
keys change meaning without brackets -- an object literal's __proto__, a
class's constructor, and a static prototype -- and separating
un-computing from de-literalizing is what confines the risk to one step,
since a key that is already non-computed can be de-literalized freely.

Shorthand collapse carries the mirror-image exclusion: in an object
literal { __proto__: x } sets the prototype while the shorthand form
defines an own property, so that one name is refused.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Schedules the reversal of javascript-obfuscator's Converting stage. Ten
of that stage's eleven transformers rewrite a single node, and none of
those reversals is encoder-specific, so this file holds only the
ordering -- which of them run, in what order, and that the group runs to
a fixpoint.

Iteration is required rather than tidy: with splitStrings on, a property
name is emitted as a chain rather than a string literal, so nothing can
un-compute it until the chain has been folded. On one corpus cell the
count of un-computable member reads rises from 33 to 365 once folding
has run.

Termination compares the tree between rounds instead of counting
reported changes, because two of the six passes are shared visitors with
no change signal and must not be edited to add one; counting only the
rest would exit a round early whenever those two were the only ones to
fire.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…encoder-derived cases

Four goldens built from real 2.19.0 output for source shapes taken from
upstream's own converting-transformer fixtures, plus three unit cases
for hazards no encoder output can reach.

The goldens come from a builder that refuses: it writes nothing unless
the decoded output runs and reproduces the pre-obfuscation source's
output, since exact string equality would otherwise make a wrong decode
look authoritative.

The class-member case covers ground the corpus cannot -- no corpus input
declares a class -- and includes upstream's own 'constructor' spelling.
The three hazard cases cover the keys that change meaning when brackets
are added or removed, each paired with a safe sibling so the guard
cannot pass vacuously.

The pattern case pins a negative worth knowing: shorthand collapse can
never fire on this encoder, because the expansion exists so renaming can
separate key from value and renaming then does, at either setting of
renameGlobals.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 66.13% 4169 / 6304
🔵 Statements 65.27% 4324 / 6624
🔵 Functions 77.86% 482 / 619
🔵 Branches 68.24% 3580 / 5246
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
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/obfuscator/detect.js 91.64% 81.9% 100% 91.56% 58, 62, 98, 104, 108-110, 138, 197, 292-296, 354, 405, 500, 511, 516, 582, 597, 633, 641, 657, 745, 835-836, 838-841, 906, 1004-1008
src/visitor/obfuscator/normalize-converting.js 96.15% 85.71% 100% 96.15% 117
src/visitor/obfuscator/normalize-statements.js 91.66% 71.42% 80% 91.3% 77, 99
src/visitor/obfuscator/string-array.js 85.64% 72.17% 100% 85.42% 203, 216, 270, 277-283, 355-359, 380-384, 394-397, 461-465, 468-472, 516-517, 520-521, 600, 603, 613, 616, 626
Generated in workflow #120 for commit 9f1edc0 by the Vitest Coverage Report Action

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