Skip to content

jsconfuser: new plugin - #117

Draft
echo094 wants to merge 128 commits into
mainfrom
jsconfuser
Draft

jsconfuser: new plugin#117
echo094 wants to merge 128 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 60.2% 3429 / 5696
🔵 Statements 59.51% 3576 / 6009
🔵 Functions 71.96% 362 / 503
🔵 Branches 64.08% 3035 / 4736
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.83% 100% 100% 95.83% 35-36
src/utility/binding-def.js 81.48% 84.37% 66.66% 81.48% 26, 33, 42, 64, 67
src/utility/check-func.js 100% 100% 100% 100%
src/utility/safe-func.js 87.8% 84.37% 100% 87.8% 46-51, 76
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 85.48% 80.33% 97.14% 85.32% 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, 1399, 1405, 1566, 1569, 1636, 1647, 1656-1657, 1663-1668, 1675, 1691, 1823, 1935, 1948, 1952, 2173, 2190, 2207, 2261, 2265, 2293, 2297, 2301, 2312, 2316, 2320, 2352, 2359, 2363, 2377, 2390, 2396, 2404, 2408, 2412, 2415, 2422, 2425, 2475, 2483, 2516, 2521, 2526, 2553, 2598, 2607, 2612, 2616, 2620, 2624, 2628, 2631, 2635, 2648, 2655, 2662, 2726, 2730, 2735, 2747, 2754, 2760, 2773, 2787, 2804-2805, 2821, 2826-2831, 2847, 2860, 2890, 2914, 2960-2961, 2964, 2971, 2994, 3067-3068, 3081-3082, 3125, 3150, 3308-3332, 3343-3369, 3373-3385
src/visitor/jsconfuser/control-flow.js 68.35% 68.61% 91.66% 67.94% 15, 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.63% 74.5% 87.5% 83.63% 16, 50-57, 96, 99, 107
src/visitor/jsconfuser/dispatcher.js 79.28% 75.93% 88.23% 86.04% 19, 21, 46, 55, 69, 76, 100, 106, 214-216, 224, 237, 240-247, 284, 285, 296, 313, 372, 397, 464, 465, 499, 517, 521, 534, 600, 602, 608, 621, 626, 633, 645, 649, 659, 665, 674, 677, 689, 701-704, 759, 799, 800, 801, 822, 841, 851, 869, 886, 900, 903, 912, 917, 919, 920, 922, 925, 947, 949, 958, 967, 971, 990, 998, 1006, 1010-1031, 1040, 1061, 1091-1105, 1115, 1117, 1120, 1122, 1152, 1162, 1218-1219
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 83.91% 81.2% 100% 91.73% 32, 36, 45, 56, 64, 72-75, 86, 97, 101, 129, 156, 158, 168, 224, 264, 266, 269, 314, 328, 330, 333, 336
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 79.34% 69.79% 100% 91.89% 30, 32, 38, 41, 44, 57, 111-114, 138, 139, 142, 144, 160, 168, 191, 192, 198, 201
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.37% 87.75% 100% 84.51% 16, 18, 22, 25, 39, 83, 93, 100, 108, 113, 116, 123, 126, 186, 195, 200, 204, 303, 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 45.09% 45.58% 100% 45.09% 30, 32-91, 131-159, 181
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 73.26% 69.49% 88.46% 75.72% 61, 77, 86, 124, 183, 190, 212, 235, 240-242, 317-318, 327, 330, 344, 369, 370, 372, 390, 423-426, 432, 451-452, 461-462, 468-472, 475-476, 520, 528, 542-567, 589-602, 621-642, 658, 674
src/visitor/jsconfuser/variable-masking.js 85.77% 80.22% 100% 85.8% 97-103, 107-112, 120, 124-127, 130, 190-192, 224, 228, 244, 250-251, 255-260, 270, 281, 322, 326, 374, 387, 410, 478, 497, 505, 509, 512, 547, 553, 595-596, 615, 624, 644, 648, 651, 673, 770, 778, 786-791, 797, 833, 837, 894, 898, 912, 915, 931, 958, 962, 984, 991, 1026, 1067
Generated in workflow #106 for commit f55d318 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 August 1, 2026 00:28
resolveXorHelper required a candidate call site's start and length arguments
to be NumericLiterals. That reads like a free extra check and is not: the
function runs before undoLiteralEntanglementInGraph, and by then those two
positions can hold plain arithmetic instead. On the 96-sample corpus the gate
rejected the only call sites present in 6 of 21 search roots.

