Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ readable source.

One target per run, selected with `-t`:

The result-aware `jsconfuser-vm` target is listed in the dedicated section below.

| `-t` | target |
|---|---|
| `common` | frequently-seen local obfuscation, not tied to any one tool — unreachable code, nested blocks, constant expressions, raw strings |
| `jjencode` | jjencode, in the variant emitted by sojson.com |
| `sojson` | sojson |
| `sojsonv7` | sojson v7 |
| `jsconfuser-vm` | JS-Confuser numeric VM containers via the standalone result-aware adapter |
| `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) |
Expand Down Expand Up @@ -47,7 +50,17 @@ A sample whose range overlaps that gap is reported as unknown rather than treate
### `jsconfuser`

Covers JS-Confuser 2.x up to and including the `high` preset. Which transforms are
reversed, which are not, and why: [docs/jsconfuser.md](docs/jsconfuser.md).
reversed, which are not, and why are documented in the
[decode-nexus jsconfuser plugin reference](https://github.com/echo094/decode-nexus/blob/main/skills/decode-js/plugins/jsconfuser.md).

### `jsconfuser-vm`

Decodes accepted numeric VM containers without executing target code. Each attempt writes the
adapter's JavaScript output and a JSON result record containing `status`, `diagnostic`, and the
standalone schema/proof metadata. The result path defaults to `<output>.result.json`; pass
`--result path.json` to choose an explicit path. A declined input is written byte-for-byte to the
output, recorded with `status: "declined"`, and exits successfully. The `--result` option is
target-specific; legacy targets keep their string/null output contract and do not write sidecars.

## Usage

Expand All @@ -71,7 +84,7 @@ npm run decode -- -t type [-i input.js] [-o output.js] [-v]
```

`xxx` is one of the predefined commands, each a shorthand for one target — `deob`,
`dejsc`, `deso`, `desov7`. See the `scripts` field in [package.json](package.json).
`dejsc`, `dejsc-vm`, `deso`, `desov7`. See the `scripts` field in [package.json](package.json).

The default input file is `input.js`. The file cannot contain additional codes other
than obfuscated code (such as non-obfuscated code).
Expand All @@ -80,6 +93,12 @@ The default output file is `output.js`.

`-v` turns on per-pass progress tracing, which is off by default.

For the result-aware VM target, use `--result` when an explicit result path is preferred:

```shell
npm run decode -- -t jsconfuser-vm -i encoded.js -o decoded.js --result decoded.json
```

## Related Projects

* [cilame/v_jstools](https://github.com/cilame/v_jstools)
Expand Down
61 changes: 0 additions & 61 deletions docs/jsconfuser.md

This file was deleted.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"deob": "node src/main.js -t obfuscator",
"deobx": "node src/main.js -t obfuscatorx",
"dejsc": "node src/main.js -t jsconfuser",
"dejsc-vm": "node src/main.js -t jsconfuser-vm",
"deso": "node src/main.js -t sojson",
"desov7": "node src/main.js -t sojsonv7",
"test": "vitest --config vitest.config.js",
Expand Down
145 changes: 145 additions & 0 deletions scripts/render-vm-switch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import fs from 'node:fs'
import path from 'node:path'
import { parseArgs } from 'node:util'
import { diagnoseStandaloneInput } from '../src/vm/jsconfuser-vm/diagnose-standalone.js'
import { numericToVmSwitch } from '../src/vm/jsconfuser-vm/numeric-to-vm-switch.js'
import { validateVmSwitchModel } from '../src/vm/switch/vm-switch-model.js'
import { emitVmSwitchProgram } from '../src/vm/switch/vm-switch-to-source.js'

const usage = `Usage:
node scripts/render-vm-switch.mjs --source encoded.js --output-dir directory [--emit-model]
node scripts/render-vm-switch.mjs --model switch-model.json --output-dir directory

Both modes write only switch-case.js by default. --emit-model also writes switch-model.json
when reading source. The JavaScript is the step-one decoded program, without a demo host
shim or output logger; execute it under the same host conditions as the encoded input.
The output directory may exist, but the tool refuses to overwrite an existing artifact.
The tool renders code; it does not execute the encoded input or generated output.`

function portable(value) {
if (value === undefined) return { $type: 'Undefined' }
if (typeof value === 'bigint')
return { $type: 'BigInt', value: value.toString() }
if (typeof value === 'number' && !Number.isFinite(value))
return { $type: 'Number', value: String(value) }
if (typeof value === 'number' && Object.is(value, -0))
return { $type: 'Number', value: '-0' }
if (value instanceof Map)
return {
$type: 'Map',
entries: [...value].map(([key, entry]) => [
portable(key),
portable(entry),
]),
}
if (value instanceof Set)
return { $type: 'Set', values: [...value].map(portable) }
if (Array.isArray(value)) return value.map(portable)
if (value && typeof value === 'object')
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, portable(entry)]),
)
return value
}

function revive(value) {
if (Array.isArray(value)) return value.map(revive)
if (value && typeof value === 'object') {
if (value.$type === 'Undefined') return undefined
if (value.$type === 'BigInt') return BigInt(value.value)
if (value.$type === 'Number') return Number(value.value)
if (value.$type === 'Map')
return new Map(
value.entries.map(([key, entry]) => [revive(key), revive(entry)]),
)
if (value.$type === 'Set') return new Set(value.values.map(revive))
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, revive(entry)]),
)
}
return value
}

