Skip to content

jsconfuser: new plugin - #117

Draft
echo094 wants to merge 95 commits into
mainfrom
jsconfuser
Draft

jsconfuser: new plugin#117
echo094 wants to merge 95 commits into
mainfrom
jsconfuser

Conversation

@echo094

@echo094 echo094 commented Sep 2, 2024

Copy link
Copy Markdown
Owner

close #112

List of transformations (in version 2.0):

  • Integrity
  • Pack
  • Finalizer
  • RenameVariables
  • AstScrambler
  • Minify
  • RenameLabels
  • MovedDeclarations
  • ControlFlowFlattening
  • Shuffle
  • DuplicateLiteralsRemoval
  • VariableMasking
  • StringCompression
  • StringConcealing
  • StringSplitting
  • OpaquePredicates
  • GlobalConcealing
  • Calculator
  • DeadCode
  • Dispatcher
  • RGF
  • Lock
  • Flatten
  • ObjectExtraction
  • Preparation

@echo094 echo094 added the wip Work in progress label Sep 2, 2024
@echo094
echo094 force-pushed the jsconfuser branch 2 times, most recently from 03a40a2 to cd2859e Compare September 22, 2024 09:57
Comment thread src/visitor/jsconfuser/stack.js Outdated
@einstein95

Copy link
Copy Markdown
/Users/pc/decode-js/src/visitor/jsconfuser/stack.js:197
  const stk_name = path.node.params[0].argument.name
                                   ^

TypeError: Cannot read properties of undefined (reading '0')
    at tryStackReplace (/Users/pc/decode-js/src/visitor/jsconfuser/stack.js:197:36)
    at processStackParam (/Users/pc/decode-js/src/visitor/jsconfuser/stack.js:298:15)
    at Identifier (/Users/pc/decode-js/src/visitor/jsconfuser/stack.js:324:9)
    at NodePath._call (/Users/pc/decode-js/node_modules/@babel/traverse/lib/path/context.js:49:20)
    at NodePath.call (/Users/pc/decode-js/node_modules/@babel/traverse/lib/path/context.js:39:18)
    at NodePath.visit (/Users/pc/decode-js/node_modules/@babel/traverse/lib/path/context.js:85:31)
    at TraversalContext.visitQueue (/Users/pc/decode-js/node_modules/@babel/traverse/lib/context.js:89:16)
    at TraversalContext.visitSingle (/Users/pc/decode-js/node_modules/@babel/traverse/lib/context.js:65:19)
    at TraversalContext.visit (/Users/pc/decode-js/node_modules/@babel/traverse/lib/context.js:112:19)
    at traverseNode (/Users/pc/decode-js/node_modules/@babel/traverse/lib/traverse-node.js:22:17)

code.js.zip