The consequence was out of all proportion to the check. A search root that
finds no helper decodes with xorFnName null, which leaves every entangled key
unresolved; flattenScopeMembersInGraph then reads the chains it can and
silently leaves the rest, so one scope slot ends up addressed two ways. The
body is relocated out of the function that binds the scope object, and the
surviving reference either dangles or binds to an unrelated same-named
identifier in the destination.

Identification now rests on the binding and the string blob, which are the
discriminators that actually distinguish this helper, rather than on a
spelling a later stage rewrites - the same correction this file has already
had to make for name-keyed lookups.

Corpus: 92/96 -> 94/96 correct, 685897B -> 671213B, 471 -> 433 array reads.
The two samples fixed are opaque-predicates.1 and moved-declarations.2, the
latter having previously produced output that never terminated.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
cleanupOrphanedScopeAnchors only considered an anchor whose holder resolves
to a binding, on the reasoning that an unbound holder might be a real global
whose property set is observable outside this file. That left the worst case
untouched. The CFF scope object is often a parameter of the main function,
and when the decode relocates that function's body the `scope.prop = {}`
statement travels with it while the parameter does not - so the surviving
statement is a guaranteed ReferenceError, which is exactly the shape the
guard was declining to remove.

An unbound member assignment cannot be doing useful work, so removing it
restores the program rather than changing it. Real globals are still
excluded, by name against an allowlist - the only test available, since the
binding that would have answered structurally is precisely what is missing.

Corpus: 94/96 -> 96/96 correct, 671213B -> 671053B. All three
decoder-introduced dangling references unbound.mjs reported are gone,
including calculator.1's, which the runtime check never caught because its
site never executes.

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

Two changes that have to land together, because the first without the second
is much worse than neither.

parseCreateFunction read its helper positionally - exactly four statements in
fixed order - and required the helper itself to be a FunctionDeclaration. On
a `high` sample MovedDeclarations has packed both: the declaration arrives as
a bare `createFn = function (){…}` assignment, and the two `var`s inside it
as a merged declarator plus assignments, sometimes with a dead trailing
`return;`. Reading the slots by role instead - the returned identifier, the
`if` test - takes template matching from 0 to 47 of 49 applications.

That alone decoded 47 wrong programs, 96/96 to 49/96. parseFnsEntry's
no-parameter fallback is reached by *any* unrecognised first statement, so
for the two spellings it does not know - MovedDeclarations' bare
`[a, b] = payload`, and a masked `[stk["a"], stk["b"]] = payload` - it
returned a zero-parameter function whose body still read the payload variable
that cleanup then deletes. parseCreateFunction's strictness had been gating
that shut, which is why the doc's claim that it "affects nothing" was wrong:
it was load-bearing as a gate. It now declines when the body still reads the
payload, which takes the whole dispatcher down with it.

Entry parsing also moves to the end of the matcher. It is the only step that
mutates - unmasking via processStackParam(entry, 0) - and that 0 is not read
from the code but is an invariant of Dispatcher's own template, so it is only
sound once the template is fully confirmed rather than partly.

Corpus unchanged at 96/96 and +8B of 671KB: no dispatcher decodes yet, since
those two unpack spellings are declined rather than understood. That is the
remaining work, and it is where the bytes are.
Three comments named MovedDeclarations as the cause of shapes it cannot
produce. It bails on any non-identifier declarator, so it cannot rewrite
`var [a, b] = payload`; and its FunctionDeclaration handling requires a
direct child of the packed function - createFunction sits inside the
non-call branch's block - and emits a conditional `if(!F){F=…}` prologue
rather than the bare assignment actually observed.

The shapes themselves are unchanged and still matched; what goes is the
causal claim. Where the source is known it is named, and where it is not the
comment now says the spelling is accepted on the strength of being observed
rather than attributed - which is the honest state and stops the next reader
building on a guess.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The unpack line of a masked function whose original parameter list contained
a destructuring pattern arrives as an ArrayPattern of ArrayPatterns, and
`processArrayPatternAssign` declined the whole assignment on the first nested
element. For a Dispatcher `fns` entry that decline is terminal: nothing later
supplies the arity needed to unmask it, so the entry stayed masked and its
whole application stayed undecoded.

