jsconfuser: new plugin - #117
Draft
echo094 wants to merge 95 commits into
Draft
Conversation
echo094
force-pushed
the
jsconfuser
branch
2 times, most recently
from
September 22, 2024 09:57
03a40a2 to
cd2859e
Compare
einstein95
reviewed
Jan 16, 2025
|
echo094
force-pushed
the
jsconfuser
branch
3 times, most recently
from
January 31, 2025 17:22
23f2641 to
215e6d6
Compare
echo094
force-pushed
the
main
branch
3 times, most recently
from
June 27, 2026 00:17
f6677df to
ddcd947
Compare
Contributor
Babel 8 ships native ESM, so `import generator from '@babel/generator'` (and traverse) resolve to the function directly, and the CJS-interop shim's `_generate.default` is now undefined. c8a97a6 already made this change everywhere else in the codebase; the jsconfuser plugin and its visitors were missed, so invoking `-t jsconfuser` threw `TypeError: traverse is not a function` immediately - invisible to the test suite, which exercises these visitors directly and never through the plugin's own entry point. Verified against real high-preset encoder output, not just the test suite, since that's exactly the gap that hid this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Reverses the wrapper Flatten leaves at a function's original position (a flat-object proxy passed to an extracted, closure-severed function) by structurally matching the wrapper and flat-object-property shapes - no identifier-name assumptions, since RenameVariables scrubs Flatten's placeholder names before real obfuscated code reaches a decoder. Handles FunctionDeclaration, FunctionExpression, and object/class methods, the strict-mode arguments-destructure parameter fallback, and nested/chained flattening (an inner function's already-flattened call can itself get re-proxied by an enclosing function's own flatten pass; inlining unwinds this by recursing into each freshly rebuilt body). Wired in after the ControlFlowFlattening decode step in the jsconfuser pipeline, since Flatten runs earliest on the encode side and its output is reshaped by every later transform. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…k-wrapped dePack returned undefined (bare `return`) whenever the last Program statement didn't match the Pack eval-wrapper shape, and jsconfuser.js unconditionally reassigns `ast = jcPack(ast)` - so any jsconfuser sample obfuscated without `pack: true` crashed the whole plugin before reaching any other decode step. Masked until now because every prior end-to-end verification happened to use pack:true (high preset) samples. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
binding.constantViolations was read without checking binding itself, crashing whenever the assignment target has no resolvable binding (e.g. an undeclared global like `TEST_OUTPUT = "..."`, common in obfuscator test fixtures across this whole project). Only checked `!name` before, not `!binding`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…orms Integrity (Order 37, the encoder's last stage): relocates a hashed function's real params/body back onto its original name from the sibling forwarder Order.Lock's first pass + Order.Integrity's second pass leave behind, and transitively cleans up the now-dead hash-utility chain (HashTemplate's wrapper -> low-level cyrb53 fn -> imul var/ polyfill) once nothing still calls it. Wired in right after Pack, the mirror image of Flatten's placement: Integrity is encoded last, so unlike everything else in this pipeline its output is never reshaped by a later encoder transform, making it the least-processed input this decoder sees. Lock (Order 3) is only partially covered here - antiDebug (bare `debugger;`), selfDefending (the self-toString()-checking IIFE), and cleanup of the invokeCountermeasures/hasInvoked dispatch wrapper once nothing decoded still calls it (deferred to Program exit and re-checked via safeDeleteNode, since dateLock/domainLock/tamperProtection calls to the same wrapper are not decoded by this pass and must not be assumed dead). dateLock, domainLock, and tamperProtection are deliberately out of scope: Order.Lock runs early on the encode side, so unlike antiDebug/ selfDefending, their load-bearing literals (a timestamp, a regex string) and the ~60-line tamperProtection prelude are meaningfully more exposed to reshaping by nearly every later transform before reaching real output, and need their own follow-up pass. Wired in late, right before Flatten, for the same reason Flatten itself runs last. Tests: test/visitor/jsconfuser/integrity/ (single hashed function with its full hash-utility chain, a named-countermeasures variant confirming invokeCountermeasures cleanup is correctly left to lock.js, two hashed functions sharing one hash fn to confirm deferred/transitive cleanup only fires once both are decoded, and a guard case) and test/visitor/jsconfuser/lock/ (antiDebug and selfDefending at both program and function-body depth, invokeCountermeasures cleanup firing once its only call site is gone, a "still live" case confirming cleanup correctly withholds when a simulated not-yet-decoded guard still calls it, and a selfDefending near-miss left untouched). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…urrent encoder
Both were marked "Covered" in decode-nexus's coverage map but turned out
completely non-functional against the current pinned js-confuser
source - confirmed empirically before touching anything: running each
decoder against real stringConcealing:true / globalConcealing:true
output left it byte-identical (formatting aside). Discovered while
scoping a Lock follow-up (domainLock needs StringConcealing decoded
first to read its regex string; tamperProtection needs GlobalConcealing
working to unwrap checkNative() guards), not something either decoder's
own test suite caught - neither had any tests at all.
Both old implementations targeted an older template shape via a shared
`global.js` helper (`findGlobalFn`) expecting a getGlobal sniffer with a
default-parameter candidate array; the current GetGlobalTemplate takes
zero parameters with a local `var` array instead, so `findGlobalFn`
returned null immediately on every current sample. global-concealing.js
also expected numeric switch keys with a returnName/fallback variant
shape; the current source always uses random string keys with one
uniform `case "key": return globalVar["name"]` shape. string-concealing.js
expected a getter/cache indirection layer with typeof-undefined fake-if
fallbacks that doesn't exist in the current source at all - just a flat
`{ph}_STR_N(start,length) => decode(array.slice(...))` pair per block.
global-concealing.js: pure static rewrite, no evaluation needed - parse
the switch's key->realName map directly (every case has one identical
shape, decoys included) and inline call sites. global.js is now unused
by anything and is deleted - its own matcher didn't match current
source either, so there was nothing left to preserve.
string-concealing.js: rewritten around evaluating each block's decode
function in the existing isolated-vm sandbox rather than hand-porting
its algorithm, since customStringEncodings makes the algorithm genuinely
pluggable (default base91, but arbitrary user code is allowed) - matches
the encoder skill doc's own stated reversal approach. Needed a new
transitive-dependency-closure collector (collectProgramDeps) to pull in
the shared bufferToString/getGlobal-sniffer chain a decode function may
call, without needing to understand that chain's own shape. Two real
bugs caught during verification against actual encoder output, not just
by construction: resolving array/decodeFn bindings from a fixed
Program-level scope instead of the wrapper's own enclosing scope silently
failed to match any block other than the Program root (a real nested-
function block surfaced this); and the dependency collector initially
had no check for "declared inside the node currently being walked",
so it hoisted a decode function's own internal locals out as separate
malformed top-level `var`s (str is not defined).
Tests: test/visitor/jsconfuser/global-concealing/ (one concealed global
among decoys, two call sites to the same global, a mismatched-globalVar
guard case) and test/visitor/jsconfuser/string-concealing/ (one
concealed string, a 3-block case - Program-level unreferenced/dead
encoder plus two nested functions each with their own, confirming both
the per-scope resolution fix and that a fully unused wrapper still gets
cleaned up - and a .substring-instead-of-.slice guard case).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Completes Lock decode coverage - antiDebug, selfDefending, and invokeCountermeasures cleanup already shipped; these three were deferred on the theory their literals were exposed to later-pass mangling. That turned out to be unfounded (lock.ts's own path.skip() persists across the rest of the pipeline via Babel's path cache), and the real blocker was that string-concealing.js/global-concealing.js were non-functional until their earlier rewrite. All three are straightforward structural matches once that dependency is met. Also fixes two real bugs surfaced while testing against combined real-encoder output: - selfDefending/invokeCountermeasures matching moved from enter to exit: a dateLock/domainLock guard can be recursively inserted into these templates' own nested blocks by the encoder's Block:exit visitor, and matching at enter saw the guard-polluted shape and silently failed to match. - Program:exit cleanup order: tamperProtection's own countermeasures call sites must be removed before invokeCountermeasures' own cleanup runs, or invokeCountermeasures is left behind permanently undeletable despite having zero real remaining references. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
deStackFuncLen's checkFuncLen matcher assumed the SetFunctionLengthTemplate's
{value, configurable} object keys were plain Identifiers, but the template
itself hard-codes quoted keys ({"value": ..., "configurable": ...}) - the same
computed bracket-string form js-confuser's Preparation pass normalizes every
object key to. The visitor was registered for Identifier nodes only, so it
never even fired on real (non-minified) output. Also fixed a second bug in the
same call site: the second call argument (the target length) is omitted
entirely when it equals the template's own `length = 1` default, which crashed
on `.value` of undefined.
Found while verifying RGF's preserveFunctionLength interaction - a third,
deeper gap (processStackParam assumes VariableMasking's rest-param shape,
which preserveFunctionLength doesn't require) remains open and undocumented
here intentionally, per discussion with the user to keep this fix scoped.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The comment still said dateLock/domainLock/tamperProtection weren't decoded yet, but lock.js has covered all six Lock sub-features since the previous session. Noticed while wiring in the new RGF decode step right below it. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
RGF (order 4) moves an eligible function's params/body into a synthetic sub-program, recursively obfuscates it with (almost) the entire pipeline, serializes it to a string, and executes it via eval at runtime, storing the result in a shared Program-level array. Reversing it means recursively running this same decode pipeline on the extracted string (a controlled circular import of this file's own plugin/jsconfuser.js default export, invoked only at traversal time), then splicing the recovered params/body back onto the original function. Matches structurally throughout (no identifier-name assumptions): the eval-wrapper function, the shared array feeding it, and each transformed function's shrunken call site. Handles both the computed bracket-string and plain dot forms of member access, since js-confuser's Preparation pass unconditionally normalizes every non-computed member access - including on RGF's own recursively obfuscated sub-program - to bracket-string form, and only Minify (not always enabled) converts it back. Wired in right after Lock and before Flatten: none of RGF's own inserted scaffolding is path.skip()-protected on the encode side, so like Lock and Flatten its output is exposed to nearly every later transform, and it needs the earlier calculate-constant-exp passes to have already folded the call site's array index into a plain NumericLiteral. Verified against real encoder output (not just constructed fixtures): a Flatten-eligible function that RGF also captures is fully restored by the recursive decode composing with Flatten's own decode, confirmed via a new whole-pipeline fixture (test/jsconfuser/) - the first of that kind, closing part of a previously-tracked gap in combined-transform test coverage. A nested-function case confirms only the RGF-eligible outer function is affected. Multiple independent RGF'd functions sharing one array are each correctly correlated by index. A preset:high + pack sample was checked too but not turned into a fixture - the leftover cruft it produces is dominated by the (separately tracked, still-unimplemented) ControlFlowFlattening reversal gap, not informative for verifying this transform specifically. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…e triple
A real `{controlFlowFlattening: 1, minify: true}` encode of a closure factory,
chosen so its CFF block terminator survives minify as a bare `return;` inside a
switch case - the shape that failed the whole enclosing application closed.
2 structural interpreter loops in, 0 out; 22266B obfuscated to 2002B decoded;
decoded output prints the same three lines as the reserved pre-obfuscation
source. Confirmed to fail against the previous decoder, so it guards the fix
rather than certifying a passthrough.
The residual x6.44 size ratio is the inline-fn call harness (rest-param
destructure, call-site entry vector, flag/`if` wrapper) that
`decodeInlineFlattenedFunction` leaves in place - a separate, already-tracked
readability gap, not this fixture's own.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
MovedDeclarations (Order 25) runs after ControlFlowFlattening (Order 24) and rewrites every single-declarator `var` into a bare assignment, hoisting the declaration to the block top or packing it into the enclosing function's parameter list. That turns the CFF entry harness's two `var` slots into `didReturn = undefined;` and `result = _main([...]);`, which matchEntryHarness rejected outright - failing the whole application closed while leaving it runtime-correct, so no output check could see it. Read each slot through a reader that accepts either form, independently per slot: MovedDeclarations decides per declaration, and its isDefinedAtTop early-return can skip one slot while moving the other. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
MovedDeclarations (Order 25) takes a FunctionDeclaration that is a direct child
of an enclosing function, retypes it to an anonymous FunctionExpression, appends
its name to that function's parameter list, and prepends an
`if (!X) { X = function (...) {...}; }` guard in its place. Nothing reversed it,
so any pass that identifies structure by looking for a FunctionDeclaration
stopped seeing it - most consequentially ControlFlowFlattening, whose entry scan
found zero applications and failed the whole block closed while staying
runtime-correct, making the gap invisible to any output check.
Restore the declaration where the guard stood, gated on the slot being a plain
Identifier parameter whose only assignment is the guard's own. The slot is
dropped only when it is last: MovedDeclarations also packs plain variables into
parameters, and those land after the function slots, so splicing out an interior
one would renumber every parameter after it. Leaving it is harmless - the
restored declaration's binding overwrites the parameter at function entry.
Reversing this separates the CFF `_main` declaration from its entry harness,
which the encoder emits adjacent to it, so scan forward for the harness rather
than assuming adjacency and splice the decoded body in at the harness - where
the flattened code actually ran - instead of at the declaration.
Measured over the cumulative `high`-transform probe, 3 runs per step:
`+movedDeclarations` goes 7->2/4->4/16->14 to 7->0/8->0/11->0, and the hard 0%
wall that started at `+stringConcealing` and held for every later transform is
gone (25->0/28->1/33->3). `high` itself is still 0%, now gated on a
previously-masked `+astScrambler` wall that reproduces identically without this
change.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Visitor-level cases for the reversal itself - the plain packed slot, a slot
followed by a packed *variable* slot (declaration restored, parameter kept), and
nesting - plus four shapes that must not be mistaken for a packed declaration: a
local `var` rather than a parameter, a named function expression (the encoder
always clears the id), a slot written more than once, and a guard with an
`else`. These run through getVisitorResult, so the reference-integrity check
also covers the scope crawl.
Plus a plugin-level fixture triple from a real
`{ controlFlowFlattening, renameVariables, movedDeclarations, dispatcher,
minify }` encode in which `_main` is packed away: six interpreters in, zero out,
runtime-verified against the reserved source. `dispatcher` is load-bearing - it
is what nests `_main` inside a PREDICTABLE function, and without it
MovedDeclarations never packs it (0/6 samples vs 6/6). Confirmed to fail against
the previous decoder.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
AstScrambler (Order 29) merges consecutive expression statements into one
no-op call, spreading any SequenceExpression it finds into the flat argument
list. That runs after CFF (Order 24), which prints every goto as exactly one
ExpressionStatement wrapping a SequenceExpression of state updates, followed by
break. Un-merging the call cannot restore the original partition -- the encode
step is many-to-one -- so ast-scrambler.js necessarily picks a canonical form,
and the goto reached interpretBlockGroup as a run of separate statements. It
matched nothing, fell through to the bare-break guard, and failed the whole
enclosing application closed.
Make the goto matcher partition-agnostic instead: read the state updates per
statement, and slice the maximal *trailing* run before the break rather than a
fixed two-statement window -- trailing because AstScrambler merges across
statement boundaries, so real user code can share the run. The zero-assignment
goto loses its placeholder entirely (an empty SequenceExpression contributes no
arguments), so a bare break is accepted as that goto too, which findGotoRunEnd
structurally cannot produce at the top level, leaving the fail-closed guard
intact.
Measured on the cff-review corpus: {cff, renameVariables, astScrambler} and
{+dispatcher} go from a hard 0% to every cell fully decoded, and the full high
preset from 0/24 to 15/24 samples decoded to zero residual interpreters.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Unit cases for both statement partitions, the bare-break zero-assignment goto, and findGotoRunEnd's trailing-run rule, plus a frozen real-encode fixture triple whose generator requires both AstScrambler shapes to be present in the obfuscated input. Four interpreters in, zero out, runtime-equivalent. The x10 size ratio is the known single-use call-harness residue (checkpoint open item #5): the slice helper's 14 references all sit inside surviving harness entry-vector literals, so the orphan sweep correctly declines to delete it. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
DeadCode (Order 8) injects templates whose argument guards throw, and CFF (Order 24) then flattens those throws into switch cases. `interpretBlockGroup` recognized only `return` and goto terminals, so a case group ending in `throw` was pushed as an ordinary statement, ran off the end of the loop, and returned `null` - failing the whole enclosing application closed rather than just that group. A `throw` needs nothing rebuilt: it stays an ordinary output statement, which also gets it literal/scope-decoded with the rest of its group, and the terminal only records that the walk stops there. It has no successor vector, and opts out of a merge point exactly as a `return` does. Found by breadcrumbing the fail-closed returns on a saved `high`-preset partial; one run named it. Full `high` preset goes 15/24 -> 24/24 samples decoded to zero residual interpreters, 24/24 runtime-correct. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
A frozen real `{ controlFlowFlattening: 1, deadCode: 1 }` triple in which
DeadCode's argument guards put three throws inside CFF switch cases: nine
interpreters in, zero out.
`cff` alone never emits a flattened throw, so the generator requires at least one
in the obfuscated input and a surviving `throw` in the decoded output - dropping
it would be a silent semantic change rather than a decode. Verified readable
rather than only self-consistent: the dead-code templates come back whole and the
source's `for`/`if`/`else` is restored.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Under the `high` preset the DuplicateLiteralsRemoval array was never decoded at all, leaving well over a thousand `literals[n]` reads - and the masked runtime prelude they index - unreadable in the output. Two causes, one in the visitor and one in the pipeline, hence the package-wide scope. Minify (Order 28) re-spells the array's `undefined` element as `void 0`, which the element matcher rejected, so a single element failed the whole array closed. And ControlFlowFlattening (Order 24) runs after DuplicateLiteralsRemoval (Order 22) and rewrites part of its reference sites to index through the CFF state array, which no pass running this early can resolve; those only become plain numbers once the CFF decode has run, so the array needs a second visit scheduled after it. Substituting just the readable half is worse than not substituting at all: downstream passes key on how a slot is spelled, so VariableMasking saw `slot[-1]` in one place and `slot[-literals[1]]` in another, treated them as two slots and split one object in two. The early pass therefore bails as a unit when any index is still unresolved, and leaves the array to the post-CFF pass. Measured over 24 `high`-preset samples: total decoded output 2258091B -> 2043954B and residual array reads 37618 -> 19613, with runtime correctness unchanged at 23/24 (the one failure is present before this change too). Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
decodeInlineFlattenedFunction decoded a dispatcher-nested interpreter in
place and stopped there, leaving the wrapper it lived in: a rest-param
destructure, a ~100-element entry vector, and a completion-flag dance
around a function called exactly once from exactly one place. That is
correct but not decoded -- and because the entry vector is built from
_cff_slice(...) calls, it is also what kept the Program-level
_cff_slice/_cff_sequence helpers referenced, so the orphan sweep
correctly declined to remove them. The orphaned helper was a symptom of
this residue, not a defect of cleanupOrphanedCffHelpers.
matchInlineEntryHarness reads the harness -- the same three-slot shape
matchEntryHarness reads for a _main, through the same readHarnessSlot so
the post-MovedDeclarations assignment spelling matches, scanned forward
for the same reason. Two differences come from the interpreter being an
expression assigned to a local: the callee carries the (1, fn) comma
guard, and the if may carry an `else { return undefined; }` that the
encoder's own harness template never emits (it is what the enclosing
function's flattening leaves behind). collapseInlineFlattenedFunction
then splices the decoded body into the enclosing block and deletes the
wrapper.
Three things worth keeping in mind about the shape of this:
- It is a second step, not a mode of the decode. keepReturnFlag stays
true either way, so a declined collapse leaves exactly the previous
output; the flag writes are stripped here instead, keyed on the
harness's own flag name, which is what makes the strip precise -- a
nested outlined function's returns are never flag-wrapped.
- scope/runtime/arg are re-declared rather than bailed on. The matcher
only accepts an entry call passing the state vector alone, so
destructuring [states, scope = D, runtime, arg] out of a one-element
rest array binds scope to D and leaves the others undefined. Without
this the collapse would decline wherever a bare one-level
scope[a] = {} write survived flattenScopeMembersInGraph, which is most
dispatcher-combo samples. A surviving states read is still a bail.
- Deletion goes through paths collected before any mutation rather than
safeDeleteNode. Its reference gate adds nothing here (the counts are
verified up front) and its re-crawl cannot be relied on mid-decode:
decodeFlattenedFunction rewrites an outlined wrapper's body by
assigning functionPath.node.body directly, which leaves Babel's cached
child paths pointing at the replaced block, so a scope-local crawl
re-registers nothing and safeDeleteNode dereferences an undefined
binding.
The five rewritten fixtures are the expected cost of this landing, not a
regression -- all runtime-correct before and after, all _cff_slice and
_cff_sequence references gone: rename-variables/control-flow-flattening
2669B -> 905B, control-flow-flattening-minify-return 2002B -> 563B,
-moved-declarations 6146B -> 2862B, -ast-scrambler 3688B -> 1762B,
-dead-code-throw 7800B -> 5567B.
Measured over a frozen 96-sample high-preset corpus (same obfuscated
bytes both sides, since the encoder samples randomly): 91/96 samples
smaller, total 8455079B -> 7959673B (-5.9%, mean ratio x375 -> x353),
best case -18%, correctness unchanged at 92/96 with the same four
pre-existing failures. The much larger fixture gains are CFF-only and
narrow-combo shapes; under a full high preset the dispatcher-closure
collapse already reached part of this.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The plugin fixture already pins the exact bytes; this pins the intent, so a future change that keeps the file small by some other route still has to clear the same structural bar: no surviving interpreter wrapper (one rest param destructured into [states, scope, runtime, arg]), no _cff_slice/_cff_sequence reference, same observable result. Shares the frozen control-flow-flattening-minify-return sample rather than committing a second 22KB copy of the same obfuscation. There is deliberately no "before" wrapper count to compare against -- that shape only exists mid-decode, once the earlier plugin stages have normalized the raw output into it, so the raw file legitimately contains none. The _cff_slice check is what keeps the test from being vacuous. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…elling MovedDeclarations is encoder Order 25 and DuplicateLiteralsRemoval Order 22, so it runs afterwards and can rewrite the literal array's own single-declarator `var arr = [...]` into a hoisted bare `var arr;` plus a separate `arr = [...]` assignment. Only the declarator spelling was matched, so on those samples the array failed closed as a unit and every reference site stayed an opaque `arr[n]` read. That blocked far more than the array itself: VariableMasking is Order 20, so DuplicateLiteralsRemoval extracts its mask keys too, and a masked slot spelled `stk[arr[4]]` is unreadable to every matcher in variable-masking.js - including the `stk["length"]` truncation statement length discovery depends on. 35 of 96 `high` samples had *every* mask key in that state, leaving whole functions undecoded. On the frozen 96-sample corpus: residual `arr[n]` reads 86664 -> 47283, bytes 7959673 -> 7507252, runtime-correctness unchanged at 92/96 (the same four pre-existing failures). Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The declarator match resolved a read against the initializer's elements without checking whether the binding is ever written again, so `var a = [9]; a = [1]; a[0]` substituted 9 where the value is 1 - wrong output rather than merely undecoded. Unreachable on real encoder output (DuplicateLiteralsRemoval's array is written once and read everywhere), and found only because the moved-declaration match added alongside it needs the same rule for its own reasons; hardened here so both spellings enforce it. No corpus change: 92/96 correct, 41012 residual array reads, byte-identical. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…l array VariableMasking is encoder Order 20 and DuplicateLiteralsRemoval Order 22, so the array holds VariableMasking's own mask keys: a slot reads `stk[arr[4]]` rather than `stk["length"]`. Both existing VariableMasking passes run before the array's second, post-CFF pass resolves those indexes, so every such key was unmatchable at the only points that looked at it and the enclosing function was left fully masked. A third pass, scheduled after the array resolves, is what actually decodes them. On the frozen 96-sample corpus, on top of the array fix: residual stack reads 60121 -> 46861, array reads 47283 -> 41012, correctness unchanged at 92/96. Bytes rise slightly (7507252 -> 7555307) because promoting a slot to a real local variable adds a declaration where an `stk[key]` write used to stand - readability, not size, is what moves here. An earlier attempt at this reschedule was recorded as worthless because it left `restFns` unchanged. That reading was wrong: the decoder never removes the rest param itself, so `restFns` cannot move regardless of how much it decodes. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…ariables Until now the transform's own masking was never undone: `processStackParam` resolves what a slot *holds* - folding literals, collapsing aliases, promoting an unconditional dynamic write - but the function kept `function f(...stk)`, original params stayed bare `stk[0]` reads, and any slot its safety rules could not classify stayed a raw `stk[key]` forever. Every one of 3200 masked functions in the `high` corpus came out that way, fully decoded ones included. `unmaskStack` finishes the job structurally: the rest param becomes `len` real parameters, every remaining slot becomes a real local, and the truncation statement goes away. The folding rules it bypasses (`checkStackInvalid`'s nested-scope and `UpdateExpression` vetoes) exist to protect substituting a *value* into a read; renaming a slot needs none of them, so a slot written inside an `if` or bumped with `++` is now readable too - as is aliasing a reassigned param, which `variable-masking.md` documented as permanently unresolvable by design. All-or-nothing per function, and gated on the exact length from the truncation statement rather than `inferParamCountFromCallSites`, which can under-count: a real param misclassified as a local is never handed its argument. It also declines wherever the array is observable as a value - a bare `stk` reference, a dynamic index, any `length` use beyond the single truncation write, or a body reading `arguments`. On the frozen 96-sample corpus: rest-masked functions 3200 -> 116 (all of them the known anonymous-FunctionExpression length gap), residual stack reads 46861 -> 858, residual `arr[n]` reads 41012 -> 15762, bytes 7555307 -> 7396608. Runtime-correctness unchanged at 92/96, the same four pre-existing failures. Every fixture in this transform's directory was also run before and after and compared by output, not just by expected text: 13/13 identical. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Its StringLiteral branch inlined into every forward reference unconditionally. Two consequences, both measured on the frozen high corpus: a large literal was duplicated into every reference, and -- the reason this is a correctness fix rather than a size one -- inlining a concealing array destroyed the binding deStringConcealingInit resolves it from, so the wrapper matcher declined on every sample. Dropping the pass converted every such decline into a match, one for one. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
deStringConcealingPlace placed one StringLiteral node at every reference site at once. Pre-existing and latent, found while guarding the same loop; the same node-sharing pitfall flatten.js and integrity.js already clone for. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
MovedDeclarations splits `var array = "..."` into `var array;` + `array = "..."`, so reading the value off the declarator's own init left it null and every wrapper failed closed -- 10 of 10 matched wrappers on the frozen corpus. Resolve from the binding instead, and carry the value into the eval bundle, which runs without the separate assignment. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…ecode On a high sample the whole program is still inside the ControlFlowFlattening interpreter when the early string passes run, so no wrapper exists for them to match and they decode nothing. Same second-visit remedy DuplicateLiteralsRemoval and VariableMasking already needed, and placed before Dispatcher, which reads its key strings as StringLiterals out of the matched body. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The encoder emits the rest param as the stack, but the ControlFlowFlattening decode reconstructs a masked function with the stack copied into a separate local first, so unmaskStack was looking up the param while every slot site sat on the local -- it collected nothing and declined on every such function, including wrappers carrying an exact truncation length. Follow the copy, allow it as the alias binding's one legitimate write, and remove it once real params are restored. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…alias readTruncationLength read the stack name off the rest param, so on a function whose stack was copied into a local it found no truncation and returned null -- and the RestElement entry point only un-masks on an exact length, so the alias handling in the previous commit could never be reached. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…offsets Every wrapper this pass matched on a `high` sample still failed, after the match, inside the bundle it evaluates -- a failure no bail-point breadcrumb reports, since both routes out are caught exceptions rather than declines. `isInsideRange` decided bundle membership from `node.start`/`node.end`, but the passes scheduled before this one rebuild whole subtrees, so those offsets are absent or fragment-relative by the time they are read: a local declared inside the decode function was hoisted beside it, out of reach of its own parameter, and the bundle would not evaluate at all. Ancestry answers the same question exactly, and document order replaces the same broken comparison for bundle ordering. The other route is the one `e48fa83` closed for the string array only: a value that MovedDeclarations (or our own control-flow decode) moved out of its declarator has to be resolved from the binding for *every* declaration entering the bundle, and the search for further dependencies has to continue into that value. Without it a dependency arrived as a bare `var decode;` and its call site failed as "not a function"; with it, the decode function no longer has to be a declaration either. Corpus (96 frozen `high` samples): bundle-eval failures 848 -> 0, call-site-eval failures 1602 -> 0, 4353518B -> 2850429B (mean x193 -> x126), correctness unchanged at 92/96. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…tions By the time this pass runs on a preset sample most wrappers are function expressions bound to a variable, so a FunctionDeclaration-only visitor reached none of them - the whole population left after the bundle-builder fix. Three things had to change together, and the first two alone measured as literally no change at all: - the wrapper is identified by a *binding* now (`resolveWrapperBinding`), which covers `var w = function …` and the split `var w;` + `w = function …` alike, and requires the binding to resolve back to this same function so a variable that holds the wrapper only part of the time is declined rather than half-substituted; - the matcher accepts the two spellings earlier decode passes leave behind: a body opening with declaration-only `var`s, and a `(1, decodeFn)` callee; - the call sites are spelled `(1, wrapper)(a, b)` too, where the reference sits in a sequence expression rather than at `callee`. That was what made the first attempt unreachable: 7 wrappers matched, 7 bindings resolved, and all 100 call sites declined. Stepping out to the sequence is gated on every discarded expression being a literal, so no side effect can be dropped. Corpus (96 frozen `high` samples): 2850429B -> 1928861B (mean x126 -> x85), correctness unchanged at 92/96, eval failures still 0. Residual wrappers on ast-scrambler.0: 9 -> 2, both of them rest-masked functions VariableMasking correctly declines to unmask. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…rder The last `node.start` comparison in the jsconfuser visitors, and the same broken premise the bundle builder had: by the time this pass runs, earlier passes have rebuilt enough of the tree that the offset is missing on exactly the nodes it is asked about, and `undefined >= n` silently dropped that reference from the set that may take the inlined literal. Corpus (96 frozen `high` samples): 1928861B -> 1921386B, correctness unchanged at 92/96. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
control-flow-graph.js gives every function it reconstructs a rest param unconditionally, and the slots are then folded to plain locals fed by one `[a, b] = rest` destructuring. Nothing could un-mask that shape afterwards: no `rest[i]` read survives, so there is no truncation statement to read, and resolveStackAlias accepts only the single-RestElement copy `[...stk] = rest`. The pattern is its own exact param count, so the signature can be restored without one. On the frozen 96-sample corpus this clears all 177 rest-masked string wrappers and takes the total 1921386B -> 1344429B (mean ratio x85 -> x60) at an unchanged 92/96 runtime-correct. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Restoring a packed FunctionDeclaration while leaving its parameter slot in place does not merely leave a dead name in the signature. The parameter and the restored declaration are one binding, and Babel resolves it to the parameter, demoting the declaration to a constantViolations entry - so every pass that identifies structure as "a binding whose path is a FunctionDeclaration" stops seeing a declaration sitting in the body, and fails closed on it. That is the same silent, total failure the guard reversal exists to prevent, moved one step later. Splicing an interior slot renumbers the parameters after it, so it is gated on arity rather than done unconditionally: removal needs the slot to be last, or no call site of the enclosing function to pass an argument that far. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`binding.path` is the definition only for the plainest spelling. A declaration split into `var X;` + `X = <value>;` puts the value in the binding's single write, and a name hoisted onto a parameter list makes `binding.kind` read 'param' with the real definition demoted to a constantViolations entry. Every matcher written as "a binding whose path is a FunctionDeclaration" - the rename-proof idiom used throughout - fails closed on both, silently. Fail-closed in the same spirit it serves: a binding written more than once has no single definition, so this declines rather than picking one. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Three shapes reached this matcher that it declined on, leaving the guard and
the whole dead helper body behind it in place on most of a high corpus:
The call is spelled `(1, f)()`. The encoder wraps a call whose callee it
rewrote into a member expression so the member call keeps its receiver; our
own ControlFlowFlattening decode then resolves that member expression back to
a bare identifier and leaves the wrapper standing, producing a shape no
encoder stage emits.
The dead helper arrives as `var X;` + `X = function () {…}` as often as the
FunctionDeclaration the encoder emitted, so it is now resolved from the
binding rather than from one declaration shape.
The consequent is sometimes already empty. Once the predicate is established
as PredicateGen's, the branch is unreachable whatever is left inside it.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Both ran only before the ControlFlowFlattening decode, where on a high sample the whole program is still inside the interpreter - so there was no guard statement in the tree to visit and both passes removed nothing at all. Not a declining matcher but an absent input, which a fail-closed pass reports identically to having nothing to do. Scheduled before Dispatcher rather than at the end: DeadCode is encoder Order 8 and OpaquePredicates 13, both later than Dispatcher's Order 6, so until their guards are gone the dispatcher matcher reads a body they have padded. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The comment credited the spelling to "earlier decode passes", which is only half right and misattributes the mechanism. The sequence wrapper is the encoder's - ControlFlowFlattening wraps a callee it rewrote into a member expression so the member call keeps its receiver, and it is the only place in the encoder emitting a literal-1-prefixed sequence. The form this pass meets, around a bare identifier, is what our own ControlFlowFlattening decode leaves when it resolves that member expression without removing the wrapper it existed for. Comment only; no behaviour change. 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.
close #112
List of transformations (in version 2.0):