Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
391270a
test(decode-js): detect stale references, and give the harness a pipe…
echo094 Aug 16, 2026
b2caa53
fix(decode-js): repair binding bookkeeping after node re-homing
echo094 Aug 16, 2026
7700770
fix(visitor/split-assignment): clone the target instead of re-using it
echo094 Aug 16, 2026
13609da
refactor(visitor/split-assignment): crawl once per traversal, not per…
echo094 Aug 16, 2026
9b3b844
fix(visitor/variable-masking): clone cached values before inlining them
echo094 Aug 16, 2026
452ef09
test(visitor/parse-control-flow-storage): pin the resolving path
echo094 Aug 16, 2026
1fc2b41
test(decode-js): pin the reference bookkeeping of three shared visitors
echo094 Aug 16, 2026
f0987ab
test(visitor/prune-if-branch): give the visitor its first committed c…
echo094 Aug 16, 2026
b73b221
feat(decode-js): give logger always-on log and error channels
echo094 Aug 16, 2026
a248ea6
feat(decode-js): add the atomic visitor layer
echo094 Aug 16, 2026
b29b9f8
feat(visitor/detect): match javascript-obfuscator's string-array shap…
echo094 Aug 16, 2026
ed1f4c9
feat(visitor/string-array): decode the string array by evaluating it
echo094 Aug 16, 2026
c76217d
feat(visitor/normalize-statements): reverse the Simplifying stage
echo094 Aug 16, 2026
69f61c9
feat(visitor/normalize-converting): reverse the Converting stage
echo094 Aug 16, 2026
16c9fc5
feat(visitor/unflatten-switch-dispatch): reverse block control-flow f…
echo094 Aug 16, 2026
6a068be
feat(visitor/unlock-env): strip the anti-tamper helpers
echo094 Aug 16, 2026
bef5f65
feat(plugin/obfuscatorx): add the era-aware entry and its report
echo094 Aug 16, 2026
1a956cb
test(decode-js): pin the string-array eras below 2.16.0
echo094 Aug 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -37,6 +38,7 @@ const plugins = {
sojson: PluginSojson,
sojsonv7: PluginSojsonV7,
obfuscator: PluginObfuscator,
obfuscatorx: PluginObfuscatorX,
awsc: PluginAwsc,
}

Expand Down
133 changes: 133 additions & 0 deletions src/plugin/obfuscatorx.js
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 21 additions & 0 deletions src/utility/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
48 changes: 48 additions & 0 deletions src/visitor/atomic/README.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions src/visitor/atomic/collapse-property-shorthand.js
Original file line number Diff line number Diff line change
@@ -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()
52 changes: 52 additions & 0 deletions src/visitor/atomic/convert-conditional-assign.js
Original file line number Diff line number Diff line change
@@ -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()
Loading