The two visitors change together because separating them regresses the corpus.
Promoting nested slots exposes entries whose stack is only *reduced*, not
removed - a slot touched by an `UpdateExpression` is marked invalid by design
and no arity fact can rescue it - while reconstruction rebuilds the entry with
the pattern's identifiers as its parameters and so drops the rest param the
stack lived in. Without the dispatcher-side guard those bodies reconstruct
reading a parameter that is no longer there.

Rewriting the promoted slots' other references in the same call is the third
part and the one that is not visible from reading: `cache` is rebuilt by
`initStackCache` on every `tryStackReplace` call, so a registration made here
never survives to the next iteration, and by then the assignment that would
re-register it is gone. References the traversal already walked past - notably
inside a nested function declared above the unpack line - would otherwise be
left addressing a stack nothing populates.

96-sample high-preset corpus, same-input A/B: 96/96 runtime-correct either
way, 671061B -> 660422B, 433 -> 267 residual array reads.

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

A slot `checkStackInvalid` marks invalid is unfoldable, not unresolvable, and
treating the two as the same thing left every such entry half-unmasked. The
marking is scoped to value substitution - nothing can be substituted into
`stk[3]++` - while `unmaskStack` never reads it, promoting each remaining slot
to a real local that `_local++` satisfies. Nothing was wrong upstream; the
routine that resolves these was simply unreachable, since `deVariableMasking`
gates it on a truncation statement and Dispatcher marks its entries PREDICTABLE
so one is never emitted.

Driving it from `parseFnsEntry` is the same move the file already makes with
`processStackParam`, and for the same reason: the entry is anonymous, zero-arity
and never called, so no arity is inferable from it, while this template knows
structurally that its entries have zero params. That is the exact length
`unmaskStack` requires - it refuses inferred counts, not exact ones.

The surviving-stack guard stays as a genuine fail-safe: `unmaskStack` has its
own declines (`arguments` use, an observable stack), and a slot surviving one
of those is still unreconstructible.

96-sample corpus: 96/96 either way, 660422B -> 660038B, 267 -> 201 residual
array reads, guard declines 6 -> 0. Reconstruction is unchanged at 9 dispatchers
/ 34 entries - the six moved to the reconstructibility check ahead of it, which
reads body[0] and now finds the locals declaration unmaskStack unshifts.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`fnsEntryIsReconstructible` and the entry match below it both read `body[0]` and
accepted only the declaration spelling, so every application that had been through
VariableMasking or MovedDeclarations declined - 70 entries corpus-wide, not one of
them at index 0 and not one still carrying stack slots.

Neither degree of freedom was optional. The assignment spelling is the encoder's:
VariableMasking's `replaceDefiningIdentifierToMemberExpression` replaces the whole
declaration with an expression statement. The displacement is partly ours, from
`unmaskStack`'s unshifted locals. What may be skipped over reuses
`unmaskDestructuredRest`'s condition rather than a looser one.

Two preconditions had been satisfied only by accident, because these entries
declined before anything looked at them - both surfaced as wrong output, not as
declines, the moment the unpack line became locatable. An entry can capture the
dispatcher's own `fnLengths` parameter through a surviving scope anchor, which
reconstruction would leave unbound; that fails closed. And an entry can already
carry a parameter list restored by our own `unmaskDestructuredRest`, which
reconstruction replaced outright; since the template only ever calls `fns[name]()`,
those names are always undefined on entry and are re-bound as the locals they are.

Corpus, same frozen input: 96/96 runtime-correct throughout, 660038B -> 628718B,
201 residual array reads unchanged, unbound references back to the encoder's own
three. Entry declines 70 -> 13.

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

safeDeleteNode guarded its entry lookup but not the lookup it repeats after
binding.scope.crawl(). A caller that resolved its target before a pass rewrote
the enclosing function body holds a binding registered against the old body; the
crawl rebuilds from the new one, and when the rewrite dropped the name the
refreshed lookup returns undefined. Reading .references off it took the whole
pass down, which is why the call-harness collapse works around it by collecting
deletion paths up front.