function reviewModel(model) {
return {
...model,
controlByFunction: new Map(
model.functions.functions.map(({ id }) => [
id,
{ mode: 'state-machine' },
]),
),
structuredControl: { edges: [] },
}
}

function render(model) {
const explicit = reviewModel(model)
validateVmSwitchModel(explicit)
return `${emitVmSwitchProgram(explicit).output}\n`
}

function main() {
const { values } = parseArgs({
options: {
source: { type: 'string' },
model: { type: 'string' },
'output-dir': { type: 'string' },
'emit-model': { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
})
if (values.help) {
process.stdout.write(`${usage}\n`)
return
}
if (Boolean(values.source) === Boolean(values.model) || !values['output-dir'])
throw new Error(usage)
if (values.model && values['emit-model'])
throw new Error('--emit-model requires --source')

let model
if (values.source) {
const diagnosis = diagnoseStandaloneInput(
fs.readFileSync(values.source, 'utf8'),
)
if (!diagnosis.ok)
throw new Error(
`Numeric VM diagnosis declined: ${diagnosis.diagnostic.code}: ${diagnosis.diagnostic.message}`,
)
model = numericToVmSwitch(diagnosis.model)
validateVmSwitchModel(model)
} else {
model = revive(JSON.parse(fs.readFileSync(values.model, 'utf8')))
}
const output = render(model)
const directory = path.resolve(values['output-dir'])
const jsPath = path.join(directory, 'switch-case.js')
const modelPath = path.join(directory, 'switch-model.json')
if (
fs.existsSync(jsPath) ||
(values['emit-model'] && fs.existsSync(modelPath))
)
throw new Error(
'Review output already exists; choose a fresh output directory',
)
fs.mkdirSync(directory, { recursive: true })
if (values['emit-model'])
fs.writeFileSync(
modelPath,
`${JSON.stringify(portable(model), null, 2)}\n`,
{
flag: 'wx',
},
)
fs.writeFileSync(jsPath, output, { flag: 'wx' })
if (values['emit-model']) process.stdout.write(`${modelPath}\n`)
process.stdout.write(`${jsPath}\n`)
}

try {
main()
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`)
process.exitCode = 1
}
65 changes: 65 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'fs'
import path from 'node:path'
import { parseArgs } from 'node:util'
import PluginCommon from './plugin/common.js'
import PluginJjencode from './plugin/jjencode.js'
Expand All @@ -8,6 +9,8 @@ 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 PluginJsconfuserVm from './plugin/jsconfuser-vm.js'
import PluginJsconfuserVmSequential from './plugin/jsconfuser-vm-sequential.js'
import logger from './utility/logger.js'

// Read arguments
Expand All @@ -16,12 +19,14 @@ const { values } = parseArgs({
type: { type: 'string', short: 't', default: 'common' },
input: { type: 'string', short: 'i', default: 'input.js' },
output: { type: 'string', short: 'o', default: 'output.js' },
result: { type: 'string' },
verbose: { type: 'boolean', short: 'v', default: false },
},
})
const type = values.type
const encodeFile = values.input
const decodeFile = values.output
const resultFile = values.result
// Per-pass progress tracing, off unless asked for. `DECODE_JS_DEBUG=1` does the same for a
// caller that imports a plugin directly rather than going through this entry point.
if (values.verbose) {
Expand All @@ -40,6 +45,8 @@ const plugins = {
obfuscator: PluginObfuscator,
obfuscatorx: PluginObfuscatorX,
awsc: PluginAwsc,
'jsconfuser-vm': PluginJsconfuserVm,
'jsconfuser-vm-sequential': PluginJsconfuserVmSequential,
}

const main = () => {
Expand All @@ -52,6 +59,18 @@ const main = () => {
return
}

const resultAwareTypes = new Set([
'jsconfuser-vm',
'jsconfuser-vm-sequential',
])
if (resultFile && !resultAwareTypes.has(type)) {
console.error(
'The --result option is only supported for jsconfuser-vm and jsconfuser-vm-sequential',
)
process.exitCode = 1
return
}

// Read the source code
let sourceCode
try {
Expand All @@ -62,6 +81,52 @@ const main = () => {
return
}

if (resultAwareTypes.has(type)) {
let record
try {
record = plugins[type](sourceCode)
} catch (e) {
console.error(`Cannot decode input ${encodeFile}: ${e.message}`)
process.exitCode = 1
return
}

const targetResultFile = resultFile || `${decodeFile}.result.json`
if (path.resolve(targetResultFile) === path.resolve(decodeFile)) {
console.error('Output and result files must be different')
process.exitCode = 1
return
}
console.log(`Status: ${record.status}`)
if (record.diagnostic) {
console.error(
`Diagnostic: ${record.diagnostic.code}: ${record.diagnostic.message}`,
)
}

try {
fs.writeFileSync(decodeFile, record.output)
} catch (e) {
console.error(`Cannot write output file ${decodeFile}: ${e.message}`)
process.exitCode = 1
return
}

try {
fs.writeFileSync(targetResultFile, `${JSON.stringify(record, null, 2)}\n`)
} catch (e) {
console.error(
`Cannot write result file ${targetResultFile}: ${e.message}`,
)
process.exitCode = 1
return
}

console.log(`Output written: ${decodeFile}`)
console.log(`Result written: ${targetResultFile}`)
return
}

// Purify the source code
const code = plugins[type](sourceCode)

Expand Down
Loading
Loading