From 391270aa7b914c677caadc3485ec8ae9ca426c58 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:44:32 +0100 Subject: [PATCH 01/18] test(decode-js): detect stale references, and give the harness a pipeline runner The oracle goes in before the fixes it makes findable, which inverts the usual order and is right only where a defect is invisible to output. A pass can rewrite the tree correctly and leave the derived state beside it inconsistent; the emitted text is then perfect byte for byte while every later pass consulting that state decides against a program that no longer exists. The cheap checks do not see it. A stale reference reports `removed === false` and its cached parent chain still reaches a Program, so only asking whether the node is reachable from the live tree works. `helper.test.js` exists to prove the detector can fail - a check that only ever reports zero is indistinguishable from one whose population is empty. `getPipelineResult` runs several passes on ONE AST because the other two helpers cannot reach the failure class a real pipeline has: a fixture built by running earlier passes and writing the result to disk is certified across a re-parse, which rebuilds every path from text and repairs exactly the state a pipeline carries forward. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- test/helper.js | 170 ++++++++++++++++++++++++++++++++++++++++++-- test/helper.test.js | 35 +++++++++ 2 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 test/helper.test.js diff --git a/test/helper.js b/test/helper.js index 44e7fba2..efdf1ae4 100644 --- a/test/helper.js +++ b/test/helper.js @@ -3,6 +3,81 @@ import { expect } from 'vitest' import { parse } from '@babel/parser' import generate from '@babel/generator' import traverse from '@babel/traverse' +import * as t from '@babel/types' + +// Every node reachable from the Program right now. +function liveNodes(ast) { + const live = new Set() + const walk = (node) => { + if (!node || typeof node.type !== 'string' || live.has(node)) return + live.add(node) + for (const key of t.VISITOR_KEYS[node.type] || []) { + const value = node[key] + if (Array.isArray(value)) value.forEach(walk) + else walk(value) + } + } + walk(ast.program || ast) + return live +} + +// Node objects reachable from the Program at more than one position. +// +// An AST is a tree, not a DAG, and a pass breaks that by re-homing a node and then handing one of +// its own subtrees back to another path API - `insertBefore(path.node)` followed by +// `replaceWith(path.node.left)` puts that `left` in two places at once. The text still generates +// correctly, so nothing about the output betrays it. +// +// **No crawl repairs this**, which is what separates it from `detachedReferences` above: a crawl +// records the node twice because it genuinely is reachable twice. The damage is deferred to +// whichever later pass resolves both occurrences - replacing the second finds its parent slot +// already holding what the first replacement put there, the path resyncs to a null key, and +// Babel's validator throws. Clone the subtree rather than re-using it. +export function aliasedNodes(ast) { + const seen = new Set() + const aliased = [] + const walk = (node) => { + if (!node || typeof node.type !== 'string') return + if (seen.has(node)) { + aliased.push(node.type) + return + } + seen.add(node) + for (const key of t.VISITOR_KEYS[node.type] || []) { + const value = node[key] + if (Array.isArray(value)) value.forEach(walk) + else walk(value) + } + } + walk(ast.program || ast) + return aliased.sort() +} + +// Bindings still pointing at nodes that are no longer in the tree. +// +// A pass that removes nodes leaves the bindings of *other* names holding references into the +// subtree it detached, and every later pass reading `binding.referencePaths` then decides against a +// program that no longer exists. Measured on a real pipeline, the first removing pass detached 90 +// of 320 references at a stroke and no later pass cleared them by itself. +// +// Reachability is the only check that sees it, which is why this walks the tree rather than asking +// the path. A stale reference reports `removed === false`, and `ref.find((p) => p.isProgram())` is +// truthy, because the cached parent chain still links it to a Program path object. +export function detachedReferences(ast) { + const live = liveNodes(ast) + const stale = [] + traverse(ast, { + Scopable(path) { + const { bindings } = path.scope + for (const name of Object.keys(bindings).sort()) { + for (const ref of bindings[name].referencePaths) { + if (!live.has(ref.node)) stale.push(`${name}: ${ref.node.type}`) + } + } + }, + }) + return stale.sort() +} // Snapshot every binding's reference bookkeeping, reading scope as Babel has // it cached — i.e. the state the visitor left behind. Babel does not re-crawl @@ -28,24 +103,107 @@ function referenceState(ast) { return state } +/** + * Assert the half of a pass's contract that its output text cannot show. + * + * A pass rewrites the tree *and* the derived state Babel keeps beside it. Leave that inconsistent + * and the emitted text is perfect byte for byte, while every later pass consulting it decides + * against a program that no longer exists. Three independent checks, because they fail differently + * and no one of them sees the others: + * + * - `detachedReferences` - a binding still points at a node the tree no longer holds; + * - `aliasedNodes` - one node object sits at two positions, which no crawl repairs; + * - `referenceState` - the cached reference bookkeeping disagrees with a fresh parse of this + * pass's own output, which is what an inflated or lost reference count looks like. + * + * **Exported so a runner that cannot use the helpers below still inherits the audit.** Passes here + * come in two shapes - a Babel visitor object, and a function taking the AST - and `getVisitorResult` + * only accepts the first. A test for a function-shaped pass therefore had to roll its own runner, + * and every such runner silently opted out of all three checks. That is how four visitors shipped + * inflated reference counts: not because the oracle was missing, but because it was only reachable + * from a helper their pass could not be passed to. + * + * `cmpCode` is the expected output. **Omit it and the comparison is made against a fresh parse of + * the tree's own generated output instead**, which is the invariant stated literally and needs no + * golden: after a pass, its derived state should equal what a fresh parse of its own output would + * produce. That matters where a golden cannot honestly be reviewed - a fixture harvested from real + * obfuscated output is thousands of bytes of generated identifiers, and committing a `.fix.js` + * nobody can read only makes exact string equality look authoritative. Self-comparison pins the + * claim that is actually being made about such a case, and no more. + */ +export function expectConsistentState(ast, cmpCode, parseOptions = {}) { + expect(detachedReferences(ast)).toEqual([]) + expect(aliasedNodes(ast)).toEqual([]) + const expectedSource = cmpCode === undefined ? generate(ast).code : cmpCode + expect(referenceState(ast)).toEqual( + referenceState(parse(expectedSource, parseOptions)), + ) +} + export function getVisitorResult(visitor, fix, input) { const sourceCode = fs.readFileSync(input + '.js', { encoding: 'utf-8' }) const ast = parse(sourceCode) traverse(ast, visitor) + // The state audit applies to no-op cases too, and deliberately: "output unchanged" is not + // "nothing happened", so a visitor that removes and rebuilds can leave stale references while + // the text round-trips. Only the reference-state comparison is fix-only - a case that does not + // mutate has nothing to compare against. + // + // This catches what the visitor inflicts on ITSELF. Staleness inherited from an earlier pass + // cannot arise here — one visitor, one fresh parse — so it is getPipelineResult that covers the + // class a real pipeline has. if (fix) { const cmpCode = fs.readFileSync(input + '.fix.js', { encoding: 'utf-8' }) expect(generate(ast).code).toBe(cmpCode) - // Reference integrity (fix cases only): the transformed AST's scope must - // match a fresh parse of the expected output. A missing or mis-scoped - // crawl() leaves stale reference counts that this catches even though the - // generated text is identical. No-op (fix === false) cases don't mutate - // the tree, so the check would be redundant there. - expect(referenceState(ast)).toEqual(referenceState(parse(cmpCode))) + expectConsistentState(ast, cmpCode) } else { expect(generate(ast).code).toBe(sourceCode) + expectConsistentState(ast) } } +/** + * Run several passes on ONE AST, the way the pipeline does, and check the result. + * + * `getVisitorResult` parses fresh and runs a single visitor, so no earlier pass has detached + * anything and inherited staleness is unreachable by construction. That is the class that actually + * bites: a pass certified alone, and against fixtures that were built by running the earlier passes + * and then *writing the result to disk*, is certified across a re-parse — and re-parsing rebuilds + * every path from text, silently repairing the one state a real pipeline carries forward. One pass + * here read clean on every residue axis and on runtime equivalence over a whole corpus that way, + * while being wrong on 108 cells the moment the same pipeline ran on a single AST. + * + * So the fixture input must be what the EARLIER passes leave in memory, never a pre-baked file. + * + * `passes` are applied in order; each is either a Babel visitor object or a function taking the + * AST, since the passes in this project come in both shapes. + */ +export function getPipelineResult(passes, fix, input) { + const sourceCode = fs.readFileSync(input + '.js', { encoding: 'utf-8' }) + const ast = parse(sourceCode, { + allowReturnOutsideFunction: true, + errorRecovery: true, + }) + for (const pass of passes) { + if (typeof pass === 'function') pass(ast) + else traverse(ast, pass) + } + const cmpCode = fix + ? fs.readFileSync(input + '.fix.js', { encoding: 'utf-8' }) + : sourceCode + expect(generate(ast).code).toBe(cmpCode) + // The same audit `getVisitorResult` runs, and it matters more here, not less: this helper exists + // to model a real pipeline, and staleness inherited from an earlier pass is the class it was + // built to expose. Checking only the output text left that class unmeasured - four visitors + // shipped inflated reference counts past this helper, and the defect surfaced instead as a + // deleted declaration in a corpus sweep. + expectConsistentState(ast, fix ? cmpCode : undefined, { + allowReturnOutsideFunction: true, + errorRecovery: true, + }) + return ast +} + export function getPluginResult(plugin, fix, input) { const sourceCode = fs.readFileSync(input + '.js', { encoding: 'utf-8' }) const out = plugin(sourceCode) diff --git a/test/helper.test.js b/test/helper.test.js new file mode 100644 index 00000000..96202432 --- /dev/null +++ b/test/helper.test.js @@ -0,0 +1,35 @@ +import { expect, test } from 'vitest' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' +import { detachedReferences } from './helper.js' + +/** + * The detector guards every visitor test, so it has to be shown capable of failing. A check that + * only ever reports zero is indistinguishable from one whose population is empty by construction, + * and that is the exact failure it exists to catch elsewhere. + */ + +test('detachedReferences is empty on a freshly parsed tree', () => { + const ast = parse('var a = 1;\nfunction f() {\n return a;\n}\nf();') + expect(detachedReferences(ast)).toEqual([]) +}) + +test('detachedReferences reports a reference detached by a node removal', () => { + const ast = parse('var a = 1;\nfunction f() {\n return a;\n}\nf();') + + // Cache the bindings first: this is the state a pass inherits from the one before it. + traverse(ast, { + Program(path) { + path.scope.crawl() + }, + }) + + // Detach the subtree holding the only reference to `a`, without telling Babel. + traverse(ast, { + FunctionDeclaration(path) { + path.node.body.body = [] + }, + }) + + expect(detachedReferences(ast)).toEqual(['a: Identifier']) +}) From b2caa53fafb61f88818e085ba2d32bf6a449b82d Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:48:07 +0100 Subject: [PATCH 02/18] fix(decode-js): repair binding bookkeeping after node re-homing Four shared visitors left `binding.references` inflated. Babel's insertion and replacement family - `replaceWith`, `insertBefore`, `insertAfter`, `unshiftContainer`, `pushContainer` - records a reference per call rather than per node, so a rewrite that re-homes a subtree books its references twice. The tree is correct and the printed output is byte-identical; what is wrong is the derived state every later pass consults. The consequence was not hypothetical. `parse-control-flow-storage` refuses to remove a declaration unless every reference resolved, and it was handed a list inflated past the live nodes - so the gate was satisfied while one live reference went unhandled, and the decoded program threw a ReferenceError. Each now crawls once on the way out, program-scoped and gated on whether it rewrote anything. Repairing at the producer rather than at the consumer is the point: a consumer-side repair has a placement question, and that question is an artifact of the wrong level. `split-variable-declaration` is the odd one - it has no `replaceWith` at all and was simply crawling the wrong scope. The same root cause was diagnosed and fixed in `split-assignment` alone eight months ago; these siblings carried it unfixed because a workaround repeated per consumer never gets priced as one defect. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/lint-if-statement.js | 39 +++++++++++++++++++++++ src/visitor/prune-if-branch.js | 33 ++++++++++++++++++- src/visitor/split-sequence.js | 30 ++++++++++++++++- src/visitor/split-variable-declaration.js | 32 ++++++++++++++++++- 4 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/visitor/lint-if-statement.js b/src/visitor/lint-if-statement.js index c3bc8e2e..0de69a89 100644 --- a/src/visitor/lint-if-statement.js +++ b/src/visitor/lint-if-statement.js @@ -1,5 +1,14 @@ import * as t from '@babel/types' +/** + * Whether this traversal rewrote anything, so the exit handler knows if a crawl is owed. + * + * Module-level because a visitor object carries no per-run state. Safe because every consumer + * invokes this as a standalone, synchronous `traverse(ast, lintIfStatement)` and `Program.enter` + * resets it at the start of each run. + */ +let rewroteSomething = false + function LintIfStatement(path) { let { test, consequent, alternate } = path.node let changed = false @@ -15,9 +24,39 @@ function LintIfStatement(path) { return } path.replaceWith(t.ifStatement(test, consequent, alternate)) + rewroteSomething = true } +/** + * The crawl restores an invariant this rewrite breaks: after a pass, the scope information Babel + * has cached should equal what a fresh parse of that pass's own output would produce. + * + * `replaceWith` is handed a new `IfStatement` built from the old node's own `test`, `consequent` + * and `alternate`, and it registers those reused subtrees' references a *second* time. Nothing is + * detached and nothing sits at two positions - `binding.referencePaths` simply lists the same live + * node twice, and `binding.references` counts it twice. Measured on one real sample: 139 duplicate + * entries, 1587 recorded references against 1448 that exist. + * + * That is invisible to output, which is what makes it dangerous rather than untidy. A later + * consumer asking "have I resolved every reference to this binding, may I delete its declaration?" + * compares its own tally against the inflated one, and the extra entries let that check pass while + * a live reference goes unhandled - deleting a declaration the program still needs, with no + * diagnostic and well-formed output text. + * + * The crawl must be **program-scoped and once per traversal**. Crawling a narrower scope makes it + * worse: it appends to outer-scope bindings that already hold those references. Gated because a + * crawl is only owed when something moved, and a crawl cannot change the tree - so this is + * invisible to output and costs nothing on a traversal that rewrote nothing. + */ export default { + Program: { + enter() { + rewroteSomething = false + }, + exit(path) { + if (rewroteSomething) path.scope.crawl() + }, + }, IfStatement: { exit: LintIfStatement, }, diff --git a/src/visitor/prune-if-branch.js b/src/visitor/prune-if-branch.js index 620540f5..1bd2a3d9 100644 --- a/src/visitor/prune-if-branch.js +++ b/src/visitor/prune-if-branch.js @@ -37,8 +37,18 @@ function replaceWithBranch(path, branch) { path.replaceWithMultiple(branch.body) } +/** + * Whether this traversal detached anything, so the exit handler knows if a crawl is owed. + * + * Module-level because a visitor object carries no per-run state. Safe because every consumer + * invokes this as a standalone, synchronous `traverse(ast, pruneIfBranch)` and `Program.enter` + * resets it at the start of each run. + */ +let detachedSomething = false + function pruneIfBranch(path) { function clear(path, toggle) { + detachedSomething = true // 判定成立 if (toggle) { replaceWithBranch(path, path.node.consequent) @@ -71,9 +81,30 @@ function pruneIfBranch(path) { /** * Prune the branch if the test is constant * - * The code must be reloaded to update the references + * Removing a branch detaches its subtree, and Babel leaves every *other* binding's + * `referencePaths` pointing into it. Such a reference reports `removed === false` and its cached + * parent chain still reaches a Program, so only node reachability can see the difference — which + * is why this went unnoticed long enough to be documented as a caller's problem rather than + * repaired here. It is not a caller's problem. Of the four consuming plugins, three re-parse the + * whole program immediately after calling this visitor (`// 刷新代码`) — the same repair written + * out three times — and the fourth does not re-parse at all, so it carried the exposure with + * nothing answering for it. A fifth consumer, composing on a single AST, inherited stale bindings + * and emitted code that threw. + * + * So the invariant is restored where it is broken: one `scope.crawl()` on the way out, and only + * when something was actually detached. A crawl cannot change the tree — it rebuilds cached scope + * information — so this is invisible to output and costs nothing on a traversal that pruned + * nothing. */ export default { + Program: { + enter() { + detachedSomething = false + }, + exit(path) { + if (detachedSomething) path.scope.crawl() + }, + }, IfStatement: pruneIfBranch, ConditionalExpression: pruneIfBranch, } diff --git a/src/visitor/split-sequence.js b/src/visitor/split-sequence.js index 5ffdad73..64d19dd9 100644 --- a/src/visitor/split-sequence.js +++ b/src/visitor/split-sequence.js @@ -1,5 +1,11 @@ import * as t from '@babel/types' +/** + * Whether this traversal split anything, so the exit handler knows if a crawl is owed. Module-level + * because a visitor object carries no per-run state; `Program.enter` resets it per run. + */ +let splitSomething = false + function doSplit(insertPath, path) { const expressions = path.node.expressions const lastExpression = expressions.pop() @@ -7,7 +13,7 @@ function doSplit(insertPath, path) { insertPath.insertBefore(t.expressionStatement(expressions.shift())) } path.replaceWith(lastExpression) - insertPath.scope.crawl() + splitSomething = true } function splitSequence(path) { @@ -47,7 +53,29 @@ function splitSequence(path) { * - VariableDeclarator * - ReturnStatement * - ExpressionStatement + * + * **The crawl restores an invariant this rewrite breaks**: after a pass, the scope information + * Babel has cached should equal what a fresh parse of that pass's own output would produce. + * `insertBefore` and `replaceWith` are both handed expressions lifted out of the existing sequence, + * and re-homing a subtree registers its references a second time - so `binding.referencePaths` + * lists the same live node twice and `binding.references` counts it twice, with nothing detached. + * Measured on one real sample: 32 duplicate entries, 1480 recorded references against 1448 real + * ones. A later consumer that gates a deletion on "have I resolved every reference" then passes + * that check while a live reference goes unhandled, and deletes a declaration still in use. + * + * It replaces a `scope.crawl()` that used to run inside `doSplit`, which was wrong twice over: it + * fired once per split rather than once per traversal, and it was scoped to the insertion point's + * scope rather than the program's - and crawling a narrower scope *adds* duplicates, by appending + * to outer-scope bindings that already hold those references. */ export default { + Program: { + enter() { + splitSomething = false + }, + exit(path) { + if (splitSomething) path.scope.crawl() + }, + }, SequenceExpression: splitSequence, } diff --git a/src/visitor/split-variable-declaration.js b/src/visitor/split-variable-declaration.js index 1d61a8b4..bbe42b49 100644 --- a/src/visitor/split-variable-declaration.js +++ b/src/visitor/split-variable-declaration.js @@ -1,5 +1,11 @@ import * as t from '@babel/types' +/** + * Whether this traversal split anything, so the exit handler knows if a crawl is owed. Module-level + * because a visitor object carries no per-run state; `Program.enter` resets it per run. + */ +let splitSomething = false + function splitVariableDeclaration(path) { // The scope of a for statement is its body if (path.parentPath.isFor()) { @@ -18,14 +24,38 @@ function splitVariableDeclaration(path) { path.insertBefore(t.variableDeclaration(kind, [item])) } path.remove() - path.scope.crawl() + splitSomething = true } /** * Split the VariableDeclaration if it has more than one VariableDeclarator * * This operation will only be performed when its container is an array + * + * **The crawl restores an invariant this rewrite breaks**: after a pass, the scope information + * Babel has cached should equal what a fresh parse of that pass's own output would produce. Each + * declarator is re-homed into a new declaration by `insertBefore`, and re-homing a subtree + * registers its references a second time, with nothing detached - the same live node listed twice. + * + * **This replaces a `path.scope.crawl()` that used to run here and made things worse.** All three + * options were measured on one real sample against a 1448-reference baseline: the old + * `path.scope.crawl()` gave **37 duplicates** and 1485 references, because crawling a scope + * narrower than the program appends to outer-scope bindings that already hold those references; + * removing the crawl entirely **lost** references, reporting 1096; and one program-scoped crawl on + * the way out gives exactly 1448 with none duplicated. Note that `path` has been removed by then, + * so its own `.scope` is not the right thing to ask - `Program.exit` is. + * + * Gated because a crawl is only owed when something moved, and a crawl cannot change the tree - so + * this is invisible to output and free on a traversal that split nothing. */ export default { + Program: { + enter() { + splitSomething = false + }, + exit(path) { + if (splitSomething) path.scope.crawl() + }, + }, VariableDeclaration: splitVariableDeclaration, } From 770077007a71049a2e05c12840488e330d78b0b8 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:48:31 +0100 Subject: [PATCH 03/18] fix(visitor/split-assignment): clone the target instead of re-using it The pass re-homes the whole assignment into an inserted statement and then handed `path.node.left` back to `replaceWith` - but that node is already live inside the statement just inserted, so the tree ended up holding **one node reachable at two positions**. Measured as two such nodes on one real sample. This is not the duplicate-reference class the siblings have, and a crawl cannot repair it: the tree genuinely holds the node twice, so the bookkeeping is an accurate description of a wrong tree. The hazard is a later pass resolving both occurrences - the second finds its parent slot already rewritten, resyncs to a null key, and throws inside Babel's validator. `t.cloneNode(node, true)` fixes it with output unchanged. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/split-assignment.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/visitor/split-assignment.js b/src/visitor/split-assignment.js index ebbe171c..1712aca3 100644 --- a/src/visitor/split-assignment.js +++ b/src/visitor/split-assignment.js @@ -59,7 +59,13 @@ function procAssignment(path) { return } insertPath.insertBefore(t.expressionStatement(path.node)) - path.replaceWith(path.node.left) + // Clone the target rather than re-using it. `path.node` has just been re-homed into the inserted + // statement, so `path.node.left` is already live there; handing that same node object back here + // would leave one node reachable at two positions - measured as two such nodes on one real + // sample. That is not a bookkeeping wart a crawl can repair, because the tree really does hold it + // twice: a later pass replacing both occurrences finds the second one's parent slot already + // rewritten, resyncs to a null key, and throws inside Babel's validator. + path.replaceWith(t.cloneNode(path.node.left, true)) // Crawl from the program scope: a moved assignment can reference bindings in // an enclosing scope, so crawling only insertPath.scope would leave those // outer bindings with stale reference counts. From 13609dad38bab3044b5c076a8b00f90143c789d7 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:48:44 +0100 Subject: [PATCH 04/18] refactor(visitor/split-assignment): crawl once per traversal, not per split It crawled the program scope after every split. The crawl is only needed once the traversal is done, so this gates it on whether anything was split and moves it to `Program.exit`, matching the shape the sibling visitors use. Safe because nothing in this pass reads scope state between splits: `getInsertPath` walks `parentPath` and tests node types and keys, never a binding. Output is byte-identical. Measured on one sample alongside the clone fix before it: 35ms to 16ms per run. Its reference count is legitimately two above the baseline afterwards - the rewrite really does create two new references - which is why the audit has to distinguish duplicate entries from entry count, or this reads as residue. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/split-assignment.js | 36 +++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/visitor/split-assignment.js b/src/visitor/split-assignment.js index 1712aca3..6390ceae 100644 --- a/src/visitor/split-assignment.js +++ b/src/visitor/split-assignment.js @@ -53,6 +53,12 @@ function getInsertPath(path) { return insertPath } +/** + * Whether this traversal split anything, so the exit handler knows if a crawl is owed. Module-level + * because a visitor object carries no per-run state; `Program.enter` resets it per run. + */ +let splitSomething = false + function procAssignment(path) { const insertPath = getInsertPath(path) if (!insertPath) { @@ -66,10 +72,7 @@ function procAssignment(path) { // twice: a later pass replacing both occurrences finds the second one's parent slot already // rewritten, resyncs to a null key, and throws inside Babel's validator. path.replaceWith(t.cloneNode(path.node.left, true)) - // Crawl from the program scope: a moved assignment can reference bindings in - // an enclosing scope, so crawling only insertPath.scope would leave those - // outer bindings with stale reference counts. - insertPath.scope.getProgramParent().crawl() + splitSomething = true } /** @@ -77,7 +80,32 @@ function procAssignment(path) { * * - In the test of IfStatement * - In the VariableDeclaration + * + * **The crawl restores an invariant this rewrite breaks**: after a pass, the scope information + * Babel has cached should equal what a fresh parse of that pass's own output would produce. The + * moved assignment is re-homed by `insertBefore`, and re-homing a subtree registers its references + * a second time - so a binding can end up listing the same live node twice, with nothing detached. + * A later consumer that gates a deletion on "have I resolved every reference to this binding" then + * passes that check while a live reference goes unhandled. + * + * **It must be program-scoped**, because a moved assignment can reference bindings in an enclosing + * scope and crawling only `insertPath.scope` would leave those outer bindings inconsistent - the + * defect this file was fixed for once already. + * + * **Once per traversal rather than once per split**, which is the only thing that changed since: + * the previous form crawled the whole program on every rewrite, so a sample with many splits paid + * for the entire program each time. Deferring is safe because nothing in this pass reads scope + * state - `getInsertPath` walks `parentPath` and tests node types and keys, never a binding - so no + * later invocation in the same traversal depends on the crawl having already run. */ export default { + Program: { + enter() { + splitSomething = false + }, + exit(path) { + if (splitSomething) path.scope.crawl() + }, + }, AssignmentExpression: procAssignment, } From 9b3b8448464dc85c3bf71e55360c4cb94d658ba3 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:50:57 +0100 Subject: [PATCH 05/18] fix(visitor/variable-masking): clone cached values before inlining them Unrelated to the javascript-obfuscator work this branch is otherwise about, and given its own commit for that reason: it was found in passing while auditing the insertion-and-replacement family across the repository, and bundling it into a feature's commit would mean neither could be reverted without the other. The pass inlined a cached value node directly at each use site, so one node object ended up reachable from every site that read it. Cloning per use is the fix. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/jsconfuser/variable-masking.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/visitor/jsconfuser/variable-masking.js b/src/visitor/jsconfuser/variable-masking.js index cb866ae5..a71cde33 100644 --- a/src/visitor/jsconfuser/variable-masking.js +++ b/src/visitor/jsconfuser/variable-masking.js @@ -140,7 +140,10 @@ function processAssignLeft( ref = cache[ref].value } if (cache[ref].type === 'value') { - right.replaceWith(cache[ref].value) + // Clone: the cache hands out one node object per entry, and inlining it directly puts that + // same node at every site the entry resolves. An AST is a tree, so a later pass resolving + // both occurrences replaces the first and then throws on the second. + right.replaceWith(t.cloneNode(cache[ref].value, true)) vm.evalSync(generator(father.node).code) cache[prop_name] = { type: 'value', @@ -350,7 +353,8 @@ function processReplace(cache, path, prop_name) { return true } if (type === 'value') { - path.replaceWith(value) + // Clone, for the same reason: `value` is the cache's own node and this runs once per use site. + path.replaceWith(t.cloneNode(value, true)) return true } if (type === 'localvar') { From 452ef0920159ae46d299789d203ee198860e26a2 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:50:57 +0100 Subject: [PATCH 06/18] test(visitor/parse-control-flow-storage): pin the resolving path The suite had one case for this visitor and it was a decline, so nothing pinned what the pass does when it succeeds - and its completeness gate is what a later defect turned out to hinge on. Three cases now cover one wrapper kind each: binary, call and logical. Each is real encoder output verified to run identically to its input, so a decode that resolved the wrong entry fails rather than merely looking different. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- .../parse-control-flow-storage.test.js | 38 ++++++++++++++++++- .../storage-binary.fix.js | 4 ++ .../storage-binary.js | 12 ++++++ .../storage-call.fix.js | 7 ++++ .../storage-call.js | 18 +++++++++ .../storage-logical.fix.js | 4 ++ .../storage-logical.js | 9 +++++ 7 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 test/visitor/parse-control-flow-storage/storage-binary.fix.js create mode 100644 test/visitor/parse-control-flow-storage/storage-binary.js create mode 100644 test/visitor/parse-control-flow-storage/storage-call.fix.js create mode 100644 test/visitor/parse-control-flow-storage/storage-call.js create mode 100644 test/visitor/parse-control-flow-storage/storage-logical.fix.js create mode 100644 test/visitor/parse-control-flow-storage/storage-logical.js diff --git a/test/visitor/parse-control-flow-storage.test.js b/test/visitor/parse-control-flow-storage.test.js index 33327ff4..3ce086d5 100644 --- a/test/visitor/parse-control-flow-storage.test.js +++ b/test/visitor/parse-control-flow-storage.test.js @@ -5,7 +5,41 @@ import parseControlFlowStorage from '#visitor/parse-control-flow-storage' const root = join(__dirname, 'parse-control-flow-storage') +/** + * The positive cases are real javascript-obfuscator 2.19.0 output, carried through the passes that + * run ahead of this visitor so each input is what it actually receives. One per wrapper kind the + * visitor implements, because a single case pins only whichever kind the encoder happened to pick + * for that source. + * + * Until these landed the suite held exactly one case and it was a **decline**, so the resolving + * path - the only reason anything depends on this visitor - was pinned by nothing committed. What + * backed it instead was a corpus, which is rebuildable and therefore not coverage. + * + * The `fix` cases get the helper's reference-integrity check for free, which matters more here + * than the printed text: this visitor replaces call sites and removes the storage, so a missing + * `crawl()` leaves stale reference counts behind output that reads identically. + */ +test('storage-binary', () => { + // two entries, both binary operators, one nested inside the other's argument list + getResult(parseControlFlowStorage, true, join(root, 'storage-binary')) +}) + +test('storage-logical', () => { + getResult(parseControlFlowStorage, true, join(root, 'storage-logical')) +}) + +test('storage-call', () => { + // the call wrapper, and a string-literal storage entry alongside it - the two shapes that make + // this case cover more than its name suggests + getResult(parseControlFlowStorage, true, join(root, 'storage-call')) +}) + +/** + * A declining case: an object of the right *form* whose function body is not a single `return`, so + * it is not a control-flow storage and must be left exactly as found. Kept as the counterweight to + * the three above - together they pin both directions of the gate rather than only the accepting + * one. + */ test('object-invalid-1', () => { - const tc = 'object-invalid-1' - getResult(parseControlFlowStorage, false, join(root, tc)) + getResult(parseControlFlowStorage, false, join(root, 'object-invalid-1')) }) diff --git a/test/visitor/parse-control-flow-storage/storage-binary.fix.js b/test/visitor/parse-control-flow-storage/storage-binary.fix.js new file mode 100644 index 00000000..6f0236ee --- /dev/null +++ b/test/visitor/parse-control-flow-storage/storage-binary.fix.js @@ -0,0 +1,4 @@ +function calc(_0x59f71b, _0x153c63) { + return _0x59f71b + _0x153c63 * 2; +} +console.log(calc(3, 4)); \ No newline at end of file diff --git a/test/visitor/parse-control-flow-storage/storage-binary.js b/test/visitor/parse-control-flow-storage/storage-binary.js new file mode 100644 index 00000000..ccaad327 --- /dev/null +++ b/test/visitor/parse-control-flow-storage/storage-binary.js @@ -0,0 +1,12 @@ +function calc(_0x59f71b, _0x153c63) { + var _0x4d7c35 = { + ooNmC: function (_0xb36c49, _0x276776) { + return _0xb36c49 + _0x276776; + }, + FyZSg: function (_0x35cb84, _0x3c559b) { + return _0x35cb84 * _0x3c559b; + } + }; + return _0x4d7c35.ooNmC(_0x59f71b, _0x4d7c35.FyZSg(_0x153c63, 2)); +} +console.log(calc(3, 4)); \ No newline at end of file diff --git a/test/visitor/parse-control-flow-storage/storage-call.fix.js b/test/visitor/parse-control-flow-storage/storage-call.fix.js new file mode 100644 index 00000000..4e715293 --- /dev/null +++ b/test/visitor/parse-control-flow-storage/storage-call.fix.js @@ -0,0 +1,7 @@ +function greet(_0x398956) { + return "hi " + _0x398956; +} +function run(_0x29269c) { + return greet(_0x29269c); +} +console.log(run("ana")); \ No newline at end of file diff --git a/test/visitor/parse-control-flow-storage/storage-call.js b/test/visitor/parse-control-flow-storage/storage-call.js new file mode 100644 index 00000000..5332e0d6 --- /dev/null +++ b/test/visitor/parse-control-flow-storage/storage-call.js @@ -0,0 +1,18 @@ +function greet(_0x398956) { + var _0x543fc3 = { + beona: function (_0x5392d9, _0x6d6c37) { + return _0x5392d9 + _0x6d6c37; + }, + WRXbv: "hi " + }; + return _0x543fc3.beona(_0x543fc3.WRXbv, _0x398956); +} +function run(_0x29269c) { + var _0x359799 = { + kkAyH: function (_0x386436, _0x2ef93a) { + return _0x386436(_0x2ef93a); + } + }; + return _0x359799.kkAyH(greet, _0x29269c); +} +console.log(run("ana")); \ No newline at end of file diff --git a/test/visitor/parse-control-flow-storage/storage-logical.fix.js b/test/visitor/parse-control-flow-storage/storage-logical.fix.js new file mode 100644 index 00000000..e54aa4e1 --- /dev/null +++ b/test/visitor/parse-control-flow-storage/storage-logical.fix.js @@ -0,0 +1,4 @@ +function pick(_0xccc389, _0xdabf1b) { + return _0xccc389 || _0xdabf1b; +} +console.log(pick(0, "fallback")); \ No newline at end of file diff --git a/test/visitor/parse-control-flow-storage/storage-logical.js b/test/visitor/parse-control-flow-storage/storage-logical.js new file mode 100644 index 00000000..946fc0b8 --- /dev/null +++ b/test/visitor/parse-control-flow-storage/storage-logical.js @@ -0,0 +1,9 @@ +function pick(_0xccc389, _0xdabf1b) { + var _0x4c85a8 = { + ywipG: function (_0x49f58b, _0x510043) { + return _0x49f58b || _0x510043; + } + }; + return _0x4c85a8.ywipG(_0xccc389, _0xdabf1b); +} +console.log(pick(0, "fallback")); \ No newline at end of file From 1fc2b4149d24f7458e47072b6c00f674a4f7cf62 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:51:14 +0100 Subject: [PATCH 07/18] test(decode-js): pin the reference bookkeeping of three shared visitors Three of the four visitors repaired earlier had no test at all, which is why the defect survived: the oracle was adequate and simply unreachable. These run through `getVisitorResult`, so they inherit the reference-state check rather than re-implementing it. Every rewriting fixture keeps a reference to an outer binding inside the re-homed subtree, and that is the property that makes the check bite. A fixture whose re-homed subtree references nothing pins the text and reads clean on the state - which is precisely how the one visitor that *did* have coverage still missed fourteen duplicates. Verified to fail for the right reason: against the pre-fix visitors every rewriting case fails on the reference-state assertion, and the declining cases pass, since a visitor that does not mutate cannot duplicate. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- test/visitor/lint-if-statement.test.js | 21 +++++++++++++++++++ .../lint-if-statement/braced-invalid.js | 9 ++++++++ .../unbraced-both-valid.fix.js | 9 ++++++++ .../lint-if-statement/unbraced-both-valid.js | 5 +++++ .../unbraced-consequent-valid.fix.js | 7 +++++++ .../unbraced-consequent-valid.js | 5 +++++ test/visitor/split-sequence.test.js | 16 ++++++++++++++ .../expression-statement-valid.fix.js | 6 ++++++ .../expression-statement-valid.js | 4 ++++ .../split-sequence/return-valid.fix.js | 6 ++++++ test/visitor/split-sequence/return-valid.js | 4 ++++ .../split-variable-declaration.test.js | 20 ++++++++++++++++++ .../for-init-invalid.js | 4 ++++ .../multi-declarator-valid.fix.js | 7 +++++++ .../multi-declarator-valid.js | 7 +++++++ .../single-declarator-invalid.js | 5 +++++ 16 files changed, 135 insertions(+) create mode 100644 test/visitor/lint-if-statement.test.js create mode 100644 test/visitor/lint-if-statement/braced-invalid.js create mode 100644 test/visitor/lint-if-statement/unbraced-both-valid.fix.js create mode 100644 test/visitor/lint-if-statement/unbraced-both-valid.js create mode 100644 test/visitor/lint-if-statement/unbraced-consequent-valid.fix.js create mode 100644 test/visitor/lint-if-statement/unbraced-consequent-valid.js create mode 100644 test/visitor/split-sequence.test.js create mode 100644 test/visitor/split-sequence/expression-statement-valid.fix.js create mode 100644 test/visitor/split-sequence/expression-statement-valid.js create mode 100644 test/visitor/split-sequence/return-valid.fix.js create mode 100644 test/visitor/split-sequence/return-valid.js create mode 100644 test/visitor/split-variable-declaration.test.js create mode 100644 test/visitor/split-variable-declaration/for-init-invalid.js create mode 100644 test/visitor/split-variable-declaration/multi-declarator-valid.fix.js create mode 100644 test/visitor/split-variable-declaration/multi-declarator-valid.js create mode 100644 test/visitor/split-variable-declaration/single-declarator-invalid.js diff --git a/test/visitor/lint-if-statement.test.js b/test/visitor/lint-if-statement.test.js new file mode 100644 index 00000000..d65fde53 --- /dev/null +++ b/test/visitor/lint-if-statement.test.js @@ -0,0 +1,21 @@ +import { join } from 'path' +import { test } from 'vitest' +import { getVisitorResult as getResult } from '../helper.js' +import lintIfStatement from '#visitor/lint-if-statement' + +const root = join(__dirname, 'lint-if-statement') + +// Each case keeps a reference to an outer binding (`x`) inside the branch being re-braced. +// That is deliberate: the branch is re-homed into a new BlockStatement, and the helper's +// reference-state check is what catches the re-homing being recorded twice. +test('unbraced-both-valid', () => { + getResult(lintIfStatement, true, join(root, 'unbraced-both-valid')) +}) + +test('unbraced-consequent-valid', () => { + getResult(lintIfStatement, true, join(root, 'unbraced-consequent-valid')) +}) + +test('braced-invalid', () => { + getResult(lintIfStatement, false, join(root, 'braced-invalid')) +}) diff --git a/test/visitor/lint-if-statement/braced-invalid.js b/test/visitor/lint-if-statement/braced-invalid.js new file mode 100644 index 00000000..b4a7a1b7 --- /dev/null +++ b/test/visitor/lint-if-statement/braced-invalid.js @@ -0,0 +1,9 @@ +var x = 1; +function pick(a) { + if (a) { + log(x); + } else { + warn(x); + } + return x; +} \ No newline at end of file diff --git a/test/visitor/lint-if-statement/unbraced-both-valid.fix.js b/test/visitor/lint-if-statement/unbraced-both-valid.fix.js new file mode 100644 index 00000000..b4a7a1b7 --- /dev/null +++ b/test/visitor/lint-if-statement/unbraced-both-valid.fix.js @@ -0,0 +1,9 @@ +var x = 1; +function pick(a) { + if (a) { + log(x); + } else { + warn(x); + } + return x; +} \ No newline at end of file diff --git a/test/visitor/lint-if-statement/unbraced-both-valid.js b/test/visitor/lint-if-statement/unbraced-both-valid.js new file mode 100644 index 00000000..82fbb716 --- /dev/null +++ b/test/visitor/lint-if-statement/unbraced-both-valid.js @@ -0,0 +1,5 @@ +var x = 1; +function pick(a) { + if (a) log(x);else warn(x); + return x; +} \ No newline at end of file diff --git a/test/visitor/lint-if-statement/unbraced-consequent-valid.fix.js b/test/visitor/lint-if-statement/unbraced-consequent-valid.fix.js new file mode 100644 index 00000000..b2e8a531 --- /dev/null +++ b/test/visitor/lint-if-statement/unbraced-consequent-valid.fix.js @@ -0,0 +1,7 @@ +var x = 1; +function pick(a) { + if (a) { + log(x); + } + return x; +} \ No newline at end of file diff --git a/test/visitor/lint-if-statement/unbraced-consequent-valid.js b/test/visitor/lint-if-statement/unbraced-consequent-valid.js new file mode 100644 index 00000000..5ec67cbf --- /dev/null +++ b/test/visitor/lint-if-statement/unbraced-consequent-valid.js @@ -0,0 +1,5 @@ +var x = 1; +function pick(a) { + if (a) log(x); + return x; +} \ No newline at end of file diff --git a/test/visitor/split-sequence.test.js b/test/visitor/split-sequence.test.js new file mode 100644 index 00000000..4b6b7573 --- /dev/null +++ b/test/visitor/split-sequence.test.js @@ -0,0 +1,16 @@ +import { join } from 'path' +import { test } from 'vitest' +import { getVisitorResult as getResult } from '../helper.js' +import splitSequence from '#visitor/split-sequence' + +const root = join(__dirname, 'split-sequence') + +// Every expression lifted out of the sequence references the outer binding `x`, so the +// helper's reference-state check sees the re-homing rather than only the text change. +test('expression-statement-valid', () => { + getResult(splitSequence, true, join(root, 'expression-statement-valid')) +}) + +test('return-valid', () => { + getResult(splitSequence, true, join(root, 'return-valid')) +}) diff --git a/test/visitor/split-sequence/expression-statement-valid.fix.js b/test/visitor/split-sequence/expression-statement-valid.fix.js new file mode 100644 index 00000000..656a9e2b --- /dev/null +++ b/test/visitor/split-sequence/expression-statement-valid.fix.js @@ -0,0 +1,6 @@ +var x = 1; +function run() { + log(x); + warn(x); + report(x); +} \ No newline at end of file diff --git a/test/visitor/split-sequence/expression-statement-valid.js b/test/visitor/split-sequence/expression-statement-valid.js new file mode 100644 index 00000000..5c712d52 --- /dev/null +++ b/test/visitor/split-sequence/expression-statement-valid.js @@ -0,0 +1,4 @@ +var x = 1; +function run() { + log(x), warn(x), report(x); +} \ No newline at end of file diff --git a/test/visitor/split-sequence/return-valid.fix.js b/test/visitor/split-sequence/return-valid.fix.js new file mode 100644 index 00000000..84c28163 --- /dev/null +++ b/test/visitor/split-sequence/return-valid.fix.js @@ -0,0 +1,6 @@ +var x = 1; +function run() { + log(x); + warn(x); + return x; +} \ No newline at end of file diff --git a/test/visitor/split-sequence/return-valid.js b/test/visitor/split-sequence/return-valid.js new file mode 100644 index 00000000..1510ce04 --- /dev/null +++ b/test/visitor/split-sequence/return-valid.js @@ -0,0 +1,4 @@ +var x = 1; +function run() { + return log(x), warn(x), x; +} \ No newline at end of file diff --git a/test/visitor/split-variable-declaration.test.js b/test/visitor/split-variable-declaration.test.js new file mode 100644 index 00000000..1be10035 --- /dev/null +++ b/test/visitor/split-variable-declaration.test.js @@ -0,0 +1,20 @@ +import { join } from 'path' +import { test } from 'vitest' +import { getVisitorResult as getResult } from '../helper.js' +import splitVariableDeclaration from '#visitor/split-variable-declaration' + +const root = join(__dirname, 'split-variable-declaration') + +// The declarators re-homed into new declarations reference the outer binding `x`. +test('multi-declarator-valid', () => { + getResult(splitVariableDeclaration, true, join(root, 'multi-declarator-valid')) +}) + +test('single-declarator-invalid', () => { + getResult(splitVariableDeclaration, false, join(root, 'single-declarator-invalid')) +}) + +// The scope of a for statement is its body, so the declaration is left alone. +test('for-init-invalid', () => { + getResult(splitVariableDeclaration, false, join(root, 'for-init-invalid')) +}) diff --git a/test/visitor/split-variable-declaration/for-init-invalid.js b/test/visitor/split-variable-declaration/for-init-invalid.js new file mode 100644 index 00000000..575073cc --- /dev/null +++ b/test/visitor/split-variable-declaration/for-init-invalid.js @@ -0,0 +1,4 @@ +var x = 1; +function run() { + for (var i = 0, n = x; i < n; i++) log(i); +} \ No newline at end of file diff --git a/test/visitor/split-variable-declaration/multi-declarator-valid.fix.js b/test/visitor/split-variable-declaration/multi-declarator-valid.fix.js new file mode 100644 index 00000000..779aa39e --- /dev/null +++ b/test/visitor/split-variable-declaration/multi-declarator-valid.fix.js @@ -0,0 +1,7 @@ +var x = 1; +function run() { + var p = log(x); + var q = warn(x); + var r = x; + return p + q + r; +} \ No newline at end of file diff --git a/test/visitor/split-variable-declaration/multi-declarator-valid.js b/test/visitor/split-variable-declaration/multi-declarator-valid.js new file mode 100644 index 00000000..cbd61e98 --- /dev/null +++ b/test/visitor/split-variable-declaration/multi-declarator-valid.js @@ -0,0 +1,7 @@ +var x = 1; +function run() { + var p = log(x), + q = warn(x), + r = x; + return p + q + r; +} \ No newline at end of file diff --git a/test/visitor/split-variable-declaration/single-declarator-invalid.js b/test/visitor/split-variable-declaration/single-declarator-invalid.js new file mode 100644 index 00000000..d6933ba5 --- /dev/null +++ b/test/visitor/split-variable-declaration/single-declarator-invalid.js @@ -0,0 +1,5 @@ +var x = 1; +function run() { + var p = log(x); + return p; +} \ No newline at end of file From f0987abb4de263314a624d77c8ffb41a59d8488a Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:51:14 +0100 Subject: [PATCH 08/18] test(visitor/prune-if-branch): give the visitor its first committed coverage Four plugins compose it and nothing pinned it. The cases cover both folding directions, the conditional-expression form, and the dead-branch shapes dead-code injection emits. Two are about what it must *not* do. `lexical-branch-kept` pins that a surviving branch owning a `let`/`const` keeps its block, since splicing it into the parent list would move a block-scoped binding. `outer-reference-in-dead-branch` pins the postcondition this pass owns: detaching a branch leaves every other binding's references pointing into it, so it crawls on the way out rather than leaving each consumer to notice. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- test/visitor/prune-if-branch.test.js | 64 +++++++++++++++++++ .../conditional-expression.fix.js | 3 + .../prune-if-branch/conditional-expression.js | 3 + .../prune-if-branch/dci-not-equal-same.fix.js | 3 + .../prune-if-branch/dci-not-equal-same.js | 7 ++ .../dci-taken-alternate.fix.js | 4 ++ .../prune-if-branch/dci-taken-alternate.js | 8 +++ .../dci-taken-consequent.fix.js | 4 ++ .../prune-if-branch/dci-taken-consequent.js | 8 +++ .../prune-if-branch/falsy-no-alternate.fix.js | 3 + .../prune-if-branch/falsy-no-alternate.js | 6 ++ .../lexical-branch-kept.fix.js | 7 ++ .../prune-if-branch/lexical-branch-kept.js | 9 +++ .../prune-if-branch/non-constant-test.js | 7 ++ .../outer-reference-in-dead-branch.fix.js | 4 ++ .../outer-reference-in-dead-branch.js | 7 ++ 16 files changed, 147 insertions(+) create mode 100644 test/visitor/prune-if-branch.test.js create mode 100644 test/visitor/prune-if-branch/conditional-expression.fix.js create mode 100644 test/visitor/prune-if-branch/conditional-expression.js create mode 100644 test/visitor/prune-if-branch/dci-not-equal-same.fix.js create mode 100644 test/visitor/prune-if-branch/dci-not-equal-same.js create mode 100644 test/visitor/prune-if-branch/dci-taken-alternate.fix.js create mode 100644 test/visitor/prune-if-branch/dci-taken-alternate.js create mode 100644 test/visitor/prune-if-branch/dci-taken-consequent.fix.js create mode 100644 test/visitor/prune-if-branch/dci-taken-consequent.js create mode 100644 test/visitor/prune-if-branch/falsy-no-alternate.fix.js create mode 100644 test/visitor/prune-if-branch/falsy-no-alternate.js create mode 100644 test/visitor/prune-if-branch/lexical-branch-kept.fix.js create mode 100644 test/visitor/prune-if-branch/lexical-branch-kept.js create mode 100644 test/visitor/prune-if-branch/non-constant-test.js create mode 100644 test/visitor/prune-if-branch/outer-reference-in-dead-branch.fix.js create mode 100644 test/visitor/prune-if-branch/outer-reference-in-dead-branch.js diff --git a/test/visitor/prune-if-branch.test.js b/test/visitor/prune-if-branch.test.js new file mode 100644 index 00000000..639d5717 --- /dev/null +++ b/test/visitor/prune-if-branch.test.js @@ -0,0 +1,64 @@ +import { join } from 'path' +import { test } from 'vitest' +import { getVisitorResult as getResult } from '../helper.js' +import pruneIfBranch from '#visitor/prune-if-branch' + +const root = join(__dirname, 'prune-if-branch') + +/** + * This visitor had no committed coverage at all, and four plugins consume it. The cases below + * are written around the shape that made that worth fixing: javascript-obfuscator's dead-code + * injection emits `if ('' <===|!==> '')` with the real block on the taken side, so + * this visitor - together with constant folding - IS the whole reversal of that transform. All + * four spellings the encoder can emit are covered, since the operator and whether the two strings + * match are drawn independently. + * + * The last two cases are the ones that pin behaviour a naive implementation would get wrong: + * a surviving branch is *spliced* into the parent statement list rather than planted whole, except + * where it owns a lexical declaration that would change meaning if relocated. + */ + +test('dci-taken-consequent: a true test splices the consequent, block and all', () => { + getResult(pruneIfBranch, true, join(root, 'dci-taken-consequent')) +}) + +test('dci-taken-alternate: a false test splices the alternate', () => { + getResult(pruneIfBranch, true, join(root, 'dci-taken-alternate')) +}) + +test('dci-not-equal-same: `!==` over two equal strings is false, so the alternate wins', () => { + getResult(pruneIfBranch, true, join(root, 'dci-not-equal-same')) +}) + +test('falsy-no-alternate: a false test with no `else` removes the statement outright', () => { + getResult(pruneIfBranch, true, join(root, 'falsy-no-alternate')) +}) + +test('conditional-expression: the same folding applies to a ternary', () => { + getResult(pruneIfBranch, true, join(root, 'conditional-expression')) +}) + +test('lexical-branch-kept: a branch owning `let`/`const` keeps its block', () => { + // Splicing these into the parent list would move block-scoped bindings into the enclosing + // scope. The residue is one redundant nesting level, which delete-nested-blocks removes as a + // separate cleanup - the trade is deliberate and this case is what pins it. + getResult(pruneIfBranch, true, join(root, 'lexical-branch-kept')) +}) + +test('outer-reference-in-dead-branch: removing a branch leaves no stale reference behind', () => { + // The case the other six cannot express, and the one a whole pipeline was defeated by: the dead + // branch references a binding declared OUTSIDE it. The existing cases stay clean only because + // their dead branches declare what they use, so the bindings leave with the branch. + // + // The output text is correct either way - `var keep = 1; return keep;` - so nothing about the + // generated code can catch this. What it asserts is `keep`'s binding no longer holding a + // referencePath into the subtree that was removed, which is the invariant every other visitor + // here is already held to and which four consuming plugins have had to pay a reload for. + getResult(pruneIfBranch, true, join(root, 'outer-reference-in-dead-branch')) +}) + +test('non-constant-test: a test that is not statically decidable is left alone', () => { + // No golden: with `fix` false the helper compares against the input source, which is what + // "left exactly as found" means for a declining case. + getResult(pruneIfBranch, false, join(root, 'non-constant-test')) +}) diff --git a/test/visitor/prune-if-branch/conditional-expression.fix.js b/test/visitor/prune-if-branch/conditional-expression.fix.js new file mode 100644 index 00000000..c1c7f9bb --- /dev/null +++ b/test/visitor/prune-if-branch/conditional-expression.fix.js @@ -0,0 +1,3 @@ +function f() { + return real(); +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/conditional-expression.js b/test/visitor/prune-if-branch/conditional-expression.js new file mode 100644 index 00000000..8d030e2e --- /dev/null +++ b/test/visitor/prune-if-branch/conditional-expression.js @@ -0,0 +1,3 @@ +function f() { + return "abcde" === "abcde" ? real() : dead(); +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/dci-not-equal-same.fix.js b/test/visitor/prune-if-branch/dci-not-equal-same.fix.js new file mode 100644 index 00000000..30b5c03a --- /dev/null +++ b/test/visitor/prune-if-branch/dci-not-equal-same.fix.js @@ -0,0 +1,3 @@ +function f() { + real(); +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/dci-not-equal-same.js b/test/visitor/prune-if-branch/dci-not-equal-same.js new file mode 100644 index 00000000..0e39e513 --- /dev/null +++ b/test/visitor/prune-if-branch/dci-not-equal-same.js @@ -0,0 +1,7 @@ +function f() { + if ("abcde" !== "abcde") { + dead(); + } else { + real(); + } +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/dci-taken-alternate.fix.js b/test/visitor/prune-if-branch/dci-taken-alternate.fix.js new file mode 100644 index 00000000..016bb79c --- /dev/null +++ b/test/visitor/prune-if-branch/dci-taken-alternate.fix.js @@ -0,0 +1,4 @@ +function f() { + real(); + more(); +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/dci-taken-alternate.js b/test/visitor/prune-if-branch/dci-taken-alternate.js new file mode 100644 index 00000000..fa9b0b73 --- /dev/null +++ b/test/visitor/prune-if-branch/dci-taken-alternate.js @@ -0,0 +1,8 @@ +function f() { + if ("abcde" === "fghij") { + dead(); + } else { + real(); + more(); + } +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/dci-taken-consequent.fix.js b/test/visitor/prune-if-branch/dci-taken-consequent.fix.js new file mode 100644 index 00000000..016bb79c --- /dev/null +++ b/test/visitor/prune-if-branch/dci-taken-consequent.fix.js @@ -0,0 +1,4 @@ +function f() { + real(); + more(); +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/dci-taken-consequent.js b/test/visitor/prune-if-branch/dci-taken-consequent.js new file mode 100644 index 00000000..9241534a --- /dev/null +++ b/test/visitor/prune-if-branch/dci-taken-consequent.js @@ -0,0 +1,8 @@ +function f() { + if ("abcde" === "abcde") { + real(); + more(); + } else { + dead(); + } +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/falsy-no-alternate.fix.js b/test/visitor/prune-if-branch/falsy-no-alternate.fix.js new file mode 100644 index 00000000..5b9fc7ba --- /dev/null +++ b/test/visitor/prune-if-branch/falsy-no-alternate.fix.js @@ -0,0 +1,3 @@ +function f() { + after(); +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/falsy-no-alternate.js b/test/visitor/prune-if-branch/falsy-no-alternate.js new file mode 100644 index 00000000..031bba5a --- /dev/null +++ b/test/visitor/prune-if-branch/falsy-no-alternate.js @@ -0,0 +1,6 @@ +function f() { + if ("abcde" === "fghij") { + dead(); + } + after(); +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/lexical-branch-kept.fix.js b/test/visitor/prune-if-branch/lexical-branch-kept.fix.js new file mode 100644 index 00000000..05f57774 --- /dev/null +++ b/test/visitor/prune-if-branch/lexical-branch-kept.fix.js @@ -0,0 +1,7 @@ +function f() { + { + const step = 2; + let total = step + 1; + use(total); + } +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/lexical-branch-kept.js b/test/visitor/prune-if-branch/lexical-branch-kept.js new file mode 100644 index 00000000..d3d973c0 --- /dev/null +++ b/test/visitor/prune-if-branch/lexical-branch-kept.js @@ -0,0 +1,9 @@ +function f() { + if ("abcde" === "abcde") { + const step = 2; + let total = step + 1; + use(total); + } else { + dead(); + } +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/non-constant-test.js b/test/visitor/prune-if-branch/non-constant-test.js new file mode 100644 index 00000000..fb95b20e --- /dev/null +++ b/test/visitor/prune-if-branch/non-constant-test.js @@ -0,0 +1,7 @@ +function f(a, b) { + if (a === b) { + one(); + } else { + two(); + } +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/outer-reference-in-dead-branch.fix.js b/test/visitor/prune-if-branch/outer-reference-in-dead-branch.fix.js new file mode 100644 index 00000000..4c7350d0 --- /dev/null +++ b/test/visitor/prune-if-branch/outer-reference-in-dead-branch.fix.js @@ -0,0 +1,4 @@ +function f() { + var keep = 1; + return keep; +} \ No newline at end of file diff --git a/test/visitor/prune-if-branch/outer-reference-in-dead-branch.js b/test/visitor/prune-if-branch/outer-reference-in-dead-branch.js new file mode 100644 index 00000000..104d8265 --- /dev/null +++ b/test/visitor/prune-if-branch/outer-reference-in-dead-branch.js @@ -0,0 +1,7 @@ +function f() { + var keep = 1; + if ("abcde" !== "abcde") { + helper(keep); + } + return keep; +} \ No newline at end of file From b73b221351e96e3bfd8a37d56e677bf68a40040e Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:51:30 +0100 Subject: [PATCH 09/18] feat(decode-js): give logger always-on log and error channels `debugLog` is per-pass tracing and is rightly off by default, but a plugin that declines needs to say why on a channel the user is already reading. A refusal nobody can read is the silent fallthrough it was meant to replace. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/utility/logger.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/utility/logger.js b/src/utility/logger.js index 48ea863e..59b45471 100644 --- a/src/utility/logger.js +++ b/src/utility/logger.js @@ -39,8 +39,29 @@ function debugLog(...args) { } } +/** + * Always-on channels, for the two things a caller cannot act on if they are hidden behind `-v`. + * + * `debugLog` above is per-pass tracing and is correctly off by default. A **refusal** is not + * tracing: a plugin that returns falsy has told the caller only that it failed, so the reason has + * to reach them or the failure is unreadable — which is exactly the weakness of a silent + * fallthrough. A **verdict** the entry was asked to produce is likewise not tracing. + * + * Routed through here rather than written as bare `console.error` at each site so that the channel + * stays one thing a caller can redirect or silence, instead of several. + */ +function log(...args) { + console.log(...args) +} + +function error(...args) { + console.error(...args) +} + export default { debugLog, setDebugLogging, isDebugLogging, + log, + error, } From a248ea6869b0bb0917bbe610538051564d4d01bf Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:51:30 +0100 Subject: [PATCH 10/18] feat(decode-js): add the atomic visitor layer Seven single-rewrite visitors, plugin-agnostic and re-runnable to a fixpoint, composed by a pipeline rather than run alone. Four reverse operator-packed control flow - a statement-level `&&`, a conditional in statement or return position, a sequence in an `if` test, and an assignment distributed into a conditional's branches. Three restore property spelling - member reads, keys, and shorthand. **The seam is the point.** Ten of the eleven reversals the encoder's Converting stage needs turned out to be encoder-agnostic, so they belong on the shared side and only the scheduling is specific to one obfuscator. Ask which side a rewrite belongs on before writing it into a plugin's own folder; the answer here went the surprising way. Each declines rather than stopping: a site failing the gate is skipped and traversal continues, because `path.stop()` halts the whole traversal and on obfuscated input the declined sites outnumber the matched ones roughly two to one. The `-invalid` fixtures are the pass working, not gaps - a gate that rejects more than it accepts is the expected shape when value-position constructs outnumber statement-position ones. `collapse-property-shorthand` is correct and, against this one encoder, dead: the shorthand is expanded so a renamer has two nodes, and the renamer then runs. It is kept because the layer is plugin-agnostic and an encoder that does not rename leaves the shape intact. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/atomic/README.md | 48 ++++++ .../atomic/collapse-property-shorthand.js | 55 ++++++ .../atomic/convert-conditional-assign.js | 52 ++++++ src/visitor/atomic/lint-conditional-if.js | 96 +++++++++++ src/visitor/atomic/lint-logical-if.js | 54 ++++++ src/visitor/atomic/split-if-test-sequence.js | 46 +++++ src/visitor/atomic/uncompute-member.js | 50 ++++++ src/visitor/atomic/uncompute-property-key.js | 69 ++++++++ .../convert-conditional-assign.test.js | 33 ++++ .../assignment-valid.fix.js | 1 + .../assignment-valid.js | 1 + .../compound-valid.fix.js | 1 + .../compound-valid.js | 1 + .../declarator-invalid.js | 1 + .../member-target-valid.fix.js | 1 + .../member-target-valid.js | 1 + .../statement-invalid.js | 1 + test/visitor/lint-conditional-if.test.js | 98 +++++++++++ .../lint-conditional-if/argument-invalid.js | 1 + .../lint-conditional-if/arrow-body-invalid.js | 1 + .../bound-return-valid.fix.js | 4 + .../lint-conditional-if/bound-return-valid.js | 4 + .../bound-statement-valid.fix.js | 4 + .../bound-statement-valid.js | 4 + .../lint-conditional-if/declarator-invalid.js | 1 + .../nested-consequent-valid.fix.js | 3 + .../nested-consequent-valid.js | 1 + .../lint-conditional-if/operand-invalid.js | 1 + .../lint-conditional-if/property-invalid.js | 3 + .../lint-conditional-if/real-output-state.js | 162 ++++++++++++++++++ .../lint-conditional-if/return-valid.fix.js | 3 + .../lint-conditional-if/return-valid.js | 3 + .../statement-valid.fix.js | 1 + .../lint-conditional-if/statement-valid.js | 1 + test/visitor/lint-logical-if.test.js | 45 +++++ .../lint-logical-if/arrow-body-invalid.js | 1 + .../lint-logical-if/chain-valid.fix.js | 1 + test/visitor/lint-logical-if/chain-valid.js | 1 + .../lint-logical-if/declarator-invalid.js | 1 + .../lint-logical-if/for-test-invalid.js | 3 + .../lint-logical-if/if-test-invalid.js | 3 + test/visitor/lint-logical-if/or-invalid.js | 1 + .../lint-logical-if/statement-valid.fix.js | 1 + .../lint-logical-if/statement-valid.js | 1 + .../lint-logical-if/while-test-invalid.js | 3 + test/visitor/split-if-test-sequence.test.js | 27 +++ .../not-in-list-invalid.js | 1 + .../not-sequence-invalid.js | 3 + .../simple-valid.fix.js | 4 + .../split-if-test-sequence/simple-valid.js | 3 + .../split-if-test-sequence/three-valid.fix.js | 5 + .../split-if-test-sequence/three-valid.js | 3 + 52 files changed, 913 insertions(+) create mode 100644 src/visitor/atomic/README.md create mode 100644 src/visitor/atomic/collapse-property-shorthand.js create mode 100644 src/visitor/atomic/convert-conditional-assign.js create mode 100644 src/visitor/atomic/lint-conditional-if.js create mode 100644 src/visitor/atomic/lint-logical-if.js create mode 100644 src/visitor/atomic/split-if-test-sequence.js create mode 100644 src/visitor/atomic/uncompute-member.js create mode 100644 src/visitor/atomic/uncompute-property-key.js create mode 100644 test/visitor/convert-conditional-assign.test.js create mode 100644 test/visitor/convert-conditional-assign/assignment-valid.fix.js create mode 100644 test/visitor/convert-conditional-assign/assignment-valid.js create mode 100644 test/visitor/convert-conditional-assign/compound-valid.fix.js create mode 100644 test/visitor/convert-conditional-assign/compound-valid.js create mode 100644 test/visitor/convert-conditional-assign/declarator-invalid.js create mode 100644 test/visitor/convert-conditional-assign/member-target-valid.fix.js create mode 100644 test/visitor/convert-conditional-assign/member-target-valid.js create mode 100644 test/visitor/convert-conditional-assign/statement-invalid.js create mode 100644 test/visitor/lint-conditional-if.test.js create mode 100644 test/visitor/lint-conditional-if/argument-invalid.js create mode 100644 test/visitor/lint-conditional-if/arrow-body-invalid.js create mode 100644 test/visitor/lint-conditional-if/bound-return-valid.fix.js create mode 100644 test/visitor/lint-conditional-if/bound-return-valid.js create mode 100644 test/visitor/lint-conditional-if/bound-statement-valid.fix.js create mode 100644 test/visitor/lint-conditional-if/bound-statement-valid.js create mode 100644 test/visitor/lint-conditional-if/declarator-invalid.js create mode 100644 test/visitor/lint-conditional-if/nested-consequent-valid.fix.js create mode 100644 test/visitor/lint-conditional-if/nested-consequent-valid.js create mode 100644 test/visitor/lint-conditional-if/operand-invalid.js create mode 100644 test/visitor/lint-conditional-if/property-invalid.js create mode 100644 test/visitor/lint-conditional-if/real-output-state.js create mode 100644 test/visitor/lint-conditional-if/return-valid.fix.js create mode 100644 test/visitor/lint-conditional-if/return-valid.js create mode 100644 test/visitor/lint-conditional-if/statement-valid.fix.js create mode 100644 test/visitor/lint-conditional-if/statement-valid.js create mode 100644 test/visitor/lint-logical-if.test.js create mode 100644 test/visitor/lint-logical-if/arrow-body-invalid.js create mode 100644 test/visitor/lint-logical-if/chain-valid.fix.js create mode 100644 test/visitor/lint-logical-if/chain-valid.js create mode 100644 test/visitor/lint-logical-if/declarator-invalid.js create mode 100644 test/visitor/lint-logical-if/for-test-invalid.js create mode 100644 test/visitor/lint-logical-if/if-test-invalid.js create mode 100644 test/visitor/lint-logical-if/or-invalid.js create mode 100644 test/visitor/lint-logical-if/statement-valid.fix.js create mode 100644 test/visitor/lint-logical-if/statement-valid.js create mode 100644 test/visitor/lint-logical-if/while-test-invalid.js create mode 100644 test/visitor/split-if-test-sequence.test.js create mode 100644 test/visitor/split-if-test-sequence/not-in-list-invalid.js create mode 100644 test/visitor/split-if-test-sequence/not-sequence-invalid.js create mode 100644 test/visitor/split-if-test-sequence/simple-valid.fix.js create mode 100644 test/visitor/split-if-test-sequence/simple-valid.js create mode 100644 test/visitor/split-if-test-sequence/three-valid.fix.js create mode 100644 test/visitor/split-if-test-sequence/three-valid.js diff --git a/src/visitor/atomic/README.md b/src/visitor/atomic/README.md new file mode 100644 index 00000000..4ec8973c --- /dev/null +++ b/src/visitor/atomic/README.md @@ -0,0 +1,48 @@ +# `visitor/atomic/` + +Single-rewrite, plugin-agnostic Babel visitors. One file does one thing to one node shape, so a +plugin composes the ones it needs instead of inheriting a bundle. + +Each file exports: + +- a **default** plain visitor object, for `traverse(ast, visitorDefault)`; +- a named **`create…(onChange)`** factory, for a caller that needs to know whether the visitor + fired — which a fixpoint loop does, since it has to decide whether to run another round. + +## What belongs here + +A visitor that is **not specific to any obfuscator**: it recognises a JavaScript shape and +rewrites it to an equivalent one. If a rewrite needs to know which encoder produced the input, +it belongs in that plugin's own folder (`visitor/obfuscator/`, `visitor/jsconfuser/`) instead. + +**A readability rewrite qualifies only if a later matcher navigates by the shape it produces.** The +test is "does something downstream read this", never "is it prettier": folding `!![]` to `true` +earns its place because a branch spelled as a unary chain cannot be pruned, so the fold is what +lets the pruning and un-flattening passes see it. The same test excludes turning concatenation back +into a template literal — nothing downstream reads template literals, so it is style, and it is +deliberately unbuilt. + +Scheduling does not belong here either. Which visitors run, in what order, and how many times is +a property of the pipeline that consumes them — see `visitor/obfuscator/normalize-statements.js` +for a worked example, where the order within a round is load-bearing and the loop runs to a +fixpoint because the rewrites unlock each other. + +## Two rules every visitor here follows + +- **Gate on position, and decline rather than stop.** Several of these rewrites are only valid + where the node is a *statement* rather than a *value*; the parent test is the whole safety + argument and each file states its own. A node that fails the gate is skipped and traversal + continues. Never `path.stop()` — it halts the entire traversal, not the subtree, and on + obfuscated input the declined sites outnumber the matched ones by roughly two to one, so + stopping at the first would abort the pass before it did any work. +- **Say what is *not* handled, and why.** `lint-logical-if.js` declining `||`, and + `convert-conditional-assign.js` declining a `VariableDeclarator`, are decisions with reasons — + recorded so the next reader does not "fix" them. + +## Relationship to the visitors in `visitor/` + +The flat files one level up (`lint-if-statement.js`, `split-sequence.js`, +`split-variable-declaration.js`, `delete-extra.js`, …) are atoms by the same definition and +predate this folder. They are **not** moved, because every existing plugin imports them by path +and relocating them would touch code this work is meant to leave alone. Migrating them is a +worthwhile refactor on its own terms and belongs in its own commit, not smuggled into a feature. diff --git a/src/visitor/atomic/collapse-property-shorthand.js b/src/visitor/atomic/collapse-property-shorthand.js new file mode 100644 index 00000000..ea8fedd7 --- /dev/null +++ b/src/visitor/atomic/collapse-property-shorthand.js @@ -0,0 +1,55 @@ +import * as t from '@babel/types' + +/** + * Collapse a property whose key and value are the same name back to shorthand: + * + * const { foo: foo } = bar; => const { foo } = bar; + * ({ foo: foo }) => ({ foo }) + * + * Obfuscators expand shorthand so that a renaming pass has two nodes where the source had one - + * in `{ foo }` the single token is both the property read and the binding declared, and a + * renamer must rewrite the binding while leaving the property name alone. Once renaming has + * happened the expanded form carries no information; it is just longer. + * + * **One exclusion, and it is the reverse of the usual `__proto__` trap.** In an + * *ObjectExpression*, `{ __proto__: x }` sets the prototype while the shorthand `{ __proto__ }` + * merely defines an own property - so collapsing it changes meaning. The special case is scoped + * to the `PropertyName : AssignmentExpression` form, which is exactly what collapsing removes. + * Verified by construction rather than from the spec: `Object.getPrototypeOf` reports the + * prototype set for the expanded form and not for the shorthand. + * + * In an *ObjectPattern* there is no such hazard - destructuring `{ __proto__: __proto__ }` and + * `{ __proto__ }` both bind the name - but the gate does not need to distinguish them, because + * refusing the name in both positions costs one unreachable collapse and removes a class of + * error entirely. + * + * **`export { foo as foo }` is deliberately not handled here.** It parses to the *same* AST as + * `export { foo }` - Babel represents both as an `ExportSpecifier` whose `local` and `exported` + * names match - so there is no node to rewrite, and the generator already prints the short form. + * That reversal is free at generation time and needs no pass. + */ +export function createCollapsePropertyShorthand(onChange) { + return { + ObjectProperty(path) { + const { node } = path + if (node.computed || node.shorthand) { + return + } + if (!t.isIdentifier(node.key) || !t.isIdentifier(node.value)) { + return + } + if (node.key.name !== node.value.name) { + return + } + if (node.key.name === '__proto__') { + return + } + node.shorthand = true + if (onChange) { + onChange() + } + }, + } +} + +export default createCollapsePropertyShorthand() diff --git a/src/visitor/atomic/convert-conditional-assign.js b/src/visitor/atomic/convert-conditional-assign.js new file mode 100644 index 00000000..4f6ab7bc --- /dev/null +++ b/src/visitor/atomic/convert-conditional-assign.js @@ -0,0 +1,52 @@ +import * as t from '@babel/types' + +/** + * Distribute an assignment into both branches of a conditional: + * + * r = t ? a : b => t ? r = a : r = b + * + * On its own this is a wash. Its purpose is positional: a conditional in *value* position + * cannot become an `if` statement, and this moves it into statement position where it can. + * So it is a prerequisite for conditional-to-if conversion rather than a simplification in + * its own right, and it is worth nothing unless that conversion runs after it. + * + * Only an `AssignmentExpression` parent is distributed. A `VariableDeclarator` + * (`var r = t ? a : b`) is deliberately left alone: making that convertible means hoisting + * the declaration out of its initializer, which changes where the binding is introduced. + * That is a scope decision, not this visitor's. + * + * The assignment target is cloned into each branch rather than shared, so the two branches + * do not alias one node. + * + * **Not safe for every target.** A target with its own side effects or an unstable value - + * `obj[i++] = t ? a : b` - would have that effect duplicated into both branches. The guard is + * that only one branch ever executes, so the effect still happens exactly once; what changes + * is that it is now evaluated *after* the test rather than before it. Where the test reads + * what the target expression writes, that is observable. + */ +export function createConvertConditionalAssign(onConvert) { + return { + ConditionalExpression: { + exit(path) { + const parent = path.parent + if (!t.isAssignmentExpression(parent) || parent.right !== path.node) { + return + } + const { test, consequent, alternate } = path.node + const { operator, left } = parent + path.parentPath.replaceWith( + t.conditionalExpression( + test, + t.assignmentExpression(operator, t.cloneNode(left), consequent), + t.assignmentExpression(operator, t.cloneNode(left), alternate), + ), + ) + if (onConvert) { + onConvert() + } + }, + }, + } +} + +export default createConvertConditionalAssign() diff --git a/src/visitor/atomic/lint-conditional-if.js b/src/visitor/atomic/lint-conditional-if.js new file mode 100644 index 00000000..027f3495 --- /dev/null +++ b/src/visitor/atomic/lint-conditional-if.js @@ -0,0 +1,96 @@ +import * as t from '@babel/types' + +/** + * Put a conditional used as a statement back into an `if`: + * + * test ? a : b; => if (test) a; else b; + * return test ? a : b; => if (test) return a; else return b; + * + * Obfuscators use the ternary to pack a two-branch `if` onto one line. Both forms restore a + * statement boundary, and each branch keeps the statement kind of the site it replaces - + * which is what makes the return form safe, since two `return`s preserve the function's + * completion where two expression statements would silently drop it. + * + * **The position gate is the whole safety argument**, and it matters more here than for the + * logical form because conditionals in value position are extremely common. The node must be + * the *entire* expression of an `ExpressionStatement`, or the *entire* argument of a + * `ReturnStatement`. Everything else is a value: a declarator initializer, a call argument, + * an operand, an arrow's concise body, a property value. Measured over one 432-sample corpus + * of obfuscated output, this gate matched 435 sites and declined 895 value-position + * conditionals; without it every one of those 895 would have been rewritten wrongly. + * + * A declined site is skipped and traversal continues. Nothing here stops the traversal - on + * obfuscated input the first declined site typically arrives long before the first match, so + * halting would abort the pass before it did any work. + * + * **A conditional in value position is often one rewrite away from being convertible.** See + * `convert-conditional-assign.js`, which distributes an assignment into both branches to move + * the conditional into statement position; run it before this one to reach those sites. + */ +export function createLintConditionalIf(onReverse) { + // Whether this traversal rewrote anything, so `Program.exit` knows if a crawl is owed. Closure + // state rather than module-level, since each factory call is its own visitor instance. + let rewroteSomething = false + return { + // The crawl restores an invariant this rewrite breaks: after a pass, the scope information + // Babel has cached should equal what a fresh parse of that pass's own output would produce. + // `replaceWith` is handed an `IfStatement` built from the conditional's own `test`, + // `consequent` and `alternate`, and it registers those reused subtrees' references a second + // time - the same live node listed twice in `binding.referencePaths`, with nothing detached. + // Measured on one real sample: 14 duplicate entries, 1462 recorded references against 1448 + // real ones. A later consumer gating a deletion on "have I resolved every reference" then + // passes that check while a live reference goes unhandled. + // + // Program-scoped and once per traversal: crawling a narrower scope *adds* duplicates, by + // appending to outer-scope bindings that already hold those references. Gated because a crawl + // is only owed when something moved, and it cannot change the tree. + Program: { + enter() { + rewroteSomething = false + }, + exit(path) { + if (rewroteSomething) path.scope.crawl() + }, + }, + ConditionalExpression: { + exit(path) { + const { test, consequent, alternate } = path.node + const parent = path.parentPath + + if ( + parent.isExpressionStatement() && + parent.node.expression === path.node + ) { + parent.replaceWith( + t.ifStatement( + test, + t.expressionStatement(consequent), + t.expressionStatement(alternate), + ), + ) + rewroteSomething = true + if (onReverse) { + onReverse() + } + return + } + + if (parent.isReturnStatement() && parent.node.argument === path.node) { + parent.replaceWith( + t.ifStatement( + test, + t.returnStatement(consequent), + t.returnStatement(alternate), + ), + ) + rewroteSomething = true + if (onReverse) { + onReverse() + } + } + }, + }, + } +} + +export default createLintConditionalIf() diff --git a/src/visitor/atomic/lint-logical-if.js b/src/visitor/atomic/lint-logical-if.js new file mode 100644 index 00000000..38d7c3d8 --- /dev/null +++ b/src/visitor/atomic/lint-logical-if.js @@ -0,0 +1,54 @@ +import * as t from '@babel/types' + +/** + * Put a short-circuit used as a statement back into an `if`: + * + * test && body; => if (test) body; + * + * Obfuscators use this to pack a branch onto one line. It is semantics-preserving in both + * directions at statement level, where the operator's value is discarded either way, and it + * restores a statement boundary that every statement-level matcher navigates by. + * + * **The position gate is the whole safety argument.** The parent must be the + * `ExpressionStatement` and the node must be its entire expression. That single test excludes + * every position where the operator is carrying a value rather than a branch - an `if`, + * `while` or `for` test, an arrow function's concise body, a declarator initializer, a call + * argument, an operand of an enclosing operator. A site that fails the gate is left alone and + * traversal continues; nothing here stops the traversal, because on obfuscated input the + * declined sites vastly outnumber the matched ones and halting on the first would abort the + * pass before it normalized anything. + * + * **Only the outermost `&&` of a chain qualifies**, and that is what makes a chain come out + * right rather than shredded. `if (c && d) { a(); }` is emitted as `c && d && a();`, which + * parses as `(c && d) && a()`; reversing the outermost alone recovers `if (c && d) a();`. + * A nested `&&`'s parent is the `LogicalExpression` above it, so it is never a candidate. + * + * **`||` is not handled.** It has no equivalent single-branch `if` form - `a || b` runs `b` + * when `a` is *falsy*, so the reversal would need a negated test, and a negation this pass + * introduced is a shape no encoder emitted. Left alone. + */ +export function createLintLogicalIf(onReverse) { + return { + LogicalExpression: { + exit(path) { + if (path.node.operator !== '&&') { + return + } + const stmt = path.parentPath + if ( + !stmt.isExpressionStatement() || + stmt.node.expression !== path.node + ) { + return + } + const { left, right } = path.node + stmt.replaceWith(t.ifStatement(left, t.expressionStatement(right))) + if (onReverse) { + onReverse() + } + }, + }, + } +} + +export default createLintLogicalIf() diff --git a/src/visitor/atomic/split-if-test-sequence.js b/src/visitor/atomic/split-if-test-sequence.js new file mode 100644 index 00000000..e35b3820 --- /dev/null +++ b/src/visitor/atomic/split-if-test-sequence.js @@ -0,0 +1,46 @@ +import * as t from '@babel/types' + +/** + * Hoist all but the last expression out of a sequence used as an `if` test: + * + * if ((a, b)) … => a; if (b) … + * + * A sequence in the test position hides every expression but the last from statement-level + * reading, and the hidden ones are frequently the interesting part - assignments an obfuscator + * folded into the test to get them off their own line. + * + * This is the gap in `split-sequence.js`, which covers a sequence in `ExpressionStatement`, + * `ReturnStatement` and first-`VariableDeclarator` position but not this one. The two are + * complementary and are meant to run together. + * + * **Requires the `if` to be in a statement list**, because the reversal inserts siblings + * before it. An `if` that is itself an unbraced branch of another `if` has nowhere to insert; + * it is left alone here and becomes eligible once `lint-if-statement.js` has given it a block, + * which is why these two want to run in the same loop rather than once each. + * + * Order within the sequence is preserved, and the hoisted expressions still evaluate before + * the test - so this is safe even when they have side effects the test depends on. + */ +export function createSplitIfTestSequence(onSplit) { + return { + IfStatement: { + enter(path) { + const test = path.node.test + if (!t.isSequenceExpression(test) || !path.inList) { + return + } + const rest = test.expressions.slice() + const last = rest.pop() + path.insertBefore( + rest.map((expression) => t.expressionStatement(expression)), + ) + path.get('test').replaceWith(last) + if (onSplit) { + onSplit() + } + }, + }, + } +} + +export default createSplitIfTestSequence() diff --git a/src/visitor/atomic/uncompute-member.js b/src/visitor/atomic/uncompute-member.js new file mode 100644 index 00000000..13202184 --- /dev/null +++ b/src/visitor/atomic/uncompute-member.js @@ -0,0 +1,50 @@ +import * as t from '@babel/types' + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +/** + * Drop the brackets from a member read whose key is a plain string: + * + * o["foo"] => o.foo + * + * Obfuscators rewrite every static property name into a computed string so that a string- + * concealing pass can reach it; once the string is back, the brackets carry no information. + * Restoring the dotted form is what lets later matchers key on `o.foo` at all, and it is the + * single most common residue shape in this kind of output. + * + * **The identifier gate is the safety argument, and it is not cosmetic.** `o["foo bar"]` and + * `o["0"]` have no dotted spelling, so they are declined and left computed. A pass that + * rewrote them anyway would emit code that does not parse. The gate doubles as a correctness + * check on the layer beneath: a decoded string sitting in a member key that is *not* a valid + * identifier is evidence that the string was decoded wrongly, because the encoder put a real + * property name there. + * + * **No key needs excluding here, unlike a property *key*.** `o["__proto__"]` and `o.__proto__` + * are the same accessor, and there is no member-read analogue of the object-literal `__proto__` + * special case or of `class C { ["constructor"](){} }`. Those hazards live in + * `uncompute-property-key.js`, which is why the two are separate files rather than one. + * + * Optional members (`o?.["foo"]`) are the same rewrite and are handled alongside. + */ +export function createUncomputeMember(onChange) { + const visit = (path) => { + const { node } = path + if (!node.computed || !t.isStringLiteral(node.property)) { + return + } + if (!IDENTIFIER.test(node.property.value)) { + return + } + node.property = t.identifier(node.property.value) + node.computed = false + if (onChange) { + onChange() + } + } + return { + MemberExpression: visit, + OptionalMemberExpression: visit, + } +} + +export default createUncomputeMember() diff --git a/src/visitor/atomic/uncompute-property-key.js b/src/visitor/atomic/uncompute-property-key.js new file mode 100644 index 00000000..cca2b5ab --- /dev/null +++ b/src/visitor/atomic/uncompute-property-key.js @@ -0,0 +1,69 @@ +import * as t from '@babel/types' + +import safeFunc from '../../utility/safe-func.js' + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +/** + * Restore a property or class-member key that was rewritten into a string: + * + * ["foo"]() {} => "foo"() {} => foo() {} + * { "foo": 1 } => { foo: 1 } + * + * **This is two rewrites, not one, and only the first is dangerous.** Un-computing changes + * meaning for three keys; de-literalizing an already-non-computed key changes meaning for none. + * Keeping them as separate steps is what lets the risky half delegate to a guard that already + * exists and the safe half stay a plain rewrite. + * + * **Step 1 - un-compute, via `safeFunc.uncomputeStringKey`.** That helper is shared and already + * refuses the three keys whose meaning changes: `{ ["__proto__"]: v }` defines an own property + * where `{ "__proto__": v }` sets the prototype; `class C { ["constructor"](){} }` is an + * ordinary method where `"constructor"(){}` *is* the class constructor; and + * `static ["prototype"]` is a runtime error un-computed. This pass calls it rather than + * reimplementing the list, so there is one place to correct if a fourth case turns up. + * + * **Step 2 - de-literalize, gated on the identifier form.** Once a key is non-computed the + * remaining change is spelling only: `{ "__proto__": v }` and `{ __proto__: v }` are both the + * prototype setter, and `"constructor"(){}` and `constructor(){}` are both the constructor. So + * no exclusion list applies here, and the only gate is whether the string has an identifier + * spelling at all - `{ "foo bar": 1 }` and `{ "0": 1 }` are declined and stay quoted. + * + * A key that was *already* non-computed skips step 1 and is still eligible for step 2, which is + * what makes the pass idempotent and safe to re-run in a fixpoint loop. + */ +export function createUncomputePropertyKey(onChange) { + const visit = (path) => { + const { node } = path + if (!t.isStringLiteral(node.key)) { + return + } + let changed = false + + // Step 1: the guarded half. `uncomputeStringKey` takes the KEY path and decides for itself + // whether this owner and name may lose the brackets. + if (node.computed) { + safeFunc.uncomputeStringKey(path.get('key')) + if (!node.computed) { + changed = true + } + } + + // Step 2: pure spelling, and only once the key is non-computed. + if (!node.computed && IDENTIFIER.test(node.key.value)) { + node.key = t.identifier(node.key.value) + changed = true + } + + if (changed && onChange) { + onChange() + } + } + return { + ObjectProperty: visit, + ObjectMethod: visit, + ClassMethod: visit, + ClassProperty: visit, + } +} + +export default createUncomputePropertyKey() diff --git a/test/visitor/convert-conditional-assign.test.js b/test/visitor/convert-conditional-assign.test.js new file mode 100644 index 00000000..585289cd --- /dev/null +++ b/test/visitor/convert-conditional-assign.test.js @@ -0,0 +1,33 @@ +import { join } from 'path' +import { test } from 'vitest' +import { getVisitorResult as getResult } from '../helper.js' +import convertConditionalAssign from '#visitor/atomic/convert-conditional-assign' + +const root = join(__dirname, 'convert-conditional-assign') + +// Distributing the assignment is positional, not simplifying: it moves a conditional out of +// value position so lint-conditional-if can reach it. + +test('assignment-valid', () => { + getResult(convertConditionalAssign, true, join(root, 'assignment-valid')) +}) + +// The target is cloned into both branches rather than shared. +test('member-target-valid', () => { + getResult(convertConditionalAssign, true, join(root, 'member-target-valid')) +}) + +test('compound-valid', () => { + getResult(convertConditionalAssign, true, join(root, 'compound-valid')) +}) + +// Distributing into a declarator would mean hoisting the declaration out of its +// initializer - a scope change, and a different decision from this one. +test('declarator-invalid', () => { + getResult(convertConditionalAssign, false, join(root, 'declarator-invalid')) +}) + +// Already in statement position; lint-conditional-if owns this one. +test('statement-invalid', () => { + getResult(convertConditionalAssign, false, join(root, 'statement-invalid')) +}) diff --git a/test/visitor/convert-conditional-assign/assignment-valid.fix.js b/test/visitor/convert-conditional-assign/assignment-valid.fix.js new file mode 100644 index 00000000..c049553e --- /dev/null +++ b/test/visitor/convert-conditional-assign/assignment-valid.fix.js @@ -0,0 +1 @@ +c ? r = a() : r = b(); \ No newline at end of file diff --git a/test/visitor/convert-conditional-assign/assignment-valid.js b/test/visitor/convert-conditional-assign/assignment-valid.js new file mode 100644 index 00000000..d06969bd --- /dev/null +++ b/test/visitor/convert-conditional-assign/assignment-valid.js @@ -0,0 +1 @@ +r = c ? a() : b(); \ No newline at end of file diff --git a/test/visitor/convert-conditional-assign/compound-valid.fix.js b/test/visitor/convert-conditional-assign/compound-valid.fix.js new file mode 100644 index 00000000..ad92cb72 --- /dev/null +++ b/test/visitor/convert-conditional-assign/compound-valid.fix.js @@ -0,0 +1 @@ +c ? r += 1 : r += 2; \ No newline at end of file diff --git a/test/visitor/convert-conditional-assign/compound-valid.js b/test/visitor/convert-conditional-assign/compound-valid.js new file mode 100644 index 00000000..c8f66873 --- /dev/null +++ b/test/visitor/convert-conditional-assign/compound-valid.js @@ -0,0 +1 @@ +r += c ? 1 : 2; \ No newline at end of file diff --git a/test/visitor/convert-conditional-assign/declarator-invalid.js b/test/visitor/convert-conditional-assign/declarator-invalid.js new file mode 100644 index 00000000..0211c4eb --- /dev/null +++ b/test/visitor/convert-conditional-assign/declarator-invalid.js @@ -0,0 +1 @@ +var r = c ? a() : b(); \ No newline at end of file diff --git a/test/visitor/convert-conditional-assign/member-target-valid.fix.js b/test/visitor/convert-conditional-assign/member-target-valid.fix.js new file mode 100644 index 00000000..bffa6f01 --- /dev/null +++ b/test/visitor/convert-conditional-assign/member-target-valid.fix.js @@ -0,0 +1 @@ +c ? o[i] = 1 : o[i] = 2; \ No newline at end of file diff --git a/test/visitor/convert-conditional-assign/member-target-valid.js b/test/visitor/convert-conditional-assign/member-target-valid.js new file mode 100644 index 00000000..59190345 --- /dev/null +++ b/test/visitor/convert-conditional-assign/member-target-valid.js @@ -0,0 +1 @@ +o[i] = c ? 1 : 2; \ No newline at end of file diff --git a/test/visitor/convert-conditional-assign/statement-invalid.js b/test/visitor/convert-conditional-assign/statement-invalid.js new file mode 100644 index 00000000..92506f14 --- /dev/null +++ b/test/visitor/convert-conditional-assign/statement-invalid.js @@ -0,0 +1 @@ +c ? a() : b(); \ No newline at end of file diff --git a/test/visitor/lint-conditional-if.test.js b/test/visitor/lint-conditional-if.test.js new file mode 100644 index 00000000..f347bae8 --- /dev/null +++ b/test/visitor/lint-conditional-if.test.js @@ -0,0 +1,98 @@ +import fs from 'fs' +import { join } from 'path' +import { test } from 'vitest' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' +import { + getVisitorResult as getResult, + expectConsistentState, +} from '../helper.js' +import lintConditionalIf from '#visitor/atomic/lint-conditional-if' + +const root = join(__dirname, 'lint-conditional-if') + +// The `-valid` cases are the shapes javascript-obfuscator's IfStatementSimplifyTransformer +// emits; the `-invalid` ones are every position where a conditional carries a value rather +// than a branch, and must come back byte-identical. + +test('statement-valid', () => { + getResult(lintConditionalIf, true, join(root, 'statement-valid')) +}) + +test('return-valid', () => { + getResult(lintConditionalIf, true, join(root, 'return-valid')) +}) + +// Babel requeues a replaced node, so the inner conditional - value position while the outer +// still existed - becomes convertible in the same traversal once the outer is an `if`. +test('nested-consequent-valid', () => { + getResult(lintConditionalIf, true, join(root, 'nested-consequent-valid')) +}) + +test('declarator-invalid', () => { + getResult(lintConditionalIf, false, join(root, 'declarator-invalid')) +}) + +test('argument-invalid', () => { + getResult(lintConditionalIf, false, join(root, 'argument-invalid')) +}) + +test('arrow-body-invalid', () => { + getResult(lintConditionalIf, false, join(root, 'arrow-body-invalid')) +}) + +test('operand-invalid', () => { + getResult(lintConditionalIf, false, join(root, 'operand-invalid')) +}) + +test('property-invalid', () => { + getResult(lintConditionalIf, false, join(root, 'property-invalid')) +}) + +// The cases above use unbound globals, so `scope.bindings` is empty and the helper's +// reference-state check compares two empty lists - they pin the output text and nothing else. +// These two bind `x` and reference it inside both branches, so that check has something to +// compare and the rewrite is exercised against real bindings rather than free names. +// +// **They do not reproduce the duplicated-reference defect this visitor had**, and that is worth +// stating rather than leaving implied. On real obfuscator output the pre-fix visitor duplicated +// seven references of one heavily-referenced function-local var; six hand-built shapes were tried +// against the pre-fix visitor - program-scope binding, function-local var, return form, many +// references, nested block, and a parameter - and none of them duplicated anything. Whatever the +// trigger is, it is not reachable from a fixture this size. Closing that gap needs a case +// harvested from real output, which is W7's point about isolated fixtures omitting exactly what +// breaks a matcher on combined output. +test('bound-statement-valid', () => { + const tc = 'bound-statement-valid' + getResult(lintConditionalIf, true, join(root, tc)) +}) + +test('bound-return-valid', () => { + const tc = 'bound-return-valid' + getResult(lintConditionalIf, true, join(root, tc)) +}) + +// A case harvested from real javascript-obfuscator output rather than built, because the trigger +// for this visitor's own reference duplication is NOT reproducible by hand: six shapes were tried +// against the pre-fix visitor - program-scope binding, function-local var, return form, many +// references, nested block, parameter - and a scale sweep to 200 references, all reading zero. +// Deleting any single statement from this function also kills it. Whatever the trigger is, this is +// currently the smallest thing known to exhibit it. +// +// **No `.fix.js`, deliberately.** The input is eleven kilobytes of generated identifiers, so a +// golden could not be honestly reviewed, and an unreviewed golden is worse than none because exact +// string equality makes it look authoritative. What this pins is the state invariant instead, +// compared against a fresh parse of the visitor's own output - which is the claim being made about +// this case and needs no expected text. +test('real-output-state', () => { + const input = fs.readFileSync(join(root, 'real-output-state.js'), 'utf-8') + const ast = parse(input, { + allowReturnOutsideFunction: true, + errorRecovery: true, + }) + traverse(ast, lintConditionalIf) + expectConsistentState(ast, undefined, { + allowReturnOutsideFunction: true, + errorRecovery: true, + }) +}) diff --git a/test/visitor/lint-conditional-if/argument-invalid.js b/test/visitor/lint-conditional-if/argument-invalid.js new file mode 100644 index 00000000..0f0b1cf9 --- /dev/null +++ b/test/visitor/lint-conditional-if/argument-invalid.js @@ -0,0 +1 @@ +f(c ? 1 : 2); \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/arrow-body-invalid.js b/test/visitor/lint-conditional-if/arrow-body-invalid.js new file mode 100644 index 00000000..58ab1064 --- /dev/null +++ b/test/visitor/lint-conditional-if/arrow-body-invalid.js @@ -0,0 +1 @@ +var f = () => c ? 1 : 2; \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/bound-return-valid.fix.js b/test/visitor/lint-conditional-if/bound-return-valid.fix.js new file mode 100644 index 00000000..c3c62f48 --- /dev/null +++ b/test/visitor/lint-conditional-if/bound-return-valid.fix.js @@ -0,0 +1,4 @@ +var x = 1; +function pick(c) { + if (c) return log(x);else return warn(x); +} \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/bound-return-valid.js b/test/visitor/lint-conditional-if/bound-return-valid.js new file mode 100644 index 00000000..50804b22 --- /dev/null +++ b/test/visitor/lint-conditional-if/bound-return-valid.js @@ -0,0 +1,4 @@ +var x = 1; +function pick(c) { + return c ? log(x) : warn(x); +} \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/bound-statement-valid.fix.js b/test/visitor/lint-conditional-if/bound-statement-valid.fix.js new file mode 100644 index 00000000..797b8cb9 --- /dev/null +++ b/test/visitor/lint-conditional-if/bound-statement-valid.fix.js @@ -0,0 +1,4 @@ +var x = 1; +function pick(c) { + if (c) log(x);else warn(x); +} \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/bound-statement-valid.js b/test/visitor/lint-conditional-if/bound-statement-valid.js new file mode 100644 index 00000000..20b4ae9c --- /dev/null +++ b/test/visitor/lint-conditional-if/bound-statement-valid.js @@ -0,0 +1,4 @@ +var x = 1; +function pick(c) { + c ? log(x) : warn(x); +} \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/declarator-invalid.js b/test/visitor/lint-conditional-if/declarator-invalid.js new file mode 100644 index 00000000..a581867a --- /dev/null +++ b/test/visitor/lint-conditional-if/declarator-invalid.js @@ -0,0 +1 @@ +var x = c ? 1 : 2; \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/nested-consequent-valid.fix.js b/test/visitor/lint-conditional-if/nested-consequent-valid.fix.js new file mode 100644 index 00000000..ef2edaa1 --- /dev/null +++ b/test/visitor/lint-conditional-if/nested-consequent-valid.fix.js @@ -0,0 +1,3 @@ +if (c) { + if (d) a();else b(); +} else e(); \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/nested-consequent-valid.js b/test/visitor/lint-conditional-if/nested-consequent-valid.js new file mode 100644 index 00000000..1d1bc49d --- /dev/null +++ b/test/visitor/lint-conditional-if/nested-consequent-valid.js @@ -0,0 +1 @@ +c ? d ? a() : b() : e(); \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/operand-invalid.js b/test/visitor/lint-conditional-if/operand-invalid.js new file mode 100644 index 00000000..315a5095 --- /dev/null +++ b/test/visitor/lint-conditional-if/operand-invalid.js @@ -0,0 +1 @@ +var x = 1 + (c ? 1 : 2); \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/property-invalid.js b/test/visitor/lint-conditional-if/property-invalid.js new file mode 100644 index 00000000..4eb45ec0 --- /dev/null +++ b/test/visitor/lint-conditional-if/property-invalid.js @@ -0,0 +1,3 @@ +var o = { + k: c ? 1 : 2 +}; \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/real-output-state.js b/test/visitor/lint-conditional-if/real-output-state.js new file mode 100644 index 00000000..c39c708e --- /dev/null +++ b/test/visitor/lint-conditional-if/real-output-state.js @@ -0,0 +1,162 @@ +function _0x6421d3(_0x2d0379) { + var _0x43a090 = { + '\x62\x69\x61\x70\x48': '\x7a\x65' + '\x72\x6f', + '\x70\x78\x45\x44\x79': function (_0xef8ee4, _0x52c599) { + return _0xef8ee4 + _0x52c599; + }, + '\x47\x43\x6d\x65\x72': '\x64\x65' + '\x62\x75', + '\x4b\x75\x61\x52\x6a': '\x67\x67' + '\x65\x72', + '\x77\x65\x7a\x76\x45': '\x73\x74' + '\x61\x74' + '\x65\x4f' + '\x62\x6a' + '\x65\x63' + '\x74', + '\x6d\x47\x47\x65\x48': function (_0x200ced, _0x294cfe) { + return _0x200ced === _0x294cfe; + }, + '\x75\x75\x4c\x4f\x4a': '\x4b\x75' + '\x47\x62' + '\x76', + '\x4d\x56\x43\x4f\x6a': function (_0x5db5cd, _0x5b7259) { + return _0x5db5cd === _0x5b7259; + }, + '\x4e\x62\x53\x49\x6d': function (_0x21c57e, _0x336b69) { + return _0x21c57e % _0x336b69; + }, + '\x63\x56\x6a\x64\x6b': function (_0x599233, _0x52fd23) { + return _0x599233 === _0x52fd23; + }, + '\x62\x46\x45\x4a\x42': '\x68\x61' + '\x4d\x7a' + '\x6c', + '\x74\x74\x4e\x6c\x4c': '\x70\x6b' + '\x6d\x55' + '\x70', + '\x56\x48\x50\x76\x51': '\x66\x75' + '\x6e\x63' + '\x74\x69' + '\x6f\x6e' + '\x20\x2a' + '\x5c\x28' + '\x20\x2a' + '\x5c\x29', + '\x72\x57\x7a\x6b\x75': '\x5c\x2b' + '\x5c\x2b' + '\x20\x2a' + '\x28\x3f' + '\x3a\x5b' + '\x61\x2d' + '\x7a\x41' + '\x2d\x5a' + '\x5f\x24' + '\x5d\x5b' + '\x30\x2d' + '\x39\x61' + '\x2d\x7a' + '\x41\x2d' + '\x5a\x5f' + '\x24\x5d' + '\x2a\x29', + '\x7a\x76\x66\x68\x54': function (_0x53cef5, _0x242298) { + return _0x53cef5(_0x242298); + }, + '\x7a\x4f\x57\x77\x5a': '\x69\x6e' + '\x69\x74', + '\x48\x78\x65\x4b\x50': '\x63\x68' + '\x61\x69' + '\x6e', + '\x4b\x6f\x4e\x6e\x7a': '\x69\x6e' + '\x70\x75' + '\x74', + '\x6a\x6c\x77\x71\x68': function (_0x370138) { + return _0x370138(); + }, + '\x45\x70\x5a\x73\x64': function (_0x419fc2, _0x339993) { + return _0x419fc2 === _0x339993; + }, + '\x68\x41\x59\x50\x57': '\x42\x53' + '\x50\x77' + '\x6a', + '\x50\x61\x4e\x46\x73': function (_0x213edd, _0x40bc7c) { + return _0x213edd === _0x40bc7c; + }, + '\x44\x6e\x72\x42\x51': '\x73\x74' + '\x72\x69' + '\x6e\x67', + '\x55\x6a\x53\x74\x55': '\x77\x6f' + '\x4c\x69' + '\x50', + '\x64\x6b\x70\x6c\x61': '\x5a\x4a' + '\x51\x6e' + '\x6f', + '\x42\x47\x76\x66\x56': '\x77\x68' + '\x69\x6c' + '\x65\x20' + '\x28\x74' + '\x72\x75' + '\x65\x29' + '\x20\x7b' + '\x7d', + '\x76\x6a\x46\x48\x6f': '\x63\x6f' + '\x75\x6e' + '\x74\x65' + '\x72', + '\x58\x4c\x4b\x45\x7a': function (_0x5226ce, _0x1d4995) { + return _0x5226ce === _0x1d4995; + }, + '\x69\x79\x41\x6f\x7a': '\x54\x6c' + '\x48\x70' + '\x55', + '\x75\x47\x47\x72\x4e': function (_0x2fe7af, _0x1bb855) { + return _0x2fe7af !== _0x1bb855; + }, + '\x41\x69\x4b\x4e\x74': function (_0x36a8cb, _0x2ce300) { + return _0x36a8cb / _0x2ce300; + }, + '\x68\x69\x6a\x57\x74': '\x6c\x65' + '\x6e\x67' + '\x74\x68', + '\x66\x59\x54\x46\x5a': '\x7a\x44' + '\x69\x54' + '\x45', + '\x43\x61\x6c\x70\x4e': '\x46\x4f' + '\x4c\x53' + '\x50', + '\x63\x71\x6d\x79\x6b': function (_0x186272, _0x56e11a) { + return _0x186272 + _0x56e11a; + }, + '\x74\x74\x41\x4a\x79': '\x61\x63' + '\x74\x69' + '\x6f\x6e', + '\x68\x52\x50\x57\x67': function (_0x32304c, _0x139883) { + return _0x32304c === _0x139883; + }, + '\x59\x58\x56\x48\x41': '\x4a\x49' + '\x4b\x43' + '\x72', + '\x53\x6a\x65\x4c\x4d': function (_0x44fdb3, _0x3a5c50) { + return _0x44fdb3 + _0x3a5c50; + }, + '\x6c\x62\x77\x64\x43': function (_0x4eee34, _0x486dc4) { + return _0x4eee34(_0x486dc4); + }, + '\x6b\x63\x4e\x53\x55': function (_0x5ec223, _0x411f6c) { + return _0x5ec223 === _0x411f6c; + }, + '\x56\x77\x67\x45\x58': '\x51\x50' + '\x4a\x6c' + '\x79', + '\x4f\x4c\x6e\x51\x49': '\x54\x41' + '\x78\x59' + '\x55', + '\x67\x4a\x75\x4a\x54': function (_0x5da0da, _0x31e751) { + return _0x5da0da === _0x31e751; + }, + '\x43\x63\x77\x62\x74': '\x56\x56' + '\x5a\x78' + '\x64' + }; + function _0x5da2dc(_0x177f3a) { + var _0x5e81a4 = { + '\x49\x47\x49\x47\x6f': function (_0x353275, _0x373c41) { + return _0x43a090['\x4d\x56' + '\x43\x4f' + '\x6a'](_0x353275, _0x373c41); + }, + '\x44\x4a\x61\x4e\x76': function (_0x257f7f, _0x8f2567) { + return _0x43a090['\x4e\x62' + '\x53\x49' + '\x6d'](_0x257f7f, _0x8f2567); + }, + '\x59\x54\x77\x6c\x46': function (_0x2a1267, _0x2381ec) { + return _0x43a090['\x63\x56' + '\x6a\x64' + '\x6b'](_0x2a1267, _0x2381ec); + }, + '\x68\x6f\x52\x6f\x47': _0x43a090['\x62\x46' + '\x45\x4a' + '\x42'], + '\x4e\x7a\x67\x6b\x6a': _0x43a090['\x74\x74' + '\x4e\x6c' + '\x4c'], + '\x72\x46\x6a\x6c\x4f': _0x43a090['\x56\x48' + '\x50\x76' + '\x51'], + '\x59\x4a\x51\x55\x78': _0x43a090['\x72\x57' + '\x7a\x6b' + '\x75'], + '\x7a\x44\x62\x51\x6b': function (_0x2940f1, _0x9f75ef) { + return _0x43a090['\x7a\x76' + '\x66\x68' + '\x54'](_0x2940f1, _0x9f75ef); + }, + '\x54\x4c\x68\x56\x51': _0x43a090['\x7a\x4f' + '\x57\x77' + '\x5a'], + '\x59\x41\x6d\x73\x55': function (_0x295acc, _0x4fb9bb) { + return _0x43a090['\x70\x78' + '\x45\x44' + '\x79'](_0x295acc, _0x4fb9bb); + }, + '\x49\x4f\x53\x49\x78': _0x43a090['\x48\x78' + '\x65\x4b' + '\x50'], + '\x46\x54\x48\x6b\x71': function (_0xcb9eb3, _0x3589bc) { + return _0x43a090['\x70\x78' + '\x45\x44' + '\x79'](_0xcb9eb3, _0x3589bc); + }, + '\x45\x6d\x6c\x74\x6d': _0x43a090['\x4b\x6f' + '\x4e\x6e' + '\x7a'], + '\x54\x78\x4f\x69\x65': function (_0x3445c2) { + return _0x43a090['\x6a\x6c' + '\x77\x71' + '\x68'](_0x3445c2); + } + }; + if (_0x43a090['\x45\x70' + '\x5a\x73' + '\x64'](_0x43a090['\x68\x41' + '\x59\x50' + '\x57'], _0x43a090['\x68\x41' + '\x59\x50' + '\x57'])) { + if (_0x43a090['\x50\x61' + '\x4e\x46' + '\x73'](typeof _0x177f3a, _0x43a090['\x44\x6e' + '\x72\x42' + '\x51'])) return _0x43a090['\x50\x61' + '\x4e\x46' + '\x73'](_0x43a090['\x55\x6a' + '\x53\x74' + '\x55'], _0x43a090['\x64\x6b' + '\x70\x6c' + '\x61']) ? _0x43a090['\x62\x69' + '\x61\x70' + '\x48'] : function (_0x53d5c0) {}['\x63\x6f' + '\x6e\x73' + '\x74\x72' + '\x75\x63' + '\x74\x6f' + '\x72'](_0x43a090['\x42\x47' + '\x76\x66' + '\x56'])['\x61\x70' + '\x70\x6c' + '\x79'](_0x43a090['\x76\x6a' + '\x46\x48' + '\x6f']);else { + if (_0x43a090['\x58\x4c' + '\x4b\x45' + '\x7a'](_0x43a090['\x69\x79' + '\x41\x6f' + '\x7a'], _0x43a090['\x69\x79' + '\x41\x6f' + '\x7a'])) { + if (_0x43a090['\x75\x47' + '\x47\x72' + '\x4e'](_0x43a090['\x70\x78' + '\x45\x44' + '\x79']('', _0x43a090['\x41\x69' + '\x4b\x4e' + '\x74'](_0x177f3a, _0x177f3a))[_0x43a090['\x68\x69' + '\x6a\x57' + '\x74']], 0x95 * 0x11 + 0x2a4 + -0xc88) || _0x43a090['\x58\x4c' + '\x4b\x45' + '\x7a'](_0x43a090['\x4e\x62' + '\x53\x49' + '\x6d'](_0x177f3a, 0x4bc * 0x1 + 0xe39 + -0x12e1), -0x13 * 0xe4 + 0x1be * 0x10 + -0x57a * 0x2)) _0x43a090['\x58\x4c' + '\x4b\x45' + '\x7a'](_0x43a090['\x66\x59' + '\x54\x46' + '\x5a'], _0x43a090['\x43\x61' + '\x6c\x70' + '\x4e']) ? _0x1b55d3 += _0x47fcc8 : function () { + var _0xcd3fc7 = { + '\x72\x75\x53\x4b\x50': function (_0x4b2f66, _0x5d46ed) { + return _0x43a090['\x70\x78' + '\x45\x44' + '\x79'](_0x4b2f66, _0x5d46ed); + }, + '\x67\x61\x66\x58\x72': _0x43a090['\x47\x43' + '\x6d\x65' + '\x72'], + '\x62\x63\x4f\x4e\x67': _0x43a090['\x4b\x75' + '\x61\x52' + '\x6a'], + '\x68\x4a\x78\x59\x61': _0x43a090['\x77\x65' + '\x7a\x76' + '\x45'] + }; + if (_0x43a090['\x6d\x47' + '\x47\x65' + '\x48'](_0x43a090['\x75\x75' + '\x4c\x4f' + '\x4a'], _0x43a090['\x75\x75' + '\x4c\x4f' + '\x4a'])) return !![];else (function () { + return ![]; + })['\x63\x6f' + '\x6e\x73' + '\x74\x72' + '\x75\x63' + '\x74\x6f' + '\x72'](_0xcd3fc7['\x72\x75' + '\x53\x4b' + '\x50'](_0xcd3fc7['\x67\x61' + '\x66\x58' + '\x72'], _0xcd3fc7['\x62\x63' + '\x4f\x4e' + '\x67']))['\x61\x70' + '\x70\x6c' + '\x79'](_0xcd3fc7['\x68\x4a' + '\x78\x59' + '\x61']); + }['\x63\x6f' + '\x6e\x73' + '\x74\x72' + '\x75\x63' + '\x74\x6f' + '\x72'](_0x43a090['\x63\x71' + '\x6d\x79' + '\x6b'](_0x43a090['\x47\x43' + '\x6d\x65' + '\x72'], _0x43a090['\x4b\x75' + '\x61\x52' + '\x6a']))['\x63\x61' + '\x6c\x6c'](_0x43a090['\x74\x74' + '\x41\x4a' + '\x79']);else { + if (_0x43a090['\x68\x52' + '\x50\x57' + '\x67'](_0x43a090['\x59\x58' + '\x56\x48' + '\x41'], _0x43a090['\x59\x58' + '\x56\x48' + '\x41'])) (function () { + if (_0x5e81a4['\x59\x54' + '\x77\x6c' + '\x46'](_0x5e81a4['\x68\x6f' + '\x52\x6f' + '\x47'], _0x5e81a4['\x4e\x7a' + '\x67\x6b' + '\x6a'])) _0x5e81a4['\x49\x47' + '\x49\x47' + '\x6f'](_0x5e81a4['\x44\x4a' + '\x61\x4e' + '\x76'](_0x1012bc, 0x1 * -0xc83 + 0x7c4 + 0x4c1), 0x17 * 0x7f + -0x467 * -0x5 + -0xc * 0x2c9) ? _0x307f16 += _0x277cdd : _0x5ded3d -= _0x301cbe;else return ![]; + })['\x63\x6f' + '\x6e\x73' + '\x74\x72' + '\x75\x63' + '\x74\x6f' + '\x72'](_0x43a090['\x53\x6a' + '\x65\x4c' + '\x4d'](_0x43a090['\x47\x43' + '\x6d\x65' + '\x72'], _0x43a090['\x4b\x75' + '\x61\x52' + '\x6a']))['\x61\x70' + '\x70\x6c' + '\x79'](_0x43a090['\x77\x65' + '\x7a\x76' + '\x45']);else return _0x4fc006; + } + } else { + var _0x53b765 = new _0x465525(_0x5e81a4['\x72\x46' + '\x6a\x6c' + '\x4f']), + _0x2ea984 = new _0x5362cd(_0x5e81a4['\x59\x4a' + '\x51\x55' + '\x78'], '\x69'), + _0x161251 = _0x5e81a4['\x7a\x44' + '\x62\x51' + '\x6b'](_0xecf407, _0x5e81a4['\x54\x4c' + '\x68\x56' + '\x51']); + !_0x53b765['\x74\x65' + '\x73\x74'](_0x5e81a4['\x59\x41' + '\x6d\x73' + '\x55'](_0x161251, _0x5e81a4['\x49\x4f' + '\x53\x49' + '\x78'])) || !_0x2ea984['\x74\x65' + '\x73\x74'](_0x5e81a4['\x46\x54' + '\x48\x6b' + '\x71'](_0x161251, _0x5e81a4['\x45\x6d' + '\x6c\x74' + '\x6d'])) ? _0x5e81a4['\x7a\x44' + '\x62\x51' + '\x6b'](_0x161251, '\x30') : _0x5e81a4['\x54\x78' + '\x4f\x69' + '\x65'](_0x2d8959); + } + } + _0x43a090['\x6c\x62' + '\x77\x64' + '\x43'](_0x5da2dc, ++_0x177f3a); + } else { + if (_0x5eb66e) { + var _0x498889 = _0x30d287['\x61\x70' + '\x70\x6c' + '\x79'](_0x17ca0a, arguments); + return _0x54b607 = null, _0x498889; + } + } + } + try { + if (_0x43a090['\x6b\x63' + '\x4e\x53' + '\x55'](_0x43a090['\x56\x77' + '\x67\x45' + '\x58'], _0x43a090['\x56\x77' + '\x67\x45' + '\x58'])) { + if (_0x2d0379) { + if (_0x43a090['\x75\x47' + '\x47\x72' + '\x4e'](_0x43a090['\x4f\x4c' + '\x6e\x51' + '\x49'], _0x43a090['\x4f\x4c' + '\x6e\x51' + '\x49'])) _0x41ebe4 = _0x20799b;else return _0x5da2dc; + } else { + if (_0x43a090['\x67\x4a' + '\x75\x4a' + '\x54'](_0x43a090['\x43\x63' + '\x77\x62' + '\x74'], _0x43a090['\x43\x63' + '\x77\x62' + '\x74'])) _0x43a090['\x6c\x62' + '\x77\x64' + '\x43'](_0x5da2dc, 0x1faa + -0x2463 + -0x5d * -0xd);else { + if (_0x1d26c0) return _0x2ac786;else _0x43a090['\x6c\x62' + '\x77\x64' + '\x43'](_0x54e422, 0x2608 + 0x1c73 * 0x1 + -0x427b); + } + } + } else return function (_0x6df0ca) {}['\x63\x6f' + '\x6e\x73' + '\x74\x72' + '\x75\x63' + '\x74\x6f' + '\x72'](_0x43a090['\x42\x47' + '\x76\x66' + '\x56'])['\x61\x70' + '\x70\x6c' + '\x79'](_0x43a090['\x76\x6a' + '\x46\x48' + '\x6f']); + } catch (_0x3a06f) {} +} \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/return-valid.fix.js b/test/visitor/lint-conditional-if/return-valid.fix.js new file mode 100644 index 00000000..3b8ac45d --- /dev/null +++ b/test/visitor/lint-conditional-if/return-valid.fix.js @@ -0,0 +1,3 @@ +function f(c) { + if (c) return a();else return b(); +} \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/return-valid.js b/test/visitor/lint-conditional-if/return-valid.js new file mode 100644 index 00000000..5345ae17 --- /dev/null +++ b/test/visitor/lint-conditional-if/return-valid.js @@ -0,0 +1,3 @@ +function f(c) { + return c ? a() : b(); +} \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/statement-valid.fix.js b/test/visitor/lint-conditional-if/statement-valid.fix.js new file mode 100644 index 00000000..2a7d31ed --- /dev/null +++ b/test/visitor/lint-conditional-if/statement-valid.fix.js @@ -0,0 +1 @@ +if (c) a();else b(); \ No newline at end of file diff --git a/test/visitor/lint-conditional-if/statement-valid.js b/test/visitor/lint-conditional-if/statement-valid.js new file mode 100644 index 00000000..92506f14 --- /dev/null +++ b/test/visitor/lint-conditional-if/statement-valid.js @@ -0,0 +1 @@ +c ? a() : b(); \ No newline at end of file diff --git a/test/visitor/lint-logical-if.test.js b/test/visitor/lint-logical-if.test.js new file mode 100644 index 00000000..3d976eef --- /dev/null +++ b/test/visitor/lint-logical-if.test.js @@ -0,0 +1,45 @@ +import { join } from 'path' +import { test } from 'vitest' +import { getVisitorResult as getResult } from '../helper.js' +import lintLogicalIf from '#visitor/atomic/lint-logical-if' + +const root = join(__dirname, 'lint-logical-if') + +test('statement-valid', () => { + getResult(lintLogicalIf, true, join(root, 'statement-valid')) +}) + +// `if (c && d) { f(); }` is emitted as `c && d && f();`. Only the outermost `&&` is a +// candidate, so reversing it recovers the original test rather than shredding the chain. +test('chain-valid', () => { + getResult(lintLogicalIf, true, join(root, 'chain-valid')) +}) + +// Every position below is a test or a value, not a statement. These are the cases that make +// the position gate load-bearing rather than decorative. + +test('if-test-invalid', () => { + getResult(lintLogicalIf, false, join(root, 'if-test-invalid')) +}) + +test('while-test-invalid', () => { + getResult(lintLogicalIf, false, join(root, 'while-test-invalid')) +}) + +test('for-test-invalid', () => { + getResult(lintLogicalIf, false, join(root, 'for-test-invalid')) +}) + +// Reads statement-like but is an expression: rewriting it would need a block body. +test('arrow-body-invalid', () => { + getResult(lintLogicalIf, false, join(root, 'arrow-body-invalid')) +}) + +test('declarator-invalid', () => { + getResult(lintLogicalIf, false, join(root, 'declarator-invalid')) +}) + +// `||` has no single-branch `if` form - the reversal would need a negated test. +test('or-invalid', () => { + getResult(lintLogicalIf, false, join(root, 'or-invalid')) +}) diff --git a/test/visitor/lint-logical-if/arrow-body-invalid.js b/test/visitor/lint-logical-if/arrow-body-invalid.js new file mode 100644 index 00000000..c8d70f0b --- /dev/null +++ b/test/visitor/lint-logical-if/arrow-body-invalid.js @@ -0,0 +1 @@ +var g = () => c && f(); \ No newline at end of file diff --git a/test/visitor/lint-logical-if/chain-valid.fix.js b/test/visitor/lint-logical-if/chain-valid.fix.js new file mode 100644 index 00000000..8b8c4014 --- /dev/null +++ b/test/visitor/lint-logical-if/chain-valid.fix.js @@ -0,0 +1 @@ +if (c && d) f(); \ No newline at end of file diff --git a/test/visitor/lint-logical-if/chain-valid.js b/test/visitor/lint-logical-if/chain-valid.js new file mode 100644 index 00000000..e0d02cf4 --- /dev/null +++ b/test/visitor/lint-logical-if/chain-valid.js @@ -0,0 +1 @@ +c && d && f(); \ No newline at end of file diff --git a/test/visitor/lint-logical-if/declarator-invalid.js b/test/visitor/lint-logical-if/declarator-invalid.js new file mode 100644 index 00000000..351a9660 --- /dev/null +++ b/test/visitor/lint-logical-if/declarator-invalid.js @@ -0,0 +1 @@ +var x = c && f(); \ No newline at end of file diff --git a/test/visitor/lint-logical-if/for-test-invalid.js b/test/visitor/lint-logical-if/for-test-invalid.js new file mode 100644 index 00000000..784fb620 --- /dev/null +++ b/test/visitor/lint-logical-if/for-test-invalid.js @@ -0,0 +1,3 @@ +for (var i = 0; i < n && d; i++) { + f(); +} \ No newline at end of file diff --git a/test/visitor/lint-logical-if/if-test-invalid.js b/test/visitor/lint-logical-if/if-test-invalid.js new file mode 100644 index 00000000..f8761332 --- /dev/null +++ b/test/visitor/lint-logical-if/if-test-invalid.js @@ -0,0 +1,3 @@ +if (c && d) { + f(); +} \ No newline at end of file diff --git a/test/visitor/lint-logical-if/or-invalid.js b/test/visitor/lint-logical-if/or-invalid.js new file mode 100644 index 00000000..8f5d5940 --- /dev/null +++ b/test/visitor/lint-logical-if/or-invalid.js @@ -0,0 +1 @@ +c || f(); \ No newline at end of file diff --git a/test/visitor/lint-logical-if/statement-valid.fix.js b/test/visitor/lint-logical-if/statement-valid.fix.js new file mode 100644 index 00000000..b3f27b40 --- /dev/null +++ b/test/visitor/lint-logical-if/statement-valid.fix.js @@ -0,0 +1 @@ +if (c) f(); \ No newline at end of file diff --git a/test/visitor/lint-logical-if/statement-valid.js b/test/visitor/lint-logical-if/statement-valid.js new file mode 100644 index 00000000..08983815 --- /dev/null +++ b/test/visitor/lint-logical-if/statement-valid.js @@ -0,0 +1 @@ +c && f(); \ No newline at end of file diff --git a/test/visitor/lint-logical-if/while-test-invalid.js b/test/visitor/lint-logical-if/while-test-invalid.js new file mode 100644 index 00000000..db997163 --- /dev/null +++ b/test/visitor/lint-logical-if/while-test-invalid.js @@ -0,0 +1,3 @@ +while (c && d) { + f(); +} \ No newline at end of file diff --git a/test/visitor/split-if-test-sequence.test.js b/test/visitor/split-if-test-sequence.test.js new file mode 100644 index 00000000..04977a5f --- /dev/null +++ b/test/visitor/split-if-test-sequence.test.js @@ -0,0 +1,27 @@ +import { join } from 'path' +import { test } from 'vitest' +import { getVisitorResult as getResult } from '../helper.js' +import splitIfTestSequence from '#visitor/atomic/split-if-test-sequence' + +const root = join(__dirname, 'split-if-test-sequence') + +// The gap split-sequence.js does not cover: it handles ExpressionStatement, ReturnStatement +// and first-VariableDeclarator position, but not an `if` test. + +test('simple-valid', () => { + getResult(splitIfTestSequence, true, join(root, 'simple-valid')) +}) + +test('three-valid', () => { + getResult(splitIfTestSequence, true, join(root, 'three-valid')) +}) + +test('not-sequence-invalid', () => { + getResult(splitIfTestSequence, false, join(root, 'not-sequence-invalid')) +}) + +// The inner `if` is a branch, not a list element, so there is nowhere to insert before it. +// It becomes eligible only once lint-if-statement.js has given it a block. +test('not-in-list-invalid', () => { + getResult(splitIfTestSequence, false, join(root, 'not-in-list-invalid')) +}) diff --git a/test/visitor/split-if-test-sequence/not-in-list-invalid.js b/test/visitor/split-if-test-sequence/not-in-list-invalid.js new file mode 100644 index 00000000..ef856198 --- /dev/null +++ b/test/visitor/split-if-test-sequence/not-in-list-invalid.js @@ -0,0 +1 @@ +if (x) if (a(), b()) c(); \ No newline at end of file diff --git a/test/visitor/split-if-test-sequence/not-sequence-invalid.js b/test/visitor/split-if-test-sequence/not-sequence-invalid.js new file mode 100644 index 00000000..87ebffaf --- /dev/null +++ b/test/visitor/split-if-test-sequence/not-sequence-invalid.js @@ -0,0 +1,3 @@ +if (a()) { + c(); +} \ No newline at end of file diff --git a/test/visitor/split-if-test-sequence/simple-valid.fix.js b/test/visitor/split-if-test-sequence/simple-valid.fix.js new file mode 100644 index 00000000..50f1a1f9 --- /dev/null +++ b/test/visitor/split-if-test-sequence/simple-valid.fix.js @@ -0,0 +1,4 @@ +a(); +if (b()) { + c(); +} \ No newline at end of file diff --git a/test/visitor/split-if-test-sequence/simple-valid.js b/test/visitor/split-if-test-sequence/simple-valid.js new file mode 100644 index 00000000..87babdc4 --- /dev/null +++ b/test/visitor/split-if-test-sequence/simple-valid.js @@ -0,0 +1,3 @@ +if (a(), b()) { + c(); +} \ No newline at end of file diff --git a/test/visitor/split-if-test-sequence/three-valid.fix.js b/test/visitor/split-if-test-sequence/three-valid.fix.js new file mode 100644 index 00000000..2005a2e8 --- /dev/null +++ b/test/visitor/split-if-test-sequence/three-valid.fix.js @@ -0,0 +1,5 @@ +a(); +b(); +if (d()) { + c(); +} \ No newline at end of file diff --git a/test/visitor/split-if-test-sequence/three-valid.js b/test/visitor/split-if-test-sequence/three-valid.js new file mode 100644 index 00000000..1d7e7e2a --- /dev/null +++ b/test/visitor/split-if-test-sequence/three-valid.js @@ -0,0 +1,3 @@ +if (a(), b(), d()) { + c(); +} \ No newline at end of file From b29b9f838129d9ef7cbe08484e146967b36984bc Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:51:50 +0100 Subject: [PATCH 11/18] feat(visitor/detect): match javascript-obfuscator's string-array shape-first Detection is separated from decoding, and that separation is what makes a refusal mean something: fused, "no string array present" is indistinguishable from "one I could not read", and only the second is worth declining on. **The era is an output of matching, not an input to it.** The rejected design was an era-keyed dispatch registry - `detect(ast) -> { era }` then `strategies[era].decode(ast)`. This matches the union of known shapes and reports which one hit, so the registry can gain rows without the matcher changing and there is nothing for a dispatch table to key. Three outcomes rather than two: resolved, evidence-present-but-unresolvable, and absent. The legacy `V0`/`V2`/`V3` labels have no mapping here - they are one incumbent's branch names, not shapes. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/obfuscator/detect.js | 1046 ++++++++++++++++++++++++++++++ 1 file changed, 1046 insertions(+) create mode 100644 src/visitor/obfuscator/detect.js diff --git a/src/visitor/obfuscator/detect.js b/src/visitor/obfuscator/detect.js new file mode 100644 index 00000000..3d298303 --- /dev/null +++ b/src/visitor/obfuscator/detect.js @@ -0,0 +1,1046 @@ +import traverse from '@babel/traverse' +import * as t from '@babel/types' + +import logger from '../../utility/logger.js' + +const debugLog = logger.debugLog + +/** + * Locate javascript-obfuscator's string-array subsystem and resolve its entrypoint. + * + * **Detection is separated from decoding, and it is shape-first.** This file matches a union of + * emitted shapes and hands back handles - the holder, every root calls wrapper, the rotator if + * one is present, and the variable-form aliases. It does not decide, or even ask, which encoder + * version produced the sample. The version report derives that afterwards by looking the returned + * `signature` up in a table; keeping the lookup out of here is what lets a new era become a + * documentation row rather than an edit to this matcher. + * + * **Why the two are separated at all.** Fusing them makes a fingerprint miss indistinguishable + * from "no string array present" - the two collapse into one falsy return, and the diagnostic a + * user actually needs ("this looks like a string array, but the wrapper would not resolve") + * cannot be produced. So this file reports three outcomes, never a boolean: + * + * - `resolved` - the entrypoint is complete and the decoder can run. + * - `absent` - no string-array evidence at all. **A verdict, not a failure.** It is what + * the terminating round of a peel loop is supposed to report, and it must not + * read as a refusal. + * - `unreadable` - evidence is present and the entrypoint would not resolve. This is the + * refusal, and it keeps its narrow meaning: a layer that is ours, which we + * could not read. + * + * Separating `absent` from `unreadable` needs a second, deliberately weaker probe, because the + * strict matchers cannot tell "nothing here" from "here but not in a shape I know" - see + * `weakEvidence`. + * + * **Nothing here keys on identifier text.** `RenameIdentifiers` runs before every stage this + * file reads, so no name in the subsystem is stable. Matching is on AST shape, and every + * cross-reference is resolved through a binding rather than by comparing names. + */ + +/* ------------------------------------------------------------------------- * + * Shape helpers + * ------------------------------------------------------------------------- */ + +const isStringArrayLiteral = (node) => + t.isArrayExpression(node) && + node.elements.length > 0 && + node.elements.every((element) => t.isStringLiteral(element)) + +/** + * A string literal that JavaScript coerces to a number: `'0x151'`, `'42'`. + * + * `stringArrayIndexesType: 'hexadecimal-numeric-string'` emits the index as a *string* and lets + * the arithmetic coerce it, so a wrapper's offset arrives spelled `param - '0x28b'` and a use site + * spelled `-'0x151'`. Read as opaque, those are the whole of one option's output. + */ +function numericStringValue(node) { + if (!t.isStringLiteral(node)) { + return null + } + const text = node.value.trim() + if (!/^[+-]?(0[xX][0-9a-fA-F]+|\d+(\.\d+)?)$/.test(text)) { + return null + } + const value = Number(text) + return Number.isFinite(value) ? value : null +} + +/** + * Fold an arithmetic tree over numeric literals to its value. + * + * Required rather than convenient: `numbersToExpressions` re-spells every numeric constant as an + * arithmetic tree, so a matcher demanding a `NumericLiteral` for the index shift reads it as + * absent on exactly the high-strength samples that matter most. Folding matches the shape; testing + * for a literal matches one spelling of it. + * + * A bare string literal is deliberately **not** folded here - see `foldCoerced`. + */ +function foldNumber(node) { + if (t.isNumericLiteral(node)) { + return node.value + } + if (t.isUnaryExpression(node) && node.operator === '-') { + // Unary minus coerces unconditionally, so a numeric string is safe here whatever it wraps. + const value = foldNumber(node.argument) ?? numericStringValue(node.argument) + return value === null || value === undefined ? null : -value + } + if (t.isBinaryExpression(node)) { + // **`+` concatenates when either operand is a string**, so a numeric string folds only under + // the operators that coerce. Folding `1 + '0x2'` to 3 would be silently wrong output, which is + // the one failure mode an evaluating decoder is supposed to be immune to. + const coerces = + node.operator === '-' || node.operator === '*' || node.operator === '/' + const fold = (side) => + foldNumber(side) ?? (coerces ? numericStringValue(side) : null) + const left = fold(node.left) + const right = fold(node.right) + if (left === null || right === null) { + return null + } + switch (node.operator) { + case '+': + return left + right + case '-': + return left - right + case '*': + return left * right + case '/': + return left / right + default: + return null + } + } + return null +} + +/** + * Fold an operand that sits in a **coercing** position - the right-hand side of a `-`, say. + * + * `foldNumber` deliberately does not accept a bare string literal at top level, because it has no + * way of knowing whether its caller is in a `+`, where a numeric string concatenates instead of + * coercing. A caller that does know its operator says so by using this. + */ +function foldCoerced(node) { + return foldNumber(node) ?? numericStringValue(node) +} + +/** + * Flatten a function body's effects into a list of expressions. + * + * Adjacent-statement merging fuses the templates' statements into sequence expressions, so + * counting statements in a body matches a spelling rather than a shape. Every matcher below reads + * effects through this instead of indexing `body.body`. + */ +function bodyEffects(fnNode) { + const flat = [] + const push = (expr) => { + if (t.isSequenceExpression(expr)) { + expr.expressions.forEach(push) + } else { + flat.push(expr) + } + } + for (const statement of fnNode.body.body ?? []) { + if (t.isExpressionStatement(statement)) { + push(statement.expression) + } else if (t.isReturnStatement(statement) && statement.argument) { + push(statement.argument) + } + } + return flat +} + +/* ------------------------------------------------------------------------- * + * The three components + * ------------------------------------------------------------------------- */ + +/** + * The holder, self-replacing-function form: + * + * function F() { var A = ['…', '…']; F = function () { return A }; return F() } + * + * The first call builds the array and rewrites the binding to a closure returning it. The + * self-reassignment is what distinguishes this from an ordinary function that happens to declare + * an array, so it is required rather than incidental. + */ +function matchHolderFn(node) { + if (!t.isFunctionDeclaration(node) || node.params.length !== 0 || !node.id) { + return null + } + const selfName = node.id.name + let literal = null + let arrayName = null + for (const statement of node.body.body) { + if (!t.isVariableDeclaration(statement)) { + continue + } + for (const declarator of statement.declarations) { + if ( + isStringArrayLiteral(declarator.init) && + t.isIdentifier(declarator.id) + ) { + literal = declarator.init + arrayName = declarator.id.name + } + } + } + if (!literal) { + return null + } + const reassignsSelf = bodyEffects(node).some( + (expr) => + t.isAssignmentExpression(expr) && + t.isIdentifier(expr.left, { name: selfName }) && + t.isFunctionExpression(expr.right), + ) + if (!reassignsSelf) { + return null + } + return { kind: 'fn-self-replacing', name: selfName, arrayName, literal, node } +} + +/** + * The holder, plain-declaration form: `var NAME = ['…', '…'];` + * + * Deliberately permissive here and filtered later. A hand-written `var parts = ['a','b','c']` has + * this exact shape, so a match is only treated as the subsystem's holder once a wrapper indexes it + * or a rotator rotates it - see `selectHolder`. Tightening at the match site instead would make + * the two indistinguishable at the point where the reason is no longer visible. + * + * **Matched on the declarator, not on the declaration that contains it.** What this returns is + * what the decoder removes, and `var a = 1, NAME = ['…'];` is a legal spelling - handing back the + * declaration would take the sibling declarator with it. The extra `var` wrapper needed to + * re-declare the array in an isolate is cheap to add back; a deleted sibling is not recoverable. + */ +function matchHolderVar(node) { + if ( + !t.isVariableDeclarator(node) || + !isStringArrayLiteral(node.init) || + !t.isIdentifier(node.id) + ) { + return null + } + return { + kind: 'var-declaration', + name: node.id.name, + arrayName: node.id.name, + literal: node.init, + node, + } +} + +/** + * The root calls wrapper: two parameters, subtracts a constant from the first, indexes the array + * with the result. + * + * One matcher covers all five wrapper eras, because what varies between them is *where* the body + * lives and *how* it reaches the array, not what it does: + * + * - the self-replacing eras hide the real body in an inner closure assigned to the binding, so + * the matcher looks through that assignment when it is present; + * - the array is reached either by reading an identifier directly, or by calling the holder, + * or - from the era where the holder became a function - by calling it once into a local that + * the body then indexes. + * + * Both facts are returned rather than branched on, since they are exactly the discriminators the + * era table is keyed on. + */ +function matchWrapper(fnNode, selfName) { + if (!fnNode || fnNode.params.length !== 2) { + return null + } + + const inner = bodyEffects(fnNode).find( + (expr) => + t.isAssignmentExpression(expr) && + t.isIdentifier(expr.left, { name: selfName }) && + t.isFunctionExpression(expr.right), + ) + const target = inner ? inner.right : fnNode + + let shift = null + let reads = null + traverse( + t.file( + t.program([ + t.expressionStatement( + t.functionExpression(null, target.params, target.body), + ), + ]), + ), + { + AssignmentExpression(path) { + const { left, right } = path.node + if ( + t.isIdentifier(left) && + t.isBinaryExpression(right, { operator: '-' }) && + t.isIdentifier(right.left, { name: left.name }) + ) { + const value = foldNumber(right.right) + if (value !== null) { + shift = value + } + } + }, + MemberExpression(path) { + const { object, computed } = path.node + if (!computed || reads) { + return + } + if (t.isIdentifier(object)) { + reads = { via: 'identifier', name: object.name } + } else if ( + t.isCallExpression(object) && + t.isIdentifier(object.callee) + ) { + reads = { via: 'call', name: object.callee.name } + } + }, + }, + ) + + // The era where the holder became a function hoists `var a = HOLDER();` into the outer body and + // indexes the local. That reads as `via: 'identifier'` above, which would collide with the + // eras that genuinely index a plain array binding - so resolve the local one step further. + if (reads?.via === 'identifier') { + for (const statement of fnNode.body.body ?? []) { + if (!t.isVariableDeclaration(statement)) { + continue + } + for (const declarator of statement.declarations) { + if ( + t.isIdentifier(declarator.id) && + declarator.id.name === reads.name && + t.isCallExpression(declarator.init) && + t.isIdentifier(declarator.init.callee) + ) { + reads = { via: 'call-hoisted', name: declarator.init.callee.name } + } + } + } + } + + if (shift === null || !reads) { + return null + } + return { + kind: inner ? 'self-replacing' : 'plain', + selfName, + shift, + reads, + node: fnNode, + } +} + +/** + * The rotator: an immediately-invoked two-parameter function that push/shifts the array in a loop + * until a stop condition holds. + * + * **The IIFE is not necessarily the statement's whole expression.** Adjacent-statement merging + * fuses it with whatever follows, so on a sample that also enables a timer the rotator arrives as + * `(function (a, b) { … })(A, 0xb89ba), setInterval(…)`. A matcher reading `.expression` directly + * reports the rotator ABSENT there - and that direction is the dangerous one, because a decode + * that runs the holder and wrapper without the rotator returns real strings from an unrotated + * array. Output then parses, runs, and reads clean on every residue axis while being wrong. So + * the candidate list is flattened before matching. + */ +function matchRotator(statement) { + if (!t.isExpressionStatement(statement)) { + return null + } + const candidates = [] + const flatten = (expr) => { + if (t.isSequenceExpression(expr)) { + expr.expressions.forEach(flatten) + } else if (t.isUnaryExpression(expr)) { + flatten(expr.argument) + } else { + candidates.push(expr) + } + } + flatten(statement.expression) + for (const candidate of candidates) { + const hit = matchRotatorCall(candidate, statement) + if (hit) { + return hit + } + } + return null +} + +function matchRotatorCall(call, statement) { + if (!t.isCallExpression(call)) { + return null + } + const fn = call.callee + if (!t.isFunctionExpression(fn) || fn.params.length !== 2) { + return null + } + + let pushes = false + let hasTry = false + let hasCounterLoop = false + let hasParseInt = false + let divides = false + traverse(t.file(t.program([t.expressionStatement(fn)])), { + CallExpression(path) { + const callee = path.node.callee + if ( + t.isMemberExpression(callee) && + ((t.isStringLiteral(callee.property) && + callee.property.value === 'push') || + t.isIdentifier(callee.property, { name: 'push' })) + ) { + pushes = true + } + if (t.isIdentifier(callee, { name: 'parseInt' })) { + hasParseInt = true + } + }, + TryStatement() { + hasTry = true + }, + WhileStatement(path) { + if (t.isUpdateExpression(path.node.test, { operator: '--' })) { + hasCounterLoop = true + } + }, + BinaryExpression(path) { + if (path.node.operator === '/') { + divides = true + } + }, + }) + if (!pushes) { + return null + } + + const firstArg = call.arguments[0] + return { + // `counter-loop` states its trip count as a literal second argument and can be unrotated by + // reading. `compare-loop` states it nowhere - it searches until a checksum over the array's + // own contents matches - which is why anything decoding that era has to run the loop rather + // than compute it. + kind: hasTry ? 'compare-loop' : hasCounterLoop ? 'counter-loop' : 'other', + comparison: hasParseInt + ? divides + ? 'parseint-div' + : 'parseint-mul' + : 'none', + argName: t.isIdentifier(firstArg) ? firstArg.name : null, + // Both handles, because they are not the same node and the decoder needs each for a + // different job. `call` is what gets evaluated and what gets removed; `node` is the statement + // it arrived in, which on a merged sample also carries unrelated effects that must survive. + call, + node: statement, + } +} + +/* ------------------------------------------------------------------------- * + * The weak probe + * ------------------------------------------------------------------------- */ + +/** + * Is there string-array-shaped evidence here at all? + * + * **This exists to make `absent` and `unreadable` different answers**, and it is deliberately + * weaker than the matchers above - a probe that mirrors a matcher gate for gate can only ever + * find near-misses, and is blind by construction to the case that matters: a whole holder kind or + * wrapper form nobody wrote a branch for. + * + * Each signal is cheap and shape-keyed, and **none is conclusive alone** - which is why the + * caller requires two before refusing. One is not a threshold chosen for caution: every signal + * here occurs in ordinary hand-written code, and `array-of-strings` fires on a literal as plain + * as `var parts = ['alpha', 'beta', 'gamma']`. Refusing on that would make the terminating round + * of a peel loop report a refusal over source that was never obfuscated, which inverts the one + * distinction this probe exists to draw. Two signals is the weakest rule that still separates + * them; the asymmetry is deliberate, because a missed diagnostic costs a re-run while a false + * refusal discards a completed decode. + */ +function weakEvidence(ast) { + const signals = [] + traverse(ast, { + ArrayExpression(path) { + if ( + signals.includes('array-of-strings') || + path.node.elements.length < 3 || + !path.node.elements.every((element) => t.isStringLiteral(element)) + ) { + return + } + signals.push('array-of-strings') + }, + CallExpression(path) { + const callee = path.node.callee + if (!t.isMemberExpression(callee)) { + return + } + const property = t.isStringLiteral(callee.property) + ? callee.property.value + : t.isIdentifier(callee.property) && !callee.computed + ? callee.property.name + : null + if ( + (property === 'push' || property === 'shift') && + !signals.includes('push-shift') + ) { + signals.push('push-shift') + } + }, + Function(path) { + // A two-parameter function that subtracts a constant from a parameter is the wrapper's + // arithmetic with none of its structure required. + if (signals.includes('index-shift') || path.node.params.length !== 2) { + return + } + const names = path.node.params + .filter(t.isIdentifier) + .map((param) => param.name) + if (!names.length) { + return + } + let found = false + path.traverse({ + BinaryExpression(inner) { + if ( + inner.node.operator === '-' && + t.isIdentifier(inner.node.left) && + names.includes(inner.node.left.name) && + foldNumber(inner.node.right) !== null + ) { + found = true + } + }, + }) + if (found) { + signals.push('index-shift') + } + }, + }) + return signals +} + +/* ------------------------------------------------------------------------- * + * Entrypoint resolution + * ------------------------------------------------------------------------- */ + +/** + * Pick the subsystem's own holder out of every array-of-strings in the file. + * + * A self-replacing holder is unambiguous - nothing hand-written has that shape. A plain + * declaration is not, so it counts only when something in the subsystem reads it. Where several + * survive, the largest wins: the encoder emits one array per program and a decoy would be + * smaller, whereas picking the first found makes the answer depend on traversal order. + */ +function selectHolder(holders, wrappers, rotators) { + const readNames = new Set( + [ + ...wrappers.map((wrapper) => wrapper.reads.name), + ...rotators.map((rotator) => rotator.argName), + ].filter(Boolean), + ) + const used = holders.filter( + (holder) => + holder.kind === 'fn-self-replacing' || readNames.has(holder.name), + ) + let best = null + for (const holder of used) { + if ( + !best || + holder.literal.elements.length > best.literal.elements.length + ) { + best = holder + } + } + return best +} + +/** + * Collect the variable-form scope aliases: `var a = W;` where `W` resolves to a root wrapper, + * possibly through another alias. + * + * **Resolved through bindings, never by name.** Renamed output reuses short names across + * non-overlapping scopes, so a name-keyed sweep both over- and under-counts, and a declaration id + * is not a reference. + * + * The function-form scope wrapper is a different shape and is collected separately, by + * `collectScopeWrappers` - it is machinery in its own right rather than an alias. + */ +/** + * Resolve an identifier to the root wrapper it names, through `var a = W` alias chains. + * + * Returns the wrapper's own node rather than a boolean, because with several encodings + * configured there are several root wrappers and *which* one a site reached decides which decode + * body has to evaluate it. Resolution already has that answer; discarding it would make the + * decoder guess. + * + * **Resolved through bindings, never by name.** Renamed output reuses short names across + * non-overlapping scopes, so a name-keyed sweep both over- and under-counts. + */ +function resolveWrapperNode(path, name, wrapperNodes, depth = 0) { + if (depth > 4) { + return null + } + const binding = path.scope.getBinding(name) + if (!binding) { + return null + } + const declared = binding.path.node + if (t.isFunctionDeclaration(declared) && wrapperNodes.has(declared)) { + return declared + } + if (t.isVariableDeclarator(declared)) { + if ( + t.isFunctionExpression(declared.init) && + wrapperNodes.has(declared.init) + ) { + return declared.init + } + if (t.isIdentifier(declared.init)) { + return resolveWrapperNode( + binding.path, + declared.init.name, + wrapperNodes, + depth + 1, + ) + } + } + return null +} + +/** + * The function-form scope wrapper's shape: `function X(a, b) { return W(a - N, b); }`. + * + * Every argument is either a bare parameter or `parameter - `, and at least one is the + * shifted kind. "At least one" rather than "exactly one" is required by chained calls: once the + * upper is another scope wrapper the encoder pads the call to + * `stringArrayWrappersParametersMaxCount` arguments and spells **every** one of them + * `parameter - `, real and fake alike. + * + * Returns the forwarding call, which is what names the upper wrapper. + */ +function matchScopeWrapperShape(fnNode) { + const body = fnNode.body?.body + if ( + !Array.isArray(body) || + body.length !== 1 || + !t.isReturnStatement(body[0]) + ) { + return null + } + const call = body[0].argument + if (!t.isCallExpression(call) || !t.isIdentifier(call.callee)) { + return null + } + const params = new Set( + fnNode.params + .filter((param) => t.isIdentifier(param)) + .map((param) => param.name), + ) + if (params.size !== fnNode.params.length) { + return null + } + let shifted = false + for (const arg of call.arguments) { + if (t.isIdentifier(arg) && params.has(arg.name)) { + continue + } + if ( + t.isBinaryExpression(arg, { operator: '-' }) && + t.isIdentifier(arg.left) && + params.has(arg.left.name) && + foldCoerced(arg.right) !== null + ) { + shifted = true + continue + } + return null + } + return shifted ? call : null +} + +/** + * Collect the function-form scope wrappers, in **extraction order**: every wrapper appears after + * the wrapper it forwards to. + * + * Two things make this more than a traversal. + * + * **The shape alone does not identify one.** `function f(a, b) { return g(a - 1, b); }` is a + * legal thing for a program to contain. What identifies a scope wrapper is that its forwarding + * chain *terminates at a root wrapper*, so candidates are matched structurally and then grown as + * a fixpoint outward from the root wrappers. A candidate whose chain never reaches one is not + * ours and is left entirely alone. + * + * **The fixpoint is a membership test, not a sort.** It answers "is this one of ours", and the + * root-ward layering falls out of it for free. The caller emits the result in that order because a + * dependency-ordered prelude is easier to debug, but nothing depends on the order: every wrapper + * is lifted as a hoisted function declaration, so a chain resolves whichever way round it is + * written. Do not add a guard here asserting the order matters - it does not, and it has been + * measured by reversing the emission. + */ +function collectScopeWrappers(ast, rootWrapperNodes, machineryNodes) { + const insideMachinery = (path) => { + for (let cursor = path.parentPath; cursor; cursor = cursor.parentPath) { + if (machineryNodes.has(cursor.node)) { + return true + } + } + return false + } + + const candidates = [] + const record = (path, node, name, decl) => { + if (insideMachinery(path)) { + return + } + const call = matchScopeWrapperShape(node) + if (call) { + candidates.push({ path, node, name, decl, call }) + } + } + + traverse(ast, { + FunctionDeclaration(path) { + if (path.node.id) { + record(path, path.node, path.node.id.name, 'function-declaration') + } + }, + VariableDeclarator(path) { + if ( + t.isFunctionExpression(path.node.init) && + t.isIdentifier(path.node.id) + ) { + record( + path, + path.node.init, + path.node.id.name, + 'var-function-expression', + ) + } + }, + }) + + // Grow outward from the root wrappers. Each round admits the candidates whose upper is already + // admitted, so the result is layered root-ward by construction and a chain that never reaches a + // root wrapper simply never gets admitted. + const admitted = [] + const admittedNodes = new Set() + let pending = candidates + for (;;) { + const next = [] + let grew = false + for (const candidate of pending) { + const upper = resolveWrapperNode( + candidate.path, + candidate.call.callee.name, + new Set([...rootWrapperNodes, ...admittedNodes]), + ) + if (!upper) { + next.push(candidate) + continue + } + // A wrapper that resolves to itself would loop forever in the prelude; the encoder cannot + // emit one, so this only fires on a hand-edited or hostile sample. + if (upper === candidate.node) { + continue + } + admitted.push({ ...candidate, upper }) + admittedNodes.add(candidate.node) + grew = true + } + if (!grew) { + break + } + pending = next + } + + return admitted +} + +function collectAliases(ast, wrapperNodes, machineryNodes) { + const aliases = [] + const undeclared = [] + const foreignWrappers = [] + const seenForeign = new Set() + + // Is this path inside something the decoder is going to delete wholesale? + // + // **Required, not an optimisation.** The encoder injects scope wrappers into *every* lexical + // scope, including the rotator's own body and the wrapper's inner closure. Those reference the + // root wrapper with non-constant arguments and so look exactly like the scope wrapper this pass + // does not own - but they vanish with the machinery that contains them, so treating them as + // blockers would refuse on samples that are entirely decodable. Measured: it is the whole + // difference between the maximal profile being finishable and not. + const insideMachinery = (path) => { + for (let cursor = path.parentPath; cursor; cursor = cursor.parentPath) { + if (machineryNodes.has(cursor.node)) { + return true + } + } + return false + } + + const resolvesToWrapper = (path, name) => + resolveWrapperNode(path, name, wrapperNodes) !== null + + traverse(ast, { + VariableDeclarator(path) { + if (!t.isIdentifier(path.node.init) || !t.isIdentifier(path.node.id)) { + return + } + if (resolvesToWrapper(path, path.node.init.name)) { + aliases.push({ name: path.node.id.name, path }) + } + }, + + // `_ = W;` with no declaration makes `_` a global, and `scope.getBinding` returns nothing for + // it. Left unreported that reads as a clean zero rather than as a failure, which is the one + // direction a census must never fail in - a real sample needed this edited by hand before any + // tool could touch it. + AssignmentExpression(path) { + const { left, right } = path.node + if (!t.isIdentifier(left) || !t.isIdentifier(right)) { + return + } + if (path.scope.getBinding(left.name)) { + return + } + if (resolvesToWrapper(path, right.name)) { + undeclared.push({ name: left.name, path }) + } + }, + + // A call into the root wrapper whose arguments cannot be evaluated - the signature of a + // function-form scope wrapper passing its own parameters through. That shape is not this + // pass's to decode, and it pins the machinery alive. + // + // **Keyed on the call, then attributed to its innermost enclosing function.** Written the + // other way round - visiting functions and searching their bodies - every ancestor of a scope + // wrapper also matches, since a traversal from a function descends through nested ones. That + // reports a count with no meaning: on one profile it read more blocking wrappers than there + // were blocked references. + CallExpression(path) { + if (!t.isIdentifier(path.node.callee) || insideMachinery(path)) { + return + } + if (!resolvesToWrapper(path, path.node.callee.name)) { + return + } + const opaque = path.node.arguments.some( + (arg) => !t.isStringLiteral(arg) && foldNumber(arg) === null, + ) + if (!opaque) { + return + } + const owner = path.getFunctionParent() + const key = owner ? owner.node : ast.program + if (seenForeign.has(key)) { + return + } + seenForeign.add(key) + foreignWrappers.push({ path: owner, call: path }) + }, + }) + + return { aliases, undeclared, foreignWrappers } +} + +/* ------------------------------------------------------------------------- * + * The detector + * ------------------------------------------------------------------------- */ + +/** + * @param {import('@babel/types').File} ast + * @returns {{ + * status: 'resolved' | 'absent' | 'unreadable', + * signature: { holder: string, wrapper: string, rotate: string }, + * holder: object | null, + * wrappers: object[], + * rotators: object[], + * aliases: object[], + * undeclared: object[], + * scopeWrappers: object[], + * foreignWrappers: object[], + * notes: string[], + * }} + */ +function detectStringArray(ast) { + const holders = [] + const wrappers = [] + const rotators = [] + const notes = [] + + for (const statement of ast.program.body) { + const rotator = matchRotator(statement) + if (rotator) { + rotators.push(rotator) + } + } + + // The holder is not necessarily at program level - control-flow flattening can move it - so the + // whole tree is searched rather than the top-level statement list. + traverse(ast, { + FunctionDeclaration(path) { + const holder = matchHolderFn(path.node) + if (holder) { + holders.push({ ...holder, path }) + } + const wrapper = matchWrapper(path.node, path.node.id?.name) + if (wrapper) { + wrappers.push({ ...wrapper, decl: 'function-declaration', path }) + } + }, + VariableDeclarator(path) { + const holder = matchHolderVar(path.node) + if (holder) { + holders.push({ ...holder, path }) + } + if ( + !t.isFunctionExpression(path.node.init) || + !t.isIdentifier(path.node.id) + ) { + return + } + const wrapper = matchWrapper(path.node.init, path.node.id.name) + if (wrapper) { + wrappers.push({ ...wrapper, decl: 'var-function-expression', path }) + } + }, + }) + + const holder = selectHolder(holders, wrappers, rotators) + + // Every root wrapper whose array read resolves to the holder, not just one: with several + // encodings configured the encoder emits one wrapper per encoding, each with its own decode + // body, and stopping at the first leaves the rest of the call sites undecodable. + const rootWrappers = holder + ? wrappers.filter( + (wrapper) => + wrapper.reads.name === holder.arrayName || + wrapper.reads.name === holder.name, + ) + : [] + + const signature = { + holder: holder ? holder.kind : 'none', + wrapper: rootWrappers.length + ? `${rootWrappers[0].decl}/${rootWrappers[0].kind}/reads-${rootWrappers[0].reads.via}` + : 'none', + rotate: rotators.length + ? `${rotators[0].kind}/${rotators[0].comparison}` + : 'none', + } + + const empty = { + status: 'absent', + signature, + holder: null, + wrappers: [], + rotators: [], + aliases: [], + undeclared: [], + scopeWrappers: [], + foreignWrappers: [], + notes, + } + + if (!holder || !rootWrappers.length) { + const signals = weakEvidence(ast) + if (signals.length < 2) { + debugLog( + `[obfuscatorx] detect: no string array present` + + (signals.length ? ` (${signals[0]} alone is not evidence)` : ''), + ) + return empty + } + // Evidence present, entrypoint unresolved. This is the refusal, and it says which half failed + // so the report can be read without re-running anything. + notes.push( + `string-array evidence present (${signals.join(', ')}) but the entrypoint did not resolve: ` + + `${holder ? 'holder matched' : 'no holder'}, ` + + `${rootWrappers.length ? `${rootWrappers.length} root wrapper(s)` : 'no root wrapper'}`, + ) + debugLog(`[obfuscatorx] detect: ${notes[notes.length - 1]}`) + return { ...empty, status: 'unreadable' } + } + + const wrapperNodes = new Set(rootWrappers.map((wrapper) => wrapper.node)) + // Everything the decoder removes as one unit. Anything lexically inside it is not residue and + // not a blocker - it goes when the machinery goes. + const machineryNodes = new Set([ + holder.node, + ...rootWrappers.map((wrapper) => wrapper.node), + // The rotator's *call*, not the statement it sits in: adjacent-statement merging fuses that + // statement with unrelated effects, and a wrapper reference inside one of those is a real + // use site rather than machinery. + ...rotators.map((rotator) => rotator.call), + ]) + // The function-form scope wrappers are machinery too, so they are resolved *before* the alias + // sweep and then folded into both sets it reads: their bodies stop looking like foreign calls + // into the root wrapper, and an alias to one resolves the same way an alias to a root wrapper + // does. + const scopeWrappers = collectScopeWrappers(ast, wrapperNodes, machineryNodes) + for (const wrapper of scopeWrappers) { + machineryNodes.add(wrapper.node) + wrapperNodes.add(wrapper.node) + } + + const { aliases, undeclared, foreignWrappers } = collectAliases( + ast, + wrapperNodes, + machineryNodes, + ) + + // Returned as well as noted, because the decoder has to *gate* on it rather than log it. An + // alias with no binding has call sites nothing can enumerate, so deleting the machinery after + // missing one is fail-open corruption rather than countable residue. + for (const item of undeclared) { + notes.push( + `wrapper alias '${item.name}' is assigned without a declaration, so it is a global and ` + + `has no binding to resolve through; its call sites cannot be found by scope lookup`, + ) + } + if (foreignWrappers.length) { + notes.push( + `${foreignWrappers.length} function(s) call the string-array machinery with non-constant ` + + `arguments and are not resolvable scope wrappers; those call sites are not this pass's ` + + `to decode, and the machinery cannot be removed while they reference it`, + ) + } + if (!rotators.length) { + // Legal - rotation is an option - so this is recorded, never treated as a miss. What tells + // the two apart is not visible here: it is whether the extracted machinery evaluates, which + // only the decoder can try. + notes.push( + 'no rotator present; the array is either unrotated or the rotator was not matched', + ) + } + + debugLog( + `[obfuscatorx] detect: holder=${signature.holder} wrapper=${signature.wrapper} ` + + `rotate=${signature.rotate}, ${rootWrappers.length} root wrapper(s), ` + + `${aliases.length} alias(es)` + + (scopeWrappers.length + ? `, ${scopeWrappers.length} scope wrapper(s)` + : '') + + (foreignWrappers.length + ? `, ${foreignWrappers.length} foreign wrapper(s)` + : ''), + ) + + return { + status: 'resolved', + signature, + holder, + wrappers: rootWrappers, + rotators, + aliases, + undeclared, + scopeWrappers, + foreignWrappers, + notes, + } +} + +export default detectStringArray +export { weakEvidence, foldNumber, resolveWrapperNode } From ed1f4c982eb8c19d3eaf4f79f59256beebff4389 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:51:50 +0100 Subject: [PATCH 12/18] feat(visitor/string-array): decode the string array by evaluating it Evaluate, do not model. The encoder chooses the per-item encoding, the index arithmetic, the rotation amount and the rotator's era; re-implementing any of that only ever covers the version that was read. So the machinery is run in a **fresh isolate per decode** - a module-scope one is shared by every decode in the process, and the second sample then evaluates into a context still holding the first's bindings. Four outcomes, and three of them are not failures: `decoded`, `absent` (built with the option off), `unowned` (a layer this does not own - success plus residue) and `unreadable`, which is the only one worth refusing on. Two guards catch the failure mode an evaluating reversal has and a static one does not. Miss one component of the machinery and every call site returns a real string, just the wrong one - output parses, runs, and drives every residue axis to zero. A decoded string in computed-member-key position must be a valid identifier, because the encoder put a real property name there; and self-validating machinery cannot terminate on an incomplete extraction, which is why the timeout is mandatory rather than defensive. Fixtures are the encoder's own spec cases rather than shapes invented here, including one damaged input per refusal path - each asserting the note it expects, since every refusal reports the same status and a case landing on the wrong guard would otherwise read as passing. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/obfuscator/string-array.js | 661 ++++++++++++++++++ test/visitor/obfuscator/string-array.test.js | 274 ++++++++ .../obfuscator/string-array/baseline.fix.js | 2 + .../obfuscator/string-array/baseline.js | 1 + .../obfuscator/string-array/baseline.src.js | 2 + .../string-array/calls-wrapper-name.fix.js | 8 + .../string-array/calls-wrapper-name.js | 1 + .../string-array/calls-wrapper-name.src.js | 8 + .../string-array/encoding-base64-rc4.fix.js | 9 + .../string-array/encoding-base64-rc4.js | 1 + .../string-array/encoding-base64-rc4.src.js | 9 + .../string-array/encoding-rc4.fix.js | 2 + .../obfuscator/string-array/encoding-rc4.js | 1 + .../string-array/encoding-rc4.src.js | 2 + .../string-array/guard-alias-undeclared.js | 1 + .../guard-alias-undeclared.src.js | 11 + .../string-array/guard-array-read-outside.js | 1 + .../guard-array-read-outside.src.js | 2 + .../string-array/guard-checksum-corrupted.js | 1 + .../guard-checksum-corrupted.src.js | 21 + .../string-array/guard-rotator-removed.js | 1 + .../string-array/guard-rotator-removed.src.js | 13 + .../string-array/guard-wrapper-removed.js | 1 + .../string-array/guard-wrapper-removed.src.js | 2 + .../string-array/index-mixed-types.fix.js | 8 + .../string-array/index-mixed-types.js | 1 + .../string-array/index-mixed-types.src.js | 10 + .../string-array/index-numeric-string.fix.js | 2 + .../string-array/index-numeric-string.js | 1 + .../string-array/index-numeric-string.src.js | 2 + .../index-shift-rotate-shuffle.fix.js | 8 + .../index-shift-rotate-shuffle.js | 1 + .../index-shift-rotate-shuffle.src.js | 10 + .../string-array/index-shift.fix.js | 8 + .../obfuscator/string-array/index-shift.js | 1 + .../string-array/index-shift.src.js | 10 + .../string-array/nested-one-layer.fix.js | 34 + .../string-array/nested-one-layer.js | 1 + .../string-array/nested-one-layer.src.js | 3 + .../string-array/object-computed-key.fix.js | 4 + .../string-array/object-computed-key.js | 1 + .../string-array/object-computed-key.src.js | 2 + .../string-array/rotate-search.fix.js | 21 + .../obfuscator/string-array/rotate-search.js | 1 + .../string-array/rotate-search.src.js | 21 + .../string-array/same-literal-values.fix.js | 3 + .../string-array/same-literal-values.js | 1 + .../string-array/same-literal-values.src.js | 3 + .../string-array/scope-chained-deep.fix.js | 19 + .../string-array/scope-chained-deep.js | 1 + .../string-array/scope-chained-deep.src.js | 28 + .../string-array/scope-chained-mangled.fix.js | 18 + .../string-array/scope-chained-mangled.js | 1 + .../string-array/scope-chained-mangled.src.js | 25 + .../scope-default-parameter.fix.js | 5 + .../string-array/scope-default-parameter.js | 1 + .../scope-default-parameter.src.js | 7 + .../scope-no-root-wrappers.fix.js | 6 + .../string-array/scope-no-root-wrappers.js | 1 + .../scope-no-root-wrappers.src.js | 7 + .../scope-numeric-string-offset.fix.js | 9 + .../scope-numeric-string-offset.js | 1 + .../scope-numeric-string-offset.src.js | 11 + .../scope-prevailing-const.fix.js | 9 + .../string-array/scope-prevailing-const.js | 1 + .../scope-prevailing-const.src.js | 11 + .../string-array/scope-prohibited-if.fix.js | 4 + .../string-array/scope-prohibited-if.js | 1 + .../string-array/scope-prohibited-if.src.js | 4 + .../string-array/short-literal-value.js | 1 + .../string-array/short-literal-value.src.js | 1 + .../string-array/string-array-off.js | 1 + .../string-array/string-array-off.src.js | 2 + .../string-array/wrappers-function.fix.js | 9 + .../string-array/wrappers-function.js | 1 + .../string-array/wrappers-function.src.js | 11 + 76 files changed, 1388 insertions(+) create mode 100644 src/visitor/obfuscator/string-array.js create mode 100644 test/visitor/obfuscator/string-array.test.js create mode 100644 test/visitor/obfuscator/string-array/baseline.fix.js create mode 100644 test/visitor/obfuscator/string-array/baseline.js create mode 100644 test/visitor/obfuscator/string-array/baseline.src.js create mode 100644 test/visitor/obfuscator/string-array/calls-wrapper-name.fix.js create mode 100644 test/visitor/obfuscator/string-array/calls-wrapper-name.js create mode 100644 test/visitor/obfuscator/string-array/calls-wrapper-name.src.js create mode 100644 test/visitor/obfuscator/string-array/encoding-base64-rc4.fix.js create mode 100644 test/visitor/obfuscator/string-array/encoding-base64-rc4.js create mode 100644 test/visitor/obfuscator/string-array/encoding-base64-rc4.src.js create mode 100644 test/visitor/obfuscator/string-array/encoding-rc4.fix.js create mode 100644 test/visitor/obfuscator/string-array/encoding-rc4.js create mode 100644 test/visitor/obfuscator/string-array/encoding-rc4.src.js create mode 100644 test/visitor/obfuscator/string-array/guard-alias-undeclared.js create mode 100644 test/visitor/obfuscator/string-array/guard-alias-undeclared.src.js create mode 100644 test/visitor/obfuscator/string-array/guard-array-read-outside.js create mode 100644 test/visitor/obfuscator/string-array/guard-array-read-outside.src.js create mode 100644 test/visitor/obfuscator/string-array/guard-checksum-corrupted.js create mode 100644 test/visitor/obfuscator/string-array/guard-checksum-corrupted.src.js create mode 100644 test/visitor/obfuscator/string-array/guard-rotator-removed.js create mode 100644 test/visitor/obfuscator/string-array/guard-rotator-removed.src.js create mode 100644 test/visitor/obfuscator/string-array/guard-wrapper-removed.js create mode 100644 test/visitor/obfuscator/string-array/guard-wrapper-removed.src.js create mode 100644 test/visitor/obfuscator/string-array/index-mixed-types.fix.js create mode 100644 test/visitor/obfuscator/string-array/index-mixed-types.js create mode 100644 test/visitor/obfuscator/string-array/index-mixed-types.src.js create mode 100644 test/visitor/obfuscator/string-array/index-numeric-string.fix.js create mode 100644 test/visitor/obfuscator/string-array/index-numeric-string.js create mode 100644 test/visitor/obfuscator/string-array/index-numeric-string.src.js create mode 100644 test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.fix.js create mode 100644 test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.js create mode 100644 test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.src.js create mode 100644 test/visitor/obfuscator/string-array/index-shift.fix.js create mode 100644 test/visitor/obfuscator/string-array/index-shift.js create mode 100644 test/visitor/obfuscator/string-array/index-shift.src.js create mode 100644 test/visitor/obfuscator/string-array/nested-one-layer.fix.js create mode 100644 test/visitor/obfuscator/string-array/nested-one-layer.js create mode 100644 test/visitor/obfuscator/string-array/nested-one-layer.src.js create mode 100644 test/visitor/obfuscator/string-array/object-computed-key.fix.js create mode 100644 test/visitor/obfuscator/string-array/object-computed-key.js create mode 100644 test/visitor/obfuscator/string-array/object-computed-key.src.js create mode 100644 test/visitor/obfuscator/string-array/rotate-search.fix.js create mode 100644 test/visitor/obfuscator/string-array/rotate-search.js create mode 100644 test/visitor/obfuscator/string-array/rotate-search.src.js create mode 100644 test/visitor/obfuscator/string-array/same-literal-values.fix.js create mode 100644 test/visitor/obfuscator/string-array/same-literal-values.js create mode 100644 test/visitor/obfuscator/string-array/same-literal-values.src.js create mode 100644 test/visitor/obfuscator/string-array/scope-chained-deep.fix.js create mode 100644 test/visitor/obfuscator/string-array/scope-chained-deep.js create mode 100644 test/visitor/obfuscator/string-array/scope-chained-deep.src.js create mode 100644 test/visitor/obfuscator/string-array/scope-chained-mangled.fix.js create mode 100644 test/visitor/obfuscator/string-array/scope-chained-mangled.js create mode 100644 test/visitor/obfuscator/string-array/scope-chained-mangled.src.js create mode 100644 test/visitor/obfuscator/string-array/scope-default-parameter.fix.js create mode 100644 test/visitor/obfuscator/string-array/scope-default-parameter.js create mode 100644 test/visitor/obfuscator/string-array/scope-default-parameter.src.js create mode 100644 test/visitor/obfuscator/string-array/scope-no-root-wrappers.fix.js create mode 100644 test/visitor/obfuscator/string-array/scope-no-root-wrappers.js create mode 100644 test/visitor/obfuscator/string-array/scope-no-root-wrappers.src.js create mode 100644 test/visitor/obfuscator/string-array/scope-numeric-string-offset.fix.js create mode 100644 test/visitor/obfuscator/string-array/scope-numeric-string-offset.js create mode 100644 test/visitor/obfuscator/string-array/scope-numeric-string-offset.src.js create mode 100644 test/visitor/obfuscator/string-array/scope-prevailing-const.fix.js create mode 100644 test/visitor/obfuscator/string-array/scope-prevailing-const.js create mode 100644 test/visitor/obfuscator/string-array/scope-prevailing-const.src.js create mode 100644 test/visitor/obfuscator/string-array/scope-prohibited-if.fix.js create mode 100644 test/visitor/obfuscator/string-array/scope-prohibited-if.js create mode 100644 test/visitor/obfuscator/string-array/scope-prohibited-if.src.js create mode 100644 test/visitor/obfuscator/string-array/short-literal-value.js create mode 100644 test/visitor/obfuscator/string-array/short-literal-value.src.js create mode 100644 test/visitor/obfuscator/string-array/string-array-off.js create mode 100644 test/visitor/obfuscator/string-array/string-array-off.src.js create mode 100644 test/visitor/obfuscator/string-array/wrappers-function.fix.js create mode 100644 test/visitor/obfuscator/string-array/wrappers-function.js create mode 100644 test/visitor/obfuscator/string-array/wrappers-function.src.js diff --git a/src/visitor/obfuscator/string-array.js b/src/visitor/obfuscator/string-array.js new file mode 100644 index 00000000..0c14a394 --- /dev/null +++ b/src/visitor/obfuscator/string-array.js @@ -0,0 +1,661 @@ +import ivm from 'isolated-vm' +import generator from '@babel/generator' +import traverse from '@babel/traverse' +import * as t from '@babel/types' + +import logger from '../../utility/logger.js' +import detectStringArray, { foldNumber, resolveWrapperNode } from './detect.js' + +const debugLog = logger.debugLog + +/** + * Reverse javascript-obfuscator's string array: replace every wrapper call with the string it + * returns, then delete the machinery that produced it. + * + * **The strings are recovered by running the encoder's own code, not by modelling it.** The + * subsystem is located structurally (see detect.js), its source text is captured, evaluated in a + * fresh isolate, and each call site is decoded by evaluating that call. Rotation, the index + * shift, and the `none` / `base64` / `rc4` encodings are therefore handled by the encoder's + * prelude rather than reimplemented here. + * + * The reason is generality over three axes that are shape-only, all of which a static model would + * have to absorb one at a time: + * + * - **encodings** - a swapped-alphabet base64 with a padding-ignoring decoder, and an rc4 keyed + * per item out of a key pool. Evaluating covers both at no cost; reimplementing them exactly + * is where "nearly right" becomes silently wrong output. + * - **eras** - the holder, wrapper and rotator shapes each move on their own axis, but their + * *semantics* never change. So extraction is era-dependent and decoding is era-invariant, + * which is why there are no per-era strategy files here. + * - **variants** - real samples come from *modified* obfuscators. A fork that alters the index + * arithmetic or the rotator's checksum still runs; a model keyed on the stock algorithm does + * not survive it. + * + * **One run decodes exactly one layer.** There is deliberately no internal loop over a + * re-obfuscated sample. A second encode wraps the first's whole output, so peeling is + * outermost-first and each layer is an ordinary single-encode once it is outermost - but the + * dangerous failure in that situation is a pass mutating the layer *beneath* it, and stopping at + * the boundary is what makes that boundary observable. Layers also need not come from the same + * encoder, so the loop belongs to whoever is driving the decoders, not inside one of them. What + * this pass owes that driver is the distinction its status carries. + * + * **What it does when it cannot finish, and why there are four answers rather than two:** + * + * - `decoded` - every call site resolved, the machinery is gone. + * - `absent` - no string array here. A verdict, not a failure: it is what the terminating + * round of a peel loop reports. + * - `unowned` - the subsystem is ours and readable, but a construct a *later unit* owns is + * still calling it. Folding this into a refusal would make a sample merely + * awaiting another pass indistinguishable from a matcher failure. + * - `unreadable` - ours, and we could not read it. The refusal, kept narrow. + * + * Every outcome other than `decoded` leaves the tree **completely untouched**. A half-resolved + * string array is worse than an untouched one: downstream matchers key on how a construct is + * spelled, so a partial decode manufactures two entities out of one. + */ + +/** + * Mandatory rather than defensive: a compare-loop rotator cannot terminate on an array it was not + * written for, so the timeout is how an incomplete extraction is observed at all. + */ +const EVAL_TIMEOUT_MS = 10000 + +const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/ + +const gen = (node) => generator(node, { compact: true }).code + +/** + * An argument this pass can evaluate: a string, or an arithmetic tree over numeric literals. + * + * Folding rather than demanding a `NumericLiteral` is required, not tidy: `numbersToExpressions` + * re-spells every numeric constant as an arithmetic tree, so a literal test reads the argument as + * unevaluable on exactly the high-strength samples that matter most. + */ +const isEvaluableArg = (node) => + t.isStringLiteral(node) || foldNumber(node) !== null + +/* ------------------------------------------------------------------------- * + * Extraction + * ------------------------------------------------------------------------- */ + +/** + * The holder's source, re-declared standalone. + * + * The plain-declaration form is matched as a *declarator*, so the `var` keyword has to be put + * back. That is the cheap direction of the trade: `var a = 1, ARRAY = ['…'];` is a legal spelling + * and handing back the whole declaration would delete the sibling. + */ +function holderSource(holder) { + return holder.kind === 'var-declaration' + ? gen(t.variableDeclaration('var', [holder.node])) + : gen(holder.node) +} + +function wrapperSource(wrapper) { + return wrapper.decl === 'var-function-expression' + ? gen(t.variableDeclaration('var', [wrapper.path.node])) + : gen(wrapper.path.node) +} + +/** + * Build the prelude in **dependency order** - holder, then every wrapper, then the rotator. + * + * Source order is not the constraint and following it would be wrong: renamed output routinely + * emits the rotator above both, relying on function hoisting that no longer applies once the + * pieces are lifted out of their file. What the order has to respect is what each piece needs + * from the others - the wrappers read the holder, and the rotator drives the array through the + * wrappers, so it must run last. + * + * The rotator is wrapped in a statement rather than emitted bare, so that a callee in function + * position is parenthesised. + */ +function buildPrelude(holder, wrappers, rotators, scopeWrappers = []) { + const parts = [holderSource(holder)] + for (const wrapper of wrappers) { + parts.push(wrapperSource(wrapper)) + } + // Scope wrappers sit before the rotator's invocation, which stays **last** so that termination + // still proves the extraction was complete. They are declarations, so placing them ahead of it + // runs nothing. + // + // Their order among themselves is deliberately *not* load-bearing: each is emitted as a hoisted + // function declaration, so a chain resolves whichever way round they are written. Measured by + // reversing this loop, which changes no result. They are emitted in the detector's root-ward + // order anyway, because a prelude that reads in dependency order is easier to debug when one + // does fail - but nothing depends on it, and a future change that reorders them is not a bug. + for (const scope of scopeWrappers) { + parts.push(scopeWrapperSource(scope)) + } + for (const rotator of rotators) { + parts.push(gen(t.expressionStatement(rotator.call))) + } + return parts.join(';\n') +} + +/** + * Re-declare one scope wrapper under a synthetic name, with its upper reference rewritten to the + * upper's own lifted name. + * + * **Renaming on lift is required, not hygiene.** Every wrapper is lifted out of its own lexical + * scope into one flat isolate global scope, and `identifierNamesGenerator: 'mangled'` reuses short + * names across non-overlapping scopes - so two wrappers from sibling scopes can arrive named `a`, + * and the second definition would silently win. The same collision is why the root wrapper's call + * sites are evaluated through `wrapper.selfName` rather than by evaluating alias declarations. + * + * The node is cloned before the callee is rewritten: the real tree must not be touched until the + * whole decode has committed, or a later failure leaves a half-rewritten program behind. + */ +function scopeWrapperSource(scope) { + const fn = t.cloneNode(scope.node, true) + const call = fn.body.body[0].argument + call.callee = t.identifier(scope.upperLiftedName) + return gen( + t.functionDeclaration(t.identifier(scope.liftedName), fn.params, fn.body), + ) +} + +/* ------------------------------------------------------------------------- * + * Call sites + * ------------------------------------------------------------------------- */ + +/** + * Every reference to a root wrapper, classified. + * + * **A reference inside the machinery is not a use site.** The encoder injects scope aliases into + * every lexical scope, the rotator's own body and the wrapper's inner closure included, and the + * decode bodies hang a memo cache off the wrapper object. All of those look like ordinary uses + * and all of them vanish with the machinery around them; decoding them would be work thrown away, + * and counting them as blockers would refuse on samples that are entirely decodable. + */ +function collectSites(ast, wrapperNodes, aliasNodes, insideMachinery) { + const sites = [] + const opaque = [] + const strayRefs = [] + + traverse(ast, { + Identifier(path) { + // A declaration id is not a reference. Counting one is the trap that makes a fail-closed + // matcher kill every application in scope. + if (!path.isReferencedIdentifier()) { + return + } + const wrapper = resolveWrapperNode(path, path.node.name, wrapperNodes) + if (!wrapper || insideMachinery(path)) { + return + } + const parent = path.parent + if (t.isCallExpression(parent) && parent.callee === path.node) { + if (parent.arguments.every(isEvaluableArg)) { + sites.push({ path: path.parentPath, wrapper }) + } else { + // **This has no producer below javascript-obfuscator 3.2.0, and a known one from there + // on.** A 2.x use site is constant by construction: every argument - the real index, the + // fake padding, the rc4 key - is built by the encoder from a literal factory, and the + // only non-constant argument it can emit is a scope wrapper forwarding `param - N`, + // which is machinery and never reaches here. From 3.2.0 `stringArrayCallsTransform` + // (StringArrayControlFlowTransformer) moves those literals into a control-flow storage + // and rewrites the site to `storage.key` - a member expression, which is exactly what + // `isEvaluableArg` refuses. + // + // So this is a gate waiting for its input, not vestigial: do not delete it as + // unreachable. `unowned` is the right answer for it too, since a storage argument is + // precisely "readable, and a construct another unit owns is calling in". + opaque.push(gen(parent)) + } + return + } + // The alias declarations this pass already knows about: `var a = W`. The reference is the + // alias being defined, not a use of it. + if ( + t.isVariableDeclarator(parent) && + parent.init === path.node && + aliasNodes.has(parent) + ) { + return + } + strayRefs.push(gen(parent)) + }, + }) + + return { sites, opaque, strayRefs } +} + +/** + * Is this call site in computed-member-key position? + * + * The check it feeds is one of only two that can catch a *missed rotator* with no expected output + * to compare against - see `decodeStringArray`. + */ +function isComputedKey(path) { + const parent = path.parent + return ( + (t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) && + parent.computed && + parent.property === path.node + ) +} + +/* ------------------------------------------------------------------------- * + * Removal + * ------------------------------------------------------------------------- */ + +/** + * Remove one declarator, keeping whatever shares its declaration. + * + * This exists for the MULTI-declarator case and nothing else. The encoder emits wrapper aliases as + * extra declarators inside one `var`, alongside the program's own variables — `var a = W, b = W, + * foo = a(0x109);` — so removing the declaration would take `foo` with it. + * + * Babel already owns the other direction: its removal hook deletes the parent VariableDeclaration + * when the declarator being removed is the only one, so after `path.remove()` the declaration is + * either gone or still has at least one declarator left. Never write a `declarations.length === 0` + * branch here — it cannot be reached, and it reads as though this function handles the empty case + * when Babel does. + */ +function removeDeclarator(path) { + path.remove() +} + +/** + * Remove an expression that was evaluated for its effect. + * + * Written for the rotator, and the care is for one spelling: adjacent-statement merging fuses the + * rotator's IIFE with whatever statement follows it, so on a sample that also enables a timer it + * arrives as `(function (a, b) { … })(A, 0xb89ba), setInterval(…)`. Removing the statement would + * take the timer with it. + */ +function removeEffectExpression(path) { + let target = path + while (target.parentPath && target.parentPath.isUnaryExpression()) { + target = target.parentPath + } + const parent = target.parentPath + if (parent && parent.isExpressionStatement()) { + parent.remove() + return + } + target.remove() + if (parent && !parent.removed && parent.isSequenceExpression()) { + const remaining = parent.node.expressions + if (remaining.length === 1) { + parent.replaceWith(remaining[0]) + } else if (remaining.length === 0) { + parent.remove() + } + } +} + +/** Find the live path for each of a set of nodes, in one pass. */ +function pathsFor(ast, nodes) { + const found = new Map() + if (!nodes.size) { + return found + } + traverse(ast, { + enter(path) { + if (nodes.has(path.node)) { + found.set(path.node, path) + } + }, + }) + return found +} + +/* ------------------------------------------------------------------------- * + * The pass + * ------------------------------------------------------------------------- */ + +function result(status, detected, extra = {}) { + return { + status, + signature: detected.signature, + replaced: 0, + removed: { holder: 0, wrappers: 0, rotators: 0, aliases: 0 }, + notes: detected.notes, + ...extra, + } +} + +/** + * @param {import('@babel/types').File} ast mutated in place, and only on `decoded` + * @param {{ timeout?: number }} [options] + * @returns {{ + * status: 'decoded' | 'absent' | 'unowned' | 'unreadable', + * signature: { holder: string, wrapper: string, rotate: string }, + * replaced: number, + * removed: { holder: number, wrappers: number, rotators: number, aliases: number }, + * notes: string[], + * }} + */ +function decodeStringArray(ast, options = {}) { + const timeout = options.timeout ?? EVAL_TIMEOUT_MS + const detected = detectStringArray(ast) + const notes = detected.notes + + if (detected.status !== 'resolved') { + return result(detected.status, detected) + } + + // **A gate, not a note.** An alias assigned without a declaration is a global, so it has no + // binding and its call sites cannot be enumerated. Deleting the machinery with one of them + // still live is fail-open corruption - the program breaks - where declining leaves residue + // that can be seen and counted. A real sample needed exactly this edited by hand before any + // tool could touch it. + if (detected.undeclared.length) { + notes.push( + `refusing: ${detected.undeclared.length} wrapper alias(es) have no binding, so their ` + + `call sites cannot be found and the machinery cannot be safely removed`, + ) + return result('unreadable', detected) + } + + // Readable, but a construct a later unit owns is still calling in. Reported as its own outcome + // so that "awaiting another pass" and "my matcher failed" stay different answers. + if (detected.foreignWrappers.length) { + notes.push( + `leaving the subsystem in place: ${detected.foreignWrappers.length} function(s) reach the ` + + `string-array machinery with non-constant arguments and are not resolvable scope wrappers`, + ) + return result('unowned', detected) + } + + const holder = detected.holder + const wrappers = detected.wrappers + // Lifted names are assigned here rather than in the detector: they exist only for the isolate, + // and the detector's job is to hand back handles, not to pick identifiers for an evaluation + // strategy it knows nothing about. Position in the array is the topological order the detector + // established, so an upper's name is always already assigned when its dependant is reached. + const scopeWrappers = detected.scopeWrappers ?? [] + const liftedByNode = new Map() + scopeWrappers.forEach((scope, index) => { + scope.liftedName = `__sw${index}` + liftedByNode.set(scope.node, scope) + }) + for (const scope of scopeWrappers) { + const upper = liftedByNode.get(scope.upper) + scope.upperLiftedName = upper + ? upper.liftedName + : wrappers.find((wrapper) => wrapper.node === scope.upper)?.selfName + if (!scope.upperLiftedName) { + notes.push( + `refusing: scope wrapper '${scope.name}' forwards to a wrapper that did not resolve to a ` + + `lifted name, so the prelude cannot be built`, + ) + return result('unreadable', detected) + } + } + // Only rotators that rotate *this* holder. One that does not is not ours - a second layer's, + // most likely - and running it would rotate an array it was never written for. + const rotators = detected.rotators.filter( + (rotator) => + rotator.argName === holder.name || rotator.argName === holder.arrayName, + ) + if (rotators.length !== detected.rotators.length) { + notes.push( + 'refusing: a rotator is present that does not rotate this string array', + ) + return result('unreadable', detected) + } + + // Scope wrappers join both sets. In `wrapperNodes` they become resolvable call targets, so a + // site naming one is collected like any other; in `machinery` they become part of what is + // deleted, so their own forwarding calls stop reading as use sites. + const wrapperNodes = new Set([ + ...wrappers.map((wrapper) => wrapper.node), + ...scopeWrappers.map((scope) => scope.node), + ]) + const wrapperByNode = new Map([ + ...wrappers.map((wrapper) => [ + wrapper.node, + { callName: wrapper.selfName }, + ]), + ...scopeWrappers.map((scope) => [ + scope.node, + { callName: scope.liftedName }, + ]), + ]) + const machinery = new Set([ + holder.node, + ...wrappers.map((wrapper) => wrapper.node), + ...scopeWrappers.map((scope) => scope.node), + ...rotators.map((rotator) => rotator.call), + ]) + const insideMachinery = (path) => { + for (let cursor = path.parentPath; cursor; cursor = cursor.parentPath) { + if (machinery.has(cursor.node)) { + return true + } + } + return false + } + + // **Nothing outside the machinery may read the array itself.** The wrapper's call sites are + // enumerated exhaustively below, but the *holder* is a separate binding and its references were + // never counted - so a program that indexes the array directly would have had it deleted out + // from under it. That is the fail-open direction: the sample breaks rather than leaving residue. + // Checked here because it is only answerable before anything is replaced. + const holderBinding = holder.path.isFunctionDeclaration() + ? holder.path.parentPath.scope.getBinding(holder.name) + : holder.path.scope.getBinding(holder.name) + const outsideHolderRefs = (holderBinding?.referencePaths ?? []).filter( + (ref) => !insideMachinery(ref), + ) + if (!holderBinding || outsideHolderRefs.length) { + notes.push( + holderBinding + ? `refusing: the string array is read from ${outsideHolderRefs.length} place(s) outside ` + + `the machinery, e.g. ${gen(outsideHolderRefs[0].parent)}` + : 'refusing: the string array holder has no binding to check its readers through', + ) + return result('unreadable', detected) + } + + const aliasNodes = new Set(detected.aliases.map((alias) => alias.path.node)) + const { sites, opaque, strayRefs } = collectSites( + ast, + wrapperNodes, + aliasNodes, + insideMachinery, + ) + if (opaque.length) { + notes.push( + `leaving the subsystem in place: ${opaque.length} call site(s) take arguments that ` + + `cannot be evaluated, e.g. ${opaque[0]}`, + ) + return result('unowned', detected) + } + if (strayRefs.length) { + notes.push( + `refusing: ${strayRefs.length} reference(s) to the root wrapper are neither calls nor ` + + `known aliases, e.g. ${strayRefs[0]}`, + ) + return result('unreadable', detected) + } + + // ---- evaluate ---------------------------------------------------------- + // + // **A fresh isolate per decode.** A module-scope one is shared by every decode in the process, + // so the second sample evaluates its machinery into a context still holding the first's + // bindings: a name that should be missing resolves, and the cell passes for the wrong reason. + const isolate = new ivm.Isolate({ memoryLimit: 128 }) + let failure = null + try { + const context = isolate.createContextSync() + const prelude = buildPrelude(holder, wrappers, rotators, scopeWrappers) + try { + // **The timeout is a correctness instrument, not a safety net.** The compare-loop rotator + // searches until a checksum over the array's own contents matches, so it *cannot* terminate + // on an array it was not written for. Termination is therefore proof that the extraction + // was complete, and a timeout means it was not - which is the one signal that catches a + // missed component on a sample with no expected output to compare against. + context.evalSync(prelude, { timeout }) + } catch (e) { + failure = + `the extracted machinery did not evaluate (${e.message}); on a compare-loop ` + + `rotator this means the extraction was incomplete rather than that the sample is hostile` + } + + if (!failure) { + // Cached because the same index recurs across a program, and the wrappers are pure with + // respect to the array once the rotation has run. + const cache = new Map() + for (const site of sites) { + const wrapper = wrapperByNode.get(site.wrapper) + // **The callee is rewritten to the wrapper's own name before evaluating**, rather than + // the alias declarations being evaluated into the isolate the way one could. Renamed + // output reuses short names across non-overlapping scopes, so evaluating alias + // declarations can collide two distinct bindings onto one name. Resolution already knows + // which wrapper this site reached, so nothing is guessed. + const args = site.path.node.arguments.map(gen).join(',') + const call = `${wrapper.callName}(${args})` + let value = cache.get(call) + if (value === undefined) { + try { + value = context.evalSync(call, { timeout }) + } catch (e) { + failure = `call site ${gen(site.path.node)} did not evaluate (${e.message})` + break + } + if (typeof value !== 'string') { + failure = `call site ${gen(site.path.node)} returned ${typeof value}, not a string` + break + } + cache.set(call, value) + } + site.value = value + } + } + } finally { + isolate.dispose() + } + + if (failure) { + notes.push(`refusing: ${failure}`) + return result('unreadable', detected) + } + + // **The second rotator-miss guard, and the general one.** A missed rotator does not throw: it + // returns real strings from an unrotated array, so the output parses, runs, and reads clean on + // every residue axis while being wrong. What it cannot do is keep a property name in a computed + // member key valid, because the encoder put a real one there. Under a per-item-keyed encoding + // the same miss also produces non-ASCII bytes, but that tell is encoding-specific and this one + // is not. + // + // Deliberately strict: it is a guard, and loosening one before it has ever failed is how a + // guard stops guarding. + const keyViolations = sites.filter( + (site) => isComputedKey(site.path) && !IDENTIFIER_RE.test(site.value), + ) + if (keyViolations.length) { + notes.push( + `refusing: ${keyViolations.length} decoded string(s) in computed-member-key position are ` + + `not valid identifiers, e.g. ${JSON.stringify(keyViolations[0].value)} - the usual ` + + `cause is a component of the machinery that was not extracted`, + ) + return result('unreadable', detected) + } + + // ---- mutate ------------------------------------------------------------ + // + // Nothing above this line has touched the tree. Every `(call site, value)` pair is resolved + // first because the alternative cannot satisfy all-or-nothing: editing while sites are still + // being resolved leaves a half-decoded array behind on any later failure. + const rotatorPaths = pathsFor( + ast, + new Set(rotators.map((rotator) => rotator.call)), + ) + // Taken while the holder's path is still attached; the Scope object outlives the removals. + const programScope = holder.path.scope.getProgramParent() + + for (const site of sites) { + site.path.replaceWith(t.stringLiteral(site.value)) + } + + let removedAliases = 0 + for (const alias of detected.aliases) { + // Aliases the encoder injected *into* the machinery go with it; removing them separately + // would only detach paths the machinery removal still has to walk. + if (alias.path.removed || insideMachinery(alias.path)) { + continue + } + removeDeclarator(alias.path) + removedAliases += 1 + } + + let removedRotators = 0 + for (const rotator of rotators) { + const path = rotatorPaths.get(rotator.call) + if (path && !path.removed) { + removeEffectExpression(path) + removedRotators += 1 + } + } + + // Ahead of the root wrappers only for readability - what actually keeps this safe is that every + // call site was replaced above, so nothing references any of them by the time they go, and the + // `removed` guard covers a wrapper that a containing one already took with it. + let removedScopeWrappers = 0 + for (const scope of [...scopeWrappers].reverse()) { + if (scope.path.removed) { + continue + } + if (scope.decl === 'var-function-expression') { + removeDeclarator(scope.path) + } else { + scope.path.remove() + } + removedScopeWrappers += 1 + } + + let removedWrappers = 0 + for (const wrapper of wrappers) { + if (wrapper.path.removed) { + continue + } + if (wrapper.decl === 'var-function-expression') { + removeDeclarator(wrapper.path) + } else { + wrapper.path.remove() + } + removedWrappers += 1 + } + + let removedHolder = 0 + if (!holder.path.removed) { + if (holder.kind === 'var-declaration') { + removeDeclarator(holder.path) + } else { + holder.path.remove() + } + removedHolder = 1 + } + + // Crawl from the **program** scope, not from any one removal site. This pass deletes bindings in + // several scopes at once and replaces references in others, so crawling locally would leave the + // enclosing scopes' reference counts stale for whatever runs next - a cleanup sweep, typically, + // which is exactly the kind of pass that decides what to delete from a count. + programScope.crawl() + + debugLog( + `[obfuscatorx] string-array: ${sites.length} call site(s) decoded, removed ` + + `${removedHolder} holder, ${removedWrappers} wrapper(s), ` + + `${removedScopeWrappers} scope wrapper(s), ${removedRotators} rotator(s), ` + + `${removedAliases} alias(es)`, + ) + + return { + status: 'decoded', + signature: detected.signature, + replaced: sites.length, + removed: { + holder: removedHolder, + wrappers: removedWrappers, + scopeWrappers: removedScopeWrappers, + rotators: removedRotators, + aliases: removedAliases, + }, + notes, + } +} + +export default decodeStringArray diff --git a/test/visitor/obfuscator/string-array.test.js b/test/visitor/obfuscator/string-array.test.js new file mode 100644 index 00000000..7dc1a6cf --- /dev/null +++ b/test/visitor/obfuscator/string-array.test.js @@ -0,0 +1,274 @@ +import fs from 'fs' +import { join } from 'path' +import { describe, expect, test } from 'vitest' +import { parse } from '@babel/parser' +import generate from '@babel/generator' +import normalizeStatements from '#visitor/obfuscator/normalize-statements' +import decodeStringArray from '#visitor/obfuscator/string-array' + +const root = join(__dirname, 'string-array') + +/** + * Every case is real javascript-obfuscator 2.19.0 output, and every `decoded` and `absent` case + * was built from a case the *encoder's own* test suite asserts — each one's comment names the + * variant it came from in `StringArrayTransformer.spec.ts` or + * `StringArrayRotateFunctionTransformer.spec.ts`. That matters because a fixture pins a claim, and + * cases invented to match what the pass happens to do pin the pass to itself. + * + * Three of the four outcomes are not failures, so only `decoded` has an obvious golden. The other + * three assert a status **and** that the tree came through byte-identical, which is what + * "resolve a matched structure completely or leave it entirely alone" means as a measurement. + */ +function run(name, options = {}) { + const input = fs.readFileSync(join(root, `${name}.js`), 'utf-8') + + // The untouched baseline is the tree after normalization, not the raw input: U1 runs first and + // does its job, so comparing against the raw bytes would report every non-`decoded` case as + // mutated and be measuring the wrong pass. + const only = parse(input, { allowReturnOutsideFunction: true }) + normalizeStatements(only) + const baseline = generate(only).code + + const ast = parse(input, { allowReturnOutsideFunction: true }) + normalizeStatements(ast) + const res = decodeStringArray(ast, options) + return { res, code: generate(ast).code, baseline } +} + +/** A `decoded` case: the golden is the whole assertion. */ +function expectDecoded(name, sites) { + const { res, code } = run(name) + expect(res.status).toBe('decoded') + expect(res.replaced).toBe(sites) + expect(code).toBe(fs.readFileSync(join(root, `${name}.fix.js`), 'utf-8')) + return res +} + +/** Any other outcome: the status, and the tree provably untouched. */ +function expectUntouched(name, status, options = {}) { + const { res, code, baseline } = run(name, options) + expect(res.status).toBe(status) + expect(res.replaced).toBe(0) + expect(code).toBe(baseline) + return res +} + +describe("decoded — claims taken from the encoder's own StringArrayTransformer.spec.ts", () => { + // Variant #1: default behaviour. + test('baseline', () => expectDecoded('baseline', 3)) + + // Variant #3.2. The index arrives as a *string* literal, `w('0x0')` rather than `w(0x0)`, and + // nothing in the corpus reaches this: `stringArrayIndexesType` appears only in the maximal + // profile, whose array is starved by its own `splitStringsChunkLength` and so has no live call + // site to spell either way. + test('index-numeric-string', () => expectDecoded('index-numeric-string', 3)) + + // Variant #3.3: both spellings in one sample, so a matcher that handles one type globally + // rather than per site still passes the two cases above and fails here. + test('index-mixed-types', () => expectDecoded('index-mixed-types', 5)) + + // Variant #4.1. `stringArrayIndexShift` rewrites the wrapper to subtract a constant from every + // index. Absorbed by evaluating the wrapper rather than modelled — which is the whole point of + // the strategy, and, like the index type above, had no corpus cell with live call sites. + test('index-shift', () => expectDecoded('index-shift', 5)) + + // Variant #4.5: shift, rotation and shuffling at once. The shift is computed against an array + // whose order the rotator has to restore first, so the three interact rather than stack. + test('index-shift-rotate-shuffle', () => + expectDecoded('index-shift-rotate-shuffle', 5)) + + // Variant #5: one array item, several call sites. Pins that the evaluation cache is keyed on + // the call rather than on the site. + test('same-literal-values', () => expectDecoded('same-literal-values', 4)) + + // Variant #8. The rc4 body hangs a memo cache off the wrapper object and carries an inline + // self-defending guard; neither may be read as a use site, and evaluating a call executes both. + test('encoding-rc4', () => expectDecoded('encoding-rc4', 3)) + + // Variant #11: two root wrappers, one per encoding, with **no** `none` wrapper — so there is no + // plain fallback and every site has to reach the wrapper it was actually compiled against. + test('encoding-base64-rc4', () => expectDecoded('encoding-base64-rc4', 10)) + + // Variant #13, and it is the name trap in the encoder's own words. Under mangled names the root + // wrapper is `b` and its parameters are `c` and `d`, which collide with a function declaration + // and with an inner `var b; function b(){}` pair. Resolution goes through bindings; a matcher + // holding the wrapper as a string decodes the wrong references here. + test('calls-wrapper-name', () => expectDecoded('calls-wrapper-name', 1)) + + // Variant #16.2. A computed object key becomes a call site in computed-member-key position — + // the positive direction of the guard that `guard-rotator-removed` exercises negatively. + test('object-computed-key', () => expectDecoded('object-computed-key', 5)) + + // StringArrayRotateFunctionTransformer, "prevent early successful comparison": enough array + // items that the rotator cannot succeed on its first trial rotation, so the checksum search + // genuinely runs inside the isolate instead of terminating immediately. + test('rotate-search', () => expectDecoded('rotate-search', 22)) +}) + +/** + * U3 - the per-scope calls wrapper. Every claim below is a describe() in the encoder's own + * StringArrayScopeCallsWrapperTransformer.spec.ts, and each case names it. + * + * All of these were `unowned` before U3: U2 could not evaluate a call whose arguments are another + * function's parameters, and could not delete machinery such a wrapper still calls. + */ +describe('scope calls wrappers — StringArrayScopeCallsWrapperTransformer.spec.ts', () => { + // Variant #1.2: function scope. Four wrappers, two of them nested and forwarding to a + // Program-scope wrapper rather than to the root, so this is already a two-hop chain. + test('wrappers-function', () => expectDecoded('wrappers-function', 8)) + + // Variant #6.1.1: `Mangled` identifier names generator. THE case the synthetic lifted names + // exist for - mangled output reuses short names across sibling scopes, and every wrapper is + // lifted into one flat isolate scope, so lifting them verbatim would let one definition win. + test('scope-chained-mangled', () => expectDecoded('scope-chained-mangled', 9)) + + // Variant #6.2.2: chained calls, advanced. Three levels deep, so a wrapper's upper is itself a + // wrapper whose upper is a wrapper - membership cannot be settled in one pass. + test('scope-chained-deep', () => expectDecoded('scope-chained-deep', 7)) + + // Variant #7.1.2: `hexadecimal-numeric-string` indexes. The offset is a coercing STRING, + // `param - '0x28b'`. Distinct from `index-numeric-string`, whose variable-form index is a bare + // literal and so never exercises the coercion. + test('scope-numeric-string-offset', () => + expectDecoded('scope-numeric-string-offset', 8)) + + // Variant #7.3: no wrappers on a root scope. The chain bottoms out at the root *wrapper*, not + // at a root-scope wrapper, which is the case a fixpoint seeded from the wrong end would miss. + test('scope-no-root-wrappers', () => + expectDecoded('scope-no-root-wrappers', 5)) + + // Variant #4: prevailing kind of variables. A `const` program gets `const` wrappers - no corpus + // input is anything but `var`, so this shape has no other coverage. + test('scope-prevailing-const', () => + expectDecoded('scope-prevailing-const', 8)) + + // Variant #3.1: if statement scope is *prohibited*, so the read inside it routes to an + // enclosing scope's wrapper. The callee is not declared in the call's own block. + test('scope-prohibited-if', () => expectDecoded('scope-prohibited-if', 3)) + + // Variant #2.4: a literal in a function default parameter, read in the enclosing scope rather + // than the function body's. + test('scope-default-parameter', () => + expectDecoded('scope-default-parameter', 5)) +}) + +describe('absent — a verdict, not a failure', () => { + // Variant #2. Obfuscated output with the option off. This is what the terminating round of a + // peel loop reads, and it must not be confusable with a fingerprint miss. + test('string-array-off', () => expectUntouched('string-array-off', 'absent')) + + // Variant #6: every literal below the encoder's three-character membership gate, so no array is + // built at all. + test('short-literal-value', () => + expectUntouched('short-literal-value', 'absent')) +}) + +describe('unowned — mine, readable, and a later unit owns the rest', () => { + // **Deliberately empty for now.** `wrappers-function` used to live here: U2 could not decode + // through a function-form scope wrapper, so it left the sample alone and said so. U3 resolves + // that shape, and the case moved to `decoded` above. + // + // The outcome itself is NOT dead and must not be deleted with the last case that reached it - + // `foreignWrappers` still fires for a function that calls the machinery with non-constant + // arguments and is not a resolvable scope wrapper, which is what U5's control-flow storage + // looks like. It has no committed case only because no fixture reaches it yet. + test.todo('a construct a later unit owns — needs a U5-shaped case') +}) + +describe('unreadable — the refusal, kept narrow', () => { + // A refusal path with no committed case is one a later refactor can silently delete, so each of + // these damages a sample that decodes cleanly in exactly one way. **The note is asserted, not + // just the status:** all five refusals report `unreadable`, so a case can land on somebody + // else's guard and still look right. That is not hypothetical — a corrupted-array case once + // terminated normally and was caught by the identifier check while claiming to test the timeout. + + // The rotator deleted. Nothing about the remaining shape says it should be there, so the array + // is simply left rotated: every call site returns a real string from the wrong slot, and the + // output parses, runs, and reads zero on every residue axis. The array items here are + // deliberately not identifier-shaped, or the wrong strings would be plausible ones and the + // guard would correctly stay silent. + test('guard-rotator-removed', () => { + const res = expectUntouched('guard-rotator-removed', 'unreadable') + expect( + res.notes.some((n) => + n.includes('computed-member-key position are not valid identifiers'), + ), + ).toBe(true) + }) + + // A checksum operand corrupted. The compare loop searches until a checksum over the array's own + // contents matches, so it cannot terminate — which is what makes the timeout a correctness + // instrument rather than a safety net. Two seconds here because ten in a committed suite is ten + // nobody wants; the pass's own default is longer. + test('guard-checksum-corrupted', () => { + const res = expectUntouched('guard-checksum-corrupted', 'unreadable', { + timeout: 2000, + }) + expect(res.notes.some((n) => n.includes('did not evaluate'))).toBe(true) + }, 20000) + + // An alias assigned without a declaration, so it is a global with no binding and its call sites + // cannot be enumerated. Deleting the machinery here breaks the program rather than leaving + // countable residue — decode-js#138's real sample needed exactly this hand-edited first. + test('guard-alias-undeclared', () => { + const res = expectUntouched('guard-alias-undeclared', 'unreadable') + expect(res.notes.some((n) => n.includes('have no binding'))).toBe(true) + }) + + // A read of the array from outside the machinery. The wrapper's call sites are enumerated + // exhaustively but the holder is a separate binding, so without this gate the array would be + // deleted out from under a program that indexes it directly. + test('guard-array-read-outside', () => { + const res = expectUntouched('guard-array-read-outside', 'unreadable') + expect(res.notes.some((n) => n.includes('is read from'))).toBe(true) + }) + + // Evidence present, entrypoint unresolvable. This is the detection-time refusal, and keeping it + // distinct from `absent` is the distinction the existing plugin collapses into one message. + test('guard-wrapper-removed', () => { + const res = expectUntouched('guard-wrapper-removed', 'unreadable') + expect( + res.notes.some((n) => n.includes('the entrypoint did not resolve')), + ).toBe(true) + }) +}) + +describe('one run, one layer', () => { + /** + * The only case in the suite that pins the layer boundary, which is where the corruption class + * lives: a pass that mutates the layer beneath it produces damage that is loud only when it + * happens to throw, where an unmatched inner layer is residue anyone can count. + * + * The input is encoded twice at 2.19.0 with different seeds. The expected output is **derivable + * rather than merely observed**: the second encode's input was the first encode's output, so + * peeling layer 2 has to reproduce that input — up to what layer 2 destroyed (local identifier + * names) and what our own normalization reprinted. So the assertions below are structural and + * behavioural, not byte equality against the separately-built single-encode. + */ + test('nested-one-layer', () => { + const res = expectDecoded('nested-one-layer', 21) + expect(res.removed.holder).toBe(1) + + // 1. What comes back is an ordinary single-encode: running the pass again finds a whole + // string-array subsystem and decodes it. A peel that had corrupted the inner layer would + // fail here, where a size- or residue-based check could easily pass. + const peeled = fs.readFileSync( + join(root, 'nested-one-layer.fix.js'), + 'utf-8', + ) + const second = parse(peeled, { allowReturnOutsideFunction: true }) + normalizeStatements(second) + const res2 = decodeStringArray(second) + expect(res2.status).toBe('decoded') + + // 2. And one further run reaches the source: the literals the original program was written + // with are back, as literals. + const final = generate(second).code + const src = fs.readFileSync(join(root, 'nested-one-layer.src.js'), 'utf-8') + for (const literal of src.match(/'[^']+'/g) || []) { + const value = literal.slice(1, -1) + if (value.length < 3 || value === '\\n') continue + expect(final).toContain(JSON.stringify(value)) + } + }) +}) diff --git a/test/visitor/obfuscator/string-array/baseline.fix.js b/test/visitor/obfuscator/string-array/baseline.fix.js new file mode 100644 index 00000000..06f31a86 --- /dev/null +++ b/test/visitor/obfuscator/string-array/baseline.fix.js @@ -0,0 +1,2 @@ +var test = "test"; +process["stdout"]["write"](String(test) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/baseline.js b/test/visitor/obfuscator/string-array/baseline.js new file mode 100644 index 00000000..c7cecc5c --- /dev/null +++ b/test/visitor/obfuscator/string-array/baseline.js @@ -0,0 +1 @@ +var test=_0x1944(0x1dc);function _0x1944(_0x5f208e,_0x417777){var _0x19442b=_0x4177();return _0x1944=function(_0x14b5ea,_0x5487cb){_0x14b5ea=_0x14b5ea-0x1dc;var _0x33faf2=_0x19442b[_0x14b5ea];return _0x33faf2;},_0x1944(_0x5f208e,_0x417777);}function _0x4177(){var _0x34f6c4=['test','stdout','write'];_0x4177=function(){return _0x34f6c4;};return _0x4177();}process[_0x1944(0x1dd)][_0x1944(0x1de)](String(test)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/baseline.src.js b/test/visitor/obfuscator/string-array/baseline.src.js new file mode 100644 index 00000000..211ed019 --- /dev/null +++ b/test/visitor/obfuscator/string-array/baseline.src.js @@ -0,0 +1,2 @@ +var test = 'test'; +process.stdout.write(String(test) + '\n'); diff --git a/test/visitor/obfuscator/string-array/calls-wrapper-name.fix.js b/test/visitor/obfuscator/string-array/calls-wrapper-name.fix.js new file mode 100644 index 00000000..735845fb --- /dev/null +++ b/test/visitor/obfuscator/string-array/calls-wrapper-name.fix.js @@ -0,0 +1,8 @@ +(function () { + function c() { + console["log"]('a'); + var d; + function d() {} + } + c(); +})(); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/calls-wrapper-name.js b/test/visitor/obfuscator/string-array/calls-wrapper-name.js new file mode 100644 index 00000000..9426628c --- /dev/null +++ b/test/visitor/obfuscator/string-array/calls-wrapper-name.js @@ -0,0 +1 @@ +function a(){var e=['log'];a=function(){return e;};return a();}function b(c,d){var e=a();return b=function(f,g){f=f-0x140;var h=e[f];return h;},b(c,d);}(function(){function c(){console[b(0x140)]('a');var d;function d(){}}c();}()); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/calls-wrapper-name.src.js b/test/visitor/obfuscator/string-array/calls-wrapper-name.src.js new file mode 100644 index 00000000..243f71f9 --- /dev/null +++ b/test/visitor/obfuscator/string-array/calls-wrapper-name.src.js @@ -0,0 +1,8 @@ +(function(){ + function foo () { + console.log('a'); + var b; + function b () {} + } + foo(); +})(); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/encoding-base64-rc4.fix.js b/test/visitor/obfuscator/string-array/encoding-base64-rc4.fix.js new file mode 100644 index 00000000..d71a3317 --- /dev/null +++ b/test/visitor/obfuscator/string-array/encoding-base64-rc4.fix.js @@ -0,0 +1,9 @@ +var s0 = "alpha"; +var s1 = "bravo"; +var s2 = "charlie"; +var s3 = "delta"; +var s4 = "echo"; +var s5 = "foxtrot"; +var s6 = "golf"; +var s7 = "hotel"; +process["stdout"]["write"](String(s0 + '|' + s1 + '|' + s2 + '|' + s3 + '|' + s4 + '|' + s5 + '|' + s6 + '|' + s7) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/encoding-base64-rc4.js b/test/visitor/obfuscator/string-array/encoding-base64-rc4.js new file mode 100644 index 00000000..baebdec1 --- /dev/null +++ b/test/visitor/obfuscator/string-array/encoding-base64-rc4.js @@ -0,0 +1 @@ +function _0x1abe(_0x4cd2e0,_0xca43dc){var _0x2f76a7=_0xca43();return _0x1abe=function(_0x261184,_0x2329a2){_0x261184=_0x261184-0x9b;var _0x4bbab0=_0x2f76a7[_0x261184];if(_0x1abe['wUlwUK']===undefined){var _0x17e871=function(_0x2a7197){var _0x2abcbb='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';var _0x162ae5='',_0x32635f='';for(var _0x292c30=0x0,_0x5d2680,_0x36171e,_0x5b286c=0x0;_0x36171e=_0x2a7197['charAt'](_0x5b286c++);~_0x36171e&&(_0x5d2680=_0x292c30%0x4?_0x5d2680*0x40+_0x36171e:_0x36171e,_0x292c30++%0x4)?_0x162ae5+=String['fromCharCode'](0xff&_0x5d2680>>(-0x2*_0x292c30&0x6)):0x0){_0x36171e=_0x2abcbb['indexOf'](_0x36171e);}for(var _0x3013e6=0x0,_0x1569e2=_0x162ae5['length'];_0x3013e6<_0x1569e2;_0x3013e6++){_0x32635f+='%'+('00'+_0x162ae5['charCodeAt'](_0x3013e6)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x32635f);};var _0x585dd1=function(_0x10a800,_0x4e418b){var _0x3c3f0d=[],_0x595072=0x0,_0x4f8fc0,_0x135ec9='';_0x10a800=_0x17e871(_0x10a800);var _0x4d5055;for(_0x4d5055=0x0;_0x4d5055<0x100;_0x4d5055++){_0x3c3f0d[_0x4d5055]=_0x4d5055;}for(_0x4d5055=0x0;_0x4d5055<0x100;_0x4d5055++){_0x595072=(_0x595072+_0x3c3f0d[_0x4d5055]+_0x4e418b['charCodeAt'](_0x4d5055%_0x4e418b['length']))%0x100,_0x4f8fc0=_0x3c3f0d[_0x4d5055],_0x3c3f0d[_0x4d5055]=_0x3c3f0d[_0x595072],_0x3c3f0d[_0x595072]=_0x4f8fc0;}_0x4d5055=0x0,_0x595072=0x0;for(var _0x10622b=0x0;_0x10622b<_0x10a800['length'];_0x10622b++){_0x4d5055=(_0x4d5055+0x1)%0x100,_0x595072=(_0x595072+_0x3c3f0d[_0x4d5055])%0x100,_0x4f8fc0=_0x3c3f0d[_0x4d5055],_0x3c3f0d[_0x4d5055]=_0x3c3f0d[_0x595072],_0x3c3f0d[_0x595072]=_0x4f8fc0,_0x135ec9+=String['fromCharCode'](_0x10a800['charCodeAt'](_0x10622b)^_0x3c3f0d[(_0x3c3f0d[_0x4d5055]+_0x3c3f0d[_0x595072])%0x100]);}return _0x135ec9;};_0x1abe['sxbZly']=_0x585dd1,_0x4cd2e0=arguments,_0x1abe['wUlwUK']=!![];}var _0x509fe6=_0x2f76a7[0x0],_0x376916=_0x261184+_0x509fe6,_0x1abe8c=_0x4cd2e0[_0x376916];return!_0x1abe8c?(_0x1abe['RtnSiS']===undefined&&(_0x1abe['RtnSiS']=!![]),_0x4bbab0=_0x1abe['sxbZly'](_0x4bbab0,_0x2329a2),_0x4cd2e0[_0x376916]=_0x4bbab0):_0x4bbab0=_0x1abe8c,_0x4bbab0;},_0x1abe(_0x4cd2e0,_0xca43dc);}var s0=_0x1abe(0x9b,'I]*R'),s1=_0x2f76(0x9c),s2=_0x1abe(0x9d,'4HtA'),s3=_0x1abe(0x9e,'nTjD'),s4=_0x2f76(0x9f),s5=_0x1abe(0xa0,'uWmk'),s6=_0x1abe(0xa1,'py!J'),s7=_0x1abe(0xa2,'GYr5');function _0x2f76(_0x4cd2e0,_0xca43dc){var _0x2f76a7=_0xca43();return _0x2f76=function(_0x261184,_0x2329a2){_0x261184=_0x261184-0x9b;var _0x4bbab0=_0x2f76a7[_0x261184];if(_0x2f76['tTeoRk']===undefined){var _0x17e871=function(_0x585dd1){var _0x2a7197='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';var _0x2abcbb='',_0x162ae5='';for(var _0x32635f=0x0,_0x292c30,_0x5d2680,_0x36171e=0x0;_0x5d2680=_0x585dd1['charAt'](_0x36171e++);~_0x5d2680&&(_0x292c30=_0x32635f%0x4?_0x292c30*0x40+_0x5d2680:_0x5d2680,_0x32635f++%0x4)?_0x2abcbb+=String['fromCharCode'](0xff&_0x292c30>>(-0x2*_0x32635f&0x6)):0x0){_0x5d2680=_0x2a7197['indexOf'](_0x5d2680);}for(var _0x5b286c=0x0,_0x3013e6=_0x2abcbb['length'];_0x5b286c<_0x3013e6;_0x5b286c++){_0x162ae5+='%'+('00'+_0x2abcbb['charCodeAt'](_0x5b286c)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x162ae5);};_0x2f76['TBNaHQ']=_0x17e871,_0x4cd2e0=arguments,_0x2f76['tTeoRk']=!![];}var _0x509fe6=_0x2f76a7[0x0],_0x376916=_0x261184+_0x509fe6,_0x1abe8c=_0x4cd2e0[_0x376916];return!_0x1abe8c?(_0x4bbab0=_0x2f76['TBNaHQ'](_0x4bbab0),_0x4cd2e0[_0x376916]=_0x4bbab0):_0x4bbab0=_0x1abe8c,_0x4bbab0;},_0x2f76(_0x4cd2e0,_0xca43dc);}function _0xca43(){var _0x2b011e=['WOVdRefJbq','yNjHDM8','WR4Yt3P6WQen','ttKyW4dcHa','zwnOBW','W5fMW4/dR8kPW7qv','WPvlW6tdUa','W4uqW4FdMmol','WPNdTfvKewq','D3jPDgu'];_0xca43=function(){return _0x2b011e;};return _0xca43();}process[_0x1abe(0xa3,'I]*R')][_0x2f76(0xa4)](String(s0+'|'+s1+'|'+s2+'|'+s3+'|'+s4+'|'+s5+'|'+s6+'|'+s7)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/encoding-base64-rc4.src.js b/test/visitor/obfuscator/string-array/encoding-base64-rc4.src.js new file mode 100644 index 00000000..7322e302 --- /dev/null +++ b/test/visitor/obfuscator/string-array/encoding-base64-rc4.src.js @@ -0,0 +1,9 @@ +var s0 = 'alpha'; +var s1 = 'bravo'; +var s2 = 'charlie'; +var s3 = 'delta'; +var s4 = 'echo'; +var s5 = 'foxtrot'; +var s6 = 'golf'; +var s7 = 'hotel'; +process.stdout.write(String(s0 + '|' + s1 + '|' + s2 + '|' + s3 + '|' + s4 + '|' + s5 + '|' + s6 + '|' + s7) + '\n'); diff --git a/test/visitor/obfuscator/string-array/encoding-rc4.fix.js b/test/visitor/obfuscator/string-array/encoding-rc4.fix.js new file mode 100644 index 00000000..06f31a86 --- /dev/null +++ b/test/visitor/obfuscator/string-array/encoding-rc4.fix.js @@ -0,0 +1,2 @@ +var test = "test"; +process["stdout"]["write"](String(test) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/encoding-rc4.js b/test/visitor/obfuscator/string-array/encoding-rc4.js new file mode 100644 index 00000000..eee6868a --- /dev/null +++ b/test/visitor/obfuscator/string-array/encoding-rc4.js @@ -0,0 +1 @@ +function _0x4177(){var _0x78aa04=['pmojcXi','lgiLaCkBeW','A1HCm8ol'];_0x4177=function(){return _0x78aa04;};return _0x4177();}var test=_0x1944(0x1dc,'ucS5');function _0x1944(_0x5f208e,_0x417777){var _0x19442b=_0x4177();return _0x1944=function(_0x14b5ea,_0x5487cb){_0x14b5ea=_0x14b5ea-0x1dc;var _0x33faf2=_0x19442b[_0x14b5ea];if(_0x1944['PhwYTu']===undefined){var _0x498ea6=function(_0x47a54e){var _0x3c2c1c='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';var _0x3e9899='',_0x4c807b='';for(var _0x48db50=0x0,_0x383951,_0x1672de,_0xc4c33e=0x0;_0x1672de=_0x47a54e['charAt'](_0xc4c33e++);~_0x1672de&&(_0x383951=_0x48db50%0x4?_0x383951*0x40+_0x1672de:_0x1672de,_0x48db50++%0x4)?_0x3e9899+=String['fromCharCode'](0xff&_0x383951>>(-0x2*_0x48db50&0x6)):0x0){_0x1672de=_0x3c2c1c['indexOf'](_0x1672de);}for(var _0x2105f8=0x0,_0x4f28ce=_0x3e9899['length'];_0x2105f8<_0x4f28ce;_0x2105f8++){_0x4c807b+='%'+('00'+_0x3e9899['charCodeAt'](_0x2105f8)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x4c807b);};var _0x54be8b=function(_0x59eafb,_0x6b3392){var _0xdc5c66=[],_0x530b08=0x0,_0x26b044,_0x6c2032='';_0x59eafb=_0x498ea6(_0x59eafb);var _0x49950b;for(_0x49950b=0x0;_0x49950b<0x100;_0x49950b++){_0xdc5c66[_0x49950b]=_0x49950b;}for(_0x49950b=0x0;_0x49950b<0x100;_0x49950b++){_0x530b08=(_0x530b08+_0xdc5c66[_0x49950b]+_0x6b3392['charCodeAt'](_0x49950b%_0x6b3392['length']))%0x100,_0x26b044=_0xdc5c66[_0x49950b],_0xdc5c66[_0x49950b]=_0xdc5c66[_0x530b08],_0xdc5c66[_0x530b08]=_0x26b044;}_0x49950b=0x0,_0x530b08=0x0;for(var _0x169124=0x0;_0x169124<_0x59eafb['length'];_0x169124++){_0x49950b=(_0x49950b+0x1)%0x100,_0x530b08=(_0x530b08+_0xdc5c66[_0x49950b])%0x100,_0x26b044=_0xdc5c66[_0x49950b],_0xdc5c66[_0x49950b]=_0xdc5c66[_0x530b08],_0xdc5c66[_0x530b08]=_0x26b044,_0x6c2032+=String['fromCharCode'](_0x59eafb['charCodeAt'](_0x169124)^_0xdc5c66[(_0xdc5c66[_0x49950b]+_0xdc5c66[_0x530b08])%0x100]);}return _0x6c2032;};_0x1944['CKnwGT']=_0x54be8b,_0x5f208e=arguments,_0x1944['PhwYTu']=!![];}var _0x44bf5d=_0x19442b[0x0],_0x110847=_0x14b5ea+_0x44bf5d,_0x40c0bd=_0x5f208e[_0x110847];return!_0x40c0bd?(_0x1944['MYsnYD']===undefined&&(_0x1944['MYsnYD']=!![]),_0x33faf2=_0x1944['CKnwGT'](_0x33faf2,_0x5487cb),_0x5f208e[_0x110847]=_0x33faf2):_0x33faf2=_0x40c0bd,_0x33faf2;},_0x1944(_0x5f208e,_0x417777);}process[_0x1944(0x1dd,'*lr@')][_0x1944(0x1de,'rvSh')](String(test)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/encoding-rc4.src.js b/test/visitor/obfuscator/string-array/encoding-rc4.src.js new file mode 100644 index 00000000..211ed019 --- /dev/null +++ b/test/visitor/obfuscator/string-array/encoding-rc4.src.js @@ -0,0 +1,2 @@ +var test = 'test'; +process.stdout.write(String(test) + '\n'); diff --git a/test/visitor/obfuscator/string-array/guard-alias-undeclared.js b/test/visitor/obfuscator/string-array/guard-alias-undeclared.js new file mode 100644 index 00000000..caf3a69f --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-alias-undeclared.js @@ -0,0 +1 @@ +_0x2001b6=_0xde91;var _0xc96050=_0xde91,foo=_0x2001b6(0x109),bar=_0xc96050(0x10a),baz=_0x2001b6(0x10b);function _0x538a(){var _0x2d29a6=['foo','bar','baz','bark','hawk','eagle','stdout','write'];_0x538a=function(){return _0x2d29a6;};return _0x538a();}function test(){var _0x5031cf=_0xc96050,_0x4d9a2c=_0x2001b6,_0x1b2a2f=_0x5031cf(0x10c),_0x3606cc=_0x5031cf(0x10d),_0x3ab71b=_0x5031cf(0x10e);}function _0xde91(_0xaf96cc,_0x538a17){var _0xde914f=_0x538a();return _0xde91=function(_0x4f8fff,_0x2d8e9e){_0x4f8fff=_0x4f8fff-0x109;var _0xb1ad15=_0xde914f[_0x4f8fff];return _0xb1ad15;},_0xde91(_0xaf96cc,_0x538a17);}process[_0x2001b6(0x10f)][_0xc96050(0x110)](String(foo+bar+baz)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/guard-alias-undeclared.src.js b/test/visitor/obfuscator/string-array/guard-alias-undeclared.src.js new file mode 100644 index 00000000..5c5e9fca --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-alias-undeclared.src.js @@ -0,0 +1,11 @@ +var foo = 'foo' +var bar = 'bar'; +var baz = 'baz'; + +function test () { + var bark = 'bark' + var hawk = 'hawk'; + var eagle = 'eagle'; +} + +process.stdout.write(String(foo + bar + baz) + '\n'); diff --git a/test/visitor/obfuscator/string-array/guard-array-read-outside.js b/test/visitor/obfuscator/string-array/guard-array-read-outside.js new file mode 100644 index 00000000..95d8920c --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-array-read-outside.js @@ -0,0 +1 @@ +function _0x1a8c(_0x4ba73e,_0x102a8a){var _0x548df1=_0x548d();return _0x1a8c=function(_0x1a8cc7,_0x53921a){_0x1a8cc7=_0x1a8cc7-0xe1;var _0x47d4fd=_0x548df1[_0x1a8cc7];return _0x47d4fd;},_0x1a8c(_0x4ba73e,_0x102a8a);}function _0x548d(){var _0x1712a0=['9261vVPYFF','1164XBFBSG','6605880SgtzSl','972swRBfV','62097DzCZRo','10040408DrSPoX','4794174FHBosn','7073470gqYGPV','testvalue','stdout','write','2SDHHUi','761416ROkhfl'];_0x548d=function(){return _0x1712a0;};return _0x548d();}(function(_0x123c43,_0x50ebde){var _0x4c7dbd=_0x123c43();while(!![]){try{var _0x2006e1=-parseInt(_0x1a8c(0xe1))/0x1*(parseInt(_0x1a8c(0xe2))/0x2)+-parseInt(_0x1a8c(0xe3))/0x3*(parseInt(_0x1a8c(0xe4))/0x4)+parseInt(_0x1a8c(0xe5))/0x5+-parseInt(_0x1a8c(0xe6))/0x6*(parseInt(_0x1a8c(0xe7))/0x7)+parseInt(_0x1a8c(0xe8))/0x8+parseInt(_0x1a8c(0xe9))/0x9+parseInt(_0x1a8c(0xea))/0xa;if(_0x2006e1===_0x50ebde)break;else _0x4c7dbd['push'](_0x4c7dbd['shift']());}catch(_0x3e2913){_0x4c7dbd['push'](_0x4c7dbd['shift']());}}})(_0x548d,0xafa41);var test=_0x1a8c(0xeb);process[_0x1a8c(0xec)][_0x1a8c(0xed)](String(test)+'\x0a');void _0x548d; \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/guard-array-read-outside.src.js b/test/visitor/obfuscator/string-array/guard-array-read-outside.src.js new file mode 100644 index 00000000..297d435b --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-array-read-outside.src.js @@ -0,0 +1,2 @@ +var test = 'testvalue'; +process.stdout.write(String(test) + '\n'); diff --git a/test/visitor/obfuscator/string-array/guard-checksum-corrupted.js b/test/visitor/obfuscator/string-array/guard-checksum-corrupted.js new file mode 100644 index 00000000..6d5cae85 --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-checksum-corrupted.js @@ -0,0 +1 @@ +function _0x1dd6(_0x135e9f,_0x1e9be){var _0x5ad0b7=_0x5ad0();return _0x1dd6=function(_0x1dd622,_0x1a0363){_0x1dd622=_0x1dd622-0x1b0;var _0x5ce6da=_0x5ad0b7[_0x1dd622];return _0x5ce6da;},_0x1dd6(_0x135e9f,_0x1e9be);}(function(_0x2060f6,_0x63ed00){var _0x4ac616=_0x2060f6();while(!![]){try{var _0x18c136=parseInt(_0x1dd6(0x1b0))/0x1+-parseInt(_0x1dd6(0x1b1))/0x2+parseInt(_0x1dd6(0x1b2))/0x3+-parseInt(_0x1dd6(0x1b3))/0x4*(-parseInt(_0x1dd6(0x1b4))/0x5)+-parseInt(_0x1dd6(0x1b5))/0x6+-parseInt(_0x1dd6(0x1b6))/0x7+parseInt(_0x1dd6(0x1b7))/0x8;if(_0x18c136===_0x63ed00)break;else _0x4ac616['push'](_0x4ac616['shift']());}catch(_0x197d93){_0x4ac616['push'](_0x4ac616['shift']());}}})(_0x5ad0,0x5c3d3);var s0=_0x1dd6(0x1b8),s1=_0x1dd6(0x1b9),s2=_0x1dd6(0x1ba),s3=_0x1dd6(0x1bb),s4=_0x1dd6(0x1bc),s5=_0x1dd6(0x1bd),s6=_0x1dd6(0x1be),s7=_0x1dd6(0x1bf),s8=_0x1dd6(0x1c0),s9=_0x1dd6(0x1c1),s10=_0x1dd6(0x1c2),s11=_0x1dd6(0x1c3),s12=_0x1dd6(0x1c4),s13=_0x1dd6(0x1c5),s14=_0x1dd6(0x1c6),s15=_0x1dd6(0x1c7),s16=_0x1dd6(0x1c8),s17=_0x1dd6(0x1c9),s18=_0x1dd6(0x1ca),s19=_0x1dd6(0x1cb);function _0x5ad0(){var _0x50ef5b=['item_12_value','item_13_value','item_14_value','item_15_value','item_16_value','item_17_value','item_18_value','item_19_value','stdout','write',"963128CwHiDo",'1214256aAXImL','1432218LuVCBt','8dZthCn','1530380bGnTUz','3865434HXNwEQ','947856YMEtCP','4895200NFbEkJ','item_0_value','item_1_value','item_2_value','item_3_value','item_4_value','item_5_value','item_6_value','item_7_value','item_8_value','item_9_value','item_10_value','item_11_value'];_0x5ad0=function(){return _0x50ef5b;};return _0x5ad0();}process[_0x1dd6(0x1cc)][_0x1dd6(0x1cd)](String(s0+','+s1+','+s2+','+s3+','+s4+','+s5+','+s6+','+s7+','+s8+','+s9+','+s10+','+s11+','+s12+','+s13+','+s14+','+s15+','+s16+','+s17+','+s18+','+s19)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/guard-checksum-corrupted.src.js b/test/visitor/obfuscator/string-array/guard-checksum-corrupted.src.js new file mode 100644 index 00000000..51bbee5e --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-checksum-corrupted.src.js @@ -0,0 +1,21 @@ +var s0 = 'item_0_value'; +var s1 = 'item_1_value'; +var s2 = 'item_2_value'; +var s3 = 'item_3_value'; +var s4 = 'item_4_value'; +var s5 = 'item_5_value'; +var s6 = 'item_6_value'; +var s7 = 'item_7_value'; +var s8 = 'item_8_value'; +var s9 = 'item_9_value'; +var s10 = 'item_10_value'; +var s11 = 'item_11_value'; +var s12 = 'item_12_value'; +var s13 = 'item_13_value'; +var s14 = 'item_14_value'; +var s15 = 'item_15_value'; +var s16 = 'item_16_value'; +var s17 = 'item_17_value'; +var s18 = 'item_18_value'; +var s19 = 'item_19_value'; +process.stdout.write(String(s0 + ',' + s1 + ',' + s2 + ',' + s3 + ',' + s4 + ',' + s5 + ',' + s6 + ',' + s7 + ',' + s8 + ',' + s9 + ',' + s10 + ',' + s11 + ',' + s12 + ',' + s13 + ',' + s14 + ',' + s15 + ',' + s16 + ',' + s17 + ',' + s18 + ',' + s19) + '\n'); diff --git a/test/visitor/obfuscator/string-array/guard-rotator-removed.js b/test/visitor/obfuscator/string-array/guard-rotator-removed.js new file mode 100644 index 00000000..a5e224b4 --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-rotator-removed.js @@ -0,0 +1 @@ +function _0x2ebf(){var _0x388f88=['value\x209\x20/\x20of\x20twelve','value\x2010\x20/\x20of\x20twelve','value\x2011\x20/\x20of\x20twelve','stdout','write','179868VyFBOI','113686Eglgfd','22632FqJAiO','76PvULvq','223920MvPVrC','18XmdfCZ','49LVAzAx','187408WqZuab','171qhTWak','100930pjFkzd','3803492gunfnM','value\x200\x20/\x20of\x20twelve','value\x201\x20/\x20of\x20twelve','value\x202\x20/\x20of\x20twelve','value\x203\x20/\x20of\x20twelve','value\x204\x20/\x20of\x20twelve','value\x205\x20/\x20of\x20twelve','value\x206\x20/\x20of\x20twelve','value\x207\x20/\x20of\x20twelve','value\x208\x20/\x20of\x20twelve'];_0x2ebf=function(){return _0x388f88;};return _0x2ebf();}function _0xa4f2(_0x247808,_0x3fe5f5){var _0x2ebff3=_0x2ebf();return _0xa4f2=function(_0xa4f277,_0x4b33ed){_0xa4f277=_0xa4f277-0x169;var _0x46109d=_0x2ebff3[_0xa4f277];return _0x46109d;},_0xa4f2(_0x247808,_0x3fe5f5);}var s0=_0xa4f2(0x174),s1=_0xa4f2(0x175),s2=_0xa4f2(0x176),s3=_0xa4f2(0x177),s4=_0xa4f2(0x178),s5=_0xa4f2(0x179),s6=_0xa4f2(0x17a),s7=_0xa4f2(0x17b),s8=_0xa4f2(0x17c),s9=_0xa4f2(0x17d),s10=_0xa4f2(0x17e),s11=_0xa4f2(0x17f);process[_0xa4f2(0x180)][_0xa4f2(0x181)](String(s0+','+s1+','+s2+','+s3+','+s4+','+s5+','+s6+','+s7+','+s8+','+s9+','+s10+','+s11)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/guard-rotator-removed.src.js b/test/visitor/obfuscator/string-array/guard-rotator-removed.src.js new file mode 100644 index 00000000..97791c65 --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-rotator-removed.src.js @@ -0,0 +1,13 @@ +var s0 = 'value 0 / of twelve'; +var s1 = 'value 1 / of twelve'; +var s2 = 'value 2 / of twelve'; +var s3 = 'value 3 / of twelve'; +var s4 = 'value 4 / of twelve'; +var s5 = 'value 5 / of twelve'; +var s6 = 'value 6 / of twelve'; +var s7 = 'value 7 / of twelve'; +var s8 = 'value 8 / of twelve'; +var s9 = 'value 9 / of twelve'; +var s10 = 'value 10 / of twelve'; +var s11 = 'value 11 / of twelve'; +process.stdout.write(String(s0 + ',' + s1 + ',' + s2 + ',' + s3 + ',' + s4 + ',' + s5 + ',' + s6 + ',' + s7 + ',' + s8 + ',' + s9 + ',' + s10 + ',' + s11) + '\n'); diff --git a/test/visitor/obfuscator/string-array/guard-wrapper-removed.js b/test/visitor/obfuscator/string-array/guard-wrapper-removed.js new file mode 100644 index 00000000..e53384cb --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-wrapper-removed.js @@ -0,0 +1 @@ +function _0x548d(){var _0x1712a0=['9261vVPYFF','1164XBFBSG','6605880SgtzSl','972swRBfV','62097DzCZRo','10040408DrSPoX','4794174FHBosn','7073470gqYGPV','testvalue','stdout','write','2SDHHUi','761416ROkhfl'];_0x548d=function(){return _0x1712a0;};return _0x548d();}(function(_0x123c43,_0x50ebde){var _0x4c7dbd=_0x123c43();while(!![]){try{var _0x2006e1=-parseInt(_0x1a8c(0xe1))/0x1*(parseInt(_0x1a8c(0xe2))/0x2)+-parseInt(_0x1a8c(0xe3))/0x3*(parseInt(_0x1a8c(0xe4))/0x4)+parseInt(_0x1a8c(0xe5))/0x5+-parseInt(_0x1a8c(0xe6))/0x6*(parseInt(_0x1a8c(0xe7))/0x7)+parseInt(_0x1a8c(0xe8))/0x8+parseInt(_0x1a8c(0xe9))/0x9+parseInt(_0x1a8c(0xea))/0xa;if(_0x2006e1===_0x50ebde)break;else _0x4c7dbd['push'](_0x4c7dbd['shift']());}catch(_0x3e2913){_0x4c7dbd['push'](_0x4c7dbd['shift']());}}})(_0x548d,0xafa41);var test=_0x1a8c(0xeb);process[_0x1a8c(0xec)][_0x1a8c(0xed)](String(test)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/guard-wrapper-removed.src.js b/test/visitor/obfuscator/string-array/guard-wrapper-removed.src.js new file mode 100644 index 00000000..297d435b --- /dev/null +++ b/test/visitor/obfuscator/string-array/guard-wrapper-removed.src.js @@ -0,0 +1,2 @@ +var test = 'testvalue'; +process.stdout.write(String(test) + '\n'); diff --git a/test/visitor/obfuscator/string-array/index-mixed-types.fix.js b/test/visitor/obfuscator/string-array/index-mixed-types.fix.js new file mode 100644 index 00000000..1a16acea --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-mixed-types.fix.js @@ -0,0 +1,8 @@ +function test() { + var _0x4966b2 = "foo" + 0x1; + var _0x1b0733 = "bar" + 0x2; + var _0x4e046f = "baz" + 0x3; + return _0x4966b2 + _0x1b0733 + _0x4e046f; +} +test(); +process["stdout"]["write"](String(test()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/index-mixed-types.js b/test/visitor/obfuscator/string-array/index-mixed-types.js new file mode 100644 index 00000000..7f62fb6d --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-mixed-types.js @@ -0,0 +1 @@ +function test(){var _0x4966b2=_0x2657('0x136')+0x1,_0x1b0733=_0x2657(0x137)+0x2,_0x4e046f=_0x2657(0x138)+0x3;return _0x4966b2+_0x1b0733+_0x4e046f;}function _0x2657(_0x2d85ca,_0x132f17){var _0x2657f9=_0x132f();return _0x2657=function(_0x58933b,_0x136274){_0x58933b=_0x58933b-0x136;var _0x28b11c=_0x2657f9[_0x58933b];return _0x28b11c;},_0x2657(_0x2d85ca,_0x132f17);}function _0x132f(){var _0x42cc5c=['foo','bar','baz','stdout','write'];_0x132f=function(){return _0x42cc5c;};return _0x132f();}test(),process[_0x2657('0x139')][_0x2657(0x13a)](String(test())+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/index-mixed-types.src.js b/test/visitor/obfuscator/string-array/index-mixed-types.src.js new file mode 100644 index 00000000..8ab5948b --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-mixed-types.src.js @@ -0,0 +1,10 @@ +function test () { + var foo = 'foo' + 1; + var bar = 'bar' + 2; + var baz = 'baz' + 3; + + return foo + bar + baz; +} + +test(); +process.stdout.write(String(test()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/index-numeric-string.fix.js b/test/visitor/obfuscator/string-array/index-numeric-string.fix.js new file mode 100644 index 00000000..06f31a86 --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-numeric-string.fix.js @@ -0,0 +1,2 @@ +var test = "test"; +process["stdout"]["write"](String(test) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/index-numeric-string.js b/test/visitor/obfuscator/string-array/index-numeric-string.js new file mode 100644 index 00000000..df66f0a7 --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-numeric-string.js @@ -0,0 +1 @@ +var test=_0x1944('0x1dc');function _0x1944(_0x5f208e,_0x417777){var _0x19442b=_0x4177();return _0x1944=function(_0x14b5ea,_0x5487cb){_0x14b5ea=_0x14b5ea-0x1dc;var _0x33faf2=_0x19442b[_0x14b5ea];return _0x33faf2;},_0x1944(_0x5f208e,_0x417777);}function _0x4177(){var _0x34f6c4=['test','stdout','write'];_0x4177=function(){return _0x34f6c4;};return _0x4177();}process[_0x1944('0x1dd')][_0x1944('0x1de')](String(test)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/index-numeric-string.src.js b/test/visitor/obfuscator/string-array/index-numeric-string.src.js new file mode 100644 index 00000000..211ed019 --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-numeric-string.src.js @@ -0,0 +1,2 @@ +var test = 'test'; +process.stdout.write(String(test) + '\n'); diff --git a/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.fix.js b/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.fix.js new file mode 100644 index 00000000..60cd5d9b --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.fix.js @@ -0,0 +1,8 @@ +function test() { + var _0x1b0733 = "foo" + 0x1; + var _0x4e046f = "bar" + 0x2; + var _0x4c48e4 = "baz" + 0x3; + return _0x1b0733 + _0x4e046f + _0x4c48e4; +} +test(); +process["stdout"]["write"](String(test()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.js b/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.js new file mode 100644 index 00000000..fa503a9a --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.js @@ -0,0 +1 @@ +function _0x5893(_0x2d85ca,_0x132f17){var _0x2657f9=_0x2657();return _0x5893=function(_0x58933b,_0x136274){_0x58933b=_0x58933b-0x136;var _0x28b11c=_0x2657f9[_0x58933b];if(_0x5893['RIPzrD']===undefined){var _0x1a64f8=function(_0x1b0733){var _0x4e046f='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';var _0x4c48e4='',_0x45af75='';for(var _0x153730=0x0,_0x4a9a43,_0x2c7bff,_0x24ec37=0x0;_0x2c7bff=_0x1b0733['charAt'](_0x24ec37++);~_0x2c7bff&&(_0x4a9a43=_0x153730%0x4?_0x4a9a43*0x40+_0x2c7bff:_0x2c7bff,_0x153730++%0x4)?_0x4c48e4+=String['fromCharCode'](0xff&_0x4a9a43>>(-0x2*_0x153730&0x6)):0x0){_0x2c7bff=_0x4e046f['indexOf'](_0x2c7bff);}for(var _0x5819d6=0x0,_0x46ed59=_0x4c48e4['length'];_0x5819d6<_0x46ed59;_0x5819d6++){_0x45af75+='%'+('00'+_0x4c48e4['charCodeAt'](_0x5819d6)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x45af75);};_0x5893['gZwhWQ']=_0x1a64f8,_0x2d85ca=arguments,_0x5893['RIPzrD']=!![];}var _0x2c046a=_0x2657f9[0x0],_0x19f98a=_0x58933b+_0x2c046a,_0x4966b2=_0x2d85ca[_0x19f98a];return!_0x4966b2?(_0x28b11c=_0x5893['gZwhWQ'](_0x28b11c),_0x2d85ca[_0x19f98a]=_0x28b11c):_0x28b11c=_0x4966b2,_0x28b11c;},_0x5893(_0x2d85ca,_0x132f17);}(function(_0x20a4ed,_0xe41dfe){var _0x35dbc6=_0x20a4ed();while(!![]){try{var _0x1b9d73=-parseInt(_0x5893(0x144))/0x1+parseInt(_0x5893(0x137))/0x2+parseInt(_0x5893(0x143))/0x3*(-parseInt(_0x5893(0x13c))/0x4)+-parseInt(_0x5893(0x13f))/0x5*(parseInt(_0x5893(0x138))/0x6)+-parseInt(_0x5893(0x13d))/0x7*(-parseInt(_0x5893(0x142))/0x8)+parseInt(_0x5893(0x145))/0x9+parseInt(_0x5893(0x13a))/0xa*(-parseInt(_0x5893(0x140))/0xb);if(_0x1b9d73===_0xe41dfe)break;else _0x35dbc6['push'](_0x35dbc6['shift']());}catch(_0x413f29){_0x35dbc6['push'](_0x35dbc6['shift']());}}}(_0x2657,0xb8f6a));function test(){var _0x1b0733=_0x5893(0x141)+0x1,_0x4e046f=_0x5893(0x13e)+0x2,_0x4c48e4=_0x5893(0x136)+0x3;return _0x1b0733+_0x4e046f+_0x4c48e4;}test(),process[_0x5893(0x13b)][_0x5893(0x139)](String(test())+'\x0a');function _0x2657(){var _0x42af0e=['odK5mda1mwzfvNjfEG','yMfY','nJboDuzPuum','mZyZr0LyrKjb','zM9V','ohLkvNvtDG','ntrXENbgs3q','odqXmZaWtNPPrgXY','nJy0nJy1m1fzqNvfqq','yMf6','mtCXmdy0mLHLBNDWEG','mtCXmdy2zujhDwnR','D3jPDgu','mJuZmteWrufYtLHr','C3rKB3v0','mJi2mJH6A0LICuW'];_0x2657=function(){return _0x42af0e;};return _0x2657();} \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.src.js b/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.src.js new file mode 100644 index 00000000..8ab5948b --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-shift-rotate-shuffle.src.js @@ -0,0 +1,10 @@ +function test () { + var foo = 'foo' + 1; + var bar = 'bar' + 2; + var baz = 'baz' + 3; + + return foo + bar + baz; +} + +test(); +process.stdout.write(String(test()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/index-shift.fix.js b/test/visitor/obfuscator/string-array/index-shift.fix.js new file mode 100644 index 00000000..1a16acea --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-shift.fix.js @@ -0,0 +1,8 @@ +function test() { + var _0x4966b2 = "foo" + 0x1; + var _0x1b0733 = "bar" + 0x2; + var _0x4e046f = "baz" + 0x3; + return _0x4966b2 + _0x1b0733 + _0x4e046f; +} +test(); +process["stdout"]["write"](String(test()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/index-shift.js b/test/visitor/obfuscator/string-array/index-shift.js new file mode 100644 index 00000000..a415c0e5 --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-shift.js @@ -0,0 +1 @@ +function test(){var _0x4966b2=_0x2657(0x136)+0x1,_0x1b0733=_0x2657(0x137)+0x2,_0x4e046f=_0x2657(0x138)+0x3;return _0x4966b2+_0x1b0733+_0x4e046f;}function _0x2657(_0x2d85ca,_0x132f17){var _0x2657f9=_0x132f();return _0x2657=function(_0x58933b,_0x136274){_0x58933b=_0x58933b-0x136;var _0x28b11c=_0x2657f9[_0x58933b];return _0x28b11c;},_0x2657(_0x2d85ca,_0x132f17);}function _0x132f(){var _0x42cc5c=['foo','bar','baz','stdout','write'];_0x132f=function(){return _0x42cc5c;};return _0x132f();}test(),process[_0x2657(0x139)][_0x2657(0x13a)](String(test())+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/index-shift.src.js b/test/visitor/obfuscator/string-array/index-shift.src.js new file mode 100644 index 00000000..8ab5948b --- /dev/null +++ b/test/visitor/obfuscator/string-array/index-shift.src.js @@ -0,0 +1,10 @@ +function test () { + var foo = 'foo' + 1; + var bar = 'bar' + 2; + var baz = 'baz' + 3; + + return foo + bar + baz; +} + +test(); +process.stdout.write(String(test()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/nested-one-layer.fix.js b/test/visitor/obfuscator/string-array/nested-one-layer.fix.js new file mode 100644 index 00000000..8f178ee4 --- /dev/null +++ b/test/visitor/obfuscator/string-array/nested-one-layer.fix.js @@ -0,0 +1,34 @@ +function _0x571f(_0x4d0fad, _0x3fec5f) { + var _0xe568f2 = _0x3605(); + _0x571f = function (_0x520afe, _0x1c224c) { + _0x520afe = _0x520afe - 0xdd; + var _0x2b3790 = _0xe568f2[_0x520afe]; + return _0x2b3790; + }; + return _0x571f(_0x4d0fad, _0x3fec5f); +} +(function (_0x282af, _0x231ad3) { + var _0x1a8ed4 = _0x282af(); + while (!![]) { + try { + var _0x34baae = parseInt(_0x571f(0xdd)) / 0x1 * (-parseInt(_0x571f(0xde)) / 0x2) + -parseInt(_0x571f(0xdf)) / 0x3 * (-parseInt(_0x571f(0xe0)) / 0x4) + parseInt(_0x571f(0xe1)) / 0x5 * (parseInt(_0x571f(0xe2)) / 0x6) + -parseInt(_0x571f(0xe3)) / 0x7 + -parseInt(_0x571f(0xe4)) / 0x8 + parseInt(_0x571f(0xe5)) / 0x9 * (-parseInt(_0x571f(0xe6)) / 0xa) + parseInt(_0x571f(0xe7)) / 0xb * (parseInt(_0x571f(0xe8)) / 0xc); + if (_0x34baae === _0x231ad3) { + break; + } else { + _0x1a8ed4["push"](_0x1a8ed4["shift"]()); + } + } catch (_0x3f65b9) { + _0x1a8ed4["push"](_0x1a8ed4["shift"]()); + } + } +})(_0x3605, 0xb2332); +function _0x3605() { + var _0x5601ee = ["hello world", "nested layers", "stdout", "write", " / ", "1535FMwCWh", "1518lbnNNN", "3153bdmAnl", "2884ZUjkBE", "109995kjkQBc", "138xPDVlq", "9852633NQFATz", "904624TxVcqa", "21033piYpJq", "3010EaNiRy", "11ZeRtDR", "34263084kXjhGm"]; + _0x3605 = function () { + return _0x5601ee; + }; + return _0x3605(); +} +var greeting = _0x571f(0xe9); +var target = _0x571f(0xea); +process[_0x571f(0xeb)][_0x571f(0xec)](String(greeting + _0x571f(0xed) + target) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/nested-one-layer.js b/test/visitor/obfuscator/string-array/nested-one-layer.js new file mode 100644 index 00000000..88784347 --- /dev/null +++ b/test/visitor/obfuscator/string-array/nested-one-layer.js @@ -0,0 +1 @@ +(function(_0x171e4f,_0x1801e7){var _0x3301ed=_0x171e4f();while(!![]){try{var _0x452224=parseInt(_0x22dc(0x10d))/0x1+-parseInt(_0x22dc(0x10e))/0x2*(-parseInt(_0x22dc(0x10f))/0x3)+parseInt(_0x22dc(0x110))/0x4+parseInt(_0x22dc(0x111))/0x5*(-parseInt(_0x22dc(0x112))/0x6)+parseInt(_0x22dc(0x113))/0x7*(-parseInt(_0x22dc(0x114))/0x8)+parseInt(_0x22dc(0x115))/0x9*(parseInt(_0x22dc(0x116))/0xa)+-parseInt(_0x22dc(0x117))/0xb;if(_0x452224===_0x1801e7)break;else _0x3301ed['push'](_0x3301ed['shift']());}catch(_0x1dd516){_0x3301ed['push'](_0x3301ed['shift']());}}}(_0x99e7,0xd01f1));function _0x22dc(_0x3efd7e,_0x38a6ed){var _0x99e7e7=_0x99e7();return _0x22dc=function(_0x22dc31,_0x36bd22){_0x22dc31=_0x22dc31-0x10d;var _0x214d66=_0x99e7e7[_0x22dc31];return _0x214d66;},_0x22dc(_0x3efd7e,_0x38a6ed);}function _0x571f(_0x4d0fad,_0x3fec5f){var _0xe568f2=_0x3605();return _0x571f=function(_0x520afe,_0x1c224c){_0x520afe=_0x520afe-0xdd;var _0x2b3790=_0xe568f2[_0x520afe];return _0x2b3790;},_0x571f(_0x4d0fad,_0x3fec5f);}(function(_0x282af,_0x231ad3){var _0x1a8ed4=_0x282af();while(!![]){try{var _0x34baae=parseInt(_0x571f(0xdd))/0x1*(-parseInt(_0x571f(0xde))/0x2)+-parseInt(_0x571f(0xdf))/0x3*(-parseInt(_0x571f(0xe0))/0x4)+parseInt(_0x571f(0xe1))/0x5*(parseInt(_0x571f(0xe2))/0x6)+-parseInt(_0x571f(0xe3))/0x7+-parseInt(_0x571f(0xe4))/0x8+parseInt(_0x571f(0xe5))/0x9*(-parseInt(_0x571f(0xe6))/0xa)+parseInt(_0x571f(0xe7))/0xb*(parseInt(_0x571f(0xe8))/0xc);if(_0x34baae===_0x231ad3)break;else _0x1a8ed4[_0x22dc(0x118)](_0x1a8ed4[_0x22dc(0x119)]());}catch(_0x3f65b9){_0x1a8ed4[_0x22dc(0x118)](_0x1a8ed4[_0x22dc(0x119)]());}}}(_0x3605,0xb2332));function _0x3605(){var _0x5601ee=[_0x22dc(0x11a),_0x22dc(0x11b),_0x22dc(0x11c),_0x22dc(0x11d),_0x22dc(0x11e),_0x22dc(0x11f),_0x22dc(0x120),_0x22dc(0x121),_0x22dc(0x122),_0x22dc(0x123),_0x22dc(0x124),_0x22dc(0x125),_0x22dc(0x126),_0x22dc(0x127),_0x22dc(0x128),_0x22dc(0x129),_0x22dc(0x12a)];return _0x3605=function(){return _0x5601ee;},_0x3605();}function _0x99e7(){var _0x2326b7=['stdout','write','\x20/\x20','1535FMwCWh','1518lbnNNN','3153bdmAnl','2884ZUjkBE','109995kjkQBc','138xPDVlq','9852633NQFATz','904624TxVcqa','21033piYpJq','3010EaNiRy','11ZeRtDR','34263084kXjhGm','351971GRGqkY','554WtnjXX','15558DQVGsr','4885124UYxddM','126305sDIrLJ','114NSxldk','4053ixwLlE','7120FqjxZL','13939326rGUbNs','10swukTM','29819394OzQcJV','push','shift','hello\x20world','nested\x20layers'];_0x99e7=function(){return _0x2326b7;};return _0x99e7();}var greeting=_0x571f(0xe9),target=_0x571f(0xea);process[_0x571f(0xeb)][_0x571f(0xec)](String(greeting+_0x571f(0xed)+target)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/nested-one-layer.src.js b/test/visitor/obfuscator/string-array/nested-one-layer.src.js new file mode 100644 index 00000000..b0f7e574 --- /dev/null +++ b/test/visitor/obfuscator/string-array/nested-one-layer.src.js @@ -0,0 +1,3 @@ +var greeting = 'hello world'; +var target = 'nested layers'; +process.stdout.write(String(greeting + ' / ' + target) + '\n'); diff --git a/test/visitor/obfuscator/string-array/object-computed-key.fix.js b/test/visitor/obfuscator/string-array/object-computed-key.fix.js new file mode 100644 index 00000000..968e9015 --- /dev/null +++ b/test/visitor/obfuscator/string-array/object-computed-key.fix.js @@ -0,0 +1,4 @@ +var test = { + ["foo"]: "barbaz" +}; +process["stdout"]["write"](String(test["foo"]) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/object-computed-key.js b/test/visitor/obfuscator/string-array/object-computed-key.js new file mode 100644 index 00000000..17b7e168 --- /dev/null +++ b/test/visitor/obfuscator/string-array/object-computed-key.js @@ -0,0 +1 @@ +var test={[_0x205a(0x8d)]:_0x205a(0x8e)};function _0x205a(_0x488832,_0x19b56e){var _0x205a4e=_0x19b5();return _0x205a=function(_0x408db9,_0x3605d4){_0x408db9=_0x408db9-0x8d;var _0x19e5b7=_0x205a4e[_0x408db9];return _0x19e5b7;},_0x205a(_0x488832,_0x19b56e);}process[_0x205a(0x8f)][_0x205a(0x90)](String(test[_0x205a(0x8d)])+'\x0a');function _0x19b5(){var _0x19d089=['foo','barbaz','stdout','write'];_0x19b5=function(){return _0x19d089;};return _0x19b5();} \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/object-computed-key.src.js b/test/visitor/obfuscator/string-array/object-computed-key.src.js new file mode 100644 index 00000000..c7ea8422 --- /dev/null +++ b/test/visitor/obfuscator/string-array/object-computed-key.src.js @@ -0,0 +1,2 @@ +var test = {['foo']: 'barbaz'}; +process.stdout.write(String(test['foo']) + '\n'); diff --git a/test/visitor/obfuscator/string-array/rotate-search.fix.js b/test/visitor/obfuscator/string-array/rotate-search.fix.js new file mode 100644 index 00000000..efc61e35 --- /dev/null +++ b/test/visitor/obfuscator/string-array/rotate-search.fix.js @@ -0,0 +1,21 @@ +var s0 = "item_0_value"; +var s1 = "item_1_value"; +var s2 = "item_2_value"; +var s3 = "item_3_value"; +var s4 = "item_4_value"; +var s5 = "item_5_value"; +var s6 = "item_6_value"; +var s7 = "item_7_value"; +var s8 = "item_8_value"; +var s9 = "item_9_value"; +var s10 = "item_10_value"; +var s11 = "item_11_value"; +var s12 = "item_12_value"; +var s13 = "item_13_value"; +var s14 = "item_14_value"; +var s15 = "item_15_value"; +var s16 = "item_16_value"; +var s17 = "item_17_value"; +var s18 = "item_18_value"; +var s19 = "item_19_value"; +process["stdout"]["write"](String(s0 + ',' + s1 + ',' + s2 + ',' + s3 + ',' + s4 + ',' + s5 + ',' + s6 + ',' + s7 + ',' + s8 + ',' + s9 + ',' + s10 + ',' + s11 + ',' + s12 + ',' + s13 + ',' + s14 + ',' + s15 + ',' + s16 + ',' + s17 + ',' + s18 + ',' + s19) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/rotate-search.js b/test/visitor/obfuscator/string-array/rotate-search.js new file mode 100644 index 00000000..b5d023f0 --- /dev/null +++ b/test/visitor/obfuscator/string-array/rotate-search.js @@ -0,0 +1 @@ +function _0x1dd6(_0x135e9f,_0x1e9be){var _0x5ad0b7=_0x5ad0();return _0x1dd6=function(_0x1dd622,_0x1a0363){_0x1dd622=_0x1dd622-0x1b0;var _0x5ce6da=_0x5ad0b7[_0x1dd622];return _0x5ce6da;},_0x1dd6(_0x135e9f,_0x1e9be);}(function(_0x2060f6,_0x63ed00){var _0x4ac616=_0x2060f6();while(!![]){try{var _0x18c136=parseInt(_0x1dd6(0x1b0))/0x1+-parseInt(_0x1dd6(0x1b1))/0x2+parseInt(_0x1dd6(0x1b2))/0x3+-parseInt(_0x1dd6(0x1b3))/0x4*(-parseInt(_0x1dd6(0x1b4))/0x5)+-parseInt(_0x1dd6(0x1b5))/0x6+-parseInt(_0x1dd6(0x1b6))/0x7+parseInt(_0x1dd6(0x1b7))/0x8;if(_0x18c136===_0x63ed00)break;else _0x4ac616['push'](_0x4ac616['shift']());}catch(_0x197d93){_0x4ac616['push'](_0x4ac616['shift']());}}}(_0x5ad0,0x5c3d3));var s0=_0x1dd6(0x1b8),s1=_0x1dd6(0x1b9),s2=_0x1dd6(0x1ba),s3=_0x1dd6(0x1bb),s4=_0x1dd6(0x1bc),s5=_0x1dd6(0x1bd),s6=_0x1dd6(0x1be),s7=_0x1dd6(0x1bf),s8=_0x1dd6(0x1c0),s9=_0x1dd6(0x1c1),s10=_0x1dd6(0x1c2),s11=_0x1dd6(0x1c3),s12=_0x1dd6(0x1c4),s13=_0x1dd6(0x1c5),s14=_0x1dd6(0x1c6),s15=_0x1dd6(0x1c7),s16=_0x1dd6(0x1c8),s17=_0x1dd6(0x1c9),s18=_0x1dd6(0x1ca),s19=_0x1dd6(0x1cb);function _0x5ad0(){var _0x50ef5b=['item_12_value','item_13_value','item_14_value','item_15_value','item_16_value','item_17_value','item_18_value','item_19_value','stdout','write','63128CwHiDo','1214256aAXImL','1432218LuVCBt','8dZthCn','1530380bGnTUz','3865434HXNwEQ','947856YMEtCP','4895200NFbEkJ','item_0_value','item_1_value','item_2_value','item_3_value','item_4_value','item_5_value','item_6_value','item_7_value','item_8_value','item_9_value','item_10_value','item_11_value'];_0x5ad0=function(){return _0x50ef5b;};return _0x5ad0();}process[_0x1dd6(0x1cc)][_0x1dd6(0x1cd)](String(s0+','+s1+','+s2+','+s3+','+s4+','+s5+','+s6+','+s7+','+s8+','+s9+','+s10+','+s11+','+s12+','+s13+','+s14+','+s15+','+s16+','+s17+','+s18+','+s19)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/rotate-search.src.js b/test/visitor/obfuscator/string-array/rotate-search.src.js new file mode 100644 index 00000000..51bbee5e --- /dev/null +++ b/test/visitor/obfuscator/string-array/rotate-search.src.js @@ -0,0 +1,21 @@ +var s0 = 'item_0_value'; +var s1 = 'item_1_value'; +var s2 = 'item_2_value'; +var s3 = 'item_3_value'; +var s4 = 'item_4_value'; +var s5 = 'item_5_value'; +var s6 = 'item_6_value'; +var s7 = 'item_7_value'; +var s8 = 'item_8_value'; +var s9 = 'item_9_value'; +var s10 = 'item_10_value'; +var s11 = 'item_11_value'; +var s12 = 'item_12_value'; +var s13 = 'item_13_value'; +var s14 = 'item_14_value'; +var s15 = 'item_15_value'; +var s16 = 'item_16_value'; +var s17 = 'item_17_value'; +var s18 = 'item_18_value'; +var s19 = 'item_19_value'; +process.stdout.write(String(s0 + ',' + s1 + ',' + s2 + ',' + s3 + ',' + s4 + ',' + s5 + ',' + s6 + ',' + s7 + ',' + s8 + ',' + s9 + ',' + s10 + ',' + s11 + ',' + s12 + ',' + s13 + ',' + s14 + ',' + s15 + ',' + s16 + ',' + s17 + ',' + s18 + ',' + s19) + '\n'); diff --git a/test/visitor/obfuscator/string-array/same-literal-values.fix.js b/test/visitor/obfuscator/string-array/same-literal-values.fix.js new file mode 100644 index 00000000..42237a1b --- /dev/null +++ b/test/visitor/obfuscator/string-array/same-literal-values.fix.js @@ -0,0 +1,3 @@ +var a = "test"; +var b = "test"; +process["stdout"]["write"](String(a + b) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/same-literal-values.js b/test/visitor/obfuscator/string-array/same-literal-values.js new file mode 100644 index 00000000..3618d71f --- /dev/null +++ b/test/visitor/obfuscator/string-array/same-literal-values.js @@ -0,0 +1 @@ +function _0x57f0(_0xdbdcbd,_0x4d4d65){var _0x57f0d8=_0x4d4d();return _0x57f0=function(_0x49df54,_0xd9f02d){_0x49df54=_0x49df54-0x78;var _0x28963c=_0x57f0d8[_0x49df54];return _0x28963c;},_0x57f0(_0xdbdcbd,_0x4d4d65);}var a=_0x57f0(0x78),b=_0x57f0(0x78);function _0x4d4d(){var _0x17db97=['test','stdout','write'];_0x4d4d=function(){return _0x17db97;};return _0x4d4d();}process[_0x57f0(0x79)][_0x57f0(0x7a)](String(a+b)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/same-literal-values.src.js b/test/visitor/obfuscator/string-array/same-literal-values.src.js new file mode 100644 index 00000000..d79a17b4 --- /dev/null +++ b/test/visitor/obfuscator/string-array/same-literal-values.src.js @@ -0,0 +1,3 @@ +var a = 'test'; +var b = 'test'; +process.stdout.write(String(a + b) + '\n'); diff --git a/test/visitor/obfuscator/string-array/scope-chained-deep.fix.js b/test/visitor/obfuscator/string-array/scope-chained-deep.fix.js new file mode 100644 index 00000000..eb811298 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-chained-deep.fix.js @@ -0,0 +1,19 @@ +const foo = "aaa"; +function test(_0x299e24, _0x74b9e9) { + const _0x2da1ee = "bbb"; + function _0x446608(_0x2235b2, _0x530d80) { + const _0x5b507e = "ccc"; + function _0x569071(_0x42bfb3, _0xbdddae) { + const _0x28c730 = "ddd"; + return _0x28c730; + } + return _0x5b507e + _0x569071(); + } + return _0x2da1ee + _0x446608(); +} +function test3(_0x54ebd2, _0x3019ca) { + const _0x379567 = "eee"; + return _0x379567; +} +foo + test() + test3(); +process["stdout"]["write"](String(foo + test() + test3()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-chained-deep.js b/test/visitor/obfuscator/string-array/scope-chained-deep.js new file mode 100644 index 00000000..c4805e05 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-chained-deep.js @@ -0,0 +1 @@ +function _0x29d3(){const _0x1142a4=['aaa','bbb','ccc','ddd','eee','stdout','write'];_0x29d3=function(){return _0x1142a4;};return _0x29d3();}const foo=_0x588d31(0x28c,0x28d);function _0x2a2725(_0x3c93dd,_0x1daa3b){return _0x1018(_0x1daa3b-0x332,_0x3c93dd);}function _0x588d31(_0x368078,_0x1603c2){return _0x1018(_0x1603c2-0x196,_0x368078);}function test(_0x299e24,_0x74b9e9){const _0x2da1ee=_0x4a4d6a(-0x29c,-0x29e);function _0x446608(_0x2235b2,_0x530d80){function _0x49bd6f(_0x1d48d4,_0x1785d2){return _0x4a4d6a(_0x1d48d4-0x325,_0x1785d2);}const _0x5b507e=_0x49bd6f(0x8a,0x86);function _0x569071(_0x42bfb3,_0xbdddae){const _0x28c730=_0xe202bc(0x4a0,0x49f);function _0xe202bc(_0x19752c,_0x10c859){return _0x49bd6f(_0x19752c-0x415,_0x10c859);}return _0x28c730;}return _0x5b507e+_0x569071();}function _0x4a4d6a(_0x25e3d0,_0x584d35){return _0x588d31(_0x584d35,_0x25e3d0- -0x52a);}return _0x2da1ee+_0x446608();}function _0x5accb9(_0x206d2f,_0x21cb51){return _0x1018(_0x21cb51-0x133,_0x206d2f);}function test3(_0x54ebd2,_0x3019ca){const _0x379567=_0x104c44(-0x27e,-0x27b);function _0x104c44(_0x689fbf,_0x302364){return _0x588d31(_0x302364,_0x689fbf- -0x50f);}return _0x379567;}function _0x1018(_0x3639ad,_0x29d345){const _0x10188d=_0x29d3();return _0x1018=function(_0x65e9d9,_0x1c4e68){_0x65e9d9=_0x65e9d9-0xf7;let _0x3c270c=_0x10188d[_0x65e9d9];return _0x3c270c;},_0x1018(_0x3639ad,_0x29d345);}foo+test()+test3(),process[_0x588d31(0x290,0x292)][_0x588d31(0x296,0x293)](String(foo+test()+test3())+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-chained-deep.src.js b/test/visitor/obfuscator/string-array/scope-chained-deep.src.js new file mode 100644 index 00000000..1a654081 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-chained-deep.src.js @@ -0,0 +1,28 @@ +const foo = 'aaa'; + +function test (a, b) { + const bar = 'bbb'; + + function test1 (a, b) { + const baz = 'ccc'; + + function test2 (a, b) { + const bark = 'ddd'; + + return bark; + } + + return baz + test2(); + } + + return bar + test1(); +} + +function test3 (a, b) { + const hawk = 'eee'; + + return hawk; +} + +foo + test() + test3(); +process.stdout.write(String(foo + test() + test3()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/scope-chained-mangled.fix.js b/test/visitor/obfuscator/string-array/scope-chained-mangled.fix.js new file mode 100644 index 00000000..686376a0 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-chained-mangled.fix.js @@ -0,0 +1,18 @@ +const foo = "aaa"; +function test(c, d) { + const e = "bbb"; + const f = "ccc"; + function g(h, i) { + const j = "ddd"; + const k = "eee"; + function l(m, n) { + const o = "ddd"; + const p = "eee"; + return o + p; + } + return j + k; + } + return e + f + g(); +} +foo + test(); +process["stdout"]["write"](String(foo + test()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-chained-mangled.js b/test/visitor/obfuscator/string-array/scope-chained-mangled.js new file mode 100644 index 00000000..531e5049 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-chained-mangled.js @@ -0,0 +1 @@ +function a(){const z=['aaa','bbb','ccc','ddd','eee','stdout','write'];a=function(){return z;};return a();}function b(c,d){const e=a();return b=function(f,g){f=f-0xcc;let h=e[f];return h;},b(c,d);}function x(c,d){return b(d-0xd2,c);}const foo=q(-0x239,-0x235);function y(c,d){return b(c-0xed,d);}function q(c,d){return b(c- -0x305,d);}function test(c,d){function s(c,d){return q(c- -0x9a,d);}function r(c,d){return q(c-0x20a,d);}const e=r(-0x2e,-0x30),f=r(-0x2d,-0x2d);function g(h,i){const j=t(0x21f,0x221);function u(c,d){return r(c- -0x4f,d);}const k=t(0x220,0x222);function t(c,d){return s(d-0x4f1,c);}function l(m,n){const o=v(0x133,0x133),p=v(0x134,0x138);function w(c,d){return t(c,d- -0x48e);}function v(c,d){return t(d,c- -0xee);}return o+p;}return j+k;}return e+f+g();}foo+test(),process[q(-0x234,-0x237)][q(-0x233,-0x231)](String(foo+test())+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-chained-mangled.src.js b/test/visitor/obfuscator/string-array/scope-chained-mangled.src.js new file mode 100644 index 00000000..a28b1a0c --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-chained-mangled.src.js @@ -0,0 +1,25 @@ +const foo = 'aaa'; + +function test (a, b) { + const bar = 'bbb'; + const baz = 'ccc'; + + function test1 (a, b) { + const bark = 'ddd'; + const hawk = 'eee'; + + function test2 (a, b) { + const bark = 'ddd'; + const hawk = 'eee'; + + return bark + hawk; + } + + return bark + hawk; + } + + return bar + baz + test1(); +} + +foo + test(); +process.stdout.write(String(foo + test()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/scope-default-parameter.fix.js b/test/visitor/obfuscator/string-array/scope-default-parameter.fix.js new file mode 100644 index 00000000..ad468097 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-default-parameter.fix.js @@ -0,0 +1,5 @@ +const foo = "foo"; +function test(_0x107f8b = "bar") { + const _0x5699f9 = "baz"; +} +process["stdout"]["write"](String(foo + test()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-default-parameter.js b/test/visitor/obfuscator/string-array/scope-default-parameter.js new file mode 100644 index 00000000..afda3db0 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-default-parameter.js @@ -0,0 +1 @@ +function _0x11ae(_0x344959,_0x284a3e){const _0x11aeb9=_0x284a();return _0x11ae=function(_0x168dc8,_0x3dd62b){_0x168dc8=_0x168dc8-0x6d;let _0x1db213=_0x11aeb9[_0x168dc8];return _0x1db213;},_0x11ae(_0x344959,_0x284a3e);}function _0x49d114(_0x39b493,_0x1d47d7){return _0x11ae(_0x1d47d7- -0x315,_0x39b493);}const foo=_0x25812c(0x33c,0x33d);function _0x25812c(_0x2a9c45,_0x56620f){return _0x11ae(_0x56620f-0x2d0,_0x2a9c45);}function _0x284a(){const _0x14fbdc=['foo','bar','baz','stdout','write'];_0x284a=function(){return _0x14fbdc;};return _0x284a();}function test(_0x107f8b=_0x49d114(-0x2a5,-0x2a7)){function _0x4990cc(_0x31f082,_0x195cf1){return _0x49d114(_0x195cf1,_0x31f082-0x6cb);}const _0x5699f9=_0x4990cc(0x425,0x427);}process[_0x49d114(-0x2a4,-0x2a5)][_0x25812c(0x33e,0x341)](String(foo+test())+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-default-parameter.src.js b/test/visitor/obfuscator/string-array/scope-default-parameter.src.js new file mode 100644 index 00000000..d5cea816 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-default-parameter.src.js @@ -0,0 +1,7 @@ +const foo = 'foo' + +function test (bar = 'bar') { + const baz = 'baz' +} + +process.stdout.write(String(foo + test()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/scope-no-root-wrappers.fix.js b/test/visitor/obfuscator/string-array/scope-no-root-wrappers.fix.js new file mode 100644 index 00000000..dfdc3a09 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-no-root-wrappers.fix.js @@ -0,0 +1,6 @@ +function test() { + const c = "foo"; + const d = "bar"; + const e = "baz"; +} +process["stdout"]["write"](String(test()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-no-root-wrappers.js b/test/visitor/obfuscator/string-array/scope-no-root-wrappers.js new file mode 100644 index 00000000..61f90a0e --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-no-root-wrappers.js @@ -0,0 +1 @@ +function b(c,d){const e=a();return b=function(f,g){f=f-0xd0;let h=e[f];return h;},b(c,d);}function g(c,d){return b(c- -0x198,d);}function a(){const h=['foo','bar','baz','stdout','write'];a=function(){return h;};return a();}function test(){const c=f(0x3a,0x38),d=f(0x3b,0x3b);function f(c,d){return b(c- -0x96,d);}const e=f(0x3c,0x3a);}process[g(-0xc5,-0xc7)][g(-0xc4,-0xc5)](String(test())+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-no-root-wrappers.src.js b/test/visitor/obfuscator/string-array/scope-no-root-wrappers.src.js new file mode 100644 index 00000000..bc4ffd0e --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-no-root-wrappers.src.js @@ -0,0 +1,7 @@ +function test () { + const foo = 'foo' + const bar = 'bar'; + const baz = 'baz'; +} + +process.stdout.write(String(test()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/scope-numeric-string-offset.fix.js b/test/visitor/obfuscator/string-array/scope-numeric-string-offset.fix.js new file mode 100644 index 00000000..6e380f66 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-numeric-string-offset.fix.js @@ -0,0 +1,9 @@ +const foo = "foo"; +const bar = "bar"; +const baz = "baz"; +function test() { + const _0x577b7f = "bark"; + const _0x5b2d6c = "hawk"; + const _0x4668da = "eagle"; +} +process["stdout"]["write"](String(foo + test()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-numeric-string-offset.js b/test/visitor/obfuscator/string-array/scope-numeric-string-offset.js new file mode 100644 index 00000000..e24f6676 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-numeric-string-offset.js @@ -0,0 +1 @@ +const foo=_0x18a0e3(-'0xd7',-'0xd4'),bar=_0x18a0e3(-'0xd6',-'0xd5'),baz=_0x3c157c('0x12a','0x126');function _0x3c157c(_0x5eae73,_0x1537ea){return _0x4136(_0x5eae73-'0xae',_0x1537ea);}function _0x4136(_0x56b826,_0x3105d1){const _0x4136a4=_0x3105();return _0x4136=function(_0x1a93bf,_0x36e495){_0x1a93bf=_0x1a93bf-0x7a;let _0x453a7e=_0x4136a4[_0x1a93bf];return _0x453a7e;},_0x4136(_0x56b826,_0x3105d1);}function test(){const _0x577b7f=_0x392f01(-'0x14e',-'0x152');function _0x392f01(_0xc061b7,_0x50d477){return _0x18a0e3(_0x50d477- -'0x7e',_0xc061b7);}function _0x5c8dff(_0x3df275,_0x4265c4){return _0x18a0e3(_0x3df275-'0x4c8',_0x4265c4);}const _0x5b2d6c=_0x5c8dff('0x3f5','0x3f7'),_0x4668da=_0x392f01(-'0x153',-'0x150');}function _0x18a0e3(_0x293d71,_0x5bef59){return _0x4136(_0x293d71- -'0x151',_0x5bef59);}process[_0x18a0e3(-'0xd1',-'0xd4')][_0x18a0e3(-'0xd0',-'0xd0')](String(foo+test())+'\x0a');function _0x3105(){const _0x254647=['foo','bar','baz','bark','hawk','eagle','stdout','write'];_0x3105=function(){return _0x254647;};return _0x3105();} \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-numeric-string-offset.src.js b/test/visitor/obfuscator/string-array/scope-numeric-string-offset.src.js new file mode 100644 index 00000000..34fcf850 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-numeric-string-offset.src.js @@ -0,0 +1,11 @@ +const foo = 'foo' +const bar = 'bar'; +const baz = 'baz'; + +function test () { + const bark = 'bark' + const hawk = 'hawk'; + const eagle = 'eagle'; +} + +process.stdout.write(String(foo + test()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/scope-prevailing-const.fix.js b/test/visitor/obfuscator/string-array/scope-prevailing-const.fix.js new file mode 100644 index 00000000..6e380f66 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-prevailing-const.fix.js @@ -0,0 +1,9 @@ +const foo = "foo"; +const bar = "bar"; +const baz = "baz"; +function test() { + const _0x577b7f = "bark"; + const _0x5b2d6c = "hawk"; + const _0x4668da = "eagle"; +} +process["stdout"]["write"](String(foo + test()) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-prevailing-const.js b/test/visitor/obfuscator/string-array/scope-prevailing-const.js new file mode 100644 index 00000000..8c8c0023 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-prevailing-const.js @@ -0,0 +1 @@ +const _0x18a0e3=_0x4136,_0x45660e=_0x4136,foo=_0x18a0e3(0x7a),bar=_0x45660e(0x7b),baz=_0x18a0e3(0x7c);function _0x4136(_0x56b826,_0x3105d1){const _0x4136a4=_0x3105();return _0x4136=function(_0x1a93bf,_0x36e495){_0x1a93bf=_0x1a93bf-0x7a;let _0x453a7e=_0x4136a4[_0x1a93bf];return _0x453a7e;},_0x4136(_0x56b826,_0x3105d1);}function test(){const _0x4f9e95=_0x18a0e3,_0x5183e3=_0x45660e,_0x577b7f=_0x4f9e95(0x7d),_0x5b2d6c=_0x5183e3(0x7e),_0x4668da=_0x5183e3(0x7f);}function _0x3105(){const _0x47feb9=['foo','bar','baz','bark','hawk','eagle','stdout','write'];_0x3105=function(){return _0x47feb9;};return _0x3105();}process[_0x45660e(0x80)][_0x45660e(0x81)](String(foo+test())+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-prevailing-const.src.js b/test/visitor/obfuscator/string-array/scope-prevailing-const.src.js new file mode 100644 index 00000000..34fcf850 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-prevailing-const.src.js @@ -0,0 +1,11 @@ +const foo = 'foo' +const bar = 'bar'; +const baz = 'baz'; + +function test () { + const bark = 'bark' + const hawk = 'hawk'; + const eagle = 'eagle'; +} + +process.stdout.write(String(foo + test()) + '\n'); diff --git a/test/visitor/obfuscator/string-array/scope-prohibited-if.fix.js b/test/visitor/obfuscator/string-array/scope-prohibited-if.fix.js new file mode 100644 index 00000000..f0514835 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-prohibited-if.fix.js @@ -0,0 +1,4 @@ +if (!![]) { + var foo = "foo"; +} +process["stdout"]["write"](String(foo) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-prohibited-if.js b/test/visitor/obfuscator/string-array/scope-prohibited-if.js new file mode 100644 index 00000000..ab63fc01 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-prohibited-if.js @@ -0,0 +1 @@ +function _0x16484b(_0x3d2f35,_0x218736){return _0x3206(_0x218736-0x30d,_0x3d2f35);}function _0x483b55(_0x3e8aa0,_0x3c8c75){return _0x3206(_0x3e8aa0- -0x253,_0x3c8c75);}function _0x3206(_0x326bb2,_0x52890f){var _0x3206ef=_0x5289();return _0x3206=function(_0x17f04c,_0x4f075b){_0x17f04c=_0x17f04c-0x130;var _0x59e678=_0x3206ef[_0x17f04c];return _0x59e678;},_0x3206(_0x326bb2,_0x52890f);}function _0x5289(){var _0x41622a=['foo','stdout','write'];_0x5289=function(){return _0x41622a;};return _0x5289();}if(!![])var foo=_0x483b55(-0x123,-0x124);process[_0x483b55(-0x122,-0x122)][_0x16484b(0x440,0x43f)](String(foo)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/scope-prohibited-if.src.js b/test/visitor/obfuscator/string-array/scope-prohibited-if.src.js new file mode 100644 index 00000000..b1acaec4 --- /dev/null +++ b/test/visitor/obfuscator/string-array/scope-prohibited-if.src.js @@ -0,0 +1,4 @@ +if (true) { + var foo = 'foo'; +} +process.stdout.write(String(foo) + '\n'); diff --git a/test/visitor/obfuscator/string-array/short-literal-value.js b/test/visitor/obfuscator/string-array/short-literal-value.js new file mode 100644 index 00000000..524379e1 --- /dev/null +++ b/test/visitor/obfuscator/string-array/short-literal-value.js @@ -0,0 +1 @@ +var test='te'; \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/short-literal-value.src.js b/test/visitor/obfuscator/string-array/short-literal-value.src.js new file mode 100644 index 00000000..7abc01ad --- /dev/null +++ b/test/visitor/obfuscator/string-array/short-literal-value.src.js @@ -0,0 +1 @@ +var test = 'te'; diff --git a/test/visitor/obfuscator/string-array/string-array-off.js b/test/visitor/obfuscator/string-array/string-array-off.js new file mode 100644 index 00000000..7ed81c7e --- /dev/null +++ b/test/visitor/obfuscator/string-array/string-array-off.js @@ -0,0 +1 @@ +var test='test';process['stdout']['write'](String(test)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/string-array-off.src.js b/test/visitor/obfuscator/string-array/string-array-off.src.js new file mode 100644 index 00000000..211ed019 --- /dev/null +++ b/test/visitor/obfuscator/string-array/string-array-off.src.js @@ -0,0 +1,2 @@ +var test = 'test'; +process.stdout.write(String(test) + '\n'); diff --git a/test/visitor/obfuscator/string-array/wrappers-function.fix.js b/test/visitor/obfuscator/string-array/wrappers-function.fix.js new file mode 100644 index 00000000..2f8d4d7a --- /dev/null +++ b/test/visitor/obfuscator/string-array/wrappers-function.fix.js @@ -0,0 +1,9 @@ +var foo = "foo"; +var bar = "bar"; +var baz = "baz"; +function test() { + var _0x1b2a2f = "bark"; + var _0x3606cc = "hawk"; + var _0x3ab71b = "eagle"; +} +process["stdout"]["write"](String(foo + bar + baz) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/wrappers-function.js b/test/visitor/obfuscator/string-array/wrappers-function.js new file mode 100644 index 00000000..57a98d72 --- /dev/null +++ b/test/visitor/obfuscator/string-array/wrappers-function.js @@ -0,0 +1 @@ +var foo=_0x2001b6(0x1c5,0x1c6);function _0x540d7f(_0x5b3888,_0x4dd5b8){return _0xde91(_0x5b3888- -0xa0,_0x4dd5b8);}function _0xde91(_0xaf96cc,_0x538a17){var _0xde914f=_0x538a();return _0xde91=function(_0x4f8fff,_0x2d8e9e){_0x4f8fff=_0x4f8fff-0x109;var _0xb1ad15=_0xde914f[_0x4f8fff];return _0xb1ad15;},_0xde91(_0xaf96cc,_0x538a17);}var bar=_0x2001b6(0x1c6,0x1c6),baz=_0x540d7f(0x6b,0x6f);function _0x2001b6(_0x1d89b3,_0x1660b2){return _0xde91(_0x1d89b3-0xbc,_0x1660b2);}function test(){function _0x462936(_0x275d59,_0xa624a){return _0x2001b6(_0x275d59- -0xe4,_0xa624a);}function _0x31b1c0(_0x3718a4,_0x5dd948){return _0x2001b6(_0x3718a4-0x2b2,_0x5dd948);}var _0x1b2a2f=_0x462936(0xe4,0xe0),_0x3606cc=_0x31b1c0(0x47b,0x47b),_0x3ab71b=_0x31b1c0(0x47c,0x480);}function _0x538a(){var _0x2cbaca=['foo','bar','baz','bark','hawk','eagle','stdout','write'];_0x538a=function(){return _0x2cbaca;};return _0x538a();}process[_0x540d7f(0x6f,0x6b)][_0x2001b6(0x1cc,0x1cc)](String(foo+bar+baz)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/string-array/wrappers-function.src.js b/test/visitor/obfuscator/string-array/wrappers-function.src.js new file mode 100644 index 00000000..5c5e9fca --- /dev/null +++ b/test/visitor/obfuscator/string-array/wrappers-function.src.js @@ -0,0 +1,11 @@ +var foo = 'foo' +var bar = 'bar'; +var baz = 'baz'; + +function test () { + var bark = 'bark' + var hawk = 'hawk'; + var eagle = 'eagle'; +} + +process.stdout.write(String(foo + bar + baz) + '\n'); From c76217d17d13cde4e061bd5286d70afa13164d50 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:52:07 +0100 Subject: [PATCH 13/18] feat(visitor/normalize-statements): reverse the Simplifying stage Schedules the four atomic operator-to-statement visitors, plus statement and declaration splitting, to a fixpoint. It runs first in the pipeline because every matcher below navigates by statement boundaries. **The fixpoint is required rather than tidy**, and the committed case pins it: four conditionals in four positions, one of which is unreachable until an `&&` has been reversed. One round is not enough, and the round count is the nesting depth rather than a constant. Declaration merging had no reversal at all until upstream's own spec fixtures were mined as a *measurement* rather than as a source of cases - encoding every fixture for a stage and comparing the decoded structure against its source, which is what surfaced a reversal nobody had noticed was missing. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- .../obfuscator/normalize-statements.js | 116 ++++++++++++++++++ .../obfuscator/normalize-statements.test.js | 52 ++++++++ .../nested-encoder-output.fix.js | 36 ++++++ .../nested-encoder-output.js | 12 ++ 4 files changed, 216 insertions(+) create mode 100644 src/visitor/obfuscator/normalize-statements.js create mode 100644 test/visitor/obfuscator/normalize-statements.test.js create mode 100644 test/visitor/obfuscator/normalize-statements/nested-encoder-output.fix.js create mode 100644 test/visitor/obfuscator/normalize-statements/nested-encoder-output.js diff --git a/src/visitor/obfuscator/normalize-statements.js b/src/visitor/obfuscator/normalize-statements.js new file mode 100644 index 00000000..b752df1f --- /dev/null +++ b/src/visitor/obfuscator/normalize-statements.js @@ -0,0 +1,116 @@ +import traverse from '@babel/traverse' + +import logger from '../../utility/logger.js' +import lintIfStatement from '../lint-if-statement.js' +import splitSequence from '../split-sequence.js' +import splitVariableDeclaration from '../split-variable-declaration.js' +import { createConvertConditionalAssign } from '../atomic/convert-conditional-assign.js' +import { createLintConditionalIf } from '../atomic/lint-conditional-if.js' +import { createLintLogicalIf } from '../atomic/lint-logical-if.js' +import { createSplitIfTestSequence } from '../atomic/split-if-test-sequence.js' + +const debugLog = logger.debugLog + +/** + * Undo javascript-obfuscator's `Simplifying` stage: put statement-level control flow that was + * packed into operators back into statements. + * + * That stage collapses a block's trailing run of statements into one comma expression, and + * rewrites an `if` whose branches collapsed that way into `&&`, `?:`, or a de-braced branch. + * Semantics are untouched; what it destroys is the statement boundary. Since every later + * matcher navigates by statement boundaries, this runs first - it is a precondition for the + * rest of the pipeline, not a cosmetic finish. + * + * **This file is scheduling and nothing else.** Every rewrite lives in a single-purpose + * visitor under `src/visitor/`, each reusable on its own and each carrying its own safety + * argument. What is obfuscator-specific is which of them run, in what order, and that they + * run to a fixpoint - so that is all this file holds. + */ + +/** + * Run the normalization group to a fixpoint. + * + * **Iteration is required, not tidiness.** The rewrites unlock each other, because the + * encoder nests its own outputs. `if (t) { x(); if (c) { a(); } else { b(); } }` is emitted as + * `t && (x(), c ? a() : b());` - a conditional inside a sequence inside a `&&`. Nothing can + * reach that conditional until the `&&` is reversed, which creates a statement; re-bracing + * then gives that statement a block; only then can the sequence split; only then is the + * conditional in statement position. Each round peels one layer, so the round count is the + * nesting depth rather than a constant. Measured on that example: three rounds. + * + * **Order within a round is what makes one round do as much as possible**, and each step + * feeds the next: + * + * 1. distribute assignments into conditionals - turns value position into statement position + * 2. re-brace `if` branches - gives 3 and 4 a statement list to insert into + * 3. split sequences in statement/return - exposes packed statements + * 4. split sequences in `if` tests - the position 3 does not cover + * 5. reverse `&&` and `?:` in statement position - the reversals themselves + * + * Step 5 last is deliberate: it is what creates the brace-less branches and fresh statement + * positions that step 2 and step 3 of the *next* round act on. + * + * `maxRounds` is a runaway guard, not a tuning knob. The loop exits as soon as a round + * reverses nothing, and a file hitting the cap means either pathological nesting or a rewrite + * oscillating - both worth knowing about, so it is reported. + */ +function normalizeStatements(ast, maxRounds = 8) { + const total = { + logical: 0, + conditional: 0, + assign: 0, + sequence: 0, + rounds: 0, + cappedOut: false, + } + + for (let round = 0; round < maxRounds; round += 1) { + let changed = 0 + + traverse( + ast, + createConvertConditionalAssign(() => (total.assign += 1)), + ) + traverse(ast, lintIfStatement) + traverse(ast, splitSequence) + traverse(ast, splitVariableDeclaration) + traverse( + ast, + createSplitIfTestSequence(() => (total.sequence += 1)), + ) + traverse( + ast, + createLintConditionalIf(() => { + total.conditional += 1 + changed += 1 + }), + ) + traverse( + ast, + createLintLogicalIf(() => { + total.logical += 1 + changed += 1 + }), + ) + + total.rounds = round + 1 + if (!changed) { + break + } + if (round === maxRounds - 1) { + total.cappedOut = true + } + } + + debugLog( + `[obfuscatorx] normalize-statements: ${total.conditional} conditional, ` + + `${total.logical} logical, ${total.assign} assign-distributed, ` + + `${total.sequence} if-test sequences, in ${total.rounds} round(s)` + + (total.cappedOut + ? ' — HIT THE ROUND CAP, output may still be packed' + : ''), + ) + return total +} + +export default normalizeStatements diff --git a/test/visitor/obfuscator/normalize-statements.test.js b/test/visitor/obfuscator/normalize-statements.test.js new file mode 100644 index 00000000..1c30b431 --- /dev/null +++ b/test/visitor/obfuscator/normalize-statements.test.js @@ -0,0 +1,52 @@ +import fs from 'fs' +import { join } from 'path' +import { expect, test } from 'vitest' +import { parse } from '@babel/parser' +import generate from '@babel/generator' +import { expectConsistentState } from '../../helper.js' +import normalizeStatements from '#visitor/obfuscator/normalize-statements' + +const root = join(__dirname, 'normalize-statements') + +// A private runner rather than `getVisitorResult`, for one reason only: that helper does +// `traverse(ast, visitor)` and `normalizeStatements` is a function taking the AST, not a visitor +// object. These cases also assert on the returned stats, which neither shared helper returns. +// +// What the private runner must NOT do is opt out of the state audit, which is exactly what it did +// until this import landed: it compared output text and nothing else, so the four visitors this +// pass composes shipped inflated reference counts straight through it. +function run(name) { + const input = fs.readFileSync(join(root, `${name}.js`), 'utf-8') + const ast = parse(input, { allowReturnOutsideFunction: true }) + const stats = normalizeStatements(ast) + const expected = fs.readFileSync(join(root, `${name}.fix.js`), 'utf-8') + expect(generate(ast).code).toBe(expected) + expectConsistentState(ast, expected, { allowReturnOutsideFunction: true }) + return stats +} + +/** + * Real javascript-obfuscator 2.19.0 output for four source shapes that all reduce to the same + * `if`/`else`, each landing the conditional in a different position: + * + * plain `c ? a() : b();` - already a statement + * inSeq `p(), c ? a() : b(), q();` - middle of a sequence + * inRet `return x(), c ? a() : b();` - last element of a sequence in a return + * outer `t && (x(), c ? a() : b());` - inside a sequence inside a `&&` + * + * `outer` is the one that forces the fixpoint: its sequence sits under a LogicalExpression, + * where split-sequence.js cannot reach it, so the conditional is unreachable until the `&&` + * has been reversed and the resulting branch re-braced. + */ +test('nested-encoder-output', () => { + const stats = run('nested-encoder-output') + + // Four conditionals and one `&&`, so nothing was left packed. + expect(stats.conditional).toBe(4) + expect(stats.logical).toBe(1) + + // The point of the test: one round is not enough, and the loop terminates on its own well + // inside the guard rather than by hitting it. + expect(stats.rounds).toBeGreaterThan(1) + expect(stats.cappedOut).toBe(false) +}) diff --git a/test/visitor/obfuscator/normalize-statements/nested-encoder-output.fix.js b/test/visitor/obfuscator/normalize-statements/nested-encoder-output.fix.js new file mode 100644 index 00000000..30ae3852 --- /dev/null +++ b/test/visitor/obfuscator/normalize-statements/nested-encoder-output.fix.js @@ -0,0 +1,36 @@ +function outer(_0x106c5d, _0x37cfdb) { + if (_0x106c5d) { + x(); + if (_0x37cfdb) { + a(); + } else { + b(); + } + } +} +function inSeq(_0x33d1df) { + p(); + if (_0x33d1df) { + a(); + } else { + b(); + } + q(); +} +function inRet(_0x2c2796, _0x449a6e) { + if (_0x2c2796) { + x(); + if (_0x449a6e) { + return a(); + } else { + return b(); + } + } +} +function plain(_0x597b2b) { + if (_0x597b2b) { + a(); + } else { + b(); + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-statements/nested-encoder-output.js b/test/visitor/obfuscator/normalize-statements/nested-encoder-output.js new file mode 100644 index 00000000..d6f837b4 --- /dev/null +++ b/test/visitor/obfuscator/normalize-statements/nested-encoder-output.js @@ -0,0 +1,12 @@ +function outer(_0x106c5d, _0x37cfdb) { + _0x106c5d && (x(), _0x37cfdb ? a() : b()); +} +function inSeq(_0x33d1df) { + p(), _0x33d1df ? a() : b(), q(); +} +function inRet(_0x2c2796, _0x449a6e) { + if (_0x2c2796) return x(), _0x449a6e ? a() : b(); +} +function plain(_0x597b2b) { + _0x597b2b ? a() : b(); +} \ No newline at end of file From 69f61c9acd2a3d1e5450b8995a174e6b59ce918a Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:52:07 +0100 Subject: [PATCH 14/18] feat(visitor/normalize-converting): reverse the Converting stage Ten of the encoder's eleven Converting transformers rewrite one node in place, so ten of the reversals are single-node rewrites and none is specific to this obfuscator - they live in the atomic layer, and this file holds only the scheduling. What is obfuscator-specific is knowing that these reversals **unlock each other**, in which order, and that the group must run to a fixpoint: with `splitStrings` on a property name arrives as a `+` chain and is not a string literal at all until it has been folded. **One deliberate improvement over the incumbent, and it is a real semantic defect avoided.** That plugin un-computes any string key unguarded. Three keys change meaning when they lose their brackets - `["__proto__"]` becomes the prototype setter, `["constructor"]` becomes the class constructor, `static ["prototype"]` becomes a runtime error - and the repository's shared guard already refuses all three, so this calls it rather than reimplementing the list. Termination compares the tree between rounds rather than counting reported rewrites: two of the six passes are shared visitors with no change signal to offer, and counting only what can report would exit a round early whenever those two were the only ones to fire. A text-level residue assertion does not work on this decoder's output - the first version of `literals-and-members` proved it, since `/0x[0-9a-f]/i` fails on a correct decode when renaming leaves `_0x185301` everywhere. Assert on the shape the census reads. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- .../obfuscator/normalize-converting.js | 120 ++++++++++++++ .../obfuscator/normalize-converting.test.js | 155 ++++++++++++++++++ .../class-member-keys.fix.js | 15 ++ .../normalize-converting/class-member-keys.js | 1 + .../class-member-keys.src.js | 7 + .../literals-and-members.fix.js | 11 ++ .../literals-and-members.js | 1 + .../literals-and-members.src.js | 5 + .../object-keys-getset.fix.js | 13 ++ .../object-keys-getset.js | 1 + .../object-keys-getset.src.js | 9 + .../object-pattern-shorthand.fix.js | 13 ++ .../object-pattern-shorthand.js | 1 + .../object-pattern-shorthand.src.js | 5 + 14 files changed, 357 insertions(+) create mode 100644 src/visitor/obfuscator/normalize-converting.js create mode 100644 test/visitor/obfuscator/normalize-converting.test.js create mode 100644 test/visitor/obfuscator/normalize-converting/class-member-keys.fix.js create mode 100644 test/visitor/obfuscator/normalize-converting/class-member-keys.js create mode 100644 test/visitor/obfuscator/normalize-converting/class-member-keys.src.js create mode 100644 test/visitor/obfuscator/normalize-converting/literals-and-members.fix.js create mode 100644 test/visitor/obfuscator/normalize-converting/literals-and-members.js create mode 100644 test/visitor/obfuscator/normalize-converting/literals-and-members.src.js create mode 100644 test/visitor/obfuscator/normalize-converting/object-keys-getset.fix.js create mode 100644 test/visitor/obfuscator/normalize-converting/object-keys-getset.js create mode 100644 test/visitor/obfuscator/normalize-converting/object-keys-getset.src.js create mode 100644 test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.fix.js create mode 100644 test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.js create mode 100644 test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.src.js diff --git a/src/visitor/obfuscator/normalize-converting.js b/src/visitor/obfuscator/normalize-converting.js new file mode 100644 index 00000000..dc755645 --- /dev/null +++ b/src/visitor/obfuscator/normalize-converting.js @@ -0,0 +1,120 @@ +import generator from '@babel/generator' +import traverse from '@babel/traverse' + +import logger from '../../utility/logger.js' +import calculateConstantExp from '../calculate-constant-exp.js' +import mergeObject from '../merge-object.js' +import { createCollapsePropertyShorthand } from '../atomic/collapse-property-shorthand.js' +import { createUncomputeMember } from '../atomic/uncompute-member.js' +import { createUncomputePropertyKey } from '../atomic/uncompute-property-key.js' + +const debugLog = logger.debugLog + +/** + * Undo javascript-obfuscator's `Converting` stage: put every re-spelled literal, property name + * and object literal back into the form it was written in. + * + * That stage holds eleven transformers, and ten of them rewrite a single node in place - a + * boolean into `!![]`, a number into hexadecimal or an arithmetic tree, a property name into a + * computed string, a string into a chain of chunks. None of those rewrites is specific to this + * obfuscator, so none of the reversals lives here: they are plain visitors under `src/visitor/` + * that any plugin may compose. + * + * **This file is scheduling and nothing else**, which is the same division + * `normalize-statements.js` already draws. What is obfuscator-specific is not how to fold a + * constant - it is knowing that these particular reversals unlock each other, in which order, + * and that the group has to run to a fixpoint. + */ + +/** + * Strip the `raw` spelling from numeric literals. + * + * `NumberLiteralTransformer` is the one member of its stage that changes no AST node at all: it + * writes the literal's `raw` and leaves `value` untouched. So there is nothing to match and + * nothing to fold - discarding `extra` *is* the entire reversal, and a generator then prints the + * value in decimal. + * + * **Scoped to numbers on purpose.** String literals also carry a `raw`, and theirs holds the + * escape spelling that the `Finalizing` stage produced; that is a different transform with its + * own reversal, so this pass leaves string literals alone rather than quietly absorbing it. + */ +function stripNumericRaw(ast) { + let count = 0 + traverse(ast, { + NumericLiteral: ({ node }) => { + if (node.extra) { + delete node.extra + count++ + } + }, + }) + return count +} + +/** + * Run the group to a fixpoint. + * + * **Iteration is required rather than tidy, and it is measurable.** The reversals expose each + * other's input, because the encoder's own transforms compose: with `splitStrings` on, a + * property name is emitted as a *chain* - `o['\x66\x6c' + '\x61\x67']` - so it is not a string + * literal at all until the chain has been folded, and a member-un-computing pass looking for a + * string key finds nothing. Measured on one corpus cell (`2.19.0/objects__all-on.js`): the + * count of un-computable member reads is 33 before folding and 365 after. So a pass that ran + * once, in any order, would leave roughly nine tenths of that shape behind. + * + * **Order within a round, and what each step feeds:** + * + * 1. strip numeric `raw` - no dependencies; cheapest first + * 2. fold constant expressions - turns `!![]` into `true`, arithmetic trees into numbers, and + * chunk chains into whole strings, which is what creates the + * string keys steps 3 and 4 need + * 3. un-compute member reads - `o["foo"]` into `o.foo` + * 4. un-compute property keys - `["foo"](){}` into `foo(){}` + * 5. merge extracted objects - reassembles `var t = {}; t.a = 1; ...` into one literal, + * which is easiest once 3 and 4 have made the writes dotted + * 6. collapse shorthand - last, because it only ever acts on what 4 produced + * + * **One ordering here is a convenience, not a constraint, and it is recorded as such.** Step 5 + * accepts both a string and an identifier property, so it does not in fact require steps 3 and 4 + * to have run. It is scheduled after them because the merged output then reads dotted, not + * because the reverse order fails. Anything asserting a stricter dependency should reverse the + * two and measure, rather than inherit this comment. + * + * `maxRounds` is a runaway guard rather than a tuning knob: the loop exits as soon as a round + * changes nothing, and hitting the cap means either pathological nesting or two rewrites + * oscillating, both of which are worth reporting. + */ +export default function normalizeConverting(ast, maxRounds = 10) { + // Termination is decided by comparing the whole tree between rounds, not by counting what the + // rewrites report. Two of the six - `calculateConstantExp` and `mergeObject` - are shared + // visitors with no change signal to offer, and §3.3 forbids editing a shared visitor to add + // one. Counting only the passes that *can* report would exit a round early whenever those two + // were the only ones to fire, silently leaving their consequences unprocessed. A generated + // comparison costs one serialization per round and cannot be wrong about whether the tree + // moved. + let previous = null + let rounds = 0 + for (; rounds < maxRounds; rounds++) { + const noted = { atomic: 0 } + const bump = () => { + noted.atomic++ + } + + stripNumericRaw(ast) + traverse(ast, calculateConstantExp) + traverse(ast, createUncomputeMember(bump)) + traverse(ast, createUncomputePropertyKey(bump)) + traverse(ast, mergeObject) + traverse(ast, createCollapsePropertyShorthand(bump)) + + const current = generator(ast, { compact: true }).code + if (current === previous) { + break + } + previous = current + } + if (rounds >= maxRounds) { + debugLog(`normalize-converting: hit the ${maxRounds}-round cap`) + } + return ast +} diff --git a/test/visitor/obfuscator/normalize-converting.test.js b/test/visitor/obfuscator/normalize-converting.test.js new file mode 100644 index 00000000..8caf75f4 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting.test.js @@ -0,0 +1,155 @@ +import fs from 'fs' +import { join } from 'path' +import { expect, test } from 'vitest' +import { parse } from '@babel/parser' +import generate from '@babel/generator' +import * as t from '@babel/types' +import traverse from '@babel/traverse' +import normalizeStatements from '#visitor/obfuscator/normalize-statements' +import decodeStringArray from '#visitor/obfuscator/string-array' +import normalizeConverting from '#visitor/obfuscator/normalize-converting' +import uncomputePropertyKey from '#visitor/atomic/uncompute-property-key' +import collapsePropertyShorthand from '#visitor/atomic/collapse-property-shorthand' + +const root = join(__dirname, 'normalize-converting') + +/** + * Each case is real javascript-obfuscator 2.19.0 output for a source shape taken from upstream's + * own converting-transformer fixtures, given a reporting tail so the golden could be checked by + * running it. The goldens were generated by a builder that refuses to write one unless the decoded + * output reproduces the pre-obfuscation source's output exactly. + * + * The string-array pass runs first because it has to: until concealed strings are substituted, a + * property key is a *call* rather than a string and nothing in this pass can match it. + */ +function run(name) { + const input = fs.readFileSync(join(root, `${name}.js`), 'utf-8') + const ast = parse(input, { + allowReturnOutsideFunction: true, + errorRecovery: true, + }) + normalizeStatements(ast) + decodeStringArray(ast) + normalizeConverting(ast) + const expected = fs.readFileSync(join(root, `${name}.fix.js`), 'utf-8') + expect(generate(ast).code).toBe(expected) + return generate(ast).code +} + +/** + * Class member keys, from upstream's `class-field-transformer` fixtures, which cover both key + * spellings and `constructor` — the one name the encoder itself exempts from literalization. + */ +test('class-member-keys', () => { + const out = run('class-member-keys') + // The point of the case: every key comes back as an identifier, including the quoted one, + // and `constructor` is still the constructor rather than a method named "constructor". + expect(out).toMatch(/\bconstructor\(\)/) + expect(out).toMatch(/\bquoted\(\)/) + expect(out).not.toMatch(/\["/) +}) + +/** Object-keys extraction with getter/setter members, from upstream's `get-set-property-kind`. */ +test('object-keys-getset', () => { + const out = run('object-keys-getset') + expect(out).not.toMatch(/\["/) +}) + +/** + * Member reads, booleans, hex numbers, numerical expressions and split strings together. + * + * **Asserted on the tree, not with a regex over the text.** A first version of this test used + * `/0x[0-9a-f]/i` for the hex axis and failed on correct output, because renaming leaves + * identifiers like `_0x185301` everywhere and they match it. Any text-level residue check on this + * decoder's output has that problem; the shape is what the census reads and it is what to assert. + */ +test('literals-and-members', () => { + const out = run('literals-and-members') + const ast = parse(out) + let hexRaw = 0 + let boolDisguise = 0 + let computedStringMember = 0 + traverse(ast, { + NumericLiteral({ node }) { + if (node.extra && /^0[xX]/.test(String(node.extra.raw))) hexRaw++ + }, + UnaryExpression(path) { + const isEmptyArr = (n) => + t.isArrayExpression(n) && n.elements.length === 0 + if (path.node.operator === '!' && isEmptyArr(path.node.argument)) + boolDisguise++ + }, + MemberExpression({ node }) { + if (node.computed && t.isStringLiteral(node.property)) + computedStringMember++ + }, + }) + expect({ hexRaw, boolDisguise, computedStringMember }).toEqual({ + hexRaw: 0, + boolDisguise: 0, + computedStringMember: 0, + }) + // and the values themselves survived, so the zeros above are a decode rather than a deletion + expect(out).toMatch(/= true/) + expect(out).toMatch(/= false/) + expect(out).toMatch(/1000/) + expect(out).toMatch(/"abcdefgh"/) +}) + +/** + * Destructuring, from upstream's `object-pattern-properties-transformer` fixtures. + * + * **This case does not exercise the shorthand collapse, and that is the finding it pins.** The + * encoder expands `{ foo }` to `{ foo: foo }` so that renaming can rewrite the binding while + * leaving the property name alone — and renaming then does exactly that, at every setting of + * `renameGlobals`. So the two names differ permanently and the collapse can never fire on this + * encoder's output. What the case asserts is that the pass leaves a renamed pattern alone rather + * than corrupting it. + */ +test('object-pattern-shorthand', () => { + const out = run('object-pattern-shorthand') + expect(out).toMatch(/\{[^{}]*foo:\s*_0x/) +}) + +/** + * The three keys that change meaning when they lose their brackets. None is reachable from stock + * encoder output — the encoder exempts `constructor` and never emits the other two — so this is + * the only place they are covered, and they are covered because a *source* can already contain + * them. + */ +test('dangerous keys are never un-computed', () => { + const code = [ + 'const a = { ["__proto__"]: base };', + 'class C { ["constructor"]() { return 1; } }', + 'class D { static ["prototype"]() { return 2; } }', + ].join('\n') + const ast = parse(code) + traverse(ast, uncomputePropertyKey) + const out = generate(ast).code + expect(out).toMatch(/\["__proto__"\]/) + expect(out).toMatch(/\["constructor"\]/) + expect(out).toMatch(/\["prototype"\]/) +}) + +/** A safe key in the same positions must still be un-computed, or the test above proves nothing. */ +test('ordinary keys are un-computed', () => { + const ast = parse('const a = { ["foo"]: 1 };\nclass C { ["bar"]() {} }') + traverse(ast, uncomputePropertyKey) + const out = generate(ast).code + expect(out).toMatch(/foo: 1/) + expect(out).toMatch(/bar\(\)/) + expect(out).not.toMatch(/\["/) +}) + +/** + * The mirror-image `__proto__` hazard: collapsing to shorthand is what changes meaning here, + * because `{ __proto__: x }` sets the prototype while `{ __proto__ }` defines an own property. + */ +test('__proto__ is never collapsed to shorthand', () => { + const ast = parse('const o = { __proto__: __proto__, foo: foo };') + traverse(ast, collapsePropertyShorthand) + const out = generate(ast).code + expect(out).toMatch(/__proto__: __proto__/) + // and the safe sibling in the same literal is collapsed, so the guard is not vacuous + expect(out).toMatch(/\bfoo\b(?!\s*:)/) +}) diff --git a/test/visitor/obfuscator/normalize-converting/class-member-keys.fix.js b/test/visitor/obfuscator/normalize-converting/class-member-keys.fix.js new file mode 100644 index 00000000..d44eff46 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/class-member-keys.fix.js @@ -0,0 +1,15 @@ +class _0x4f1c97 { + constructor() { + this.made = "ctor"; + } + bar() { + return "bar"; + } + quoted() { + return "quoted"; + } + static make() { + return new _0x4f1c97(); + } +} +process.stdout.write(_0x4f1c97.make().made + '\x20' + new _0x4f1c97().bar() + '\x20' + new _0x4f1c97().quoted() + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-converting/class-member-keys.js b/test/visitor/obfuscator/normalize-converting/class-member-keys.js new file mode 100644 index 00000000..bfd1f2e9 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/class-member-keys.js @@ -0,0 +1 @@ +var _0x120466=_0x1a1f;(function(_0x5bf326,_0x251577){var _0x5e9d09=_0x1a1f,_0x4a34cc=_0x5bf326();while(!![]){try{var _0x7bf0f=parseInt(_0x5e9d09(0x89))/(0x23a4+-0x1*0x2302+-0x7*0x17)*(parseInt(_0x5e9d09(0x83))/(-0xbd7*0x1+0x1e39+-0x1260))+-parseInt(_0x5e9d09(0x8b))/(-0x9a9*-0x2+-0x1f*-0x9f+-0x2690)+-parseInt(_0x5e9d09(0x80))/(-0x15b4+-0xa08+-0x1fc0*-0x1)*(parseInt(_0x5e9d09(0x8c))/(-0x65a+-0x293*-0x1+-0x3*-0x144))+parseInt(_0x5e9d09(0x8a))/(0x7d6+-0x1bdb+0x140b)*(parseInt(_0x5e9d09(0x90))/(-0x106b*0x1+0x2388+-0x1316))+parseInt(_0x5e9d09(0x81))/(0x1340+-0xb7*0x1d+0x1*0x183)*(-parseInt(_0x5e9d09(0x8e))/(0x93*-0x13+0xc*0x18d+-0x7aa))+parseInt(_0x5e9d09(0x91))/(-0x1*-0x2cd+0x43*-0x2+-0xbf*0x3)+parseInt(_0x5e9d09(0x82))/(0x24b2+0x1*0x531+-0xa76*0x4)*(-parseInt(_0x5e9d09(0x8d))/(-0x1f2f+-0x11c*-0xf+0xe97));if(_0x7bf0f===_0x251577)break;else _0x4a34cc['push'](_0x4a34cc['shift']());}catch(_0x102350){_0x4a34cc['push'](_0x4a34cc['shift']());}}}(_0x3e45,-0x871aa+-0x3*-0x2f6ce+0x7de5b));function _0x3e45(){var _0x22d6d6=['934764oOWEFB','158715YHUZqj','24SNeLlC','4182174IEkcoX','quot','1379441TzdSFg','5780710BxlXVY','ctor','4mVPxDB','8AQXAAG','668459PzABFM','14vZyNrI','make','writ','bar','made','stdo','15469pTFsGe','24xswivE'];_0x3e45=function(){return _0x22d6d6;};return _0x3e45();}function _0x1a1f(_0x57a012,_0x2e74f8){var _0x5880a8=_0x3e45();return _0x1a1f=function(_0x562713,_0x4f1c97){_0x562713=_0x562713-(-0xd*-0xe3+0x32b+-0xe32);var _0x2f2a04=_0x5880a8[_0x562713];return _0x2f2a04;},_0x1a1f(_0x57a012,_0x2e74f8);}class _0x4f1c97{constructor(){var _0x468035=_0x1a1f;this[_0x468035(0x87)]=_0x468035(0x92);}[_0x120466(0x86)](){var _0x59ccff=_0x120466;return _0x59ccff(0x86);}[_0x120466(0x8f)+'ed'](){var _0x144eb0=_0x120466;return _0x144eb0(0x8f)+'ed';}static[_0x120466(0x84)](){return new _0x4f1c97();}}process[_0x120466(0x88)+'ut'][_0x120466(0x85)+'e'](_0x4f1c97[_0x120466(0x84)]()[_0x120466(0x87)]+'\x20'+new _0x4f1c97()[_0x120466(0x86)]()+'\x20'+new _0x4f1c97()[_0x120466(0x8f)+'ed']()+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-converting/class-member-keys.src.js b/test/visitor/obfuscator/normalize-converting/class-member-keys.src.js new file mode 100644 index 00000000..339d2d20 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/class-member-keys.src.js @@ -0,0 +1,7 @@ +class Foo { + constructor () { this.made = 'ctor'; } + bar () { return 'bar'; } + 'quoted' () { return 'quoted'; } + static make () { return new Foo(); } +} +process.stdout.write(Foo.make().made + ' ' + new Foo().bar() + ' ' + new Foo().quoted() + '\n'); diff --git a/test/visitor/obfuscator/normalize-converting/literals-and-members.fix.js b/test/visitor/obfuscator/normalize-converting/literals-and-members.fix.js new file mode 100644 index 00000000..f98adc12 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/literals-and-members.fix.js @@ -0,0 +1,11 @@ +(function () { + var _0x185301 = true; + var _0x4dfddb = false; + var _0x255a46 = 1000; + var _0x2fa2fd = "abcdefgh"; + var _0x52e814 = { + name: "widget", + size: 3 + }; + process.stdout.write(_0x52e814.name + '\x20' + _0x52e814.size + '\x20' + _0x185301 + '\x20' + _0x4dfddb + '\x20' + _0x255a46 + '\x20' + _0x2fa2fd + '\x0a'); +})(); \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-converting/literals-and-members.js b/test/visitor/obfuscator/normalize-converting/literals-and-members.js new file mode 100644 index 00000000..d88c85a8 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/literals-and-members.js @@ -0,0 +1 @@ +(function(_0x22451a,_0x55a41a){var _0x201a14=_0x4231,_0x400f84=_0x22451a();while(!![]){try{var _0x5f2456=parseInt(_0x201a14(0xc0))/(0x2ff*0x8+0xb8d+-0x2384)*(-parseInt(_0x201a14(0xbd))/(-0x15bc+-0x1bc+0x177a))+-parseInt(_0x201a14(0xc3))/(-0x5*0x36b+-0x1b5+0x12cf)*(parseInt(_0x201a14(0xc4))/(0x44b*-0x4+0x46c+-0x13*-0xac))+-parseInt(_0x201a14(0xc7))/(-0x11*0xc7+0x2494+-0xc*0x1f2)+parseInt(_0x201a14(0xb8))/(0xfe3+0x124a+-0x4e1*0x7)+parseInt(_0x201a14(0xbe))/(0x1*0x8f5+0xb8c+-0x147a)*(parseInt(_0x201a14(0xbf))/(0x179f+-0xa3*0xa+-0x1*0x1139))+parseInt(_0x201a14(0xc6))/(-0x1317+-0x11*0x1aa+0x2f6a)+-parseInt(_0x201a14(0xba))/(-0x1353+0x248*-0xf+0x3595)*(-parseInt(_0x201a14(0xb9))/(0x2*-0xad3+-0x1d39+0x32ea));if(_0x5f2456===_0x55a41a)break;else _0x400f84['push'](_0x400f84['shift']());}catch(_0x4c9f94){_0x400f84['push'](_0x400f84['shift']());}}}(_0x7e8c,-0x2e0b7+-0x9f95*0x2+0x9505*0xb),function(){var _0x58bd0e=_0x4231,_0x185301=!![],_0x4dfddb=![],_0x255a46=-0x1684+-0x29*-0x5e+-0xb5e*-0x1,_0x2fa2fd=_0x58bd0e(0xbc)+_0x58bd0e(0xbb),_0x2d0852={};_0x2d0852[_0x58bd0e(0xb7)]=_0x58bd0e(0xc1)+'et',_0x2d0852[_0x58bd0e(0xc2)]=0x3;var _0x52e814=_0x2d0852;process[_0x58bd0e(0xc8)+'ut'][_0x58bd0e(0xc5)+'e'](_0x52e814[_0x58bd0e(0xb7)]+'\x20'+_0x52e814[_0x58bd0e(0xc2)]+'\x20'+_0x185301+'\x20'+_0x4dfddb+'\x20'+_0x255a46+'\x20'+_0x2fa2fd+'\x0a');}());function _0x4231(_0x77e3cc,_0x46e413){var _0xce24a3=_0x7e8c();return _0x4231=function(_0x1b842b,_0x143560){_0x1b842b=_0x1b842b-(-0x1458+0x11d7*-0x1+0x26e6);var _0x420fe8=_0xce24a3[_0x1b842b];return _0x420fe8;},_0x4231(_0x77e3cc,_0x46e413);}function _0x7e8c(){var _0x1b0c96=['abcd','362698XACoJa','319984SQUbZm','16cTxTCR','1lTYVtn','widg','size','39SfNOGe','63604MdcuqA','writ','819018oddLfK','661535yrQZiD','stdo','name','1607064nMfcFQ','2413763cmPbDz','10gVlKLs','efgh'];_0x7e8c=function(){return _0x1b0c96;};return _0x7e8c();} \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-converting/literals-and-members.src.js b/test/visitor/obfuscator/normalize-converting/literals-and-members.src.js new file mode 100644 index 00000000..0944a001 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/literals-and-members.src.js @@ -0,0 +1,5 @@ +(function () { + var flag = true, off = false, n = 1000, s = 'abcdefgh'; + var o = { name: 'widget', size: 3 }; + process.stdout.write(o.name + ' ' + o['size'] + ' ' + flag + ' ' + off + ' ' + n + ' ' + s + '\n'); +})(); diff --git a/test/visitor/obfuscator/normalize-converting/object-keys-getset.fix.js b/test/visitor/obfuscator/normalize-converting/object-keys-getset.fix.js new file mode 100644 index 00000000..a837be50 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/object-keys-getset.fix.js @@ -0,0 +1,13 @@ +(function () { + const _0x2d2e4c = { + get baz() { + return 2; + }, + set bark(_0x54cf52) { + this.stored = _0x54cf52; + }, + bar: 1 + }; + _0x2d2e4c.bark = 9; + process.stdout.write(_0x2d2e4c.bar + '\x20' + _0x2d2e4c.baz + '\x20' + _0x2d2e4c.stored + '\x0a'); +})(); \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-converting/object-keys-getset.js b/test/visitor/obfuscator/normalize-converting/object-keys-getset.js new file mode 100644 index 00000000..81d1a6f8 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/object-keys-getset.js @@ -0,0 +1 @@ +function _0x556c(){const _0x142704=['11412kILLWr','30RSPayH','128QLtxkG','494035nusrAE','14lnzbHJ','stor','281521wOrRBx','bar','writ','1846suXDjb','492CUTrzh','367266qAiGJD','bark','stdo','1643164DrhvdE','baz','2277011HOoGQp'];_0x556c=function(){return _0x142704;};return _0x556c();}function _0x3d13(_0x1a5845,_0x5259d0){const _0x154d32=_0x556c();return _0x3d13=function(_0x330be7,_0x2d15a6){_0x330be7=_0x330be7-(0x11*-0xe9+-0xc25+0x1ceb);let _0x118c78=_0x154d32[_0x330be7];return _0x118c78;},_0x3d13(_0x1a5845,_0x5259d0);}(function(_0x334631,_0x222db7){const _0x41c595=_0x3d13,_0x1f7e45=_0x334631();while(!![]){try{const _0x14f7be=parseInt(_0x41c595(0x157))/(-0x1*-0x7aa+-0x3d4+0x1*-0x3d5)+parseInt(_0x41c595(0x15a))/(-0x2f*-0x63+-0x14af+0x284)*(-parseInt(_0x41c595(0x15b))/(0x2*-0x83f+-0xc7e+0x1cff))+-parseInt(_0x41c595(0x14e))/(-0x9cc+0x2065+-0x1695)+parseInt(_0x41c595(0x154))/(-0x48b*0x2+-0x4*0x7d7+0x2877)+-parseInt(_0x41c595(0x15c))/(-0x26e2+-0x18e4+0x3fcc*0x1)*(parseInt(_0x41c595(0x155))/(0x210*0x5+0xa*0x25f+-0x21ff))+parseInt(_0x41c595(0x153))/(-0x2549+0x11*-0x21+0x2782)*(-parseInt(_0x41c595(0x151))/(-0x1426+-0x2698+0x3ac7))+-parseInt(_0x41c595(0x152))/(0x1709+-0x290+0x146f*-0x1)*(-parseInt(_0x41c595(0x150))/(0x158a+0x2*-0xf90+0x9a1));if(_0x14f7be===_0x222db7)break;else _0x1f7e45['push'](_0x1f7e45['shift']());}catch(_0x3e5b2c){_0x1f7e45['push'](_0x1f7e45['shift']());}}}(_0x556c,-0x71bae+0x4d2a*-0x1e+0x14aca4),function(){const _0x29a4eb=_0x3d13,_0xad991d={get 'baz'(){return 0x2*-0x111+-0xda*0x1d+0x1ad6;},set 'bark'(_0x54cf52){const _0x1cce74=_0x3d13;this[_0x1cce74(0x156)+'ed']=_0x54cf52;}};_0xad991d[_0x29a4eb(0x158)]=0x1;const _0x2d2e4c=_0xad991d;_0x2d2e4c[_0x29a4eb(0x15d)]=-0x13f9*-0x1+0x19d*0xe+-0x2*0x1543,process[_0x29a4eb(0x14d)+'ut'][_0x29a4eb(0x159)+'e'](_0x2d2e4c[_0x29a4eb(0x158)]+'\x20'+_0x2d2e4c[_0x29a4eb(0x14f)]+'\x20'+_0x2d2e4c[_0x29a4eb(0x156)+'ed']+'\x0a');}()); \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-converting/object-keys-getset.src.js b/test/visitor/obfuscator/normalize-converting/object-keys-getset.src.js new file mode 100644 index 00000000..03d0d0c6 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/object-keys-getset.src.js @@ -0,0 +1,9 @@ +(function () { + const foo = { + bar: 1, + get baz() { return 2; }, + set bark(value) { this.stored = value; } + }; + foo.bark = 9; + process.stdout.write(foo.bar + ' ' + foo.baz + ' ' + foo.stored + '\n'); +})(); diff --git a/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.fix.js b/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.fix.js new file mode 100644 index 00000000..d76b9ad8 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.fix.js @@ -0,0 +1,13 @@ +(function () { + const _0x3e3d36 = { + foo: 1, + bar: 2, + other: 3 + }; + const { + foo: _0x31be2d, + bar: _0x4c9319, + ..._0x1c6b8b + } = _0x3e3d36; + process.stdout.write(_0x31be2d + '\x20' + _0x4c9319 + '\x20' + JSON.stringify(_0x1c6b8b) + '\x0a'); +})(); \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.js b/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.js new file mode 100644 index 00000000..eba9f8b1 --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.js @@ -0,0 +1 @@ +(function(_0x19b9e4,_0x3abb50){const _0x4fe0a8=_0x35e0,_0x5e72d0=_0x19b9e4();while(!![]){try{const _0x54f466=-parseInt(_0x4fe0a8(0x1d9))/(-0x175e+0xc46+0xb19)+parseInt(_0x4fe0a8(0x1ce))/(-0x2f1*-0x2+-0x3f2*-0x5+-0x1*0x199a)+parseInt(_0x4fe0a8(0x1d4))/(0x6*0x34c+-0x1*0x193+-0x1232)*(parseInt(_0x4fe0a8(0x1cc))/(-0x1bf4*0x1+0x20e5+0x4ed*-0x1))+-parseInt(_0x4fe0a8(0x1d5))/(0x3*0x873+-0x1a2c+-0x4*-0x36)*(-parseInt(_0x4fe0a8(0x1d0))/(-0x267d+0x13*0x1+0x2670))+parseInt(_0x4fe0a8(0x1d8))/(0x1*0x1264+-0x281+-0x1c*0x91)+-parseInt(_0x4fe0a8(0x1d6))/(0x87f*0x1+-0x1697+0xe20)*(parseInt(_0x4fe0a8(0x1da))/(-0x375+0xf*0xf1+-0xaa1))+-parseInt(_0x4fe0a8(0x1ca))/(0x2455+-0x8+-0x2443)*(-parseInt(_0x4fe0a8(0x1cd))/(0x1*0x1a16+-0x18b2+-0x159));if(_0x54f466===_0x3abb50)break;else _0x5e72d0['push'](_0x5e72d0['shift']());}catch(_0x2ad616){_0x5e72d0['push'](_0x5e72d0['shift']());}}}(_0x458c,-0xafe79+-0x18b374+0x181aa5*0x2),function(){const _0x41d0a7=_0x35e0,_0x593ec2={};_0x593ec2[_0x41d0a7(0x1d3)]=0x1,_0x593ec2[_0x41d0a7(0x1d7)]=0x2,_0x593ec2[_0x41d0a7(0x1cb)+'r']=0x3;const _0x3e3d36=_0x593ec2,{foo:_0x31be2d,bar:_0x4c9319,..._0x1c6b8b}=_0x3e3d36;process[_0x41d0a7(0x1d1)+'ut'][_0x41d0a7(0x1d2)+'e'](_0x31be2d+'\x20'+_0x4c9319+'\x20'+JSON[_0x41d0a7(0x1cf)+_0x41d0a7(0x1c9)+'y'](_0x1c6b8b)+'\x0a');}());function _0x35e0(_0x1a6602,_0x4e33e7){const _0x2838b0=_0x458c();return _0x35e0=function(_0x3344f2,_0x593ec2){_0x3344f2=_0x3344f2-(0x1*0x18b+-0x2661+0x269f*0x1);let _0x3e3d36=_0x2838b0[_0x3344f2];return _0x3e3d36;},_0x35e0(_0x1a6602,_0x4e33e7);}function _0x458c(){const _0x1b1dc8=['stri','6219186McNVjJ','stdo','writ','foo','69jiftWh','5KJBHjm','8ZzfmlL','bar','2635563XHEgSb','1597286WaLrbG','11794581xRhWNj','ngif','11590rEIUKW','othe','111236XqYYZf','2266NiTFaW','2872910WsHHVp'];_0x458c=function(){return _0x1b1dc8;};return _0x458c();} \ No newline at end of file diff --git a/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.src.js b/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.src.js new file mode 100644 index 00000000..86fc058a --- /dev/null +++ b/test/visitor/obfuscator/normalize-converting/object-pattern-shorthand.src.js @@ -0,0 +1,5 @@ +(function () { + const src = { foo: 1, bar: 2, other: 3 }; + const { foo, bar, ...rest } = src; + process.stdout.write(foo + ' ' + bar + ' ' + JSON.stringify(rest) + '\n'); +})(); From 16c9fc55075c82d64de921423b4d75b91ad06d6f Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:52:26 +0100 Subject: [PATCH 15/18] feat(visitor/unflatten-switch-dispatch): reverse block control-flow flattening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fork of the shared `remove-control-flow-ob`, not an import, and the four behaviours that forced the fork are why: the shared visitor accepts any prefix unary as the loop test and so deletes an ordinary `while (!done)` loop; it walks forward into a fallthrough case and appends it twice; it reads the discriminant's `property.argument.name` unguarded and throws, aborting the decode; and it resolves the control declarations by binding, which happily accepts a conditionally-initialised controller. Three of those are worse than declining. Narrowing a shared visitor is not an option when two other plugins consume it. **The reversal is a permutation read, in one step.** The controller string holds, for each original position, the index of the case now carrying it, so reading it left to right and indexing the case list recovers the order directly - no scanning, no walking forward, no state. Every invariant is checked before any mutation, so a decline is safe: there is no point at which the pass has half-applied itself and then found a reason to stop. Declarations are resolved by scanning previous siblings rather than through `scope.getBinding()`, for two reasons both paid for. `var` hoists, so a binding lookup resolves `if (x) { var C = …; }` while the *initialisation* is conditional. And a binding records the path as of the last crawl, so it can point at a detached node that still prints identically - a binding-based version accepted every hand-built case and rejected all 108 live corpus blocks, because only the hand-built trees had never been rewritten. Five of the seven cases pin a decline, which is unusual and is the point: the shared visitor passes both rewrite cases and mishandles every decline case, so a suite covering only the happy path would not distinguish the two implementations at all. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- .../obfuscator/unflatten-switch-dispatch.js | 264 ++++++++++++++++++ .../unflatten-switch-dispatch.test.js | 98 +++++++ .../all-cases-empty.fix.js | 1 + .../all-cases-empty.js | 13 + .../unflatten-switch-dispatch/baseline.fix.js | 6 + .../unflatten-switch-dispatch/baseline.js | 20 ++ .../conditional-declaration.js | 17 ++ .../directive-empty-case.fix.js | 7 + .../directive-empty-case.js | 21 ++ .../fallthrough-case.js | 14 + .../merged-declaration.fix.js | 5 + .../merged-declaration.js | 17 ++ .../unflatten-switch-dispatch/no-increment.js | 12 + .../not-always-true.js | 15 + .../order-mismatch.js | 17 ++ 15 files changed, 527 insertions(+) create mode 100644 src/visitor/obfuscator/unflatten-switch-dispatch.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch.test.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/all-cases-empty.fix.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/all-cases-empty.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/baseline.fix.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/baseline.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/conditional-declaration.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/directive-empty-case.fix.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/directive-empty-case.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/fallthrough-case.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/merged-declaration.fix.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/merged-declaration.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/no-increment.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/not-always-true.js create mode 100644 test/visitor/obfuscator/unflatten-switch-dispatch/order-mismatch.js diff --git a/src/visitor/obfuscator/unflatten-switch-dispatch.js b/src/visitor/obfuscator/unflatten-switch-dispatch.js new file mode 100644 index 00000000..735a9b90 --- /dev/null +++ b/src/visitor/obfuscator/unflatten-switch-dispatch.js @@ -0,0 +1,264 @@ +import * as t from '@babel/types' + +/** + * Undo javascript-obfuscator's block-statement control-flow flattening: a block whose statements + * have been shuffled into a `switch`, driven in the original order by a `"|"`-joined string of + * case indexes. + * + * { + * const C = '2|0|3|1|4'['split']('|'); + * let I = 0; + * while (true) { + * switch (C[I++]) { + * case '0': ; continue; + * ... + * case '4': return ; + * } + * break; + * } + * } + * + * **The reversal is a permutation read, and this pass is written as one.** The controller string + * holds, for each *original* statement position, the index of the case that now carries it. So + * reading it left to right and indexing the case list recovers the original order directly, in one + * step, with no scanning. + * + * A copy of the shared `remove-control-flow-ob.js` was rejected rather than adapted. That visitor + * reaches the same output on this encoder - it decodes every live flattened block in the frozen + * corpus - but it gets there by *walking forward from each index until it meets a `continue`*, a + * generality this encoder never needs, and three of its behaviours off that path are worse than + * declining. Its own doc records them. This file exists because a shared visitor cannot be + * narrowed without changing what two other plugins get, not because its output here was wrong. + * + * **Every check happens before any mutation.** The pass either rewrites a block completely or + * leaves it exactly as it found it; there is no point at which it has removed a declaration and + * then discovers it cannot continue. That is the difference that makes a decline safe, and it is + * why the invariants are gathered into `match()` rather than tested as it goes. + */ + +/** `while (true)`, and nothing else. */ +function isAlwaysTrueTest(node) { + return t.isBooleanLiteral(node, { value: true }) +} + +/** + * `C[I++]` - a computed read of one identifier indexed by the suffix increment of another. + * + * Checked in full before anything is read off it. The shared visitor checks only that the + * discriminant is a `MemberExpression` and then reads `property.argument.name`, which throws on a + * discriminant like `C[I]` and takes the whole decode down with it. + */ +function readDispatch(node) { + if (!t.isMemberExpression(node) || !node.computed) return null + if (!t.isIdentifier(node.object)) return null + const p = node.property + if (!t.isUpdateExpression(p) || p.operator !== '++' || p.prefix) return null + if (!t.isIdentifier(p.argument)) return null + return { controller: node.object.name, index: p.argument.name } +} + +/** + * A case that contributes no statement at all. Distinct from `null`, which is this function's + * reject signal - the two must not collapse, because one means "skip this slot" and the other + * means "do not touch this block". + */ +const EMPTY_CASE = Symbol('empty-case') + +/** The three consequent shapes this encoder emits, and no others. */ +function readConsequent(cs) { + if (cs.length === 2 && t.isContinueStatement(cs[1]) && !cs[1].label) + return cs[0] + if (cs.length === 1 && t.isReturnStatement(cs[0])) return cs[0] + // A bare `continue` is an EMPTY case, and the encoder does emit one: control-flow flattening + // runs at stage 4 and puts a function's leading directive into a case like any other statement, + // then `DirectivePlacementTransformer` re-emits that directive at the top of the scope during + // `Finalizing` - leaving the case it came from with nothing in it. Any function whose body opens + // with `'use strict'` and gets flattened produces this, which is a large slice of real-world + // input. Omitting the slot is the whole reversal: the order string is a permutation, so the case + // is visited exactly once, and executing it does nothing but return to the loop. + if (cs.length === 1 && t.isContinueStatement(cs[0]) && !cs[0].label) + return EMPTY_CASE + return null +} + +/** + * Resolve a name to the declarator that binds it: a declarator in a **preceding sibling + * declaration** of the loop, in the same block. + * + * Scanning previous siblings handles `simplify`'s fused `var C = …, I = 0;` for free, since it + * walks each declaration's declarator list. Resolving by *binding* instead is unsound, and a + * hand-built case caught it: + * + * function f() { if (x) { var C = '1|0'.split('|'); var I = 0; } + * while (true) { switch (C[I++]) { … } break; } } + * + * `var` hoists, so the binding resolves - but the *initialisation* is conditional. When `x` is + * falsy `C` is `undefined`, the switch matches nothing, and the block does nothing. Rewriting it + * to the linear body would run statements the original never runs. Requiring the declaration to + * precede the loop as a sibling is a cheap sufficient condition for the initialisation dominating + * the dispatch, and it is exactly what the shared visitor's previous-sibling scan enforced by + * accident. + * + * **It reads the current tree, not the scope's cached bindings, and that is not a style choice.** + * `scope.getBinding()` hands back a path recorded at the last crawl, and the passes that must run + * before this one - constant folding, branch pruning, the storage inlining - replace nodes + * wholesale. The binding then points at a detached node that still *prints* identically to the one + * in the tree, so an identity comparison against it silently fails and every real block declines. + * Measured: resolving by binding rejected all 108 live corpus blocks while accepting hand-built + * ones, because only the hand-built trees had never been rewritten. + */ +function resolveDeclarator(path, name) { + for (const sibling of path.getAllPrevSiblings()) { + if (!sibling.isVariableDeclaration()) continue + for (let i = 0; i < sibling.node.declarations.length; i++) { + const declarator = sibling.get(`declarations.${i}`) + if (t.isIdentifier(declarator.node.id, { name })) return declarator + } + } + return null +} + +/** + * Is every mention of `name` inside `path`? + * + * Asked by walking the enclosing statement list rather than by reading `binding.referencePaths`, + * for the same staleness reason as above. Cheap, since the search is bounded by the block the loop + * sits in. + */ +function usedOnlyInside(path, name) { + let outside = 0 + const parent = path.parentPath + parent.traverse({ + Identifier(id) { + if (id.node.name !== name) return + if (id.findParent((q) => q === path)) return + // the declarator's own id is a binding site, not a use + if ( + id.parentPath.isVariableDeclarator() && + id.parentPath.node.id === id.node + ) + return + outside++ + }, + }) + return outside === 0 +} + +/** `'2|0|3'['split']('|')` or `'2|0|3'.split('|')`, after constant folding has run. */ +function readOrderString(init) { + if (!t.isCallExpression(init) || init.arguments.length !== 1) return null + if (!t.isStringLiteral(init.arguments[0], { value: '|' })) return null + const callee = init.callee + if (!t.isMemberExpression(callee)) return null + const prop = callee.property + const name = t.isStringLiteral(prop) + ? prop.value + : t.isIdentifier(prop) + ? prop.name + : null + if (name !== 'split') return null + if (!t.isStringLiteral(callee.object)) return null + return callee.object.value +} + +/** + * Decide whether this `while` is a flattened block, and gather everything the rewrite needs. + * + * Returns `null` for anything that is not exactly the emitted shape. The strictness is the point: + * an ordinary `while (!done) { switch (state) { ... } break; }` is a perfectly common thing to + * write, and a looser gate rewrites it into straight-line code that runs once. + */ +function match(path) { + const node = path.node + if (!isAlwaysTrueTest(node.test)) return null + if (!t.isBlockStatement(node.body) || node.body.body.length !== 2) return null + const [head, tail] = node.body.body + if (!t.isSwitchStatement(head)) return null + if (!t.isBreakStatement(tail) || tail.label) return null + + const dispatch = readDispatch(head.discriminant) + if (!dispatch) return null + + const cases = head.cases + if (!cases.length) return null + + // Case tests are `String(i)` in ascending order, and there is no `default`. A `default` would + // mean an execution path the order string does not describe. + for (let i = 0; i < cases.length; i++) { + if (!t.isStringLiteral(cases[i].test, { value: String(i) })) return null + } + + // Every consequent is one of the two emitted shapes. Anything else - a bare fallthrough, an + // extra statement, a labelled `continue` - means the tree is not what this pass assumes, and + // walking forward through it is what duplicates statements. + const statements = cases.map((c) => readConsequent(c.consequent)) + if (statements.some((s) => s === null)) return null + + const controllerDecl = resolveDeclarator(path, dispatch.controller) + const indexDecl = resolveDeclarator(path, dispatch.index) + if (!controllerDecl || !indexDecl) return null + + const order = readOrderString(controllerDecl.node.init) + if (order === null) return null + + const indexInit = indexDecl.node.init + if (!t.isNumericLiteral(indexInit, { value: 0 })) return null + + // The order is a permutation of the case indexes: same length, every index once. A shorter or + // longer order string, or a repeated index, means the two halves disagree about the block. + const parts = order.split('|') + if (parts.length !== cases.length) return null + const seen = new Set() + for (const part of parts) { + if (!/^\d+$/.test(part)) return null + const idx = Number(part) + if (idx >= cases.length || seen.has(idx)) return null + seen.add(idx) + } + + // Neither control variable may be touched anywhere but inside this loop, or removing its + // declaration changes what the surrounding code can see. + // + // Stated as containment rather than as a count, because the index is *necessarily* mutated - + // `I++` is the dispatch - so a "no writes" gate can never pass, and a "one reference" gate + // depends on whether Babel books that increment as a read, a write, or both. Asking whether + // every use sits under the node being replaced is the property actually needed, and it does not + // depend on that bookkeeping. + if (!usedOnlyInside(path, dispatch.controller)) return null + if (!usedOnlyInside(path, dispatch.index)) return null + + return { statements, parts, controllerDecl, indexDecl } +} + +/** + * @param {(info: object) => void} [onChange] notified once per rewritten block, so a caller can + * run a pipeline to a fixpoint without re-serializing the tree. + */ +export function createUnflattenSwitchDispatch(onChange) { + return { + WhileStatement: { + exit(path) { + const found = match(path) + if (!found) return + const { statements, parts, controllerDecl, indexDecl } = found + + // Read the controller left to right: position k names the case holding original + // statement k. Empty cases drop out here rather than at the gate, so the permutation + // check above still sees every index. + const rebuilt = parts + .map((part) => statements[Number(part)]) + .filter((s) => s !== EMPTY_CASE) + + controllerDecl.remove() + indexDecl.remove() + // Every case empty is a block that does nothing. `replaceWithMultiple([])` is not a + // removal, so ask for one explicitly. + if (rebuilt.length === 0) path.remove() + else path.replaceWithMultiple(rebuilt) + if (onChange) onChange({ count: rebuilt.length }) + }, + }, + } +} + +export default createUnflattenSwitchDispatch() diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch.test.js b/test/visitor/obfuscator/unflatten-switch-dispatch.test.js new file mode 100644 index 00000000..ad1802b6 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch.test.js @@ -0,0 +1,98 @@ +import { join } from 'path' +import { expect, test } from 'vitest' +import { getVisitorResult } from '../../helper.js' +import { createUnflattenSwitchDispatch } from '#visitor/obfuscator/unflatten-switch-dispatch' + +const root = join(__dirname, 'unflatten-switch-dispatch') + +/** + * The shared helper owns both assertions this suite needs, so it is used rather than reimplemented. + * + * - **A declining case needs no golden.** With `fix` false the helper compares the generated tree + * against the *input source*, which is what "left exactly as found" means; the input file is its + * own expected output. That check is not optional here even though every decline case also + * asserts a zero count: a count of zero says the pass reported no change, not that it made none, + * and mutate-then-decline is precisely the shared visitor's failure mode this fork exists to + * avoid. + * - **A rewriting case gets the reference-state comparison for free**, which a local helper is + * liable to omit: the transformed tree's scope bookkeeping must match a fresh parse of the + * expected output, so a missing or mis-scoped `crawl()` fails even when the printed text is + * identical. Stale scope state was one of this pass's own two defects, so it is the last check + * to hand-roll around. + * + * What the helper cannot supply is the `onChange` count, and that is the only reason for the + * wrapper below. The channel is part of the pass's interface - it is how a caller runs a pipeline + * to a fixpoint without re-serializing the tree - so a rewrite that forgot to report itself would + * silently make a fixpoint driver exit a round early. Nothing else pins that. + */ +function run(name, fix) { + let fired = 0 + getVisitorResult( + createUnflattenSwitchDispatch(() => fired++), + fix, + join(root, name), + ) + return fired +} + +/** + * Five of the seven cases below pin a **decline**, which is unusual for a fixture set and is the + * whole point of this suite. The shared `remove-control-flow-ob.js` passes the two rewrite cases + * and mishandles every decline case - two by corrupting output silently, one by throwing - so a + * suite covering only the happy path would not tell the two implementations apart. + */ + +test('baseline: the controller is a permutation, read left to right', () => { + // order '2|0|3|1' over cases 0=b,1=return d,2=a,3=c -> a(); b(); c(); return d(); + expect(run('baseline', true)).toBe(1) +}) + +test('merged-declaration: `simplify` fuses the two declarations into one', () => { + expect(run('merged-declaration', true)).toBe(1) +}) + +test('not-always-true: an ordinary `while (!done)` loop is left alone', () => { + // The shared visitor accepts any prefix unary here and deletes the loop, emitting its body + // straight-line. Declining is the only safe answer: nothing distinguishes this from a real + // state machine. + expect(run('not-always-true', false)).toBe(0) +}) + +test('fallthrough-case: a case without `continue` or `return` is left alone', () => { + // The shared visitor walks forward into the next case and appends it twice. + expect(run('fallthrough-case', false)).toBe(0) +}) + +test('no-increment: a discriminant without `++` is declined, not thrown on', () => { + // The shared visitor reads `property.argument.name` unguarded and throws, aborting the decode. + expect(run('no-increment', false)).toBe(0) +}) + +test('conditional-declaration: a controller initialised under an `if` is left alone', () => { + // `var` hoists, so a binding lookup resolves it - but the initialisation does not dominate the + // loop, and rewriting would run statements the original never runs. + expect(run('conditional-declaration', false)).toBe(0) +}) + +test('order-mismatch: an order string shorter than the case list is left alone', () => { + expect(run('order-mismatch', false)).toBe(0) +}) + +test('directive-empty-case: a case emptied by directive re-hoisting drops out', () => { + // The encoder produces this on its own, and it is not a corner: control-flow flattening runs at + // stage 4 and puts a function's leading `'use strict'` into a case like any other statement, + // then DirectivePlacementTransformer re-emits the directive at the top of the scope during + // Finalizing - leaving that case with nothing in it. Any flattened function whose body opens + // with a directive lands here, which is a large slice of real-world input. + // + // Found by appending a corpus input carrying a directive: before that, the whole matrix had + // none, and this pass declined on every cff cell of the new fixture at every column. + expect(run('directive-empty-case', true)).toBe(1) +}) + +test('all-cases-empty: a dispatch with nothing in any case removes the loop', () => { + // Hand-built rather than harvested - the encoder has no reason to flatten a body that holds + // only a directive - but the branch it covers is ours, and `replaceWithMultiple([])` is not a + // removal, so without this case the empty path would be untested code. + expect(run('all-cases-empty', true)).toBe(1) +}) diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/all-cases-empty.fix.js b/test/visitor/obfuscator/unflatten-switch-dispatch/all-cases-empty.fix.js new file mode 100644 index 00000000..9aa2d181 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/all-cases-empty.fix.js @@ -0,0 +1 @@ +function f() {} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/all-cases-empty.js b/test/visitor/obfuscator/unflatten-switch-dispatch/all-cases-empty.js new file mode 100644 index 00000000..e5069380 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/all-cases-empty.js @@ -0,0 +1,13 @@ +function f() { + var o = '1|0'.split('|'); + var i = 0; + while (true) { + switch (o[i++]) { + case '0': + continue; + case '1': + continue; + } + break; + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/baseline.fix.js b/test/visitor/obfuscator/unflatten-switch-dispatch/baseline.fix.js new file mode 100644 index 00000000..225f2942 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/baseline.fix.js @@ -0,0 +1,6 @@ +function f() { + a(); + b(); + c(); + return d(); +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/baseline.js b/test/visitor/obfuscator/unflatten-switch-dispatch/baseline.js new file mode 100644 index 00000000..73cc9f39 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/baseline.js @@ -0,0 +1,20 @@ +function f() { + var o = '2|0|3|1'.split('|'); + var i = 0; + while (true) { + switch (o[i++]) { + case '0': + b(); + continue; + case '1': + return d(); + case '2': + a(); + continue; + case '3': + c(); + continue; + } + break; + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/conditional-declaration.js b/test/visitor/obfuscator/unflatten-switch-dispatch/conditional-declaration.js new file mode 100644 index 00000000..60913c0e --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/conditional-declaration.js @@ -0,0 +1,17 @@ +function f(x) { + if (x) { + var o = '1|0'.split('|'); + var i = 0; + } + while (true) { + switch (o[i++]) { + case '0': + a(); + continue; + case '1': + b(); + continue; + } + break; + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/directive-empty-case.fix.js b/test/visitor/obfuscator/unflatten-switch-dispatch/directive-empty-case.fix.js new file mode 100644 index 00000000..e77e7765 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/directive-empty-case.fix.js @@ -0,0 +1,7 @@ +function f() { + 'use strict'; + + a(); + b(); + return c(); +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/directive-empty-case.js b/test/visitor/obfuscator/unflatten-switch-dispatch/directive-empty-case.js new file mode 100644 index 00000000..8a96908d --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/directive-empty-case.js @@ -0,0 +1,21 @@ +function f() { + 'use strict'; + + var o = '3|0|1|2'.split('|'); + var i = 0; + while (true) { + switch (o[i++]) { + case '0': + a(); + continue; + case '1': + b(); + continue; + case '2': + return c(); + case '3': + continue; + } + break; + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/fallthrough-case.js b/test/visitor/obfuscator/unflatten-switch-dispatch/fallthrough-case.js new file mode 100644 index 00000000..39361988 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/fallthrough-case.js @@ -0,0 +1,14 @@ +function f() { + var o = '0|1'.split('|'); + var i = 0; + while (true) { + switch (o[i++]) { + case '0': + a(); + case '1': + b(); + continue; + } + break; + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/merged-declaration.fix.js b/test/visitor/obfuscator/unflatten-switch-dispatch/merged-declaration.fix.js new file mode 100644 index 00000000..13d9cb50 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/merged-declaration.fix.js @@ -0,0 +1,5 @@ +function f() { + a(); + b(); + return c(); +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/merged-declaration.js b/test/visitor/obfuscator/unflatten-switch-dispatch/merged-declaration.js new file mode 100644 index 00000000..b13352be --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/merged-declaration.js @@ -0,0 +1,17 @@ +function f() { + var o = '1|0|2'.split('|'), + i = 0; + while (true) { + switch (o[i++]) { + case '0': + b(); + continue; + case '1': + a(); + continue; + case '2': + return c(); + } + break; + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/no-increment.js b/test/visitor/obfuscator/unflatten-switch-dispatch/no-increment.js new file mode 100644 index 00000000..c4a44e37 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/no-increment.js @@ -0,0 +1,12 @@ +function f() { + var o = '0'.split('|'); + var i = 0; + while (true) { + switch (o[i]) { + case '0': + a(); + continue; + } + break; + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/not-always-true.js b/test/visitor/obfuscator/unflatten-switch-dispatch/not-always-true.js new file mode 100644 index 00000000..6802b603 --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/not-always-true.js @@ -0,0 +1,15 @@ +function f(done) { + var o = '1|0'.split('|'); + var i = 0; + while (!done) { + switch (o[i++]) { + case '0': + a(); + continue; + case '1': + b(); + continue; + } + break; + } +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unflatten-switch-dispatch/order-mismatch.js b/test/visitor/obfuscator/unflatten-switch-dispatch/order-mismatch.js new file mode 100644 index 00000000..b0524ffa --- /dev/null +++ b/test/visitor/obfuscator/unflatten-switch-dispatch/order-mismatch.js @@ -0,0 +1,17 @@ +function f() { + var o = '1|0'.split('|'); + var i = 0; + while (true) { + switch (o[i++]) { + case '0': + a(); + continue; + case '1': + b(); + continue; + case '2': + return c(); + } + break; + } +} \ No newline at end of file From 6a068bec9996ea2a55f142272f0f4fbeca50c22f Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:52:26 +0100 Subject: [PATCH 16/18] feat(visitor/unlock-env): strip the anti-tamper helpers Self-defending, debug protection and console disabling share one removal path because they share the encoder's calls-controller indirection, so the only per-protection code is a classifier table. **The guard is not dormant and never was.** Parse a raw sample and print it back with zero passes applied and the program hangs, at every era in range, while the untouched sample runs - so anything that regenerates without stripping will hang, which is the trap that first looked like a slow corpus. The two self-defending eras are **not equally strippable**, and the newer one fails in the silent direction. The older is classified by `new RegExp`, whose callee no encoding touches, so it matches on an undecoded tree. The newer is classified by `.search` member calls, and that property name is a string-array call until the array resolves - so with the string array unresolved the classifier misses, the guard survives, and it spins forever. A second test file exists because the strip's dependency on an earlier pass is only observable on a composition: run through the shared pipeline helper on one AST, since a fixture built by writing an intermediate to disk is certified across a re-parse that repairs the state a real pipeline carries. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/obfuscator/unlock-env.js | 361 ++++++++++++++++++ .../obfuscator/unlock-env-pipeline.test.js | 64 ++++ .../debug-protection-flattened.fix.js | 9 + .../debug-protection-flattened.js | 1 + test/visitor/obfuscator/unlock-env.test.js | 121 ++++++ .../unlock-env/console-output.fix.js | 5 + .../obfuscator/unlock-env/console-output.js | 42 ++ .../unlock-env/console-output.src.js | 5 + .../debug-protection-interval.fix.js | 5 + .../unlock-env/debug-protection-interval.js | 59 +++ .../debug-protection-interval.src.js | 5 + .../unlock-env/debug-protection.fix.js | 5 + .../obfuscator/unlock-env/debug-protection.js | 56 +++ .../unlock-env/debug-protection.src.js | 5 + .../decline-controller-used-elsewhere.js | 24 ++ .../unlock-env/decline-two-guards.js | 26 ++ .../unlock-env/decline-unrecognised-guard.js | 22 ++ .../unlock-env/interval-fused-sequence.fix.js | 6 + .../unlock-env/interval-fused-sequence.js | 59 +++ .../unlock-env/self-defending-function.fix.js | 5 + .../unlock-env/self-defending-function.js | 23 ++ .../unlock-env/self-defending-function.src.js | 5 + .../unlock-env/self-defending-global.fix.js | 2 + .../unlock-env/self-defending-global.js | 20 + .../unlock-env/self-defending-global.src.js | 2 + .../self-defending-regexp-era.fix.js | 5 + .../unlock-env/self-defending-regexp-era.js | 27 ++ .../self-defending-regexp-era.src.js | 5 + .../unlock-env/two-protections.fix.js | 5 + .../obfuscator/unlock-env/two-protections.js | 60 +++ .../unlock-env/two-protections.src.js | 5 + 31 files changed, 1044 insertions(+) create mode 100644 src/visitor/obfuscator/unlock-env.js create mode 100644 test/visitor/obfuscator/unlock-env-pipeline.test.js create mode 100644 test/visitor/obfuscator/unlock-env-pipeline/debug-protection-flattened.fix.js create mode 100644 test/visitor/obfuscator/unlock-env-pipeline/debug-protection-flattened.js create mode 100644 test/visitor/obfuscator/unlock-env.test.js create mode 100644 test/visitor/obfuscator/unlock-env/console-output.fix.js create mode 100644 test/visitor/obfuscator/unlock-env/console-output.js create mode 100644 test/visitor/obfuscator/unlock-env/console-output.src.js create mode 100644 test/visitor/obfuscator/unlock-env/debug-protection-interval.fix.js create mode 100644 test/visitor/obfuscator/unlock-env/debug-protection-interval.js create mode 100644 test/visitor/obfuscator/unlock-env/debug-protection-interval.src.js create mode 100644 test/visitor/obfuscator/unlock-env/debug-protection.fix.js create mode 100644 test/visitor/obfuscator/unlock-env/debug-protection.js create mode 100644 test/visitor/obfuscator/unlock-env/debug-protection.src.js create mode 100644 test/visitor/obfuscator/unlock-env/decline-controller-used-elsewhere.js create mode 100644 test/visitor/obfuscator/unlock-env/decline-two-guards.js create mode 100644 test/visitor/obfuscator/unlock-env/decline-unrecognised-guard.js create mode 100644 test/visitor/obfuscator/unlock-env/interval-fused-sequence.fix.js create mode 100644 test/visitor/obfuscator/unlock-env/interval-fused-sequence.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-function.fix.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-function.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-function.src.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-global.fix.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-global.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-global.src.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-regexp-era.fix.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-regexp-era.js create mode 100644 test/visitor/obfuscator/unlock-env/self-defending-regexp-era.src.js create mode 100644 test/visitor/obfuscator/unlock-env/two-protections.fix.js create mode 100644 test/visitor/obfuscator/unlock-env/two-protections.js create mode 100644 test/visitor/obfuscator/unlock-env/two-protections.src.js diff --git a/src/visitor/obfuscator/unlock-env.js b/src/visitor/obfuscator/unlock-env.js new file mode 100644 index 00000000..369dfab8 --- /dev/null +++ b/src/visitor/obfuscator/unlock-env.js @@ -0,0 +1,361 @@ +import traverse from '@babel/traverse' +import * as t from '@babel/types' + +import logger from '../../utility/logger.js' + +const debugLog = logger.debugLog + +/** + * Strip javascript-obfuscator's custom code helpers: the self-defending guard, the console-output + * disabler, the debug-protection trap, and the calls controller the three of them run through. + * + * The encoder injects these at its *second* stage, so by the time anything here sees them they + * have been through dead-code injection, control-flow flattening, literal re-spelling, renaming + * and the string array. This pass therefore assumes those have already been reversed: it matches + * decoded shapes, and it is the last thing in the pipeline rather than the first. + * + * Each protection is emitted as two pieces in *different scopes* - a definition and a trigger - + * plus a per-group calls controller: + * + * var C = (function () { // the calls controller, one PER GROUP + * var first = true; + * return function (context, fn) { + * var r = first ? function () { if (fn) { ... } } : function () {}; + * first = false; + * return r; + * }; + * })(); + * + * var G = C(this, function () { ... }); // the definition + * G(); // the trigger + * + * Debug protection is the exception: its guard is invoked where it is built, inside an IIFE with + * no name bound to it, and it additionally emits a top-level `function D(ret) { ... }` and an + * optional `setInterval` firing it. + * + * **Deleting the controller with its guard is only safe because of an encoder property**, not a + * decoder one: each helper group builds its *own* controller rather than sharing one, so a + * controller never has a second guard depending on it. This pass verifies that per controller + * instead of assuming it, and declines when it does not hold - which is what would happen against + * a variant encoder that shared them. + * + * **The console-output guard references its own controller from inside its callback** + * (`C.constructor.prototype.bind(C)`, `C.bind(C)`). So a liveness test on the controller must not + * count references that live inside the guard being removed. Here that is explicit: references are + * partitioned before anything is deleted, rather than relying on deleting the guard first and + * re-crawling. + * + * **Match completely, then mutate.** Every gate is checked before the first removal, so a guard is + * either taken out whole or left exactly as found. Declining costs legible residue that a census + * counts; a half-removed guard leaves a program referencing a binding that no longer exists. + */ + +/** `function (...) {}` or `(...) => {}`, with a block body. */ +function isFnWithBlock(node) { + return ( + (t.isFunctionExpression(node) || t.isArrowFunctionExpression(node)) && t.isBlockStatement(node.body) + ) +} + +/** + * A member key in the two spellings that reach this point: `o.k` and `o['k']`. + * + * Reading only the first is the trap that makes a matcher accept hand-built cases and reject every + * real one - our own `Converting` reversal un-computes most keys, but not all of them, so both + * spellings are live in this pass's input. + */ +function memberKey(node) { + if (!t.isMemberExpression(node)) return null + if (!node.computed && t.isIdentifier(node.property)) return node.property.name + if (node.computed && t.isStringLiteral(node.property)) return node.property.value + return null +} + +/** + * Count nodes under `node` matching `pred`. + * + * A plain walk rather than a Babel traversal: the subjects here are block statements and function + * bodies, which `traverse` cannot be pointed at without a path or a synthetic program wrapper, and + * wrapping is what makes such a helper throw on the first node type it cannot convert. + */ +function countNodes(node, pred) { + let n = 0 + const walk = (x) => { + if (!x || typeof x.type !== 'string') return + if (pred(x)) n++ + for (const key of t.VISITOR_KEYS[x.type] || []) { + const v = x[key] + if (Array.isArray(v)) v.forEach(walk) + else walk(v) + } + } + walk(node) + return n +} + +/** + * The calls controller, matched on shape: an immediately-invoked function taking no arguments, + * whose body returns a two-parameter function that chooses between two function expressions. + * + * Keyed on the choice rather than on the `firstCall` flag, because the flag is a renamed local and + * the conditional is the part that carries the meaning - the wrapper runs its target once. + */ +function isCallsControllerInit(node) { + if (!t.isCallExpression(node) || node.arguments.length || !isFnWithBlock(node.callee)) return false + return ( + countNodes(node.callee.body, (n) => + isFnWithBlock(n) && + n.params.length === 2 && + countNodes(n.body, (c) => + t.isConditionalExpression(c) && isFnWithBlock(c.consequent) && isFnWithBlock(c.alternate)) > 0) > 0 + ) +} + +/** + * Which protection a guard callback implements, or `null` for a shape this pass does not know. + * + * Returning `null` is a decline, never a default: an unrecognised callback is left in place so a + * residue census still counts it. The alternative - treating "matched the wrapper" as enough - + * would delete arbitrary code that happens to be called as `C(this, fn)`. + */ +function classifyGuard(body) { + const searches = countNodes(body, (n) => t.isCallExpression(n) && memberKey(n.callee) === 'search') + if (searches >= 2) return 'self-defending' + + const regexps = countNodes(body, (n) => t.isNewExpression(n) && t.isIdentifier(n.callee, { name: 'RegExp' })) + if (regexps >= 2) return 'debug-protection-call' + + const methodList = countNodes(body, (n) => { + if (!t.isArrayExpression(n) || n.elements.length < 5) return false + const strings = n.elements.filter((e) => t.isStringLiteral(e)).map((e) => e.value) + return strings.length >= 5 && strings.includes('log') && strings.includes('warn') + }) + if (methodList > 0) return 'console-output' + + // The era below `E-selfdef-search` builds its regexp through `constructor` rather than `RegExp`, + // and is recognised by the nested function it declares and immediately calls. Checked last + // because it is the loosest of the four. + const nested = countNodes(body, (n) => isFnWithBlock(n) || t.isFunctionDeclaration(n)) + if (nested > 0) return 'self-defending' + + return null +} + +/** Is `path` inside `ancestor`? Asked of the live tree, never of cached positions. */ +function isInside(path, ancestorNode) { + return path.findParent((p) => p.node === ancestorNode) !== null +} + +/** + * The outermost statement that exists only to hold this guard. + * + * Debug protection's guard is invoked where it is built, inside an IIFE that wraps nothing else: + * + * (function () { C(this, function () { … })(); })(); + * + * Removing the inner statement leaves `(function () {})();` behind - a statement with no effect + * that no census keyed on the *encoder's* shapes can see, because the encoder never emits it. We + * do. So the removal target is computed by ascending through every wrapper whose body holds this + * statement and nothing else, and the ascent is written as a loop because nesting depth is not + * something to assume. + */ +/** + * What to delete for a single effect. + * + * The encoder's adjacent-statement merging fuses neighbouring statements into one sequence + * expression, and it does not care whose they are - a removal target of ours can end up sharing a + * statement with the program's own calls. Deleting the statement then deletes those too, which is + * corruption rather than residue and is silent: the program runs and simply stops producing + * output. So delete the sequence *element* when there is one, and the statement otherwise. + */ +function effectPath(callPath) { + return callPath.parentPath && callPath.parentPath.isSequenceExpression() + ? callPath + : callPath.getStatementParent() +} + +function outermostWrapperStatement(guardCall) { + // A fused guard yields its own element here, not a statement; the loop below then declines to + // walk outward on its first test, which is correct - there is no wrapper to unwrap. + let stmt = effectPath(guardCall) + for (;;) { + const block = stmt.parentPath + if (!block || !block.isBlockStatement()) break + if (block.node.body.length !== 1 || block.node.body[0] !== stmt.node) break + const fn = block.parentPath + if (!fn || !isFnWithBlock(fn.node) || fn.node.params.length) break + const call = fn.parentPath + if (!call || !call.isCallExpression() || call.node.callee !== fn.node || call.node.arguments.length) break + const outer = call.getStatementParent() + if (!outer || !outer.isExpressionStatement()) break + stmt = outer + } + return stmt +} + +/** + * Remove one guard and the controller it runs through, or leave both untouched. + * + * Returns the protection's name when it removed something, `null` when it declined. + */ +function stripGuard(controllerPath, removed) { + const id = controllerPath.node.id + if (!t.isIdentifier(id)) return null + const binding = controllerPath.scope.getBinding(id.name) + if (!binding) return null + + // Partition the controller's references BEFORE touching anything: the calls that build a guard, + // and everything else. The console-output callback puts several of the latter inside the former. + const guardCalls = [] + const others = [] + for (const ref of binding.referencePaths) { + const parent = ref.parentPath + if ( + parent && parent.isCallExpression() && parent.node.callee === ref.node && + parent.node.arguments.length === 2 && + t.isThisExpression(parent.node.arguments[0]) && isFnWithBlock(parent.node.arguments[1]) + ) { + guardCalls.push(parent) + } else { + others.push(ref) + } + } + if (guardCalls.length !== 1) { + debugLog(`unlock-env: declining ${id.name}, ${guardCalls.length} guards on one controller`) + return null + } + const guardCall = guardCalls[0] + const callback = guardCall.node.arguments[1] + const kind = classifyGuard(callback.body) + if (!kind) { + debugLog(`unlock-env: declining ${id.name}, unrecognised guard callback`) + return null + } + // Every remaining reference must be inside the callback we are about to delete. One that is not + // means something else uses this controller, and deleting it would break that caller. + if (others.some((ref) => !isInside(ref, callback))) { + debugLog(`unlock-env: declining ${id.name}, controller referenced outside its guard`) + return null + } + + // Two definition shapes. `var G = C(this, fn); G()` binds a name, and the trigger is a separate + // statement; debug protection's call form invokes the guard where it is built and binds nothing. + const declarator = guardCall.parentPath.isVariableDeclarator() ? guardCall.parentPath : null + let triggers = [] + if (declarator) { + if (!t.isIdentifier(declarator.node.id)) return null + const guardBinding = declarator.scope.getBinding(declarator.node.id.name) + if (!guardBinding) return null + for (const ref of guardBinding.referencePaths) { + const call = ref.parentPath + if (call && call.isCallExpression() && call.node.callee === ref.node && !call.node.arguments.length) { + triggers.push(call.parentPath.isExpressionStatement() ? call.parentPath : call) + } else if (!isInside(ref, callback)) { + debugLog(`unlock-env: declining ${id.name}, guard referenced outside its own trigger`) + return null + } + } + } + + // --- past every gate; only now does anything move --- + for (const trigger of triggers) trigger.remove() + if (declarator) { + declarator.remove() + } else { + // the guard is invoked in place: remove the whole wrapper that exists only to hold it + outermostWrapperStatement(guardCall).remove() + } + controllerPath.remove() + removed.push(kind) + return kind +} + +/** + * The debug-protection function and whatever fires it. + * + * Matched on its two-statement body - a nested function declaration and a `try`/`catch` - which is + * the shape the encoder's template guarantees and which no program body reaches by accident. + * Removed after the guards, because the guard callback that calls this function is one of its + * references and must be gone before the rest can be read. + */ +function stripDebugProtectionFunction(path, removed) { + const { id, params, body } = path.node + if (!t.isIdentifier(id) || params.length !== 1 || body.body.length !== 2) return + if (!t.isFunctionDeclaration(body.body[0]) || !t.isTryStatement(body.body[1])) return + + const binding = path.scope.getBinding(id.name) + if (!binding) return + + // Each remaining reference must be an interval firing it. Anything else and the function is + // still doing work we do not understand, so it stays. + const intervals = [] + for (const ref of binding.referencePaths) { + const fnParent = ref.getFunctionParent() + const call = fnParent && fnParent.parentPath + if (call && call.isCallExpression() && t.isIdentifier(call.node.callee, { name: 'setInterval' })) { + intervals.push(effectPath(call)) + continue + } + if (ref.parentPath.isCallExpression() && + t.isIdentifier(ref.parentPath.node.callee, { name: 'setInterval' })) { + intervals.push(effectPath(ref.parentPath)) + continue + } + debugLog(`unlock-env: declining ${id.name}, debug-protection referenced outside an interval`) + return + } + + for (const interval of intervals) if (!interval.removed) interval.remove() + path.remove() + removed.push('debug-protection') +} + +/** + * Strip every custom code helper the sample carries. Returns the list of protections removed, so a + * caller can report what it did rather than inferring it from a diff. + */ +export default function unlockEnv(ast) { + const removed = [] + + // This pass's gates read `binding.referencePaths`, so it depends on those references still + // pointing into the live tree. That is now guaranteed by the pass that breaks it rather than + // defended here: `prune-if-branch` detaches subtrees and crawls on its way out, so no stale + // reference reaches this point. This pass previously opened with a `traverse.cache.clear()` to + // repair it, which was a symptom patch at the consumer - removed once the producer was fixed, + // and its removal verified byte-identical over the corpus. + // + // Guards first. Each one's callback holds references to the debug-protection function and to its + // own controller, so removing guards is what makes the remaining references readable. + traverse(ast, { + VariableDeclarator(path) { + if (!isCallsControllerInit(path.node.init)) return + stripGuard(path, removed) + }, + }) + + // Re-crawl before reading any binding again. The guard removals above detached nodes, and a + // binding records its references as of the last crawl - so without this the debug-protection + // function still lists the reference that lived inside the guard callback we just deleted, that + // reference matches none of the accepted shapes, and the pass declines on every sample that has + // one. This is the stale-scope trap, and the tell was exactly the one on record - a matcher that + // accepts a freshly parsed tree and rejects every tree a pass has touched. + // + // The crawl is the whole remedy here, because the only nodes detached before this point are the + // ones this pass's own first phase removed, and a crawl repairs what it detached. It used to be + // half of one: a `traverse.cache.clear()` opened this function to absorb staleness inherited + // from `prune-if-branch`, which is now repaired at that producer instead. + traverse(ast, { + Program(path) { + path.scope.crawl() + }, + }) + + traverse(ast, { + FunctionDeclaration(path) { + stripDebugProtectionFunction(path, removed) + }, + }) + + if (removed.length) debugLog(`unlock-env: removed ${removed.join(', ')}`) + return ast +} diff --git a/test/visitor/obfuscator/unlock-env-pipeline.test.js b/test/visitor/obfuscator/unlock-env-pipeline.test.js new file mode 100644 index 00000000..e6400145 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env-pipeline.test.js @@ -0,0 +1,64 @@ +import { join } from 'path' +import { test } from 'vitest' +import traverse from '@babel/traverse' + +import normalizeStatements from '#visitor/obfuscator/normalize-statements' +import decodeStringArray from '#visitor/obfuscator/string-array' +import normalizeConverting from '#visitor/obfuscator/normalize-converting' +import parseControlFlowStorage from '#visitor/parse-control-flow-storage' +import calculateConstantExp from '#visitor/calculate-constant-exp' +import pruneIfBranch from '#visitor/prune-if-branch' +import { createUnflattenSwitchDispatch } from '#visitor/obfuscator/unflatten-switch-dispatch' +import unlockEnv from '#visitor/obfuscator/unlock-env' +import { getPipelineResult } from '../../helper.js' + +/** + * The one case that runs `unlock-env` on a tree the earlier passes have actually rewritten, in + * memory, rather than on a re-parse of their output. + * + * **This is not a duplicate of `unlock-env.test.js`.** Those cases pin the pass's matchers, and + * every one of them parses a pre-baked file — so no earlier pass has detached anything and the + * tree is pristine by construction. That is precisely the state a real pipeline never has. Run on + * one AST, this pass declined on every cell combining debug protection with control-flow + * flattening, deleting the calls controller while leaving the guard, and the output threw a + * `ReferenceError`. The corpus census and runtime equivalence both read clean beforehand, because + * both were measured across a re-parse. + * + * So the input here is **raw encoder output** and the passes run inside the test. Writing the + * intermediate to disk and reading it back would restore exactly the state that hid the defect. + * + * The input is `2.9.6` `all-on`, the smallest cell in the corpus that reproduces. The golden was + * written by a builder that refuses unless the result **runs** and reproduces the pre-obfuscation + * source's output, and unless no anti-tamper residue remains — a residue count alone cannot + * separate "stripped" from "deleted too much", since both drive it to zero. + * + * **This case is coupled to the passes ahead of it on purpose.** An upstream change that moves + * decoded output breaks it, and that is the point: the dependency is the thing under test. + */ +test('unlock-env strips debug protection on a tree the pipeline has rewritten', () => { + // The pipeline's own order, and the fixpoint the U4+U5 group needs: storage inlining re-opens + // Converting work that had already reported clean, so one sweep is not enough. + const group = (ast) => { + let previous = null + for (let round = 0; round < 8; round++) { + normalizeConverting(ast) + traverse(ast, calculateConstantExp) + traverse(ast, parseControlFlowStorage) + traverse(ast, calculateConstantExp) + traverse(ast, pruneIfBranch) + traverse( + ast, + createUnflattenSwitchDispatch(() => {}), + ) + const current = JSON.stringify(ast.program) + if (current === previous) break + previous = current + } + } + + getPipelineResult( + [normalizeStatements, decodeStringArray, group, unlockEnv], + true, + join(__dirname, 'unlock-env-pipeline', 'debug-protection-flattened'), + ) +}) diff --git a/test/visitor/obfuscator/unlock-env-pipeline/debug-protection-flattened.fix.js b/test/visitor/obfuscator/unlock-env-pipeline/debug-protection-flattened.fix.js new file mode 100644 index 00000000..07c7519b --- /dev/null +++ b/test/visitor/obfuscator/unlock-env-pipeline/debug-protection-flattened.fix.js @@ -0,0 +1,9 @@ +function _0xdd673(_0x470766) { + return "hello, " + _0x470766 + '\x21'; +} +var _0x497fc4 = ["alpha", "beta", "gamma"]; +var _0x4d2827 = _0x497fc4.join('\x2d'); +var _0x5e7db6 = _0x4d2827.toUpperCase(); +console.log("console-channel"); +process.stdout.write(_0xdd673("world") + '\x0a'); +process.stdout.write(_0x4d2827 + '\x20' + _0x5e7db6 + '\x20' + _0x497fc4.length + '\x20' + "literal".charAt(0) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env-pipeline/debug-protection-flattened.js b/test/visitor/obfuscator/unlock-env-pipeline/debug-protection-flattened.js new file mode 100644 index 00000000..572c954c --- /dev/null +++ b/test/visitor/obfuscator/unlock-env-pipeline/debug-protection-flattened.js @@ -0,0 +1 @@ +function _0xdd673(_0x470766){var _0x32221b={};_0x32221b['\x5a\x6d'+'\x68\x77'+'\x45']=function(_0x5dd8c7){return _0x5dd8c7();},_0x32221b['\x68\x55'+'\x64\x6f'+'\x52']=function(_0x562216,_0x1dbd46){return _0x562216===_0x1dbd46;},_0x32221b['\x4d\x4c'+'\x54\x7a'+'\x44']='\x50\x48'+'\x45\x5a'+'\x41',_0x32221b['\x4b\x71'+'\x57\x74'+'\x77']='\x79\x7a'+'\x55\x64'+'\x67',_0x32221b['\x53\x49'+'\x72\x6e'+'\x54']='\x62\x59'+'\x44\x64'+'\x78',_0x32221b['\x62\x78'+'\x72\x4a'+'\x6b']=function(_0xfd02ef,_0x506720){return _0xfd02ef+_0x506720;},_0x32221b['\x41\x73'+'\x77\x5a'+'\x54']='\x64\x65'+'\x62\x75',_0x32221b['\x53\x72'+'\x45\x5a'+'\x56']='\x67\x67'+'\x65\x72',_0x32221b['\x69\x6d'+'\x65\x78'+'\x4c']='\x73\x74'+'\x61\x74'+'\x65\x4f'+'\x62\x6a'+'\x65\x63'+'\x74',_0x32221b['\x63\x52'+'\x4a\x52'+'\x74']=function(_0x8ffeda,_0x327580){return _0x8ffeda(_0x327580);},_0x32221b['\x75\x47'+'\x52\x4b'+'\x55']=function(_0x5b0a17,_0x3b9e58){return _0x5b0a17===_0x3b9e58;},_0x32221b['\x76\x66'+'\x57\x63'+'\x50']='\x42\x76'+'\x7a\x48'+'\x5a',_0x32221b['\x4e\x62'+'\x4c\x4c'+'\x56']='\x79\x52'+'\x47\x53'+'\x50',_0x32221b['\x73\x43'+'\x4a\x6e'+'\x5a']=function(_0x11ee08,_0x37c07c){return _0x11ee08!==_0x37c07c;},_0x32221b['\x71\x79'+'\x61\x6e'+'\x76']='\x47\x65'+'\x68\x49'+'\x58',_0x32221b['\x6d\x4d'+'\x48\x4b'+'\x6d']=function(_0x3774d9,_0x4aadeb){return _0x3774d9(_0x4aadeb);},_0x32221b['\x74\x43'+'\x41\x64'+'\x44']='\x61\x63'+'\x74\x69'+'\x6f\x6e',_0x32221b['\x6c\x41'+'\x6a\x55'+'\x4d']='\x54\x78'+'\x70\x74'+'\x61',_0x32221b['\x51\x75'+'\x53\x65'+'\x52']='\x66\x75'+'\x6e\x63'+'\x74\x69'+'\x6f\x6e'+'\x20\x2a'+'\x5c\x28'+'\x20\x2a'+'\x5c\x29',_0x32221b['\x6b\x70'+'\x62\x56'+'\x73']='\x5c\x2b'+'\x5c\x2b'+'\x20\x2a'+'\x28\x3f'+'\x3a\x5b'+'\x61\x2d'+'\x7a\x41'+'\x2d\x5a'+'\x5f\x24'+'\x5d\x5b'+'\x30\x2d'+'\x39\x61'+'\x2d\x7a'+'\x41\x2d'+'\x5a\x5f'+'\x24\x5d'+'\x2a\x29',_0x32221b['\x75\x71'+'\x71\x55'+'\x4b']='\x69\x6e'+'\x69\x74',_0x32221b['\x49\x56'+'\x71\x45'+'\x4c']=function(_0x3a8c14,_0x1e3e17){return _0x3a8c14+_0x1e3e17;},_0x32221b['\x44\x6e'+'\x55\x71'+'\x51']='\x63\x68'+'\x61\x69'+'\x6e',_0x32221b['\x4b\x6d'+'\x68\x62'+'\x66']=function(_0x4cd7b8,_0x533d94){return _0x4cd7b8+_0x533d94;},_0x32221b['\x79\x74'+'\x4c\x6f'+'\x65']='\x69\x6e'+'\x70\x75'+'\x74',_0x32221b['\x68\x6c'+'\x6f\x70'+'\x53']='\x6d\x4f'+'\x4b\x47'+'\x67',_0x32221b['\x4f\x69'+'\x70\x62'+'\x51']='\x6a\x75'+'\x67\x56'+'\x69',_0x32221b['\x66\x43'+'\x78\x4a'+'\x75']=function(_0x5ac93d,_0x300c2e){return _0x5ac93d(_0x300c2e);},_0x32221b['\x65\x4e'+'\x62\x70'+'\x79']='\x6b\x49'+'\x78\x41'+'\x52',_0x32221b['\x41\x45'+'\x56\x68'+'\x64']='\x43\x66'+'\x70\x58'+'\x47',_0x32221b['\x67\x41'+'\x56\x48'+'\x6f']=function(_0x24f833,_0x1ebbf4){return _0x24f833(_0x1ebbf4);},_0x32221b['\x46\x66'+'\x74\x6b'+'\x71']='\x72\x65'+'\x74\x75'+'\x72\x6e'+'\x20\x28'+'\x66\x75'+'\x6e\x63'+'\x74\x69'+'\x6f\x6e'+'\x28\x29'+'\x20',_0x32221b['\x4d\x4b'+'\x6b\x73'+'\x66']='\x7b\x7d'+'\x2e\x63'+'\x6f\x6e'+'\x73\x74'+'\x72\x75'+'\x63\x74'+'\x6f\x72'+'\x28\x22'+'\x72\x65'+'\x74\x75'+'\x72\x6e'+'\x20\x74'+'\x68\x69'+'\x73\x22'+'\x29\x28'+'\x20\x29',_0x32221b['\x48\x4e'+'\x75\x5a'+'\x4f']=function(_0x2a1a44,_0x145fd5){return _0x2a1a44===_0x145fd5;},_0x32221b['\x58\x78'+'\x73\x45'+'\x67']='\x61\x68'+'\x65\x4c'+'\x42',_0x32221b['\x62\x4c'+'\x4a\x74'+'\x70']='\x70\x6d'+'\x43\x75'+'\x6c',_0x32221b['\x76\x44'+'\x6c\x4e'+'\x71']=function(_0x5c95a1,_0x48b492,_0x127e39){return _0x5c95a1(_0x48b492,_0x127e39);},_0x32221b['\x47\x46'+'\x4d\x79'+'\x76']='\x32\x7c'+'\x30\x7c'+'\x34\x7c'+'\x31\x7c'+'\x35\x7c'+'\x33',_0x32221b['\x55\x4a'+'\x41\x49'+'\x79']=function(_0x1a2fea,_0x3cbc2a){return _0x1a2fea!==_0x3cbc2a;},_0x32221b['\x6d\x62'+'\x6e\x64'+'\x58']='\x4e\x57'+'\x45\x54'+'\x4f',_0x32221b['\x56\x5a'+'\x57\x70'+'\x7a']='\x64\x50'+'\x4f\x67'+'\x6a',_0x32221b['\x6f\x72'+'\x68\x7a'+'\x66']='\x58\x6d'+'\x77\x65'+'\x59',_0x32221b['\x54\x66'+'\x6f\x42'+'\x74']=function(_0x3ea181,_0x30ca8d){return _0x3ea181===_0x30ca8d;},_0x32221b['\x53\x6e'+'\x67\x50'+'\x45']='\x79\x6b'+'\x4e\x59'+'\x73',_0x32221b['\x72\x41'+'\x6f\x51'+'\x55']='\x4c\x44'+'\x48\x6a'+'\x61',_0x32221b['\x49\x77'+'\x6f\x6d'+'\x79']='\x4f\x55'+'\x45\x4d'+'\x4d',_0x32221b['\x65\x52'+'\x59\x48'+'\x75']='\x4b\x61'+'\x77\x4c'+'\x47',_0x32221b['\x6f\x54'+'\x43\x74'+'\x6f']=function(_0x4b9784,_0x472b1d,_0x592225){return _0x4b9784(_0x472b1d,_0x592225);},_0x32221b['\x6c\x48'+'\x4f\x69'+'\x67']=function(_0x172472,_0x16ae21){return _0x172472+_0x16ae21;},_0x32221b['\x57\x7a'+'\x71\x58'+'\x62']=function(_0x15ffb6,_0x431474){return _0x15ffb6+_0x431474;},_0x32221b['\x47\x49'+'\x65\x42'+'\x4a']=function(_0x4cca76,_0x3b1856){return _0x4cca76(_0x3b1856);},_0x32221b['\x42\x75'+'\x63\x70'+'\x4c']=function(_0x43a9a7,_0x35c992){return _0x43a9a7!==_0x35c992;},_0x32221b['\x6e\x64'+'\x49\x64'+'\x6d']='\x6e\x6a'+'\x77\x44'+'\x59',_0x32221b['\x62\x79'+'\x7a\x5a'+'\x71']='\x43\x6b'+'\x4f\x67'+'\x70',_0x32221b['\x6a\x46'+'\x6f\x51'+'\x41']=function(_0x3ac94a,_0x4fbefc){return _0x3ac94a(_0x4fbefc);},_0x32221b['\x51\x6f'+'\x6f\x75'+'\x52']='\x74\x4f'+'\x4e\x6c'+'\x79',_0x32221b['\x55\x71'+'\x6a\x72'+'\x50']='\x6e\x5a'+'\x43\x44'+'\x46',_0x32221b['\x62\x59'+'\x44\x64'+'\x71']='\x54\x51'+'\x52\x63'+'\x61',_0x32221b['\x57\x77'+'\x49\x63'+'\x71']=function(_0x21cb63){return _0x21cb63();},_0x32221b['\x61\x53'+'\x4e\x48'+'\x75']='\x6c\x6f'+'\x67',_0x32221b['\x77\x52'+'\x54\x78'+'\x4d']='\x77\x61'+'\x72\x6e',_0x32221b['\x49\x54'+'\x4c\x68'+'\x45']='\x69\x6e'+'\x66\x6f',_0x32221b['\x61\x69'+'\x63\x6b'+'\x58']='\x65\x72'+'\x72\x6f'+'\x72',_0x32221b['\x4d\x51'+'\x41\x43'+'\x79']='\x65\x78'+'\x63\x65'+'\x70\x74'+'\x69\x6f'+'\x6e',_0x32221b['\x4e\x75'+'\x48\x78'+'\x52']='\x74\x61'+'\x62\x6c'+'\x65',_0x32221b['\x6d\x54'+'\x55\x70'+'\x55']='\x74\x72'+'\x61\x63'+'\x65',_0x32221b['\x48\x71'+'\x79\x49'+'\x67']=function(_0x400e6b,_0x2c5465){return _0x400e6b<_0x2c5465;},_0x32221b['\x65\x55'+'\x69\x6c'+'\x78']='\x72\x75'+'\x4e\x52'+'\x71',_0x32221b['\x55\x6b'+'\x75\x4c'+'\x53']='\x32\x7c'+'\x33\x7c'+'\x31\x7c'+'\x30\x7c'+'\x35\x7c'+'\x34',_0x32221b['\x41\x74'+'\x5a\x54'+'\x48']=function(_0x55f9cd,_0x1f214e,_0x2f1d4b){return _0x55f9cd(_0x1f214e,_0x2f1d4b);},_0x32221b['\x46\x54'+'\x7a\x53'+'\x4c']=function(_0x457cdf,_0x2507f8){return _0x457cdf+_0x2507f8;},_0x32221b['\x66\x65'+'\x4a\x69'+'\x49']='\x68\x65'+'\x6c\x6c'+'\x6f\x2c'+'\x20';var _0x2d4ba5=_0x32221b,_0x2cc314=function(){var _0xa57717={};_0xa57717['\x57\x72'+'\x6b\x75'+'\x6c']=function(_0x4b2aff){return _0x2d4ba5['\x5a\x6d'+'\x68\x77'+'\x45'](_0x4b2aff);},_0xa57717['\x52\x52'+'\x49\x67'+'\x76']=function(_0xd24a69,_0x5af1c0){return _0x2d4ba5['\x68\x55'+'\x64\x6f'+'\x52'](_0xd24a69,_0x5af1c0);},_0xa57717['\x46\x50'+'\x46\x71'+'\x6c']=_0x2d4ba5['\x4d\x4c'+'\x54\x7a'+'\x44'],_0xa57717['\x55\x76'+'\x44\x46'+'\x52']=_0x2d4ba5['\x4b\x71'+'\x57\x74'+'\x77'],_0xa57717['\x78\x59'+'\x6e\x54'+'\x67']=_0x2d4ba5['\x53\x49'+'\x72\x6e'+'\x54'],_0xa57717['\x44\x69'+'\x44\x5a'+'\x4f']=function(_0x401b22,_0x4e4e4e){return _0x2d4ba5['\x62\x78'+'\x72\x4a'+'\x6b'](_0x401b22,_0x4e4e4e);},_0xa57717['\x4b\x66'+'\x57\x6d'+'\x76']=_0x2d4ba5['\x41\x73'+'\x77\x5a'+'\x54'],_0xa57717['\x44\x48'+'\x47\x64'+'\x6c']=_0x2d4ba5['\x53\x72'+'\x45\x5a'+'\x56'],_0xa57717['\x66\x44'+'\x4c\x5a'+'\x41']=_0x2d4ba5['\x69\x6d'+'\x65\x78'+'\x4c'],_0xa57717['\x5a\x73'+'\x77\x6f'+'\x67']=function(_0x96d85f,_0x1bbd97){return _0x2d4ba5['\x63\x52'+'\x4a\x52'+'\x74'](_0x96d85f,_0x1bbd97);},_0xa57717['\x6f\x69'+'\x68\x79'+'\x47']=function(_0x530b64,_0x3a4655){return _0x2d4ba5['\x75\x47'+'\x52\x4b'+'\x55'](_0x530b64,_0x3a4655);},_0xa57717['\x6d\x52'+'\x78\x78'+'\x70']=_0x2d4ba5['\x76\x66'+'\x57\x63'+'\x50'],_0xa57717['\x4c\x6d'+'\x67\x52'+'\x68']=_0x2d4ba5['\x4e\x62'+'\x4c\x4c'+'\x56'];var _0x2acd06=_0xa57717;if(_0x2d4ba5['\x73\x43'+'\x4a\x6e'+'\x5a'](_0x2d4ba5['\x71\x79'+'\x61\x6e'+'\x76'],_0x2d4ba5['\x71\x79'+'\x61\x6e'+'\x76'])){function _0x30c460(){return!![];}}else{var _0x20dc8b=!![];return function(_0x175fb0,_0x5d1421){var _0x106e8d={};_0x106e8d['\x6f\x57'+'\x75\x45'+'\x59']=function(_0x59c331,_0x5f1f2e){return _0x2acd06['\x44\x69'+'\x44\x5a'+'\x4f'](_0x59c331,_0x5f1f2e);},_0x106e8d['\x50\x70'+'\x48\x59'+'\x51']=_0x2acd06['\x4b\x66'+'\x57\x6d'+'\x76'],_0x106e8d['\x47\x6b'+'\x43\x46'+'\x51']=_0x2acd06['\x44\x48'+'\x47\x64'+'\x6c'],_0x106e8d['\x6f\x69'+'\x62\x78'+'\x74']=_0x2acd06['\x66\x44'+'\x4c\x5a'+'\x41'],_0x106e8d['\x76\x5a'+'\x57\x68'+'\x65']=function(_0x50186d,_0x27d3b7){return _0x2acd06['\x5a\x73'+'\x77\x6f'+'\x67'](_0x50186d,_0x27d3b7);};var _0xd8f9fe=_0x106e8d;if(_0x2acd06['\x6f\x69'+'\x68\x79'+'\x47'](_0x2acd06['\x6d\x52'+'\x78\x78'+'\x70'],_0x2acd06['\x4c\x6d'+'\x67\x52'+'\x68'])){function _0x454750(){mnEPoz['\x57\x72'+'\x6b\x75'+'\x6c'](_0x5005e4);}}else{var _0x9fc2f4=_0x20dc8b?function(){if(_0x2acd06['\x52\x52'+'\x49\x67'+'\x76'](_0x2acd06['\x46\x50'+'\x46\x71'+'\x6c'],_0x2acd06['\x55\x76'+'\x44\x46'+'\x52'])){function _0x3702e6(){(function(){return![];}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](dWVfyv['\x6f\x57'+'\x75\x45'+'\x59'](dWVfyv['\x50\x70'+'\x48\x59'+'\x51'],dWVfyv['\x47\x6b'+'\x43\x46'+'\x51']))['\x61\x70'+'\x70\x6c'+'\x79'](dWVfyv['\x6f\x69'+'\x62\x78'+'\x74']));}}else{if(_0x5d1421){if(_0x2acd06['\x52\x52'+'\x49\x67'+'\x76'](_0x2acd06['\x78\x59'+'\x6e\x54'+'\x67'],_0x2acd06['\x78\x59'+'\x6e\x54'+'\x67'])){var _0x58bb48=_0x5d1421['\x61\x70'+'\x70\x6c'+'\x79'](_0x175fb0,arguments);return _0x5d1421=null,_0x58bb48;}else{function _0x133b3e(){dWVfyv['\x76\x5a'+'\x57\x68'+'\x65'](_0xb850e7,'\x30');}}}}}:function(){};return _0x20dc8b=![],_0x9fc2f4;}};}}();(function(){var _0x24210c={};_0x24210c['\x52\x43'+'\x73\x69'+'\x58']=function(_0x3c2ba2,_0x599651){return _0x2d4ba5['\x67\x41'+'\x56\x48'+'\x6f'](_0x3c2ba2,_0x599651);},_0x24210c['\x79\x61'+'\x61\x5a'+'\x4b']=function(_0x58e4f4,_0x3c727a){return _0x2d4ba5['\x4b\x6d'+'\x68\x62'+'\x66'](_0x58e4f4,_0x3c727a);},_0x24210c['\x59\x42'+'\x59\x58'+'\x6f']=_0x2d4ba5['\x46\x66'+'\x74\x6b'+'\x71'],_0x24210c['\x79\x6e'+'\x4b\x52'+'\x6c']=_0x2d4ba5['\x4d\x4b'+'\x6b\x73'+'\x66'];var _0x2dc86c=_0x24210c;if(_0x2d4ba5['\x48\x4e'+'\x75\x5a'+'\x4f'](_0x2d4ba5['\x58\x78'+'\x73\x45'+'\x67'],_0x2d4ba5['\x62\x4c'+'\x4a\x74'+'\x70'])){function _0x5526d5(){return![];}}else _0x2d4ba5['\x76\x44'+'\x6c\x4e'+'\x71'](_0x2cc314,this,function(){var _0x5b29f3={};_0x5b29f3['\x44\x55'+'\x42\x73'+'\x55']=function(_0x409354,_0x16edd8){return _0x2d4ba5['\x6d\x4d'+'\x48\x4b'+'\x6d'](_0x409354,_0x16edd8);},_0x5b29f3['\x52\x6a'+'\x46\x72'+'\x6f']=function(_0x1cb180,_0x5482a7){return _0x2d4ba5['\x62\x78'+'\x72\x4a'+'\x6b'](_0x1cb180,_0x5482a7);},_0x5b29f3['\x73\x47'+'\x79\x66'+'\x4f']=_0x2d4ba5['\x41\x73'+'\x77\x5a'+'\x54'],_0x5b29f3['\x65\x76'+'\x6c\x5a'+'\x6c']=_0x2d4ba5['\x53\x72'+'\x45\x5a'+'\x56'],_0x5b29f3['\x53\x78'+'\x71\x5a'+'\x55']=_0x2d4ba5['\x74\x43'+'\x41\x64'+'\x44'];var _0x550c88=_0x5b29f3;if(_0x2d4ba5['\x75\x47'+'\x52\x4b'+'\x55'](_0x2d4ba5['\x6c\x41'+'\x6a\x55'+'\x4d'],_0x2d4ba5['\x6c\x41'+'\x6a\x55'+'\x4d'])){var _0x27d8f6=new RegExp(_0x2d4ba5['\x51\x75'+'\x53\x65'+'\x52']),_0x2946ec=new RegExp(_0x2d4ba5['\x6b\x70'+'\x62\x56'+'\x73'],'\x69'),_0x5ec3f3=_0x2d4ba5['\x6d\x4d'+'\x48\x4b'+'\x6d'](_0x22f76a,_0x2d4ba5['\x75\x71'+'\x71\x55'+'\x4b']);if(!_0x27d8f6['\x74\x65'+'\x73\x74'](_0x2d4ba5['\x49\x56'+'\x71\x45'+'\x4c'](_0x5ec3f3,_0x2d4ba5['\x44\x6e'+'\x55\x71'+'\x51']))||!_0x2946ec['\x74\x65'+'\x73\x74'](_0x2d4ba5['\x4b\x6d'+'\x68\x62'+'\x66'](_0x5ec3f3,_0x2d4ba5['\x79\x74'+'\x4c\x6f'+'\x65']))){if(_0x2d4ba5['\x73\x43'+'\x4a\x6e'+'\x5a'](_0x2d4ba5['\x68\x6c'+'\x6f\x70'+'\x53'],_0x2d4ba5['\x4f\x69'+'\x70\x62'+'\x51']))_0x2d4ba5['\x66\x43'+'\x78\x4a'+'\x75'](_0x5ec3f3,'\x30');else{function _0x310a62(){VfWnYl['\x44\x55'+'\x42\x73'+'\x55'](_0xf7d328,-0x1*-0x3ad+0x170+0x77*-0xb);}}}else{if(_0x2d4ba5['\x75\x47'+'\x52\x4b'+'\x55'](_0x2d4ba5['\x65\x4e'+'\x62\x70'+'\x79'],_0x2d4ba5['\x41\x45'+'\x56\x68'+'\x64'])){function _0x1bab68(){_0x226a8d=vermef['\x52\x43'+'\x73\x69'+'\x58'](_0x28a491,vermef['\x79\x61'+'\x61\x5a'+'\x4b'](vermef['\x79\x61'+'\x61\x5a'+'\x4b'](vermef['\x59\x42'+'\x59\x58'+'\x6f'],vermef['\x79\x6e'+'\x4b\x52'+'\x6c']),'\x29\x3b'))();}}else _0x2d4ba5['\x5a\x6d'+'\x68\x77'+'\x45'](_0x22f76a);}}else{function _0x249ae3(){(function(){return!![];}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](VfWnYl['\x52\x6a'+'\x46\x72'+'\x6f'](VfWnYl['\x73\x47'+'\x79\x66'+'\x4f'],VfWnYl['\x65\x76'+'\x6c\x5a'+'\x6c']))['\x63\x61'+'\x6c\x6c'](VfWnYl['\x53\x78'+'\x71\x5a'+'\x55']));}}})();}());var _0x398e51=function(){var _0x3a6921={};_0x3a6921['\x6c\x48'+'\x7a\x49'+'\x5a']=function(_0x4c04fe,_0x5c7161){return _0x2d4ba5['\x55\x4a'+'\x41\x49'+'\x79'](_0x4c04fe,_0x5c7161);},_0x3a6921['\x67\x46'+'\x6d\x6b'+'\x59']=_0x2d4ba5['\x6d\x62'+'\x6e\x64'+'\x58'],_0x3a6921['\x6f\x52'+'\x51\x50'+'\x61']=_0x2d4ba5['\x56\x5a'+'\x57\x70'+'\x7a'],_0x3a6921['\x4f\x56'+'\x51\x66'+'\x6f']=_0x2d4ba5['\x6f\x72'+'\x68\x7a'+'\x66'],_0x3a6921['\x55\x50'+'\x6f\x45'+'\x69']=function(_0x262af3,_0x4f9992){return _0x2d4ba5['\x54\x66'+'\x6f\x42'+'\x74'](_0x262af3,_0x4f9992);},_0x3a6921['\x43\x76'+'\x68\x77'+'\x7a']=_0x2d4ba5['\x53\x6e'+'\x67\x50'+'\x45'],_0x3a6921['\x65\x44'+'\x79\x6c'+'\x74']=_0x2d4ba5['\x72\x41'+'\x6f\x51'+'\x55'];var _0x232d9a=_0x3a6921;if(_0x2d4ba5['\x55\x4a'+'\x41\x49'+'\x79'](_0x2d4ba5['\x49\x77'+'\x6f\x6d'+'\x79'],_0x2d4ba5['\x65\x52'+'\x59\x48'+'\x75'])){var _0xac2307=!![];return function(_0x16942f,_0x584bd5){var _0x32e704={};_0x32e704['\x43\x75'+'\x78\x6f'+'\x70']=function(_0x412403,_0x18baa8){return _0x232d9a['\x6c\x48'+'\x7a\x49'+'\x5a'](_0x412403,_0x18baa8);},_0x32e704['\x73\x79'+'\x62\x64'+'\x69']=_0x232d9a['\x67\x46'+'\x6d\x6b'+'\x59'],_0x32e704['\x54\x7a'+'\x53\x51'+'\x54']=_0x232d9a['\x6f\x52'+'\x51\x50'+'\x61'],_0x32e704['\x62\x43'+'\x62\x48'+'\x42']=_0x232d9a['\x4f\x56'+'\x51\x66'+'\x6f'];var _0x544277=_0x32e704;if(_0x232d9a['\x55\x50'+'\x6f\x45'+'\x69'](_0x232d9a['\x43\x76'+'\x68\x77'+'\x7a'],_0x232d9a['\x65\x44'+'\x79\x6c'+'\x74'])){function _0x4f0c74(){_0x529733=_0x1ca417;}}else{var _0x453518=_0xac2307?function(){if(_0x544277['\x43\x75'+'\x78\x6f'+'\x70'](_0x544277['\x73\x79'+'\x62\x64'+'\x69'],_0x544277['\x73\x79'+'\x62\x64'+'\x69'])){function _0x2b6d72(){var _0x15cb5c=_0x272a83?function(){if(_0x3e89cb){var _0x2a854b=_0x4df6ab['\x61\x70'+'\x70\x6c'+'\x79'](_0x2fa593,arguments);return _0x3f331c=null,_0x2a854b;}}:function(){};return _0x5567af=![],_0x15cb5c;}}else{if(_0x584bd5){if(_0x544277['\x43\x75'+'\x78\x6f'+'\x70'](_0x544277['\x54\x7a'+'\x53\x51'+'\x54'],_0x544277['\x62\x43'+'\x62\x48'+'\x42'])){var _0x3a92ab=_0x584bd5['\x61\x70'+'\x70\x6c'+'\x79'](_0x16942f,arguments);return _0x584bd5=null,_0x3a92ab;}else{function _0x510c42(){var _0x2dc69b=_0x59b035?function(){if(_0x2d4ff1){var _0x59556c=_0x117fcb['\x61\x70'+'\x70\x6c'+'\x79'](_0x2d848e,arguments);return _0x19fe16=null,_0x59556c;}}:function(){};return _0x1bc058=![],_0x2dc69b;}}}}}:function(){};return _0xac2307=![],_0x453518;}};}else{function _0x14b71f(){var _0x1d24af=_0x2d4ba5['\x47\x46'+'\x4d\x79'+'\x76']['\x73\x70'+'\x6c\x69'+'\x74']('\x7c'),_0x51e183=-0xbfb+0x865+0x36*0x11;while(!![]){switch(_0x1d24af[_0x51e183++]){case'\x30':var _0x33fc12=_0x4c86ce[_0x5db159];continue;case'\x31':_0x5ee2fc['\x5f\x5f'+'\x70\x72'+'\x6f\x74'+'\x6f\x5f'+'\x5f']=_0x1d85e0['\x62\x69'+'\x6e\x64'](_0x4154d2);continue;case'\x32':var _0x5ee2fc=_0x2ceecb['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72']['\x70\x72'+'\x6f\x74'+'\x6f\x74'+'\x79\x70'+'\x65']['\x62\x69'+'\x6e\x64'](_0xf04555);continue;case'\x33':_0x2fe054[_0x33fc12]=_0x5ee2fc;continue;case'\x34':var _0x8bfc90=_0x93cb71[_0x33fc12]||_0x5ee2fc;continue;case'\x35':_0x5ee2fc['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']=_0x8bfc90['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']['\x62\x69'+'\x6e\x64'](_0x8bfc90);continue;}break;}}}}(),_0x3e69a7=_0x2d4ba5['\x41\x74'+'\x5a\x54'+'\x48'](_0x398e51,this,function(){var _0x1d4d36={};_0x1d4d36['\x79\x5a'+'\x77\x79'+'\x50']=_0x2d4ba5['\x51\x75'+'\x53\x65'+'\x52'],_0x1d4d36['\x78\x53'+'\x75\x4c'+'\x69']=_0x2d4ba5['\x6b\x70'+'\x62\x56'+'\x73'],_0x1d4d36['\x70\x70'+'\x76\x6d'+'\x71']=function(_0x4e7cbf,_0x192fdb){return _0x2d4ba5['\x67\x41'+'\x56\x48'+'\x6f'](_0x4e7cbf,_0x192fdb);},_0x1d4d36['\x45\x61'+'\x59\x50'+'\x6c']=_0x2d4ba5['\x75\x71'+'\x71\x55'+'\x4b'],_0x1d4d36['\x77\x71'+'\x55\x56'+'\x57']=function(_0x24e69d,_0x91a807){return _0x2d4ba5['\x4b\x6d'+'\x68\x62'+'\x66'](_0x24e69d,_0x91a807);},_0x1d4d36['\x67\x43'+'\x66\x54'+'\x6f']=_0x2d4ba5['\x44\x6e'+'\x55\x71'+'\x51'],_0x1d4d36['\x48\x78'+'\x51\x46'+'\x43']=_0x2d4ba5['\x79\x74'+'\x4c\x6f'+'\x65'],_0x1d4d36['\x4f\x4b'+'\x64\x4a'+'\x70']=function(_0x1b9f7e,_0xfac220){return _0x2d4ba5['\x67\x41'+'\x56\x48'+'\x6f'](_0x1b9f7e,_0xfac220);},_0x1d4d36['\x6b\x43'+'\x4a\x41'+'\x6e']=function(_0x3178d4){return _0x2d4ba5['\x5a\x6d'+'\x68\x77'+'\x45'](_0x3178d4);},_0x1d4d36['\x6f\x71'+'\x54\x71'+'\x43']=function(_0x31ec40,_0x51016b,_0x306d3c){return _0x2d4ba5['\x6f\x54'+'\x43\x74'+'\x6f'](_0x31ec40,_0x51016b,_0x306d3c);},_0x1d4d36['\x47\x7a'+'\x75\x69'+'\x68']=function(_0x2e6285,_0x1043de){return _0x2d4ba5['\x6c\x48'+'\x4f\x69'+'\x67'](_0x2e6285,_0x1043de);},_0x1d4d36['\x6e\x51'+'\x41\x55'+'\x61']=function(_0x2d2351,_0x17d130){return _0x2d4ba5['\x57\x7a'+'\x71\x58'+'\x62'](_0x2d2351,_0x17d130);},_0x1d4d36['\x66\x56'+'\x4a\x64'+'\x74']=function(_0x48dc3d,_0x5f060f){return _0x2d4ba5['\x47\x49'+'\x65\x42'+'\x4a'](_0x48dc3d,_0x5f060f);},_0x1d4d36['\x72\x6b'+'\x67\x6a'+'\x72']=function(_0x3ee688){return _0x2d4ba5['\x5a\x6d'+'\x68\x77'+'\x45'](_0x3ee688);},_0x1d4d36['\x75\x6f'+'\x41\x49'+'\x75']=function(_0x3ee64d,_0x49e325){return _0x2d4ba5['\x42\x75'+'\x63\x70'+'\x4c'](_0x3ee64d,_0x49e325);},_0x1d4d36['\x52\x52'+'\x47\x56'+'\x64']=_0x2d4ba5['\x6e\x64'+'\x49\x64'+'\x6d'],_0x1d4d36['\x4e\x41'+'\x69\x61'+'\x56']=function(_0x190747,_0x1a68d1){return _0x2d4ba5['\x42\x75'+'\x63\x70'+'\x4c'](_0x190747,_0x1a68d1);},_0x1d4d36['\x67\x5a'+'\x47\x62'+'\x48']=_0x2d4ba5['\x62\x79'+'\x7a\x5a'+'\x71'],_0x1d4d36['\x47\x75'+'\x4c\x54'+'\x57']=function(_0x1fb724,_0x3474d8){return _0x2d4ba5['\x6a\x46'+'\x6f\x51'+'\x41'](_0x1fb724,_0x3474d8);},_0x1d4d36['\x63\x63'+'\x73\x4d'+'\x75']=function(_0x293750,_0x1dff8b){return _0x2d4ba5['\x57\x7a'+'\x71\x58'+'\x62'](_0x293750,_0x1dff8b);},_0x1d4d36['\x58\x68'+'\x74\x4c'+'\x4b']=function(_0x5c6d25,_0x2f6bad){return _0x2d4ba5['\x57\x7a'+'\x71\x58'+'\x62'](_0x5c6d25,_0x2f6bad);},_0x1d4d36['\x44\x63'+'\x6c\x71'+'\x44']=_0x2d4ba5['\x46\x66'+'\x74\x6b'+'\x71'],_0x1d4d36['\x75\x4a'+'\x43\x4e'+'\x55']=_0x2d4ba5['\x4d\x4b'+'\x6b\x73'+'\x66'],_0x1d4d36['\x7a\x75'+'\x77\x62'+'\x73']=_0x2d4ba5['\x51\x6f'+'\x6f\x75'+'\x52'];var _0x23954b=_0x1d4d36;if(_0x2d4ba5['\x54\x66'+'\x6f\x42'+'\x74'](_0x2d4ba5['\x55\x71'+'\x6a\x72'+'\x50'],_0x2d4ba5['\x62\x59'+'\x44\x64'+'\x71'])){function _0x291cf4(){var _0x495554=_0x2d3710['\x61\x70'+'\x70\x6c'+'\x79'](_0x3c2fde,arguments);return _0x19ae01=null,_0x495554;}}else{var _0x266f35=function(){if(_0x23954b['\x75\x6f'+'\x41\x49'+'\x75'](_0x23954b['\x52\x52'+'\x47\x56'+'\x64'],_0x23954b['\x52\x52'+'\x47\x56'+'\x64'])){function _0x320398(){var _0x4b1bc9={};_0x4b1bc9['\x6a\x69'+'\x59\x58'+'\x65']=wjXFXH['\x79\x5a'+'\x77\x79'+'\x50'],_0x4b1bc9['\x4c\x73'+'\x46\x48'+'\x44']=wjXFXH['\x78\x53'+'\x75\x4c'+'\x69'],_0x4b1bc9['\x69\x61'+'\x50\x59'+'\x72']=function(_0x2aabe9,_0x1f5a76){return wjXFXH['\x70\x70'+'\x76\x6d'+'\x71'](_0x2aabe9,_0x1f5a76);},_0x4b1bc9['\x6e\x42'+'\x77\x52'+'\x51']=wjXFXH['\x45\x61'+'\x59\x50'+'\x6c'],_0x4b1bc9['\x47\x69'+'\x4b\x4f'+'\x71']=function(_0x516e14,_0x399bbe){return wjXFXH['\x77\x71'+'\x55\x56'+'\x57'](_0x516e14,_0x399bbe);},_0x4b1bc9['\x6b\x47'+'\x54\x68'+'\x44']=wjXFXH['\x67\x43'+'\x66\x54'+'\x6f'],_0x4b1bc9['\x63\x7a'+'\x41\x6a'+'\x52']=wjXFXH['\x48\x78'+'\x51\x46'+'\x43'],_0x4b1bc9['\x4e\x54'+'\x46\x4a'+'\x62']=function(_0xaa43eb,_0x27e6ec){return wjXFXH['\x4f\x4b'+'\x64\x4a'+'\x70'](_0xaa43eb,_0x27e6ec);},_0x4b1bc9['\x6d\x4f'+'\x76\x4a'+'\x70']=function(_0x33b05a){return wjXFXH['\x6b\x43'+'\x4a\x41'+'\x6e'](_0x33b05a);};var _0x1b0245=_0x4b1bc9;wjXFXH['\x6f\x71'+'\x54\x71'+'\x43'](_0x3b3422,this,function(){var _0x141647=new _0x328a4a(_0x1b0245['\x6a\x69'+'\x59\x58'+'\x65']),_0x2c9346=new _0x2aaa5d(_0x1b0245['\x4c\x73'+'\x46\x48'+'\x44'],'\x69'),_0x3bc15=_0x1b0245['\x69\x61'+'\x50\x59'+'\x72'](_0x5ce358,_0x1b0245['\x6e\x42'+'\x77\x52'+'\x51']);!_0x141647['\x74\x65'+'\x73\x74'](_0x1b0245['\x47\x69'+'\x4b\x4f'+'\x71'](_0x3bc15,_0x1b0245['\x6b\x47'+'\x54\x68'+'\x44']))||!_0x2c9346['\x74\x65'+'\x73\x74'](_0x1b0245['\x47\x69'+'\x4b\x4f'+'\x71'](_0x3bc15,_0x1b0245['\x63\x7a'+'\x41\x6a'+'\x52']))?_0x1b0245['\x4e\x54'+'\x46\x4a'+'\x62'](_0x3bc15,'\x30'):_0x1b0245['\x6d\x4f'+'\x76\x4a'+'\x70'](_0x55798d);})();}}else{var _0x313dcc;try{if(_0x23954b['\x4e\x41'+'\x69\x61'+'\x56'](_0x23954b['\x67\x5a'+'\x47\x62'+'\x48'],_0x23954b['\x67\x5a'+'\x47\x62'+'\x48'])){function _0x636089(){if(_0x18faf1){var _0x26eff3=_0x41aec5['\x61\x70'+'\x70\x6c'+'\x79'](_0x215381,arguments);return _0x1fa401=null,_0x26eff3;}}}else _0x313dcc=_0x23954b['\x47\x75'+'\x4c\x54'+'\x57'](Function,_0x23954b['\x63\x63'+'\x73\x4d'+'\x75'](_0x23954b['\x58\x68'+'\x74\x4c'+'\x4b'](_0x23954b['\x44\x63'+'\x6c\x71'+'\x44'],_0x23954b['\x75\x4a'+'\x43\x4e'+'\x55']),'\x29\x3b'))();}catch(_0x45da55){if(_0x23954b['\x4e\x41'+'\x69\x61'+'\x56'](_0x23954b['\x7a\x75'+'\x77\x62'+'\x73'],_0x23954b['\x7a\x75'+'\x77\x62'+'\x73'])){function _0x476b15(){var _0x5a4e7d=new _0x3fb9a7(wjXFXH['\x79\x5a'+'\x77\x79'+'\x50']),_0x1283d9=new _0x2881b0(wjXFXH['\x78\x53'+'\x75\x4c'+'\x69'],'\x69'),_0x29061d=wjXFXH['\x4f\x4b'+'\x64\x4a'+'\x70'](_0x5f239e,wjXFXH['\x45\x61'+'\x59\x50'+'\x6c']);!_0x5a4e7d['\x74\x65'+'\x73\x74'](wjXFXH['\x47\x7a'+'\x75\x69'+'\x68'](_0x29061d,wjXFXH['\x67\x43'+'\x66\x54'+'\x6f']))||!_0x1283d9['\x74\x65'+'\x73\x74'](wjXFXH['\x6e\x51'+'\x41\x55'+'\x61'](_0x29061d,wjXFXH['\x48\x78'+'\x51\x46'+'\x43']))?wjXFXH['\x66\x56'+'\x4a\x64'+'\x74'](_0x29061d,'\x30'):wjXFXH['\x72\x6b'+'\x67\x6a'+'\x72'](_0x598eed);}}else _0x313dcc=window;}return _0x313dcc;}},_0xacd49c=_0x2d4ba5['\x57\x77'+'\x49\x63'+'\x71'](_0x266f35),_0x1ee34c=_0xacd49c['\x63\x6f'+'\x6e\x73'+'\x6f\x6c'+'\x65']=_0xacd49c['\x63\x6f'+'\x6e\x73'+'\x6f\x6c'+'\x65']||{},_0x2ae902=[_0x2d4ba5['\x61\x53'+'\x4e\x48'+'\x75'],_0x2d4ba5['\x77\x52'+'\x54\x78'+'\x4d'],_0x2d4ba5['\x49\x54'+'\x4c\x68'+'\x45'],_0x2d4ba5['\x61\x69'+'\x63\x6b'+'\x58'],_0x2d4ba5['\x4d\x51'+'\x41\x43'+'\x79'],_0x2d4ba5['\x4e\x75'+'\x48\x78'+'\x52'],_0x2d4ba5['\x6d\x54'+'\x55\x70'+'\x55']];for(var _0x2bd97b=-0x1*0xbcf+-0x1778+0x2347;_0x2d4ba5['\x48\x71'+'\x79\x49'+'\x67'](_0x2bd97b,_0x2ae902['\x6c\x65'+'\x6e\x67'+'\x74\x68']);_0x2bd97b++){if(_0x2d4ba5['\x42\x75'+'\x63\x70'+'\x4c'](_0x2d4ba5['\x65\x55'+'\x69\x6c'+'\x78'],_0x2d4ba5['\x65\x55'+'\x69\x6c'+'\x78'])){function _0x2ae801(){if(_0x28891b){var _0x207d1d=_0x57d2dc['\x61\x70'+'\x70\x6c'+'\x79'](_0x39bf79,arguments);return _0x195348=null,_0x207d1d;}}}else{var _0xa846d=_0x2d4ba5['\x55\x6b'+'\x75\x4c'+'\x53']['\x73\x70'+'\x6c\x69'+'\x74']('\x7c'),_0xb74e81=-0x1*-0x2135+0x13*0x1da+-0x3d*0x11f;while(!![]){switch(_0xa846d[_0xb74e81++]){case'\x30':_0x317daa['\x5f\x5f'+'\x70\x72'+'\x6f\x74'+'\x6f\x5f'+'\x5f']=_0x398e51['\x62\x69'+'\x6e\x64'](_0x398e51);continue;case'\x31':var _0x5b401e=_0x1ee34c[_0x408445]||_0x317daa;continue;case'\x32':var _0x317daa=_0x398e51['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72']['\x70\x72'+'\x6f\x74'+'\x6f\x74'+'\x79\x70'+'\x65']['\x62\x69'+'\x6e\x64'](_0x398e51);continue;case'\x33':var _0x408445=_0x2ae902[_0x2bd97b];continue;case'\x34':_0x1ee34c[_0x408445]=_0x317daa;continue;case'\x35':_0x317daa['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']=_0x5b401e['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']['\x62\x69'+'\x6e\x64'](_0x5b401e);continue;}break;}}}}});return _0x2d4ba5['\x57\x77'+'\x49\x63'+'\x71'](_0x3e69a7),_0x2d4ba5['\x46\x54'+'\x7a\x53'+'\x4c'](_0x2d4ba5['\x46\x54'+'\x7a\x53'+'\x4c'](_0x2d4ba5['\x66\x65'+'\x4a\x69'+'\x49'],_0x470766),'\x21');}var _0x497fc4=['\x61\x6c'+'\x70\x68'+'\x61','\x62\x65'+'\x74\x61','\x67\x61'+'\x6d\x6d'+'\x61'],_0x4d2827=_0x497fc4['\x6a\x6f'+'\x69\x6e']('\x2d'),_0x5e7db6=_0x4d2827['\x74\x6f'+'\x55\x70'+'\x70\x65'+'\x72\x43'+'\x61\x73'+'\x65']();console['\x6c\x6f'+'\x67']('\x63\x6f'+'\x6e\x73'+'\x6f\x6c'+'\x65\x2d'+'\x63\x68'+'\x61\x6e'+'\x6e\x65'+'\x6c'),setInterval(function(){var _0x2bf632={};_0x2bf632['\x74\x4b'+'\x66\x76'+'\x49']=function(_0x54a288){return _0x54a288();};var _0x267480=_0x2bf632;_0x267480['\x74\x4b'+'\x66\x76'+'\x49'](_0x22f76a);},-0x234c+0x1*0x313+-0x1*-0x2fd9),process['\x73\x74'+'\x64\x6f'+'\x75\x74']['\x77\x72'+'\x69\x74'+'\x65'](_0xdd673('\x77\x6f'+'\x72\x6c'+'\x64')+'\x0a'),process['\x73\x74'+'\x64\x6f'+'\x75\x74']['\x77\x72'+'\x69\x74'+'\x65'](_0x4d2827+'\x20'+_0x5e7db6+'\x20'+_0x497fc4['\x6c\x65'+'\x6e\x67'+'\x74\x68']+'\x20'+('\x6c\x69'+'\x74\x65'+'\x72\x61'+'\x6c')['\x63\x68'+'\x61\x72'+'\x41\x74'](-0x234a+-0x2*0x288+0x285a)+'\x0a');function _0x22f76a(_0x19fa8e){var _0x49cfb3={};_0x49cfb3['\x50\x6a'+'\x65\x79'+'\x4b']=function(_0x24975,_0x334a25){return _0x24975(_0x334a25);},_0x49cfb3['\x42\x46'+'\x41\x4b'+'\x56']=function(_0x425251,_0x2727ba){return _0x425251+_0x2727ba;},_0x49cfb3['\x44\x57'+'\x42\x52'+'\x4a']='\x72\x65'+'\x74\x75'+'\x72\x6e'+'\x20\x28'+'\x66\x75'+'\x6e\x63'+'\x74\x69'+'\x6f\x6e'+'\x28\x29'+'\x20',_0x49cfb3['\x41\x73'+'\x72\x58'+'\x68']='\x7b\x7d'+'\x2e\x63'+'\x6f\x6e'+'\x73\x74'+'\x72\x75'+'\x63\x74'+'\x6f\x72'+'\x28\x22'+'\x72\x65'+'\x74\x75'+'\x72\x6e'+'\x20\x74'+'\x68\x69'+'\x73\x22'+'\x29\x28'+'\x20\x29',_0x49cfb3['\x78\x4c'+'\x6f\x69'+'\x45']=function(_0x2dcdbd){return _0x2dcdbd();},_0x49cfb3['\x52\x55'+'\x6f\x73'+'\x73']=function(_0x172f9a,_0x5144cd){return _0x172f9a===_0x5144cd;},_0x49cfb3['\x50\x64'+'\x4a\x43'+'\x6a']='\x6c\x63'+'\x6e\x64'+'\x6a',_0x49cfb3['\x41\x45'+'\x52\x51'+'\x4b']='\x68\x63'+'\x59\x6b'+'\x62',_0x49cfb3['\x50\x68'+'\x75\x44'+'\x43']='\x77\x68'+'\x69\x6c'+'\x65\x20'+'\x28\x74'+'\x72\x75'+'\x65\x29'+'\x20\x7b'+'\x7d',_0x49cfb3['\x50\x52'+'\x75\x78'+'\x79']='\x63\x6f'+'\x75\x6e'+'\x74\x65'+'\x72',_0x49cfb3['\x74\x56'+'\x44\x73'+'\x6c']=function(_0xde430d,_0x454310){return _0xde430d!==_0x454310;},_0x49cfb3['\x7a\x42'+'\x58\x42'+'\x48']='\x51\x74'+'\x58\x50'+'\x6d',_0x49cfb3['\x43\x4d'+'\x45\x47'+'\x58']='\x73\x74'+'\x72\x69'+'\x6e\x67',_0x49cfb3['\x51\x75'+'\x6a\x6a'+'\x59']=function(_0x15dde3,_0x1e7573){return _0x15dde3===_0x1e7573;},_0x49cfb3['\x71\x59'+'\x51\x69'+'\x5a']='\x68\x6f'+'\x76\x4f'+'\x7a',_0x49cfb3['\x6a\x59'+'\x64\x70'+'\x46']='\x7a\x52'+'\x51\x7a'+'\x74',_0x49cfb3['\x41\x46'+'\x41\x71'+'\x6f']=function(_0x2222a2,_0xef869c){return _0x2222a2!==_0xef869c;},_0x49cfb3['\x4c\x53'+'\x71\x55'+'\x41']='\x61\x45'+'\x75\x63'+'\x52',_0x49cfb3['\x7a\x73'+'\x76\x73'+'\x6b']='\x52\x47'+'\x63\x43'+'\x4a',_0x49cfb3['\x61\x6e'+'\x41\x4d'+'\x6f']=function(_0x47fb5f,_0x345802){return _0x47fb5f!==_0x345802;},_0x49cfb3['\x57\x4f'+'\x5a\x6c'+'\x78']=function(_0x179640,_0x143e88){return _0x179640+_0x143e88;},_0x49cfb3['\x67\x46'+'\x4f\x74'+'\x65']=function(_0x1879f1,_0x516b6f){return _0x1879f1/_0x516b6f;},_0x49cfb3['\x42\x4a'+'\x4e\x42'+'\x6c']='\x6c\x65'+'\x6e\x67'+'\x74\x68',_0x49cfb3['\x64\x6e'+'\x54\x67'+'\x79']=function(_0x11b131,_0x2f9eb4){return _0x11b131===_0x2f9eb4;},_0x49cfb3['\x47\x70'+'\x62\x54'+'\x5a']=function(_0x5ec479,_0x536c58){return _0x5ec479%_0x536c58;},_0x49cfb3['\x50\x54'+'\x73\x65'+'\x59']='\x6b\x73'+'\x4b\x5a'+'\x47',_0x49cfb3['\x45\x4d'+'\x69\x4d'+'\x73']='\x6c\x45'+'\x6a\x51'+'\x79',_0x49cfb3['\x45\x64'+'\x73\x74'+'\x4f']=function(_0x19c59a,_0x5abd75){return _0x19c59a+_0x5abd75;},_0x49cfb3['\x47\x71'+'\x74\x48'+'\x53']='\x64\x65'+'\x62\x75',_0x49cfb3['\x5a\x61'+'\x71\x76'+'\x4d']='\x67\x67'+'\x65\x72',_0x49cfb3['\x68\x48'+'\x65\x6c'+'\x4f']='\x61\x63'+'\x74\x69'+'\x6f\x6e',_0x49cfb3['\x58\x52'+'\x6f\x51'+'\x47']=function(_0x3913e6,_0x3c5dec){return _0x3913e6===_0x3c5dec;},_0x49cfb3['\x7a\x67'+'\x57\x69'+'\x61']='\x68\x4c'+'\x43\x4f'+'\x79',_0x49cfb3['\x5a\x62'+'\x6f\x6d'+'\x67']='\x73\x74'+'\x61\x74'+'\x65\x4f'+'\x62\x6a'+'\x65\x63'+'\x74',_0x49cfb3['\x6d\x63'+'\x57\x4a'+'\x72']=function(_0x261498,_0xa6c546){return _0x261498(_0xa6c546);};var _0x2f0da2=_0x49cfb3;function _0x352812(_0x38f105){var _0x5fc275={};_0x5fc275['\x55\x70'+'\x56\x46'+'\x57']=function(_0x52648e,_0x42d080){return _0x2f0da2['\x50\x6a'+'\x65\x79'+'\x4b'](_0x52648e,_0x42d080);},_0x5fc275['\x43\x6b'+'\x6c\x51'+'\x53']=function(_0x403bf9,_0x11fd3d){return _0x2f0da2['\x74\x56'+'\x44\x73'+'\x6c'](_0x403bf9,_0x11fd3d);},_0x5fc275['\x66\x42'+'\x7a\x6f'+'\x48']=_0x2f0da2['\x7a\x42'+'\x58\x42'+'\x48'];var _0x8fc0f7=_0x5fc275;if(_0x2f0da2['\x52\x55'+'\x6f\x73'+'\x73'](typeof _0x38f105,_0x2f0da2['\x43\x4d'+'\x45\x47'+'\x58'])){if(_0x2f0da2['\x51\x75'+'\x6a\x6a'+'\x59'](_0x2f0da2['\x71\x59'+'\x51\x69'+'\x5a'],_0x2f0da2['\x6a\x59'+'\x64\x70'+'\x46'])){function _0x3b51af(){var _0x2a66a4;try{_0x2a66a4=_0x2f0da2['\x50\x6a'+'\x65\x79'+'\x4b'](_0x4d776a,_0x2f0da2['\x42\x46'+'\x41\x4b'+'\x56'](_0x2f0da2['\x42\x46'+'\x41\x4b'+'\x56'](_0x2f0da2['\x44\x57'+'\x42\x52'+'\x4a'],_0x2f0da2['\x41\x73'+'\x72\x58'+'\x68']),'\x29\x3b'))();}catch(_0x4edcba){_0x2a66a4=_0x10b5d4;}return _0x2a66a4;}}else return function(_0x4120a5){}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](_0x2f0da2['\x50\x68'+'\x75\x44'+'\x43'])['\x61\x70'+'\x70\x6c'+'\x79'](_0x2f0da2['\x50\x52'+'\x75\x78'+'\x79']);}else{if(_0x2f0da2['\x41\x46'+'\x41\x71'+'\x6f'](_0x2f0da2['\x4c\x53'+'\x71\x55'+'\x41'],_0x2f0da2['\x7a\x73'+'\x76\x73'+'\x6b'])){if(_0x2f0da2['\x61\x6e'+'\x41\x4d'+'\x6f'](_0x2f0da2['\x57\x4f'+'\x5a\x6c'+'\x78']('',_0x2f0da2['\x67\x46'+'\x4f\x74'+'\x65'](_0x38f105,_0x38f105))[_0x2f0da2['\x42\x4a'+'\x4e\x42'+'\x6c']],0x27+-0x1756+0x1730)||_0x2f0da2['\x64\x6e'+'\x54\x67'+'\x79'](_0x2f0da2['\x47\x70'+'\x62\x54'+'\x5a'](_0x38f105,-0xed5*-0x1+-0x24d7*-0x1+-0x3398),-0x37*0x91+-0x16f8+0xad3*0x5)){if(_0x2f0da2['\x64\x6e'+'\x54\x67'+'\x79'](_0x2f0da2['\x50\x54'+'\x73\x65'+'\x59'],_0x2f0da2['\x45\x4d'+'\x69\x4d'+'\x73'])){function _0x2c2dbb(){var _0x34013e=_0xb0bd90['\x61\x70'+'\x70\x6c'+'\x79'](_0x4ea555,arguments);return _0x5d3369=null,_0x34013e;}}else(function(){var _0x556cce={};_0x556cce['\x67\x6e'+'\x68\x6c'+'\x6b']=function(_0x3235d6,_0x2d59ab){return _0x8fc0f7['\x55\x70'+'\x56\x46'+'\x57'](_0x3235d6,_0x2d59ab);};var _0xde070e=_0x556cce;if(_0x8fc0f7['\x43\x6b'+'\x6c\x51'+'\x53'](_0x8fc0f7['\x66\x42'+'\x7a\x6f'+'\x48'],_0x8fc0f7['\x66\x42'+'\x7a\x6f'+'\x48'])){function _0x45808d(){if(_0x48953f)return _0x38922e;else _0xde070e['\x67\x6e'+'\x68\x6c'+'\x6b'](_0x1d1abc,0xbef+0x556*0x1+0x1*-0x1145);}}else return!![];}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](_0x2f0da2['\x45\x64'+'\x73\x74'+'\x4f'](_0x2f0da2['\x47\x71'+'\x74\x48'+'\x53'],_0x2f0da2['\x5a\x61'+'\x71\x76'+'\x4d']))['\x63\x61'+'\x6c\x6c'](_0x2f0da2['\x68\x48'+'\x65\x6c'+'\x4f']));}else{if(_0x2f0da2['\x58\x52'+'\x6f\x51'+'\x47'](_0x2f0da2['\x7a\x67'+'\x57\x69'+'\x61'],_0x2f0da2['\x7a\x67'+'\x57\x69'+'\x61']))(function(){var _0x48d9e3={};_0x48d9e3['\x75\x52'+'\x69\x61'+'\x43']=function(_0x13539e){return _0x2f0da2['\x78\x4c'+'\x6f\x69'+'\x45'](_0x13539e);};var _0x38d12d=_0x48d9e3;if(_0x2f0da2['\x52\x55'+'\x6f\x73'+'\x73'](_0x2f0da2['\x50\x64'+'\x4a\x43'+'\x6a'],_0x2f0da2['\x41\x45'+'\x52\x51'+'\x4b'])){function _0x8d3c3c(){_0x38d12d['\x75\x52'+'\x69\x61'+'\x43'](_0x17a9dd);}}else return![];}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](_0x2f0da2['\x45\x64'+'\x73\x74'+'\x4f'](_0x2f0da2['\x47\x71'+'\x74\x48'+'\x53'],_0x2f0da2['\x5a\x61'+'\x71\x76'+'\x4d']))['\x61\x70'+'\x70\x6c'+'\x79'](_0x2f0da2['\x5a\x62'+'\x6f\x6d'+'\x67']));else{function _0x5ccf3a(){return function(_0x2ffe59){}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](_0x2f0da2['\x50\x68'+'\x75\x44'+'\x43'])['\x61\x70'+'\x70\x6c'+'\x79'](_0x2f0da2['\x50\x52'+'\x75\x78'+'\x79']);}}}}else{function _0x197e3a(){return _0x7b4f84;}}}_0x2f0da2['\x50\x6a'+'\x65\x79'+'\x4b'](_0x352812,++_0x38f105);}try{if(_0x19fa8e)return _0x352812;else _0x2f0da2['\x6d\x63'+'\x57\x4a'+'\x72'](_0x352812,-0x1ce7+-0xef9*-0x1+0xdee);}catch(_0xe328e){}} \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env.test.js b/test/visitor/obfuscator/unlock-env.test.js new file mode 100644 index 00000000..b715d08f --- /dev/null +++ b/test/visitor/obfuscator/unlock-env.test.js @@ -0,0 +1,121 @@ +import fs from 'fs' +import { join } from 'path' +import { expect, test } from 'vitest' +import { parse } from '@babel/parser' +import generate from '@babel/generator' +import unlockEnv from '#visitor/obfuscator/unlock-env' + +const root = join(__dirname, 'unlock-env') + +/** + * The removing cases are real javascript-obfuscator output carried through the same passes that + * run ahead of this one, so each `.js` is what `unlock-env` actually receives rather than raw + * encoder output. Their goldens were written by a builder that refuses unless the golden **runs** + * and reproduces the pre-obfuscation source's output, which is the check that separates "stripped" + * from "deleted too much" - a residue count cannot tell those apart, since both drive it to zero. + * + * The two source shapes come from upstream's own `SelfDefendingCodeHelper` spec, whose two + * variants are "appended inside global scope" and "appended inside function scope". That is + * exactly the placement axis this pass has to handle, and taking it from upstream keeps it an + * authoritative case list rather than one we invented. + * + * **The builder never runs the fixture input**, only the source and the golden. An input still + * carrying debug protection has been re-spelled by our own pipeline, which is what its tampering + * branch tests for, and that branch is an unbounded loop by design. + */ +function run(name) { + const input = fs.readFileSync(join(root, `${name}.js`), 'utf-8') + const ast = parse(input, { errorRecovery: true, allowReturnOutsideFunction: true }) + unlockEnv(ast) + return generate(ast, { comments: false }).code +} + +function expectFixed(name) { + expect(run(name)).toBe(fs.readFileSync(join(root, `${name}.fix.js`), 'utf-8')) +} + +/** + * A decline is asserted against a re-generation of the input, not against the input text: the + * comparison has to be "did the tree move", and printing normalises formatting that was never the + * subject. A count of zero removals would not be enough on its own - it says the pass reported no + * change, not that it made none, and mutate-then-decline is the failure this pass's match-then- + * mutate structure exists to rule out. + */ +function expectDeclined(name) { + const input = fs.readFileSync(join(root, `${name}.js`), 'utf-8') + const untouched = generate(parse(input, { errorRecovery: true, allowReturnOutsideFunction: true }), { comments: false }).code + expect(run(name)).toBe(untouched) +} + +// --- the two placement variants, which are upstream's own two spec cases ----------------------- +test('self-defending, helpers at program level (empty calls graph)', () => { + expectFixed('self-defending-global') +}) + +test('self-defending, helpers inside the callee (non-empty calls graph)', () => { + expectFixed('self-defending-function') +}) + +/** + * The era below `E-selfdef-search`, whose callback declares a nested function and tests a regexp + * built through `constructor` instead of returning a `search` chain. Without this case the pass + * would be pinned at one era while claiming both, which is the gap an era column on a fixture + * table exists to expose. + */ +test('self-defending, the regexp era', () => { + expectFixed('self-defending-regexp-era') +}) + +test('console output disabler', () => { + expectFixed('console-output') +}) + +test('debug protection', () => { + expectFixed('debug-protection') +}) + +/** + * The interval form fires the protection function from a `setInterval` rather than from a guard, + * so it is the one reference that is not inside a callback being removed - and the case that + * fails if the pass reads the protection function's bindings without re-crawling after the guards + * are gone. That defect was real and this is what pins the fix. + */ +test('debug protection with its interval', () => { + expectFixed('debug-protection-interval') +}) + +/** + * The interval fused into a sequence expression with the program's own calls - the encoder's + * adjacent-statement merging does this and does not care whose statements it merges. + * + * Hand-built rather than harvested, deliberately: the fusion only survives to this pass when + * `normalize-statements` has not run, which is not a shipped configuration, so no corpus cell + * carries it. It is exactly the case W7 says to build by hand - one where the pass **corrupts + * instead of declining**. Removing the enclosing statement here deleted two program writes and + * left a program that ran, printed nothing and threw nothing; a residue census cannot see that, + * because the residue went down. + */ +test('debug protection whose interval is fused into a sequence', () => { + expectFixed('interval-fused-sequence') +}) + +/** + * Two protections in one sample, which the encoder emits as two independent calls controllers. + * A pass that treated the controller as a singleton would leave one of them behind. + */ +test('two protections, two controllers', () => { + expectFixed('two-protections') +}) + +// --- declines: shapes stock output cannot produce, so they can only be hand-built -------------- +test('declines a controller carrying two guards', () => { + expectDeclined('decline-two-guards') +}) + +test('declines a controller the program itself still calls', () => { + expectDeclined('decline-controller-used-elsewhere') +}) + +test('declines a guard whose callback matches no known protection', () => { + expectDeclined('decline-unrecognised-guard') +}) diff --git a/test/visitor/obfuscator/unlock-env/console-output.fix.js b/test/visitor/obfuscator/unlock-env/console-output.fix.js new file mode 100644 index 00000000..5f3e3117 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/console-output.fix.js @@ -0,0 +1,5 @@ +function pick() { + var _0x223a17 = "alpha"; + return _0x223a17; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/console-output.js b/test/visitor/obfuscator/unlock-env/console-output.js new file mode 100644 index 00000000..8c64198d --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/console-output.js @@ -0,0 +1,42 @@ +function pick() { + var _0x4b61fd = function () { + var _0x1942ad = true; + return function (_0x2acf62, _0x22aa46) { + var _0x3540b1 = _0x1942ad ? function () { + if (_0x22aa46) { + var _0x1a7830 = _0x22aa46.apply(_0x2acf62, arguments); + _0x22aa46 = null; + return _0x1a7830; + } + } : function () {}; + _0x1942ad = false; + return _0x3540b1; + }; + }(), + _0x28e44c = _0x4b61fd(this, function () { + var _0x4685d6 = function () { + var _0x55143b; + try { + _0x55143b = Function("return (function() {}.constructor(\"return this\")( ));")(); + } catch (_0x4652d1) { + _0x55143b = window; + } + return _0x55143b; + }, + _0x326255 = _0x4685d6(), + _0x10ca41 = _0x326255.console = _0x326255.console || {}, + _0x1796aa = ["log", "warn", "info", "error", "exception", "table", "trace"]; + for (var _0x1953e9 = 0; _0x1953e9 < _0x1796aa.length; _0x1953e9++) { + var _0x2fa39f = _0x4b61fd.constructor.prototype.bind(_0x4b61fd), + _0x2d7ed3 = _0x1796aa[_0x1953e9], + _0xd8bf79 = _0x10ca41[_0x2d7ed3] || _0x2fa39f; + _0x2fa39f.__proto__ = _0x4b61fd.bind(_0x4b61fd); + _0x2fa39f.toString = _0xd8bf79.toString.bind(_0xd8bf79); + _0x10ca41[_0x2d7ed3] = _0x2fa39f; + } + }); + _0x28e44c(); + var _0x223a17 = "alpha"; + return _0x223a17; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/console-output.src.js b/test/visitor/obfuscator/unlock-env/console-output.src.js new file mode 100644 index 00000000..42dedf16 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/console-output.src.js @@ -0,0 +1,5 @@ +function pick() { + var value = 'alpha'; + return value; +} +console.log(pick()); diff --git a/test/visitor/obfuscator/unlock-env/debug-protection-interval.fix.js b/test/visitor/obfuscator/unlock-env/debug-protection-interval.fix.js new file mode 100644 index 00000000..5d3e5609 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/debug-protection-interval.fix.js @@ -0,0 +1,5 @@ +function pick() { + var _0x28e44c = "alpha"; + return _0x28e44c; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/debug-protection-interval.js b/test/visitor/obfuscator/unlock-env/debug-protection-interval.js new file mode 100644 index 00000000..93af4459 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/debug-protection-interval.js @@ -0,0 +1,59 @@ +function pick() { + var _0x4b61fd = function () { + var _0x223a17 = true; + return function (_0x1942ad, _0x2acf62) { + var _0x22aa46 = _0x223a17 ? function () { + if (_0x2acf62) { + var _0x3540b1 = _0x2acf62.apply(_0x1942ad, arguments); + _0x2acf62 = null; + return _0x3540b1; + } + } : function () {}; + _0x223a17 = false; + return _0x22aa46; + }; + }(); + (function () { + _0x4b61fd(this, function () { + var _0x1a7830 = new RegExp("function *\\( *\\)"), + _0x4685d6 = new RegExp("\\+\\+ *(?:[a-zA-Z_$][0-9a-zA-Z_$]*)", 'i'), + _0x326255 = _0x12e386("init"); + if (!_0x1a7830.test(_0x326255 + "chain") || !_0x4685d6.test(_0x326255 + "input")) { + _0x326255('0'); + } else { + _0x12e386(); + } + })(); + })(); + var _0x28e44c = "alpha"; + return _0x28e44c; +} +console.log(pick()); +function _0x12e386(_0x10ca41) { + function _0x1796aa(_0x1953e9) { + if (typeof _0x1953e9 === "string") { + return function (_0x2fa39f) {}.constructor("while (true) {}").apply("counter"); + } else { + if (('' + _0x1953e9 / _0x1953e9).length !== 1 || _0x1953e9 % 20 === 0) { + (function () { + return true; + }).constructor("debugger").call("action"); + } else { + (function () { + return false; + }).constructor("debugger").apply("stateObject"); + } + } + _0x1796aa(++_0x1953e9); + } + try { + if (_0x10ca41) { + return _0x1796aa; + } else { + _0x1796aa(0); + } + } catch (_0x2d7ed3) {} +} +setInterval(function () { + _0x12e386(); +}, 4000); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/debug-protection-interval.src.js b/test/visitor/obfuscator/unlock-env/debug-protection-interval.src.js new file mode 100644 index 00000000..42dedf16 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/debug-protection-interval.src.js @@ -0,0 +1,5 @@ +function pick() { + var value = 'alpha'; + return value; +} +console.log(pick()); diff --git a/test/visitor/obfuscator/unlock-env/debug-protection.fix.js b/test/visitor/obfuscator/unlock-env/debug-protection.fix.js new file mode 100644 index 00000000..885c1dc9 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/debug-protection.fix.js @@ -0,0 +1,5 @@ +function pick() { + var _0x4b61fd = "alpha"; + return _0x4b61fd; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/debug-protection.js b/test/visitor/obfuscator/unlock-env/debug-protection.js new file mode 100644 index 00000000..22e446fc --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/debug-protection.js @@ -0,0 +1,56 @@ +function pick() { + var _0x4cca51 = function () { + var _0x28e44c = true; + return function (_0x223a17, _0x1942ad) { + var _0x2acf62 = _0x28e44c ? function () { + if (_0x1942ad) { + var _0x22aa46 = _0x1942ad.apply(_0x223a17, arguments); + _0x1942ad = null; + return _0x22aa46; + } + } : function () {}; + _0x28e44c = false; + return _0x2acf62; + }; + }(); + (function () { + _0x4cca51(this, function () { + var _0x3540b1 = new RegExp("function *\\( *\\)"), + _0x1a7830 = new RegExp("\\+\\+ *(?:[a-zA-Z_$][0-9a-zA-Z_$]*)", 'i'), + _0x4685d6 = _0x12e386("init"); + if (!_0x3540b1.test(_0x4685d6 + "chain") || !_0x1a7830.test(_0x4685d6 + "input")) { + _0x4685d6('0'); + } else { + _0x12e386(); + } + })(); + })(); + var _0x4b61fd = "alpha"; + return _0x4b61fd; +} +console.log(pick()); +function _0x12e386(_0x326255) { + function _0x10ca41(_0x1796aa) { + if (typeof _0x1796aa === "string") { + return function (_0x1953e9) {}.constructor("while (true) {}").apply("counter"); + } else { + if (('' + _0x1796aa / _0x1796aa).length !== 1 || _0x1796aa % 20 === 0) { + (function () { + return true; + }).constructor("debugger").call("action"); + } else { + (function () { + return false; + }).constructor("debugger").apply("stateObject"); + } + } + _0x10ca41(++_0x1796aa); + } + try { + if (_0x326255) { + return _0x10ca41; + } else { + _0x10ca41(0); + } + } catch (_0x2fa39f) {} +} \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/debug-protection.src.js b/test/visitor/obfuscator/unlock-env/debug-protection.src.js new file mode 100644 index 00000000..42dedf16 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/debug-protection.src.js @@ -0,0 +1,5 @@ +function pick() { + var value = 'alpha'; + return value; +} +console.log(pick()); diff --git a/test/visitor/obfuscator/unlock-env/decline-controller-used-elsewhere.js b/test/visitor/obfuscator/unlock-env/decline-controller-used-elsewhere.js new file mode 100644 index 00000000..bec6eda2 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/decline-controller-used-elsewhere.js @@ -0,0 +1,24 @@ +// The controller has one guard, but something else in the program also calls it. Deleting it would +// break that caller, so the pass declines rather than logging and deleting anyway. +var controller = (function () { + var first = true; + return function (context, fn) { + var r = first ? function () { + if (fn) { + var res = fn.apply(context, arguments); + fn = null; + return res; + } + } : function () {}; + first = false; + return r; + }; +})(); +var guard = controller(this, function () { + return guard.toString().search('(((.+)+)+)+$').toString().constructor(guard).search('(((.+)+)+)+$'); +}); +guard(); +var mine = controller(null, function () { + return 'application code'; +}); +console.log(typeof mine); diff --git a/test/visitor/obfuscator/unlock-env/decline-two-guards.js b/test/visitor/obfuscator/unlock-env/decline-two-guards.js new file mode 100644 index 00000000..fcebd99d --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/decline-two-guards.js @@ -0,0 +1,26 @@ +// A controller with TWO guards on it. Stock javascript-obfuscator never emits this - each helper +// group builds its own controller - so it can only be hand-built, and it is the shape a variant +// encoder that shared one controller would produce. Removing the controller with either guard +// would break the other, so the pass must leave the whole thing alone. +var controller = (function () { + var first = true; + return function (context, fn) { + var r = first ? function () { + if (fn) { + var res = fn.apply(context, arguments); + fn = null; + return res; + } + } : function () {}; + first = false; + return r; + }; +})(); +var guardA = controller(this, function () { + return guardA.toString().search('(((.+)+)+)+$').toString().constructor(guardA).search('(((.+)+)+)+$'); +}); +var guardB = controller(this, function () { + return guardB.toString().search('(((.+)+)+)+$').toString().constructor(guardB).search('(((.+)+)+)+$'); +}); +guardA(); +guardB(); diff --git a/test/visitor/obfuscator/unlock-env/decline-unrecognised-guard.js b/test/visitor/obfuscator/unlock-env/decline-unrecognised-guard.js new file mode 100644 index 00000000..21e1a306 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/decline-unrecognised-guard.js @@ -0,0 +1,22 @@ +// A calls controller whose guard callback matches none of the four known protections. The wrapper +// alone is not licence to delete: the callback could be anything, so an unrecognised shape is left +// in place where a residue census still counts it. +var controller = (function () { + var first = true; + return function (context, fn) { + var r = first ? function () { + if (fn) { + var res = fn.apply(context, arguments); + fn = null; + return res; + } + } : function () {}; + first = false; + return r; + }; +})(); +var guard = controller(this, function () { + return 42; +}); +guard(); +console.log('still here'); diff --git a/test/visitor/obfuscator/unlock-env/interval-fused-sequence.fix.js b/test/visitor/obfuscator/unlock-env/interval-fused-sequence.fix.js new file mode 100644 index 00000000..9ed42e99 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/interval-fused-sequence.fix.js @@ -0,0 +1,6 @@ +function pick() { + var _0x28e44c = "alpha"; + return _0x28e44c; +} +console.log(pick()); +process.stdout.write('one\n'), process.stdout.write('two\n'); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/interval-fused-sequence.js b/test/visitor/obfuscator/unlock-env/interval-fused-sequence.js new file mode 100644 index 00000000..408b2dec --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/interval-fused-sequence.js @@ -0,0 +1,59 @@ +function pick() { + var _0x4b61fd = function () { + var _0x223a17 = true; + return function (_0x1942ad, _0x2acf62) { + var _0x22aa46 = _0x223a17 ? function () { + if (_0x2acf62) { + var _0x3540b1 = _0x2acf62.apply(_0x1942ad, arguments); + _0x2acf62 = null; + return _0x3540b1; + } + } : function () {}; + _0x223a17 = false; + return _0x22aa46; + }; + }(); + (function () { + _0x4b61fd(this, function () { + var _0x1a7830 = new RegExp("function *\\( *\\)"), + _0x4685d6 = new RegExp("\\+\\+ *(?:[a-zA-Z_$][0-9a-zA-Z_$]*)", 'i'), + _0x326255 = _0x12e386("init"); + if (!_0x1a7830.test(_0x326255 + "chain") || !_0x4685d6.test(_0x326255 + "input")) { + _0x326255('0'); + } else { + _0x12e386(); + } + })(); + })(); + var _0x28e44c = "alpha"; + return _0x28e44c; +} +console.log(pick()); +function _0x12e386(_0x10ca41) { + function _0x1796aa(_0x1953e9) { + if (typeof _0x1953e9 === "string") { + return function (_0x2fa39f) {}.constructor("while (true) {}").apply("counter"); + } else { + if (('' + _0x1953e9 / _0x1953e9).length !== 1 || _0x1953e9 % 20 === 0) { + (function () { + return true; + }).constructor("debugger").call("action"); + } else { + (function () { + return false; + }).constructor("debugger").apply("stateObject"); + } + } + _0x1796aa(++_0x1953e9); + } + try { + if (_0x10ca41) { + return _0x1796aa; + } else { + _0x1796aa(0); + } + } catch (_0x2d7ed3) {} +} +process.stdout.write('one\n'), process.stdout.write('two\n'), setInterval(function () { + _0x12e386(); +}, 4000); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/self-defending-function.fix.js b/test/visitor/obfuscator/unlock-env/self-defending-function.fix.js new file mode 100644 index 00000000..5d3e5609 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-function.fix.js @@ -0,0 +1,5 @@ +function pick() { + var _0x28e44c = "alpha"; + return _0x28e44c; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/self-defending-function.js b/test/visitor/obfuscator/unlock-env/self-defending-function.js new file mode 100644 index 00000000..6808b708 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-function.js @@ -0,0 +1,23 @@ +function pick() { + var _0x4cca51 = function () { + var _0x223a17 = true; + return function (_0x1942ad, _0x2acf62) { + var _0x22aa46 = _0x223a17 ? function () { + if (_0x2acf62) { + var _0x3540b1 = _0x2acf62.apply(_0x1942ad, arguments); + _0x2acf62 = null; + return _0x3540b1; + } + } : function () {}; + _0x223a17 = false; + return _0x22aa46; + }; + }(), + _0x4b61fd = _0x4cca51(this, function () { + return _0x4b61fd.toString().search("(((.+)+)+)+$").toString().constructor(_0x4b61fd).search("(((.+)+)+)+$"); + }); + _0x4b61fd(); + var _0x28e44c = "alpha"; + return _0x28e44c; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/self-defending-function.src.js b/test/visitor/obfuscator/unlock-env/self-defending-function.src.js new file mode 100644 index 00000000..42dedf16 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-function.src.js @@ -0,0 +1,5 @@ +function pick() { + var value = 'alpha'; + return value; +} +console.log(pick()); diff --git a/test/visitor/obfuscator/unlock-env/self-defending-global.fix.js b/test/visitor/obfuscator/unlock-env/self-defending-global.fix.js new file mode 100644 index 00000000..d0ff08e7 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-global.fix.js @@ -0,0 +1,2 @@ +var value = "alpha"; +console.log(value); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/self-defending-global.js b/test/visitor/obfuscator/unlock-env/self-defending-global.js new file mode 100644 index 00000000..aa4eacda --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-global.js @@ -0,0 +1,20 @@ +var _0x346094 = function () { + var _0x5bdf7a = true; + return function (_0xf814fb, _0x5a0908) { + var _0x45013d = _0x5bdf7a ? function () { + if (_0x5a0908) { + var _0x5319ff = _0x5a0908.apply(_0xf814fb, arguments); + _0x5a0908 = null; + return _0x5319ff; + } + } : function () {}; + _0x5bdf7a = false; + return _0x45013d; + }; + }(), + _0x1dcce9 = _0x346094(this, function () { + return _0x1dcce9.toString().search("(((.+)+)+)+$").toString().constructor(_0x1dcce9).search("(((.+)+)+)+$"); + }); +_0x1dcce9(); +var value = "alpha"; +console.log(value); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/self-defending-global.src.js b/test/visitor/obfuscator/unlock-env/self-defending-global.src.js new file mode 100644 index 00000000..40a98861 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-global.src.js @@ -0,0 +1,2 @@ +var value = 'alpha'; +console.log(value); diff --git a/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.fix.js b/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.fix.js new file mode 100644 index 00000000..5f3e3117 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.fix.js @@ -0,0 +1,5 @@ +function pick() { + var _0x223a17 = "alpha"; + return _0x223a17; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.js b/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.js new file mode 100644 index 00000000..2aae494f --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.js @@ -0,0 +1,27 @@ +function pick() { + var _0x4b61fd = function () { + var _0x1942ad = true; + return function (_0x2acf62, _0x22aa46) { + var _0x3540b1 = _0x1942ad ? function () { + if (_0x22aa46) { + var _0x1a7830 = _0x22aa46.apply(_0x2acf62, arguments); + _0x22aa46 = null; + return _0x1a7830; + } + } : function () {}; + _0x1942ad = false; + return _0x3540b1; + }; + }(), + _0x28e44c = _0x4b61fd(this, function () { + var _0x4685d6 = function () { + var _0x326255 = _0x4685d6.constructor("return /\" + this + \"/")().constructor("^([^ ]+( +[^ ]+)+)+[^ ]}"); + return !_0x326255.test(_0x28e44c); + }; + return _0x4685d6(); + }); + _0x28e44c(); + var _0x223a17 = "alpha"; + return _0x223a17; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.src.js b/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.src.js new file mode 100644 index 00000000..42dedf16 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/self-defending-regexp-era.src.js @@ -0,0 +1,5 @@ +function pick() { + var value = 'alpha'; + return value; +} +console.log(pick()); diff --git a/test/visitor/obfuscator/unlock-env/two-protections.fix.js b/test/visitor/obfuscator/unlock-env/two-protections.fix.js new file mode 100644 index 00000000..006096af --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/two-protections.fix.js @@ -0,0 +1,5 @@ +function pick() { + var _0x1a7830 = "alpha"; + return _0x1a7830; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/two-protections.js b/test/visitor/obfuscator/unlock-env/two-protections.js new file mode 100644 index 00000000..1c001101 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/two-protections.js @@ -0,0 +1,60 @@ +function pick() { + var _0x1942ad = function () { + var _0x4685d6 = true; + return function (_0x326255, _0x10ca41) { + var _0x1796aa = _0x4685d6 ? function () { + if (_0x10ca41) { + var _0x1953e9 = _0x10ca41.apply(_0x326255, arguments); + _0x10ca41 = null; + return _0x1953e9; + } + } : function () {}; + _0x4685d6 = false; + return _0x1796aa; + }; + }(), + _0x2acf62 = _0x1942ad(this, function () { + return _0x2acf62.toString().search("(((.+)+)+)+$").toString().constructor(_0x2acf62).search("(((.+)+)+)+$"); + }); + _0x2acf62(); + var _0x22aa46 = function () { + var _0x2fa39f = true; + return function (_0x2d7ed3, _0xd8bf79) { + var _0x55143b = _0x2fa39f ? function () { + if (_0xd8bf79) { + var _0x4652d1 = _0xd8bf79.apply(_0x2d7ed3, arguments); + _0xd8bf79 = null; + return _0x4652d1; + } + } : function () {}; + _0x2fa39f = false; + return _0x55143b; + }; + }(), + _0x3540b1 = _0x22aa46(this, function () { + var _0x444699 = function () { + var _0xcf3bc4; + try { + _0xcf3bc4 = Function("return (function() {}.constructor(\"return this\")( ));")(); + } catch (_0x489109) { + _0xcf3bc4 = window; + } + return _0xcf3bc4; + }, + _0x15a92e = _0x444699(), + _0x41cabc = _0x15a92e.console = _0x15a92e.console || {}, + _0x4d17ea = ["log", "warn", "info", "error", "exception", "table", "trace"]; + for (var _0x5c6617 = 0; _0x5c6617 < _0x4d17ea.length; _0x5c6617++) { + var _0x190aa8 = _0x22aa46.constructor.prototype.bind(_0x22aa46), + _0x1ded8f = _0x4d17ea[_0x5c6617], + _0x41a47c = _0x41cabc[_0x1ded8f] || _0x190aa8; + _0x190aa8.__proto__ = _0x22aa46.bind(_0x22aa46); + _0x190aa8.toString = _0x41a47c.toString.bind(_0x41a47c); + _0x41cabc[_0x1ded8f] = _0x190aa8; + } + }); + _0x3540b1(); + var _0x1a7830 = "alpha"; + return _0x1a7830; +} +console.log(pick()); \ No newline at end of file diff --git a/test/visitor/obfuscator/unlock-env/two-protections.src.js b/test/visitor/obfuscator/unlock-env/two-protections.src.js new file mode 100644 index 00000000..42dedf16 --- /dev/null +++ b/test/visitor/obfuscator/unlock-env/two-protections.src.js @@ -0,0 +1,5 @@ +function pick() { + var value = 'alpha'; + return value; +} +console.log(pick()); From bef5f658d1436e4d52dc9313d22924213107ce87 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:52:54 +0100 Subject: [PATCH 17/18] feat(plugin/obfuscatorx): add the era-aware entry and its report An **additive second entry** for javascript-obfuscator, not a patch. The existing one is widely depended on, so changing it in place risks breaking people relying on its behaviour; the two are expected to disagree, and that is the point of the split rather than a problem to reconcile. No version in the name, deliberately: coverage is non-contiguous and growing, so any single version would misdescribe it. **Refusal is narrow and means one thing: a layer that is mine, which I could not read.** A falsy return is the only signal the interface has, so it is spent on the case where output would otherwise be silently half-decoded. `absent` and `unowned` both fall through - a foreign residual layer is success plus residue, and declining on it would discard a completed peel and leave no intermediate to chain from, which is how the recorded field workflow actually runs. The cost is stated because the log is the only place it shows: a truthy return no longer implies fully decoded. Exposing a machine-readable verdict was rejected as a wider blast radius than an additive entry intends. **The pipeline is era-invariant** - same passes, same order, every era - with one fixpoint group in the middle because the dependency is a cycle: storage inlining re-opens Converting work that has already reported clean. Three placements were settled by measurement rather than argued, and `prune-if-branch` is load-bearing three times over, so anyone reorganising the group must move it knowing all three roles. The report derives a version *range* by intersecting per-component verdicts and never names a version. It never gates: an unrecognised signature yields `unknown`, an empty intersection is a not-stock diagnostic, and `rotate=none` contributes **no evidence** rather than an era - collapsing that into unknown would invent an era for a sample built with rotation off. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- README.md | 20 +++ package.json | 1 + src/main.js | 2 + src/plugin/obfuscatorx.js | 133 ++++++++++++++ src/visitor/obfuscator/report.js | 167 ++++++++++++++++++ test/obfuscatorx/2.19.0-all-on-objects.fix.js | 20 +++ test/obfuscatorx/2.19.0-all-on-objects.js | 1 + test/obfuscatorx/2.19.0-all-on-objects.src.js | 11 ++ .../2.19.0-dead-code-control.fix.js | 32 ++++ test/obfuscatorx/2.19.0-dead-code-control.js | 1 + .../2.19.0-dead-code-control.src.js | 17 ++ .../obfuscatorx/2.9.6-baseline-strings.fix.js | 9 + test/obfuscatorx/2.9.6-baseline-strings.js | 1 + .../obfuscatorx/2.9.6-baseline-strings.src.js | 10 ++ test/obfuscatorx/obfuscatorx.test.js | 149 ++++++++++++++++ 15 files changed, 574 insertions(+) create mode 100644 src/plugin/obfuscatorx.js create mode 100644 src/visitor/obfuscator/report.js create mode 100644 test/obfuscatorx/2.19.0-all-on-objects.fix.js create mode 100644 test/obfuscatorx/2.19.0-all-on-objects.js create mode 100644 test/obfuscatorx/2.19.0-all-on-objects.src.js create mode 100644 test/obfuscatorx/2.19.0-dead-code-control.fix.js create mode 100644 test/obfuscatorx/2.19.0-dead-code-control.js create mode 100644 test/obfuscatorx/2.19.0-dead-code-control.src.js create mode 100644 test/obfuscatorx/2.9.6-baseline-strings.fix.js create mode 100644 test/obfuscatorx/2.9.6-baseline-strings.js create mode 100644 test/obfuscatorx/2.9.6-baseline-strings.src.js create mode 100644 test/obfuscatorx/obfuscatorx.test.js diff --git a/README.md b/README.md index c67e5073..d1185027 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ One target per run, selected with `-t`: | `sojson` | sojson | | `sojsonv7` | sojson v7 | | `obfuscator` | [javascript-obfuscator](https://github.com/javascript-obfuscator/javascript-obfuscator) (obfuscator.io) | +| `obfuscatorx` | the same obfuscator, version-aware — see below for how it differs | | `jsconfuser` | [JS-Confuser](https://github.com/MichaelXF/js-confuser) | ### `obfuscator` @@ -24,6 +25,25 @@ One target per run, selected with `-t`: * transformer (ObjectExpression, SplitString, and etc.) * customCode (self-defending, debug-protection, console-output) +### `obfuscatorx` + +The same encoder as `obfuscator`, decoded era by era rather than against one shape. It is an +**additional** target, not a replacement: `obfuscator` is widely depended on and is left untouched, +and the two are expected to disagree on some samples. + +Two differences worth knowing before choosing between them: + +* **It declines rather than half-decoding.** Where a string-array layer is present and cannot be + read, this entry returns nothing and says why, instead of emitting a partly-resolved program. + A layer it does not own is not that case — it returns the partial decode so the output can be + fed to another target, and logs what it left behind. +* **It reports the encoder era.** The version range is derived from the emitted shape and printed + after the decode. Emitted output can only identify a *range*, never an exact version, and a + sample carrying no evidence on an axis is reported as such rather than guessed at. + +Coverage is **non-contiguous**: the 2.x eras and the pinned 5.5.0, with 3.0.0–4.2.2 unverified. +A sample whose range overlaps that gap is reported as unknown rather than treated as covered. + ### `jsconfuser` Covers JS-Confuser 2.x up to and including the `high` preset. Which transforms are diff --git a/package.json b/package.json index 080132f5..d01d7350 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "decode": "node src/main.js", "deob": "node src/main.js -t obfuscator", + "deobx": "node src/main.js -t obfuscatorx", "dejsc": "node src/main.js -t jsconfuser", "deso": "node src/main.js -t sojson", "desov7": "node src/main.js -t sojsonv7", diff --git a/src/main.js b/src/main.js index b71ebb4f..59501be5 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,7 @@ import PluginJsconfuser from './plugin/jsconfuser.js' import PluginSojson from './plugin/sojson.js' import PluginSojsonV7 from './plugin/sojsonv7.js' import PluginObfuscator from './plugin/obfuscator.js' +import PluginObfuscatorX from './plugin/obfuscatorx.js' import PluginAwsc from './plugin/awsc.js' import logger from './utility/logger.js' @@ -37,6 +38,7 @@ const plugins = { sojson: PluginSojson, sojsonv7: PluginSojsonV7, obfuscator: PluginObfuscator, + obfuscatorx: PluginObfuscatorX, awsc: PluginAwsc, } diff --git a/src/plugin/obfuscatorx.js b/src/plugin/obfuscatorx.js new file mode 100644 index 00000000..dc7bbfe8 --- /dev/null +++ b/src/plugin/obfuscatorx.js @@ -0,0 +1,133 @@ +import { parse } from '@babel/parser' +import generator from '@babel/generator' +import traverse from '@babel/traverse' + +import logger from '../utility/logger.js' +import calculateConstantExp from '../visitor/calculate-constant-exp.js' +import deleteExtra from '../visitor/delete-extra.js' +import pruneIfBranch from '../visitor/prune-if-branch.js' +import parseControlFlowStorage from '../visitor/parse-control-flow-storage.js' +import normalizeStatements from '../visitor/obfuscator/normalize-statements.js' +import decodeStringArray from '../visitor/obfuscator/string-array.js' +import normalizeConverting from '../visitor/obfuscator/normalize-converting.js' +import { createUnflattenSwitchDispatch } from '../visitor/obfuscator/unflatten-switch-dispatch.js' +import unlockEnv from '../visitor/obfuscator/unlock-env.js' +import { reportEra } from '../visitor/obfuscator/report.js' + +/** + * A version-aware entry for javascript-obfuscator output. + * + * **Additive, and the existing `obfuscator` entry is left untouched.** That one is widely depended + * on, so changing it in place risks breaking people relying on its behaviour; this is a second + * target rather than a replacement, and the two are expected to disagree. + * + * **No version in the name, deliberately.** Precedent puts the encoder's version in the suffix + * (`sojson`/`sojsonv7`), but this entry's coverage is non-contiguous and growing - the phase-1 2.x + * eras plus the 5.5.0 pin, with 3.0.0-4.2.2 unverified - so any single version in the name would + * misdescribe it. + * + * **The pipeline is era-invariant**: the same passes in the same order for every era. Era knowledge + * lives in the detector, which matches shape-first and *outputs* an era rather than taking one as + * input, so there are no per-era strategies here and nothing for a registry to key. + */ + +/** + * The fixpoint group. Storage inlining re-opens Converting work that has already reported clean, so + * these are one group repeated rather than a line run once - a cycle in the dependency order, which + * a re-cut of pass boundaries cannot remove. + * + * `prune-if-branch` is load-bearing three times over: it is the un-flattener's precondition, since + * dead-code injection copies flattened blocks into scopes that do not hold their controller + * storage; it is the whole of the dead-code reversal; and it keeps donated helper clones out of the + * anti-tamper strip's way. + */ +function runGroup(ast, maxRounds = 8) { + let previous = null + let rounds = 0 + for (let round = 0; round < maxRounds; round++) { + rounds = round + 1 + normalizeConverting(ast) + traverse(ast, calculateConstantExp) + traverse(ast, parseControlFlowStorage) + traverse(ast, calculateConstantExp) + traverse(ast, pruneIfBranch) + traverse( + ast, + createUnflattenSwitchDispatch(() => {}), + ) + const current = generator(ast, { compact: true }).code + if (current === previous) break + previous = current + } + return rounds +} + +export default function (code) { + let ast + try { + ast = parse(code, { errorRecovery: true, allowReturnOutsideFunction: true }) + } catch (e) { + logger.error( + `[obfuscatorx] cannot parse input: ${e.reasonCode ?? e.message}`, + ) + return null + } + + // Normalization first: the encoder's Simplifying stage packs statement-level control flow into + // operators, and every matcher below navigates by statement boundaries. + normalizeStatements(ast) + + const sa = decodeStringArray(ast) + + // **Refusal is narrow and means one thing: a layer that is mine, which I could not read.** + // Returning falsy is the only signal a plugin has, so it is spent on the case where the output + // would otherwise be silently half-decoded. The diagnostic is the point - a silent fallthrough is + // what makes the existing entry's failures unreadable. + if (sa.status === 'unreadable') { + logger.error( + '[obfuscatorx] refusing: a javascript-obfuscator string-array layer is present and could ' + + 'not be read, so decoding would emit a half-resolved program.', + ) + for (const note of sa.notes) logger.error(`[obfuscatorx] ${note}`) + return null + } + + // `absent` is not a failure - a sample built with `stringArray: false` carries every other + // transform - and `unowned` is success plus residue, so both fall through to the rest of the + // pipeline. Only the log distinguishes them. + if (sa.status === 'unowned') { + logger.log( + '[obfuscatorx] a string-array layer is present that this entry does not own — decoding ' + + "this entry's own layers and leaving it in place.", + ) + for (const note of sa.notes) logger.log(`[obfuscatorx] ${note}`) + } + + const rounds = runGroup(ast) + // The anti-tamper strip runs last, outside the group: its matchers need the string array decoded + // and the control-flow storage inlined, and inside-versus-after was measured byte-identical, so + // after wins on cost. + unlockEnv(ast) + + logger.debugLog(`[obfuscatorx] fixpoint settled in ${rounds} round(s)`) + + // Reporting only. An unrecognised signature yields `unknown` and never blocks a decode whose + // entrypoint resolved, so this runs after the work rather than gating it. + reportEra(sa.signature) + + // `EscapeSequenceTransformer` rewrites only a literal's `raw` spelling, so the parsed VALUE is + // already what we want and there is no shape to match - discarding `extra` is the whole reversal, + // exactly as it is for numbers. It runs *last*, and print-time is the honest slot: the option + // named after it (`unicodeEscapeSequence`) does not gate the transformer at all, it only widens + // which characters are escaped, so every sample carries this whatever it was built with, and no + // matcher above navigates by `raw`. Scheduling it earlier would also re-spell the source that + // `decodeStringArray` hands to its isolate, for no gain. + traverse(ast, deleteExtra) + + // **A truthy return no longer implies fully decoded**, which is a deliberate weakening of the + // contract taken in exchange for chainability: a foreign residual layer returns the partial + // decode so the caller can feed it to the next target, and the log is the only place that + // difference is visible. + return generator(ast, { comments: false, jsescOption: { minimal: true } }) + .code +} diff --git a/src/visitor/obfuscator/report.js b/src/visitor/obfuscator/report.js new file mode 100644 index 00000000..e7b8b836 --- /dev/null +++ b/src/visitor/obfuscator/report.js @@ -0,0 +1,167 @@ +import logger from '../../utility/logger.js' + +/** + * Turn a detector signature into an era vector and a version range. + * + * Emitted output cannot identify an exact version, only an **era** - a maximal version range over + * which some named thing about the encoder holds still. So this reports a range, derived as the + * *intersection* of the per-component verdicts, and never a single version. + * + * **It never gates a decode.** An unrecognised signature yields `unknown` for that component and a + * decode that has already resolved its entrypoint proceeds regardless; refusal is reserved for a + * layer that is ours and unreadable, which is a different question this file does not answer. + * + * **Per-component, because a verdict has to be able to be partial.** A sample built with rotation + * disabled carries no evidence on that axis at any version, so naming an era for it would be + * inventing one. Only the axes that carry evidence take part in the intersection. + */ + +/** + * Era ranges, transcribed from the encoder package's era registry, which is the only place a range + * and the commit that verified it appear together. Bounds are inclusive. + * + * Kept as data rather than as branches so a registry gaining a row is one edit here and none in the + * matcher - detection is shape-first, so the era is an output of matching rather than an input to + * it, and nothing downstream keys on these names. + */ +const ERA_RANGES = { + 'E-sa-array-declaration': ['0.25.0', '2.18.1'], + 'E-sa-array-self-replacing-fn': ['2.19.0', '5.5.0'], + 'E-sa-wrapper-var-fn-expression': ['2.9.0', '2.11.1'], + 'E-sa-wrapper-fn-declaration': ['2.12.0', '2.15.3'], + 'E-sa-wrapper-self-replacing': ['2.15.4', '2.18.1'], + 'E-sa-wrapper-array-fn-call': ['2.19.0', '4.1.1'], + 'E-sa-wrapper-flat': ['4.2.0', '5.5.0'], + 'E-sa-rotate-counter-loop': ['0.28.0', '2.9.6'], + 'E-sa-rotate-compare-loop': ['2.10.0', '2.18.1'], + 'E-sa-rotate-compare-loop-fn-arg': ['2.19.0', '5.5.0'], +} + +/** Signature string -> era ID, per component. */ +const SIGNATURE_ERAS = { + holder: { + 'var-declaration': 'E-sa-array-declaration', + 'fn-self-replacing': 'E-sa-array-self-replacing-fn', + }, + wrapper: { + 'var-function-expression/plain/reads-identifier': + 'E-sa-wrapper-var-fn-expression', + 'function-declaration/plain/reads-identifier': + 'E-sa-wrapper-fn-declaration', + 'function-declaration/self-replacing/reads-identifier': + 'E-sa-wrapper-self-replacing', + 'function-declaration/self-replacing/reads-call-hoisted': + 'E-sa-wrapper-array-fn-call', + }, + rotate: { + 'counter-loop/none': 'E-sa-rotate-counter-loop', + 'compare-loop/parseint-mul': 'E-sa-rotate-compare-loop', + 'compare-loop/parseint-div': 'E-sa-rotate-compare-loop-fn-arg', + }, +} + +/** + * The range this entry has actually verified, which is deliberately **not contiguous**: the 2.x + * eras studied in phase 1, and separately the pinned 5.5.0. Nothing between has been built or run, + * so a range landing inside the hole is reported as unverified rather than interpolated across. + */ +const COVERAGE_HOLE = ['3.0.0', '4.2.2'] + +const parts = (v) => v.split('.').map(Number) +function compareVersions(a, b) { + const [x, y] = [parts(a), parts(b)] + for (let i = 0; i < 3; i++) { + if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0) + } + return 0 +} +const maxV = (a, b) => (compareVersions(a, b) >= 0 ? a : b) +const minV = (a, b) => (compareVersions(a, b) <= 0 ? a : b) + +/** + * @param {{holder: string, wrapper: string, rotate: string}} signature + * @returns {{ + * eras: Record, + * range: {low: string, high: string}|null, + * conflict: boolean, + * inCoverageHole: boolean, + * }} + */ +export function deriveEra(signature) { + const eras = {} + for (const component of ['holder', 'wrapper', 'rotate']) { + const value = signature?.[component] + if (value === undefined || value === 'none') { + // `rotate=none` is the case this exists for: rotation is an option, so its absence is + // evidence about the options and says nothing about the version. Not unknown - *absent*. + eras[component] = null + continue + } + eras[component] = SIGNATURE_ERAS[component][value] ?? 'unknown' + } + + const known = Object.values(eras).filter((e) => e && e !== 'unknown') + if (!known.length) { + return { eras, range: null, conflict: false, inCoverageHole: false } + } + + let low = '0.0.0' + let high = '99.99.99' + for (const era of known) { + const [lo, hi] = ERA_RANGES[era] + low = maxV(low, lo) + high = minV(high, hi) + } + const conflict = compareVersions(low, high) > 0 + const inCoverageHole = + !conflict && + compareVersions(low, COVERAGE_HOLE[1]) <= 0 && + compareVersions(high, COVERAGE_HOLE[0]) >= 0 + return { + eras, + range: conflict ? null : { low, high }, + conflict, + inCoverageHole, + } +} + +/** + * Log the verdict. Reporting only - it returns nothing a caller can gate on, by design. + * + * **An empty intersection is a diagnostic, not a failure.** It means the components disagree about + * which version could have emitted them, so the sample is either not stock - a modified variant, a + * hand-edited file, a second encoder over the top - or from a version whose shape nobody has + * recorded. Either way the decode has already happened by the time this runs. + */ +export function reportEra(signature) { + const verdict = deriveEra(signature) + const vector = Object.entries(verdict.eras) + .map(([k, v]) => `${k}=${v ?? 'no-evidence'}`) + .join(' ') + + if (verdict.conflict) { + logger.error( + `[obfuscatorx] era: ${vector} — components disagree, so no version could have emitted ` + + `all of them. Not stock: a modified variant, or a version whose shape is unrecorded. ` + + `The decode above is unaffected.`, + ) + return verdict + } + if (!verdict.range) { + logger.log( + `[obfuscatorx] era: ${vector} — no component carries version evidence, so the version is ` + + `unknown. This is the expected reading for a sample with no string array.`, + ) + return verdict + } + logger.log( + `[obfuscatorx] era: ${vector} — version in [${verdict.range.low}, ${verdict.range.high}]` + + (verdict.inCoverageHole + ? ', which overlaps the 3.0.0–4.2.2 range this entry has never verified; treat as unknown ' + + 'rather than covered' + : ''), + ) + return verdict +} + +export default reportEra diff --git a/test/obfuscatorx/2.19.0-all-on-objects.fix.js b/test/obfuscatorx/2.19.0-all-on-objects.fix.js new file mode 100644 index 00000000..1903c169 --- /dev/null +++ b/test/obfuscatorx/2.19.0-all-on-objects.fix.js @@ -0,0 +1,20 @@ +var _0x49c7d0 = { + flag: true +}; +var _0x2be2b2 = { + name: "widget", + size: 3, + nested: _0x49c7d0 +}; +function _0xc8adcc(_0x519e8c) { + var _0x1d414f = _0x519e8c; + return function () { + _0x1d414f += 1; + return _0x1d414f; + }; +} +var _0x1d089d = _0xc8adcc(_0x2be2b2.size); +var _0x5779ed = "name"; +console.log("console-channel"); +process.stdout.write(_0x2be2b2[_0x5779ed] + " " + _0x2be2b2.nested.flag + " " + _0x2be2b2.size + "\n"); +process.stdout.write(_0x1d089d() + " " + _0x1d089d() + " " + Object.keys(_0x2be2b2).join(",") + "\n"); \ No newline at end of file diff --git a/test/obfuscatorx/2.19.0-all-on-objects.js b/test/obfuscatorx/2.19.0-all-on-objects.js new file mode 100644 index 00000000..30862858 --- /dev/null +++ b/test/obfuscatorx/2.19.0-all-on-objects.js @@ -0,0 +1 @@ +function _0xb602(_0x418cdb,_0x433fa8){var _0x256fdb=_0x1096();return _0xb602=function(_0x47bc8a,_0x403186){_0x47bc8a=_0x47bc8a-(-0x2157+0xa1d*0x2+0xe43);var _0x4eaba0=_0x256fdb[_0x47bc8a];if(_0xb602['\x55\x46\x64\x61\x4b\x53']===undefined){var _0x42c0a3=function(_0x24738c){var _0x1929ba='\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x2b\x2f\x3d';var _0x3ec19e='',_0x364ce6='';for(var _0x43c312=-0x1f93+-0x895+0x2828,_0x4e695b,_0x42b50,_0x1bc77a=0x2e9*-0x1+0x230b*0x1+-0xab6*0x3;_0x42b50=_0x24738c['\x63\x68\x61\x72\x41\x74'](_0x1bc77a++);~_0x42b50&&(_0x4e695b=_0x43c312%(-0x1591*-0x1+0x6*0x20+-0x164d*0x1)?_0x4e695b*(0x13*-0x11+0xaab+-0x928)+_0x42b50:_0x42b50,_0x43c312++%(0x1dd7*-0x1+-0x204f+0x3e2a))?_0x3ec19e+=String['\x66\x72\x6f\x6d\x43\x68\x61\x72\x43\x6f\x64\x65'](-0x7a+-0x742+-0x2e9*-0x3&_0x4e695b>>(-(-0x509*-0x7+-0x237e*-0x1+-0x3b9*0x13)*_0x43c312&0x1ca8+-0x189*0x14+0x212)):-0x13f7*-0x1+0x1b41+-0xbce*0x4){_0x42b50=_0x1929ba['\x69\x6e\x64\x65\x78\x4f\x66'](_0x42b50);}for(var _0x2a6a1c=-0x1471+-0x1d04+0xb*0x47f,_0x3a37c8=_0x3ec19e['\x6c\x65\x6e\x67\x74\x68'];_0x2a6a1c<_0x3a37c8;_0x2a6a1c++){_0x364ce6+='\x25'+('\x30\x30'+_0x3ec19e['\x63\x68\x61\x72\x43\x6f\x64\x65\x41\x74'](_0x2a6a1c)['\x74\x6f\x53\x74\x72\x69\x6e\x67'](0x1436+-0x2c2*-0x1+-0x16e8))['\x73\x6c\x69\x63\x65'](-(-0x1509+-0x34c+0x1857));}return decodeURIComponent(_0x364ce6);};_0xb602['\x77\x63\x69\x71\x47\x74']=_0x42c0a3,_0x418cdb=arguments,_0xb602['\x55\x46\x64\x61\x4b\x53']=!![];}var _0x106b82=_0x256fdb[0x3*0xb49+-0x107f*0x2+0x11*-0xd],_0x4837dd=_0x47bc8a+_0x106b82,_0x66126b=_0x418cdb[_0x4837dd];return!_0x66126b?(_0x4eaba0=_0xb602['\x77\x63\x69\x71\x47\x74'](_0x4eaba0),_0x418cdb[_0x4837dd]=_0x4eaba0):_0x4eaba0=_0x66126b,_0x4eaba0;},_0xb602(_0x418cdb,_0x433fa8);}(function(_0x490a46,_0x13c98d){function _0x123e78(_0x4bd219,_0x3ffbf5,_0x19948b,_0x38b09b,_0x28d1db){return _0x4265(_0x3ffbf5- -0x53,_0x38b09b);}function _0x41f479(_0x2c09b9,_0x550201,_0x1b3de9,_0x66e24a,_0x3317c6){return _0xb602(_0x2c09b9- -'\x30\x78\x33\x30\x62',_0x3317c6);}function _0x40eee4(_0x5813e2,_0x4aa943,_0x51baca,_0x539378,_0x2294ad){return _0x2c22(_0x539378- -'\x30\x78\x64\x63',_0x5813e2);}function _0x4b03b0(_0x365f91,_0xd515b6,_0x567030,_0x26fb04,_0x118cc6){return _0x4265(_0x365f91-'\x30\x78\x33\x63\x66',_0x118cc6);}var _0x524300=_0x490a46();function _0x35f6b5(_0x1676f5,_0x317a19,_0xc60cb0,_0x117806,_0xa2ff4b){return _0x2c22(_0x1676f5-'\x30\x78\x31\x63\x33',_0xc60cb0);}function _0x22f21b(_0x4ae414,_0x581e92,_0x1a1447,_0x22f872,_0x19b8df){return _0x4265(_0x22f872-0x23a,_0x4ae414);}function _0xb5bb06(_0x42e364,_0x5530e3,_0x3e7919,_0x302189,_0x408594){return _0x2c22(_0x3e7919- -'\x30\x78\x33\x31\x37',_0x5530e3);}function _0x1d357c(_0x441ef5,_0x1d0db9,_0x509e25,_0x4cd429,_0x37b359){return _0x4265(_0x509e25- -0x38a,_0x37b359);}function _0x93bba7(_0x3b6e36,_0xa5ea68,_0x597cbd,_0x2364a3,_0x1a9de4){return _0x2c22(_0x3b6e36- -0x296,_0x2364a3);}function _0x38a976(_0xcea198,_0x328984,_0x26b591,_0x14ce2f,_0x310343){return _0x4265(_0x26b591- -'\x30\x78\x32\x64\x36',_0xcea198);}function _0x3cb65d(_0x233083,_0x2ad371,_0x19a6bc,_0x12d711,_0x46910a){return _0xb602(_0x46910a- -0x3d1,_0x2ad371);}while(!![]){try{var _0x1c62fd=parseInt(_0x4b03b0(0x4fc,'\x30\x78\x34\x66\x36',0x4f6,0x500,'\x46\x61\x62\x5e'))/(-0x390*-0x2+-0x9e5*-0x3+-0x2a1*0xe)+parseInt(_0x4b03b0('\x30\x78\x34\x66\x61',0x4fa,'\x30\x78\x35\x30\x32',0x503,'\x6a\x46\x21\x4b'))/(-0x9*0x6c+0x1*0xcc7+-0x8f9*0x1)*(-parseInt(_0x38a976('\x6a\x46\x21\x4b',-0x19f,-0x1a5,-'\x30\x78\x31\x39\x64',-'\x30\x78\x31\x39\x66'))/(-0x2223+0x1*0xe89+0x139d))+-parseInt(_0x35f6b5('\x30\x78\x32\x66\x33',0x2f2,0x2f6,0x2fb,0x2eb))/(0x1*0x13dc+0xabd+0x1e95*-0x1)+-parseInt(_0x4b03b0(0x508,'\x30\x78\x35\x30\x32',0x50a,0x512,'\x52\x4e\x51\x6d'))/(-0x2034+-0x1609*0x1+-0xf*-0x39e)*(-parseInt(_0xb5bb06(-0x1da,-0x1db,-'\x30\x78\x31\x65\x30',-0x1da,-0x1dc))/(-0x13af*0x1+-0x1a3f+0x2df4))+-parseInt(_0x3cb65d(-'\x30\x78\x32\x39\x65',-0x292,-0x293,-0x290,-0x299))/(0x7be+-0xa*0x30+-0x5d7)*(parseInt(_0x3cb65d(-'\x30\x78\x32\x61\x35',-0x2a4,-'\x30\x78\x32\x39\x61',-'\x30\x78\x32\x61\x36',-'\x30\x78\x32\x39\x66'))/(0x219*-0x7+-0x220f+0x30c6))+parseInt(_0x40eee4(0x52,'\x30\x78\x35\x37','\x30\x78\x35\x37',0x59,'\x30\x78\x35\x64'))/(0x5e*-0xb+-0x31*0x16+0x849)+-parseInt(_0xb5bb06(-0x1dc,-'\x30\x78\x31\x64\x38',-'\x30\x78\x31\x65\x31',-'\x30\x78\x31\x64\x61',-0x1e0))/(-0x1a0e+-0xc46*0x1+0x265e)*(-parseInt(_0x22f21b('\x47\x63\x57\x65','\x30\x78\x33\x35\x62',0x368,'\x30\x78\x33\x36\x31',0x361))/(-0x26dc+-0x1a21+0x4108));if(_0x1c62fd===_0x13c98d)break;else _0x524300['push'](_0x524300['shift']());}catch(_0x40a744){_0x524300['push'](_0x524300['shift']());}}}(_0x1096,0x3*0x1ad99+-0x1*0x49de+-0x21db0));var _0x49c7d0={};_0x49c7d0['\x66\x6c'+'\x61\x67']=!![];var _0x45c94f={};function _0x2c22(_0x926360,_0x253667){var _0x25e330=_0x1096();return _0x2c22=function(_0x1ce159,_0x1096d3){_0x1ce159=_0x1ce159-(-0x2157+0xa1d*0x2+0xe43);var _0x2c22e1=_0x25e330[_0x1ce159];return _0x2c22e1;},_0x2c22(_0x926360,_0x253667);}_0x45c94f['\x6e\x61'+'\x6d\x65']='\x77\x69'+'\x64\x67'+'\x65\x74',_0x45c94f['\x73\x69'+'\x7a\x65']=0x3,_0x45c94f['\x6e\x65'+'\x73\x74'+'\x65\x64']=_0x49c7d0;var _0x2be2b2=_0x45c94f;function _0xc8adcc(_0x519e8c){var _0x1fd9c8={'\x6e\x46\x46\x77\x7a':function(_0x1271c4,_0x4d8a94){return _0x1271c4(_0x4d8a94);},'\x7a\x42\x4a\x79\x57':function(_0x507be2,_0x354d30){return _0x507be2+_0x354d30;},'\x57\x4f\x79\x71\x52':function(_0x390d27,_0x3c7396){return _0x390d27+_0x3c7396;},'\x62\x73\x7a\x56\x76':'\x72\x65'+'\x74\x75'+'\x72\x6e'+'\x20\x28'+'\x66\x75'+'\x6e\x63'+'\x74\x69'+'\x6f\x6e'+'\x28\x29'+'\x20','\x70\x54\x68\x6e\x5a':'\x7b\x7d'+'\x2e\x63'+'\x6f\x6e'+'\x73\x74'+'\x72\x75'+'\x63\x74'+'\x6f\x72'+'\x28\x22'+'\x72\x65'+'\x74\x75'+'\x72\x6e'+'\x20\x74'+'\x68\x69'+'\x73\x22'+'\x29\x28'+'\x20\x29','\x41\x42\x6c\x73\x6a':function(_0x4608d9){return _0x4608d9();},'\x6f\x75\x47\x65\x72':'\x77\x68'+'\x69\x6c'+'\x65\x20'+'\x28\x74'+'\x72\x75'+'\x65\x29'+'\x20\x7b'+'\x7d','\x62\x67\x57\x4e\x61':'\x63\x6f'+'\x75\x6e'+'\x74\x65'+'\x72','\x61\x55\x53\x55\x72':function(_0x169ff3,_0x3e2341){return _0x169ff3===_0x3e2341;},'\x63\x62\x53\x72\x65':'\x6f\x61'+'\x6e\x50'+'\x50','\x53\x69\x48\x61\x7a':'\x6f\x74'+'\x55\x6e'+'\x45','\x43\x67\x41\x44\x63':'\x74\x50'+'\x64\x75'+'\x66','\x67\x78\x68\x6b\x70':'\x53\x72'+'\x4e\x61'+'\x61','\x4d\x7a\x46\x42\x4a':'\x47\x68'+'\x4b\x6c'+'\x4f','\x61\x4c\x53\x44\x48':function(_0x49f34c,_0x2e78e1){return _0x49f34c!==_0x2e78e1;},'\x57\x70\x49\x47\x73':'\x61\x55'+'\x61\x56'+'\x66','\x55\x58\x72\x4f\x6f':'\x41\x4f'+'\x68\x78'+'\x49','\x73\x59\x53\x69\x5a':'\x4f\x65'+'\x63\x50'+'\x72','\x6b\x4c\x47\x61\x79':'\x73\x61'+'\x57\x4b'+'\x59','\x67\x53\x4b\x79\x4a':'\x66\x75'+'\x6e\x63'+'\x74\x69'+'\x6f\x6e'+'\x20\x2a'+'\x5c\x28'+'\x20\x2a'+'\x5c\x29','\x5a\x56\x51\x55\x6a':'\x5c\x2b'+'\x5c\x2b'+'\x20\x2a'+'\x28\x3f'+'\x3a\x5b'+'\x61\x2d'+'\x7a\x41'+'\x2d\x5a'+'\x5f\x24'+'\x5d\x5b'+'\x30\x2d'+'\x39\x61'+'\x2d\x7a'+'\x41\x2d'+'\x5a\x5f'+'\x24\x5d'+'\x2a\x29','\x43\x58\x7a\x64\x54':function(_0xde3bb7,_0xd5d85){return _0xde3bb7(_0xd5d85);},'\x4d\x55\x41\x65\x52':'\x69\x6e'+'\x69\x74','\x47\x6a\x42\x6f\x64':function(_0x3c613a,_0x37873e){return _0x3c613a+_0x37873e;},'\x4d\x53\x74\x42\x72':'\x63\x68'+'\x61\x69'+'\x6e','\x4f\x54\x55\x6b\x4a':'\x69\x6e'+'\x70\x75'+'\x74','\x6c\x67\x74\x6e\x61':function(_0x9a5229,_0x33803c){return _0x9a5229===_0x33803c;},'\x4e\x75\x58\x44\x56':'\x4d\x7a'+'\x72\x4e'+'\x43','\x49\x70\x69\x6c\x46':'\x51\x42'+'\x6d\x44'+'\x49','\x6c\x69\x6c\x52\x4d':function(_0x2031be,_0x56e421){return _0x2031be===_0x56e421;},'\x43\x76\x48\x70\x4b':'\x59\x62'+'\x73\x4c'+'\x70','\x6a\x78\x65\x5a\x68':'\x44\x66'+'\x6a\x64'+'\x78','\x70\x55\x78\x4f\x70':'\x33\x7c'+'\x34\x7c'+'\x31\x7c'+'\x30\x7c'+'\x32\x7c'+'\x35','\x65\x68\x62\x79\x44':'\x6a\x4f'+'\x73\x70'+'\x72','\x7a\x70\x46\x5a\x48':'\x44\x69'+'\x6c\x65'+'\x54','\x73\x49\x69\x75\x53':function(_0x1b9967,_0x12ef5b,_0x213a31){return _0x1b9967(_0x12ef5b,_0x213a31);},'\x7a\x4e\x42\x6f\x50':function(_0x2c8cea,_0x181c9a){return _0x2c8cea+_0x181c9a;},'\x66\x58\x49\x46\x71':function(_0x5c44f3,_0x4b72c6){return _0x5c44f3===_0x4b72c6;},'\x77\x64\x41\x42\x4b':'\x53\x70'+'\x48\x76'+'\x78','\x64\x6c\x54\x65\x53':function(_0x4ae9ed,_0x1e2ab0){return _0x4ae9ed===_0x1e2ab0;},'\x4f\x46\x61\x65\x50':'\x41\x50'+'\x6b\x7a'+'\x66','\x67\x56\x65\x74\x5a':'\x75\x79'+'\x63\x73'+'\x4b','\x4a\x54\x55\x47\x72':function(_0x17be37,_0x3c171b){return _0x17be37+_0x3c171b;},'\x79\x55\x48\x76\x66':'\x64\x65'+'\x62\x75','\x49\x63\x48\x61\x72':'\x67\x67'+'\x65\x72','\x75\x42\x72\x7a\x4d':'\x73\x74'+'\x61\x74'+'\x65\x4f'+'\x62\x6a'+'\x65\x63'+'\x74','\x55\x6f\x56\x4a\x71':function(_0xafda7,_0x2b06d2){return _0xafda7===_0x2b06d2;},'\x6c\x64\x4b\x46\x62':'\x54\x4b'+'\x69\x5a'+'\x43','\x48\x74\x59\x4a\x69':function(_0x134cca,_0x5133b4){return _0x134cca(_0x5133b4);},'\x4f\x57\x71\x4a\x73':'\x4c\x71'+'\x56\x41'+'\x77','\x46\x78\x55\x51\x62':'\x44\x78'+'\x79\x44'+'\x67','\x57\x45\x4c\x67\x50':'\x51\x77'+'\x4c\x6b'+'\x66','\x4c\x64\x67\x59\x61':function(_0x23c380,_0x5a7ac2){return _0x23c380(_0x5a7ac2);},'\x74\x6a\x58\x4f\x57':function(_0x4d97e7,_0x2c8673){return _0x4d97e7+_0x2c8673;},'\x62\x53\x78\x70\x63':'\x47\x56'+'\x45\x72'+'\x4c','\x55\x58\x43\x41\x50':'\x6c\x6f'+'\x67','\x45\x59\x77\x50\x63':'\x77\x61'+'\x72\x6e','\x46\x77\x48\x75\x46':'\x69\x6e'+'\x66\x6f','\x43\x6a\x72\x75\x41':'\x65\x72'+'\x72\x6f'+'\x72','\x65\x6d\x73\x45\x6f':'\x65\x78'+'\x63\x65'+'\x70\x74'+'\x69\x6f'+'\x6e','\x6b\x71\x73\x4f\x4c':'\x74\x61'+'\x62\x6c'+'\x65','\x66\x42\x6f\x76\x49':'\x74\x72'+'\x61\x63'+'\x65','\x4d\x44\x55\x7a\x55':function(_0x762eff,_0x438dcc){return _0x762eff<_0x438dcc;},'\x55\x58\x50\x4e\x6e':function(_0x515e19,_0x4b69cd){return _0x515e19===_0x4b69cd;},'\x4a\x4b\x58\x66\x48':'\x4e\x71'+'\x72\x4c'+'\x4a','\x4c\x63\x4b\x61\x47':'\x33\x7c'+'\x34\x7c'+'\x35\x7c'+'\x30\x7c'+'\x32\x7c'+'\x31','\x7a\x46\x4d\x6d\x67':function(_0x496a90,_0x495700){return _0x496a90(_0x495700);},'\x5a\x51\x75\x6f\x4a':function(_0x52e8d2){return _0x52e8d2();},'\x70\x76\x49\x41\x75':'\x4b\x61'+'\x61\x69'+'\x71','\x73\x61\x49\x78\x49':function(_0x1c9ac6,_0x1c95be,_0xfb5ac5){return _0x1c9ac6(_0x1c95be,_0xfb5ac5);},'\x74\x76\x51\x6a\x43':function(_0x5ca09e){return _0x5ca09e();}},_0xf9deb3=function(){var _0x45714f={'\x73\x68\x54\x70\x73':function(_0x193143,_0x364736){return _0x1fd9c8['\x6e\x46'+'\x46\x77'+'\x7a'](_0x193143,_0x364736);},'\x79\x5a\x78\x77\x45':function(_0x84cef1,_0x2c3909){return _0x1fd9c8['\x7a\x42'+'\x4a\x79'+'\x57'](_0x84cef1,_0x2c3909);},'\x49\x79\x56\x41\x79':function(_0x14f2f4,_0x4bd07f){return _0x1fd9c8['\x57\x4f'+'\x79\x71'+'\x52'](_0x14f2f4,_0x4bd07f);},'\x6b\x68\x54\x66\x74':_0x1fd9c8['\x62\x73'+'\x7a\x56'+'\x76'],'\x74\x76\x51\x4b\x48':_0x1fd9c8['\x70\x54'+'\x68\x6e'+'\x5a'],'\x75\x74\x66\x6c\x4c':function(_0x35cba8){return _0x1fd9c8['\x41\x42'+'\x6c\x73'+'\x6a'](_0x35cba8);},'\x51\x5a\x56\x58\x69':function(_0x322247,_0x426311){return _0x1fd9c8['\x6e\x46'+'\x46\x77'+'\x7a'](_0x322247,_0x426311);},'\x76\x46\x4e\x4d\x69':_0x1fd9c8['\x6f\x75'+'\x47\x65'+'\x72'],'\x5a\x6e\x50\x41\x46':_0x1fd9c8['\x62\x67'+'\x57\x4e'+'\x61'],'\x44\x58\x4c\x75\x6a':function(_0x7e511e,_0x2a2050){return _0x1fd9c8['\x61\x55'+'\x53\x55'+'\x72'](_0x7e511e,_0x2a2050);},'\x6a\x44\x44\x58\x52':_0x1fd9c8['\x63\x62'+'\x53\x72'+'\x65'],'\x68\x42\x4e\x4d\x62':_0x1fd9c8['\x53\x69'+'\x48\x61'+'\x7a'],'\x4d\x76\x61\x7a\x55':_0x1fd9c8['\x43\x67'+'\x41\x44'+'\x63'],'\x6d\x74\x49\x65\x53':_0x1fd9c8['\x67\x78'+'\x68\x6b'+'\x70'],'\x6f\x4d\x79\x4d\x71':_0x1fd9c8['\x4d\x7a'+'\x46\x42'+'\x4a']};if(_0x1fd9c8['\x61\x4c'+'\x53\x44'+'\x48'](_0x1fd9c8['\x57\x70'+'\x49\x47'+'\x73'],_0x1fd9c8['\x55\x58'+'\x72\x4f'+'\x6f'])){var _0x1331ea=!![];return function(_0x185fcd,_0xa66d7){var _0x2f0129={'\x5a\x4b\x77\x69\x6a':function(_0x1401d8,_0x1be323){return _0x45714f['\x51\x5a'+'\x56\x58'+'\x69'](_0x1401d8,_0x1be323);},'\x4b\x4a\x47\x6f\x52':_0x45714f['\x76\x46'+'\x4e\x4d'+'\x69'],'\x53\x4d\x69\x4e\x68':_0x45714f['\x5a\x6e'+'\x50\x41'+'\x46'],'\x79\x48\x76\x4a\x76':function(_0x1d9511,_0x25021a){return _0x45714f['\x44\x58'+'\x4c\x75'+'\x6a'](_0x1d9511,_0x25021a);},'\x64\x6a\x4e\x6e\x62':_0x45714f['\x6a\x44'+'\x44\x58'+'\x52'],'\x68\x61\x6a\x44\x49':_0x45714f['\x68\x42'+'\x4e\x4d'+'\x62'],'\x5a\x62\x78\x46\x4e':_0x45714f['\x4d\x76'+'\x61\x7a'+'\x55']};if(_0x45714f['\x44\x58'+'\x4c\x75'+'\x6a'](_0x45714f['\x6d\x74'+'\x49\x65'+'\x53'],_0x45714f['\x6f\x4d'+'\x79\x4d'+'\x71'])){var _0x1dba32=ausdmg['\x73\x68'+'\x54\x70'+'\x73'](_0x29c11c,ausdmg['\x79\x5a'+'\x78\x77'+'\x45'](ausdmg['\x49\x79'+'\x56\x41'+'\x79'](ausdmg['\x6b\x68'+'\x54\x66'+'\x74'],ausdmg['\x74\x76'+'\x51\x4b'+'\x48']),'\x29\x3b'));_0x24e75e=ausdmg['\x75\x74'+'\x66\x6c'+'\x4c'](_0x1dba32);}else{var _0x14dc1c=_0x1331ea?function(){var _0x51f4c0={};_0x51f4c0['\x46\x79'+'\x42\x61'+'\x59']=_0x2f0129['\x4b\x4a'+'\x47\x6f'+'\x52'],_0x51f4c0['\x73\x67'+'\x68\x6f'+'\x46']=_0x2f0129['\x53\x4d'+'\x69\x4e'+'\x68'];var _0x2506fa=_0x51f4c0;if(_0x2f0129['\x79\x48'+'\x76\x4a'+'\x76'](_0x2f0129['\x64\x6a'+'\x4e\x6e'+'\x62'],_0x2f0129['\x68\x61'+'\x6a\x44'+'\x49']))return function(_0x58f664){}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](iebRPL['\x46\x79'+'\x42\x61'+'\x59'])['\x61\x70'+'\x70\x6c'+'\x79'](iebRPL['\x73\x67'+'\x68\x6f'+'\x46']);else{if(_0xa66d7){if(_0x2f0129['\x79\x48'+'\x76\x4a'+'\x76'](_0x2f0129['\x5a\x62'+'\x78\x46'+'\x4e'],_0x2f0129['\x5a\x62'+'\x78\x46'+'\x4e'])){var _0x37e280=_0xa66d7['\x61\x70'+'\x70\x6c'+'\x79'](_0x185fcd,arguments);return _0xa66d7=null,_0x37e280;}else ryjkpw['\x5a\x4b'+'\x77\x69'+'\x6a'](_0x47aea1,-0x132e+-0x23c0+0x36ee);}}}:function(){};return _0x1331ea=![],_0x14dc1c;}};}else{var _0x2d90ff=_0x4983de?function(){if(_0x379309){var _0x4246f0=_0x10e8b6['\x61\x70'+'\x70\x6c'+'\x79'](_0x5cb853,arguments);return _0x51dbab=null,_0x4246f0;}}:function(){};return _0x5ba3b4=![],_0x2d90ff;}}();(function(){var _0x39d146={};_0x39d146['\x4c\x45'+'\x63\x4f'+'\x6a']=_0x1fd9c8['\x70\x55'+'\x78\x4f'+'\x70'];var _0x43c0d6=_0x39d146;if(_0x1fd9c8['\x6c\x69'+'\x6c\x52'+'\x4d'](_0x1fd9c8['\x65\x68'+'\x62\x79'+'\x44'],_0x1fd9c8['\x7a\x70'+'\x46\x5a'+'\x48'])){var _0x3da697=_0x29a46a['\x61\x70'+'\x70\x6c'+'\x79'](_0xda768d,arguments);return _0x5be4c6=null,_0x3da697;}else _0x1fd9c8['\x73\x49'+'\x69\x75'+'\x53'](_0xf9deb3,this,function(){if(_0x1fd9c8['\x61\x55'+'\x53\x55'+'\x72'](_0x1fd9c8['\x73\x59'+'\x53\x69'+'\x5a'],_0x1fd9c8['\x6b\x4c'+'\x47\x61'+'\x79']))return _0x31b9cc+=-0xc0b*0x1+-0xf3e+0x1b4a,_0x48be42;else{var _0x138a05=new RegExp(_0x1fd9c8['\x67\x53'+'\x4b\x79'+'\x4a']),_0x5801f7=new RegExp(_0x1fd9c8['\x5a\x56'+'\x51\x55'+'\x6a'],'\x69'),_0x1ee410=_0x1fd9c8['\x43\x58'+'\x7a\x64'+'\x54'](_0x3a7191,_0x1fd9c8['\x4d\x55'+'\x41\x65'+'\x52']);if(!_0x138a05['\x74\x65'+'\x73\x74'](_0x1fd9c8['\x47\x6a'+'\x42\x6f'+'\x64'](_0x1ee410,_0x1fd9c8['\x4d\x53'+'\x74\x42'+'\x72']))||!_0x5801f7['\x74\x65'+'\x73\x74'](_0x1fd9c8['\x47\x6a'+'\x42\x6f'+'\x64'](_0x1ee410,_0x1fd9c8['\x4f\x54'+'\x55\x6b'+'\x4a']))){if(_0x1fd9c8['\x6c\x67'+'\x74\x6e'+'\x61'](_0x1fd9c8['\x4e\x75'+'\x58\x44'+'\x56'],_0x1fd9c8['\x49\x70'+'\x69\x6c'+'\x46'])){var _0x515d9a=_0x1ea160?function(){if(_0xa1b3b2){var _0x6d6b80=_0xfd8d09['\x61\x70'+'\x70\x6c'+'\x79'](_0x461332,arguments);return _0x1fe8d8=null,_0x6d6b80;}}:function(){};return _0x40ce9a=![],_0x515d9a;}else _0x1fd9c8['\x43\x58'+'\x7a\x64'+'\x54'](_0x1ee410,'\x30');}else{if(_0x1fd9c8['\x6c\x69'+'\x6c\x52'+'\x4d'](_0x1fd9c8['\x43\x76'+'\x48\x70'+'\x4b'],_0x1fd9c8['\x6a\x78'+'\x65\x5a'+'\x68'])){var _0x5cc6e=_0x43c0d6['\x4c\x45'+'\x63\x4f'+'\x6a']['\x73\x70'+'\x6c\x69'+'\x74']('\x7c'),_0x3a764d=-0x134c+0x13a6+-0x5a;while(!![]){switch(_0x5cc6e[_0x3a764d++]){case'\x30':_0x893979['\x5f\x5f'+'\x70\x72'+'\x6f\x74'+'\x6f\x5f'+'\x5f']=_0x560d98['\x62\x69'+'\x6e\x64'](_0x565d7f);continue;case'\x31':var _0x435615=_0x8e6bab[_0x56d6d2]||_0x893979;continue;case'\x32':_0x893979['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']=_0x435615['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']['\x62\x69'+'\x6e\x64'](_0x435615);continue;case'\x33':var _0x893979=_0x230a88['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72']['\x70\x72'+'\x6f\x74'+'\x6f\x74'+'\x79\x70'+'\x65']['\x62\x69'+'\x6e\x64'](_0x3089be);continue;case'\x34':var _0x56d6d2=_0x224ac8[_0x110cdc];continue;case'\x35':_0x389a7e[_0x56d6d2]=_0x893979;continue;}break;}}else _0x1fd9c8['\x41\x42'+'\x6c\x73'+'\x6a'](_0x3a7191);}}})();}());var _0x2cb339=function(){var _0x3de2d2={'\x47\x4d\x4c\x77\x4c':function(_0x4dc9d8,_0x1d18c4){return _0x1fd9c8['\x4a\x54'+'\x55\x47'+'\x72'](_0x4dc9d8,_0x1d18c4);},'\x43\x4e\x4f\x74\x63':_0x1fd9c8['\x79\x55'+'\x48\x76'+'\x66'],'\x54\x4a\x4c\x48\x65':_0x1fd9c8['\x49\x63'+'\x48\x61'+'\x72'],'\x69\x64\x57\x41\x45':_0x1fd9c8['\x75\x42'+'\x72\x7a'+'\x4d']};if(_0x1fd9c8['\x55\x6f'+'\x56\x4a'+'\x71'](_0x1fd9c8['\x6c\x64'+'\x4b\x46'+'\x62'],_0x1fd9c8['\x6c\x64'+'\x4b\x46'+'\x62'])){var _0x501619=!![];return function(_0x2390dc,_0x2cf227){var _0x391f9c={'\x76\x63\x51\x73\x6c':_0x1fd9c8['\x67\x53'+'\x4b\x79'+'\x4a'],'\x4c\x43\x44\x6e\x62':_0x1fd9c8['\x5a\x56'+'\x51\x55'+'\x6a'],'\x43\x62\x48\x6c\x74':function(_0x391c3d,_0x9cf06d){return _0x1fd9c8['\x43\x58'+'\x7a\x64'+'\x54'](_0x391c3d,_0x9cf06d);},'\x67\x74\x61\x6d\x6c':_0x1fd9c8['\x4d\x55'+'\x41\x65'+'\x52'],'\x50\x56\x54\x4a\x6d':function(_0x170e4f,_0x1f3403){return _0x1fd9c8['\x47\x6a'+'\x42\x6f'+'\x64'](_0x170e4f,_0x1f3403);},'\x75\x4e\x6b\x42\x51':_0x1fd9c8['\x4d\x53'+'\x74\x42'+'\x72'],'\x75\x49\x67\x67\x53':function(_0x52c150,_0x41582b){return _0x1fd9c8['\x7a\x4e'+'\x42\x6f'+'\x50'](_0x52c150,_0x41582b);},'\x73\x4d\x61\x58\x66':_0x1fd9c8['\x4f\x54'+'\x55\x6b'+'\x4a'],'\x46\x55\x49\x44\x4d':function(_0x40f043){return _0x1fd9c8['\x41\x42'+'\x6c\x73'+'\x6a'](_0x40f043);},'\x78\x4d\x6e\x50\x74':function(_0x1c3539,_0x462b52){return _0x1fd9c8['\x66\x58'+'\x49\x46'+'\x71'](_0x1c3539,_0x462b52);},'\x44\x75\x46\x4f\x52':_0x1fd9c8['\x77\x64'+'\x41\x42'+'\x4b'],'\x6d\x61\x5a\x48\x50':function(_0x3418b6,_0x499350){return _0x1fd9c8['\x64\x6c'+'\x54\x65'+'\x53'](_0x3418b6,_0x499350);},'\x4f\x59\x47\x58\x63':_0x1fd9c8['\x4f\x46'+'\x61\x65'+'\x50']};if(_0x1fd9c8['\x64\x6c'+'\x54\x65'+'\x53'](_0x1fd9c8['\x67\x56'+'\x65\x74'+'\x5a'],_0x1fd9c8['\x67\x56'+'\x65\x74'+'\x5a'])){var _0x4b469b=_0x501619?function(){if(_0x391f9c['\x78\x4d'+'\x6e\x50'+'\x74'](_0x391f9c['\x44\x75'+'\x46\x4f'+'\x52'],_0x391f9c['\x44\x75'+'\x46\x4f'+'\x52'])){if(_0x2cf227){if(_0x391f9c['\x6d\x61'+'\x5a\x48'+'\x50'](_0x391f9c['\x4f\x59'+'\x47\x58'+'\x63'],_0x391f9c['\x4f\x59'+'\x47\x58'+'\x63'])){var _0x1f34ed=_0x2cf227['\x61\x70'+'\x70\x6c'+'\x79'](_0x2390dc,arguments);return _0x2cf227=null,_0x1f34ed;}else{var _0x1533db=new _0x47bc8a(kgtOAr['\x76\x63'+'\x51\x73'+'\x6c']),_0xf6a153=new _0x403186(kgtOAr['\x4c\x43'+'\x44\x6e'+'\x62'],'\x69'),_0x588c8b=kgtOAr['\x43\x62'+'\x48\x6c'+'\x74'](_0x4eaba0,kgtOAr['\x67\x74'+'\x61\x6d'+'\x6c']);!_0x1533db['\x74\x65'+'\x73\x74'](kgtOAr['\x50\x56'+'\x54\x4a'+'\x6d'](_0x588c8b,kgtOAr['\x75\x4e'+'\x6b\x42'+'\x51']))||!_0xf6a153['\x74\x65'+'\x73\x74'](kgtOAr['\x75\x49'+'\x67\x67'+'\x53'](_0x588c8b,kgtOAr['\x73\x4d'+'\x61\x58'+'\x66']))?kgtOAr['\x43\x62'+'\x48\x6c'+'\x74'](_0x588c8b,'\x30'):kgtOAr['\x46\x55'+'\x49\x44'+'\x4d'](_0x106b82);}}}else return _0x4c5ec6;}:function(){};return _0x501619=![],_0x4b469b;}else _0x3528d0=_0x2663ed;};}else(function(){return![];}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](PDhiFM['\x47\x4d'+'\x4c\x77'+'\x4c'](PDhiFM['\x43\x4e'+'\x4f\x74'+'\x63'],PDhiFM['\x54\x4a'+'\x4c\x48'+'\x65']))['\x61\x70'+'\x70\x6c'+'\x79'](PDhiFM['\x69\x64'+'\x57\x41'+'\x45']));}(),_0x5c2126=_0x1fd9c8['\x73\x61'+'\x49\x78'+'\x49'](_0x2cb339,this,function(){var _0x36f7f1={'\x79\x4b\x58\x4f\x65':function(_0x3f804a){return _0x1fd9c8['\x41\x42'+'\x6c\x73'+'\x6a'](_0x3f804a);}};if(_0x1fd9c8['\x55\x6f'+'\x56\x4a'+'\x71'](_0x1fd9c8['\x4f\x57'+'\x71\x4a'+'\x73'],_0x1fd9c8['\x4f\x57'+'\x71\x4a'+'\x73'])){var _0x4061fe;try{if(_0x1fd9c8['\x55\x6f'+'\x56\x4a'+'\x71'](_0x1fd9c8['\x46\x78'+'\x55\x51'+'\x62'],_0x1fd9c8['\x57\x45'+'\x4c\x67'+'\x50'])){if(_0x123641){var _0x20d47a=_0x404226['\x61\x70'+'\x70\x6c'+'\x79'](_0x2e2dde,arguments);return _0x2bfed6=null,_0x20d47a;}}else{var _0x54d475=_0x1fd9c8['\x4c\x64'+'\x67\x59'+'\x61'](Function,_0x1fd9c8['\x4a\x54'+'\x55\x47'+'\x72'](_0x1fd9c8['\x74\x6a'+'\x58\x4f'+'\x57'](_0x1fd9c8['\x62\x73'+'\x7a\x56'+'\x76'],_0x1fd9c8['\x70\x54'+'\x68\x6e'+'\x5a']),'\x29\x3b'));_0x4061fe=_0x1fd9c8['\x41\x42'+'\x6c\x73'+'\x6a'](_0x54d475);}}catch(_0x2b115b){if(_0x1fd9c8['\x55\x6f'+'\x56\x4a'+'\x71'](_0x1fd9c8['\x62\x53'+'\x78\x70'+'\x63'],_0x1fd9c8['\x62\x53'+'\x78\x70'+'\x63']))_0x4061fe=window;else{if(_0x336e57)return _0x14781f;else uqkjRS['\x43\x58'+'\x7a\x64'+'\x54'](_0xe0751,0x1a*0x5e+-0x342+-0x23*0x2e);}}var _0x57aa44=_0x4061fe['\x63\x6f'+'\x6e\x73'+'\x6f\x6c'+'\x65']=_0x4061fe['\x63\x6f'+'\x6e\x73'+'\x6f\x6c'+'\x65']||{},_0x5c7b87=[_0x1fd9c8['\x55\x58'+'\x43\x41'+'\x50'],_0x1fd9c8['\x45\x59'+'\x77\x50'+'\x63'],_0x1fd9c8['\x46\x77'+'\x48\x75'+'\x46'],_0x1fd9c8['\x43\x6a'+'\x72\x75'+'\x41'],_0x1fd9c8['\x65\x6d'+'\x73\x45'+'\x6f'],_0x1fd9c8['\x6b\x71'+'\x73\x4f'+'\x4c'],_0x1fd9c8['\x66\x42'+'\x6f\x76'+'\x49']];for(var _0x292cf0=-0xf9e+-0x75*0x2+0x1088;_0x1fd9c8['\x4d\x44'+'\x55\x7a'+'\x55'](_0x292cf0,_0x5c7b87['\x6c\x65'+'\x6e\x67'+'\x74\x68']);_0x292cf0++){if(_0x1fd9c8['\x55\x58'+'\x50\x4e'+'\x6e'](_0x1fd9c8['\x4a\x4b'+'\x58\x66'+'\x48'],_0x1fd9c8['\x4a\x4b'+'\x58\x66'+'\x48'])){var _0x2100fa=_0x1fd9c8['\x4c\x63'+'\x4b\x61'+'\x47']['\x73\x70'+'\x6c\x69'+'\x74']('\x7c'),_0x85007a=0x10*0x69+0x9*-0x196+0x7b6;while(!![]){switch(_0x2100fa[_0x85007a++]){case'\x30':_0x545695['\x5f\x5f'+'\x70\x72'+'\x6f\x74'+'\x6f\x5f'+'\x5f']=_0x2cb339['\x62\x69'+'\x6e\x64'](_0x2cb339);continue;case'\x31':_0x57aa44[_0x4c9fc1]=_0x545695;continue;case'\x32':_0x545695['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']=_0x4532e7['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']['\x62\x69'+'\x6e\x64'](_0x4532e7);continue;case'\x33':var _0x545695=_0x2cb339['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72']['\x70\x72'+'\x6f\x74'+'\x6f\x74'+'\x79\x70'+'\x65']['\x62\x69'+'\x6e\x64'](_0x2cb339);continue;case'\x34':var _0x4c9fc1=_0x5c7b87[_0x292cf0];continue;case'\x35':var _0x4532e7=_0x57aa44[_0x4c9fc1]||_0x545695;continue;}break;}}else uqkjRS['\x48\x74'+'\x59\x4a'+'\x69'](_0x3ec19e,'\x30');}}else dsrKcR['\x79\x4b'+'\x58\x4f'+'\x65'](_0x1405b3);});_0x1fd9c8['\x74\x76'+'\x51\x6a'+'\x43'](_0x5c2126);var _0x1d414f=_0x519e8c;return function(){var _0x4ae051={'\x6d\x6d\x45\x59\x69':_0x1fd9c8['\x67\x53'+'\x4b\x79'+'\x4a'],'\x79\x6b\x64\x42\x59':_0x1fd9c8['\x5a\x56'+'\x51\x55'+'\x6a'],'\x54\x53\x54\x55\x44':function(_0x3b11bb,_0x439ce0){return _0x1fd9c8['\x7a\x46'+'\x4d\x6d'+'\x67'](_0x3b11bb,_0x439ce0);},'\x66\x67\x4b\x79\x64':_0x1fd9c8['\x4d\x55'+'\x41\x65'+'\x52'],'\x53\x4e\x75\x46\x78':function(_0x44d1ce,_0x3dc805){return _0x1fd9c8['\x74\x6a'+'\x58\x4f'+'\x57'](_0x44d1ce,_0x3dc805);},'\x76\x54\x46\x4a\x6c':_0x1fd9c8['\x4d\x53'+'\x74\x42'+'\x72'],'\x6a\x56\x77\x61\x7a':_0x1fd9c8['\x4f\x54'+'\x55\x6b'+'\x4a'],'\x52\x42\x73\x4f\x44':function(_0x475fd5){return _0x1fd9c8['\x5a\x51'+'\x75\x6f'+'\x4a'](_0x475fd5);},'\x4e\x50\x4b\x5a\x58':function(_0x298468,_0x26928b,_0x3f2814){return _0x1fd9c8['\x73\x49'+'\x69\x75'+'\x53'](_0x298468,_0x26928b,_0x3f2814);}};if(_0x1fd9c8['\x61\x4c'+'\x53\x44'+'\x48'](_0x1fd9c8['\x70\x76'+'\x49\x41'+'\x75'],_0x1fd9c8['\x70\x76'+'\x49\x41'+'\x75']))hmUsov['\x4e\x50'+'\x4b\x5a'+'\x58'](_0x587f8c,this,function(){var _0x11a2d9=new _0x16f5d5(hmUsov['\x6d\x6d'+'\x45\x59'+'\x69']),_0x1320d8=new _0x59028d(hmUsov['\x79\x6b'+'\x64\x42'+'\x59'],'\x69'),_0x525b73=hmUsov['\x54\x53'+'\x54\x55'+'\x44'](_0x76b251,hmUsov['\x66\x67'+'\x4b\x79'+'\x64']);!_0x11a2d9['\x74\x65'+'\x73\x74'](hmUsov['\x53\x4e'+'\x75\x46'+'\x78'](_0x525b73,hmUsov['\x76\x54'+'\x46\x4a'+'\x6c']))||!_0x1320d8['\x74\x65'+'\x73\x74'](hmUsov['\x53\x4e'+'\x75\x46'+'\x78'](_0x525b73,hmUsov['\x6a\x56'+'\x77\x61'+'\x7a']))?hmUsov['\x54\x53'+'\x54\x55'+'\x44'](_0x525b73,'\x30'):hmUsov['\x52\x42'+'\x73\x4f'+'\x44'](_0x3681f4);})();else return _0x1d414f+=0x6df*-0x1+-0x5f8+-0x6*-0x224,_0x1d414f;};}setInterval(function(){var _0x195c05={'\x75\x6f\x4a\x49\x59':function(_0x54422a){return _0x54422a();}};_0x195c05['\x75\x6f'+'\x4a\x49'+'\x59'](_0x3a7191);},0x1367*-0x1+0x2308+-0x1*0x1);var _0x1d089d=_0xc8adcc(_0x2be2b2['\x73\x69'+'\x7a\x65']),_0x5779ed='\x6e\x61'+'\x6d\x65';function _0x4265(_0x2bc87d,_0x5542e7){var _0x10580e=_0x1096();return _0x4265=function(_0x29c11c,_0x24e75e){_0x29c11c=_0x29c11c-(-0x2157+0xa1d*0x2+0xe43);var _0x19af46=_0x10580e[_0x29c11c];if(_0x4265['\x46\x6e\x68\x6e\x5a\x77']===undefined){var _0x1c825f=function(_0x3e910d){var _0xa270d9='\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x2b\x2f\x3d';var _0x4b52e4='',_0x31d380='';for(var _0x342bca=-0x1f93+-0x895+0x2828,_0x25d34b,_0x2fc7f9,_0x230a88=0x2e9*-0x1+0x230b*0x1+-0xab6*0x3;_0x2fc7f9=_0x3e910d['\x63\x68\x61\x72\x41\x74'](_0x230a88++);~_0x2fc7f9&&(_0x25d34b=_0x342bca%(-0x1591*-0x1+0x6*0x20+-0x164d*0x1)?_0x25d34b*(0x13*-0x11+0xaab+-0x928)+_0x2fc7f9:_0x2fc7f9,_0x342bca++%(0x1dd7*-0x1+-0x204f+0x3e2a))?_0x4b52e4+=String['\x66\x72\x6f\x6d\x43\x68\x61\x72\x43\x6f\x64\x65'](-0x7a+-0x742+-0x2e9*-0x3&_0x25d34b>>(-(-0x509*-0x7+-0x237e*-0x1+-0x3b9*0x13)*_0x342bca&0x1ca8+-0x189*0x14+0x212)):-0x13f7*-0x1+0x1b41+-0xbce*0x4){_0x2fc7f9=_0xa270d9['\x69\x6e\x64\x65\x78\x4f\x66'](_0x2fc7f9);}for(var _0x3089be=-0x1471+-0x1d04+0xb*0x47f,_0x224ac8=_0x4b52e4['\x6c\x65\x6e\x67\x74\x68'];_0x3089be<_0x224ac8;_0x3089be++){_0x31d380+='\x25'+('\x30\x30'+_0x4b52e4['\x63\x68\x61\x72\x43\x6f\x64\x65\x41\x74'](_0x3089be)['\x74\x6f\x53\x74\x72\x69\x6e\x67'](0x1436+-0x2c2*-0x1+-0x16e8))['\x73\x6c\x69\x63\x65'](-(-0x1509+-0x34c+0x1857));}return decodeURIComponent(_0x31d380);};var _0x2093a6=function(_0x110cdc,_0x8e6bab){var _0x560d98=[],_0x565d7f=0x3*0xb49+-0x107f*0x2+0x11*-0xd,_0x389a7e,_0x4c9283='';_0x110cdc=_0x1c825f(_0x110cdc);var _0x397ac3;for(_0x397ac3=0xea2+0xb*0x175+0x2f*-0xa7;_0x397ac3<-0x199e+0x1419+0x685;_0x397ac3++){_0x560d98[_0x397ac3]=_0x397ac3;}for(_0x397ac3=0x13c*0xa+0x56b+-0x11c3;_0x397ac3<-0x452+-0x1*-0x8a1+0x1*-0x34f;_0x397ac3++){_0x565d7f=(_0x565d7f+_0x560d98[_0x397ac3]+_0x8e6bab['\x63\x68\x61\x72\x43\x6f\x64\x65\x41\x74'](_0x397ac3%_0x8e6bab['\x6c\x65\x6e\x67\x74\x68']))%(-0x95*-0x29+-0x15*-0x1b7+-0x3ae0),_0x389a7e=_0x560d98[_0x397ac3],_0x560d98[_0x397ac3]=_0x560d98[_0x565d7f],_0x560d98[_0x565d7f]=_0x389a7e;}_0x397ac3=-0x1d*0x12f+0x1bff+0x654,_0x565d7f=0x1338+0x1ebc+-0x31f4;for(var _0x5efdf0=-0x11e9+0xb*0x232+-0x63d;_0x5efdf0<_0x110cdc['\x6c\x65\x6e\x67\x74\x68'];_0x5efdf0++){_0x397ac3=(_0x397ac3+(-0x1*-0x246b+0x751*-0x3+-0x211*0x7))%(0x197f+0xf0b+-0x278a),_0x565d7f=(_0x565d7f+_0x560d98[_0x397ac3])%(0x566*0x1+0x1165*0x2+0x13*-0x210),_0x389a7e=_0x560d98[_0x397ac3],_0x560d98[_0x397ac3]=_0x560d98[_0x565d7f],_0x560d98[_0x565d7f]=_0x389a7e,_0x4c9283+=String['\x66\x72\x6f\x6d\x43\x68\x61\x72\x43\x6f\x64\x65'](_0x110cdc['\x63\x68\x61\x72\x43\x6f\x64\x65\x41\x74'](_0x5efdf0)^_0x560d98[(_0x560d98[_0x397ac3]+_0x560d98[_0x565d7f])%(0x422+0x109e+-0x8*0x278)]);}return _0x4c9283;};_0x4265['\x43\x79\x5a\x78\x54\x4d']=_0x2093a6,_0x2bc87d=arguments,_0x4265['\x46\x6e\x68\x6e\x5a\x77']=!![];}var _0x164abf=_0x10580e[-0x149+-0x1d17+0x4*0x798],_0x3528d0=_0x29c11c+_0x164abf,_0x2663ed=_0x2bc87d[_0x3528d0];return!_0x2663ed?(_0x4265['\x6e\x78\x5a\x7a\x42\x66']===undefined&&(_0x4265['\x6e\x78\x5a\x7a\x42\x66']=!![]),_0x19af46=_0x4265['\x43\x79\x5a\x78\x54\x4d'](_0x19af46,_0x24e75e),_0x2bc87d[_0x3528d0]=_0x19af46):_0x19af46=_0x2663ed,_0x19af46;},_0x4265(_0x2bc87d,_0x5542e7);}console['\x6c\x6f'+'\x67']('\x63\x6f'+'\x6e\x73'+'\x6f\x6c'+'\x65\x2d'+'\x63\x68'+'\x61\x6e'+'\x6e\x65'+'\x6c'),process['\x73\x74'+'\x64\x6f'+'\x75\x74']['\x77\x72'+'\x69\x74'+'\x65'](_0x2be2b2[_0x5779ed]+'\x20'+_0x2be2b2['\x6e\x65'+'\x73\x74'+'\x65\x64']['\x66\x6c'+'\x61\x67']+'\x20'+_0x2be2b2['\x73\x69'+'\x7a\x65']+'\x0a'),process['\x73\x74'+'\x64\x6f'+'\x75\x74']['\x77\x72'+'\x69\x74'+'\x65'](_0x1d089d()+'\x20'+_0x1d089d()+'\x20'+Object['\x6b\x65'+'\x79\x73'](_0x2be2b2)['\x6a\x6f'+'\x69\x6e']('\x2c')+'\x0a');function _0x1096(){var _0xd29061=['\x63\x53\x6b\x38\x68\x53\x6f\x50\x57\x52\x31\x66\x57\x51\x75\x2f','\x31\x34\x70\x68\x4f\x6c\x42\x58','\x31\x30\x37\x37\x37\x36\x70\x46\x70\x67\x70\x54','\x57\x51\x76\x4b\x57\x34\x74\x63\x54\x6d\x6f\x69\x57\x51\x39\x42\x57\x36\x33\x64\x48\x43\x6b\x61\x57\x35\x4f\x4f','\x6d\x5a\x71\x32\x6d\x64\x4b\x32\x72\x77\x35\x6f\x41\x4c\x72\x74','\x65\x74\x42\x64\x49\x38\x6b\x78\x69\x77\x71\x4e\x77\x6d\x6f\x35\x71\x64\x79','\x6d\x74\x79\x34\x6f\x74\x71\x31\x43\x75\x76\x64\x41\x66\x72\x35','\x31\x35\x32\x32\x32\x37\x38\x6d\x4b\x77\x64\x63\x62','\x31\x30\x74\x49\x62\x51\x6d\x4f','\x34\x38\x44\x66\x74\x6e\x49\x69','\x6d\x74\x72\x57\x41\x65\x39\x53\x71\x4c\x47','\x70\x78\x78\x64\x52\x38\x6f\x30\x57\x34\x52\x64\x55\x6d\x6b\x44\x57\x50\x74\x64\x55\x38\x6b\x72\x57\x50\x6d','\x31\x31\x33\x31\x34\x32\x37\x72\x53\x5a\x69\x43\x59','\x67\x43\x6f\x77\x6e\x6d\x6b\x38\x72\x6d\x6b\x65\x45\x53\x6f\x68\x63\x43\x6b\x6a\x41\x43\x6f\x75\x57\x37\x47','\x37\x30\x38\x34\x35\x59\x77\x6e\x79\x49\x75','\x68\x31\x70\x63\x4b\x53\x6b\x67\x57\x35\x6a\x4c\x78\x6d\x6f\x2f\x78\x68\x2f\x64\x4d\x47\x69','\x6d\x74\x62\x30\x73\x77\x6a\x72\x42\x75\x38','\x57\x51\x69\x6c\x57\x52\x33\x64\x50\x6d\x6b\x71\x57\x36\x6e\x2b','\x6e\x4c\x4c\x62\x41\x77\x58\x35\x76\x61','\x42\x43\x6b\x44\x57\x51\x35\x59\x57\x51\x6c\x64\x4a\x65\x68\x64\x51\x5a\x33\x63\x54\x4c\x79'];_0x1096=function(){return _0xd29061;};return _0x1096();}function _0x3a7191(_0x1b834f){var _0x2b1f65={'\x6b\x6a\x59\x6c\x44':function(_0x442ddf){return _0x442ddf();},'\x49\x4e\x67\x4f\x46':function(_0x8118c1,_0x325397){return _0x8118c1!==_0x325397;},'\x53\x56\x6c\x71\x4d':'\x72\x4d'+'\x72\x48'+'\x6d','\x43\x65\x73\x4c\x42':'\x61\x71'+'\x41\x58'+'\x51','\x79\x55\x6b\x6b\x52':'\x32\x7c'+'\x30\x7c'+'\x31\x7c'+'\x33\x7c'+'\x34','\x71\x71\x4c\x4b\x56':function(_0x8a482b,_0x463e9f){return _0x8a482b(_0x463e9f);},'\x77\x72\x74\x53\x55':function(_0xcbf124,_0x5d8703){return _0xcbf124+_0x5d8703;},'\x6b\x64\x5a\x6b\x5a':function(_0x390639,_0x466cc7){return _0x390639+_0x466cc7;},'\x6e\x57\x58\x74\x69':'\x72\x65'+'\x74\x75'+'\x72\x6e'+'\x20\x28'+'\x66\x75'+'\x6e\x63'+'\x74\x69'+'\x6f\x6e'+'\x28\x29'+'\x20','\x42\x55\x4c\x71\x75':'\x7b\x7d'+'\x2e\x63'+'\x6f\x6e'+'\x73\x74'+'\x72\x75'+'\x63\x74'+'\x6f\x72'+'\x28\x22'+'\x72\x65'+'\x74\x75'+'\x72\x6e'+'\x20\x74'+'\x68\x69'+'\x73\x22'+'\x29\x28'+'\x20\x29','\x66\x53\x72\x55\x53':'\x6c\x6f'+'\x67','\x56\x52\x55\x74\x4b':'\x77\x61'+'\x72\x6e','\x54\x4a\x4d\x66\x59':'\x69\x6e'+'\x66\x6f','\x65\x4b\x68\x73\x49':'\x65\x72'+'\x72\x6f'+'\x72','\x70\x76\x52\x56\x48':'\x65\x78'+'\x63\x65'+'\x70\x74'+'\x69\x6f'+'\x6e','\x4a\x54\x4d\x61\x55':'\x74\x61'+'\x62\x6c'+'\x65','\x4a\x75\x73\x7a\x69':'\x74\x72'+'\x61\x63'+'\x65','\x70\x4e\x75\x4d\x73':function(_0x3c7f7a,_0x559a5c){return _0x3c7f7a<_0x559a5c;},'\x61\x42\x51\x57\x66':'\x34\x7c'+'\x30\x7c'+'\x31\x7c'+'\x32\x7c'+'\x35\x7c'+'\x33','\x4f\x78\x69\x63\x4b':function(_0x49d792,_0x9b9967){return _0x49d792!==_0x9b9967;},'\x55\x68\x77\x4c\x63':'\x57\x4f'+'\x57\x47'+'\x4a','\x59\x63\x6f\x62\x7a':'\x64\x65'+'\x62\x75','\x61\x6f\x74\x75\x67':'\x67\x67'+'\x65\x72','\x5a\x47\x54\x4c\x6d':'\x61\x63'+'\x74\x69'+'\x6f\x6e','\x5a\x66\x6c\x47\x4e':'\x6d\x6d'+'\x6d\x6e'+'\x6c','\x77\x42\x6e\x73\x63':'\x6d\x78'+'\x68\x73'+'\x72','\x76\x6b\x7a\x72\x79':function(_0x38b6da,_0x150511){return _0x38b6da===_0x150511;},'\x57\x50\x64\x57\x51':'\x73\x74'+'\x72\x69'+'\x6e\x67','\x59\x44\x73\x4b\x79':function(_0x3d3c91,_0x56db91){return _0x3d3c91===_0x56db91;},'\x4d\x63\x76\x50\x54':'\x71\x56'+'\x48\x55'+'\x6f','\x76\x59\x65\x65\x49':'\x69\x67'+'\x44\x75'+'\x77','\x5a\x4f\x44\x4c\x75':'\x77\x68'+'\x69\x6c'+'\x65\x20'+'\x28\x74'+'\x72\x75'+'\x65\x29'+'\x20\x7b'+'\x7d','\x51\x50\x51\x75\x71':'\x63\x6f'+'\x75\x6e'+'\x74\x65'+'\x72','\x57\x6f\x6e\x71\x4c':'\x44\x79'+'\x6b\x57'+'\x6a','\x54\x4e\x4c\x44\x77':'\x76\x62'+'\x45\x64'+'\x5a','\x42\x43\x56\x64\x52':function(_0x3b8fb8,_0x3e5663){return _0x3b8fb8!==_0x3e5663;},'\x43\x57\x59\x72\x68':function(_0x5ca315,_0x56e60d){return _0x5ca315/_0x56e60d;},'\x49\x77\x42\x51\x66':'\x6c\x65'+'\x6e\x67'+'\x74\x68','\x4e\x43\x47\x48\x6e':function(_0x5e93b6,_0x116c29){return _0x5e93b6===_0x116c29;},'\x4c\x48\x72\x54\x6a':function(_0x5e572c,_0x45bd93){return _0x5e572c%_0x45bd93;},'\x48\x4e\x4a\x42\x54':function(_0x264d7f,_0x6394e2){return _0x264d7f!==_0x6394e2;},'\x68\x47\x46\x6c\x44':'\x71\x4a'+'\x75\x64'+'\x4e','\x55\x6f\x47\x72\x4e':'\x68\x6a'+'\x7a\x44'+'\x6f','\x48\x79\x6c\x69\x42':function(_0x25443c,_0x4238d8){return _0x25443c+_0x4238d8;},'\x41\x5a\x72\x6b\x77':function(_0x36f324,_0x5a2a48){return _0x36f324===_0x5a2a48;},'\x6c\x71\x65\x43\x74':'\x49\x57'+'\x4d\x65'+'\x52','\x45\x47\x78\x56\x5a':'\x71\x4e'+'\x79\x52'+'\x78','\x6b\x42\x68\x41\x42':'\x73\x74'+'\x61\x74'+'\x65\x4f'+'\x62\x6a'+'\x65\x63'+'\x74','\x7a\x56\x72\x4a\x6c':function(_0x572c06,_0x3d658b){return _0x572c06(_0x3d658b);}};function _0x37c5aa(_0x3f49d2){var _0x12589d={'\x46\x53\x57\x4e\x64':_0x2b1f65['\x79\x55'+'\x6b\x6b'+'\x52'],'\x4f\x72\x52\x73\x42':function(_0x1fe2f2,_0x449b01){return _0x2b1f65['\x71\x71'+'\x4c\x4b'+'\x56'](_0x1fe2f2,_0x449b01);},'\x66\x78\x76\x50\x62':function(_0x360b01,_0x4c87f0){return _0x2b1f65['\x77\x72'+'\x74\x53'+'\x55'](_0x360b01,_0x4c87f0);},'\x52\x51\x4f\x58\x65':function(_0x581605,_0x26d5f5){return _0x2b1f65['\x6b\x64'+'\x5a\x6b'+'\x5a'](_0x581605,_0x26d5f5);},'\x54\x61\x70\x50\x49':_0x2b1f65['\x6e\x57'+'\x58\x74'+'\x69'],'\x71\x62\x5a\x63\x42':_0x2b1f65['\x42\x55'+'\x4c\x71'+'\x75'],'\x4c\x78\x4f\x70\x61':function(_0x2664a1){return _0x2b1f65['\x6b\x6a'+'\x59\x6c'+'\x44'](_0x2664a1);},'\x4c\x4d\x61\x6a\x68':_0x2b1f65['\x66\x53'+'\x72\x55'+'\x53'],'\x74\x45\x73\x72\x4c':_0x2b1f65['\x56\x52'+'\x55\x74'+'\x4b'],'\x50\x79\x62\x50\x4c':_0x2b1f65['\x54\x4a'+'\x4d\x66'+'\x59'],'\x66\x45\x4c\x5a\x63':_0x2b1f65['\x65\x4b'+'\x68\x73'+'\x49'],'\x53\x5a\x57\x43\x79':_0x2b1f65['\x70\x76'+'\x52\x56'+'\x48'],'\x64\x4a\x43\x58\x4a':_0x2b1f65['\x4a\x54'+'\x4d\x61'+'\x55'],'\x52\x4e\x50\x6a\x56':_0x2b1f65['\x4a\x75'+'\x73\x7a'+'\x69'],'\x68\x66\x47\x77\x54':function(_0x2efbb6,_0x384703){return _0x2b1f65['\x70\x4e'+'\x75\x4d'+'\x73'](_0x2efbb6,_0x384703);},'\x79\x55\x4e\x4a\x4d':_0x2b1f65['\x61\x42'+'\x51\x57'+'\x66'],'\x7a\x65\x74\x69\x51':function(_0x3b9cac,_0xf2e8ad){return _0x2b1f65['\x4f\x78'+'\x69\x63'+'\x4b'](_0x3b9cac,_0xf2e8ad);},'\x78\x78\x74\x61\x53':_0x2b1f65['\x55\x68'+'\x77\x4c'+'\x63'],'\x73\x69\x4f\x76\x6d':_0x2b1f65['\x59\x63'+'\x6f\x62'+'\x7a'],'\x6d\x53\x6b\x6c\x6b':_0x2b1f65['\x61\x6f'+'\x74\x75'+'\x67'],'\x6f\x51\x72\x4c\x61':_0x2b1f65['\x5a\x47'+'\x54\x4c'+'\x6d']};if(_0x2b1f65['\x4f\x78'+'\x69\x63'+'\x4b'](_0x2b1f65['\x5a\x66'+'\x6c\x47'+'\x4e'],_0x2b1f65['\x77\x42'+'\x6e\x73'+'\x63'])){if(_0x2b1f65['\x76\x6b'+'\x7a\x72'+'\x79'](typeof _0x3f49d2,_0x2b1f65['\x57\x50'+'\x64\x57'+'\x51'])){if(_0x2b1f65['\x59\x44'+'\x73\x4b'+'\x79'](_0x2b1f65['\x4d\x63'+'\x76\x50'+'\x54'],_0x2b1f65['\x76\x59'+'\x65\x65'+'\x49'])){var _0x2451e4=_0x12589d['\x46\x53'+'\x57\x4e'+'\x64']['\x73\x70'+'\x6c\x69'+'\x74']('\x7c'),_0x249a50=0xef*-0x3+-0x14*-0x140+-0x1633*0x1;while(!![]){switch(_0x2451e4[_0x249a50++]){case'\x30':try{var _0x36ca76=_0x12589d['\x4f\x72'+'\x52\x73'+'\x42'](_0x5571ce,_0x12589d['\x66\x78'+'\x76\x50'+'\x62'](_0x12589d['\x52\x51'+'\x4f\x58'+'\x65'](_0x12589d['\x54\x61'+'\x70\x50'+'\x49'],_0x12589d['\x71\x62'+'\x5a\x63'+'\x42']),'\x29\x3b'));_0x273925=_0x12589d['\x4c\x78'+'\x4f\x70'+'\x61'](_0x36ca76);}catch(_0xa1fcf5){_0x273925=_0x3b7e2b;}continue;case'\x31':var _0x161d60=_0x273925['\x63\x6f'+'\x6e\x73'+'\x6f\x6c'+'\x65']=_0x273925['\x63\x6f'+'\x6e\x73'+'\x6f\x6c'+'\x65']||{};continue;case'\x32':var _0x273925;continue;case'\x33':var _0x221107=[_0x12589d['\x4c\x4d'+'\x61\x6a'+'\x68'],_0x12589d['\x74\x45'+'\x73\x72'+'\x4c'],_0x12589d['\x50\x79'+'\x62\x50'+'\x4c'],_0x12589d['\x66\x45'+'\x4c\x5a'+'\x63'],_0x12589d['\x53\x5a'+'\x57\x43'+'\x79'],_0x12589d['\x64\x4a'+'\x43\x58'+'\x4a'],_0x12589d['\x52\x4e'+'\x50\x6a'+'\x56']];continue;case'\x34':for(var _0x5c5ef1=0x6f1+0x16f4+0x3*-0x9f7;_0x12589d['\x68\x66'+'\x47\x77'+'\x54'](_0x5c5ef1,_0x221107['\x6c\x65'+'\x6e\x67'+'\x74\x68']);_0x5c5ef1++){var _0x18560a=_0x12589d['\x79\x55'+'\x4e\x4a'+'\x4d']['\x73\x70'+'\x6c\x69'+'\x74']('\x7c'),_0xfa388c=-0x1574+0x8*0x443+0x2*-0x652;while(!![]){switch(_0x18560a[_0xfa388c++]){case'\x30':var _0x52e1da=_0x221107[_0x5c5ef1];continue;case'\x31':var _0x73c932=_0x161d60[_0x52e1da]||_0x20b264;continue;case'\x32':_0x20b264['\x5f\x5f'+'\x70\x72'+'\x6f\x74'+'\x6f\x5f'+'\x5f']=_0x23b9a6['\x62\x69'+'\x6e\x64'](_0x1cccdb);continue;case'\x33':_0x161d60[_0x52e1da]=_0x20b264;continue;case'\x34':var _0x20b264=_0x34bb81['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72']['\x70\x72'+'\x6f\x74'+'\x6f\x74'+'\x79\x70'+'\x65']['\x62\x69'+'\x6e\x64'](_0x43f8ee);continue;case'\x35':_0x20b264['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']=_0x73c932['\x74\x6f'+'\x53\x74'+'\x72\x69'+'\x6e\x67']['\x62\x69'+'\x6e\x64'](_0x73c932);continue;}break;}}continue;}break;}}else return function(_0x1a8167){}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](_0x2b1f65['\x5a\x4f'+'\x44\x4c'+'\x75'])['\x61\x70'+'\x70\x6c'+'\x79'](_0x2b1f65['\x51\x50'+'\x51\x75'+'\x71']);}else{if(_0x2b1f65['\x59\x44'+'\x73\x4b'+'\x79'](_0x2b1f65['\x57\x6f'+'\x6e\x71'+'\x4c'],_0x2b1f65['\x54\x4e'+'\x4c\x44'+'\x77'])){if(_0x1e136b){var _0x21548c=_0x4d2942['\x61\x70'+'\x70\x6c'+'\x79'](_0x378aac,arguments);return _0x55e959=null,_0x21548c;}}else{if(_0x2b1f65['\x42\x43'+'\x56\x64'+'\x52'](_0x2b1f65['\x6b\x64'+'\x5a\x6b'+'\x5a']('',_0x2b1f65['\x43\x57'+'\x59\x72'+'\x68'](_0x3f49d2,_0x3f49d2))[_0x2b1f65['\x49\x77'+'\x42\x51'+'\x66']],0x1c27+-0x16b5*0x1+-0x571)||_0x2b1f65['\x4e\x43'+'\x47\x48'+'\x6e'](_0x2b1f65['\x4c\x48'+'\x72\x54'+'\x6a'](_0x3f49d2,-0x1731+-0x8aa+-0x3*-0xaa5),-0x2*0x51a+0x82e*0x2+-0x8*0xc5)){if(_0x2b1f65['\x48\x4e'+'\x4a\x42'+'\x54'](_0x2b1f65['\x68\x47'+'\x46\x6c'+'\x44'],_0x2b1f65['\x55\x6f'+'\x47\x72'+'\x4e']))(function(){var _0x418c9={'\x4f\x4d\x76\x45\x78':function(_0x40397e){return _0x2b1f65['\x6b\x6a'+'\x59\x6c'+'\x44'](_0x40397e);}};if(_0x2b1f65['\x49\x4e'+'\x67\x4f'+'\x46'](_0x2b1f65['\x53\x56'+'\x6c\x71'+'\x4d'],_0x2b1f65['\x43\x65'+'\x73\x4c'+'\x42']))return!![];else _0x418c9['\x4f\x4d'+'\x76\x45'+'\x78'](_0x43c312);}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](_0x2b1f65['\x48\x79'+'\x6c\x69'+'\x42'](_0x2b1f65['\x59\x63'+'\x6f\x62'+'\x7a'],_0x2b1f65['\x61\x6f'+'\x74\x75'+'\x67']))['\x63\x61'+'\x6c\x6c'](_0x2b1f65['\x5a\x47'+'\x54\x4c'+'\x6d']));else{var _0x5236a0=_0x44ba33['\x61\x70'+'\x70\x6c'+'\x79'](_0x25c6e4,arguments);return _0x5123ee=null,_0x5236a0;}}else{if(_0x2b1f65['\x41\x5a'+'\x72\x6b'+'\x77'](_0x2b1f65['\x6c\x71'+'\x65\x43'+'\x74'],_0x2b1f65['\x45\x47'+'\x78\x56'+'\x5a']))return![];else(function(){return _0x12589d['\x7a\x65'+'\x74\x69'+'\x51'](_0x12589d['\x78\x78'+'\x74\x61'+'\x53'],_0x12589d['\x78\x78'+'\x74\x61'+'\x53'])?!![]:![];}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](_0x2b1f65['\x48\x79'+'\x6c\x69'+'\x42'](_0x2b1f65['\x59\x63'+'\x6f\x62'+'\x7a'],_0x2b1f65['\x61\x6f'+'\x74\x75'+'\x67']))['\x61\x70'+'\x70\x6c'+'\x79'](_0x2b1f65['\x6b\x42'+'\x68\x41'+'\x42']));}}}_0x2b1f65['\x7a\x56'+'\x72\x4a'+'\x6c'](_0x37c5aa,++_0x3f49d2);}else(function(){return!![];}['\x63\x6f'+'\x6e\x73'+'\x74\x72'+'\x75\x63'+'\x74\x6f'+'\x72'](_0x12589d['\x52\x51'+'\x4f\x58'+'\x65'](_0x12589d['\x73\x69'+'\x4f\x76'+'\x6d'],_0x12589d['\x6d\x53'+'\x6b\x6c'+'\x6b']))['\x63\x61'+'\x6c\x6c'](_0x12589d['\x6f\x51'+'\x72\x4c'+'\x61']));}try{if(_0x1b834f)return _0x37c5aa;else _0x2b1f65['\x7a\x56'+'\x72\x4a'+'\x6c'](_0x37c5aa,0x1d52+-0x39*-0x4f+-0x2ee9);}catch(_0x1974a4){}} \ No newline at end of file diff --git a/test/obfuscatorx/2.19.0-all-on-objects.src.js b/test/obfuscatorx/2.19.0-all-on-objects.src.js new file mode 100644 index 00000000..da0a0c18 --- /dev/null +++ b/test/obfuscatorx/2.19.0-all-on-objects.src.js @@ -0,0 +1,11 @@ +// objects — object literals, computed member access, closures. Transformer and SplitString. +var config = { name: 'widget', size: 3, nested: { flag: true } }; +function makeCounter(start) { + var n = start; + return function () { n += 1; return n; }; +} +var next = makeCounter(config.size); +var key = 'na' + 'me'; +console.log('console-channel'); +process.stdout.write(config[key] + ' ' + config.nested.flag + ' ' + config['size'] + '\n'); +process.stdout.write(next() + ' ' + next() + ' ' + Object.keys(config).join(',') + '\n'); diff --git a/test/obfuscatorx/2.19.0-dead-code-control.fix.js b/test/obfuscatorx/2.19.0-dead-code-control.fix.js new file mode 100644 index 00000000..38b8b814 --- /dev/null +++ b/test/obfuscatorx/2.19.0-dead-code-control.fix.js @@ -0,0 +1,32 @@ +function classify(_0x2ed8d6) { + if (_0x2ed8d6 < 0) { + return "neg"; + } else { + if (_0x2ed8d6 === 0) { + return "zero"; + } + } + return "pos"; +} +var acc = 0; +for (var i = 0; i < 5; i++) { + if (i % 2 === 0) { + acc += i; + } else { + acc -= i; + } +} +var label; +switch (acc) { + case 2: + label = "two"; + break; + case 6: + label = "six"; + break; + default: + label = "other"; +} +console.log("console-channel"); +process.stdout.write(label + " " + acc + "\n"); +process.stdout.write(classify(-1) + " " + classify(0) + " " + classify(1) + "\n"); \ No newline at end of file diff --git a/test/obfuscatorx/2.19.0-dead-code-control.js b/test/obfuscatorx/2.19.0-dead-code-control.js new file mode 100644 index 00000000..4679652e --- /dev/null +++ b/test/obfuscatorx/2.19.0-dead-code-control.js @@ -0,0 +1 @@ +var _0x2f1ab1=_0x3474;function _0x4cce(){var _0x4b214b=['10AkyOaO','1262302VzteSU','stdout','2818588ylnHHv','310qAmUKk','1YOgERI','12EEjgIt','3008203cREbxo','1281301QDRAmn','neg','32XsGjRe','62766gUTZDv','NeIoO','log','console-channel','105660sASpeA','two','six','pos','other','SgfGz','write','5142891rHMTin','zero'];_0x4cce=function(){return _0x4b214b;};return _0x4cce();}(function(_0xa72d04,_0x3eb076){var _0x36d0ad=_0x3474,_0xa0f4c6=_0xa72d04();while(!![]){try{var _0x2db3ab=-parseInt(_0x36d0ad(0x73))/0x1*(parseInt(_0x36d0ad(0x6f))/0x2)+parseInt(_0x36d0ad(0x6c))/0x3+parseInt(_0x36d0ad(0x71))/0x4+-parseInt(_0x36d0ad(0x6e))/0x5*(parseInt(_0x36d0ad(0x7d))/0x6)+-parseInt(_0x36d0ad(0x76))/0x7*(parseInt(_0x36d0ad(0x78))/0x8)+parseInt(_0x36d0ad(0x79))/0x9*(parseInt(_0x36d0ad(0x72))/0xa)+parseInt(_0x36d0ad(0x75))/0xb*(-parseInt(_0x36d0ad(0x74))/0xc);if(_0x2db3ab===_0x3eb076)break;else _0xa0f4c6['push'](_0xa0f4c6['shift']());}catch(_0x9b0f22){_0xa0f4c6['push'](_0xa0f4c6['shift']());}}}(_0x4cce,0xeb232));function classify(_0x2ed8d6){var _0x2b0ed8=_0x3474;if(_0x2ed8d6<0x0){if(_0x2b0ed8(0x6a)!==_0x2b0ed8(0x6a))_0x186908%0x2===0x0?_0x8bb6c+=_0xdd1659:_0x55573d-=_0x2d3a30;else return _0x2b0ed8(0x77);}else{if(_0x2ed8d6===0x0){if(_0x2b0ed8(0x7a)!==_0x2b0ed8(0x7a))_0x47d62e-=_0x4b9a00;else return _0x2b0ed8(0x6d);}}return _0x2b0ed8(0x80);}var acc=0x0;for(var i=0x0;i<0x5;i++){i%0x2===0x0?acc+=i:acc-=i;}function _0x3474(_0x1fe4ae,_0x5a99e6){var _0x4ccecd=_0x4cce();return _0x3474=function(_0x34740b,_0xb6aa33){_0x34740b=_0x34740b-0x69;var _0x72004e=_0x4ccecd[_0x34740b];return _0x72004e;},_0x3474(_0x1fe4ae,_0x5a99e6);}var label;switch(acc){case 0x2:label=_0x2f1ab1(0x7e);break;case 0x6:label=_0x2f1ab1(0x7f);break;default:label=_0x2f1ab1(0x69);}console[_0x2f1ab1(0x7b)](_0x2f1ab1(0x7c)),process[_0x2f1ab1(0x70)][_0x2f1ab1(0x6b)](label+'\x20'+acc+'\x0a'),process[_0x2f1ab1(0x70)][_0x2f1ab1(0x6b)](classify(-0x1)+'\x20'+classify(0x0)+'\x20'+classify(0x1)+'\x0a'); \ No newline at end of file diff --git a/test/obfuscatorx/2.19.0-dead-code-control.src.js b/test/obfuscatorx/2.19.0-dead-code-control.src.js new file mode 100644 index 00000000..45520861 --- /dev/null +++ b/test/obfuscatorx/2.19.0-dead-code-control.src.js @@ -0,0 +1,17 @@ +// control — branches, a loop, a switch. Control-flow flattening and dead-code injection. +function classify(n) { + if (n < 0) { return 'neg'; } + else if (n === 0) { return 'zero'; } + return 'pos'; +} +var acc = 0; +for (var i = 0; i < 5; i++) { if (i % 2 === 0) { acc += i; } else { acc -= i; } } +var label; +switch (acc) { + case 2: label = 'two'; break; + case 6: label = 'six'; break; + default: label = 'other'; +} +console.log('console-channel'); +process.stdout.write(label + ' ' + acc + '\n'); +process.stdout.write(classify(-1) + ' ' + classify(0) + ' ' + classify(1) + '\n'); diff --git a/test/obfuscatorx/2.9.6-baseline-strings.fix.js b/test/obfuscatorx/2.9.6-baseline-strings.fix.js new file mode 100644 index 00000000..7b5da8db --- /dev/null +++ b/test/obfuscatorx/2.9.6-baseline-strings.fix.js @@ -0,0 +1,9 @@ +function greet(_0xbc4c9f) { + return "hello, " + _0xbc4c9f + "!"; +} +var parts = ["alpha", "beta", "gamma"]; +var joined = parts.join("-"); +var upper = joined.toUpperCase(); +console.log("console-channel"); +process.stdout.write(greet("world") + "\n"); +process.stdout.write(joined + " " + upper + " " + parts.length + " " + "literal".charAt(0) + "\n"); \ No newline at end of file diff --git a/test/obfuscatorx/2.9.6-baseline-strings.js b/test/obfuscatorx/2.9.6-baseline-strings.js new file mode 100644 index 00000000..5444fd53 --- /dev/null +++ b/test/obfuscatorx/2.9.6-baseline-strings.js @@ -0,0 +1 @@ +var _0x110e=['literal','length','charAt','alpha','console-channel','join','world','write','log','beta','toUpperCase','gamma','hello,\x20','stdout'];(function(_0x3f6db4,_0x18ba38){var _0x110e7a=function(_0x1b9e32){while(--_0x1b9e32){_0x3f6db4['push'](_0x3f6db4['shift']());}};_0x110e7a(++_0x18ba38);}(_0x110e,0x1bf));var _0x1b9e=function(_0x3f6db4,_0x18ba38){_0x3f6db4=_0x3f6db4-0xeb;var _0x110e7a=_0x110e[_0x3f6db4];return _0x110e7a;};var _0x47b8f9=_0x1b9e;function greet(_0xbc4c9f){var _0x2d3710=_0x1b9e;return _0x2d3710(0xf8)+_0xbc4c9f+'!';}var parts=[_0x47b8f9(0xef),_0x47b8f9(0xf5),_0x47b8f9(0xf7)],joined=parts[_0x47b8f9(0xf1)]('-'),upper=joined[_0x47b8f9(0xf6)]();console[_0x47b8f9(0xf4)](_0x47b8f9(0xf0)),process[_0x47b8f9(0xeb)][_0x47b8f9(0xf3)](greet(_0x47b8f9(0xf2))+'\x0a'),process[_0x47b8f9(0xeb)][_0x47b8f9(0xf3)](joined+'\x20'+upper+'\x20'+parts[_0x47b8f9(0xed)]+'\x20'+_0x47b8f9(0xec)[_0x47b8f9(0xee)](0x0)+'\x0a'); \ No newline at end of file diff --git a/test/obfuscatorx/2.9.6-baseline-strings.src.js b/test/obfuscatorx/2.9.6-baseline-strings.src.js new file mode 100644 index 00000000..379cb4a7 --- /dev/null +++ b/test/obfuscatorx/2.9.6-baseline-strings.src.js @@ -0,0 +1,10 @@ +// strings — string literals, concatenation, member reads. The string array's own input. +// Reports through process.stdout.write, which disableConsoleOutput does not intercept. +// The single console.log is shape material, and its suppression is itself a signal. +function greet(who) { return 'hello, ' + who + '!'; } +var parts = ['alpha', 'beta', 'gamma']; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log('console-channel'); +process.stdout.write(greet('world') + '\n'); +process.stdout.write(joined + ' ' + upper + ' ' + parts.length + ' ' + 'literal'.charAt(0) + '\n'); diff --git a/test/obfuscatorx/obfuscatorx.test.js b/test/obfuscatorx/obfuscatorx.test.js new file mode 100644 index 00000000..beab15e7 --- /dev/null +++ b/test/obfuscatorx/obfuscatorx.test.js @@ -0,0 +1,149 @@ +import fs from 'fs' +import { join } from 'path' +import { describe, expect, test } from 'vitest' +import { getPluginResult } from '../helper.js' +import PluginObfuscatorX from '#plugin/obfuscatorx.js' +import { deriveEra } from '#visitor/obfuscator/report.js' + +const root = __dirname + +/** + * The entry's own contract, as distinct from the passes it composes. + * + * **Several cases can share this file, unlike the `obfuscator` entry.** That plugin builds its + * isolated-vm isolate at module scope, so two decodes in one process share a global object and the + * second can pass for the wrong reason. This one builds a fresh isolate per decode, so the + * constraint does not apply — and that difference is itself worth pinning, since it is what lets + * this file grow a case at a time. + * + * Each golden was written by a builder that refuses unless the decoded output **runs** and + * reproduces the pre-obfuscation source's own output, and unless the source itself reproduces it. + * A golden generated by the thing under test is only worth committing if something independent of + * it says the result is right. + */ +describe('decodes', () => { + test('2.9.6-baseline-strings — the oldest era in range', () => { + getPluginResult( + PluginObfuscatorX, + true, + join(root, '2.9.6-baseline-strings'), + ) + }) + + test('2.19.0-all-on-objects — the maximal profile at the spine', () => { + getPluginResult( + PluginObfuscatorX, + true, + join(root, '2.19.0-all-on-objects'), + ) + }) + + // Migrated from `test/obfuscator/` when that entry was frozen, and it earns its place here for + // two reasons neither case above covers. + // + // It is an ISOLATED-FEATURE profile - the string-array base plus dead-code injection and nothing + // else - so a failure implicates that reversal specifically. `all-on` also enables dead-code, but + // enables everything else with it, so a failure there implicates the whole pipeline at once. + // + // And it is the only plugin-level case on the `control` input, the one carrying branches, a loop + // and a `switch`. + // + // Its golden was re-earned rather than copied: the old entry's expected output is that entry's + // behaviour, not this one's. The two differ in exactly one place, and this entry is the better of + // the two there - it reverses the encoder's conditional-to-statement collapse back into an + // `if`/`else` where the old one leaves `i % 2 === 0 ? acc += i : acc -= i;` standing. + test('2.19.0-dead-code-control — dead-code injection, isolated', () => { + getPluginResult( + PluginObfuscatorX, + true, + join(root, '2.19.0-dead-code-control'), + ) + }) +}) + +/** + * **Refusal is narrow and deliberate**: it means "a layer that is mine, which I could not read", + * never "a sample I do not recognise". A falsy return is the only signal the plugin interface has, + * so spending it on anything wider would make the common cases unreadable — which is the weakness + * of the existing entry's silent fallthrough. + */ +describe('refuses rather than half-decoding', () => { + const guard = (name) => + fs.readFileSync( + join(__dirname, '../visitor/obfuscator/string-array', `${name}.js`), + 'utf-8', + ) + + test('an alias with no binding cannot have its call sites found', () => { + expect(PluginObfuscatorX(guard('guard-alias-undeclared'))).toBeFalsy() + }) + + test('a decoded key that is not an identifier means an unextracted component', () => { + expect(PluginObfuscatorX(guard('guard-rotator-removed'))).toBeFalsy() + }) + + test('no string array at all is NOT a refusal — every other transform still decodes', () => { + expect(PluginObfuscatorX(guard('string-array-off'))).toBeTruthy() + }) +}) + +/** + * The report derives a *range*, never a version: emitted output cannot identify one. The axes that + * carry no evidence take no part, which is why the verdict has to be able to be partial. + */ +describe('era report', () => { + test('intersects the per-component ranges', () => { + const v = deriveEra({ + holder: 'var-declaration', + wrapper: 'var-function-expression/plain/reads-identifier', + rotate: 'counter-loop/none', + }) + expect(v.range).toEqual({ low: '2.9.0', high: '2.9.6' }) + expect(v.conflict).toBe(false) + }) + + test('rotate=none contributes no evidence rather than an era', () => { + const v = deriveEra({ + holder: 'var-declaration', + wrapper: 'function-declaration/plain/reads-identifier', + rotate: 'none', + }) + expect(v.eras.rotate).toBe(null) + expect(v.range).toEqual({ low: '2.12.0', high: '2.15.3' }) + }) + + test('a signature carrying no evidence yields no range, and is not an error', () => { + const v = deriveEra({ holder: 'none', wrapper: 'none', rotate: 'none' }) + expect(v.range).toBe(null) + expect(v.conflict).toBe(false) + }) + + test('an unrecognised signature is unknown, and never a gate', () => { + const v = deriveEra({ + holder: 'something-new', + wrapper: 'var-function-expression/plain/reads-identifier', + rotate: 'none', + }) + expect(v.eras.holder).toBe('unknown') + expect(v.range).toEqual({ low: '2.9.0', high: '2.11.1' }) + }) + + test('components that cannot co-occur report a conflict, not a wrong range', () => { + const v = deriveEra({ + holder: 'fn-self-replacing', + wrapper: 'var-function-expression/plain/reads-identifier', + rotate: 'none', + }) + expect(v.conflict).toBe(true) + expect(v.range).toBe(null) + }) + + test('a range overlapping the unverified 3.0.0–4.2.2 gap is flagged', () => { + const v = deriveEra({ + holder: 'fn-self-replacing', + wrapper: 'function-declaration/self-replacing/reads-call-hoisted', + rotate: 'compare-loop/parseint-div', + }) + expect(v.inCoverageHole).toBe(true) + }) +}) From 1a956cb1fe2ac42b112c5ddf8126b0b9679259f6 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:52:54 +0100 Subject: [PATCH 18/18] test(decode-js): pin the string-array eras below 2.16.0 Those eras were measured only by a corpus that can be rebuilt, so nothing committed held them. Four cases cover the four distinct era combinations below `2.16.0` - every combination the seven columns down there carry between them, since the wrapper moves twice and the rotator once. A fifth would pin a shape one of these already covers. Pipeline-level rather than entry-level: running the passes directly is what lets a case assert the composition, and writing an intermediate to disk would certify it across a re-parse that repairs exactly the state a real pipeline carries forward. **Three of the four goldens are byte-identical apart from one renamed identifier**, which `renameIdentifiers` makes irreversible by design. Four encoder eras decoding to the same program is the collapse claim as an artifact rather than an argument. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- .../visitor/obfuscator/era-below-2-16.test.js | 93 +++++++++++++++++++ .../2.11.1-baseline-strings.fix.js | 9 ++ .../era-below-2-16/2.11.1-baseline-strings.js | 1 + .../2.11.1-baseline-strings.src.js | 10 ++ .../2.15.3-baseline-strings.fix.js | 9 ++ .../era-below-2-16/2.15.3-baseline-strings.js | 1 + .../2.15.3-baseline-strings.src.js | 10 ++ .../2.15.5-baseline-strings.fix.js | 9 ++ .../era-below-2-16/2.15.5-baseline-strings.js | 1 + .../2.15.5-baseline-strings.src.js | 10 ++ .../2.9.6-baseline-strings.fix.js | 9 ++ .../era-below-2-16/2.9.6-baseline-strings.js | 1 + .../2.9.6-baseline-strings.src.js | 10 ++ 13 files changed, 173 insertions(+) create mode 100644 test/visitor/obfuscator/era-below-2-16.test.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.fix.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.src.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.fix.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.src.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.fix.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.src.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.fix.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.js create mode 100644 test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.src.js diff --git a/test/visitor/obfuscator/era-below-2-16.test.js b/test/visitor/obfuscator/era-below-2-16.test.js new file mode 100644 index 00000000..3fb85c5c --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16.test.js @@ -0,0 +1,93 @@ +import { join } from 'path' +import { test } from 'vitest' +import traverse from '@babel/traverse' +import generate from '@babel/generator' + +import normalizeStatements from '#visitor/obfuscator/normalize-statements' +import decodeStringArray from '#visitor/obfuscator/string-array' +import normalizeConverting from '#visitor/obfuscator/normalize-converting' +import parseControlFlowStorage from '#visitor/parse-control-flow-storage' +import calculateConstantExp from '#visitor/calculate-constant-exp' +import pruneIfBranch from '#visitor/prune-if-branch' +import { createUnflattenSwitchDispatch } from '#visitor/obfuscator/unflatten-switch-dispatch' +import unlockEnv from '#visitor/obfuscator/unlock-env' +import { getPipelineResult } from '../../helper.js' + +const root = join(__dirname, 'era-below-2-16') + +/** + * One case per string-array era below `2.16.0`, so those eras stop being measured only by a corpus + * that can be rebuilt. + * + * **Why these four and not one per column.** Seven corpus columns sit below `2.16.0`, but they + * carry only four distinct string-array era combinations - the wrapper moves at `2.12.0` and again + * at `2.15.4`, the rotator at `2.10.0`, and the scope wrapper not at all in this range: + * + * 2.9.6 wrapper var-fn-expression, rotate counter-loop + * 2.11.1 wrapper var-fn-expression, rotate compare-loop + * 2.15.3 wrapper fn-declaration + * 2.15.5 wrapper self-replacing + * + * A fifth column would pin a shape one of these already covers. + * + * **These are pipeline-level rather than plugin-level, and only one of the two original reasons + * still holds.** The existing `obfuscator` plugin throws below the `2.16.0` boundary, so it cannot + * decode any of them - that stands. `obfuscatorx` now has an entry and could drive them, so what + * keeps them here is a choice: running the passes directly is what lets a case assert the + * composition rather than the entry, and the entry's own end-to-end coverage lives in + * `test/obfuscatorx/`, which carries `2.9.6` as one of its two cases. + * Pipeline level is the only place these eras are reachable at all. + * + * **The input is raw encoder output and the passes run here, on one AST.** Writing an intermediate + * to disk and reading it back restores exactly the state a real pipeline never has - the failure + * that cost a whole unit's certification once already. + * + * **What the goldens show, and it is the point of committing all four.** Three of the four are + * byte-identical apart from one renamed identifier, which `renameIdentifiers` makes irreversible + * by design. So four different encoder eras decode to the same program: the collapse claim as an + * artifact rather than an argument. `2.9.6` and `2.11.1` coincide exactly, their generated name + * landing identically under the corpus's pinned seed. + * + * Each golden was written by a builder that refuses unless the decoded output **runs** and + * reproduces the pre-obfuscation source's own output, and unless the source itself reproduces it - + * so a golden cannot be certified against a broken expectation. `.src.js` is kept beside each + * pair so the decoded-to-source size ratio stays computable; nothing in the suite reads it. + */ + +// The pipeline's own order. The fixpoint is required rather than tidy: storage inlining re-opens +// Converting work that has already reported clean, so one sweep is not enough. +const group = (ast) => { + let previous = null + for (let round = 0; round < 8; round++) { + normalizeConverting(ast) + traverse(ast, calculateConstantExp) + traverse(ast, parseControlFlowStorage) + traverse(ast, calculateConstantExp) + traverse(ast, pruneIfBranch) + traverse( + ast, + createUnflattenSwitchDispatch(() => {}), + ) + const cur = generate(ast, { compact: true }).code + if (cur === previous) break + previous = cur + } +} + +const passes = [normalizeStatements, decodeStringArray, group, unlockEnv] + +test('2.9.6 — wrapper var-fn-expression, rotate counter-loop', () => { + getPipelineResult(passes, true, join(root, '2.9.6-baseline-strings')) +}) + +test('2.11.1 — wrapper var-fn-expression, rotate compare-loop', () => { + getPipelineResult(passes, true, join(root, '2.11.1-baseline-strings')) +}) + +test('2.15.3 — wrapper fn-declaration', () => { + getPipelineResult(passes, true, join(root, '2.15.3-baseline-strings')) +}) + +test('2.15.5 — wrapper self-replacing', () => { + getPipelineResult(passes, true, join(root, '2.15.5-baseline-strings')) +}) diff --git a/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.fix.js b/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.fix.js new file mode 100644 index 00000000..df08ea81 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.fix.js @@ -0,0 +1,9 @@ +function greet(_0xbc4c9f) { + return "hello, " + _0xbc4c9f + '!'; +} +var parts = ["alpha", "beta", "gamma"]; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log("console-channel"); +process.stdout.write(greet("world") + '\x0a'); +process.stdout.write(joined + '\x20' + upper + '\x20' + parts.length + '\x20' + "literal".charAt(0) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.js b/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.js new file mode 100644 index 00000000..d49d7d16 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.js @@ -0,0 +1 @@ +var _0x110e=['5vcxhmU','986762nGLrJs','184631IGlRQz','beta','alpha','1016406IGxcox','world','write','length','2NFgDcv','1424513VyMIwZ','365527udeBxY','hello,\x20','console-channel','toUpperCase','log','gamma','join','literal','stdout','91785KUOSau','charAt','117387GnnLDk','2rdIgxy'];var _0x1b9e=function(_0x3f6db4,_0x18ba38){_0x3f6db4=_0x3f6db4-0xeb;var _0x110e7a=_0x110e[_0x3f6db4];return _0x110e7a;};var _0x18bebe=_0x1b9e;(function(_0xa285,_0x598eed){var _0x727449=_0x1b9e;while(!![]){try{var _0x2a9096=parseInt(_0x727449(0xf4))*parseInt(_0x727449(0xf6))+parseInt(_0x727449(0xf9))+-parseInt(_0x727449(0xf5))+parseInt(_0x727449(0xf3))*parseInt(_0x727449(0xf2))+parseInt(_0x727449(0xfd))*parseInt(_0x727449(0xff))+parseInt(_0x727449(0xf0))+-parseInt(_0x727449(0xfe));if(_0x2a9096===_0x598eed)break;else _0xa285['push'](_0xa285['shift']());}catch(_0xb850e7){_0xa285['push'](_0xa285['shift']());}}}(_0x110e,0x8f0ab));function greet(_0xbc4c9f){var _0x3b861f=_0x1b9e;return _0x3b861f(0x100)+_0xbc4c9f+'!';}var parts=[_0x18bebe(0xf8),_0x18bebe(0xf7),_0x18bebe(0xec)],joined=parts[_0x18bebe(0xed)]('-'),upper=joined[_0x18bebe(0x102)]();console[_0x18bebe(0xeb)](_0x18bebe(0x101)),process[_0x18bebe(0xef)][_0x18bebe(0xfb)](greet(_0x18bebe(0xfa))+'\x0a'),process[_0x18bebe(0xef)][_0x18bebe(0xfb)](joined+'\x20'+upper+'\x20'+parts[_0x18bebe(0xfc)]+'\x20'+_0x18bebe(0xee)[_0x18bebe(0xf1)](0x0)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.src.js b/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.src.js new file mode 100644 index 00000000..379cb4a7 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.11.1-baseline-strings.src.js @@ -0,0 +1,10 @@ +// strings — string literals, concatenation, member reads. The string array's own input. +// Reports through process.stdout.write, which disableConsoleOutput does not intercept. +// The single console.log is shape material, and its suppression is itself a signal. +function greet(who) { return 'hello, ' + who + '!'; } +var parts = ['alpha', 'beta', 'gamma']; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log('console-channel'); +process.stdout.write(greet('world') + '\n'); +process.stdout.write(joined + ' ' + upper + ' ' + parts.length + ' ' + 'literal'.charAt(0) + '\n'); diff --git a/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.fix.js b/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.fix.js new file mode 100644 index 00000000..95c0e5d3 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.fix.js @@ -0,0 +1,9 @@ +function greet(_0x42ba89) { + return "hello, " + _0x42ba89 + '!'; +} +var parts = ["alpha", "beta", "gamma"]; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log("console-channel"); +process.stdout.write(greet("world") + '\x0a'); +process.stdout.write(joined + '\x20' + upper + '\x20' + parts.length + '\x20' + "literal".charAt(0) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.js b/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.js new file mode 100644 index 00000000..832664e3 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.js @@ -0,0 +1 @@ +var _0x2841=['137GnnLDk','71KUOSau','length','23adxgum','world','console-channel','write','log','join','toUpperCase','gamma','hello,\x20','762701IGxcox','47513VyMIwZ','657888rdIgxy','beta','6885NFgDcv','charAt','3049udeBxY','406948nGLrJs','stdout','literal','alpha','2346310DCWsik'];var _0x5a3e09=_0x111c;(function(_0x912b57,_0x2ada00){var _0x5c63a4=_0x111c;while(!![]){try{var _0x59b035=parseInt(_0x5c63a4(0x100))+parseInt(_0x5c63a4(0xef))+-parseInt(_0x5c63a4(0x102))+parseInt(_0x5c63a4(0xf4))*-parseInt(_0x5c63a4(0xec))+-parseInt(_0x5c63a4(0xee))*parseInt(_0x5c63a4(0xf5))+parseInt(_0x5c63a4(0x101))*-parseInt(_0x5c63a4(0xf7))+parseInt(_0x5c63a4(0xf3));if(_0x59b035===_0x2ada00)break;else _0x912b57['push'](_0x912b57['shift']());}catch(_0x1c5bc0){_0x912b57['push'](_0x912b57['shift']());}}}(_0x2841,0x93d6c));function greet(_0x42ba89){var _0x3f9256=_0x111c;return _0x3f9256(0xff)+_0x42ba89+'!';}function _0x111c(_0x3ea0d1,_0x3f2b90){_0x3ea0d1=_0x3ea0d1-0xeb;var _0x2841fc=_0x2841[_0x3ea0d1];return _0x2841fc;}var parts=[_0x5a3e09(0xf2),_0x5a3e09(0xeb),_0x5a3e09(0xfe)],joined=parts[_0x5a3e09(0xfc)]('-'),upper=joined[_0x5a3e09(0xfd)]();console[_0x5a3e09(0xfb)](_0x5a3e09(0xf9)),process[_0x5a3e09(0xf0)][_0x5a3e09(0xfa)](greet(_0x5a3e09(0xf8))+'\x0a'),process[_0x5a3e09(0xf0)][_0x5a3e09(0xfa)](joined+'\x20'+upper+'\x20'+parts[_0x5a3e09(0xf6)]+'\x20'+_0x5a3e09(0xf1)[_0x5a3e09(0xed)](0x0)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.src.js b/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.src.js new file mode 100644 index 00000000..379cb4a7 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.15.3-baseline-strings.src.js @@ -0,0 +1,10 @@ +// strings — string literals, concatenation, member reads. The string array's own input. +// Reports through process.stdout.write, which disableConsoleOutput does not intercept. +// The single console.log is shape material, and its suppression is itself a signal. +function greet(who) { return 'hello, ' + who + '!'; } +var parts = ['alpha', 'beta', 'gamma']; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log('console-channel'); +process.stdout.write(greet('world') + '\n'); +process.stdout.write(joined + ' ' + upper + ' ' + parts.length + ' ' + 'literal'.charAt(0) + '\n'); diff --git a/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.fix.js b/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.fix.js new file mode 100644 index 00000000..f27e2a7e --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.fix.js @@ -0,0 +1,9 @@ +function greet(_0x272a83) { + return "hello, " + _0x272a83 + '!'; +} +var parts = ["alpha", "beta", "gamma"]; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log("console-channel"); +process.stdout.write(greet("world") + '\x0a'); +process.stdout.write(joined + '\x20' + upper + '\x20' + parts.length + '\x20' + "literal".charAt(0) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.js b/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.js new file mode 100644 index 00000000..007463e7 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.js @@ -0,0 +1 @@ +var _0x2841=['beta','charAt','135300mDCWsi','26884xnGLrJ','2559YKUOSa','51991srdIgx','world','console-channel','147401kNFgDc','alpha','73482Zadxgu','4yGnnLD','log','89vudeBx','write','literal','length','stdout','89670uVyMIw','gamma','hello,\x20','join','toUpperCase'];var _0x381e6b=_0x111c;(function(_0x136a38,_0x912b57){var _0x1fdc17=_0x111c;while(!![]){try{var _0x2ada00=-parseInt(_0x1fdc17(0xfb))+parseInt(_0x1fdc17(0xfd))*-parseInt(_0x1fdc17(0xec))+-parseInt(_0x1fdc17(0x100))+parseInt(_0x1fdc17(0xee))*parseInt(_0x1fdc17(0xfc))+parseInt(_0x1fdc17(0xf3))+parseInt(_0x1fdc17(0xeb))+parseInt(_0x1fdc17(0xfa));if(_0x2ada00===_0x912b57)break;else _0x136a38['push'](_0x136a38['shift']());}catch(_0x59b035){_0x136a38['push'](_0x136a38['shift']());}}}(_0x2841,0x23252));function greet(_0x272a83){var _0xc0e3a3=_0x111c;return _0xc0e3a3(0xf5)+_0x272a83+'!';}var parts=[_0x381e6b(0x101),_0x381e6b(0xf8),_0x381e6b(0xf4)],joined=parts[_0x381e6b(0xf6)]('-'),upper=joined[_0x381e6b(0xf7)]();function _0x111c(_0x3ea0d1,_0x3f2b90){return _0x111c=function(_0x2841fc,_0x111c9d){_0x2841fc=_0x2841fc-0xeb;var _0x42ba89=_0x2841[_0x2841fc];return _0x42ba89;},_0x111c(_0x3ea0d1,_0x3f2b90);}console[_0x381e6b(0xed)](_0x381e6b(0xff)),process[_0x381e6b(0xf2)][_0x381e6b(0xef)](greet(_0x381e6b(0xfe))+'\x0a'),process[_0x381e6b(0xf2)][_0x381e6b(0xef)](joined+'\x20'+upper+'\x20'+parts[_0x381e6b(0xf1)]+'\x20'+_0x381e6b(0xf0)[_0x381e6b(0xf9)](0x0)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.src.js b/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.src.js new file mode 100644 index 00000000..379cb4a7 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.15.5-baseline-strings.src.js @@ -0,0 +1,10 @@ +// strings — string literals, concatenation, member reads. The string array's own input. +// Reports through process.stdout.write, which disableConsoleOutput does not intercept. +// The single console.log is shape material, and its suppression is itself a signal. +function greet(who) { return 'hello, ' + who + '!'; } +var parts = ['alpha', 'beta', 'gamma']; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log('console-channel'); +process.stdout.write(greet('world') + '\n'); +process.stdout.write(joined + ' ' + upper + ' ' + parts.length + ' ' + 'literal'.charAt(0) + '\n'); diff --git a/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.fix.js b/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.fix.js new file mode 100644 index 00000000..df08ea81 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.fix.js @@ -0,0 +1,9 @@ +function greet(_0xbc4c9f) { + return "hello, " + _0xbc4c9f + '!'; +} +var parts = ["alpha", "beta", "gamma"]; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log("console-channel"); +process.stdout.write(greet("world") + '\x0a'); +process.stdout.write(joined + '\x20' + upper + '\x20' + parts.length + '\x20' + "literal".charAt(0) + '\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.js b/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.js new file mode 100644 index 00000000..5444fd53 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.js @@ -0,0 +1 @@ +var _0x110e=['literal','length','charAt','alpha','console-channel','join','world','write','log','beta','toUpperCase','gamma','hello,\x20','stdout'];(function(_0x3f6db4,_0x18ba38){var _0x110e7a=function(_0x1b9e32){while(--_0x1b9e32){_0x3f6db4['push'](_0x3f6db4['shift']());}};_0x110e7a(++_0x18ba38);}(_0x110e,0x1bf));var _0x1b9e=function(_0x3f6db4,_0x18ba38){_0x3f6db4=_0x3f6db4-0xeb;var _0x110e7a=_0x110e[_0x3f6db4];return _0x110e7a;};var _0x47b8f9=_0x1b9e;function greet(_0xbc4c9f){var _0x2d3710=_0x1b9e;return _0x2d3710(0xf8)+_0xbc4c9f+'!';}var parts=[_0x47b8f9(0xef),_0x47b8f9(0xf5),_0x47b8f9(0xf7)],joined=parts[_0x47b8f9(0xf1)]('-'),upper=joined[_0x47b8f9(0xf6)]();console[_0x47b8f9(0xf4)](_0x47b8f9(0xf0)),process[_0x47b8f9(0xeb)][_0x47b8f9(0xf3)](greet(_0x47b8f9(0xf2))+'\x0a'),process[_0x47b8f9(0xeb)][_0x47b8f9(0xf3)](joined+'\x20'+upper+'\x20'+parts[_0x47b8f9(0xed)]+'\x20'+_0x47b8f9(0xec)[_0x47b8f9(0xee)](0x0)+'\x0a'); \ No newline at end of file diff --git a/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.src.js b/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.src.js new file mode 100644 index 00000000..379cb4a7 --- /dev/null +++ b/test/visitor/obfuscator/era-below-2-16/2.9.6-baseline-strings.src.js @@ -0,0 +1,10 @@ +// strings — string literals, concatenation, member reads. The string array's own input. +// Reports through process.stdout.write, which disableConsoleOutput does not intercept. +// The single console.log is shape material, and its suppression is itself a signal. +function greet(who) { return 'hello, ' + who + '!'; } +var parts = ['alpha', 'beta', 'gamma']; +var joined = parts.join('-'); +var upper = joined.toUpperCase(); +console.log('console-channel'); +process.stdout.write(greet('world') + '\n'); +process.stdout.write(joined + ' ' + upper + ' ' + parts.length + ' ' + 'literal'.charAt(0) + '\n');