The mechanism recorded in that workaround's comment was wrong, and it named a
non-fix: this is not Babel's child-path cache going stale, so a path-based
get('body').replaceWith() behaves identically and fixes nothing. Measured with
sandbox-tests/mask/repro-safedelete.mjs across both rewrite forms and all three
crawl orderings.

Declining matches the answer the entry guard already gives for a name that binds
nowhere - the same state, discovered one step later. Corpus output is
byte-identical (96/96, 628718B), since nothing in the pipeline reaches this path
today; it is #10/#3/#4 that will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The scope object's own `scope[k] = {}` sub-scope initializers were left standing.
matchScopeMemberChain deliberately requires two levels, so flattenScopeMembersInGraph
never rewrites a one-level initializer, and the scope object's binding does not
survive this decode - its parameter slot is replaced wholesale.

That is why cleanupOrphanedScopeAnchors could not remove them. By the time it runs,
`scope` in a leftover `scope.k = {}` resolves up the scope chain to an unrelated
same-named entity, and the guard weighs that entity's references instead. Measured
corpus-wide before this change: 266 anchors seen, 31 declined, of which 30 on
`escapes` and 1 on `opaqueKey` - and zero on a genuine read of the anchor's own key.
On one sample the resolved holder was a live UTF-8 encoding helper that has nothing
to do with the anchor.

Dropping them where they are produced needs no name resolution at all: scopeName is
already resolved from the interpreter's own shape, so there is nothing to
misresolve. Removal is a filter on the array foldBranchesInGraph emits, not a tree
mutation, so no scope work is involved. Fails closed as a whole on a bare scope
reference or an unreadable key, since either makes every key's liveness unknown
rather than one key's.

Corpus, same frozen input: 96/96 throughout, 628718B -> 621768B, 201 -> 191 array
reads. Anchors reaching the Program cleanup 266 -> 64 with declines 31 -> 1;
survivors in decoded output 16 -> 9. Dispatcher fns-entry declines 13 -> 12, the
capture guard 2 -> 1 - the one that closed was a dissolved scope object, the one
left genuinely addresses a dispatcher parameter. unbound.mjs A/B'd on the three
affected samples: identical, nothing introduced.

The two fixture updates are pure removals of the dead initializers, runtime-verified
against their .src.js.

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

dropDeadScopeAnchorsInGraph inspected every Identifier sharing the scope object's
name, so three unrelated things read as the holder being used as a bare value -
which is a bail, failing that whole graph's anchors closed:

  var <name>                      a declarator id
  function <name>(...)            a function's own name
  function (a, <name>, c)         a nested function's parameter

None is a reference. Breadcrumbing the bails made this unmistakable: the "live
keys" they reported were indexOf, length, key, val - Array and String members, not
the random slot keys a scope object has. RenameVariables hands out short names that
collide freely across scopes, so this is the common case, not an edge one.

Two filters, in order: the identifier must be a reference at all, and it must
resolve to the holder's own binding, taken from an anchor site rather than from the
name. Name matching now stands in only when the holder has no resolvable binding,
and then only to fail closed - an unrelated reference can add a live key or force a
bail, never authorise a drop.

Corpus, same frozen input: 96/96 throughout, 621768B -> 617081B, mean ratio x28 ->
x27, 191 array reads unchanged. Anchors dropped at the graph 205 -> 271, with bails
28 -> 0 and no anchor kept for a live key. Surviving anchors in decoded output
9 -> 0. Dispatcher fns-entry declines 12 -> 11 with the capture guard now firing
nowhere, and 98 entries reconstructed: the one decline previously written up as a
genuine dispatcher-parameter capture was the same misresolution one level down, and
binding identity dissolves it too. unbound.mjs unchanged.

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

`unmaskDestructuredRest` required every element of the folded `[a, b] = rest`
pattern to be a plain identifier, so a default (`[a, b, c, d = {}] = rest`)
declined the whole function. That is not an edge case: it is every dispatcher
this pipeline's own control-flow-graph reconstruction hands on, since the
Dispatcher template's fourth parameter carries an object default. 29 of them
corpus-wide, one cause, measured at the bail.