@echo094
echo094 force-pushed the jsconfuser branch 3 times, most recently from 23f2641 to 215e6d6 Compare January 31, 2025 17:22
@echo094
echo094 force-pushed the main branch 3 times, most recently from f6677df to ddcd947 Compare June 27, 2026 00:17
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 58.49% 3132 / 5354
🔵 Statements 57.98% 3252 / 5608
🔵 Functions 70.75% 329 / 465
🔵 Branches 62.96% 2778 / 4412
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/main.js 0% 0% 0% 0% 12-75
src/plugin/jsconfuser.js 95.65% 100% 100% 95.65% 35-36
src/utility/binding-def.js 14.81% 15.62% 33.33% 14.81% 25-45, 62-74
src/utility/check-func.js 100% 100% 100% 100%
src/utility/safe-func.js 79.48% 73.33% 100% 79.48% 11, 23, 26, 37-42, 67
src/visitor/calculate-constant-exp.js 77.96% 73.33% 100% 77.96% 17-20, 65-66, 77-78, 83-87, 97-98
src/visitor/jsconfuser/anti-tooling.js 90% 83.33% 100% 90% 9, 29, 47
src/visitor/jsconfuser/ast-scrambler.js 84.44% 77.14% 100% 97.05% 12, 22, 24, 57, 61, 63, 64
src/visitor/jsconfuser/calculator.js 82.75% 77.55% 100% 93.33% 29, 31, 35, 36, 39, 49, 87, 96, 99, 102
src/visitor/jsconfuser/control-flow-graph.js 88.06% 81.18% 100% 87.87% 30-36, 86, 106, 140, 148, 157, 216, 223, 227, 231, 235, 265, 270, 282-287, 332, 389, 402, 406, 424, 456, 476, 513, 527, 555, 572, 752, 860, 867, 897, 1079, 1112, 1124, 1178, 1224, 1253, 1593, 1705, 1718, 1722, 1850, 1867, 1877, 1923, 1927, 1955, 1959, 1963, 1974, 1978, 1982, 2014, 2021, 2025, 2034, 2047, 2053, 2061, 2065, 2069, 2072, 2079, 2082, 2110, 2115, 2120, 2147, 2192, 2201, 2206, 2210, 2214, 2218, 2222, 2225, 2229, 2242, 2249, 2256, 2320, 2324, 2329, 2341, 2348, 2354, 2367, 2381, 2415, 2421, 2441, 2454, 2490, 2537-2538, 2541, 2548, 2571, 2644-2645, 2658-2659, 2685, 2694, 2708, 2712, 2732, 2738, 2754, 2774, 2781, 2794, 2807, 2815, 2829-2832, 2874, 2882, 2888, 2894, 2904, 2915, 2923, 2926, 2931, 3005, 3012, 3039, 3064
src/visitor/jsconfuser/control-flow.js 68.98% 69.34% 91.66% 68.58% 27, 31, 37-38, 45-64, 94-95, 100-128, 181-182, 271, 280, 284, 320, 327, 336
src/visitor/jsconfuser/dead-code.js 83.67% 75% 85.71% 83.67% 43-50, 89, 92, 100
src/visitor/jsconfuser/dispatcher.js 74.79% 78.89% 88.88% 81.22% 16, 18, 33, 40, 49, 63, 70, 105, 106, 157, 165, 175, 179, 189, 195, 203, 212, 225, 237-240, 242, 243, 278, 279, 280, 286, 293, 301, 311, 328, 338, 349, 351, 366, 371, 375, 384, 393, 401, 414, 428, 438, 447, 455, 463, 486-500, 510, 512, 515, 517, 547, 557, 607-608
src/visitor/jsconfuser/duplicate-literal.js 86.31% 85.71% 100% 95.94% 56, 77, 128, 130, 131, 132, 134, 141, 147, 225, 236, 255, 257
src/visitor/jsconfuser/flatten.js 85.03% 84.72% 100% 90.09% 28, 32, 41, 52, 60, 68-71, 82, 93, 97, 112, 136, 203, 243, 245, 248, 299, 303, 306
src/visitor/jsconfuser/function-length.js 83.92% 76.19% 100% 83.33% 16, 45, 48, 51, 55, 60, 64, 82-85
src/visitor/jsconfuser/global-concealing.js 80.55% 75.36% 100% 93.1% 27, 29, 35, 38, 46, 76, 77, 80, 86, 102, 110, 113, 119, 122
src/visitor/jsconfuser/integrity.js 82.35% 80.72% 100% 90.27% 41, 52, 59, 61, 70, 75, 83, 89, 93, 104, 106, 134, 140, 154, 170
src/visitor/jsconfuser/lock.js 80.75% 87.98% 100% 84.93% 16, 18, 22, 25, 39, 83, 93, 100, 108, 113, 116, 123, 126, 186, 195, 200, 204, 312, 345, 349, 357, 383, 396, 400, 467, 497, 508, 519, 530, 540, 549, 561, 577, 629, 642, 650, 653, 655, 665, 680, 689, 697, 709, 713, 723, 745, 752, 804, 815, 825, 866
src/visitor/jsconfuser/moved-declarations.js 92.3% 86.76% 100% 92.1% 59, 62, 90, 121, 138, 142
src/visitor/jsconfuser/opaque-predicates.js 42.1% 44.61% 100% 42.1% 28, 30-89, 129-157
src/visitor/jsconfuser/pack.js 86.36% 70.83% 100% 86.36% 13, 16, 19, 22, 25, 32
src/visitor/jsconfuser/rgf.js 81.08% 78.06% 100% 92.43% 20, 24, 26, 27, 32, 36, 46, 68, 73, 94, 97, 99, 101, 104, 106, 109, 110, 123, 127, 149, 151, 172, 187, 210, 212, 229, 234, 244
src/visitor/jsconfuser/string-compression.js 22.58% 21.73% 40% 22.58% 21, 26, 35-91, 122-177
src/visitor/jsconfuser/string-concealing.js 72.93% 69.56% 88% 75.42% 59, 75, 84, 158, 165, 187, 210, 215-217, 292-293, 302, 305, 309, 334, 335, 337, 355, 388-391, 397, 416-417, 426-427, 433-437, 440-441, 485, 493, 507-532, 554-567, 586-607, 623, 639
src/visitor/jsconfuser/variable-masking.js 86.34% 76.09% 100% 86.05% 96-102, 106-111, 119, 123-126, 129, 189-191, 220, 224, 230, 234, 297, 310, 333, 388, 407, 415, 419, 422, 457, 463, 505-506, 525, 534, 553-558, 561, 583, 649, 680, 684, 729, 743, 746, 762, 782, 786, 808, 813-815, 850, 861, 882
Generated in workflow #103 for commit 8c6b71f by the Vitest Coverage Report Action

echo094 and others added 3 commits July 22, 2026 00:47
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>
echo094 and others added 8 commits July 22, 2026 09:13
…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>
echo094 and others added 30 commits July 25, 2026 23:27
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wip Work in progress

Projects

None yet

Development

Successfully merging this pull request may close these issues.

jsconfuser: 新混淆工具

2 participants