A destructuring default and a parameter default fire on the same condition, so
the two spellings mean the same thing. What does not simply move is the default
*expression*: a non-simple parameter list gets its own scope that cannot see the
body's `var`s, and a body with a directive cannot have one at all. Both are
refused rather than relocated.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The graph carries a function's implicit "and then it ends" as an explicit
`return` node, so every reconstructed body ended in a `return;` / `return
undefined;` doing exactly what falling off the end already does. The call-harness
collapse then adds one of its own, correctly where the spliced statements would
fall through to something and pointlessly where they are the tail of a function
body.

Dead is not harmless here: it displaces the real last statement, which is what a
matcher reading a template's roles from the end of a body has to see. That is an
Upstream Effect of ours, so it is fixed at the pass that emits it rather than
tolerated in each matcher it defeats.

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

ControlFlowFlattening re-spells a variable it moves into its scope object as
`scope["a"]["b"]`, and has to re-guard a direct call of one as
`(1, scope["a"]["b"])()` so the member access does not supply a `this` the
original call never had (controlFlowFlattening.ts, "Preserve proper 'this'
context when directly calling functions").

`flattenScopeMembersInGraph` replaces that chain with a plain identifier, for
which `(1, f)()` and `f()` are the same call - so the guard's whole reason is
gone and it is left as noise on every such call site. Worse than noise for a
consumer: it puts a SequenceExpression where the reference should be, which is
what walked the dispatcher matcher past its own call sites. Unwrapped here, at
the pass that strands it.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`deDispatcherInit` scanned a block for a `FunctionDeclaration`, and a
`FunctionDeclaration` is one spelling rather than the spelling: this pipeline's
own control-flow-graph reconstruction emits `X = function (...) {...}` beside a
hoisted `var X`, and the dispatcher is as flattenable as anything else
(ControlFlowFlattening is encoder Order 24, Dispatcher Order 6). 29 dispatchers
corpus-wide never reached the matcher at all.

The name now comes from the holder's binding rather than from the assignment's
left-hand text: `resolveBindingFunction` is asked what the binding defines and
the candidate is taken only when that is this very function, so a binding written
more than once declines - reconstruction deletes the holder, and a second write
would mean deleting someone else's definition with it.

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

`isDummyPredicateFn` - duplicated by design in opaque-predicates.js and
dead-code.js - required a niladic, empty top-level `FunctionDeclaration`. That
is the encoder's spelling, and it is a proxy this decoder's own passes break
twice over: control-flow-graph.js reconstructs the anchor as `var X;` +
`X = function (...r) {}`, so `binding.path` is an init-less declarator and the
arity is a rest parameter we added. 69 anchors carrying 192 guard sites were
walked past corpus-wide.

The soundness condition was never the shape. `"p" in X` reads false exactly
while nothing adds `p` to `X`, so the test is now that every reference to the
binding is an `in` test's own right operand - never a member base, a callee, or
a value handed somewhere that could write to it. Measured first: all 192 sites'
anchors already satisfy it. The arity is not consulted at all, since an empty
body's own-property set does not depend on its parameter list.

Nothing else was needed for the dead helpers behind those guards: both matchers
already resolved *those* through `resolveBindingFunction` and swept them at
`Program: exit`. Only the anchor was blind.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`findUnpackLine` could only skip initializer-less declarations on its way to the
entry's unpack line, so a hoisted `FunctionDeclaration` or an `X = function (){}`
assignment above it declined the entry - and with it the whole dispatcher, the
match being all-or-nothing. 16 entries across 14 dispatchers corpus-wide.

The condition was guarding the right hazard with the wrong test. What breaks the
rewrite is a *read* of a promoted name reaching the program ahead of the unpack
line: it sees `undefined` today and would see the argument once the line is
dropped and the names become parameters. Declaring a function does not read
anything, and building a closure evaluates neither its body nor its parameter
defaults - the reads inside happen when it is called, which nothing above the
line does. An `if` can run arbitrary code and is still refused; one entry in the
corpus declines on exactly that, correctly.

Inertness is only half of it. A skipped statement that *binds or writes* a
promoted name would survive the rewrite while the unpack line does not, becoming
the last word on that name instead of being overwritten by the payload, so those
are refused too - excluding declarations, which the assignment form requires and
`stripPromotedDeclarators` removes.

Dispatchers matched 64 -> 77 of 78, entries reconstructed 146 -> 161, corpus
96/96 at 390809B -> 374771B, `unbound.mjs` still zero.

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

MovedDeclarations (encoder Order 25) can pack a declaration onto the enclosing
function's parameter list. The binding then reads `kind: 'param'` with
`binding.path` pointing at the parameter's own Identifier, and the real
declaration demoted to a constant violation - so three separate checks here were
asking the wrong node:

  - `resolveWrapperBinding` compared `binding.path === fnPath` and declined a
    wrapper that was otherwise entirely matchable;
  - the decode-fn check read `isFunctionDeclaration()` off the parameter and saw
    false for something that is one;
  - `collectProgramDeps` filtered the same way, so a transitive dependency in
    that spelling would have been dropped from the bundle silently, and
    `addDependency` would have emitted a bare parameter name where a definition
    belongs.

All four now go through one `declarationPath` helper over
`utility/binding-def.js`, which is what reads a binding's real definition. Only
the first was reachable on the corpus; the other three are the same defect found
by grepping for it rather than by waiting for it.

Corpus 96/96 at 374771B -> 340069B, residual `arr[n]` reads 25 -> 0, wrappers
1949 matched / 1949 replaced, `unbound.mjs` zero. The dispatcher's last entry
decline closes with it: the guard it hit was DeadCode's, unrecognisable only
because this pass had left its key string concealed.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`deDispatcher` is scheduled immediately before this matcher and after the CFF
decode, so a CFF-flattened dispatcher is already back in template shape by the
time it runs - and it reverses every call-site spelling `createDispatcherCall`
emits, including the two this collapse was written to grow into. The narrow
collapse therefore matched nothing: zero call sites across the 96-sample `high`
corpus, and zero on the fixture written for it, whose output it turns out not to
have produced. Removing it leaves the corpus byte-identical.

What that slot still owes is the second `cleanupOrphanedCffHelpers` sweep, which
is genuinely load-bearing: the dispatcher template is routinely the last holder
of a CFF runtime helper's reference, so the sweep inside the CFF decode cannot
reach those helpers and only this one can. The pass is renamed for what it does.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The Program-level sweep looked only for a FunctionDeclaration, which is how
dispatcher.ts prepends the helper - but ControlFlowFlattening (Order 24, well
after Dispatcher's Order 6) can outline that declaration into its own switch/case
table like any other, and an outlined function reads back as a plain assignment
against a hoisted declarator. On a `high` corpus that is the only spelling the
helper survives in, so the sweep declined on every one of them.

No output moves on its own: the reconstruction still hands these functions a rest
parameter, which the shape check refuses. The companion commit removes that.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
An outlined nested function was given the shared dispatcher's rest parameter
unconditionally, so a function the original source declared with no parameters
decoded to `function (...arg) {}`. The parameter is invented: a rest parameter
contributes nothing to `fn.length` and a body that never mentions the name
cannot observe it, so emitting one only re-spells a niladic function as a
variadic one - and every later matcher reading for the niladic shape then
declines. Fixed at the pass that emits it rather than tolerated per consumer,
the same as dropTrailingDeadReturn beside it.

The visible consequence is dispatcher.js's d_fnLength sweep, whose empty-no-op
shape check requires zero parameters: the helper now reaches it in the shape the
encoder emitted, and 30 of the 31 orphaned stubs across the 96-sample `high`
corpus go with it. The survivor is a different animal - a real function whose
body only becomes empty two passes later - and is left alone rather than absorbed
by widening that matcher.

Corpus: 96/96 runtime-correct, 340069B -> 338767B, zero unbound references.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…meter use

statementsMentionName counted any Identifier spelling the name, so a wrapper
containing another decoded function was always reported as using its own rest
parameter. That is not an edge case: every wrapper in one recursion is handed the
same ctx.argName, so a nested wrapper binds the identical name by construction,
and the scan therefore answered "used" for exactly the case the caller needs
decided - any function with a decoded function inside it.

It now stops at a function that rebinds the name, which is reading someone else's
variable. Only parameter shadowing is modelled; a nested `var name` still counts
as a use, which keeps a redundant parameter rather than dropping a read one.

Closes the last empty-function orphan in the corpus. It was DeadCode-injected
fake code whose guard dead-code#2 correctly removes, orphaning the closure inside
it and emptying the body - and the invented rest parameter was the only thing
keeping the Program-level empty-helper sweep off it.

Corpus: 96/96 runtime-correct, 338767B -> 338724B, S3 orphans 1 -> 0 across all
96 samples, zero unbound references.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Removing a matched entry harness left two shapes standing in every
reconstructed body it was MovedDeclarations-hoisted in. Both are dead only
because the harness went, so both belong at the removal site rather than in a
matcher: the bare `var didReturn, result;` declarators Mechanism 1 pushes onto
the enclosing block's leading `var` statement, and the `didReturnVar = true`
writes Stage 2 wrapped around returns nested inside statements CFF copied
through opaquely, which `parseReturnValue` never sees.

Gated on the flag being unread rather than on which decode path produced it -
that is the condition making the writes unobservable, and it declines on an
inline-flattened function's still-live flag without knowing that path exists.

Corpus: 1200 wrapped returns and 321 dead declarators removed across 96
samples, 338724B -> 315048B, mean ratio x15 -> x14, 96/96 still runtime-correct
with zero unbound references.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The pass matched nothing on a `high` sample: its switch function is sealed
inside the ControlFlowFlattening interpreter where it runs, so the population
there is zero. Running it again straight after the CFF decode is not enough
either - what that decode hands back fails every gate the matcher has at once
(rest param, string-decode wrappers standing ahead of the switch, a
discriminant that is not the param, case tests still spelled as wrapper calls).
Measured per stage, the candidate shape first exists after StringConcealing's
second visit and the full match only after the fold following it, so the second
visit is scheduled there.

Two matcher fixes the newly-reachable path needed. The sniffer is identifiable
only as the callee of globalVar's initializer, and MovedDeclarations splits that
declaration, so reading node.init alone left it as a zero-reference orphan.
And one reference can be registered twice - 50 referencePaths over 49 distinct
nodes on a real sample - where replacing the second occurrence resynced to a
null key and threw inside Babel's validator rather than declining.

Corpus: 315048B -> 165901B, mean ratio x14 -> x7, zero residual GlobalConcealing
switch functions across all 96, 96/96 runtime-correct, zero unbound references.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The CFF decode hands back the masked stack as a bare declaration plus a
separate copy statement, so removing the copy is what makes the declaration
dead - and the pass that creates the deadness is the one that should clear it,
rather than every downstream matcher learning to look past a leading dead var.

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

Keying the visitor on FunctionDeclaration meant it never fired on a high
sample, where the CFF decode hands the switch function back as a hoisted var
plus an assignment - not a declined match but no match attempted. Driving it
from what the binding defines is what the sibling visitors already do, and it
is fail-closed on a re-assigned holder, whose call sites are not all reading
the function the match was built from.

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

The scan read two statement shapes and the doc described it as binding-driven,
which it was not: var d = function (){} - what the CFF decode leaves when
nothing afterwards splits it - was walked past, and the matcher was never
called at all rather than declining. Byte-identical across the corpus today,
so this is reach rather than behaviour.

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

A two-statement body test read the MovedDeclarations-split flat object as a
different pattern and declined before any real matching, on roughly half of
high runs. Reading the return backwards and resolving the object through its
binding accepts both spellings, and fails closed on a second write - where the
object handed to flatFn would not be the one read here.

Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The CFF decode rebuilds a swallowed function as a declarator or an assignment,
which a binding.path.isFunctionDeclaration() gate refused - 12 of 12 runs under
flatten+controlFlowFlattening, with the wrapper above already matched. Reach
rather than behaviour: byte-identical across the corpus, because the same
population then declines one step later in extractGShape.

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

control-flow-graph.js emits [flatObject, [a, b]] = rest for a function whose
original parameter list held a pattern, and readFoldedElement accepted only
identifiers - so every such function stayed rest-masked and each consumer met a
shape it could not read. The element moves into the parameter list unchanged:
the same destructuring, one step earlier. Closes the half of checkpoint 6.3(b)
that no binding could reach.

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

Minify (encoder Order 28, later than GlobalConcealing's Order 12) rewrites
globalVar["Math"] to globalVar.Math wherever the key is a valid identifier.
The matcher is all-or-nothing, so requiring the computed form let a single
minified case among forty leave an entire GlobalConcealing layer undecoded -
4246B to 1308B on the worst affected sample.

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