From 4091eb29ec2e835e51cfa1b502cfea27a5bd2ab2 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:49:18 +0100 Subject: [PATCH 1/5] fix(visitor/control-flow-graph): avoid capturing lexical bindings Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- src/visitor/jsconfuser/control-flow-graph.js | 100 ++++++++++++++++--- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/src/visitor/jsconfuser/control-flow-graph.js b/src/visitor/jsconfuser/control-flow-graph.js index 1b739f73..6d1d0448 100644 --- a/src/visitor/jsconfuser/control-flow-graph.js +++ b/src/visitor/jsconfuser/control-flow-graph.js @@ -1414,7 +1414,7 @@ function makeScopeFlattenVisitor(scopeName, nameFor) { return } path.replaceWith( - t.identifier(nameFor(match.scopeProperty, match.varName)), + t.identifier(nameFor(match.scopeProperty, match.varName, path)), ) path.skip() unwrapThisGuardCallee(path) @@ -1460,18 +1460,33 @@ function makeScopeFlattenVisitor(scopeName, nameFor) { */ function flattenScopeMembersInGraph( root, - { scopeName, pairNames = new Map(), usedNames = new Set() }, + { + scopeName, + pairNames = new Map(), + usedNames = new Set(), + reservedNames = new Set(), + }, ) { const introduced = [] - function nameFor(scopeProperty, varName) { + function nameFor(scopeProperty, varName, referencePath) { const key = `${scopeProperty}::${varName}` if (pairNames.has(key)) { return pairNames.get(key) } let candidate = varName let suffix = 2 - while (usedNames.has(candidate)) { + // `varName` came from the encoder's scope-member key, but the graph still carries + // ordinary lexical bindings from helpers which were never moved into that scope. + // Resolve the candidate at the exact reference site so an introduced declaration + // cannot capture or be captured by a real binding with the same spelling. The + // binding object is the source-derived identity; comparing text alone is unsafe + // when nested functions shadow a helper name. + while ( + usedNames.has(candidate) || + reservedNames.has(candidate) || + referencePath?.scope.getBinding(candidate) + ) { candidate = `${varName}_${suffix}` suffix++ } @@ -1873,6 +1888,28 @@ function declareIntroducedVariables(names) { ) } +/** + * Returns the lexical names owned by a function before its body is replaced by decoded + * graph statements. Graph paths are intentionally detached from that original scope by + * the time flattening runs, so their `NodePath.scope` cannot be used as the reservation + * source; this snapshot keeps the binding identity information while it is still live. + */ +function functionBindingNames(functionPath) { + const names = new Set() + let scope = functionPath.scope + while (scope) { + for (const name of Object.keys(scope.bindings)) { + names.add(name) + } + scope = scope.parent + } + const body = functionPath.get('body') + for (const name of Object.keys(body.scope.bindings)) { + names.add(name) + } + return names +} + /** * Matches one outlined-nested-function wrapper's `FunctionExpression`: * `function(...restName){ return mainFnName(vector, @@ -2152,6 +2189,7 @@ function decodeFlattenedFunction(vector, ctx) { scopeName, pairNames, usedNames, + reservedNames, keepReturnFlag, } = ctx @@ -2174,6 +2212,7 @@ function decodeFlattenedFunction(vector, ctx) { scopeName, pairNames, usedNames, + reservedNames, }) const wrappers = findOutlinedFunctionWrappers(graph, { @@ -2181,6 +2220,9 @@ function decodeFlattenedFunction(vector, ctx) { runtimeName, }) for (const wrapper of wrappers) { + for (const name of functionBindingNames(wrapper.functionPath)) { + usedNames.add(name) + } const body = decodeFlattenedFunction(wrapper.entryVector, ctx) if (body === null) { return null @@ -2515,7 +2557,12 @@ function dropDeadHarnessSlot(blockPath, name) { * if that conversion fired), it's unwrapped back into a plain expression statement (or * dropped entirely if it had no argument) before splicing into the Program. */ -function decodeControlFlowApplication(mainFnPath, harness, isProgram) { +function decodeControlFlowApplication( + mainFnPath, + harness, + isProgram, + blockUsedNames = new Set(), +) { const dispatcher = parseDispatcher(mainFnPath) if (!dispatcher) { return null @@ -2552,7 +2599,8 @@ function decodeControlFlowApplication(mainFnPath, harness, isProgram) { stringsBlob: xor ? xor.stringsBlob : null, scopeName, pairNames: new Map(), - usedNames: new Set(), + usedNames: blockUsedNames, + reservedNames: functionBindingNames(mainFnPath), }) if (!body) { return null @@ -2957,7 +3005,11 @@ function collapseInlineFlattenedFunction(match, name) { * external entry matches every real sample seen; a genuine multi-external-entry inline fn - * not yet observed - is left untouched rather than mis-decoded from an arbitrary one.) */ -function decodeInlineFlattenedFunction(match, searchRoot) { +function decodeInlineFlattenedFunction( + match, + searchRoot, + sharedUsedNames = new Set(), +) { const parent = match.fnPath.parentPath let name = null if (parent.isAssignmentExpression() && parent.get('left').isIdentifier()) { @@ -2990,7 +3042,8 @@ function decodeInlineFlattenedFunction(match, searchRoot) { stringsBlob: xor ? xor.stringsBlob : null, scopeName: match.scopeName, pairNames: new Map(), - usedNames: new Set(), + usedNames: sharedUsedNames, + reservedNames: functionBindingNames(match.fnPath), // An inline fn stays callable and its enclosing `if (flag) return ...` harness is not // removed, so its returns must keep the `didReturnVar = true` write (see parseReturnValue). keepReturnFlag: true, @@ -3028,7 +3081,10 @@ function decodeInlineFlattenedFunction(match, searchRoot) { * doesn't re-attempt and loop on it forever while still giving every *other* match in the * same block its own chance. */ -function decodeControlFlowFlatteningInBlock(blockPath) { +function decodeControlFlowFlatteningInBlock( + blockPath, + sharedUsedNames = new Set(), +) { const isProgram = blockPath.isProgram() const block = isProgram ? blockPath @@ -3040,6 +3096,18 @@ function decodeControlFlowFlatteningInBlock(blockPath) { } const failed = new Set() + // Separate CFF applications can be lifted into this same lexical block. Their pair + // caches must stay independent, but their emitted declarations share one namespace; + // reserve the block's live names and allocate every newly introduced name from one + // block-wide set so two applications cannot each emit an unrelated `d`. + const blockUsedNames = sharedUsedNames + let scope = block.scope + while (scope) { + for (const name of Object.keys(scope.bindings)) { + blockUsedNames.add(name) + } + scope = scope.parent + } for (;;) { const stmts = block.get('body') @@ -3081,7 +3149,12 @@ function decodeControlFlowFlatteningInBlock(blockPath) { return } - const body = decodeControlFlowApplication(mainFnPath, harness, isProgram) + const body = decodeControlFlowApplication( + mainFnPath, + harness, + isProgram, + blockUsedNames, + ) if (!body) { failed.add(mainFnPath.node) continue @@ -3459,23 +3532,24 @@ function deCffHelperCleanupInit() { * convention. */ function deControlFlowFlatteningGraphInit() { + const sharedUsedNames = new Set() return { Program: { exit(path) { - decodeControlFlowFlatteningInBlock(path) + decodeControlFlowFlatteningInBlock(path, sharedUsedNames) cleanupOrphanedCffHelpers(path) }, }, Function: { exit(path) { - decodeControlFlowFlatteningInBlock(path) + decodeControlFlowFlatteningInBlock(path, sharedUsedNames) // An inline-flattened function IS itself a `Function`, so its own `exit` is where we // decode it. Its external call site lives in an enclosing // scope, so the whole Program is the search root for the entry vector. const inlineMatch = matchInlineFlattenedFunction(path) if (inlineMatch) { const program = path.findParent((p) => p.isProgram()) - decodeInlineFlattenedFunction(inlineMatch, program) + decodeInlineFlattenedFunction(inlineMatch, program, sharedUsedNames) } }, }, From d0ef33492c62c5668cc0b48ff19978e041e4e0c2 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:49:18 +0100 Subject: [PATCH 2/5] feat(decode-js): add two-step VM recovery Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- README.md | 23 +- package.json | 1 + scripts/render-vm-switch.mjs | 145 +++ src/main.js | 65 ++ src/plugin/jsconfuser-vm-sequential.js | 339 ++++++ src/plugin/jsconfuser-vm.js | 27 + .../analyze-closure-lifetimes.js | 250 +++++ .../analyze-exception-finally.js | 262 +++++ .../analyze-property-collections.js | 654 ++++++++++++ src/vm/jsconfuser-vm/analyze-scalar-values.js | 601 +++++++++++ src/vm/jsconfuser-vm/build-call-frames.js | 685 +++++++++++++ src/vm/jsconfuser-vm/build-cfg-edges.js | 530 ++++++++++ src/vm/jsconfuser-vm/build-cfg-preflight.js | 606 +++++++++++ src/vm/jsconfuser-vm/build-cfg-wordcode.js | 665 ++++++++++++ src/vm/jsconfuser-vm/build-cfg.js | 620 +++++++++++ src/vm/jsconfuser-vm/call-completion-input.js | 522 ++++++++++ src/vm/jsconfuser-vm/call-frames-preflight.js | 771 ++++++++++++++ .../closure-exception-records.js | 642 ++++++++++++ .../jsconfuser-vm/closure-lifetimes-flow.js | 555 ++++++++++ .../jsconfuser-vm/closure-lifetimes-input.js | 823 +++++++++++++++ .../closure-lifetimes-ownership.js | 695 +++++++++++++ .../jsconfuser-vm/container-role-helpers.js | 626 +++++++++++ src/vm/jsconfuser-vm/decode-standalone.js | 122 +++ src/vm/jsconfuser-vm/diagnose-standalone.js | 256 +++++ src/vm/jsconfuser-vm/emit-call-completion.js | 542 ++++++++++ .../jsconfuser-vm/emit-closure-exception.js | 588 +++++++++++ .../jsconfuser-vm/emit-structured-control.js | 502 +++++++++ .../exception-finally-control.js | 751 ++++++++++++++ .../jsconfuser-vm/exception-finally-input.js | 772 ++++++++++++++ .../jsconfuser-vm/exception-finally-routes.js | 793 ++++++++++++++ src/vm/jsconfuser-vm/extract-container.js | 823 +++++++++++++++ src/vm/jsconfuser-vm/numeric-to-vm-switch.js | 394 +++++++ src/vm/jsconfuser-vm/partition-functions.js | 968 ++++++++++++++++++ .../property-collections-operands.js | 796 ++++++++++++++ src/vm/jsconfuser-vm/read-wordcode.js | 816 +++++++++++++++ .../jsconfuser-vm/scalar-values-preflight.js | 590 +++++++++++ src/vm/jsconfuser-vm/standalone-contract.js | 24 + .../jsconfuser-vm/structured-control-cfg.js | 406 ++++++++ .../structured-control-exceptions.js | 500 +++++++++ .../jsconfuser-vm/structured-control-input.js | 864 ++++++++++++++++ .../validate-references-operands.js | 626 +++++++++++ .../validate-references-wordcode.js | 517 ++++++++++ src/vm/jsconfuser-vm/validate-references.js | 434 ++++++++ .../validate-structured-control.js | 262 +++++ src/vm/switch/vm-switch-control.js | 247 +++++ src/vm/switch/vm-switch-copy-planning.js | 606 +++++++++++ src/vm/switch/vm-switch-model.js | 423 ++++++++ src/vm/switch/vm-switch-structured-emitter.js | 809 +++++++++++++++ src/vm/switch/vm-switch-to-source.js | 266 +++++ 49 files changed, 24802 insertions(+), 2 deletions(-) create mode 100644 scripts/render-vm-switch.mjs create mode 100644 src/plugin/jsconfuser-vm-sequential.js create mode 100644 src/plugin/jsconfuser-vm.js create mode 100644 src/vm/jsconfuser-vm/analyze-closure-lifetimes.js create mode 100644 src/vm/jsconfuser-vm/analyze-exception-finally.js create mode 100644 src/vm/jsconfuser-vm/analyze-property-collections.js create mode 100644 src/vm/jsconfuser-vm/analyze-scalar-values.js create mode 100644 src/vm/jsconfuser-vm/build-call-frames.js create mode 100644 src/vm/jsconfuser-vm/build-cfg-edges.js create mode 100644 src/vm/jsconfuser-vm/build-cfg-preflight.js create mode 100644 src/vm/jsconfuser-vm/build-cfg-wordcode.js create mode 100644 src/vm/jsconfuser-vm/build-cfg.js create mode 100644 src/vm/jsconfuser-vm/call-completion-input.js create mode 100644 src/vm/jsconfuser-vm/call-frames-preflight.js create mode 100644 src/vm/jsconfuser-vm/closure-exception-records.js create mode 100644 src/vm/jsconfuser-vm/closure-lifetimes-flow.js create mode 100644 src/vm/jsconfuser-vm/closure-lifetimes-input.js create mode 100644 src/vm/jsconfuser-vm/closure-lifetimes-ownership.js create mode 100644 src/vm/jsconfuser-vm/container-role-helpers.js create mode 100644 src/vm/jsconfuser-vm/decode-standalone.js create mode 100644 src/vm/jsconfuser-vm/diagnose-standalone.js create mode 100644 src/vm/jsconfuser-vm/emit-call-completion.js create mode 100644 src/vm/jsconfuser-vm/emit-closure-exception.js create mode 100644 src/vm/jsconfuser-vm/emit-structured-control.js create mode 100644 src/vm/jsconfuser-vm/exception-finally-control.js create mode 100644 src/vm/jsconfuser-vm/exception-finally-input.js create mode 100644 src/vm/jsconfuser-vm/exception-finally-routes.js create mode 100644 src/vm/jsconfuser-vm/extract-container.js create mode 100644 src/vm/jsconfuser-vm/numeric-to-vm-switch.js create mode 100644 src/vm/jsconfuser-vm/partition-functions.js create mode 100644 src/vm/jsconfuser-vm/property-collections-operands.js create mode 100644 src/vm/jsconfuser-vm/read-wordcode.js create mode 100644 src/vm/jsconfuser-vm/scalar-values-preflight.js create mode 100644 src/vm/jsconfuser-vm/standalone-contract.js create mode 100644 src/vm/jsconfuser-vm/structured-control-cfg.js create mode 100644 src/vm/jsconfuser-vm/structured-control-exceptions.js create mode 100644 src/vm/jsconfuser-vm/structured-control-input.js create mode 100644 src/vm/jsconfuser-vm/validate-references-operands.js create mode 100644 src/vm/jsconfuser-vm/validate-references-wordcode.js create mode 100644 src/vm/jsconfuser-vm/validate-references.js create mode 100644 src/vm/jsconfuser-vm/validate-structured-control.js create mode 100644 src/vm/switch/vm-switch-control.js create mode 100644 src/vm/switch/vm-switch-copy-planning.js create mode 100644 src/vm/switch/vm-switch-model.js create mode 100644 src/vm/switch/vm-switch-structured-emitter.js create mode 100644 src/vm/switch/vm-switch-to-source.js diff --git a/README.md b/README.md index d1185027..2411ba75 100644 --- a/README.md +++ b/README.md @@ -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) | @@ -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 `.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 @@ -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). @@ -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) diff --git a/package.json b/package.json index d01d7350..83bcc4c7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/render-vm-switch.mjs b/scripts/render-vm-switch.mjs new file mode 100644 index 00000000..df13ba40 --- /dev/null +++ b/scripts/render-vm-switch.mjs @@ -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 +} diff --git a/src/main.js b/src/main.js index 59501be5..b68d1bc5 100644 --- a/src/main.js +++ b/src/main.js @@ -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' @@ -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 @@ -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) { @@ -40,6 +45,8 @@ const plugins = { obfuscator: PluginObfuscator, obfuscatorx: PluginObfuscatorX, awsc: PluginAwsc, + 'jsconfuser-vm': PluginJsconfuserVm, + 'jsconfuser-vm-sequential': PluginJsconfuserVmSequential, } const main = () => { @@ -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 { @@ -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) diff --git a/src/plugin/jsconfuser-vm-sequential.js b/src/plugin/jsconfuser-vm-sequential.js new file mode 100644 index 00000000..63883bd8 --- /dev/null +++ b/src/plugin/jsconfuser-vm-sequential.js @@ -0,0 +1,339 @@ +import { parse } from '@babel/parser' + +import PluginJsconfuser from './jsconfuser.js' +import PluginJsconfuserVm from './jsconfuser-vm.js' + +const COORDINATOR_SCHEMA = 'jsconfuser-vm-sequential.v1' +const OUTER_STAGE = 'outer' +const OUTER_PARSE_STAGE = 'outer-parse' +const VM_STAGE = 'vm' +const RECOVERED_PARSE_STAGE = 'recovered-parse' + +const REQUIRED_PROOF = Object.freeze([ + 'allPredecessorsReconstructed', + 'noTargetExecution', + 'noVmRuntimeEmission', + 'freshJavaScriptEmission', +]) + +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) + return value + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +function errorMessage(error) { + return error instanceof Error + ? `${error.name}: ${error.message}` + : String(error) +} + +function diagnostic(stage, code, message) { + return { stage, code, message } +} + +function parseStrict(source, parser) { + try { + parser(source, { + errorRecovery: false, + sourceType: 'unambiguous', + }) + return { status: 'success', ok: true } + } catch (error) { + return { + status: 'declined', + ok: false, + code: 'strict-parse-failed', + message: errorMessage(error), + } + } +} + +function stageFailure(status, code, message) { + return { status, ok: false, code, message } +} + +function stageSuccess(output, extra = {}) { + return { status: 'success', ok: true, output, ...extra } +} + +function createDeclined(original, stages, failedStage, failedDiagnostic) { + return deepFreeze({ + schemaVersion: COORDINATOR_SCHEMA, + ok: false, + status: 'declined', + input: original, + output: original, + result: null, + diagnostic: failedDiagnostic, + failedStage, + stages, + }) +} + +function isCompleteVmResult(record, expectedInput) { + if ( + !record || + typeof record !== 'object' || + record.schemaVersion !== 'jsconfuser-vm-adapter.v1' || + record.ok !== true || + record.status !== 'success' || + record.input !== expectedInput || + typeof record.output !== 'string' || + !record.result || + typeof record.result !== 'object' || + record.result.schemaVersion !== 'jsconfuser-vm-standalone.v1' || + record.result.output !== record.output + ) { + return false + } + + const proof = record.result.proof + return ( + proof && + REQUIRED_PROOF.every((key) => proof[key] === true) && + record.result.parseOnly === true && + record.result.targetExecution === false && + record.result.vmExecuted === false && + record.result.emittedJavaScript === true + ) +} + +function vmFailure(record, expectedInput) { + if (!record || typeof record !== 'object') { + return stageFailure( + 'declined', + 'vm-incomplete', + 'VM decoder returned no result', + ) + } + + if (record.status === 'success' || record.ok === true) { + if (typeof record.input === 'string' && record.input !== expectedInput) { + return stageFailure( + 'declined', + 'vm-input-mismatch', + 'VM decoder result input does not match the outer handoff', + ) + } + return stageFailure( + 'declined', + 'vm-incomplete', + 'VM decoder returned an incomplete success result', + ) + } + + if (record.status === 'timeout' || record.diagnostic?.code === 'timeout') { + return stageFailure( + 'timeout', + 'vm-timeout', + record.diagnostic?.message || 'VM decoder timed out', + ) + } + + if (record.status === 'error') { + return stageFailure( + 'error', + 'vm-error', + record.diagnostic?.message || 'VM decoder failed', + ) + } + + return stageFailure( + 'declined', + record.diagnostic?.code || 'vm-declined', + record.diagnostic?.message || 'VM decoder declined the outer result', + ) +} + +/** + * Create the independent outer-to-VM coordinator. + * + * The optional dependencies keep parser and decoder boundaries explicit for focused tests. + * Production callers use the defaults, which are the existing independent decoders. + */ +export function createJsconfuserVmSequentialCoordinator({ + outerDecoder = PluginJsconfuser, + vmDecoder = PluginJsconfuserVm, + parser = parse, +} = {}) { + return (source) => { + if (typeof source !== 'string') { + throw new TypeError('source must be a string') + } + + const stages = { + outer: stageFailure('pending', 'pending', 'Outer decoder has not run'), + outerParse: stageFailure( + 'pending', + 'pending', + 'Outer output has not been parsed', + ), + vm: stageFailure('pending', 'pending', 'VM decoder has not run'), + recoveredParse: stageFailure( + 'pending', + 'pending', + 'Recovered output has not been parsed', + ), + } + + let outerSource + try { + outerSource = outerDecoder(source) + } catch (error) { + stages.outer = stageFailure( + 'error', + 'outer-exception', + errorMessage(error), + ) + return createDeclined( + source, + stages, + OUTER_STAGE, + diagnostic(OUTER_STAGE, 'outer-exception', errorMessage(error)), + ) + } + + if (outerSource === null || outerSource === undefined) { + stages.outer = stageFailure( + 'declined', + 'outer-declined', + 'Outer decoder declined the input', + ) + return createDeclined( + source, + stages, + OUTER_STAGE, + diagnostic( + OUTER_STAGE, + 'outer-declined', + 'Outer decoder declined the input', + ), + ) + } + + if (outerSource && typeof outerSource === 'object') { + const outerDiagnostic = outerSource.diagnostic + const outerStatus = outerSource.status + const code = + outerStatus === 'timeout' || outerDiagnostic?.code === 'timeout' + ? 'outer-timeout' + : outerStatus === 'error' + ? 'outer-error' + : 'outer-incomplete' + const status = + code === 'outer-timeout' + ? 'timeout' + : code === 'outer-error' + ? 'error' + : 'declined' + const message = + outerDiagnostic?.message || 'Outer decoder did not return source bytes' + stages.outer = stageFailure(status, code, message) + return createDeclined( + source, + stages, + OUTER_STAGE, + diagnostic(OUTER_STAGE, code, message), + ) + } + + if (typeof outerSource !== 'string') { + stages.outer = stageFailure( + 'declined', + 'outer-incomplete', + 'Outer decoder did not return source bytes', + ) + return createDeclined( + source, + stages, + OUTER_STAGE, + diagnostic( + OUTER_STAGE, + 'outer-incomplete', + 'Outer decoder did not return source bytes', + ), + ) + } + + stages.outer = stageSuccess(outerSource) + const outerParse = parseStrict(outerSource, parser) + stages.outerParse = outerParse + if (!outerParse.ok) { + return createDeclined( + source, + stages, + OUTER_PARSE_STAGE, + diagnostic(OUTER_PARSE_STAGE, 'outer-parse-failed', outerParse.message), + ) + } + + let vmRecord + try { + vmRecord = vmDecoder(outerSource) + } catch (error) { + stages.vm = stageFailure('error', 'vm-exception', errorMessage(error)) + return createDeclined( + source, + stages, + VM_STAGE, + diagnostic(VM_STAGE, 'vm-exception', errorMessage(error)), + ) + } + + if (!isCompleteVmResult(vmRecord, outerSource)) { + const failure = vmFailure(vmRecord, outerSource) + stages.vm = { + ...failure, + output: + typeof vmRecord?.output === 'string' ? vmRecord.output : undefined, + record: vmRecord, + } + return createDeclined( + source, + stages, + VM_STAGE, + diagnostic(VM_STAGE, failure.code, failure.message), + ) + } + + stages.vm = stageSuccess(vmRecord.output, { + result: vmRecord.result, + record: vmRecord, + }) + const recoveredParse = parseStrict(vmRecord.output, parser) + stages.recoveredParse = recoveredParse + if (!recoveredParse.ok) { + return createDeclined( + source, + stages, + RECOVERED_PARSE_STAGE, + diagnostic( + RECOVERED_PARSE_STAGE, + 'recovered-parse-failed', + recoveredParse.message, + ), + ) + } + + return deepFreeze({ + schemaVersion: COORDINATOR_SCHEMA, + ok: true, + status: 'success', + input: source, + output: vmRecord.output, + result: vmRecord.result, + diagnostic: null, + failedStage: null, + stages, + }) + } +} + +export function decodeJsconfuserVmSequential(source, dependencies) { + return createJsconfuserVmSequentialCoordinator(dependencies)(source) +} + +export default decodeJsconfuserVmSequential diff --git a/src/plugin/jsconfuser-vm.js b/src/plugin/jsconfuser-vm.js new file mode 100644 index 00000000..cdf794b7 --- /dev/null +++ b/src/plugin/jsconfuser-vm.js @@ -0,0 +1,27 @@ +import { diagnoseStandalone } from '../vm/jsconfuser-vm/decode-standalone.js' + +const ADAPTER_SCHEMA = 'jsconfuser-vm-adapter.v1' + +/** + * Recover js-confuser VM source and retain the standalone diagnosis for the caller. + * + * The adapter deliberately returns one result-aware record rather than projecting the + * standalone result to a string. A caller can write `output` and the accompanying + * `result`/`diagnostic` without diagnosing the source again. Declines are atomic: their + * output is the exact input source, not a partially emitted program. + */ +export function decodeJsconfuserVm(source) { + const diagnosis = diagnoseStandalone(source) + const success = diagnosis.ok + return Object.freeze({ + schemaVersion: ADAPTER_SCHEMA, + ok: success, + status: success ? 'success' : 'declined', + input: source, + output: success ? diagnosis.result.output : source, + result: success ? diagnosis.result : null, + diagnostic: success ? null : diagnosis.diagnostic, + }) +} + +export default decodeJsconfuserVm diff --git a/src/vm/jsconfuser-vm/analyze-closure-lifetimes.js b/src/vm/jsconfuser-vm/analyze-closure-lifetimes.js new file mode 100644 index 00000000..087a270f --- /dev/null +++ b/src/vm/jsconfuser-vm/analyze-closure-lifetimes.js @@ -0,0 +1,250 @@ +import { + CALL_FRAME_SCHEMA, + CFG_SCHEMA, + CLOSURE_SCHEMA, + ClosureLifetimeDecline, + FUNCTION_SCHEMA, + REFERENCE_SCHEMA, + WORDCODE_SCHEMA, + cloneData, + decline, + deepFreeze, + register, + sortedNumbers, + sortedStrings, + validateReferences, + validateWordcode, +} from './closure-lifetimes-input.js' +import { + validateCallFrames, + validateCfg, + validateFunctions, +} from './closure-lifetimes-ownership.js' +import { + analyzeProvenance, + buildCapturePairs, + collectUses, + resolveBindings, + terminalEvents, +} from './closure-lifetimes-flow.js' + +export function sameAbstractValue(left, right) { + if ( + !left || + !right || + left.precise !== right.precise || + left.sites.size !== right.sites.size + ) + return false + return [...left.sites].every((siteId) => right.sites.has(siteId)) +} + +export function sameEnvironment(left, right, regCount) { + for (let index = 0; index < regCount; index += 1) { + if (!sameAbstractValue(left.get(index), right.get(index))) return false + } + return true +} + +function applyProvenance(captureResult, bindingData, useResult) { + const closureById = new Map( + captureResult.closureSites.map((site) => [site.id, site]), + ) + for (const event of useResult.events.values()) { + const site = closureById.get(event.siteId) + if (!site) + decline( + 'stale-predecessor', + `Closure provenance names unknown site ${event.siteId}`, + ) + site.escapeEvents.push({ + kind: event.kind, + functionId: event.functionId, + pc: event.pc, + provenEscape: event.provenEscape, + targetFunctions: sortedNumbers(event.targetFunctions), + }) + } + const sitesByChild = new Map() + for (const site of captureResult.closureSites) { + if (!sitesByChild.has(site.childFunctionId)) + sitesByChild.set(site.childFunctionId, []) + sitesByChild.get(site.childFunctionId).push(site) + } + for (const use of useResult.uses) { + const closureSites = sitesByChild.get(use.functionId) ?? [] + const hasClosedPath = closureSites.some((site) => + site.escapeEvents.some( + ({ kind, provenEscape }) => + provenEscape && ['return', 'global-store'].includes(kind), + ), + ) + const hasOpenPath = closureSites.some( + (site) => + site.parentFunctionId === use.functionId || + site.escapeEvents.some(({ kind }) => kind === 'internal-call-argument'), + ) + const possibleStates = new Set() + if (hasOpenPath) possibleStates.add('open') + if (hasClosedPath) possibleStates.add('closed') + if (possibleStates.size === 0) { + possibleStates.add( + use.functionId === + bindingData.bindings.get(use.bindingId).ownerFunctionId + ? 'open' + : 'closed', + ) + } + use.possibleStates = sortedStrings(possibleStates) + use.state = + use.possibleStates.length === 1 ? use.possibleStates[0] : 'open-or-closed' + } + const terminationEvents = [] + for (const binding of bindingData.bindings.values()) { + binding.closeEvents.forEach((event) => { + terminationEvents.push({ bindingId: binding.id, ...event }) + }) + } + return { terminationEvents } +} + +function buildResult(wordcodeData, functionData, cfgData, callFrameData) { + const bindingData = resolveBindings(functionData) + const captureResult = buildCapturePairs(functionData, bindingData, cfgData) + const uses = collectUses(wordcodeData, functionData, cfgData, bindingData) + terminalEvents(cfgData, bindingData) + const provenance = analyzeProvenance( + wordcodeData, + functionData, + cfgData, + callFrameData, + ) + const useResult = { ...provenance, uses } + const { terminationEvents } = applyProvenance( + captureResult, + bindingData, + useResult, + ) + const bindings = [...bindingData.bindings.values()].map((binding) => ({ + id: binding.id, + ownerFunctionId: binding.ownerFunctionId, + register: register(binding.ownerRegister), + capturePairIds: [...binding.capturePairIds], + useIds: [...binding.useIds], + lifecycle: cloneData(binding.lifecycle), + })) + const functionSummary = functionData.functions.map((fn) => ({ + id: fn.id, + kind: fn.kind, + startPc: fn.startPc, + endPc: fn.endPc, + parentFunctionId: fn.parentFunctionId, + captureCount: fn.captureCount, + capturePairIds: captureResult.capturePairs + .filter(({ childFunctionId }) => childFunctionId === fn.id) + .map(({ id }) => id), + useIds: uses + .filter(({ functionId }) => functionId === fn.id) + .map(({ id }) => id), + closeBindingIds: bindings + .filter(({ ownerFunctionId }) => ownerFunctionId === fn.id) + .map(({ id }) => id), + })) + return { + schemaVersion: CLOSURE_SCHEMA, + encoding: 'numeric-u32', + wordCount: wordcodeData.words.length, + instructionCount: wordcodeData.instructions.length, + functionCount: functionData.functions.length, + capturePairCount: captureResult.capturePairs.length, + bindingCount: bindings.length, + closureSiteCount: captureResult.closureSites.length, + functions: functionSummary, + capturePairs: captureResult.capturePairs, + bindings, + uses, + closureSites: captureResult.closureSites, + terminationEvents, + source: { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: REFERENCE_SCHEMA, + functionsSchema: FUNCTION_SCHEMA, + cfgSchema: CFG_SCHEMA, + callFramesSchema: CALL_FRAME_SCHEMA, + }, + } +} + +function buildClosureLifetimeResult( + wordcode, + references, + functions, + cfg, + callFrames, +) { + const wordcodeData = validateWordcode(wordcode) + const referenceData = validateReferences(references, wordcodeData) + const functionData = validateFunctions(functions, wordcodeData, referenceData) + const cfgData = validateCfg(cfg, wordcodeData, functionData) + const callFrameData = validateCallFrames( + callFrames, + wordcodeData, + functionData, + cfgData, + ) + return deepFreeze( + buildResult(wordcodeData, functionData, cfgData, callFrameData), + ) +} + +export function diagnoseClosureLifetimes( + wordcode, + references, + functions, + cfg, + callFrames, +) { + try { + return deepFreeze({ + ok: true, + result: buildClosureLifetimeResult( + wordcode, + references, + functions, + cfg, + callFrames, + ), + diagnostic: null, + }) + } catch (error) { + const diagnostic = { + code: + error instanceof ClosureLifetimeDecline + ? error.code + : 'closure-lifetime-error', + message: error instanceof Error ? error.message : String(error), + } + return deepFreeze({ ok: false, result: null, diagnostic }) + } +} + +export function analyzeClosureLifetimes( + wordcode, + references, + functions, + cfg, + callFrames, +) { + const diagnosis = diagnoseClosureLifetimes( + wordcode, + references, + functions, + cfg, + callFrames, + ) + return diagnosis.ok ? diagnosis.result : null +} + +export const buildClosureLifetimes = analyzeClosureLifetimes + +export default analyzeClosureLifetimes diff --git a/src/vm/jsconfuser-vm/analyze-exception-finally.js b/src/vm/jsconfuser-vm/analyze-exception-finally.js new file mode 100644 index 00000000..528f2125 --- /dev/null +++ b/src/vm/jsconfuser-vm/analyze-exception-finally.js @@ -0,0 +1,262 @@ +import { + CALL_FRAME_SCHEMA, + CFG_SCHEMA, + COMPLETION_KINDS, + EXCEPTION_FINALLY_SCHEMA, + ExceptionFinallyDecline, + FUNCTION_SCHEMA, + REFERENCE_SCHEMA, + WORDCODE_SCHEMA, + cloneData, + deepFreeze, + register, + sameValue, + sortedNumbers, + stateView, + validateFunctions, + validateReferences, + validateWordcode, +} from './exception-finally-input.js' +import { + makeRecords, + validateCallFrames, + validateCfg, + validateDirectTargets, +} from './exception-finally-control.js' +import { + crossCheckF, + routeView, + solveFunction, +} from './exception-finally-routes.js' + +function buildResult( + wordcodeData, + functionData, + cfgData, + solveResults, + recordData, + callFrames, +) { + const functionResults = [] + const allInstructionStates = [] + const allEdgeStates = [] + const allMerges = [] + const allJumpRegChecks = [] + const allTransitions = [] + const allHandlers = [] + const allFinallys = [] + for (const fn of functionData.list) { + const records = recordData.recordsByFunction.get(fn.id) + const solved = solveResults.get(fn.id) + const efn = cfgData.cfgFunctions.find(({ id }) => id === fn.id) + const instructionStates = fn.instructionPcs.map((pc) => { + const outgoing = efn.edges.filter((edge) => edge.sourcePc === pc) + const afterStates = outgoing.map((edge) => edge.handlerStateAfter) + const handlerStateAfter = + afterStates.length > 0 && + afterStates.every((state) => sameValue(state, afterStates[0])) + ? cloneData(afterStates[0]) + : null + return { + pc, + reachable: solved.inStates.has(pc), + handlerStateBefore: solved.inStates.has(pc) + ? stateView(solved.inStates.get(pc), records) + : null, + handlerStateAfter, + } + }) + const edgeStates = efn.edges.map((edge) => routeView(edge)) + const merges = [...solved.incoming.entries()] + .filter(([, incoming]) => incoming.size > 1) + .map(([pc, incoming]) => ({ + pc, + incomingCount: incoming.size, + handlerState: stateView(solved.inStates.get(pc), records), + })) + const jumpRegChecks = (cfgData.indirectByFunction.get(fn.id) ?? []).map( + (entry) => { + const instruction = wordcodeData.byPc.get(entry.pc) + const finalizer = [...records.values()].find( + ({ type, finallyPc, throwPadPc }) => + type === 'finally' && + entry.pc >= finallyPc && + entry.pc < throwPadPc, + ) + return { + pc: entry.pc, + sourceRegister: register(entry.sourceRegister), + targets: sortedNumbers(entry.targets), + finalizer: finalizer?.id ?? null, + finite: true, + sameFunction: entry.targets.every( + (target) => functionData.ownerByPc.get(target) === fn.id, + ), + continuationRegister: finalizer + ? register(finalizer.continuationReg) + : null, + instructionName: instruction.name, + } + }, + ) + const handlers = [...records.values()] + .filter(({ type }) => type === 'handler') + .map(cloneData) + const finallys = [...records.values()] + .filter(({ type }) => type === 'finally') + .map(cloneData) + allHandlers.push(...handlers) + allFinallys.push(...finallys) + allInstructionStates.push( + ...instructionStates.map((item) => ({ functionId: fn.id, ...item })), + ) + allEdgeStates.push( + ...edgeStates.map((item) => ({ functionId: fn.id, ...item })), + ) + allMerges.push(...merges.map((item) => ({ functionId: fn.id, ...item }))) + allJumpRegChecks.push( + ...jumpRegChecks.map((item) => ({ functionId: fn.id, ...item })), + ) + allTransitions.push( + ...solved.transitions.map((item) => ({ functionId: fn.id, ...item })), + ) + functionResults.push({ + id: fn.id, + startPc: fn.startPc, + endPc: fn.endPc, + handlerRecords: handlers, + finallyRecords: finallys, + instructionStates, + edgeStates, + merges, + jumpRegChecks, + transitions: solved.transitions, + }) + } + const completionRoutes = allEdgeStates.filter(({ kind }) => + COMPLETION_KINDS.has(kind), + ) + return { + schemaVersion: EXCEPTION_FINALLY_SCHEMA, + encoding: 'numeric-u32', + wordCount: wordcodeData.words.length, + instructionCount: wordcodeData.instructions.length, + functionCount: functionData.list.length, + functions: functionResults, + handlerRecords: allHandlers, + finallyRecords: allFinallys, + instructionStates: allInstructionStates, + edgeStates: allEdgeStates, + merges: allMerges, + jumpRegChecks: allJumpRegChecks, + transitions: allTransitions, + completionRoutes, + callFrameRouteCount: callFrames.completionRoutes.length, + source: { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: REFERENCE_SCHEMA, + functionsSchema: FUNCTION_SCHEMA, + cfgSchema: CFG_SCHEMA, + callFramesSchema: CALL_FRAME_SCHEMA, + }, + } +} + +function analyze(wordcode, references, functions, cfg, callFrames) { + const wordcodeData = validateWordcode(wordcode) + const referenceData = validateReferences(references, wordcodeData) + const functionData = validateFunctions(functions, wordcodeData) + validateDirectTargets(wordcodeData, referenceData, functionData) + const recordData = makeRecords(wordcodeData, functionData) + const cfgData = validateCfg(cfg, wordcodeData, functionData, recordData) + const callFrameData = validateCallFrames( + callFrames, + wordcodeData, + functionData, + cfgData, + recordData, + ) + const solveResults = new Map() + for (const fn of functionData.list) { + const efn = cfgData.cfgFunctions.find(({ id }) => id === fn.id) + solveResults.set( + fn.id, + solveFunction( + fn, + efn, + wordcodeData, + functionData, + recordData, + cfgData.indirectByFunction.get(fn.id) ?? [], + ), + ) + } + crossCheckF( + callFrameData, + cfgData, + functionData, + wordcodeData, + solveResults, + recordData, + ) + return deepFreeze( + buildResult( + wordcodeData, + functionData, + cfgData, + solveResults, + recordData, + callFrameData, + ), + ) +} + +export function diagnoseExceptionFinally( + wordcode, + references, + functions, + cfg, + callFrames, +) { + try { + return deepFreeze({ + ok: true, + result: analyze(wordcode, references, functions, cfg, callFrames), + diagnostic: null, + }) + } catch (error) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: + error instanceof ExceptionFinallyDecline + ? error.code + : 'exception-finally-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function analyzeExceptionFinally( + wordcode, + references, + functions, + cfg, + callFrames, +) { + const diagnosis = diagnoseExceptionFinally( + wordcode, + references, + functions, + cfg, + callFrames, + ) + return diagnosis.ok ? diagnosis.result : null +} + +export const analyzeExceptionFinallyState = analyzeExceptionFinally +export const diagnoseExceptionFinallyState = diagnoseExceptionFinally +export const buildExceptionFinally = analyzeExceptionFinally +export default analyzeExceptionFinally diff --git a/src/vm/jsconfuser-vm/analyze-property-collections.js b/src/vm/jsconfuser-vm/analyze-property-collections.js new file mode 100644 index 00000000..54ee0755 --- /dev/null +++ b/src/vm/jsconfuser-vm/analyze-property-collections.js @@ -0,0 +1,654 @@ +import { + CALL_FRAME_SCHEMA, + CFG_SCHEMA, + EXCLUDED_OPCODE_GROUPS, + EXCLUDED_OPCODE_NAMES, + FUNCTION_SCHEMA, + J_DEFINITION_BY_NAME, + J_OPCODE_DEFINITIONS, + PROPERTY_COLLECTION_SCHEMA, + PropertyCollectionsDecline, + REFERENCE_SCHEMA, + WORDCODE_SCHEMA, + cloneData, + decline, + deepFreeze, + destinationFor, + evaluationOrder, + inputIndexes, + operandRoles, + ownerByPc, + preflight, + reachablePcs, + register, + requireOperandKinds, +} from './property-collections-operands.js' + +function controlFlowFor(instruction, functionId, cfg) { + if (instruction.name !== 'FOR_IN_SETUP' && instruction.name !== 'FOR_IN_NEXT') + return null + const cfgFunction = cfg.functions.find(({ id }) => id === functionId) + if (!cfgFunction) + decline('invalid-function-ownership', `Missing CFG function ${functionId}`) + const edges = cfgFunction.edges.filter( + ({ sourcePc }) => sourcePc === instruction.pc, + ) + if (instruction.name === 'FOR_IN_SETUP') { + const fallthrough = edges.filter( + ({ kind, targetPc }) => + kind === 'fallthrough' && targetPc === instruction.nextPc, + ) + if (fallthrough.length !== 1 || edges.length !== 1) { + decline( + 'stale-predecessor', + `Packet E FOR_IN_SETUP@${instruction.pc} edges are stale`, + ) + } + return { + kind: 'fallthrough', + normalTargetPc: instruction.nextPc, + edge: cloneData(fallthrough[0]), + } + } + const exitTargetPc = instruction.operands[2].pc + const exitEdges = edges.filter( + ({ kind, targetPc }) => kind === 'branch' && targetPc === exitTargetPc, + ) + const normalEdges = edges.filter( + ({ kind, targetPc }) => + kind === 'fallthrough' && targetPc === instruction.nextPc, + ) + if ( + exitEdges.length !== 1 || + normalEdges.length !== 1 || + edges.length !== 2 + ) { + decline( + 'stale-predecessor', + `Packet E FOR_IN_NEXT@${instruction.pc} edges are stale`, + ) + } + return { + kind: 'iterator-conditional', + normalTargetPc: instruction.nextPc, + exitTargetPc, + normal: { + condition: 'iterator has another enumerable key', + writesDestination: true, + incrementsIterator: true, + edge: cloneData(normalEdges[0]), + }, + exit: { + condition: 'iterator is exhausted', + writesDestination: false, + incrementsIterator: false, + edge: cloneData(exitEdges[0]), + }, + } +} + +function semanticClassification(name, roles, operands, controlFlow) { + const common = { + known: false, + certainty: 'structural-only', + roles: cloneData(roles), + evaluationOrder: evaluationOrder(name, operands), + } + if (name === 'GET_PROP') { + return { + ...common, + kind: 'property-read', + resultType: 'dynamic', + runtime: + 'reads object[key] through ordinary JavaScript property semantics', + unknowns: [ + 'object identity', + 'property key value', + 'property result', + 'accessor/proxy behavior', + ], + mayThrow: true, + } + } + if (name === 'SET_PROP') { + return { + ...common, + kind: 'property-write', + resultType: 'no-register-result', + runtime: + 'calls Reflect.set(object, key, value) and ignores its boolean result', + unknowns: [ + 'object identity', + 'property key value', + 'value', + 'setter/proxy behavior', + ], + mayThrow: true, + } + } + if (name === 'DELETE_PROP') { + return { + ...common, + kind: 'property-delete', + resultType: 'boolean', + runtime: 'computes delete object[key]', + unknowns: [ + 'object identity', + 'property key value', + 'deletion result', + 'proxy behavior', + ], + mayThrow: true, + } + } + if (name === 'IN') { + return { + ...common, + kind: 'membership-test', + resultType: 'boolean', + runtime: 'computes propertyKey in object, including the prototype chain', + unknowns: [ + 'property key value', + 'object identity', + 'membership result', + 'proxy behavior', + ], + mayThrow: true, + } + } + if (name === 'INSTANCEOF') { + return { + ...common, + kind: 'prototype-relation-test', + resultType: 'boolean', + runtime: + 'computes instance instanceof constructor using JavaScript prototype relation rules', + unknowns: [ + 'instance identity', + 'constructor identity', + 'prototype relation result', + 'custom hasInstance behavior', + ], + mayThrow: true, + } + } + if (name === 'BUILD_ARRAY') { + return { + ...common, + kind: 'array-construction', + resultType: 'array', + runtime: + 'allocates an Array(count) and writes element registers in payload order', + count: operands[1].value, + unknowns: ['element values', 'array identity'], + mayThrow: false, + } + } + if (name === 'BUILD_OBJECT') { + return { + ...common, + kind: 'object-construction', + resultType: 'object', + runtime: + 'allocates an ordinary object and assigns each key/value pair in payload order', + pairCount: operands[1].value, + duplicateKeyBehavior: + 'later ordered assignment overwrites an earlier property', + unknowns: [ + 'object identity', + 'key values', + 'value contents', + 'prototype interactions', + ], + mayThrow: true, + } + } + if (name === 'DEFINE_GETTER' || name === 'DEFINE_SETTER') { + const kind = name === 'DEFINE_GETTER' ? 'getter' : 'setter' + return { + ...common, + kind: `${kind}-definition`, + resultType: 'no-register-result', + runtime: `defines an own ${kind} with configurable=true and enumerable=true; preserves an existing opposite accessor when present`, + accessorKind: kind, + descriptor: { + configurable: true, + enumerable: true, + preservesExistingOppositeAccessor: true, + }, + unknowns: [ + 'object identity', + 'property key value', + 'accessor function identity', + 'receiver behavior', + ], + mayThrow: true, + } + } + if (name === 'FOR_IN_SETUP') { + return { + ...common, + kind: 'for-in-setup', + resultType: 'iterator', + runtime: + 'collects enumerable own names across the prototype chain, de-duplicates first-seen names, and stores {_keys, i: 0}', + setup: { + nullishSourceProducesEmptyKeys: true, + boxesPrimitiveSource: true, + walksPrototypeChain: true, + deDuplicatesNames: true, + }, + unknowns: ['source value', 'enumerated key list', 'iterator identity'], + mayThrow: true, + } + } + return { + ...common, + kind: 'for-in-next', + resultType: 'property-key-or-exit', + runtime: + 'writes the next enumerable key and increments i, or jumps to exitTarget when exhausted', + exitTargetPc: operands[2].pc, + exit: controlFlow, + unknowns: ['iterator contents', 'next key value', 'iteration count'], + mayThrow: true, + } +} + +function effectFor(name, operands, destination, roles, semantic) { + const registerResult = destination ? { ...destination } : null + const reads = + name === 'GET_PROP' || name === 'DELETE_PROP' + ? { object: roles.object, key: roles.key } + : name === 'SET_PROP' + ? { object: roles.object, key: roles.key, value: roles.value } + : name === 'IN' + ? { propertyKey: roles.propertyKey, object: roles.object } + : name === 'INSTANCEOF' + ? { instance: roles.instance, constructor: roles.constructor } + : name === 'BUILD_ARRAY' + ? { elements: roles.elements } + : name === 'BUILD_OBJECT' + ? { pairs: roles.pairs } + : name === 'DEFINE_GETTER' || name === 'DEFINE_SETTER' + ? { + object: roles.object, + key: roles.key, + accessorFunction: roles.accessorFunction, + } + : name === 'FOR_IN_SETUP' + ? { source: roles.source } + : { iterator: roles.iterator } + const effect = { + kind: J_DEFINITION_BY_NAME.get(name).effect, + register: registerResult, + resultType: semantic.resultType, + reads, + mutates: [ + 'SET_PROP', + 'DELETE_PROP', + 'DEFINE_GETTER', + 'DEFINE_SETTER', + 'FOR_IN_NEXT', + ].includes(name), + } + if (name === 'SET_PROP') effect.writesProperty = { ...roles } + if (name === 'DELETE_PROP') effect.deletesProperty = { ...roles } + if (name === 'DEFINE_GETTER' || name === 'DEFINE_SETTER') + effect.definesAccessor = { ...roles } + if (name === 'FOR_IN_SETUP') effect.createsIterator = { ...roles } + if (name === 'FOR_IN_NEXT') effect.stepsIterator = { ...roles } + return effect +} + +function buildOperation(instruction, definition, functionId, reachable, cfg) { + const operands = requireOperandKinds(instruction, definition) + const destination = destinationFor(definition.name, operands) + const inputIndexesForOperation = inputIndexes(definition.name, operands) + const inputs = inputIndexesForOperation.map((index) => + register( + operands[index].index, + `${definition.name}@${instruction.pc} input ${index}`, + ), + ) + const roles = operandRoles(definition.name, operands) + const controlFlow = controlFlowFor(instruction, functionId, cfg) + const semantic = semanticClassification( + definition.name, + roles, + operands, + controlFlow, + ) + const payload = + definition.name === 'BUILD_ARRAY' + ? { + kind: 'array', + count: operands[1].value, + elementRegisters: operands + .slice(2) + .map((operand) => ({ ...operand })), + } + : definition.name === 'BUILD_OBJECT' + ? { + kind: 'object', + pairCount: operands[1].value, + pairs: roles.pairs.map(({ pairIndex, key, value }) => ({ + pairIndex, + key: { ...key.register }, + value: { ...value.register }, + })), + } + : definition.name === 'FOR_IN_NEXT' + ? { + kind: 'iterator-next', + iterator: { ...operands[1] }, + exitTarget: { ...operands[2] }, + } + : null + return { + id: `property-collection:${functionId}:${instruction.pc}`, + functionId, + pc: instruction.pc, + reachable, + opcode: { name: definition.name, value: definition.opcode }, + name: definition.name, + family: definition.family, + operandForm: definition.operandForm, + operands, + wordOperands: cloneData(instruction.wordOperands), + words: instruction.words.slice(), + width: instruction.width, + nextPc: instruction.nextPc, + destination, + inputs, + roles, + payload, + controlFlow, + useDef: { + uses: inputs.map((input) => ({ ...input })), + defines: destination ? { ...destination } : null, + conditionalDefine: + definition.name === 'FOR_IN_NEXT' + ? 'only when an enumerable key is available' + : null, + }, + effect: effectFor(definition.name, operands, destination, roles, semantic), + evaluationOrder: semantic.evaluationOrder, + semantic, + } +} + +function buildOperations(wordcode, functions, cfg) { + const owners = ownerByPc(functions) + const reachable = reachablePcs(cfg) + const operations = [] + const seen = new Set() + for (const instruction of wordcode.instructions) { + const definition = J_DEFINITION_BY_NAME.get(instruction.name) + if (!definition) { + if (!EXCLUDED_OPCODE_NAMES.has(instruction.name)) { + decline( + 'unsupported-opcode', + `${instruction.name}@${instruction.pc} is outside the known pinned opcode map`, + ) + } + continue + } + const functionId = owners.get(String(instruction.pc)) + if (!Number.isInteger(functionId)) { + decline( + 'invalid-function-ownership', + `${instruction.name}@${instruction.pc} has no function owner`, + ) + } + const key = `${functionId}:${instruction.pc}` + if (seen.has(key)) + decline('duplicate-operation', `Duplicate property operation ${key}`) + seen.add(key) + operations.push( + buildOperation( + instruction, + definition, + functionId, + reachable.has(key), + cfg, + ), + ) + } + const expected = wordcode.instructions.filter(({ name }) => + J_DEFINITION_BY_NAME.has(name), + ) + if (operations.length !== expected.length || seen.size !== expected.length) { + decline( + 'incomplete-property-census', + `Property operation census has ${operations.length} records for ${expected.length} J instructions`, + ) + } + return operations +} + +function makeRows(operations) { + const counts = Object.fromEntries( + J_OPCODE_DEFINITIONS.map(({ name }) => [name, 0]), + ) + for (const operation of operations) counts[operation.name] += 1 + const rows = J_OPCODE_DEFINITIONS.map((definition) => ({ + name: definition.name, + opcode: definition.opcode, + family: definition.family, + operandForm: definition.operandForm, + operation: definition.operation, + effect: definition.effect, + resultType: definition.resultType, + known: definition.known, + evaluationOrder: [...definition.evaluationRoles], + width: ['BUILD_ARRAY', 'BUILD_OBJECT'].includes(definition.name) + ? 'variable' + : definition.name === 'FOR_IN_SETUP' + ? 3 + : 4, + count: counts[definition.name], + })) + if (rows.reduce((sum, row) => sum + row.count, 0) !== operations.length) { + decline( + 'incomplete-property-census', + 'Property opcode row counts do not sum to operations', + ) + } + return { counts, rows } +} + +function makeFunctionCensus(operations, functions) { + return functions.functions.map((fn) => { + const rows = operations.filter(({ functionId }) => functionId === fn.id) + return { + functionId: fn.id, + startPc: fn.startPc, + endPc: fn.endPc, + instructionCount: fn.instructionCount, + propertyOperationCount: rows.length, + reachablePropertyOperationCount: rows.filter(({ reachable }) => reachable) + .length, + unreachablePropertyOperationCount: rows.filter( + ({ reachable }) => !reachable, + ).length, + } + }) +} + +function makeResult( + wordcode, + references, + functions, + cfg, + callFrames, + operations, +) { + const rowData = makeRows(operations) + const expectedNames = J_OPCODE_DEFINITIONS.map(({ name }) => name) + const operationKeys = operations.map( + ({ functionId, pc }) => `${functionId}:${pc}`, + ) + return deepFreeze({ + schemaVersion: PROPERTY_COLLECTION_SCHEMA, + encoding: 'numeric-u32', + api: { + analyze: + 'analyzePropertyCollections(wordcode, references, functions, cfg, callFrames)', + diagnose: + 'diagnosePropertyCollections(wordcode, references, functions, cfg, callFrames)', + returns: 'deeply frozen property/collection IR or null', + }, + parseOnly: true, + targetExecution: false, + wordCount: wordcode.wordCount, + instructionCount: wordcode.instructionCount, + functionCount: functions.functions.length, + opcodeScope: { + family: 'J-property-collection', + count: expectedNames.length, + names: expectedNames, + rows: rowData.rows, + }, + operations, + operationCount: operations.length, + functionCensus: makeFunctionCensus(operations, functions), + census: { + expectedInstructionCount: wordcode.instructions.filter(({ name }) => + J_DEFINITION_BY_NAME.has(name), + ).length, + operationCount: operations.length, + countsByName: rowData.counts, + operationKeys, + dropped: [], + duplicated: [], + complete: true, + }, + reachability: { + source: 'Packet E per-function CFG edges from each function entry', + unreachableOperationCount: operations.filter( + ({ reachable }) => !reachable, + ).length, + }, + predecessorSchemas: { + wordcode: WORDCODE_SCHEMA, + references: REFERENCE_SCHEMA, + functions: FUNCTION_SCHEMA, + cfg: CFG_SCHEMA, + callFrames: CALL_FRAME_SCHEMA, + }, + predecessorInputs: { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: REFERENCE_SCHEMA, + functionsSchema: FUNCTION_SCHEMA, + cfgSchema: CFG_SCHEMA, + callFramesSchema: CALL_FRAME_SCHEMA, + }, + boundaryOmissions: EXCLUDED_OPCODE_GROUPS.map(({ boundary, names }) => ({ + boundary, + names: [...names], + })), + proof: { + allInstructionsRevalidated: true, + allFunctionOwnershipRevalidated: true, + allCfgEdgesAndBlocksRevalidated: true, + allCallFramesRevalidated: true, + allPropertyRowsPresent: operationKeys.length === operations.length, + allPropertyRowsUnique: + new Set(operationKeys).size === operationKeys.length, + historicalForInNextWordOperandsPreserved: operations + .filter(({ name }) => name === 'FOR_IN_NEXT') + .every( + ({ wordOperands }) => + wordOperands[1]?.kind === 'label-target' && + wordOperands[2]?.kind === 'register', + ), + noTargetCodeExecuted: true, + }, + inputs: { + wordCount: wordcode.wordCount, + instructionCount: wordcode.instructionCount, + constantPoolSize: references.constants.poolSize, + functionCount: functions.functions.length, + cfgEdgeCount: cfg.edges.length, + cfgBlockCount: cfg.functions.reduce( + (count, fn) => count + fn.blocks.length, + 0, + ), + callSiteCount: callFrames.callSites.length, + returnCount: callFrames.returns.length, + throwCount: callFrames.throws.length, + }, + }) +} + +function recognize(wordcode, references, functions, cfg, callFrames) { + const preflighted = preflight( + wordcode, + references, + functions, + cfg, + callFrames, + ) + const operations = buildOperations( + preflighted.wordcode, + preflighted.functions, + preflighted.cfg, + ) + return makeResult( + preflighted.wordcode, + preflighted.references, + preflighted.functions, + preflighted.cfg, + preflighted.callFrames, + operations, + ) +} + +export function diagnosePropertyCollections( + wordcode, + references, + functions, + cfg, + callFrames, +) { + try { + const result = recognize(wordcode, references, functions, cfg, callFrames) + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof PropertyCollectionsDecline) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + } + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'property-collections-analysis-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function analyzePropertyCollections( + wordcode, + references, + functions, + cfg, + callFrames, +) { + return diagnosePropertyCollections( + wordcode, + references, + functions, + cfg, + callFrames, + ).result +} + +export const analyzePropertyCollectionSemantics = analyzePropertyCollections +export default analyzePropertyCollections diff --git a/src/vm/jsconfuser-vm/analyze-scalar-values.js b/src/vm/jsconfuser-vm/analyze-scalar-values.js new file mode 100644 index 00000000..40b73ce7 --- /dev/null +++ b/src/vm/jsconfuser-vm/analyze-scalar-values.js @@ -0,0 +1,601 @@ +import { buildControlFlow } from './build-cfg.js' +import { partitionFunctions } from './partition-functions.js' +import { readWordcode } from './read-wordcode.js' +import { validateReferences } from './validate-references.js' +import { + CFG_SCHEMA, + EXCLUDED_OPCODE_GROUPS, + EXCLUDED_OPCODE_NAMES, + FUNCTION_SCHEMA, + I_DEFINITION_BY_NAME, + I_OPCODE_DEFINITIONS, + REFERENCE_SCHEMA, + SCALAR_VALUES_SCHEMA, + ScalarValuesDecline, + WORDCODE_SCHEMA, + canonicalContainer, + decline, + deepFreeze, + isUint32, + requireObject, + requirePreflightShape, + sameArray, + snapshot, +} from './scalar-values-preflight.js' + +function preflight(wordcode, references, functions, cfg) { + requirePreflightShape(wordcode, references, functions, cfg) + const container = canonicalContainer(wordcode, references) + const parsedWordcode = readWordcode(container) + if (!parsedWordcode) { + decline( + 'stale-predecessor', + 'Packet B cannot be reconstructed from its numeric word stream', + ) + } + if ( + snapshot(wordcode, 'Packet B') !== + snapshot(parsedWordcode, 'parsed Packet B') + ) { + decline( + 'stale-predecessor', + 'Packet B is not the canonical parsed wordcode result', + ) + } + + const parsedReferences = validateReferences(container, parsedWordcode) + if (!parsedReferences) { + decline( + 'stale-predecessor', + 'Packet C cannot be reconstructed from Packet B and its constant pool', + ) + } + if ( + snapshot(references, 'Packet C') !== + snapshot(parsedReferences, 'parsed Packet C') + ) { + decline( + 'stale-predecessor', + 'Packet C is not the canonical reference/frame result for Packet B', + ) + } + + const parsedFunctions = partitionFunctions( + container, + parsedWordcode, + parsedReferences, + ) + if (!parsedFunctions) { + decline( + 'stale-predecessor', + 'Packet D cannot be reconstructed from the accepted B/C results', + ) + } + if ( + snapshot(functions, 'Packet D') !== + snapshot(parsedFunctions, 'parsed Packet D') + ) { + decline( + 'stale-predecessor', + 'Packet D is not the canonical function ownership result', + ) + } + + const parsedCfg = buildControlFlow( + parsedWordcode, + parsedReferences, + parsedFunctions, + ) + if (!parsedCfg) { + decline( + 'stale-predecessor', + 'Packet E cannot be reconstructed from the accepted B/C/D results', + ) + } + if (snapshot(cfg, 'Packet E') !== snapshot(parsedCfg, 'parsed Packet E')) { + decline('stale-predecessor', 'Packet E is not the canonical CFG result') + } + return { + wordcode: parsedWordcode, + references: parsedReferences, + functions: parsedFunctions, + cfg: parsedCfg, + } +} + +function register(index, location) { + if (!isUint32(index)) + decline('invalid-register', `${location} is not a register`) + return { kind: 'register', index } +} + +function copyOperand(operand, location) { + requireObject(operand, 'malformed-operation', `${location} is not an operand`) + if (operand.kind === 'register') return register(operand.index, location) + if (operand.kind === 'constant-ref') { + if (!isUint32(operand.index) || !isUint32(operand.concealKey)) { + decline('malformed-operation', `${location} is not a constant reference`) + } + return { + kind: 'constant-ref', + index: operand.index, + concealKey: operand.concealKey, + } + } + if (operand.kind === 'immediate') { + if (!isUint32(operand.value)) + decline('malformed-operation', `${location} is not an immediate`) + return { kind: 'immediate', value: operand.value } + } + decline( + 'unsupported-value-form', + `${location} has unsupported operand form ${String(operand.kind)}`, + ) +} + +function reachablePcs(cfg) { + const reachable = new Map() + for (const fn of cfg.functions) { + const edgesBySource = new Map() + for (const edge of fn.edges) { + const edges = edgesBySource.get(edge.sourcePc) ?? [] + edges.push(edge) + edgesBySource.set(edge.sourcePc, edges) + } + const seen = new Set([fn.startPc]) + const queue = [fn.startPc] + while (queue.length) { + const pc = queue.shift() + for (const edge of edgesBySource.get(pc) ?? []) { + if (edge.targetPc !== null && !seen.has(edge.targetPc)) { + seen.add(edge.targetPc) + queue.push(edge.targetPc) + } + } + } + for (const pc of seen) reachable.set(`${fn.id}:${pc}`, true) + } + return reachable +} + +function ownerByPc(functions) { + const owners = new Map() + for (const fn of functions.functions) { + for (const pc of fn.instructionPcs) { + const key = String(pc) + if (owners.has(key)) + decline( + 'invalid-function-ownership', + `Instruction ${pc} has duplicate owners`, + ) + owners.set(key, fn.id) + } + } + return owners +} + +function constantReference(instruction, references, operandIndex) { + const ref = references.constants.references.find( + ({ pc, instruction: name, operand }) => + pc === instruction.pc && + name === instruction.name && + operand === operandIndex, + ) + if (!ref) { + decline( + 'missing-constant-reference', + `${instruction.name}@${instruction.pc} has no Packet C constant reference`, + ) + } + if ( + !isUint32(ref.index) || + !isUint32(ref.concealKey) || + ref.concealKey !== 0 || + ref.index >= references.constants.values.length + ) { + decline( + 'invalid-constant-reference', + `${instruction.name}@${instruction.pc} has an invalid constant reference`, + ) + } + return { + kind: 'constant', + index: ref.index, + concealKey: ref.concealKey, + value: references.constants.values[ref.index], + } +} + +function operationShape(instruction, definition) { + if (!Array.isArray(instruction.operands)) + decline( + 'malformed-operation', + `${instruction.name}@${instruction.pc} has no operands`, + ) + const kinds = instruction.operands.map((operand) => operand?.kind) + if (!sameArray(kinds, definition.operandKinds)) { + decline( + 'unsupported-value-form', + `${instruction.name}@${instruction.pc} has an unsupported operand form`, + ) + } + const operands = instruction.operands.map((operand, index) => + copyOperand( + operand, + `${instruction.name}@${instruction.pc} operand ${index}`, + ), + ) + const destination = + definition.name === 'STORE_GLOBAL' + ? null + : register( + instruction.words[1], + `${instruction.name}@${instruction.pc} destination`, + ) + const inputIndexes = + definition.name === 'LOAD_CONST' || + definition.name === 'LOAD_INT' || + definition.name === 'LOAD_GLOBAL' + ? [] + : definition.name === 'STORE_GLOBAL' + ? [1] + : definition.operandKinds.length === 3 + ? [1, 2] + : definition.name === 'MOVE' || + definition.family === 'unary' || + definition.name === 'TYPEOF' || + definition.name === 'VOID' + ? [1] + : [] + const inputs = inputIndexes.map((index) => + register( + instruction.operands[index].index, + `${instruction.name}@${instruction.pc} input ${index}`, + ), + ) + return { operands, destination, inputs } +} + +function valueSemantics(definition, operation, constant, immediate) { + if (definition.name === 'LOAD_CONST') { + return { + kind: 'known-constant', + operation: definition.operation, + resultType: 'constant', + known: true, + value: constant.value, + } + } + if (definition.name === 'LOAD_INT') { + return { + kind: 'known-integer', + operation: definition.operation, + resultType: 'integer', + known: true, + value: immediate, + } + } + if (definition.name === 'MOVE') { + return { + kind: 'register-copy', + operation: definition.operation, + resultType: 'copy', + known: false, + sourceRegister: operation.inputs[0], + } + } + if (definition.name === 'STORE_GLOBAL') { + return { + kind: 'input-value', + operation: definition.operation, + resultType: 'no-register-result', + known: false, + sourceRegister: operation.inputs[0], + } + } + if (definition.name === 'VOID') { + return { + kind: 'known-undefined', + operation: definition.operation, + resultType: 'undefined', + known: true, + } + } + return { + kind: 'dynamic-operation', + operation: definition.operation, + resultType: definition.resultType, + known: false, + } +} + +function makeOperation( + instruction, + definition, + functionId, + reachable, + references, +) { + const operation = operationShape(instruction, definition) + const constantOperandIndex = + definition.name === 'STORE_GLOBAL' + ? 0 + : definition.name === 'LOAD_CONST' || + definition.name === 'LOAD_GLOBAL' || + definition.name === 'TYPEOF_SAFE' + ? 1 + : null + const constant = + constantOperandIndex === null + ? null + : constantReference(instruction, references, constantOperandIndex) + const immediate = + definition.name === 'LOAD_INT' ? instruction.operands[1].value : null + const globalReference = + definition.name === 'LOAD_GLOBAL' || + definition.name === 'STORE_GLOBAL' || + definition.name === 'TYPEOF_SAFE' + ? { + kind: 'global', + name: constant.value, + constant: { ...constant }, + } + : null + const globalReads = + definition.effect === 'global-read' ? [globalReference] : [] + const globalWrites = + definition.effect === 'global-write' ? [globalReference] : [] + return { + id: `scalar:${functionId}:${instruction.pc}`, + functionId, + pc: instruction.pc, + reachable, + opcode: { name: definition.name, value: definition.opcode }, + name: definition.name, + family: definition.family, + operands: operation.operands, + words: instruction.words.slice(), + width: instruction.width, + nextPc: instruction.nextPc, + destination: operation.destination, + inputs: operation.inputs, + constantReference: constant, + globalReference, + immediate: immediate === null ? null : { kind: 'uint32', value: immediate }, + useDef: { + uses: operation.inputs.map((input) => ({ ...input })), + defines: operation.destination ? { ...operation.destination } : null, + globalReads: globalReads.map((reference) => ({ ...reference })), + globalWrites: globalWrites.map((reference) => ({ ...reference })), + }, + effect: { + kind: definition.effect, + register: operation.destination ? { ...operation.destination } : null, + global: globalReference ? { ...globalReference } : null, + }, + valueSemantics: valueSemantics(definition, operation, constant, immediate), + } +} + +function buildOperations(wordcode, references, functions, cfg) { + const owners = ownerByPc(functions) + const reachable = reachablePcs(cfg) + const operations = [] + const seen = new Set() + for (const instruction of wordcode.instructions) { + const definition = I_DEFINITION_BY_NAME.get(instruction.name) + if (!definition) { + if (!EXCLUDED_OPCODE_NAMES.has(instruction.name)) { + decline( + 'unsupported-opcode', + `${instruction.name}@${instruction.pc} is outside the known pinned opcode map`, + ) + } + continue + } + const functionId = owners.get(String(instruction.pc)) + if (!Number.isInteger(functionId)) { + decline( + 'invalid-function-ownership', + `${instruction.name}@${instruction.pc} has no function owner`, + ) + } + const key = `${functionId}:${instruction.pc}` + if (seen.has(key)) + decline('duplicate-operation', `Duplicate scalar operation ${key}`) + seen.add(key) + operations.push( + makeOperation( + instruction, + definition, + functionId, + reachable.has(key), + references, + ), + ) + } + const expected = wordcode.instructions.filter(({ name }) => + I_DEFINITION_BY_NAME.has(name), + ) + if (operations.length !== expected.length || seen.size !== expected.length) { + decline( + 'incomplete-scalar-census', + `Scalar operation census has ${operations.length} records for ${expected.length} I instructions`, + ) + } + return operations +} + +function makeRows(operations) { + const counts = Object.fromEntries( + I_OPCODE_DEFINITIONS.map(({ name }) => [name, 0]), + ) + for (const operation of operations) counts[operation.name] += 1 + const rows = I_OPCODE_DEFINITIONS.map((definition) => ({ + name: definition.name, + opcode: definition.opcode, + family: definition.family, + operandForm: definition.operandForm, + operation: definition.operation, + effect: definition.effect, + resultType: definition.resultType, + known: definition.known, + count: counts[definition.name], + })) + if (rows.reduce((sum, row) => sum + row.count, 0) !== operations.length) { + decline( + 'incomplete-scalar-census', + 'Scalar opcode row counts do not sum to operations', + ) + } + return { counts, rows } +} + +function makeFunctionCensus(operations, functions) { + return functions.functions.map((fn) => { + const rows = operations.filter(({ functionId }) => functionId === fn.id) + return { + functionId: fn.id, + startPc: fn.startPc, + endPc: fn.endPc, + instructionCount: fn.instructionCount, + scalarOperationCount: rows.length, + reachableScalarOperationCount: rows.filter(({ reachable }) => reachable) + .length, + unreachableScalarOperationCount: rows.filter( + ({ reachable }) => !reachable, + ).length, + } + }) +} + +function makeResult(wordcode, references, functions, cfg, operations) { + const rowData = makeRows(operations) + const expectedNames = I_OPCODE_DEFINITIONS.map(({ name }) => name) + const operationKeys = operations.map( + ({ functionId, pc }) => `${functionId}:${pc}`, + ) + return deepFreeze({ + schemaVersion: SCALAR_VALUES_SCHEMA, + encoding: 'numeric-u32', + api: { + analyze: 'analyzeScalarValues(wordcode, references, functions, cfg)', + diagnose: 'diagnoseScalarValues(wordcode, references, functions, cfg)', + returns: 'deeply frozen scalar/value IR or null', + }, + parseOnly: true, + targetExecution: false, + wordCount: wordcode.wordCount, + instructionCount: wordcode.instructionCount, + functionCount: functions.functions.length, + opcodeScope: { + family: 'I-scalar-value', + count: expectedNames.length, + names: expectedNames, + rows: rowData.rows, + }, + operations, + operationCount: operations.length, + functionCensus: makeFunctionCensus(operations, functions), + census: { + expectedInstructionCount: wordcode.instructions.filter(({ name }) => + I_DEFINITION_BY_NAME.has(name), + ).length, + operationCount: operations.length, + countsByName: rowData.counts, + operationKeys, + dropped: [], + duplicated: [], + complete: true, + }, + reachability: { + source: 'Packet E per-function CFG edges from each function entry', + unreachableOperationCount: operations.filter( + ({ reachable }) => !reachable, + ).length, + }, + valueState: { + dataflow: 'not-introduced', + mergeAgreement: 'not-applicable', + limitation: + 'I records preserve exact use/def facts and typed operation semantics; they do not propagate register values across CFG merges.', + }, + predecessorSchemas: { + wordcode: WORDCODE_SCHEMA, + references: REFERENCE_SCHEMA, + functions: FUNCTION_SCHEMA, + cfg: CFG_SCHEMA, + }, + boundaryOmissions: EXCLUDED_OPCODE_GROUPS.map(({ boundary, names }) => ({ + boundary, + names: [...names], + })), + proof: { + allInstructionsRevalidated: true, + allFunctionOwnershipRevalidated: true, + allCfgEdgesAndBlocksRevalidated: true, + allScalarRowsPresent: operationKeys.length === operations.length, + allScalarRowsUnique: new Set(operationKeys).size === operationKeys.length, + noTargetCodeExecuted: true, + }, + inputs: { + wordCount: wordcode.wordCount, + instructionCount: wordcode.instructionCount, + constantPoolSize: references.constants.poolSize, + functionCount: functions.functions.length, + cfgEdgeCount: cfg.edges.length, + cfgBlockCount: cfg.functions.reduce( + (count, fn) => count + fn.blocks.length, + 0, + ), + }, + }) +} + +function recognize(wordcode, references, functions, cfg) { + const preflighted = preflight(wordcode, references, functions, cfg) + const operations = buildOperations( + preflighted.wordcode, + preflighted.references, + preflighted.functions, + preflighted.cfg, + ) + return makeResult( + preflighted.wordcode, + preflighted.references, + preflighted.functions, + preflighted.cfg, + operations, + ) +} + +export function diagnoseScalarValues(wordcode, references, functions, cfg) { + try { + const result = recognize(wordcode, references, functions, cfg) + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof ScalarValuesDecline) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + } + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'scalar-values-analysis-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function analyzeScalarValues(wordcode, references, functions, cfg) { + return diagnoseScalarValues(wordcode, references, functions, cfg).result +} + +export const analyzeScalarValueSemantics = analyzeScalarValues + +export default analyzeScalarValues diff --git a/src/vm/jsconfuser-vm/build-call-frames.js b/src/vm/jsconfuser-vm/build-call-frames.js new file mode 100644 index 00000000..f608abe0 --- /dev/null +++ b/src/vm/jsconfuser-vm/build-call-frames.js @@ -0,0 +1,685 @@ +import { + CALL_NAMES, + CFG_SCHEMA, + CallFrameDecline, + EDGE_KINDS, + FUNCTION_SCHEMA, + REFERENCE_SCHEMA, + REGISTER_DESTINATION_NAMES, + WORDCODE_SCHEMA, + cloneData, + decline, + deepFreeze, + isObject, + isUint, + register, + requireObject, + sameArray, + validateFunctions, + validateReferences, + validateWordcode, +} from './call-frames-preflight.js' + +function validateCfg(cfg, wordcodeData, functionsData, referencesData) { + requireObject(cfg, 'invalid-input', 'Expected a Packet E control-flow result') + if ( + cfg.schemaVersion !== CFG_SCHEMA || + cfg.encoding !== 'numeric-u32' || + cfg.wordCount !== wordcodeData.words.length || + cfg.instructionCount !== wordcodeData.instructions.length || + cfg.functionCount !== functionsData.functions.length || + !Array.isArray(cfg.functions) || + cfg.functions.length !== functionsData.functions.length || + !Array.isArray(cfg.edges) || + !Array.isArray(cfg.callSites) + ) { + decline('invalid-cfg', 'Input is not a complete Packet E CFG result') + } + if ( + cfg.source?.wordcodeSchema !== WORDCODE_SCHEMA || + cfg.source?.referencesSchema !== REFERENCE_SCHEMA || + cfg.source?.functionsSchema !== FUNCTION_SCHEMA || + !sameArray( + cfg.ownership?.map(({ pc, functionId }) => `${pc}:${functionId}`), + [...functionsData.ownerByPc.entries()].map( + ([pc, functionId]) => `${pc}:${functionId}`, + ), + ) + ) { + decline('input-mismatch', 'Packet E source or ownership metadata is stale') + } + + const cfgFunctionById = new Map() + for (const input of cfg.functions) { + requireObject(input, 'invalid-cfg', 'Packet E has a malformed function CFG') + const fn = functionsData.functionById.get(input.id) + if ( + !fn || + input.startPc !== fn.startPc || + input.endPc !== fn.endPc || + input.instructionCount !== fn.instructionPcs.length || + !sameArray(input.instructionPcs, fn.instructionPcs) || + !Array.isArray(input.edges) || + !Array.isArray(input.blocks) || + !Array.isArray(input.callSites) + ) { + decline( + 'input-mismatch', + `Packet E function ${String(input.id)} is stale`, + ) + } + if (cfgFunctionById.has(input.id)) { + decline('invalid-cfg', `Packet E repeats function ${input.id}`) + } + cfgFunctionById.set(input.id, input) + } + if (cfgFunctionById.size !== functionsData.functions.length) { + decline('invalid-cfg', 'Packet E does not describe every function') + } + + const edges = [] + for (const edge of cfg.edges) { + requireObject(edge, 'invalid-cfg', 'Packet E has a malformed edge') + const fn = functionsData.functionById.get(edge.functionId) + if ( + !fn || + !isUint(edge.sourcePc) || + functionsData.ownerByPc.get(edge.sourcePc) !== fn.id || + !EDGE_KINDS.has(edge.kind) || + (edge.targetPc !== null && + (!isUint(edge.targetPc) || + functionsData.ownerByPc.get(edge.targetPc) !== fn.id)) || + (edge.callSitePc !== null && + (!isUint(edge.callSitePc) || + functionsData.ownerByPc.get(edge.callSitePc) !== fn.id)) + ) { + decline('invalid-cfg', 'Packet E contains an out-of-owner edge') + } + edges.push(edge) + } + + const callsByPc = new Map() + const expectedCalls = [] + for (const fn of functionsData.functions) { + const expected = fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter((instruction) => CALL_NAMES.has(instruction.name)) + expectedCalls.push(...expected.map((instruction) => ({ fn, instruction }))) + } + for (const callSite of cfg.callSites) { + requireObject( + callSite, + 'malformed-call-site', + 'Packet E has a malformed call site', + ) + const instruction = wordcodeData.byPc.get(callSite.pc) + const fnId = functionsData.ownerByPc.get(callSite.pc) + if ( + !instruction || + !CALL_NAMES.has(instruction.name) || + callSite.kind !== instruction.name || + callsByPc.has(callSite.pc) + ) { + decline( + 'input-mismatch', + `Packet E call site at ${String(callSite.pc)} is stale`, + ) + } + const parsed = instruction.parsedCall + if ( + !isObject(callSite.destination) || + callSite.destination.kind !== 'register' || + callSite.destination.index !== parsed.destination.index || + !isObject(callSite.callee) || + callSite.callee.kind !== 'register' || + callSite.callee.index !== parsed.callee.index || + (parsed.receiver + ? callSite.receiver?.kind !== 'register' || + callSite.receiver.index !== parsed.receiver.index + : callSite.receiver !== null) || + callSite.continuationPc !== instruction.nextPc || + callSite.returnDestination?.kind !== 'register' || + callSite.returnDestination.index !== parsed.destination.index + ) { + decline( + 'input-mismatch', + `Packet E call site at ${callSite.pc} disagrees with Packet B`, + ) + } + if ( + !functionsData.ownerByPc.has(callSite.continuationPc) || + functionsData.ownerByPc.get(callSite.continuationPc) !== fnId + ) { + decline( + 'missing-call-continuation', + `Call@${callSite.pc} has no local continuation`, + ) + } + if (!isObject(callSite.exceptionalCompletion)) { + decline( + 'missing-call-completion', + `Call@${callSite.pc} has no exceptional route`, + ) + } + callsByPc.set(callSite.pc, { ...callSite, functionId: fnId }) + } + if (callsByPc.size !== expectedCalls.length) { + decline('invalid-cfg', 'Packet E call-site census is incomplete') + } + for (const { fn, instruction } of expectedCalls) { + const call = callsByPc.get(instruction.pc) + if (!call || call.functionId !== fn.id) { + decline('input-mismatch', `Packet E is missing call@${instruction.pc}`) + } + const sourceEdges = edges.filter( + ({ sourcePc }) => sourcePc === instruction.pc, + ) + if ( + !sourceEdges.some( + (edge) => + edge.kind === 'call' && + edge.callSitePc === instruction.pc && + edge.route === 'normal' && + edge.targetPc === instruction.nextPc, + ) || + !sourceEdges.some( + (edge) => edge.callSitePc === instruction.pc && edge.route !== 'normal', + ) + ) { + decline( + 'missing-call-completion', + `Call@${instruction.pc} lacks complete CFG routes`, + ) + } + } + return { edges, callsByPc, cfgFunctionById, referencesData } +} + +function unknownCell() { + return { unknown: true, targets: new Set() } +} + +function knownClosureCell(functionId) { + return { unknown: false, targets: new Set([functionId]) } +} + +function cloneCell(cell) { + return { unknown: cell.unknown, targets: new Set(cell.targets) } +} + +function cloneState(state) { + return state.map(cloneCell) +} + +function statesEqual(left, right) { + if (left.length !== right.length) return false + return left.every((cell, index) => { + const other = right[index] + if ( + !other || + cell.unknown !== other.unknown || + cell.targets.size !== other.targets.size + ) { + return false + } + for (const target of cell.targets) + if (!other.targets.has(target)) return false + return true + }) +} + +function mergeCell(left, right) { + const targets = new Set([...left.targets, ...right.targets]) + const knownKindsDisagree = + (left.targets.size === 0 && right.targets.size > 0) || + (right.targets.size === 0 && left.targets.size > 0) + return { + unknown: left.unknown || right.unknown || knownKindsDisagree, + targets, + } +} + +function mergeState(left, right) { + return left.map((cell, index) => mergeCell(cell, right[index])) +} + +function initialState(regCount) { + return Array.from({ length: regCount }, () => unknownCell()) +} + +function destinationRegister(instruction) { + if (!REGISTER_DESTINATION_NAMES.has(instruction.name)) return null + return instruction.words[1] +} + +function callResultCell(state, instruction, returnSummaries) { + if (instruction.name === 'NEW') return unknownCell() + const calleeIndex = instruction.name === 'CALL_METHOD' ? 3 : 2 + const callee = state[calleeIndex] ?? unknownCell() + if (callee.unknown || callee.targets.size === 0) return unknownCell() + const targets = new Set() + for (const functionId of callee.targets) { + const summary = returnSummaries.get(functionId) + if (!summary) return unknownCell() + for (const target of summary.targets) targets.add(target) + if (!summary.known) return { unknown: true, targets } + } + return { unknown: false, targets } +} + +function transferState(state, instruction, closureSitesByPc, returnSummaries) { + const next = cloneState(state) + if (instruction.name === 'MAKE_CLOSURE') { + const site = closureSitesByPc.get(instruction.pc) + if (!site) + decline( + 'invalid-function-boundary', + `MAKE_CLOSURE@${instruction.pc} has no child function`, + ) + next[instruction.words[1]] = knownClosureCell(site.childFunctionId) + return next + } + if (instruction.name === 'MOVE') { + const destination = instruction.words[1] + const source = instruction.words[2] + next[destination] = cloneCell(state[source] ?? unknownCell()) + return next + } + if (CALL_NAMES.has(instruction.name)) { + next[instruction.words[1]] = callResultCell( + state, + instruction, + returnSummaries, + ) + return next + } + const destination = destinationRegister(instruction) + if (destination !== null) next[destination] = unknownCell() + return next +} + +function isExceptionalCallEdge(edge, sourceInstruction) { + return ( + CALL_NAMES.has(sourceInstruction.name) && + edge.callSitePc === sourceInstruction.pc && + edge.route !== 'normal' + ) +} + +function solveClosureProvenance( + fn, + cfgEdges, + wordcodeData, + closureSitesByPc, + returnSummaries, +) { + const inStates = new Map([[fn.startPc, initialState(fn.regCount)]]) + const queue = [fn.startPc] + const edgesBySource = new Map() + for (const edge of cfgEdges) { + const list = edgesBySource.get(edge.sourcePc) ?? [] + list.push(edge) + edgesBySource.set(edge.sourcePc, list) + } + while (queue.length > 0) { + const pc = queue.shift() + const instruction = wordcodeData.byPc.get(pc) + const input = inStates.get(pc) + if (!instruction || !input) { + decline( + 'invalid-cfg', + `Function ${fn.id} has no provenance node at ${pc}`, + ) + } + const output = transferState( + input, + instruction, + closureSitesByPc, + returnSummaries, + ) + for (const edge of edgesBySource.get(pc) ?? []) { + if (edge.targetPc === null) continue + const edgeState = isExceptionalCallEdge(edge, instruction) + ? input + : output + const prior = inStates.get(edge.targetPc) + const merged = prior + ? mergeState(prior, edgeState) + : cloneState(edgeState) + if (!prior || !statesEqual(prior, merged)) { + inStates.set(edge.targetPc, merged) + queue.push(edge.targetPc) + } + } + } + return inStates +} + +function summariesEqual(left, right) { + if (left.known !== right.known || left.targets.size !== right.targets.size) { + return false + } + for (const target of left.targets) + if (!right.targets.has(target)) return false + return true +} + +function summaryForFunction(fn, provenance, wordcodeData) { + const targets = new Set() + let sawReachableReturn = false + let known = true + for (const pc of fn.instructionPcs) { + const instruction = wordcodeData.byPc.get(pc) + if (instruction.name !== 'RETURN' || !provenance.has(pc)) continue + sawReachableReturn = true + const cell = provenance.get(pc)[instruction.words[1]] + if (!cell || cell.unknown) { + known = false + continue + } + for (const target of cell.targets) targets.add(target) + } + return { + known: sawReachableReturn && known, + targets, + } +} + +function solveAllClosureProvenance(functionsData, cfgData, wordcodeData) { + let returnSummaries = new Map( + functionsData.functions.map((fn) => [ + fn.id, + { known: false, targets: new Set() }, + ]), + ) + let solved = new Map() + for ( + let iteration = 0; + iteration <= functionsData.functions.length + 2; + iteration += 1 + ) { + const nextSolved = new Map() + const nextSummaries = new Map() + for (const fn of functionsData.functions) { + const edges = cfgData.edges.filter((edge) => edge.functionId === fn.id) + const provenance = solveClosureProvenance( + fn, + edges, + wordcodeData, + functionsData.closureSitesByPc, + returnSummaries, + ) + nextSolved.set(fn.id, provenance) + nextSummaries.set(fn.id, summaryForFunction(fn, provenance, wordcodeData)) + } + const stable = functionsData.functions.every((fn) => + summariesEqual(returnSummaries.get(fn.id), nextSummaries.get(fn.id)), + ) + solved = nextSolved + returnSummaries = nextSummaries + if (stable) break + } + return { provenance: solved, returnSummaries } +} + +function frameRecord(fn, referencesData) { + const headerSize = referencesData.frame.headerSize + const frameSize = headerSize + fn.regCount + return { + functionId: fn.id, + kind: fn.kind, + frameBase: fn.id === 0 ? referencesData.frame.frameStart : null, + frameSize, + registerBaseOffset: headerSize, + registerWindow: { + start: headerSize, + end: headerSize + fn.regCount, + }, + regCount: fn.regCount, + paramCount: fn.paramCount, + hasRest: fn.hasRest, + } +} + +function completionRoute(edge) { + return { + kind: edge.kind, + sourcePc: edge.sourcePc, + targetPc: edge.targetPc, + route: edge.route, + mode: edge.mode, + callSitePc: edge.callSitePc, + continuationPc: edge.continuationPc, + payload: cloneData(edge.payload), + handlerStateBefore: cloneData(edge.handlerStateBefore), + handlerStateAfter: cloneData(edge.handlerStateAfter), + } +} + +function buildCallSite(fn, instruction, cfgData, provenance) { + const parsed = instruction.parsedCall + const state = provenance.get(instruction.pc) + const calleeCell = state?.[parsed.callee.index] + const calleeFunctions = + calleeCell && !calleeCell.unknown + ? [...calleeCell.targets].sort((left, right) => left - right) + : [] + for (const functionId of calleeFunctions) { + if (!Number.isInteger(functionId)) { + decline( + 'invalid-closure-provenance', + `Call@${instruction.pc} has an invalid callee function`, + ) + } + } + const cfgCall = cfgData.callsByPc.get(instruction.pc) + if (!cfgCall) + decline( + 'missing-call-completion', + `Call@${instruction.pc} has no Packet E call site`, + ) + const exceptionalEdge = cfgData.edges.find( + (edge) => + edge.sourcePc === instruction.pc && + edge.callSitePc === instruction.pc && + edge.route !== 'normal', + ) + if (!exceptionalEdge) { + decline( + 'missing-call-completion', + `Call@${instruction.pc} has no exceptional completion`, + ) + } + return { + pc: instruction.pc, + functionId: fn.id, + kind: parsed.kind, + destination: cloneData(parsed.destination), + callee: cloneData(parsed.callee), + calleeFunctions, + receiver: cloneData(parsed.receiver), + constructorState: { + isConstructor: parsed.kind === 'NEW', + mode: parsed.kind === 'NEW' ? 'allocate-this' : 'ordinary', + }, + arguments: cloneData(parsed.arguments), + continuationPc: parsed.continuationPc, + returnDestination: cloneData(parsed.destination), + exceptionalCompletion: { + kind: exceptionalEdge.kind, + targetPc: exceptionalEdge.targetPc, + route: exceptionalEdge.route, + mode: exceptionalEdge.mode, + callSitePc: exceptionalEdge.callSitePc, + payload: cloneData(exceptionalEdge.payload), + }, + cfg: { + normalTargetPc: parsed.continuationPc, + handlerStateBefore: cloneData(exceptionalEdge.handlerStateBefore), + handlerStateAfter: cloneData(exceptionalEdge.handlerStateAfter), + }, + } +} + +function makeCallerDestination(callSite) { + return { + callerFunctionId: callSite.functionId, + callSitePc: callSite.pc, + destination: cloneData(callSite.returnDestination), + continuationPc: callSite.continuationPc, + constructorState: cloneData(callSite.constructorState), + } +} + +function recognize(wordcode, references, functions, cfg) { + const wordcodeData = validateWordcode(wordcode) + const referencesData = validateReferences(references, wordcodeData) + const functionsData = validateFunctions( + functions, + wordcodeData, + referencesData, + ) + const cfgData = validateCfg(cfg, wordcodeData, functionsData, referencesData) + + const solved = solveAllClosureProvenance(functionsData, cfgData, wordcodeData) + const functionResults = [] + const allCallSites = [] + for (const fn of functionsData.functions) { + const fnCfgEdges = cfgData.edges.filter((edge) => edge.functionId === fn.id) + const provenance = solved.provenance.get(fn.id) + const callSites = fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter((instruction) => CALL_NAMES.has(instruction.name)) + .map((instruction) => buildCallSite(fn, instruction, cfgData, provenance)) + allCallSites.push(...callSites) + + const routes = fnCfgEdges + .filter( + (edge) => + edge.kind === 'return' || + edge.kind === 'throw' || + edge.kind === 'handler' || + edge.kind === 'finally' || + (edge.callSitePc !== null && edge.route !== 'normal'), + ) + .map(completionRoute) + const returns = fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter(({ name }) => name === 'RETURN') + .map((instruction) => ({ + pc: instruction.pc, + sourceRegister: register( + instruction.words[1], + `RETURN@${instruction.pc} source`, + ), + reachable: provenance.has(instruction.pc), + routes: routes.filter(({ sourcePc }) => sourcePc === instruction.pc), + })) + const throws = fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter(({ name }) => name === 'THROW') + .map((instruction) => ({ + pc: instruction.pc, + payloadRegister: register( + instruction.words[1], + `THROW@${instruction.pc} payload`, + ), + reachable: provenance.has(instruction.pc), + routes: routes.filter(({ sourcePc }) => sourcePc === instruction.pc), + })) + functionResults.push({ + id: fn.id, + kind: fn.kind, + startPc: fn.startPc, + endPc: fn.endPc, + parentFunctionId: fn.parentFunctionId, + regCount: fn.regCount, + paramCount: fn.paramCount, + captureCount: fn.captureCount, + hasRest: fn.hasRest, + frame: frameRecord(fn, referencesData), + callSites, + returns, + throws, + completionRoutes: routes, + }) + } + + const callerDestinationsByFunction = new Map() + for (const callSite of allCallSites) { + for (const functionId of callSite.calleeFunctions) { + const list = callerDestinationsByFunction.get(functionId) ?? [] + list.push(makeCallerDestination(callSite)) + callerDestinationsByFunction.set(functionId, list) + } + } + for (const fn of functionResults) { + const callers = callerDestinationsByFunction.get(fn.id) ?? [] + for (const record of fn.returns) + record.callerDestinations = cloneData(callers) + } + + const frames = functionResults.map(({ frame }) => cloneData(frame)) + const frame = { + frameStart: referencesData.frame.frameStart, + headerSize: referencesData.frame.headerSize, + slots: { ...referencesData.frame.slots }, + mainStartPc: referencesData.frame.mainStartPc, + mainRegCount: referencesData.frame.mainRegCount, + root: cloneData(referencesData.frame.root), + descriptors: cloneData(referencesData.frame.descriptors), + } + return deepFreeze({ + schemaVersion: 'jsconfuser-vm-call-frames.v1', + encoding: 'numeric-u32', + wordCount: wordcodeData.words.length, + instructionCount: wordcodeData.instructions.length, + functionCount: functionResults.length, + frame, + frames, + functions: functionResults, + callSites: allCallSites, + returns: functionResults.flatMap(({ returns: records }) => records), + throws: functionResults.flatMap(({ throws: records }) => records), + completionRoutes: functionResults.flatMap( + ({ completionRoutes: routes }) => routes, + ), + source: { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: REFERENCE_SCHEMA, + functionsSchema: FUNCTION_SCHEMA, + cfgSchema: CFG_SCHEMA, + }, + }) +} + +export function diagnoseCallFrames(wordcode, references, functions, cfg) { + try { + const result = recognize(wordcode, references, functions, cfg) + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof CallFrameDecline) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + } + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'call-frame-builder-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function buildCallFrames(wordcode, references, functions, cfg) { + return diagnoseCallFrames(wordcode, references, functions, cfg).result +} + +export const buildCallFrameModel = buildCallFrames + +export default buildCallFrames diff --git a/src/vm/jsconfuser-vm/build-cfg-edges.js b/src/vm/jsconfuser-vm/build-cfg-edges.js new file mode 100644 index 00000000..bbcc8dd1 --- /dev/null +++ b/src/vm/jsconfuser-vm/build-cfg-edges.js @@ -0,0 +1,530 @@ +import { + REGISTER_DESTINATIONS, + decline, + isUint32, + sameArray, +} from './build-cfg-wordcode.js' +import { registerIndices } from './build-cfg-preflight.js' + +export function destinationRegister(instruction) { + return REGISTER_DESTINATIONS.has(instruction.name) + ? instruction.words[1] + : null +} + +function labelTargetFor(instruction, operand, referencesData) { + const reference = referencesData.labelByLocation.get( + `${instruction.pc}:${operand}`, + ) + if (!reference) + decline( + 'invalid-label-reference', + `${instruction.name}@${instruction.pc} has no label reference`, + ) + return reference.target +} + +export function validateRegisterRanges(wordcodeData, functionsData) { + for (const fn of functionsData.functions) { + for (const pc of fn.instructionPcs) { + const instruction = wordcodeData.byPc.get(pc) + if (!instruction) + decline( + 'invalid-function-boundary', + `Function ${fn.id} references missing instruction ${pc}`, + ) + for (const register of registerIndices(instruction)) { + if (!isUint32(register) || register >= fn.regCount) { + decline( + 'register-range', + `Function ${fn.id} uses register ${register} outside its descriptor`, + ) + } + } + } + } +} + +export function makeHandlerRecords( + wordcodeData, + functionsData, + referencesData, +) { + const records = new Map() + for (const fn of functionsData.functions) { + for (const pc of fn.instructionPcs) { + const instruction = wordcodeData.byPc.get(pc) + if (instruction.name === 'TRY_SETUP') { + const handlerPc = labelTargetFor(instruction, 0, referencesData) + if (functionsData.ownerByPc.get(handlerPc) !== fn.id) { + decline( + 'invalid-handler-routing', + `TRY_SETUP@${pc} routes outside function ${fn.id}`, + ) + } + const exceptionReg = instruction.words[2] + records.set(`try@${pc}`, { + id: `try@${pc}`, + type: 'handler', + ownerFunction: fn.id, + setupPc: pc, + handlerPc, + exceptionReg, + }) + } else if (instruction.name === 'FINALLY_SETUP') { + const finallyPc = labelTargetFor(instruction, 0, referencesData) + const throwPadPc = labelTargetFor(instruction, 3, referencesData) + if ( + functionsData.ownerByPc.get(finallyPc) !== fn.id || + functionsData.ownerByPc.get(throwPadPc) !== fn.id + ) { + decline( + 'invalid-handler-routing', + `FINALLY_SETUP@${pc} routes outside function ${fn.id}`, + ) + } + records.set(`finally@${pc}`, { + id: `finally@${pc}`, + type: 'finally', + ownerFunction: fn.id, + setupPc: pc, + finallyPc, + continuationReg: instruction.words[2], + payloadReg: instruction.words[3], + throwPadPc, + }) + } + } + } + return records +} + +export function recordView(record) { + if (record.type === 'handler') { + return { + type: record.type, + setupPc: record.setupPc, + handlerPc: record.handlerPc, + exceptionReg: record.exceptionReg, + } + } + return { + type: record.type, + setupPc: record.setupPc, + finallyPc: record.finallyPc, + continuationReg: record.continuationReg, + payloadReg: record.payloadReg, + throwPadPc: record.throwPadPc, + } +} + +export function stateView(stack, records) { + return { + stack: stack.map((id) => recordView(records.get(id))), + finallyStack: stack + .filter((id) => records.get(id).type === 'finally') + .map((id) => recordView(records.get(id))), + } +} + +function sameStack(left, right) { + return sameArray(left, right) +} + +function emptyEdge(kind, sourcePc, targetPc, options = {}) { + return { + kind, + sourcePc, + targetPc, + condition: options.condition ?? null, + continuationPc: options.continuationPc ?? null, + payload: options.payload ?? null, + callSitePc: options.callSitePc ?? null, + route: options.route ?? 'direct', + mode: options.mode ?? 'direct', + extraWrites: options.extraWrites ?? [], + } +} + +function setupPayload(record, sourceRegister = null) { + if (record.type === 'handler') { + return { + throwRegister: sourceRegister, + exceptionRegister: record.exceptionReg, + } + } + return { + throwRegister: sourceRegister, + finallyPc: record.finallyPc, + continuationRegister: record.continuationReg, + payloadRegister: record.payloadReg, + throwPadPc: record.throwPadPc, + } +} + +function directEdge(instruction, target, records, options = {}) { + const finallyRecord = [...records.values()].find( + (record) => + record.type === 'finally' && + record.ownerFunction === options.functionId && + record.finallyPc === target, + ) + const kind = + finallyRecord && instruction.name === 'JUMP' + ? 'finally' + : (options.kind ?? 'branch') + return emptyEdge(kind, instruction.pc, target, { + ...options, + functionId: undefined, + route: kind === 'finally' ? 'normal' : (options.route ?? 'direct'), + payload: + kind === 'finally' + ? setupPayload(finallyRecord) + : (options.payload ?? null), + }) +} + +function routeAbrupt(instruction, stack, records, isCall = false) { + const sourceRegister = + instruction.name === 'THROW' || instruction.name === 'RETURN' + ? instruction.words[1] + : null + if (instruction.name === 'RETURN') { + let finallyIndex = -1 + for (let index = stack.length - 1; index >= 0; index -= 1) { + if (records.get(stack[index]).type === 'finally') { + finallyIndex = index + break + } + } + if (finallyIndex < 0) { + return { + edge: emptyEdge('return', instruction.pc, null, { + route: 'propagate', + payload: { returnRegister: sourceRegister }, + }), + after: [], + extraWrites: [], + } + } + const record = records.get(stack[finallyIndex]) + return { + edge: emptyEdge('finally', instruction.pc, record.finallyPc, { + route: 'exceptional', + payload: setupPayload(record, sourceRegister), + extraWrites: [ + { + register: record.continuationReg, + kind: 'unknown', + origin: `return@${instruction.pc}`, + }, + { + register: record.payloadReg, + kind: 'unknown', + origin: `return@${instruction.pc}`, + }, + ], + }), + after: stack.slice(0, finallyIndex), + extraWrites: [ + { + register: record.continuationReg, + kind: 'unknown', + origin: `return@${instruction.pc}`, + }, + { + register: record.payloadReg, + kind: 'unknown', + origin: `return@${instruction.pc}`, + }, + ], + } + } + + const top = stack.length ? records.get(stack[stack.length - 1]) : null + if (!top) { + return { + edge: emptyEdge('throw', instruction.pc, null, { + route: 'propagate', + callSitePc: isCall ? instruction.pc : null, + payload: isCall + ? { exceptionalCompletion: true } + : { throwRegister: sourceRegister }, + }), + after: [], + extraWrites: [], + } + } + if (top.type === 'handler') { + const extraWrites = [ + { + register: top.exceptionReg, + kind: 'unknown', + origin: `exception@${instruction.pc}`, + }, + ] + return { + edge: emptyEdge('handler', instruction.pc, top.handlerPc, { + route: 'exceptional', + callSitePc: isCall ? instruction.pc : null, + payload: setupPayload(top, sourceRegister), + extraWrites, + }), + after: stack.slice(0, -1), + extraWrites, + } + } + const extraWrites = [ + { + register: top.continuationReg, + kind: 'singleton', + value: top.throwPadPc, + origin: `exception@${instruction.pc}`, + }, + { + register: top.payloadReg, + kind: 'unknown', + origin: `exception@${instruction.pc}`, + }, + ] + return { + edge: emptyEdge('finally', instruction.pc, top.finallyPc, { + route: 'exceptional', + callSitePc: isCall ? instruction.pc : null, + payload: setupPayload(top, sourceRegister), + extraWrites, + }), + after: stack.slice(0, -1), + extraWrites, + } +} + +function outgoing( + fn, + instruction, + stack, + jumpTargets, + records, + functionsData, + referencesData, +) { + const next = instruction.nextPc < fn.endPc ? instruction.nextPc : null + const out = [] + const addFallthrough = (target = next, after = stack) => { + if (target !== null) { + out.push({ + edge: emptyEdge('fallthrough', instruction.pc, target), + after, + extraWrites: [], + }) + } else if (after.length) { + decline( + 'unbalanced-handler-stack', + `Function ${fn.id} ends with active handler state at ${instruction.pc}`, + ) + } + } + + if (instruction.name === 'TRY_SETUP') { + addFallthrough(next, [...stack, `try@${instruction.pc}`]) + } else if (instruction.name === 'FINALLY_SETUP') { + addFallthrough(next, [...stack, `finally@${instruction.pc}`]) + } else if (instruction.name === 'TRY_END') { + if (!stack.length) + decline( + 'handler-stack-underflow', + `TRY_END@${instruction.pc} has an empty handler stack`, + ) + addFallthrough(next, stack.slice(0, -1)) + } else if (instruction.name === 'JUMP') { + const target = labelTargetFor(instruction, 0, referencesData) + if (functionsData.ownerByPc.get(target) !== fn.id) + decline( + 'cross-function-target', + `Function ${fn.id}: JUMP@${instruction.pc} leaves its owner`, + ) + out.push({ + edge: directEdge(instruction, target, records, { functionId: fn.id }), + after: stack, + extraWrites: [], + }) + } else if ( + instruction.name === 'JUMP_IF_FALSE' || + instruction.name === 'JUMP_IF_TRUE' + ) { + const target = labelTargetFor(instruction, 1, referencesData) + if (functionsData.ownerByPc.get(target) !== fn.id) + decline( + 'cross-function-target', + `Function ${fn.id}: conditional@${instruction.pc} leaves its owner`, + ) + out.push({ + edge: emptyEdge('conditional', instruction.pc, target, { + condition: { + register: instruction.words[1], + truthy: instruction.name === 'JUMP_IF_TRUE', + }, + route: 'direct', + }), + after: stack, + extraWrites: [], + }) + addFallthrough(next, stack) + } else if (instruction.name === 'FOR_IN_NEXT') { + const target = labelTargetFor(instruction, 2, referencesData) + if (functionsData.ownerByPc.get(target) !== fn.id) + decline( + 'cross-function-target', + `Function ${fn.id}: FOR_IN_NEXT@${instruction.pc} leaves its owner`, + ) + out.push({ + edge: emptyEdge('branch', instruction.pc, target, { + condition: { + kind: 'iterator', + register: instruction.words[2], + available: false, + }, + }), + after: stack, + extraWrites: [], + }) + addFallthrough(next, stack) + } else if (instruction.name === 'JUMP_REG') { + for (const target of jumpTargets.get(instruction.pc) ?? []) { + if (functionsData.ownerByPc.get(target) !== fn.id) + decline( + 'cross-function-target', + `Function ${fn.id}: JUMP_REG@${instruction.pc} leaves its owner`, + ) + const finalizer = [...records.values()].find( + (record) => + record.type === 'finally' && + record.ownerFunction === fn.id && + target >= record.finallyPc && + target < record.throwPadPc && + instruction.pc >= record.finallyPc && + instruction.pc < record.throwPadPc, + ) + out.push({ + edge: emptyEdge('branch', instruction.pc, target, { + mode: 'indirect', + route: finalizer ? 'continuation' : 'direct', + payload: finalizer ? { continuationPc: target } : null, + }), + after: stack, + extraWrites: [], + }) + } + } else if ( + instruction.name === 'CALL' || + instruction.name === 'CALL_METHOD' || + instruction.name === 'NEW' + ) { + if (next === null) + decline( + 'missing-call-continuation', + `Function ${fn.id}: call@${instruction.pc} has no continuation boundary`, + ) + out.push({ + edge: emptyEdge('call', instruction.pc, next, { + continuationPc: next, + callSitePc: instruction.pc, + route: 'normal', + payload: { callSitePc: instruction.pc }, + }), + after: stack, + extraWrites: [], + }) + const exceptional = routeAbrupt(instruction, stack, records, true) + out.push(exceptional) + } else if (instruction.name === 'THROW') { + out.push(routeAbrupt(instruction, stack, records, false)) + } else if (instruction.name === 'RETURN') { + out.push(routeAbrupt(instruction, stack, records, false)) + } else { + addFallthrough(next, stack) + } + return out +} + +export function solveStacks( + fn, + jumpTargets, + records, + functionsData, + referencesData, + wordcodeData, +) { + const inStates = new Map([[fn.startPc, []]]) + const queue = [fn.startPc] + const edges = [] + const incoming = new Map() + const transitions = [] + const addState = (target, state, edge) => { + if (target === null) return + const key = `${edge.sourcePc}:${edge.kind}:${edge.targetPc}:${edge.callSitePc ?? ''}` + const incomingAtTarget = incoming.get(target) ?? new Set() + incomingAtTarget.add(key) + incoming.set(target, incomingAtTarget) + if (inStates.has(target)) { + if (!sameStack(inStates.get(target), state)) { + decline( + 'handler-stack-merge-disagreement', + `Function ${fn.id}: incompatible handler state at ${target}`, + ) + } + return + } + inStates.set(target, state) + queue.push(target) + } + + while (queue.length) { + const pc = queue.shift() + const instruction = wordcodeData.byPc.get(pc) + if (!instruction) + decline( + 'invalid-cfg-node', + `Function ${fn.id}: missing instruction at ${pc}`, + ) + const before = inStates.get(pc) + const generated = outgoing( + fn, + instruction, + before, + jumpTargets, + records, + functionsData, + referencesData, + ) + transitions.push({ + pc, + name: instruction.name, + handlerStateIn: stateView(before, records), + outgoing: generated.map(({ edge, after }) => ({ + kind: edge.kind, + targetPc: edge.targetPc, + handlerStateAfter: stateView(after, records), + })), + }) + for (const item of generated) { + const edge = { + ...item.edge, + handlerStateBefore: stateView(before, records), + handlerStateAfter: stateView(item.after, records), + } + edges.push(edge) + addState(edge.targetPc, item.after, edge) + } + } + + for (const record of records.values()) { + if (record.ownerFunction === fn.id && !inStates.has(record.setupPc)) { + decline( + 'unbalanced-handler-stack', + `Function ${fn.id}: setup ${record.setupPc} is unreachable`, + ) + } + } + return { inStates, edges, transitions, incoming } +} diff --git a/src/vm/jsconfuser-vm/build-cfg-preflight.js b/src/vm/jsconfuser-vm/build-cfg-preflight.js new file mode 100644 index 00000000..92a071a7 --- /dev/null +++ b/src/vm/jsconfuser-vm/build-cfg-preflight.js @@ -0,0 +1,606 @@ +import { + CANONICAL_SLOTS, + DIRECT_LABEL_OPERANDS, + FUNCTION_SCHEMA, + LABEL_ROLES, + REFERENCE_SCHEMA, + decline, + exactObjectValues, + isObject, + isSafeCount, + isUint32, + requireObject, + requireUint32, + sameArray, +} from './build-cfg-wordcode.js' + +export function validateReferences(references, wordcodeData) { + requireObject( + references, + 'invalid-input', + 'Expected a Packet C reference/frame result', + ) + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-references', + 'Input is not a Packet C reference/frame result', + ) + } + if ( + references.wordCount !== wordcodeData.words.length || + references.instructionCount !== wordcodeData.instructions.length + ) { + decline('input-mismatch', 'Packet C counts do not match Packet B wordcode') + } + + const labels = requireObject( + references.labels, + 'invalid-references', + 'Packet C result has no labels section', + ) + if (!sameArray(labels.boundaries, wordcodeData.boundaries)) { + decline( + 'input-mismatch', + 'Packet C instruction boundaries do not match Packet B', + ) + } + if (!Array.isArray(labels.references)) { + decline( + 'invalid-label-reference', + 'Packet C label references are not an array', + ) + } + const labelByLocation = new Map() + for (const reference of labels.references) { + requireObject( + reference, + 'invalid-label-reference', + 'Packet C has a malformed label reference', + ) + const pc = requireUint32( + reference.pc, + 'invalid-label-reference', + 'Label reference pc is invalid', + ) + const operand = isSafeCount(reference.operand) + ? reference.operand + : decline('invalid-label-reference', 'Label reference operand is invalid') + const target = requireUint32( + reference.target, + 'invalid-label-reference', + 'Label reference target is invalid', + ) + const instruction = wordcodeData.byPc.get(pc) + if (!instruction || instruction.name !== reference.instruction) { + decline( + 'input-mismatch', + `Label reference at ${pc} does not match Packet B`, + ) + } + const expectedOperands = DIRECT_LABEL_OPERANDS[instruction.name] ?? [] + if (!expectedOperands.includes(operand)) { + decline( + 'invalid-label-reference', + `${instruction.name}@${pc} has an unexpected label operand`, + ) + } + const typedOperand = instruction.operands[operand] + if ( + !isObject(typedOperand) || + typedOperand.kind !== 'label-target' || + typedOperand.pc !== target || + !wordcodeData.boundarySet.has(target) + ) { + decline( + 'input-mismatch', + `${instruction.name}@${pc} label reference does not match Packet B`, + ) + } + if (reference.role !== LABEL_ROLES[instruction.name]?.[operand]) { + decline( + 'invalid-label-reference', + `${instruction.name}@${pc} has an invalid label role`, + ) + } + const key = `${pc}:${operand}` + if (labelByLocation.has(key)) + decline('invalid-label-reference', `Duplicate label reference ${key}`) + labelByLocation.set(key, { + pc, + instruction: instruction.name, + operand, + role: reference.role, + target, + }) + } + for (const instruction of wordcodeData.instructions) { + const expectedOperands = DIRECT_LABEL_OPERANDS[instruction.name] ?? [] + const actualOperands = instruction.operands + .map((operand, index) => + operand?.kind === 'label-target' ? index : null, + ) + .filter((index) => index !== null) + if (!sameArray(expectedOperands, actualOperands)) { + decline( + 'invalid-label-reference', + `${instruction.name}@${instruction.pc} has an invalid label layout`, + ) + } + for (const operand of expectedOperands) { + if (!labelByLocation.has(`${instruction.pc}:${operand}`)) { + decline( + 'invalid-label-reference', + `Missing label reference for ${instruction.name}@${instruction.pc}`, + ) + } + } + } + + const frame = requireObject( + references.frame, + 'invalid-frame-metadata', + 'Packet C result has no frame section', + ) + requireUint32( + frame.frameStart, + 'invalid-frame-metadata', + 'Packet C frameStart is invalid', + ) + requireUint32( + frame.headerSize, + 'invalid-frame-metadata', + 'Packet C headerSize is invalid', + ) + if ( + frame.frameStart !== 1 || + frame.headerSize !== 8 || + !exactObjectValues(frame.slots, CANONICAL_SLOTS) + ) { + decline( + 'invalid-frame-metadata', + 'Packet C frame constants do not match the pinned baseline', + ) + } + requireUint32( + frame.mainStartPc, + 'invalid-frame-metadata', + 'Packet C mainStartPc is invalid', + ) + requireUint32( + frame.mainRegCount, + 'invalid-frame-metadata', + 'Packet C mainRegCount is invalid', + ) + if ( + frame.mainStartPc !== wordcodeData.boundaries[0] || + frame.mainRegCount < 1 + ) { + decline('invalid-frame-metadata', 'Packet C root frame metadata is invalid') + } + const root = requireObject( + frame.root, + 'invalid-frame-metadata', + 'Packet C result has no root frame record', + ) + const rootFrameSize = frame.headerSize + frame.mainRegCount + const rootRegisterBase = frame.frameStart + frame.headerSize + if ( + root.frameBase !== frame.frameStart || + root.frameSize !== rootFrameSize || + root.registerBase !== rootRegisterBase || + root.frameEnd !== frame.frameStart + rootFrameSize || + root.registerWindow?.start !== rootRegisterBase || + root.registerWindow?.end !== frame.frameStart + rootFrameSize + ) { + decline( + 'invalid-frame-metadata', + 'Packet C root frame arithmetic is invalid', + ) + } + + if (!Array.isArray(frame.descriptors)) + decline('invalid-descriptor', 'Packet C descriptors are not an array') + const descriptors = [] + for (const descriptor of frame.descriptors) { + requireObject( + descriptor, + 'invalid-descriptor', + 'Packet C has a malformed descriptor', + ) + if ( + !isUint32(descriptor.creationPc) || + !isUint32(descriptor.startPc) || + !isUint32(descriptor.paramCount) || + !isUint32(descriptor.regCount) || + !isUint32(descriptor.captureCount) || + typeof descriptor.hasRest !== 'boolean' || + !Array.isArray(descriptor.captures) || + descriptor.captures.length !== descriptor.captureCount || + !isUint32(descriptor.frameSize) || + descriptor.frameSize !== frame.headerSize + descriptor.regCount || + descriptor.registerBaseOffset !== frame.headerSize || + !wordcodeData.boundarySet.has(descriptor.creationPc) || + !wordcodeData.boundarySet.has(descriptor.startPc) || + descriptor.regCount < 1 || + descriptor.paramCount > descriptor.regCount + ) { + decline('invalid-descriptor', 'Packet C descriptor bounds are invalid') + } + descriptors.push({ + creationPc: descriptor.creationPc, + startPc: descriptor.startPc, + paramCount: descriptor.paramCount, + regCount: descriptor.regCount, + captureCount: descriptor.captureCount, + hasRest: descriptor.hasRest, + captures: descriptor.captures.map((capture) => { + requireObject( + capture, + 'invalid-descriptor', + 'Packet C descriptor capture is malformed', + ) + if ( + (capture.kind !== 'local' && capture.kind !== 'upvalue') || + !isUint32(capture.index) || + capture.isLocal !== (capture.kind === 'local') + ) { + decline( + 'invalid-descriptor', + 'Packet C descriptor capture is invalid', + ) + } + return { + kind: capture.kind, + index: capture.index, + isLocal: capture.isLocal, + } + }), + frameSize: descriptor.frameSize, + registerBaseOffset: descriptor.registerBaseOffset, + }) + } + return { frame, descriptors, labelByLocation } +} + +export function validateFunctions( + functionsResult, + wordcodeData, + referencesData, +) { + requireObject( + functionsResult, + 'invalid-input', + 'Expected a Packet D function partition result', + ) + if ( + functionsResult.schemaVersion !== FUNCTION_SCHEMA || + functionsResult.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-functions', + 'Input is not a Packet D function partition result', + ) + } + if ( + functionsResult.wordCount !== wordcodeData.words.length || + functionsResult.instructionCount !== wordcodeData.instructions.length || + !Array.isArray(functionsResult.functions) || + functionsResult.functions.length === 0 + ) { + decline( + 'input-mismatch', + 'Packet D counts or function list do not match Packet B', + ) + } + if ( + !sameArray( + functionsResult.entryPcs, + functionsResult.functions.map(({ startPc }) => startPc), + ) + ) { + decline( + 'invalid-function-boundary', + 'Packet D entry PCs do not match its function records', + ) + } + const partitionFrame = requireObject( + functionsResult.frame, + 'invalid-function-boundary', + 'Packet D result has no frame summary', + ) + if ( + partitionFrame.frameStart !== referencesData.frame.frameStart || + partitionFrame.headerSize !== referencesData.frame.headerSize || + !exactObjectValues(partitionFrame.slots, CANONICAL_SLOTS) || + !isObject(partitionFrame.root) || + partitionFrame.root.functionId !== 0 || + partitionFrame.root.startPc !== referencesData.frame.mainStartPc || + partitionFrame.root.regCount !== referencesData.frame.mainRegCount + ) { + decline( + 'invalid-function-boundary', + 'Packet D frame summary does not match Packet C', + ) + } + + const functionById = new Map() + const ownerByPc = new Map() + let previousEnd = null + for (let index = 0; index < functionsResult.functions.length; index += 1) { + const input = functionsResult.functions[index] + requireObject( + input, + 'invalid-function-boundary', + 'Packet D contains a malformed function', + ) + if ( + input.id !== index || + (input.kind !== 'root' && input.kind !== 'closure') || + !isUint32(input.startPc) || + !isUint32(input.endPc) || + !wordcodeData.boundarySet.has(input.startPc) || + (input.endPc !== wordcodeData.words.length && + !wordcodeData.boundarySet.has(input.endPc)) || + input.endPc <= input.startPc || + (previousEnd !== null && input.startPc !== previousEnd) || + !isUint32(input.regCount) || + input.regCount < 1 || + !isUint32(input.paramCount) || + input.paramCount > input.regCount || + !isUint32(input.captureCount) || + typeof input.hasRest !== 'boolean' || + !Array.isArray(input.instructionPcs) || + input.instructionCount !== input.instructionPcs.length + ) { + decline( + 'invalid-function-boundary', + `Packet D function ${index} is malformed`, + ) + } + if ( + index === 0 && + (input.kind !== 'root' || + input.startPc !== referencesData.frame.mainStartPc || + input.parentFunctionId !== null) + ) { + decline( + 'invalid-function-boundary', + 'Packet D root function metadata is invalid', + ) + } + const expectedPcs = wordcodeData.instructions + .filter(({ pc }) => pc >= input.startPc && pc < input.endPc) + .map(({ pc }) => pc) + if ( + !sameArray(input.instructionPcs, expectedPcs) || + input.instructionCount !== expectedPcs.length || + expectedPcs.length === 0 + ) { + decline( + 'invalid-function-boundary', + `Packet D function ${index} does not own its interval exactly`, + ) + } + for (const pc of expectedPcs) { + if (ownerByPc.has(pc)) + decline( + 'invalid-function-boundary', + `Instruction at ${pc} has multiple owners`, + ) + ownerByPc.set(pc, index) + } + const descriptor = input.descriptor + if (input.kind === 'closure') { + requireObject( + descriptor, + 'invalid-function-boundary', + `Closure function ${index} has no descriptor`, + ) + if ( + descriptor.startPc !== input.startPc || + descriptor.regCount !== input.regCount || + descriptor.paramCount !== input.paramCount || + descriptor.captureCount !== input.captureCount || + descriptor.hasRest !== input.hasRest + ) { + decline( + 'invalid-function-boundary', + `Closure function ${index} descriptor does not match its record`, + ) + } + } else if (descriptor !== null) { + decline( + 'invalid-function-boundary', + 'Root function must not carry a closure descriptor', + ) + } + const normalized = { + id: input.id, + kind: input.kind, + startPc: input.startPc, + endPc: input.endPc, + regCount: input.regCount, + paramCount: input.paramCount, + captureCount: input.captureCount, + hasRest: input.hasRest, + parentFunctionId: input.parentFunctionId, + instructionPcs: expectedPcs, + input, + } + functionById.set(index, normalized) + previousEnd = input.endPc + } + if ( + ownerByPc.size !== wordcodeData.instructions.length || + previousEnd !== wordcodeData.words.length + ) { + decline( + 'invalid-function-boundary', + 'Packet D does not assign the complete stream exactly once', + ) + } + for (const fn of functionById.values()) { + if (fn.kind === 'closure') { + if ( + !Number.isInteger(fn.parentFunctionId) || + !functionById.has(fn.parentFunctionId) || + fn.parentFunctionId >= fn.id + ) { + decline( + 'invalid-function-boundary', + `Closure function ${fn.id} has an invalid parent`, + ) + } + } + } + + if ( + !Array.isArray(functionsResult.ownership) || + functionsResult.ownership.length !== wordcodeData.instructions.length + ) { + decline( + 'invalid-function-boundary', + 'Packet D ownership is missing or incomplete', + ) + } + for (let index = 0; index < functionsResult.ownership.length; index += 1) { + const ownership = functionsResult.ownership[index] + requireObject( + ownership, + 'invalid-function-boundary', + 'Packet D contains a malformed ownership record', + ) + const expectedPc = wordcodeData.instructions[index].pc + if ( + ownership.pc !== expectedPc || + ownership.functionId !== ownerByPc.get(expectedPc) + ) { + decline( + 'input-mismatch', + `Packet D ownership does not match Packet B at ${expectedPc}`, + ) + } + } + + if ( + !Array.isArray(functionsResult.labels) || + functionsResult.labels.length !== referencesData.labelByLocation.size + ) { + decline( + 'invalid-function-boundary', + 'Packet D labels are missing or incomplete', + ) + } + const seenLabels = new Set() + for (const label of functionsResult.labels) { + requireObject( + label, + 'invalid-function-boundary', + 'Packet D contains a malformed label record', + ) + const key = `${label.pc}:${label.operand}` + const reference = referencesData.labelByLocation.get(key) + if ( + !reference || + label.target !== reference.target || + label.instruction !== reference.instruction || + label.role !== reference.role || + label.sourceFunctionId !== ownerByPc.get(label.pc) || + label.targetFunctionId !== ownerByPc.get(label.target) + ) { + decline( + 'input-mismatch', + `Packet D label record ${key} does not match predecessor packets`, + ) + } + if (seenLabels.has(key)) + decline('invalid-function-boundary', `Duplicate Packet D label ${key}`) + seenLabels.add(key) + } + return { functions: [...functionById.values()], functionById, ownerByPc } +} + +export function registerIndices(instruction) { + const raw = instruction.words + const name = instruction.name + if ( + name === 'LOAD_CONST' || + name === 'LOAD_INT' || + name === 'LOAD_GLOBAL' || + name === 'LOAD_UPVALUE' || + name === 'LOAD_THIS' || + name === 'MAKE_CLOSURE' || + name === 'BUILD_ARRAY' || + name === 'BUILD_OBJECT' || + name === 'FOR_IN_SETUP' + ) { + if (name === 'BUILD_ARRAY' || name === 'BUILD_OBJECT') + return [raw[1], ...raw.slice(3)] + if (name === 'FOR_IN_SETUP') return [raw[1], raw[2]] + return [raw[1]] + } + if (name === 'STORE_GLOBAL') return [raw[3]] + if (name === 'STORE_UPVALUE') return [raw[2]] + if (name === 'MOVE') return [raw[1], raw[2]] + if ( + name === 'GET_PROP' || + name === 'SET_PROP' || + name === 'DELETE_PROP' || + name === 'ADD' || + name === 'SUB' || + name === 'MUL' || + name === 'DIV' || + name === 'MOD' || + name === 'EXP' || + name === 'BAND' || + name === 'BOR' || + name === 'BXOR' || + name === 'SHL' || + name === 'SHR' || + name === 'USHR' || + name === 'LT' || + name === 'GT' || + name === 'LTE' || + name === 'GTE' || + name === 'EQ' || + name === 'NEQ' || + name === 'LOOSE_EQ' || + name === 'LOOSE_NEQ' || + name === 'IN' || + name === 'INSTANCEOF' || + name === 'DEFINE_GETTER' || + name === 'DEFINE_SETTER' + ) { + return raw.slice(1) + } + if ( + name === 'UNARY_NEG' || + name === 'UNARY_POS' || + name === 'UNARY_NOT' || + name === 'UNARY_BITNOT' || + name === 'TYPEOF' || + name === 'VOID' || + name === 'TYPEOF_SAFE' + ) { + return [raw[1]] + } + if (name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE') return [raw[1]] + if (name === 'CALL' || name === 'NEW') { + return raw[3] === 65535 + ? [raw[1], raw[2], raw[4]] + : [raw[1], raw[2], ...raw.slice(4)] + } + if (name === 'CALL_METHOD') { + return raw[4] === 65535 + ? [raw[1], raw[2], raw[3], raw[5]] + : [raw[1], raw[2], raw[3], ...raw.slice(5)] + } + if (name === 'RETURN' || name === 'THROW' || name === 'JUMP_REG') + return [raw[1]] + if (name === 'FOR_IN_NEXT') return [raw[1], raw[2]] + if (name === 'FINALLY_SETUP') return [raw[2], raw[3]] + return [] +} diff --git a/src/vm/jsconfuser-vm/build-cfg-wordcode.js b/src/vm/jsconfuser-vm/build-cfg-wordcode.js new file mode 100644 index 00000000..316151bc --- /dev/null +++ b/src/vm/jsconfuser-vm/build-cfg-wordcode.js @@ -0,0 +1,665 @@ +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +const UINT32_MAX = 0xffffffff + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +const NAME_BY_OPCODE = new Map( + Object.entries(CANONICAL_OPCODES).map(([name, value]) => [value, name]), +) + +export const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const DIRECT_LABEL_OPERANDS = Object.freeze({ + JUMP: Object.freeze([0]), + JUMP_IF_FALSE: Object.freeze([1]), + JUMP_IF_TRUE: Object.freeze([1]), + FOR_IN_NEXT: Object.freeze([2]), + TRY_SETUP: Object.freeze([0]), + FINALLY_SETUP: Object.freeze([0, 3]), + MAKE_CLOSURE: Object.freeze([1]), +}) + +export const LABEL_ROLES = Object.freeze({ + JUMP: Object.freeze({ 0: 'target' }), + JUMP_IF_FALSE: Object.freeze({ 1: 'target' }), + JUMP_IF_TRUE: Object.freeze({ 1: 'target' }), + FOR_IN_NEXT: Object.freeze({ 2: 'exit' }), + TRY_SETUP: Object.freeze({ 0: 'handler' }), + FINALLY_SETUP: Object.freeze({ 0: 'finally', 3: 'throwPad' }), + MAKE_CLOSURE: Object.freeze({ 1: 'functionEntry' }), +}) + +export const REGISTER_DESTINATIONS = new Set([ + 'LOAD_CONST', + 'LOAD_INT', + 'LOAD_GLOBAL', + 'LOAD_UPVALUE', + 'LOAD_THIS', + 'MOVE', + 'GET_PROP', + 'DELETE_PROP', + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'IN', + 'INSTANCEOF', + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', + 'TYPEOF_SAFE', + 'CALL', + 'CALL_METHOD', + 'NEW', + 'MAKE_CLOSURE', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'FOR_IN_SETUP', + 'FOR_IN_NEXT', +]) + +const CONDITIONAL_NAMES = new Set([ + 'JUMP_IF_FALSE', + 'JUMP_IF_TRUE', + 'FOR_IN_NEXT', +]) + +export const TERMINATOR_NAMES = new Set([ + 'JUMP', + 'JUMP_REG', + 'RETURN', + 'THROW', + ...CONDITIONAL_NAMES, +]) + +export class CfgDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'CfgDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new CfgDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) + return value + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function isUint32(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +export function isSafeCount(value) { + return Number.isSafeInteger(value) && value >= 0 +} + +export function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +export function sameArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +export function exactObjectValues(actual, expected) { + if (!isObject(actual)) return false + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + actual[key] === expected[key], + ) + ) +} + +export function requireUint32(value, code, message) { + if (!isUint32(value)) decline(code, message) + return value +} + +function requireRegister(value, location) { + requireObject(value, 'malformed-instruction', `${location} is not an operand`) + if (value.kind !== 'register' || !isUint32(value.index)) { + decline('malformed-instruction', `${location} is not a register operand`) + } + return value.index +} + +function requireLabel(value, location) { + requireObject(value, 'malformed-instruction', `${location} is not an operand`) + if (value.kind !== 'label-target' || !isUint32(value.pc)) { + decline('malformed-instruction', `${location} is not a label operand`) + } + return value.pc +} + +function requireImmediate(value, expected, location) { + requireObject(value, 'malformed-instruction', `${location} is not an operand`) + if (value.kind !== 'immediate' || value.value !== expected) { + decline( + 'malformed-instruction', + `${location} is not the expected immediate`, + ) + } + return value.value +} + +function requireConstantRef(value, index, key, location) { + requireObject(value, 'malformed-instruction', `${location} is not an operand`) + if ( + value.kind !== 'constant-ref' || + value.index !== index || + value.concealKey !== key + ) { + decline( + 'malformed-instruction', + `${location} is not the expected constant reference`, + ) + } +} + +function requireUpvalue(value, index, location) { + requireObject(value, 'malformed-instruction', `${location} is not an operand`) + if (value.kind !== 'upvalue-index' || value.index !== index) { + decline( + 'malformed-instruction', + `${location} is not the expected upvalue reference`, + ) + } +} + +function validateInstructionOperands(instruction) { + const raw = instruction.words + const operands = instruction.operands + if (!Array.isArray(operands)) { + decline( + 'malformed-instruction', + `Packet B instruction at ${instruction.pc} has no typed operands`, + ) + } + const name = instruction.name + const registerOperands = (offsets) => { + if (operands.length !== offsets.length) { + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has an invalid operand count`, + ) + } + offsets.forEach((offset, index) => + requireRegister( + operands[index], + `${name}@${instruction.pc} operand ${offset}`, + ), + ) + } + + if (name === 'LOAD_INT') { + if (operands.length !== 2) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireImmediate(operands[1], raw[2], `${name}@${instruction.pc} immediate`) + return + } + if (name === 'LOAD_CONST' || name === 'LOAD_GLOBAL') { + if (operands.length !== 2) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireConstantRef( + operands[1], + raw[2], + raw[3], + `${name}@${instruction.pc} constant`, + ) + return + } + if (name === 'LOAD_UPVALUE') { + if (operands.length !== 2) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireUpvalue(operands[1], raw[2], `${name}@${instruction.pc} upvalue`) + return + } + if (name === 'TYPEOF_SAFE') { + if (operands.length !== 2) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireConstantRef( + operands[1], + raw[2], + raw[3], + `${name}@${instruction.pc} constant`, + ) + return + } + if (name === 'MOVE') { + registerOperands([1, 2]) + return + } + if (name === 'JUMP') { + if (operands.length !== 1) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireLabel(operands[0], `${name}@${instruction.pc} target`) + return + } + if (name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE') { + if (operands.length !== 2) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} condition`) + requireLabel(operands[1], `${name}@${instruction.pc} target`) + return + } + if (name === 'FOR_IN_NEXT') { + if (operands.length !== 3) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireRegister(operands[1], `${name}@${instruction.pc} iterator`) + requireLabel(operands[2], `${name}@${instruction.pc} exit`) + return + } + if (name === 'TRY_SETUP') { + if (operands.length !== 2) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireLabel(operands[0], `${name}@${instruction.pc} handler`) + requireRegister(operands[1], `${name}@${instruction.pc} exception register`) + return + } + if (name === 'FINALLY_SETUP') { + if (operands.length !== 4) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireLabel(operands[0], `${name}@${instruction.pc} finally`) + requireRegister( + operands[1], + `${name}@${instruction.pc} continuation register`, + ) + requireRegister(operands[2], `${name}@${instruction.pc} payload register`) + requireLabel(operands[3], `${name}@${instruction.pc} throw pad`) + return + } + if (name === 'JUMP_REG' || name === 'RETURN' || name === 'THROW') { + registerOperands([1]) + return + } + if (name === 'CALL' || name === 'NEW') { + const argc = raw[3] + const expected = argc === 65535 ? 4 : 3 + argc + if (operands.length !== expected) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireRegister(operands[1], `${name}@${instruction.pc} callee`) + if (argc === 65535) { + requireObject( + operands[2], + 'malformed-instruction', + `${name}@${instruction.pc} spread sentinel`, + ) + if (operands[2].kind !== 'spread-sentinel' || operands[2].value !== argc) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} spread sentinel does not match words`, + ) + requireRegister(operands[3], `${name}@${instruction.pc} spread array`) + } else { + requireImmediate(operands[2], argc, `${name}@${instruction.pc} argc`) + for (let index = 3; index < operands.length; index += 1) + requireRegister( + operands[index], + `${name}@${instruction.pc} argument ${index - 3}`, + ) + } + return + } + if (name === 'CALL_METHOD') { + const argc = raw[4] + const expected = argc === 65535 ? 5 : 4 + argc + if (operands.length !== expected) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireRegister(operands[1], `${name}@${instruction.pc} receiver`) + requireRegister(operands[2], `${name}@${instruction.pc} callee`) + if (argc === 65535) { + requireObject( + operands[3], + 'malformed-instruction', + `${name}@${instruction.pc} spread sentinel`, + ) + if (operands[3].kind !== 'spread-sentinel' || operands[3].value !== argc) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} spread sentinel does not match words`, + ) + requireRegister(operands[4], `${name}@${instruction.pc} spread array`) + } else { + requireImmediate(operands[3], argc, `${name}@${instruction.pc} argc`) + for (let index = 4; index < operands.length; index += 1) + requireRegister( + operands[index], + `${name}@${instruction.pc} argument ${index - 4}`, + ) + } + return + } + if (name === 'MAKE_CLOSURE') { + const captureCount = raw[5] + if ( + operands.length !== 6 + captureCount || + !Array.isArray(instruction.captures) || + instruction.captures.length !== captureCount + ) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireLabel(operands[1], `${name}@${instruction.pc} function entry`) + for (let index = 2; index < 6; index += 1) + requireImmediate( + operands[index], + raw[index + 1], + `${name}@${instruction.pc} metadata ${index}`, + ) + for (let index = 0; index < captureCount; index += 1) { + const capture = operands[6 + index] + requireObject( + capture, + 'malformed-instruction', + `${name}@${instruction.pc} capture ${index}`, + ) + const kind = raw[7 + index * 2] === 1 ? 'local' : 'upvalue' + if ( + capture.kind !== kind || + capture.index !== raw[8 + index * 2] || + capture.isLocal !== (kind === 'local') + ) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} capture ${index} does not match words`, + ) + } + return + } + + if (name === 'BUILD_ARRAY' || name === 'BUILD_OBJECT') { + const count = raw[2] + const expected = name === 'BUILD_ARRAY' ? 2 + count : 2 + count * 2 + if (operands.length !== expected) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has invalid operands`, + ) + requireRegister(operands[0], `${name}@${instruction.pc} destination`) + requireImmediate(operands[1], count, `${name}@${instruction.pc} count`) + for (let index = 2; index < operands.length; index += 1) + requireRegister( + operands[index], + `${name}@${instruction.pc} element ${index - 2}`, + ) + return + } + + if (name === 'DEBUGGER' || name === 'TRY_END') { + if (operands.length !== 0) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} must have no operands`, + ) + return + } + + if (REGISTER_DESTINATIONS.has(name)) { + if (operands.length < 1) + decline( + 'malformed-instruction', + `${name}@${instruction.pc} has no destination`, + ) + operands.forEach((operand, index) => + requireRegister(operand, `${name}@${instruction.pc} operand ${index}`), + ) + } +} + +export function validateWordcode(wordcode) { + requireObject( + wordcode, + 'invalid-input', + 'Expected a Packet B wordcode result', + ) + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-wordcode', + 'Input is not a Packet B numeric wordcode result', + ) + } + const words = wordcode.words + if (!Array.isArray(words) || words.length === 0 || !words.every(isUint32)) { + decline( + 'invalid-wordcode', + 'Packet B words must be a non-empty unsigned 32-bit array', + ) + } + if ( + wordcode.wordCount !== words.length || + wordcode.consumedWords !== words.length || + wordcode.nextPc !== words.length || + !Array.isArray(wordcode.instructions) || + wordcode.instructionCount !== wordcode.instructions.length || + wordcode.instructions.length === 0 + ) { + decline('invalid-wordcode', 'Packet B consumption metadata is inconsistent') + } + + const instructions = [] + const byPc = new Map() + let nextPc = 0 + for (const instruction of wordcode.instructions) { + requireObject( + instruction, + 'malformed-instruction', + 'Packet B has a malformed instruction record', + ) + if (instruction.pc !== nextPc || !isSafeCount(instruction.pc)) { + decline('invalid-wordcode', 'Packet B instruction PCs are not contiguous') + } + if ( + typeof instruction.name !== 'string' || + NAME_BY_OPCODE.get(instruction.words?.[0]) !== instruction.name + ) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has an unknown opcode name`, + ) + } + if (instruction.name === 'PATCH') { + decline( + 'unsupported-hardening-opcode', + `PATCH@${instruction.pc} is outside the numeric baseline`, + ) + } + if ( + !Array.isArray(instruction.words) || + instruction.words.length === 0 || + !instruction.words.every(isUint32) || + !isSafeCount(instruction.width) || + instruction.width !== instruction.words.length || + instruction.nextPc !== instruction.pc + instruction.width || + !sameArray( + instruction.words, + words.slice(instruction.pc, instruction.nextPc), + ) + ) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has invalid width or words`, + ) + } + validateInstructionOperands(instruction) + if (byPc.has(instruction.pc)) + decline( + 'invalid-wordcode', + `Packet B contains duplicate instruction pc ${instruction.pc}`, + ) + byPc.set(instruction.pc, instruction) + instructions.push(instruction) + nextPc = instruction.nextPc + } + if (nextPc !== words.length) + decline( + 'invalid-wordcode', + 'Packet B instructions do not consume all words', + ) + return { + words, + instructions, + byPc, + boundaries: instructions.map(({ pc }) => pc), + boundarySet: new Set(instructions.map(({ pc }) => pc)), + } +} diff --git a/src/vm/jsconfuser-vm/build-cfg.js b/src/vm/jsconfuser-vm/build-cfg.js new file mode 100644 index 00000000..15980e20 --- /dev/null +++ b/src/vm/jsconfuser-vm/build-cfg.js @@ -0,0 +1,620 @@ +import { + CFG_SCHEMA, + CfgDecline, + FUNCTION_SCHEMA, + REFERENCE_SCHEMA, + TERMINATOR_NAMES, + WORDCODE_SCHEMA, + decline, + deepFreeze, + sameArray, + validateWordcode, +} from './build-cfg-wordcode.js' +import { validateFunctions, validateReferences } from './build-cfg-preflight.js' +import { + destinationRegister, + makeHandlerRecords, + recordView, + solveStacks, + stateView, + validateRegisterRanges, +} from './build-cfg-edges.js' + +function emptyDataState(regCount) { + return Array.from({ length: regCount }, () => ({ + values: new Set(), + unknown: true, + origins: new Map(), + })) +} + +function cloneDataCell(cell) { + return { + values: new Set(cell.values), + unknown: cell.unknown, + origins: new Map( + [...cell.origins].map(([value, origins]) => [value, new Set(origins)]), + ), + } +} + +function cloneDataState(state) { + return state.map(cloneDataCell) +} + +function mergeDataCell(left, right) { + const merged = cloneDataCell(left) + merged.unknown = merged.unknown || right.unknown + for (const value of right.values) merged.values.add(value) + for (const [value, origins] of right.origins) { + const mergedOrigins = merged.origins.get(value) ?? new Set() + for (const origin of origins) mergedOrigins.add(origin) + merged.origins.set(value, mergedOrigins) + } + return merged +} + +function mergeDataState(left, right) { + return left.map((cell, index) => mergeDataCell(cell, right[index])) +} + +function sameDataState(left, right) { + if (left.length !== right.length) return false + return left.every((cell, index) => { + const other = right[index] + if ( + cell.unknown !== other.unknown || + cell.values.size !== other.values.size + ) + return false + for (const value of cell.values) if (!other.values.has(value)) return false + if (cell.origins.size !== other.origins.size) return false + for (const [value, origins] of cell.origins) { + const otherOrigins = other.origins.get(value) + if (!otherOrigins || origins.size !== otherOrigins.size) return false + for (const origin of origins) if (!otherOrigins.has(origin)) return false + } + return true + }) +} + +function writeUnknown(state, register) { + state[register] = { values: new Set(), unknown: true, origins: new Map() } +} + +function writeSingleton(state, register, value, origin) { + state[register] = { + values: new Set([value]), + unknown: false, + origins: new Map([[value, new Set([origin])]]), + } +} + +function transferData(instruction, state) { + const next = cloneDataState(state) + const destination = destinationRegister(instruction) + if (instruction.name === 'LOAD_INT') { + writeSingleton(next, destination, instruction.words[2], instruction.pc) + } else if (instruction.name === 'MOVE') { + next[destination] = cloneDataCell(next[instruction.words[2]]) + } else if (destination !== null) { + writeUnknown(next, destination) + } + return next +} + +function applyExtraWrites(state, writes) { + const next = cloneDataState(state) + for (const write of writes) { + if (write.kind === 'singleton') + writeSingleton(next, write.register, write.value, write.origin) + else if (write.kind === 'unknown') writeUnknown(next, write.register) + else + decline( + 'invalid-dataflow-write', + `Unknown data-flow write kind ${write.kind}`, + ) + } + return next +} + +function solveRegisterData(fn, stackResult, wordcodeData) { + const inStates = new Map([[fn.startPc, emptyDataState(fn.regCount)]]) + const queue = [fn.startPc] + const bySource = new Map() + for (const edge of stackResult.edges) { + const list = bySource.get(edge.sourcePc) ?? [] + list.push(edge) + bySource.set(edge.sourcePc, list) + } + + while (queue.length) { + const pc = queue.shift() + const instruction = wordcodeData.byPc.get(pc) + if (!instruction) + decline( + 'invalid-cfg-node', + `Function ${fn.id}: missing instruction at ${pc}`, + ) + const output = transferData(instruction, inStates.get(pc)) + for (const edge of bySource.get(pc) ?? []) { + if (edge.targetPc === null) continue + const next = applyExtraWrites(output, edge.extraWrites) + const prior = inStates.get(edge.targetPc) + const merged = prior ? mergeDataState(prior, next) : next + if (!prior || !sameDataState(prior, merged)) { + inStates.set(edge.targetPc, merged) + queue.push(edge.targetPc) + } + } + } + + const jumps = [] + for (const instruction of fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter((item) => item.name === 'JUMP_REG')) { + const state = inStates.get(instruction.pc) + if (!state) + decline( + 'unresolved-jump-reg', + `Function ${fn.id}: JUMP_REG@${instruction.pc} is unreachable`, + ) + const source = state[instruction.words[1]] + if (!source || source.unknown || source.values.size === 0) { + decline( + 'unresolved-jump-reg', + `Function ${fn.id}: JUMP_REG@${instruction.pc} has no finite known target set`, + ) + } + const targets = [...source.values].sort((left, right) => left - right) + const origins = {} + for (const target of targets) { + origins[target] = [...(source.origins.get(target) ?? [])] + .map(String) + .sort() + } + jumps.push({ + pc: instruction.pc, + sourceRegister: instruction.words[1], + targets, + origins, + }) + } + return { inStates, jumps } +} + +function sameJumpMap(left, right) { + const keys = new Set([...left.keys(), ...right.keys()]) + for (const key of keys) { + if (!sameArray(left.get(key) ?? [], right.get(key) ?? [])) return false + } + return true +} + +function solveControlFlow( + fn, + records, + functionsData, + referencesData, + wordcodeData, +) { + const jumpTargets = new Map( + fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter((instruction) => instruction.name === 'JUMP_REG') + .map((instruction) => [instruction.pc, []]), + ) + let finalStack = null + let finalData = null + for (let iteration = 0; iteration < 32; iteration += 1) { + finalStack = solveStacks( + fn, + jumpTargets, + records, + functionsData, + referencesData, + wordcodeData, + ) + finalData = solveRegisterData(fn, finalStack, wordcodeData) + const next = new Map(finalData.jumps.map((jump) => [jump.pc, jump.targets])) + for (const jump of finalData.jumps) { + for (const target of jump.targets) { + if (!wordcodeData.boundarySet.has(target)) { + decline( + 'out-of-range-target', + `Function ${fn.id}: JUMP_REG@${jump.pc} targets non-boundary pc ${target}`, + ) + } + if (functionsData.ownerByPc.get(target) !== fn.id) { + decline( + 'cross-function-target', + `Function ${fn.id}: JUMP_REG@${jump.pc} targets function ${functionsData.ownerByPc.get(target)}`, + ) + } + } + } + if (sameJumpMap(jumpTargets, next)) break + jumpTargets.clear() + for (const [pc, targets] of next) jumpTargets.set(pc, targets) + if (iteration === 31) + decline( + 'dataflow-nonconvergence', + `Function ${fn.id}: JUMP_REG data flow did not converge`, + ) + } + const reachableJumps = new Set(finalData.jumps.map((jump) => jump.pc)) + for (const pc of jumpTargets.keys()) { + if (!reachableJumps.has(pc)) + decline( + 'unresolved-jump-reg', + `Function ${fn.id}: JUMP_REG@${pc} is unreachable`, + ) + } + return { + ...finalStack, + ...finalData, + inStates: finalStack.inStates, + dataInStates: finalData.inStates, + jumpTargets, + } +} + +function edgeTargetEdges(edges, sourcePc) { + return edges.filter((edge) => edge.sourcePc === sourcePc) +} + +function findIrreducibleRegions(fn, edges) { + const nodes = fn.instructionPcs + const nodeSet = new Set(nodes) + const adjacency = new Map(nodes.map((pc) => [pc, new Set()])) + for (const edge of edges) { + if (edge.targetPc !== null && nodeSet.has(edge.targetPc)) { + adjacency.get(edge.sourcePc)?.add(edge.targetPc) + } + } + + let nextIndex = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + const visit = (node) => { + indexes.set(node, nextIndex) + lowLinks.set(node, nextIndex) + nextIndex += 1 + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node) ?? []) { + if (!indexes.has(target)) { + visit(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) { + lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + } + if (lowLinks.get(node) === indexes.get(node)) { + const component = [] + let value + do { + value = stack.pop() + onStack.delete(value) + component.push(value) + } while (value !== node) + components.push(component.sort((left, right) => left - right)) + } + } + for (const node of nodes) if (!indexes.has(node)) visit(node) + + const regions = [] + for (const component of components) { + const componentSet = new Set(component) + const isCycle = + component.length > 1 || + edges.some( + (edge) => + edge.sourcePc === component[0] && edge.targetPc === component[0], + ) + if (!isCycle) continue + const entries = new Set() + for (const edge of edges) { + if ( + edge.targetPc !== null && + componentSet.has(edge.targetPc) && + !componentSet.has(edge.sourcePc) + ) { + entries.add(edge.targetPc) + } + } + if (entries.size > 1) { + regions.push({ + startPcs: [...entries].sort((left, right) => left - right), + instructionPcs: component, + reason: 'multiple-entry-cycle', + }) + } + } + return regions +} + +function buildBlocks(fn, stackResult, records, wordcodeData) { + const leaders = new Set([fn.startPc]) + for (const record of records.values()) { + if (record.ownerFunction !== fn.id) continue + leaders.add(record.type === 'handler' ? record.handlerPc : record.finallyPc) + if (record.type === 'finally') leaders.add(record.throwPadPc) + } + for (const instruction of fn.instructionPcs.map((pc) => + wordcodeData.byPc.get(pc), + )) { + for (const edge of edgeTargetEdges(stackResult.edges, instruction.pc)) { + if (edge.targetPc !== null) leaders.add(edge.targetPc) + } + if ( + TERMINATOR_NAMES.has(instruction.name) && + instruction.nextPc < fn.endPc + ) { + leaders.add(instruction.nextPc) + } + } + + const sortedLeaders = [...leaders].sort((left, right) => left - right) + const blocks = [] + for (let index = 0; index < sortedLeaders.length; index += 1) { + const startPc = sortedLeaders[index] + const endPc = sortedLeaders[index + 1] ?? fn.endPc + const instructionPcs = fn.instructionPcs.filter( + (pc) => pc >= startPc && pc < endPc, + ) + if (instructionPcs.length === 0 || instructionPcs[0] !== startPc) { + decline( + 'invalid-basic-block', + `Function ${fn.id}: leader ${startPc} does not start a block`, + ) + } + const lastPc = instructionPcs.at(-1) + blocks.push({ + id: `${fn.id}:b@${startPc}`, + functionId: fn.id, + startPc, + endPc, + instructionPcs, + successors: edgeTargetEdges(stackResult.edges, lastPc), + handlerStateIn: stateView( + stackResult.inStates.get(startPc) ?? [], + records, + ), + }) + } + const covered = blocks.flatMap(({ instructionPcs }) => instructionPcs) + if (!sameArray(covered, fn.instructionPcs)) { + decline( + 'invalid-basic-block', + `Function ${fn.id}: basic blocks do not cover its instructions exactly`, + ) + } + return { leaders: sortedLeaders, blocks } +} + +function copyEdge(edge, functionId) { + const copy = { ...edge, functionId } + delete copy.extraWrites + return copy +} + +function serializeRecord(record) { + return { + id: record.id, + ownerFunction: record.ownerFunction, + ...recordView(record), + } +} + +function registerValue(index) { + return { kind: 'register', index } +} + +function makeCallSite(instruction, edges) { + const raw = instruction.words + const method = instruction.name === 'CALL_METHOD' + const destination = raw[1] + const receiver = method ? raw[2] : null + const callee = method ? raw[3] : raw[2] + const argc = method ? raw[4] : raw[3] + const firstArgument = method ? 5 : 4 + const argumentsValue = + argc === 65535 + ? { kind: 'spread', arrayRegister: registerValue(raw[firstArgument]) } + : { + kind: 'fixed', + count: argc, + registers: raw + .slice(firstArgument, firstArgument + argc) + .map(registerValue), + } + const exceptional = edges.find( + (edge) => edge.callSitePc === instruction.pc && edge.route !== 'normal', + ) + return { + pc: instruction.pc, + kind: instruction.name, + destination: registerValue(destination), + callee: registerValue(callee), + calleeFunctions: [], + receiver: receiver === null ? null : registerValue(receiver), + constructorState: { + isConstructor: instruction.name === 'NEW', + mode: instruction.name === 'NEW' ? 'allocate-this' : 'ordinary', + }, + arguments: argumentsValue, + continuationPc: instruction.nextPc, + returnDestination: registerValue(destination), + exceptionalCompletion: exceptional + ? { + kind: exceptional.kind, + route: exceptional.route, + targetPc: exceptional.targetPc, + payload: exceptional.payload, + } + : null, + } +} + +function makeFunctionResult(fn, cfg, records, wordcodeData) { + const edges = cfg.edges.map((edge) => copyEdge(edge, fn.id)) + const blocks = cfg.blocks.map((block) => ({ + ...block, + successors: block.successors.map((edge) => copyEdge(edge, fn.id)), + })) + const callSites = fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter( + (instruction) => + instruction.name === 'CALL' || + instruction.name === 'CALL_METHOD' || + instruction.name === 'NEW', + ) + .map((instruction) => makeCallSite(instruction, cfg.edges)) + const handlerRecords = [...records.values()] + .filter((record) => record.ownerFunction === fn.id) + .map(serializeRecord) + const irreducibleRegions = findIrreducibleRegions(fn, cfg.edges) + return { + id: fn.id, + kind: fn.kind, + startPc: fn.startPc, + endPc: fn.endPc, + parentFunctionId: fn.parentFunctionId, + regCount: fn.regCount, + paramCount: fn.paramCount, + captureCount: fn.captureCount, + hasRest: fn.hasRest, + instructionPcs: [...fn.instructionPcs], + instructionCount: fn.instructionPcs.length, + leaders: [...cfg.leaders], + blocks, + edges, + indirectTargets: cfg.jumps.map((jump) => ({ ...jump })), + callSites, + handlerRecords: handlerRecords.filter(({ type }) => type === 'handler'), + finallyRecords: handlerRecords.filter(({ type }) => type === 'finally'), + irreducible: { + required: irreducibleRegions.length > 0, + fallback: irreducibleRegions.length > 0 ? 'state-machine' : null, + regions: irreducibleRegions, + }, + } +} + +function makeResult( + wordcodeData, + functionsResult, + functionsData, + functionResults, +) { + const allEdges = functionResults.flatMap((fn) => fn.edges) + const allIndirectTargets = functionResults.flatMap((fn) => + fn.indirectTargets.map((jump) => ({ functionId: fn.id, ...jump })), + ) + const allCallSites = functionResults.flatMap((fn) => fn.callSites) + return deepFreeze({ + schemaVersion: CFG_SCHEMA, + encoding: 'numeric-u32', + wordCount: wordcodeData.words.length, + instructionCount: wordcodeData.instructions.length, + functionCount: functionResults.length, + functions: functionResults, + edges: allEdges, + indirectTargets: allIndirectTargets, + callSites: allCallSites, + ownership: functionsResult.ownership.map((ownership) => ({ ...ownership })), + frame: { + frameStart: functionsResult.frame.frameStart, + headerSize: functionsResult.frame.headerSize, + slots: { ...functionsResult.frame.slots }, + root: { ...functionsResult.frame.root }, + }, + irreducibleFunctions: functionResults + .filter(({ irreducible }) => irreducible.required) + .map(({ id }) => id), + source: { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: REFERENCE_SCHEMA, + functionsSchema: FUNCTION_SCHEMA, + functionEntries: functionsData.functions.map( + ({ id, startPc, endPc }) => ({ + id, + startPc, + endPc, + }), + ), + }, + }) +} + +function recognize(wordcode, references, functionsResult) { + const wordcodeData = validateWordcode(wordcode) + const referencesData = validateReferences(references, wordcodeData) + const functionsData = validateFunctions( + functionsResult, + wordcodeData, + referencesData, + ) + validateRegisterRanges(wordcodeData, functionsData) + const records = makeHandlerRecords( + wordcodeData, + functionsData, + referencesData, + ) + const functionResults = [] + for (const fn of functionsData.functions) { + const cfg = solveControlFlow( + fn, + records, + functionsData, + referencesData, + wordcodeData, + ) + const blocks = buildBlocks(fn, cfg, records, wordcodeData) + functionResults.push( + makeFunctionResult(fn, { ...cfg, ...blocks }, records, wordcodeData), + ) + } + return makeResult( + wordcodeData, + functionsResult, + functionsData, + functionResults, + ) +} + +export function diagnoseControlFlow(wordcode, references, functions) { + try { + const result = recognize(wordcode, references, functions) + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof CfgDecline) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + } + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'cfg-builder-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function buildControlFlow(wordcode, references, functions) { + return diagnoseControlFlow(wordcode, references, functions).result +} + +export const buildCfg = buildControlFlow + +export default buildControlFlow diff --git a/src/vm/jsconfuser-vm/call-completion-input.js b/src/vm/jsconfuser-vm/call-completion-input.js new file mode 100644 index 00000000..f254943a --- /dev/null +++ b/src/vm/jsconfuser-vm/call-completion-input.js @@ -0,0 +1,522 @@ +import { analyzeClosureLifetimes } from './analyze-closure-lifetimes.js' +import { buildCallFrames } from './build-call-frames.js' +import { buildControlFlow } from './build-cfg.js' +import { partitionFunctions } from './partition-functions.js' +import { readWordcode } from './read-wordcode.js' +import { validateReferences } from './validate-references.js' + +const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const CALL_FRAME_SCHEMA = 'jsconfuser-vm-call-frames.v1' +export const CLOSURE_LIFETIME_SCHEMA = 'jsconfuser-vm-closure-lifetimes.v1' +export const CALL_COMPLETION_SCHEMA = 'jsconfuser-vm-call-completion.v1' +const UINT32_MAX = 0xffffffff +const CALL_SPREAD = 65535 + +const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +export const CALL_NAMES = new Set(['CALL', 'CALL_METHOD', 'NEW']) +export const COMPLETION_NAMES = new Set(['RETURN', 'THROW']) +export const RELEVANT_NAMES = new Set([...CALL_NAMES, ...COMPLETION_NAMES]) + +export const ALL_OPCODE_NAMES = Object.freeze(Object.keys(CANONICAL_OPCODES)) +export const OMITTED_OPCODE_GROUPS = Object.freeze([ + { + boundary: 'non-call-completion', + names: Object.freeze( + ALL_OPCODE_NAMES.filter((name) => !RELEVANT_NAMES.has(name)), + ), + }, +]) + +export class CallCompletionDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'CallCompletionDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new CallCompletionDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +function isUint32(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +function sameValue(left, right, seen = new WeakMap()) { + if (Object.is(left, right)) return true + if (typeof left !== typeof right || left === null || right === null) { + return false + } + if (typeof left !== 'object') return false + if (seen.get(left) === right) return true + seen.set(left, right) + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => sameValue(value, right[index], seen)) + ) + } + if (!isObject(left) || !isObject(right)) return false + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + return ( + leftKeys.length === rightKeys.length && + rightKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(left, key) && + sameValue(left[key], right[key], seen), + ) + ) +} + +function requireSame(left, right, code, message) { + if (!sameValue(left, right)) decline(code, message) +} + +function canonicalContainer(wordcode, references) { + const frame = references.frame + return { + schemaVersion: CONTAINER_SCHEMA, + encoding: 'numeric-u32', + roles: { + pool: { values: [...references.constants.values] }, + words: { values: [...wordcode.words] }, + op: { values: cloneData(CANONICAL_OPCODES) }, + sentinels: { values: { CALL_SPREAD } }, + slots: { values: CANONICAL_SLOTS }, + scalars: { + MAIN_START_PC: { value: frame.mainStartPc }, + MAIN_REG_COUNT: { value: frame.mainRegCount }, + ENCODE_BYTECODE: { value: false }, + TIMING_CHECKS: { value: false }, + HEADER_SIZE: { value: frame.headerSize }, + FRAME_START: { value: frame.frameStart }, + }, + }, + } +} + +function requirePreflightShape( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, +) { + requireObject(wordcode, 'invalid-wordcode', 'Expected a Packet B result') + requireObject(references, 'invalid-references', 'Expected a Packet C result') + requireObject(functions, 'invalid-functions', 'Expected a Packet D result') + requireObject(cfg, 'invalid-cfg', 'Expected a Packet E result') + requireObject(callFrames, 'invalid-call-frames', 'Expected a Packet F result') + requireObject( + closureLifetimes, + 'invalid-closure-lifetimes', + 'Expected a Packet G result', + ) + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' || + !Array.isArray(wordcode.words) || + wordcode.words.length === 0 || + !wordcode.words.every(isUint32) + ) { + decline('invalid-wordcode', 'Packet B numeric wordcode metadata is invalid') + } + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' || + !isObject(references.constants) || + !Array.isArray(references.constants.values) || + !isObject(references.frame) + ) { + decline( + 'invalid-references', + 'Packet C constants or frame metadata is invalid', + ) + } + if ( + functions.schemaVersion !== FUNCTION_SCHEMA || + functions.encoding !== 'numeric-u32' + ) { + decline('invalid-functions', 'Packet D function metadata is invalid') + } + if (cfg.schemaVersion !== CFG_SCHEMA || cfg.encoding !== 'numeric-u32') { + decline('invalid-cfg', 'Packet E CFG metadata is invalid') + } + if ( + callFrames.schemaVersion !== CALL_FRAME_SCHEMA || + callFrames.encoding !== 'numeric-u32' + ) { + decline('invalid-call-frames', 'Packet F call-frame metadata is invalid') + } + if ( + closureLifetimes.schemaVersion !== CLOSURE_LIFETIME_SCHEMA || + closureLifetimes.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-closure-lifetimes', + 'Packet G closure-lifetime metadata is invalid', + ) + } +} + +export function preflight( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, +) { + requirePreflightShape( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + ) + const container = canonicalContainer(wordcode, references) + const parsedWordcode = readWordcode(container) + if (!parsedWordcode) { + decline( + 'stale-predecessor', + 'Packet B cannot be reconstructed from its numeric word stream', + ) + } + requireSame( + wordcode, + parsedWordcode, + 'stale-predecessor', + 'Packet B is not the canonical parsed wordcode result', + ) + + const parsedReferences = validateReferences(container, parsedWordcode) + if (!parsedReferences) { + decline( + 'stale-predecessor', + 'Packet C cannot be reconstructed from Packet B and its constant pool', + ) + } + requireSame( + references, + parsedReferences, + 'stale-predecessor', + 'Packet C is not the canonical reference/frame result for Packet B', + ) + + const parsedFunctions = partitionFunctions( + container, + parsedWordcode, + parsedReferences, + ) + if (!parsedFunctions) { + decline( + 'stale-predecessor', + 'Packet D cannot be reconstructed from the accepted B/C results', + ) + } + requireSame( + functions, + parsedFunctions, + 'stale-predecessor', + 'Packet D is not the canonical function ownership result', + ) + + const parsedCfg = buildControlFlow( + parsedWordcode, + parsedReferences, + parsedFunctions, + ) + if (!parsedCfg) { + decline( + 'stale-predecessor', + 'Packet E cannot be reconstructed from the accepted B/C/D results', + ) + } + requireSame( + cfg, + parsedCfg, + 'stale-predecessor', + 'Packet E is not the canonical CFG result', + ) + + const parsedCallFrames = buildCallFrames( + parsedWordcode, + parsedReferences, + parsedFunctions, + parsedCfg, + ) + if (!parsedCallFrames) { + decline( + 'stale-predecessor', + 'Packet F cannot be reconstructed from the accepted B/C/D/E results', + ) + } + requireSame( + callFrames, + parsedCallFrames, + 'stale-predecessor', + 'Packet F is not the canonical call-frame result', + ) + + const parsedClosureLifetimes = analyzeClosureLifetimes( + parsedWordcode, + parsedReferences, + parsedFunctions, + parsedCfg, + parsedCallFrames, + ) + if (!parsedClosureLifetimes) { + decline( + 'stale-predecessor', + 'Packet G cannot be reconstructed from the accepted B/C/D/E/F results', + ) + } + requireSame( + closureLifetimes, + parsedClosureLifetimes, + 'stale-predecessor', + 'Packet G is not the canonical closure-lifetime result', + ) + + return { + wordcode: parsedWordcode, + references: parsedReferences, + functions: parsedFunctions, + cfg: parsedCfg, + callFrames: parsedCallFrames, + closureLifetimes: parsedClosureLifetimes, + } +} + +export function ownerMaps(functions, cfg) { + const ownerByPc = new Map() + const functionById = new Map() + for (const fn of functions.functions) { + functionById.set(fn.id, fn) + for (const pc of fn.instructionPcs) { + if (ownerByPc.has(pc)) + decline( + 'invalid-function-ownership', + `Instruction ${pc} has two owners`, + ) + ownerByPc.set(pc, fn.id) + } + } + if (ownerByPc.size !== functions.instructionCount) { + decline( + 'invalid-function-ownership', + 'Packet D ownership does not cover every instruction', + ) + } + + const reachable = new Set() + for (const fn of cfg.functions) { + const edgesBySource = new Map() + for (const edge of fn.edges) { + const edges = edgesBySource.get(edge.sourcePc) ?? [] + edges.push(edge) + edgesBySource.set(edge.sourcePc, edges) + } + const seen = new Set([fn.startPc]) + const queue = [fn.startPc] + while (queue.length > 0) { + const pc = queue.shift() + for (const edge of edgesBySource.get(pc) ?? []) { + if (edge.targetPc !== null && !seen.has(edge.targetPc)) { + seen.add(edge.targetPc) + queue.push(edge.targetPc) + } + } + } + for (const pc of seen) reachable.add(`${fn.id}:${pc}`) + } + return { ownerByPc, functionById, reachable } +} + +export function functionOwner(functionId, functionById, location) { + const fn = functionById.get(functionId) + if (!fn) decline('invalid-function-ownership', `${location} has no owner`) + return { + functionId: fn.id, + kind: fn.kind, + startPc: fn.startPc, + endPc: fn.endPc, + parentFunctionId: fn.parentFunctionId, + regCount: fn.regCount, + paramCount: fn.paramCount, + captureCount: fn.captureCount, + hasRest: fn.hasRest, + } +} + +export function instructionLeaf(instruction, functionId, reachable) { + return { + id: `instruction:${functionId}:${instruction.pc}`, + functionId, + pc: instruction.pc, + name: instruction.name, + opcode: cloneData(instruction.opcode), + words: [...instruction.words], + width: instruction.width, + nextPc: instruction.nextPc, + operands: cloneData(instruction.operands), + wordOperands: cloneData(instruction.wordOperands), + reachable, + kind: RELEVANT_NAMES.has(instruction.name) + ? instruction.name.toLowerCase() + : 'opaque-instruction', + semanticBoundary: 'later-packet', + } +} + +export function callGrammar(instruction, callSite) { + const method = instruction.name === 'CALL_METHOD' + const argcIndex = method ? 4 : 3 + const payloadStart = argcIndex + 1 + const argcWord = instruction.words[argcIndex] + const spread = callSite.arguments.kind === 'spread' + if (spread !== (argcWord === CALL_SPREAD)) { + decline( + 'input-mismatch', + `Call@${instruction.pc} spread grammar disagrees with Packet F`, + ) + } + const payloadWords = instruction.words.slice(payloadStart) + if ( + (spread && payloadWords.length !== 1) || + (!spread && payloadWords.length !== argcWord) + ) { + decline( + 'malformed-call-grammar', + `Call@${instruction.pc} has malformed argument width`, + ) + } + return { + kind: spread ? 'spread' : 'fixed', + argcWord, + sentinel: spread ? CALL_SPREAD : null, + operandOffset: payloadStart, + payloadWords, + count: spread ? null : argcWord, + arrayRegister: + spread && callSite.arguments.arrayRegister + ? cloneData(callSite.arguments.arrayRegister) + : null, + registers: + !spread && callSite.arguments.registers + ? cloneData(callSite.arguments.registers) + : [], + } +} diff --git a/src/vm/jsconfuser-vm/call-frames-preflight.js b/src/vm/jsconfuser-vm/call-frames-preflight.js new file mode 100644 index 00000000..f702d2f9 --- /dev/null +++ b/src/vm/jsconfuser-vm/call-frames-preflight.js @@ -0,0 +1,771 @@ +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +const CALL_SPREAD = 65535 + +const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +const KNOWN_NAMES = new Set([ + 'LOAD_CONST', + 'LOAD_INT', + 'LOAD_GLOBAL', + 'LOAD_UPVALUE', + 'LOAD_THIS', + 'MOVE', + 'STORE_GLOBAL', + 'STORE_UPVALUE', + 'GET_PROP', + 'SET_PROP', + 'DELETE_PROP', + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'IN', + 'INSTANCEOF', + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', + 'TYPEOF_SAFE', + 'JUMP', + 'JUMP_IF_FALSE', + 'JUMP_IF_TRUE', + 'CALL', + 'CALL_METHOD', + 'NEW', + 'RETURN', + 'THROW', + 'MAKE_CLOSURE', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'DEFINE_GETTER', + 'DEFINE_SETTER', + 'FOR_IN_SETUP', + 'FOR_IN_NEXT', + 'TRY_SETUP', + 'TRY_END', + 'PATCH', + 'DEBUGGER', + 'JUMP_REG', + 'FINALLY_SETUP', +]) + +export const CALL_NAMES = new Set(['CALL', 'CALL_METHOD', 'NEW']) + +export const REGISTER_DESTINATION_NAMES = new Set([ + 'LOAD_CONST', + 'LOAD_INT', + 'LOAD_GLOBAL', + 'LOAD_UPVALUE', + 'LOAD_THIS', + 'MOVE', + 'GET_PROP', + 'DELETE_PROP', + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'IN', + 'INSTANCEOF', + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', + 'TYPEOF_SAFE', + 'CALL', + 'CALL_METHOD', + 'NEW', + 'MAKE_CLOSURE', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'FOR_IN_SETUP', + 'FOR_IN_NEXT', +]) + +export const EDGE_KINDS = new Set([ + 'fallthrough', + 'branch', + 'conditional', + 'call', + 'return', + 'throw', + 'handler', + 'finally', +]) + +export class CallFrameDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'CallFrameDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new CallFrameDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +export function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +export function isUint(value) { + return Number.isInteger(value) && value >= 0 && value <= 0xffffffff +} + +function isCount(value) { + return Number.isSafeInteger(value) && value >= 0 +} + +function exactObject(left, right) { + return ( + isObject(left) && + Object.keys(left).length === Object.keys(right).length && + Object.entries(right).every( + ([key, value]) => + Object.prototype.hasOwnProperty.call(left, key) && left[key] === value, + ) + ) +} + +export function sameArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +function requireRegister(value, location) { + requireObject(value, 'malformed-call-site', `${location} is not a register`) + if (value.kind !== 'register' || !isUint(value.index)) { + decline('malformed-call-site', `${location} is not a valid register`) + } + return { kind: 'register', index: value.index } +} + +export function register(index, location) { + if (!isUint(index)) + decline('malformed-call-site', `${location} is not a register`) + return { kind: 'register', index } +} + +function validateCallEncoding(instruction) { + const { name, words, pc } = instruction + if (!CALL_NAMES.has(name)) return null + const method = name === 'CALL_METHOD' + const argcIndex = method ? 4 : 3 + const argumentStart = argcIndex + 1 + const destination = register(words[1], `${name}@${pc} destination`) + const receiver = method ? register(words[2], `${name}@${pc} receiver`) : null + const callee = register(words[method ? 3 : 2], `${name}@${pc} callee`) + const argc = words[argcIndex] + if (!isUint(argc)) decline('malformed-call-site', `${name}@${pc} has no argc`) + const spread = argc === CALL_SPREAD + const expectedWidth = argumentStart + (spread ? 1 : argc) + if (words.length !== expectedWidth) { + decline( + 'malformed-call-site', + `${name}@${pc} has an inconsistent argument payload`, + ) + } + const argumentsValue = spread + ? { + kind: 'spread', + arrayRegister: register( + words[argumentStart], + `${name}@${pc} spread array`, + ), + } + : { + kind: 'fixed', + count: argc, + registers: words + .slice(argumentStart) + .map((value, index) => + register(value, `${name}@${pc} argument ${index}`), + ), + } + + const form = requireObject( + instruction.form, + 'malformed-call-site', + `${name}@${pc} has no form metadata`, + ) + if ( + form.kind !== argumentsValue.kind || + form.argc !== argc || + form.sentinel !== (spread ? CALL_SPREAD : null) || + (spread + ? form.arrayRegister?.index !== argumentsValue.arrayRegister.index + : form.arrayRegister !== null) + ) { + decline( + 'input-mismatch', + `${name}@${pc} form metadata disagrees with words`, + ) + } + const metadataArguments = requireObject( + instruction.arguments, + 'malformed-call-site', + `${name}@${pc} has no argument metadata`, + ) + if ( + metadataArguments.kind !== argumentsValue.kind || + (spread + ? metadataArguments.arrayRegister?.index !== + argumentsValue.arrayRegister.index + : metadataArguments.count !== argc || + !sameArray( + metadataArguments.registers?.map(({ index }) => index), + argumentsValue.registers.map(({ index }) => index), + )) + ) { + decline( + 'input-mismatch', + `${name}@${pc} argument metadata disagrees with words`, + ) + } + if ( + requireRegister(instruction.destination, `${name}@${pc} destination`) + .index !== destination.index + ) { + decline( + 'input-mismatch', + `${name}@${pc} destination metadata disagrees with words`, + ) + } + if ( + requireRegister(instruction.callee, `${name}@${pc} callee`).index !== + callee.index + ) { + decline( + 'input-mismatch', + `${name}@${pc} callee metadata disagrees with words`, + ) + } + if ( + method && + requireRegister(instruction.receiver, `${name}@${pc} receiver`).index !== + receiver.index + ) { + decline( + 'input-mismatch', + `${name}@${pc} receiver metadata disagrees with words`, + ) + } + if (!method && instruction.receiver !== null) { + decline('input-mismatch', `${name}@${pc} has an unexpected receiver`) + } + return { + pc, + kind: name, + destination, + callee, + receiver, + arguments: argumentsValue, + continuationPc: instruction.nextPc, + } +} + +export function validateWordcode(wordcode) { + requireObject( + wordcode, + 'invalid-input', + 'Expected a Packet B wordcode result', + ) + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-wordcode', + 'Input is not a Packet B numeric wordcode result', + ) + } + const words = wordcode.words + if (!Array.isArray(words) || words.length === 0 || !words.every(isUint)) { + decline('invalid-wordcode', 'Packet B has no complete numeric word stream') + } + if ( + wordcode.wordCount !== words.length || + wordcode.consumedWords !== words.length || + wordcode.nextPc !== words.length || + !Array.isArray(wordcode.instructions) || + wordcode.instructionCount !== wordcode.instructions.length || + wordcode.instructions.length === 0 + ) { + decline('invalid-wordcode', 'Packet B consumption metadata is inconsistent') + } + + const instructions = [] + const byPc = new Map() + let nextPc = 0 + for (const input of wordcode.instructions) { + requireObject( + input, + 'malformed-instruction', + 'Packet B has a malformed instruction', + ) + if ( + !isCount(input.pc) || + input.pc !== nextPc || + !Array.isArray(input.words) || + input.words.length === 0 || + !input.words.every(isUint) || + input.width !== input.words.length || + input.nextPc !== input.pc + input.width || + !sameArray(input.words, words.slice(input.pc, input.nextPc)) || + input.name === undefined || + !KNOWN_NAMES.has(input.name) || + input.words[0] !== input.opcode?.value || + input.opcode?.name !== input.name + ) { + decline( + 'input-mismatch', + `Packet B instruction at ${String(input.pc)} is malformed or stale`, + ) + } + const parsedCall = validateCallEncoding(input) + const instruction = { ...input, parsedCall } + instructions.push(instruction) + byPc.set(input.pc, instruction) + nextPc = input.nextPc + } + if (nextPc !== words.length) { + decline('invalid-wordcode', 'Packet B does not consume the complete stream') + } + return { + words, + instructions, + byPc, + boundaries: instructions.map(({ pc }) => pc), + } +} + +export function validateReferences(references, wordcodeData) { + requireObject( + references, + 'invalid-input', + 'Expected a Packet C reference/frame result', + ) + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-references', + 'Input is not a Packet C numeric reference result', + ) + } + if ( + references.wordCount !== wordcodeData.words.length || + references.instructionCount !== wordcodeData.instructions.length + ) { + decline('input-mismatch', 'Packet C counts do not match Packet B') + } + const labels = requireObject( + references.labels, + 'invalid-references', + 'Packet C has no label section', + ) + if (!sameArray(labels.boundaries, wordcodeData.boundaries)) { + decline('input-mismatch', 'Packet C boundaries do not match Packet B') + } + const frame = requireObject( + references.frame, + 'invalid-frame-metadata', + 'Packet C has no frame section', + ) + if ( + frame.frameStart !== 1 || + frame.headerSize !== 8 || + frame.mainStartPc !== 0 || + !isCount(frame.mainRegCount) || + frame.mainRegCount < 1 || + !exactObject(frame.slots, CANONICAL_SLOTS) + ) { + decline( + 'invalid-frame-metadata', + 'Packet C frame metadata is not canonical', + ) + } + const root = requireObject( + frame.root, + 'invalid-frame-metadata', + 'Packet C root frame is missing', + ) + const expectedRootSize = frame.headerSize + frame.mainRegCount + if ( + root.frameBase !== frame.frameStart || + root.frameSize !== expectedRootSize || + root.registerBase !== frame.frameStart + frame.headerSize || + root.registerWindow?.start !== root.registerBase || + root.registerWindow?.end !== root.registerBase + frame.mainRegCount || + root.frameEnd !== frame.frameStart + expectedRootSize + ) { + decline( + 'invalid-frame-metadata', + 'Packet C root frame arithmetic is invalid', + ) + } + + const descriptors = frame.descriptors + if (!Array.isArray(descriptors)) { + decline('invalid-frame-metadata', 'Packet C descriptors are missing') + } + const descriptorsByStart = new Map() + const descriptorCreationPcs = new Set() + for (const descriptor of descriptors) { + requireObject( + descriptor, + 'invalid-descriptor', + 'Packet C contains a malformed closure descriptor', + ) + if ( + !isUint(descriptor.creationPc) || + !isUint(descriptor.startPc) || + !wordcodeData.byPc.has(descriptor.creationPc) || + !wordcodeData.byPc.has(descriptor.startPc) || + wordcodeData.byPc.get(descriptor.creationPc).name !== 'MAKE_CLOSURE' || + !isCount(descriptor.paramCount) || + !isCount(descriptor.regCount) || + descriptor.regCount < 1 || + descriptor.paramCount > descriptor.regCount || + !isCount(descriptor.captureCount) || + typeof descriptor.hasRest !== 'boolean' || + !Array.isArray(descriptor.captures) || + descriptor.captures.length !== descriptor.captureCount || + descriptor.frameSize !== frame.headerSize + descriptor.regCount || + descriptor.registerBaseOffset !== frame.headerSize || + descriptorsByStart.has(descriptor.startPc) || + descriptorCreationPcs.has(descriptor.creationPc) + ) { + decline( + 'invalid-descriptor', + 'Packet C contains inconsistent closure metadata', + ) + } + for (const capture of descriptor.captures) { + if ( + !isObject(capture) || + !['local', 'upvalue'].includes(capture.kind) || + !isUint(capture.index) || + capture.isLocal !== (capture.kind === 'local') + ) { + decline( + 'invalid-descriptor', + 'Packet C contains an invalid capture pair', + ) + } + } + descriptorsByStart.set(descriptor.startPc, descriptor) + descriptorCreationPcs.add(descriptor.creationPc) + } + return { frame, descriptors, descriptorsByStart } +} + +export function validateFunctions( + functionsResult, + wordcodeData, + referencesData, +) { + requireObject( + functionsResult, + 'invalid-input', + 'Expected a Packet D function ownership result', + ) + if ( + functionsResult.schemaVersion !== FUNCTION_SCHEMA || + functionsResult.encoding !== 'numeric-u32' || + functionsResult.wordCount !== wordcodeData.words.length || + functionsResult.instructionCount !== wordcodeData.instructions.length || + !Array.isArray(functionsResult.functions) || + functionsResult.functions.length === 0 || + !sameArray( + functionsResult.entryPcs, + functionsResult.functions.map(({ startPc }) => startPc), + ) + ) { + decline( + 'invalid-functions', + 'Input is not a complete Packet D function result', + ) + } + + const functionById = new Map() + const ownerByPc = new Map() + let previousEnd = null + for (let id = 0; id < functionsResult.functions.length; id += 1) { + const input = functionsResult.functions[id] + requireObject( + input, + 'invalid-function-boundary', + `Function ${id} is malformed`, + ) + const expectedPcs = wordcodeData.instructions + .filter(({ pc }) => pc >= input.startPc && pc < input.endPc) + .map(({ pc }) => pc) + if ( + input.id !== id || + !['root', 'closure'].includes(input.kind) || + !isUint(input.startPc) || + !isUint(input.endPc) || + !wordcodeData.byPc.has(input.startPc) || + (input.endPc !== wordcodeData.words.length && + !wordcodeData.byPc.has(input.endPc)) || + input.endPc <= input.startPc || + (previousEnd !== null && input.startPc !== previousEnd) || + !isCount(input.regCount) || + input.regCount < 1 || + !isCount(input.paramCount) || + input.paramCount > input.regCount || + !isCount(input.captureCount) || + typeof input.hasRest !== 'boolean' || + !sameArray(input.instructionPcs, expectedPcs) || + input.instructionCount !== expectedPcs.length || + expectedPcs.length === 0 + ) { + decline( + 'invalid-function-boundary', + `Function ${id} does not own its interval exactly`, + ) + } + if ( + id === 0 && + (input.kind !== 'root' || + input.startPc !== referencesData.frame.mainStartPc || + input.parentFunctionId !== null) + ) { + decline('invalid-function-boundary', 'Packet D root metadata is invalid') + } + if (input.kind === 'closure') { + const descriptor = input.descriptor + const referenceDescriptor = referencesData.descriptorsByStart.get( + input.startPc, + ) + if ( + !isObject(descriptor) || + !referenceDescriptor || + descriptor.startPc !== input.startPc || + descriptor.paramCount !== input.paramCount || + descriptor.regCount !== input.regCount || + descriptor.captureCount !== input.captureCount || + descriptor.hasRest !== input.hasRest || + descriptor.creationPc !== referenceDescriptor.creationPc || + !sameArray( + descriptor.captures?.map((capture) => JSON.stringify(capture)), + referenceDescriptor.captures.map((capture) => + JSON.stringify(capture), + ), + ) + ) { + decline( + 'input-mismatch', + `Function ${id} descriptor disagrees with Packet C`, + ) + } + } else if (input.descriptor !== null) { + decline( + 'invalid-function-boundary', + 'Root function has a closure descriptor', + ) + } + for (const pc of expectedPcs) { + if (ownerByPc.has(pc)) { + decline('invalid-function-boundary', `Instruction ${pc} has two owners`) + } + ownerByPc.set(pc, id) + } + functionById.set(id, { + id, + kind: input.kind, + startPc: input.startPc, + endPc: input.endPc, + regCount: input.regCount, + paramCount: input.paramCount, + captureCount: input.captureCount, + hasRest: input.hasRest, + parentFunctionId: input.parentFunctionId, + instructionPcs: expectedPcs, + input, + }) + previousEnd = input.endPc + } + if ( + ownerByPc.size !== wordcodeData.instructions.length || + previousEnd !== wordcodeData.words.length + ) { + decline( + 'invalid-function-boundary', + 'Packet D does not cover the stream exactly', + ) + } + for (const fn of functionById.values()) { + if ( + fn.kind === 'closure' && + (!Number.isInteger(fn.parentFunctionId) || + !functionById.has(fn.parentFunctionId) || + fn.parentFunctionId >= fn.id) + ) { + decline( + 'invalid-function-boundary', + `Function ${fn.id} has an invalid parent`, + ) + } + } + + if ( + !Array.isArray(functionsResult.ownership) || + functionsResult.ownership.length !== wordcodeData.instructions.length + ) { + decline('invalid-function-boundary', 'Packet D ownership is incomplete') + } + for (let index = 0; index < functionsResult.ownership.length; index += 1) { + const ownership = functionsResult.ownership[index] + if ( + !isObject(ownership) || + ownership.pc !== wordcodeData.instructions[index].pc || + ownership.functionId !== ownerByPc.get(ownership.pc) + ) { + decline('input-mismatch', 'Packet D ownership disagrees with Packet B') + } + } + + const closureSitesByPc = new Map() + for (const fn of functionById.values()) { + const sites = fn.input.closureSites + if (!Array.isArray(sites)) { + decline( + 'invalid-function-boundary', + `Function ${fn.id} has no closure sites`, + ) + } + for (const site of sites) { + requireObject( + site, + 'invalid-function-boundary', + 'Packet D has a malformed closure site', + ) + const instruction = wordcodeData.byPc.get(site.creationPc) + const child = functionById.get(site.childFunctionId) + if ( + closureSitesByPc.has(site.creationPc) || + !instruction || + instruction.name !== 'MAKE_CLOSURE' || + site.parentFunctionId !== fn.id || + ownerByPc.get(site.creationPc) !== fn.id || + !child || + child.startPc !== instruction.words[2] || + child.parentFunctionId !== fn.id || + site.childStartPc !== child.startPc || + site.destinationRegister !== instruction.words[1] + ) { + decline( + 'input-mismatch', + `Packet D closure site at ${site.creationPc} is invalid`, + ) + } + const descriptor = referencesData.descriptorsByStart.get(child.startPc) + if (!descriptor || descriptor.creationPc !== site.creationPc) { + decline( + 'invalid-descriptor', + `Closure site ${site.creationPc} has no Packet C descriptor`, + ) + } + closureSitesByPc.set(site.creationPc, { + ...site, + parentFunctionId: fn.id, + }) + } + } + for (const instruction of wordcodeData.instructions) { + if ( + instruction.name === 'MAKE_CLOSURE' && + !closureSitesByPc.has(instruction.pc) + ) { + decline( + 'invalid-function-boundary', + `MAKE_CLOSURE@${instruction.pc} has no ownership site`, + ) + } + } + return { + functions: [...functionById.values()], + functionById, + ownerByPc, + closureSitesByPc, + } +} diff --git a/src/vm/jsconfuser-vm/closure-exception-records.js b/src/vm/jsconfuser-vm/closure-exception-records.js new file mode 100644 index 00000000..b99f8f9c --- /dev/null +++ b/src/vm/jsconfuser-vm/closure-exception-records.js @@ -0,0 +1,642 @@ +import { analyzeClosureLifetimes } from './analyze-closure-lifetimes.js' +import { analyzeExceptionFinally } from './analyze-exception-finally.js' +import { buildCallFrames } from './build-call-frames.js' +import { buildControlFlow } from './build-cfg.js' +import { partitionFunctions } from './partition-functions.js' +import { readWordcode } from './read-wordcode.js' +import { validateReferences } from './validate-references.js' + +const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const CALL_FRAME_SCHEMA = 'jsconfuser-vm-call-frames.v1' +export const CLOSURE_SCHEMA = 'jsconfuser-vm-closure-lifetimes.v1' +export const EXCEPTION_SCHEMA = 'jsconfuser-vm-exception-finally.v1' +export const EMISSION_SCHEMA = 'jsconfuser-vm-closure-exception-emission.v1' + +const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +export const SETUP_NAMES = new Set(['TRY_SETUP', 'FINALLY_SETUP']) +export const COMPLETION_NAMES = new Set(['RETURN', 'THROW']) +const UINT32_MAX = 0xffffffff +export const SEMANTIC_BOUNDARY = 'later-packet' + +export class ClosureExceptionDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'ClosureExceptionDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new ClosureExceptionDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function isUint32(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +function sameValue(left, right, seen = new WeakMap()) { + if (Object.is(left, right)) return true + if (typeof left !== typeof right || left === null || right === null) { + return false + } + if (typeof left !== 'object') return false + if (seen.get(left) === right) return true + seen.set(left, right) + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => sameValue(value, right[index], seen)) + ) + } + if (!isObject(left) || !isObject(right)) return false + const leftKeys = Object.keys(left) + const rightKeys = Object.keys(right) + return ( + leftKeys.length === rightKeys.length && + rightKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(left, key) && + sameValue(left[key], right[key], seen), + ) + ) +} + +function requireSame(actual, expected, code, message) { + if (!sameValue(actual, expected)) decline(code, message) +} + +export function register(index) { + return { kind: 'register', index } +} + +function canonicalContainer(wordcode, references) { + const frame = references.frame + return { + schemaVersion: CONTAINER_SCHEMA, + encoding: 'numeric-u32', + roles: { + pool: { values: [...references.constants.values] }, + words: { values: [...wordcode.words] }, + op: { values: cloneData(CANONICAL_OPCODES) }, + sentinels: { values: { CALL_SPREAD: 65535 } }, + slots: { values: CANONICAL_SLOTS }, + scalars: { + MAIN_START_PC: { value: frame.mainStartPc }, + MAIN_REG_COUNT: { value: frame.mainRegCount }, + ENCODE_BYTECODE: { value: false }, + TIMING_CHECKS: { value: false }, + HEADER_SIZE: { value: frame.headerSize }, + FRAME_START: { value: frame.frameStart }, + }, + }, + } +} + +function requirePreflightShape( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, +) { + requireObject(wordcode, 'invalid-wordcode', 'Expected a Packet B result') + requireObject(references, 'invalid-references', 'Expected a Packet C result') + requireObject(functions, 'invalid-functions', 'Expected a Packet D result') + requireObject(cfg, 'invalid-cfg', 'Expected a Packet E result') + requireObject(callFrames, 'invalid-call-frames', 'Expected a Packet F result') + requireObject( + closureLifetimes, + 'invalid-closure-lifetimes', + 'Expected a Packet G result', + ) + requireObject( + exceptionFinally, + 'invalid-exception-finally', + 'Expected a Packet H result', + ) + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' || + !Array.isArray(wordcode.words) || + wordcode.words.length === 0 || + !wordcode.words.every(isUint32) + ) { + decline('invalid-wordcode', 'Packet B numeric wordcode metadata is invalid') + } + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' || + !isObject(references.constants) || + !Array.isArray(references.constants.values) || + !isObject(references.frame) + ) { + decline( + 'invalid-references', + 'Packet C constants or frame metadata is invalid', + ) + } + if ( + functions.schemaVersion !== FUNCTION_SCHEMA || + functions.encoding !== 'numeric-u32' + ) { + decline('invalid-functions', 'Packet D function metadata is invalid') + } + if (cfg.schemaVersion !== CFG_SCHEMA || cfg.encoding !== 'numeric-u32') { + decline('invalid-cfg', 'Packet E CFG metadata is invalid') + } + if ( + callFrames.schemaVersion !== CALL_FRAME_SCHEMA || + callFrames.encoding !== 'numeric-u32' + ) { + decline('invalid-call-frames', 'Packet F call-frame metadata is invalid') + } + if ( + closureLifetimes.schemaVersion !== CLOSURE_SCHEMA || + closureLifetimes.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-closure-lifetimes', + 'Packet G closure-lifetime metadata is invalid', + ) + } + if ( + exceptionFinally.schemaVersion !== EXCEPTION_SCHEMA || + exceptionFinally.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-exception-finally', + 'Packet H exception/finally metadata is invalid', + ) + } +} + +export function preflight( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, +) { + requirePreflightShape( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, + ) + const container = canonicalContainer(wordcode, references) + const parsedWordcode = readWordcode(container) + if (!parsedWordcode) { + decline( + 'stale-predecessor', + 'Packet B cannot be reconstructed from its numeric word stream', + ) + } + requireSame( + wordcode, + parsedWordcode, + 'stale-predecessor', + 'Packet B is not the canonical parsed wordcode result', + ) + + const parsedReferences = validateReferences(container, parsedWordcode) + if (!parsedReferences) { + decline( + 'stale-predecessor', + 'Packet C cannot be reconstructed from Packet B and its constant pool', + ) + } + requireSame( + references, + parsedReferences, + 'stale-predecessor', + 'Packet C is not the canonical reference/frame result for Packet B', + ) + + const parsedFunctions = partitionFunctions( + container, + parsedWordcode, + parsedReferences, + ) + if (!parsedFunctions) { + decline( + 'stale-predecessor', + 'Packet D cannot be reconstructed from the accepted B/C results', + ) + } + requireSame( + functions, + parsedFunctions, + 'stale-predecessor', + 'Packet D is not the canonical function ownership result', + ) + + const parsedCfg = buildControlFlow( + parsedWordcode, + parsedReferences, + parsedFunctions, + ) + if (!parsedCfg) { + decline( + 'stale-predecessor', + 'Packet E cannot be reconstructed from the accepted B/C/D results', + ) + } + requireSame( + cfg, + parsedCfg, + 'stale-predecessor', + 'Packet E is not the canonical CFG result', + ) + + const parsedCallFrames = buildCallFrames( + parsedWordcode, + parsedReferences, + parsedFunctions, + parsedCfg, + ) + if (!parsedCallFrames) { + decline( + 'stale-predecessor', + 'Packet F cannot be reconstructed from the accepted B/C/D/E results', + ) + } + requireSame( + callFrames, + parsedCallFrames, + 'stale-predecessor', + 'Packet F is not the canonical call-frame result', + ) + + const parsedClosures = analyzeClosureLifetimes( + parsedWordcode, + parsedReferences, + parsedFunctions, + parsedCfg, + parsedCallFrames, + ) + if (!parsedClosures) { + decline( + 'stale-predecessor', + 'Packet G cannot be reconstructed from the accepted B/C/D/E/F results', + ) + } + requireSame( + closureLifetimes, + parsedClosures, + 'stale-predecessor', + 'Packet G is not the canonical closure-lifetime result', + ) + + const parsedExceptions = analyzeExceptionFinally( + parsedWordcode, + parsedReferences, + parsedFunctions, + parsedCfg, + parsedCallFrames, + ) + if (!parsedExceptions) { + decline( + 'stale-predecessor', + 'Packet H cannot be reconstructed from the accepted B/C/D/E/F results', + ) + } + requireSame( + exceptionFinally, + parsedExceptions, + 'stale-predecessor', + 'Packet H is not the canonical exception/finally result', + ) + + return { + wordcode: parsedWordcode, + references: parsedReferences, + functions: parsedFunctions, + cfg: parsedCfg, + callFrames: parsedCallFrames, + closureLifetimes: parsedClosures, + exceptionFinally: parsedExceptions, + } +} + +export function functionMaps(functions) { + const functionById = new Map() + const ownerByPc = new Map() + for (const fn of functions.functions) { + functionById.set(fn.id, fn) + for (const pc of fn.instructionPcs) ownerByPc.set(pc, fn.id) + } + if (ownerByPc.size !== functions.instructionCount) { + decline( + 'invalid-function-ownership', + 'Packet D ownership does not cover every instruction', + ) + } + return { functionById, ownerByPc } +} + +export function stateKey(functionId, pc) { + return `${functionId}:${pc}` +} + +function hStateMaps(exceptionFinally) { + const instructionStates = new Map() + for (const state of exceptionFinally.instructionStates) { + const key = stateKey(state.functionId, state.pc) + if (instructionStates.has(key)) { + decline( + 'duplicated-state', + `Packet H duplicates instruction state ${key}`, + ) + } + instructionStates.set(key, state) + } + return { instructionStates } +} + +export function edgeKey(edge) { + return JSON.stringify({ + functionId: edge.functionId, + kind: edge.kind, + sourcePc: edge.sourcePc, + targetPc: edge.targetPc, + route: edge.route, + mode: edge.mode, + callSitePc: edge.callSitePc, + continuationPc: edge.continuationPc, + payload: edge.payload, + }) +} + +function routeIdentity(edge) { + return `route:${edgeKey(edge)}` +} + +export function routeRecords(exceptionFinally) { + const edgeRecords = exceptionFinally.edgeStates.map((edge) => ({ + id: routeIdentity(edge), + ...cloneData(edge), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const byKey = new Map() + for (const edge of edgeRecords) { + const key = edgeKey(edge) + if (byKey.has(key)) + decline('duplicated-route', `Packet H duplicates edge ${key}`) + byKey.set(key, edge.id) + } + const completionRoutes = exceptionFinally.completionRoutes.map((route) => { + const id = byKey.get(edgeKey(route)) + if (!id) { + decline( + 'invalid-route-identity', + `Packet H completion route at ${route.sourcePc} has no edge identity`, + ) + } + return { id, ...cloneData(route), semanticBoundary: SEMANTIC_BOUNDARY } + }) + return { edgeRecords, completionRoutes } +} + +function instructionKind(name) { + if (name === 'MAKE_CLOSURE') return 'closure-creation' + if (name === 'TRY_SETUP') return 'handler-setup' + if (name === 'TRY_END') return 'handler-finally-end' + if (name === 'FINALLY_SETUP') return 'finally-setup' + if (COMPLETION_NAMES.has(name)) return 'completion' + return 'opaque-opcode' +} + +export function instructionRecords(wordcode, functions, exceptionFinally) { + const { ownerByPc } = functionMaps(functions) + const { instructionStates } = hStateMaps(exceptionFinally) + const records = wordcode.instructions.map((instruction) => { + const functionId = ownerByPc.get(instruction.pc) + const state = instructionStates.get(stateKey(functionId, instruction.pc)) + if (!state) { + decline( + 'incomplete-instruction-state', + `Packet H has no state for instruction ${functionId}:${instruction.pc}`, + ) + } + return { + id: `instruction:${functionId}:${instruction.pc}`, + kind: instructionKind(instruction.name), + functionId, + ...cloneData(instruction), + reachable: state.reachable, + handlerStateBefore: cloneData(state.handlerStateBefore), + handlerStateAfter: cloneData(state.handlerStateAfter), + semanticBoundary: SEMANTIC_BOUNDARY, + } + }) + const keys = records.map(({ functionId, pc }) => stateKey(functionId, pc)) + if (new Set(keys).size !== keys.length) { + decline( + 'duplicated-instruction', + 'Instruction emission records are duplicated', + ) + } + if ( + records.length !== wordcode.instructions.length || + !wordcode.instructions.every(({ pc }) => + records.some((record) => record.pc === pc), + ) + ) { + decline( + 'incomplete-instruction', + 'Instruction emission coverage is incomplete', + ) + } + return records +} + +export function closureRecords(closureLifetimes) { + const pairsById = new Map() + for (const pair of closureLifetimes.capturePairs) { + if (pairsById.has(pair.id)) + decline('duplicated-capture', `Duplicate capture ${pair.id}`) + pairsById.set(pair.id, pair) + } + const sites = closureLifetimes.closureSites.map((site) => { + const captures = site.capturePairIds.map((pairId) => { + const pair = pairsById.get(pairId) + if (!pair) decline('incomplete-capture', `Missing capture ${pairId}`) + return cloneData(pair) + }) + return { + id: site.id, + kind: 'lexical-closure-creation', + creationPc: site.creationPc, + parentFunctionId: site.parentFunctionId, + childFunctionId: site.childFunctionId, + childStartPc: site.childStartPc, + destinationRegister: cloneData(site.destinationRegister), + reachable: site.reachable, + capturePairIds: [...site.capturePairIds], + captures, + escapeEvents: cloneData(site.escapeEvents), + semanticBoundary: SEMANTIC_BOUNDARY, + } + }) + const captures = closureLifetimes.capturePairs.map((pair) => ({ + id: pair.id, + kind: 'capture-edge', + creationPc: pair.creationPc, + parentFunctionId: pair.parentFunctionId, + childFunctionId: pair.childFunctionId, + childStartPc: pair.childStartPc, + childClosureId: pair.childClosureId, + captureIndex: pair.captureIndex, + source: cloneData(pair.source), + bindingId: pair.bindingId, + bindingOwnerFunctionId: pair.bindingOwnerFunctionId, + bindingRegister: cloneData(pair.bindingRegister), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const bindings = closureLifetimes.bindings.map((binding) => ({ + id: binding.id, + kind: 'lexical-binding', + ownerFunctionId: binding.ownerFunctionId, + register: cloneData(binding.register), + capturePairIds: [...binding.capturePairIds], + useIds: [...binding.useIds], + lifecycle: cloneData(binding.lifecycle), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const uses = closureLifetimes.uses.map((use) => ({ + id: use.id, + kind: 'upvalue-use', + ...cloneData(use), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const terminations = closureLifetimes.terminationEvents.map((event) => ({ + id: `lifetime:${event.bindingId}:${event.functionId}:${event.sourcePc}`, + kind: 'closure-lifetime-transition', + fromState: 'open-upvalue', + toState: 'closed-captured-value', + ...cloneData(event), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + return { + sites, + captures, + bindings, + uses, + terminations, + } +} diff --git a/src/vm/jsconfuser-vm/closure-lifetimes-flow.js b/src/vm/jsconfuser-vm/closure-lifetimes-flow.js new file mode 100644 index 00000000..bdde86c0 --- /dev/null +++ b/src/vm/jsconfuser-vm/closure-lifetimes-flow.js @@ -0,0 +1,555 @@ +import { + CALL_NAMES, + REGISTER_RESULT_NAMES, + UPVALUE_NAMES, + cloneData, + decline, + destinationFor, + register, +} from './closure-lifetimes-input.js' +import { + sameAbstractValue, + sameEnvironment, +} from './analyze-closure-lifetimes.js' + +export function routeMatches(edge, route) { + return ( + edge.kind === route.kind && + edge.sourcePc === route.sourcePc && + edge.targetPc === route.targetPc && + edge.route === route.route && + edge.mode === route.mode && + (edge.callSitePc ?? null) === (route.callSitePc ?? null) && + (edge.continuationPc ?? null) === (route.continuationPc ?? null) + ) +} + +function bindingId(ownerFunctionId, index) { + return `binding:${ownerFunctionId}:register:${index}` +} + +function closureId(site) { + return `closure:${site.parentFunctionId}:${site.creationPc}` +} + +export function resolveBindings(functionData) { + const resolved = new Map() + const resolving = new Set() + const bindings = new Map() + + function resolve(functionId, captureIndex) { + const key = `${functionId}:${captureIndex}` + if (resolved.has(key)) return resolved.get(key) + if (resolving.has(key)) + decline('incomplete-capture', `Capture resolution cycles at ${key}`) + resolving.add(key) + const fn = functionData.byId.get(functionId) + if (!fn || fn.kind !== 'closure' || !fn.descriptor) { + decline('incomplete-capture', `Capture ${key} has no closure descriptor`) + } + if ( + captureIndex >= fn.captureCount || + !fn.descriptor.captures[captureIndex] + ) { + decline('incomplete-capture', `Capture ${key} is missing its pair`) + } + const capture = fn.descriptor.captures[captureIndex] + let result + if (capture.kind === 'local') { + const owner = functionData.byId.get(fn.parentFunctionId) + if (!owner || capture.index >= owner.regCount) { + decline( + 'incomplete-capture', + `Local capture ${key} has no parent register owner`, + ) + } + result = { + bindingId: bindingId(owner.id, capture.index), + ownerFunctionId: owner.id, + ownerRegister: capture.index, + source: { + kind: 'local', + parentFunctionId: owner.id, + register: register(capture.index), + }, + } + } else if (capture.kind === 'upvalue') { + const parent = functionData.byId.get(fn.parentFunctionId) + if ( + !parent || + parent.kind !== 'closure' || + capture.index >= parent.captureCount + ) { + decline( + 'incomplete-capture', + `Upvalue capture ${key} has no parent upvalue owner`, + ) + } + const parentBinding = resolve(parent.id, capture.index) + result = { + bindingId: parentBinding.bindingId, + ownerFunctionId: parentBinding.ownerFunctionId, + ownerRegister: parentBinding.ownerRegister, + source: { + kind: 'upvalue', + parentFunctionId: parent.id, + upvalueIndex: capture.index, + }, + } + } else { + decline('invalid-capture-pair', `Capture ${key} has an unknown kind`) + } + resolving.delete(key) + resolved.set(key, result) + if (!bindings.has(result.bindingId)) { + bindings.set(result.bindingId, { + id: result.bindingId, + ownerFunctionId: result.ownerFunctionId, + ownerRegister: result.ownerRegister, + capturePairIds: [], + useIds: [], + }) + } + return result + } + + return { resolve, bindings } +} + +export function buildCapturePairs(functionData, bindingData, cfgData) { + const capturePairs = [] + const closureSites = [] + for (const site of functionData.closureSitesByPc.values()) { + const child = functionData.byId.get(site.childFunctionId) + if (!child?.descriptor || child.descriptor.creationPc !== site.creationPc) { + decline( + 'incomplete-capture', + `Closure site ${site.creationPc} has no matching descriptor`, + ) + } + const siteId = closureId(site) + const siteReachable = cfgData.reachableByFunction + .get(site.parentFunctionId) + .has(site.creationPc) + const pairIds = [] + child.descriptor.captures.forEach((capture, captureIndex) => { + const binding = bindingData.resolve(child.id, captureIndex) + const pairId = `capture:${site.parentFunctionId}:${site.creationPc}:${captureIndex}` + const pair = { + id: pairId, + creationPc: site.creationPc, + reachable: siteReachable, + parentFunctionId: site.parentFunctionId, + childFunctionId: site.childFunctionId, + childStartPc: site.childStartPc, + childClosureId: siteId, + captureIndex, + source: cloneData(binding.source), + bindingId: binding.bindingId, + bindingOwnerFunctionId: binding.ownerFunctionId, + bindingRegister: register(binding.ownerRegister), + } + capturePairs.push(pair) + pairIds.push(pairId) + bindingData.bindings.get(binding.bindingId).capturePairIds.push(pairId) + }) + closureSites.push({ + id: siteId, + creationPc: site.creationPc, + parentFunctionId: site.parentFunctionId, + childFunctionId: site.childFunctionId, + childStartPc: site.childStartPc, + destinationRegister: register(site.destinationRegister), + reachable: siteReachable, + capturePairIds: pairIds, + escapeEvents: [], + }) + } + return { capturePairs, closureSites } +} + +export function collectUses(wordcodeData, functionData, cfgData, bindingData) { + const uses = [] + for (const fn of functionData.functions) { + for (const pc of fn.instructionPcs) { + const instruction = wordcodeData.byPc.get(pc) + if (!UPVALUE_NAMES.has(instruction.name)) continue + const upvalueIndex = + instruction.operands[instruction.name === 'LOAD_UPVALUE' ? 1 : 0]?.index + if (!Number.isInteger(upvalueIndex) || upvalueIndex >= fn.captureCount) { + decline( + 'use-without-owner', + `Upvalue use at PC ${pc} has no capture owner`, + ) + } + const resolved = bindingData.resolve(fn.id, upvalueIndex) + const use = { + id: `use:${fn.id}:${pc}`, + pc, + functionId: fn.id, + operation: instruction.name === 'LOAD_UPVALUE' ? 'read' : 'write', + upvalueIndex, + register: register( + instruction.operands[instruction.name === 'LOAD_UPVALUE' ? 0 : 1] + .index, + ), + bindingId: resolved.bindingId, + reachable: cfgData.reachableByFunction.get(fn.id).has(pc), + state: null, + possibleStates: [], + } + uses.push(use) + bindingData.bindings.get(resolved.bindingId).useIds.push(use.id) + } + } + return uses +} + +export function terminalEvents(cfgData, bindingData) { + for (const binding of bindingData.bindings.values()) { + const closeEvents = cfgData.edges + .filter( + ({ functionId, targetPc, kind }) => + functionId === binding.ownerFunctionId && + targetPc === null && + (kind === 'return' || kind === 'throw'), + ) + .map((edge) => ({ + sourcePc: edge.sourcePc, + functionId: edge.functionId, + kind: edge.kind, + completion: edge.kind === 'return' ? 'return' : 'throw', + route: edge.route, + mode: edge.mode, + callSitePc: edge.callSitePc ?? null, + })) + if (closeEvents.length === 0) { + decline( + 'unknown-lifetime', + `Binding ${binding.id} has no proved close event`, + ) + } + binding.closeEvents = closeEvents + binding.lifecycle = { + initialState: 'open-upvalue', + closeEvents, + afterCloseState: 'closed-captured-value', + runtimeIdentity: 'owner-function-register-slot', + } + } +} + +function emptyValue() { + return { precise: true, sites: new Set() } +} + +function unknownValue() { + return { precise: false, sites: new Set() } +} + +function closureValue(siteId) { + return { precise: true, sites: new Set([siteId]) } +} + +function joinValues(left, right) { + return { + precise: left.precise && right.precise, + sites: new Set([...left.sites, ...right.sites]), + } +} + +function joinEnvironments(left, right, regCount) { + const result = new Map() + for (let index = 0; index < regCount; index += 1) { + result.set( + index, + joinValues( + left.get(index) ?? unknownValue(), + right.get(index) ?? unknownValue(), + ), + ) + } + return result +} + +function cloneEnvironment(environment) { + return new Map( + [...environment].map(([index, value]) => [ + index, + { precise: value.precise, sites: new Set(value.sites) }, + ]), + ) +} + +function readRegister(environment, operand) { + return operand?.kind === 'register' + ? (environment.get(operand.index) ?? unknownValue()) + : unknownValue() +} + +function instructionInputRegisters(instruction) { + const operands = instruction.operands + if (instruction.name === 'BUILD_ARRAY') return operands.slice(2) + if (instruction.name === 'BUILD_OBJECT') return operands.slice(2) + if (instruction.name === 'STORE_GLOBAL') return operands.slice(2) + if ( + instruction.name === 'SET_PROP' || + instruction.name === 'DEFINE_GETTER' || + instruction.name === 'DEFINE_SETTER' + ) + return operands.slice(2) + if (instruction.name === 'CALL') return [operands[1], ...operands.slice(3)] + if (instruction.name === 'CALL_METHOD') + return [operands[0], operands[1], ...operands.slice(4)] + if (instruction.name === 'NEW') return [operands[1], ...operands.slice(3)] + if (instruction.name === 'RETURN' || instruction.name === 'THROW') + return [operands[0]] + if (instruction.name === 'MOVE') return [operands[1]] + return [] +} + +function callArgumentOperands(callSite, instruction) { + if (callSite.arguments?.kind === 'fixed') { + const registers = callSite.arguments.registers ?? [] + return registers.map((item) => ({ kind: 'register', index: item.index })) + } + if (callSite.arguments?.kind === 'spread') { + return [{ kind: 'register', index: callSite.arguments.arrayRegister.index }] + } + return instructionInputRegisters(instruction).slice( + instruction.name === 'CALL_METHOD' ? 2 : 1, + ) +} + +function unionInputValues(environment, operands) { + if (operands.length === 0) return emptyValue() + return operands + .map((operand) => readRegister(environment, operand)) + .reduce((left, right) => joinValues(left, right), emptyValue()) +} + +function emitClosureEvents(events, siteIds, event) { + for (const siteId of siteIds) { + const key = `${siteId}:${event.kind}:${event.functionId}:${event.pc}:${event.provenEscape}` + if (!events.has(key)) events.set(key, { siteId, ...event }) + } +} + +function transferInstruction( + environment, + instruction, + edge, + callFrameData, + summaries, + events, +) { + const next = cloneEnvironment(environment) + const addUseEvent = (value, event) => { + if (value.precise) emitClosureEvents(events, value.sites, event) + } + const callSite = CALL_NAMES.has(instruction.name) + ? callFrameData.callSiteByPc.get(instruction.pc) + : null + if (callSite) { + const calleeValue = readRegister( + environment, + instruction.operands[instruction.name === 'CALL_METHOD' ? 2 : 1], + ) + addUseEvent(calleeValue, { + kind: + callSite.calleeFunctions.length > 0 + ? 'invoke-internal' + : 'invoke-external', + functionId: callSite.functionId, + pc: instruction.pc, + provenEscape: false, + targetFunctions: [...callSite.calleeFunctions], + }) + const argumentValue = unionInputValues( + environment, + callArgumentOperands(callSite, instruction), + ) + addUseEvent(argumentValue, { + kind: + callSite.calleeFunctions.length > 0 + ? 'internal-call-argument' + : 'external-call-argument', + functionId: callSite.functionId, + pc: instruction.pc, + provenEscape: false, + targetFunctions: [...callSite.calleeFunctions], + }) + if (instruction.name === 'CALL_METHOD') { + addUseEvent(readRegister(environment, instruction.operands[0]), { + kind: + callSite.calleeFunctions.length > 0 + ? 'internal-call-receiver' + : 'external-call-receiver', + functionId: callSite.functionId, + pc: instruction.pc, + provenEscape: false, + targetFunctions: [...callSite.calleeFunctions], + }) + } + if (edge.kind === 'call') { + const returnValue = callSite.calleeFunctions + .map((id) => summaries.get(id) ?? unknownValue()) + .reduce((left, right) => joinValues(left, right), emptyValue()) + next.set(callSite.destination.index, returnValue) + } else { + next.set(callSite.destination.index, unknownValue()) + } + return next + } + + if (instruction.name === 'MAKE_CLOSURE') { + next.set( + destinationFor(instruction).index, + closureValue( + closureId({ + parentFunctionId: edge.functionId, + creationPc: instruction.pc, + }), + ), + ) + } else if (instruction.name === 'MOVE') { + next.set( + destinationFor(instruction).index, + readRegister(environment, instruction.operands[1]), + ) + } else if ( + instruction.name === 'BUILD_ARRAY' || + instruction.name === 'BUILD_OBJECT' + ) { + next.set( + destinationFor(instruction).index, + unionInputValues(environment, instructionInputRegisters(instruction)), + ) + } else if ( + instruction.name === 'LOAD_THIS' || + instruction.name === 'LOAD_INT' || + instruction.name === 'LOAD_CONST' + ) { + next.set(destinationFor(instruction).index, emptyValue()) + } else if (REGISTER_RESULT_NAMES.has(instruction.name)) { + next.set(destinationFor(instruction).index, unknownValue()) + } + + if (instruction.name === 'STORE_GLOBAL') { + addUseEvent(readRegister(environment, instruction.operands.at(-1)), { + kind: 'global-store', + functionId: edge.functionId, + pc: instruction.pc, + provenEscape: true, + targetFunctions: [], + }) + } + if ( + instruction.name === 'SET_PROP' || + instruction.name === 'DEFINE_GETTER' || + instruction.name === 'DEFINE_SETTER' + ) { + addUseEvent(readRegister(environment, instruction.operands.at(-1)), { + kind: 'property-store', + functionId: edge.functionId, + pc: instruction.pc, + provenEscape: false, + targetFunctions: [], + }) + } + if (instruction.name === 'RETURN') { + addUseEvent(readRegister(environment, instruction.operands[0]), { + kind: 'return', + functionId: edge.functionId, + pc: instruction.pc, + provenEscape: true, + targetFunctions: [], + }) + } + return next +} + +export function analyzeProvenance( + wordcodeData, + functionData, + cfgData, + callFrameData, +) { + let summaries = new Map( + functionData.functions.map((fn) => [fn.id, unknownValue()]), + ) + let finalStates = new Map() + let finalEvents = new Map() + let converged = false + const maxIterations = functionData.functions.length * 4 + 4 + for (let iteration = 0; iteration < maxIterations; iteration += 1) { + const states = new Map() + const events = new Map() + const nextSummaries = new Map() + for (const fn of functionData.functions) { + const entry = new Map() + for (let index = 0; index < fn.regCount; index += 1) + entry.set(index, unknownValue()) + const stateAt = new Map([[fn.startPc, entry]]) + const queue = [fn.startPc] + const fnEdges = cfgData.edgesByFunction.get(fn.id) + while (queue.length > 0) { + const pc = queue.shift() + const environment = stateAt.get(pc) + const instruction = wordcodeData.byPc.get(pc) + for (const edge of fnEdges.filter(({ sourcePc }) => sourcePc === pc)) { + const outgoing = transferInstruction( + environment, + instruction, + edge, + callFrameData, + summaries, + events, + ) + if (edge.targetPc === null) continue + const previous = stateAt.get(edge.targetPc) + const joined = previous + ? joinEnvironments(previous, outgoing, fn.regCount) + : outgoing + if (!previous || !sameEnvironment(previous, joined, fn.regCount)) { + stateAt.set(edge.targetPc, joined) + queue.push(edge.targetPc) + } + } + } + states.set(fn.id, stateAt) + const returnRecords = callFrameData.functions[fn.id].returns.filter( + ({ reachable }) => reachable, + ) + if (returnRecords.length === 0) { + nextSummaries.set(fn.id, unknownValue()) + } else { + let summary = emptyValue() + for (const record of returnRecords) { + const environment = stateAt.get(record.pc) ?? new Map() + summary = joinValues( + summary, + readRegister(environment, record.sourceRegister), + ) + } + nextSummaries.set(fn.id, summary) + } + } + const stable = functionData.functions.every((fn) => + sameAbstractValue(summaries.get(fn.id), nextSummaries.get(fn.id)), + ) + summaries = nextSummaries + finalStates = states + finalEvents = events + if (stable) { + converged = true + break + } + } + if (!converged) + decline( + 'nonconvergent-dataflow', + 'Closure provenance did not reach a finite fixed point', + ) + return { summaries, states: finalStates, events: finalEvents } +} diff --git a/src/vm/jsconfuser-vm/closure-lifetimes-input.js b/src/vm/jsconfuser-vm/closure-lifetimes-input.js new file mode 100644 index 00000000..66a34918 --- /dev/null +++ b/src/vm/jsconfuser-vm/closure-lifetimes-input.js @@ -0,0 +1,823 @@ +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const CALL_FRAME_SCHEMA = 'jsconfuser-vm-call-frames.v1' +export const CLOSURE_SCHEMA = 'jsconfuser-vm-closure-lifetimes.v1' + +export const FRAME_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const CALL_NAMES = new Set(['CALL', 'CALL_METHOD', 'NEW']) +export const UPVALUE_NAMES = new Set(['LOAD_UPVALUE', 'STORE_UPVALUE']) +export const EDGE_KINDS = new Set([ + 'fallthrough', + 'branch', + 'conditional', + 'call', + 'return', + 'throw', + 'handler', + 'finally', +]) + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +export const REGISTER_RESULT_NAMES = new Set([ + 'LOAD_CONST', + 'LOAD_INT', + 'LOAD_GLOBAL', + 'LOAD_UPVALUE', + 'LOAD_THIS', + 'MOVE', + 'GET_PROP', + 'DELETE_PROP', + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'IN', + 'INSTANCEOF', + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', + 'TYPEOF_SAFE', + 'CALL', + 'CALL_METHOD', + 'NEW', + 'MAKE_CLOSURE', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'FOR_IN_SETUP', + 'FOR_IN_NEXT', +]) + +export class ClosureLifetimeDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'ClosureLifetimeDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new ClosureLifetimeDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +export function requireArray(value, code, message) { + if (!Array.isArray(value)) decline(code, message) + return value +} + +export function requireInteger(value, code, message) { + if (!Number.isInteger(value) || value < 0) decline(code, message) + return value +} + +export function requireBoolean(value, code, message) { + if (typeof value !== 'boolean') decline(code, message) + return value +} + +export function sameValue(actual, expected) { + if (actual === expected) return true + if (Array.isArray(actual) && Array.isArray(expected)) { + return ( + actual.length === expected.length && + actual.every((value, index) => sameValue(value, expected[index])) + ) + } + if (isObject(actual) && isObject(expected)) { + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + sameValue(actual[key], expected[key]), + ) + ) + } + return false +} + +export function requireSame(actual, expected, code, message) { + if (!sameValue(actual, expected)) decline(code, message) +} + +export function register(index) { + return { kind: 'register', index } +} + +export function destinationFor(instruction) { + return instruction.destination ?? instruction.operands[0] +} + +export function arraySet(values) { + return new Set(values) +} + +export function sortedNumbers(values) { + return [...values].sort((left, right) => left - right) +} + +export function sortedStrings(values) { + return [...values].sort() +} + +export function validateWordcode(wordcode) { + requireObject(wordcode, 'invalid-wordcode', 'Packet B must be an object') + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' + ) { + decline('invalid-wordcode', 'Packet B schema or encoding is not accepted') + } + requireInteger( + wordcode.wordCount, + 'invalid-wordcode', + 'Packet B wordCount is invalid', + ) + requireInteger( + wordcode.instructionCount, + 'invalid-wordcode', + 'Packet B instructionCount is invalid', + ) + const words = requireArray( + wordcode.words, + 'invalid-wordcode', + 'Packet B has no copied word stream', + ) + if (words.length !== wordcode.wordCount) { + decline('invalid-wordcode', 'Packet B wordCount does not match words') + } + words.forEach((word) => { + if (!Number.isInteger(word) || word < 0 || word > 0xffffffff) { + decline('invalid-wordcode', 'Packet B contains a non-u32 word') + } + }) + const instructions = requireArray( + wordcode.instructions, + 'invalid-wordcode', + 'Packet B has no instructions', + ) + if (instructions.length !== wordcode.instructionCount) { + decline( + 'invalid-wordcode', + 'Packet B instructionCount does not match instructions', + ) + } + + let expectedPc = 0 + const byPc = new Map() + for (const instruction of instructions) { + requireObject( + instruction, + 'invalid-wordcode', + 'Packet B instruction is malformed', + ) + requireInteger( + instruction.pc, + 'invalid-wordcode', + 'Packet B instruction PC is invalid', + ) + if (instruction.pc !== expectedPc || byPc.has(instruction.pc)) { + decline( + 'invalid-wordcode', + 'Packet B instruction boundaries are not contiguous', + ) + } + const name = instruction.name + if (typeof name !== 'string' || CANONICAL_OPCODES[name] === undefined) { + decline( + 'invalid-wordcode', + `Packet B has unknown opcode at PC ${instruction.pc}`, + ) + } + if (name === 'PATCH') { + decline('invalid-wordcode', 'Packet B contains a forbidden PATCH opcode') + } + requireObject( + instruction.opcode, + 'invalid-wordcode', + 'Packet B opcode is malformed', + ) + requireSame( + instruction.opcode, + { name, value: CANONICAL_OPCODES[name] }, + 'invalid-wordcode', + `Packet B opcode metadata is stale at PC ${instruction.pc}`, + ) + const encodedWords = requireArray( + instruction.words, + 'invalid-wordcode', + `Packet B words are missing at PC ${instruction.pc}`, + ) + requireInteger( + instruction.width, + 'invalid-wordcode', + 'Packet B instruction width is invalid', + ) + if ( + instruction.width !== encodedWords.length || + instruction.nextPc !== instruction.pc + instruction.width || + instruction.nextPc > wordcode.wordCount || + encodedWords.length === 0 + ) { + decline( + 'invalid-wordcode', + `Packet B instruction width is stale at PC ${instruction.pc}`, + ) + } + requireSame( + encodedWords, + words.slice(instruction.pc, instruction.nextPc), + 'invalid-wordcode', + `Packet B instruction words are stale at PC ${instruction.pc}`, + ) + requireArray( + instruction.operands, + 'invalid-wordcode', + 'Packet B operands are missing', + ) + requireArray( + instruction.wordOperands, + 'invalid-wordcode', + 'Packet B word operands are missing', + ) + if (REGISTER_RESULT_NAMES.has(name)) { + const destination = destinationFor(instruction) + if (!destination || destination.kind !== 'register') { + decline( + 'invalid-wordcode', + `Packet B result destination is missing at PC ${instruction.pc}`, + ) + } + } + if (name === 'MAKE_CLOSURE') validateClosureInstruction(instruction) + if (name === 'LOAD_UPVALUE' || name === 'STORE_UPVALUE') { + validateUpvalueInstruction(instruction) + } + byPc.set(instruction.pc, instruction) + expectedPc = instruction.nextPc + } + if (expectedPc !== wordcode.wordCount) { + decline( + 'invalid-wordcode', + 'Packet B does not consume the complete word stream', + ) + } + return { + words, + instructions, + byPc, + boundaries: arraySet(instructions.map(({ pc }) => pc)), + } +} + +function validateClosureInstruction(instruction) { + const metadata = requireObject( + instruction.functionMeta, + 'invalid-capture-pair', + `MAKE_CLOSURE metadata is missing at PC ${instruction.pc}`, + ) + const form = requireObject( + instruction.form, + 'invalid-capture-pair', + `MAKE_CLOSURE form is missing at PC ${instruction.pc}`, + ) + if ( + form.kind !== 'closure' || + !sameValue( + { + startPc: form.startPc, + paramCount: form.paramCount, + regCount: form.regCount, + captureCount: form.captureCount, + hasRest: form.hasRest, + }, + metadata, + ) + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE form is stale at PC ${instruction.pc}`, + ) + } + for (const key of ['startPc', 'paramCount', 'regCount', 'captureCount']) { + requireInteger( + metadata[key], + 'invalid-capture-pair', + `MAKE_CLOSURE ${key} is invalid at PC ${instruction.pc}`, + ) + } + requireBoolean( + metadata.hasRest, + 'invalid-capture-pair', + `MAKE_CLOSURE hasRest is invalid at PC ${instruction.pc}`, + ) + if ( + metadata.startPc !== instruction.words[2] || + metadata.paramCount !== instruction.words[3] + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE header disagrees at PC ${instruction.pc}`, + ) + } + if ( + metadata.regCount !== instruction.words[4] || + metadata.captureCount !== instruction.words[5] || + Number(metadata.hasRest) !== instruction.words[6] + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE capture metadata disagrees at PC ${instruction.pc}`, + ) + } + const captures = requireArray( + instruction.captures, + 'invalid-capture-pair', + `MAKE_CLOSURE captures are missing at PC ${instruction.pc}`, + ) + const capturePairs = requireArray( + instruction.capturePairs, + 'invalid-capture-pair', + `MAKE_CLOSURE capturePairs are missing at PC ${instruction.pc}`, + ) + if ( + captures.length !== metadata.captureCount || + capturePairs.length !== metadata.captureCount || + instruction.words.length !== 7 + metadata.captureCount * 2 + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE capture count is incomplete at PC ${instruction.pc}`, + ) + } + requireSame( + captures, + capturePairs, + 'invalid-capture-pair', + `MAKE_CLOSURE capture views diverge at PC ${instruction.pc}`, + ) + captures.forEach((capture, index) => { + requireObject( + capture, + 'invalid-capture-pair', + 'MAKE_CLOSURE capture is malformed', + ) + if (capture.kind !== 'local' && capture.kind !== 'upvalue') { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE capture kind is invalid at PC ${instruction.pc}`, + ) + } + requireInteger( + capture.index, + 'invalid-capture-pair', + `MAKE_CLOSURE capture index is invalid at PC ${instruction.pc}`, + ) + if (capture.isLocal !== (capture.kind === 'local')) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE capture identity is inconsistent at PC ${instruction.pc}`, + ) + } + const pairOffset = 7 + index * 2 + if ( + instruction.words[pairOffset] !== Number(capture.isLocal) || + instruction.words[pairOffset + 1] !== capture.index + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE raw pair is stale at PC ${instruction.pc}`, + ) + } + }) +} + +function validateUpvalueInstruction(instruction) { + const operands = instruction.operands + if ( + operands.length !== 2 || + operands[0]?.kind !== + (instruction.name === 'LOAD_UPVALUE' ? 'register' : 'upvalue-index') || + operands[1]?.kind !== + (instruction.name === 'LOAD_UPVALUE' ? 'upvalue-index' : 'register') + ) { + decline( + 'invalid-wordcode', + `Packet B upvalue operand shape is invalid at PC ${instruction.pc}`, + ) + } + for (const operand of operands) { + requireInteger( + operand.index, + 'invalid-wordcode', + `Packet B upvalue operand index is invalid at PC ${instruction.pc}`, + ) + } +} + +export function validateReferences(references, wordcodeData) { + requireObject(references, 'invalid-references', 'Packet C must be an object') + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' || + references.wordCount !== wordcodeData.words.length || + references.instructionCount !== wordcodeData.instructions.length + ) { + decline( + 'stale-predecessor', + 'Packet C does not describe the supplied Packet B', + ) + } + const frame = requireObject( + references.frame, + 'invalid-references', + 'Packet C frame metadata is missing', + ) + if ( + frame.frameStart !== 1 || + frame.headerSize !== 8 || + frame.mainStartPc !== 0 || + !sameValue(frame.slots, FRAME_SLOTS) + ) { + decline('invalid-references', 'Packet C frame layout is not canonical') + } + requireInteger( + frame.mainRegCount, + 'invalid-references', + 'Packet C main register count is invalid', + ) + const root = requireObject( + frame.root, + 'invalid-references', + 'Packet C root frame is missing', + ) + if ( + root.frameBase !== 1 || + root.registerBase !== 9 || + root.registerWindow?.start !== 9 || + root.registerWindow?.end !== 9 + frame.mainRegCount || + root.frameSize !== 8 + frame.mainRegCount || + root.frameEnd !== root.registerWindow.end + ) { + decline( + 'invalid-references', + 'Packet C root frame arithmetic is inconsistent', + ) + } + const descriptors = requireArray( + frame.descriptors, + 'invalid-references', + 'Packet C closure descriptors are missing', + ) + const closureInstructions = wordcodeData.instructions.filter( + ({ name }) => name === 'MAKE_CLOSURE', + ) + if (descriptors.length !== closureInstructions.length) { + decline( + 'missing-capture-descriptor', + 'Packet C does not describe every MAKE_CLOSURE', + ) + } + const descriptorsByCreation = new Map() + for (const descriptor of descriptors) { + requireObject( + descriptor, + 'invalid-references', + 'Packet C descriptor is malformed', + ) + for (const key of [ + 'creationPc', + 'startPc', + 'paramCount', + 'regCount', + 'captureCount', + 'frameSize', + 'registerBaseOffset', + ]) { + requireInteger( + descriptor[key], + 'invalid-references', + `Packet C descriptor ${key} is invalid`, + ) + } + requireBoolean( + descriptor.hasRest, + 'invalid-references', + 'Packet C descriptor hasRest is invalid', + ) + const instruction = wordcodeData.byPc.get(descriptor.creationPc) + if (!instruction || instruction.name !== 'MAKE_CLOSURE') { + decline( + 'missing-capture-descriptor', + 'Packet C descriptor has no MAKE_CLOSURE owner', + ) + } + if ( + descriptorsByCreation.has(descriptor.creationPc) || + descriptor.startPc !== instruction.functionMeta.startPc || + descriptor.paramCount !== instruction.functionMeta.paramCount || + descriptor.regCount !== instruction.functionMeta.regCount || + descriptor.captureCount !== instruction.functionMeta.captureCount || + descriptor.hasRest !== instruction.functionMeta.hasRest || + descriptor.frameSize !== 8 + descriptor.regCount || + descriptor.registerBaseOffset !== 8 + ) { + decline( + 'stale-predecessor', + `Packet C descriptor disagrees at PC ${descriptor.creationPc}`, + ) + } + requireSame( + descriptor.captures, + instruction.captures, + 'stale-predecessor', + `Packet C capture descriptor disagrees at PC ${descriptor.creationPc}`, + ) + descriptorsByCreation.set(descriptor.creationPc, descriptor) + } + for (const instruction of closureInstructions) { + if (!descriptorsByCreation.has(instruction.pc)) { + decline( + 'missing-capture-descriptor', + `Packet C is missing descriptor at PC ${instruction.pc}`, + ) + } + } + + validateReferenceSections(references, wordcodeData) + return { frame, descriptorsByCreation } +} + +function validateReferenceSections(references, wordcodeData) { + const constants = requireObject( + references.constants, + 'invalid-references', + 'Packet C constants are missing', + ) + requireInteger( + constants.poolSize, + 'invalid-references', + 'Packet C constant pool size is invalid', + ) + const constantValues = requireArray( + constants.values, + 'invalid-references', + 'Packet C constant values are missing', + ) + if (constantValues.length !== constants.poolSize) { + decline('invalid-references', 'Packet C constant pool is incomplete') + } + const constantRefs = requireArray( + constants.references, + 'invalid-references', + 'Packet C constant references are missing', + ) + for (const reference of constantRefs) { + requireObject( + reference, + 'invalid-references', + 'Packet C constant reference is malformed', + ) + requireInteger( + reference.pc, + 'invalid-references', + 'Packet C constant reference PC is invalid', + ) + requireInteger( + reference.operand, + 'invalid-references', + 'Packet C constant reference operand is invalid', + ) + requireInteger( + reference.index, + 'invalid-references', + 'Packet C constant reference index is invalid', + ) + if ( + !wordcodeData.byPc.has(reference.pc) || + reference.index >= constants.poolSize + ) { + decline( + 'invalid-references', + 'Packet C constant reference is out of range', + ) + } + } + const registers = requireObject( + references.registers, + 'invalid-references', + 'Packet C register references are missing', + ) + for (const key of ['rootCount', 'maxCount', 'maxCaptureCount']) { + requireInteger( + registers[key], + 'invalid-references', + `Packet C ${key} is invalid`, + ) + } + if ( + registers.rootCount !== references.frame.mainRegCount || + registers.maxCount < registers.rootCount + ) { + decline('invalid-references', 'Packet C register summary is inconsistent') + } + for (const reference of requireArray( + registers.references, + 'invalid-references', + 'Packet C register references are missing', + )) { + requireObject( + reference, + 'invalid-references', + 'Packet C register reference is malformed', + ) + requireInteger( + reference.pc, + 'invalid-references', + 'Packet C register reference PC is invalid', + ) + requireInteger( + reference.operand, + 'invalid-references', + 'Packet C register reference operand is invalid', + ) + requireInteger( + reference.index, + 'invalid-references', + 'Packet C register reference index is invalid', + ) + if (!wordcodeData.byPc.has(reference.pc)) + decline('invalid-references', 'Packet C register reference PC is unknown') + } + const labels = requireObject( + references.labels, + 'invalid-references', + 'Packet C labels are missing', + ) + const boundaries = requireArray( + labels.boundaries, + 'invalid-references', + 'Packet C label boundaries are missing', + ) + requireSame( + boundaries, + [...wordcodeData.boundaries], + 'stale-predecessor', + 'Packet C label boundaries are stale', + ) + for (const reference of requireArray( + labels.references, + 'invalid-references', + 'Packet C label references are missing', + )) { + requireObject( + reference, + 'invalid-references', + 'Packet C label reference is malformed', + ) + requireInteger( + reference.pc, + 'invalid-references', + 'Packet C label reference PC is invalid', + ) + requireInteger( + reference.operand, + 'invalid-references', + 'Packet C label reference operand is invalid', + ) + requireInteger( + reference.target, + 'invalid-references', + 'Packet C label target is invalid', + ) + if ( + !wordcodeData.byPc.has(reference.pc) || + !wordcodeData.boundaries.has(reference.target) + ) { + decline('invalid-references', 'Packet C label reference is out of range') + } + } +} diff --git a/src/vm/jsconfuser-vm/closure-lifetimes-ownership.js b/src/vm/jsconfuser-vm/closure-lifetimes-ownership.js new file mode 100644 index 00000000..f62c83bc --- /dev/null +++ b/src/vm/jsconfuser-vm/closure-lifetimes-ownership.js @@ -0,0 +1,695 @@ +import { + CALL_FRAME_SCHEMA, + CALL_NAMES, + CFG_SCHEMA, + EDGE_KINDS, + FRAME_SLOTS, + FUNCTION_SCHEMA, + REFERENCE_SCHEMA, + WORDCODE_SCHEMA, + arraySet, + decline, + destinationFor, + requireArray, + requireBoolean, + requireInteger, + requireObject, + requireSame, + sameValue, +} from './closure-lifetimes-input.js' +import { routeMatches } from './closure-lifetimes-flow.js' + +export function validateFunctions( + functionsResult, + wordcodeData, + referenceData, +) { + requireObject( + functionsResult, + 'invalid-functions', + 'Packet D must be an object', + ) + if ( + functionsResult.schemaVersion !== FUNCTION_SCHEMA || + functionsResult.encoding !== 'numeric-u32' || + functionsResult.wordCount !== wordcodeData.words.length || + functionsResult.instructionCount !== wordcodeData.instructions.length + ) { + decline( + 'stale-predecessor', + 'Packet D does not describe the supplied predecessor data', + ) + } + const functions = requireArray( + functionsResult.functions, + 'invalid-functions', + 'Packet D functions are missing', + ) + if (functionsResult.entryPcs?.[0] !== 0) { + decline('invalid-functions', 'Packet D has no root function at PC 0') + } + const byId = new Map() + const ownerByPc = new Map() + for (const fn of functions) { + requireObject(fn, 'invalid-functions', 'Packet D function is malformed') + requireInteger( + fn.id, + 'invalid-functions', + 'Packet D function id is invalid', + ) + if (fn.id !== byId.size || byId.has(fn.id)) + decline('invalid-functions', 'Packet D function ids are not canonical') + if (fn.kind !== 'root' && fn.kind !== 'closure') + decline('invalid-functions', 'Packet D function kind is invalid') + for (const key of [ + 'startPc', + 'endPc', + 'paramCount', + 'regCount', + 'captureCount', + 'instructionCount', + ]) { + requireInteger( + fn[key], + 'invalid-functions', + `Packet D function ${key} is invalid`, + ) + } + if ( + fn.endPc <= fn.startPc || + fn.instructionCount !== fn.instructionPcs?.length + ) { + decline( + 'invalid-functions', + `Packet D function interval is malformed for ${fn.id}`, + ) + } + requireBoolean( + fn.hasRest, + 'invalid-functions', + 'Packet D hasRest is invalid', + ) + if ( + fn.kind === 'root' + ? fn.parentFunctionId !== null + : !Number.isInteger(fn.parentFunctionId) + ) { + decline( + 'inconsistent-ownership', + `Packet D parent ownership is malformed for ${fn.id}`, + ) + } + const descriptor = fn.descriptor + if (fn.kind === 'root') { + if (descriptor !== null || fn.id !== 0 || fn.startPc !== 0) + decline( + 'inconsistent-ownership', + 'Packet D root metadata is inconsistent', + ) + } else { + requireObject( + descriptor, + 'missing-capture-descriptor', + `Packet D descriptor is missing for ${fn.id}`, + ) + const descriptorFromReference = [ + ...referenceData.descriptorsByCreation.values(), + ].find((candidate) => candidate.startPc === fn.startPc) + if ( + !descriptorFromReference || + !sameValue(descriptor, descriptorFromReference) + ) { + decline( + 'stale-predecessor', + `Packet D descriptor is stale for ${fn.id}`, + ) + } + } + byId.set(fn.id, fn) + const pcs = requireArray( + fn.instructionPcs, + 'invalid-functions', + 'Packet D instructionPcs are missing', + ) + let previousPc = null + for (const pc of pcs) { + requireInteger( + pc, + 'invalid-functions', + 'Packet D instruction PC is invalid', + ) + const instruction = wordcodeData.byPc.get(pc) + if ( + !instruction || + pc < fn.startPc || + pc >= fn.endPc || + (previousPc !== null && pc <= previousPc) || + ownerByPc.has(pc) + ) { + decline( + 'inconsistent-ownership', + `Packet D instruction ownership is inconsistent at PC ${pc}`, + ) + } + ownerByPc.set(pc, fn.id) + previousPc = pc + } + } + if (ownerByPc.size !== wordcodeData.instructions.length) { + decline( + 'inconsistent-ownership', + 'Packet D does not own every Packet B instruction', + ) + } + const ownership = requireArray( + functionsResult.ownership, + 'invalid-functions', + 'Packet D ownership is missing', + ) + requireSame( + ownership, + wordcodeData.instructions.map(({ pc }) => ({ + pc, + functionId: ownerByPc.get(pc), + })), + 'inconsistent-ownership', + 'Packet D ownership list is stale', + ) + const closureSitesByPc = new Map() + for (const fn of functions) { + const sites = requireArray( + fn.closureSites, + 'invalid-functions', + `Packet D closure sites are missing for ${fn.id}`, + ) + const makeSites = fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter(({ name }) => name === 'MAKE_CLOSURE') + if (sites.length !== makeSites.length) + decline( + 'inconsistent-ownership', + `Packet D closure sites are incomplete for ${fn.id}`, + ) + for (const site of sites) { + requireObject( + site, + 'invalid-functions', + 'Packet D closure site is malformed', + ) + for (const key of [ + 'creationPc', + 'parentFunctionId', + 'childFunctionId', + 'childStartPc', + 'destinationRegister', + ]) { + requireInteger( + site[key], + 'invalid-functions', + `Packet D closure site ${key} is invalid`, + ) + } + const instruction = wordcodeData.byPc.get(site.creationPc) + const child = byId.get(site.childFunctionId) + if ( + !instruction || + instruction.name !== 'MAKE_CLOSURE' || + ownerByPc.get(site.creationPc) !== fn.id || + site.parentFunctionId !== fn.id || + !child || + child.kind !== 'closure' || + child.startPc !== site.childStartPc || + child.parentFunctionId !== fn.id || + destinationFor(instruction).index !== site.destinationRegister || + closureSitesByPc.has(site.creationPc) + ) { + decline( + 'inconsistent-ownership', + `Packet D closure ownership is inconsistent at PC ${site.creationPc}`, + ) + } + closureSitesByPc.set(site.creationPc, site) + } + } + for (const instruction of wordcodeData.instructions) { + if ( + instruction.name === 'MAKE_CLOSURE' && + !closureSitesByPc.has(instruction.pc) + ) { + decline( + 'inconsistent-ownership', + `Packet D is missing closure ownership at PC ${instruction.pc}`, + ) + } + } + for (const fn of functions) { + if ( + fn.kind === 'closure' && + ![...closureSitesByPc.values()].some( + ({ childFunctionId }) => childFunctionId === fn.id, + ) + ) { + decline( + 'inconsistent-ownership', + `Packet D closure ${fn.id} has no creation site`, + ) + } + } + return { functions, byId, ownerByPc, closureSitesByPc } +} + +export function validateCfg(cfg, wordcodeData, functionData) { + requireObject(cfg, 'invalid-cfg', 'Packet E must be an object') + if ( + cfg.schemaVersion !== CFG_SCHEMA || + cfg.encoding !== 'numeric-u32' || + cfg.wordCount !== wordcodeData.words.length || + cfg.instructionCount !== wordcodeData.instructions.length || + cfg.functionCount !== functionData.functions.length + ) { + decline( + 'stale-predecessor', + 'Packet E does not describe the supplied predecessor data', + ) + } + requireSame( + cfg.ownership, + [...functionData.ownerByPc].map(([pc, functionId]) => ({ pc, functionId })), + 'stale-predecessor', + 'Packet E ownership is stale', + ) + const cfgFunctions = requireArray( + cfg.functions, + 'invalid-cfg', + 'Packet E functions are missing', + ) + if (cfgFunctions.length !== functionData.functions.length) + decline('invalid-cfg', 'Packet E function list is incomplete') + const edges = requireArray( + cfg.edges, + 'invalid-cfg', + 'Packet E edges are missing', + ) + const edgesByFunction = new Map( + functionData.functions.map((fn) => [fn.id, []]), + ) + for (const edge of edges) { + requireObject(edge, 'invalid-cfg', 'Packet E edge is malformed') + if (!EDGE_KINDS.has(edge.kind)) + decline('invalid-cfg', 'Packet E edge kind is unknown') + requireInteger( + edge.sourcePc, + 'invalid-cfg', + 'Packet E edge source is invalid', + ) + if (edge.targetPc !== null) + requireInteger( + edge.targetPc, + 'invalid-cfg', + 'Packet E edge target is invalid', + ) + requireInteger( + edge.functionId, + 'invalid-cfg', + 'Packet E edge function is invalid', + ) + const sourceOwner = functionData.ownerByPc.get(edge.sourcePc) + if ( + sourceOwner !== edge.functionId || + !wordcodeData.byPc.has(edge.sourcePc) + ) { + decline( + 'inconsistent-ownership', + `Packet E edge source ownership is inconsistent at PC ${edge.sourcePc}`, + ) + } + if ( + edge.targetPc !== null && + functionData.ownerByPc.get(edge.targetPc) !== edge.functionId + ) { + decline( + 'inconsistent-ownership', + `Packet E edge target crosses function ownership at PC ${edge.targetPc}`, + ) + } + edgesByFunction.get(edge.functionId).push(edge) + } + const reachableByFunction = new Map() + for (const fn of functionData.functions) { + const cfgFn = cfgFunctions[fn.id] + requireObject( + cfgFn, + 'invalid-cfg', + `Packet E function ${fn.id} is malformed`, + ) + if ( + cfgFn.id !== fn.id || + cfgFn.startPc !== fn.startPc || + cfgFn.endPc !== fn.endPc || + !sameValue(cfgFn.instructionPcs, fn.instructionPcs) + ) { + decline('stale-predecessor', `Packet E function ${fn.id} is stale`) + } + const reachable = arraySet([fn.startPc]) + const queue = [fn.startPc] + while (queue.length > 0) { + const pc = queue.shift() + for (const edge of edgesByFunction.get(fn.id)) { + if ( + edge.sourcePc !== pc || + edge.targetPc === null || + reachable.has(edge.targetPc) + ) + continue + reachable.add(edge.targetPc) + queue.push(edge.targetPc) + } + } + for (const edge of edgesByFunction.get(fn.id)) { + if ( + !reachable.has(edge.sourcePc) || + (edge.targetPc !== null && !reachable.has(edge.targetPc)) + ) { + decline( + 'invalid-cfg', + `Packet E contains an unreachable edge for function ${fn.id}`, + ) + } + } + reachableByFunction.set(fn.id, reachable) + } + const callSites = requireArray( + cfg.callSites, + 'invalid-cfg', + 'Packet E call sites are missing', + ) + const expectedCallPcs = wordcodeData.instructions + .filter(({ name }) => CALL_NAMES.has(name)) + .map(({ pc }) => pc) + if ( + callSites.length !== expectedCallPcs.length || + !sameValue( + callSites.map(({ pc }) => pc), + expectedCallPcs, + ) + ) { + decline('invalid-cfg', 'Packet E call-site list is incomplete or unordered') + } + return { edges, edgesByFunction, reachableByFunction, callSites } +} + +export function validateCallFrames( + callFrames, + wordcodeData, + functionData, + cfgData, +) { + requireObject(callFrames, 'invalid-call-frames', 'Packet F must be an object') + if ( + callFrames.schemaVersion !== CALL_FRAME_SCHEMA || + callFrames.encoding !== 'numeric-u32' || + callFrames.wordCount !== wordcodeData.words.length || + callFrames.instructionCount !== wordcodeData.instructions.length || + callFrames.functionCount !== functionData.functions.length + ) { + decline( + 'stale-predecessor', + 'Packet F does not describe the supplied predecessor data', + ) + } + requireSame( + callFrames.source, + { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: REFERENCE_SCHEMA, + functionsSchema: FUNCTION_SCHEMA, + cfgSchema: CFG_SCHEMA, + }, + 'stale-predecessor', + 'Packet F source schemas are stale', + ) + requireSame( + callFrames.frame?.slots, + FRAME_SLOTS, + 'invalid-call-frames', + 'Packet F frame slots are not canonical', + ) + if ( + callFrames.frame?.frameStart !== 1 || + callFrames.frame?.headerSize !== 8 + ) { + decline('invalid-call-frames', 'Packet F frame header is not canonical') + } + const frames = requireArray( + callFrames.frames, + 'invalid-call-frames', + 'Packet F frames are missing', + ) + if (frames.length !== functionData.functions.length) + decline('invalid-call-frames', 'Packet F frame list is incomplete') + for (const fn of functionData.functions) { + const frame = frames[fn.id] + if ( + !frame || + frame.functionId !== fn.id || + frame.frameSize !== 8 + fn.regCount || + frame.registerBaseOffset !== 8 || + frame.registerWindow?.start !== 8 || + frame.registerWindow?.end !== 8 + fn.regCount || + (fn.kind === 'root' && frame.frameBase !== 1) + ) { + decline( + 'stale-predecessor', + `Packet F frame layout is stale for function ${fn.id}`, + ) + } + } + const functions = requireArray( + callFrames.functions, + 'invalid-call-frames', + 'Packet F functions are missing', + ) + if (functions.length !== functionData.functions.length) + decline('invalid-call-frames', 'Packet F function state is incomplete') + for (const fn of functionData.functions) { + const state = functions[fn.id] + if ( + !state || + state.id !== fn.id || + state.kind !== fn.kind || + state.startPc !== fn.startPc || + state.endPc !== fn.endPc || + state.parentFunctionId !== fn.parentFunctionId || + state.regCount !== fn.regCount || + state.paramCount !== fn.paramCount || + state.captureCount !== fn.captureCount || + state.hasRest !== fn.hasRest + ) { + decline( + 'stale-predecessor', + `Packet F function metadata is stale for ${fn.id}`, + ) + } + validateCompletionRecords(state, fn, wordcodeData, cfgData) + } + const callSites = requireArray( + callFrames.callSites, + 'invalid-call-frames', + 'Packet F call sites are missing', + ) + const expectedCallPcs = wordcodeData.instructions + .filter(({ name }) => CALL_NAMES.has(name)) + .map(({ pc }) => pc) + if ( + callSites.length !== expectedCallPcs.length || + !sameValue( + callSites.map(({ pc }) => pc), + expectedCallPcs, + ) + ) { + decline( + 'invalid-call-frames', + 'Packet F call-site list is incomplete or unordered', + ) + } + const callSiteByPc = new Map() + for (const callSite of callSites) { + requireObject( + callSite, + 'invalid-call-frames', + 'Packet F call site is malformed', + ) + const instruction = wordcodeData.byPc.get(callSite.pc) + if ( + !instruction || + instruction.name !== callSite.kind || + callSite.functionId !== functionData.ownerByPc.get(callSite.pc) + ) { + decline( + 'inconsistent-ownership', + `Packet F call ownership is inconsistent at PC ${callSite.pc}`, + ) + } + if ( + callSite.destination?.kind !== 'register' || + callSite.callee?.kind !== 'register' || + !Array.isArray(callSite.calleeFunctions) || + callSite.calleeFunctions.some((id) => !functionData.byId.has(id)) + ) { + decline( + 'invalid-call-frames', + `Packet F call provenance is malformed at PC ${callSite.pc}`, + ) + } + if ( + callSite.calleeFunctions.some( + (id, index, ids) => ids.indexOf(id) !== index, + ) + ) { + decline( + 'invalid-call-frames', + `Packet F call provenance is not canonical at PC ${callSite.pc}`, + ) + } + if ( + callSite.continuationPc !== null && + !functionData.ownerByPc.has(callSite.continuationPc) + ) { + decline( + 'invalid-call-frames', + `Packet F continuation is unknown at PC ${callSite.pc}`, + ) + } + callSiteByPc.set(callSite.pc, callSite) + } + const allCompletionRoutes = requireArray( + callFrames.completionRoutes, + 'invalid-call-frames', + 'Packet F completion routes are missing', + ) + const expectedCompletionEdges = cfgData.edges.filter((edge) => + ['return', 'throw', 'handler', 'finally'].includes(edge.kind), + ) + if (allCompletionRoutes.length !== expectedCompletionEdges.length) { + decline( + 'stale-predecessor', + 'Packet F completion routes do not cover CFG completion', + ) + } + for (const route of allCompletionRoutes) { + if (!expectedCompletionEdges.some((edge) => routeMatches(edge, route))) { + decline( + 'stale-predecessor', + 'Packet F completion route is not present in Packet E', + ) + } + } + return { frames, functions, callSites, callSiteByPc } +} + +function validateCompletionRecords(state, fn, wordcodeData, cfgData) { + for (const [name, instructionName] of [ + ['returns', 'RETURN'], + ['throws', 'THROW'], + ]) { + const records = requireArray( + state[name], + 'invalid-call-frames', + `Packet F ${name} are missing for ${fn.id}`, + ) + const expected = fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter(({ name: opcodeName }) => opcodeName === instructionName) + if ( + records.length !== expected.length || + !sameValue( + records.map(({ pc }) => pc), + expected.map(({ pc }) => pc), + ) + ) { + decline( + 'stale-predecessor', + `Packet F ${name} are incomplete for ${fn.id}`, + ) + } + for (const record of records) { + requireObject( + record, + 'invalid-call-frames', + 'Packet F completion record is malformed', + ) + const instruction = wordcodeData.byPc.get(record.pc) + const sourceKey = + instructionName === 'RETURN' ? 'sourceRegister' : 'payloadRegister' + if ( + !instruction || + typeof record.reachable !== 'boolean' || + record[sourceKey]?.kind !== 'register' || + record[sourceKey].index !== instruction.operands[0]?.index + ) { + decline( + 'stale-predecessor', + `Packet F completion record is stale at PC ${record.pc}`, + ) + } + const routes = requireArray( + record.routes, + 'invalid-call-frames', + 'Packet F completion routes are missing', + ) + const expectedRoutes = cfgData.edges.filter( + (edge) => + edge.functionId === fn.id && + edge.sourcePc === record.pc && + ['return', 'throw', 'handler', 'finally'].includes(edge.kind), + ) + if ( + routes.length !== expectedRoutes.length || + !routes.every((route) => + expectedRoutes.some((edge) => routeMatches(edge, route)), + ) + ) { + decline( + 'stale-predecessor', + `Packet F completion routes are stale at PC ${record.pc}`, + ) + } + if (instructionName === 'RETURN') { + requireArray( + record.callerDestinations, + 'invalid-call-frames', + 'Packet F caller destinations are missing', + ) + } + } + } + const completionRoutes = requireArray( + state.completionRoutes, + 'invalid-call-frames', + 'Packet F function completion routes are missing', + ) + const functionEdges = cfgData.edges.filter( + ({ functionId, kind }) => + functionId === fn.id && + ['return', 'throw', 'handler', 'finally'].includes(kind), + ) + if ( + completionRoutes.length !== functionEdges.length || + !completionRoutes.every((route) => + functionEdges.some((edge) => routeMatches(edge, route)), + ) + ) { + decline( + 'stale-predecessor', + `Packet F function completion routes are stale for ${fn.id}`, + ) + } + for (const route of completionRoutes) { + if (!['return', 'throw', 'handler', 'finally'].includes(route.kind)) { + decline( + 'invalid-call-frames', + `Packet F route kind is invalid for ${fn.id}`, + ) + } + } +} diff --git a/src/vm/jsconfuser-vm/container-role-helpers.js b/src/vm/jsconfuser-vm/container-role-helpers.js new file mode 100644 index 00000000..527f252e --- /dev/null +++ b/src/vm/jsconfuser-vm/container-role-helpers.js @@ -0,0 +1,626 @@ +import traverse from '@babel/traverse' + +export const EXPECTED_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +export const EXPECTED_SENTINELS = Object.freeze({ CALL_SPREAD: 65535 }) + +export const EXPECTED_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const PARSER_OPTIONS = Object.freeze({ + allowReturnOutsideFunction: true, + errorRecovery: false, + sourceType: 'script', +}) + +export class ContainerDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'ContainerDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new ContainerDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function isIdentifier(node, name = null) { + return node?.type === 'Identifier' && (name === null || node.name === name) +} + +export function propertyName(node) { + if (isIdentifier(node)) return node.name + if (node?.type === 'StringLiteral') return node.value + return null +} + +export function numberValue(node) { + return node?.type === 'NumericLiteral' && Number.isFinite(node.value) + ? node.value + : null +} + +export function scalarValue(node) { + if (node?.type === 'NumericLiteral' || node?.type === 'BooleanLiteral') { + return node.value + } + return undefined +} + +function isLiteralPoolValue(node, context) { + if ( + node?.type === 'StringLiteral' || + node?.type === 'NumericLiteral' || + node?.type === 'BooleanLiteral' || + node?.type === 'NullLiteral' + ) { + return true + } + return isIdentifier(node, 'undefined') && !context.bindingFor(node) +} + +export function readPlainObject(node) { + if (node?.type !== 'ObjectExpression') return null + const entries = new Map() + for (const property of node.properties) { + if ( + property.type !== 'ObjectProperty' || + property.computed || + property.method + ) { + return null + } + const key = propertyName(property.key) + if (key === null || entries.has(key)) return null + entries.set(key, property.value) + } + return entries +} + +export function exactNumericObject(node, expected) { + const entries = readPlainObject(node) + if (!entries || entries.size !== Object.keys(expected).length) return false + return Object.entries(expected).every( + ([key, value]) => numberValue(entries.get(key)) === value, + ) +} + +export function isPoolArray(node, context) { + if (node?.type !== 'ArrayExpression' || node.elements.length === 0) { + return false + } + let hasString = false + for (const element of node.elements) { + if (!element || !isLiteralPoolValue(element, context)) return false + if (element.type === 'StringLiteral') hasString = true + } + return hasString +} + +export function isWordArray(node) { + if (node?.type !== 'ArrayExpression' || node.elements.length === 0) { + return false + } + return node.elements.every((element) => { + const value = numberValue(element) + return ( + value !== null && + Number.isSafeInteger(value) && + value >= 0 && + value <= 0xffffffff + ) + }) +} + +function isWithin(path, rootNode) { + for (let current = path; current; current = current.parentPath) { + if (current.node === rootNode) return true + } + return false +} + +export function makeContext(ast) { + const paths = new Map() + let programPath + traverse(ast, { + enter(path) { + paths.set(path.node, path) + }, + Program(path) { + programPath = path + }, + }) + if (!programPath) decline('malformed-input', 'Expected a Babel Program') + + const context = { + ast, + program: programPath.node, + paths, + programPath, + bindingFor(node) { + if (!isIdentifier(node)) return null + const path = paths.get(node) + return path?.scope.getBinding(node.name) ?? null + }, + isReference(node, binding) { + if (!isIdentifier(node) || !binding) return false + const path = paths.get(node) + return ( + !!path && + path.isReferencedIdentifier() && + path.scope.getBinding(node.name) === binding + ) + }, + nodes(rootNode) { + return [...paths.entries()] + .filter(([node]) => isWithin(paths.get(node), rootNode)) + .map(([node, path]) => ({ node, path })) + }, + sameRef(node, binding) { + return context.isReference(node, binding) + }, + } + return context +} + +export function topLevelDeclarations(context) { + const declarations = [] + for (const statement of context.program.body) { + if (statement.type === 'VariableDeclaration') { + for (const declaration of statement.declarations) { + if (!isIdentifier(declaration.id)) continue + declarations.push({ + id: declaration.id, + init: declaration.init, + kind: 'variable', + name: declaration.id.name, + node: declaration, + statement, + binding: context.bindingFor(declaration.id), + }) + } + } else if (statement.type === 'FunctionDeclaration') { + if (!isIdentifier(statement.id)) continue + declarations.push({ + id: statement.id, + init: statement, + kind: 'function', + name: statement.id.name, + node: statement, + statement, + binding: context.bindingFor(statement.id), + }) + } + } + return declarations +} + +export function isStableDeclaration(declaration) { + return ( + !!declaration.binding && + (declaration.binding.path.node === declaration.node || + declaration.binding.identifier === declaration.id) && + declaration.binding.constantViolations.length === 0 + ) +} + +export function uniqueCandidate(candidates, role) { + if (candidates.length === 1 && isStableDeclaration(candidates[0])) { + return candidates[0] + } + if (candidates.length === 0) { + decline('missing-role', `Missing mandatory ${role}`) + } + decline('ambiguous-role', `Ambiguous ${role}`) +} + +export function topLevelVariables(declarations) { + return declarations.filter((declaration) => declaration.kind === 'variable') +} + +export function referencePaths(context, binding, rootNode = context.program) { + if (!binding) return [] + return context + .nodes(rootNode) + .filter(({ node }) => context.isReference(node, binding)) + .map(({ path }) => path) +} + +export function referenceCount(context, binding, rootNode = context.program) { + return referencePaths(context, binding, rootNode).length +} + +export function member(node, objectPredicate, property, computed = false) { + if (node?.type !== 'MemberExpression' || node.computed !== computed) { + return false + } + if ( + typeof objectPredicate === 'function' + ? !objectPredicate(node.object) + : node.object !== objectPredicate + ) { + return false + } + return propertyName(node.property) === property +} + +export function thisMember(node, property) { + return member(node, (value) => value?.type === 'ThisExpression', property) +} + +export function computedMember(node, objectPredicate, propertyPredicate) { + return ( + node?.type === 'MemberExpression' && + node.computed && + objectPredicate(node.object) && + propertyPredicate(node.property) + ) +} + +export function slotMember(node, slotsBinding, role, context) { + return member(node, (value) => context.sameRef(value, slotsBinding), role) +} + +export function hasNode(context, rootNode, predicate) { + return context.nodes(rootNode).some(({ node, path }) => predicate(node, path)) +} + +function variableDeclarators(context, rootNode) { + return context + .nodes(rootNode) + .filter(({ node }) => node.type === 'VariableDeclarator') + .map(({ node }) => node) +} + +export function findVariable(context, rootNode, predicate) { + return variableDeclarators(context, rootNode).find((declaration) => + predicate(declaration.init, declaration.id), + ) +} + +export function binaryParts(node, operator = '+') { + return node?.type === 'BinaryExpression' && node.operator === operator + ? [node.left, node.right] + : null +} + +function directAssignments(fn) { + if (fn.body?.type !== 'BlockStatement') return null + const assignments = new Map() + for (const statement of fn.body.body) { + const expression = + statement.type === 'ExpressionStatement' ? statement.expression : null + if ( + expression?.type !== 'AssignmentExpression' || + !thisMember(expression.left, propertyName(expression.left.property)) + ) { + continue + } + const property = propertyName(expression.left.property) + if (assignments.has(property)) return null + assignments.set(property, expression.right) + } + return assignments +} + +function constructorShape(fn, fields) { + const assignments = directAssignments(fn) + if (!assignments || assignments.size !== fields.length) return null + if (!fields.every((field) => assignments.has(field))) return null + return assignments +} + +function emptyArray(node) { + return node?.type === 'ArrayExpression' && node.elements.length === 0 +} + +function emptyObject(node) { + return node?.type === 'ObjectExpression' && node.properties.length === 0 +} + +export function parameterBinding(context, fn, index) { + const parameter = fn.params[index] + return isIdentifier(parameter) ? context.bindingFor(parameter) : null +} + +export function upvalueShape(fn, context) { + const assignments = constructorShape(fn, [ + '_regs', + '_absSlot', + '_closed', + '_value', + ]) + const first = parameterBinding(context, fn, 0) + const second = parameterBinding(context, fn, 1) + return ( + !!assignments && + fn.params.length === 2 && + context.sameRef(assignments.get('_regs'), first) && + context.sameRef(assignments.get('_absSlot'), second) && + assignments.get('_closed')?.type === 'BooleanLiteral' && + assignments.get('_closed').value === false && + isIdentifier(assignments.get('_value'), 'undefined') && + !context.bindingFor(assignments.get('_value')) + ) +} + +export function closureShape(fn, context) { + const assignments = constructorShape(fn, ['fn', 'upvalues', 'prototype']) + const first = parameterBinding(context, fn, 0) + return ( + !!assignments && + fn.params.length === 1 && + context.sameRef(assignments.get('fn'), first) && + emptyArray(assignments.get('upvalues')) && + emptyObject(assignments.get('prototype')) + ) +} + +export function vmShape(fn, context) { + const assignments = constructorShape(fn, [ + 'bytecode', + 'constants', + 'globals', + '_openUpvalues', + '_regs', + '_regsTop', + '_f', + ]) + const bytecode = parameterBinding(context, fn, 0) + const constants = parameterBinding(context, fn, 1) + const globals = parameterBinding(context, fn, 2) + if ( + !assignments || + fn.params.length !== 3 || + !context.sameRef(assignments.get('bytecode'), bytecode) || + !context.sameRef(assignments.get('constants'), constants) || + !context.sameRef(assignments.get('globals'), globals) || + assignments.get('_openUpvalues')?.type !== 'NullLiteral' || + !emptyArray(assignments.get('_regs')) || + !isIdentifier(assignments.get('_regsTop')) || + numberValue(assignments.get('_f')) !== 0 + ) { + return null + } + return { assignments, frameStart: assignments.get('_regsTop') } +} + +export function functionDeclarations(declarations) { + return declarations + .filter((declaration) => declaration.kind === 'function') + .map((declaration) => declaration.init) +} + +export function prototypeMethods(context, owner) { + const methods = [] + for (const statement of context.program.body) { + if ( + statement.type !== 'ExpressionStatement' || + statement.expression.type !== 'AssignmentExpression' + ) { + continue + } + const left = statement.expression.left + if ( + left?.type !== 'MemberExpression' || + left.computed || + left.object?.type !== 'MemberExpression' || + !member( + left.object, + (value) => context.sameRef(value, owner.binding), + 'prototype', + ) || + statement.expression.right?.type !== 'FunctionExpression' + ) { + continue + } + methods.push({ + fn: statement.expression.right, + name: propertyName(left.property), + node: statement, + }) + } + return methods +} + +function isUpvalueRead(fn) { + if (fn.params.length !== 0 || fn.body?.body.length !== 1) return false + const statement = fn.body.body[0] + const expression = + statement.type === 'ReturnStatement' ? statement.argument : null + return ( + !!expression && + expression.type === 'ConditionalExpression' && + thisMember(expression.test, '_closed') && + thisMember(expression.consequent, '_value') && + computedMember( + expression.alternate, + (value) => thisMember(value, '_regs'), + (value) => thisMember(value, '_absSlot'), + ) + ) +} + +function isUpvalueWrite(fn, context) { + if (fn.params.length !== 1 || fn.body?.body.length !== 1) return false + const parameter = parameterBinding(context, fn, 0) + const statement = fn.body.body[0] + if ( + statement.type !== 'IfStatement' || + !thisMember(statement.test, '_closed') + ) { + return false + } + const assigned = (branch, expectedLeft) => { + const branchStatement = + branch?.type === 'BlockStatement' + ? branch.body.length === 1 + ? branch.body[0] + : null + : branch + const expression = + branchStatement?.type === 'ExpressionStatement' + ? branchStatement.expression + : null + return ( + expression?.type === 'AssignmentExpression' && + expectedLeft(expression.left) && + context.sameRef(expression.right, parameter) + ) + } + return ( + assigned(statement.consequent, (left) => thisMember(left, '_value')) && + assigned(statement.alternate, (left) => + computedMember( + left, + (value) => thisMember(value, '_regs'), + (value) => thisMember(value, '_absSlot'), + ), + ) + ) +} + +function isUpvalueClose(fn) { + if (fn.params.length !== 0 || fn.body?.body.length !== 2) return false + const first = fn.body.body[0] + const second = fn.body.body[1] + const firstExpression = + first.type === 'ExpressionStatement' ? first.expression : null + const secondExpression = + second.type === 'ExpressionStatement' ? second.expression : null + return ( + firstExpression?.type === 'AssignmentExpression' && + thisMember(firstExpression.left, '_value') && + computedMember( + firstExpression.right, + (value) => thisMember(value, '_regs'), + (value) => thisMember(value, '_absSlot'), + ) && + secondExpression?.type === 'AssignmentExpression' && + thisMember(secondExpression.left, '_closed') && + secondExpression.right?.type === 'BooleanLiteral' && + secondExpression.right.value === true + ) +} + +export function upvalueMethodRoles(methods, context) { + return { + read: methods.filter(({ fn }) => isUpvalueRead(fn)), + write: methods.filter(({ fn }) => isUpvalueWrite(fn, context)), + close: methods.filter(({ fn }) => isUpvalueClose(fn)), + } +} + +export function methodCall( + context, + rootNode, + objectPredicate, + methodName, + firstArgument = null, +) { + return hasNode(context, rootNode, (node) => { + if ( + node.type !== 'CallExpression' || + node.callee?.type !== 'MemberExpression' || + node.callee.computed || + propertyName(node.callee.property) !== methodName || + !objectPredicate(node.callee.object) + ) { + return false + } + return ( + firstArgument === null || + context.sameRef(node.arguments[0], firstArgument) + ) + }) +} + +export function newCall(context, rootNode, constructorBinding) { + return hasNode( + context, + rootNode, + (node) => + node.type === 'NewExpression' && + context.sameRef(node.callee, constructorBinding), + ) +} diff --git a/src/vm/jsconfuser-vm/decode-standalone.js b/src/vm/jsconfuser-vm/decode-standalone.js new file mode 100644 index 00000000..7b9422f4 --- /dev/null +++ b/src/vm/jsconfuser-vm/decode-standalone.js @@ -0,0 +1,122 @@ +import { parse } from '@babel/parser' +import { StandaloneDecline, deepFreeze } from './standalone-contract.js' +import { diagnoseStandaloneInput } from './diagnose-standalone.js' +import { numericToVmSwitch } from './numeric-to-vm-switch.js' +import { VmSwitchDecline } from '../switch/vm-switch-model.js' +import { emitVmSwitchProgram } from '../switch/vm-switch-to-source.js' + +const STANDALONE_SCHEMA = 'jsconfuser-vm-standalone.v1' +const RESIDUE_IDENTIFIERS = new Set([ + 'BYTECODE', + 'CONSTANTS', + 'ENCODE_BYTECODE', + 'VM', + 'Closure', + 'Upvalue', + 'decodeBytecode', +]) + +function validateNumericVmResidue(source) { + const ast = parse(source, { errorRecovery: false, sourceType: 'script' }) + let residue = null + const visit = (node) => { + if (!node || residue) return + if (node.type === 'Identifier' && RESIDUE_IDENTIFIERS.has(node.name)) { + residue = node.name + return + } + if ( + node.type === 'MemberExpression' && + node.object?.type === 'Identifier' && + node.object.name === 'vm' && + node.property?.type === 'Identifier' && + node.property.name === 'run' && + node.computed === false + ) { + residue = 'vm.run' + return + } + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) if (child?.type) visit(child) + } else if (value?.type) visit(value) + } + } + visit(ast) + if (residue) + throw new VmSwitchDecline( + 'emitted-residue', + `Standalone output contains forbidden VM residue ${residue}`, + ) +} + +export function diagnoseStandalone(source) { + try { + const preflight = diagnoseStandaloneInput(source) + if (!preflight.ok) + return deepFreeze({ + ok: false, + result: null, + diagnostic: preflight.diagnostic, + }) + const model = preflight.model + const switchModel = numericToVmSwitch(model) + const emitted = emitVmSwitchProgram(switchModel) + validateNumericVmResidue(emitted.output) + const result = { + schemaVersion: STANDALONE_SCHEMA, + packet: 'N', + parseOnly: true, + targetExecution: false, + vmExecuted: false, + emittedJavaScript: true, + finalJavaScriptSemantics: false, + wordCount: model.wordcode.wordCount, + instructionCount: model.wordcode.instructionCount, + functionCount: model.functions.functions.length, + output: emitted.output, + simplification: { + ...emitted.simplification, + schemaVersion: 'jsconfuser-vm-simplification.v1', + }, + coverage: { + instructions: model.wordcode.instructionCount, + functions: model.functions.functions.length, + }, + control: model.structuredControl, + proof: { + allPredecessorsReconstructed: true, + noTargetExecution: true, + noVmRuntimeEmission: true, + freshJavaScriptEmission: true, + freshSimplificationState: true, + simplificationDispositionsDisjoint: true, + finalParseValidated: true, + finalResidueValidated: true, + }, + } + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof StandaloneDecline || error instanceof VmSwitchDecline) + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'standalone-emission-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function decodeStandalone(source) { + return diagnoseStandalone(source).result?.output ?? null +} + +export const emitStandalone = decodeStandalone +export default decodeStandalone diff --git a/src/vm/jsconfuser-vm/diagnose-standalone.js b/src/vm/jsconfuser-vm/diagnose-standalone.js new file mode 100644 index 00000000..4ce4dd7f --- /dev/null +++ b/src/vm/jsconfuser-vm/diagnose-standalone.js @@ -0,0 +1,256 @@ +import { + StandaloneDecline, + decline, + requireValue, +} from './standalone-contract.js' +import { validateStructuredControl } from './validate-structured-control.js' +import { extractContainerFromSource } from './extract-container.js' +import { readWordcode } from './read-wordcode.js' +import { validateReferences } from './validate-references.js' +import { partitionFunctions } from './partition-functions.js' +import { buildControlFlow } from './build-cfg.js' +import { buildCallFrames } from './build-call-frames.js' +import { analyzeClosureLifetimes } from './analyze-closure-lifetimes.js' +import { analyzeExceptionFinally } from './analyze-exception-finally.js' +import { analyzeScalarValues } from './analyze-scalar-values.js' +import { analyzePropertyCollections } from './analyze-property-collections.js' +import { emitStructuredControl } from './emit-structured-control.js' +import { emitCallCompletion } from './emit-call-completion.js' +import { emitClosureException } from './emit-closure-exception.js' + +const SUPPORTED_INSTRUCTIONS = new Set([ + 'ADD', + 'BAND', + 'BOR', + 'BXOR', + 'CALL', + 'CALL_METHOD', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'DEBUGGER', + 'DELETE_PROP', + 'DEFINE_GETTER', + 'DEFINE_SETTER', + 'DIV', + 'EQ', + 'EXP', + 'FINALLY_SETUP', + 'FOR_IN_NEXT', + 'FOR_IN_SETUP', + 'GET_PROP', + 'GTE', + 'GT', + 'IN', + 'INSTANCEOF', + 'JUMP', + 'JUMP_IF_FALSE', + 'JUMP_IF_TRUE', + 'JUMP_REG', + 'LOAD_CONST', + 'LOAD_GLOBAL', + 'LOAD_INT', + 'LOAD_THIS', + 'LOAD_UPVALUE', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'LT', + 'LTE', + 'MAKE_CLOSURE', + 'MOD', + 'MOVE', + 'MUL', + 'NEW', + 'NEQ', + 'RETURN', + 'SET_PROP', + 'SHL', + 'SHR', + 'STORE_GLOBAL', + 'STORE_UPVALUE', + 'SUB', + 'THROW', + 'TRY_END', + 'TRY_SETUP', + 'TYPEOF', + 'TYPEOF_SAFE', + 'UNARY_BITNOT', + 'UNARY_NEG', + 'UNARY_NOT', + 'UNARY_POS', + 'USHR', + 'VOID', +]) + +function instructionMap(wordcode) { + return new Map( + wordcode.instructions.map((instruction) => [instruction.pc, instruction]), + ) +} + +function functionInstructionMap(wordcode, functions) { + const byPc = instructionMap(wordcode) + return new Map( + functions.functions.map((functionRecord) => [ + functionRecord.id, + functionRecord.instructionPcs.map((pc) => { + const instruction = byPc.get(pc) + return requireValue( + instruction, + 'missing-instruction', + `Function ${functionRecord.id} owns missing instruction ${pc}`, + ) + }), + ]), + ) +} + +function callRecordMap(callCompletion) { + return new Map( + callCompletion.calls.map((call) => [`${call.functionId}:${call.pc}`, call]), + ) +} + +function reconstruct(source) { + if (typeof source !== 'string') + decline('invalid-input', 'Standalone decoder input must be a string') + const container = requireValue( + extractContainerFromSource(source), + 'container-declined', + 'Packet A declined the input container', + ) + const wordcode = requireValue( + readWordcode(container), + 'wordcode-declined', + 'Packet B declined the input wordcode', + ) + const references = requireValue( + validateReferences(container, wordcode), + 'references-declined', + 'Packet C declined the input references', + ) + const functions = requireValue( + partitionFunctions(container, wordcode, references), + 'functions-declined', + 'Packet D declined the input function partition', + ) + const cfg = requireValue( + buildControlFlow(wordcode, references, functions), + 'cfg-declined', + 'Packet E declined the input control flow', + ) + const callFrames = requireValue( + buildCallFrames(wordcode, references, functions, cfg), + 'call-frames-declined', + 'Packet F declined the input call/frame model', + ) + const closureLifetimes = requireValue( + analyzeClosureLifetimes(wordcode, references, functions, cfg, callFrames), + 'closure-lifetimes-declined', + 'Packet G declined the input closure model', + ) + const exceptionFinally = requireValue( + analyzeExceptionFinally(wordcode, references, functions, cfg, callFrames), + 'exception-finally-declined', + 'Packet H declined the input exception model', + ) + const scalarValues = requireValue( + analyzeScalarValues(wordcode, references, functions, cfg), + 'scalar-values-declined', + 'Packet I declined the input scalar/value model', + ) + const propertyCollections = requireValue( + analyzePropertyCollections( + wordcode, + references, + functions, + cfg, + callFrames, + ), + 'property-collections-declined', + 'Packet J declined the input property/collection model', + ) + const structuredControl = requireValue( + emitStructuredControl(wordcode, cfg, exceptionFinally), + 'structured-control-declined', + 'Packet K declined the input structured-control model', + ) + const callCompletion = requireValue( + emitCallCompletion( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + ), + 'call-completion-declined', + 'Packet L declined the input call/completion model', + ) + const closureException = requireValue( + emitClosureException( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, + ), + 'closure-exception-declined', + 'Packet M declined the input closure/exception model', + ) + for (const instruction of wordcode.instructions) { + if (!SUPPORTED_INSTRUCTIONS.has(instruction.name)) + decline( + 'unsupported-opcode', + `Standalone emission does not support ${instruction.name}`, + ) + } + if ( + !structuredControl.coverage.instructions.complete || + !structuredControl.coverage.edges.complete || + !callCompletion.coverage.instructions.complete || + !callCompletion.coverage.completions.complete || + !callCompletion.coverage.completionRoutes.complete || + !closureException.coverage.instructions.complete || + !closureException.coverage.completions.complete || + !closureException.coverage.routes.complete || + !closureException.proof.allPredecessorsReconstructed + ) { + decline( + 'incomplete-predecessor-coverage', + 'An accepted predecessor reports incomplete coverage', + ) + } + const structuredControlModel = validateStructuredControl( + structuredControl, + wordcode, + functions, + ) + return { + constants: references.constants.values, + functions, + instructionsByFunction: functionInstructionMap(wordcode, functions), + controlByFunction: structuredControlModel.controlByFunction, + structuredControl, + calls: callRecordMap(callCompletion), + scalarValues, + propertyCollections, + wordcode, + closureException, + } +} + +export function diagnoseStandaloneInput(source) { + try { + return { ok: true, model: reconstruct(source), diagnostic: null } + } catch (error) { + if (error instanceof StandaloneDecline) + return { + ok: false, + model: null, + diagnostic: { code: error.code, message: error.message }, + } + throw error + } +} diff --git a/src/vm/jsconfuser-vm/emit-call-completion.js b/src/vm/jsconfuser-vm/emit-call-completion.js new file mode 100644 index 00000000..9d4237a8 --- /dev/null +++ b/src/vm/jsconfuser-vm/emit-call-completion.js @@ -0,0 +1,542 @@ +import { + ALL_OPCODE_NAMES, + CALL_COMPLETION_SCHEMA, + CALL_FRAME_SCHEMA, + CALL_NAMES, + CFG_SCHEMA, + CLOSURE_LIFETIME_SCHEMA, + COMPLETION_NAMES, + CallCompletionDecline, + FUNCTION_SCHEMA, + OMITTED_OPCODE_GROUPS, + REFERENCE_SCHEMA, + RELEVANT_NAMES, + WORDCODE_SCHEMA, + callGrammar, + cloneData, + decline, + deepFreeze, + functionOwner, + instructionLeaf, + ownerMaps, + preflight, +} from './call-completion-input.js' + +function readOrder(instruction, callSite, grammar) { + const roles = [] + if (instruction.name === 'CALL_METHOD') roles.push('receiver') + roles.push('callee', 'argument-count') + if (grammar.kind === 'spread') { + roles.push('spread-array') + } else { + for (let index = 0; index < grammar.count; index += 1) + roles.push(`argument[${index}]`) + } + const registers = { + ...(instruction.name === 'CALL_METHOD' + ? { receiver: cloneData(callSite.receiver) } + : {}), + callee: cloneData(callSite.callee), + ...(grammar.kind === 'spread' + ? { 'spread-array': cloneData(grammar.arrayRegister) } + : Object.fromEntries( + grammar.registers.map((register, index) => [ + `argument[${index}]`, + cloneData(register), + ]), + )), + } + return { + kind: 'left-to-right-register-read', + roles, + steps: roles.map((role, order) => ({ + order, + role, + register: registers[role] ?? null, + word: + role === 'argument-count' + ? instruction.words[instruction.name === 'CALL_METHOD' ? 4 : 3] + : null, + })), + caveat: + 'This records compiler/runtime register-read order; property access, coercion, getters, proxies, and final JavaScript semantics remain later-packet facts.', + } +} + +function targetDescription(callSite, closureLifetimes) { + const internal = callSite.calleeFunctions.length > 0 + const closureSites = internal + ? closureLifetimes.closureSites + .filter((site) => + callSite.calleeFunctions.includes(site.childFunctionId), + ) + .map((site) => ({ + id: site.id, + creationPc: site.creationPc, + parentFunctionId: site.parentFunctionId, + childFunctionId: site.childFunctionId, + childStartPc: site.childStartPc, + })) + : [] + return { + kind: internal ? 'internal-closure' : 'host-or-dynamic', + functionIds: [...callSite.calleeFunctions], + closureSiteIds: closureSites.map(({ id }) => id), + closureSites, + certainty: internal ? 'statically-proven-by-packet-f' : 'not-invented', + } +} + +function normalCallRoute(callSite) { + return { + kind: 'call', + sourcePc: callSite.pc, + targetPc: callSite.continuationPc, + route: 'normal', + mode: 'direct', + callSitePc: callSite.pc, + continuationPc: callSite.continuationPc, + payload: { + returnDestination: cloneData(callSite.returnDestination), + }, + } +} + +function callRecord(instruction, callSite, owner, reachable, closureLifetimes) { + if (!callSite || callSite.functionId !== owner.functionId) { + decline( + 'input-mismatch', + `Packet F is missing the owner for ${instruction.name}@${instruction.pc}`, + ) + } + const grammar = callGrammar(instruction, callSite) + const target = targetDescription(callSite, closureLifetimes) + const isMethod = instruction.name === 'CALL_METHOD' + const isConstructor = instruction.name === 'NEW' + return { + id: `call:${owner.functionId}:${instruction.pc}`, + functionId: owner.functionId, + pc: instruction.pc, + name: instruction.name, + opcode: cloneData(instruction.opcode), + words: [...instruction.words], + width: instruction.width, + nextPc: instruction.nextPc, + reachable, + destination: cloneData(callSite.destination), + callee: cloneData(callSite.callee), + receiver: cloneData(callSite.receiver), + receiverPreservation: isMethod + ? { + kind: 'call-method-this-argument', + register: cloneData(callSite.receiver), + preservedThrough: 'CALL_METHOD', + } + : { kind: 'none' }, + target, + callMode: target.kind, + constructorState: cloneData(callSite.constructorState), + construction: isConstructor + ? { + kind: 'new-construction', + allocationMode: callSite.constructorState.mode, + returnRule: 'explicit-object-or-allocated-this', + semanticBoundary: 'later-packet', + } + : { + kind: 'ordinary-call', + thisMode: isMethod ? 'receiver-register' : 'null-or-host-default', + semanticBoundary: 'later-packet', + }, + arguments: cloneData(callSite.arguments), + grammar, + evaluationOrder: readOrder(instruction, callSite, grammar), + continuationPc: callSite.continuationPc, + returnDestination: cloneData(callSite.returnDestination), + completion: { + normal: normalCallRoute(callSite), + exceptional: cloneData(callSite.exceptionalCompletion), + }, + cfg: cloneData(callSite.cfg), + semanticBoundary: 'later-packet', + } +} + +function completionRouteRecords(callFrames, ownerByPc) { + return callFrames.completionRoutes.map((route, index) => { + const functionId = ownerByPc.get(route.sourcePc) + if (!Number.isInteger(functionId)) { + decline( + 'invalid-completion-route', + `Completion route at ${route.sourcePc} has no function owner`, + ) + } + return { + id: `completion:${functionId}:${route.sourcePc}:${index}`, + functionId, + ...cloneData(route), + semanticBoundary: 'later-packet', + } + }) +} + +function completionRecord(instruction, record, owner, reachable, routeRecords) { + if (!record || record.pc !== instruction.pc) { + decline( + 'input-mismatch', + `Packet F is missing ${instruction.name}@${instruction.pc}`, + ) + } + const routes = routeRecords.filter( + ({ sourcePc, functionId }) => + sourcePc === instruction.pc && functionId === owner.functionId, + ) + return { + id: `${instruction.name.toLowerCase()}:${owner.functionId}:${instruction.pc}`, + functionId: owner.functionId, + pc: instruction.pc, + name: instruction.name, + opcode: cloneData(instruction.opcode), + words: [...instruction.words], + width: instruction.width, + nextPc: instruction.nextPc, + reachable, + sourceRegister: cloneData(record.sourceRegister), + payloadRegister: cloneData(record.payloadRegister), + callerDestinations: cloneData(record.callerDestinations), + routes: cloneData(record.routes), + routeRecordIds: routes.map(({ id }) => id), + constructorCallers: (record.callerDestinations ?? []) + .filter(({ constructorState }) => constructorState?.isConstructor) + .map((destination) => ({ + callSitePc: destination.callSitePc, + callerFunctionId: destination.callerFunctionId, + constructorState: cloneData(destination.constructorState), + })), + semanticBoundary: 'later-packet', + } +} + +function functionOwnerFacts(functions, closureLifetimes) { + const sitesByParent = new Map() + for (const site of closureLifetimes.closureSites) { + const sites = sitesByParent.get(site.parentFunctionId) ?? [] + sites.push(site.id) + sitesByParent.set(site.parentFunctionId, sites) + } + return functions.functions.map((fn) => ({ + functionId: fn.id, + kind: fn.kind, + startPc: fn.startPc, + endPc: fn.endPc, + parentFunctionId: fn.parentFunctionId, + instructionPcs: [...fn.instructionPcs], + closureSiteIds: sitesByParent.get(fn.id) ?? [], + semanticBoundary: 'later-packet', + })) +} + +function makeCensus(wordcode, calls, returns, throws, routes, instructions) { + const countsByName = Object.fromEntries( + ALL_OPCODE_NAMES.map((name) => [name, 0]), + ) + for (const instruction of instructions) countsByName[instruction.name] += 1 + const relevant = instructions.filter(({ name }) => RELEVANT_NAMES.has(name)) + const keys = relevant.map(({ functionId, pc }) => `${functionId}:${pc}`) + const callKeys = calls.map(({ functionId, pc }) => `${functionId}:${pc}`) + const completionKeys = [...returns, ...throws].map( + ({ functionId, pc }) => `${functionId}:${pc}`, + ) + return { + expectedInstructionCount: wordcode.instructions.length, + emittedInstructionCount: instructions.length, + countsByName, + relevantInstructionCount: relevant.length, + callCount: calls.length, + returnCount: returns.length, + throwCount: throws.length, + completionRouteCount: routes.length, + operationKeys: keys, + callKeys, + completionKeys, + dropped: [], + duplicated: [], + complete: + instructions.length === wordcode.instructions.length && + new Set(keys).size === keys.length, + } +} + +function coverage(expected, emitted, keys) { + return { + expected, + emitted, + unique: new Set(keys).size === keys.length, + complete: expected === emitted && new Set(keys).size === expected, + } +} + +function recognize( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, +) { + const data = preflight( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + ) + const maps = ownerMaps(data.functions, data.cfg) + const instructions = data.wordcode.instructions.map((instruction) => { + const functionId = maps.ownerByPc.get(instruction.pc) + if (!Number.isInteger(functionId)) { + decline( + 'invalid-function-ownership', + `Instruction ${instruction.pc} has no owner`, + ) + } + return instructionLeaf( + instruction, + functionId, + maps.reachable.has(`${functionId}:${instruction.pc}`), + ) + }) + const instructionByPc = new Map( + data.wordcode.instructions.map((instruction) => [ + instruction.pc, + instruction, + ]), + ) + const callSitesByPc = new Map( + data.callFrames.callSites.map((callSite) => [callSite.pc, callSite]), + ) + const calls = instructions + .filter(({ name }) => CALL_NAMES.has(name)) + .map((leaf) => { + const instruction = instructionByPc.get(leaf.pc) + const owner = functionOwner( + leaf.functionId, + maps.functionById, + `${leaf.name}@${leaf.pc}`, + ) + return callRecord( + instruction, + callSitesByPc.get(leaf.pc), + owner, + leaf.reachable, + data.closureLifetimes, + ) + }) + const routeRecords = completionRouteRecords(data.callFrames, maps.ownerByPc) + const returnsByPc = new Map( + data.callFrames.returns.map((record) => [record.pc, record]), + ) + const throwsByPc = new Map( + data.callFrames.throws.map((record) => [record.pc, record]), + ) + const returns = instructions + .filter(({ name }) => name === 'RETURN') + .map((leaf) => + completionRecord( + instructionByPc.get(leaf.pc), + returnsByPc.get(leaf.pc), + functionOwner(leaf.functionId, maps.functionById, `RETURN@${leaf.pc}`), + leaf.reachable, + routeRecords, + ), + ) + const throws = instructions + .filter(({ name }) => name === 'THROW') + .map((leaf) => + completionRecord( + instructionByPc.get(leaf.pc), + throwsByPc.get(leaf.pc), + functionOwner(leaf.functionId, maps.functionById, `THROW@${leaf.pc}`), + leaf.reachable, + routeRecords, + ), + ) + const callKeys = calls.map(({ functionId, pc }) => `${functionId}:${pc}`) + const completionKeys = [...returns, ...throws].map( + ({ functionId, pc }) => `${functionId}:${pc}`, + ) + const census = makeCensus( + data.wordcode, + calls, + returns, + throws, + routeRecords, + instructions, + ) + return deepFreeze({ + schemaVersion: CALL_COMPLETION_SCHEMA, + encoding: 'numeric-u32', + api: { + emit: 'emitCallCompletion(wordcode, references, functions, cfg, callFrames, closureLifetimes)', + diagnose: + 'diagnoseCallCompletion(wordcode, references, functions, cfg, callFrames, closureLifetimes)', + returns: 'deeply frozen parse-only call/completion emission IR or null', + }, + parseOnly: true, + targetExecution: false, + emittedJavaScript: false, + vmExecuted: false, + finalJavaScriptSemantics: false, + semanticBoundary: 'later-packet', + wordCount: data.wordcode.wordCount, + instructionCount: data.wordcode.instructionCount, + functionCount: data.functions.functions.length, + opcodeScope: { + family: 'L-call-receiver-construction-completion', + names: [...RELEVANT_NAMES], + count: RELEVANT_NAMES.size, + }, + instructions, + calls, + returns, + throws, + completionRoutes: routeRecords, + frame: cloneData(data.callFrames.frame), + frames: cloneData(data.callFrames.frames), + ownerFacts: functionOwnerFacts(data.functions, data.closureLifetimes), + closureLifetimes: cloneData(data.closureLifetimes), + coverage: { + instructions: coverage( + data.wordcode.instructions.length, + instructions.length, + instructions.map(({ functionId, pc }) => `${functionId}:${pc}`), + ), + calls: coverage( + data.wordcode.instructions.filter(({ name }) => CALL_NAMES.has(name)) + .length, + calls.length, + callKeys, + ), + completions: coverage( + data.wordcode.instructions.filter(({ name }) => + COMPLETION_NAMES.has(name), + ).length, + returns.length + throws.length, + completionKeys, + ), + completionRoutes: coverage( + data.callFrames.completionRoutes.length, + routeRecords.length, + routeRecords.map(({ id }) => id), + ), + }, + census, + boundaryOmissions: OMITTED_OPCODE_GROUPS.map(({ boundary, names }) => ({ + boundary, + names: [...names], + })), + predecessorSchemas: { + wordcode: WORDCODE_SCHEMA, + references: REFERENCE_SCHEMA, + functions: FUNCTION_SCHEMA, + cfg: CFG_SCHEMA, + callFrames: CALL_FRAME_SCHEMA, + closureLifetimes: CLOSURE_LIFETIME_SCHEMA, + }, + proof: { + allPredecessorsReconstructed: true, + allInstructionsRevalidated: true, + allCallRowsPresent: + calls.length === + data.wordcode.instructions.filter(({ name }) => CALL_NAMES.has(name)) + .length, + allCompletionRowsPresent: + returns.length + throws.length === + data.wordcode.instructions.filter(({ name }) => + COMPLETION_NAMES.has(name), + ).length, + allRouteRowsPresent: + routeRecords.length === data.callFrames.completionRoutes.length, + allInstructionRowsUnique: + new Set(instructions.map(({ functionId, pc }) => `${functionId}:${pc}`)) + .size === instructions.length, + closureLifetimeAndOwnerFactsCarried: true, + noTargetCodeExecuted: true, + noFinalJavaScriptEmitted: true, + }, + inputs: { + wordCount: data.wordcode.wordCount, + instructionCount: data.wordcode.instructionCount, + functionCount: data.functions.functions.length, + cfgEdgeCount: data.cfg.edges.length, + cfgBlockCount: data.cfg.functions.reduce( + (count, fn) => count + fn.blocks.length, + 0, + ), + callSiteCount: data.callFrames.callSites.length, + returnCount: data.callFrames.returns.length, + throwCount: data.callFrames.throws.length, + closureSiteCount: data.closureLifetimes.closureSites.length, + bindingCount: data.closureLifetimes.bindings.length, + }, + }) +} + +export function diagnoseCallCompletion( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, +) { + try { + return deepFreeze({ + ok: true, + result: recognize( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + ), + diagnostic: null, + }) + } catch (error) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: + error instanceof CallCompletionDecline + ? error.code + : 'call-completion-analysis-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function emitCallCompletion( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, +) { + return diagnoseCallCompletion( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + ).result +} + +export const emitCallCompletionBoundary = emitCallCompletion +export const buildCallCompletionEmission = emitCallCompletion +export default emitCallCompletion diff --git a/src/vm/jsconfuser-vm/emit-closure-exception.js b/src/vm/jsconfuser-vm/emit-closure-exception.js new file mode 100644 index 00000000..96ceb25e --- /dev/null +++ b/src/vm/jsconfuser-vm/emit-closure-exception.js @@ -0,0 +1,588 @@ +import { + CALL_FRAME_SCHEMA, + CANONICAL_OPCODES, + CFG_SCHEMA, + CLOSURE_SCHEMA, + COMPLETION_NAMES, + ClosureExceptionDecline, + EMISSION_SCHEMA, + EXCEPTION_SCHEMA, + FUNCTION_SCHEMA, + REFERENCE_SCHEMA, + SEMANTIC_BOUNDARY, + SETUP_NAMES, + WORDCODE_SCHEMA, + cloneData, + closureRecords, + decline, + deepFreeze, + edgeKey, + functionMaps, + instructionRecords, + preflight, + register, + routeRecords, + stateKey, +} from './closure-exception-records.js' + +function exceptionRecords(exceptionFinally) { + const handlers = exceptionFinally.handlerRecords.map((record) => ({ + id: record.id, + kind: 'try-catch-region', + ...cloneData(record), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const finalizers = exceptionFinally.finallyRecords.map((record) => ({ + id: record.id, + kind: 'try-finally-region', + ...cloneData(record), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const instructionStates = exceptionFinally.instructionStates.map((state) => ({ + id: `state:${state.functionId}:${state.pc}`, + kind: 'handler-finally-state', + ...cloneData(state), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const merges = exceptionFinally.merges.map((merge) => ({ + id: `merge:${merge.functionId}:${merge.pc}`, + kind: 'handler-finally-merge', + ...cloneData(merge), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const transitions = exceptionFinally.transitions.map((transition) => ({ + id: `transition:${transition.functionId}:${transition.pc}`, + kind: 'handler-finally-transition', + ...cloneData(transition), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const jumps = exceptionFinally.jumpRegChecks.map((jump) => ({ + id: `jump-reg:${jump.functionId}:${jump.pc}`, + kind: 'finally-continuation-check', + ...cloneData(jump), + semanticBoundary: SEMANTIC_BOUNDARY, + })) + const throwPads = finalizers.map((record) => ({ + id: `throw-pad:${record.id}`, + kind: 'finally-throw-pad', + finalizerId: record.id, + ownerFunction: record.ownerFunction, + setupPc: record.setupPc, + finallyPc: record.finallyPc, + continuationReg: record.continuationReg, + payloadReg: record.payloadReg, + throwPadPc: record.throwPadPc, + semanticBoundary: SEMANTIC_BOUNDARY, + })) + return { + handlers, + finalizers, + instructionStates, + merges, + transitions, + jumps, + throwPads, + } +} + +function completionRecords(instructions, routes) { + return instructions + .filter(({ name }) => COMPLETION_NAMES.has(name)) + .map((instruction) => { + const kind = instruction.name === 'RETURN' ? 'return' : 'throw' + const sourceRegister = register(instruction.words[1]) + return { + id: `completion:${instruction.functionId}:${instruction.pc}`, + kind: `${kind}-completion`, + functionId: instruction.functionId, + pc: instruction.pc, + name: instruction.name, + opcode: cloneData(instruction.opcode), + words: [...instruction.words], + width: instruction.width, + operands: cloneData(instruction.operands), + sourceRegister, + payloadRegister: kind === 'throw' ? sourceRegister : null, + reachable: instruction.reachable, + routes: routes + .filter( + ({ functionId, sourcePc }) => + functionId === instruction.functionId && + sourcePc === instruction.pc, + ) + .map(cloneData), + semanticBoundary: SEMANTIC_BOUNDARY, + } + }) +} + +function fallbackRecords(functions, cfg, edgeRecords, instructions) { + const edgeIds = new Map(edgeRecords.map((edge) => [edgeKey(edge), edge.id])) + const instructionIds = new Map( + instructions.map((instruction) => [ + stateKey(instruction.functionId, instruction.pc), + instruction.id, + ]), + ) + return functions.functions.map((fn) => { + const cfgFunction = cfg.functions.find(({ id }) => id === fn.id) + if (!cfgFunction) + decline('invalid-cfg', `Packet E has no function ${fn.id}`) + const blocks = cfgFunction.blocks.map((block) => ({ + id: block.id, + startPc: block.startPc, + endPc: block.endPc, + instructionPcs: [...block.instructionPcs], + instructionIds: block.instructionPcs.map((pc) => { + const id = instructionIds.get(stateKey(fn.id, pc)) + if (!id) + decline( + 'incomplete-instruction', + `No emitted instruction ${fn.id}:${pc}`, + ) + return id + }), + successorRouteIds: block.successors.map((successor) => { + const id = edgeIds.get(edgeKey(successor)) + if (!id) + decline( + 'invalid-route-identity', + `No emitted edge for ${fn.id}:${successor.sourcePc}`, + ) + return id + }), + handlerStateIn: cloneData(block.handlerStateIn), + })) + const requiredStateMachine = cfgFunction.irreducible?.required === true + const fallback = requiredStateMachine + ? { + kind: 'state-machine', + required: true, + stateVariable: '__jsconfuserVmState', + entryBlockId: `${fn.id}:b@${fn.startPc}`, + dispatch: blocks.map((block) => ({ + state: block.id, + blockId: block.id, + successorRouteIds: [...block.successorRouteIds], + })), + proof: cloneData(cfgFunction.irreducible), + } + : { + kind: 'opaque', + required: false, + blockIds: blocks.map(({ id }) => id), + reason: + 'The closure/exception boundary does not claim a structured JavaScript control form', + } + return { + id: fn.id, + kind: 'function-emission-boundary', + functionKind: fn.kind, + startPc: fn.startPc, + endPc: fn.endPc, + parentFunctionId: fn.parentFunctionId, + regCount: fn.regCount, + paramCount: fn.paramCount, + captureCount: fn.captureCount, + hasRest: fn.hasRest, + blocks, + fallback, + semanticBoundary: SEMANTIC_BOUNDARY, + } + }) +} + +function functionCoverage(records) { + const keys = records.map(({ id }) => id) + return { + expected: records.length, + emitted: records.length, + unique: new Set(keys).size === keys.length, + complete: true, + } +} + +function coverage(expected, emitted, keys) { + return { + expected, + emitted, + unique: new Set(keys).size === keys.length, + complete: expected === emitted && new Set(keys).size === expected, + } +} + +function countBy(items, key) { + return Object.fromEntries( + [...new Set(items.map((item) => item[key]))] + .sort() + .map((value) => [ + value, + items.filter((item) => item[key] === value).length, + ]), + ) +} + +function buildResult(data) { + const maps = functionMaps(data.functions) + const instructions = instructionRecords( + data.wordcode, + data.functions, + data.exceptionFinally, + ) + const closures = closureRecords(data.closureLifetimes) + const exceptions = exceptionRecords(data.exceptionFinally) + const routes = routeRecords(data.exceptionFinally) + const completions = completionRecords(instructions, routes.completionRoutes) + const functions = fallbackRecords( + data.functions, + data.cfg, + routes.edgeRecords, + instructions, + ) + const setupInstructions = instructions.filter(({ name }) => + SETUP_NAMES.has(name), + ) + const tryEnds = instructions.filter(({ name }) => name === 'TRY_END') + const throws = instructions.filter(({ name }) => name === 'THROW') + const returns = instructions.filter(({ name }) => name === 'RETURN') + const instructionKeys = instructions.map(({ functionId, pc }) => + stateKey(functionId, pc), + ) + const routeKeys = routes.edgeRecords.map(edgeKey) + const closureSiteKeys = closures.sites.map(({ id }) => id) + const captureKeys = closures.captures.map(({ id }) => id) + const bindingKeys = closures.bindings.map(({ id }) => id) + const useKeys = closures.uses.map(({ id }) => id) + const terminationKeys = closures.terminations.map(({ id }) => id) + const completionKeys = completions.map(({ id }) => id) + const stateKeys = exceptions.instructionStates.map(({ id }) => id) + const mergeKeys = exceptions.merges.map(({ id }) => id) + const transitionKeys = exceptions.transitions.map(({ id }) => id) + const jumpKeys = exceptions.jumps.map(({ id }) => id) + const handlerKeys = exceptions.handlers.map(({ id }) => id) + const finalizerKeys = exceptions.finalizers.map(({ id }) => id) + const throwPadKeys = exceptions.throwPads.map(({ id }) => id) + + const opcodeCounts = Object.fromEntries( + Object.keys(CANONICAL_OPCODES).map((name) => [ + name, + instructions.filter((instruction) => instruction.name === name).length, + ]), + ) + const routeCounts = countBy(routes.completionRoutes, 'route') + const routeKindCounts = countBy(routes.completionRoutes, 'kind') + const captureKindCounts = countBy( + closures.captures.map(({ source }) => source), + 'kind', + ) + + return deepFreeze({ + schemaVersion: EMISSION_SCHEMA, + encoding: 'numeric-u32', + parseOnly: true, + targetExecution: false, + emittedJavaScript: false, + vmExecuted: false, + finalJavaScriptSemantics: false, + semanticBoundary: SEMANTIC_BOUNDARY, + wordCount: data.wordcode.wordCount, + instructionCount: data.wordcode.instructionCount, + functionCount: data.functions.functions.length, + predecessorSchemas: { + wordcode: WORDCODE_SCHEMA, + references: REFERENCE_SCHEMA, + functions: FUNCTION_SCHEMA, + cfg: CFG_SCHEMA, + callFrames: CALL_FRAME_SCHEMA, + closureLifetimes: CLOSURE_SCHEMA, + exceptionFinally: EXCEPTION_SCHEMA, + }, + functions, + instructions, + closureRecords: closures.sites, + captureRecords: closures.captures, + bindingRecords: closures.bindings, + upvalueUseRecords: closures.uses, + lifetimeTransitionRecords: closures.terminations, + handlerRecords: cloneData(data.exceptionFinally.handlerRecords), + finallyRecords: cloneData(data.exceptionFinally.finallyRecords), + handlerEmissionRecords: exceptions.handlers, + finallyEmissionRecords: exceptions.finalizers, + handlerFinallyStateRecords: exceptions.instructionStates, + mergeRecords: exceptions.merges, + transitionRecords: exceptions.transitions, + finallyContinuationChecks: exceptions.jumps, + throwPadRecords: exceptions.throwPads, + completionRecords: completions, + routeRecords: routes.completionRoutes, + edgeRecords: routes.edgeRecords, + closureSites: cloneData(data.closureLifetimes.closureSites), + capturePairs: cloneData(data.closureLifetimes.capturePairs), + bindings: cloneData(data.closureLifetimes.bindings), + uses: cloneData(data.closureLifetimes.uses), + terminationEvents: cloneData(data.closureLifetimes.terminationEvents), + exceptionStates: cloneData(data.exceptionFinally.instructionStates), + edgeStates: cloneData(data.exceptionFinally.edgeStates), + merges: cloneData(data.exceptionFinally.merges), + jumpRegChecks: cloneData(data.exceptionFinally.jumpRegChecks), + transitions: cloneData(data.exceptionFinally.transitions), + completionRoutes: cloneData(data.exceptionFinally.completionRoutes), + census: { + opcodeCounts, + closures: { + sites: closures.sites.length, + capturePairs: closures.captures.length, + localCapturePairs: captureKindCounts.local ?? 0, + upvalueCapturePairs: captureKindCounts.upvalue ?? 0, + bindings: closures.bindings.length, + upvalueUses: closures.uses.length, + terminationEvents: closures.terminations.length, + }, + exceptions: { + trySetup: instructions.filter(({ name }) => name === 'TRY_SETUP') + .length, + tryEnd: tryEnds.length, + finallySetup: instructions.filter( + ({ name }) => name === 'FINALLY_SETUP', + ).length, + handlers: exceptions.handlers.length, + finalizers: exceptions.finalizers.length, + handlerFinallyStates: exceptions.instructionStates.length, + merges: exceptions.merges.length, + transitions: exceptions.transitions.length, + jumpRegChecks: exceptions.jumps.length, + throwPads: exceptions.throwPads.length, + }, + completions: { + returns: returns.length, + throws: throws.length, + records: completions.length, + }, + routes: { + total: routes.completionRoutes.length, + byKind: routeKindCounts, + byRoute: routeCounts, + }, + }, + coverage: { + instructions: coverage( + data.wordcode.instructions.length, + instructions.length, + instructionKeys, + ), + closures: { + sites: coverage( + data.closureLifetimes.closureSites.length, + closures.sites.length, + closureSiteKeys, + ), + capturePairs: coverage( + data.closureLifetimes.capturePairs.length, + closures.captures.length, + captureKeys, + ), + bindings: coverage( + data.closureLifetimes.bindings.length, + closures.bindings.length, + bindingKeys, + ), + uses: coverage( + data.closureLifetimes.uses.length, + closures.uses.length, + useKeys, + ), + terminationEvents: coverage( + data.closureLifetimes.terminationEvents.length, + closures.terminations.length, + terminationKeys, + ), + }, + exceptions: { + handlers: coverage( + data.exceptionFinally.handlerRecords.length, + exceptions.handlers.length, + handlerKeys, + ), + finalizers: coverage( + data.exceptionFinally.finallyRecords.length, + exceptions.finalizers.length, + finalizerKeys, + ), + setupInstructions: coverage( + data.wordcode.instructions.filter(({ name }) => SETUP_NAMES.has(name)) + .length, + setupInstructions.length, + setupInstructions.map(({ functionId, pc }) => + stateKey(functionId, pc), + ), + ), + tryEnds: coverage( + data.wordcode.instructions.filter(({ name }) => name === 'TRY_END') + .length, + tryEnds.length, + tryEnds.map(({ functionId, pc }) => stateKey(functionId, pc)), + ), + throws: coverage( + data.wordcode.instructions.filter(({ name }) => name === 'THROW') + .length, + throws.length, + throws.map(({ functionId, pc }) => stateKey(functionId, pc)), + ), + states: coverage( + data.exceptionFinally.instructionStates.length, + exceptions.instructionStates.length, + stateKeys, + ), + edges: coverage( + data.exceptionFinally.edgeStates.length, + routes.edgeRecords.length, + routeKeys, + ), + merges: coverage( + data.exceptionFinally.merges.length, + exceptions.merges.length, + mergeKeys, + ), + transitions: coverage( + data.exceptionFinally.transitions.length, + exceptions.transitions.length, + transitionKeys, + ), + jumpRegChecks: coverage( + data.exceptionFinally.jumpRegChecks.length, + exceptions.jumps.length, + jumpKeys, + ), + throwPads: coverage( + data.exceptionFinally.finallyRecords.length, + exceptions.throwPads.length, + throwPadKeys, + ), + }, + completions: coverage( + data.wordcode.instructions.filter(({ name }) => + COMPLETION_NAMES.has(name), + ).length, + completions.length, + completionKeys, + ), + routes: coverage( + data.exceptionFinally.completionRoutes.length, + routes.completionRoutes.length, + routes.completionRoutes.map(({ id }) => id), + ), + completionRoutes: coverage( + data.exceptionFinally.completionRoutes.length, + routes.completionRoutes.length, + routes.completionRoutes.map(({ id }) => id), + ), + functions: functionCoverage(functions), + }, + proof: { + allPredecessorsReconstructed: true, + exactInstructionCoverage: true, + exactClosureCoverage: true, + exactExceptionCoverage: true, + noTargetExecution: true, + noJavaScriptEmission: true, + noFinalJavaScriptSemantics: true, + ownerIdentityFromPacketD: + maps.ownerByPc.size === data.wordcode.instructions.length, + }, + omissions: { + finalJavaScript: 'not emitted', + JavaScriptSemantics: + 'not claimed; closure and exception records remain target-local', + sourceReplacement: 'not performed', + visitorCliPluginRegistry: 'not integrated', + targetExecution: 'not performed', + hardenedVariants: 'not supported', + runtimeEquivalence: 'not checked', + }, + }) +} + +function analyze( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, +) { + const data = preflight( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, + ) + return buildResult(data) +} + +export function diagnoseClosureException( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, +) { + try { + return deepFreeze({ + ok: true, + result: analyze( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, + ), + diagnostic: null, + }) + } catch (error) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: + error instanceof ClosureExceptionDecline + ? error.code + : 'closure-exception-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function emitClosureException( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, +) { + const diagnosis = diagnoseClosureException( + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, + ) + return diagnosis.ok ? diagnosis.result : null +} + +export const emitClosureExceptionBoundary = emitClosureException +export const buildClosureExceptionEmission = emitClosureException +export const diagnoseClosureExceptionEmission = diagnoseClosureException +export default emitClosureException diff --git a/src/vm/jsconfuser-vm/emit-structured-control.js b/src/vm/jsconfuser-vm/emit-structured-control.js new file mode 100644 index 00000000..211e9f6a --- /dev/null +++ b/src/vm/jsconfuser-vm/emit-structured-control.js @@ -0,0 +1,502 @@ +import { + CFG_SCHEMA, + EXCEPTION_FINALLY_SCHEMA, + STRUCTURED_CONTROL_SCHEMA, + StructuredControlDecline, + WORDCODE_SCHEMA, + arrayEqual, + canonicalWordcode, + cloneData, + decline, + deepFreeze, + edgeKey, +} from './structured-control-input.js' +import { validateCfg } from './structured-control-cfg.js' +import { validateExceptionFinally } from './structured-control-exceptions.js' + +function blockGraph(fn, edges, blockById) { + const byPc = new Map( + fn.instructionPcs.map((pc) => [ + pc, + [...blockById.values()].find((block) => + block.instructionPcs.includes(pc), + ), + ]), + ) + const adjacency = new Map( + [...blockById.values()].map((block) => [block.id, []]), + ) + for (const edge of edges) { + const sourceBlock = byPc.get(edge.sourcePc) + if (!sourceBlock) + decline( + 'invalid-basic-block', + `No block owns edge source ${edge.sourcePc}`, + ) + if (edge.targetPc !== null) { + const targetBlock = byPc.get(edge.targetPc) + if (!targetBlock) + decline( + 'invalid-basic-block', + `No block owns edge target ${edge.targetPc}`, + ) + adjacency.get(sourceBlock.id).push({ target: targetBlock.id, edge }) + } + } + return { byPc, adjacency } +} + +function reachableBlockIds(entry, adjacency) { + const visited = new Set([entry]) + const queue = [entry] + while (queue.length) { + const id = queue.shift() + for (const { target } of adjacency.get(id) ?? []) { + if (!visited.has(target)) { + visited.add(target) + queue.push(target) + } + } + } + return visited +} + +function dominators(blockIds, entry, adjacency) { + const all = new Set(blockIds) + const predecessors = new Map([...all].map((id) => [id, []])) + for (const [source, successors] of adjacency) { + for (const { target } of successors) + if (predecessors.has(target)) predecessors.get(target).push(source) + } + const dom = new Map( + [...all].map((id) => [id, id === entry ? new Set([entry]) : new Set(all)]), + ) + let changed = true + while (changed) { + changed = false + for (const id of all) { + if (id === entry) continue + const preds = predecessors.get(id).filter((pred) => all.has(pred)) + const intersection = preds.length + ? new Set( + preds.reduce( + (left, pred) => + [...left].filter((candidate) => dom.get(pred).has(candidate)), + [...all], + ), + ) + : new Set() + intersection.add(id) + if (!sameSet(dom.get(id), intersection)) { + dom.set(id, intersection) + changed = true + } + } + } + return { dom, predecessors } +} + +function sameSet(left, right) { + return ( + left.size === right.size && [...left].every((value) => right.has(value)) + ) +} + +function naturalLoop(header, source, predecessors) { + const loop = new Set([header, source]) + const work = [source] + while (work.length) { + const node = work.pop() + for (const predecessor of predecessors.get(node) ?? []) { + if (!loop.has(predecessor)) { + loop.add(predecessor) + if (predecessor !== header) work.push(predecessor) + } + } + } + return loop +} + +function findNaturalLoops(fn, edges, blockById, edgeIdsByKey) { + const graph = blockGraph(fn, edges, blockById) + const reachable = reachableBlockIds( + `${fn.id}:b@${fn.startPc}`, + graph.adjacency, + ) + const { dom, predecessors } = dominators( + reachable, + `${fn.id}:b@${fn.startPc}`, + graph.adjacency, + ) + const loops = [] + for (const [source, successors] of graph.adjacency) { + for (const { target, edge } of successors) { + if ( + !reachable.has(source) || + !reachable.has(target) || + !dom.get(source)?.has(target) || + source === target + ) + continue + const members = naturalLoop(target, source, predecessors) + const exitEdges = edges.filter((candidate) => { + const candidateSource = graph.byPc.get(candidate.sourcePc)?.id + const candidateTarget = + candidate.targetPc === null + ? null + : graph.byPc.get(candidate.targetPc)?.id + return ( + members.has(candidateSource) && + (candidateTarget === null || !members.has(candidateTarget)) + ) + }) + const headerEdges = edges.filter( + (candidate) => + graph.byPc.get(candidate.sourcePc)?.id === target && + candidate.targetPc !== null && + members.has(graph.byPc.get(candidate.targetPc)?.id), + ) + if (!exitEdges.length || !headerEdges.length) continue + const key = `${target}:${[...members].sort().join(',')}` + if (loops.some((loop) => loop.key === key)) continue + loops.push({ + key, + kind: 'natural-loop', + headerBlockId: target, + backEdgeIds: [edgeIdsByKey.get(edgeKey(edge))], + bodyBlockIds: [...members].sort( + (left, right) => + blockById.get(left).startPc - blockById.get(right).startPc, + ), + exitEdgeIds: exitEdges.map((candidate) => + edgeIdsByKey.get(edgeKey(candidate)), + ), + headerCondition: + headerEdges.find((candidate) => candidate.kind === 'conditional') + ?.condition ?? null, + proof: + 'header dominates the back-edge source and the loop has an explicit exit', + }) + } + } + return loops.map((loop) => { + const result = { ...loop } + delete result.key + return result + }) +} + +function bfsDistances(start, adjacency) { + const distances = new Map([[start, 0]]) + const queue = [start] + while (queue.length) { + const current = queue.shift() + for (const { target } of adjacency.get(current) ?? []) { + if (!distances.has(target)) { + distances.set(target, distances.get(current) + 1) + queue.push(target) + } + } + } + return distances +} + +function findConditionalRegions(fn, edges, blockById, edgeIdsByKey) { + const graph = blockGraph(fn, edges, blockById) + const regions = [] + for (const block of blockById.values()) { + const outgoing = edges.filter( + (edge) => edge.sourcePc === block.instructionPcs.at(-1), + ) + const conditional = outgoing.find((edge) => edge.kind === 'conditional') + const fallthrough = outgoing.find((edge) => edge.kind === 'fallthrough') + if ( + !conditional || + !fallthrough || + conditional.targetPc === null || + fallthrough.targetPc === null + ) + continue + const branchBlock = graph.byPc.get(conditional.targetPc) + const fallthroughBlock = graph.byPc.get(fallthrough.targetPc) + if (!branchBlock || !fallthroughBlock) continue + const left = bfsDistances(branchBlock.id, graph.adjacency) + const right = bfsDistances(fallthroughBlock.id, graph.adjacency) + const candidates = [...left.keys()].filter((candidate) => + right.has(candidate), + ) + if (!candidates.length) continue + const bestScore = Math.min( + ...candidates.map((candidate) => + Math.max(left.get(candidate), right.get(candidate)), + ), + ) + const best = candidates.filter( + (candidate) => + Math.max(left.get(candidate), right.get(candidate)) === bestScore, + ) + if (best.length !== 1) continue + const mergeBlock = blockById.get(best[0]) + if (!mergeBlock) continue + regions.push({ + kind: 'conditional', + testBlockId: block.id, + condition: cloneData(conditional.condition), + branchEdgeId: edgeIdsByKey.get(edgeKey(conditional)), + branchTargetPc: conditional.targetPc, + fallthroughEdgeId: edgeIdsByKey.get(edgeKey(fallthrough)), + fallthroughTargetPc: fallthrough.targetPc, + mergeBlockId: mergeBlock.id, + proof: + 'both proven successors have one unique nearest reconvergence block', + }) + } + return regions +} + +function structuredControl( + fn, + edges, + blockById, + irreducibleRegions, + edgeIdsByKey, +) { + const blocks = [...blockById.values()].sort( + (left, right) => left.startPc - right.startPc, + ) + const blockOrder = blocks.map(({ id }) => id) + if (irreducibleRegions.length) { + return { + mode: 'state-machine', + control: { + kind: 'state-machine', + stateVariable: '__jsconfuserVmState', + entryBlockId: `${fn.id}:b@${fn.startPc}`, + blockOrder, + dispatch: blocks.map((block) => ({ + state: block.id, + blockId: block.id, + successorEdgeIds: edges + .filter(({ sourcePc }) => sourcePc === block.instructionPcs.at(-1)) + .map((edge) => edgeIdsByKey.get(edgeKey(edge))), + })), + proof: { + irreducible: cloneData(fn.irreducible), + exactBlockGraph: true, + explicitLeaves: true, + }, + }, + } + } + const allFallthrough = edges.every( + (edge) => edge.kind === 'fallthrough' || edge.kind === 'return', + ) + const regions = [] + if (allFallthrough) { + regions.push({ + kind: 'linear', + blockIds: blockOrder, + proof: + 'every reachable successor is the next serialized block or an explicit return', + }) + } else { + regions.push(...findConditionalRegions(fn, edges, blockById, edgeIdsByKey)) + regions.push(...findNaturalLoops(fn, edges, blockById, edgeIdsByKey)) + const coveredControlSources = new Set( + regions.flatMap((region) => { + if (region.kind === 'conditional') return [region.testBlockId] + if (region.kind === 'natural-loop') return [region.headerBlockId] + return [] + }), + ) + const unsupported = blocks + .filter((block) => !coveredControlSources.has(block.id)) + .map(({ id }) => id) + if (unsupported.length) { + regions.push({ + kind: 'opaque', + blockIds: unsupported, + reason: 'control shape not structurally proved by the accepted CFG', + }) + } + } + return { + mode: 'structured', + control: { + kind: 'structured', + blockOrder, + regions, + proof: + 'structured nodes are emitted only from CFG edges, dominance, and unique reconvergence', + }, + } +} + +function buildResult(wordcodeData, cfgData, hData) { + const globalEdges = cfgData.allEdges.map((edge, index) => ({ + id: `e${index}`, + ...cloneData(edge), + })) + const edgeIdsByKey = new Map( + cfgData.allEdges.map((edge, index) => [edgeKey(edge), `e${index}`]), + ) + const functionResults = [] + let structuredCount = 0 + let stateMachineCount = 0 + const emittedPcs = [] + for (const fn of cfgData.functions) { + const blockById = cfgData.functionBlocks.get(fn.id) + const fnEdges = cfgData.edgesByFunction.get(fn.id) + const control = structuredControl( + fn, + fnEdges, + blockById, + cfgData.irreducibleByFunction.get(fn.id), + edgeIdsByKey, + ) + if (control.mode === 'structured') structuredCount += 1 + else stateMachineCount += 1 + const states = hData.statesByFunction.get(fn.id) + const blocks = [...blockById.values()] + .sort((left, right) => left.startPc - right.startPc) + .map((block) => { + const leaves = block.instructionPcs.map((pc) => { + const instruction = wordcodeData.byPc.get(pc) + const state = states.get(pc) + emittedPcs.push(pc) + return { + kind: 'opcode', + ...cloneData(instruction), + reachable: state.reachable, + semanticBoundary: 'later-packet', + } + }) + return { + id: block.id, + functionId: fn.id, + startPc: block.startPc, + endPc: block.endPc, + leaves, + handlerStateIn: cloneData(block.handlerStateIn), + successorEdgeIds: fnEdges + .filter(({ sourcePc }) => sourcePc === block.instructionPcs.at(-1)) + .map((edge) => edgeIdsByKey.get(edgeKey(edge))), + } + }) + const instructionCount = blocks.reduce( + (count, block) => count + block.leaves.length, + 0, + ) + functionResults.push({ + id: fn.id, + kind: fn.kind, + startPc: fn.startPc, + endPc: fn.endPc, + parentFunctionId: fn.parentFunctionId, + regCount: fn.regCount, + paramCount: fn.paramCount, + captureCount: fn.captureCount, + hasRest: fn.hasRest, + instructionCount, + blockCount: blocks.length, + mode: control.mode, + control: control.control, + blocks, + }) + } + const overallMode = + stateMachineCount === 0 + ? 'structured' + : structuredCount === 0 + ? 'state-machine' + : 'mixed' + return deepFreeze({ + schemaVersion: STRUCTURED_CONTROL_SCHEMA, + encoding: 'numeric-u32', + parseOnly: true, + targetExecution: false, + wordCount: wordcodeData.words.length, + instructionCount: wordcodeData.instructions.length, + functionCount: functionResults.length, + mode: overallMode, + modeCounts: { + structured: structuredCount, + stateMachine: stateMachineCount, + }, + predecessorSchemas: { + wordcode: WORDCODE_SCHEMA, + cfg: CFG_SCHEMA, + exceptionFinally: EXCEPTION_FINALLY_SCHEMA, + }, + functions: functionResults, + edges: globalEdges, + coverage: { + instructions: { + expected: wordcodeData.instructions.length, + emitted: emittedPcs.length, + unique: new Set(emittedPcs).size === emittedPcs.length, + complete: arrayEqual( + [...emittedPcs].sort((left, right) => left - right), + [...wordcodeData.boundaries].sort((left, right) => left - right), + ), + }, + edges: { + expected: cfgData.allEdges.length, + emitted: globalEdges.length, + unique: + new Set(globalEdges.map(({ id }) => id)).size === globalEdges.length, + complete: globalEdges.length === cfgData.allEdges.length, + }, + }, + omissions: { + targetSemantics: + 'not-emitted; opcode leaves retain exact typed B records', + callsPropertiesScalarsClosuresCompletion: + 'not-reclassified; owned by later packets', + exceptionEmission: + 'not-emitted; exact Packet H states and routes are carried', + sourceReplacement: 'not performed', + visitorCliIntegration: 'not performed', + targetExecution: 'not performed', + }, + }) +} + +function recognize(wordcode, cfg, exceptionFinally) { + const wordcodeData = canonicalWordcode(wordcode) + const cfgData = validateCfg(cfg, wordcodeData) + const hData = validateExceptionFinally( + exceptionFinally, + wordcodeData, + cfgData, + ) + return buildResult(wordcodeData, cfgData, hData) +} + +export function diagnoseStructuredControl(wordcode, cfg, exceptionFinally) { + try { + return deepFreeze({ + ok: true, + result: recognize(wordcode, cfg, exceptionFinally), + diagnostic: null, + }) + } catch (error) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: + error instanceof StructuredControlDecline + ? error.code + : 'structured-control-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function emitStructuredControl(wordcode, cfg, exceptionFinally) { + return diagnoseStructuredControl(wordcode, cfg, exceptionFinally).result +} + +export default emitStructuredControl diff --git a/src/vm/jsconfuser-vm/exception-finally-control.js b/src/vm/jsconfuser-vm/exception-finally-control.js new file mode 100644 index 00000000..0977ec42 --- /dev/null +++ b/src/vm/jsconfuser-vm/exception-finally-control.js @@ -0,0 +1,751 @@ +import { + CALL_FRAME_SCHEMA, + CFG_SCHEMA, + EDGE_KINDS, + FUNCTION_SCHEMA, + REFERENCE_SCHEMA, + WORDCODE_SCHEMA, + decline, + recordId, + requireArray, + requireInteger, + requireObject, + requireSame, + sameValue, + stateFromView, +} from './exception-finally-input.js' +import { recordsForPc, validateFRoute } from './exception-finally-routes.js' + +function directTarget(instruction) { + if (instruction.name === 'JUMP') + return { operand: 0, target: instruction.words[1], role: 'target' } + if ( + instruction.name === 'JUMP_IF_FALSE' || + instruction.name === 'JUMP_IF_TRUE' + ) { + return { operand: 1, target: instruction.words[2], role: 'target' } + } + if (instruction.name === 'FOR_IN_NEXT') { + return { operand: 2, target: instruction.words[3], role: 'exit' } + } + if (instruction.name === 'TRY_SETUP') { + return { operand: 0, target: instruction.words[1], role: 'handler' } + } + return null +} + +export function validateDirectTargets( + wordcodeData, + referenceData, + functionData, +) { + const referencesByLocation = new Map( + referenceData.labelReferences.map((reference) => [ + `${reference.pc}:${reference.operand}`, + reference, + ]), + ) + for (const instruction of wordcodeData.instructions) { + const targets = [] + const direct = directTarget(instruction) + if (direct) targets.push(direct) + if (instruction.name === 'FINALLY_SETUP') { + targets.push( + { operand: 0, target: instruction.words[1], role: 'finally' }, + { operand: 3, target: instruction.words[4], role: 'throwPad' }, + ) + } + for (const target of targets) { + const owner = functionData.ownerByPc.get(instruction.pc) + if (!wordcodeData.boundaries.has(target.target)) { + decline( + target.role === 'handler' || + target.role === 'finally' || + target.role === 'throwPad' + ? 'invalid-handler-routing' + : 'cross-function-target', + `${instruction.name}@${instruction.pc} targets non-boundary ${target.target}`, + ) + } + if (functionData.ownerByPc.get(target.target) !== owner) { + decline( + target.role === 'handler' || + target.role === 'finally' || + target.role === 'throwPad' + ? 'invalid-handler-routing' + : 'cross-function-target', + `${instruction.name}@${instruction.pc} leaves function ${owner}`, + ) + } + const reference = referencesByLocation.get( + `${instruction.pc}:${target.operand}`, + ) + if ( + !reference || + reference.target !== target.target || + reference.role !== target.role + ) { + decline( + 'stale-predecessor', + `${instruction.name}@${instruction.pc} label reference is stale`, + ) + } + } + } +} + +export function makeRecords(wordcodeData, functionData) { + const recordsByFunction = new Map() + const allRecords = new Map() + for (const fn of functionData.list) { + const records = new Map() + for (const pc of fn.instructionPcs) { + const instruction = wordcodeData.byPc.get(pc) + if (instruction.name === 'TRY_SETUP') { + const record = { + id: recordId('handler', pc), + ownerFunction: fn.id, + type: 'handler', + setupPc: pc, + handlerPc: instruction.words[1], + exceptionReg: instruction.words[2], + } + validateRecordTargets(record, fn, wordcodeData, functionData) + records.set(record.id, record) + allRecords.set(record.id, record) + } else if (instruction.name === 'FINALLY_SETUP') { + const record = { + id: recordId('finally', pc), + ownerFunction: fn.id, + type: 'finally', + setupPc: pc, + finallyPc: instruction.words[1], + continuationReg: instruction.words[2], + payloadReg: instruction.words[3], + throwPadPc: instruction.words[4], + } + validateRecordTargets(record, fn, wordcodeData, functionData) + records.set(record.id, record) + allRecords.set(record.id, record) + } + } + recordsByFunction.set(fn.id, records) + } + return { recordsByFunction, allRecords } +} + +function validateRecordTargets(record, fn, wordcodeData, functionData) { + const targets = + record.type === 'handler' + ? [record.handlerPc] + : [record.finallyPc, record.throwPadPc] + for (const target of targets) { + if ( + !wordcodeData.boundaries.has(target) || + functionData.ownerByPc.get(target) !== fn.id + ) { + decline( + 'invalid-handler-routing', + `${record.id} routes outside function ${fn.id}`, + ) + } + } + const registers = + record.type === 'handler' + ? [record.exceptionReg] + : [record.continuationReg, record.payloadReg] + for (const index of registers) { + if (!Number.isInteger(index) || index < 0 || index >= fn.regCount) { + decline('invalid-handler-state', `${record.id} has an invalid register`) + } + } +} + +function validateERecord(record, expected, fn, wordcodeData, functionData) { + requireObject( + record, + 'invalid-handler-state', + `${fn.id} CFG record is malformed`, + ) + if (record.type !== expected.type || record.ownerFunction !== fn.id) { + decline('stale-predecessor', `${fn.id} CFG record identity is stale`) + } + const target = + expected.type === 'handler' ? record.handlerPc : record.finallyPc + if ( + !wordcodeData.boundaries.has(target) || + functionData.ownerByPc.get(target) !== fn.id + ) { + decline( + 'invalid-handler-routing', + `${record.id} target leaves function ${fn.id}`, + ) + } + if (expected.type === 'finally') { + if ( + !wordcodeData.boundaries.has(record.throwPadPc) || + functionData.ownerByPc.get(record.throwPadPc) !== fn.id + ) { + decline( + 'invalid-handler-routing', + `${record.id} throw pad leaves function ${fn.id}`, + ) + } + } + requireSame( + record, + expected, + 'stale-predecessor', + `${fn.id} CFG record is stale`, + ) +} + +export function validateCfg(cfg, wordcodeData, functionData, recordData) { + requireObject(cfg, 'invalid-cfg', 'Packet E must be an object') + if ( + cfg.schemaVersion !== CFG_SCHEMA || + cfg.encoding !== 'numeric-u32' || + cfg.wordCount !== wordcodeData.words.length || + cfg.instructionCount !== wordcodeData.instructions.length || + cfg.functionCount !== functionData.list.length + ) { + decline('stale-predecessor', 'Packet E does not describe Packets B-D') + } + requireSame( + cfg.ownership, + [...functionData.ownerByPc].map(([pc, functionId]) => ({ pc, functionId })), + 'stale-predecessor', + 'Packet E ownership is stale', + ) + requireObject( + cfg.source, + 'invalid-cfg', + 'Packet E source metadata is missing', + ) + requireSame( + cfg.source, + { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: REFERENCE_SCHEMA, + functionsSchema: FUNCTION_SCHEMA, + functionEntries: functionData.list.map(({ id, startPc, endPc }) => ({ + id, + startPc, + endPc, + })), + }, + 'stale-predecessor', + 'Packet E source metadata is stale', + ) + const cfgFunctions = requireArray( + cfg.functions, + 'invalid-cfg', + 'Packet E functions are missing', + ) + if (cfgFunctions.length !== functionData.list.length) { + decline('stale-predecessor', 'Packet E function count is stale') + } + if (new Set(cfgFunctions.map(({ id }) => id)).size !== cfgFunctions.length) { + decline('stale-predecessor', 'Packet E function records are duplicated') + } + const edges = requireArray( + cfg.edges, + 'invalid-cfg', + 'Packet E edges are missing', + ) + const indirectTargets = requireArray( + cfg.indirectTargets, + 'invalid-cfg', + 'Packet E indirect targets are missing', + ) + const edgesByFunction = new Map() + const indirectByFunction = new Map() + for (const fn of cfgFunctions) { + requireObject(fn, 'invalid-cfg', 'Packet E function is malformed') + const expectedFn = functionData.byId.get(fn.id) + if (!expectedFn) + decline( + 'stale-predecessor', + `Packet E references unknown function ${fn.id}`, + ) + for (const key of [ + 'startPc', + 'endPc', + 'regCount', + 'paramCount', + 'captureCount', + ]) { + if (fn[key] !== expectedFn[key]) + decline('stale-predecessor', `Packet E ${key} is stale for ${fn.id}`) + } + requireSame( + fn.instructionPcs, + expectedFn.instructionPcs, + 'stale-predecessor', + `Packet E PCs are stale for ${fn.id}`, + ) + const records = recordData.recordsByFunction.get(fn.id) + const eHandlers = requireArray( + fn.handlerRecords, + 'invalid-handler-state', + `${fn.id} handler records are missing`, + ) + const eFinallys = requireArray( + fn.finallyRecords, + 'invalid-handler-state', + `${fn.id} finally records are missing`, + ) + const expectedHandlers = [...records.values()].filter( + ({ type }) => type === 'handler', + ) + const expectedFinallys = [...records.values()].filter( + ({ type }) => type === 'finally', + ) + if ( + eHandlers.length !== expectedHandlers.length || + eFinallys.length !== expectedFinallys.length + ) { + decline('stale-predecessor', `${fn.id} CFG record count is stale`) + } + for (let index = 0; index < eHandlers.length; index += 1) { + validateERecord( + eHandlers[index], + expectedHandlers[index], + fn, + wordcodeData, + functionData, + ) + } + for (let index = 0; index < eFinallys.length; index += 1) { + validateERecord( + eFinallys[index], + expectedFinallys[index], + fn, + wordcodeData, + functionData, + ) + } + const fnEdges = requireArray( + fn.edges, + 'invalid-cfg', + `${fn.id} edges are missing`, + ) + edgesByFunction.set(fn.id, fnEdges) + const fnIndirect = requireArray( + fn.indirectTargets, + 'invalid-cfg', + `${fn.id} indirect targets are missing`, + ) + indirectByFunction.set(fn.id, fnIndirect) + validateCfgBlocks(fn, wordcodeData, functionData, records) + } + const flatEdges = [] + for (const fn of cfgFunctions) { + for (const edge of edgesByFunction.get(fn.id)) { + validateCfgEdge( + edge, + fn, + wordcodeData, + functionData, + recordData.recordsByFunction.get(fn.id), + ) + flatEdges.push(edge) + } + } + requireSame( + edges, + flatEdges, + 'stale-predecessor', + 'Packet E flat edge list is stale', + ) + for (const entry of indirectTargets) { + validateIndirect(entry, functionData, wordcodeData, cfgFunctions) + validateIndirectOrigins(entry, wordcodeData, functionData, edgesByFunction) + const list = indirectByFunction.get(functionData.ownerByPc.get(entry.pc)) + const localEntry = list?.find(({ pc }) => pc === entry.pc) + const comparableEntry = localEntry + ? { ...localEntry, functionId: entry.functionId } + : null + if (!comparableEntry || !sameValue(entry, comparableEntry)) { + decline( + 'stale-predecessor', + `Packet E indirect target ${entry.pc} is stale`, + ) + } + } + const allIndirect = [...indirectByFunction.entries()].flatMap( + ([functionId, entries]) => + entries.map((entry) => ({ ...entry, functionId })), + ) + requireSame( + allIndirect, + indirectTargets, + 'stale-predecessor', + 'Packet E indirect target list is stale', + ) + const callSites = requireArray( + cfg.callSites, + 'invalid-cfg', + 'Packet E call sites are missing', + ) + for (const callSite of callSites) { + requireObject(callSite, 'invalid-cfg', 'Packet E call site is malformed') + requireInteger( + callSite.pc, + 'invalid-cfg', + 'Packet E call site PC is invalid', + ) + } + return { cfgFunctions, edgesByFunction, indirectByFunction, callSites } +} + +function validateIndirectOrigins( + entry, + wordcodeData, + functionData, + edgesByFunction, +) { + const owner = functionData.ownerByPc.get(entry.pc) + for (const target of entry.targets) { + const origins = entry.origins[String(target)] + if (!Array.isArray(origins) || !origins.length) { + decline( + 'stale-predecessor', + `JUMP_REG@${entry.pc} has no origin for ${target}`, + ) + } + for (const origin of origins) { + if (typeof origin === 'string' && origin.startsWith('exception@')) { + const sourcePc = Number(origin.slice('exception@'.length)) + const route = edgesByFunction + .get(owner) + ?.find( + (edge) => + edge.sourcePc === sourcePc && + edge.kind === 'finally' && + edge.route === 'exceptional' && + edge.payload?.throwPadPc === target, + ) + if (!route) { + decline( + 'stale-predecessor', + `JUMP_REG@${entry.pc} exception origin is stale`, + ) + } + continue + } + const sourcePc = Number(origin) + const instruction = wordcodeData.byPc.get(sourcePc) + if (!instruction || instruction.name !== 'LOAD_INT') { + decline('stale-predecessor', `JUMP_REG@${entry.pc} origin is stale`) + } + const value = instruction.words[2] + if (value !== target) { + if ( + wordcodeData.boundaries.has(value) && + functionData.ownerByPc.get(value) !== owner + ) { + decline( + 'cross-function-target', + `JUMP_REG@${entry.pc} origin leaves function ${owner}`, + ) + } + decline( + 'stale-predecessor', + `JUMP_REG@${entry.pc} origin value is stale`, + ) + } + if (functionData.ownerByPc.get(value) !== owner) { + decline( + 'cross-function-target', + `JUMP_REG@${entry.pc} origin leaves function ${owner}`, + ) + } + } + } +} + +function validateCfgBlocks(fn, wordcodeData, functionData, records) { + const blocks = requireArray( + fn.blocks, + 'invalid-cfg', + `${fn.id} blocks are missing`, + ) + const seen = new Set() + for (const block of blocks) { + requireObject(block, 'invalid-cfg', `${fn.id} block is malformed`) + requireInteger( + block.startPc, + 'invalid-cfg', + `${fn.id} block start is invalid`, + ) + requireInteger(block.endPc, 'invalid-cfg', `${fn.id} block end is invalid`) + if ( + seen.has(block.startPc) || + block.startPc < fn.startPc || + block.endPc > fn.endPc || + block.startPc >= block.endPc || + !wordcodeData.boundaries.has(block.startPc) + ) { + decline('stale-predecessor', `${fn.id} block boundaries are stale`) + } + seen.add(block.startPc) + stateFromView( + block.handlerStateIn, + records, + `${fn.id} block ${block.startPc}`, + ) + const owner = functionData.ownerByPc.get(block.startPc) + if (owner !== fn.id) + decline('stale-predecessor', `${fn.id} block ownership is stale`) + const instructionPcs = requireArray( + block.instructionPcs, + 'invalid-cfg', + `${fn.id} block instructions are missing`, + ) + requireSame( + instructionPcs, + wordcodeData.instructions + .filter(({ pc }) => pc >= block.startPc && pc < block.endPc) + .map(({ pc }) => pc), + 'stale-predecessor', + `${fn.id} block instructions are stale`, + ) + } +} + +function validateCfgEdge(edge, fn, wordcodeData, functionData, records) { + requireObject(edge, 'invalid-cfg', `${fn.id} edge is malformed`) + for (const key of ['sourcePc', 'targetPc', 'functionId']) { + if (key === 'targetPc' && edge.targetPc === null) continue + if (key === 'targetPc' && !Number.isInteger(edge.targetPc)) { + decline('invalid-cfg', `${fn.id} edge target is invalid`) + } + if (key !== 'targetPc') + requireInteger( + edge[key], + 'invalid-cfg', + `${fn.id} edge ${key} is invalid`, + ) + } + if ( + edge.functionId !== fn.id || + !wordcodeData.boundaries.has(edge.sourcePc) + ) { + decline('stale-predecessor', `${fn.id} edge ownership is stale`) + } + if (edge.targetPc !== null) { + if (!wordcodeData.boundaries.has(edge.targetPc)) { + decline( + edge.kind === 'handler' || edge.kind === 'finally' + ? 'invalid-handler-routing' + : 'cross-function-target', + `${fn.id} edge targets a non-boundary`, + ) + } + if (functionData.ownerByPc.get(edge.targetPc) !== fn.id) { + decline( + edge.kind === 'handler' || edge.kind === 'finally' + ? 'invalid-handler-routing' + : 'cross-function-target', + `${fn.id} edge leaves its owner`, + ) + } + } + if (!EDGE_KINDS.has(edge.kind)) + decline('invalid-cfg', `${fn.id} edge kind is invalid`) + stateFromView(edge.handlerStateBefore, records, `${fn.id} edge before`) + stateFromView(edge.handlerStateAfter, records, `${fn.id} edge after`) +} + +function validateIndirect(entry, functionData, wordcodeData, cfgFunctions) { + requireObject(entry, 'invalid-cfg', 'Packet E indirect target is malformed') + requireInteger(entry.pc, 'invalid-cfg', 'Packet E indirect PC is invalid') + requireInteger( + entry.sourceRegister, + 'invalid-cfg', + 'Packet E indirect register is invalid', + ) + const targets = requireArray( + entry.targets, + 'invalid-cfg', + 'Packet E indirect targets are missing', + ) + if (!targets.length || new Set(targets).size !== targets.length) { + decline( + 'unresolved-jump-reg', + `JUMP_REG@${entry.pc} has no finite target set`, + ) + } + const owner = functionData.ownerByPc.get(entry.pc) + const fn = cfgFunctions.find(({ id }) => id === owner) + if (!fn || entry.sourceRegister >= fn.regCount) + decline('unresolved-jump-reg', `JUMP_REG@${entry.pc} register is invalid`) + for (const target of targets) { + requireInteger(target, 'invalid-cfg', 'Packet E indirect target is invalid') + if ( + !wordcodeData.boundaries.has(target) || + functionData.ownerByPc.get(target) !== owner + ) { + decline( + 'cross-function-target', + `JUMP_REG@${entry.pc} leaves function ${owner}`, + ) + } + } + requireObject( + entry.origins, + 'invalid-cfg', + `JUMP_REG@${entry.pc} origins are missing`, + ) +} + +export function validateCallFrames( + callFrames, + wordcodeData, + functionData, + cfgData, + records, +) { + requireObject(callFrames, 'invalid-call-frames', 'Packet F must be an object') + if ( + callFrames.schemaVersion !== CALL_FRAME_SCHEMA || + callFrames.encoding !== 'numeric-u32' || + callFrames.wordCount !== wordcodeData.words.length || + callFrames.instructionCount !== wordcodeData.instructions.length || + callFrames.functionCount !== functionData.list.length + ) { + decline('stale-predecessor', 'Packet F does not describe Packets B-E') + } + requireObject( + callFrames.source, + 'invalid-call-frames', + 'Packet F source metadata is missing', + ) + requireSame( + callFrames.source, + { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: REFERENCE_SCHEMA, + functionsSchema: FUNCTION_SCHEMA, + cfgSchema: CFG_SCHEMA, + }, + 'stale-predecessor', + 'Packet F source metadata is stale', + ) + const fnRecords = requireArray( + callFrames.functions, + 'invalid-call-frames', + 'Packet F functions are missing', + ) + if (fnRecords.length !== functionData.list.length) { + decline('stale-predecessor', 'Packet F function records are stale') + } + if (new Set(fnRecords.map(({ id }) => id)).size !== fnRecords.length) { + decline('stale-predecessor', 'Packet F function records are duplicated') + } + const calls = requireArray( + callFrames.callSites, + 'invalid-call-frames', + 'Packet F call sites are missing', + ) + const returns = requireArray( + callFrames.returns, + 'invalid-call-frames', + 'Packet F returns are missing', + ) + const throws = requireArray( + callFrames.throws, + 'invalid-call-frames', + 'Packet F throws are missing', + ) + const completionRoutes = requireArray( + callFrames.completionRoutes, + 'invalid-call-frames', + 'Packet F completion routes are missing', + ) + for (const fn of fnRecords) { + requireObject(fn, 'invalid-call-frames', 'Packet F function is malformed') + requireInteger( + fn.id, + 'invalid-call-frames', + 'Packet F function ID is invalid', + ) + if (!functionData.byId.has(fn.id)) + decline('stale-predecessor', 'Packet F function ID is stale') + requireArray( + fn.callSites, + 'invalid-call-frames', + `Packet F calls are missing for ${fn.id}`, + ) + requireArray( + fn.returns, + 'invalid-call-frames', + `Packet F returns are missing for ${fn.id}`, + ) + requireArray( + fn.throws, + 'invalid-call-frames', + `Packet F throws are missing for ${fn.id}`, + ) + requireArray( + fn.completionRoutes, + 'invalid-call-frames', + `Packet F routes are missing for ${fn.id}`, + ) + } + for (const call of calls) { + requireObject( + call, + 'invalid-call-frames', + 'Packet F call site is malformed', + ) + requireInteger( + call.pc, + 'invalid-call-frames', + 'Packet F call PC is invalid', + ) + stateFromView( + call.cfg?.handlerStateBefore, + recordsForPc(call.pc, functionData, records), + `Packet F call ${call.pc} before`, + ) + stateFromView( + call.cfg?.handlerStateAfter, + recordsForPc(call.pc, functionData, records), + `Packet F call ${call.pc} after`, + ) + } + const expectedCallPcs = cfgData.callSites + .map(({ pc }) => pc) + .sort((left, right) => left - right) + const actualCallPcs = calls + .map(({ pc }) => pc) + .sort((left, right) => left - right) + requireSame( + actualCallPcs, + expectedCallPcs, + 'stale-predecessor', + 'Packet F call sites are stale', + ) + for (const route of completionRoutes) + validateFRoute(route, functionData, records) + for (const item of [...returns, ...throws]) { + requireObject( + item, + 'invalid-call-frames', + 'Packet F completion record is malformed', + ) + requireInteger( + item.pc, + 'invalid-call-frames', + 'Packet F completion PC is invalid', + ) + requireArray( + item.routes, + 'invalid-call-frames', + `Packet F routes are missing at ${item.pc}`, + ) + } + return { fnRecords, calls, returns, throws, completionRoutes } +} diff --git a/src/vm/jsconfuser-vm/exception-finally-input.js b/src/vm/jsconfuser-vm/exception-finally-input.js new file mode 100644 index 00000000..8c45f906 --- /dev/null +++ b/src/vm/jsconfuser-vm/exception-finally-input.js @@ -0,0 +1,772 @@ +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const CALL_FRAME_SCHEMA = 'jsconfuser-vm-call-frames.v1' +export const EXCEPTION_FINALLY_SCHEMA = 'jsconfuser-vm-exception-finally.v1' + +const FRAME_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +export const CALL_NAMES = new Set(['CALL', 'CALL_METHOD', 'NEW']) +export const COMPLETION_KINDS = new Set([ + 'return', + 'throw', + 'handler', + 'finally', +]) +export const EDGE_KINDS = new Set([ + 'fallthrough', + 'branch', + 'conditional', + 'call', + 'return', + 'throw', + 'handler', + 'finally', +]) + +export class ExceptionFinallyDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'ExceptionFinallyDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new ExceptionFinallyDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +export function requireArray(value, code, message) { + if (!Array.isArray(value)) decline(code, message) + return value +} + +export function requireInteger(value, code, message) { + if (!Number.isInteger(value) || value < 0) decline(code, message) + return value +} + +function requireBoolean(value, code, message) { + if (typeof value !== 'boolean') decline(code, message) + return value +} + +export function sameValue(actual, expected) { + if (actual === expected) return true + if (Array.isArray(actual) && Array.isArray(expected)) { + return ( + actual.length === expected.length && + actual.every((value, index) => sameValue(value, expected[index])) + ) + } + if (isObject(actual) && isObject(expected)) { + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + sameValue(actual[key], expected[key]), + ) + ) + } + return false +} + +export function requireSame(actual, expected, code, message) { + if (!sameValue(actual, expected)) decline(code, message) +} + +export function sortedNumbers(values) { + return [...values].sort((left, right) => left - right) +} + +export function register(index) { + return { kind: 'register', index } +} + +export function recordId(type, setupPc) { + return `${type === 'handler' ? 'try' : 'finally'}@${setupPc}` +} + +function recordView(record) { + if (record.type === 'handler') { + return { + type: 'handler', + setupPc: record.setupPc, + handlerPc: record.handlerPc, + exceptionReg: record.exceptionReg, + } + } + return { + type: 'finally', + setupPc: record.setupPc, + finallyPc: record.finallyPc, + continuationReg: record.continuationReg, + payloadReg: record.payloadReg, + throwPadPc: record.throwPadPc, + } +} + +export function stateView(stack, records) { + const views = stack.map((id) => { + const record = records.get(id) + if (!record) + decline('invalid-handler-state', `Unknown handler record ${id}`) + return recordView(record) + }) + return { + stack: views, + finallyStack: views.filter(({ type }) => type === 'finally'), + } +} + +export function stateFromView(state, records, context) { + requireObject(state, 'invalid-handler-state', `${context} state is missing`) + const stack = requireArray( + state.stack, + 'invalid-handler-state', + `${context} stack is missing`, + ) + const finallyStack = requireArray( + state.finallyStack, + 'invalid-handler-state', + `${context} finallyStack is missing`, + ) + const ids = [] + for (const view of stack) { + requireObject( + view, + 'invalid-handler-state', + `${context} record is malformed`, + ) + const candidates = [...records.values()].filter((record) => + sameValue(recordView(record), view), + ) + if (candidates.length !== 1) { + decline('invalid-handler-state', `${context} contains an unknown record`) + } + ids.push(candidates[0].id) + } + if (new Set(ids).size !== ids.length) { + decline('invalid-handler-state', `${context} repeats a handler record`) + } + requireSame( + finallyStack, + ids + .map((id) => records.get(id)) + .filter(({ type }) => type === 'finally') + .map((record) => recordView(record)), + 'invalid-handler-state', + `${context} finallyStack disagrees with stack`, + ) + return ids +} + +export function validateWordcode(wordcode) { + requireObject(wordcode, 'invalid-wordcode', 'Packet B must be an object') + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' + ) { + decline('stale-predecessor', 'Packet B schema or encoding is not accepted') + } + requireInteger( + wordcode.wordCount, + 'invalid-wordcode', + 'Packet B wordCount is invalid', + ) + requireInteger( + wordcode.instructionCount, + 'invalid-wordcode', + 'Packet B instructionCount is invalid', + ) + const words = requireArray( + wordcode.words, + 'invalid-wordcode', + 'Packet B has no copied word stream', + ) + if (words.length !== wordcode.wordCount) { + decline('stale-predecessor', 'Packet B wordCount does not match words') + } + words.forEach((word) => { + if (!Number.isInteger(word) || word < 0 || word > 0xffffffff) { + decline('invalid-wordcode', 'Packet B contains a non-u32 word') + } + }) + const instructions = requireArray( + wordcode.instructions, + 'invalid-wordcode', + 'Packet B has no instructions', + ) + if (instructions.length !== wordcode.instructionCount) { + decline( + 'stale-predecessor', + 'Packet B instructionCount does not match instructions', + ) + } + let expectedPc = 0 + const byPc = new Map() + for (const instruction of instructions) { + requireObject( + instruction, + 'invalid-wordcode', + 'Packet B instruction is malformed', + ) + requireInteger( + instruction.pc, + 'invalid-wordcode', + 'Packet B instruction PC is invalid', + ) + if (instruction.pc !== expectedPc || byPc.has(instruction.pc)) { + decline( + 'invalid-wordcode', + 'Packet B instruction boundaries are not contiguous', + ) + } + if ( + typeof instruction.name !== 'string' || + CANONICAL_OPCODES[instruction.name] === undefined + ) { + decline( + 'invalid-wordcode', + `Packet B has unknown opcode at PC ${instruction.pc}`, + ) + } + if (instruction.name === 'PATCH') { + decline( + 'unsupported-structured-form', + 'PATCH is outside the pinned decoder input', + ) + } + requireSame( + instruction.opcode, + { name: instruction.name, value: CANONICAL_OPCODES[instruction.name] }, + 'stale-predecessor', + `Packet B opcode metadata is stale at PC ${instruction.pc}`, + ) + const encodedWords = requireArray( + instruction.words, + 'invalid-wordcode', + `Packet B words are missing at PC ${instruction.pc}`, + ) + requireInteger( + instruction.width, + 'invalid-wordcode', + 'Packet B width is invalid', + ) + if ( + instruction.width !== encodedWords.length || + instruction.nextPc !== instruction.pc + instruction.width || + instruction.nextPc > wordcode.wordCount || + encodedWords.length === 0 + ) { + decline( + 'invalid-wordcode', + `Packet B width is stale at PC ${instruction.pc}`, + ) + } + requireSame( + encodedWords, + words.slice(instruction.pc, instruction.nextPc), + instruction.name === 'MAKE_CLOSURE' + ? 'incomplete-capture-pair' + : 'stale-predecessor', + `Packet B words are stale at PC ${instruction.pc}`, + ) + requireArray( + instruction.operands, + 'invalid-wordcode', + 'Packet B operands are missing', + ) + requireArray( + instruction.wordOperands, + 'invalid-wordcode', + 'Packet B word operands are missing', + ) + if (instruction.name === 'MAKE_CLOSURE') { + validateCapturePairShape(instruction) + } + if (instruction.name === 'TRY_SETUP' && encodedWords.length !== 3) { + decline( + 'invalid-handler-state', + `TRY_SETUP@${instruction.pc} has an invalid width`, + ) + } + if (instruction.name === 'FINALLY_SETUP' && encodedWords.length !== 5) { + decline( + 'invalid-handler-state', + `FINALLY_SETUP@${instruction.pc} has an invalid width`, + ) + } + byPc.set(instruction.pc, instruction) + expectedPc = instruction.nextPc + } + if (expectedPc !== wordcode.wordCount) { + decline( + 'invalid-wordcode', + 'Packet B does not consume the complete word stream', + ) + } + return { + words, + instructions, + byPc, + boundaries: new Set(instructions.map(({ pc }) => pc)), + } +} + +function validateCapturePairShape(instruction) { + const captureCount = instruction.words[5] + const captures = instruction.captures + const capturePairs = instruction.capturePairs + if ( + !Number.isInteger(captureCount) || + captureCount < 0 || + instruction.words.length !== 7 + captureCount * 2 || + !Array.isArray(captures) || + !Array.isArray(capturePairs) || + captures.length !== captureCount || + capturePairs.length !== captureCount + ) { + decline( + 'incomplete-capture-pair', + `MAKE_CLOSURE capture pairs are incomplete at PC ${instruction.pc}`, + ) + } +} + +export function validateReferences(references, wordcodeData) { + requireObject(references, 'invalid-references', 'Packet C must be an object') + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' || + references.wordCount !== wordcodeData.words.length || + references.instructionCount !== wordcodeData.instructions.length + ) { + decline('stale-predecessor', 'Packet C does not describe Packet B') + } + const frame = requireObject( + references.frame, + 'invalid-references', + 'Packet C frame metadata is missing', + ) + if ( + frame.frameStart !== 1 || + frame.headerSize !== 8 || + frame.mainStartPc !== 0 || + !sameValue(frame.slots, FRAME_SLOTS) + ) { + decline('stale-predecessor', 'Packet C frame layout is stale') + } + requireInteger( + frame.mainRegCount, + 'invalid-references', + 'Packet C mainRegCount is invalid', + ) + const root = requireObject( + frame.root, + 'invalid-references', + 'Packet C root frame is missing', + ) + if ( + root.frameBase !== 1 || + root.registerBase !== 9 || + root.registerWindow?.start !== 9 || + root.registerWindow?.end !== 9 + frame.mainRegCount || + root.frameSize !== 8 + frame.mainRegCount || + root.frameEnd !== root.registerWindow.end + ) { + decline('stale-predecessor', 'Packet C root frame arithmetic is stale') + } + const labels = requireObject( + references.labels, + 'invalid-references', + 'Packet C labels are missing', + ) + requireSame( + labels.boundaries, + [...wordcodeData.boundaries].sort((left, right) => left - right), + 'stale-predecessor', + 'Packet C label boundaries are stale', + ) + const labelReferences = requireArray( + labels.references, + 'invalid-references', + 'Packet C label references are missing', + ) + for (const reference of labelReferences) { + requireObject( + reference, + 'invalid-references', + 'Packet C label reference is malformed', + ) + requireInteger( + reference.pc, + 'invalid-references', + 'Packet C label PC is invalid', + ) + requireInteger( + reference.operand, + 'invalid-references', + 'Packet C label operand is invalid', + ) + requireInteger( + reference.target, + 'invalid-references', + 'Packet C label target is invalid', + ) + if (!wordcodeData.boundaries.has(reference.pc)) { + decline( + 'stale-predecessor', + `Packet C label PC ${reference.pc} is not an instruction`, + ) + } + if (!wordcodeData.boundaries.has(reference.target)) { + decline( + 'invalid-handler-routing', + `Packet C label target ${reference.target} is not a boundary`, + ) + } + } + return { frame, labelReferences } +} + +export function validateFunctions(functions, wordcodeData) { + requireObject(functions, 'invalid-functions', 'Packet D must be an object') + if ( + functions.schemaVersion !== FUNCTION_SCHEMA || + functions.encoding !== 'numeric-u32' || + functions.wordCount !== wordcodeData.words.length || + functions.instructionCount !== wordcodeData.instructions.length + ) { + decline('stale-predecessor', 'Packet D does not describe Packets B/C') + } + const list = requireArray( + functions.functions, + 'invalid-functions', + 'Packet D functions are missing', + ) + if (!list.length || list.length !== functions.entryPcs?.length) { + decline('stale-predecessor', 'Packet D function entries are stale') + } + const byId = new Map() + for (const fn of list) { + requireObject(fn, 'invalid-functions', 'Packet D function is malformed') + for (const key of [ + 'id', + 'startPc', + 'endPc', + 'regCount', + 'paramCount', + 'captureCount', + ]) { + requireInteger(fn[key], 'invalid-functions', `Packet D ${key} is invalid`) + } + requireBoolean( + fn.hasRest, + 'invalid-functions', + 'Packet D hasRest is invalid', + ) + if (byId.has(fn.id) || fn.endPc <= fn.startPc) { + decline('stale-predecessor', `Packet D function ${fn.id} is invalid`) + } + const expectedPcs = wordcodeData.instructions + .filter(({ pc }) => pc >= fn.startPc && pc < fn.endPc) + .map(({ pc }) => pc) + requireSame( + fn.instructionPcs, + expectedPcs, + 'stale-predecessor', + `Packet D instruction interval is stale for function ${fn.id}`, + ) + byId.set(fn.id, fn) + } + const ownership = requireArray( + functions.ownership, + 'invalid-functions', + 'Packet D ownership is missing', + ) + if (ownership.length !== wordcodeData.instructions.length) { + decline('stale-predecessor', 'Packet D ownership length is stale') + } + const ownerByPc = new Map() + for (const owner of ownership) { + requireObject( + owner, + 'invalid-functions', + 'Packet D ownership entry is malformed', + ) + requireInteger( + owner.pc, + 'invalid-functions', + 'Packet D ownership PC is invalid', + ) + requireInteger( + owner.functionId, + 'invalid-functions', + 'Packet D ownership function is invalid', + ) + if (!wordcodeData.boundaries.has(owner.pc) || !byId.has(owner.functionId)) { + decline('stale-predecessor', 'Packet D ownership is stale') + } + if (ownerByPc.has(owner.pc)) + decline('stale-predecessor', 'Packet D ownership is duplicated') + ownerByPc.set(owner.pc, owner.functionId) + } + for (const instruction of wordcodeData.instructions) { + const owner = ownerByPc.get(instruction.pc) + const fn = byId.get(owner) + if (!fn || instruction.pc < fn.startPc || instruction.pc >= fn.endPc) { + decline( + 'stale-predecessor', + `Packet D has no owner for ${instruction.pc}`, + ) + } + for (const index of registerIndices(instruction)) { + if (index >= fn.regCount) { + decline( + 'register-range', + `Function ${fn.id} uses register ${index} outside its descriptor`, + ) + } + } + } + const labels = requireArray( + functions.labels, + 'invalid-functions', + 'Packet D labels are missing', + ) + for (const label of labels) { + requireObject(label, 'invalid-functions', 'Packet D label is malformed') + const sourceOwner = ownerByPc.get(label.pc) + if ( + !wordcodeData.boundaries.has(label.pc) || + !wordcodeData.boundaries.has(label.target) || + sourceOwner !== label.sourceFunctionId + ) { + decline('stale-predecessor', 'Packet D label ownership is stale') + } + if ( + label.role !== 'functionEntry' && + ownerByPc.get(label.target) !== sourceOwner + ) { + decline( + 'cross-function-target', + `Label at ${label.pc} leaves function ${sourceOwner}`, + ) + } + } + return { list, byId, ownerByPc, labels } +} + +function registerIndices(instruction) { + const words = instruction.words + const name = instruction.name + const indices = [] + const add = (index) => { + if (Number.isInteger(words[index])) indices.push(words[index]) + } + if ( + [ + 'LOAD_CONST', + 'LOAD_INT', + 'LOAD_GLOBAL', + 'LOAD_UPVALUE', + 'LOAD_THIS', + 'MOVE', + 'GET_PROP', + 'DELETE_PROP', + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'IN', + 'INSTANCEOF', + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', + 'TYPEOF_SAFE', + 'CALL', + 'CALL_METHOD', + 'NEW', + 'MAKE_CLOSURE', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'FOR_IN_SETUP', + 'FOR_IN_NEXT', + ].includes(name) + ) { + add(1) + } + if (name === 'LOAD_UPVALUE') add(1) + if (name === 'STORE_UPVALUE') add(1) + if (name === 'MOVE') add(2) + if (name === 'GET_PROP' || name === 'SET_PROP' || name === 'DELETE_PROP') + add(2) + if (name === 'CALL') { + add(2) + const count = words[3] + for ( + let index = 0; + index < (Number.isInteger(count) && count < 0xffff ? count : 0); + index += 1 + ) { + add(4 + index) + } + } + if (name === 'CALL_METHOD') { + add(2) + add(3) + const count = words[4] + for ( + let index = 0; + index < (Number.isInteger(count) && count < 0xffff ? count : 0); + index += 1 + ) { + add(5 + index) + } + } + if (name === 'NEW') { + add(2) + const count = words[3] + for ( + let index = 0; + index < (Number.isInteger(count) && count < 0xffff ? count : 0); + index += 1 + ) { + add(4 + index) + } + } + if (name === 'RETURN' || name === 'THROW' || name === 'JUMP_REG') add(1) + if (name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE') add(1) + if (name === 'FOR_IN_NEXT') add(2) + if (name === 'TRY_SETUP') add(2) + if (name === 'FINALLY_SETUP') { + add(2) + add(3) + } + return indices +} diff --git a/src/vm/jsconfuser-vm/exception-finally-routes.js b/src/vm/jsconfuser-vm/exception-finally-routes.js new file mode 100644 index 00000000..e7598f26 --- /dev/null +++ b/src/vm/jsconfuser-vm/exception-finally-routes.js @@ -0,0 +1,793 @@ +import { + CALL_NAMES, + COMPLETION_KINDS, + cloneData, + decline, + recordId, + register, + requireInteger, + requireObject, + requireSame, + sameValue, + sortedNumbers, + stateFromView, + stateView, +} from './exception-finally-input.js' + +export function recordsForPc(pc, functionData, records) { + const owner = functionData.ownerByPc.get(pc) + return records.recordsByFunction.get(owner) ?? new Map() +} + +export function validateFRoute(route, functionData, recordData) { + requireObject(route, 'invalid-call-frames', 'Packet F route is malformed') + if (!COMPLETION_KINDS.has(route.kind)) + decline('invalid-call-frames', 'Packet F route kind is invalid') + requireInteger( + route.sourcePc, + 'invalid-call-frames', + 'Packet F route source is invalid', + ) + if (route.targetPc !== null) + requireInteger( + route.targetPc, + 'invalid-call-frames', + 'Packet F route target is invalid', + ) + const owner = functionData.ownerByPc.get(route.sourcePc) + if ( + owner === undefined || + (route.targetPc !== null && + functionData.ownerByPc.get(route.targetPc) !== owner) + ) { + decline('invalid-handler-routing', 'Packet F route leaves its owner') + } + const records = recordData.recordsByFunction.get(owner) ?? new Map() + stateFromView( + route.handlerStateBefore, + records, + `Packet F route ${route.sourcePc} before`, + ) + stateFromView( + route.handlerStateAfter, + records, + `Packet F route ${route.sourcePc} after`, + ) +} + +function payloadFor(record, sourceRegister) { + if (record.type === 'handler') { + return { + throwRegister: sourceRegister, + exceptionRegister: record.exceptionReg, + } + } + return { + throwRegister: sourceRegister, + finallyPc: record.finallyPc, + continuationRegister: record.continuationReg, + payloadRegister: record.payloadReg, + throwPadPc: record.throwPadPc, + } +} + +function routeAbrupt(instruction, stack, records, isCall = false) { + const sourceRegister = + instruction.name === 'THROW' || instruction.name === 'RETURN' + ? instruction.words[1] + : null + if (instruction.name === 'RETURN') { + let finallyIndex = -1 + for (let index = stack.length - 1; index >= 0; index -= 1) { + if (records.get(stack[index])?.type === 'finally') { + finallyIndex = index + break + } + } + if (finallyIndex < 0) { + return { + kind: 'return', + targetPc: null, + route: 'propagate', + mode: 'direct', + callSitePc: null, + continuationPc: null, + payload: { returnRegister: sourceRegister }, + after: [], + } + } + const record = records.get(stack[finallyIndex]) + return { + kind: 'finally', + targetPc: record.finallyPc, + route: 'exceptional', + mode: 'direct', + callSitePc: null, + continuationPc: null, + payload: payloadFor(record, sourceRegister), + after: stack.slice(0, finallyIndex), + } + } + const top = stack.length ? records.get(stack[stack.length - 1]) : null + if (!top) { + return { + kind: 'throw', + targetPc: null, + route: 'propagate', + mode: 'direct', + callSitePc: isCall ? instruction.pc : null, + continuationPc: null, + payload: isCall + ? { exceptionalCompletion: true } + : { throwRegister: sourceRegister }, + after: [], + } + } + if (top.type === 'handler') { + return { + kind: 'handler', + targetPc: top.handlerPc, + route: 'exceptional', + mode: 'direct', + callSitePc: isCall ? instruction.pc : null, + continuationPc: null, + payload: payloadFor(top, sourceRegister), + after: stack.slice(0, -1), + } + } + return { + kind: 'finally', + targetPc: top.finallyPc, + route: 'exceptional', + mode: 'direct', + callSitePc: isCall ? instruction.pc : null, + continuationPc: null, + payload: payloadFor(top, sourceRegister), + after: stack.slice(0, -1), + } +} + +function expectedEdge(instruction, fn, before, after, item, records) { + return { + kind: item.kind, + sourcePc: instruction.pc, + targetPc: item.targetPc, + condition: item.condition ?? null, + continuationPc: item.continuationPc ?? null, + payload: item.payload ?? null, + callSitePc: item.callSitePc ?? null, + route: item.route ?? 'direct', + mode: item.mode ?? 'direct', + handlerStateBefore: stateView(before, records), + handlerStateAfter: stateView(after, records), + functionId: fn.id, + } +} + +function directBranchBypassesFinally(instruction, target, stack, records) { + if ( + !['JUMP', 'JUMP_IF_FALSE', 'JUMP_IF_TRUE', 'FOR_IN_NEXT'].includes( + instruction.name, + ) + ) { + return + } + for (const id of stack) { + const record = records.get(id) + if ( + record?.type === 'finally' && + !(target > record.setupPc && target < record.finallyPc) + ) { + decline( + 'invalid-handler-routing', + `${instruction.name}@${instruction.pc} bypasses ${record.id}`, + ) + } + } +} + +export function solveFunction( + fn, + efn, + wordcodeData, + functionData, + recordData, + indirectEntries, +) { + const records = recordData.recordsByFunction.get(fn.id) + const instructions = new Map( + fn.instructionPcs.map((pc) => [pc, wordcodeData.byPc.get(pc)]), + ) + const edgesBySource = new Map() + for (const edge of efn.edges) { + const list = edgesBySource.get(edge.sourcePc) ?? [] + list.push(edge) + edgesBySource.set(edge.sourcePc, list) + } + const indirectByPc = new Map( + indirectEntries.map((entry) => [entry.pc, entry]), + ) + const inStates = new Map([[fn.startPc, []]]) + const incoming = new Map() + const queue = [fn.startPc] + const transitions = [] + const visitedEdges = new Set() + + for (const block of efn.blocks) { + const blockInstructions = block.instructionPcs.map((pc) => + instructions.get(pc), + ) + if (blockInstructions.some((instruction) => !instruction)) { + decline('stale-predecessor', `${fn.id}: block instructions are stale`) + } + const terminal = efn.edges.filter( + (edge) => + block.instructionPcs.includes(edge.sourcePc) && edge.targetPc === null, + ) + if (block.handlerStateIn.stack.length && terminal.length) { + decline( + 'unbalanced-handler-stack', + `${fn.id}: terminal block retains handler state`, + ) + } + } + + function addState(target, state, edge) { + if (target === null) return + const prior = inStates.get(target) + const key = `${edge.sourcePc}:${edge.kind}:${edge.targetPc}:${edge.callSitePc ?? ''}` + const entries = incoming.get(target) ?? new Map() + entries.set(key, state) + incoming.set(target, entries) + if ( + prior && + !sameValue(stateView(prior, records), stateView(state, records)) + ) { + decline( + 'handler-stack-merge-disagreement', + `${fn.id}: merge at ${target} disagrees`, + ) + } + if (!prior) { + inStates.set(target, state) + queue.push(target) + } + } + + while (queue.length) { + const pc = queue.shift() + const instruction = instructions.get(pc) + if (!instruction) + decline('invalid-cfg-node', `${fn.id}: missing instruction at ${pc}`) + const before = inStates.get(pc) + const actual = edgesBySource.get(pc) ?? [] + for (const edge of actual) { + if (edge.targetPc === null && edge.handlerStateAfter.stack.length) { + decline( + 'unbalanced-handler-stack', + `${fn.id}: terminal edge retains handler state`, + ) + } + } + const expected = expectedEdges( + instruction, + fn, + before, + records, + indirectByPc.get(pc), + ) + if (instruction.name === 'TRY_END') { + const eBefore = actual[0]?.handlerStateBefore + if (eBefore && eBefore.stack.length === 0) { + decline( + 'handler-stack-underflow', + `TRY_END@${pc} has an empty predecessor stack`, + ) + } + } + if (expected.length !== actual.length) { + decline('stale-predecessor', `${fn.id}: edge count is stale at ${pc}`) + } + for (let index = 0; index < expected.length; index += 1) { + const expectedItem = expected[index] + const actualEdge = actual[index] + const actualBefore = stateFromView( + actualEdge.handlerStateBefore, + records, + `E edge ${pc} before`, + ) + const actualAfter = stateFromView( + actualEdge.handlerStateAfter, + records, + `E edge ${pc} after`, + ) + if (instruction.name === 'TRY_END' && actualBefore.length === 0) { + decline( + 'handler-stack-underflow', + `TRY_END@${pc} has an empty predecessor stack`, + ) + } + const expectedRecord = expectedEdge( + instruction, + fn, + before, + expectedItem.after, + expectedItem, + records, + ) + if (!sameValue(actualEdge, expectedRecord)) { + if ( + actualEdge.kind === 'handler' || + actualEdge.kind === 'finally' || + expectedRecord.kind === 'handler' || + expectedRecord.kind === 'finally' + ) { + decline( + 'invalid-handler-routing', + `${fn.id}: handler route is stale at ${pc}`, + ) + } + if ( + !sameValue(actualBefore, before) || + !sameValue(actualAfter, expectedItem.after) + ) { + decline( + 'invalid-handler-state', + `${fn.id}: edge state is stale at ${pc}`, + ) + } + decline( + 'stale-predecessor', + `${fn.id}: edge metadata is stale at ${pc}`, + ) + } + if (!sameValue(actualBefore, before)) { + decline( + 'invalid-handler-state', + `${fn.id}: edge before state disagrees at ${pc}`, + ) + } + if (!sameValue(actualAfter, expectedItem.after)) { + decline( + 'invalid-handler-state', + `${fn.id}: edge after state disagrees at ${pc}`, + ) + } + if ( + actualEdge.targetPc !== null && + !wordcodeData.boundaries.has(actualEdge.targetPc) + ) { + decline( + 'invalid-handler-routing', + `${fn.id}: edge target is not a boundary`, + ) + } + visitedEdges.add(actualEdge) + addState(actualEdge.targetPc, expectedItem.after, actualEdge) + } + transitions.push({ + pc, + name: instruction.name, + handlerStateBefore: stateView(before, records), + outgoing: expected.map(({ after, ...edge }) => ({ + ...edge, + handlerStateAfter: stateView(after, records), + })), + }) + } + + for (const edge of efn.edges) { + if (!inStates.has(edge.sourcePc)) { + decline( + 'stale-predecessor', + `${fn.id}: CFG contains an unreachable edge at ${edge.sourcePc}`, + ) + } + if (edge.targetPc === null && edge.handlerStateAfter.stack.length) { + decline( + 'unbalanced-handler-stack', + `${fn.id}: terminal edge retains handler state`, + ) + } + } + for (const record of records.values()) { + if (!inStates.has(record.setupPc)) { + decline( + 'unbalanced-handler-stack', + `${fn.id}: ${record.id} is unreachable`, + ) + } + } + for (const block of efn.blocks) { + const expectedState = inStates.get(block.startPc) + if (!expectedState) { + if ( + block.handlerStateIn.stack.length || + block.handlerStateIn.finallyStack.length + ) { + decline( + 'invalid-handler-state', + `${fn.id}: unreachable block has handler state`, + ) + } + continue + } + const actualState = stateFromView( + block.handlerStateIn, + records, + `${fn.id} block ${block.startPc}`, + ) + if (!sameValue(actualState, expectedState)) { + decline( + 'invalid-handler-state', + `${fn.id}: block entry state disagrees at ${block.startPc}`, + ) + } + } + return { + inStates, + incoming, + transitions, + visitedEdges, + } +} + +function expectedEdges(instruction, fn, before, records, indirectEntry) { + const next = instruction.nextPc < fn.endPc ? instruction.nextPc : null + const out = [] + const addFallthrough = (target = next, after = before) => { + if (target !== null) { + out.push({ kind: 'fallthrough', targetPc: target, after }) + } else if (after.length) { + decline( + 'unbalanced-handler-stack', + `${fn.id}: terminal state retains handler stack at ${instruction.pc}`, + ) + } + } + if (instruction.name === 'TRY_SETUP') { + addFallthrough(next, [...before, recordId('handler', instruction.pc)]) + } else if (instruction.name === 'FINALLY_SETUP') { + addFallthrough(next, [...before, recordId('finally', instruction.pc)]) + } else if (instruction.name === 'TRY_END') { + if (!before.length) + decline( + 'handler-stack-underflow', + `TRY_END@${instruction.pc} has an empty stack`, + ) + addFallthrough(next, before.slice(0, -1)) + } else if (instruction.name === 'JUMP') { + const target = instruction.words[1] + const record = [...records.values()].find( + ({ type, finallyPc }) => type === 'finally' && finallyPc === target, + ) + if (record) { + if (before.includes(record.id)) { + decline( + 'invalid-handler-routing', + `JUMP@${instruction.pc} re-enters active ${record.id}`, + ) + } + out.push({ + kind: 'finally', + targetPc: target, + route: 'normal', + mode: 'direct', + payload: payloadFor(record, null), + after: before, + }) + } else { + directBranchBypassesFinally(instruction, target, before, records) + out.push({ kind: 'branch', targetPc: target, after: before }) + } + } else if ( + instruction.name === 'JUMP_IF_FALSE' || + instruction.name === 'JUMP_IF_TRUE' + ) { + const target = instruction.words[2] + directBranchBypassesFinally(instruction, target, before, records) + out.push({ + kind: 'conditional', + targetPc: target, + condition: { + register: instruction.words[1], + truthy: instruction.name === 'JUMP_IF_TRUE', + }, + after: before, + }) + addFallthrough(next, before) + } else if (instruction.name === 'FOR_IN_NEXT') { + const target = instruction.words[3] + directBranchBypassesFinally(instruction, target, before, records) + out.push({ + kind: 'branch', + targetPc: target, + condition: { + kind: 'iterator', + register: instruction.words[2], + available: false, + }, + after: before, + }) + addFallthrough(next, before) + } else if (instruction.name === 'JUMP_REG') { + if ( + !indirectEntry || + indirectEntry.sourceRegister !== instruction.words[1] + ) { + decline( + 'unresolved-jump-reg', + `JUMP_REG@${instruction.pc} has no matching finite dataflow`, + ) + } + const finalizer = [...records.values()].find( + ({ type, finallyPc, throwPadPc }) => + type === 'finally' && + instruction.pc >= finallyPc && + instruction.pc < throwPadPc, + ) + if (finalizer && instruction.words[1] !== finalizer.continuationReg) { + decline( + 'invalid-handler-routing', + `JUMP_REG@${instruction.pc} does not use ${finalizer.id} continuation register`, + ) + } + for (const target of sortedNumbers(indirectEntry.targets)) { + if (finalizer && target < finalizer.finallyPc) { + decline( + 'invalid-handler-routing', + `JUMP_REG@${instruction.pc} leaves ${finalizer.id}`, + ) + } + const isContinuation = + finalizer && + target >= finalizer.finallyPc && + target < finalizer.throwPadPc + out.push({ + kind: 'branch', + targetPc: target, + mode: 'indirect', + route: isContinuation ? 'continuation' : 'direct', + payload: isContinuation ? { continuationPc: target } : null, + after: before, + }) + } + } else if (CALL_NAMES.has(instruction.name)) { + if (next === null) + decline( + 'missing-call-continuation', + `Call@${instruction.pc} has no continuation`, + ) + out.push({ + kind: 'call', + targetPc: next, + continuationPc: next, + callSitePc: instruction.pc, + route: 'normal', + payload: { callSitePc: instruction.pc }, + after: before, + }) + out.push(routeAbrupt(instruction, before, records, true)) + } else if (instruction.name === 'THROW' || instruction.name === 'RETURN') { + out.push(routeAbrupt(instruction, before, records)) + } else { + addFallthrough(next, before) + } + return out +} + +export function routeView(edge) { + return { + kind: edge.kind, + sourcePc: edge.sourcePc, + targetPc: edge.targetPc, + route: edge.route, + mode: edge.mode, + callSitePc: edge.callSitePc, + continuationPc: edge.continuationPc, + payload: cloneData(edge.payload), + handlerStateBefore: cloneData(edge.handlerStateBefore), + handlerStateAfter: cloneData(edge.handlerStateAfter), + } +} + +function callExceptionalRoute(edge) { + return { + kind: edge.kind, + targetPc: edge.targetPc, + route: edge.route, + mode: edge.mode, + callSitePc: edge.callSitePc, + payload: cloneData(edge.payload), + } +} + +export function crossCheckF( + callFrames, + cfgData, + functionData, + wordcodeData, + solveResults, + recordData, +) { + const expectedRoutes = cfgData.cfgFunctions + .flatMap(({ id }) => cfgData.edgesByFunction.get(id)) + .filter(({ kind }) => COMPLETION_KINDS.has(kind)) + .map(routeView) + requireSame( + callFrames.completionRoutes, + expectedRoutes, + 'stale-predecessor', + 'Packet F completion routes are stale', + ) + for (const fn of callFrames.fnRecords) { + const expectedFnRoutes = expectedRoutes.filter( + ({ sourcePc }) => functionData.ownerByPc.get(sourcePc) === fn.id, + ) + requireSame( + fn.completionRoutes, + expectedFnRoutes, + 'stale-predecessor', + `Packet F completion routes are stale for ${fn.id}`, + ) + } + const callsByPc = new Map(callFrames.calls.map((call) => [call.pc, call])) + for (const call of callFrames.calls) { + const fnId = functionData.ownerByPc.get(call.pc) + const edges = cfgData.edgesByFunction.get(fnId) ?? [] + const normal = edges.find( + ({ sourcePc, kind, route }) => + sourcePc === call.pc && kind === 'call' && route === 'normal', + ) + const exceptional = edges.find( + ({ sourcePc, callSitePc, kind }) => + sourcePc === call.pc && callSitePc === call.pc && kind !== 'call', + ) + if (!normal || !exceptional) + decline( + 'stale-predecessor', + `Packet F call ${call.pc} has stale CFG routes`, + ) + const records = recordData.recordsByFunction.get(fnId) + const before = stateFromView( + call.cfg.handlerStateBefore, + records, + `Packet F call ${call.pc} before`, + ) + const after = stateFromView( + call.cfg.handlerStateAfter, + records, + `Packet F call ${call.pc} after`, + ) + if ( + !sameValue( + before, + stateFromView( + normal.handlerStateBefore, + records, + `E call ${call.pc} before`, + ), + ) + ) { + decline( + 'invalid-handler-state', + `Packet F call ${call.pc} before state is stale`, + ) + } + if ( + !sameValue( + after, + stateFromView( + exceptional.handlerStateAfter, + records, + `E call ${call.pc} after`, + ), + ) + ) { + decline( + 'invalid-handler-state', + `Packet F call ${call.pc} after state is stale`, + ) + } + requireSame( + call.exceptionalCompletion, + callExceptionalRoute(exceptional), + 'stale-predecessor', + `Packet F call ${call.pc} exceptional route is stale`, + ) + } + const byFunctionAndPc = (items) => + new Map( + items.map((item) => [ + `${functionData.ownerByPc.get(item.pc)}:${item.pc}`, + item, + ]), + ) + const returns = byFunctionAndPc(callFrames.returns) + const throws = byFunctionAndPc(callFrames.throws) + const expectedReturnCount = wordcodeData.instructions.filter( + ({ name }) => name === 'RETURN', + ).length + const expectedThrowCount = wordcodeData.instructions.filter( + ({ name }) => name === 'THROW', + ).length + if ( + callFrames.returns.length !== expectedReturnCount || + returns.size !== expectedReturnCount + ) { + decline('stale-predecessor', 'Packet F return records are stale') + } + if ( + callFrames.throws.length !== expectedThrowCount || + throws.size !== expectedThrowCount + ) { + decline('stale-predecessor', 'Packet F throw records are stale') + } + for (const fn of functionData.list) { + const result = solveResults.get(fn.id) + for (const instruction of fn.instructionPcs.map((pc) => + wordcodeData.byPc.get(pc), + )) { + if (instruction.name === 'RETURN') { + const item = returns.get(`${fn.id}:${instruction.pc}`) + if (!item) + decline( + 'stale-predecessor', + `Packet F is missing RETURN@${instruction.pc}`, + ) + const expected = cfgData.edgesByFunction + .get(fn.id) + .filter(({ sourcePc }) => sourcePc === instruction.pc) + .map(routeView) + requireSame( + item.sourceRegister, + register(instruction.words[1]), + 'stale-predecessor', + `Packet F RETURN@${instruction.pc} register is stale`, + ) + requireSame( + item.routes, + expected, + 'stale-predecessor', + `Packet F RETURN@${instruction.pc} routes are stale`, + ) + if (item.reachable !== result.inStates.has(instruction.pc)) + decline( + 'stale-predecessor', + `Packet F RETURN@${instruction.pc} reachability is stale`, + ) + } + if (instruction.name === 'THROW') { + const item = throws.get(`${fn.id}:${instruction.pc}`) + if (!item) + decline( + 'stale-predecessor', + `Packet F is missing THROW@${instruction.pc}`, + ) + const expected = cfgData.edgesByFunction + .get(fn.id) + .filter(({ sourcePc }) => sourcePc === instruction.pc) + .map(routeView) + requireSame( + item.payloadRegister, + register(instruction.words[1]), + 'stale-predecessor', + `Packet F THROW@${instruction.pc} register is stale`, + ) + requireSame( + item.routes, + expected, + 'stale-predecessor', + `Packet F THROW@${instruction.pc} routes are stale`, + ) + if (item.reachable !== result.inStates.has(instruction.pc)) + decline( + 'stale-predecessor', + `Packet F THROW@${instruction.pc} reachability is stale`, + ) + } + } + } + if (callsByPc.size !== callFrames.calls.length) + decline('stale-predecessor', 'Packet F call sites are duplicated') +} diff --git a/src/vm/jsconfuser-vm/extract-container.js b/src/vm/jsconfuser-vm/extract-container.js new file mode 100644 index 00000000..5ff88752 --- /dev/null +++ b/src/vm/jsconfuser-vm/extract-container.js @@ -0,0 +1,823 @@ +import { parse } from '@babel/parser' +import { + ContainerDecline, + EXPECTED_OPCODES, + EXPECTED_SENTINELS, + EXPECTED_SLOTS, + PARSER_OPTIONS, + binaryParts, + closureShape, + computedMember, + decline, + deepFreeze, + exactNumericObject, + findVariable, + functionDeclarations, + hasNode, + isIdentifier, + isPoolArray, + isStableDeclaration, + isWordArray, + makeContext, + member, + methodCall, + newCall, + numberValue, + parameterBinding, + propertyName, + prototypeMethods, + readPlainObject, + referenceCount, + referencePaths, + scalarValue, + slotMember, + thisMember, + topLevelDeclarations, + topLevelVariables, + uniqueCandidate, + upvalueMethodRoles, + upvalueShape, + vmShape, +} from './container-role-helpers.js' + +function identifyVmMethodRoles(methods, context, roles) { + const { op, slots, upvalue, upvalueClose } = roles + const operand = methods.filter(({ fn }) => { + if (fn.params.length !== 0 || fn.body?.body.length !== 1) return false + const statement = fn.body.body[0] + const outer = + statement.type === 'ReturnStatement' ? statement.argument : null + if ( + !computedMember( + outer, + (value) => thisMember(value, 'bytecode'), + (value) => + value?.type === 'UpdateExpression' && + value.operator === '++' && + value.prefix === false, + ) + ) { + return false + } + const pc = outer.property.argument + return computedMember( + pc, + (value) => thisMember(value, '_regs'), + (value) => + value?.type === 'BinaryExpression' && + value.operator === '+' && + thisMember(value.left, '_f') && + slotMember(value.right, slots.binding, 'PC', context), + ) + }) + + const pushFrame = methods.filter(({ fn }) => { + if (fn.params.length !== 4) return false + const fnLocal = findVariable(context, fn, (init) => + member( + init, + (value) => context.sameRef(value, parameterBinding(context, fn, 0)), + 'fn', + ), + ) + const regsLocal = findVariable(context, fn, (init) => + thisMember(init, '_regs'), + ) + const fpLocal = findVariable(context, fn, (init) => + thisMember(init, '_regsTop'), + ) + if (!fnLocal || !regsLocal || !fpLocal) return false + const headers = roles.headerCandidates + const sizeLocal = findVariable(context, fn, (init) => { + const parts = binaryParts(init) + return ( + !!parts && + parts.some((part) => + member( + part, + (value) => context.sameRef(value, context.bindingFor(fnLocal.id)), + 'regCount', + ), + ) && + parts.some((part) => + headers.some((header) => context.sameRef(part, header.binding)), + ) + ) + }) + const baseLocal = findVariable(context, fn, (init) => { + const parts = binaryParts(init) + return ( + !!parts && + parts.some((part) => + context.sameRef(part, context.bindingFor(fpLocal.id)), + ) && + parts.some((part) => + headers.some((header) => context.sameRef(part, header.binding)), + ) + ) + }) + const endLocal = findVariable(context, fn, (init) => { + const parts = binaryParts(init) + return ( + !!parts && + parts.some((part) => + context.sameRef(part, context.bindingFor(fpLocal.id)), + ) && + parts.some((part) => + context.sameRef(part, context.bindingFor(sizeLocal?.id)), + ) + ) + }) + if (!sizeLocal || !baseLocal || !endLocal) return false + return ( + hasNode( + context, + fn, + (node) => + node.type === 'AssignmentExpression' && + thisMember(node.left, '_regsTop') && + context.sameRef(node.right, context.bindingFor(endLocal.id)), + ) && + hasNode( + context, + fn, + (node) => + node.type === 'AssignmentExpression' && + thisMember(node.left, '_f') && + context.sameRef(node.right, context.bindingFor(fpLocal.id)), + ) && + Object.keys(EXPECTED_SLOTS) + .filter((role) => role !== 'HANDLERS') + .every((role) => + hasNode(context, fn, (node) => + slotMember(node, slots.binding, role, context), + ), + ) + ) + }) + + const captureUpvalue = methods.filter( + ({ fn }) => + fn.params.length === 2 && + newCall(context, fn, upvalue.binding) && + hasNode(context, fn, (node) => thisMember(node, '_openUpvalues')) && + hasNode(context, fn, (node) => thisMember(node, '_regs')) && + hasNode(context, fn, (node) => + slotMember(node, slots.binding, 'REG_BASE', context), + ) && + hasNode(context, fn, (node) => node.type === 'ReturnStatement'), + ) + + const constant = methods.filter( + ({ fn }) => + fn.params.length === 2 && + hasNode(context, fn, (node) => thisMember(node, 'constants')) && + methodCall( + context, + fn, + (value) => value?.type === 'ThisExpression', + operand[0]?.name, + ) && + hasNode(context, fn, (node) => node.type === 'ReturnStatement'), + ) + + const closeUpvalues = methods.filter( + ({ fn }) => + fn.params.length === 1 && + hasNode(context, fn, (node) => thisMember(node, '_openUpvalues')) && + hasNode(context, fn, (node) => thisMember(node, '_regs')) && + hasNode(context, fn, (node) => + slotMember(node, slots.binding, 'FRAME_SIZE', context), + ) && + hasNode(context, fn, (node) => + slotMember(node, slots.binding, 'REG_BASE', context), + ) && + methodCall(context, fn, () => true, upvalueClose[0]?.name), + ) + + const run = methods.filter( + ({ fn }) => + fn.params.length === 3 && + hasNode(context, fn, (node) => node.type === 'WhileStatement') && + hasNode(context, fn, (node) => node.type === 'SwitchStatement') && + hasNode(context, fn, (node) => node.type === 'ReturnStatement') && + hasNode( + context, + fn, + (node) => + node.type === 'MemberExpression' && + !node.computed && + context.sameRef(node.object, op.binding), + ), + ) + + return { operand, pushFrame, captureUpvalue, constant, closeUpvalues, run } +} + +function bootGraph(context, declarations, roles) { + const variables = topLevelVariables(declarations) + const globals = variables.filter( + (candidate) => + isIdentifier(candidate.init, 'globalThis') && + isStableDeclaration(candidate), + ) + const globalBinding = uniqueCandidate(globals, 'global object binding') + const vmRoots = variables.filter( + (candidate) => + candidate.init?.type === 'NewExpression' && + context.sameRef(candidate.init.callee, roles.vm.binding), + ) + const rootCandidates = vmRoots.filter((candidate) => { + const args = candidate.init.arguments + return ( + args.length === 3 && + args[0]?.type === 'CallExpression' && + args[0].arguments.length === 1 && + context.sameRef(args[0].arguments[0], roles.words.binding) && + context.sameRef(args[1], roles.pool.binding) && + context.sameRef(args[2], globalBinding.binding) + ) + }) + const vmRoot = uniqueCandidate(rootCandidates, 'root VM binding') + const decoderBinding = context.bindingFor(vmRoot.init.arguments[0].callee) + const decoder = declarations.filter( + (candidate) => + candidate.kind === 'function' && candidate.binding === decoderBinding, + ) + const decoderRole = uniqueCandidate(decoder, 'bytecode decoder binding') + const calls = context + .nodes(context.program) + .filter( + ({ node }) => + node.type === 'CallExpression' && + node.callee?.type === 'MemberExpression' && + !node.callee.computed && + context.sameRef(node.callee.object, vmRoot.binding), + ) + if (calls.length !== 1) { + decline('ambiguous-role', 'Expected exactly one root VM method call') + } + const rootCall = calls[0].node + const args = rootCall.arguments + if ( + args.length !== 3 || + args[0]?.type !== 'NewExpression' || + !context.sameRef(args[0].callee, roles.closure.binding) || + args[1]?.type !== 'Identifier' || + args[1].name !== 'undefined' || + context.bindingFor(args[1]) || + args[2]?.type !== 'NullLiteral' + ) { + decline('altered-boot', 'Root VM boot call is not the pinned Closure form') + } + const closureEntries = readPlainObject(args[0].arguments[0]) + if ( + !closureEntries || + closureEntries.size !== 3 || + numberValue(closureEntries.get('paramCount')) !== 0 + ) { + decline('malformed-role', 'Root Closure descriptor is malformed') + } + const start = context.bindingFor(closureEntries.get('startPc')) + const regCount = context.bindingFor(closureEntries.get('regCount')) + if (!start || !regCount) { + decline('missing-role', 'Root Closure descriptor lacks scalar bindings') + } + const scalar = (binding, role) => { + const candidate = variables.find((item) => item.binding === binding) + if (!candidate || scalarValue(candidate.init) === undefined) { + decline('missing-role', `Missing ${role} scalar binding`) + } + return candidate + } + const startRole = scalar(start, 'MAIN_START_PC') + const regRole = scalar(regCount, 'MAIN_REG_COUNT') + if ( + scalarValue(startRole.init) !== 0 || + !Number.isSafeInteger(scalarValue(regRole.init)) || + scalarValue(regRole.init) <= 0 + ) { + decline( + 'altered-boot', + 'Root scalar values do not describe the baseline frame', + ) + } + if ( + referenceCount(context, startRole.binding) !== 1 || + referenceCount(context, regRole.binding) !== 1 + ) { + decline('ambiguous-role', 'Root scalar bindings have unexpected references') + } + return { + closure: roles.closure, + decoder: decoderRole, + globals: globalBinding, + regCount: regRole, + rootCall, + start: startRole, + vm: vmRoot, + } +} + +function decoderUsesNumericPath(context, decoder, encode) { + const parameter = parameterBinding(context, decoder.init, 0) + return ( + hasNode( + context, + decoder.init, + (node) => + node.type === 'IfStatement' && + node.test?.type === 'UnaryExpression' && + node.test.operator === '!' && + context.sameRef(node.test.argument, encode.binding) && + node.consequent?.type === 'ReturnStatement' && + context.sameRef(node.consequent.argument, parameter), + ) && + hasNode( + context, + decoder.init, + (node) => + node.type === 'NewExpression' && + isIdentifier(node.callee, 'Uint32Array'), + ) + ) +} + +function identifyScalars(context, declarations, roles, boot) { + const variables = topLevelVariables(declarations) + const scalars = variables.filter( + (candidate) => scalarValue(candidate.init) !== undefined, + ) + const encodedCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === true && + hasNode( + context, + boot.decoder.init, + (node) => + node.type === 'IfStatement' && + node.test?.type === 'UnaryExpression' && + node.test.operator === '!' && + context.sameRef(node.test.argument, candidate.binding), + ), + ) + if (encodedCandidates.length === 1) { + decline( + 'unsupported-encoded-bytecode', + 'ENCODE_BYTECODE=true is outside the numeric-wordcode boundary', + ) + } + if (encodedCandidates.length > 1) { + decline('ambiguous-role', 'Ambiguous ENCODE_BYTECODE scalar') + } + const encodeCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === false && + referenceCount(context, candidate.binding) === 1 && + decoderUsesNumericPath(context, boot.decoder, candidate), + ) + const encode = uniqueCandidate(encodeCandidates, 'ENCODE_BYTECODE scalar') + + const frameStartCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === 1 && + candidate.binding === roles.vmShape.frameStartBinding && + referenceCount(context, candidate.binding, roles.vm.init) === 1 && + referenceCount(context, candidate.binding) === 1, + ) + const frameStart = uniqueCandidate(frameStartCandidates, 'FRAME_START scalar') + + const headerCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === 8 && + referenceCount( + context, + candidate.binding, + roles.vmMethods.pushFrame[0].fn, + ) === 2 && + hasNode(context, roles.vmMethods.pushFrame[0].fn, (node) => { + const parts = binaryParts(node) + return ( + !!parts && + parts.some((part) => context.sameRef(part, candidate.binding)) && + parts.some((part) => member(part, () => true, 'regCount')) + ) + }), + ) + const header = uniqueCandidate(headerCandidates, 'HEADER_SIZE scalar') + + const timingCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === false && + referenceCount(context, candidate.binding, roles.vmMethods.run[0].fn) === + 2 && + hasNode( + context, + roles.vmMethods.run[0].fn, + (node) => + node.type === 'IfStatement' && + context.sameRef(node.test, candidate.binding), + ) && + hasNode( + context, + roles.vmMethods.run[0].fn, + (node) => + node.type === 'BinaryExpression' && + node.operator === '>' && + context.sameRef(node.right, candidate.binding), + ), + ) + const timing = uniqueCandidate(timingCandidates, 'TIMING_CHECKS scalar') + + if ( + !encode || + !frameStart || + !header || + !timing || + scalarValue(encode.init) !== false || + scalarValue(timing.init) !== false + ) { + decline('malformed-role', 'Pinned scalar roles are inconsistent') + } + roles.headerBinding = header.binding + return { encode, frameStart, header, timing } +} + +function assertMapReferences(context, runtime, roles) { + const opUses = referencePaths(context, roles.op.binding) + const opUseNames = new Set( + opUses + .map((path) => path.parentPath?.node) + .filter((node) => node?.type === 'MemberExpression' && !node.computed) + .map((node) => propertyName(node.property)), + ) + if ( + Object.keys(EXPECTED_OPCODES).some((name) => !opUseNames.has(name)) || + opUses.some( + (path) => + path.parentPath?.node?.type !== 'MemberExpression' || + path.parentPath.node.computed || + !Object.prototype.hasOwnProperty.call( + EXPECTED_OPCODES, + propertyName(path.parentPath.node.property), + ), + ) || + opUses.some( + (path) => + !path.findParent( + (parent) => parent.node === runtime.vmMethods.run[0].fn, + ), + ) + ) { + decline( + 'incomplete-role', + 'OP map is not connected to the complete dispatch', + ) + } + + const sentinelUses = referencePaths(context, roles.sentinels.binding) + if ( + sentinelUses.length !== 3 || + sentinelUses.some( + (path) => + path.parentPath?.node?.type !== 'MemberExpression' || + propertyName(path.parentPath.node.property) !== 'CALL_SPREAD', + ) + ) { + decline('incomplete-role', 'CALL_SPREAD is not connected to all call paths') + } + + const slotUses = referencePaths(context, roles.slots.binding) + const slotNames = new Set( + slotUses + .map((path) => path.parentPath?.node) + .filter((node) => node?.type === 'MemberExpression' && !node.computed) + .map((node) => propertyName(node.property)), + ) + if (Object.keys(EXPECTED_SLOTS).some((name) => !slotNames.has(name))) { + decline('incomplete-role', 'Frame slot map is not structurally connected') + } + + if ( + referenceCount(context, roles.pool.binding) !== 1 || + referenceCount(context, roles.words.binding) !== 1 + ) { + decline( + 'ambiguous-role', + 'Pool or wordcode binding has unexpected references', + ) + } +} + +function assertRuntimeGraph(context, roles, scalarRoles, boot) { + const methods = roles.vmMethods + const run = methods.run[0].fn + const requiredCalls = [ + ['operand', methods.operand[0].name], + ['constant', methods.constant[0].name], + ['captureUpvalue', methods.captureUpvalue[0].name], + ['closeUpvalues', methods.closeUpvalues[0].name], + ] + for (const [role, name] of requiredCalls) { + if ( + !methodCall( + context, + run, + (value) => value?.type === 'ThisExpression', + name, + ) + ) { + decline('incomplete-role', `VM.run does not use ${role}`) + } + } + if ( + !methodCall( + context, + run, + (value) => value?.type === 'ThisExpression', + methods.pushFrame[0].name, + parameterBinding(context, run, 0), + ) || + !methodCall(context, run, () => true, roles.upvalueMethods.read[0].name) || + !methodCall(context, run, () => true, roles.upvalueMethods.write[0].name) || + !newCall(context, run, roles.vm.binding) || + !newCall(context, run, roles.closure.binding) + ) { + decline( + 'incomplete-role', + 'VM/Closure execution relationship is incomplete', + ) + } + + if ( + !methodCall( + context, + methods.closeUpvalues[0].fn, + () => true, + roles.upvalueMethods.close[0].name, + ) || + !newCall(context, methods.captureUpvalue[0].fn, roles.upvalue.binding) + ) { + decline('incomplete-role', 'Upvalue lifetime relationship is incomplete') + } + + const runProperty = methods.run[0].name + if (propertyName(boot.rootCall.callee.property) !== runProperty) { + decline('altered-boot', 'Root boot call is not bound to VM.run') + } + if ( + scalarRoles.encode.binding !== + context.bindingFor( + boot.decoder.init.body.body.find((node) => node.type === 'IfStatement') + ?.test?.argument, + ) || + scalarRoles.frameStart.binding !== roles.vmShape.frameStartBinding || + scalarRoles.header.binding !== roles.headerBinding + ) { + decline( + 'incomplete-role', + 'Scalar roles are not connected to runtime metadata', + ) + } +} + +function valuesForPool(node) { + return node.elements.map((element) => { + if ( + element.type === 'StringLiteral' || + element.type === 'NumericLiteral' || + element.type === 'BooleanLiteral' + ) { + return element.value + } + if (element.type === 'NullLiteral') return null + return undefined + }) +} + +function makeResult(roles, scalarRoles, boot) { + return deepFreeze({ + schemaVersion: 'jsconfuser-vm-container.v1', + encoding: 'numeric-u32', + roles: { + pool: { name: roles.pool.name, values: valuesForPool(roles.pool.init) }, + words: { + name: roles.words.name, + values: roles.words.init.elements.map((element) => element.value), + }, + op: { name: roles.op.name, values: { ...EXPECTED_OPCODES } }, + sentinels: { + name: roles.sentinels.name, + values: { ...EXPECTED_SENTINELS }, + }, + slots: { name: roles.slots.name, values: { ...EXPECTED_SLOTS } }, + scalars: Object.fromEntries( + Object.entries({ + MAIN_START_PC: boot.start, + MAIN_REG_COUNT: boot.regCount, + ENCODE_BYTECODE: scalarRoles.encode, + TIMING_CHECKS: scalarRoles.timing, + HEADER_SIZE: scalarRoles.header, + FRAME_START: scalarRoles.frameStart, + }).map(([role, declaration]) => [ + role, + { name: declaration.name, value: scalarValue(declaration.init) }, + ]), + ), + constructors: { + Upvalue: { name: roles.upvalue.name }, + Closure: { name: roles.closure.name }, + VM: { name: roles.vm.name }, + }, + methods: { + upvalue: Object.fromEntries( + Object.entries(roles.upvalueMethods).map(([role, list]) => [ + role, + { name: list[0].name }, + ]), + ), + vm: Object.fromEntries( + Object.entries(roles.vmMethods).map(([role, list]) => [ + role, + { name: list[0].name }, + ]), + ), + }, + roots: { + globals: { name: boot.globals.name }, + vm: { name: boot.vm.name }, + decoder: { name: boot.decoder.name }, + bootMethod: { name: propertyName(boot.rootCall.callee.property) }, + }, + }, + }) +} + +function recognize(ast) { + const context = makeContext(ast) + const declarations = topLevelDeclarations(context) + const variables = topLevelVariables(declarations) + + const pool = uniqueCandidate( + variables.filter((candidate) => isPoolArray(candidate.init, context)), + 'constant pool array', + ) + const words = uniqueCandidate( + variables.filter((candidate) => isWordArray(candidate.init)), + 'numeric word array', + ) + const op = uniqueCandidate( + variables.filter((candidate) => + exactNumericObject(candidate.init, EXPECTED_OPCODES), + ), + 'canonical opcode map', + ) + const sentinels = uniqueCandidate( + variables.filter((candidate) => + exactNumericObject(candidate.init, EXPECTED_SENTINELS), + ), + 'sentinel map', + ) + const slots = uniqueCandidate( + variables.filter((candidate) => + exactNumericObject(candidate.init, EXPECTED_SLOTS), + ), + 'frame slot map', + ) + + const functions = functionDeclarations(declarations) + const upvalue = uniqueCandidate( + functions + .map((fn) => declarations.find((declaration) => declaration.init === fn)) + .filter((declaration) => upvalueShape(declaration.init, context)), + 'Upvalue constructor', + ) + const closure = uniqueCandidate( + functions + .map((fn) => declarations.find((declaration) => declaration.init === fn)) + .filter((declaration) => closureShape(declaration.init, context)), + 'Closure constructor', + ) + const vmCandidates = functions + .map((fn) => declarations.find((declaration) => declaration.init === fn)) + .map((declaration) => ({ + declaration, + shape: vmShape(declaration.init, context), + })) + .filter(({ shape }) => !!shape) + const vm = uniqueCandidate( + vmCandidates.map(({ declaration }) => declaration), + 'VM constructor', + ) + const vmShapeResult = vmCandidates.find( + ({ declaration }) => declaration === vm, + ).shape + + const upvalueMethods = prototypeMethods(context, upvalue) + const upvalueMethodRolesResult = upvalueMethodRoles(upvalueMethods, context) + if ( + upvalueMethods.length !== 3 || + Object.values(upvalueMethodRolesResult).some((list) => list.length !== 1) + ) { + decline('incomplete-role', 'Upvalue methods are not complete and unique') + } + + const headerCandidates = variables.filter( + (candidate) => scalarValue(candidate.init) === 8, + ) + const vmMethods = prototypeMethods(context, vm) + const vmMethodRoles = identifyVmMethodRoles(vmMethods, context, { + op, + slots, + upvalue, + upvalueClose: upvalueMethodRolesResult.close, + vmShape: vmShapeResult, + headerCandidates, + }) + if ( + vmMethods.length !== 6 || + Object.values(vmMethodRoles).some((list) => list.length !== 1) + ) { + decline( + 'incomplete-role', + `VM methods are not complete and unique (${Object.entries(vmMethodRoles) + .map(([role, list]) => `${role}=${list.length}`) + .join(', ')})`, + ) + } + const frameStartBinding = context.bindingFor(vmShapeResult.frameStart) + if (!frameStartBinding) decline('missing-role', 'Missing FRAME_START binding') + + const structuralRoles = { + closure, + op, + pool, + sentinels, + slots, + upvalue, + vm, + vmShape: { ...vmShapeResult, frameStartBinding }, + vmMethods: vmMethodRoles, + upvalueMethods: upvalueMethodRolesResult, + words, + } + const boot = bootGraph(context, declarations, structuralRoles) + const scalarRoles = identifyScalars( + context, + declarations, + structuralRoles, + boot, + ) + assertMapReferences(context, structuralRoles, structuralRoles) + assertRuntimeGraph(context, structuralRoles, scalarRoles, boot) + return makeResult( + { + ...structuralRoles, + headerBinding: scalarRoles.header.binding, + }, + scalarRoles, + boot, + ) +} + +function parseInput(input) { + if (typeof input === 'string') return parse(input, PARSER_OPTIONS) + if (input?.type === 'File' || input?.type === 'Program') return input + decline( + 'invalid-input', + 'Container extractor expects source text or a Babel AST', + ) +} + +export function diagnoseContainer(input) { + try { + const result = recognize(parseInput(input)) + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof ContainerDecline) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + } + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'extractor-error', + message: error.message, + }, + }) + } +} + +export function extractContainer(input) { + return diagnoseContainer(input).result +} + +export function extractContainerFromSource(source) { + return extractContainer(source) +} + +export default extractContainer diff --git a/src/vm/jsconfuser-vm/numeric-to-vm-switch.js b/src/vm/jsconfuser-vm/numeric-to-vm-switch.js new file mode 100644 index 00000000..16faf597 --- /dev/null +++ b/src/vm/jsconfuser-vm/numeric-to-vm-switch.js @@ -0,0 +1,394 @@ +import { decline } from './standalone-contract.js' +import { VM_SWITCH_SCHEMA } from '../switch/vm-switch-model.js' + +const BINARY = Object.freeze({ + ADD: '+', + SUB: '-', + MUL: '*', + DIV: '/', + MOD: '%', + EXP: '**', + BAND: '&', + BOR: '|', + BXOR: '^', + SHL: '<<', + SHR: '>>', + USHR: '>>>', + LT: '<', + GT: '>', + LTE: '<=', + GTE: '>=', + EQ: '===', + NEQ: '!==', + LOOSE_EQ: '==', + LOOSE_NEQ: '!=', + IN: 'in', + INSTANCEOF: 'instanceof', +}) +const UNARY = Object.freeze({ + UNARY_NEG: '-', + UNARY_POS: '+', + UNARY_NOT: '!', + UNARY_BITNOT: '~', +}) + +function reg(operand) { + if ( + operand?.kind !== 'register' || + !Number.isSafeInteger(operand.index) || + operand.index < 0 + ) + decline( + 'invalid-switch-register', + 'VM switch operation requires a register', + ) + return operand.index +} + +function index(operand, kind) { + if ( + operand?.kind !== kind || + !Number.isSafeInteger(operand.index) || + operand.index < 0 + ) + decline('invalid-switch-reference', `VM switch operation requires ${kind}`) + return operand.index +} + +function target(operand) { + if ( + operand?.kind !== 'label-target' || + !Number.isSafeInteger(operand.pc) || + operand.pc < 0 + ) + decline( + 'invalid-switch-target', + 'VM switch transfer requires a label target', + ) + return operand.pc +} + +function immediate(operand) { + if ( + operand?.kind !== 'immediate' || + !Number.isSafeInteger(operand.value) || + operand.value < 0 + ) + decline( + 'invalid-switch-immediate', + 'VM switch operation requires an immediate', + ) + return operand.value +} + +function constant(operand, values) { + if ( + operand?.kind !== 'constant-ref' || + !Number.isSafeInteger(operand.index) || + operand.index < 0 || + operand.index >= values.length || + operand.concealKey !== 0 + ) + decline( + 'unsupported-constant-reference', + 'VM switch requires a resolved zero-key constant', + ) + return values[operand.index] +} + +function globalName(operand, values) { + const value = constant(operand, values) + if (typeof value !== 'string') + decline('invalid-global-name', 'Global references require string constants') + return value +} + +function lowerCall(instruction, functionId, calls) { + const { name, operands, pc } = instruction + const record = calls.get(`${functionId}:${pc}`) + if (!record) decline('missing-call-record', `Missing call record for ${pc}`) + const method = name === 'CALL_METHOD' + const countOffset = method ? 3 : 2 + const countOperand = operands[countOffset] + if ( + !countOperand || + !['immediate', 'spread-sentinel'].includes(countOperand.kind) + ) + decline('invalid-call-argc', 'Call count is not resolved') + const spread = countOperand.kind === 'spread-sentinel' + const count = countOperand.value + if (!Number.isSafeInteger(count) || count < 0 || (spread && count !== 65535)) + decline('invalid-call-argc', 'Call count is not valid') + const argumentOperands = operands.slice(countOffset + 1) + if (argumentOperands.length !== (spread ? 1 : count)) + decline('invalid-call-width', 'Call argument width is inconsistent') + return { + kind: 'call', + mode: name === 'NEW' ? 'construct' : 'apply', + destination: reg(operands[0]), + receiver: + name === 'NEW' + ? { kind: 'none' } + : method + ? { kind: 'register', index: reg(operands[1]) } + : { + kind: + record.target?.kind === 'internal-closure' ? 'global' : 'null', + }, + callee: reg(operands[method ? 2 : 1]), + arguments: { + kind: spread ? 'array-register' : 'fixed', + registers: argumentOperands.map(reg), + }, + } +} + +function lower(instruction, functionId, model) { + const { name, operands: o } = instruction + const values = model.constants + if (name === 'LOAD_CONST') + return { + kind: 'assign', + destination: reg(o[0]), + value: { kind: 'literal', value: constant(o[1], values) }, + } + if (name === 'LOAD_INT') + return { + kind: 'assign', + destination: reg(o[0]), + value: { kind: 'literal', value: immediate(o[1]) }, + } + if (name === 'LOAD_THIS') + return { kind: 'assign', destination: reg(o[0]), value: { kind: 'this' } } + if (name === 'MOVE') + return { + kind: 'assign', + destination: reg(o[0]), + value: { kind: 'register', index: reg(o[1]) }, + } + if (name === 'LOAD_GLOBAL') + return { + kind: 'global-read', + destination: reg(o[0]), + name: globalName(o[1], values), + } + if (name === 'STORE_GLOBAL') + return { + kind: 'global-write', + name: globalName(o[0], values), + source: reg(o[1]), + } + if (name === 'TYPEOF_SAFE') + return { + kind: 'safe-typeof', + destination: reg(o[0]), + name: globalName(o[1], values), + } + if (name === 'LOAD_UPVALUE') + return { + kind: 'upvalue-read', + destination: reg(o[0]), + index: index(o[1], 'upvalue-index'), + } + if (name === 'STORE_UPVALUE') + return { + kind: 'upvalue-write', + index: index(o[0], 'upvalue-index'), + source: reg(o[1]), + } + if (name === 'GET_PROP') + return { + kind: 'property-read', + destination: reg(o[0]), + object: reg(o[1]), + key: reg(o[2]), + } + if (name === 'SET_PROP') + return { + kind: 'property-write', + object: reg(o[0]), + key: reg(o[1]), + value: reg(o[2]), + } + if (name === 'DELETE_PROP') + return { + kind: 'property-delete', + destination: reg(o[0]), + object: reg(o[1]), + key: reg(o[2]), + } + if (BINARY[name]) + return { + kind: 'assign', + destination: reg(o[0]), + value: { + kind: 'binary', + operator: BINARY[name], + left: reg(o[1]), + right: reg(o[2]), + }, + } + if (UNARY[name]) + return { + kind: 'assign', + destination: reg(o[0]), + value: { kind: 'unary', operator: UNARY[name], source: reg(o[1]) }, + } + if (name === 'TYPEOF') + return { + kind: 'assign', + destination: reg(o[0]), + value: { kind: 'typeof', source: reg(o[1]) }, + } + if (name === 'VOID') + return { + kind: 'assign', + destination: reg(o[0]), + value: { kind: 'undefined' }, + } + if (name === 'JUMP') return { kind: 'jump', target: target(o[0]) } + if (name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE') + return { + kind: 'branch', + condition: reg(o[0]), + when: name === 'JUMP_IF_FALSE' ? 'false' : 'true', + target: target(o[1]), + } + if (name === 'JUMP_REG') return { kind: 'indirect-jump', source: reg(o[0]) } + if (name === 'RETURN') return { kind: 'return', source: reg(o[0]) } + if (name === 'THROW') return { kind: 'throw', source: reg(o[0]) } + if (name === 'CALL' || name === 'CALL_METHOD' || name === 'NEW') + return lowerCall(instruction, functionId, model.calls) + if (name === 'MAKE_CLOSURE') + return { + kind: 'closure', + destination: reg(o[0]), + entry: target(o[1]), + captures: o + .slice(6) + .map((capture) => ({ kind: capture.kind, index: capture.index })), + } + if (name === 'BUILD_ARRAY') + return { + kind: 'array', + destination: reg(o[0]), + elements: o.slice(2).map(reg), + } + if (name === 'BUILD_OBJECT') + return { + kind: 'object', + destination: reg(o[0]), + pairs: Array.from({ length: immediate(o[1]) }, (_, i) => ({ + key: reg(o[2 + i * 2]), + value: reg(o[3 + i * 2]), + })), + } + if (name === 'DEFINE_GETTER' || name === 'DEFINE_SETTER') + return { + kind: 'accessor', + accessor: name === 'DEFINE_GETTER' ? 'get' : 'set', + object: reg(o[0]), + key: reg(o[1]), + callback: reg(o[2]), + } + if (name === 'FOR_IN_SETUP') + return { kind: 'for-in-setup', destination: reg(o[0]), source: reg(o[1]) } + if (name === 'FOR_IN_NEXT') + return { + kind: 'for-in-next', + destination: reg(o[0]), + iterator: reg(o[1]), + exit: target(o[2]), + } + if (name === 'TRY_SETUP') + return { + kind: 'catch-setup', + target: target(o[0]), + exceptionRegister: reg(o[1]), + } + if (name === 'TRY_END') return { kind: 'handler-pop' } + if (name === 'FINALLY_SETUP') + return { + kind: 'finally-setup', + target: target(o[0]), + continuation: reg(o[1]), + payload: reg(o[2]), + throwPad: target(o[3]), + } + if (name === 'DEBUGGER') return { kind: 'debugger' } + decline('unsupported-opcode', `No VM switch lowering for ${name}`) +} + +export function numericToVmSwitch(model) { + const lowered = new Map() + const instructionsByFunction = new Map() + for (const fn of model.functions.functions) { + const instructions = model.instructionsByFunction.get(fn.id) + if (!instructions || instructions.length !== fn.instructionCount) + decline( + 'stale-switch-function', + `Function ${fn.id} has incomplete instructions`, + ) + const cases = instructions.map((instruction) => { + const result = { + pc: instruction.pc, + nextPc: instruction.nextPc, + operation: lower(instruction, fn.id, model), + } + if (lowered.has(instruction.pc)) + decline('duplicate-switch-case', `Duplicate PC ${instruction.pc}`) + lowered.set(instruction.pc, result) + return result + }) + instructionsByFunction.set(fn.id, cases) + } + if (lowered.size !== model.wordcode.instructionCount) + decline( + 'incomplete-switch-cases', + 'VM switch does not cover every wordcode instruction', + ) + const controlByFunction = new Map() + for (const [id, control] of model.controlByFunction) { + controlByFunction.set(id, { + ...control, + blocks: control.blocks.map((block) => ({ + ...block, + leaves: block.leaves.map((leaf) => { + const item = lowered.get(leaf.pc) + if (!item || item.nextPc !== leaf.nextPc) + decline( + 'stale-switch-case', + `Control leaf ${leaf.pc} has no matching case`, + ) + return { + pc: leaf.pc, + nextPc: leaf.nextPc, + reachable: leaf.reachable, + operation: item.operation, + } + }), + })), + }) + } + return { + schemaVersion: VM_SWITCH_SCHEMA, + code: { + kind: 'numeric-u32', + wordCount: model.wordcode.wordCount, + instructionCount: lowered.size, + }, + storage: { + kind: 'register-machine', + functions: model.functions.functions.map((fn) => ({ + id: fn.id, + registerCount: fn.regCount, + captureCount: fn.captureCount, + })), + }, + functions: model.functions, + instructionsByFunction, + controlByFunction, + structuredControl: { edges: model.structuredControl.edges }, + instructionCount: lowered.size, + } +} diff --git a/src/vm/jsconfuser-vm/partition-functions.js b/src/vm/jsconfuser-vm/partition-functions.js new file mode 100644 index 00000000..522486ff --- /dev/null +++ b/src/vm/jsconfuser-vm/partition-functions.js @@ -0,0 +1,968 @@ +const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' +const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +const UINT32_MAX = 0xffffffff + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +const NAME_BY_OPCODE = new Map( + Object.entries(CANONICAL_OPCODES).map(([name, value]) => [value, name]), +) + +const DIRECT_LABEL_OPERANDS = Object.freeze({ + JUMP: Object.freeze([0]), + JUMP_IF_FALSE: Object.freeze([1]), + JUMP_IF_TRUE: Object.freeze([1]), + FOR_IN_NEXT: Object.freeze([2]), + TRY_SETUP: Object.freeze([0]), + FINALLY_SETUP: Object.freeze([0, 3]), + MAKE_CLOSURE: Object.freeze([1]), +}) + +const LABEL_ROLES = Object.freeze({ + JUMP: Object.freeze({ 0: 'target' }), + JUMP_IF_FALSE: Object.freeze({ 1: 'target' }), + JUMP_IF_TRUE: Object.freeze({ 1: 'target' }), + FOR_IN_NEXT: Object.freeze({ 2: 'exit' }), + TRY_SETUP: Object.freeze({ 0: 'handler' }), + FINALLY_SETUP: Object.freeze({ 0: 'finally', 3: 'throwPad' }), + MAKE_CLOSURE: Object.freeze({ 1: 'functionEntry' }), +}) + +class PartitionDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'PartitionDecline' + this.code = code + } +} + +function decline(code, message) { + throw new PartitionDecline(code, message) +} + +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +function isUint32(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +function isSafeCount(value) { + return Number.isSafeInteger(value) && value >= 0 +} + +function exactObjectValues(actual, expected) { + if (!isObject(actual)) return false + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + actual[key] === expected[key], + ) + ) +} + +function sameArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +function requireUint32(value, code, message) { + if (!isUint32(value)) decline(code, message) + return value +} + +function requireSafeCount(value, code, message) { + if (!isSafeCount(value)) decline(code, message) + return value +} + +function cloneCapture(capture, location) { + requireObject(capture, 'invalid-descriptor', `${location} is not an object`) + if ( + (capture.kind !== 'local' && capture.kind !== 'upvalue') || + !isUint32(capture.index) || + capture.isLocal !== (capture.kind === 'local') + ) { + decline('invalid-descriptor', `${location} has an invalid capture`) + } + return { + kind: capture.kind, + index: capture.index, + isLocal: capture.isLocal, + } +} + +function descriptorSignature(descriptor) { + return JSON.stringify({ + startPc: descriptor.startPc, + paramCount: descriptor.paramCount, + regCount: descriptor.regCount, + captureCount: descriptor.captureCount, + hasRest: descriptor.hasRest, + captures: descriptor.captures, + }) +} + +function validateContainer(container) { + requireObject( + container, + 'invalid-input', + 'Expected a Packet A container result', + ) + if ( + container.schemaVersion !== CONTAINER_SCHEMA || + container.encoding !== 'numeric-u32' + ) { + decline('invalid-container', 'Input is not a Packet A numeric container') + } + const roles = requireObject( + container.roles, + 'invalid-container', + 'Packet A result has no roles object', + ) + const wordsRole = requireObject( + roles.words, + 'invalid-container', + 'Packet A result has no wordcode role', + ) + const words = wordsRole.values + if (!Array.isArray(words) || words.length === 0 || !words.every(isUint32)) { + decline( + 'invalid-container', + 'Packet A wordcode must be a non-empty unsigned 32-bit array', + ) + } + return words +} + +function validateWordcode(wordcode, containerWords) { + requireObject( + wordcode, + 'invalid-input', + 'Expected a Packet B wordcode result', + ) + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-wordcode', + 'Input is not a Packet B numeric wordcode result', + ) + } + const words = wordcode.words + if ( + !Array.isArray(words) || + !sameArray(words, containerWords) || + !words.every(isUint32) + ) { + decline('input-mismatch', 'Packet B words do not match Packet A wordcode') + } + if ( + wordcode.wordCount !== words.length || + wordcode.consumedWords !== words.length || + wordcode.nextPc !== words.length || + !Array.isArray(wordcode.instructions) || + wordcode.instructionCount !== wordcode.instructions.length || + wordcode.instructions.length === 0 + ) { + decline('invalid-wordcode', 'Packet B consumption metadata is inconsistent') + } + + const instructions = [] + const byPc = new Map() + let nextPc = 0 + for (const instruction of wordcode.instructions) { + requireObject( + instruction, + 'malformed-instruction', + 'Packet B contains a malformed instruction record', + ) + if (instruction.pc !== nextPc || !isSafeCount(instruction.pc)) { + decline('invalid-wordcode', 'Packet B instruction PCs are not contiguous') + } + if ( + typeof instruction.name !== 'string' || + NAME_BY_OPCODE.get(instruction.words?.[0]) !== instruction.name + ) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has an unknown opcode name`, + ) + } + if ( + !Array.isArray(instruction.words) || + instruction.words.length === 0 || + !instruction.words.every(isUint32) || + !isSafeCount(instruction.width) || + instruction.width !== instruction.words.length || + instruction.nextPc !== instruction.pc + instruction.width || + !sameArray( + instruction.words, + words.slice(instruction.pc, instruction.nextPc), + ) + ) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has invalid width or words`, + ) + } + if (!Array.isArray(instruction.operands)) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has no typed operands`, + ) + } + if (byPc.has(instruction.pc)) { + decline( + 'invalid-wordcode', + `Packet B contains duplicate instruction pc ${instruction.pc}`, + ) + } + byPc.set(instruction.pc, instruction) + instructions.push(instruction) + nextPc = instruction.nextPc + } + if (nextPc !== words.length) { + decline( + 'invalid-wordcode', + 'Packet B instructions do not consume all words', + ) + } + return { + words, + instructions, + byPc, + boundaries: instructions.map(({ pc }) => pc), + boundarySet: new Set(instructions.map(({ pc }) => pc)), + } +} + +function validateFrame(references, wordcodeData) { + requireObject( + references, + 'invalid-input', + 'Expected a Packet C reference/frame result', + ) + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-references', + 'Input is not a Packet C reference/frame result', + ) + } + if ( + references.wordCount !== wordcodeData.words.length || + references.instructionCount !== wordcodeData.instructions.length + ) { + decline('input-mismatch', 'Packet C counts do not match Packet B wordcode') + } + + const labels = requireObject( + references.labels, + 'invalid-references', + 'Packet C result has no labels section', + ) + if (!sameArray(labels.boundaries, wordcodeData.boundaries)) { + decline( + 'input-mismatch', + 'Packet C instruction boundaries do not match Packet B', + ) + } + const frame = requireObject( + references.frame, + 'invalid-frame-metadata', + 'Packet C result has no frame section', + ) + requireUint32( + frame.frameStart, + 'invalid-frame-metadata', + 'Packet C frameStart is not an unsigned integer', + ) + requireUint32( + frame.headerSize, + 'invalid-frame-metadata', + 'Packet C headerSize is not an unsigned integer', + ) + if (frame.frameStart !== 1 || frame.headerSize !== 8) { + decline( + 'invalid-frame-metadata', + 'Packet C frame constants do not match the pinned baseline', + ) + } + if (!exactObjectValues(frame.slots, CANONICAL_SLOTS)) { + decline('invalid-frame-metadata', 'Packet C frame slots are not canonical') + } + requireUint32( + frame.mainStartPc, + 'invalid-frame-metadata', + 'Packet C mainStartPc is not an unsigned integer', + ) + requireUint32( + frame.mainRegCount, + 'invalid-frame-metadata', + 'Packet C mainRegCount is not an unsigned integer', + ) + if ( + frame.mainRegCount < 1 || + frame.mainStartPc !== wordcodeData.boundaries[0] || + !wordcodeData.boundarySet.has(frame.mainStartPc) + ) { + decline('invalid-frame-metadata', 'Packet C root frame metadata is invalid') + } + + const root = requireObject( + frame.root, + 'invalid-frame-metadata', + 'Packet C result has no root frame record', + ) + const rootFrameSize = frame.headerSize + frame.mainRegCount + const rootRegisterBase = frame.frameStart + frame.headerSize + const rootFrameEnd = frame.frameStart + rootFrameSize + if ( + root.frameBase !== frame.frameStart || + root.frameSize !== rootFrameSize || + root.registerBase !== rootRegisterBase || + root.frameEnd !== rootFrameEnd || + root.registerWindow?.start !== rootRegisterBase || + root.registerWindow?.end !== rootFrameEnd + ) { + decline( + 'invalid-frame-metadata', + 'Packet C root frame arithmetic is invalid', + ) + } + + const descriptorValues = references.frame.descriptors + if (!Array.isArray(descriptorValues)) { + decline('invalid-descriptor', 'Packet C descriptors are not an array') + } + const descriptors = [] + const byStart = new Map() + for (const input of descriptorValues) { + requireObject( + input, + 'invalid-descriptor', + 'Packet C has a malformed descriptor', + ) + const descriptor = { + creationPc: requireUint32( + input.creationPc, + 'invalid-descriptor', + 'Descriptor creationPc is invalid', + ), + startPc: requireUint32( + input.startPc, + 'invalid-descriptor', + 'Descriptor startPc is invalid', + ), + paramCount: requireUint32( + input.paramCount, + 'invalid-descriptor', + 'Descriptor paramCount is invalid', + ), + regCount: requireUint32( + input.regCount, + 'invalid-descriptor', + 'Descriptor regCount is invalid', + ), + captureCount: requireUint32( + input.captureCount, + 'invalid-descriptor', + 'Descriptor captureCount is invalid', + ), + hasRest: input.hasRest, + captures: [], + frameSize: requireUint32( + input.frameSize, + 'invalid-descriptor', + 'Descriptor frameSize is invalid', + ), + registerBaseOffset: requireUint32( + input.registerBaseOffset, + 'invalid-descriptor', + 'Descriptor registerBaseOffset is invalid', + ), + } + if ( + typeof descriptor.hasRest !== 'boolean' || + descriptor.regCount < 1 || + descriptor.paramCount > descriptor.regCount || + descriptor.captureCount !== input.captures?.length || + descriptor.frameSize !== frame.headerSize + descriptor.regCount || + descriptor.registerBaseOffset !== frame.headerSize || + !wordcodeData.boundarySet.has(descriptor.creationPc) || + !wordcodeData.boundarySet.has(descriptor.startPc) + ) { + decline('invalid-descriptor', 'Packet C descriptor bounds are invalid') + } + descriptor.captures = input.captures.map((capture, index) => + cloneCapture( + capture, + `descriptor ${descriptor.startPc} capture ${index}`, + ), + ) + const signature = descriptorSignature(descriptor) + const previous = byStart.get(descriptor.startPc) + if (previous && previous.signature !== signature) { + decline( + 'invalid-descriptor', + `Conflicting descriptors share start pc ${descriptor.startPc}`, + ) + } + if (!previous) { + byStart.set(descriptor.startPc, { descriptor, signature }) + descriptors.push(descriptor) + } + } + return { + frame, + descriptors, + descriptorsByStart: new Map( + descriptors.map((descriptor) => [descriptor.startPc, descriptor]), + ), + labelReferences: validateLabelReferences(labels.references, wordcodeData), + } +} + +function validateLabelReferences(input, wordcodeData) { + if (!Array.isArray(input)) { + decline( + 'invalid-label-reference', + 'Packet C label references are not an array', + ) + } + const byLocation = new Map() + const normalized = [] + for (const reference of input) { + requireObject( + reference, + 'invalid-label-reference', + 'Packet C has a malformed label reference', + ) + const pc = requireUint32( + reference.pc, + 'invalid-label-reference', + 'Label reference pc is invalid', + ) + const operand = requireSafeCount( + reference.operand, + 'invalid-label-reference', + 'Label reference operand is invalid', + ) + const target = requireUint32( + reference.target, + 'invalid-label-reference', + 'Label reference target is invalid', + ) + const instruction = wordcodeData.byPc.get(pc) + if (!instruction || instruction.name !== reference.instruction) { + decline( + 'input-mismatch', + `Label reference at ${pc} does not match Packet B`, + ) + } + const expectedOperands = DIRECT_LABEL_OPERANDS[instruction.name] ?? [] + if (!expectedOperands.includes(operand)) { + decline( + 'invalid-label-reference', + `${instruction.name}@${pc} has an unexpected label operand`, + ) + } + const typedOperand = instruction.operands[operand] + if ( + !isObject(typedOperand) || + typedOperand.kind !== 'label-target' || + typedOperand.pc !== target || + !wordcodeData.boundarySet.has(target) + ) { + decline( + 'input-mismatch', + `${instruction.name}@${pc} label reference does not match Packet B`, + ) + } + const expectedRole = LABEL_ROLES[instruction.name]?.[operand] + if (reference.role !== expectedRole) { + decline( + 'invalid-label-reference', + `${instruction.name}@${pc} has an invalid label role`, + ) + } + const key = `${pc}:${operand}` + if (byLocation.has(key)) { + decline('invalid-label-reference', `Duplicate label reference ${key}`) + } + byLocation.set(key, reference) + normalized.push({ + pc, + instruction: instruction.name, + operand, + role: reference.role, + target, + }) + } + + for (const instruction of wordcodeData.instructions) { + const expectedOperands = DIRECT_LABEL_OPERANDS[instruction.name] ?? [] + const actualOperands = instruction.operands + .map((operand, index) => + isObject(operand) && operand.kind === 'label-target' ? index : null, + ) + .filter((index) => index !== null) + if (!sameArray(expectedOperands, actualOperands)) { + decline( + 'invalid-label-reference', + `${instruction.name}@${instruction.pc} has an invalid label layout`, + ) + } + for (const operand of expectedOperands) { + if (!byLocation.has(`${instruction.pc}:${operand}`)) { + decline( + 'invalid-label-reference', + `Missing label reference for ${instruction.name}@${instruction.pc}`, + ) + } + } + } + return normalized +} + +function validateClosureDescriptor(instruction, descriptor) { + if (instruction.name !== 'MAKE_CLOSURE') { + decline( + 'invalid-descriptor', + `Descriptor creation pc ${descriptor.creationPc} is not MAKE_CLOSURE`, + ) + } + const metadata = requireObject( + instruction.functionMeta, + 'invalid-descriptor', + `MAKE_CLOSURE@${instruction.pc} has no function metadata`, + ) + if ( + metadata.startPc !== descriptor.startPc || + metadata.paramCount !== descriptor.paramCount || + metadata.regCount !== descriptor.regCount || + metadata.captureCount !== descriptor.captureCount || + metadata.hasRest !== descriptor.hasRest || + instruction.operands[1]?.pc !== descriptor.startPc || + instruction.operands[2]?.value !== descriptor.paramCount || + instruction.operands[3]?.value !== descriptor.regCount || + instruction.operands[4]?.value !== descriptor.captureCount || + instruction.operands[5]?.value !== (descriptor.hasRest ? 1 : 0) || + !Array.isArray(instruction.captures) || + instruction.captures.length !== descriptor.captures.length + ) { + decline( + 'input-mismatch', + `Descriptor at ${instruction.pc} does not match Packet B`, + ) + } + for (let index = 0; index < descriptor.captures.length; index += 1) { + const expected = descriptor.captures[index] + const actual = instruction.captures[index] + if ( + !isObject(actual) || + actual.kind !== expected.kind || + actual.index !== expected.index || + actual.isLocal !== expected.isLocal + ) { + decline( + 'input-mismatch', + `Descriptor capture ${index} at ${instruction.pc} does not match Packet B`, + ) + } + } +} + +function assignIntervals(wordcodeData, frameData) { + const entries = [ + { + startPc: frameData.frame.mainStartPc, + kind: 'root', + descriptor: null, + }, + ...frameData.descriptors.map((descriptor) => ({ + startPc: descriptor.startPc, + kind: 'closure', + descriptor, + })), + ].sort((left, right) => left.startPc - right.startPc) + + if (entries[0].startPc !== frameData.frame.mainStartPc) { + decline('invalid-function-boundary', 'Root function is not the first entry') + } + const seenStarts = new Set() + const functions = [] + const ownerByPc = new Map() + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index] + if (seenStarts.has(entry.startPc)) { + decline( + 'invalid-function-boundary', + `Multiple function entries share pc ${entry.startPc}`, + ) + } + seenStarts.add(entry.startPc) + const endPc = entries[index + 1]?.startPc ?? wordcodeData.words.length + if ( + endPc <= entry.startPc || + (endPc !== wordcodeData.words.length && + !wordcodeData.boundarySet.has(endPc)) + ) { + decline( + 'invalid-function-boundary', + `Function interval ${entry.startPc}..${endPc} is not boundary-aligned`, + ) + } + const intervalInstructions = wordcodeData.instructions.filter( + ({ pc }) => pc >= entry.startPc && pc < endPc, + ) + if ( + intervalInstructions.length === 0 || + intervalInstructions[0].pc !== entry.startPc || + intervalInstructions.at(-1).nextPc !== endPc + ) { + decline( + 'invalid-function-boundary', + `Function interval ${entry.startPc}..${endPc} has a gap or overlap`, + ) + } + const functionRecord = { + id: index, + kind: entry.kind, + startPc: entry.startPc, + endPc, + paramCount: entry.descriptor?.paramCount ?? 0, + regCount: entry.descriptor?.regCount ?? frameData.frame.mainRegCount, + captureCount: entry.descriptor?.captureCount ?? 0, + hasRest: entry.descriptor?.hasRest ?? false, + parentFunctionId: null, + instructionPcs: intervalInstructions.map(({ pc }) => pc), + instructionCount: intervalInstructions.length, + descriptor: entry.descriptor + ? { + ...entry.descriptor, + captures: entry.descriptor.captures.map((capture) => ({ + ...capture, + })), + } + : null, + closureSites: [], + } + for (const instruction of intervalInstructions) { + if (ownerByPc.has(instruction.pc)) { + decline( + 'invalid-function-boundary', + `Instruction at ${instruction.pc} has multiple owners`, + ) + } + ownerByPc.set(instruction.pc, index) + } + functions.push(functionRecord) + } + if (ownerByPc.size !== wordcodeData.instructions.length) { + decline( + 'invalid-function-boundary', + 'Not every instruction belongs to exactly one function', + ) + } + return { functions, ownerByPc, entries } +} + +function attachClosures(wordcodeData, frameData, partition) { + const functionByStart = new Map( + partition.functions.map((functionRecord) => [ + functionRecord.startPc, + functionRecord, + ]), + ) + const descriptorsWithSites = new Set() + const parentSets = new Map() + for (const instruction of wordcodeData.instructions) { + if (instruction.name !== 'MAKE_CLOSURE') continue + const target = instruction.operands[1]?.pc + const descriptor = frameData.descriptorsByStart.get(target) + const child = functionByStart.get(target) + const parentId = partition.ownerByPc.get(instruction.pc) + if ( + !descriptor || + !child || + parentId === undefined || + child.id === parentId + ) { + decline( + 'invalid-function-boundary', + `MAKE_CLOSURE@${instruction.pc} has no valid child owner`, + ) + } + validateClosureDescriptor(instruction, descriptor) + const site = { + creationPc: instruction.pc, + parentFunctionId: parentId, + childFunctionId: child.id, + childStartPc: child.startPc, + destinationRegister: instruction.operands[0]?.index, + } + partition.functions[parentId].closureSites.push(site) + const parents = parentSets.get(child.id) ?? new Set() + parents.add(parentId) + parentSets.set(child.id, parents) + descriptorsWithSites.add(child.id) + } + for (const functionRecord of partition.functions) { + if (functionRecord.kind === 'closure') { + const parents = parentSets.get(functionRecord.id) + if (!parents || parents.size === 0) { + decline( + 'invalid-function-boundary', + `Function entry ${functionRecord.startPc} has no closure creation site`, + ) + } + if (parents.size > 1) { + decline( + 'invalid-function-boundary', + `Function entry ${functionRecord.startPc} has ambiguous parents`, + ) + } + functionRecord.parentFunctionId = [...parents][0] + } + } + return { functionByStart, descriptorsWithSites } +} + +function validateDirectTargets(wordcodeData, labelReferences, partition) { + const normalized = [] + for (const reference of labelReferences) { + const sourceFunctionId = partition.ownerByPc.get(reference.pc) + const targetFunctionId = partition.ownerByPc.get(reference.target) + if (sourceFunctionId === undefined || targetFunctionId === undefined) { + decline( + 'invalid-function-boundary', + `Label ${reference.pc} -> ${reference.target} has no function owner`, + ) + } + if ( + reference.role === 'functionEntry' && + (reference.instruction !== 'MAKE_CLOSURE' || + targetFunctionId === sourceFunctionId) + ) { + decline( + 'invalid-function-boundary', + `MAKE_CLOSURE@${reference.pc} does not enter another function`, + ) + } + if ( + reference.role !== 'functionEntry' && + targetFunctionId !== sourceFunctionId + ) { + decline( + 'cross-function-target', + `${reference.instruction}@${reference.pc} targets function ${targetFunctionId} from ${sourceFunctionId}`, + ) + } + normalized.push({ + pc: reference.pc, + instruction: reference.instruction, + operand: reference.operand, + role: reference.role, + target: reference.target, + sourceFunctionId, + targetFunctionId, + }) + } + return normalized +} + +function makeResult(wordcodeData, frameData, partition, labels) { + return deepFreeze({ + schemaVersion: FUNCTION_SCHEMA, + encoding: 'numeric-u32', + wordCount: wordcodeData.words.length, + instructionCount: wordcodeData.instructions.length, + entryPcs: partition.functions.map(({ startPc }) => startPc), + functions: partition.functions.map((functionRecord) => ({ + ...functionRecord, + instructionPcs: [...functionRecord.instructionPcs], + descriptor: functionRecord.descriptor + ? { + ...functionRecord.descriptor, + captures: functionRecord.descriptor.captures.map((capture) => ({ + ...capture, + })), + } + : null, + closureSites: functionRecord.closureSites.map((site) => ({ ...site })), + })), + ownership: wordcodeData.instructions.map((instruction) => ({ + pc: instruction.pc, + functionId: partition.ownerByPc.get(instruction.pc), + })), + labels, + frame: { + frameStart: frameData.frame.frameStart, + headerSize: frameData.frame.headerSize, + slots: { ...frameData.frame.slots }, + root: { + functionId: partition.functions[0].id, + startPc: frameData.frame.mainStartPc, + regCount: frameData.frame.mainRegCount, + }, + }, + }) +} + +function recognize(container, wordcode, references) { + const containerWords = validateContainer(container) + const wordcodeData = validateWordcode(wordcode, containerWords) + const frameData = validateFrame(references, wordcodeData) + for (const descriptor of frameData.descriptors) { + const instruction = wordcodeData.byPc.get(descriptor.creationPc) + if (!instruction) { + decline( + 'invalid-descriptor', + `Descriptor creation pc ${descriptor.creationPc} is not an instruction`, + ) + } + validateClosureDescriptor(instruction, descriptor) + } + const partition = assignIntervals(wordcodeData, frameData) + attachClosures(wordcodeData, frameData, partition) + const labels = validateDirectTargets( + wordcodeData, + frameData.labelReferences, + partition, + ) + return makeResult(wordcodeData, frameData, partition, labels) +} + +export function diagnoseFunctionPartition(container, wordcode, references) { + try { + const result = recognize(container, wordcode, references) + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof PartitionDecline) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + } + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'function-partition-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function partitionFunctions(container, wordcode, references) { + return diagnoseFunctionPartition(container, wordcode, references).result +} + +export const partitionFunctionOwnership = partitionFunctions + +export default partitionFunctions diff --git a/src/vm/jsconfuser-vm/property-collections-operands.js b/src/vm/jsconfuser-vm/property-collections-operands.js new file mode 100644 index 00000000..3bcdc4f9 --- /dev/null +++ b/src/vm/jsconfuser-vm/property-collections-operands.js @@ -0,0 +1,796 @@ +import { buildCallFrames } from './build-call-frames.js' +import { buildControlFlow } from './build-cfg.js' +import { partitionFunctions } from './partition-functions.js' +import { readWordcode } from './read-wordcode.js' +import { validateReferences } from './validate-references.js' + +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const CALL_FRAME_SCHEMA = 'jsconfuser-vm-call-frames.v1' +export const PROPERTY_COLLECTION_SCHEMA = + 'jsconfuser-vm-property-collections.v1' +const UINT32_MAX = 0xffffffff +const CALL_SPREAD = 65535 + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const J_OPCODE_DEFINITIONS = Object.freeze( + [ + { + name: 'GET_PROP', + family: 'property', + operandForm: 'dst, obj, key', + operation: 'get-property', + effect: 'property-read', + resultType: 'dynamic', + known: false, + evaluationRoles: ['object', 'key'], + }, + { + name: 'SET_PROP', + family: 'property', + operandForm: 'obj, key, val', + operation: 'set-property', + effect: 'property-write', + resultType: 'no-register-result', + known: false, + evaluationRoles: ['object', 'key', 'value'], + }, + { + name: 'DELETE_PROP', + family: 'property', + operandForm: 'dst, obj, key', + operation: 'delete-property', + effect: 'property-delete', + resultType: 'boolean', + known: false, + evaluationRoles: ['object', 'key'], + }, + { + name: 'IN', + family: 'membership', + operandForm: 'dst, src1, src2', + operation: 'membership-test', + effect: 'property-membership-read', + resultType: 'boolean', + known: false, + evaluationRoles: ['propertyKey', 'object'], + }, + { + name: 'INSTANCEOF', + family: 'prototype-relation', + operandForm: 'dst, src1, src2', + operation: 'instanceof-test', + effect: 'prototype-relation-read', + resultType: 'boolean', + known: false, + evaluationRoles: ['instance', 'constructor'], + }, + { + name: 'BUILD_ARRAY', + family: 'collection', + operandForm: 'dst, count, elemRegs', + operation: 'build-array', + effect: 'array-construction', + resultType: 'array', + known: false, + evaluationRoles: ['elements'], + }, + { + name: 'BUILD_OBJECT', + family: 'collection', + operandForm: 'dst, pairCount, key/value regs', + operation: 'build-object', + effect: 'object-construction', + resultType: 'object', + known: false, + evaluationRoles: ['pairs'], + }, + { + name: 'DEFINE_GETTER', + family: 'accessor', + operandForm: 'obj, key, fn', + operation: 'define-getter', + effect: 'accessor-definition', + resultType: 'no-register-result', + known: false, + evaluationRoles: ['object', 'key', 'function'], + }, + { + name: 'DEFINE_SETTER', + family: 'accessor', + operandForm: 'obj, key, fn', + operation: 'define-setter', + effect: 'accessor-definition', + resultType: 'no-register-result', + known: false, + evaluationRoles: ['object', 'key', 'function'], + }, + { + name: 'FOR_IN_SETUP', + family: 'enumeration', + operandForm: 'dst, src', + operation: 'for-in-setup', + effect: 'iterator-creation', + resultType: 'iterator', + known: false, + evaluationRoles: ['source'], + }, + { + name: 'FOR_IN_NEXT', + family: 'enumeration', + operandForm: 'dst, iter, label(exit)', + operation: 'for-in-next', + effect: 'iterator-step', + resultType: 'property-key-or-exit', + known: false, + evaluationRoles: ['iterator', 'exit'], + }, + ].map((definition) => + Object.freeze({ + ...definition, + opcode: CANONICAL_OPCODES[definition.name], + }), + ), +) + +export const J_DEFINITION_BY_NAME = new Map( + J_OPCODE_DEFINITIONS.map((definition) => [definition.name, definition]), +) + +export const EXCLUDED_OPCODE_GROUPS = Object.freeze( + [ + { + boundary: 'receiver/L-scope', + names: ['LOAD_THIS'], + }, + { + boundary: 'upvalue-closure-state', + names: ['LOAD_UPVALUE', 'STORE_UPVALUE'], + }, + { + boundary: 'I-scalar-value', + names: [ + 'LOAD_CONST', + 'LOAD_INT', + 'LOAD_GLOBAL', + 'MOVE', + 'STORE_GLOBAL', + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', + 'TYPEOF_SAFE', + ], + }, + { + boundary: 'K-control-flow', + names: ['JUMP', 'JUMP_IF_FALSE', 'JUMP_IF_TRUE', 'JUMP_REG'], + }, + { + boundary: 'L-calls-and-completion', + names: ['CALL', 'CALL_METHOD', 'NEW', 'RETURN', 'THROW'], + }, + { + boundary: 'M-closures', + names: ['MAKE_CLOSURE'], + }, + { + boundary: 'H-handler-finally', + names: ['TRY_SETUP', 'TRY_END', 'FINALLY_SETUP'], + }, + { + boundary: 'hardening-only', + names: ['PATCH'], + }, + { + boundary: 'debug-statement', + names: ['DEBUGGER'], + }, + ].map((group) => + Object.freeze({ ...group, names: Object.freeze(group.names) }), + ), +) + +export const EXCLUDED_OPCODE_NAMES = new Set( + EXCLUDED_OPCODE_GROUPS.flatMap(({ names }) => names), +) + +export class PropertyCollectionsDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'PropertyCollectionsDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new PropertyCollectionsDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +function isUint32(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +function sameArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +function snapshot(value, label) { + try { + return JSON.stringify(value) + } catch (error) { + decline( + 'malformed-predecessor', + `${label} cannot be compared as immutable JSON data: ${error.message}`, + ) + } +} + +function canonicalContainer(wordcode, references) { + const frame = references.frame + return { + schemaVersion: 'jsconfuser-vm-container.v1', + encoding: 'numeric-u32', + roles: { + pool: { values: references.constants.values }, + words: { values: wordcode.words }, + op: { values: CANONICAL_OPCODES }, + sentinels: { values: { CALL_SPREAD } }, + slots: { values: CANONICAL_SLOTS }, + scalars: { + MAIN_START_PC: { value: frame.mainStartPc }, + MAIN_REG_COUNT: { value: frame.mainRegCount }, + ENCODE_BYTECODE: { value: false }, + TIMING_CHECKS: { value: false }, + HEADER_SIZE: { value: frame.headerSize }, + FRAME_START: { value: frame.frameStart }, + }, + }, + } +} + +function requirePreflightShape( + wordcode, + references, + functions, + cfg, + callFrames, +) { + requireObject(wordcode, 'invalid-wordcode', 'Expected a Packet B result') + requireObject(references, 'invalid-references', 'Expected a Packet C result') + requireObject(functions, 'invalid-functions', 'Expected a Packet D result') + requireObject(cfg, 'invalid-cfg', 'Expected a Packet E result') + requireObject(callFrames, 'invalid-call-frames', 'Expected a Packet F result') + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' || + !Array.isArray(wordcode.words) || + wordcode.words.length === 0 || + !wordcode.words.every(isUint32) + ) { + decline('invalid-wordcode', 'Packet B numeric wordcode metadata is invalid') + } + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' || + !isObject(references.constants) || + !Array.isArray(references.constants.values) || + !isObject(references.frame) + ) { + decline( + 'invalid-references', + 'Packet C constants or frame metadata is invalid', + ) + } + if ( + functions.schemaVersion !== FUNCTION_SCHEMA || + functions.encoding !== 'numeric-u32' + ) { + decline('invalid-functions', 'Packet D function metadata is invalid') + } + if (cfg.schemaVersion !== CFG_SCHEMA || cfg.encoding !== 'numeric-u32') { + decline('invalid-cfg', 'Packet E CFG metadata is invalid') + } + if ( + callFrames.schemaVersion !== CALL_FRAME_SCHEMA || + callFrames.encoding !== 'numeric-u32' + ) { + decline('invalid-call-frames', 'Packet F call-frame metadata is invalid') + } +} + +export function preflight(wordcode, references, functions, cfg, callFrames) { + requirePreflightShape(wordcode, references, functions, cfg, callFrames) + const container = canonicalContainer(wordcode, references) + const parsedWordcode = readWordcode(container) + if (!parsedWordcode) { + decline( + 'stale-predecessor', + 'Packet B cannot be reconstructed from its numeric word stream', + ) + } + if ( + snapshot(wordcode, 'Packet B') !== + snapshot(parsedWordcode, 'parsed Packet B') + ) { + decline( + 'stale-predecessor', + 'Packet B is not the canonical parsed wordcode result', + ) + } + + const parsedReferences = validateReferences(container, parsedWordcode) + if (!parsedReferences) { + decline( + 'stale-predecessor', + 'Packet C cannot be reconstructed from Packet B and its constant pool', + ) + } + if ( + snapshot(references, 'Packet C') !== + snapshot(parsedReferences, 'parsed Packet C') + ) { + decline( + 'stale-predecessor', + 'Packet C is not the canonical reference/frame result for Packet B', + ) + } + + const parsedFunctions = partitionFunctions( + container, + parsedWordcode, + parsedReferences, + ) + if (!parsedFunctions) { + decline( + 'stale-predecessor', + 'Packet D cannot be reconstructed from the accepted B/C results', + ) + } + if ( + snapshot(functions, 'Packet D') !== + snapshot(parsedFunctions, 'parsed Packet D') + ) { + decline( + 'stale-predecessor', + 'Packet D is not the canonical function ownership result', + ) + } + + const parsedCfg = buildControlFlow( + parsedWordcode, + parsedReferences, + parsedFunctions, + ) + if (!parsedCfg) { + decline( + 'stale-predecessor', + 'Packet E cannot be reconstructed from the accepted B/C/D results', + ) + } + if (snapshot(cfg, 'Packet E') !== snapshot(parsedCfg, 'parsed Packet E')) { + decline('stale-predecessor', 'Packet E is not the canonical CFG result') + } + + const parsedCallFrames = buildCallFrames( + parsedWordcode, + parsedReferences, + parsedFunctions, + parsedCfg, + ) + if (!parsedCallFrames) { + decline( + 'stale-predecessor', + 'Packet F cannot be reconstructed from the accepted B/C/D/E results', + ) + } + if ( + snapshot(callFrames, 'Packet F') !== + snapshot(parsedCallFrames, 'parsed Packet F') + ) { + decline( + 'stale-predecessor', + 'Packet F is not the canonical call-frame result', + ) + } + return { + wordcode: parsedWordcode, + references: parsedReferences, + functions: parsedFunctions, + cfg: parsedCfg, + callFrames: parsedCallFrames, + } +} + +export function register(index, location) { + if (!isUint32(index)) + decline('invalid-register', `${location} is not a register`) + return { kind: 'register', index } +} + +function labelTarget(pc, location) { + if (!isUint32(pc)) + decline('invalid-label', `${location} is not a label target`) + return { kind: 'label-target', pc } +} + +function copyOperand(operand, location) { + requireObject(operand, 'malformed-operation', `${location} is not an operand`) + if (operand.kind === 'register') return register(operand.index, location) + if (operand.kind === 'immediate') { + if (!isUint32(operand.value)) + decline('malformed-operation', `${location} is not an immediate`) + return { kind: 'immediate', value: operand.value } + } + if (operand.kind === 'label-target') { + return labelTarget(operand.pc, location) + } + decline( + 'unsupported-value-form', + `${location} has unsupported operand form ${String(operand.kind)}`, + ) +} + +export function reachablePcs(cfg) { + const reachable = new Map() + for (const fn of cfg.functions) { + const edgesBySource = new Map() + for (const edge of fn.edges) { + const edges = edgesBySource.get(edge.sourcePc) ?? [] + edges.push(edge) + edgesBySource.set(edge.sourcePc, edges) + } + const seen = new Set([fn.startPc]) + const queue = [fn.startPc] + while (queue.length) { + const pc = queue.shift() + for (const edge of edgesBySource.get(pc) ?? []) { + if (edge.targetPc !== null && !seen.has(edge.targetPc)) { + seen.add(edge.targetPc) + queue.push(edge.targetPc) + } + } + } + for (const pc of seen) reachable.set(`${fn.id}:${pc}`, true) + } + return reachable +} + +export function ownerByPc(functions) { + const owners = new Map() + for (const fn of functions.functions) { + for (const pc of fn.instructionPcs) { + if (owners.has(String(pc))) { + decline( + 'invalid-function-ownership', + `Instruction ${pc} has duplicate owners`, + ) + } + owners.set(String(pc), fn.id) + } + } + return owners +} + +export function requireOperandKinds(instruction, definition) { + const operands = instruction.operands + if (!Array.isArray(operands)) + decline( + 'malformed-operation', + `${instruction.name}@${instruction.pc} has no operands`, + ) + const kinds = operands.map((operand) => operand?.kind) + let expected + if (definition.name === 'BUILD_ARRAY') { + const count = operands[1]?.value + expected = [ + 'register', + 'immediate', + ...Array.from({ length: count ?? -1 }, () => 'register'), + ] + } else if (definition.name === 'BUILD_OBJECT') { + const pairCount = operands[1]?.value + expected = [ + 'register', + 'immediate', + ...Array.from({ length: (pairCount ?? -1) * 2 }, () => 'register'), + ] + } else if (definition.name === 'FOR_IN_NEXT') { + expected = ['register', 'register', 'label-target'] + } else if (definition.name === 'FOR_IN_SETUP') { + expected = ['register', 'register'] + } else { + expected = Array.from({ length: 3 }, () => 'register') + } + if (!sameArray(kinds, expected)) { + decline( + 'unsupported-value-form', + `${instruction.name}@${instruction.pc} has an unsupported operand form`, + ) + } + if ( + (definition.name === 'BUILD_ARRAY' || definition.name === 'BUILD_OBJECT') && + (!Number.isSafeInteger(operands[1].value) || operands[1].value < 0) + ) { + decline( + 'malformed-operation', + `${instruction.name}@${instruction.pc} has an invalid count`, + ) + } + return operands.map((operand, index) => + copyOperand( + operand, + `${instruction.name}@${instruction.pc} operand ${index}`, + ), + ) +} + +export function destinationFor(name, operands) { + if (['SET_PROP', 'DEFINE_GETTER', 'DEFINE_SETTER'].includes(name)) return null + return register(operands[0].index, `${name} destination`) +} + +export function inputIndexes(name, operands) { + if (name === 'GET_PROP' || name === 'DELETE_PROP') return [1, 2] + if ( + name === 'SET_PROP' || + name === 'DEFINE_GETTER' || + name === 'DEFINE_SETTER' + ) { + return [0, 1, 2] + } + if (name === 'IN' || name === 'INSTANCEOF') return [1, 2] + if (name === 'BUILD_ARRAY') + return Array.from({ length: operands.length - 2 }, (_, index) => index + 2) + if (name === 'BUILD_OBJECT') + return Array.from({ length: operands.length - 2 }, (_, index) => index + 2) + if (name === 'FOR_IN_SETUP') return [1] + if (name === 'FOR_IN_NEXT') return [1] + return [] +} + +function roleValue(role, operand, operandIndex) { + return { role, operand: operandIndex, register: { ...operand } } +} + +export function operandRoles(name, operands) { + if (name === 'GET_PROP' || name === 'DELETE_PROP') { + return { + object: roleValue('object', operands[1], 1), + key: roleValue('key', operands[2], 2), + result: roleValue('result', operands[0], 0), + } + } + if (name === 'SET_PROP') { + return { + object: roleValue('object', operands[0], 0), + key: roleValue('key', operands[1], 1), + value: roleValue('value', operands[2], 2), + } + } + if (name === 'IN') { + return { + propertyKey: roleValue('propertyKey', operands[1], 1), + object: roleValue('object', operands[2], 2), + result: roleValue('result', operands[0], 0), + } + } + if (name === 'INSTANCEOF') { + return { + instance: roleValue('instance', operands[1], 1), + constructor: roleValue('constructor', operands[2], 2), + result: roleValue('result', operands[0], 0), + } + } + if (name === 'BUILD_ARRAY') { + return { + result: roleValue('result', operands[0], 0), + elements: operands + .slice(2) + .map((operand, index) => + roleValue(`element[${index}]`, operand, index + 2), + ), + } + } + if (name === 'BUILD_OBJECT') { + return { + result: roleValue('result', operands[0], 0), + pairs: Array.from({ length: operands[1].value }, (_, pairIndex) => ({ + pairIndex, + key: roleValue('key', operands[2 + pairIndex * 2], 2 + pairIndex * 2), + value: roleValue( + 'value', + operands[3 + pairIndex * 2], + 3 + pairIndex * 2, + ), + })), + } + } + if (name === 'DEFINE_GETTER' || name === 'DEFINE_SETTER') { + return { + object: roleValue('object', operands[0], 0), + key: roleValue('key', operands[1], 1), + accessorFunction: roleValue('function', operands[2], 2), + } + } + if (name === 'FOR_IN_SETUP') { + return { + iterator: roleValue('iterator', operands[0], 0), + source: roleValue('source', operands[1], 1), + } + } + return { + key: roleValue('key', operands[0], 0), + iterator: roleValue('iterator', operands[1], 1), + exit: { role: 'exit', operand: 2, targetPc: operands[2].pc }, + } +} + +export function evaluationOrder(name, operands) { + const roles = + name === 'BUILD_ARRAY' + ? operands.slice(2).map((_, index) => `element[${index}]`) + : name === 'BUILD_OBJECT' + ? Array.from({ length: operands[1].value }, (_, index) => [ + `pair[${index}].key`, + `pair[${index}].value`, + ]).flat() + : name === 'FOR_IN_NEXT' + ? ['iterator', 'exit'] + : J_DEFINITION_BY_NAME.get(name).evaluationRoles + const roleMap = operandRoles(name, operands) + const registerForRole = (role) => { + if (role.startsWith('element[')) { + return roleMap.elements[Number(role.slice(8, -1))].register + } + const pairMatch = role.match(/^pair\[(\d+)\]\.(key|value)$/) + if (pairMatch) { + const pair = roleMap.pairs[Number(pairMatch[1])] + return pair[pairMatch[2]].register + } + if (role === 'exit') return null + return roleMap[role]?.register ?? null + } + return { + kind: 'left-to-right-register-read', + roles, + steps: roles.map((role, index) => ({ + order: index, + role, + register: registerForRole(role), + })), + caveat: + 'This is compiler/runtime operand-read order; dynamic coercion, getters, proxies, and prototype lookup are not evaluated here.', + } +} diff --git a/src/vm/jsconfuser-vm/read-wordcode.js b/src/vm/jsconfuser-vm/read-wordcode.js new file mode 100644 index 00000000..085792eb --- /dev/null +++ b/src/vm/jsconfuser-vm/read-wordcode.js @@ -0,0 +1,816 @@ +const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' +const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +const UINT32_MAX = 0xffffffff + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +const CANONICAL_SENTINEL = 65535 + +const BINARY_REGISTER_OPCODES = new Set([ + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'IN', + 'INSTANCEOF', +]) + +const UNARY_REGISTER_OPCODES = new Set([ + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', +]) + +const THREE_REGISTER_OPCODES = new Set([ + 'GET_PROP', + 'SET_PROP', + 'DELETE_PROP', + 'DEFINE_GETTER', + 'DEFINE_SETTER', +]) + +const FIXED_WORD_WIDTHS = Object.freeze({ + LOAD_CONST: 4, + LOAD_INT: 3, + LOAD_GLOBAL: 4, + LOAD_UPVALUE: 3, + LOAD_THIS: 2, + MOVE: 3, + STORE_GLOBAL: 4, + STORE_UPVALUE: 3, + GET_PROP: 4, + SET_PROP: 4, + DELETE_PROP: 4, + ADD: 4, + SUB: 4, + MUL: 4, + DIV: 4, + MOD: 4, + EXP: 4, + BAND: 4, + BOR: 4, + BXOR: 4, + SHL: 4, + SHR: 4, + USHR: 4, + LT: 4, + GT: 4, + LTE: 4, + GTE: 4, + EQ: 4, + NEQ: 4, + LOOSE_EQ: 4, + LOOSE_NEQ: 4, + IN: 4, + INSTANCEOF: 4, + UNARY_NEG: 3, + UNARY_POS: 3, + UNARY_NOT: 3, + UNARY_BITNOT: 3, + TYPEOF: 3, + VOID: 3, + TYPEOF_SAFE: 4, + JUMP: 2, + JUMP_IF_FALSE: 3, + JUMP_IF_TRUE: 3, + RETURN: 2, + THROW: 2, + DEFINE_GETTER: 4, + DEFINE_SETTER: 4, + FOR_IN_SETUP: 3, + FOR_IN_NEXT: 4, + TRY_SETUP: 3, + TRY_END: 1, + DEBUGGER: 1, + JUMP_REG: 2, + FINALLY_SETUP: 5, +}) + +const NAME_BY_OPCODE = new Map( + Object.entries(CANONICAL_OPCODES).map(([name, value]) => [value, name]), +) + +class WordcodeDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'WordcodeDecline' + this.code = code + } +} + +function decline(code, message) { + throw new WordcodeDecline(code, message) +} + +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +function sameObjectValues(actual, expected) { + if (!actual || typeof actual !== 'object' || Array.isArray(actual)) { + return false + } + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + actual[key] === expected[key], + ) + ) +} + +function validateContainer(container) { + if (!container || typeof container !== 'object') { + decline('invalid-input', 'Word reader expects a Packet A result') + } + + if (container.roles?.scalars?.ENCODE_BYTECODE?.value === true) { + decline( + 'unsupported-encoded-bytecode', + 'ENCODE_BYTECODE=true is outside the numeric-wordcode boundary', + ) + } + + if ( + container.schemaVersion !== CONTAINER_SCHEMA || + container.encoding !== 'numeric-u32' + ) { + decline('invalid-container', 'Input is not a Packet A numeric container') + } + + const words = container.roles?.words?.values + if (!Array.isArray(words)) { + decline('invalid-container', 'Packet A result has no numeric word stream') + } + if (words.length === 0) { + decline('empty-word-stream', 'BYTECODE must contain at least one word') + } + + if (!sameObjectValues(container.roles?.op?.values, CANONICAL_OPCODES)) { + decline('invalid-container', 'Packet A opcode map is not canonical') + } + if ( + !sameObjectValues(container.roles?.sentinels?.values, { + CALL_SPREAD: CANONICAL_SENTINEL, + }) + ) { + decline('invalid-container', 'Packet A sentinel map is not canonical') + } + if (container.roles?.scalars?.ENCODE_BYTECODE?.value !== false) { + decline('invalid-container', 'Packet A numeric mode is not explicit') + } + if (container.roles?.scalars?.MAIN_START_PC?.value !== 0) { + decline('invalid-container', 'Packet A main start PC is not zero') + } + + return words +} + +function isWord(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +function register(index) { + return { kind: 'register', index } +} + +function immediate(value) { + return { kind: 'immediate', value } +} + +function upvalueIndex(index) { + return { kind: 'upvalue-index', index } +} + +function labelTarget(value) { + return { kind: 'label-target', pc: value } +} + +function constantRef(index, key) { + return { kind: 'constant-ref', index, concealKey: key } +} + +function spreadSentinel(value) { + return { kind: 'spread-sentinel', value } +} + +function wordOperand(kind, value) { + if (kind === 'register') return register(value) + if (kind === 'label-target') return labelTarget(value) + if (kind === 'constant-index') { + return { kind, index: value } + } + if (kind === 'constant-key') { + return { kind, value } + } + if (kind === 'capture-kind' || kind === 'capture-index') { + return { kind, value } + } + if (kind === 'upvalue-index') { + return upvalueIndex(value) + } + if (kind === 'spread-sentinel') return spreadSentinel(value) + return immediate(value) +} + +function wordOperands(name, words) { + const operands = words.slice(1) + if (name === 'CALL' || name === 'NEW' || name === 'CALL_METHOD') { + const method = name === 'CALL_METHOD' + const argcOffset = method ? 3 : 2 + const firstArgumentOffset = method ? 4 : 3 + const argc = operands[argcOffset] + const argumentWords = operands.slice(firstArgumentOffset) + return [ + wordOperand('register', operands[0]), + ...(method ? [wordOperand('register', operands[1])] : []), + wordOperand('register', operands[method ? 2 : 1]), + wordOperand( + argc === CANONICAL_SENTINEL ? 'spread-sentinel' : 'immediate', + argc, + ), + ...argumentWords.map((value) => wordOperand('register', value)), + ] + } + if (name === 'MAKE_CLOSURE') { + const result = [ + wordOperand('register', operands[0]), + wordOperand('label-target', operands[1]), + wordOperand('immediate', operands[2]), + wordOperand('immediate', operands[3]), + wordOperand('immediate', operands[4]), + wordOperand('immediate', operands[5]), + ] + for (let index = 6; index < operands.length; index += 2) { + result.push( + wordOperand('capture-kind', operands[index]), + wordOperand('capture-index', operands[index + 1]), + ) + } + return result + } + if (name === 'BUILD_ARRAY') { + return [ + wordOperand('register', operands[0]), + wordOperand('immediate', operands[1]), + ...operands.slice(2).map((value) => wordOperand('register', value)), + ] + } + if (name === 'BUILD_OBJECT') { + const result = [ + wordOperand('register', operands[0]), + wordOperand('immediate', operands[1]), + ] + for (let index = 2; index < operands.length; index += 2) { + result.push( + wordOperand('register', operands[index]), + wordOperand('register', operands[index + 1]), + ) + } + return result + } + if (name === 'LOAD_CONST' || name === 'LOAD_GLOBAL') { + return [ + wordOperand('register', operands[0]), + wordOperand('constant-index', operands[1]), + wordOperand('constant-key', operands[2]), + ] + } + if (name === 'STORE_GLOBAL') { + return [ + wordOperand('constant-index', operands[0]), + wordOperand('constant-key', operands[1]), + wordOperand('register', operands[2]), + ] + } + if ( + name === 'JUMP' || + name === 'JUMP_IF_FALSE' || + name === 'JUMP_IF_TRUE' || + name === 'FOR_IN_NEXT' || + name === 'TRY_SETUP' + ) { + const labelOffset = name === 'JUMP' ? 0 : name === 'TRY_SETUP' ? 0 : 1 + return operands.map((value, index) => + wordOperand(index === labelOffset ? 'label-target' : 'register', value), + ) + } + if (name === 'FINALLY_SETUP') { + return [ + wordOperand('label-target', operands[0]), + wordOperand('register', operands[1]), + wordOperand('register', operands[2]), + wordOperand('label-target', operands[3]), + ] + } + if (name === 'LOAD_INT') { + return [wordOperand('register', operands[0]), immediate(operands[1])] + } + if (BINARY_REGISTER_OPCODES.has(name)) { + return operands.map((value) => wordOperand('register', value)) + } + if (UNARY_REGISTER_OPCODES.has(name)) { + return operands.map((value) => wordOperand('register', value)) + } + if (THREE_REGISTER_OPCODES.has(name)) { + return operands.map((value) => wordOperand('register', value)) + } + if ( + name === 'MOVE' || + name === 'LOAD_THIS' || + name === 'RETURN' || + name === 'THROW' || + name === 'JUMP_REG' || + name === 'FOR_IN_SETUP' + ) { + return operands.map((value) => wordOperand('register', value)) + } + if (name === 'LOAD_UPVALUE') { + return [wordOperand('register', operands[0]), upvalueIndex(operands[1])] + } + if (name === 'STORE_UPVALUE') { + return [upvalueIndex(operands[0]), wordOperand('register', operands[1])] + } + if (name === 'TYPEOF_SAFE') { + return [ + wordOperand('register', operands[0]), + wordOperand('constant-index', operands[1]), + wordOperand('constant-key', operands[2]), + ] + } + if (name === 'DEBUGGER' || name === 'TRY_END') return [] + return operands.map((value) => wordOperand('immediate', value)) +} + +function makeInstruction(pc, name, value, words, operands, extra = {}) { + return { + pc, + name, + opcode: { name, value }, + operands, + wordOperands: wordOperands(name, words), + words: words.slice(), + width: words.length, + nextPc: pc + words.length, + ...extra, + } +} + +function checkCount(count, remaining, name, field, pc) { + if (!Number.isSafeInteger(count) || count < 0) { + decline('invalid-count', `${name} at ${pc} has an invalid ${field}`) + } + if (count > remaining) { + decline( + 'truncated-instruction', + `${name} at ${pc} is truncated in its ${field} payload`, + ) + } +} + +function checkPairCount(count, remaining, name, field, pc) { + if (!Number.isSafeInteger(count) || count < 0) { + decline('invalid-count', `${name} at ${pc} has an invalid ${field}`) + } + if (count > Math.floor(remaining / 2)) { + decline( + 'truncated-instruction', + `${name} at ${pc} is truncated in its ${field} pairs`, + ) + } +} + +function makeCursor(words) { + let pc = 0 + let current = [] + return { + get pc() { + return pc + }, + get remaining() { + return words.length - pc + }, + begin() { + current = [] + }, + read(name, reason = 'operand') { + if (pc >= words.length) { + decline( + 'truncated-instruction', + `${name} at ${pc - current.length} is missing a ${reason}`, + ) + } + const value = words[pc] + if (!isWord(value)) { + decline( + 'invalid-word', + `Word at ${pc} is not an unsigned 32-bit integer`, + ) + } + pc += 1 + current.push(value) + return value + }, + takeCurrent() { + return current.slice() + }, + } +} + +function fixedInstruction(cursor, pc, name, value) { + const width = FIXED_WORD_WIDTHS[name] + if (!width) decline('unknown-opcode', `No width is defined for ${name}`) + const raw = [cursor.read(name, 'opcode')] + for (let i = 1; i < width; i += 1) raw.push(cursor.read(name)) + const operands = [] + const registerOperands = (values) => values.map((item) => register(item)) + + if (name === 'LOAD_CONST' || name === 'LOAD_GLOBAL') { + operands.push(register(raw[1]), constantRef(raw[2], raw[3])) + } else if (name === 'STORE_GLOBAL') { + operands.push(constantRef(raw[1], raw[2]), register(raw[3])) + } else if (name === 'TYPEOF_SAFE') { + operands.push(register(raw[1]), constantRef(raw[2], raw[3])) + } else if (name === 'LOAD_INT') { + operands.push(register(raw[1]), immediate(raw[2])) + } else if (name === 'LOAD_UPVALUE') { + operands.push(register(raw[1]), upvalueIndex(raw[2])) + } else if (name === 'STORE_UPVALUE') { + operands.push(upvalueIndex(raw[1]), register(raw[2])) + } else if (name === 'JUMP') { + operands.push(labelTarget(raw[1])) + } else if (name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE') { + operands.push(register(raw[1]), labelTarget(raw[2])) + } else if (name === 'FOR_IN_NEXT') { + operands.push(register(raw[1]), register(raw[2]), labelTarget(raw[3])) + } else if (name === 'TRY_SETUP') { + operands.push(labelTarget(raw[1]), register(raw[2])) + } else if (name === 'FINALLY_SETUP') { + operands.push( + labelTarget(raw[1]), + register(raw[2]), + register(raw[3]), + labelTarget(raw[4]), + ) + } else if (BINARY_REGISTER_OPCODES.has(name)) { + operands.push(...registerOperands(raw.slice(1))) + } else if (UNARY_REGISTER_OPCODES.has(name)) { + operands.push(...registerOperands(raw.slice(1))) + } else if (THREE_REGISTER_OPCODES.has(name)) { + operands.push(...registerOperands(raw.slice(1))) + } else if ( + name === 'MOVE' || + name === 'LOAD_THIS' || + name === 'RETURN' || + name === 'THROW' || + name === 'JUMP_REG' || + name === 'FOR_IN_SETUP' + ) { + operands.push(...registerOperands(raw.slice(1))) + } + + return makeInstruction(pc, name, value, raw, operands) +} + +function readCall(cursor, pc, name, value) { + const raw = [cursor.read(name, 'opcode')] + const destination = cursor.read(name) + const receiverOrCallee = cursor.read(name) + const callee = name === 'CALL_METHOD' ? cursor.read(name) : receiverOrCallee + const argc = cursor.read(name, 'argc') + const fixedStart = name === 'CALL_METHOD' ? 5 : 4 + const spread = argc === CANONICAL_SENTINEL + let argumentRegisters + if (spread) { + argumentRegisters = [cursor.read(name, 'spread array register')] + } else { + checkCount(argc, cursor.remaining, name, 'argc', pc) + argumentRegisters = [] + for (let index = 0; index < argc; index += 1) { + argumentRegisters.push(cursor.read(name, `argument register ${index}`)) + } + } + raw.push(...cursor.takeCurrent().slice(raw.length)) + + const argumentValue = spread + ? { + kind: 'spread', + sentinel: CANONICAL_SENTINEL, + arrayRegister: register(argumentRegisters[0]), + } + : { + kind: 'fixed', + count: argc, + registers: argumentRegisters.map((item) => register(item)), + } + const operands = [register(destination)] + if (name === 'CALL_METHOD') operands.push(register(receiverOrCallee)) + operands.push(register(callee), spreadSentinel(argc)) + if (!spread) operands[operands.length - 1] = immediate(argc) + operands.push(...(argumentValue.registers ?? [argumentValue.arrayRegister])) + return makeInstruction(pc, name, value, raw, operands, { + form: { + kind: spread ? 'spread' : 'fixed', + argc, + sentinel: spread ? CANONICAL_SENTINEL : null, + arrayRegister: spread ? argumentValue.arrayRegister : null, + }, + arguments: argumentValue, + callee: register(callee), + destination: register(destination), + receiver: name === 'CALL_METHOD' ? register(receiverOrCallee) : null, + serializedArgumentOffset: fixedStart, + }) +} + +function readClosure(cursor, pc, name, value) { + const raw = [cursor.read(name, 'opcode')] + const destination = cursor.read(name) + const startPc = cursor.read(name, 'function entry PC') + const paramCount = cursor.read(name) + const regCount = cursor.read(name) + const captureCount = cursor.read(name, 'capture count') + const hasRest = cursor.read(name, 'hasRest flag') + if (hasRest !== 0 && hasRest !== 1) { + decline( + 'invalid-descriptor', + `${name} at ${pc} has an invalid hasRest flag`, + ) + } + checkPairCount(captureCount, cursor.remaining, name, 'capture count', pc) + const captures = [] + for (let index = 0; index < captureCount; index += 1) { + const isLocal = cursor.read(name, `capture ${index} kind`) + const captureIndex = cursor.read(name, `capture ${index} index`) + if (isLocal !== 0 && isLocal !== 1) { + decline( + 'invalid-capture-pair', + `${name} at ${pc} has an invalid capture kind at pair ${index}`, + ) + } + captures.push({ + kind: isLocal === 1 ? 'local' : 'upvalue', + index: captureIndex, + isLocal: isLocal === 1, + }) + } + raw.push(...cursor.takeCurrent().slice(raw.length)) + const functionMeta = { + startPc, + paramCount, + regCount, + captureCount, + hasRest: hasRest === 1, + } + const operands = [ + register(destination), + labelTarget(startPc), + immediate(paramCount), + immediate(regCount), + immediate(captureCount), + immediate(hasRest), + ...captures, + ] + return makeInstruction(pc, name, value, raw, operands, { + form: { kind: 'closure', ...functionMeta }, + functionMeta, + captures, + capturePairs: captures, + destination: register(destination), + }) +} + +function readArray(cursor, pc, name, value) { + const raw = [cursor.read(name, 'opcode')] + const destination = cursor.read(name) + const count = cursor.read(name, 'element count') + checkCount(count, cursor.remaining, name, 'element count', pc) + const elements = [] + for (let index = 0; index < count; index += 1) { + elements.push(cursor.read(name, `element register ${index}`)) + } + raw.push(...cursor.takeCurrent().slice(raw.length)) + const elementRegisters = elements.map((item) => register(item)) + return makeInstruction( + pc, + name, + value, + raw, + [register(destination), immediate(count), ...elementRegisters], + { + form: { kind: 'array', count, elementRegisters }, + count, + destination: register(destination), + elementRegisters, + }, + ) +} + +function readObject(cursor, pc, name, value) { + const raw = [cursor.read(name, 'opcode')] + const destination = cursor.read(name) + const pairCount = cursor.read(name, 'pair count') + checkPairCount(pairCount, cursor.remaining, name, 'pair count', pc) + const pairs = [] + for (let index = 0; index < pairCount; index += 1) { + pairs.push({ + key: register(cursor.read(name, `key register ${index}`)), + value: register(cursor.read(name, `value register ${index}`)), + }) + } + raw.push(...cursor.takeCurrent().slice(raw.length)) + return makeInstruction( + pc, + name, + value, + raw, + [ + register(destination), + immediate(pairCount), + ...pairs.flatMap((pair) => [pair.key, pair.value]), + ], + { + form: { kind: 'object', pairCount, pairs }, + pairCount, + destination: register(destination), + pairs, + }, + ) +} + +function parseWords(words) { + const cursor = makeCursor(words) + const instructions = [] + while (cursor.remaining > 0) { + const pc = cursor.pc + cursor.begin() + const opcode = words[pc] + if (!isWord(opcode)) { + decline('invalid-word', `Word at ${pc} is not an unsigned 32-bit integer`) + } + const name = NAME_BY_OPCODE.get(opcode) + if (!name) decline('unknown-opcode', `Unknown opcode ${opcode} at pc ${pc}`) + if (name === 'PATCH') { + decline( + 'unsupported-hardening-opcode', + `PATCH at pc ${pc} is hardening-only and outside Phase 1`, + ) + } + let instruction + if (name === 'CALL' || name === 'CALL_METHOD' || name === 'NEW') { + instruction = readCall(cursor, pc, name, opcode) + } else if (name === 'MAKE_CLOSURE') { + instruction = readClosure(cursor, pc, name, opcode) + } else if (name === 'BUILD_ARRAY') { + instruction = readArray(cursor, pc, name, opcode) + } else if (name === 'BUILD_OBJECT') { + instruction = readObject(cursor, pc, name, opcode) + } else { + instruction = fixedInstruction(cursor, pc, name, opcode) + } + if (instruction.nextPc !== cursor.pc) { + decline( + 'reader-internal-error', + `${name} at ${pc} did not consume its declared width`, + ) + } + instructions.push(instruction) + } + if (cursor.pc !== words.length) { + decline('trailing-word', 'Word stream was not consumed completely') + } + return instructions +} + +function recognize(container) { + const words = validateContainer(container) + const instructions = parseWords(words) + return deepFreeze({ + schemaVersion: WORDCODE_SCHEMA, + encoding: 'numeric-u32', + words: words.slice(), + wordCount: words.length, + instructions, + instructionCount: instructions.length, + consumedWords: words.length, + nextPc: words.length, + }) +} + +export function diagnoseWordcode(container) { + try { + const result = recognize(container) + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof WordcodeDecline) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + } + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'word-reader-error', + message: error.message, + }, + }) + } +} + +export function readWordcode(container) { + return diagnoseWordcode(container).result +} + +export default readWordcode diff --git a/src/vm/jsconfuser-vm/scalar-values-preflight.js b/src/vm/jsconfuser-vm/scalar-values-preflight.js new file mode 100644 index 00000000..f903160d --- /dev/null +++ b/src/vm/jsconfuser-vm/scalar-values-preflight.js @@ -0,0 +1,590 @@ +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const SCALAR_VALUES_SCHEMA = 'jsconfuser-vm-scalar-values.v1' +const UINT32_MAX = 0xffffffff +const CALL_SPREAD = 65535 + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const I_OPCODE_DEFINITIONS = Object.freeze( + [ + { + name: 'LOAD_CONST', + family: 'load', + operandForm: 'dst, constant(value)', + operandKinds: ['register', 'constant-ref'], + operation: 'load-constant', + effect: 'register-write', + resultType: 'constant', + known: true, + }, + { + name: 'LOAD_INT', + family: 'load', + operandForm: 'dst, number or label', + operandKinds: ['register', 'immediate'], + operation: 'load-integer', + effect: 'register-write', + resultType: 'integer', + known: true, + }, + { + name: 'LOAD_GLOBAL', + family: 'load', + operandForm: 'dst, constant(name)', + operandKinds: ['register', 'constant-ref'], + operation: 'load-global', + effect: 'global-read', + resultType: 'dynamic', + known: false, + }, + { + name: 'MOVE', + family: 'move', + operandForm: 'dst, src', + operandKinds: ['register', 'register'], + operation: 'move', + effect: 'register-write', + resultType: 'copy', + known: false, + }, + { + name: 'STORE_GLOBAL', + family: 'global-mutation', + operandForm: 'constant(name), src', + operandKinds: ['constant-ref', 'register'], + operation: 'store-global', + effect: 'global-write', + resultType: 'input', + known: false, + }, + { + name: 'ADD', + family: 'arithmetic', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'add', + effect: 'register-write', + resultType: 'dynamic', + known: false, + }, + { + name: 'SUB', + family: 'arithmetic', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'subtract', + effect: 'register-write', + resultType: 'dynamic', + known: false, + }, + { + name: 'MUL', + family: 'arithmetic', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'multiply', + effect: 'register-write', + resultType: 'dynamic', + known: false, + }, + { + name: 'DIV', + family: 'arithmetic', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'divide', + effect: 'register-write', + resultType: 'dynamic', + known: false, + }, + { + name: 'MOD', + family: 'arithmetic', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'modulo', + effect: 'register-write', + resultType: 'dynamic', + known: false, + }, + { + name: 'EXP', + family: 'arithmetic', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'exponentiate', + effect: 'register-write', + resultType: 'dynamic', + known: false, + }, + { + name: 'BAND', + family: 'bitwise', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'bitwise-and', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'BOR', + family: 'bitwise', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'bitwise-or', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'BXOR', + family: 'bitwise', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'bitwise-xor', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'SHL', + family: 'bitwise', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'shift-left', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'SHR', + family: 'bitwise', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'shift-right', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'USHR', + family: 'bitwise', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'unsigned-shift-right', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'LT', + family: 'relational', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'less-than', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'GT', + family: 'relational', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'greater-than', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'LTE', + family: 'relational', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'less-than-or-equal', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'GTE', + family: 'relational', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'greater-than-or-equal', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'EQ', + family: 'equality', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'strict-equal', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'NEQ', + family: 'equality', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'strict-not-equal', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'LOOSE_EQ', + family: 'equality', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'loose-equal', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'LOOSE_NEQ', + family: 'equality', + operandForm: 'dst, src1, src2', + operandKinds: ['register', 'register', 'register'], + operation: 'loose-not-equal', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'UNARY_NEG', + family: 'unary', + operandForm: 'dst, src', + operandKinds: ['register', 'register'], + operation: 'unary-negate', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'UNARY_POS', + family: 'unary', + operandForm: 'dst, src', + operandKinds: ['register', 'register'], + operation: 'unary-positive', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'UNARY_NOT', + family: 'unary', + operandForm: 'dst, src', + operandKinds: ['register', 'register'], + operation: 'logical-not', + effect: 'register-write', + resultType: 'boolean', + known: false, + }, + { + name: 'UNARY_BITNOT', + family: 'unary', + operandForm: 'dst, src', + operandKinds: ['register', 'register'], + operation: 'bitwise-not', + effect: 'register-write', + resultType: 'number', + known: false, + }, + { + name: 'TYPEOF', + family: 'conversion', + operandForm: 'dst, src', + operandKinds: ['register', 'register'], + operation: 'typeof', + effect: 'register-write', + resultType: 'string', + known: false, + }, + { + name: 'VOID', + family: 'conversion', + operandForm: 'dst, src', + operandKinds: ['register', 'register'], + operation: 'void', + effect: 'register-write', + resultType: 'undefined', + known: true, + }, + { + name: 'TYPEOF_SAFE', + family: 'conversion', + operandForm: 'dst, constant(globalName)', + operandKinds: ['register', 'constant-ref'], + operation: 'typeof-safe-global', + effect: 'global-read', + resultType: 'string', + known: false, + }, + ].map((definition) => + Object.freeze({ + ...definition, + opcode: CANONICAL_OPCODES[definition.name], + }), + ), +) + +export const I_DEFINITION_BY_NAME = new Map( + I_OPCODE_DEFINITIONS.map((definition) => [definition.name, definition]), +) + +export const EXCLUDED_OPCODE_GROUPS = Object.freeze( + [ + { + boundary: 'G-closure-state', + names: ['LOAD_UPVALUE', 'STORE_UPVALUE'], + }, + { boundary: 'receiver/call-emission', names: ['LOAD_THIS'] }, + { + boundary: 'J-property-prototype-collection', + names: [ + 'GET_PROP', + 'SET_PROP', + 'DELETE_PROP', + 'IN', + 'INSTANCEOF', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'DEFINE_GETTER', + 'DEFINE_SETTER', + 'FOR_IN_SETUP', + 'FOR_IN_NEXT', + ], + }, + { + boundary: 'K-control-flow', + names: ['JUMP', 'JUMP_IF_FALSE', 'JUMP_IF_TRUE', 'JUMP_REG'], + }, + { + boundary: 'L-calls-and-completion', + names: ['CALL', 'CALL_METHOD', 'NEW', 'RETURN', 'THROW'], + }, + { boundary: 'M-closures', names: ['MAKE_CLOSURE'] }, + { + boundary: 'H-handler-finally', + names: ['TRY_SETUP', 'TRY_END', 'FINALLY_SETUP'], + }, + { boundary: 'hardening-only', names: ['PATCH'] }, + { boundary: 'debug-statement', names: ['DEBUGGER'] }, + ].map((group) => + Object.freeze({ ...group, names: Object.freeze(group.names) }), + ), +) + +export const EXCLUDED_OPCODE_NAMES = new Set( + EXCLUDED_OPCODE_GROUPS.flatMap(({ names }) => names), +) + +export class ScalarValuesDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'ScalarValuesDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new ScalarValuesDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function isUint32(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +export function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +export function sameArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +export function snapshot(value, label) { + try { + return JSON.stringify(value) + } catch (error) { + decline( + 'malformed-predecessor', + `${label} cannot be compared as immutable JSON data: ${error.message}`, + ) + } +} + +export function canonicalContainer(wordcode, references) { + const frame = references.frame + const scalars = { + MAIN_START_PC: { value: frame.mainStartPc }, + MAIN_REG_COUNT: { value: frame.mainRegCount }, + ENCODE_BYTECODE: { value: false }, + TIMING_CHECKS: { value: false }, + HEADER_SIZE: { value: frame.headerSize }, + FRAME_START: { value: frame.frameStart }, + } + return { + schemaVersion: 'jsconfuser-vm-container.v1', + encoding: 'numeric-u32', + roles: { + pool: { values: references.constants.values }, + words: { values: wordcode.words }, + op: { values: CANONICAL_OPCODES }, + sentinels: { values: { CALL_SPREAD } }, + slots: { values: CANONICAL_SLOTS }, + scalars, + }, + } +} + +export function requirePreflightShape(wordcode, references, functions, cfg) { + requireObject( + wordcode, + 'invalid-wordcode', + 'Expected a Packet B wordcode result', + ) + requireObject( + references, + 'invalid-references', + 'Expected a Packet C reference/frame result', + ) + requireObject( + functions, + 'invalid-functions', + 'Expected a Packet D function partition result', + ) + requireObject(cfg, 'invalid-cfg', 'Expected a Packet E CFG result') + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' || + !Array.isArray(wordcode.words) || + wordcode.words.length === 0 || + !wordcode.words.every(isUint32) + ) { + decline('invalid-wordcode', 'Packet B numeric wordcode metadata is invalid') + } + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' || + !isObject(references.constants) || + !Array.isArray(references.constants.values) || + !isObject(references.frame) + ) { + decline( + 'invalid-references', + 'Packet C constants or frame metadata is invalid', + ) + } + if ( + functions.schemaVersion !== FUNCTION_SCHEMA || + functions.encoding !== 'numeric-u32' + ) { + decline('invalid-functions', 'Packet D function metadata is invalid') + } + if (cfg.schemaVersion !== CFG_SCHEMA || cfg.encoding !== 'numeric-u32') { + decline('invalid-cfg', 'Packet E CFG metadata is invalid') + } +} diff --git a/src/vm/jsconfuser-vm/standalone-contract.js b/src/vm/jsconfuser-vm/standalone-contract.js new file mode 100644 index 00000000..68192556 --- /dev/null +++ b/src/vm/jsconfuser-vm/standalone-contract.js @@ -0,0 +1,24 @@ +export class StandaloneDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'StandaloneDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new StandaloneDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) + return value + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function requireValue(value, code, message) { + if (!value) decline(code, message) + return value +} diff --git a/src/vm/jsconfuser-vm/structured-control-cfg.js b/src/vm/jsconfuser-vm/structured-control-cfg.js new file mode 100644 index 00000000..22de4b63 --- /dev/null +++ b/src/vm/jsconfuser-vm/structured-control-cfg.js @@ -0,0 +1,406 @@ +import { + CALLS, + CFG_SCHEMA, + WORDCODE_SCHEMA, + decline, + edgeKey, + expectedCallSites, + expectedRecords, + ownerMapFromFunctions, + requireArray, + requireObject, + requireSame, + sameValue, + validateEdgeShape, + validateFrame, + validateFunctionBlocks, + validateIndirectTargets, +} from './structured-control-input.js' + +export function reachablePcs(fn, edges) { + const adjacency = new Map() + for (const edge of edges) { + if (edge.targetPc === null) continue + const targets = adjacency.get(edge.sourcePc) ?? [] + targets.push(edge.targetPc) + adjacency.set(edge.sourcePc, targets) + } + const visited = new Set([fn.startPc]) + const queue = [fn.startPc] + while (queue.length) { + const pc = queue.shift() + for (const target of adjacency.get(pc) ?? []) { + if (!visited.has(target)) { + visited.add(target) + queue.push(target) + } + } + } + return visited +} + +function validateOutgoingKinds(fn, edges, reachable, wordcodeData) { + const bySource = new Map() + for (const edge of edges) { + const list = bySource.get(edge.sourcePc) ?? [] + list.push(edge) + bySource.set(edge.sourcePc, list) + } + for (const pc of reachable) { + const instruction = wordcodeData.byPc.get(pc) + const outgoing = bySource.get(pc) ?? [] + const names = outgoing.map(({ kind }) => kind) + const next = instruction.nextPc < fn.endPc ? instruction.nextPc : null + let expected + if (instruction.name === 'JUMP') expected = ['branch', 'finally'] + else if (['JUMP_IF_FALSE', 'JUMP_IF_TRUE'].includes(instruction.name)) + expected = ['conditional', 'fallthrough'] + else if (instruction.name === 'FOR_IN_NEXT') + expected = ['branch', 'fallthrough'] + else if (instruction.name === 'JUMP_REG') + expected = Array.from({ length: outgoing.length }, () => 'branch') + else if (CALLS.has(instruction.name)) + expected = ['call', 'throw', 'handler', 'finally'] + else if (instruction.name === 'RETURN') expected = ['return', 'finally'] + else if (instruction.name === 'THROW') + expected = ['throw', 'handler', 'finally'] + else expected = next === null ? [] : ['fallthrough'] + const possible = new Set(expected) + if (!outgoing.every(({ kind }) => possible.has(kind))) { + decline('invalid-cfg-edge', `CFG has an unexpected successor at ${pc}`) + } + if (instruction.name === 'JUMP_REG') { + const targets = outgoing + .map(({ targetPc }) => targetPc) + .sort((left, right) => left - right) + const entry = fn.indirectTargets.find(({ pc: entryPc }) => entryPc === pc) + requireSame( + targets, + entry.targets, + 'stale-predecessor', + `JUMP_REG@${pc} successors are stale`, + ) + } else if (instruction.name === 'JUMP') { + if (outgoing.length !== 1) + decline('invalid-cfg-edge', `JUMP@${pc} does not have one successor`) + } else if ( + ['JUMP_IF_FALSE', 'JUMP_IF_TRUE', 'FOR_IN_NEXT'].includes( + instruction.name, + ) + ) { + if (outgoing.length !== 2 || !names.includes('fallthrough')) + decline( + 'invalid-cfg-edge', + `${instruction.name}@${pc} does not have two successors`, + ) + if ( + !outgoing.some( + (edge) => edge.targetPc === next && edge.kind === 'fallthrough', + ) + ) + decline( + 'invalid-cfg-edge', + `${instruction.name}@${pc} has a stale fallthrough`, + ) + } else if (CALLS.has(instruction.name)) { + if ( + outgoing.length !== 2 || + !outgoing.some(({ kind }) => kind === 'call') + ) + decline( + 'invalid-cfg-edge', + `${instruction.name}@${pc} has incomplete completion edges`, + ) + } else if (instruction.name === 'RETURN' || instruction.name === 'THROW') { + if (outgoing.length !== 1) + decline( + 'invalid-cfg-edge', + `${instruction.name}@${pc} has incomplete completion edges`, + ) + } else if (outgoing.length !== expected.length) { + decline( + 'invalid-cfg-edge', + `Instruction ${instruction.name}@${pc} has incomplete successors`, + ) + } + } +} + +function stronglyConnectedRegions(fn, edges) { + const nodes = [...fn.instructionPcs] + const nodeSet = new Set(nodes) + const adjacency = new Map(nodes.map((pc) => [pc, new Set()])) + for (const edge of edges) { + if (edge.targetPc !== null && nodeSet.has(edge.targetPc)) { + adjacency.get(edge.sourcePc)?.add(edge.targetPc) + } + } + let nextIndex = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + const visit = (node) => { + indexes.set(node, nextIndex) + lowLinks.set(node, nextIndex) + nextIndex += 1 + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node) ?? []) { + if (!indexes.has(target)) { + visit(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) { + lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + } + if (lowLinks.get(node) === indexes.get(node)) { + const component = [] + let value + do { + value = stack.pop() + onStack.delete(value) + component.push(value) + } while (value !== node) + components.push(component.sort((left, right) => left - right)) + } + } + for (const node of nodes) if (!indexes.has(node)) visit(node) + return components + .filter((component) => { + return ( + component.length > 1 || + edges.some( + (edge) => + edge.sourcePc === component[0] && edge.targetPc === component[0], + ) + ) + }) + .map((component) => { + const set = new Set(component) + const entries = new Set() + for (const edge of edges) { + if ( + edge.targetPc !== null && + set.has(edge.targetPc) && + !set.has(edge.sourcePc) + ) + entries.add(edge.targetPc) + } + return { + startPcs: [...entries].sort((left, right) => left - right), + instructionPcs: component, + reason: 'multiple-entry-cycle', + } + }) + .filter(({ startPcs }) => startPcs.length > 1) +} + +function validateIrreducible(fn, edges) { + requireObject( + fn.irreducible, + 'invalid-irreducible-metadata', + `CFG function ${fn.id} has no irreducible metadata`, + ) + const regions = stronglyConnectedRegions(fn, edges) + requireSame( + fn.irreducible, + { + required: regions.length > 0, + fallback: regions.length > 0 ? 'state-machine' : null, + regions, + }, + 'stale-predecessor', + `CFG function ${fn.id} irreducible metadata is stale`, + ) + return regions +} + +export function validateCfg(cfg, wordcodeData) { + requireObject(cfg, 'invalid-input', 'Expected a Packet E CFG result') + if (cfg.schemaVersion !== CFG_SCHEMA || cfg.encoding !== 'numeric-u32') { + decline('invalid-cfg', 'Input is not a Packet E numeric CFG result') + } + if ( + cfg.wordCount !== wordcodeData.words.length || + cfg.instructionCount !== wordcodeData.instructions.length || + !Number.isInteger(cfg.functionCount) || + !Array.isArray(cfg.functions) || + cfg.functionCount !== cfg.functions.length || + cfg.functions.length === 0 + ) { + decline('input-mismatch', 'Packet E counts do not match Packet B') + } + const ownerByPc = ownerMapFromFunctions(cfg.functions, wordcodeData) + validateFrame(cfg.frame, cfg.functions) + requireArray( + cfg.ownership, + 'invalid-function-boundary', + 'CFG ownership is missing', + ) + if (cfg.ownership.length !== wordcodeData.instructions.length) { + decline('invalid-function-boundary', 'CFG ownership is incomplete') + } + for (let index = 0; index < cfg.ownership.length; index += 1) { + requireSame( + cfg.ownership[index], + { + pc: wordcodeData.instructions[index].pc, + functionId: ownerByPc.get(wordcodeData.instructions[index].pc), + }, + 'stale-predecessor', + `CFG ownership is stale at ${wordcodeData.instructions[index].pc}`, + ) + } + requireObject(cfg.source, 'invalid-cfg', 'CFG source metadata is missing') + if ( + cfg.source.wordcodeSchema !== WORDCODE_SCHEMA || + !Array.isArray(cfg.source.functionEntries) || + !sameValue( + cfg.source.functionEntries, + cfg.functions.map(({ id, startPc, endPc }) => ({ id, startPc, endPc })), + ) + ) { + decline('stale-predecessor', 'CFG source metadata is stale') + } + + const edgesByFunction = new Map() + const allEdges = [] + const functionBlocks = new Map() + const indirectByFunction = new Map() + const irreducibleByFunction = new Map() + for (const fn of cfg.functions) { + const records = expectedRecords(fn, wordcodeData) + requireSame( + fn.handlerRecords, + records.handlers, + 'stale-predecessor', + `CFG function ${fn.id} handler records are stale`, + ) + requireSame( + fn.finallyRecords, + records.finallys, + 'stale-predecessor', + `CFG function ${fn.id} finally records are stale`, + ) + const edges = requireArray( + fn.edges, + 'invalid-cfg-edge', + `CFG function ${fn.id} has no edges`, + ) + const indirectByPc = validateIndirectTargets(fn, wordcodeData) + indirectByFunction.set(fn.id, indirectByPc) + const seen = new Set() + for (const edge of edges) { + validateEdgeShape(edge, fn, ownerByPc, wordcodeData, indirectByPc) + const key = edgeKey(edge) + if (seen.has(key)) + decline('invalid-cfg-edge', `CFG function ${fn.id} duplicates an edge`) + seen.add(key) + } + edgesByFunction.set(fn.id, edges) + allEdges.push(...edges) + const blockById = new Map() + validateFunctionBlocks(fn, fn.id, blockById, edgesByFunction, wordcodeData) + functionBlocks.set(fn.id, blockById) + const reachable = reachablePcs(fn, edges) + validateOutgoingKinds(fn, edges, reachable, wordcodeData) + irreducibleByFunction.set(fn.id, validateIrreducible(fn, edges)) + requireSame( + fn.callSites, + expectedCallSites(fn, edges, wordcodeData), + 'stale-predecessor', + `CFG function ${fn.id} call sites are stale`, + ) + } + requireSame( + cfg.edges, + allEdges, + 'stale-predecessor', + 'Packet E global edges are stale', + ) + requireArray( + cfg.indirectTargets, + 'invalid-cfg-edge', + 'CFG global indirect targets are missing', + ) + requireSame( + cfg.indirectTargets, + cfg.functions.flatMap((fn) => + fn.indirectTargets.map((entry) => ({ functionId: fn.id, ...entry })), + ), + 'stale-predecessor', + 'CFG global indirect targets are stale', + ) + requireSame( + cfg.callSites, + cfg.functions.flatMap((fn) => fn.callSites), + 'stale-predecessor', + 'CFG global call sites are stale', + ) + const expectedIrreducible = cfg.functions + .filter(({ irreducible }) => irreducible.required) + .map(({ id }) => id) + requireSame( + cfg.irreducibleFunctions, + expectedIrreducible, + 'stale-predecessor', + 'CFG irreducible function list is stale', + ) + return { + cfg, + functions: cfg.functions, + ownerByPc, + edgesByFunction, + allEdges, + functionBlocks, + indirectByFunction, + irreducibleByFunction, + } +} + +export function validateRecord(record, type, fn, wordcodeData, ownerByPc) { + requireObject(record, 'invalid-handler-state', 'Packet H record is malformed') + if ( + record.type !== type || + record.ownerFunction !== fn.id || + !Number.isInteger(record.setupPc) || + ownerByPc.get(record.setupPc) !== fn.id + ) { + decline('invalid-handler-state', `Packet H ${type} record is malformed`) + } + const setup = wordcodeData.byPc.get(record.setupPc) + if (setup?.name !== (type === 'handler' ? 'TRY_SETUP' : 'FINALLY_SETUP')) { + decline( + 'stale-predecessor', + `Packet H ${type} record does not match Packet B`, + ) + } + if (type === 'handler') { + if ( + record.id !== `try@${record.setupPc}` || + record.handlerPc !== setup.operands[0].pc || + record.exceptionReg !== setup.words[2] || + ownerByPc.get(record.handlerPc) !== fn.id + ) { + decline( + 'stale-predecessor', + `Packet H handler record at ${record.setupPc} is stale`, + ) + } + } else if ( + record.id !== `finally@${record.setupPc}` || + record.finallyPc !== setup.operands[0].pc || + record.continuationReg !== setup.words[2] || + record.payloadReg !== setup.words[3] || + record.throwPadPc !== setup.operands[3].pc || + ownerByPc.get(record.finallyPc) !== fn.id || + ownerByPc.get(record.throwPadPc) !== fn.id + ) { + decline( + 'stale-predecessor', + `Packet H finally record at ${record.setupPc} is stale`, + ) + } +} diff --git a/src/vm/jsconfuser-vm/structured-control-exceptions.js b/src/vm/jsconfuser-vm/structured-control-exceptions.js new file mode 100644 index 00000000..473ec78b --- /dev/null +++ b/src/vm/jsconfuser-vm/structured-control-exceptions.js @@ -0,0 +1,500 @@ +import { + CFG_SCHEMA, + COMPLETIONS, + EXCEPTION_FINALLY_SCHEMA, + WORDCODE_SCHEMA, + arrayEqual, + decline, + edgeProjection, + requireArray, + requireObject, + requireSame, + sameValue, + stateForPc, + validateStateShape, +} from './structured-control-input.js' +import { reachablePcs, validateRecord } from './structured-control-cfg.js' + +export function validateExceptionFinally( + exceptionFinally, + wordcodeData, + cfgData, +) { + requireObject( + exceptionFinally, + 'invalid-input', + 'Expected a Packet H exception/finally result', + ) + if ( + exceptionFinally.schemaVersion !== EXCEPTION_FINALLY_SCHEMA || + exceptionFinally.encoding !== 'numeric-u32' + ) { + decline('invalid-exception-finally', 'Input is not a Packet H result') + } + if ( + exceptionFinally.wordCount !== wordcodeData.words.length || + exceptionFinally.instructionCount !== wordcodeData.instructions.length || + exceptionFinally.functionCount !== cfgData.functions.length || + !Array.isArray(exceptionFinally.functions) || + exceptionFinally.functions.length !== cfgData.functions.length + ) { + decline('input-mismatch', 'Packet H counts do not match Packet B/E') + } + requireObject( + exceptionFinally.source, + 'invalid-exception-finally', + 'Packet H source metadata is missing', + ) + requireSame( + exceptionFinally.source, + { + wordcodeSchema: WORDCODE_SCHEMA, + referencesSchema: 'jsconfuser-vm-references.v1', + functionsSchema: 'jsconfuser-vm-functions.v1', + cfgSchema: CFG_SCHEMA, + callFramesSchema: 'jsconfuser-vm-call-frames.v1', + }, + 'stale-predecessor', + 'Packet H source metadata is stale', + ) + const ownerByPc = cfgData.ownerByPc + const hFunctions = exceptionFinally.functions + const recordsByFunction = new Map() + const statesByFunction = new Map() + const transitionsByFunction = new Map() + const mergesByFunction = new Map() + const jumpsByFunction = new Map() + const hEdgesByFunction = new Map() + for (const efn of cfgData.functions) { + const hfn = hFunctions[efn.id] + requireObject( + hfn, + 'invalid-exception-finally', + `Packet H function ${efn.id} is malformed`, + ) + if ( + hfn.id !== efn.id || + hfn.startPc !== efn.startPc || + hfn.endPc !== efn.endPc + ) { + decline( + 'stale-predecessor', + `Packet H function ${efn.id} does not match Packet E`, + ) + } + const records = [ + ...requireArray( + hfn.handlerRecords, + 'invalid-handler-state', + `Packet H function ${efn.id} handlers are missing`, + ), + ...requireArray( + hfn.finallyRecords, + 'invalid-handler-state', + `Packet H function ${efn.id} finalizers are missing`, + ), + ] + for (const record of records) + validateRecord(record, record.type, efn, wordcodeData, ownerByPc) + if (new Set(records.map(({ id }) => id)).size !== records.length) + decline( + 'invalid-handler-state', + `Packet H function ${efn.id} duplicates a record`, + ) + requireSame( + records, + [...efn.handlerRecords, ...efn.finallyRecords], + 'stale-predecessor', + `Packet H function ${efn.id} records do not match Packet E`, + ) + recordsByFunction.set(efn.id, records) + + const stateRecordsForFunction = records + const states = requireArray( + hfn.instructionStates, + 'invalid-handler-state', + `Packet H function ${efn.id} instruction states are missing`, + ) + if (states.length !== efn.instructionPcs.length) + decline( + 'invalid-handler-state', + `Packet H function ${efn.id} instruction states are incomplete`, + ) + const stateByPc = new Map() + const eEdges = cfgData.edgesByFunction.get(efn.id) + const reachable = reachablePcs(efn, eEdges) + for (const state of states) { + requireObject( + state, + 'invalid-handler-state', + 'Packet H instruction state is malformed', + ) + if ( + !efn.instructionPcs.includes(state.pc) || + stateByPc.has(state.pc) || + state.reachable !== reachable.has(state.pc) + ) { + decline( + 'stale-predecessor', + `Packet H instruction state at ${state.pc} is stale`, + ) + } + if (state.reachable) { + validateStateShape( + state.handlerStateBefore, + stateRecordsForFunction, + `Packet H instruction ${state.pc} before`, + ) + } else if ( + state.handlerStateBefore !== null || + state.handlerStateAfter !== null + ) { + decline( + 'invalid-handler-state', + `Unreachable Packet H instruction ${state.pc} carries state`, + ) + } + const outgoing = eEdges.filter(({ sourcePc }) => sourcePc === state.pc) + const before = outgoing.length ? outgoing[0].handlerStateBefore : null + if (state.reachable && !before) + decline( + 'invalid-handler-state', + `Packet H instruction ${state.pc} has no incoming state`, + ) + if ( + state.reachable && + !outgoing.every(({ handlerStateBefore }) => + sameValue(handlerStateBefore, before), + ) + ) { + decline( + 'handler-stack-merge-disagreement', + `Packet E states disagree at ${state.pc}`, + ) + } + if (state.reachable) + requireSame( + state.handlerStateBefore, + before, + 'stale-predecessor', + `Packet H before state at ${state.pc} is stale`, + ) + const afterStates = outgoing.map( + ({ handlerStateAfter }) => handlerStateAfter, + ) + const after = + afterStates.length && + afterStates.every((value) => sameValue(value, afterStates[0])) + ? afterStates[0] + : null + if (after !== null) + validateStateShape( + after, + stateRecordsForFunction, + `Packet H instruction ${state.pc} after`, + ) + requireSame( + state.handlerStateAfter, + after, + 'stale-predecessor', + `Packet H after state at ${state.pc} is stale`, + ) + stateByPc.set(state.pc, state) + } + if ( + !arrayEqual( + [...stateByPc.keys()].sort((a, b) => a - b), + [...efn.instructionPcs].sort((a, b) => a - b), + ) + ) + decline( + 'invalid-handler-state', + `Packet H function ${efn.id} states do not cover instructions`, + ) + statesByFunction.set(efn.id, stateByPc) + for (const block of cfgData.functionBlocks.get(efn.id).values()) { + validateStateShape( + block.handlerStateIn, + stateRecordsForFunction, + `CFG block ${block.id} handler state`, + ) + const entryState = stateByPc.get(block.startPc) + if (entryState.reachable) { + requireSame( + block.handlerStateIn, + entryState.handlerStateBefore, + 'stale-predecessor', + `CFG block ${block.id} handler state is stale`, + ) + } else { + requireSame( + block.handlerStateIn, + { stack: [], finallyStack: [] }, + 'stale-predecessor', + `Unreachable CFG block ${block.id} carries handler state`, + ) + } + } + + const hEdges = requireArray( + hfn.edgeStates, + 'invalid-handler-state', + `Packet H function ${efn.id} edge states are missing`, + ) + requireSame( + hEdges, + eEdges.map(edgeProjection).map((edge) => { + const projection = { ...edge } + delete projection.functionId + return projection + }), + 'stale-predecessor', + `Packet H function ${efn.id} edge states do not match Packet E`, + ) + hEdgesByFunction.set(efn.id, hEdges) + + const transitions = requireArray( + hfn.transitions, + 'invalid-handler-state', + `Packet H function ${efn.id} transitions are missing`, + ) + const transitionByPc = new Map() + for (const transition of transitions) { + requireObject( + transition, + 'invalid-handler-state', + 'Packet H transition is malformed', + ) + if ( + !reachable.has(transition.pc) || + transitionByPc.has(transition.pc) || + transition.name !== wordcodeData.byPc.get(transition.pc).name + ) + decline( + 'stale-predecessor', + `Packet H transition at ${transition.pc} is stale`, + ) + validateStateShape( + transition.handlerStateBefore, + stateRecordsForFunction, + `Packet H transition ${transition.pc}`, + ) + const expectedOutgoing = eEdges + .filter(({ sourcePc }) => sourcePc === transition.pc) + .map((edge) => { + const transitionEdge = { + kind: edge.kind, + targetPc: edge.targetPc, + } + if (edge.condition !== null) transitionEdge.condition = edge.condition + const callRoute = edge.kind === 'call' + const fullRoute = + edge.kind === 'return' || + edge.kind === 'throw' || + edge.kind === 'handler' || + edge.kind === 'finally' + const explicitCompletionFields = + fullRoute && !(edge.kind === 'finally' && edge.route === 'normal') + const indirectRoute = edge.mode !== 'direct' + if ( + explicitCompletionFields || + callRoute || + edge.continuationPc !== null + ) { + transitionEdge.continuationPc = edge.continuationPc + } + if ( + explicitCompletionFields || + callRoute || + indirectRoute || + edge.payload !== null + ) { + transitionEdge.payload = edge.payload + } + if ( + explicitCompletionFields || + callRoute || + edge.callSitePc !== null + ) { + transitionEdge.callSitePc = edge.callSitePc + } + if ( + fullRoute || + callRoute || + indirectRoute || + edge.route !== 'direct' + ) { + transitionEdge.route = edge.route + } + if (fullRoute || edge.mode !== 'direct') + transitionEdge.mode = edge.mode + transitionEdge.handlerStateAfter = edge.handlerStateAfter + return transitionEdge + }) + requireSame( + transition.outgoing, + expectedOutgoing, + 'stale-predecessor', + `Packet H transition at ${transition.pc} is stale`, + ) + transitionByPc.set(transition.pc, transition) + } + if (transitionByPc.size !== reachable.size) + decline( + 'invalid-handler-state', + `Packet H function ${efn.id} transitions are incomplete`, + ) + transitionsByFunction.set(efn.id, transitionByPc) + + const merges = requireArray( + hfn.merges, + 'invalid-handler-state', + `Packet H function ${efn.id} merges are missing`, + ) + const incoming = new Map() + for (const edge of eEdges) { + if (edge.targetPc === null) continue + const key = `${edge.sourcePc}:${edge.kind}:${edge.targetPc}:${edge.callSitePc ?? ''}` + const keys = incoming.get(edge.targetPc) ?? new Set() + keys.add(key) + incoming.set(edge.targetPc, keys) + } + const expectedMerges = [...incoming.entries()] + .filter(([, keys]) => keys.size > 1) + .map(([pc, keys]) => ({ + pc, + incomingCount: keys.size, + handlerState: stateForPc(stateByPc, pc, `Packet H merge ${pc}`) + .handlerStateBefore, + })) + requireSame( + merges, + expectedMerges, + 'stale-predecessor', + `Packet H function ${efn.id} merges are stale`, + ) + mergesByFunction.set(efn.id, merges) + + const jumps = requireArray( + hfn.jumpRegChecks, + 'invalid-handler-state', + `Packet H function ${efn.id} jump checks are missing`, + ) + const expectedJumps = efn.indirectTargets.map((entry) => { + const finalizer = records.find( + ({ type, finallyPc, throwPadPc }) => + type === 'finally' && entry.pc >= finallyPc && entry.pc < throwPadPc, + ) + const instruction = wordcodeData.byPc.get(entry.pc) + return { + pc: entry.pc, + sourceRegister: { kind: 'register', index: entry.sourceRegister }, + targets: [...entry.targets], + finalizer: finalizer?.id ?? null, + finite: true, + sameFunction: entry.targets.every( + (target) => ownerByPc.get(target) === efn.id, + ), + continuationRegister: finalizer + ? { kind: 'register', index: finalizer.continuationReg } + : null, + instructionName: instruction.name, + } + }) + requireSame( + jumps, + expectedJumps, + 'stale-predecessor', + `Packet H function ${efn.id} jump checks are stale`, + ) + jumpsByFunction.set(efn.id, jumps) + } + requireSame( + exceptionFinally.handlerRecords, + cfgData.functions.flatMap((fn) => fn.handlerRecords), + 'stale-predecessor', + 'Packet H global handler records are stale', + ) + requireSame( + exceptionFinally.finallyRecords, + cfgData.functions.flatMap((fn) => fn.finallyRecords), + 'stale-predecessor', + 'Packet H global finally records are stale', + ) + requireSame( + exceptionFinally.instructionStates, + cfgData.functions.flatMap((fn) => + hFunctions[fn.id].instructionStates.map((state) => ({ + functionId: fn.id, + ...state, + })), + ), + 'stale-predecessor', + 'Packet H global instruction states are stale', + ) + requireSame( + exceptionFinally.edgeStates, + cfgData.functions.flatMap((fn) => + hFunctions[fn.id].edgeStates.map((edge) => ({ + functionId: fn.id, + ...edge, + })), + ), + 'stale-predecessor', + 'Packet H global edge states are stale', + ) + requireSame( + exceptionFinally.merges, + cfgData.functions.flatMap((fn) => + hFunctions[fn.id].merges.map((merge) => ({ + functionId: fn.id, + ...merge, + })), + ), + 'stale-predecessor', + 'Packet H global merges are stale', + ) + requireSame( + exceptionFinally.jumpRegChecks, + cfgData.functions.flatMap((fn) => + hFunctions[fn.id].jumpRegChecks.map((jump) => ({ + functionId: fn.id, + ...jump, + })), + ), + 'stale-predecessor', + 'Packet H global jump checks are stale', + ) + requireSame( + exceptionFinally.transitions, + cfgData.functions.flatMap((fn) => + hFunctions[fn.id].transitions.map((transition) => ({ + functionId: fn.id, + ...transition, + })), + ), + 'stale-predecessor', + 'Packet H global transitions are stale', + ) + requireSame( + exceptionFinally.completionRoutes, + exceptionFinally.edgeStates.filter(({ kind }) => COMPLETIONS.has(kind)), + 'stale-predecessor', + 'Packet H completion routes are stale', + ) + requireSame( + exceptionFinally.callFrameRouteCount, + cfgData.allEdges.filter(({ kind }) => COMPLETIONS.has(kind)).length, + 'stale-predecessor', + 'Packet H call-frame route count is stale', + ) + return { + exceptionFinally, + recordsByFunction, + statesByFunction, + hEdgesByFunction, + transitionsByFunction, + mergesByFunction, + jumpsByFunction, + } +} diff --git a/src/vm/jsconfuser-vm/structured-control-input.js b/src/vm/jsconfuser-vm/structured-control-input.js new file mode 100644 index 00000000..7bc30c54 --- /dev/null +++ b/src/vm/jsconfuser-vm/structured-control-input.js @@ -0,0 +1,864 @@ +import { readWordcode } from './read-wordcode.js' + +const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const EXCEPTION_FINALLY_SCHEMA = 'jsconfuser-vm-exception-finally.v1' +export const STRUCTURED_CONTROL_SCHEMA = 'jsconfuser-vm-structured-control.v1' +const UINT32_MAX = 0xffffffff +const CALL_SPREAD = 65535 + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const CALLS = new Set(['CALL', 'CALL_METHOD', 'NEW']) +export const COMPLETIONS = new Set(['return', 'throw', 'handler', 'finally']) + +export class StructuredControlDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'StructuredControlDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new StructuredControlDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +export function requireArray(value, code, message) { + if (!Array.isArray(value)) decline(code, message) + return value +} + +function requireUint32(value, code, message) { + if (!Number.isInteger(value) || value < 0 || value > UINT32_MAX) { + decline(code, message) + } + return value +} + +export function sameValue(actual, expected) { + if (actual === expected) return true + if (Array.isArray(actual) && Array.isArray(expected)) { + return ( + actual.length === expected.length && + actual.every((value, index) => sameValue(value, expected[index])) + ) + } + if (isObject(actual) && isObject(expected)) { + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + sameValue(actual[key], expected[key]), + ) + ) + } + return false +} + +export function requireSame(actual, expected, code, message) { + if (!sameValue(actual, expected)) decline(code, message) +} + +export function arrayEqual(left, right) { + return sameValue(left, right) +} + +export function canonicalWordcode(wordcode) { + requireObject( + wordcode, + 'invalid-input', + 'Expected a Packet B wordcode result', + ) + const words = requireArray( + wordcode.words, + 'invalid-wordcode', + 'Packet B has no word stream', + ) + if (words.length === 0 || !words.every((value) => Number.isInteger(value))) { + decline('invalid-wordcode', 'Packet B words are not numeric') + } + const container = { + schemaVersion: CONTAINER_SCHEMA, + encoding: 'numeric-u32', + roles: { + words: { values: [...words] }, + op: { values: { ...CANONICAL_OPCODES } }, + sentinels: { values: { CALL_SPREAD } }, + scalars: { + ENCODE_BYTECODE: { value: false }, + MAIN_START_PC: { value: 0 }, + }, + }, + } + const rebuilt = readWordcode(container) + if (!rebuilt) decline('invalid-wordcode', 'Packet B cannot be reconstructed') + requireSame( + wordcode, + rebuilt, + 'stale-predecessor', + 'Packet B is not the canonical reconstruction of its numeric words', + ) + return { + words: [...rebuilt.words], + instructions: rebuilt.instructions, + byPc: new Map( + rebuilt.instructions.map((instruction) => [instruction.pc, instruction]), + ), + boundaries: rebuilt.instructions.map(({ pc }) => pc), + boundarySet: new Set(rebuilt.instructions.map(({ pc }) => pc)), + } +} + +function expectedInstructionPcs(startPc, endPc, boundaries) { + return boundaries.filter((pc) => pc >= startPc && pc < endPc) +} + +export function ownerMapFromFunctions(functions, wordcodeData) { + const ownerByPc = new Map() + let previousEnd = null + for (let id = 0; id < functions.length; id += 1) { + const fn = functions[id] + requireObject( + fn, + 'invalid-function-boundary', + `CFG function ${id} is malformed`, + ) + if ( + fn.id !== id || + (fn.kind !== 'root' && fn.kind !== 'closure') || + !Number.isInteger(fn.startPc) || + !Number.isInteger(fn.endPc) || + !wordcodeData.boundarySet.has(fn.startPc) || + (fn.endPc !== wordcodeData.words.length && + !wordcodeData.boundarySet.has(fn.endPc)) || + fn.endPc <= fn.startPc || + (previousEnd !== null && fn.startPc !== previousEnd) || + !Number.isInteger(fn.regCount) || + fn.regCount < 1 || + !Number.isInteger(fn.paramCount) || + fn.paramCount > fn.regCount || + !Number.isInteger(fn.captureCount) || + fn.captureCount < 0 || + typeof fn.hasRest !== 'boolean' || + !Array.isArray(fn.instructionPcs) || + fn.instructionCount !== fn.instructionPcs.length + ) { + decline('invalid-function-boundary', `CFG function ${id} is malformed`) + } + if ( + id === 0 && + (fn.kind !== 'root' || fn.startPc !== 0 || fn.parentFunctionId !== null) + ) { + decline( + 'invalid-function-boundary', + 'CFG root function metadata is invalid', + ) + } + if ( + id > 0 && + (!Number.isInteger(fn.parentFunctionId) || + fn.parentFunctionId < 0 || + fn.parentFunctionId >= id) + ) { + decline( + 'invalid-function-boundary', + `CFG function ${id} has an invalid parent`, + ) + } + const expected = expectedInstructionPcs( + fn.startPc, + fn.endPc, + wordcodeData.boundaries, + ) + requireSame( + fn.instructionPcs, + expected, + 'invalid-function-boundary', + `CFG function ${id} does not own its interval exactly`, + ) + if (expected.length === 0) { + decline('invalid-function-boundary', `CFG function ${id} is empty`) + } + for (const pc of expected) { + if (ownerByPc.has(pc)) { + decline( + 'invalid-function-boundary', + `Instruction ${pc} has multiple owners`, + ) + } + ownerByPc.set(pc, id) + } + previousEnd = fn.endPc + } + if ( + previousEnd !== wordcodeData.words.length || + ownerByPc.size !== wordcodeData.instructions.length + ) { + decline( + 'invalid-function-boundary', + 'CFG does not assign the complete stream', + ) + } + return ownerByPc +} + +export function validateFrame(frame, functions) { + requireObject(frame, 'invalid-frame-metadata', 'CFG has no frame summary') + if ( + frame.frameStart !== 1 || + frame.headerSize !== 8 || + !sameValue(frame.slots, CANONICAL_SLOTS) + ) { + decline('invalid-frame-metadata', 'CFG frame constants are not canonical') + } + requireObject(frame.root, 'invalid-frame-metadata', 'CFG has no root frame') + if ( + frame.root.functionId !== 0 || + frame.root.startPc !== functions[0].startPc || + frame.root.regCount !== functions[0].regCount + ) { + decline( + 'invalid-frame-metadata', + 'CFG root frame does not match function zero', + ) + } +} + +function registerValue(index) { + return { kind: 'register', index } +} + +export function expectedRecords(fn, wordcodeData) { + const records = fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .flatMap((instruction) => { + if (instruction.name === 'TRY_SETUP') { + return [ + { + id: `try@${instruction.pc}`, + ownerFunction: fn.id, + type: 'handler', + setupPc: instruction.pc, + handlerPc: instruction.operands[0].pc, + exceptionReg: instruction.words[2], + }, + ] + } + if (instruction.name === 'FINALLY_SETUP') { + return [ + { + id: `finally@${instruction.pc}`, + ownerFunction: fn.id, + type: 'finally', + setupPc: instruction.pc, + finallyPc: instruction.operands[0].pc, + continuationReg: instruction.words[2], + payloadReg: instruction.words[3], + throwPadPc: instruction.operands[3].pc, + }, + ] + } + return [] + }) + return { + handlers: records.filter(({ type }) => type === 'handler'), + finallys: records.filter(({ type }) => type === 'finally'), + } +} + +export function expectedCallSites(fn, edges, wordcodeData) { + return fn.instructionPcs + .map((pc) => wordcodeData.byPc.get(pc)) + .filter((instruction) => CALLS.has(instruction.name)) + .map((instruction) => { + const raw = instruction.words + const method = instruction.name === 'CALL_METHOD' + const destination = raw[1] + const receiver = method ? raw[2] : null + const callee = method ? raw[3] : raw[2] + const argc = method ? raw[4] : raw[3] + const firstArgument = method ? 5 : 4 + const argumentsValue = + argc === CALL_SPREAD + ? { + kind: 'spread', + arrayRegister: registerValue(raw[firstArgument]), + } + : { + kind: 'fixed', + count: argc, + registers: raw + .slice(firstArgument, firstArgument + argc) + .map(registerValue), + } + const exceptional = edges.find( + (edge) => edge.callSitePc === instruction.pc && edge.route !== 'normal', + ) + return { + pc: instruction.pc, + kind: instruction.name, + destination: registerValue(destination), + callee: registerValue(callee), + calleeFunctions: [], + receiver: receiver === null ? null : registerValue(receiver), + constructorState: { + isConstructor: instruction.name === 'NEW', + mode: instruction.name === 'NEW' ? 'allocate-this' : 'ordinary', + }, + arguments: argumentsValue, + continuationPc: instruction.nextPc, + returnDestination: registerValue(destination), + exceptionalCompletion: exceptional + ? { + kind: exceptional.kind, + route: exceptional.route, + targetPc: exceptional.targetPc, + payload: exceptional.payload, + } + : null, + } + }) +} + +export function edgeKey(edge) { + return JSON.stringify(edge) +} + +export function edgeProjection(edge) { + return { + functionId: edge.functionId, + kind: edge.kind, + sourcePc: edge.sourcePc, + targetPc: edge.targetPc, + route: edge.route, + mode: edge.mode, + callSitePc: edge.callSitePc, + continuationPc: edge.continuationPc, + payload: edge.payload, + handlerStateBefore: edge.handlerStateBefore, + handlerStateAfter: edge.handlerStateAfter, + } +} + +export function validateStateShape(state, records, context) { + requireObject( + state, + 'invalid-handler-state', + `${context} is not a state object`, + ) + const stack = requireArray( + state.stack, + 'invalid-handler-state', + `${context}.stack is missing`, + ) + const finallyStack = requireArray( + state.finallyStack, + 'invalid-handler-state', + `${context}.finallyStack is missing`, + ) + const ids = [] + for (const record of stack) { + requireObject( + record, + 'invalid-handler-state', + `${context} has a malformed record`, + ) + const matches = records.filter((candidate) => + sameValue(stateRecordView(candidate), record), + ) + if (matches.length !== 1) { + decline('invalid-handler-state', `${context} contains an unknown record`) + } + ids.push(matches[0].id) + } + if (new Set(ids).size !== ids.length) { + decline('invalid-handler-state', `${context} repeats a handler record`) + } + const expectedFinally = ids + .map((id) => records.find((record) => record.id === id)) + .filter((record) => record.type === 'finally') + .map(stateRecordView) + requireSame( + finallyStack, + expectedFinally, + 'invalid-handler-state', + `${context}.finallyStack disagrees with stack`, + ) + return true +} + +function stateRecordView(record) { + if (record.type === 'handler') { + return { + type: 'handler', + setupPc: record.setupPc, + handlerPc: record.handlerPc, + exceptionReg: record.exceptionReg, + } + } + return { + type: 'finally', + setupPc: record.setupPc, + finallyPc: record.finallyPc, + continuationReg: record.continuationReg, + payloadReg: record.payloadReg, + throwPadPc: record.throwPadPc, + } +} + +export function stateForPc(stateByPc, pc, context) { + const state = stateByPc.get(pc) + if (!state) + decline('invalid-handler-state', `${context} has no state at ${pc}`) + return state +} + +export function validateFunctionBlocks( + fn, + functionIndex, + blockById, + edgesByFunction, + wordcodeData, +) { + const blocks = requireArray( + fn.blocks, + 'invalid-basic-block', + `CFG function ${functionIndex} has no blocks`, + ) + const leaders = requireArray( + fn.leaders, + 'invalid-basic-block', + `CFG function ${functionIndex} has no leaders`, + ) + const sortedBlocks = [...blocks].sort( + (left, right) => left.startPc - right.startPc, + ) + const expectedLeaders = sortedBlocks.map(({ startPc }) => startPc) + requireSame( + leaders, + expectedLeaders, + 'invalid-basic-block', + `CFG function ${functionIndex} leaders are stale`, + ) + let previousEnd = fn.startPc + const covered = [] + for (const block of sortedBlocks) { + requireObject( + block, + 'invalid-basic-block', + `CFG function ${functionIndex} has a malformed block`, + ) + if ( + block.id !== `${fn.id}:b@${block.startPc}` || + block.functionId !== fn.id || + !wordcodeData.boundarySet.has(block.startPc) || + (block.endPc !== fn.endPc && + !wordcodeData.boundarySet.has(block.endPc)) || + block.startPc !== previousEnd || + block.endPc <= block.startPc || + !Array.isArray(block.instructionPcs) || + block.instructionPcs.length === 0 + ) { + decline( + 'invalid-basic-block', + `CFG block ${block.id ?? ''} is malformed`, + ) + } + const expected = expectedInstructionPcs( + block.startPc, + block.endPc, + fn.instructionPcs, + ) + requireSame( + block.instructionPcs, + expected, + 'invalid-basic-block', + `CFG block ${block.id} does not cover its interval`, + ) + if (block.endPc > fn.endPc || block.instructionPcs[0] !== block.startPc) { + decline( + 'invalid-basic-block', + `CFG block ${block.id} leaves its function`, + ) + } + const lastPc = block.instructionPcs.at(-1) + const expectedSuccessors = edgesByFunction + .get(fn.id) + .filter((edge) => edge.sourcePc === lastPc) + requireSame( + block.successors, + expectedSuccessors, + 'stale-predecessor', + `CFG block ${block.id} successors are stale`, + ) + if (blockById.has(block.id)) { + decline('invalid-basic-block', `CFG block ${block.id} is duplicated`) + } + blockById.set(block.id, block) + covered.push(...block.instructionPcs) + previousEnd = block.endPc + } + requireSame( + covered, + fn.instructionPcs, + 'invalid-basic-block', + `CFG function ${functionIndex} blocks do not cover instructions exactly`, + ) +} + +export function validateIndirectTargets(fn, wordcodeData) { + const entries = requireArray( + fn.indirectTargets, + 'invalid-cfg-edge', + `CFG function ${fn.id} has no indirect-target records`, + ) + const byPc = new Map() + for (const entry of entries) { + requireObject(entry, 'invalid-cfg-edge', 'CFG indirect target is malformed') + if ( + !Number.isInteger(entry.pc) || + !wordcodeData.boundarySet.has(entry.pc) || + !Number.isInteger(entry.sourceRegister) || + !Array.isArray(entry.targets) || + entry.targets.length === 0 || + !arrayEqual( + entry.targets, + [...entry.targets].sort((left, right) => left - right), + ) || + new Set(entry.targets).size !== entry.targets.length + ) { + decline( + 'invalid-cfg-edge', + `CFG indirect target at ${entry.pc} is malformed`, + ) + } + const instruction = wordcodeData.byPc.get(entry.pc) + if ( + !instruction || + instruction.name !== 'JUMP_REG' || + instruction.words[1] !== entry.sourceRegister + ) { + decline( + 'stale-predecessor', + `CFG indirect target at ${entry.pc} disagrees with Packet B`, + ) + } + for (const target of entry.targets) { + requireUint32( + target, + 'invalid-cfg-edge', + `CFG indirect target ${target} is invalid`, + ) + if (!wordcodeData.boundarySet.has(target)) { + decline( + 'invalid-cfg-edge', + `CFG indirect target ${target} is not an instruction boundary`, + ) + } + } + if (byPc.has(entry.pc)) + decline( + 'invalid-cfg-edge', + `CFG indirect target ${entry.pc} is duplicated`, + ) + byPc.set(entry.pc, entry) + } + for (const instruction of fn.instructionPcs.map((pc) => + wordcodeData.byPc.get(pc), + )) { + if (instruction.name === 'JUMP_REG' && !byPc.has(instruction.pc)) { + decline( + 'invalid-cfg-edge', + `CFG has no indirect targets for JUMP_REG@${instruction.pc}`, + ) + } + if (instruction.name !== 'JUMP_REG' && byPc.has(instruction.pc)) { + decline( + 'invalid-cfg-edge', + `CFG has an indirect target for ${instruction.name}@${instruction.pc}`, + ) + } + } + return byPc +} + +export function validateEdgeShape( + edge, + fn, + ownerByPc, + wordcodeData, + indirectByPc, +) { + requireObject(edge, 'invalid-cfg-edge', 'CFG edge is malformed') + if ( + edge.functionId !== fn.id || + !Number.isInteger(edge.sourcePc) || + ownerByPc.get(edge.sourcePc) !== fn.id || + !wordcodeData.byPc.has(edge.sourcePc) || + ![ + 'fallthrough', + 'branch', + 'conditional', + 'call', + 'return', + 'throw', + 'handler', + 'finally', + ].includes(edge.kind) || + !['direct', 'indirect'].includes(edge.mode) || + typeof edge.route !== 'string' + ) { + decline( + 'invalid-cfg-edge', + `CFG edge at ${edge.sourcePc ?? ''} is malformed`, + ) + } + if (edge.targetPc !== null) { + requireUint32( + edge.targetPc, + 'invalid-cfg-edge', + 'CFG edge target is invalid', + ) + if ( + !wordcodeData.boundarySet.has(edge.targetPc) || + ownerByPc.get(edge.targetPc) !== fn.id + ) { + decline( + 'cross-function-target', + `CFG edge ${edge.sourcePc}->${edge.targetPc} leaves function ${fn.id}`, + ) + } + } + if (edge.callSitePc !== null && edge.callSitePc !== edge.sourcePc) { + decline( + 'invalid-cfg-edge', + `CFG edge at ${edge.sourcePc} has a stale call site`, + ) + } + const instruction = wordcodeData.byPc.get(edge.sourcePc) + if (edge.kind === 'conditional') { + if ( + !['JUMP_IF_FALSE', 'JUMP_IF_TRUE'].includes(instruction.name) || + edge.mode !== 'direct' || + edge.targetPc !== instruction.operands[1].pc + ) { + decline( + 'invalid-cfg-edge', + `Conditional edge at ${edge.sourcePc} is not a conditional instruction`, + ) + } + requireSame( + edge.condition, + { + register: instruction.words[1], + truthy: instruction.name === 'JUMP_IF_TRUE', + }, + 'stale-predecessor', + `Conditional edge at ${edge.sourcePc} has a stale condition`, + ) + } else if (edge.kind === 'branch' && instruction.name === 'JUMP') { + if ( + edge.mode !== 'direct' || + edge.targetPc !== instruction.operands[0].pc || + edge.route !== 'direct' + ) { + decline('stale-predecessor', `JUMP edge at ${edge.sourcePc} is stale`) + } + } else if (edge.kind === 'call') { + if ( + !CALLS.has(instruction.name) || + edge.targetPc !== instruction.nextPc || + edge.continuationPc !== instruction.nextPc || + edge.callSitePc !== instruction.pc || + edge.route !== 'normal' + ) { + decline('invalid-cfg-edge', `Call edge at ${edge.sourcePc} is malformed`) + } + } else if (edge.kind === 'branch' && instruction.name === 'FOR_IN_NEXT') { + if (edge.mode !== 'direct' || edge.targetPc !== instruction.operands[2].pc) + decline('stale-predecessor', `Iterator edge at ${edge.sourcePc} is stale`) + requireSame( + edge.condition, + { kind: 'iterator', register: instruction.words[2], available: false }, + 'stale-predecessor', + `Iterator edge at ${edge.sourcePc} has a stale condition`, + ) + } else if (edge.kind === 'branch' && instruction.name === 'JUMP_REG') { + if (edge.mode !== 'indirect' || !indirectByPc.has(instruction.pc)) { + decline( + 'invalid-cfg-edge', + `Indirect edge at ${edge.sourcePc} is not proven`, + ) + } + } + if (edge.kind === 'fallthrough') { + if ( + edge.mode !== 'direct' || + edge.targetPc !== instruction.nextPc || + instruction.nextPc >= fn.endPc + ) + decline( + 'invalid-cfg-edge', + `Fallthrough edge at ${edge.sourcePc} is malformed`, + ) + if ( + edge.condition !== null || + edge.route !== 'direct' || + edge.callSitePc !== null || + edge.continuationPc !== null || + edge.payload !== null + ) { + decline( + 'invalid-cfg-edge', + `Fallthrough edge at ${edge.sourcePc} carries unsupported metadata`, + ) + } + } + if (edge.kind === 'return' && instruction.name !== 'RETURN') { + decline( + 'invalid-cfg-edge', + `Return edge at ${edge.sourcePc} has the wrong instruction`, + ) + } + if ( + edge.kind === 'throw' && + !CALLS.has(instruction.name) && + instruction.name !== 'THROW' + ) { + decline( + 'invalid-cfg-edge', + `Throw edge at ${edge.sourcePc} has the wrong instruction`, + ) + } + if (edge.kind === 'handler' && edge.route !== 'exceptional') { + decline( + 'invalid-cfg-edge', + `Handler edge at ${edge.sourcePc} has the wrong route`, + ) + } + if ( + edge.kind === 'finally' && + !['JUMP', ...CALLS, 'RETURN', 'THROW'].includes(instruction.name) + ) { + decline( + 'invalid-cfg-edge', + `Finally edge at ${edge.sourcePc} has the wrong instruction`, + ) + } + if ( + edge.kind !== 'conditional' && + edge.condition !== null && + !(edge.kind === 'branch' && instruction.name === 'FOR_IN_NEXT') + ) { + decline( + 'invalid-cfg-edge', + `CFG edge at ${edge.sourcePc} has an unexpected condition`, + ) + } +} diff --git a/src/vm/jsconfuser-vm/validate-references-operands.js b/src/vm/jsconfuser-vm/validate-references-operands.js new file mode 100644 index 00000000..a62cefbe --- /dev/null +++ b/src/vm/jsconfuser-vm/validate-references-operands.js @@ -0,0 +1,626 @@ +const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +const UINT32_MAX = 0xffffffff + +export const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +export const CANONICAL_SENTINELS = Object.freeze({ CALL_SPREAD: 65535 }) + +export const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const NAME_BY_OPCODE = new Map( + Object.entries(CANONICAL_OPCODES).map(([name, value]) => [value, name]), +) + +const BINARY_REGISTER_NAMES = new Set([ + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'IN', + 'INSTANCEOF', +]) + +const UNARY_REGISTER_NAMES = new Set([ + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', +]) + +const THREE_REGISTER_NAMES = new Set([ + 'GET_PROP', + 'SET_PROP', + 'DELETE_PROP', + 'DEFINE_GETTER', + 'DEFINE_SETTER', +]) + +export const FIXED_WIDTHS = Object.freeze({ + LOAD_CONST: 4, + LOAD_INT: 3, + LOAD_GLOBAL: 4, + LOAD_UPVALUE: 3, + LOAD_THIS: 2, + MOVE: 3, + STORE_GLOBAL: 4, + STORE_UPVALUE: 3, + GET_PROP: 4, + SET_PROP: 4, + DELETE_PROP: 4, + ADD: 4, + SUB: 4, + MUL: 4, + DIV: 4, + MOD: 4, + EXP: 4, + BAND: 4, + BOR: 4, + BXOR: 4, + SHL: 4, + SHR: 4, + USHR: 4, + LT: 4, + GT: 4, + LTE: 4, + GTE: 4, + EQ: 4, + NEQ: 4, + LOOSE_EQ: 4, + LOOSE_NEQ: 4, + IN: 4, + INSTANCEOF: 4, + UNARY_NEG: 3, + UNARY_POS: 3, + UNARY_NOT: 3, + UNARY_BITNOT: 3, + TYPEOF: 3, + VOID: 3, + TYPEOF_SAFE: 4, + JUMP: 2, + JUMP_IF_FALSE: 3, + JUMP_IF_TRUE: 3, + RETURN: 2, + THROW: 2, + DEFINE_GETTER: 4, + DEFINE_SETTER: 4, + FOR_IN_SETUP: 3, + FOR_IN_NEXT: 4, + TRY_SETUP: 3, + TRY_END: 1, + DEBUGGER: 1, + JUMP_REG: 2, + FINALLY_SETUP: 5, +}) + +const REGISTER_ONLY_NAMES = new Set([ + 'LOAD_THIS', + 'MOVE', + 'RETURN', + 'THROW', + 'JUMP_REG', + 'FOR_IN_SETUP', +]) + +export class ReferenceDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'ReferenceDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new ReferenceDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function isObject(value) { + return value !== null && typeof value === 'object' +} + +export function isUint32(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +export function isSafeCount(value) { + return Number.isSafeInteger(value) && value >= 0 +} + +function exactObjectValues(actual, expected) { + if (!isObject(actual) || Array.isArray(actual)) return false + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + actual[key] === expected[key], + ) + ) +} + +export function sameArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +function primitivePoolValue(value) { + if (value === null || value === undefined) return true + if (typeof value === 'string' || typeof value === 'boolean') return true + return typeof value === 'number' && Number.isFinite(value) +} + +export function requireObject(value, code, message) { + if (!isObject(value) || Array.isArray(value)) decline(code, message) + return value +} + +export function validateContainer(container) { + requireObject( + container, + 'invalid-input', + 'Expected a Packet A container result', + ) + if ( + container.schemaVersion !== CONTAINER_SCHEMA || + container.encoding !== 'numeric-u32' + ) { + decline('invalid-container', 'Input is not a Packet A numeric container') + } + + const roles = requireObject( + container.roles, + 'invalid-container', + 'Packet A result has no roles object', + ) + const pool = requireObject( + roles.pool, + 'invalid-container', + 'Packet A result has no constant-pool role', + ).values + const words = requireObject( + roles.words, + 'invalid-container', + 'Packet A result has no wordcode role', + ).values + if (!Array.isArray(pool) || pool.length === 0) { + decline('invalid-constant-pool', 'Packet A constant pool must be non-empty') + } + if (!Array.isArray(words) || words.length === 0 || !words.every(isUint32)) { + decline( + 'invalid-container', + 'Packet A wordcode must be a non-empty unsigned 32-bit array', + ) + } + if (!pool.every(primitivePoolValue)) { + decline( + 'invalid-constant-pool', + 'Packet A constant pool contains an unsupported value', + ) + } + + if (!exactObjectValues(roles.op?.values, CANONICAL_OPCODES)) { + decline('invalid-container', 'Packet A opcode map is not canonical') + } + if (!exactObjectValues(roles.sentinels?.values, CANONICAL_SENTINELS)) { + decline('invalid-container', 'Packet A sentinel map is not canonical') + } + if (!exactObjectValues(roles.slots?.values, CANONICAL_SLOTS)) { + decline('invalid-container', 'Packet A frame slot map is not canonical') + } + + const scalars = requireObject( + roles.scalars, + 'invalid-frame-metadata', + 'Packet A result has no scalar metadata', + ) + const scalarValues = {} + for (const role of [ + 'MAIN_START_PC', + 'MAIN_REG_COUNT', + 'ENCODE_BYTECODE', + 'TIMING_CHECKS', + 'HEADER_SIZE', + 'FRAME_START', + ]) { + const scalar = requireObject( + scalars[role], + 'invalid-frame-metadata', + `Packet A result is missing ${role}`, + ) + scalarValues[role] = scalar.value + } + if (scalarValues.MAIN_START_PC !== 0) { + decline('invalid-frame-metadata', 'MAIN_START_PC must be zero') + } + if ( + !isUint32(scalarValues.MAIN_REG_COUNT) || + scalarValues.MAIN_REG_COUNT < 1 + ) { + decline( + 'invalid-frame-metadata', + 'MAIN_REG_COUNT must be a positive unsigned 32-bit count', + ) + } + if (scalarValues.ENCODE_BYTECODE === true) { + decline( + 'unsupported-encoded-bytecode', + 'ENCODE_BYTECODE=true is outside the numeric-wordcode boundary', + ) + } + if (scalarValues.ENCODE_BYTECODE !== false) { + decline( + 'invalid-frame-metadata', + 'ENCODE_BYTECODE must be explicitly false', + ) + } + if (scalarValues.TIMING_CHECKS !== false) { + decline('invalid-frame-metadata', 'TIMING_CHECKS must be explicitly false') + } + if (scalarValues.HEADER_SIZE !== 8 || scalarValues.FRAME_START !== 1) { + decline( + 'invalid-frame-metadata', + 'HEADER_SIZE and FRAME_START do not match the pinned baseline', + ) + } + + return { pool, words, scalars: scalarValues } +} + +export function expectedWordKinds(name, raw) { + if (name === 'LOAD_CONST' || name === 'LOAD_GLOBAL') { + return ['register', 'constant-index', 'constant-key'] + } + if (name === 'STORE_GLOBAL' || name === 'TYPEOF_SAFE') { + return name === 'STORE_GLOBAL' + ? ['constant-index', 'constant-key', 'register'] + : ['register', 'constant-index', 'constant-key'] + } + if (name === 'LOAD_INT') return ['register', 'immediate'] + if (name === 'LOAD_UPVALUE') return ['register', 'upvalue-index'] + if (name === 'STORE_UPVALUE') return ['upvalue-index', 'register'] + if (name === 'JUMP') return ['label-target'] + if (name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE') { + return ['register', 'label-target'] + } + if (name === 'FOR_IN_NEXT') { + // Packet B currently exposes this instruction's historical word-operand + // layout (dst, iterator label slot, exit register slot). Its semantic + // operands below retain the compiler meaning (dst, iterator, exit). + return ['register', 'label-target', 'register'] + } + if (name === 'TRY_SETUP') return ['label-target', 'register'] + if (name === 'FINALLY_SETUP') { + return ['label-target', 'register', 'register', 'label-target'] + } + if (name === 'MAKE_CLOSURE') { + const captureCount = raw[5] + return [ + 'register', + 'label-target', + 'immediate', + 'immediate', + 'immediate', + 'immediate', + ...Array.from({ length: captureCount }, () => [ + 'capture-kind', + 'capture-index', + ]).flat(), + ] + } + if (name === 'BUILD_ARRAY') { + return [ + 'register', + 'immediate', + ...Array.from({ length: raw[2] }, () => 'register'), + ] + } + if (name === 'BUILD_OBJECT') { + return [ + 'register', + 'immediate', + ...Array.from({ length: raw[2] * 2 }, () => 'register'), + ] + } + if (name === 'CALL' || name === 'NEW' || name === 'CALL_METHOD') { + const argcIndex = name === 'CALL_METHOD' ? 4 : 3 + const fixedPrefix = name === 'CALL_METHOD' ? 3 : 2 + const argc = raw[argcIndex] + return [ + ...Array.from({ length: fixedPrefix }, () => 'register'), + argc === CANONICAL_SENTINELS.CALL_SPREAD + ? 'spread-sentinel' + : 'immediate', + ...Array.from( + { + length: argc === CANONICAL_SENTINELS.CALL_SPREAD ? 1 : argc, + }, + () => 'register', + ), + ] + } + if (BINARY_REGISTER_NAMES.has(name)) + return ['register', 'register', 'register'] + if (UNARY_REGISTER_NAMES.has(name)) return ['register', 'register'] + if (THREE_REGISTER_NAMES.has(name)) { + return ['register', 'register', 'register'] + } + if (REGISTER_ONLY_NAMES.has(name)) { + return name === 'FOR_IN_SETUP' + ? ['register', 'register'] + : name === 'LOAD_THIS' + ? ['register'] + : ['register', 'register'].slice(0, name === 'MOVE' ? 2 : 1) + } + if (name === 'TRY_END' || name === 'DEBUGGER') return [] + return [] +} + +export function expectedSemanticKinds(name, raw) { + if (name === 'LOAD_CONST' || name === 'LOAD_GLOBAL') { + return ['register', 'constant-ref'] + } + if (name === 'STORE_GLOBAL') return ['constant-ref', 'register'] + if (name === 'TYPEOF_SAFE') return ['register', 'constant-ref'] + if (name === 'LOAD_INT') return ['register', 'immediate'] + if (name === 'LOAD_UPVALUE') return ['register', 'upvalue-index'] + if (name === 'STORE_UPVALUE') return ['upvalue-index', 'register'] + if (name === 'JUMP') return ['label-target'] + if (name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE') { + return ['register', 'label-target'] + } + if (name === 'FOR_IN_NEXT') { + return ['register', 'register', 'label-target'] + } + if (name === 'TRY_SETUP') return ['label-target', 'register'] + if (name === 'FINALLY_SETUP') { + return ['label-target', 'register', 'register', 'label-target'] + } + if (name === 'MAKE_CLOSURE') { + return [ + 'register', + 'label-target', + 'immediate', + 'immediate', + 'immediate', + 'immediate', + ...Array.from({ length: raw[5] }, () => 'capture'), + ] + } + if (name === 'BUILD_ARRAY') { + return [ + 'register', + 'immediate', + ...Array.from({ length: raw[2] }, () => 'register'), + ] + } + if (name === 'BUILD_OBJECT') { + return [ + 'register', + 'immediate', + ...Array.from({ length: raw[2] * 2 }, () => 'register'), + ] + } + if (name === 'CALL' || name === 'NEW' || name === 'CALL_METHOD') { + const argcIndex = name === 'CALL_METHOD' ? 4 : 3 + const fixedPrefix = name === 'CALL_METHOD' ? 3 : 2 + const argc = raw[argcIndex] + return [ + ...Array.from({ length: fixedPrefix }, () => 'register'), + argc === CANONICAL_SENTINELS.CALL_SPREAD + ? 'spread-sentinel' + : 'immediate', + ...Array.from( + { + length: argc === CANONICAL_SENTINELS.CALL_SPREAD ? 1 : argc, + }, + () => 'register', + ), + ] + } + if (BINARY_REGISTER_NAMES.has(name)) + return ['register', 'register', 'register'] + if (UNARY_REGISTER_NAMES.has(name)) return ['register', 'register'] + if (THREE_REGISTER_NAMES.has(name)) { + return ['register', 'register', 'register'] + } + if (REGISTER_ONLY_NAMES.has(name)) { + return name === 'FOR_IN_SETUP' + ? ['register', 'register'] + : name === 'LOAD_THIS' + ? ['register'] + : name === 'MOVE' + ? ['register', 'register'] + : ['register'] + } + if (name === 'TRY_END' || name === 'DEBUGGER') return [] + return [] +} + +export function checkTypedOperand(operand, expectedKind, rawValue, location) { + requireObject( + operand, + 'malformed-instruction', + `${location} has a malformed typed operand`, + ) + if (operand.kind !== expectedKind) { + decline( + 'malformed-instruction', + `${location} has kind ${String(operand.kind)}, expected ${expectedKind}`, + ) + } + if (expectedKind === 'register') { + if (operand.index !== rawValue || !isUint32(operand.index)) { + decline('malformed-instruction', `${location} has a malformed register`) + } + } else if (expectedKind === 'label-target') { + if (operand.pc !== rawValue || !isUint32(operand.pc)) { + decline( + 'malformed-instruction', + `${location} has a malformed label target`, + ) + } + } else if (expectedKind === 'constant-ref') { + if (!isUint32(operand.index) || !isUint32(operand.concealKey)) { + decline( + 'malformed-instruction', + `${location} has a malformed constant reference`, + ) + } + } else if (expectedKind === 'upvalue-index') { + if (operand.index !== rawValue || !isUint32(operand.index)) { + decline( + 'malformed-instruction', + `${location} has a malformed upvalue index`, + ) + } + } else if ( + expectedKind === 'immediate' || + expectedKind === 'spread-sentinel' + ) { + const value = operand.value + if (value !== rawValue || !isUint32(value)) { + decline('malformed-instruction', `${location} has a malformed immediate`) + } + if ( + expectedKind === 'spread-sentinel' && + value !== CANONICAL_SENTINELS.CALL_SPREAD + ) { + decline( + 'malformed-instruction', + `${location} has an invalid spread sentinel`, + ) + } + } else if (expectedKind === 'capture-pair') { + if ( + !isObject(operand) || + (operand.kind !== 'local' && operand.kind !== 'upvalue') || + !isUint32(operand.index) || + operand.isLocal !== (operand.kind === 'local') + ) { + decline( + 'malformed-instruction', + `${location} has a malformed capture pair`, + ) + } + } else if (expectedKind === 'constant-index') { + if (operand.index !== rawValue || !isUint32(operand.index)) { + decline( + 'malformed-instruction', + `${location} has a malformed constant index`, + ) + } + } else if ( + expectedKind === 'constant-key' || + expectedKind === 'capture-kind' || + expectedKind === 'capture-index' + ) { + if (operand.value !== rawValue || !isUint32(operand.value)) { + decline('malformed-instruction', `${location} does not match its word`) + } + } +} diff --git a/src/vm/jsconfuser-vm/validate-references-wordcode.js b/src/vm/jsconfuser-vm/validate-references-wordcode.js new file mode 100644 index 00000000..bc6d1c63 --- /dev/null +++ b/src/vm/jsconfuser-vm/validate-references-wordcode.js @@ -0,0 +1,517 @@ +import { + CANONICAL_OPCODES, + CANONICAL_SENTINELS, + FIXED_WIDTHS, + NAME_BY_OPCODE, + WORDCODE_SCHEMA, + checkTypedOperand, + decline, + expectedSemanticKinds, + expectedWordKinds, + isObject, + isSafeCount, + isUint32, + requireObject, + sameArray, +} from './validate-references-operands.js' + +function validateWordOperands(instruction) { + const { name, words, wordOperands, operands, pc } = instruction + if (!Array.isArray(words) || words.length === 0 || !words.every(isUint32)) { + decline( + 'malformed-instruction', + `Instruction at ${pc} has invalid raw words`, + ) + } + const wordKinds = expectedWordKinds(name, words) + if ( + !Array.isArray(wordOperands) || + wordOperands.length !== wordKinds.length + ) { + decline( + 'malformed-instruction', + `Instruction at ${pc} has invalid word operands`, + ) + } + wordKinds.forEach((kind, index) => { + checkTypedOperand( + wordOperands[index], + kind, + words[index + 1], + `${name}@${pc} word operand ${index}`, + ) + }) + + const semanticKinds = expectedSemanticKinds(name, words) + if (!Array.isArray(operands) || operands.length !== semanticKinds.length) { + decline( + 'malformed-instruction', + `Instruction at ${pc} has invalid operands`, + ) + } + semanticKinds.forEach((kind, index) => { + const operand = operands[index] + requireObject( + operand, + 'malformed-instruction', + `${name}@${pc} operand ${index} is not typed`, + ) + const kindMatches = + kind === 'capture' + ? operand.kind === 'local' || operand.kind === 'upvalue' + : operand.kind === kind + if (!kindMatches) { + decline( + 'malformed-instruction', + `${name}@${pc} operand ${index} has kind ${String(operand.kind)}`, + ) + } + const rawIndex = (() => { + if (name !== 'CALL_METHOD' && name !== 'CALL' && name !== 'NEW') { + return null + } + const registerPrefix = name === 'CALL_METHOD' ? 3 : 2 + const argcIndex = name === 'CALL_METHOD' ? 4 : 3 + if (index < registerPrefix) return index + 1 + if (index === registerPrefix) return argcIndex + return argcIndex + 1 + (index - registerPrefix - 1) + })() + if (rawIndex !== null) { + checkTypedOperand( + operand, + kind, + words[rawIndex], + `${name}@${pc} semantic operand ${index}`, + ) + return + } + if (kind === 'register') { + const registerWord = + name === 'STORE_GLOBAL' + ? words[3] + : name === 'STORE_UPVALUE' + ? words[2] + : name === 'TYPEOF_SAFE' + ? words[1] + : name === 'LOAD_CONST' || name === 'LOAD_GLOBAL' + ? words[1] + : name === 'LOAD_UPVALUE' || name === 'LOAD_INT' + ? words[1] + : name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE' + ? words[1] + : name === 'FOR_IN_NEXT' + ? index === 0 + ? words[1] + : words[2] + : name === 'TRY_SETUP' + ? words[2] + : name === 'FINALLY_SETUP' + ? words[index + 1] + : name === 'MAKE_CLOSURE' + ? words[1] + : name === 'BUILD_ARRAY' || name === 'BUILD_OBJECT' + ? index === 0 + ? words[1] + : words[index + 1] + : name === 'FOR_IN_SETUP' + ? words[index + 1] + : name === 'LOAD_THIS' || + name === 'RETURN' || + name === 'THROW' || + name === 'JUMP_REG' + ? words[1] + : words[index + 1] + checkTypedOperand( + operand, + kind, + registerWord, + `${name}@${pc} semantic operand ${index}`, + ) + } else if (kind === 'label-target') { + const labelWord = + name === 'JUMP' + ? words[1] + : name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE' + ? words[2] + : name === 'FOR_IN_NEXT' + ? words[3] + : name === 'TRY_SETUP' + ? words[1] + : name === 'FINALLY_SETUP' + ? index === 0 + ? words[1] + : words[4] + : words[2] + checkTypedOperand( + operand, + kind, + labelWord, + `${name}@${pc} semantic operand ${index}`, + ) + } else if (kind === 'constant-ref') { + const constantIndex = name === 'STORE_GLOBAL' ? words[1] : words[2] + const constantKey = name === 'STORE_GLOBAL' ? words[2] : words[3] + if ( + operand.index !== constantIndex || + operand.concealKey !== constantKey + ) { + decline( + 'malformed-instruction', + `${name}@${pc} constant pair does not match its words`, + ) + } + } else if (kind === 'upvalue-index') { + const value = name === 'STORE_UPVALUE' ? words[1] : words[2] + checkTypedOperand( + operand, + kind, + value, + `${name}@${pc} semantic operand ${index}`, + ) + } else if (kind === 'capture') { + const kindWord = words[7 + (index - 6) * 2] + const indexWord = words[8 + (index - 6) * 2] + if ( + operand.index !== indexWord || + operand.isLocal !== (kindWord === 1) || + operand.kind !== (kindWord === 1 ? 'local' : 'upvalue') + ) { + decline( + 'malformed-instruction', + `${name}@${pc} capture pair does not match its words`, + ) + } + } else if (kind === 'immediate' || kind === 'spread-sentinel') { + const immediateWord = + name === 'MAKE_CLOSURE' + ? words[index + 1] + : name === 'BUILD_ARRAY' || name === 'BUILD_OBJECT' + ? words[index + 1] + : name === 'CALL' || name === 'NEW' || name === 'CALL_METHOD' + ? words[name === 'CALL_METHOD' ? 4 : 3] + : words[index + 1] + checkTypedOperand( + operand, + kind, + immediateWord, + `${name}@${pc} semantic operand ${index}`, + ) + } + }) +} + +function validateInstructionMetadata(instruction) { + const { name, words, pc } = instruction + if (!isObject(instruction.opcode)) { + decline( + 'malformed-instruction', + `Instruction at ${pc} has no opcode record`, + ) + } + if ( + instruction.opcode.name !== name || + instruction.opcode.value !== CANONICAL_OPCODES[name] || + words[0] !== instruction.opcode.value + ) { + decline( + 'malformed-instruction', + `Instruction at ${pc} has an invalid opcode record`, + ) + } + if (name === 'PATCH') { + decline( + 'unsupported-hardening-opcode', + `PATCH at ${pc} is outside the baseline reference validator`, + ) + } + if (FIXED_WIDTHS[name] && words.length !== FIXED_WIDTHS[name]) { + decline('malformed-instruction', `${name}@${pc} has an invalid fixed width`) + } + if (name === 'CALL' || name === 'NEW' || name === 'CALL_METHOD') { + const argcIndex = name === 'CALL_METHOD' ? 4 : 3 + const argc = words[argcIndex] + if (!isUint32(argc)) { + decline('malformed-instruction', `${name}@${pc} has an invalid argc`) + } + const expectedWidth = + (name === 'CALL_METHOD' ? 5 : 4) + + (argc === CANONICAL_SENTINELS.CALL_SPREAD ? 1 : argc) + if (words.length !== expectedWidth) { + decline( + 'malformed-instruction', + `${name}@${pc} has an invalid call width`, + ) + } + } + if (name === 'MAKE_CLOSURE') { + const captureCount = words[5] + if (!isSafeCount(captureCount) || words.length !== 7 + captureCount * 2) { + decline( + 'invalid-descriptor', + `${name}@${pc} has an invalid capture payload`, + ) + } + if (words[6] !== 0 && words[6] !== 1) { + decline('invalid-descriptor', `${name}@${pc} has an invalid hasRest flag`) + } + } + if (name === 'BUILD_ARRAY') { + if (!isSafeCount(words[2]) || words.length !== 3 + words[2]) { + decline( + 'invalid-descriptor', + `${name}@${pc} has an invalid element payload`, + ) + } + } + if (name === 'BUILD_OBJECT') { + if (!isSafeCount(words[2]) || words.length !== 3 + words[2] * 2) { + decline('invalid-descriptor', `${name}@${pc} has an invalid pair payload`) + } + } +} + +function sameRegister(left, right) { + return ( + isObject(left) && + left.kind === 'register' && + isObject(right) && + right.kind === 'register' && + left.index === right.index + ) +} + +function sameRegisterArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => sameRegister(value, right[index])) + ) +} + +function validateRichInstructionMetadata(instruction) { + const { name, words, operands, pc } = instruction + if (name === 'CALL' || name === 'CALL_METHOD' || name === 'NEW') { + const method = name === 'CALL_METHOD' + const spread = words[method ? 4 : 3] === CANONICAL_SENTINELS.CALL_SPREAD + const prefix = method ? 3 : 2 + const argc = words[method ? 4 : 3] + const argumentRegisters = operands.slice(prefix + 1) + const destination = operands[0] + const receiver = method ? operands[1] : null + const callee = operands[method ? 2 : 1] + if ( + !sameRegister(instruction.destination, destination) || + !sameRegister(instruction.callee, callee) || + (method + ? !sameRegister(instruction.receiver, receiver) + : instruction.receiver !== null) || + instruction.serializedArgumentOffset !== (method ? 5 : 4) + ) { + decline( + 'malformed-instruction', + `${name}@${pc} has inconsistent call metadata`, + ) + } + if (!isObject(instruction.form) || !isObject(instruction.arguments)) { + decline( + 'malformed-instruction', + `${name}@${pc} has incomplete call metadata`, + ) + } + const expectedArgumentRegisters = spread + ? [argumentRegisters[0]] + : argumentRegisters + if ( + instruction.form.kind !== (spread ? 'spread' : 'fixed') || + instruction.form.argc !== argc || + instruction.form.sentinel !== + (spread ? CANONICAL_SENTINELS.CALL_SPREAD : null) || + (spread + ? !sameRegister(instruction.form.arrayRegister, argumentRegisters[0]) + : instruction.form.arrayRegister !== null) || + instruction.arguments.kind !== (spread ? 'spread' : 'fixed') || + (spread + ? instruction.arguments.sentinel !== CANONICAL_SENTINELS.CALL_SPREAD || + !sameRegister( + instruction.arguments.arrayRegister, + argumentRegisters[0], + ) + : instruction.arguments.count !== argc || + !sameRegisterArray( + instruction.arguments.registers, + expectedArgumentRegisters, + )) + ) { + decline( + 'malformed-instruction', + `${name}@${pc} has inconsistent call form metadata`, + ) + } + return + } + if (name === 'MAKE_CLOSURE') { + const metadata = instruction.functionMeta + if (!isObject(metadata) || !isObject(instruction.form)) { + decline( + 'invalid-descriptor', + `${name}@${pc} has incomplete closure metadata`, + ) + } + if ( + instruction.form.kind !== 'closure' || + instruction.form.startPc !== metadata.startPc || + instruction.form.paramCount !== metadata.paramCount || + instruction.form.regCount !== metadata.regCount || + instruction.form.captureCount !== metadata.captureCount || + instruction.form.hasRest !== metadata.hasRest || + !sameRegister(instruction.destination, operands[0]) + ) { + decline( + 'invalid-descriptor', + `${name}@${pc} has inconsistent closure metadata`, + ) + } + return + } + if (name === 'BUILD_ARRAY') { + const elements = operands.slice(2) + if ( + !isObject(instruction.form) || + instruction.form.kind !== 'array' || + instruction.form.count !== words[2] || + instruction.count !== words[2] || + !sameRegister(instruction.destination, operands[0]) || + !sameRegisterArray(instruction.form.elementRegisters, elements) || + !sameRegisterArray(instruction.elementRegisters, elements) + ) { + decline( + 'invalid-descriptor', + `${name}@${pc} has inconsistent array metadata`, + ) + } + return + } + if (name === 'BUILD_OBJECT') { + const pairs = [] + for (let index = 2; index < operands.length; index += 2) { + pairs.push({ key: operands[index], value: operands[index + 1] }) + } + if ( + !isObject(instruction.form) || + instruction.form.kind !== 'object' || + instruction.form.pairCount !== words[2] || + instruction.pairCount !== words[2] || + !sameRegister(instruction.destination, operands[0]) || + !Array.isArray(instruction.form.pairs) || + !Array.isArray(instruction.pairs) || + instruction.form.pairs.length !== pairs.length || + instruction.pairs.length !== pairs.length || + pairs.some( + (pair, index) => + !sameRegister(instruction.form.pairs[index]?.key, pair.key) || + !sameRegister(instruction.form.pairs[index]?.value, pair.value) || + !sameRegister(instruction.pairs[index]?.key, pair.key) || + !sameRegister(instruction.pairs[index]?.value, pair.value), + ) + ) { + decline( + 'invalid-descriptor', + `${name}@${pc} has inconsistent object metadata`, + ) + } + } +} + +export function validateWordcode(containerData, wordcode) { + requireObject( + wordcode, + 'invalid-input', + 'Expected a Packet B wordcode result', + ) + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-wordcode', + 'Input is not a Packet B numeric wordcode result', + ) + } + const words = wordcode.words + if ( + !Array.isArray(words) || + words.length === 0 || + !words.every(isUint32) || + !sameArray(words, containerData.words) + ) { + decline('input-mismatch', 'Packet B words do not match Packet A wordcode') + } + if ( + wordcode.wordCount !== words.length || + wordcode.consumedWords !== words.length || + wordcode.nextPc !== words.length || + !Array.isArray(wordcode.instructions) || + wordcode.instructionCount !== wordcode.instructions.length || + wordcode.instructions.length === 0 + ) { + decline('invalid-wordcode', 'Packet B consumption metadata is inconsistent') + } + + let nextPc = 0 + const boundaries = [] + const instructionNames = [] + for (const instruction of wordcode.instructions) { + requireObject( + instruction, + 'malformed-instruction', + 'Packet B contains a malformed instruction record', + ) + if (instruction.pc !== nextPc || !isSafeCount(instruction.pc)) { + decline('invalid-wordcode', 'Packet B instruction PCs are not contiguous') + } + if ( + !isSafeCount(instruction.width) || + instruction.width !== instruction.words?.length || + instruction.nextPc !== instruction.pc + instruction.width + ) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has invalid width metadata`, + ) + } + if ( + !sameArray(instruction.words, words.slice(nextPc, instruction.nextPc)) + ) { + decline( + 'input-mismatch', + `Instruction at ${instruction.pc} does not match Packet B words`, + ) + } + const name = instruction.name + if (NAME_BY_OPCODE.get(instruction.words[0]) !== name) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has an unknown opcode name`, + ) + } + validateInstructionMetadata(instruction) + validateWordOperands(instruction) + validateRichInstructionMetadata(instruction) + boundaries.push(instruction.pc) + instructionNames.push(name) + nextPc = instruction.nextPc + } + if (nextPc !== words.length) { + decline( + 'invalid-wordcode', + 'Packet B instructions do not consume all words', + ) + } + return { + words, + instructions: wordcode.instructions, + boundaries, + instructionNames, + } +} diff --git a/src/vm/jsconfuser-vm/validate-references.js b/src/vm/jsconfuser-vm/validate-references.js new file mode 100644 index 00000000..e9d6a06b --- /dev/null +++ b/src/vm/jsconfuser-vm/validate-references.js @@ -0,0 +1,434 @@ +import { + CANONICAL_SLOTS, + REFERENCE_SCHEMA, + ReferenceDecline, + decline, + deepFreeze, + isObject, + isSafeCount, + isUint32, + validateContainer, +} from './validate-references-operands.js' +import { validateWordcode } from './validate-references-wordcode.js' + +function validateFrameSlots(containerData, wordcodeData) { + const { MAIN_START_PC: mainStartPc, MAIN_REG_COUNT: mainRegCount } = + containerData.scalars + const headerSize = containerData.scalars.HEADER_SIZE + const frameStart = containerData.scalars.FRAME_START + const slots = CANONICAL_SLOTS + const boundarySet = new Set(wordcodeData.boundaries) + if (!boundarySet.has(mainStartPc)) { + decline( + 'invalid-label-reference', + 'MAIN_START_PC is not an instruction boundary', + ) + } + if (wordcodeData.boundaries[0] !== mainStartPc) { + decline( + 'invalid-frame-metadata', + 'Wordcode does not begin at MAIN_START_PC', + ) + } + const slotValues = Object.values(slots) + if ( + slotValues.some((value) => !Number.isInteger(value) || value < 0) || + new Set(slotValues).size !== slotValues.length || + Math.max(...slotValues) >= headerSize + ) { + decline( + 'invalid-frame-metadata', + 'Frame slot offsets do not fit HEADER_SIZE', + ) + } + if (slots.FRAME_SIZE !== 6 || slots.REG_BASE !== 7) { + decline( + 'invalid-frame-metadata', + 'FRAME_SIZE/REG_BASE slots are not canonical', + ) + } + + const rootFrameSize = headerSize + mainRegCount + const rootRegisterBase = frameStart + headerSize + const rootFrameEnd = frameStart + rootFrameSize + if (!Number.isSafeInteger(rootFrameEnd)) { + decline('invalid-frame-metadata', 'Root frame arithmetic overflowed') + } + return { + boundarySet, + frame: { + frameStart, + headerSize, + slots: { ...slots }, + mainStartPc, + mainRegCount, + root: { + frameBase: frameStart, + frameSize: rootFrameSize, + registerBase: rootRegisterBase, + registerWindow: { start: rootRegisterBase, end: rootFrameEnd }, + frameEnd: rootFrameEnd, + }, + }, + } +} + +function descriptorRecord(instruction) { + const metadata = instruction.functionMeta + const captures = instruction.captures + return { + creationPc: instruction.pc, + startPc: metadata.startPc, + paramCount: metadata.paramCount, + regCount: metadata.regCount, + captureCount: metadata.captureCount, + hasRest: metadata.hasRest, + captures: captures.map(({ kind, index, isLocal }) => ({ + kind, + index, + isLocal, + })), + } +} + +function validateDescriptors(wordcodeData, frameData) { + const closureInstructions = wordcodeData.instructions.filter( + ({ name }) => name === 'MAKE_CLOSURE', + ) + const descriptors = [] + const byStart = new Map() + let maxRegisterCount = frameData.frame.mainRegCount + let maxCaptureCount = 0 + for (const instruction of closureInstructions) { + const metadata = instruction.functionMeta + if (!isObject(metadata) || !Array.isArray(instruction.captures)) { + decline( + 'invalid-descriptor', + `MAKE_CLOSURE@${instruction.pc} has no metadata`, + ) + } + if ( + metadata.startPc !== instruction.words[2] || + !isSafeCount(metadata.paramCount) || + !isUint32(metadata.paramCount) || + !isSafeCount(metadata.regCount) || + !isUint32(metadata.regCount) || + metadata.regCount < 1 || + metadata.paramCount > metadata.regCount || + metadata.captureCount !== instruction.words[5] || + !isSafeCount(metadata.captureCount) || + metadata.hasRest !== (instruction.words[6] === 1) || + (metadata.hasRest && metadata.paramCount < 1) + ) { + decline( + 'invalid-descriptor', + `MAKE_CLOSURE@${instruction.pc} has invalid frame metadata`, + ) + } + if ( + !frameData.boundarySet.has(metadata.startPc) || + instruction.operands[1]?.pc !== metadata.startPc + ) { + decline( + 'invalid-label-reference', + `MAKE_CLOSURE@${instruction.pc} has an invalid entry PC`, + ) + } + if ( + instruction.captures.length !== metadata.captureCount || + instruction.capturePairs?.length !== metadata.captureCount + ) { + decline( + 'invalid-descriptor', + `MAKE_CLOSURE@${instruction.pc} capture metadata is inconsistent`, + ) + } + const captures = instruction.captures.map((capture, index) => { + if ( + !isObject(capture) || + (capture.kind !== 'local' && capture.kind !== 'upvalue') || + !isUint32(capture.index) || + capture.isLocal !== (capture.kind === 'local') || + !sameCapture(capture, instruction.capturePairs[index]) + ) { + decline( + 'invalid-descriptor', + `MAKE_CLOSURE@${instruction.pc} has an invalid capture pair`, + ) + } + return { + kind: capture.kind, + index: capture.index, + isLocal: capture.isLocal, + } + }) + const record = { + ...descriptorRecord({ ...instruction, functionMeta: metadata, captures }), + captures, + frameSize: frameData.frame.headerSize + metadata.regCount, + registerBaseOffset: frameData.frame.headerSize, + } + const prior = byStart.get(record.startPc) + const signature = JSON.stringify({ + paramCount: record.paramCount, + regCount: record.regCount, + captureCount: record.captureCount, + hasRest: record.hasRest, + captures: record.captures, + }) + if (prior && prior.signature !== signature) { + decline( + 'invalid-descriptor', + `Conflicting MAKE_CLOSURE descriptors at ${record.startPc}`, + ) + } + if (!prior) byStart.set(record.startPc, { signature }) + descriptors.push(record) + maxRegisterCount = Math.max(maxRegisterCount, metadata.regCount) + maxCaptureCount = Math.max(maxCaptureCount, metadata.captureCount) + } + for (const descriptor of descriptors) { + for (const capture of descriptor.captures) { + if (capture.kind === 'local' && capture.index >= maxRegisterCount) { + decline( + 'invalid-register-reference', + `MAKE_CLOSURE@${descriptor.creationPc} captures r${capture.index} outside the known frame bounds`, + ) + } + if (capture.kind === 'upvalue' && capture.index >= maxCaptureCount) { + decline( + 'invalid-descriptor', + `MAKE_CLOSURE@${descriptor.creationPc} captures upvalue ${capture.index} outside the known descriptor bounds`, + ) + } + } + } + return { descriptors, maxRegisterCount, maxCaptureCount } +} + +function sameCapture(left, right) { + return ( + isObject(right) && + right.kind === left.kind && + right.index === left.index && + right.isLocal === left.isLocal + ) +} + +function labelRole(name, index) { + if (name === 'JUMP') return 'target' + if (name === 'JUMP_IF_FALSE' || name === 'JUMP_IF_TRUE') return 'target' + if (name === 'FOR_IN_NEXT') return 'exit' + if (name === 'TRY_SETUP') return 'handler' + if (name === 'FINALLY_SETUP') return index === 0 ? 'finally' : 'throwPad' + if (name === 'MAKE_CLOSURE') return 'functionEntry' + return 'label' +} + +function collectReferences( + containerData, + wordcodeData, + frameData, + descriptors, +) { + const constantReferences = [] + const registerReferences = [] + const labelReferences = [] + const registerLimit = descriptors.maxRegisterCount + const captureLimit = descriptors.maxCaptureCount + const { pool } = containerData + const directLabelIndexes = { + JUMP: [0], + JUMP_IF_FALSE: [1], + JUMP_IF_TRUE: [1], + FOR_IN_NEXT: [2], + TRY_SETUP: [0], + FINALLY_SETUP: [0, 3], + MAKE_CLOSURE: [1], + } + + for (const instruction of wordcodeData.instructions) { + instruction.operands.forEach((operand, operandIndex) => { + if (operand.kind === 'register') { + if (!isUint32(operand.index) || operand.index >= registerLimit) { + decline( + 'invalid-register-reference', + `${instruction.name}@${instruction.pc} references r${operand.index} outside the known frame bounds`, + ) + } + registerReferences.push({ + pc: instruction.pc, + instruction: instruction.name, + operand: operandIndex, + index: operand.index, + }) + } else if (operand.kind === 'constant-ref') { + if ( + !isUint32(operand.index) || + operand.index >= pool.length || + operand.concealKey !== 0 + ) { + decline( + 'invalid-constant-reference', + `${instruction.name}@${instruction.pc} has an invalid constant pair`, + ) + } + if ( + (instruction.name === 'LOAD_GLOBAL' || + instruction.name === 'STORE_GLOBAL' || + instruction.name === 'TYPEOF_SAFE') && + typeof pool[operand.index] !== 'string' + ) { + decline( + 'invalid-constant-reference', + `${instruction.name}@${instruction.pc} requires a string constant name`, + ) + } + constantReferences.push({ + pc: instruction.pc, + instruction: instruction.name, + operand: operandIndex, + index: operand.index, + concealKey: operand.concealKey, + }) + } else if (operand.kind === 'upvalue-index') { + if (!isUint32(operand.index) || operand.index >= captureLimit) { + decline( + 'invalid-descriptor', + `${instruction.name}@${instruction.pc} references an unknown upvalue bound`, + ) + } + } else if (operand.kind === 'local' || operand.kind === 'upvalue') { + // Capture-pair bounds and coherence are checked from functionMeta below. + // Their owner function is deliberately left to the partition packet. + } else if ( + operand.kind !== 'immediate' && + operand.kind !== 'spread-sentinel' + ) { + if (operand.kind !== 'label-target') { + decline( + 'malformed-instruction', + `${instruction.name}@${instruction.pc} has an unsupported operand kind`, + ) + } + } + }) + + const expectedLabels = directLabelIndexes[instruction.name] ?? [] + const actualLabels = instruction.operands + .map((operand, index) => (operand.kind === 'label-target' ? index : null)) + .filter((index) => index !== null) + if (JSON.stringify(actualLabels) !== JSON.stringify(expectedLabels)) { + decline( + 'malformed-instruction', + `${instruction.name}@${instruction.pc} has an invalid label operand layout`, + ) + } + for (const operandIndex of actualLabels) { + const target = instruction.operands[operandIndex].pc + if (!frameData.boundarySet.has(target)) { + decline( + 'invalid-label-reference', + `${instruction.name}@${instruction.pc} targets non-boundary pc ${target}`, + ) + } + labelReferences.push({ + pc: instruction.pc, + instruction: instruction.name, + operand: operandIndex, + role: labelRole(instruction.name, operandIndex), + target, + }) + } + } + return { constantReferences, registerReferences, labelReferences } +} + +function makeResult( + containerData, + wordcodeData, + frameData, + descriptorData, + refs, +) { + const frame = { + ...frameData.frame, + descriptors: descriptorData.descriptors, + } + return deepFreeze({ + schemaVersion: REFERENCE_SCHEMA, + encoding: 'numeric-u32', + wordCount: wordcodeData.words.length, + instructionCount: wordcodeData.instructions.length, + constants: { + poolSize: containerData.pool.length, + values: containerData.pool.slice(), + references: refs.constantReferences, + }, + registers: { + rootCount: frameData.frame.mainRegCount, + maxCount: descriptorData.maxRegisterCount, + maxCaptureCount: descriptorData.maxCaptureCount, + references: refs.registerReferences, + }, + labels: { + boundaries: + frameData.frame.mainStartPc === 0 + ? [...frameData.boundarySet].sort((left, right) => left - right) + : [...frameData.boundarySet], + references: refs.labelReferences, + }, + frame, + }) +} + +function recognize(container, wordcode) { + const containerData = validateContainer(container) + const wordcodeData = validateWordcode(containerData, wordcode) + const frameData = validateFrameSlots(containerData, wordcodeData) + const descriptorData = validateDescriptors(wordcodeData, frameData) + const refs = collectReferences( + containerData, + wordcodeData, + frameData, + descriptorData, + ) + return makeResult( + containerData, + wordcodeData, + frameData, + descriptorData, + refs, + ) +} + +export function diagnoseReferences(container, wordcode) { + try { + const result = recognize(container, wordcode) + return deepFreeze({ ok: true, result, diagnostic: null }) + } catch (error) { + if (error instanceof ReferenceDecline) { + return deepFreeze({ + ok: false, + result: null, + diagnostic: { code: error.code, message: error.message }, + }) + } + return deepFreeze({ + ok: false, + result: null, + diagnostic: { + code: 'reference-validator-error', + message: error instanceof Error ? error.message : String(error), + }, + }) + } +} + +export function validateReferencesResult(container, wordcode) { + return diagnoseReferences(container, wordcode).result +} + +export const validateReferences = validateReferencesResult + +export default validateReferencesResult diff --git a/src/vm/jsconfuser-vm/validate-structured-control.js b/src/vm/jsconfuser-vm/validate-structured-control.js new file mode 100644 index 00000000..309717af --- /dev/null +++ b/src/vm/jsconfuser-vm/validate-structured-control.js @@ -0,0 +1,262 @@ +import { decline, requireValue } from './standalone-contract.js' + +function arraysEqual(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +export function validateStructuredControl( + structuredControl, + wordcode, + functions, +) { + requireValue( + structuredControl, + 'structured-control-declined', + 'Packet K did not return a structured-control result', + ) + if ( + structuredControl.schemaVersion !== 'jsconfuser-vm-structured-control.v1' || + structuredControl.encoding !== 'numeric-u32' || + !Array.isArray(structuredControl.functions) || + structuredControl.functions.length !== functions.functions.length || + structuredControl.functionCount !== functions.functions.length || + !Array.isArray(structuredControl.edges) + ) + decline( + 'stale-structured-control', + 'Packet K does not describe the live Packet D function partition', + ) + if ( + !structuredControl.coverage?.instructions?.complete || + !structuredControl.coverage?.edges?.complete || + structuredControl.coverage.instructions.expected !== + wordcode.instructionCount || + structuredControl.coverage.edges.expected !== structuredControl.edges.length + ) + decline( + 'incomplete-structured-control', + 'Packet K reports incomplete instruction or edge coverage', + ) + const edgeIds = new Set() + const edgeById = new Map() + for (const edge of structuredControl.edges) { + if (!edge || typeof edge.id !== 'string' || edgeIds.has(edge.id)) + decline('stale-structured-control', 'Packet K edge IDs are not unique') + edgeIds.add(edge.id) + edgeById.set(edge.id, edge) + } + const functionById = new Map( + functions.functions.map((functionRecord) => [ + functionRecord.id, + functionRecord, + ]), + ) + const controlByFunction = new Map() + let structuredCount = 0 + let stateMachineCount = 0 + const emittedPcs = [] + for (const controlFunction of structuredControl.functions) { + const functionRecord = functionById.get(controlFunction.id) + if (!functionRecord || controlByFunction.has(controlFunction.id)) + decline( + 'stale-structured-control', + 'Packet K function IDs do not match the live Packet D partition', + ) + if ( + controlFunction.startPc !== functionRecord.startPc || + controlFunction.endPc !== functionRecord.endPc || + controlFunction.parentFunctionId !== functionRecord.parentFunctionId || + controlFunction.regCount !== functionRecord.regCount || + controlFunction.paramCount !== functionRecord.paramCount || + controlFunction.captureCount !== functionRecord.captureCount || + controlFunction.hasRest !== functionRecord.hasRest || + controlFunction.instructionCount !== functionRecord.instructionCount || + controlFunction.blockCount !== controlFunction.blocks?.length + ) + decline( + 'stale-structured-control', + `Packet K function ${controlFunction.id} does not match Packet D metadata`, + ) + if (!['structured', 'state-machine'].includes(controlFunction.mode)) + decline( + 'invalid-structured-control', + `Packet K function ${controlFunction.id} has an unknown mode`, + ) + if (controlFunction.mode === 'structured') structuredCount += 1 + else stateMachineCount += 1 + const functionPcs = new Set(functionRecord.instructionPcs) + const blockIds = new Set() + const pcs = [] + const referencedEdgeIds = new Set() + for (const block of controlFunction.blocks) { + if ( + !block || + typeof block.id !== 'string' || + blockIds.has(block.id) || + block.functionId !== controlFunction.id || + !Array.isArray(block.leaves) || + !Array.isArray(block.successorEdgeIds) || + block.leaves.length === 0 + ) + decline( + 'stale-structured-control', + `Packet K function ${controlFunction.id} has duplicate blocks`, + ) + blockIds.add(block.id) + if ( + block.startPc !== block.leaves[0].pc || + block.endPc !== block.leaves.at(-1).nextPc + ) + decline( + 'stale-structured-control', + `Packet K block ${block.id} has stale boundaries`, + ) + for (const leaf of block.leaves) { + const instruction = wordcode.instructions.find( + ({ pc }) => pc === leaf.pc, + ) + if ( + !instruction || + !functionPcs.has(leaf.pc) || + leaf.kind !== 'opcode' || + leaf.name !== instruction.name || + leaf.nextPc !== instruction.nextPc + ) + decline( + 'stale-structured-control', + `Packet K leaf ${leaf.pc} is stale relative to Packet B`, + ) + pcs.push(leaf.pc) + emittedPcs.push(leaf.pc) + } + for (const edgeId of block.successorEdgeIds) { + if (referencedEdgeIds.has(edgeId)) + decline( + 'stale-structured-control', + `Packet K function ${controlFunction.id} repeats a successor edge`, + ) + const edge = edgeById.get(edgeId) + if ( + !edge || + edge.functionId !== controlFunction.id || + edge.sourcePc !== block.leaves.at(-1).pc || + (edge.targetPc !== null && !functionPcs.has(edge.targetPc)) + ) + decline( + 'stale-structured-control', + `Packet K block ${block.id} refers to a missing edge`, + ) + referencedEdgeIds.add(edgeId) + } + } + if ( + !arraysEqual(pcs, functionRecord.instructionPcs) || + !arraysEqual(controlFunction.control.blockOrder, [...blockIds]) + ) + decline( + 'stale-structured-control', + `Packet K function ${controlFunction.id} instruction or block order is stale`, + ) + const liveEdgeIds = new Set( + structuredControl.edges + .filter(({ functionId }) => functionId === controlFunction.id) + .map(({ id }) => id), + ) + if ( + liveEdgeIds.size !== referencedEdgeIds.size || + [...liveEdgeIds].some((edgeId) => !referencedEdgeIds.has(edgeId)) + ) + decline( + 'stale-structured-control', + `Packet K function ${controlFunction.id} edge coverage is stale`, + ) + if (controlFunction.mode === 'structured') { + if (controlFunction.control?.kind !== 'structured') + decline( + 'invalid-structured-control', + `Packet K structured function ${controlFunction.id} has no structured proof`, + ) + for (const region of controlFunction.control.regions || []) { + if ( + !['linear', 'conditional', 'natural-loop', 'opaque'].includes( + region.kind, + ) + ) + decline( + 'invalid-structured-control', + `Packet K function ${controlFunction.id} has an unknown region kind`, + ) + const regionBlocks = region.blockIds || region.bodyBlockIds || [] + if (!regionBlocks.every((blockId) => blockIds.has(blockId))) + decline( + 'stale-structured-control', + `Packet K function ${controlFunction.id} region refers to a missing block`, + ) + for (const edgeId of [ + region.branchEdgeId, + region.fallthroughEdgeId, + ...(region.backEdgeIds || []), + ...(region.exitEdgeIds || []), + ].filter((edgeId) => edgeId !== undefined)) + if ( + !edgeById.has(edgeId) || + edgeById.get(edgeId).functionId !== controlFunction.id + ) + decline( + 'stale-structured-control', + `Packet K function ${controlFunction.id} region refers to a missing edge`, + ) + if (region.kind === 'conditional') { + if ( + !blockIds.has(region.testBlockId) || + !blockIds.has(region.mergeBlockId) + ) + decline( + 'stale-structured-control', + `Packet K conditional region in function ${controlFunction.id} refers to a missing block`, + ) + } + if (region.kind === 'natural-loop') { + if ( + !blockIds.has(region.headerBlockId) || + !region.bodyBlockIds?.includes(region.headerBlockId) + ) + decline( + 'stale-structured-control', + `Packet K natural loop in function ${controlFunction.id} has a stale header`, + ) + } + } + } else { + const proof = controlFunction.control?.proof + if ( + controlFunction.control?.kind !== 'state-machine' || + proof?.exactBlockGraph !== true || + proof?.explicitLeaves !== true || + proof?.irreducible?.required !== true || + proof.irreducible.fallback !== 'state-machine' + ) + decline( + 'invalid-irreducible-fallback', + `Packet K state-machine function ${controlFunction.id} lacks an irreducible proof`, + ) + } + controlByFunction.set(controlFunction.id, controlFunction) + } + if ( + emittedPcs.length !== wordcode.instructionCount || + new Set(emittedPcs).size !== emittedPcs.length || + structuredCount !== structuredControl.modeCounts?.structured || + stateMachineCount !== structuredControl.modeCounts?.stateMachine + ) + decline( + 'stale-structured-control', + 'Packet K function and instruction coverage projections are stale', + ) + return { controlByFunction, edgeById } +} diff --git a/src/vm/switch/vm-switch-control.js b/src/vm/switch/vm-switch-control.js new file mode 100644 index 00000000..1f165de4 --- /dev/null +++ b/src/vm/switch/vm-switch-control.js @@ -0,0 +1,247 @@ +// Build a source-independent control plan from typed VM switch cases. A frontend +// may supply a stronger qualified plan; this constructor never reads source +// opcodes, word widths, or rendered switch text. + +function successors(item) { + const { operation: op, nextPc } = item + if (op.kind === 'jump') return [{ kind: 'branch', targetPc: op.target }] + if (op.kind === 'branch') + return [ + { kind: 'conditional', targetPc: op.target }, + { kind: 'fallthrough', targetPc: nextPc }, + ] + if (op.kind === 'for-in-next') + return [ + { kind: 'branch', targetPc: op.exit }, + { kind: 'fallthrough', targetPc: nextPc }, + ] + if (op.kind === 'return' || op.kind === 'throw') + return [{ kind: op.kind, targetPc: null }] + return [{ kind: 'fallthrough', targetPc: nextPc }] +} + +function reachable(entry, adjacency) { + const visited = new Set([entry]) + const queue = [entry] + while (queue.length) { + for (const target of adjacency.get(queue.shift()) ?? []) { + if (visited.has(target)) continue + visited.add(target) + queue.push(target) + } + } + return visited +} + +function distances(start, adjacency) { + const result = new Map([[start, 0]]) + const queue = [start] + while (queue.length) { + const source = queue.shift() + for (const target of adjacency.get(source) ?? []) { + if (result.has(target)) continue + result.set(target, result.get(source) + 1) + queue.push(target) + } + } + return result +} + +function dominators(nodes, entry, predecessors) { + const dom = new Map( + [...nodes].map((node) => [ + node, + node === entry ? new Set([entry]) : new Set(nodes), + ]), + ) + let changed = true + while (changed) { + changed = false + for (const node of nodes) { + if (node === entry) continue + const incoming = (predecessors.get(node) ?? []).filter((pc) => + nodes.has(pc), + ) + const intersection = incoming.length + ? new Set( + [...dom.get(incoming[0])].filter((candidate) => + incoming.every((pc) => dom.get(pc).has(candidate)), + ), + ) + : new Set() + intersection.add(node) + const previous = dom.get(node) + if ( + previous.size !== intersection.size || + [...previous].some((value) => !intersection.has(value)) + ) { + dom.set(node, intersection) + changed = true + } + } + } + return dom +} + +function naturalLoop(header, source, predecessors, live) { + const members = new Set([header, source]) + const queue = [source] + while (queue.length) { + for (const pc of predecessors.get(queue.pop()) ?? []) { + if (!live.has(pc) || members.has(pc)) continue + members.add(pc) + if (pc !== header) queue.push(pc) + } + } + return members +} + +function regions(fn, edges, blockId, adjacency, predecessors, live) { + const result = [] + const bySource = new Map() + for (const edge of edges) { + const list = bySource.get(edge.sourcePc) ?? [] + list.push(edge) + bySource.set(edge.sourcePc, list) + } + if ( + edges.every((edge) => + ['fallthrough', 'return', 'throw'].includes(edge.kind), + ) + ) { + result.push({ + kind: 'linear', + blockIds: [...live].map(blockId), + proof: 'typed cases have only normal fallthrough or terminal completion', + }) + return result + } + for (const pc of live) { + const outgoing = bySource.get(pc) ?? [] + const branch = outgoing.find(({ kind }) => kind === 'conditional') + const fallthrough = outgoing.find(({ kind }) => kind === 'fallthrough') + if (!branch || !fallthrough) continue + const left = distances(branch.targetPc, adjacency) + const right = distances(fallthrough.targetPc, adjacency) + const common = [...left.keys()].filter((target) => right.has(target)) + if (!common.length) continue + const score = Math.min( + ...common.map((target) => Math.max(left.get(target), right.get(target))), + ) + const nearest = common.filter( + (target) => Math.max(left.get(target), right.get(target)) === score, + ) + if (nearest.length !== 1) continue + result.push({ + kind: 'conditional', + testBlockId: blockId(pc), + branchEdgeId: branch.id, + branchTargetPc: branch.targetPc, + fallthroughEdgeId: fallthrough.id, + fallthroughTargetPc: fallthrough.targetPc, + mergeBlockId: blockId(nearest[0]), + proof: 'both typed successors have one nearest reconvergence', + }) + } + const dom = dominators(live, fn.startPc, predecessors) + const loopKeys = new Set() + for (const edge of edges) { + const { sourcePc, targetPc } = edge + if ( + targetPc === null || + sourcePc === targetPc || + !live.has(sourcePc) || + !live.has(targetPc) || + !dom.get(sourcePc).has(targetPc) + ) + continue + const members = naturalLoop(targetPc, sourcePc, predecessors, live) + const exits = edges.filter( + (candidate) => + members.has(candidate.sourcePc) && + (candidate.targetPc === null || !members.has(candidate.targetPc)), + ) + if (!exits.length) continue + const key = `${targetPc}:${[...members].sort((a, b) => a - b).join(',')}` + if (loopKeys.has(key)) continue + loopKeys.add(key) + result.push({ + kind: 'natural-loop', + headerBlockId: blockId(targetPc), + backEdgeIds: [edge.id], + bodyBlockIds: [...members].sort((a, b) => a - b).map(blockId), + exitEdgeIds: exits.map(({ id }) => id), + proof: 'loop header dominates its back edge and has explicit exits', + }) + } + return result +} + +export function buildVmSwitchControl(model) { + const controlByFunction = new Map() + const edges = [] + for (const fn of model.functions.functions) { + const cases = model.instructionsByFunction.get(fn.id) + const blockId = (pc) => `${fn.id}:b@${pc}` + const needsDispatch = cases.some(({ operation: { kind } }) => + ['indirect-jump', 'catch-setup', 'finally-setup', 'handler-pop'].includes( + kind, + ), + ) + if (needsDispatch) { + controlByFunction.set(fn.id, { mode: 'state-machine' }) + continue + } + const functionEdges = cases.flatMap((item) => + successors(item).map((edge, index) => ({ + id: `switch:${fn.id}:${item.pc}:${index}`, + functionId: fn.id, + sourcePc: item.pc, + ...edge, + })), + ) + edges.push(...functionEdges) + const adjacency = new Map(cases.map(({ pc }) => [pc, []])) + const predecessors = new Map(cases.map(({ pc }) => [pc, []])) + for (const edge of functionEdges) { + if (edge.targetPc === null) continue + adjacency.get(edge.sourcePc).push(edge.targetPc) + predecessors.get(edge.targetPc).push(edge.sourcePc) + } + const live = reachable(fn.startPc, adjacency) + const blocks = [...cases] + .sort((left, right) => + left.pc === fn.startPc + ? -1 + : right.pc === fn.startPc + ? 1 + : left.pc - right.pc, + ) + .map((item) => ({ + id: blockId(item.pc), + functionId: fn.id, + startPc: item.pc, + endPc: item.nextPc, + leaves: [{ ...item, reachable: live.has(item.pc) }], + successorEdgeIds: functionEdges + .filter(({ sourcePc }) => sourcePc === item.pc) + .map(({ id }) => id), + })) + controlByFunction.set(fn.id, { + mode: 'structured', + blocks, + control: { + kind: 'structured', + regions: regions( + fn, + functionEdges, + blockId, + adjacency, + predecessors, + live, + ), + }, + }) + } + return { controlByFunction, structuredControl: { edges } } +} diff --git a/src/vm/switch/vm-switch-copy-planning.js b/src/vm/switch/vm-switch-copy-planning.js new file mode 100644 index 00000000..e4bdd255 --- /dev/null +++ b/src/vm/switch/vm-switch-copy-planning.js @@ -0,0 +1,606 @@ +import { VmSwitchDecline } from './vm-switch-model.js' + +export function decline(code, message) { + throw new VmSwitchDecline(code, message) +} + +export function requireValue(value, code, message) { + if (!value) decline(code, message) + return value +} + +function literal(value) { + if (value === undefined) return 'void 0' + if (value === null) return 'null' + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (typeof value === 'number') { + if (Number.isNaN(value)) return 'NaN' + if (value === Infinity) return 'Infinity' + if (value === -Infinity) return '-Infinity' + if (Object.is(value, -0)) return '-0' + return String(value) + } + if (typeof value === 'bigint') return `${String(value)}n` + decline('unsupported-constant-value', 'Constant is not a supported literal') +} + +export function functionName(functionId) { + return `__recovered_function_${functionId}` +} + +export function registerName(functionId, register) { + return `__recovered_r_${functionId}_${register}` +} + +export function functionRegisterSet(instructions) { + const registers = new Set() + for (const instruction of instructions) { + for (const register of readRegisters(instruction)) registers.add(register) + const destination = destinationRegister(instruction) + if (destination !== null) registers.add(destination) + const operation = instruction.operation + if (operation.kind === 'catch-setup') + registers.add(operation.exceptionRegister) + if (operation.kind === 'finally-setup') { + registers.add(operation.continuation) + registers.add(operation.payload) + } + if (operation.kind === 'closure') + for (const capture of operation.captures) + if (capture.kind === 'local') registers.add(capture.index) + } + return registers +} + +export function localCaptureRegisterSet(instructions) { + const registers = new Set() + for (const instruction of instructions) { + if (instruction.operation.kind !== 'closure') continue + for (const capture of instruction.operation.captures) + if (capture.kind === 'local') registers.add(capture.index) + } + return registers +} + +export function functionNeedsArguments(instructions, functionRecord) { + const reads = registerReadCounts(instructions) + const usedRegisters = new Set([ + ...reads.keys(), + ...localCaptureRegisterSet(instructions), + ]) + const argumentRegisters = [] + for (let register = 0; register < functionRecord.paramCount; register += 1) + argumentRegisters.push(register) + if (functionRecord.paramCount < functionRecord.regCount) + argumentRegisters.push(functionRecord.paramCount) + return argumentRegisters.some((register) => usedRegisters.has(register)) +} + +function readRegisters(instruction) { + const op = instruction.operation + if (op.kind === 'assign') { + const value = op.value + if (value.kind === 'register') return [value.index] + if (value.kind === 'binary') return [value.left, value.right] + if (value.kind === 'unary' || value.kind === 'typeof') return [value.source] + return [] + } + if (op.kind === 'global-write' || op.kind === 'upvalue-write') + return [op.source] + if (op.kind === 'property-read' || op.kind === 'property-delete') + return [op.object, op.key] + if (op.kind === 'property-write') return [op.object, op.key, op.value] + if (op.kind === 'branch') return [op.condition] + if (['indirect-jump', 'return', 'throw', 'for-in-setup'].includes(op.kind)) + return [op.source] + if (op.kind === 'call') + return [ + ...(op.receiver.kind === 'register' ? [op.receiver.index] : []), + op.callee, + ...op.arguments.registers, + ] + if (op.kind === 'array') return op.elements + if (op.kind === 'object') + return op.pairs.flatMap(({ key, value }) => [key, value]) + if (op.kind === 'accessor') return [op.object, op.key, op.callback] + if (op.kind === 'for-in-next') return [op.iterator] + return [] +} + +function registerReadCounts(instructions) { + const counts = new Map() + for (const instruction of instructions) { + for (const register of readRegisters(instruction)) + counts.set(register, (counts.get(register) || 0) + 1) + } + return counts +} + +function destinationRegister(instruction) { + // Iterator advance writes only on its non-exit route; adjacent-copy elimination + // cannot redirect it as an unconditional definition. + if (instruction.operation.kind === 'for-in-next') return null + return Number.isSafeInteger(instruction.operation.destination) + ? instruction.operation.destination + : null +} + +function definitionState(regCount) { + return new Map( + Array.from({ length: regCount }, (_, register) => [register, new Set()]), + ) +} + +function cloneDefinitionState(state, regCount) { + return new Map( + Array.from({ length: regCount }, (_, register) => [ + register, + new Set(state.get(register)), + ]), + ) +} + +function mergeDefinitionState(target, source, regCount) { + let changed = false + for (let register = 0; register < regCount; register += 1) { + const targetDefinitions = target.get(register) + for (const definition of source.get(register)) { + if (targetDefinitions.has(definition)) continue + targetDefinitions.add(definition) + changed = true + } + } + return changed +} + +function sameDefinitionState(left, right, regCount) { + for (let register = 0; register < regCount; register += 1) { + const leftDefinitions = left.get(register) + const rightDefinitions = right.get(register) + if ( + leftDefinitions.size !== rightDefinitions.size || + [...leftDefinitions].some( + (definition) => !rightDefinitions.has(definition), + ) + ) + return false + } + return true +} + +function reachingDefinitionUses(blocks, edgeById, regCount) { + const inStates = new Map( + blocks.map((block) => [block.id, definitionState(regCount)]), + ) + const outStates = new Map( + blocks.map((block) => [block.id, definitionState(regCount)]), + ) + const predecessors = new Map(blocks.map((block) => [block.id, []])) + const blockByStart = new Map(blocks.map((block) => [block.startPc, block])) + for (const block of blocks) { + for (const edgeId of block.successorEdgeIds) { + const edge = edgeById.get(edgeId) + const target = + edge?.targetPc === null ? null : blockByStart.get(edge?.targetPc) + if (target) predecessors.get(target.id).push(block) + } + } + + const entry = blocks[0] + const limit = Math.max(32, blocks.length * Math.max(1, regCount) * 8) + let changed = true + let iterations = 0 + while (changed) { + changed = false + iterations += 1 + if (iterations > limit) + decline( + 'simplification-nonconvergent', + 'Adjacent-copy reaching definitions did not converge', + ) + for (const block of blocks) { + const incoming = definitionState(regCount) + if (block === entry) { + for (let register = 0; register < regCount; register += 1) + incoming.get(register).add(`entry:${register}`) + } + for (const predecessor of predecessors.get(block.id)) + mergeDefinitionState(incoming, outStates.get(predecessor.id), regCount) + if (!sameDefinitionState(inStates.get(block.id), incoming, regCount)) { + inStates.set(block.id, incoming) + changed = true + } + + const outgoing = cloneDefinitionState(incoming, regCount) + for (const instruction of block.leaves) { + const destination = destinationRegister(instruction) + if (destination !== null) + outgoing.set(destination, new Set([`${instruction.pc}`])) + } + if (!sameDefinitionState(outStates.get(block.id), outgoing, regCount)) { + outStates.set(block.id, outgoing) + changed = true + } + } + } + + const uses = new Map() + for (const block of blocks) { + const state = cloneDefinitionState(inStates.get(block.id), regCount) + for (const instruction of block.leaves) { + for (const register of readRegisters(instruction)) { + for (const definition of state.get(register)) { + const sites = uses.get(definition) || [] + sites.push({ pc: instruction.pc, register }) + uses.set(definition, sites) + } + } + const destination = destinationRegister(instruction) + if (destination !== null) + state.set(destination, new Set([`${instruction.pc}`])) + } + } + return uses +} + +function adjacentCopyPlan( + functionId, + instructions, + blocks, + edgeById, + regCount, +) { + const uses = reachingDefinitionUses(blocks, edgeById, regCount) + const localCaptureRegisters = localCaptureRegisterSet(instructions) + const plan = new Map() + const proofs = [] + const coalescedMovePcs = new Set() + const blockByStart = new Map(blocks.map((block) => [block.startPc, block])) + const predecessors = new Map(blocks.map((block) => [block.id, []])) + for (const block of blocks) { + for (const edgeId of block.successorEdgeIds) { + const edge = edgeById.get(edgeId) + const target = + edge?.targetPc === null ? null : blockByStart.get(edge?.targetPc) + if (target) predecessors.get(target.id).push({ block, edge }) + } + } + + const consider = (producer, move) => { + if ( + producer.reachable !== true || + move.reachable !== true || + move.operation.kind !== 'assign' || + move.operation.value.kind !== 'register' || + destinationRegister(producer) === null + ) + return + const producerDestination = destinationRegister(producer) + const moveDestination = destinationRegister(move) + const moveSource = move.operation.value.index + if ( + producerDestination !== moveSource || + producerDestination === moveDestination || + localCaptureRegisters.has(producerDestination) || + localCaptureRegisters.has(moveDestination) + ) + return + const sites = uses.get(`${producer.pc}`) || [] + if (sites.length !== 1 || sites[0].pc !== move.pc) return + if (plan.has(producer.pc) || coalescedMovePcs.has(move.pc)) return + plan.set(producer.pc, moveDestination) + coalescedMovePcs.add(move.pc) + proofs.push({ + functionId, + producerPc: producer.pc, + movePc: move.pc, + sourceRegister: producerDestination, + destinationRegister: moveDestination, + reachingDefinitions: [`${producer.pc}`], + useSites: [{ pc: move.pc, register: moveSource }], + disposition: 'safely-eliminated', + reason: 'single-reaching-definition-use', + producerRetained: true, + evaluationOrderPreserved: true, + receiverSemanticsPreserved: true, + }) + } + + for (const block of blocks) { + for (let index = 0; index + 1 < block.leaves.length; index += 1) { + consider(block.leaves[index], block.leaves[index + 1]) + } + const producer = block.leaves.at(-1) + if (!producer) continue + for (const edgeId of block.successorEdgeIds) { + const edge = edgeById.get(edgeId) + const target = + edge?.targetPc === null ? null : blockByStart.get(edge?.targetPc) + if (edge?.kind !== 'fallthrough' || !target) continue + if (predecessors.get(target.id).length === 1 && target.leaves[0]) + consider(producer, target.leaves[0]) + } + } + return { plan, proofs, coalescedMovePcs } +} + +export function effectiveStructuredInstructions( + functionId, + instructions, + controlFunction, + edges, + regCount, +) { + const edgeById = structuredControlEdges(controlFunction, edges) + const adjacent = adjacentCopyPlan( + functionId, + instructions, + controlFunction.blocks, + edgeById, + regCount, + ) + const { plan: copyPlan, proofs: copyProofs, coalescedMovePcs } = adjacent + return { + copyPlan, + copyProofs, + coalescedMovePcs, + instructions: instructions + .filter(({ pc }) => !coalescedMovePcs.has(pc)) + .map((instruction) => { + const destination = copyPlan.get(instruction.pc) + return destination === undefined + ? instruction + : retargetDestination(instruction, destination) + }), + } +} + +export function retargetDestination(instruction, register) { + return { + ...instruction, + operation: { ...instruction.operation, destination: register }, + } +} + +function functionForPc(functions, startPc) { + const functionRecord = functions.functions.find( + ({ startPc: candidate }) => candidate === startPc, + ) + if (!functionRecord) { + decline( + 'missing-closure-function', + `Closure points at missing function entry ${startPc}`, + ) + } + return functionRecord +} + +function captureExpression(functionId, instruction, functions) { + const { captures: captureRecords, entry } = instruction.operation + const captures = [] + for (const capture of captureRecords) { + if (!capture || !Number.isSafeInteger(capture.index) || capture.index < 0) { + decline( + 'invalid-capture-pair', + 'Closure contains an invalid capture pair', + ) + } + if (capture.kind === 'local') { + const cell = `__recovered_cells_${functionId}[${capture.index}]` + captures.push(cell) + } else if (capture.kind === 'upvalue') { + captures.push(`__recovered_captures[${capture.index}]`) + } else { + decline('unsupported-capture-kind', 'Closure has an unknown capture kind') + } + } + functionForPc(functions, entry) + return captures +} + +function argExpression(functionId, args) { + if (args.kind === 'array-register') + return registerName(functionId, args.registers[0]) + return `[${args.registers.map((register) => registerName(functionId, register)).join(', ')}]` +} + +function emitCall(functionId, operation) { + const destination = registerName(functionId, operation.destination) + const callee = registerName(functionId, operation.callee) + const args = argExpression(functionId, operation.arguments) + if (operation.mode === 'apply') { + const receiver = + operation.receiver.kind === 'register' + ? registerName(functionId, operation.receiver.index) + : operation.receiver.kind === 'global' + ? 'globalThis' + : 'null' + return `${destination} = Reflect.apply(${callee}, ${receiver}, ${args});` + } + return `${destination} = Reflect.construct(${callee}, ${args});` +} + +function emitCollection(functionId, operation) { + const destination = registerName(functionId, operation.destination) + if (operation.kind === 'array') { + const values = operation.elements.map((register) => + registerName(functionId, register), + ) + return `${destination} = [${values.join(', ')}];` + } + const lines = [`${destination} = {};`] + for (const pair of operation.pairs) { + const key = registerName(functionId, pair.key) + const value = registerName(functionId, pair.value) + lines.push(`Reflect.set(${destination}, ${key}, ${value});`) + } + return lines.join('\n') +} + +export function emitInstruction( + functionId, + instruction, + functions, + emissionMode = 'dispatch', +) { + const operation = instruction.operation + if (!operation) + decline( + 'missing-switch-operation', + 'VM switch case has no semantic operation', + ) + const nextPc = instruction.nextPc + const next = + emissionMode === 'dispatch' + ? `__recovered_pc = ${nextPc};\ncontinue __recovered_dispatch;` + : '' + const destination = () => registerName(functionId, operation.destination) + const source = (register) => registerName(functionId, register) + + if (operation.kind === 'assign') { + const value = operation.value + let expression + if (value.kind === 'literal') expression = literal(value.value) + else if (value.kind === 'this') expression = 'this' + else if (value.kind === 'register') expression = source(value.index) + else if (value.kind === 'binary') + expression = `${source(value.left)} ${value.operator} ${source(value.right)}` + else if (value.kind === 'unary') + expression = `${value.operator}${source(value.source)}` + else if (value.kind === 'typeof') + expression = `typeof ${source(value.source)}` + else if (value.kind === 'undefined') expression = 'void 0' + else + decline( + 'unsupported-switch-value', + `Unknown VM switch value ${value.kind}`, + ) + return `${destination()} = ${expression};\n${next}` + } + if (operation.kind === 'global-read') { + const value = literal(operation.name) + return `if (!(${value} in globalThis)) throw new ReferenceError(${value} + ' is not defined');\n${destination()} = globalThis[${value}];\n${next}` + } + if (operation.kind === 'global-write') + return `globalThis[${literal(operation.name)}] = ${source(operation.source)};\n${next}` + if (operation.kind === 'upvalue-read') + return `${destination()} = __recovered_captures[${operation.index}].value;\n${next}` + if (operation.kind === 'upvalue-write') + return `__recovered_captures[${operation.index}].value = ${source(operation.source)};\n${next}` + if (operation.kind === 'property-read') + return `${destination()} = ${source(operation.object)}[${source(operation.key)}];\n${next}` + if (operation.kind === 'property-write') + return `Reflect.set(${source(operation.object)}, ${source(operation.key)}, ${source(operation.value)});\n${next}` + if (operation.kind === 'property-delete') + return `${destination()} = Reflect.deleteProperty(${source(operation.object)}, ${source(operation.key)});\n${next}` + if (operation.kind === 'safe-typeof') { + const global = literal(operation.name) + return `${destination()} = Object.prototype.hasOwnProperty.call(globalThis, ${global}) ? typeof globalThis[${global}] : 'undefined';\n${next}` + } + if (operation.kind === 'jump') + return `__recovered_pc = ${operation.target};\ncontinue __recovered_dispatch;` + if (operation.kind === 'branch') { + const condition = source(operation.condition) + const predicate = operation.when === 'false' ? `!${condition}` : condition + return `__recovered_pc = ${predicate} ? ${operation.target} : ${nextPc};\ncontinue __recovered_dispatch;` + } + if (operation.kind === 'indirect-jump') + return `__recovered_pc = ${source(operation.source)};\ncontinue __recovered_dispatch;` + if (operation.kind === 'call') + return `${emitCall(functionId, operation)}\n${next}` + if (operation.kind === 'return') return `return ${source(operation.source)};` + if (operation.kind === 'throw') return `throw ${source(operation.source)};` + if (operation.kind === 'closure') { + const captures = captureExpression(functionId, instruction, functions) + const child = functionForPc(functions, operation.entry) + const captureName = `__recovered_closure_captures_${functionId}_${instruction.pc}` + const localCells = operation.captures + .filter((operand) => operand.kind === 'local') + .map((operand) => operand.index) + const statements = localCells.map( + (register) => + `__recovered_cells_${functionId}[${register}] ||= { get value() { return ${registerName(functionId, register)}; }, set value(__value) { ${registerName(functionId, register)} = __value; } };`, + ) + if (child.captureCount > 0) + statements.push(`const ${captureName} = [${captures.join(', ')}];`) + const childArguments = + child.captureCount > 0 + ? `[${captureName}, ...__recovered_closure_args]` + : '[...__recovered_closure_args]' + statements.push( + `${destination()} = function (...__recovered_closure_args) { const __recovered_this = this == null ? globalThis : this; return Reflect.apply(${functionName(child.id)}, __recovered_this, ${childArguments}); };`, + ) + statements.push(next) + return statements.join('\n') + } + if (operation.kind === 'array' || operation.kind === 'object') + return `${emitCollection(functionId, operation)}\n${next}` + if (operation.kind === 'accessor') { + const object = source(operation.object) + const key = source(operation.key) + const callback = source(operation.callback) + const descriptorName = `__recovered_descriptor_${functionId}_${instruction.pc}` + const existingName = `__recovered_existing_${functionId}_${instruction.pc}` + const accessor = operation.accessor + const other = accessor === 'get' ? 'set' : 'get' + return `{\nconst ${existingName} = Object.getOwnPropertyDescriptor(${object}, ${key});\nconst ${descriptorName} = { ${accessor}: ${callback}, configurable: true, enumerable: true };\nif (${existingName} && typeof ${existingName}.${other} === 'function') ${descriptorName}.${other} = ${existingName}.${other};\nObject.defineProperty(${object}, ${key}, ${descriptorName});\n}\n${next}` + } + if (operation.kind === 'for-in-setup') + return `${destination()} = { keys: __recovered_enumerable_keys(${source(operation.source)}), index: 0 };\n${next}` + if (operation.kind === 'for-in-next') { + const iterator = source(operation.iterator) + return `if (${iterator}.index >= ${iterator}.keys.length) { __recovered_pc = ${operation.exit}; } else { ${destination()} = ${iterator}.keys[${iterator}.index++]; __recovered_pc = ${nextPc}; }\ncontinue __recovered_dispatch;` + } + if (operation.kind === 'catch-setup') + return `__recovered_handlers.push({ kind: 'catch', target: ${operation.target}, register: ${operation.exceptionRegister} });\n${next}` + if (operation.kind === 'handler-pop') + return `__recovered_handlers.pop();\n${next}` + if (operation.kind === 'finally-setup') + return `__recovered_handlers.push({ kind: 'finally', target: ${operation.target}, continuation: ${operation.continuation}, payload: ${operation.payload}, throwPad: ${operation.throwPad} });\n${next}` + if (operation.kind === 'debugger') return `void 0;\n${next}` + decline( + 'unsupported-switch-operation', + `VM switch emission does not support ${operation.kind}`, + ) +} + +export function emitHandlerRegisterAssignment( + functionId, + registerCount, + property, + value, +) { + const lines = [`switch (__recovered_handler.${property}) {`] + for (let register = 0; register < registerCount; register += 1) { + lines.push( + `case ${register}: ${registerName(functionId, register)} = ${value}; break;`, + ) + } + lines.push("default: throw new Error('Invalid recovered handler register');") + lines.push('}') + return lines +} + +export function appendRendered(lines, rendered) { + const renderedLines = rendered.split('\n') + while (renderedLines.at(-1) === '') renderedLines.pop() + lines.push(...renderedLines) +} + +export function invertPredicate(predicate) { + return predicate.startsWith('!') ? predicate.slice(1) : `!(${predicate})` +} + +export function structuredControlEdges(control, edges) { + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + for (const block of control.blocks) { + for (const edgeId of block.successorEdgeIds) + requireValue( + edgeById.get(edgeId), + 'invalid-structured-control', + `Structured block ${block.id} refers to a missing edge ${edgeId}`, + ) + } + return edgeById +} diff --git a/src/vm/switch/vm-switch-model.js b/src/vm/switch/vm-switch-model.js new file mode 100644 index 00000000..8d12727d --- /dev/null +++ b/src/vm/switch/vm-switch-model.js @@ -0,0 +1,423 @@ +export const VM_SWITCH_SCHEMA = 'decode-js-vm-switch.v1' + +const BINARY = new Set([ + '+', + '-', + '*', + '/', + '%', + '**', + '&', + '|', + '^', + '<<', + '>>', + '>>>', + '<', + '>', + '<=', + '>=', + '===', + '!==', + '==', + '!=', + 'in', + 'instanceof', +]) +const UNARY = new Set(['-', '+', '!', '~']) +const CODE_KINDS = new Set(['numeric-u32', 'binary', 'source-switch']) + +export class VmSwitchDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'VmSwitchDecline' + this.code = code + } +} + +function fail(code, message) { + throw new VmSwitchDecline(code, message) +} + +function nonnegative(value, label) { + if (!Number.isSafeInteger(value) || value < 0) + fail('invalid-switch-model', `${label} must be a non-negative safe integer`) +} + +function register(value, count, label) { + nonnegative(value, label) + if (value >= count) + fail( + 'invalid-switch-register', + `${label} exceeds the function register count`, + ) +} + +function literal(value) { + if (value === null || value === undefined) return + if (['string', 'boolean', 'number', 'bigint'].includes(typeof value)) return + fail( + 'unsupported-switch-literal', + 'VM switch literal is not a primitive value', + ) +} + +function registerList(values, count, label) { + if (!Array.isArray(values)) + fail('invalid-switch-model', `${label} must be an array`) + for (const value of values) register(value, count, label) +} + +function validateOperation(op, fn, functionEntries) { + const count = fn.regCount + if ( + !op || + typeof op !== 'object' || + Array.isArray(op) || + typeof op.kind !== 'string' + ) + fail('invalid-switch-operation', 'VM switch case has no typed operation') + const destination = () => register(op.destination, count, 'destination') + const source = () => register(op.source, count, 'source') + const name = () => { + if (typeof op.name !== 'string') + fail('invalid-switch-operation', 'Global name must be a string') + } + switch (op.kind) { + case 'assign': { + destination() + const value = op.value + if (!value || typeof value !== 'object') + fail('invalid-switch-operation', 'Assignment needs a typed value') + if (value.kind === 'literal') literal(value.value) + else if (value.kind === 'register') + register(value.index, count, 'value register') + else if (value.kind === 'binary') { + if (!BINARY.has(value.operator)) + fail( + 'unsupported-switch-operator', + 'Binary operator is not in the JS operator set', + ) + register(value.left, count, 'left register') + register(value.right, count, 'right register') + } else if (value.kind === 'unary') { + if (!UNARY.has(value.operator)) + fail( + 'unsupported-switch-operator', + 'Unary operator is not in the JS operator set', + ) + register(value.source, count, 'unary source') + } else if (value.kind === 'typeof') + register(value.source, count, 'typeof source') + else if (!['this', 'undefined'].includes(value.kind)) + fail( + 'unsupported-switch-value', + `Unknown VM switch value ${value.kind}`, + ) + return + } + case 'global-read': + case 'safe-typeof': + destination() + name() + return + case 'global-write': + name() + source() + return + case 'upvalue-read': + destination() + nonnegative(op.index, 'upvalue index') + if (op.index >= fn.captureCount) + fail('invalid-switch-reference', 'Upvalue read exceeds capture count') + return + case 'upvalue-write': + nonnegative(op.index, 'upvalue index') + if (op.index >= fn.captureCount) + fail('invalid-switch-reference', 'Upvalue write exceeds capture count') + source() + return + case 'property-read': + case 'property-delete': + destination() + register(op.object, count, 'property object') + register(op.key, count, 'property key') + return + case 'property-write': + register(op.object, count, 'property object') + register(op.key, count, 'property key') + register(op.value, count, 'property value') + return + case 'jump': + nonnegative(op.target, 'jump target') + return + case 'branch': + register(op.condition, count, 'branch condition') + if (!['true', 'false'].includes(op.when)) + fail('invalid-switch-operation', 'Branch polarity is invalid') + nonnegative(op.target, 'branch target') + return + case 'indirect-jump': + case 'return': + case 'throw': + source() + return + case 'call': + destination() + if (!['apply', 'construct'].includes(op.mode)) + fail('invalid-switch-operation', 'Call mode is invalid') + register(op.callee, count, 'callee') + if (op.receiver?.kind === 'register') + register(op.receiver.index, count, 'receiver') + else if (!['none', 'null', 'global'].includes(op.receiver?.kind)) + fail('invalid-switch-operation', 'Call receiver is invalid') + if (op.mode === 'construct' && op.receiver.kind !== 'none') + fail('invalid-switch-operation', 'Constructor call has a receiver') + if (op.mode === 'apply' && op.receiver.kind === 'none') + fail('invalid-switch-operation', 'Apply call has no receiver mode') + if ( + !op.arguments || + !['fixed', 'array-register'].includes(op.arguments.kind) + ) + fail('invalid-switch-operation', 'Call arguments are invalid') + registerList(op.arguments.registers, count, 'call arguments') + if ( + op.arguments.kind === 'array-register' && + op.arguments.registers.length !== 1 + ) + fail('invalid-switch-operation', 'Spread call needs one array register') + return + case 'closure': { + destination() + nonnegative(op.entry, 'closure entry') + const child = functionEntries.get(op.entry) + if (!child) + fail('invalid-switch-target', 'Closure entry is not a function') + if (!Array.isArray(op.captures)) + fail('invalid-switch-operation', 'Closure captures are missing') + if (op.captures.length !== child.captureCount) + fail( + 'invalid-switch-reference', + 'Closure capture count differs from child frame', + ) + for (const capture of op.captures) { + if (!['local', 'upvalue'].includes(capture?.kind)) + fail('invalid-switch-operation', 'Closure capture kind is invalid') + if (capture.kind === 'local') + register(capture.index, count, 'local capture') + else { + nonnegative(capture.index, 'upvalue capture') + if (capture.index >= fn.captureCount) + fail( + 'invalid-switch-reference', + 'Inherited capture exceeds parent frame', + ) + } + } + return + } + case 'array': + destination() + registerList(op.elements, count, 'array elements') + return + case 'object': + destination() + if (!Array.isArray(op.pairs)) + fail('invalid-switch-operation', 'Object pairs are missing') + for (const pair of op.pairs) { + register(pair?.key, count, 'object key') + register(pair?.value, count, 'object value') + } + return + case 'accessor': + if (!['get', 'set'].includes(op.accessor)) + fail('invalid-switch-operation', 'Accessor kind is invalid') + register(op.object, count, 'accessor object') + register(op.key, count, 'accessor key') + register(op.callback, count, 'accessor callback') + return + case 'for-in-setup': + destination() + source() + return + case 'for-in-next': + destination() + register(op.iterator, count, 'for-in iterator') + nonnegative(op.exit, 'for-in exit') + return + case 'catch-setup': + nonnegative(op.target, 'catch target') + register(op.exceptionRegister, count, 'exception register') + return + case 'handler-pop': + case 'debugger': + return + case 'finally-setup': + nonnegative(op.target, 'finally target') + register(op.continuation, count, 'continuation register') + register(op.payload, count, 'payload register') + nonnegative(op.throwPad, 'throw pad') + return + default: + fail( + 'unsupported-switch-operation', + `Unknown VM switch operation ${op.kind}`, + ) + } +} + +export function validateVmSwitchModel(model) { + if ( + model?.schemaVersion !== VM_SWITCH_SCHEMA || + !CODE_KINDS.has(model.code?.kind) || + model.storage?.kind !== 'register-machine' || + !Array.isArray(model.storage.functions) || + !Array.isArray(model.functions?.functions) || + !(model.instructionsByFunction instanceof Map) || + (model.controlByFunction !== undefined && + !(model.controlByFunction instanceof Map)) || + (model.structuredControl !== undefined && + !Array.isArray(model.structuredControl?.edges)) || + (model.controlByFunction === undefined) !== + (model.structuredControl === undefined) + ) + fail('invalid-switch-model', 'VM switch model has an invalid envelope') + const functions = model.functions.functions + if (!functions.length || functions[0].id !== 0) + fail('invalid-switch-model', 'VM switch requires function zero as its root') + const entries = new Map(functions.map((fn) => [fn.startPc, fn])) + if (entries.size !== functions.length) + fail('invalid-switch-model', 'Function entries are not unique') + if (model.storage.functions.length !== functions.length) + fail('invalid-switch-model', 'Storage does not cover every function') + if ( + new Set(model.storage.functions.map(({ id }) => id)).size !== + functions.length + ) + fail('invalid-switch-model', 'Function storage identities are not unique') + nonnegative(model.code.instructionCount, 'code instruction count') + if (model.code.kind === 'numeric-u32') + nonnegative(model.code.wordCount, 'code word count') + const edgeById = new Map() + for (const edge of model.structuredControl?.edges ?? []) { + if (typeof edge?.id !== 'string' || edgeById.has(edge.id)) + fail('invalid-switch-model', 'Control edge IDs are not unique') + edgeById.set(edge.id, edge) + } + const seen = new Set() + const ids = new Set() + let total = 0 + for (const fn of functions) { + nonnegative(fn.id, 'function ID') + nonnegative(fn.startPc, 'function entry') + nonnegative(fn.regCount, 'register count') + nonnegative(fn.paramCount, 'parameter count') + nonnegative(fn.captureCount, 'capture count') + if ( + ids.has(fn.id) || + fn.paramCount > fn.regCount || + typeof fn.hasRest !== 'boolean' + ) + fail('invalid-switch-model', 'Function metadata is inconsistent') + ids.add(fn.id) + const storage = model.storage.functions.find(({ id }) => id === fn.id) + if ( + !storage || + storage.registerCount !== fn.regCount || + storage.captureCount !== fn.captureCount + ) + fail('invalid-switch-model', 'Function storage does not match its frame') + const cases = model.instructionsByFunction.get(fn.id) + const control = model.controlByFunction?.get(fn.id) + if (!Array.isArray(cases) || !cases.length) + fail('invalid-switch-model', `Function ${fn.id} has no cases`) + if ( + model.controlByFunction && + !['structured', 'state-machine'].includes(control?.mode) + ) + fail('invalid-switch-model', `Function ${fn.id} has no control mode`) + const local = new Set() + for (const item of cases) { + nonnegative(item?.pc, 'case PC') + nonnegative(item?.nextPc, 'next PC') + if (item.nextPc <= item.pc || seen.has(item.pc)) + fail( + 'invalid-switch-model', + 'VM switch case identity or order is invalid', + ) + seen.add(item.pc) + local.add(item.pc) + validateOperation(item.operation, fn, entries) + } + if (!local.has(fn.startPc)) + fail('invalid-switch-model', 'Function entry has no case') + for (const item of cases) { + const op = item.operation + const targets = [] + if (['jump', 'branch', 'catch-setup', 'finally-setup'].includes(op.kind)) + targets.push(op.target) + if (op.kind === 'for-in-next') targets.push(op.exit) + if (op.kind === 'finally-setup') targets.push(op.throwPad) + if (targets.some((target) => !local.has(target))) + fail( + 'invalid-switch-target', + `Function ${fn.id} transfers to a missing case`, + ) + if ( + !['jump', 'indirect-jump', 'return', 'throw'].includes(op.kind) && + !local.has(item.nextPc) + ) + fail( + 'invalid-switch-target', + `Function ${fn.id} falls through to a missing case`, + ) + } + if (control?.mode === 'structured') { + if (!Array.isArray(control.blocks)) + fail( + 'invalid-switch-model', + 'Structured control has no block inventory', + ) + const leaves = control.blocks.flatMap((block) => block.leaves ?? []) + if ( + leaves.length !== cases.length || + new Set(leaves.map(({ pc }) => pc)).size !== cases.length || + leaves.some( + (leaf) => + !local.has(leaf.pc) || + leaf.operation !== + cases.find((item) => item.pc === leaf.pc).operation, + ) + ) + fail( + 'incomplete-switch-cases', + 'Structured leaves do not match VM switch cases', + ) + for (const block of control.blocks) { + if ( + block.functionId !== fn.id || + !Array.isArray(block.successorEdgeIds) || + block.successorEdgeIds.some((id) => { + const edge = edgeById.get(id) + return ( + !edge || + edge.functionId !== fn.id || + !local.has(edge.sourcePc) || + (edge.targetPc !== null && !local.has(edge.targetPc)) + ) + }) + ) + fail('invalid-switch-model', 'Structured block has an invalid edge') + } + } + total += cases.length + } + if ( + model.instructionsByFunction.size !== ids.size || + (model.controlByFunction && model.controlByFunction.size !== ids.size) || + total !== model.instructionCount || + total !== model.code.instructionCount + ) + fail('incomplete-switch-cases', 'VM switch case census is incomplete') + return model +} diff --git a/src/vm/switch/vm-switch-structured-emitter.js b/src/vm/switch/vm-switch-structured-emitter.js new file mode 100644 index 00000000..cf013042 --- /dev/null +++ b/src/vm/switch/vm-switch-structured-emitter.js @@ -0,0 +1,809 @@ +import { + appendRendered, + decline, + effectiveStructuredInstructions, + emitInstruction, + functionName, + functionNeedsArguments, + functionRegisterSet, + invertPredicate, + localCaptureRegisterSet, + registerName, + requireValue, + retargetDestination, + structuredControlEdges, +} from './vm-switch-copy-planning.js' + +class StructuredFunctionEmitter { + constructor( + functionId, + instructions, + functionRecord, + controlFunction, + edges, + functions, + copyPlan, + coalescedMovePcs, + ) { + this.functionId = functionId + this.instructions = instructions + this.functionRecord = functionRecord + this.controlFunction = controlFunction + this.blocks = controlFunction.blocks + this.blockById = new Map(this.blocks.map((block) => [block.id, block])) + this.edgeById = structuredControlEdges(controlFunction, edges) + this.functions = functions + this.copyPlan = copyPlan + this.coalescedMovePcs = coalescedMovePcs + this.conditionalByTest = new Map( + controlFunction.control.regions + .filter(({ kind }) => kind === 'conditional') + .map((region) => [region.testBlockId, region]), + ) + this.loopByHeader = new Map( + controlFunction.control.regions + .filter(({ kind }) => kind === 'natural-loop') + .map((region) => [region.headerBlockId, region]), + ) + this.emittedBlocks = new Set() + this.loopNumber = 0 + } + + blockForPc(pc) { + const block = this.blocks.find(({ leaves }) => + leaves.some((leaf) => leaf.pc === pc), + ) + return requireValue( + block, + 'invalid-structured-control', + `Structured control has no block for target ${pc}`, + ) + } + + outgoing(block) { + return block.successorEdgeIds.map((edgeId) => + requireValue( + this.edgeById.get(edgeId), + 'invalid-structured-control', + `Structured block ${block.id} refers to missing edge ${edgeId}`, + ), + ) + } + + targetBlock(edge) { + return edge.targetPc === null ? null : this.blockForPc(edge.targetPc).id + } + + markBlock(block) { + if (this.emittedBlocks.has(block.id)) + decline( + 'unemittable-structured-control', + `Structured block ${block.id} would be emitted more than once`, + ) + this.emittedBlocks.add(block.id) + } + + activeLoop(context) { + return context.loops.at(-1) ?? null + } + + loopTransfer(context, target) { + for (let index = context.loops.length - 1; index >= 0; index -= 1) { + const loop = context.loops[index] + if (target === loop.headerBlockId) + return { kind: 'continue', loop, target } + if (loop.exitTargets.has(target)) return { kind: 'break', loop, target } + if (loop.exitAliases.has(target)) + return { + kind: 'break', + loop, + target: loop.exitAliases.get(target), + } + } + return null + } + + edgeTo(block, kind) { + return this.outgoing(block).find(({ kind: edgeKind }) => edgeKind === kind) + } + + renderInstruction(instruction, lines) { + if (instruction.coalesced || this.coalescedMovePcs.has(instruction.pc)) + return + appendRendered( + lines, + emitInstruction( + this.functionId, + instruction, + this.functions, + 'structured', + ), + ) + } + + renderNonTerminatingLeaves(block, lines) { + let last = block.leaves.at(-1) + for (let index = 0; index < block.leaves.length - 1; index += 1) { + const instruction = block.leaves[index] + const move = block.leaves[index + 1] + const destination = this.copyPlan.get(instruction.pc) + if ( + destination !== undefined && + move.operation.kind === 'assign' && + move.operation.value.kind === 'register' + ) { + this.renderInstruction( + retargetDestination(instruction, destination), + lines, + ) + if (move === last) last = { ...move, coalesced: true } + index += 1 + continue + } + this.renderInstruction(instruction, lines) + } + if (this.copyPlan.has(last.pc)) + last = retargetDestination(last, this.copyPlan.get(last.pc)) + if (this.coalescedMovePcs.has(last.pc)) last = { ...last, coalesced: true } + return last + } + + renderConditional(region, context, stopIds) { + const test = this.blockById.get(region.testBlockId) + requireValue( + test, + 'invalid-structured-control', + `Conditional region refers to missing test block ${region.testBlockId}`, + ) + this.markBlock(test) + const lines = [] + const instruction = this.renderNonTerminatingLeaves(test, lines) + if (instruction.operation.kind !== 'branch') + decline( + 'unemittable-structured-control', + `Conditional region ${region.testBlockId} has a non-conditional leaf`, + ) + const branchEdge = this.edgeById.get(region.branchEdgeId) + const fallthroughEdge = this.edgeById.get(region.fallthroughEdgeId) + if ( + !branchEdge || + !fallthroughEdge || + branchEdge.kind !== 'conditional' || + fallthroughEdge.kind !== 'fallthrough' + ) + decline( + 'invalid-structured-control', + `Conditional region ${region.testBlockId} has stale edge metadata`, + ) + const merge = region.mergeBlockId + const branch = this.sequence( + this.targetBlock(branchEdge), + new Set([...stopIds, merge]), + context, + ) + const fallthrough = this.sequence( + this.targetBlock(fallthroughEdge), + new Set([...stopIds, merge]), + context, + ) + const nonTerminated = [branch, fallthrough].filter( + (path) => !path.terminated, + ) + const nextIds = new Set(nonTerminated.map(({ nextBlockId }) => nextBlockId)) + const mergeIsStructuredStop = + stopIds.has(merge) || + context.loops.some( + (loop) => + loop.headerBlockId === merge || + loop.exitTargets.has(merge) || + loop.exitAliases.has(merge), + ) + if ( + (nextIds.size > 1 && + ![...nextIds].every((nextBlockId) => nextBlockId === merge)) || + (nextIds.size === 1 && + ![...nextIds].every( + (nextBlockId) => nextBlockId === merge || stopIds.has(nextBlockId), + )) || + (!nonTerminated.length && !mergeIsStructuredStop) + ) + decline( + 'unemittable-structured-control', + `Conditional region ${region.testBlockId} does not reconverge at ${merge}`, + ) + const condition = registerName( + this.functionId, + instruction.operation.condition, + ) + const predicate = + instruction.operation.when === 'false' ? `!${condition}` : condition + if (branch.lines.length === 0 && fallthrough.lines.length === 0) { + // Both paths only reconverge; the condition has no recovered body. + } else if (branch.lines.length === 0) { + lines.push(`if (${invertPredicate(predicate)}) {`) + lines.push(...fallthrough.lines.map((line) => ` ${line}`)) + lines.push('}') + } else if (fallthrough.lines.length === 0) { + lines.push(`if (${predicate}) {`) + lines.push(...branch.lines.map((line) => ` ${line}`)) + lines.push('}') + } else { + lines.push(`if (${predicate}) {`) + lines.push(...branch.lines.map((line) => ` ${line}`)) + lines.push('} else {') + lines.push(...fallthrough.lines.map((line) => ` ${line}`)) + lines.push('}') + } + if (!nonTerminated.length) + return { + lines, + nextBlockId: merge, + terminated: true, + } + return { + lines, + nextBlockId: [...nextIds][0], + terminated: false, + } + } + + normalTargets(block) { + return this.outgoing(block) + .filter( + ({ kind, targetPc }) => + ['fallthrough', 'branch', 'conditional', 'call'].includes(kind) && + targetPc !== null, + ) + .map((edge) => this.targetBlock(edge)) + } + + findTryEnd(startBlockId) { + const queue = [{ blockId: startBlockId, distance: 0 }] + const visited = new Set() + const candidates = [] + while (queue.length) { + const { blockId, distance } = queue.shift() + if (visited.has(blockId)) continue + visited.add(blockId) + const block = this.blockById.get(blockId) + requireValue( + block, + 'invalid-structured-control', + `TRY_SETUP body refers to missing block ${blockId}`, + ) + if (block.leaves.at(-1).operation.kind === 'handler-pop') { + candidates.push({ blockId, distance }) + continue + } + for (const target of this.normalTargets(block)) + queue.push({ blockId: target, distance: distance + 1 }) + } + if (!candidates.length) + decline( + 'unemittable-structured-control', + `TRY_SETUP at ${startBlockId} has no proved TRY_END`, + ) + const nearest = Math.min(...candidates.map(({ distance }) => distance)) + const nearestCandidates = candidates.filter( + ({ distance }) => distance === nearest, + ) + if (nearestCandidates.length !== 1) + decline( + 'unemittable-structured-control', + `TRY_SETUP at ${startBlockId} has ambiguous TRY_END structure`, + ) + return nearestCandidates[0].blockId + } + + renderHandler(handler, exceptionRegister, lines) { + this.markBlock(handler) + lines.push('} catch (__recovered_error) {') + lines.push( + ` ${registerName(this.functionId, exceptionRegister)} = __recovered_error;`, + ) + const last = handler.leaves.at(-1) + for (const instruction of handler.leaves) { + if ( + instruction === last && + ['return', 'throw', 'jump'].includes(instruction.operation.kind) + ) + break + this.renderInstruction(instruction, lines) + } + if (['return', 'throw'].includes(last.operation.kind)) { + const source = registerName(this.functionId, last.operation.source) + lines.push(`${last.operation.kind} ${source};`) + } else if (last.operation.kind === 'jump') { + decline( + 'unemittable-structured-control', + `Handler block ${handler.id} has an unstructured jump`, + ) + } + lines.push('}') + } + + renderTryCatch(block, context) { + this.markBlock(block) + const setup = block.leaves.at(-1) + if (setup.operation.kind !== 'catch-setup') + decline( + 'unemittable-structured-control', + `Expected TRY_SETUP at ${block.startPc}`, + ) + const handler = this.blockForPc(setup.operation.target) + const exceptionRegister = setup.operation.exceptionRegister + const bodyStartEdge = this.edgeTo(block, 'fallthrough') + if (!bodyStartEdge || bodyStartEdge.targetPc === null) + decline( + 'unemittable-structured-control', + `TRY_SETUP at ${setup.pc} has no body entry`, + ) + const tryEndId = this.findTryEnd(this.targetBlock(bodyStartEdge)) + const body = this.sequence( + this.targetBlock(bodyStartEdge), + new Set([tryEndId]), + context, + ) + if (body.terminated || body.nextBlockId !== tryEndId) + decline( + 'unemittable-structured-control', + `TRY_SETUP at ${setup.pc} has an unstructured body`, + ) + const tryEnd = this.blockById.get(tryEndId) + this.markBlock(tryEnd) + if (tryEnd.leaves.at(-1).operation.kind !== 'handler-pop') + decline( + 'unemittable-structured-control', + `Expected TRY_END at ${tryEnd.startPc}`, + ) + const afterTryEdge = this.edgeTo(tryEnd, 'fallthrough') + if (!afterTryEdge || afterTryEdge.targetPc === null) + decline( + 'unemittable-structured-control', + `TRY_END at ${tryEnd.startPc} has no continuation`, + ) + const normalNext = this.blockForPc(afterTryEdge.targetPc) + const handlerNext = this.blockForPc(handler.leaves.at(-1).nextPc) + let continuation = normalNext + if (normalNext.leaves.at(-1).operation.kind === 'jump') { + const jump = normalNext.leaves.at(-1) + const target = this.blockForPc(jump.operation.target) + if (target.id !== handlerNext.id) + decline( + 'unemittable-structured-control', + `TRY_END at ${tryEnd.startPc} has a non-shared continuation`, + ) + this.markBlock(normalNext) + continuation = handlerNext + } else if (normalNext.id !== handlerNext.id) { + decline( + 'unemittable-structured-control', + `TRY_SETUP at ${setup.pc} does not rejoin after its handler`, + ) + } + const lines = ['try {'] + lines.push(...body.lines.map((line) => ` ${line}`)) + this.renderHandler(handler, exceptionRegister, lines) + return { lines, nextBlockId: continuation.id, terminated: false } + } + + renderForInNext(block, context) { + const loop = this.activeLoop(context) + if (!loop) + decline( + 'unemittable-structured-control', + `FOR_IN_NEXT at ${block.startPc} is not inside a proved natural loop`, + ) + const instruction = block.leaves.at(-1) + const outgoing = this.outgoing(block) + const branch = outgoing.find((edge) => edge.kind === 'branch') + const fallthrough = outgoing.find((edge) => edge.kind === 'fallthrough') + if (!branch || !fallthrough || branch.targetPc === null) + decline( + 'unemittable-structured-control', + `FOR_IN_NEXT at ${instruction.pc} has no natural loop exit`, + ) + const exit = this.targetBlock(branch) + if (!loop.exitTargets.has(exit)) + decline( + 'unemittable-structured-control', + `FOR_IN_NEXT at ${instruction.pc} exits outside the proved loop`, + ) + const iterator = registerName( + this.functionId, + instruction.operation.iterator, + ) + const destination = registerName( + this.functionId, + instruction.operation.destination, + ) + const lines = [ + `if (${iterator}.index >= ${iterator}.keys.length) break ${loop.label};`, + `${destination} = ${iterator}.keys[${iterator}.index++];`, + ] + return { + lines, + nextBlockId: this.targetBlock(fallthrough), + terminated: false, + } + } + + renderJump(block, context, stopIds, lines) { + const outgoing = this.outgoing(block) + const edge = outgoing.find(({ kind }) => kind === 'branch') + if ( + !edge || + edge.targetPc === null || + outgoing.some(({ kind }) => kind === 'finally') + ) + decline( + 'unemittable-structured-control', + `Unconditional jump at ${block.startPc} is not a direct structured edge`, + ) + const target = this.targetBlock(edge) + const transfer = this.loopTransfer(context, target) + if (transfer?.kind === 'continue') { + lines.push(`continue ${transfer.loop.label};`) + return { lines, nextBlockId: target, terminated: true } + } + if (transfer?.kind === 'break') { + lines.push(`break ${transfer.loop.label};`) + return { lines, nextBlockId: transfer.target, terminated: true } + } + if (stopIds.has(target)) + return { lines, nextBlockId: target, terminated: false } + decline( + 'unemittable-structured-control', + `Unconditional jump at ${block.startPc} targets ${target} outside its proved region`, + ) + } + + renderReturnOrThrow(block, instruction, lines) { + const outgoing = this.outgoing(block) + const edge = outgoing.find( + ({ kind }) => kind === 'return' || kind === 'throw', + ) + if ( + !edge || + edge.targetPc !== null || + outgoing.some(({ kind }) => kind === 'finally' || kind === 'handler') + ) + decline( + 'unemittable-structured-control', + `${instruction.operation.kind} at ${instruction.pc} has an unstructured completion route`, + ) + const source = registerName(this.functionId, instruction.operation.source) + lines.push(`${instruction.operation.kind} ${source};`) + return { lines, nextBlockId: null, terminated: true } + } + + renderOrdinary(block, instruction, lines) { + const outgoing = this.outgoing(block) + const normal = outgoing.filter( + ({ kind }) => kind === 'fallthrough' || kind === 'call', + ) + const exceptional = outgoing.filter(({ kind }) => + ['throw', 'handler', 'finally'].includes(kind), + ) + if ( + normal.length !== 1 || + normal[0].targetPc === null || + exceptional.some( + ({ targetPc, kind }) => kind !== 'throw' || targetPc !== null, + ) + ) + decline( + 'unemittable-structured-control', + `${instruction.operation.kind} at ${instruction.pc} has an unstructured successor`, + ) + this.renderInstruction(instruction, lines) + return { + lines, + nextBlockId: this.targetBlock(normal[0]), + terminated: false, + } + } + + emitBlock(block, context, stopIds) { + if (block.leaves.at(-1).operation.kind === 'catch-setup') + return this.renderTryCatch(block, context) + this.markBlock(block) + const lines = [] + const instruction = this.renderNonTerminatingLeaves(block, lines) + if (instruction.operation.kind === 'jump') + return this.renderJump(block, context, stopIds, lines) + if (['return', 'throw'].includes(instruction.operation.kind)) + return this.renderReturnOrThrow(block, instruction, lines) + if (instruction.operation.kind === 'branch') + decline( + 'unemittable-structured-control', + `Conditional block ${block.id} has no qualified conditional region`, + ) + if (instruction.operation.kind === 'indirect-jump') + decline( + 'unemittable-structured-control', + `JUMP_REG at ${instruction.pc} lacks a natural structured representation`, + ) + if (instruction.operation.kind === 'for-in-next') + return this.renderForInNext(block, context) + if ( + ['catch-setup', 'handler-pop', 'finally-setup'].includes( + instruction.operation.kind, + ) + ) + decline( + 'unemittable-structured-control', + `${instruction.operation.kind} at ${instruction.pc} requires structured exception syntax`, + ) + return this.renderOrdinary(block, instruction, lines) + } + + emitLoop(region, context) { + const bodyIds = new Set(region.bodyBlockIds) + const exitEdges = region.exitEdgeIds.map((edgeId) => + requireValue( + this.edgeById.get(edgeId), + 'invalid-structured-control', + `Natural loop ${region.headerBlockId} refers to missing exit edge`, + ), + ) + const rawExitTargets = new Set( + exitEdges + .filter((edge) => edge.targetPc !== null) + .map((edge) => this.targetBlock(edge)) + .filter((target) => !bodyIds.has(target)), + ) + const parentLoops = context.loops + const exitTargets = new Set( + [...rawExitTargets].filter( + (target) => + !parentLoops.some( + (loop) => + loop.exitTargets.has(target) || loop.exitAliases.has(target), + ), + ), + ) + const exitAliases = new Map() + for (const target of [...exitTargets]) { + const block = this.blockById.get(target) + const terminal = block?.leaves.at(-1) + if ( + block && + block.leaves.length === 1 && + terminal.operation.kind === 'jump' + ) { + const jumpTarget = this.blockForPc(terminal.operation.target).id + if (!bodyIds.has(jumpTarget) && jumpTarget !== target) { + exitTargets.delete(target) + exitTargets.add(jumpTarget) + exitAliases.set(target, jumpTarget) + } + } + } + if (exitTargets.size !== 1) + decline( + 'unemittable-structured-control', + `Natural loop ${region.headerBlockId} has multiple unstructured exits`, + ) + const label = `__recovered_loop_${this.functionId}_${this.loopNumber++}` + const active = { + headerBlockId: region.headerBlockId, + exitTargets, + exitAliases, + label, + } + const body = this.sequence(region.headerBlockId, exitTargets, { + ...context, + loops: [...context.loops, active], + }) + if (!body.terminated && !exitTargets.has(body.nextBlockId)) + decline( + 'unemittable-structured-control', + `Natural loop ${region.headerBlockId} did not reach its proved exit`, + ) + const lines = [`${label}: while (true) {`] + lines.push(...body.lines.map((line) => ` ${line}`)) + if (!body.terminated) lines.push(` break ${label};`) + lines.push('}') + return { lines, nextBlockId: [...exitTargets][0], terminated: false } + } + + sequence(startBlockId, stopIds, context) { + const lines = [] + let current = startBlockId + const visited = new Set() + while (current !== null && !stopIds.has(current)) { + if (visited.has(current)) + decline( + 'unemittable-structured-control', + `Structured path revisits block ${current} without a proved loop`, + ) + visited.add(current) + const loop = this.loopByHeader.get(current) + if (loop && this.activeLoop(context)?.headerBlockId !== current) { + const result = this.emitLoop(loop, context) + lines.push(...result.lines) + current = result.nextBlockId + continue + } + const transfer = + this.activeLoop(context)?.headerBlockId === current + ? null + : this.loopTransfer(context, current) + if (transfer?.kind === 'continue') { + const transferBlock = this.blockById.get(current) + if (transferBlock?.leaves.at(-1).operation.kind === 'jump') + this.markBlock(transferBlock) + lines.push(`continue ${transfer.loop.label};`) + return { lines, nextBlockId: current, terminated: true } + } + if (transfer?.kind === 'break') { + const transferBlock = this.blockById.get(current) + if (transferBlock?.leaves.at(-1).operation.kind === 'jump') + this.markBlock(transferBlock) + lines.push(`break ${transfer.loop.label};`) + return { lines, nextBlockId: transfer.target, terminated: true } + } + const conditional = this.conditionalByTest.get(current) + if (conditional) { + const result = this.renderConditional(conditional, context, stopIds) + lines.push(...result.lines) + if (result.terminated) + return { lines, nextBlockId: result.nextBlockId, terminated: true } + current = result.nextBlockId + continue + } + const block = this.blockById.get(current) + requireValue( + block, + 'invalid-structured-control', + `Structured path refers to missing block ${current}`, + ) + const result = this.emitBlock(block, context, stopIds) + lines.push(...result.lines) + if (result.terminated) + return { lines, nextBlockId: result.nextBlockId, terminated: true } + current = result.nextBlockId + } + return { lines, nextBlockId: current, terminated: false } + } + + emitUnreachableBlocks(lines) { + for (const block of this.blocks) { + if (this.emittedBlocks.has(block.id)) continue + if (block.leaves.some(({ reachable }) => reachable)) + decline( + 'unemittable-structured-control', + `Reachable structured block ${block.id} was not covered by qualified regions`, + ) + lines.push('if (false) {') + for (const instruction of block.leaves) { + if ( + [ + 'jump', + 'branch', + 'indirect-jump', + 'for-in-next', + 'return', + 'throw', + 'catch-setup', + 'handler-pop', + 'finally-setup', + ].includes(instruction.operation.kind) + ) + lines.push(' void 0;') + else { + const rendered = [] + this.renderInstruction(instruction, rendered) + lines.push(...rendered.map((line) => ` ${line}`)) + } + } + lines.push('}') + this.emittedBlocks.add(block.id) + } + } + + emit() { + const entry = `${this.functionId}:b@${this.functionRecord.startPc}` + const result = this.sequence(entry, new Set(), { loops: [] }) + if (!result.terminated || result.nextBlockId !== null) + decline( + 'unemittable-structured-control', + `Structured function ${this.functionId} has no complete natural exit`, + ) + this.emitUnreachableBlocks(result.lines) + return result.lines + } +} + +export function emitStructuredFunction( + functionId, + instructions, + functionRecord, + controlFunction, + edges, + functions, +) { + const lines = [] + const indent = (line) => ` ${line}` + const effective = effectiveStructuredInstructions( + functionId, + instructions, + controlFunction, + edges, + functionRecord.regCount, + ) + const registers = functionRegisterSet(effective.instructions) + const localCaptureRegisters = localCaptureRegisterSet(instructions) + const needsCaptures = functionRecord.captureCount > 0 + const needsArguments = functionNeedsArguments( + effective.instructions, + functionRecord, + ) + const parameters = [] + if (needsCaptures) parameters.push('__recovered_captures') + if (needsArguments) parameters.push('...__recovered_args') + lines.push(`function ${functionName(functionId)}(${parameters.join(', ')}) {`) + if (localCaptureRegisters.size > 0) + lines.push(indent(`const __recovered_cells_${functionId} = [];`)) + if (registers.size > 0) + lines.push( + indent( + `let ${[...registers] + .sort((left, right) => left - right) + .map((register) => registerName(functionId, register)) + .join(', ')};`, + ), + ) + if (functionId !== 0) { + if (needsArguments && functionRecord.hasRest) { + const restRegister = functionRecord.paramCount - 1 + for (let register = 0; register < restRegister; register++) { + if (!registers.has(register)) continue + lines.push( + indent( + `${registerName(functionId, register)} = __recovered_args[${register}];`, + ), + ) + } + if (registers.has(restRegister)) + lines.push( + indent( + `${registerName(functionId, restRegister)} = __recovered_args.slice(${restRegister});`, + ), + ) + } else if (needsArguments) { + for (let register = 0; register < functionRecord.paramCount; register++) { + if (!registers.has(register)) continue + lines.push( + indent( + `${registerName(functionId, register)} = __recovered_args[${register}];`, + ), + ) + } + } + if ( + needsArguments && + functionRecord.paramCount < functionRecord.regCount && + registers.has(functionRecord.paramCount) + ) + lines.push( + indent( + `${registerName(functionId, functionRecord.paramCount)} = __recovered_args;`, + ), + ) + } + const body = new StructuredFunctionEmitter( + functionId, + instructions, + functionRecord, + controlFunction, + edges, + functions, + effective.copyPlan, + effective.coalescedMovePcs, + ).emit() + lines.push(...body.map(indent)) + lines.push('}') + return { lines, copyProofs: effective.copyProofs } +} diff --git a/src/vm/switch/vm-switch-to-source.js b/src/vm/switch/vm-switch-to-source.js new file mode 100644 index 00000000..c24f6307 --- /dev/null +++ b/src/vm/switch/vm-switch-to-source.js @@ -0,0 +1,266 @@ +import { parse } from '@babel/parser' +import { buildVmSwitchControl } from './vm-switch-control.js' +import { VmSwitchDecline, validateVmSwitchModel } from './vm-switch-model.js' +import { + decline, + emitHandlerRegisterAssignment, + emitInstruction, + functionName, + functionNeedsArguments, + localCaptureRegisterSet, + registerName, +} from './vm-switch-copy-planning.js' +import { emitStructuredFunction } from './vm-switch-structured-emitter.js' + +function emitStateMachineFunction( + functionId, + instructions, + functionRecord, + functions, +) { + const lines = [] + const indent = (line) => ` ${line}` + const needsCaptures = functionRecord.captureCount > 0 + const needsArguments = functionNeedsArguments(instructions, functionRecord) + const hasHandlerSetup = instructions.some(({ operation }) => + ['catch-setup', 'finally-setup'].includes(operation.kind), + ) + const usesHandlers = + hasHandlerSetup || + instructions.some(({ operation }) => operation.kind === 'handler-pop') + const parameters = [] + if (needsCaptures) parameters.push('__recovered_captures') + if (needsArguments) parameters.push('...__recovered_args') + lines.push(`function ${functionName(functionId)}(${parameters.join(', ')}) {`) + const localCaptureRegisters = localCaptureRegisterSet(instructions) + if (localCaptureRegisters.size > 0) + lines.push(indent(`const __recovered_cells_${functionId} = [];`)) + if (functionRecord.regCount > 0) + lines.push( + indent( + `let ${Array.from({ length: functionRecord.regCount }, (_, register) => registerName(functionId, register)).join(', ')};`, + ), + ) + if (usesHandlers) lines.push(indent('const __recovered_handlers = [];')) + if (functionId !== 0) { + if (needsArguments && functionRecord.hasRest) { + const restRegister = functionRecord.paramCount - 1 + for (let register = 0; register < restRegister; register++) + lines.push( + indent( + `${registerName(functionId, register)} = __recovered_args[${register}];`, + ), + ) + lines.push( + indent( + `${registerName(functionId, restRegister)} = __recovered_args.slice(${restRegister});`, + ), + ) + } else if (needsArguments) { + for (let register = 0; register < functionRecord.paramCount; register++) + lines.push( + indent( + `${registerName(functionId, register)} = __recovered_args[${register}];`, + ), + ) + } + if (needsArguments && functionRecord.paramCount < functionRecord.regCount) + lines.push( + indent( + `${registerName(functionId, functionRecord.paramCount)} = __recovered_args;`, + ), + ) + } + lines.push(indent(`let __recovered_pc = ${functionRecord.startPc};`)) + lines.push(indent('__recovered_dispatch: while (true) {')) + if (hasHandlerSetup) lines.push(indent(' try {')) + const switchIndent = hasHandlerSetup ? ' ' : ' ' + lines.push(indent(`${switchIndent}switch (__recovered_pc) {`)) + for (const instruction of instructions) { + const body = emitInstruction(functionId, instruction, functions) + lines.push(indent(`${switchIndent} case ${instruction.pc}: {`)) + for (const line of body.split('\n')) + lines.push(indent(`${switchIndent} ${line}`)) + lines.push(indent(`${switchIndent} }`)) + } + lines.push( + indent( + `${switchIndent} default: throw new Error('Invalid recovered control state');`, + ), + ) + lines.push(indent(`${switchIndent}}`)) + if (hasHandlerSetup) { + lines.push(indent(' } catch (__recovered_error) {')) + lines.push( + indent(' const __recovered_handler = __recovered_handlers.pop();'), + ) + lines.push(indent(' if (!__recovered_handler) throw __recovered_error;')) + lines.push(indent(" if (__recovered_handler.kind === 'catch') {")) + for (const line of emitHandlerRegisterAssignment( + functionId, + functionRecord.regCount, + 'register', + '__recovered_error', + )) + lines.push(indent(` ${line}`)) + lines.push(indent(' __recovered_pc = __recovered_handler.target;')) + lines.push(indent(' continue __recovered_dispatch;')) + lines.push(indent(' }')) + for (const line of emitHandlerRegisterAssignment( + functionId, + functionRecord.regCount, + 'continuation', + '__recovered_handler.throwPad', + )) + lines.push(indent(` ${line}`)) + for (const line of emitHandlerRegisterAssignment( + functionId, + functionRecord.regCount, + 'payload', + '__recovered_error', + )) + lines.push(indent(` ${line}`)) + lines.push(indent(' __recovered_pc = __recovered_handler.target;')) + lines.push(indent(' continue __recovered_dispatch;')) + lines.push(indent(' }')) + } + lines.push(indent('}')) + lines.push('}') + return lines +} + +function emitFunction( + functionId, + instructions, + functionRecord, + controlFunction, + controlEdges, + functions, +) { + if (controlFunction.mode === 'structured') + return emitStructuredFunction( + functionId, + instructions, + functionRecord, + controlFunction, + controlEdges, + functions, + ) + if (controlFunction.mode === 'state-machine') + return { + lines: emitStateMachineFunction( + functionId, + instructions, + functionRecord, + functions, + ), + copyProofs: [], + } + decline( + 'invalid-structured-control', + `Function ${functionId} has an unknown switch control mode`, + ) +} + +export function emitVmSwitchProgram(model) { + validateVmSwitchModel(model) + const inferredControl = model.controlByFunction === undefined + const planned = inferredControl + ? { ...model, ...buildVmSwitchControl(model) } + : model + validateVmSwitchModel(planned) + const lines = ['(function () {', " 'use strict';"] + const functionSimplifications = [] + const hasForIn = [...model.instructionsByFunction.values()].some( + (instructions) => + instructions.some(({ operation }) => operation.kind === 'for-in-setup'), + ) + if (hasForIn) + lines.push( + ' function __recovered_enumerable_keys(__value) {', + ' const __keys = [];', + ' if (__value === null || __value === void 0) return __keys;', + ' const __seen = Object.create(null);', + ' let __current = Object(__value);', + ' while (__current !== null) {', + ' for (const __key of Object.getOwnPropertyNames(__current)) {', + ' if (__key in __seen) continue;', + ' __seen[__key] = true;', + ' const __descriptor = Object.getOwnPropertyDescriptor(__current, __key);', + ' if (__descriptor && __descriptor.enumerable) __keys.push(__key);', + ' }', + ' __current = Object.getPrototypeOf(__current);', + ' }', + ' return __keys;', + ' }', + ) + for (const functionRecord of planned.functions.functions) { + const control = planned.controlByFunction.get(functionRecord.id) + let emitted + try { + emitted = emitFunction( + functionRecord.id, + planned.instructionsByFunction.get(functionRecord.id), + functionRecord, + control, + planned.structuredControl.edges, + planned.functions, + ) + } catch (error) { + if ( + !inferredControl || + control.mode !== 'structured' || + !(error instanceof VmSwitchDecline) || + error.code !== 'unemittable-structured-control' + ) + throw error + emitted = emitFunction( + functionRecord.id, + planned.instructionsByFunction.get(functionRecord.id), + functionRecord, + { mode: 'state-machine' }, + [], + planned.functions, + ) + } + lines.push(...emitted.lines.map((line) => ` ${line}`)) + functionSimplifications.push({ + functionId: functionRecord.id, + copies: emitted.copyProofs, + }) + } + const root = model.functions.functions[0] + const rootCaptures = root.captureCount > 0 ? '[]' : '' + lines.push(` ${functionName(0)}(${rootCaptures});`) + lines.push('})();') + const copies = functionSimplifications.flatMap(({ copies: proofs }) => proofs) + const output = lines.join('\n') + validateEmittedJavaScript(output) + return { + output, + simplification: { + schemaVersion: 'vm-switch-simplification.v1', + functions: functionSimplifications, + summary: { + emitted: model.instructionCount - copies.length, + safelyEliminated: copies.length, + provenUnreachable: 0, + }, + }, + } +} + +function validateEmittedJavaScript(source) { + try { + parse(source, { + allowReturnOutsideFunction: true, + errorRecovery: false, + sourceType: 'script', + }) + } catch (error) { + decline( + 'emitted-parse-failed', + `Fresh parse of VM switch output failed: ${error.message}`, + ) + } +} From 26ad33efe645a52dc3ded0c0d4ae27df7c58d16f Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:49:18 +0100 Subject: [PATCH 3/5] test(decode-js): cover VM recovery and switch output Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- test/scripts/render-vm-switch.test.js | 106 ++ .../analyze-closure-lifetimes.test.js | 298 ++++ .../analyze-exception-finally.test.js | 464 +++++++ .../analyze-property-collections.test.js | 502 +++++++ .../analyze-scalar-values.test.js | 413 ++++++ .../jsconfuser-vm/build-call-frames.test.js | 275 ++++ test/vm/jsconfuser-vm/build-cfg.test.js | 330 +++++ .../jsconfuser-vm/decode-standalone.test.js | 333 +++++ .../jsconfuser-vm/diagnose-standalone.test.js | 33 + .../emit-call-completion.test.js | 403 ++++++ .../emit-closure-exception.test.js | 585 ++++++++ .../emit-structured-control.test.js | 455 ++++++ .../jsconfuser-vm/extract-container.test.js | 391 ++++++ .../encoded.js | 1019 ++++++++++++++ .../oracle.json | 44 + .../source.js | 9 + .../composition-closure-upvalue/encoded.js | 1019 ++++++++++++++ .../composition-closure-upvalue/oracle.json | 38 + .../composition-closure-upvalue/source.js | 8 + .../encoded.js | 1019 ++++++++++++++ .../oracle.json | 39 + .../source.js | 12 + .../encoded.js | 1019 ++++++++++++++ .../oracle.json | 42 + .../source.js | 10 + .../composition-receiver-mutation/encoded.js | 1019 ++++++++++++++ .../composition-receiver-mutation/oracle.json | 39 + .../composition-receiver-mutation/source.js | 8 + .../focused-call-method-spread/encoded.js | 1019 ++++++++++++++ .../focused-call-method-spread/oracle.json | 37 + .../focused-call-method-spread/source.js | 1 + .../focused/focused-new-spread/encoded.js | 1019 ++++++++++++++ .../focused/focused-new-spread/oracle.json | 34 + .../focused/focused-new-spread/source.js | 1 + .../encoded.js | 1019 ++++++++++++++ .../oracle.json | 30 + .../focused-store-global-assignment/source.js | 1 + .../encoded.js | 1019 ++++++++++++++ .../oracle.json | 34 + .../focused-unary-positive-coercion/source.js | 1 + .../closure-invalid-capture/encoded.js | 1019 ++++++++++++++ .../closure-invalid-capture/oracle.json | 27 + .../closure-invalid-capture/source.js | 3 + .../negative/control-stale-target/encoded.js | 1019 ++++++++++++++ .../negative/control-stale-target/oracle.json | 28 + .../negative/control-stale-target/source.js | 3 + .../negative/encoded-bytecode-mode/encoded.js | 1019 ++++++++++++++ .../encoded-bytecode-mode/oracle.json | 27 + .../negative/encoded-bytecode-mode/source.js | 1 + .../negative/extra-trailing-word/encoded.js | 1019 ++++++++++++++ .../negative/extra-trailing-word/oracle.json | 27 + .../negative/extra-trailing-word/source.js | 1 + .../negative/finally-stale-target/encoded.js | 1019 ++++++++++++++ .../negative/finally-stale-target/oracle.json | 27 + .../negative/finally-stale-target/source.js | 3 + .../handler-invalid-target/encoded.js | 1019 ++++++++++++++ .../handler-invalid-target/oracle.json | 27 + .../negative/handler-invalid-target/source.js | 3 + .../hardened-self-modifying/encoded.js | 1019 ++++++++++++++ .../hardened-self-modifying/oracle.json | 28 + .../hardened-self-modifying/source.js | 2 + .../encoded.js | 1019 ++++++++++++++ .../oracle.json | 27 + .../source.js | 1 + .../missing-container-role/encoded.js | 1019 ++++++++++++++ .../missing-container-role/oracle.json | 27 + .../negative/missing-container-role/source.js | 1 + .../negative/ordinary-near-miss/encoded.js | 2 + .../negative/ordinary-near-miss/oracle.json | 27 + .../negative/ordinary-near-miss/source.js | 2 + .../reference-invalid-constant/encoded.js | 1019 ++++++++++++++ .../reference-invalid-constant/oracle.json | 27 + .../reference-invalid-constant/source.js | 1 + .../reference-invalid-label/encoded.js | 1019 ++++++++++++++ .../reference-invalid-label/oracle.json | 28 + .../reference-invalid-label/source.js | 3 + .../reference-invalid-ownership/encoded.js | 1019 ++++++++++++++ .../reference-invalid-ownership/oracle.json | 27 + .../reference-invalid-ownership/source.js | 3 + .../reference-invalid-register/encoded.js | 1019 ++++++++++++++ .../reference-invalid-register/oracle.json | 27 + .../reference-invalid-register/source.js | 1 + .../structural-container-role/encoded.js | 1019 ++++++++++++++ .../structural-container-role/oracle.json | 27 + .../structural-container-role/source.js | 1 + .../negative/truncated-wordcode/encoded.js | 1019 ++++++++++++++ .../negative/truncated-wordcode/oracle.json | 27 + .../negative/truncated-wordcode/source.js | 1 + .../corpus/negative/unknown-opcode/encoded.js | 1019 ++++++++++++++ .../negative/unknown-opcode/oracle.json | 27 + .../corpus/negative/unknown-opcode/source.js | 1 + .../raw/f-accessors-and-methods/encoded.js | 1019 ++++++++++++++ .../raw/f-accessors-and-methods/oracle.json | 42 + .../raw/f-accessors-and-methods/source.js | 5 + .../corpus/raw/f-arrow-forms/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-arrow-forms/oracle.json | 42 + .../corpus/raw/f-arrow-forms/source.js | 4 + .../raw/f-arrow-lexical-state/encoded.js | 1019 ++++++++++++++ .../raw/f-arrow-lexical-state/oracle.json | 35 + .../raw/f-arrow-lexical-state/source.js | 4 + .../corpus/raw/f-branching/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-branching/oracle.json | 34 + .../fixtures/corpus/raw/f-branching/source.js | 5 + .../corpus/raw/f-closures-state/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-closures-state/oracle.json | 35 + .../corpus/raw/f-closures-state/source.js | 3 + .../corpus/raw/f-debugger-order/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-debugger-order/oracle.json | 32 + .../corpus/raw/f-debugger-order/source.js | 5 + .../raw/f-expression-mutation/encoded.js | 1019 ++++++++++++++ .../raw/f-expression-mutation/oracle.json | 45 + .../raw/f-expression-mutation/source.js | 4 + .../corpus/raw/f-expression-values/encoded.js | 1019 ++++++++++++++ .../raw/f-expression-values/oracle.json | 43 + .../corpus/raw/f-expression-values/source.js | 4 + .../corpus/raw/f-finally-abrupt/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-finally-abrupt/oracle.json | 52 + .../corpus/raw/f-finally-abrupt/source.js | 7 + .../raw/f-for-in-enumeration/encoded.js | 1019 ++++++++++++++ .../raw/f-for-in-enumeration/oracle.json | 40 + .../corpus/raw/f-for-in-enumeration/source.js | 5 + .../raw/f-functions-call-return/encoded.js | 1019 ++++++++++++++ .../raw/f-functions-call-return/oracle.json | 40 + .../raw/f-functions-call-return/source.js | 4 + .../corpus/raw/f-functions-params/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-functions-params/oracle.json | 45 + .../corpus/raw/f-functions-params/source.js | 3 + .../corpus/raw/f-functions-rest/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-functions-rest/oracle.json | 48 + .../corpus/raw/f-functions-rest/source.js | 3 + .../corpus/raw/f-global-resolution/encoded.js | 1019 ++++++++++++++ .../raw/f-global-resolution/oracle.json | 36 + .../corpus/raw/f-global-resolution/source.js | 3 + .../corpus/raw/f-instanceof-errors/encoded.js | 1019 ++++++++++++++ .../raw/f-instanceof-errors/oracle.json | 33 + .../corpus/raw/f-instanceof-errors/source.js | 3 + .../corpus/raw/f-labeled-control/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-labeled-control/oracle.json | 34 + .../corpus/raw/f-labeled-control/source.js | 5 + .../corpus/raw/f-literals-order/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-literals-order/oracle.json | 80 ++ .../corpus/raw/f-literals-order/source.js | 8 + .../corpus/raw/f-loop-abrupt/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-loop-abrupt/oracle.json | 39 + .../corpus/raw/f-loop-abrupt/source.js | 5 + .../corpus/raw/f-loop-forms/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-loop-forms/oracle.json | 35 + .../corpus/raw/f-loop-forms/source.js | 4 + .../corpus/raw/f-regexp-state/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-regexp-state/oracle.json | 40 + .../corpus/raw/f-regexp-state/source.js | 7 + .../corpus/raw/f-short-circuit/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-short-circuit/oracle.json | 41 + .../corpus/raw/f-short-circuit/source.js | 7 + .../corpus/raw/f-spread-order/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-spread-order/oracle.json | 37 + .../corpus/raw/f-spread-order/source.js | 4 + .../corpus/raw/f-switch-flow/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-switch-flow/oracle.json | 33 + .../corpus/raw/f-switch-flow/source.js | 3 + .../corpus/raw/f-template-literals/encoded.js | 1019 ++++++++++++++ .../raw/f-template-literals/oracle.json | 34 + .../corpus/raw/f-template-literals/source.js | 1 + .../corpus/raw/f-this-explicit/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-this-explicit/oracle.json | 35 + .../corpus/raw/f-this-explicit/source.js | 5 + .../corpus/raw/f-this-receiver/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-this-receiver/oracle.json | 35 + .../corpus/raw/f-this-receiver/source.js | 5 + .../corpus/raw/f-throw-catch/encoded.js | 1019 ++++++++++++++ .../corpus/raw/f-throw-catch/oracle.json | 47 + .../corpus/raw/f-throw-catch/source.js | 4 + .../corpus/raw/program-cash/encoded.js | 1019 ++++++++++++++ .../corpus/raw/program-cash/oracle.json | 67 + .../corpus/raw/program-cash/source.js | 2 + .../corpus/raw/program-sha256/encoded.js | 1019 ++++++++++++++ .../corpus/raw/program-sha256/oracle.json | 74 + .../corpus/raw/program-sha256/source.js | 459 +++++++ .../fixtures/legacy-common/common.js | 1 + .../readability/arithmetic/decoded.js | 21 + .../readability/arithmetic/encoded.js | 1035 ++++++++++++++ .../fixtures/readability/arithmetic/source.js | 5 + .../fixtures/readability/baseline.json | 125 ++ .../readability/closure-loop/decoded.js | 59 + .../readability/closure-loop/encoded.js | 1062 ++++++++++++++ .../readability/closure-loop/source.js | 12 + .../fixtures/readability/manifest.json | 29 + .../fixtures/readability/scale/decoded.js | 209 +++ .../fixtures/readability/scale/encoded.js | 1220 +++++++++++++++++ .../fixtures/readability/scale/source.js | 98 ++ .../fixtures/reference/encoded.debug.js | 1029 ++++++++++++++ .../fixtures/reference/encoded.js | 1019 ++++++++++++++ .../fixtures/reference/oracle.json | 20 + .../fixtures/reference/source.js | 1 + .../sequential/positive-capture-outer.js | 1 + test/vm/jsconfuser-vm/integration/cli.test.js | 299 ++++ test/vm/jsconfuser-vm/integration/harness.js | 690 ++++++++++ .../jsconfuser-vm/integration/harness.test.js | 203 +++ .../integration/integration.test.js | 62 + .../jsconfuser-vm/integration/plugin.test.js | 144 ++ .../integration/readability-metrics.js | 485 +++++++ .../integration/readability.test.js | 200 +++ .../integration/sequential.test.js | 312 +++++ .../numeric-to-vm-switch.test.js | 34 + .../jsconfuser-vm/partition-functions.test.js | 365 +++++ test/vm/jsconfuser-vm/read-wordcode.test.js | 347 +++++ .../jsconfuser-vm/validate-references.test.js | 287 ++++ .../vm-copy-simplification.test.js | 145 ++ test/vm/switch/vm-switch-to-source.test.js | 318 +++++ 209 files changed, 72148 insertions(+) create mode 100644 test/scripts/render-vm-switch.test.js create mode 100644 test/vm/jsconfuser-vm/analyze-closure-lifetimes.test.js create mode 100644 test/vm/jsconfuser-vm/analyze-exception-finally.test.js create mode 100644 test/vm/jsconfuser-vm/analyze-property-collections.test.js create mode 100644 test/vm/jsconfuser-vm/analyze-scalar-values.test.js create mode 100644 test/vm/jsconfuser-vm/build-call-frames.test.js create mode 100644 test/vm/jsconfuser-vm/build-cfg.test.js create mode 100644 test/vm/jsconfuser-vm/decode-standalone.test.js create mode 100644 test/vm/jsconfuser-vm/diagnose-standalone.test.js create mode 100644 test/vm/jsconfuser-vm/emit-call-completion.test.js create mode 100644 test/vm/jsconfuser-vm/emit-closure-exception.test.js create mode 100644 test/vm/jsconfuser-vm/emit-structured-control.test.js create mode 100644 test/vm/jsconfuser-vm/extract-container.test.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/legacy-common/common.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/arithmetic/decoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/arithmetic/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/arithmetic/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/baseline.json create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/closure-loop/decoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/closure-loop/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/closure-loop/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/manifest.json create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/scale/decoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/scale/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/readability/scale/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/reference/encoded.debug.js create mode 100644 test/vm/jsconfuser-vm/fixtures/reference/encoded.js create mode 100644 test/vm/jsconfuser-vm/fixtures/reference/oracle.json create mode 100644 test/vm/jsconfuser-vm/fixtures/reference/source.js create mode 100644 test/vm/jsconfuser-vm/fixtures/sequential/positive-capture-outer.js create mode 100644 test/vm/jsconfuser-vm/integration/cli.test.js create mode 100644 test/vm/jsconfuser-vm/integration/harness.js create mode 100644 test/vm/jsconfuser-vm/integration/harness.test.js create mode 100644 test/vm/jsconfuser-vm/integration/integration.test.js create mode 100644 test/vm/jsconfuser-vm/integration/plugin.test.js create mode 100644 test/vm/jsconfuser-vm/integration/readability-metrics.js create mode 100644 test/vm/jsconfuser-vm/integration/readability.test.js create mode 100644 test/vm/jsconfuser-vm/integration/sequential.test.js create mode 100644 test/vm/jsconfuser-vm/numeric-to-vm-switch.test.js create mode 100644 test/vm/jsconfuser-vm/partition-functions.test.js create mode 100644 test/vm/jsconfuser-vm/read-wordcode.test.js create mode 100644 test/vm/jsconfuser-vm/validate-references.test.js create mode 100644 test/vm/jsconfuser-vm/vm-copy-simplification.test.js create mode 100644 test/vm/switch/vm-switch-to-source.test.js diff --git a/test/scripts/render-vm-switch.test.js b/test/scripts/render-vm-switch.test.js new file mode 100644 index 00000000..b5299df0 --- /dev/null +++ b/test/scripts/render-vm-switch.test.js @@ -0,0 +1,106 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { expect, test } from 'vitest' + +const repository = fileURLToPath(new URL('../../', import.meta.url)) +const script = path.join(repository, 'scripts/render-vm-switch.mjs') +const fixture = fileURLToPath( + new URL( + '../vm/jsconfuser-vm/fixtures/readability/arithmetic/encoded.js', + import.meta.url, + ), +) + +function run(...args) { + return spawnSync(process.execPath, args, { + cwd: repository, + encoding: 'utf8', + }) +} + +function execute(source) { + const context = { window: {}, performance: { now: () => 0 } } + runInNewContext(source, context, { timeout: 5000 }) + return context.window +} + +test('step-one switch output preserves the encoded fixture behavior', () => { + const directory = mkdtempSync( + path.join(os.tmpdir(), 'decode-js-switch-review-'), + ) + try { + const sourceOutput = path.join(directory, 'source') + const sourceRun = run( + script, + '--source', + fixture, + '--output-dir', + sourceOutput, + ) + expect(sourceRun.status, sourceRun.stderr).toBe(0) + + const sourcePath = path.join(sourceOutput, 'switch-case.js') + const code = readFileSync(sourcePath, 'utf8') + expect(existsSync(path.join(sourceOutput, 'switch-model.json'))).toBe(false) + expect(code).toContain('switch (__recovered_pc)') + expect(code).toContain('case 17:') + expect(code).not.toContain('catch (__recovered_error)') + expect(code).not.toContain('__recovered_handlers') + expect(code).not.toContain('console.log') + expect(code).not.toContain('globalThis.window =') + const encodedWindow = execute(readFileSync(fixture, 'utf8')) + const decodedWindow = execute(code) + expect(decodedWindow).toEqual(encodedWindow) + expect(decodedWindow.TEST_OUTPUT).toBe(7) + + const savedOutput = path.join(directory, 'saved') + const savedRun = run( + script, + '--source', + fixture, + '--output-dir', + savedOutput, + '--emit-model', + ) + expect(savedRun.status, savedRun.stderr).toBe(0) + const modelPath = path.join(savedOutput, 'switch-model.json') + const model = JSON.parse(readFileSync(modelPath, 'utf8')) + expect(model.schemaVersion).toBe('decode-js-vm-switch.v1') + expect(model.instructionsByFunction.$type).toBe('Map') + expect(readFileSync(path.join(savedOutput, 'switch-case.js'), 'utf8')).toBe( + code, + ) + + const modelOutput = path.join(directory, 'model') + const modelRun = run( + script, + '--model', + modelPath, + '--output-dir', + modelOutput, + ) + expect(modelRun.status, modelRun.stderr).toBe(0) + expect(readFileSync(path.join(modelOutput, 'switch-case.js'), 'utf8')).toBe( + code, + ) + expect( + execute(readFileSync(path.join(modelOutput, 'switch-case.js'), 'utf8')), + ).toEqual(encodedWindow) + + const overwrite = run( + script, + '--model', + modelPath, + '--output-dir', + modelOutput, + ) + expect(overwrite.status).not.toBe(0) + expect(overwrite.stderr).toContain('Review output already exists') + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/test/vm/jsconfuser-vm/analyze-closure-lifetimes.test.js b/test/vm/jsconfuser-vm/analyze-closure-lifetimes.test.js new file mode 100644 index 00000000..c4b3e779 --- /dev/null +++ b/test/vm/jsconfuser-vm/analyze-closure-lifetimes.test.js @@ -0,0 +1,298 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { buildControlFlow } from '../../../src/vm/jsconfuser-vm/build-cfg.js' +import { buildCallFrames } from '../../../src/vm/jsconfuser-vm/build-call-frames.js' +import { + analyzeClosureLifetimes, + diagnoseClosureLifetimes, +} from '../../../src/vm/jsconfuser-vm/analyze-closure-lifetimes.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions, `Packet D declined ${relativePath}`).not.toBeNull() + const cfg = buildControlFlow(wordcode, references, functions) + expect(cfg, `Packet E declined ${relativePath}`).not.toBeNull() + const callFrames = buildCallFrames(wordcode, references, functions, cfg) + expect(callFrames, `Packet F declined ${relativePath}`).not.toBeNull() + return { wordcode, references, functions, cfg, callFrames } +} + +function resultAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseClosureLifetimes( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return Object.isFrozen(value) && Object.values(value).every(deepFrozen) +} + +function diagnosisFor(packet) { + const diagnosis = diagnoseClosureLifetimes( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + expect(deepFrozen(diagnosis)).toBe(true) + return diagnosis +} + +function clonePacket(packet) { + return structuredClone(packet) +} + +function corpusPaths() { + return ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) +} + +test('identifies a local capture, owner return close, and closed child uses', () => { + const packet = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + ) + const predecessorSnapshot = JSON.stringify(packet) + const pair = packet.result.capturePairs.find( + ({ id }) => id === 'capture:1:64:0', + ) + expect(pair).toMatchObject({ + source: { + kind: 'local', + parentFunctionId: 1, + register: { kind: 'register', index: 2 }, + }, + bindingId: 'binding:1:register:2', + childFunctionId: 2, + }) + const binding = packet.result.bindings.find( + ({ id }) => id === 'binding:1:register:2', + ) + expect(binding.lifecycle).toMatchObject({ + initialState: 'open-upvalue', + afterCloseState: 'closed-captured-value', + runtimeIdentity: 'owner-function-register-slot', + }) + expect(binding.lifecycle.closeEvents).toEqual([ + expect.objectContaining({ + functionId: 1, + sourcePc: 73, + completion: 'return', + }), + ]) + expect(packet.result.uses).toEqual([ + expect.objectContaining({ pc: 83, operation: 'read', state: 'closed' }), + expect.objectContaining({ pc: 97, operation: 'write', state: 'closed' }), + expect.objectContaining({ pc: 100, operation: 'read', state: 'closed' }), + ]) + expect( + packet.result.closureSites.find(({ id }) => id === 'closure:1:64') + .escapeEvents, + ).toContainEqual( + expect.objectContaining({ + kind: 'return', + functionId: 1, + pc: 73, + provenEscape: true, + }), + ) + expect(deepFrozen(packet.result)).toBe(true) + expect(JSON.stringify(packet)).toBe(predecessorSnapshot) +}) + +test('resolves nested upvalue pairs to an ancestor binding identity', () => { + const packet = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/encoded.js', + ) + const pair = packet.result.capturePairs.find( + ({ source }) => source.kind === 'upvalue', + ) + expect(pair).toBeDefined() + expect(pair.source.parentFunctionId).not.toBe(pair.bindingOwnerFunctionId) + expect(pair.bindingId).toBe( + `binding:${pair.bindingOwnerFunctionId}:register:${pair.bindingRegister.index}`, + ) + expect( + packet.result.uses.filter(({ bindingId }) => bindingId === pair.bindingId), + ).not.toHaveLength(0) + expect(packet.result.capturePairCount).toBe(531) + expect(packet.result.uses).toHaveLength(451) +}) + +test('records host use without inventing an internal closure target', () => { + const packet = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/encoded.js', + ) + const hostUse = packet.result.closureSites + .find(({ id }) => id === 'closure:0:14') + .escapeEvents.find(({ kind }) => kind === 'external-call-receiver') + expect(hostUse).toMatchObject({ + provenEscape: false, + targetFunctions: [], + }) + expect( + packet.result.closureSites.find(({ id }) => id === 'closure:1:158') + .escapeEvents, + ).toContainEqual( + expect.objectContaining({ kind: 'return', provenEscape: true }), + ) +}) + +test('accepts every complete stripped raw and focused corpus packet', () => { + const paths = [ + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ...corpusPaths(), + ] + expect(paths).toHaveLength(34) + for (const relativePath of paths) { + const packet = resultAt(relativePath) + expect(packet.result.capturePairCount).toBe( + packet.result.capturePairs.length, + ) + expect(packet.result.bindingCount).toBe(packet.result.bindings.length) + expect(packet.result.closureSiteCount).toBe( + packet.result.closureSites.length, + ) + expect(deepFrozen(packet.result)).toBe(true) + } +}) + +test('declines malformed capture pairs atomically', () => { + const original = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + ) + const packet = clonePacket(original) + const target = packet.wordcode.instructions.find(({ pc }) => pc === 64) + target.captures[0].kind = 'upvalue' + const before = JSON.stringify(original.wordcode) + const diagnosis = diagnosisFor(packet) + expect(diagnosis.diagnostic.code).toBe('invalid-capture-pair') + expect(JSON.stringify(original.wordcode)).toBe(before) +}) + +test('declines missing descriptors, ownership mismatches, and uses without owners', () => { + const source = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js' + const original = packetAt(source) + + const missing = clonePacket(original) + missing.references.frame.descriptors.pop() + expect(diagnosisFor(missing).diagnostic.code).toBe( + 'missing-capture-descriptor', + ) + + const ownership = clonePacket(original) + ownership.functions.functions[2].parentFunctionId = 0 + expect(diagnosisFor(ownership).diagnostic.code).toBe('inconsistent-ownership') + + const use = clonePacket(original) + const load = use.wordcode.instructions.find(({ pc }) => pc === 83) + load.words[2] = 99 + load.operands[1].index = 99 + load.wordOperands[1].index = 99 + use.wordcode.words[85] = 99 + expect(diagnosisFor(use).diagnostic.code).toBe('use-without-owner') +}) + +test('declines an incomplete nested capture after predecessor shapes agree', () => { + const source = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js' + const packet = clonePacket(packetAt(source)) + const pair = { kind: 'upvalue', index: 99, isLocal: false } + const instruction = packet.wordcode.instructions.find(({ pc }) => pc === 64) + instruction.captures[0] = pair + instruction.capturePairs[0] = pair + instruction.words[7] = 0 + instruction.words[8] = 99 + packet.wordcode.words[71] = 0 + packet.wordcode.words[72] = 99 + packet.references.frame.descriptors[1].captures[0] = pair + packet.functions.functions[2].descriptor.captures[0] = pair + expect(diagnosisFor(packet).diagnostic.code).toBe('incomplete-capture') +}) + +test('declines stale predecessor state and unknown binding lifetime', () => { + const source = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js' + const original = packetAt(source) + + const stale = clonePacket(original) + stale.callFrames.source.cfgSchema = 'stale' + expect(diagnosisFor(stale).diagnostic.code).toBe('stale-predecessor') + + const unknown = clonePacket(original) + unknown.cfg.edges = unknown.cfg.edges.filter( + ({ functionId, sourcePc }) => !(functionId === 1 && sourcePc === 73), + ) + unknown.callFrames.functions[1].completionRoutes = [] + unknown.callFrames.functions[1].returns[0].routes = [] + unknown.callFrames.completionRoutes = + unknown.callFrames.completionRoutes.filter( + ({ sourcePc }) => sourcePc !== 73, + ) + expect(diagnosisFor(unknown).diagnostic.code).toBe('unknown-lifetime') +}) + +test('returns null on decline and exposes the same null contract through analyze API', () => { + const packet = clonePacket( + packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + ), + ) + packet.wordcode.instructions[0].name = 'not-an-opcode' + const diagnosis = diagnosisFor(packet) + expect( + analyzeClosureLifetimes( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ), + ).toBeNull() + expect(diagnosis.result).toBeNull() +}) diff --git a/test/vm/jsconfuser-vm/analyze-exception-finally.test.js b/test/vm/jsconfuser-vm/analyze-exception-finally.test.js new file mode 100644 index 00000000..691c5608 --- /dev/null +++ b/test/vm/jsconfuser-vm/analyze-exception-finally.test.js @@ -0,0 +1,464 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { buildControlFlow } from '../../../src/vm/jsconfuser-vm/build-cfg.js' +import { buildCallFrames } from '../../../src/vm/jsconfuser-vm/build-call-frames.js' +import { + analyzeExceptionFinally, + diagnoseExceptionFinally, +} from '../../../src/vm/jsconfuser-vm/analyze-exception-finally.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions, `Packet D declined ${relativePath}`).not.toBeNull() + const cfg = buildControlFlow(wordcode, references, functions) + expect(cfg, `Packet E declined ${relativePath}`).not.toBeNull() + const callFrames = buildCallFrames(wordcode, references, functions, cfg) + expect(callFrames, `Packet F declined ${relativePath}`).not.toBeNull() + return { wordcode, references, functions, cfg, callFrames } +} + +function hAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseExceptionFinally( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function clonePacket(packet) { + return Object.fromEntries( + Object.entries(packet).map(([key, value]) => [key, structuredClone(value)]), + ) +} + +function diagnoseMutation(packet, mutate) { + const mutated = clonePacket(packet) + mutate(mutated) + const diagnosis = diagnoseExceptionFinally( + mutated.wordcode, + mutated.references, + mutated.functions, + mutated.cfg, + mutated.callFrames, + ) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + expect(deepFrozen(diagnosis)).toBe(true) + return diagnosis.diagnostic +} + +const finallyCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js' + +test('analyzes nested handler/finally states and abrupt routes', () => { + const packet = hAt(finallyCase) + + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-exception-finally.v1', + encoding: 'numeric-u32', + wordCount: 517, + instructionCount: 152, + functionCount: 5, + source: { + wordcodeSchema: 'jsconfuser-vm-wordcode.v1', + referencesSchema: 'jsconfuser-vm-references.v1', + functionsSchema: 'jsconfuser-vm-functions.v1', + cfgSchema: 'jsconfuser-vm-cfg.v1', + callFramesSchema: 'jsconfuser-vm-call-frames.v1', + }, + }) + expect( + packet.result.handlerRecords.map( + ({ setupPc, handlerPc, exceptionReg }) => ({ + setupPc, + handlerPc, + exceptionReg, + }), + ), + ).toEqual([ + { setupPc: 189, handlerPc: 201, exceptionReg: 2 }, + { setupPc: 262, handlerPc: 310, exceptionReg: 2 }, + ]) + expect( + packet.result.finallyRecords.map( + ({ setupPc, finallyPc, continuationReg, payloadReg, throwPadPc }) => ({ + setupPc, + finallyPc, + continuationReg, + payloadReg, + throwPadPc, + }), + ), + ).toEqual([ + { + setupPc: 104, + finallyPc: 149, + continuationReg: 2, + payloadReg: 3, + throwPadPc: 172, + }, + { + setupPc: 184, + finallyPc: 224, + continuationReg: 3, + payloadReg: 4, + throwPadPc: 247, + }, + { + setupPc: 257, + finallyPc: 341, + continuationReg: 3, + payloadReg: 4, + throwPadPc: 364, + }, + { + setupPc: 265, + finallyPc: 282, + continuationReg: 5, + payloadReg: 6, + throwPadPc: 305, + }, + { + setupPc: 392, + finallyPc: 462, + continuationReg: 3, + payloadReg: 4, + throwPadPc: 489, + }, + ]) + expect( + packet.wordcode.instructions + .filter(({ name }) => name === 'TRY_END') + .map(({ pc }) => pc), + ).toEqual([140, 143, 198, 218, 276, 307, 335, 411, 428, 456]) + const routes = packet.result.completionRoutes.map( + ({ sourcePc, kind, targetPc, route, callSitePc }) => ({ + sourcePc, + kind, + targetPc, + route, + callSitePc, + }), + ) + for (const route of [ + { + sourcePc: 124, + kind: 'finally', + targetPc: 149, + route: 'exceptional', + callSitePc: 124, + }, + { + sourcePc: 141, + kind: 'finally', + targetPc: 149, + route: 'normal', + callSitePc: null, + }, + { + sourcePc: 196, + kind: 'handler', + targetPc: 201, + route: 'exceptional', + callSitePc: null, + }, + { + sourcePc: 212, + kind: 'finally', + targetPc: 224, + route: 'exceptional', + callSitePc: 212, + }, + { + sourcePc: 222, + kind: 'finally', + targetPc: 224, + route: 'normal', + callSitePc: null, + }, + { + sourcePc: 274, + kind: 'finally', + targetPc: 282, + route: 'exceptional', + callSitePc: null, + }, + { + sourcePc: 297, + kind: 'handler', + targetPc: 310, + route: 'exceptional', + callSitePc: 297, + }, + { + sourcePc: 329, + kind: 'finally', + targetPc: 341, + route: 'exceptional', + callSitePc: 329, + }, + { + sourcePc: 339, + kind: 'finally', + targetPc: 341, + route: 'normal', + callSitePc: null, + }, + { + sourcePc: 412, + kind: 'finally', + targetPc: 462, + route: 'normal', + callSitePc: null, + }, + { + sourcePc: 429, + kind: 'finally', + targetPc: 462, + route: 'normal', + callSitePc: null, + }, + { + sourcePc: 450, + kind: 'finally', + targetPc: 462, + route: 'exceptional', + callSitePc: 450, + }, + { + sourcePc: 460, + kind: 'finally', + targetPc: 462, + route: 'normal', + callSitePc: null, + }, + { + sourcePc: 305, + kind: 'handler', + targetPc: 310, + route: 'exceptional', + callSitePc: null, + }, + { + sourcePc: 356, + kind: 'throw', + targetPc: null, + route: 'propagate', + callSitePc: 356, + }, + { + sourcePc: 364, + kind: 'throw', + targetPc: null, + route: 'propagate', + callSitePc: null, + }, + { + sourcePc: 481, + kind: 'throw', + targetPc: null, + route: 'propagate', + callSitePc: 481, + }, + { + sourcePc: 489, + kind: 'throw', + targetPc: null, + route: 'propagate', + callSitePc: null, + }, + ]) { + expect(routes).toContainEqual(route) + } + expect(packet.result.jumpRegChecks).toContainEqual({ + functionId: 1, + pc: 170, + sourceRegister: { kind: 'register', index: 2 }, + targets: [172, 174], + finalizer: 'finally@104', + finite: true, + sameFunction: true, + continuationRegister: { kind: 'register', index: 2 }, + instructionName: 'JUMP_REG', + }) + expect(packet.result.functions.find(({ id }) => id === 3).merges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ pc: 310, incomingCount: 2 }), + ]), + ) + expect(deepFrozen(packet.result)).toBe(true) +}) + +test('accepts focused and handler-only corpus fixtures without target execution', () => { + const focused = hAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js', + ) + const handler = hAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/encoded.js', + ) + expect(focused.result.schemaVersion).toBe( + 'jsconfuser-vm-exception-finally.v1', + ) + expect(focused.result.handlerRecords).toEqual([]) + expect(handler.result.handlerRecords.length).toBe(2) + expect(handler.result.finallyRecords).toEqual([]) + expect(handler.result.completionRoutes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'handler', + targetPc: expect.any(Number), + route: 'exceptional', + }), + ]), + ) +}) + +test('rejects required stale and malformed mutations with atomic diagnostics', () => { + const packet = packetAt(finallyCase) + const crossFunction = diagnoseMutation(packet, ({ wordcode }) => { + const instruction = wordcode.instructions.find(({ pc }) => pc === 408) + instruction.words[2] = 174 + wordcode.words[410] = 174 + }) + expect(crossFunction.code).toBe('cross-function-target') + + const unresolved = diagnoseMutation(packet, ({ wordcode }) => { + const instruction = wordcode.instructions.find(({ pc }) => pc === 487) + instruction.words[1] = 0 + wordcode.words[488] = 0 + }) + expect(unresolved.code).toBe('unresolved-jump-reg') + + const incompleteCapture = diagnoseMutation(packet, ({ wordcode }) => { + const instruction = wordcode.instructions.find( + ({ name }) => name === 'MAKE_CLOSURE', + ) + instruction.captures = [] + instruction.capturePairs = [] + }) + expect(incompleteCapture.code).toBe('incomplete-capture-pair') + + const underflow = diagnoseMutation(packet, ({ cfg }) => { + const edge = cfg.functions + .find(({ id }) => id === 1) + .edges.find(({ sourcePc }) => sourcePc === 140) + edge.handlerStateBefore = { stack: [], finallyStack: [] } + }) + expect(underflow.code).toBe('handler-stack-underflow') + + const unbalanced = diagnoseMutation(packet, ({ cfg }) => { + const edge = cfg.functions + .find(({ id }) => id === 1) + .edges.find(({ sourcePc }) => sourcePc === 174) + edge.handlerStateAfter = { + stack: [ + { + type: 'finally', + setupPc: 104, + finallyPc: 149, + continuationReg: 2, + payloadReg: 3, + throwPadPc: 172, + }, + ], + finallyStack: [ + { + type: 'finally', + setupPc: 104, + finallyPc: 149, + continuationReg: 2, + payloadReg: 3, + throwPadPc: 172, + }, + ], + } + }) + expect(unbalanced.code).toBe('unbalanced-handler-stack') + + const invalidRouting = diagnoseMutation(packet, ({ cfg }) => { + cfg.functions.find(({ id }) => id === 1).finallyRecords[0].finallyPc = 999 + }) + expect(invalidRouting.code).toBe('invalid-handler-routing') + + const stale = diagnoseMutation(packet, ({ references }) => { + references.schemaVersion = 'stale' + }) + expect(stale.code).toBe('stale-predecessor') +}) + +test('preserves predecessor snapshots and freezes successful and declined APIs', () => { + const packet = packetAt(finallyCase) + const snapshot = structuredClone(packet) + const result = analyzeExceptionFinally( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ) + expect(result).not.toBeNull() + expect(deepFrozen(result)).toBe(true) + expect(packet).toEqual(snapshot) + + const diagnosis = diagnoseExceptionFinally( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ) + expect(diagnosis).toMatchObject({ ok: true, result, diagnostic: null }) + expect(deepFrozen(diagnosis)).toBe(true) + + const declined = diagnoseExceptionFinally( + packet.wordcode, + packet.references, + packet.functions, + { ...structuredClone(packet.cfg), schemaVersion: 'stale' }, + packet.callFrames, + ) + expect(declined.ok).toBe(false) + expect(declined.result).toBeNull() + expect( + analyzeExceptionFinally( + packet.wordcode, + packet.references, + packet.functions, + { ...structuredClone(packet.cfg), schemaVersion: 'stale' }, + packet.callFrames, + ), + ).toBeNull() + expect(deepFrozen(declined)).toBe(true) +}) diff --git a/test/vm/jsconfuser-vm/analyze-property-collections.test.js b/test/vm/jsconfuser-vm/analyze-property-collections.test.js new file mode 100644 index 00000000..06408854 --- /dev/null +++ b/test/vm/jsconfuser-vm/analyze-property-collections.test.js @@ -0,0 +1,502 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { buildControlFlow } from '../../../src/vm/jsconfuser-vm/build-cfg.js' +import { buildCallFrames } from '../../../src/vm/jsconfuser-vm/build-call-frames.js' +import { + analyzePropertyCollections, + diagnosePropertyCollections, +} from '../../../src/vm/jsconfuser-vm/analyze-property-collections.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +const propertyNames = [ + 'GET_PROP', + 'SET_PROP', + 'DELETE_PROP', + 'IN', + 'INSTANCEOF', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'DEFINE_GETTER', + 'DEFINE_SETTER', + 'FOR_IN_SETUP', + 'FOR_IN_NEXT', +] + +const expectedCounts = { + GET_PROP: 688, + SET_PROP: 194, + DELETE_PROP: 2, + IN: 5, + INSTANCEOF: 5, + BUILD_ARRAY: 71, + BUILD_OBJECT: 38, + DEFINE_GETTER: 1, + DEFINE_SETTER: 1, + FOR_IN_SETUP: 10, + FOR_IN_NEXT: 10, +} + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions, `Packet D declined ${relativePath}`).not.toBeNull() + const cfg = buildControlFlow(wordcode, references, functions) + expect(cfg, `Packet E declined ${relativePath}`).not.toBeNull() + const callFrames = buildCallFrames(wordcode, references, functions, cfg) + expect(callFrames, `Packet F declined ${relativePath}`).not.toBeNull() + return { wordcode, references, functions, cfg, callFrames } +} + +function resultAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnosePropertyCollections( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function clonePacket(packet) { + return Object.fromEntries( + Object.entries(packet).map(([key, value]) => [key, structuredClone(value)]), + ) +} + +function diagnoseDecline(packet) { + const diagnosis = diagnosePropertyCollections( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + expect(deepFrozen(diagnosis)).toBe(true) + return diagnosis.diagnostic +} + +function corpusPaths() { + return ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) +} + +test('emits the complete property and collection operation inventory', () => { + const packet = resultAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-property-collections.v1', + encoding: 'numeric-u32', + parseOnly: true, + targetExecution: false, + wordCount: 32, + instructionCount: 9, + functionCount: 1, + operationCount: 1, + opcodeScope: { family: 'J-property-collection', count: 11 }, + predecessorSchemas: { + wordcode: 'jsconfuser-vm-wordcode.v1', + references: 'jsconfuser-vm-references.v1', + functions: 'jsconfuser-vm-functions.v1', + cfg: 'jsconfuser-vm-cfg.v1', + callFrames: 'jsconfuser-vm-call-frames.v1', + }, + }) + expect(packet.result.opcodeScope.names).toEqual(propertyNames) + expect(packet.result.opcodeScope.rows).toHaveLength(11) + expect( + packet.result.opcodeScope.rows.find(({ name }) => name === 'FOR_IN_SETUP'), + ).toMatchObject({ width: 3 }) + expect(packet.result.operations[0]).toMatchObject({ + functionId: 0, + pc: 22, + reachable: true, + opcode: { name: 'SET_PROP', value: 9 }, + name: 'SET_PROP', + operands: [ + { kind: 'register', index: 2 }, + { kind: 'register', index: 3 }, + { kind: 'register', index: 6 }, + ], + destination: null, + inputs: [ + { kind: 'register', index: 2 }, + { kind: 'register', index: 3 }, + { kind: 'register', index: 6 }, + ], + useDef: { + defines: null, + conditionalDefine: null, + }, + effect: { kind: 'property-write', register: null }, + semantic: { + kind: 'property-write', + resultType: 'no-register-result', + known: false, + }, + }) + expect(packet.result.operations[0].roles).toMatchObject({ + object: { register: { kind: 'register', index: 2 } }, + key: { register: { kind: 'register', index: 3 } }, + value: { register: { kind: 'register', index: 6 } }, + }) + expect(packet.result.operations[0].evaluationOrder.roles).toEqual([ + 'object', + 'key', + 'value', + ]) + expect(packet.result.proof.historicalForInNextWordOperandsPreserved).toBe( + true, + ) + expect(deepFrozen(packet.result)).toBe(true) +}) + +test('retains variable payloads and conservative property semantics', () => { + const accessors = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/encoded.js', + ) + const array = accessors.result.operations.find( + ({ name }) => name === 'BUILD_ARRAY', + ) + expect(array).toMatchObject({ + width: 7, + payload: { + kind: 'array', + count: 4, + elementRegisters: [ + { kind: 'register', index: 8 }, + { kind: 'register', index: 7 }, + { kind: 'register', index: 9 }, + { kind: 'register', index: 10 }, + ], + }, + semantic: { + kind: 'array-construction', + resultType: 'array', + unknowns: ['element values', 'array identity'], + }, + }) + expect(array.evaluationOrder.roles).toEqual([ + 'element[0]', + 'element[1]', + 'element[2]', + 'element[3]', + ]) + + const object = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/encoded.js', + ).result.operations.find(({ name }) => name === 'BUILD_OBJECT') + expect(object).toMatchObject({ + payload: { + kind: 'object', + pairCount: 2, + pairs: [ + { + pairIndex: 0, + key: { kind: 'register', index: 0 }, + value: { kind: 'register', index: 1 }, + }, + { + pairIndex: 1, + key: { kind: 'register', index: 2 }, + value: { kind: 'register', index: 3 }, + }, + ], + }, + semantic: { + kind: 'object-construction', + resultType: 'object', + pairCount: 2, + }, + }) + expect(object.evaluationOrder.roles).toEqual([ + 'pair[0].key', + 'pair[0].value', + 'pair[1].key', + 'pair[1].value', + ]) + expect(object.semantic.duplicateKeyBehavior).toContain('later') + + const getter = accessors.result.operations.find( + ({ name }) => name === 'DEFINE_GETTER', + ) + const setter = accessors.result.operations.find( + ({ name }) => name === 'DEFINE_SETTER', + ) + expect(getter).toMatchObject({ + roles: { accessorFunction: { register: { kind: 'register', index: 7 } } }, + semantic: { + kind: 'getter-definition', + accessorKind: 'getter', + descriptor: { + configurable: true, + enumerable: true, + preservesExistingOppositeAccessor: true, + }, + }, + }) + expect(setter.semantic).toMatchObject({ + kind: 'setter-definition', + accessorKind: 'setter', + }) +}) + +test('records deletion, membership, prototype, and enumeration behavior', () => { + const deletion = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/encoded.js', + ).result.operations.find(({ name }) => name === 'DELETE_PROP') + expect(deletion).toMatchObject({ + semantic: { + kind: 'property-delete', + resultType: 'boolean', + runtime: 'computes delete object[key]', + }, + roles: { + object: { register: { kind: 'register', index: 3 } }, + key: { register: { kind: 'register', index: 6 } }, + }, + }) + + const values = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/encoded.js', + ) + const membership = values.result.operations.find(({ name }) => name === 'IN') + const instanceofOperation = values.result.operations.find( + ({ name }) => name === 'INSTANCEOF', + ) + expect(membership).toMatchObject({ + roles: { + propertyKey: { register: { kind: 'register', index: 4 } }, + object: { register: { kind: 'register', index: 11 } }, + }, + semantic: { + kind: 'membership-test', + resultType: 'boolean', + }, + }) + expect(membership.evaluationOrder.roles).toEqual(['propertyKey', 'object']) + expect(instanceofOperation).toMatchObject({ + roles: { + instance: { register: { kind: 'register', index: 4 } }, + constructor: { register: { kind: 'register', index: 10 } }, + }, + semantic: { kind: 'prototype-relation-test', resultType: 'boolean' }, + }) + + const enumeration = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/encoded.js', + ) + const setup = enumeration.result.operations.find( + ({ name }) => name === 'FOR_IN_SETUP', + ) + const next = enumeration.result.operations.find( + ({ name }) => name === 'FOR_IN_NEXT', + ) + expect(setup).toMatchObject({ + roles: { + iterator: { register: { kind: 'register', index: 5 } }, + source: { register: { kind: 'register', index: 3 } }, + }, + semantic: { + kind: 'for-in-setup', + setup: { + nullishSourceProducesEmptyKeys: true, + boxesPrimitiveSource: true, + walksPrototypeChain: true, + deDuplicatesNames: true, + }, + }, + }) + expect(next).toMatchObject({ + wordOperands: [ + { kind: 'register', index: 7 }, + { kind: 'label-target', pc: 5 }, + { kind: 'register', index: 83 }, + ], + operands: [ + { kind: 'register', index: 7 }, + { kind: 'register', index: 5 }, + { kind: 'label-target', pc: 83 }, + ], + payload: { + kind: 'iterator-next', + iterator: { kind: 'register', index: 5 }, + exitTarget: { kind: 'label-target', pc: 83 }, + }, + controlFlow: { + kind: 'iterator-conditional', + normalTargetPc: 64, + exitTargetPc: 83, + }, + semantic: { + kind: 'for-in-next', + exitTargetPc: 83, + exit: { + kind: 'iterator-conditional', + normalTargetPc: 64, + exitTargetPc: 83, + }, + }, + }) + expect(next.evaluationOrder.roles).toEqual(['iterator', 'exit']) + expect(next.semantic.exit.normal.writesDestination).toBe(true) + expect(next.semantic.exit.exit.writesDestination).toBe(false) +}) + +test('accepts the tracked stripped corpus with a complete property census', () => { + const paths = [ + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ...corpusPaths(), + ] + expect(paths).toHaveLength(34) + const counts = Object.fromEntries(propertyNames.map((name) => [name, 0])) + let operationCount = 0 + for (const relativePath of paths) { + const packet = resultAt(relativePath) + expect(packet.result.census.complete).toBe(true) + expect(packet.result.census.dropped).toEqual([]) + expect(packet.result.census.duplicated).toEqual([]) + expect(packet.result.census.expectedInstructionCount).toBe( + packet.result.operationCount, + ) + expect(packet.result.census.operationKeys).toHaveLength( + packet.result.operationCount, + ) + expect(new Set(packet.result.census.operationKeys).size).toBe( + packet.result.operationCount, + ) + expect(packet.result.proof.allPropertyRowsPresent).toBe(true) + expect(packet.result.proof.allPropertyRowsUnique).toBe(true) + expect(deepFrozen(packet.result)).toBe(true) + operationCount += packet.result.operationCount + for (const [name, count] of Object.entries( + packet.result.census.countsByName, + )) { + counts[name] += count + } + } + expect(operationCount).toBe(1025) + expect(counts).toEqual(expectedCounts) +}) + +test('declines malformed or stale B/C/D/E/F predecessors atomically', () => { + const original = packetAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + const before = JSON.stringify(original) + + const badWordcode = clonePacket(original) + badWordcode.wordcode.words[1] = 99 + expect(diagnoseDecline(badWordcode).code).toBe('stale-predecessor') + + const badReferences = clonePacket(original) + badReferences.references.labels.boundaries[0] = 1 + expect(diagnoseDecline(badReferences).code).toBe('stale-predecessor') + + const badFunctions = clonePacket(original) + badFunctions.functions.ownership[0].functionId = 99 + expect(diagnoseDecline(badFunctions).code).toBe('stale-predecessor') + + const badCfg = clonePacket(original) + badCfg.cfg.edges.pop() + expect(diagnoseDecline(badCfg).code).toBe('stale-predecessor') + + const badCallFrames = clonePacket(original) + badCallFrames.callFrames.functionCount = 99 + expect(diagnoseDecline(badCallFrames).code).toBe('stale-predecessor') + + expect(JSON.stringify(original)).toBe(before) + expect( + analyzePropertyCollections( + original.wordcode, + original.references, + original.functions, + original.cfg, + original.callFrames, + ), + ).not.toBeNull() +}) + +test('keeps inputs unchanged, freezes results and diagnoses, and returns null publicly', () => { + const packet = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/encoded.js', + ) + const before = JSON.stringify(packet) + const diagnosis = diagnosePropertyCollections( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + ) + expect(diagnosis.ok).toBe(true) + expect(deepFrozen(diagnosis)).toBe(true) + expect(deepFrozen(diagnosis.result)).toBe(true) + expect(JSON.stringify(packet)).toBe(before) + expect(() => diagnosis.result.operations.push({})).toThrow() + expect(() => (diagnosis.result.operations[0].pc = 99)).toThrow() + + const declined = diagnoseDecline({ + ...clonePacket(packet), + cfg: { ...clonePacket(packet.cfg), schemaVersion: 'stale' }, + }) + expect(declined.code).toBe('invalid-cfg') + expect(analyzePropertyCollections(null, null, null, null, null)).toBeNull() + const nullDiagnosis = diagnosePropertyCollections( + null, + null, + null, + null, + null, + ) + expect(nullDiagnosis).toMatchObject({ + ok: false, + result: null, + diagnostic: { code: 'invalid-wordcode' }, + }) + expect(deepFrozen(nullDiagnosis)).toBe(true) +}) diff --git a/test/vm/jsconfuser-vm/analyze-scalar-values.test.js b/test/vm/jsconfuser-vm/analyze-scalar-values.test.js new file mode 100644 index 00000000..9cfeca4a --- /dev/null +++ b/test/vm/jsconfuser-vm/analyze-scalar-values.test.js @@ -0,0 +1,413 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { buildControlFlow } from '../../../src/vm/jsconfuser-vm/build-cfg.js' +import { + analyzeScalarValues, + diagnoseScalarValues, +} from '../../../src/vm/jsconfuser-vm/analyze-scalar-values.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +const scalarNames = [ + 'LOAD_CONST', + 'LOAD_INT', + 'LOAD_GLOBAL', + 'MOVE', + 'STORE_GLOBAL', + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', + 'TYPEOF_SAFE', +] + +const operandKinds = { + LOAD_CONST: ['register', 'constant-ref'], + LOAD_INT: ['register', 'immediate'], + LOAD_GLOBAL: ['register', 'constant-ref'], + MOVE: ['register', 'register'], + STORE_GLOBAL: ['constant-ref', 'register'], + ADD: ['register', 'register', 'register'], + SUB: ['register', 'register', 'register'], + MUL: ['register', 'register', 'register'], + DIV: ['register', 'register', 'register'], + MOD: ['register', 'register', 'register'], + EXP: ['register', 'register', 'register'], + BAND: ['register', 'register', 'register'], + BOR: ['register', 'register', 'register'], + BXOR: ['register', 'register', 'register'], + SHL: ['register', 'register', 'register'], + SHR: ['register', 'register', 'register'], + USHR: ['register', 'register', 'register'], + LT: ['register', 'register', 'register'], + GT: ['register', 'register', 'register'], + LTE: ['register', 'register', 'register'], + GTE: ['register', 'register', 'register'], + EQ: ['register', 'register', 'register'], + NEQ: ['register', 'register', 'register'], + LOOSE_EQ: ['register', 'register', 'register'], + LOOSE_NEQ: ['register', 'register', 'register'], + UNARY_NEG: ['register', 'register'], + UNARY_POS: ['register', 'register'], + UNARY_NOT: ['register', 'register'], + UNARY_BITNOT: ['register', 'register'], + TYPEOF: ['register', 'register'], + VOID: ['register', 'register'], + TYPEOF_SAFE: ['register', 'constant-ref'], +} + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions, `Packet D declined ${relativePath}`).not.toBeNull() + const cfg = buildControlFlow(wordcode, references, functions) + expect(cfg, `Packet E declined ${relativePath}`).not.toBeNull() + return { wordcode, references, functions, cfg } +} + +function resultAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseScalarValues( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return Object.isFrozen(value) && Object.values(value).every(deepFrozen) +} + +function corpusPaths() { + return ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) +} + +function diagnoseDecline(packet) { + const diagnosis = diagnoseScalarValues( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + ) + expect(diagnosis).toMatchObject({ ok: false, result: null }) + expect(deepFrozen(diagnosis)).toBe(true) + return diagnosis +} + +test('emits the exact immutable scalar/value opcode census', () => { + const packet = resultAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-scalar-values.v1', + encoding: 'numeric-u32', + parseOnly: true, + targetExecution: false, + wordCount: 32, + instructionCount: 9, + functionCount: 1, + operationCount: 6, + opcodeScope: { family: 'I-scalar-value', count: 32 }, + valueState: { + dataflow: 'not-introduced', + mergeAgreement: 'not-applicable', + }, + }) + expect(packet.result.opcodeScope.names).toEqual(scalarNames) + expect(packet.result.opcodeScope.rows).toHaveLength(scalarNames.length) + expect(packet.result.census.complete).toBe(true) + expect(packet.result.census.dropped).toEqual([]) + expect(packet.result.census.duplicated).toEqual([]) + expect(packet.result.census.operationCount).toBe( + packet.result.operations.length, + ) + expect(packet.result.operations).toHaveLength(6) + for (const operation of packet.result.operations) { + expect(operation.functionId).toBe(0) + expect(operation.reachable).toBe(true) + expect(operation.opcode.name).toBe(operation.name) + expect(operation.opcode.value).toBe( + packet.result.opcodeScope.rows.find(({ name }) => name === operation.name) + .opcode, + ) + expect(operation.operands.map(({ kind }) => kind)).toEqual( + operandKinds[operation.name], + ) + } + expect(deepFrozen(packet.result)).toBe(true) +}) + +test('represents STORE_GLOBAL and unary positive coercion precisely', () => { + const store = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/encoded.js', + ) + const storeOperation = store.result.operations.find( + ({ name }) => name === 'STORE_GLOBAL', + ) + expect(storeOperation).toMatchObject({ + family: 'global-mutation', + destination: null, + inputs: [{ kind: 'register', index: 2 }], + constantReference: { + kind: 'constant', + value: 'TEST_OUTPUT', + concealKey: 0, + }, + globalReference: { kind: 'global', name: 'TEST_OUTPUT' }, + effect: { kind: 'global-write', register: null }, + useDef: { + defines: null, + globalWrites: [{ kind: 'global', name: 'TEST_OUTPUT' }], + }, + valueSemantics: { + kind: 'input-value', + resultType: 'no-register-result', + known: false, + }, + }) + + const unary = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/encoded.js', + ) + const positive = unary.result.operations.filter( + ({ name }) => name === 'UNARY_POS', + ) + expect(positive).toHaveLength(3) + expect( + positive.every( + ({ inputs, destination, valueSemantics }) => + inputs.length === 1 && + destination.kind === 'register' && + valueSemantics.operation === 'unary-positive' && + valueSemantics.resultType === 'number' && + valueSemantics.known === false, + ), + ).toBe(true) +}) + +test('covers expression semantics, global names, literals, and unreachable rows', () => { + const expression = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/encoded.js', + ) + expect(expression.result.census.countsByName.EXP).toBeGreaterThan(0) + expect( + expression.result.operations.find(({ name }) => name === 'EXP'), + ).toMatchObject({ + family: 'arithmetic', + valueSemantics: { + operation: 'exponentiate', + kind: 'dynamic-operation', + known: false, + }, + }) + expect( + expression.result.operations + .filter(({ name }) => name === 'TYPEOF_SAFE') + .every( + ({ inputs, globalReference }) => + inputs.length === 0 && + typeof globalReference.name === 'string' && + globalReference.constant.value === globalReference.name, + ), + ).toBe(true) + + const globals = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/encoded.js', + ) + expect( + globals.result.operations.filter(({ globalReference }) => globalReference), + ).not.toHaveLength(0) + for (const operation of globals.result.operations) { + if (operation.globalReference) { + expect(typeof operation.globalReference.name).toBe('string') + expect(operation.globalReference.constant.value).toBe( + operation.globalReference.name, + ) + } + } + + const literals = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js', + ) + expect( + literals.result.operations + .filter(({ name }) => name === 'LOAD_CONST') + .every(({ constantReference, valueSemantics }) => + Object.is(valueSemantics.value, constantReference.value), + ), + ).toBe(true) + + const shortCircuit = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/encoded.js', + ) + expect( + shortCircuit.result.reachability.unreachableOperationCount, + ).toBeGreaterThan(0) + expect( + shortCircuit.result.operations.some(({ reachable }) => !reachable), + ).toBe(true) + + const boundary = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/encoded.js', + ) + expect( + boundary.wordcode.instructions + .filter(({ name }) => name === 'IN' || name === 'INSTANCEOF') + .map(({ name }) => name), + ).toEqual(['INSTANCEOF', 'INSTANCEOF']) + expect(boundary.result.operations.map(({ name }) => name)).not.toContain('IN') + expect(boundary.result.operations.map(({ name }) => name)).not.toContain( + 'INSTANCEOF', + ) + expect( + boundary.result.boundaryOmissions.find( + ({ boundary: name }) => name === 'J-property-prototype-collection', + ).names, + ).toContain('INSTANCEOF') +}) + +test('accepts reference plus every complete stripped corpus input', () => { + const paths = [ + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ...corpusPaths(), + ] + expect(paths).toHaveLength(34) + for (const relativePath of paths) { + const packet = resultAt(relativePath) + expect(packet.result.census.expectedInstructionCount).toBe( + packet.result.operationCount, + ) + expect(packet.result.census.operationKeys).toHaveLength( + packet.result.operationCount, + ) + expect(new Set(packet.result.census.operationKeys).size).toBe( + packet.result.operationCount, + ) + expect(packet.result.proof.allScalarRowsPresent).toBe(true) + expect(packet.result.proof.allScalarRowsUnique).toBe(true) + expect(deepFrozen(packet.result)).toBe(true) + } +}) + +test('declines stale or malformed predecessor packets atomically', () => { + const original = packetAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + const before = JSON.stringify(original) + + const malformedWordcode = structuredClone(original) + malformedWordcode.wordcode.words[1] = 99 + expect(diagnoseDecline(malformedWordcode).diagnostic.code).toBe( + 'stale-predecessor', + ) + + const malformedReferences = structuredClone(original) + malformedReferences.references.labels.boundaries[0] = 1 + expect(diagnoseDecline(malformedReferences).diagnostic.code).toBe( + 'stale-predecessor', + ) + + const malformedFunctions = structuredClone(original) + malformedFunctions.functions.ownership[0].functionId = 99 + expect(diagnoseDecline(malformedFunctions).diagnostic.code).toBe( + 'stale-predecessor', + ) + + const malformedCfg = structuredClone(original) + malformedCfg.cfg.edges.pop() + expect(diagnoseDecline(malformedCfg).diagnostic.code).toBe( + 'stale-predecessor', + ) + + expect(JSON.stringify(original)).toBe(before) + expect( + analyzeScalarValues( + original.wordcode, + original.references, + original.functions, + original.cfg, + ), + ).not.toBeNull() +}) + +test('keeps input packets unchanged and freezes successful and declined diagnoses', () => { + const packet = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/encoded.js', + ) + const before = JSON.stringify(packet) + const diagnosis = diagnoseScalarValues( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + ) + expect(diagnosis.ok).toBe(true) + expect(deepFrozen(diagnosis)).toBe(true) + expect(deepFrozen(diagnosis.result)).toBe(true) + expect(JSON.stringify(packet)).toBe(before) + expect(() => diagnosis.result.operations.push({})).toThrow() + expect(() => (diagnosis.result.operations[0].pc = 99)).toThrow() + + const declined = diagnoseDecline({ + ...structuredClone(packet), + cfg: { ...structuredClone(packet.cfg), schemaVersion: 'stale' }, + }) + expect(deepFrozen(declined)).toBe(true) + expect(declined.result).toBeNull() +}) diff --git a/test/vm/jsconfuser-vm/build-call-frames.test.js b/test/vm/jsconfuser-vm/build-call-frames.test.js new file mode 100644 index 00000000..1327908f --- /dev/null +++ b/test/vm/jsconfuser-vm/build-call-frames.test.js @@ -0,0 +1,275 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { buildControlFlow } from '../../../src/vm/jsconfuser-vm/build-cfg.js' +import { + buildCallFrames, + diagnoseCallFrames, +} from '../../../src/vm/jsconfuser-vm/build-call-frames.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions, `Packet D declined ${relativePath}`).not.toBeNull() + const cfg = buildControlFlow(wordcode, references, functions) + expect(cfg, `Packet E declined ${relativePath}`).not.toBeNull() + return { container, wordcode, references, functions, cfg } +} + +function frameAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseCallFrames( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function diagnosticFor(packet) { + const diagnosis = diagnoseCallFrames( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + ) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + return diagnosis.diagnostic +} + +function corpusPaths() { + return ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) +} + +test('materializes the pinned frame layout and root completion', () => { + const packet = frameAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-call-frames.v1', + encoding: 'numeric-u32', + wordCount: 32, + instructionCount: 9, + functionCount: 1, + frame: { + frameStart: 1, + headerSize: 8, + slots: { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, + }, + }, + frames: [ + { + functionId: 0, + frameBase: 1, + frameSize: 15, + registerBaseOffset: 8, + registerWindow: { start: 8, end: 15 }, + }, + ], + }) + expect(packet.result.returns).toEqual([ + { + pc: 30, + sourceRegister: { kind: 'register', index: 2 }, + reachable: true, + routes: [ + expect.objectContaining({ + kind: 'return', + sourcePc: 30, + targetPc: null, + route: 'propagate', + }), + ], + callerDestinations: [], + }, + ]) +}) + +test('resolves closure call provenance through return values and MOVE', () => { + const packet = frameAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + ) + + expect( + packet.result.callSites.map(({ pc, calleeFunctions }) => [ + pc, + calleeFunctions, + ]), + ).toEqual([ + [12, [1]], + [27, [2]], + [31, [2]], + [35, [2]], + ]) + expect( + packet.result.functions + .find(({ id }) => id === 2) + .returns.map((record) => ({ + pc: record.pc, + callers: record.callerDestinations.map(({ callSitePc }) => callSitePc), + })), + ).toEqual([ + { pc: 103, callers: [27, 31, 35] }, + { pc: 109, callers: [27, 31, 35] }, + ]) + expect( + packet.result.callSites.every( + ({ exceptionalCompletion }) => exceptionalCompletion !== null, + ), + ).toBe(true) +}) + +test('preserves method receivers, constructor state, and spread arrays', () => { + const method = frameAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js', + ) + const methodSpread = method.result.callSites.find( + ({ kind, arguments: args }) => + kind === 'CALL_METHOD' && args.kind === 'spread', + ) + expect(methodSpread).toMatchObject({ + receiver: { kind: 'register' }, + arguments: { kind: 'spread', arrayRegister: { kind: 'register' } }, + constructorState: { isConstructor: false, mode: 'ordinary' }, + }) + + const constructor = frameAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js', + ) + const newSpread = constructor.result.callSites.find( + ({ kind, arguments: args }) => kind === 'NEW' && args.kind === 'spread', + ) + expect(newSpread).toMatchObject({ + constructorState: { isConstructor: true, mode: 'allocate-this' }, + arguments: { kind: 'spread', arrayRegister: { kind: 'register' } }, + }) +}) + +test('accepts every stripped raw and focused corpus call-frame packet', () => { + const paths = [ + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ...corpusPaths(), + ] + expect(paths).toHaveLength(34) + for (const relativePath of paths) { + const packet = frameAt(relativePath) + const expectedCalls = packet.wordcode.instructions.filter(({ name }) => + ['CALL', 'CALL_METHOD', 'NEW'].includes(name), + ) + expect(packet.result.callSites).toHaveLength(expectedCalls.length) + expect(packet.result.functions).toHaveLength(packet.result.functionCount) + expect(packet.result.source).toEqual({ + wordcodeSchema: 'jsconfuser-vm-wordcode.v1', + referencesSchema: 'jsconfuser-vm-references.v1', + functionsSchema: 'jsconfuser-vm-functions.v1', + cfgSchema: 'jsconfuser-vm-cfg.v1', + }) + } +}) + +test('retains dynamic host calls without inventing internal targets', () => { + const packet = frameAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js', + ) + const hostCalls = packet.result.callSites.filter( + ({ calleeFunctions }) => calleeFunctions.length === 0, + ) + expect(hostCalls.length).toBeGreaterThan(0) + expect( + hostCalls.every( + ({ continuationPc, returnDestination, exceptionalCompletion }) => + continuationPc !== null && + returnDestination.kind === 'register' && + exceptionalCompletion !== null, + ), + ).toBe(true) +}) + +test('declines stale predecessors atomically', () => { + const source = packetAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + + const badFrame = structuredClone(source) + badFrame.references.frame.slots.PC = 99 + expect(diagnosticFor(badFrame).code).toBe('invalid-frame-metadata') + + const badCfg = structuredClone( + packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js', + ), + ) + badCfg.cfg.callSites[0] = { pc: 0 } + expect(diagnosticFor(badCfg).code).toBe('input-mismatch') + + const badFunctions = structuredClone(source) + badFunctions.functions.ownership[0].functionId = 99 + expect(diagnosticFor(badFunctions).code).toBe('input-mismatch') +}) + +test('does not mutate frozen predecessors and deeply freezes output', () => { + const packet = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js', + ) + const before = structuredClone(packet) + const result = buildCallFrames( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + ) + expect(result).not.toBeNull() + expect(packet).toEqual(before) + expect(deepFrozen(result)).toBe(true) + expect(() => result.callSites.push({})).toThrow() + expect(() => (result.frame.headerSize = 99)).toThrow() +}) diff --git a/test/vm/jsconfuser-vm/build-cfg.test.js b/test/vm/jsconfuser-vm/build-cfg.test.js new file mode 100644 index 00000000..25ec5286 --- /dev/null +++ b/test/vm/jsconfuser-vm/build-cfg.test.js @@ -0,0 +1,330 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { + buildControlFlow, + diagnoseControlFlow, +} from '../../../src/vm/jsconfuser-vm/build-cfg.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions, `Packet D declined ${relativePath}`).not.toBeNull() + return { container, wordcode, references, functions } +} + +function cfgAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseControlFlow( + packet.wordcode, + packet.references, + packet.functions, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function diagnosticFor(packet) { + const diagnosis = diagnoseControlFlow( + packet.wordcode, + packet.references, + packet.functions, + ) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + return diagnosis.diagnostic +} + +function instructionAt(packet, pc) { + const instruction = packet.wordcode.instructions.find( + (item) => item.pc === pc, + ) + expect(instruction).toBeDefined() + return instruction +} + +function setImmediate(packet, pc, value) { + const instruction = instructionAt(packet, pc) + packet.wordcode.words[pc + 2] = value + instruction.words[2] = value + instruction.operands[1].value = value + instruction.wordOperands[1].value = value +} + +function setJumpRegister(packet, pc, register) { + const instruction = instructionAt(packet, pc) + packet.wordcode.words[pc + 1] = register + instruction.words[1] = register + instruction.operands[0].index = register + instruction.wordOperands[0].index = register +} + +function corpusPaths() { + return ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) +} + +function edgeCounts(result) { + return Object.fromEntries( + [...new Set(result.edges.map(({ kind }) => kind))] + .sort() + .map((kind) => [ + kind, + result.edges.filter((edge) => edge.kind === kind).length, + ]), + ) +} + +test('builds an immutable reference CFG with an explicit return edge', () => { + const packet = cfgAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-cfg.v1', + encoding: 'numeric-u32', + wordCount: 32, + instructionCount: 9, + functionCount: 1, + indirectTargets: [], + functions: [ + { + id: 0, + startPc: 0, + endPc: 32, + instructionCount: 9, + irreducible: { required: false, fallback: null }, + }, + ], + }) + expect(edgeCounts(packet.result)).toEqual({ fallthrough: 8, return: 1 }) + expect(packet.result.edges.at(-1)).toMatchObject({ + kind: 'return', + sourcePc: 30, + targetPc: null, + route: 'propagate', + }) + expect(deepFrozen(packet.result)).toBe(true) +}) + +test('builds closure CFGs with call continuations and no indirect targets', () => { + const packet = cfgAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + ) + + expect( + packet.result.functions.map(({ id, startPc, endPc }) => [ + id, + startPc, + endPc, + ]), + ).toEqual([ + [0, 0, 55], + [1, 55, 81], + [2, 81, 111], + ]) + expect( + packet.result.functions.reduce((count, fn) => count + fn.blocks.length, 0), + ).toBe(29) + expect(edgeCounts(packet.result)).toEqual({ + call: 4, + fallthrough: 20, + return: 3, + throw: 4, + }) + expect(packet.result.callSites).toHaveLength(4) + expect( + packet.result.callSites.every( + ({ continuationPc, returnDestination }) => + continuationPc !== null && returnDestination.kind === 'register', + ), + ).toBe(true) + expect(packet.result.indirectTargets).toEqual([]) +}) + +test('builds finally and abrupt CFGs with finite JUMP_REG branches', () => { + const packet = cfgAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js', + ) + + expect( + packet.result.functions.map(({ startPc, endPc }) => [startPc, endPc]), + ).toEqual([ + [0, 102], + [102, 182], + [182, 255], + [255, 372], + [372, 517], + ]) + expect(edgeCounts(packet.result)).toEqual({ + branch: 14, + call: 13, + conditional: 3, + fallthrough: 101, + finally: 11, + handler: 3, + return: 5, + throw: 12, + }) + expect(packet.result.edges).toHaveLength(162) + expect( + packet.result.functions.reduce((count, fn) => count + fn.blocks.length, 0), + ).toBe(145) + expect( + packet.result.indirectTargets.map( + ({ functionId, pc, sourceRegister, targets }) => ({ + functionId, + pc, + sourceRegister, + targets, + }), + ), + ).toEqual([ + { functionId: 1, pc: 170, sourceRegister: 2, targets: [172, 174] }, + { functionId: 2, pc: 245, sourceRegister: 3, targets: [247, 249] }, + { functionId: 3, pc: 303, sourceRegister: 5, targets: [305] }, + { functionId: 3, pc: 362, sourceRegister: 3, targets: [364, 366] }, + { + functionId: 4, + pc: 487, + sourceRegister: 3, + targets: [489, 491, 493, 495], + }, + ]) + for (const jump of packet.result.indirectTargets) { + const successors = packet.result.edges.filter( + ({ sourcePc }) => sourcePc === jump.pc, + ) + expect(successors).toHaveLength(jump.targets.length) + expect( + successors.every( + ({ kind, mode }) => kind === 'branch' && mode === 'indirect', + ), + ).toBe(true) + } +}) + +test('accepts every stripped raw and focused corpus cell', () => { + const paths = corpusPaths() + expect(paths).toHaveLength(33) + for (const relativePath of paths) { + const packet = cfgAt(relativePath) + expect(packet.result.functions[0].startPc).toBe(0) + expect(packet.result.functions.at(-1).endPc).toBe(packet.result.wordCount) + expect(packet.result.ownership).toHaveLength(packet.result.instructionCount) + expect( + packet.result.edges.every( + ({ handlerStateBefore, handlerStateAfter }) => + deepFrozen(handlerStateBefore) && deepFrozen(handlerStateAfter), + ), + ).toBe(true) + } +}) + +test('declines unresolved, non-boundary, out-of-range, and cross-function JUMP_REG targets', () => { + const unresolved = structuredClone( + packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js', + ), + ) + setJumpRegister(unresolved, 487, 0) + expect(diagnosticFor(unresolved).code).toBe('unresolved-jump-reg') + + const nonBoundary = structuredClone( + packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js', + ), + ) + setImmediate(nonBoundary, 408, 409) + expect(diagnosticFor(nonBoundary).code).toBe('out-of-range-target') + + const outOfRange = structuredClone( + packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js', + ), + ) + setImmediate(outOfRange, 408, outOfRange.wordcode.words.length + 1) + expect(diagnosticFor(outOfRange).code).toBe('out-of-range-target') + + const crossFunction = structuredClone( + packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js', + ), + ) + setImmediate(crossFunction, 408, 174) + expect(diagnosticFor(crossFunction).code).toBe('cross-function-target') +}) + +test('declines malformed predecessor state atomically and preserves inputs', () => { + const packet = packetAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + const before = structuredClone(packet) + const malformed = structuredClone(packet) + malformed.references.labels.boundaries[0] = 1 + const report = diagnoseControlFlow( + malformed.wordcode, + malformed.references, + malformed.functions, + ) + expect(report).toMatchObject({ + ok: false, + result: null, + diagnostic: { code: 'input-mismatch' }, + }) + expect(packet).toEqual(before) + expect(deepFrozen(report)).toBe(true) +}) + +test('does not mutate frozen predecessors and freezes successful output', () => { + const packet = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js', + ) + const before = structuredClone(packet) + const result = buildControlFlow( + packet.wordcode, + packet.references, + packet.functions, + ) + expect(result).not.toBeNull() + expect(packet).toEqual(before) + expect(deepFrozen(result)).toBe(true) + expect(() => result.functions.push({})).toThrow() + expect(() => (result.functions[0].startPc = 99)).toThrow() +}) diff --git a/test/vm/jsconfuser-vm/decode-standalone.test.js b/test/vm/jsconfuser-vm/decode-standalone.test.js new file mode 100644 index 00000000..2ad469dd --- /dev/null +++ b/test/vm/jsconfuser-vm/decode-standalone.test.js @@ -0,0 +1,333 @@ +import fs from 'node:fs' +import path from 'node:path' +import vm from 'node:vm' +import { fileURLToPath } from 'node:url' +import { expect, test } from 'vitest' +import { + assertDerivedStateConsistency, + assertNoVmResidue, + parseSuccessfulOutput, +} from './integration/harness.js' +import { + decodeStandalone, + diagnoseStandalone, +} from '../../../src/vm/jsconfuser-vm/decode-standalone.js' + +const repositoryRootUrl = new URL('../../../', import.meta.url) +function corpusCases() { + const entries = [ + { + corpusKind: 'reference', + cellId: 'reference', + relativePath: 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + }, + ] + for (const corpusKind of ['raw', 'focused']) { + const directory = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${corpusKind}/`, + repositoryRootUrl, + ) + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const relativePath = `test/vm/jsconfuser-vm/fixtures/corpus/${corpusKind}/${entry.name}/encoded.js` + if (!fs.existsSync(new URL(relativePath, repositoryRootUrl))) continue + entries.push({ + corpusKind, + cellId: entry.name, + relativePath, + }) + } + } + return entries + .sort((left, right) => left.relativePath.localeCompare(right.relativePath)) + .map((entry) => { + const encodedPath = fileURLToPath( + new URL(entry.relativePath, repositoryRootUrl), + ) + const directory = path.dirname(encodedPath) + return { + ...entry, + encodedSource: fs.readFileSync(encodedPath, 'utf8'), + oracle: JSON.parse( + fs.readFileSync(path.join(directory, 'oracle.json'), 'utf8'), + ), + } + }) +} + +function assertStaticOutput(output) { + const ast = parseSuccessfulOutput(output) + expect(assertNoVmResidue(ast).ok).toBe(true) + expect(assertDerivedStateConsistency(ast, output).ok).toBe(true) +} + +const expectedStructuredDeclines = new Set([ + 'f-finally-abrupt', + 'f-switch-flow', + 'f-throw-catch', + 'program-cash', + 'program-sha256', +]) + +function functionOutput(output, functionId) { + const start = output.indexOf(`function __recovered_function_${functionId}(`) + expect(start).toBeGreaterThanOrEqual(0) + const next = output.indexOf('\n function __recovered_function_', start + 1) + return output.slice(start, next === -1 ? output.length : next) +} + +function assertControlContract(diagnosis, output, entry) { + const control = diagnosis.result.control + expect(control.schemaVersion).toBe('jsconfuser-vm-structured-control.v1') + expect(control.functions).toHaveLength(diagnosis.result.functionCount) + for (const controlFunction of control.functions) { + const functionSource = functionOutput(output, controlFunction.id) + if (controlFunction.mode === 'structured') { + expect(controlFunction.control.kind).toBe('structured') + expect(functionSource).not.toContain('__recovered_pc') + expect(functionSource).not.toContain('__recovered_dispatch') + expect(functionSource).not.toContain('switch (__recovered_pc)') + } else { + expect(controlFunction.mode).toBe('state-machine') + expect(controlFunction.control.kind).toBe('state-machine') + expect(controlFunction.control.proof).toMatchObject({ + exactBlockGraph: true, + explicitLeaves: true, + irreducible: { required: true, fallback: 'state-machine' }, + }) + expect(functionSource).toContain('__recovered_dispatch') + } + } + if (['reference', 'f-branching', 'f-loop-forms'].includes(entry.cellId)) { + expect(control.functions.every(({ mode }) => mode === 'structured')).toBe( + true, + ) + expect(output).not.toContain('__recovered_pc') + expect(output).not.toContain('__recovered_dispatch') + expect(output).not.toContain('switch (__recovered_pc)') + } +} + +function isError(value) { + return ( + value && + typeof value === 'object' && + typeof value.name === 'string' && + typeof value.message === 'string' && + (Object.prototype.toString.call(value).endsWith('Error]') || + 'stack' in value) + ) +} + +function project(value, seen = new WeakSet()) { + if (value === undefined) return { type: 'undefined' } + if (typeof value === 'number') { + if (Number.isNaN(value)) return { type: 'nan' } + if (value === Infinity) return { type: 'infinity' } + if (value === -Infinity) return { type: '-infinity' } + if (Object.is(value, -0)) return { type: '-0' } + return value + } + if (typeof value === 'bigint') return { type: 'bigint', value: String(value) } + if (typeof value === 'function') + return { type: 'function', name: value.name || '' } + if (value === null || typeof value !== 'object') return value + if (isError(value)) + return { type: 'error', name: value.name, message: value.message } + if (seen.has(value)) return { type: 'cycle' } + seen.add(value) + const output = Array.isArray(value) + ? value.map((item) => project(item, seen)) + : Object.fromEntries( + Object.keys(value).map((key) => [key, project(value[key], seen)]), + ) + seen.delete(value) + return output +} + +function thrown(value) { + return isError(value) + ? { type: 'error', name: value.name, message: value.message } + : { type: 'primitive', value: project(value) } +} + +function makeExecutionContext(hostProfile) { + const window = { TEST_OUTPUT: null } + if (hostProfile === 'cash-dom-test-v1') { + window.document = { + documentElement: {}, + createElement: function () { + return { style: {} } + }, + } + window.module = { exports: {} } + window.exports = undefined + for (const name of Object.getOwnPropertyNames(globalThis)) { + if (name !== 'globalThis' && !(name in window)) + window[name] = globalThis[name] + } + } + window.window = window + return { context: vm.createContext(window), window } +} + +function observe(window, actions) { + if (!actions?.length) return project(window.TEST_OUTPUT) + const result = {} + const at = (value, keys) => keys.reduce((current, key) => current[key], value) + for (const action of actions) { + const root = action.root === 'window' ? window : window.TEST_OUTPUT + if (action.op === 'keys') + result[action.as] = Object.keys(at(root, action.path || [])) + else if (action.op === 'typeOf') + result[action.as] = typeof at(root, action.path || []) + else if (action.op === 'get') + result[action.as] = project(at(root, action.path || [])) + else if (action.op === 'call') { + const keys = action.path || [] + const parent = keys.length ? at(root, keys.slice(0, -1)) : undefined + const fn = keys.length ? parent[keys[keys.length - 1]] : root + result[action.as] = project(fn.apply(parent, action.args || [])) + } else throw new Error(`Unknown oracle action ${action.op}`) + } + return result +} + +function runRecovered(output, oracle) { + const { context, window } = makeExecutionContext(oracle.hostProfile) + try { + vm.runInContext(output, context, { + timeout: oracle.timeoutMs || 2000, + }) + return { ok: true, value: observe(window, oracle.actions) } + } catch (error) { + return { ok: false, thrown: thrown(error) } + } +} + +function expectedBehavior(oracle) { + return oracle.expectedThrow + ? { ok: false, thrown: oracle.expectedThrow } + : { ok: true, value: oracle.expected } +} + +function replaceBytecodeWords(source, words) { + const match = /var BYTECODE = \[([\s\S]*?)\];/.exec(source) + if (!match) + throw new Error('reference input has no numeric BYTECODE declaration') + const replacement = `var BYTECODE = [${words.join(', ')}];` + return ( + source.slice(0, match.index) + + replacement + + source.slice(match.index + match[0].length) + ) +} + +test( + 'recovers the tracked corpus with complete structure and bounded behavior', + { timeout: 120000 }, + () => { + const entries = corpusCases() + expect(entries).toHaveLength(34) + for (const entry of entries) { + const diagnosis = diagnoseStandalone(entry.encodedSource) + if (!diagnosis.ok) { + expect( + expectedStructuredDeclines.has(entry.cellId), + entry.relativePath, + ).toBe(true) + expect(diagnosis.diagnostic.code, entry.relativePath).toBe( + 'unemittable-structured-control', + ) + expect( + decodeStandalone(entry.encodedSource), + entry.relativePath, + ).toBeNull() + continue + } + expect(Object.isFrozen(diagnosis)).toBe(true) + expect(Object.isFrozen(diagnosis.result)).toBe(true) + expect(diagnosis.result.proof).toMatchObject({ + allPredecessorsReconstructed: true, + noTargetExecution: true, + noVmRuntimeEmission: true, + freshJavaScriptEmission: true, + }) + + const output = diagnosis.result.output + assertControlContract(diagnosis, output, entry) + assertStaticOutput(output) + expect(runRecovered(output, entry.oracle), entry.relativePath).toEqual( + expectedBehavior(entry.oracle), + ) + } + }, +) + +test('malformed and unsupported containers never emit partial source', () => { + const reference = corpusCases().find( + ({ corpusKind }) => corpusKind === 'reference', + ).encodedSource + const words = [ + 4, 1, 2, 2, 0, 0, 0, 3, 1, 0, 0, 4, 2, 0, 0, 5, 3, 0, 11, 6, 4, 5, 9, 2, 3, + 6, 0, 2, 4, 0, 45, 2, + ] + const mutations = [ + [ + 'encoded bytecode', + reference.replace( + 'var ENCODE_BYTECODE = false;', + 'var ENCODE_BYTECODE = true;', + ), + 'container-declined', + ], + [ + 'truncated wordcode', + replaceBytecodeWords(reference, words.slice(0, -1)), + 'wordcode-declined', + ], + [ + 'extra wordcode', + replaceBytecodeWords(reference, [...words, 0]), + 'wordcode-declined', + ], + [ + 'unknown opcode', + replaceBytecodeWords(reference, [56, ...words.slice(1)]), + 'wordcode-declined', + ], + [ + 'missing bytecode binding', + reference.replace('var BYTECODE =', 'var NOT_BYTECODE ='), + 'container-declined', + ], + [ + 'malformed source', + 'not a valid js-confuser-vm container', + 'container-declined', + ], + ] + for (const [name, input, code] of mutations) { + expect(input, name).not.toBe(reference) + const diagnosis = diagnoseStandalone(input) + expect(diagnosis.ok, name).toBe(false) + expect(diagnosis.result, name).toBeNull() + expect(diagnosis.diagnostic.code, name).toBe(code) + expect(Object.isFrozen(diagnosis), name).toBe(true) + expect(Object.isFrozen(diagnosis.diagnostic), name).toBe(true) + expect(decodeStandalone(input), name).toBeNull() + } +}) + +test('decodeStandalone returns only the diagnosed source', () => { + const input = fs.readFileSync( + new URL( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + repositoryRootUrl, + ), + 'utf8', + ) + const diagnosis = diagnoseStandalone(input) + expect(diagnosis.ok).toBe(true) + expect(decodeStandalone(input)).toBe(diagnosis.result.output) +}) diff --git a/test/vm/jsconfuser-vm/diagnose-standalone.test.js b/test/vm/jsconfuser-vm/diagnose-standalone.test.js new file mode 100644 index 00000000..8ed06b22 --- /dev/null +++ b/test/vm/jsconfuser-vm/diagnose-standalone.test.js @@ -0,0 +1,33 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { diagnoseStandaloneInput } from '../../../src/vm/jsconfuser-vm/diagnose-standalone.js' + +function fixture(relativePath) { + return fs.readFileSync( + new URL(`./fixtures/${relativePath}`, import.meta.url), + 'utf8', + ) +} + +test('standalone diagnosis separates complete preflight from a container decline', () => { + const positive = diagnoseStandaloneInput( + fixture('corpus/raw/f-literals-order/encoded.js'), + ) + expect(positive.ok).toBe(true) + expect(positive.diagnostic).toBeNull() + expect(positive.model.wordcode.instructionCount).toBeGreaterThan(0) + expect(positive.model.controlByFunction.size).toBeGreaterThan(0) + expect(positive.model).not.toHaveProperty('output') + + const negative = diagnoseStandaloneInput( + fixture('corpus/negative/encoded-bytecode-mode/encoded.js'), + ) + expect(negative).toEqual({ + ok: false, + model: null, + diagnostic: { + code: 'container-declined', + message: 'Packet A declined the input container', + }, + }) +}) diff --git a/test/vm/jsconfuser-vm/emit-call-completion.test.js b/test/vm/jsconfuser-vm/emit-call-completion.test.js new file mode 100644 index 00000000..8c8bed16 --- /dev/null +++ b/test/vm/jsconfuser-vm/emit-call-completion.test.js @@ -0,0 +1,403 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { buildControlFlow } from '../../../src/vm/jsconfuser-vm/build-cfg.js' +import { buildCallFrames } from '../../../src/vm/jsconfuser-vm/build-call-frames.js' +import { analyzeClosureLifetimes } from '../../../src/vm/jsconfuser-vm/analyze-closure-lifetimes.js' +import { + diagnoseCallCompletion, + emitCallCompletion, +} from '../../../src/vm/jsconfuser-vm/emit-call-completion.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions, `Packet D declined ${relativePath}`).not.toBeNull() + const cfg = buildControlFlow(wordcode, references, functions) + expect(cfg, `Packet E declined ${relativePath}`).not.toBeNull() + const callFrames = buildCallFrames(wordcode, references, functions, cfg) + expect(callFrames, `Packet F declined ${relativePath}`).not.toBeNull() + const closureLifetimes = analyzeClosureLifetimes( + wordcode, + references, + functions, + cfg, + callFrames, + ) + expect(closureLifetimes, `Packet G declined ${relativePath}`).not.toBeNull() + return { + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + } +} + +function resultAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseCallCompletion( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + packet.closureLifetimes, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return Object.isFrozen(value) && Object.values(value).every(deepFrozen) +} + +function clonePacket(packet) { + return structuredClone(packet) +} + +function diagnosisFor(packet) { + const diagnosis = diagnoseCallCompletion( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + packet.closureLifetimes, + ) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + expect(deepFrozen(diagnosis)).toBe(true) + return diagnosis +} + +function corpusPaths() { + return ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) +} + +const referenceCase = 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js' +const methodCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js' +const newCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js' +const closureCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js' +const throwCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/encoded.js' +const abruptCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js' + +test('emits exact all-instruction coverage and the reference completion boundary', () => { + const packet = resultAt(referenceCase) + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-call-completion.v1', + encoding: 'numeric-u32', + parseOnly: true, + targetExecution: false, + emittedJavaScript: false, + vmExecuted: false, + finalJavaScriptSemantics: false, + semanticBoundary: 'later-packet', + wordCount: 32, + instructionCount: 9, + functionCount: 1, + opcodeScope: { + family: 'L-call-receiver-construction-completion', + count: 5, + }, + }) + expect(packet.result.calls).toHaveLength(0) + expect(packet.result.returns).toHaveLength(1) + expect(packet.result.throws).toHaveLength(0) + expect(packet.result.instructions).toHaveLength(9) + expect(packet.result.coverage).toMatchObject({ + instructions: { expected: 9, emitted: 9, unique: true, complete: true }, + calls: { expected: 0, emitted: 0, unique: true, complete: true }, + completions: { expected: 1, emitted: 1, unique: true, complete: true }, + completionRoutes: { unique: true, complete: true }, + }) + expect(packet.result.returns[0]).toMatchObject({ + pc: 30, + sourceRegister: { kind: 'register', index: 2 }, + reachable: true, + semanticBoundary: 'later-packet', + }) + expect(packet.result.returns[0].routes).toHaveLength(1) + expect(deepFrozen(packet.result)).toBe(true) +}) + +test('preserves plain, method, and constructor call grammar exactly', () => { + const method = resultAt(methodCase) + const methodCalls = method.result.calls + expect(methodCalls.map(({ name }) => name)).toEqual([ + 'CALL_METHOD', + 'CALL_METHOD', + ]) + expect(methodCalls[0]).toMatchObject({ + pc: 58, + width: 6, + receiver: { kind: 'register', index: 4 }, + callee: { kind: 'register', index: 7 }, + grammar: { + kind: 'fixed', + argcWord: 1, + count: 1, + sentinel: null, + }, + receiverPreservation: { + kind: 'call-method-this-argument', + register: { kind: 'register', index: 4 }, + }, + evaluationOrder: { + roles: ['receiver', 'callee', 'argument-count', 'argument[0]'], + }, + }) + expect(methodCalls[1]).toMatchObject({ + pc: 64, + width: 6, + grammar: { + kind: 'spread', + argcWord: 65535, + sentinel: 65535, + arrayRegister: { kind: 'register', index: 6 }, + payloadWords: [6], + }, + arguments: { + kind: 'spread', + arrayRegister: { kind: 'register', index: 6 }, + }, + }) + + const constructor = resultAt(newCase) + const newCall = constructor.result.calls.find(({ name }) => name === 'NEW') + expect(newCall).toMatchObject({ + pc: 45, + width: 5, + receiver: null, + grammar: { + kind: 'spread', + argcWord: 65535, + sentinel: 65535, + arrayRegister: { kind: 'register', index: 6 }, + payloadWords: [6], + }, + target: { kind: 'internal-closure', functionIds: [1] }, + constructorState: { isConstructor: true, mode: 'allocate-this' }, + construction: { + kind: 'new-construction', + allocationMode: 'allocate-this', + returnRule: 'explicit-object-or-allocated-this', + }, + }) + expect(newCall.evaluationOrder.roles).toEqual([ + 'callee', + 'argument-count', + 'spread-array', + ]) +}) + +test('preserves internal/host call targets and closure owner facts', () => { + const internal = resultAt(closureCase) + expect(internal.result.calls.map(({ target }) => target.kind)).toEqual([ + 'internal-closure', + 'internal-closure', + 'internal-closure', + 'internal-closure', + ]) + expect(internal.result.calls[1].target).toMatchObject({ + functionIds: [2], + closureSiteIds: ['closure:1:64'], + }) + expect(internal.result.ownerFacts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + functionId: 1, + parentFunctionId: 0, + closureSiteIds: ['closure:1:64'], + }), + ]), + ) + expect(internal.result.closureLifetimes.bindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'binding:1:register:2', + lifecycle: expect.objectContaining({ + initialState: 'open-upvalue', + afterCloseState: 'closed-captured-value', + }), + }), + ]), + ) + + const host = resultAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js', + ) + expect( + host.result.calls.some(({ target }) => target.kind === 'host-or-dynamic'), + ).toBe(true) + expect( + host.result.calls + .filter(({ target }) => target.kind === 'host-or-dynamic') + .every(({ target }) => target.functionIds.length === 0), + ).toBe(true) +}) + +test('retains reachable and unreachable return/throw records with routes', () => { + const abrupt = resultAt(abruptCase) + expect(abrupt.result.returns.length).toBeGreaterThan(0) + expect(abrupt.result.throws.length).toBeGreaterThan(0) + expect(abrupt.result.returns.some(({ reachable }) => !reachable)).toBe(true) + expect(abrupt.result.throws.every(({ routes }) => routes.length > 0)).toBe( + true, + ) + expect( + abrupt.result.completionRoutes.some(({ kind }) => kind === 'finally'), + ).toBe(true) + expect( + abrupt.result.calls.some( + ({ completion }) => completion.exceptional?.kind === 'finally', + ), + ).toBe(true) + + const throws = resultAt(throwCase) + expect(throws.result.throws.map(({ pc }) => pc)).toEqual([15, 68, 137]) + expect(throws.result.throws[0]).toMatchObject({ + payloadRegister: { kind: 'register', index: 4 }, + reachable: true, + routes: [ + { + kind: 'handler', + route: 'exceptional', + payload: { throwRegister: 4 }, + }, + ], + }) +}) + +test('accepts every reference, raw, and focused input with exact L census', () => { + const paths = [referenceCase, ...corpusPaths()] + expect(paths).toHaveLength(34) + for (const relativePath of paths) { + const { result } = resultAt(relativePath) + expect(result.coverage.instructions).toMatchObject({ + expected: result.instructionCount, + emitted: result.instructionCount, + unique: true, + complete: true, + }) + expect(result.coverage.calls).toMatchObject({ + unique: true, + complete: true, + }) + expect(result.coverage.completions).toMatchObject({ + unique: true, + complete: true, + }) + expect(result.coverage.completionRoutes).toMatchObject({ + unique: true, + complete: true, + }) + expect(result.census.dropped).toEqual([]) + expect(result.census.duplicated).toEqual([]) + expect(result.proof.allPredecessorsReconstructed).toBe(true) + } +}) + +test('declines malformed or stale predecessors atomically', () => { + const original = packetAt(abruptCase) + const before = structuredClone(original) + const mutations = [ + (packet) => { + packet.wordcode.instructions[0].width += 1 + }, + (packet) => { + packet.cfg.edges.find(({ sourcePc }) => sourcePc === 297).targetPc = 0 + }, + (packet) => { + packet.callFrames.callSites.find(({ pc }) => pc === 297).continuationPc = + 0 + }, + (packet) => { + packet.closureLifetimes.wordCount += 1 + }, + (packet) => { + packet.wordcode.instructions.find( + ({ name }) => name === 'CALL_METHOD', + ).width = 5 + }, + ] + for (const mutate of mutations) { + const packet = clonePacket(original) + mutate(packet) + const diagnosis = diagnosisFor(packet) + expect(diagnosis.diagnostic.code).toMatch( + /stale|invalid|input|completion|wordcode|cfg/i, + ) + } + const malformed = clonePacket(original) + malformed.callFrames.schemaVersion = 'wrong' + const malformedDiagnosis = diagnosisFor(malformed) + expect(malformedDiagnosis.diagnostic.code).toBe('invalid-call-frames') + expect(original).toEqual(before) +}) + +test('does not mutate inputs and deeply freezes results and diagnoses', () => { + const packet = packetAt(methodCase) + const before = structuredClone(packet) + const result = emitCallCompletion( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + packet.closureLifetimes, + ) + expect(result).not.toBeNull() + expect(deepFrozen(result)).toBe(true) + expect(packet).toEqual(before) + expect(() => result.calls.push({})).toThrow() + expect(() => { + result.calls[0].width = 99 + }).toThrow() + + const diagnosis = diagnosisFor({ + ...clonePacket(packet), + wordcode: { ...clonePacket(packet).wordcode, wordCount: 1 }, + }) + expect(deepFrozen(diagnosis)).toBe(true) +}) diff --git a/test/vm/jsconfuser-vm/emit-closure-exception.test.js b/test/vm/jsconfuser-vm/emit-closure-exception.test.js new file mode 100644 index 00000000..75981c63 --- /dev/null +++ b/test/vm/jsconfuser-vm/emit-closure-exception.test.js @@ -0,0 +1,585 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { buildControlFlow } from '../../../src/vm/jsconfuser-vm/build-cfg.js' +import { buildCallFrames } from '../../../src/vm/jsconfuser-vm/build-call-frames.js' +import { analyzeClosureLifetimes } from '../../../src/vm/jsconfuser-vm/analyze-closure-lifetimes.js' +import { analyzeExceptionFinally } from '../../../src/vm/jsconfuser-vm/analyze-exception-finally.js' +import { + diagnoseClosureException, + emitClosureException, +} from '../../../src/vm/jsconfuser-vm/emit-closure-exception.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetFromContainer(container) { + const wordcode = readWordcode(container) + expect(wordcode).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions).not.toBeNull() + const cfg = buildControlFlow(wordcode, references, functions) + expect(cfg).not.toBeNull() + const callFrames = buildCallFrames(wordcode, references, functions, cfg) + expect(callFrames).not.toBeNull() + const closureLifetimes = analyzeClosureLifetimes( + wordcode, + references, + functions, + cfg, + callFrames, + ) + expect(closureLifetimes).not.toBeNull() + const exceptionFinally = analyzeExceptionFinally( + wordcode, + references, + functions, + cfg, + callFrames, + ) + expect(exceptionFinally).not.toBeNull() + return { + wordcode, + references, + functions, + cfg, + callFrames, + closureLifetimes, + exceptionFinally, + } +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + return packetFromContainer(container) +} + +function resultAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseClosureException( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + packet.closureLifetimes, + packet.exceptionFinally, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function clonePacket(packet) { + return Object.fromEntries( + Object.entries(packet).map(([key, value]) => [key, structuredClone(value)]), + ) +} + +function diagnoseMutation(packet, mutate) { + const mutated = clonePacket(packet) + mutate(mutated) + const diagnosis = diagnoseClosureException( + mutated.wordcode, + mutated.references, + mutated.functions, + mutated.cfg, + mutated.callFrames, + mutated.closureLifetimes, + mutated.exceptionFinally, + ) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + expect(deepFrozen(diagnosis)).toBe(true) + return diagnosis.diagnostic +} + +function corpusPaths() { + const corpus = ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) + return ['test/vm/jsconfuser-vm/fixtures/reference/encoded.js', ...corpus] +} + +const OPCODES = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +} + +function syntheticIrreduciblePacket() { + return packetFromContainer({ + schemaVersion: 'jsconfuser-vm-container.v1', + encoding: 'numeric-u32', + roles: { + words: { values: [40, 0, 5, 39, 5, 39, 3] }, + op: { values: OPCODES }, + sentinels: { values: { CALL_SPREAD: 65535 } }, + scalars: { + ENCODE_BYTECODE: { value: false }, + MAIN_START_PC: { value: 0 }, + MAIN_REG_COUNT: { value: 1 }, + FRAME_START: { value: 1 }, + HEADER_SIZE: { value: 8 }, + TIMING_CHECKS: { value: false }, + }, + pool: { values: ['synthetic'] }, + slots: { + values: { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, + }, + }, + }, + }) +} + +const referenceCase = 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js' +const closureCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js' +const nestedClosureCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/encoded.js' +const throwCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/encoded.js' +const finallyCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js' + +test('emits an exact instruction boundary with an opaque later-packet fallback', () => { + const packet = resultAt(referenceCase) + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-closure-exception-emission.v1', + encoding: 'numeric-u32', + parseOnly: true, + targetExecution: false, + emittedJavaScript: false, + vmExecuted: false, + finalJavaScriptSemantics: false, + semanticBoundary: 'later-packet', + wordCount: 32, + instructionCount: 9, + functionCount: 1, + }) + expect(packet.result.coverage.instructions).toEqual({ + expected: 9, + emitted: 9, + unique: true, + complete: true, + }) + expect(packet.result.instructions).toHaveLength(9) + expect( + packet.result.instructions.every( + ({ semanticBoundary }) => semanticBoundary === 'later-packet', + ), + ).toBe(true) + expect(packet.result.completionRecords).toContainEqual( + expect.objectContaining({ + pc: 30, + kind: 'return-completion', + sourceRegister: { kind: 'register', index: 2 }, + routes: [expect.objectContaining({ kind: 'return' })], + }), + ) + expect(packet.result.functions[0].fallback.kind).toBe('opaque') + expect(packet.result.closureRecords).toEqual([]) + expect(packet.result.handlerRecords).toEqual([]) + expect(packet.result.finallyRecords).toEqual([]) + expect(deepFrozen(packet.result)).toBe(true) +}) + +test('preserves local and nested upvalue capture, owner, use, and lifetime records', () => { + const local = resultAt(closureCase) + expect(local.result.closureRecords).toContainEqual( + expect.objectContaining({ + id: 'closure:1:64', + parentFunctionId: 1, + childFunctionId: 2, + captures: [ + expect.objectContaining({ + source: { + kind: 'local', + parentFunctionId: 1, + register: { kind: 'register', index: 2 }, + }, + captureIndex: 0, + }), + ], + }), + ) + expect(local.result.bindingRecords).toContainEqual( + expect.objectContaining({ + id: 'binding:1:register:2', + lifecycle: expect.objectContaining({ + initialState: 'open-upvalue', + afterCloseState: 'closed-captured-value', + }), + }), + ) + expect(local.result.upvalueUseRecords).toEqual( + expect.arrayContaining([ + expect.objectContaining({ operation: 'read', state: 'closed' }), + expect.objectContaining({ operation: 'write', state: 'closed' }), + ]), + ) + expect(local.result.lifetimeTransitionRecords).toContainEqual( + expect.objectContaining({ + bindingId: 'binding:1:register:2', + fromState: 'open-upvalue', + toState: 'closed-captured-value', + completion: 'return', + }), + ) + + const nested = resultAt(nestedClosureCase) + const nestedCapture = nested.result.captureRecords.find( + ({ source }) => source.kind === 'upvalue', + ) + expect(nestedCapture).toBeDefined() + expect(nestedCapture.bindingOwnerFunctionId).not.toBe( + nestedCapture.parentFunctionId, + ) + expect(nestedCapture.bindingId).toBe( + `binding:${nestedCapture.bindingOwnerFunctionId}:register:${nestedCapture.bindingRegister.index}`, + ) + expect(nested.result.coverage.closures.capturePairs).toMatchObject({ + expected: nested.result.capturePairs.length, + emitted: nested.result.capturePairs.length, + complete: true, + }) +}) + +test('preserves try/catch/finally, abrupt completion, pads, merges, and route identity', () => { + const handler = resultAt(throwCase) + expect(handler.result.census.exceptions.handlers).toBe(2) + expect(handler.result.completionRecords).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'throw-completion' }), + ]), + ) + expect( + handler.result.routeRecords.some( + ({ kind, route }) => kind === 'handler' && route === 'exceptional', + ), + ).toBe(true) + expect( + handler.result.routeRecords.every(({ id }) => id.startsWith('route:')), + ).toBe(true) + + const abrupt = resultAt(finallyCase) + expect(abrupt.result.census.exceptions).toMatchObject({ + trySetup: 2, + tryEnd: 10, + finallySetup: 5, + handlers: 2, + finalizers: 5, + merges: 8, + jumpRegChecks: 5, + throwPads: 5, + }) + expect(abrupt.result.finallyRecords).toHaveLength(5) + expect(abrupt.result.finallyEmissionRecords).toContainEqual( + expect.objectContaining({ + id: 'finally@104', + kind: 'try-finally-region', + throwPadPc: 172, + }), + ) + expect(abrupt.result.throwPadRecords).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + finalizerId: 'finally@104', + throwPadPc: 172, + continuationReg: 2, + payloadReg: 3, + }), + ]), + ) + expect(abrupt.result.handlerFinallyStateRecords).toContainEqual( + expect.objectContaining({ + functionId: 3, + pc: 310, + kind: 'handler-finally-state', + handlerStateBefore: expect.objectContaining({ stack: expect.any(Array) }), + }), + ) + expect(abrupt.result.mergeRecords).toContainEqual( + expect.objectContaining({ functionId: 3, pc: 310, incomingCount: 2 }), + ) + expect(abrupt.result.completionRecords).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'throw-completion', + routes: expect.arrayContaining([ + expect.objectContaining({ kind: 'finally' }), + ]), + }), + ]), + ) + expect(abrupt.result.coverage.exceptions).toMatchObject({ + edges: { complete: true }, + states: { complete: true }, + }) + expect(abrupt.result.coverage.routes.complete).toBe(true) +}) + +test('uses a readable state-machine fallback for required irreducible control', () => { + const packet = syntheticIrreduciblePacket() + expect(packet.cfg.functions[0].irreducible).toMatchObject({ + required: true, + fallback: 'state-machine', + }) + const result = emitClosureException( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + packet.closureLifetimes, + packet.exceptionFinally, + ) + expect(result).not.toBeNull() + expect(result.functions[0].fallback).toMatchObject({ + kind: 'state-machine', + required: true, + stateVariable: '__jsconfuserVmState', + }) + expect(result.functions[0].fallback.dispatch).toHaveLength( + packet.cfg.functions[0].blocks.length, + ) + expect(deepFrozen(result)).toBe(true) +}) + +test('covers every instruction in the tracked reference and corpus inputs', () => { + const paths = corpusPaths() + expect(paths).toHaveLength(34) + for (const relativePath of paths) { + const { result } = resultAt(relativePath) + expect(result.coverage.instructions.complete).toBe(true) + expect(result.coverage.closures.sites.complete).toBe(true) + expect(result.coverage.closures.capturePairs.complete).toBe(true) + expect(result.coverage.exceptions.handlers.complete).toBe(true) + expect(result.coverage.exceptions.finalizers.complete).toBe(true) + expect(result.coverage.exceptions.edges.complete).toBe(true) + expect(result.coverage.completions.complete).toBe(true) + expect(result.coverage.routes.complete).toBe(true) + expect(result.proof.allPredecessorsReconstructed).toBe(true) + expect(deepFrozen(result)).toBe(true) + } +}) + +test('declines malformed, stale, invalid, unsupported, and incomplete inputs atomically', () => { + const original = packetAt(finallyCase) + const originalSnapshot = structuredClone(original) + + expect( + diagnoseMutation(original, ({ closureLifetimes }) => { + closureLifetimes.schemaVersion = 'wrong' + }).code, + ).toBe('invalid-closure-lifetimes') + + expect( + diagnoseMutation(original, ({ wordcode }) => { + wordcode.instructions[0].width += 1 + }).code, + ).toBe('stale-predecessor') + + expect( + diagnoseMutation(original, ({ wordcode }) => { + wordcode.encoding = 'encoded-bytes' + }).code, + ).toBe('invalid-wordcode') + + expect( + diagnoseMutation(original, ({ closureLifetimes }) => { + closureLifetimes.capturePairs[0].source.register.index = 999 + }).code, + ).toBe('stale-predecessor') + + expect( + diagnoseMutation(original, ({ functions }) => { + functions.functions[1].parentFunctionId = 1 + }).code, + ).toBe('stale-predecessor') + + expect( + diagnoseMutation(original, ({ cfg }) => { + const edge = cfg.functions + .find(({ id }) => id === 1) + .edges.find(({ sourcePc }) => sourcePc === 140) + edge.handlerStateBefore = { stack: [], finallyStack: [] } + }).code, + ).toBe('stale-predecessor') + + expect( + diagnoseMutation(original, ({ exceptionFinally }) => { + const edge = exceptionFinally.edgeStates.find( + ({ handlerStateAfter }) => handlerStateAfter.stack.length > 0, + ) + edge.handlerStateAfter = { + stack: [], + finallyStack: [], + } + }).code, + ).toBe('stale-predecessor') + + expect( + diagnoseMutation(original, ({ exceptionFinally }) => { + exceptionFinally.completionRoutes.pop() + }).code, + ).toBe('stale-predecessor') + + expect( + diagnoseMutation(original, ({ exceptionFinally }) => { + const route = exceptionFinally.completionRoutes.find( + ({ kind }) => kind === 'finally', + ) + route.payload = null + }).code, + ).toBe('stale-predecessor') + + expect(original).toEqual(originalSnapshot) +}) + +test('returns null on decline and freezes both success and diagnostic boundaries', () => { + const packet = packetAt(closureCase) + const before = structuredClone(packet) + const result = emitClosureException( + packet.wordcode, + packet.references, + packet.functions, + packet.cfg, + packet.callFrames, + packet.closureLifetimes, + packet.exceptionFinally, + ) + expect(result).not.toBeNull() + expect(deepFrozen(result)).toBe(true) + expect(packet).toEqual(before) + expect(() => result.instructions.push({})).toThrow() + expect(() => { + result.instructions[0].pc = 999 + }).toThrow() + + const declined = diagnoseClosureException( + packet.wordcode, + packet.references, + packet.functions, + { ...structuredClone(packet.cfg), schemaVersion: 'wrong' }, + packet.callFrames, + packet.closureLifetimes, + packet.exceptionFinally, + ) + expect(declined).toMatchObject({ + ok: false, + result: null, + diagnostic: { code: 'invalid-cfg' }, + }) + expect(deepFrozen(declined)).toBe(true) + expect( + emitClosureException( + packet.wordcode, + packet.references, + packet.functions, + { ...structuredClone(packet.cfg), schemaVersion: 'wrong' }, + packet.callFrames, + packet.closureLifetimes, + packet.exceptionFinally, + ), + ).toBeNull() +}) diff --git a/test/vm/jsconfuser-vm/emit-structured-control.test.js b/test/vm/jsconfuser-vm/emit-structured-control.test.js new file mode 100644 index 00000000..7bc77c14 --- /dev/null +++ b/test/vm/jsconfuser-vm/emit-structured-control.test.js @@ -0,0 +1,455 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { partitionFunctions } from '../../../src/vm/jsconfuser-vm/partition-functions.js' +import { buildControlFlow } from '../../../src/vm/jsconfuser-vm/build-cfg.js' +import { buildCallFrames } from '../../../src/vm/jsconfuser-vm/build-call-frames.js' +import { analyzeExceptionFinally } from '../../../src/vm/jsconfuser-vm/analyze-exception-finally.js' +import { + diagnoseStructuredControl, + emitStructuredControl, +} from '../../../src/vm/jsconfuser-vm/emit-structured-control.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + const functions = partitionFunctions(container, wordcode, references) + expect(functions, `Packet D declined ${relativePath}`).not.toBeNull() + const cfg = buildControlFlow(wordcode, references, functions) + expect(cfg, `Packet E declined ${relativePath}`).not.toBeNull() + const callFrames = buildCallFrames(wordcode, references, functions, cfg) + expect(callFrames, `Packet F declined ${relativePath}`).not.toBeNull() + const exceptionFinally = analyzeExceptionFinally( + wordcode, + references, + functions, + cfg, + callFrames, + ) + expect(exceptionFinally, `Packet H declined ${relativePath}`).not.toBeNull() + return { container, wordcode, cfg, exceptionFinally } +} + +function resultAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseStructuredControl( + packet.wordcode, + packet.cfg, + packet.exceptionFinally, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function clonePacket(packet) { + return { + wordcode: structuredClone(packet.wordcode), + cfg: structuredClone(packet.cfg), + exceptionFinally: structuredClone(packet.exceptionFinally), + } +} + +function diagnosePacket(packet) { + return diagnoseStructuredControl( + packet.wordcode, + packet.cfg, + packet.exceptionFinally, + ) +} + +function edgeWithoutId(edge) { + return Object.fromEntries( + Object.entries(edge).filter(([key]) => key !== 'id'), + ) +} + +function edgeWithoutIdOrCondition(edge) { + return Object.fromEntries( + Object.entries(edge).filter(([key]) => key !== 'id' && key !== 'condition'), + ) +} + +function corpusPaths() { + const reference = 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js' + const corpus = ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) + return [reference, ...corpus] +} + +function recursivelyFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) + return value + Object.freeze(value) + for (const child of Object.values(value)) recursivelyFreeze(child) + return value +} + +const IRREDUCIBLE_OPCODES = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +} + +function syntheticIrreduciblePacket() { + const container = recursivelyFreeze({ + schemaVersion: 'jsconfuser-vm-container.v1', + encoding: 'numeric-u32', + roles: { + words: { values: [40, 0, 5, 39, 5, 39, 3] }, + op: { values: IRREDUCIBLE_OPCODES }, + sentinels: { values: { CALL_SPREAD: 65535 } }, + scalars: { + ENCODE_BYTECODE: { value: false }, + MAIN_START_PC: { value: 0 }, + MAIN_REG_COUNT: { value: 1 }, + FRAME_START: { value: 1 }, + HEADER_SIZE: { value: 8 }, + TIMING_CHECKS: { value: false }, + }, + pool: { values: ['synthetic'] }, + slots: { + values: { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, + }, + }, + }, + }) + const wordcode = readWordcode(container) + const references = validateReferences(container, wordcode) + const functions = partitionFunctions(container, wordcode, references) + const cfg = buildControlFlow(wordcode, references, functions) + const callFrames = buildCallFrames(wordcode, references, functions, cfg) + const exceptionFinally = analyzeExceptionFinally( + wordcode, + references, + functions, + cfg, + callFrames, + ) + expect(cfg.functions[0].irreducible).toMatchObject({ + required: true, + fallback: 'state-machine', + }) + return { wordcode, cfg, exceptionFinally } +} + +const referenceCase = 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js' +const branchingCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/encoded.js' +const loopCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/encoded.js' +const finallyCase = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js' + +test('emits frozen straight-line control with exact reference leaves and edges', () => { + const packet = resultAt(referenceCase) + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-structured-control.v1', + encoding: 'numeric-u32', + parseOnly: true, + targetExecution: false, + wordCount: 32, + instructionCount: 9, + functionCount: 1, + mode: 'structured', + modeCounts: { structured: 1, stateMachine: 0 }, + }) + expect(packet.result.functions[0].control).toMatchObject({ + kind: 'structured', + regions: [ + { + kind: 'linear', + }, + ], + }) + expect(packet.result.functions[0].control.regions[0].blockIds).toHaveLength(9) + expect(packet.result.coverage).toEqual({ + instructions: { expected: 9, emitted: 9, unique: true, complete: true }, + edges: { expected: 9, emitted: 9, unique: true, complete: true }, + }) + expect( + packet.result.functions[0].blocks.flatMap(({ leaves }) => + leaves.map(({ pc }) => pc), + ), + ).toEqual(packet.wordcode.instructions.map(({ pc }) => pc)) + expect(packet.result.edges.map(edgeWithoutId)).toEqual(packet.cfg.edges) + expect(new Set(packet.result.edges.map(({ id }) => id)).size).toBe(9) + expect(deepFrozen(packet.result)).toBe(true) +}) + +test('emits a proven conditional with its unique reconvergence and condition', () => { + const packet = resultAt(branchingCase) + const conditional = packet.result.functions + .flatMap(({ control }) => control.regions) + .find(({ kind }) => kind === 'conditional') + expect(conditional).toBeDefined() + expect(conditional.proof).toContain('unique nearest reconvergence') + const edgesById = new Map(packet.result.edges.map((edge) => [edge.id, edge])) + expect(edgesById.get(conditional.branchEdgeId)).toMatchObject({ + kind: 'conditional', + targetPc: conditional.branchTargetPc, + condition: conditional.condition, + }) + expect(edgesById.get(conditional.fallthroughEdgeId)).toMatchObject({ + kind: 'fallthrough', + targetPc: conditional.fallthroughTargetPc, + }) + expect(conditional.mergeBlockId).toMatch(/^0:b@\d+$/) + expect( + packet.result.functions[0].blocks.some( + ({ id }) => id === conditional.mergeBlockId, + ), + ).toBe(true) +}) + +test('emits natural loops only with a validated header, back-edge, and exit', () => { + const packet = resultAt(loopCase) + const loops = packet.result.functions + .flatMap(({ control }) => control.regions) + .filter(({ kind }) => kind === 'natural-loop') + expect(loops.length).toBeGreaterThan(0) + const edgesById = new Map(packet.result.edges.map((edge) => [edge.id, edge])) + for (const loop of loops) { + expect(loop.proof).toContain('header dominates') + expect(loop.bodyBlockIds).toContain(loop.headerBlockId) + expect(loop.backEdgeIds.length).toBeGreaterThan(0) + expect(loop.exitEdgeIds.length).toBeGreaterThan(0) + for (const edgeId of [...loop.backEdgeIds, ...loop.exitEdgeIds]) + expect(edgesById.has(edgeId)).toBe(true) + } +}) + +test('carries handler/finally states and typed routes without emitting exception syntax', () => { + const packet = resultAt(finallyCase) + expect( + packet.cfg.functions.some(({ finallyRecords }) => finallyRecords.length), + ).toBe(true) + expect( + packet.cfg.functions.some(({ handlerRecords }) => handlerRecords.length), + ).toBe(true) + expect(packet.result.omissions.exceptionEmission).toContain('not-emitted') + expect(packet.result.edges.map(edgeWithoutId)).toEqual(packet.cfg.edges) + expect(packet.result.edges.map(edgeWithoutIdOrCondition)).toEqual( + packet.exceptionFinally.edgeStates, + ) + const resultBlocks = packet.result.functions.flatMap(({ blocks }) => blocks) + const cfgBlocks = packet.cfg.functions.flatMap(({ blocks }) => blocks) + expect(resultBlocks.map(({ handlerStateIn }) => handlerStateIn)).toEqual( + cfgBlocks.map(({ handlerStateIn }) => handlerStateIn), + ) + const leafReachability = packet.result.functions.flatMap(({ blocks }) => + blocks.flatMap(({ leaves }) => + leaves.map(({ pc, reachable }) => ({ pc, reachable })), + ), + ) + expect(leafReachability).toEqual( + packet.exceptionFinally.instructionStates.map(({ pc, reachable }) => ({ + pc, + reachable, + })), + ) + expect( + packet.result.functions.some(({ blocks }) => + blocks.some(({ handlerStateIn }) => handlerStateIn.stack.length > 0), + ), + ).toBe(true) +}) + +test('preserves instruction and edge coverage across the tracked corpus', () => { + const paths = corpusPaths() + expect(paths).toHaveLength(34) + for (const relativePath of paths) { + const packet = resultAt(relativePath) + const leaves = packet.result.functions.flatMap(({ blocks }) => + blocks.flatMap(({ leaves }) => leaves), + ) + expect(leaves.map(({ pc }) => pc)).toEqual( + packet.wordcode.instructions.map(({ pc }) => pc), + ) + expect(new Set(leaves.map(({ pc }) => pc)).size).toBe(leaves.length) + expect(packet.result.edges).toHaveLength(packet.cfg.edges.length) + expect(packet.result.edges.map(edgeWithoutId)).toEqual(packet.cfg.edges) + expect(packet.result.coverage).toMatchObject({ + instructions: { unique: true, complete: true }, + edges: { unique: true, complete: true }, + }) + } +}) + +test('declines stale predecessors atomically and freezes every diagnosis', () => { + const packet = packetAt(finallyCase) + const before = structuredClone(packet) + const mutations = [ + (mutated) => { + mutated.wordcode.wordCount += 1 + }, + (mutated) => { + mutated.cfg.functions[0].blocks[0].successors.push({}) + }, + (mutated) => { + mutated.exceptionFinally.edgeStates[0].targetPc = 0 + }, + ] + for (const mutate of mutations) { + const mutated = clonePacket(packet) + mutate(mutated) + const diagnosis = diagnosePacket(mutated) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + expect(diagnosis.diagnostic.code).toMatch( + /stale|invalid|input|handler|cfg|wordcode/i, + ) + expect(deepFrozen(diagnosis)).toBe(true) + } + expect(packet).toEqual(before) +}) + +test('does not mutate predecessor objects and returns a deeply frozen result', () => { + const packet = packetAt(referenceCase) + const before = structuredClone(packet) + const result = emitStructuredControl( + packet.wordcode, + packet.cfg, + packet.exceptionFinally, + ) + expect(result).not.toBeNull() + expect(deepFrozen(result)).toBe(true) + expect(packet).toEqual(before) + expect(() => result.functions.push({})).toThrow() + expect(() => { + result.functions[0].blocks[0].leaves[0].pc = 999 + }).toThrow() +}) + +test('uses an explicit state-machine fallback for an irreducible multiple-entry cycle', () => { + const packet = syntheticIrreduciblePacket() + const diagnosis = diagnoseStructuredControl( + packet.wordcode, + packet.cfg, + packet.exceptionFinally, + ) + expect(diagnosis.ok).toBe(true) + expect(diagnosis.result).toMatchObject({ + mode: 'state-machine', + modeCounts: { structured: 0, stateMachine: 1 }, + coverage: { + instructions: { expected: 3, emitted: 3, unique: true, complete: true }, + edges: { expected: 4, emitted: 4, unique: true, complete: true }, + }, + }) + const fn = diagnosis.result.functions[0] + expect(fn).toMatchObject({ + mode: 'state-machine', + control: { kind: 'state-machine' }, + }) + expect(fn.control.stateVariable).toBe('__jsconfuserVmState') + expect(fn.control.dispatch).toHaveLength(fn.blockCount) + expect( + fn.control.dispatch.flatMap(({ successorEdgeIds }) => successorEdgeIds), + ).not.toContain(null) + expect(fn.blocks.flatMap(({ leaves }) => leaves)).toHaveLength(3) + expect(deepFrozen(diagnosis)).toBe(true) +}) diff --git a/test/vm/jsconfuser-vm/extract-container.test.js b/test/vm/jsconfuser-vm/extract-container.test.js new file mode 100644 index 00000000..ea784eaf --- /dev/null +++ b/test/vm/jsconfuser-vm/extract-container.test.js @@ -0,0 +1,391 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { parse } from '@babel/parser' +import generate from '@babel/generator' +import traverse from '@babel/traverse' +import { + diagnoseContainer, + extractContainer, + extractContainerFromSource, +} from '../../../src/vm/jsconfuser-vm/extract-container.js' + +const repositoryRoot = new URL('../../../', import.meta.url) +const referencePath = new URL( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + repositoryRoot, +) +const debugReferencePath = new URL( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.debug.js', + repositoryRoot, +) +const reference = fs.readFileSync(referencePath, 'utf8') +const debugReference = fs.readFileSync(debugReferencePath, 'utf8') + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function renameBindings(source) { + const bindingAliases = new Map([ + ['CONSTANTS', 'poolBindingAlias'], + ['BYTECODE', 'wordcodeBindingAlias'], + ['MAIN_START_PC', 'startPcBindingAlias'], + ['MAIN_REG_COUNT', 'regCountBindingAlias'], + ['ENCODE_BYTECODE', 'encodedFlagAlias'], + ['TIMING_CHECKS', 'timingFlagAlias'], + ['HEADER_SIZE', 'headerSizeAlias'], + ['FRAME_START', 'frameStartAlias'], + ['OP', 'opcodeMapAlias'], + ['SENTINELS', 'sentinelMapAlias'], + ['SLOTS', 'slotMapAlias'], + ['Upvalue', 'upvalueConstructorAlias'], + ['Closure', 'closureConstructorAlias'], + ['VM', 'vmConstructorAlias'], + ['decodeBytecode', 'decoderFunctionAlias'], + ['globals', 'globalObjectAlias'], + ['vm', 'vmInstanceAlias'], + ]) + const methodAliases = new Map([ + ['_read', 'readMethodAlias'], + ['_write', 'writeMethodAlias'], + ['_close', 'closeMethodAlias'], + ['_operand', 'operandMethodAlias'], + ['_pushFrame', 'pushFrameMethodAlias'], + ['captureUpvalue', 'captureMethodAlias'], + ['_constant', 'constantMethodAlias'], + ['_closeUpvaluesFor', 'closeUpvaluesMethodAlias'], + ['run', 'runMethodAlias'], + ]) + const ast = parse(source, { + allowReturnOutsideFunction: true, + sourceType: 'script', + }) + traverse(ast, { + Program(path) { + for (const [from, to] of bindingAliases) { + if (path.scope.getBinding(from)) path.scope.rename(from, to) + } + }, + MemberExpression(path) { + if (path.node.computed || path.node.property.type !== 'Identifier') { + return + } + const alias = methodAliases.get(path.node.property.name) + if (alias) path.node.property.name = alias + }, + }) + return generate(ast, { comments: true }).code +} + +function topLevelDeclaration(source, name) { + const ast = parse(source, { + allowReturnOutsideFunction: true, + sourceType: 'script', + }) + return ast.program.body.find( + (statement) => + statement.type === 'VariableDeclaration' && + statement.declarations.some( + (declaration) => declaration.id.name === name, + ), + ) +} + +function withoutTopLevelDeclaration(source, name) { + const ast = parse(source, { + allowReturnOutsideFunction: true, + sourceType: 'script', + }) + ast.program.body = ast.program.body.filter( + (statement) => + !( + statement.type === 'VariableDeclaration' && + statement.declarations.some( + (declaration) => declaration.id.name === name, + ) + ), + ) + return generate(ast, { comments: true }).code +} + +function withoutPrototypeMethod(source, owner, method) { + const ast = parse(source, { + allowReturnOutsideFunction: true, + sourceType: 'script', + }) + ast.program.body = ast.program.body.filter((statement) => { + const expression = + statement.type === 'ExpressionStatement' ? statement.expression : null + const left = + expression?.type === 'AssignmentExpression' ? expression.left : null + return !( + left?.type === 'MemberExpression' && + left.object?.type === 'MemberExpression' && + left.object.object?.type === 'Identifier' && + left.object.object.name === owner && + left.object.property?.name === 'prototype' && + left.property?.name === method + ) + }) + return generate(ast, { comments: true }).code +} + +function duplicateOpcodeMap(source) { + const declaration = topLevelDeclaration(source, 'OP') + const duplicate = generate(declaration, { comments: false }).code.replace( + /\bOP\b/g, + 'OP_DUPLICATE', + ) + return `${source}\n${duplicate}\n` +} + +function replaceOnce(source, from, to) { + const index = source.indexOf(from) + expect(index, `fixture text not found: ${from}`).toBeGreaterThanOrEqual(0) + return `${source.slice(0, index)}${to}${source.slice(index + from.length)}` +} + +test('extracts the complete pinned reference graph and preserves the known scalar values', () => { + const result = extractContainerFromSource(reference) + + expect(result).not.toBeNull() + expect(result.schemaVersion).toBe('jsconfuser-vm-container.v1') + expect(result.encoding).toBe('numeric-u32') + expect(result.roles.pool.name).toBe('CONSTANTS') + expect(result.roles.words.name).toBe('BYTECODE') + expect(result.roles.words.values).toHaveLength(32) + expect(result.roles.op.values).toEqual({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, + }) + expect(result.roles.sentinels.values).toEqual({ CALL_SPREAD: 65535 }) + expect(result.roles.slots.values).toEqual({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, + }) + expect(result.roles.scalars).toMatchObject({ + MAIN_START_PC: { name: 'MAIN_START_PC', value: 0 }, + MAIN_REG_COUNT: { name: 'MAIN_REG_COUNT', value: 7 }, + ENCODE_BYTECODE: { name: 'ENCODE_BYTECODE', value: false }, + TIMING_CHECKS: { name: 'TIMING_CHECKS', value: false }, + HEADER_SIZE: { name: 'HEADER_SIZE', value: 8 }, + FRAME_START: { name: 'FRAME_START', value: 1 }, + }) + expect(result.roles.constructors).toEqual({ + Upvalue: { name: 'Upvalue' }, + Closure: { name: 'Closure' }, + VM: { name: 'VM' }, + }) + expect(result.roles.methods.vm).toEqual({ + operand: { name: '_operand' }, + pushFrame: { name: '_pushFrame' }, + captureUpvalue: { name: 'captureUpvalue' }, + constant: { name: '_constant' }, + closeUpvalues: { name: '_closeUpvaluesFor' }, + run: { name: 'run' }, + }) + expect(result.roles.roots).toEqual({ + globals: { name: 'globals' }, + vm: { name: 'vm' }, + decoder: { name: 'decodeBytecode' }, + bootMethod: { name: 'run' }, + }) +}) + +test('accepts renamed bindings and renamed prototype methods by their relationships', () => { + const renamed = renameBindings(reference) + const result = extractContainer(renamed) + + expect(result).not.toBeNull() + expect(result.roles.pool.name).toBe('poolBindingAlias') + expect(result.roles.words.name).toBe('wordcodeBindingAlias') + expect(result.roles.constructors).toEqual({ + Upvalue: { name: 'upvalueConstructorAlias' }, + Closure: { name: 'closureConstructorAlias' }, + VM: { name: 'vmConstructorAlias' }, + }) + expect(result.roles.methods.vm.run.name).toBe('runMethodAlias') + expect(result.roles.methods.upvalue.read.name).toBe('readMethodAlias') +}) + +test('is independent of debug and disassembly comments', () => { + const result = extractContainerFromSource(debugReference) + expect(result).not.toBeNull() + expect(result.roles.scalars.MAIN_REG_COUNT.value).toBe(7) +}) + +test('accepts an AST without mutating it and returns deeply frozen data', () => { + const ast = parse(reference, { + allowReturnOutsideFunction: true, + sourceType: 'script', + }) + const before = JSON.stringify(ast) + const result = extractContainer(ast) + + expect(JSON.stringify(ast)).toBe(before) + expect(deepFrozen(result)).toBe(true) + expect(() => result.roles.words.values.push(99)).toThrow() + expect(() => { + result.roles.scalars.MAIN_START_PC.value = 1 + }).toThrow() +}) + +test('also accepts a generated baseline with a program-specific register count', () => { + const source = fs.readFileSync( + new URL( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js', + repositoryRoot, + ), + 'utf8', + ) + const result = extractContainerFromSource(source) + + expect(result).not.toBeNull() + expect(result.roles.scalars.MAIN_REG_COUNT.value).toBe(24) +}) + +const negatives = [ + ['ordinary JavaScript near-miss', 'var answer = 42;', 'missing-role'], + [ + 'missing mandatory root scalar', + withoutTopLevelDeclaration(reference, 'MAIN_REG_COUNT'), + 'missing-role', + ], + ['duplicate opcode map', duplicateOpcodeMap(reference), 'ambiguous-role'], + [ + 'ambiguous constant pool', + `${reference}\nvar OTHER_POOL = ["window"];\n`, + 'ambiguous-role', + ], + [ + 'malformed word array', + replaceOnce(reference, 'var BYTECODE = [4,', 'var BYTECODE = [4294967296,'), + 'missing-role', + ], + [ + 'unknown opcode map entry', + replaceOnce(reference, 'var OP = {', 'var OP = {\n UNKNOWN_OPCODE: 61,'), + 'missing-role', + ], + [ + 'altered opcode map value', + replaceOnce(reference, 'LOAD_CONST: 0', 'LOAD_CONST: 999'), + 'missing-role', + ], + [ + 'altered frame slot', + replaceOnce(reference, 'PC: 0', 'PC: 99'), + 'missing-role', + ], + [ + 'altered root boot method', + replaceOnce(reference, 'vm.run(new Closure(', 'vm._operand(new Closure('), + 'altered-boot', + ], + [ + 'incomplete Upvalue method graph', + withoutPrototypeMethod(reference, 'Upvalue', '_close'), + 'incomplete-role', + ], + [ + 'unsupported encoded bytecode mode', + replaceOnce( + reference, + 'var ENCODE_BYTECODE = false;', + 'var ENCODE_BYTECODE = true;', + ), + 'unsupported-encoded-bytecode', + ], +] + +test.each(negatives)( + 'declines %s before producing a result', + (_label, source, code) => { + const report = diagnoseContainer(source) + + expect(report.ok).toBe(false) + expect(report.result).toBeNull() + expect(report.diagnostic.code).toBe(code) + expect(extractContainer(source)).toBeNull() + }, +) + +test('returns a stable diagnostic rather than throwing for malformed input', () => { + const report = diagnoseContainer('var =') + + expect(report).toMatchObject({ + ok: false, + result: null, + diagnostic: { code: 'extractor-error' }, + }) + expect(report.diagnostic.message).toContain('Unexpected token') +}) diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/encoded.js new file mode 100644 index 00000000..afa88c54 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"_value", /* 1 */2, /* 2 */"value", /* 3 */"push", /* 4 */"rhs", /* 5 */3, /* 6 */"window", /* 7 */"TEST_OUTPUT", /* 8 */"events", /* 9 */undefined, /* 10 */"key", /* 11 */"get", /* 12 */"set"]; +var BYTECODE = [4, 1, 47, 5, 153, 0, 6, 1, 0, 1, 2, 5, 3, 5, 48, 5, 0, 5, 2, 5, 0, 5, 0, 0, 0, 6, 1, 0, 49, 7, 1, 5, 6, 0, 5, 2, 0, 47, 6, 188, 0, 6, 1, 0, 1, 2, 50, 7, 5, 6, 0, 5, 2, 0, 47, 6, 227, 1, 7, 1, 0, 1, 2, 51, 7, 5, 6, 5, 4, 7, 42, 5, 3, 0, 0, 6, 2, 0, 8, 7, 4, 6, 0, 6, 3, 0, 8, 8, 2, 6, 0, 6, 4, 0, 43, 9, 2, 8, 1, 6, 0, 6, 5, 0, 11, 8, 7, 6, 9, 4, 5, 8, 2, 5, 6, 0, 0, 6, 7, 0, 0, 7, 2, 0, 0, 8, 2, 0, 8, 9, 4, 8, 0, 8, 8, 0, 49, 10, 2, 7, 9, 8, 2, 9, 5, 6, 10, 0, 5, 9, 0, 45, 5, 4, 1, 3, 2, 0, 0, 3, 3, 0, 8, 4, 2, 3, 0, 3, 10, 0, 43, 5, 2, 4, 1, 3, 0, 2, 2, 0, 45, 2, 0, 2, 9, 0, 45, 2, 4, 1, 3, 2, 0, 0, 3, 3, 0, 8, 4, 2, 3, 0, 3, 11, 0, 43, 5, 2, 4, 1, 3, 0, 2, 0, 0, 8, 3, 1, 2, 45, 3, 0, 2, 9, 0, 45, 2, 4, 2, 3, 3, 0, 0, 4, 3, 0, 8, 5, 3, 4, 0, 4, 12, 0, 43, 6, 3, 5, 1, 4, 0, 3, 0, 0, 9, 2, 3, 0, 0, 3, 9, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 11; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/oracle.json new file mode 100644 index 00000000..ee65bc3c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/oracle.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "composition-accessor-evaluation-order", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": { + "value": 5, + "events": [ + "key", + "get", + "rhs", + "set", + "get" + ] + }, + "expectedThrow": null, + "semanticTags": [ + "computed-property", + "getter", + "setter", + "receiver-preservation", + "assignment-evaluation-order", + "observable-side-effect-order" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection excludes identity-only assertions and host-dependent stack data.", + "The accessor event list is the observable that distinguishes computed-key/getter/RHS/setter evaluation order." + ], + "exclusions": [ + "Encoder internals, debug comments, and decoder behavior are not used to define the expected observable.", + "The try/finally cell remains a source-backed unsupported boundary if the registered decoder declines it." + ], + "comparison": { + "sourceExpected": "exact-normalized-observable", + "recoveredExpected": "exact-normalized-observable on success only" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/source.js new file mode 100644 index 00000000..cbb2d154 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-accessor-evaluation-order/source.js @@ -0,0 +1,9 @@ +var events = []; +function key() { events.push("key"); return "value"; } +var object = { + _value: 2, + get value() { events.push("get"); return this._value; }, + set value(next) { events.push("set"); this._value = next; } +}; +object[key()] = object.value + (events.push("rhs"), 3); +window.TEST_OUTPUT = { value: object.value, events: events }; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/encoded.js new file mode 100644 index 00000000..aaabe582 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */4, /* 1 */2, /* 2 */"window", /* 3 */"TEST_OUTPUT", /* 4 */1, /* 5 */undefined]; +var BYTECODE = [4, 1, 47, 6, 77, 1, 5, 0, 0, 5, 2, 6, 0, 6, 0, 0, 42, 7, 2, 1, 6, 5, 3, 7, 0, 6, 1, 0, 42, 7, 3, 1, 6, 5, 4, 7, 42, 6, 3, 1, 4, 5, 5, 6, 2, 6, 2, 0, 0, 7, 3, 0, 0, 8, 4, 0, 42, 9, 3, 1, 8, 48, 8, 3, 4, 5, 9, 9, 6, 7, 8, 0, 6, 5, 0, 45, 6, 4, 2, 5, 3, 0, 47, 4, 99, 1, 5, 1, 0, 1, 3, 45, 4, 0, 4, 5, 0, 45, 4, 4, 2, 3, 3, 0, 11, 4, 3, 0, 7, 0, 4, 3, 3, 0, 45, 3, 0, 3, 5, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 10; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/oracle.json new file mode 100644 index 00000000..1ab7fa23 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/oracle.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "composition-closure-upvalue", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 6, + 12, + 13 + ], + "expectedThrow": null, + "semanticTags": [ + "returned-closure", + "upvalue", + "mutable-captured-state", + "repeated-call", + "call-result-feeds-next-call" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection excludes identity-only assertions and host-dependent stack data.", + "The accessor event list is the observable that distinguishes computed-key/getter/RHS/setter evaluation order." + ], + "exclusions": [ + "Encoder internals, debug comments, and decoder behavior are not used to define the expected observable.", + "The try/finally cell remains a source-backed unsupported boundary if the registered decoder declines it." + ], + "comparison": { + "sourceExpected": "exact-normalized-observable", + "recoveredExpected": "exact-normalized-observable on success only" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/source.js new file mode 100644 index 00000000..d9896278 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-closure-upvalue/source.js @@ -0,0 +1,8 @@ +function makeAccumulator(seed) { + var total = seed; + return function (step) { total += step; return total; }; +} +var advance = makeAccumulator(4); +var first = advance(2); +var second = advance(first); +window.TEST_OUTPUT = [first, second, advance(1)]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/encoded.js new file mode 100644 index 00000000..c0212ba7 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */true, /* 1 */"window", /* 2 */"TEST_OUTPUT", /* 3 */undefined, /* 4 */"push", /* 5 */"try-", /* 6 */"returned", /* 7 */"normal", /* 8 */"finally-"]; +var BYTECODE = [4, 1, 47, 5, 55, 1, 9, 1, 0, 1, 2, 5, 3, 5, 48, 5, 0, 5, 2, 5, 0, 5, 0, 0, 42, 6, 3, 1, 5, 5, 4, 6, 2, 5, 1, 0, 0, 6, 2, 0, 48, 7, 2, 4, 2, 9, 5, 6, 7, 0, 5, 3, 0, 45, 5, 4, 2, 59, 122, 3, 4, 149, 3, 5, 0, 0, 6, 4, 0, 8, 7, 5, 6, 0, 6, 5, 0, 11, 8, 6, 0, 43, 6, 5, 7, 1, 8, 40, 0, 103, 0, 5, 6, 0, 5, 4, 5, 1, 3, 151, 55, 39, 122, 0, 5, 7, 0, 5, 4, 5, 1, 3, 153, 55, 39, 122, 55, 1, 3, 155, 39, 122, 3, 5, 0, 0, 6, 4, 0, 8, 7, 5, 6, 0, 6, 8, 0, 11, 8, 6, 0, 43, 6, 5, 7, 1, 8, 58, 3, 46, 4, 45, 4, 45, 4, 0, 5, 3, 0, 45, 5]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 8; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/oracle.json new file mode 100644 index 00000000..036ce4c3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/oracle.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "composition-finally-abrupt-return", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + "returned", + [ + "try-true", + "finally-true" + ] + ], + "expectedThrow": null, + "semanticTags": [ + "try-finally", + "abrupt-return", + "completion-order", + "finally-side-effect" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection excludes identity-only assertions and host-dependent stack data.", + "The accessor event list is the observable that distinguishes computed-key/getter/RHS/setter evaluation order." + ], + "exclusions": [ + "Encoder internals, debug comments, and decoder behavior are not used to define the expected observable.", + "The try/finally cell remains a source-backed unsupported boundary if the registered decoder declines it." + ], + "comparison": { + "sourceExpected": "exact-normalized-observable", + "recoveredExpected": "exact-normalized-observable on success only" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/source.js new file mode 100644 index 00000000..fe089f70 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-finally-abrupt-return/source.js @@ -0,0 +1,12 @@ +var log = []; +function run(flag) { + try { + log.push("try-" + flag); + if (flag) return "returned"; + return "normal"; + } finally { + log.push("finally-" + flag); + } +} +var result = run(true); +window.TEST_OUTPUT = [result, log]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/encoded.js new file mode 100644 index 00000000..0b46f9e5 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */0, /* 1 */1, /* 2 */4, /* 3 */"push", /* 4 */3, /* 5 */"window", /* 6 */"TEST_OUTPUT", /* 7 */"trace", /* 8 */"total", /* 9 */undefined, /* 10 */2]; +var BYTECODE = [4, 1, 47, 7, 154, 1, 7, 0, 0, 5, 2, 7, 48, 7, 0, 5, 3, 7, 0, 7, 0, 0, 5, 4, 7, 0, 7, 1, 0, 5, 5, 7, 0, 7, 2, 0, 24, 8, 5, 7, 40, 8, 121, 42, 7, 2, 1, 5, 5, 6, 7, 0, 7, 3, 0, 8, 8, 3, 7, 43, 7, 3, 8, 1, 6, 0, 7, 4, 0, 23, 8, 6, 7, 40, 8, 98, 0, 7, 1, 0, 12, 8, 6, 7, 42, 7, 2, 1, 8, 11, 8, 4, 7, 5, 4, 8, 39, 105, 11, 7, 4, 6, 5, 4, 7, 5, 7, 5, 0, 7, 1, 0, 11, 8, 5, 7, 5, 5, 8, 39, 32, 2, 7, 5, 0, 0, 8, 6, 0, 0, 9, 7, 0, 0, 10, 8, 0, 49, 11, 2, 9, 3, 10, 4, 9, 7, 8, 11, 0, 7, 9, 0, 45, 7, 4, 2, 0, 3, 10, 0, 15, 4, 0, 3, 0, 3, 0, 0, 26, 5, 4, 3, 40, 5, 188, 0, 3, 10, 0, 14, 4, 0, 3, 5, 3, 4, 39, 207, 0, 4, 4, 0, 13, 5, 0, 4, 0, 4, 1, 0, 11, 6, 5, 4, 5, 3, 6, 45, 3, 0, 3, 9, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 12; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/oracle.json new file mode 100644 index 00000000..47bbc8be --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/oracle.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "composition-loop-dependent-calls", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": { + "trace": [ + 4, + 1, + 10, + 2 + ], + "total": 41 + }, + "expectedThrow": null, + "semanticTags": [ + "for-loop", + "call-in-loop", + "call-result-branch", + "dependent-second-call", + "method-mutation" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection excludes identity-only assertions and host-dependent stack data.", + "The accessor event list is the observable that distinguishes computed-key/getter/RHS/setter evaluation order." + ], + "exclusions": [ + "Encoder internals, debug comments, and decoder behavior are not used to define the expected observable.", + "The try/finally cell remains a source-backed unsupported boundary if the registered decoder declines it." + ], + "comparison": { + "sourceExpected": "exact-normalized-observable", + "recoveredExpected": "exact-normalized-observable on success only" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/source.js new file mode 100644 index 00000000..df18de24 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-loop-dependent-calls/source.js @@ -0,0 +1,10 @@ +function classify(value) { return value % 2 === 0 ? value / 2 : value * 3 + 1; } +var trace = []; +var total = 0; +for (var i = 1; i <= 4; i++) { + var transformed = classify(i); + trace.push(transformed); + if (transformed > 3) total += classify(transformed - 1); + else total += transformed; +} +window.TEST_OUTPUT = { trace: trace, total: total }; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/encoded.js new file mode 100644 index 00000000..40485ac2 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"prototype", /* 1 */"add", /* 2 */"read", /* 3 */2, /* 4 */5, /* 5 */3, /* 6 */"value", /* 7 */1, /* 8 */"window", /* 9 */"TEST_OUTPUT", /* 10 */undefined]; +var BYTECODE = [4, 1, 47, 7, 213, 1, 4, 0, 0, 5, 2, 7, 0, 7, 0, 0, 8, 8, 2, 7, 0, 7, 1, 0, 47, 9, 229, 1, 6, 0, 0, 9, 8, 7, 9, 0, 7, 0, 0, 8, 8, 2, 7, 0, 7, 2, 0, 47, 9, 255, 0, 4, 0, 0, 9, 8, 7, 9, 0, 7, 3, 0, 44, 8, 2, 1, 7, 5, 3, 8, 0, 7, 4, 0, 44, 8, 2, 1, 7, 5, 4, 8, 0, 7, 1, 0, 8, 8, 3, 7, 0, 7, 5, 0, 43, 9, 3, 8, 1, 7, 0, 7, 1, 0, 8, 8, 9, 7, 0, 7, 6, 0, 8, 10, 3, 7, 43, 7, 9, 8, 1, 10, 0, 8, 2, 0, 8, 9, 7, 8, 43, 8, 7, 9, 0, 5, 5, 8, 0, 7, 1, 0, 8, 8, 4, 7, 0, 7, 7, 0, 43, 9, 4, 8, 1, 7, 0, 7, 2, 0, 8, 8, 9, 7, 43, 7, 9, 8, 0, 5, 6, 7, 2, 7, 8, 0, 0, 8, 9, 0, 0, 9, 6, 0, 8, 10, 3, 9, 0, 9, 6, 0, 8, 11, 4, 9, 48, 9, 4, 5, 6, 10, 11, 9, 7, 8, 9, 0, 7, 10, 0, 45, 7, 4, 2, 0, 3, 6, 0, 9, 2, 3, 0, 0, 3, 10, 0, 45, 3, 4, 2, 0, 3, 6, 0, 8, 4, 2, 3, 11, 5, 4, 0, 9, 2, 3, 5, 45, 2, 0, 3, 10, 0, 45, 3, 4, 1, 0, 2, 6, 0, 8, 3, 1, 2, 45, 3, 0, 2, 10, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 12; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/oracle.json new file mode 100644 index 00000000..be6b2e62 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/oracle.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "composition-receiver-mutation", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 10, + 6, + 10, + 6 + ], + "expectedThrow": null, + "semanticTags": [ + "receiver-preservation", + "method-call", + "property-mutation", + "chained-call", + "final-observation-depends-on-receiver-and-mutation" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection excludes identity-only assertions and host-dependent stack data.", + "The accessor event list is the observable that distinguishes computed-key/getter/RHS/setter evaluation order." + ], + "exclusions": [ + "Encoder internals, debug comments, and decoder behavior are not used to define the expected observable.", + "The try/finally cell remains a source-backed unsupported boundary if the registered decoder declines it." + ], + "comparison": { + "sourceExpected": "exact-normalized-observable", + "recoveredExpected": "exact-normalized-observable on success only" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/source.js new file mode 100644 index 00000000..ab5a9ab2 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/composition/composition-receiver-mutation/source.js @@ -0,0 +1,8 @@ +function Box(seed) { this.value = seed; } +Box.prototype.add = function (delta) { this.value += delta; return this; }; +Box.prototype.read = function () { return this.value; }; +var left = new Box(2); +var right = new Box(5); +var leftResult = left.add(3).add(left.value).read(); +var rightResult = right.add(1).read(); +window.TEST_OUTPUT = [leftResult, rightResult, left.value, right.value]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js new file mode 100644 index 00000000..8b552265 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */1, /* 1 */2, /* 2 */3, /* 3 */4, /* 4 */5, /* 5 */"push", /* 6 */"concat", /* 7 */"window", /* 8 */"TEST_OUTPUT", /* 9 */undefined]; +var BYTECODE = [4, 1, 0, 4, 0, 0, 0, 5, 1, 0, 48, 6, 2, 4, 5, 5, 2, 6, 0, 4, 2, 0, 0, 5, 3, 0, 0, 6, 4, 0, 48, 7, 3, 4, 5, 6, 5, 3, 7, 0, 4, 5, 0, 8, 5, 2, 4, 48, 4, 0, 0, 6, 6, 0, 8, 7, 4, 6, 43, 6, 4, 7, 1, 3, 43, 4, 2, 5, 65535, 6, 2, 4, 7, 0, 0, 5, 8, 0, 9, 4, 5, 2, 0, 4, 9, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 8; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/oracle.json new file mode 100644 index 00000000..198557e1 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/oracle.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "focused-call-method-spread", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 1, + 2, + 3, + 4, + 5 + ], + "expectedThrow": null, + "semanticTags": [ + "spread-element", + "method-call", + "call-spread-sentinel", + "receiver-preservation" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection compares the declared TEST_OUTPUT value only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and decoder behavior are excluded from this focused fixture." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/source.js new file mode 100644 index 00000000..ebf72b2f --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/source.js @@ -0,0 +1 @@ +var arr = [1, 2]; var more = [3, 4, 5]; arr.push(...more); window.TEST_OUTPUT = arr; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js new file mode 100644 index 00000000..f15b0a2e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */10, /* 1 */20, /* 2 */"concat", /* 3 */"window", /* 4 */"TEST_OUTPUT", /* 5 */"x", /* 6 */"y", /* 7 */undefined]; +var BYTECODE = [4, 1, 47, 5, 92, 2, 5, 0, 0, 5, 2, 5, 0, 5, 0, 0, 0, 6, 1, 0, 48, 7, 2, 5, 6, 5, 3, 7, 48, 5, 0, 0, 6, 2, 0, 8, 7, 5, 6, 43, 6, 5, 7, 1, 3, 44, 5, 2, 65535, 6, 5, 4, 5, 2, 5, 3, 0, 0, 6, 4, 0, 0, 7, 5, 0, 8, 8, 4, 7, 0, 7, 6, 0, 8, 9, 4, 7, 48, 7, 2, 8, 9, 9, 5, 6, 7, 0, 5, 7, 0, 45, 5, 4, 3, 0, 4, 5, 0, 9, 3, 4, 0, 0, 4, 6, 0, 9, 3, 4, 1, 0, 4, 7, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 10; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/oracle.json new file mode 100644 index 00000000..0b1a02d3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/oracle.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "focused-new-spread", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 10, + 20 + ], + "expectedThrow": null, + "semanticTags": [ + "spread-element", + "constructor-call", + "new-spread-sentinel", + "constructed-fields" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection compares the declared TEST_OUTPUT value only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and decoder behavior are excluded from this focused fixture." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/source.js new file mode 100644 index 00000000..0824aa17 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/source.js @@ -0,0 +1 @@ +function Point(x, y) { this.x = x; this.y = y; } var coords = [10, 20]; var p = new Point(...coords); window.TEST_OUTPUT = [p.x, p.y]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/encoded.js new file mode 100644 index 00000000..b8d4f5d8 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */17, /* 1 */"TEST_OUTPUT", /* 2 */"window", /* 3 */undefined]; +var BYTECODE = [4, 1, 0, 3, 0, 0, 5, 2, 3, 6, 1, 0, 2, 2, 3, 2, 0, 0, 4, 1, 0, 2, 5, 1, 0, 9, 3, 4, 5, 0, 3, 3, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 6; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/oracle.json new file mode 100644 index 00000000..078505f0 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/oracle.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "focused-store-global-assignment", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": 17, + "expectedThrow": null, + "semanticTags": [ + "undeclared-global-assignment", + "store-global", + "global-readback" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection compares the declared TEST_OUTPUT value only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and decoder behavior are excluded from this focused fixture." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/source.js new file mode 100644 index 00000000..56cbed2c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/source.js @@ -0,0 +1 @@ +var value = 17; TEST_OUTPUT = value; window.TEST_OUTPUT = TEST_OUTPUT; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/encoded.js new file mode 100644 index 00000000..6ca54ac1 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */true, /* 4 */"4", /* 5 */undefined]; +var BYTECODE = [4, 1, 2, 2, 0, 0, 0, 3, 1, 0, 0, 4, 2, 0, 32, 5, 4, 33, 4, 5, 0, 5, 3, 0, 33, 6, 5, 0, 5, 4, 0, 33, 7, 5, 48, 5, 3, 4, 6, 7, 9, 2, 3, 5, 0, 2, 5, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 8; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/oracle.json new file mode 100644 index 00000000..218ba4a3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/oracle.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "focused-unary-positive-coercion", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + -3, + 1, + 4 + ], + "expectedThrow": null, + "semanticTags": [ + "unary-plus", + "numeric-coercion", + "operator-family" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged JSON-safe projection compares the declared TEST_OUTPUT value only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and decoder behavior are excluded from this focused fixture." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/source.js new file mode 100644 index 00000000..768f8965 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = [+(-3), +true, +"4"]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/encoded.js new file mode 100644 index 00000000..90be5d6c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */2, /* 1 */"window", /* 2 */"TEST_OUTPUT", /* 3 */1, /* 4 */undefined]; +var BYTECODE = [4,1,47,4,65,1,5,0,0,5,2,4,0,4,0,0,42,5,2,1,4,5,3,5,2,4,1,0,0,5,2,0,0,6,3,0,42,7,3,1,6,0,6,0,0,42,8,3,1,6,48,6,2,7,8,9,4,5,6,0,4,4,0,45,4,4,2,5,3,0,47,4,87,1,5,1,0,1,4294967295,45,4,0,4,4,0,45,4,4,2,3,3,0,11,4,3,0,7,0,4,3,3,0,45,3,0,3,4,0,45,3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 9; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/oracle.json new file mode 100644 index 00000000..0548e457 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "closure-invalid-capture", + "expectedStatus": "declined", + "expectedDiagnostic": "references-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "A captured-register descriptor points outside the captured function's register file.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Closures.test.js:4-110" + ], + "semanticTags": [ + "closure-reference", + "capture-descriptor", + "out-of-range" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/source.js new file mode 100644 index 00000000..1a18c894 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/closure-invalid-capture/source.js @@ -0,0 +1,3 @@ +function make(seed) { var total = seed; return function (step) { total += step; return total; }; } +var next = make(2); +window.TEST_OUTPUT = [next(1), next(2)]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/encoded.js new file mode 100644 index 00000000..6c5798cb --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */2, /* 1 */1, /* 2 */0, /* 3 */"window", /* 4 */"TEST_OUTPUT", /* 5 */undefined]; +var BYTECODE = [4,1,0,3,0,0,5,2,3,0,3,1,0,23,4,2,3,40,4,0,0,3,1,0,11,4,2,3,5,2,4,39,40,0,3,2,0,5,2,3,2,3,3,0,0,4,4,0,9,3,4,2,0,3,5,0,45,3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/oracle.json new file mode 100644 index 00000000..038e3a6b --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/oracle.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "control-stale-target", + "expectedStatus": "declined", + "expectedDiagnostic": "unemittable-structured-control", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "A branch is redirected to a valid boundary that violates the structured-control premise.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/IfStatements.test.js:4-115", + "encoder/js-confuser-vm/test/features/Loops.test.js:4-186" + ], + "semanticTags": [ + "structured-control", + "stale-target", + "parseable-reference" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/source.js new file mode 100644 index 00000000..e74b28cf --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/control-stale-target/source.js @@ -0,0 +1,3 @@ +var x = 2; +if (x > 1) { x = x + 1; } else { x = 0; } +window.TEST_OUTPUT = x; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/encoded.js new file mode 100644 index 00000000..e51de742 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var BYTECODE = "BAAAAAEAAAACAAAAAgAAAAAAAAAAAAAAAAAAAAMAAAABAAAAAAAAAAAAAAAEAAAAAgAAAAAAAAAJAAAAAgAAAAMAAAAEAAAAAAAAAAIAAAADAAAAAAAAAC0AAAACAAAA"; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = true; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/oracle.json new file mode 100644 index 00000000..005d2ed0 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "encoded-bytecode-mode", + "expectedStatus": "declined", + "expectedDiagnostic": "container-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The pinned encoder's encodeBytecode mode emits a string payload outside numeric wordcode mode.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "real-encoder-output", + "encoded-bytecode", + "unsupported-container-mode" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/encoded-bytecode-mode/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/encoded.js new file mode 100644 index 00000000..10fb31b4 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var BYTECODE = [4,1,2,2,0,0,0,3,1,0,0,4,2,0,9,2,3,4,0,2,3,0,45,2,0]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/oracle.json new file mode 100644 index 00000000..35577c9c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "extra-trailing-word", + "expectedStatus": "declined", + "expectedDiagnostic": "wordcode-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "An additional trailing word is not a complete instruction and is rejected.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "numeric-wordcode", + "trailing-word", + "atomic-decline" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/extra-trailing-word/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/encoded.js new file mode 100644 index 00000000..206148ef --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */undefined, /* 3 */"push", /* 4 */"try", /* 5 */1, /* 6 */"finally"]; +var BYTECODE = [4,1,47,4,47,0,8,1,0,1,2,5,3,4,48,4,0,5,2,4,2,4,0,0,0,5,1,0,42,6,3,0,48,7,2,6,2,9,4,5,7,0,4,2,0,45,4,4,1,59,94,2,3,0,3,4,0,0,5,3,0,8,6,4,5,0,5,4,0,43,7,4,6,1,5,0,4,5,0,5,3,4,1,2,119,55,39,94,55,1,2,121,39,94,3,4,0,0,5,3,0,8,6,4,5,0,5,6,0,43,7,4,6,1,5,58,2,46,3,45,3,0,4,2,0,45,4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 8; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/oracle.json new file mode 100644 index 00000000..d389084d --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "finally-stale-target", + "expectedStatus": "declined", + "expectedDiagnostic": "functions-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The FINALLY_SETUP throw-pad target is redirected into the wrong function region.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/TryFinally.test.js:1-402" + ], + "semanticTags": [ + "finally-handler", + "stale-throwpad", + "function-partition" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/source.js new file mode 100644 index 00000000..b4274f74 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/finally-stale-target/source.js @@ -0,0 +1,3 @@ +var log = []; +function run() { try { log.push("try"); return 1; } finally { log.push("finally"); } } +window.TEST_OUTPUT = [run(), log]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/encoded.js new file mode 100644 index 00000000..8658bbd9 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */undefined, /* 3 */"push", /* 4 */"try", /* 5 */1, /* 6 */"finally"]; +var BYTECODE = [4,1,47,4,47,0,8,1,0,1,2,5,3,4,48,4,0,5,2,4,2,4,0,0,0,5,1,0,42,6,3,0,48,7,2,6,2,9,4,5,7,0,4,2,0,45,4,4,1,59,4294967295,2,3,117,3,4,0,0,5,3,0,8,6,4,5,0,5,4,0,43,7,4,6,1,5,0,4,5,0,5,3,4,1,2,119,55,39,94,55,1,2,121,39,94,3,4,0,0,5,3,0,8,6,4,5,0,5,6,0,43,7,4,6,1,5,58,2,46,3,45,3,0,4,2,0,45,4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 8; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/oracle.json new file mode 100644 index 00000000..ac065f91 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "handler-invalid-target", + "expectedStatus": "declined", + "expectedDiagnostic": "references-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The FINALLY_SETUP handler target is outside the wordcode stream.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/TryFinally.test.js:1-402" + ], + "semanticTags": [ + "finally-handler", + "target-reference", + "out-of-range" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/source.js new file mode 100644 index 00000000..b4274f74 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/handler-invalid-target/source.js @@ -0,0 +1,3 @@ +var log = []; +function run() { try { log.push("try"); return 1; } finally { log.push("finally"); } } +window.TEST_OUTPUT = [run(), log]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/encoded.js new file mode 100644 index 00000000..2b2a2169 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */undefined, /* 1 */1, /* 2 */"window", /* 3 */"TEST_OUTPUT"]; +var BYTECODE = [56, 5, 343, 345, 3092119747, 4, 16, 56, 12, 71, 85, 2815821416, 22, 19, 13, 1, 56, 351, 345, 359, 1162981188, 24, 20, 21, 15, 6, 56, 31, 85, 89, 922064385, 16, 18, 21, 8, 45, 3, 56, 42, 286, 296, 3926570441, 56, 264, 210, 225, 1133370746, 35, 5, 22, 55, 50, 56, 57, 197, 209, 732538703, 22, 14, 8, 11, 26, 0, 13, 13, 22, 9, 18, 16, 45, 2, 1175433310, 3829652466, 2188927049, 548595927, 3203305567, 1562380717, 4217208993, 2576685223, 935916461, 3590748059, 1950081215, 309274734, 2963452402, 1323096183, 3576407743, 1936134839, 295473065, 2950038425, 55, 27, 2, 13, 15, 22, 10, 6, 8, 14, 8, 7, 22, 15, 10, 2, 3, 12, 7, 14, 20, 56, 356, 246, 261, 1882108077, 36, 14, 23, 56, 379, 245, 258, 1244307586, 12, 7, 14, 18, 14, 23, 3, 22, 17, 6, 5, 8, 51, 12, 0, 0, 13, 56, 278, 356, 364, 1058828629, 18, 2, 15, 9, 36, 3, 19, 56, 362, 217, 229, 3422473957, 12, 1, 24, 20, 24, 24, 3, 15, 32, 13, 4, 56, 156, 224, 236, 3878930308, 56, 169, 237, 242, 3084740417, 19, 9, 17, 7, 55, 56, 265, 233, 241, 253300231, 27, 9, 17, 10, 32, 12, 7, 55, 19, 3387387430, 1746655784, 105915936, 2760026726, 1119690210, 3773924986, 2133721833, 493198865, 3147379216, 1507239441, 4161544758, 2521296440, 25, 12, 20, 20, 56, 221, 222, 237, 3494454838, 36, 17, 15, 56, 234, 172, 177, 3359029983, 55, 56, 215, 354, 367, 1960247450, 19, 16, 13, 17, 56, 236, 356, 365, 981536214, 8, 13, 3, 7, 23, 14, 16, 12, 56, 209, 254, 268, 500399168, 56, 354, 217, 223, 2890314093, 55, 21, 17, 6, 8, 27, 2, 14, 0, 56, 209, 269, 283, 2296705066, 35, 15, 19, 8, 0, 5, 14, 27, 6, 5, 17, 55, 17, 2286316937, 645438105, 3300329093, 1659603841, 18882434, 2673044101, 1032775050, 3686817180, 2046702216, 406173366, 24, 8, 22, 19, 11, 14, 10, 19, 35, 19, 13, 26, 7, 5, 8, 24, 14, 18, 22, 4, 20, 35, 4, 1, 25, 22, 18, 6, 14, 9, 17, 12, 32, 21, 24, 24, 16, 20, 6, 24, 5, 9, 15, 1, 18, 19300, 28, 1451740752, 4105916894, 35, 2, 4, 14, 21, 3, 13, 15, 7, 9, 14, 56, 365, 354, 363, 3308854293, 55, 56, 353, 210, 212, 2679368728, 56, 158, 224, 239, 1030826405, 4, 11, 56, 167, 299, 313, 2391009396, 55, 12, 20, 7, 13, 13, 11, 4, 2, 26, 22, 3, 7, 56, 353, 262, 276, 731718124, 32, 8, 12, 34, 14, 24, 8]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 4; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/oracle.json new file mode 100644 index 00000000..af1d54e4 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/oracle.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "hardened-self-modifying", + "expectedStatus": "declined", + "expectedDiagnostic": "wordcode-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The real selfModifying option emits PATCH opcodes that the registered standalone decoder declines.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/options/selfModyfing.test.js:1-32" + ], + "semanticTags": [ + "real-encoder-output", + "phase-2", + "self-modifying", + "patch-opcode" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/source.js new file mode 100644 index 00000000..dbd8058c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/hardened-self-modifying/source.js @@ -0,0 +1,2 @@ +function f() { window.TEST_OUTPUT = 1; } +f(); diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/encoded.js new file mode 100644 index 00000000..1820f7dc --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var BYTECODE = []; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/oracle.json new file mode 100644 index 00000000..bf6cf299 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "malformed-container-empty-wordcode", + "expectedStatus": "declined", + "expectedDiagnostic": "container-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The VM roles are present but the bytecode stream is empty, which violates the container contract before wordcode decoding.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "parseable-container", + "malformed-wordcode", + "empty-stream" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/malformed-container-empty-wordcode/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/encoded.js new file mode 100644 index 00000000..6a9668cc --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var NOT_BYTECODE = [4, 1, 2, 2, 0, 0, 0, 3, 1, 0, 0, 4, 2, 0, 9, 2, 3, 4, 0, 2, 3, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/oracle.json new file mode 100644 index 00000000..f4f697c2 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "missing-container-role", + "expectedStatus": "declined", + "expectedDiagnostic": "container-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The bytecode role is renamed, so the source is not a valid container.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "parseable-source", + "missing-bytecode-role", + "container-contract" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/missing-container-role/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/encoded.js new file mode 100644 index 00000000..c596cf89 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/encoded.js @@ -0,0 +1,2 @@ +const answer = 42; +window.TEST_OUTPUT = answer; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/oracle.json new file mode 100644 index 00000000..2fd15211 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "ordinary-near-miss", + "expectedStatus": "declined", + "expectedDiagnostic": "container-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "Parseable ordinary JavaScript has no jsconfuser-vm container role.", + "sourceEvidence": [ + "decoder/decode-js/test/vm/jsconfuser-vm/integration/harness.test.js:167-179" + ], + "semanticTags": [ + "ordinary-javascript", + "parseable", + "not-a-vm-container" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/source.js new file mode 100644 index 00000000..c596cf89 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/ordinary-near-miss/source.js @@ -0,0 +1,2 @@ +const answer = 42; +window.TEST_OUTPUT = answer; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/encoded.js new file mode 100644 index 00000000..00111f30 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var BYTECODE = [4,1,2,2,0,0,0,3,4294967295,0,0,4,2,0,9,2,3,4,0,2,3,0,45,2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/oracle.json new file mode 100644 index 00000000..60f23b7a --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "reference-invalid-constant", + "expectedStatus": "declined", + "expectedDiagnostic": "references-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "A LOAD_CONST constant-table index is outside the container's constant table.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "constant-reference", + "out-of-range", + "atomic-decline" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-constant/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/encoded.js new file mode 100644 index 00000000..682b615c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */2, /* 1 */1, /* 2 */0, /* 3 */"window", /* 4 */"TEST_OUTPUT", /* 5 */undefined]; +var BYTECODE = [4,1,0,3,0,0,5,2,3,0,3,1,0,23,4,2,3,40,4,4294967295,0,3,1,0,11,4,2,3,5,2,4,39,40,0,3,2,0,5,2,3,2,3,3,0,0,4,4,0,9,3,4,2,0,3,5,0,45,3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/oracle.json new file mode 100644 index 00000000..cdb0cf52 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/oracle.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "reference-invalid-label", + "expectedStatus": "declined", + "expectedDiagnostic": "references-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "A conditional branch target is changed to an out-of-range wordcode address.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/IfStatements.test.js:4-115", + "encoder/js-confuser-vm/test/features/Loops.test.js:4-186" + ], + "semanticTags": [ + "label-reference", + "out-of-range", + "branch-operand" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/source.js new file mode 100644 index 00000000..e74b28cf --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-label/source.js @@ -0,0 +1,3 @@ +var x = 2; +if (x > 1) { x = x + 1; } else { x = 0; } +window.TEST_OUTPUT = x; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/encoded.js new file mode 100644 index 00000000..e915f361 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */2, /* 1 */"window", /* 2 */"TEST_OUTPUT", /* 3 */1, /* 4 */undefined]; +var BYTECODE = [4,1,47,4,0,1,5,0,0,5,2,4,0,4,0,0,42,5,2,1,4,5,3,5,2,4,1,0,0,5,2,0,0,6,3,0,42,7,3,1,6,0,6,0,0,42,8,3,1,6,48,6,2,7,8,9,4,5,6,0,4,4,0,45,4,4,2,5,3,0,47,4,87,1,5,1,0,1,3,45,4,0,4,4,0,45,4,4,2,3,3,0,11,4,3,0,7,0,4,3,3,0,45,3,0,3,4,0,45,3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 9; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/oracle.json new file mode 100644 index 00000000..3beb8e1a --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "reference-invalid-ownership", + "expectedStatus": "declined", + "expectedDiagnostic": "functions-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "A MAKE_CLOSURE target is redirected to the root function, breaking ownership partitioning.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Closures.test.js:4-110" + ], + "semanticTags": [ + "closure-reference", + "function-ownership", + "invalid-target" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/source.js new file mode 100644 index 00000000..1a18c894 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-ownership/source.js @@ -0,0 +1,3 @@ +function make(seed) { var total = seed; return function (step) { total += step; return total; }; } +var next = make(2); +window.TEST_OUTPUT = [next(1), next(2)]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/encoded.js new file mode 100644 index 00000000..af6ab2aa --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var BYTECODE = [4,1,2,2,0,0,0,4294967295,1,0,0,4,2,0,9,2,3,4,0,2,3,0,45,2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/oracle.json new file mode 100644 index 00000000..7a8ec773 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "reference-invalid-register", + "expectedStatus": "declined", + "expectedDiagnostic": "references-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "A LOAD_CONST destination register is outside the function register file.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "register-reference", + "out-of-range", + "atomic-decline" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/reference-invalid-register/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/encoded.js new file mode 100644 index 00000000..3150cf9b --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var BYTECODE = [4, 1, 2, 2, 0, 0, 0, 3, 1, 0, 0, 4, 2, 0, 9, 2, 3, 4, 0, 2, 3, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 8 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/oracle.json new file mode 100644 index 00000000..0bf79393 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "structural-container-role", + "expectedStatus": "declined", + "expectedDiagnostic": "container-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The registered role metadata is parseable but has an invalid register-base shape.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "parseable-source", + "role-operand-shape", + "container-schema" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/structural-container-role/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/encoded.js new file mode 100644 index 00000000..4e4e915e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var BYTECODE = [4,1,2,2,0,0,0,3,1,0,0,4,2,0,9,2,3,4,0,2,3,0,45]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/oracle.json new file mode 100644 index 00000000..e5f2d985 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "truncated-wordcode", + "expectedStatus": "declined", + "expectedDiagnostic": "wordcode-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The final instruction is removed, leaving a structurally truncated stream.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "numeric-wordcode", + "truncated-instruction", + "atomic-decline" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/truncated-wordcode/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/encoded.js new file mode 100644 index 00000000..f2223716 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */3, /* 3 */undefined]; +var BYTECODE = [4294967295,1,2,2,0,0,0,3,1,0,0,4,2,0,9,2,3,4,0,2,3,0,45,2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/oracle.json new file mode 100644 index 00000000..0114644e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/oracle.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "negative-oracle.v1", + "cellId": "unknown-opcode", + "expectedStatus": "declined", + "expectedDiagnostic": "wordcode-declined", + "outputMustEqualInput": true, + "targetExecution": false, + "vmExecuted": false, + "behavioralOracleInvoked": false, + "reason": "The first opcode is outside the pinned VM opcode table.", + "sourceEvidence": [ + "encoder/js-confuser-vm/test/features/Literals.test.js:4-177" + ], + "semanticTags": [ + "numeric-wordcode", + "unknown-opcode", + "atomic-decline" + ], + "comparison": { + "sourceExpected": "static-decline-contract", + "recoveredExpected": "not-applicable" + }, + "exclusions": [ + "This negative oracle is never evaled or executed; it records only the registered decline contract.", + "No behavioral source oracle is used to make a decline look like successful decompilation." + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/source.js new file mode 100644 index 00000000..6d3a42f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/negative/unknown-opcode/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 3; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/encoded.js new file mode 100644 index 00000000..7f644743 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */1, /* 1 */"computed", /* 2 */"value", /* 3 */"method", /* 4 */11, /* 5 */7, /* 6 */"window", /* 7 */"TEST_OUTPUT", /* 8 */"Object", /* 9 */"keys", /* 10 */"sort", /* 11 */undefined]; +var BYTECODE = [4, 1, 0, 5, 0, 0, 5, 2, 5, 0, 5, 1, 0, 5, 3, 5, 49, 5, 0, 0, 6, 2, 0, 47, 7, 172, 0, 3, 1, 0, 1, 2, 50, 5, 6, 7, 0, 6, 2, 0, 47, 7, 185, 1, 4, 1, 0, 1, 2, 51, 5, 6, 7, 0, 6, 3, 0, 47, 7, 196, 0, 4, 0, 0, 9, 5, 6, 7, 0, 6, 4, 0, 9, 5, 3, 6, 5, 4, 5, 0, 5, 2, 0, 0, 6, 5, 0, 9, 4, 5, 6, 2, 5, 6, 0, 0, 6, 7, 0, 0, 7, 2, 0, 8, 8, 4, 7, 0, 7, 3, 0, 8, 9, 4, 7, 43, 7, 4, 9, 0, 8, 9, 4, 3, 2, 10, 8, 0, 0, 11, 9, 0, 8, 12, 10, 11, 43, 11, 10, 12, 1, 4, 0, 10, 10, 0, 8, 12, 11, 10, 43, 10, 11, 12, 0, 48, 11, 4, 8, 7, 9, 10, 9, 5, 6, 11, 0, 5, 11, 0, 45, 5, 4, 1, 3, 2, 0, 45, 2, 0, 2, 11, 0, 45, 2, 4, 2, 7, 0, 0, 0, 3, 11, 0, 45, 3, 4, 1, 0, 2, 2, 0, 8, 3, 1, 2, 45, 3, 0, 2, 11, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 13; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/oracle.json new file mode 100644 index 00000000..87dd10bf --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/oracle.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-accessors-and-methods", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 7, + 7, + 11, + [ + "computed", + "method", + "value" + ] + ], + "expectedThrow": null, + "semanticTags": [ + "getter", + "setter", + "computed-key", + "computed-state", + "method-shorthand", + "enumeration" + ], + "hazards": [ + "Computed key evaluation and accessor/method enumeration are observed through explicit values and sorted keys; property identity is not compared across processes.", + "The child uses the minimal-window-v1 profile and no ambient globals beyond the declared source." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/source.js new file mode 100644 index 00000000..6db7b240 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-accessors-and-methods/source.js @@ -0,0 +1,5 @@ +var backing = 1; +var computedKey = "computed"; +var object = { get value() { return backing; }, set value(next) { backing = next; }, method() { return this.value; }, [computedKey]: 11 }; +object.value = 7; +window.TEST_OUTPUT = [object.value, object.method(), object[computedKey], Object.keys(object).sort()]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/encoded.js new file mode 100644 index 00000000..8657da0a --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */1, /* 1 */2, /* 2 */3, /* 3 */"map", /* 4 */"window", /* 5 */"TEST_OUTPUT", /* 6 */undefined, /* 7 */"x", /* 8 */"y"]; +var BYTECODE = [4, 1, 47, 5, 106, 2, 3, 0, 0, 5, 2, 5, 47, 5, 112, 0, 5, 0, 0, 42, 6, 5, 0, 5, 3, 6, 0, 5, 0, 0, 0, 6, 1, 0, 0, 7, 2, 0, 48, 8, 3, 5, 6, 7, 0, 5, 3, 0, 8, 6, 8, 5, 47, 5, 137, 1, 3, 0, 0, 43, 7, 8, 6, 1, 5, 5, 4, 7, 2, 5, 4, 0, 0, 6, 5, 0, 0, 7, 1, 0, 0, 8, 2, 0, 42, 9, 2, 2, 7, 8, 48, 7, 3, 9, 3, 4, 9, 5, 6, 7, 0, 5, 6, 0, 45, 5, 11, 2, 0, 1, 45, 2, 0, 0, 7, 0, 0, 1, 0, 0, 0, 2, 8, 0, 0, 3, 1, 0, 49, 4, 2, 0, 1, 2, 3, 45, 4, 0, 1, 1, 0, 13, 2, 0, 1, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 10; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/oracle.json new file mode 100644 index 00000000..454cd1c8 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/oracle.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-arrow-forms", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 5, + { + "x": 1, + "y": 2 + }, + [ + 2, + 4, + 6 + ] + ], + "expectedThrow": null, + "semanticTags": [ + "arrow", + "concise-body", + "object-return", + "higher-order" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/source.js new file mode 100644 index 00000000..aa844abe --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/source.js @@ -0,0 +1,4 @@ +var add = (a, b) => a + b; +var object = (() => ({ x: 1, y: 2 }))(); +var doubled = [1, 2, 3].map((value) => value * 2); +window.TEST_OUTPUT = [add(2, 3), object, doubled]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/encoded.js new file mode 100644 index 00000000..65758bdc --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"base", /* 1 */10, /* 2 */"make", /* 3 */1, /* 4 */2, /* 5 */3, /* 6 */"window", /* 7 */"TEST_OUTPUT", /* 8 */"call", /* 9 */100, /* 10 */5, /* 11 */null, /* 12 */undefined, /* 13 */0]; +var BYTECODE = [4, 1, 0, 5, 0, 0, 0, 6, 1, 0, 0, 7, 2, 0, 47, 8, 156, 0, 3, 0, 0, 49, 9, 2, 5, 6, 7, 8, 5, 2, 9, 0, 5, 2, 0, 8, 6, 2, 5, 0, 5, 3, 0, 0, 7, 4, 0, 43, 8, 2, 6, 2, 5, 7, 5, 3, 8, 0, 5, 2, 0, 8, 6, 2, 5, 0, 5, 5, 0, 43, 7, 2, 6, 1, 5, 5, 4, 7, 2, 5, 6, 0, 0, 6, 7, 0, 0, 7, 8, 0, 8, 8, 3, 7, 0, 7, 0, 0, 0, 9, 9, 0, 49, 10, 1, 7, 9, 0, 7, 10, 0, 43, 9, 3, 8, 2, 10, 7, 0, 7, 8, 0, 8, 8, 4, 7, 0, 7, 11, 0, 0, 10, 4, 0, 43, 11, 4, 8, 2, 7, 10, 48, 7, 2, 9, 11, 9, 5, 6, 7, 0, 5, 12, 0, 45, 5, 4, 1, 47, 2, 177, 1, 5, 2, 0, 1, 1, 1, 0, 45, 2, 0, 2, 12, 0, 45, 2, 3, 1, 0, 0, 2, 0, 0, 8, 3, 1, 2, 11, 1, 3, 0, 3, 2, 1, 0, 3, 13, 0, 8, 4, 2, 3, 11, 2, 1, 4, 3, 1, 1, 0, 3, 3, 0, 8, 4, 1, 3, 5, 1, 4, 41, 1, 231, 0, 3, 13, 0, 5, 1, 3, 11, 3, 2, 1, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 12; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/oracle.json new file mode 100644 index 00000000..872a166e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/oracle.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-arrow-lexical-state", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 18, + 15 + ], + "expectedThrow": null, + "semanticTags": [ + "arrow", + "lexical-this", + "lexical-arguments", + "closure", + "receiver" + ], + "hazards": [ + "The arrow must retain both the method receiver and enclosing arguments despite explicit call/apply receivers; only numeric results are compared.", + "Missing arguments[1] is deliberately normalized through the source's own fallback, not by the oracle." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/source.js new file mode 100644 index 00000000..280c43f3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-lexical-state/source.js @@ -0,0 +1,4 @@ +var object = { base: 10, make: function () { return (value) => this.base + value + arguments[0] + (arguments[1] || 0); } }; +var first = object.make(1, 2); +var second = object.make(3); +window.TEST_OUTPUT = [first.call({ base: 100 }, 5), second.call(null, 2)]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/encoded.js new file mode 100644 index 00000000..8ef12b3e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */3, /* 1 */undefined, /* 2 */4, /* 3 */"high", /* 4 */2, /* 5 */"mid", /* 6 */"low", /* 7 */1, /* 8 */"odd", /* 9 */"even", /* 10 */"window", /* 11 */"TEST_OUTPUT"]; +var BYTECODE = [4, 1, 0, 5, 0, 0, 5, 2, 5, 0, 3, 1, 0, 0, 5, 2, 0, 23, 6, 2, 5, 40, 6, 33, 0, 5, 3, 0, 5, 3, 5, 39, 60, 0, 5, 4, 0, 23, 6, 2, 5, 40, 6, 53, 0, 5, 5, 0, 5, 3, 5, 39, 60, 0, 5, 6, 0, 5, 3, 5, 0, 5, 4, 0, 15, 6, 2, 5, 0, 5, 7, 0, 26, 7, 6, 5, 40, 7, 88, 0, 5, 8, 0, 5, 6, 5, 39, 95, 0, 5, 9, 0, 5, 6, 5, 5, 4, 6, 2, 5, 10, 0, 0, 6, 11, 0, 48, 7, 2, 3, 4, 9, 5, 6, 7, 0, 5, 1, 0, 45, 5]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 8; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/oracle.json new file mode 100644 index 00000000..c60da2b4 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/oracle.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-branching", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + "mid", + "odd" + ], + "expectedThrow": null, + "semanticTags": [ + "if", + "else-if", + "nested-branch", + "conditional-expression" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/source.js new file mode 100644 index 00000000..8fa189a8 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/source.js @@ -0,0 +1,5 @@ +var n = 3; +var branch; +if (n > 4) branch = "high"; else if (n > 2) branch = "mid"; else branch = "low"; +var nested = n % 2 === 1 ? "odd" : "even"; +window.TEST_OUTPUT = [branch, nested]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js new file mode 100644 index 00000000..a4b112bd --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */undefined, /* 3 */0, /* 4 */1]; +var BYTECODE = [4, 1, 47, 4, 55, 0, 4, 0, 0, 5, 2, 4, 42, 4, 2, 0, 5, 3, 4, 2, 4, 0, 0, 0, 5, 1, 0, 42, 6, 3, 0, 42, 7, 3, 0, 42, 8, 3, 0, 48, 9, 3, 6, 7, 8, 9, 4, 5, 9, 0, 4, 2, 0, 45, 4, 4, 1, 0, 3, 3, 0, 5, 2, 3, 47, 3, 81, 0, 5, 1, 0, 1, 2, 45, 3, 0, 3, 2, 0, 45, 3, 4, 1, 3, 2, 0, 5, 3, 2, 0, 3, 4, 0, 11, 4, 2, 3, 7, 0, 4, 3, 2, 0, 45, 2, 0, 2, 2, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 10; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/oracle.json new file mode 100644 index 00000000..ec025de4 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/oracle.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-closures-state", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 1, + 2, + 3 + ], + "expectedThrow": null, + "semanticTags": [ + "closure", + "upvalue", + "mutable-state", + "repeated-call" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/source.js new file mode 100644 index 00000000..49c89bfd --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/source.js @@ -0,0 +1,3 @@ +function factory() { var count = 0; return function () { count++; return count; }; } +var next = factory(); +window.TEST_OUTPUT = [next(), next(), next()]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/encoded.js new file mode 100644 index 00000000..c5667377 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"push", /* 1 */"before", /* 2 */"after", /* 3 */"window", /* 4 */"TEST_OUTPUT", /* 5 */undefined]; +var BYTECODE = [4, 1, 48, 3, 0, 5, 2, 3, 0, 3, 0, 0, 8, 4, 2, 3, 0, 3, 1, 0, 43, 5, 2, 4, 1, 3, 57, 0, 3, 0, 0, 8, 4, 2, 3, 0, 3, 2, 0, 43, 5, 2, 4, 1, 3, 2, 3, 3, 0, 0, 4, 4, 0, 9, 3, 4, 2, 0, 3, 5, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 6; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/oracle.json new file mode 100644 index 00000000..42fda81a --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/oracle.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-debugger-order", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + "before", + "after" + ], + "expectedThrow": null, + "semanticTags": [ + "debugger-statement", + "statement-order" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/source.js new file mode 100644 index 00000000..2a2d391e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/source.js @@ -0,0 +1,5 @@ +var log = []; +log.push("before"); +debugger; +log.push("after"); +window.TEST_OUTPUT = log; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/encoded.js new file mode 100644 index 00000000..350f7a1d --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */1, /* 1 */"x", /* 2 */10, /* 3 */5, /* 4 */"missing", /* 5 */3, /* 6 */2, /* 7 */"window", /* 8 */"TEST_OUTPUT", /* 9 */"result", /* 10 */"object", /* 11 */undefined]; +var BYTECODE = [4, 1, 0, 5, 0, 0, 5, 2, 5, 0, 5, 1, 0, 0, 6, 2, 0, 49, 7, 1, 5, 6, 5, 3, 7, 5, 5, 2, 0, 6, 0, 0, 11, 7, 2, 6, 5, 2, 7, 0, 6, 0, 0, 11, 7, 2, 6, 5, 2, 7, 0, 6, 1, 0, 8, 8, 3, 6, 0, 9, 3, 0, 11, 10, 8, 9, 9, 3, 6, 10, 0, 6, 1, 0, 8, 8, 3, 6, 5, 9, 8, 0, 11, 0, 0, 12, 12, 8, 11, 9, 3, 6, 12, 0, 6, 4, 0, 10, 8, 3, 6, 0, 6, 5, 0, 5, 2, 6, 0, 6, 6, 0, 13, 11, 2, 6, 48, 6, 6, 5, 7, 10, 9, 8, 11, 5, 4, 6, 2, 5, 7, 0, 0, 6, 8, 0, 0, 7, 9, 0, 0, 8, 1, 0, 0, 9, 10, 0, 49, 10, 3, 7, 4, 8, 2, 9, 3, 9, 5, 6, 10, 0, 5, 11, 0, 45, 5]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 13; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/oracle.json new file mode 100644 index 00000000..715a1fff --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/oracle.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-expression-mutation", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": { + "result": [ + 1, + 3, + 15, + 15, + true, + 6 + ], + "x": 3, + "object": { + "x": 14 + } + }, + "expectedThrow": null, + "semanticTags": [ + "assignment", + "mutation", + "update", + "delete", + "sequence" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/source.js new file mode 100644 index 00000000..7fae670f --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-mutation/source.js @@ -0,0 +1,4 @@ +var x = 1; +var object = { x: 10 }; +var result = [x++, ++x, object.x += 5, object.x--, delete object.missing, (x = 3, x * 2)]; +window.TEST_OUTPUT = { result: result, x: x, object: object }; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/encoded.js new file mode 100644 index 00000000..e9040525 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */2, /* 3 */3, /* 4 */5, /* 5 */8, /* 6 */4, /* 7 */"x", /* 8 */1, /* 9 */"Array", /* 10 */"undefined", /* 11 */0, /* 12 */undefined]; +var BYTECODE = [4, 1, 2, 2, 0, 0, 0, 3, 1, 0, 0, 4, 2, 0, 0, 5, 3, 0, 11, 6, 4, 5, 0, 4, 2, 0, 0, 5, 3, 0, 0, 7, 2, 0, 60, 8, 5, 7, 60, 5, 4, 8, 0, 4, 4, 0, 0, 7, 3, 0, 16, 8, 4, 7, 0, 4, 5, 0, 17, 7, 8, 4, 0, 4, 3, 0, 0, 8, 6, 0, 22, 9, 4, 8, 0, 4, 7, 0, 0, 8, 7, 0, 0, 10, 8, 0, 49, 11, 1, 8, 10, 30, 8, 4, 11, 48, 4, 0, 2, 10, 9, 0, 31, 11, 4, 10, 38, 4, 10, 0, 0, 10, 11, 0, 37, 12, 10, 48, 10, 8, 6, 5, 7, 9, 8, 11, 4, 12, 9, 2, 3, 10, 0, 2, 12, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 13; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/oracle.json new file mode 100644 index 00000000..a7c64d0e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/oracle.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-expression-values", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 5, + 512, + 9, + true, + true, + true, + "undefined", + { + "type": "undefined" + } + ], + "expectedThrow": null, + "semanticTags": [ + "operators", + "precedence", + "instanceof", + "typeof", + "void" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/source.js new file mode 100644 index 00000000..a36d0c1d --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/source.js @@ -0,0 +1,4 @@ +window.TEST_OUTPUT = [ + 2 + 3, 2 ** 3 ** 2, (5 & 3) | 8, 3 < 4, + "x" in { x: 1 }, [] instanceof Array, typeof undefined, void 0, +]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js new file mode 100644 index 00000000..56dbd118 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */undefined, /* 3 */"push", /* 4 */"try", /* 5 */"normal", /* 6 */"finally", /* 7 */"boom", /* 8 */"finally-catch", /* 9 */"deep", /* 10 */"inner-finally", /* 11 */"nested-caught-", /* 12 */"outer-finally", /* 13 */0, /* 14 */3, /* 15 */1, /* 16 */2, /* 17 */"loop-body-", /* 18 */"loop-finally-"]; +var BYTECODE = [4, 1, 47, 9, 102, 0, 8, 1, 0, 1, 2, 5, 3, 9, 47, 9, 182, 0, 9, 1, 0, 1, 2, 5, 4, 9, 47, 9, 255, 0, 11, 1, 0, 1, 2, 5, 5, 9, 47, 9, 372, 0, 9, 1, 0, 1, 2, 5, 6, 9, 48, 9, 0, 5, 2, 9, 42, 9, 3, 0, 5, 7, 9, 42, 9, 4, 0, 5, 8, 9, 42, 9, 5, 0, 42, 9, 6, 0, 2, 9, 0, 0, 0, 10, 1, 0, 48, 11, 3, 7, 8, 2, 9, 9, 10, 11, 0, 9, 2, 0, 45, 9, 4, 1, 59, 149, 2, 3, 172, 3, 4, 0, 0, 5, 3, 0, 8, 6, 4, 5, 0, 5, 4, 0, 43, 7, 4, 6, 1, 5, 0, 4, 5, 0, 5, 3, 4, 1, 2, 174, 55, 39, 149, 55, 1, 2, 176, 39, 149, 3, 4, 0, 0, 5, 3, 0, 8, 6, 4, 5, 0, 5, 6, 0, 43, 7, 4, 6, 1, 5, 58, 2, 46, 3, 45, 3, 0, 4, 2, 0, 45, 4, 4, 1, 59, 224, 3, 4, 247, 54, 201, 2, 0, 5, 7, 0, 46, 5, 55, 39, 218, 3, 5, 0, 0, 6, 3, 0, 8, 7, 5, 6, 43, 6, 5, 7, 1, 2, 55, 1, 3, 249, 39, 224, 3, 5, 0, 0, 6, 3, 0, 8, 7, 5, 6, 0, 6, 8, 0, 43, 8, 5, 7, 1, 6, 58, 3, 46, 4, 0, 5, 2, 0, 45, 5, 4, 1, 59, 341, 3, 4, 364, 54, 310, 2, 59, 282, 5, 6, 305, 0, 7, 9, 0, 46, 7, 55, 1, 5, 307, 39, 282, 3, 7, 0, 0, 8, 3, 0, 8, 9, 7, 8, 0, 8, 10, 0, 43, 10, 7, 9, 1, 8, 58, 5, 46, 6, 55, 39, 335, 3, 7, 0, 0, 8, 3, 0, 8, 9, 7, 8, 0, 8, 11, 0, 11, 10, 8, 2, 43, 8, 7, 9, 1, 10, 55, 1, 3, 366, 39, 341, 3, 7, 0, 0, 8, 3, 0, 8, 9, 7, 8, 0, 8, 12, 0, 43, 10, 7, 9, 1, 8, 58, 3, 46, 4, 0, 7, 2, 0, 45, 7, 4, 1, 0, 5, 13, 0, 5, 2, 5, 0, 5, 14, 0, 22, 6, 2, 5, 40, 6, 511, 59, 462, 3, 4, 489, 0, 5, 15, 0, 26, 6, 2, 5, 40, 6, 414, 1, 3, 491, 55, 39, 462, 0, 5, 16, 0, 26, 6, 2, 5, 40, 6, 431, 1, 3, 493, 55, 39, 462, 3, 5, 0, 0, 6, 3, 0, 8, 7, 5, 6, 0, 6, 17, 0, 11, 8, 6, 2, 43, 6, 5, 7, 1, 8, 55, 1, 3, 495, 39, 462, 3, 5, 0, 0, 6, 3, 0, 8, 7, 5, 6, 0, 6, 18, 0, 11, 8, 6, 2, 43, 6, 5, 7, 1, 8, 58, 3, 46, 4, 39, 495, 39, 511, 5, 5, 2, 0, 5, 15, 0, 11, 6, 2, 5, 5, 2, 6, 39, 381, 0, 5, 2, 0, 45, 5]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 12; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/oracle.json new file mode 100644 index 00000000..b73a1d5f --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/oracle.json @@ -0,0 +1,52 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-finally-abrupt", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + "normal", + { + "type": "undefined" + }, + [ + "try", + "finally", + "boom", + "finally-catch", + "inner-finally", + "nested-caught-deep", + "outer-finally", + "loop-body-0", + "loop-finally-0", + "loop-finally-1", + "loop-finally-2" + ] + ], + "expectedThrow": null, + "semanticTags": [ + "finally", + "return", + "throw", + "catch", + "nested-finally", + "loop-abrupt", + "completion-order" + ], + "hazards": [ + "The log is the source-visible oracle for nested-finalizer order and for finalizers crossed by continue/break; exception stacks are excluded.", + "The loop bounds are fixed and synchronous so every abrupt completion remains finite and reproducible." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/source.js new file mode 100644 index 00000000..62fa58b8 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/source.js @@ -0,0 +1,7 @@ +var log = []; +function normal() { try { log.push("try"); return "normal"; } finally { log.push("finally"); } } +function caught() { try { throw "boom"; } catch (error) { log.push(error); } finally { log.push("finally-catch"); } } +function nested() { try { try { throw "deep"; } finally { log.push("inner-finally"); } } catch (error) { log.push("nested-caught-" + error); } finally { log.push("outer-finally"); } } +function loopAbrupt() { for (var i = 0; i < 3; i++) { try { if (i === 1) continue; if (i === 2) break; log.push("loop-body-" + i); } finally { log.push("loop-finally-" + i); } } } +var first = normal(); var second = caught(); nested(); loopAbrupt(); +window.TEST_OUTPUT = [first, second, log]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/encoded.js new file mode 100644 index 00000000..610767eb --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"prototype", /* 1 */"inherited", /* 2 */1, /* 3 */"own", /* 4 */2, /* 5 */"push", /* 6 */"window", /* 7 */"TEST_OUTPUT", /* 8 */"raw", /* 9 */"sorted", /* 10 */"slice", /* 11 */"sort", /* 12 */undefined]; +var BYTECODE = [4, 1, 47, 7, 142, 0, 3, 0, 0, 5, 2, 7, 0, 7, 0, 0, 8, 8, 2, 7, 0, 7, 1, 0, 0, 9, 2, 0, 9, 8, 7, 9, 44, 7, 2, 0, 5, 3, 7, 0, 7, 3, 0, 0, 8, 4, 0, 9, 3, 7, 8, 48, 7, 0, 5, 4, 7, 52, 5, 3, 53, 7, 5, 83, 5, 6, 7, 0, 7, 5, 0, 8, 8, 4, 7, 43, 7, 4, 8, 1, 6, 39, 60, 2, 7, 6, 0, 0, 8, 7, 0, 0, 9, 8, 0, 0, 10, 9, 0, 0, 11, 10, 0, 8, 12, 4, 11, 43, 11, 4, 12, 0, 0, 12, 11, 0, 8, 13, 11, 12, 43, 12, 11, 13, 0, 49, 11, 2, 9, 4, 10, 12, 9, 7, 8, 11, 0, 7, 12, 0, 45, 7, 4, 1, 0, 2, 12, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 14; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/oracle.json new file mode 100644 index 00000000..23279fa6 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/oracle.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-for-in-enumeration", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": { + "raw": [ + "own", + "inherited" + ], + "sorted": [ + "inherited", + "own" + ] + }, + "expectedThrow": null, + "semanticTags": [ + "for-in", + "enumeration", + "prototype", + "own-properties" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/source.js new file mode 100644 index 00000000..c4c5251e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/source.js @@ -0,0 +1,5 @@ +function Parent() {} +Parent.prototype.inherited = 1; +var object = new Parent(); object.own = 2; +var keys = []; for (var key in object) keys.push(key); +window.TEST_OUTPUT = { raw: keys, sorted: keys.slice().sort() }; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/encoded.js new file mode 100644 index 00000000..f785ebc8 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */2, /* 3 */3, /* 4 */4, /* 5 */"undefined", /* 6 */undefined, /* 7 */"hoisted", /* 8 */7]; +var BYTECODE = [4, 1, 47, 5, 112, 2, 5, 0, 0, 5, 2, 5, 47, 5, 126, 1, 5, 0, 0, 5, 3, 5, 47, 5, 144, 0, 3, 0, 0, 5, 4, 5, 2, 5, 0, 0, 0, 6, 1, 0, 0, 7, 2, 0, 0, 8, 3, 0, 42, 9, 2, 2, 7, 8, 0, 7, 4, 0, 42, 8, 3, 1, 7, 47, 7, 158, 0, 3, 0, 0, 42, 10, 7, 0, 47, 7, 172, 1, 4, 0, 0, 2, 11, 5, 0, 42, 12, 7, 1, 11, 42, 7, 4, 0, 48, 11, 5, 9, 8, 10, 12, 7, 9, 5, 6, 11, 0, 5, 6, 0, 45, 5, 4, 3, 11, 4, 0, 1, 45, 4, 0, 4, 6, 0, 45, 4, 4, 2, 0, 3, 2, 0, 13, 4, 0, 3, 45, 4, 0, 3, 6, 0, 45, 3, 4, 1, 0, 2, 7, 0, 45, 2, 0, 2, 6, 0, 45, 2, 4, 1, 0, 2, 8, 0, 45, 2, 0, 2, 6, 0, 45, 2, 4, 2, 45, 0, 0, 3, 6, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 13; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/oracle.json new file mode 100644 index 00000000..e31128a4 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/oracle.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-functions-call-return", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 5, + 8, + 7, + { + "type": "undefined" + }, + "hoisted" + ], + "expectedThrow": null, + "semanticTags": [ + "function", + "call", + "return", + "iife", + "hoisting" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/source.js new file mode 100644 index 00000000..f24d5403 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-call-return/source.js @@ -0,0 +1,4 @@ +function add(a, b) { return a + b; } +function double(value) { return value * 2; } +function before() { return "hoisted"; } +window.TEST_OUTPUT = [add(2, 3), double(4), (function () { return 7; })(), (function (value) { return value; })(undefined), before()]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/encoded.js new file mode 100644 index 00000000..7516c79b --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */"Ada", /* 3 */1, /* 4 */2, /* 5 */undefined, /* 6 */"World", /* 7 */"Hello, ", /* 8 */"!", /* 9 */"length"]; +var BYTECODE = [4, 1, 47, 4, 83, 1, 6, 0, 0, 5, 2, 4, 47, 4, 127, 2, 6, 0, 0, 5, 3, 4, 2, 4, 0, 0, 0, 5, 1, 0, 42, 6, 2, 0, 0, 7, 2, 0, 42, 8, 2, 1, 7, 0, 7, 3, 0, 0, 9, 4, 0, 42, 10, 3, 2, 7, 9, 0, 7, 3, 0, 42, 9, 3, 1, 7, 48, 7, 4, 6, 8, 10, 9, 9, 4, 5, 7, 0, 4, 5, 0, 45, 4, 4, 2, 0, 3, 5, 0, 26, 4, 0, 3, 40, 4, 103, 0, 3, 6, 0, 5, 0, 3, 0, 3, 7, 0, 11, 4, 3, 0, 0, 3, 8, 0, 11, 5, 4, 3, 45, 5, 0, 3, 5, 0, 45, 3, 4, 3, 0, 4, 9, 0, 8, 5, 2, 4, 48, 4, 3, 5, 0, 1, 45, 4, 0, 4, 5, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 11; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/oracle.json new file mode 100644 index 00000000..5d5e4a1d --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/oracle.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-functions-params", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + "Hello, World!", + "Hello, Ada!", + [ + 2, + 1, + 2 + ], + [ + 1, + 1, + { + "type": "undefined" + } + ] + ], + "expectedThrow": null, + "semanticTags": [ + "default-parameters", + "arguments", + "arity" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/source.js new file mode 100644 index 00000000..5c5bf22b --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-params/source.js @@ -0,0 +1,3 @@ +function greet(name = "World") { return "Hello, " + name + "!"; } +function collect(a, b) { return [arguments.length, a, b]; } +window.TEST_OUTPUT = [greet(), greet("Ada"), collect(1, 2), collect(1)]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/encoded.js new file mode 100644 index 00000000..5985b2a3 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */1, /* 3 */2, /* 4 */3, /* 5 */4, /* 6 */5, /* 7 */10, /* 8 */20, /* 9 */30, /* 10 */undefined, /* 11 */"reduce", /* 12 */0, /* 13 */"length"]; +var BYTECODE = [4, 1, 47, 4, 103, 1, 7, 0, 1, 5, 2, 4, 47, 4, 153, 2, 8, 0, 1, 5, 3, 4, 2, 4, 0, 0, 0, 5, 1, 0, 0, 6, 2, 0, 0, 7, 3, 0, 0, 8, 4, 0, 0, 9, 5, 0, 0, 10, 6, 0, 42, 11, 2, 5, 6, 7, 8, 9, 10, 0, 6, 7, 0, 0, 7, 8, 0, 0, 8, 9, 0, 42, 9, 3, 3, 6, 7, 8, 0, 6, 7, 0, 42, 7, 3, 1, 6, 48, 6, 3, 11, 9, 7, 9, 4, 5, 6, 0, 4, 10, 0, 45, 4, 4, 2, 0, 3, 11, 0, 8, 4, 0, 3, 47, 3, 139, 2, 5, 0, 0, 0, 5, 12, 0, 43, 6, 0, 4, 2, 3, 5, 45, 6, 0, 3, 10, 0, 45, 3, 4, 3, 11, 4, 0, 1, 45, 4, 0, 4, 10, 0, 45, 4, 4, 3, 0, 4, 13, 0, 8, 5, 1, 4, 0, 4, 12, 0, 8, 6, 1, 4, 0, 4, 2, 0, 8, 7, 1, 4, 48, 4, 4, 0, 5, 6, 7, 45, 4, 0, 4, 10, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 12; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/oracle.json new file mode 100644 index 00000000..b059c9be --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/oracle.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-functions-rest", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 15, + [ + 10, + 2, + 20, + 30 + ], + [ + 10, + 0, + { + "type": "undefined" + }, + { + "type": "undefined" + } + ] + ], + "expectedThrow": null, + "semanticTags": [ + "rest-parameters", + "argument-packing", + "array" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/source.js new file mode 100644 index 00000000..461cc3bb --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-functions-rest/source.js @@ -0,0 +1,3 @@ +function sum(...values) { return values.reduce(function (a, b) { return a + b; }, 0); } +function head(first, ...rest) { return [first, rest.length, rest[0], rest[1]]; } +window.TEST_OUTPUT = [sum(1, 2, 3, 4, 5), head(10, 20, 30), head(10)]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/encoded.js new file mode 100644 index 00000000..cb1c703c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */null, /* 1 */"definitely_missing_global", /* 2 */"name", /* 3 */"ReferenceError", /* 4 */"window", /* 5 */"TEST_OUTPUT", /* 6 */undefined]; +var BYTECODE = [4, 1, 0, 4, 0, 0, 5, 2, 4, 54, 19, 3, 2, 4, 1, 0, 55, 39, 43, 0, 4, 2, 0, 8, 5, 3, 4, 2, 4, 3, 0, 31, 6, 3, 4, 48, 4, 2, 5, 6, 5, 2, 4, 2, 4, 4, 0, 0, 5, 5, 0, 38, 6, 1, 0, 48, 7, 2, 2, 6, 9, 4, 5, 7, 0, 4, 6, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 8; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/oracle.json new file mode 100644 index 00000000..096feacf --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/oracle.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-global-resolution", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + [ + "ReferenceError", + true + ], + "undefined" + ], + "expectedThrow": null, + "semanticTags": [ + "global-lookup", + "reference-error", + "typeof" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/source.js new file mode 100644 index 00000000..3814abfa --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-global-resolution/source.js @@ -0,0 +1,3 @@ +var missing = null; +try { definitely_missing_global; } catch (error) { missing = [error.name, error instanceof ReferenceError]; } +window.TEST_OUTPUT = [missing, typeof definitely_missing_global]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/encoded.js new file mode 100644 index 00000000..322d8321 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */null, /* 1 */"name", /* 2 */"TypeError", /* 3 */"window", /* 4 */"TEST_OUTPUT", /* 5 */undefined]; +var BYTECODE = [4, 1, 0, 4, 0, 0, 5, 2, 4, 54, 25, 3, 48, 4, 0, 49, 5, 0, 31, 6, 4, 5, 55, 39, 49, 0, 4, 1, 0, 8, 5, 3, 4, 2, 4, 2, 0, 31, 6, 3, 4, 48, 4, 2, 5, 6, 5, 2, 4, 2, 4, 3, 0, 0, 5, 4, 0, 9, 4, 5, 2, 0, 4, 5, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 7; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/oracle.json new file mode 100644 index 00000000..fe9fd104 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/oracle.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-instanceof-errors", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + "TypeError", + true + ], + "expectedThrow": null, + "semanticTags": [ + "instanceof", + "native-error", + "exception" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/source.js new file mode 100644 index 00000000..6d697450 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-instanceof-errors/source.js @@ -0,0 +1,3 @@ +var caught = null; +try { [] instanceof {}; } catch (error) { caught = [error.name, error instanceof TypeError]; } +window.TEST_OUTPUT = caught; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/encoded.js new file mode 100644 index 00000000..95616ef4 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */0, /* 1 */3, /* 2 */1, /* 3 */"window", /* 4 */"TEST_OUTPUT", /* 5 */undefined]; +var BYTECODE = [4, 1, 0, 8, 0, 0, 5, 2, 8, 0, 8, 0, 0, 5, 3, 8, 0, 8, 1, 0, 22, 9, 3, 8, 40, 9, 104, 0, 8, 0, 0, 5, 4, 8, 0, 8, 1, 0, 22, 9, 4, 8, 40, 9, 88, 5, 8, 2, 0, 8, 2, 0, 11, 9, 2, 8, 5, 2, 9, 0, 8, 2, 0, 26, 9, 4, 8, 40, 9, 72, 39, 104, 5, 8, 4, 0, 8, 2, 0, 11, 9, 4, 8, 5, 4, 9, 39, 34, 5, 8, 3, 0, 8, 2, 0, 11, 9, 3, 8, 5, 3, 9, 39, 16, 0, 8, 0, 0, 5, 5, 8, 0, 8, 0, 0, 5, 6, 8, 0, 8, 1, 0, 22, 9, 6, 8, 40, 9, 206, 0, 8, 0, 0, 5, 7, 8, 0, 8, 1, 0, 22, 9, 7, 8, 40, 9, 190, 0, 8, 2, 0, 26, 9, 7, 8, 40, 9, 160, 39, 190, 5, 8, 5, 0, 8, 2, 0, 11, 9, 5, 8, 5, 5, 9, 5, 8, 7, 0, 8, 2, 0, 11, 9, 7, 8, 5, 7, 9, 39, 136, 5, 8, 6, 0, 8, 2, 0, 11, 9, 6, 8, 5, 6, 9, 39, 118, 2, 8, 3, 0, 0, 9, 4, 0, 48, 10, 2, 2, 5, 9, 8, 9, 10, 0, 8, 5, 0, 45, 8]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 11; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/oracle.json new file mode 100644 index 00000000..66e56aaa --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/oracle.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-labeled-control", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 2, + 3 + ], + "expectedThrow": null, + "semanticTags": [ + "labels", + "break-target", + "continue-target", + "nested-loop" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/source.js new file mode 100644 index 00000000..ea81c156 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-labeled-control/source.js @@ -0,0 +1,5 @@ +var breakCount = 0; +outer: for (var i = 0; i < 3; i++) { for (var j = 0; j < 3; j++) { breakCount++; if (j === 1) break outer; } } +var continueCount = 0; +outer2: for (var x = 0; x < 3; x++) { for (var y = 0; y < 3; y++) { if (y === 1) continue outer2; continueCount++; } } +window.TEST_OUTPUT = [breakCount, continueCount]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js new file mode 100644 index 00000000..0eea0bf5 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */0, /* 1 */"window", /* 2 */"TEST_OUTPUT", /* 3 */"bool", /* 4 */true, /* 5 */"number", /* 6 */42, /* 7 */"nan", /* 8 */"NaN", /* 9 */"infinity", /* 10 */"Infinity", /* 11 */"undef", /* 12 */"undefined", /* 13 */"array", /* 14 */"a", /* 15 */null, /* 16 */"object", /* 17 */"x", /* 18 */"y", /* 19 */"z", /* 20 */undefined, /* 21 */1]; +var BYTECODE = [4, 1, 47, 4, 188, 1, 7, 1, 0, 1, 2, 5, 3, 4, 0, 4, 0, 0, 5, 2, 4, 2, 4, 1, 0, 0, 5, 2, 0, 0, 6, 3, 0, 0, 7, 4, 0, 42, 8, 3, 1, 7, 0, 7, 5, 0, 0, 9, 6, 0, 42, 10, 3, 1, 9, 0, 9, 7, 0, 2, 11, 8, 0, 42, 12, 3, 1, 11, 0, 11, 9, 0, 2, 13, 10, 0, 42, 14, 3, 1, 13, 0, 13, 11, 0, 2, 15, 12, 0, 42, 16, 3, 1, 15, 0, 15, 13, 0, 0, 17, 14, 0, 42, 18, 3, 1, 17, 0, 17, 15, 0, 42, 19, 3, 1, 17, 48, 17, 2, 18, 19, 0, 18, 16, 0, 0, 19, 17, 0, 0, 20, 0, 0, 32, 21, 20, 42, 20, 3, 1, 21, 0, 21, 18, 0, 0, 22, 19, 0, 42, 23, 3, 1, 22, 49, 22, 2, 19, 20, 21, 23, 49, 19, 7, 6, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15, 17, 18, 22, 9, 4, 5, 19, 0, 4, 20, 0, 45, 4, 4, 2, 3, 3, 0, 5, 4, 3, 0, 5, 21, 0, 11, 6, 3, 5, 7, 0, 6, 48, 3, 2, 0, 4, 45, 3, 0, 3, 20, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 24; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/oracle.json new file mode 100644 index 00000000..22a54657 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/oracle.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-literals-order", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": { + "bool": [ + true, + 0 + ], + "number": [ + 42, + 1 + ], + "nan": [ + { + "type": "nan" + }, + 2 + ], + "infinity": [ + { + "type": "infinity" + }, + 3 + ], + "undef": [ + { + "type": "undefined" + }, + 4 + ], + "array": [ + [ + "a", + 5 + ], + [ + null, + 6 + ] + ], + "object": { + "x": [ + { + "type": "-0" + }, + 7 + ], + "y": [ + "z", + 8 + ] + } + }, + "expectedThrow": null, + "semanticTags": [ + "literals", + "nested-values", + "special-numbers", + "evaluation-order" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/source.js new file mode 100644 index 00000000..6bfe0b54 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/source.js @@ -0,0 +1,8 @@ +var index = 0; +function mark(value) { return [value, index++]; } +window.TEST_OUTPUT = { + bool: mark(true), number: mark(42), nan: mark(NaN), + infinity: mark(Infinity), undef: mark(undefined), + array: [mark("a"), mark(null)], + object: { x: mark(-0), y: mark("z") }, +}; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/encoded.js new file mode 100644 index 00000000..742ec7f8 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */0, /* 1 */6, /* 2 */2, /* 3 */5, /* 4 */"push", /* 5 */1, /* 6 */3, /* 7 */"window", /* 8 */"TEST_OUTPUT", /* 9 */undefined]; +var BYTECODE = [4, 1, 48, 7, 0, 5, 2, 7, 0, 7, 0, 0, 5, 3, 7, 0, 7, 1, 0, 22, 8, 3, 7, 40, 8, 82, 0, 7, 2, 0, 26, 8, 3, 7, 40, 8, 39, 39, 66, 0, 7, 3, 0, 26, 8, 3, 7, 40, 8, 52, 39, 82, 0, 7, 4, 0, 8, 8, 2, 7, 43, 7, 2, 8, 1, 3, 5, 7, 3, 0, 7, 5, 0, 11, 8, 3, 7, 5, 3, 8, 39, 15, 0, 7, 0, 0, 5, 4, 7, 0, 7, 0, 0, 5, 5, 7, 0, 7, 6, 0, 22, 8, 5, 7, 40, 8, 184, 0, 7, 0, 0, 5, 6, 7, 0, 7, 6, 0, 22, 8, 6, 7, 40, 8, 168, 5, 7, 4, 0, 7, 5, 0, 11, 8, 4, 7, 5, 4, 8, 0, 7, 5, 0, 26, 8, 6, 7, 40, 8, 152, 39, 168, 5, 7, 6, 0, 7, 5, 0, 11, 8, 6, 7, 5, 6, 8, 39, 114, 5, 7, 5, 0, 7, 5, 0, 11, 8, 5, 7, 5, 5, 8, 39, 96, 2, 7, 7, 0, 0, 8, 8, 0, 48, 9, 2, 2, 4, 9, 7, 8, 9, 0, 7, 9, 0, 45, 7]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 10; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/oracle.json new file mode 100644 index 00000000..84b7c47a --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/oracle.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-loop-abrupt", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + [ + 0, + 1, + 3, + 4 + ], + 6 + ], + "expectedThrow": null, + "semanticTags": [ + "break", + "continue", + "nested-loop", + "abrupt-completion" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/source.js new file mode 100644 index 00000000..21c008e6 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-abrupt/source.js @@ -0,0 +1,5 @@ +var values = []; +for (var i = 0; i < 6; i++) { if (i === 2) continue; if (i === 5) break; values.push(i); } +var nested = 0; +for (var r = 0; r < 3; r++) { for (var c = 0; c < 3; c++) { nested++; if (c === 1) break; } } +window.TEST_OUTPUT = [values, nested]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/encoded.js new file mode 100644 index 00000000..1268d01e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */0, /* 1 */1, /* 2 */5, /* 3 */2, /* 4 */3, /* 5 */"window", /* 6 */"TEST_OUTPUT", /* 7 */undefined]; +var BYTECODE = [4, 1, 0, 7, 0, 0, 5, 2, 7, 0, 7, 1, 0, 5, 3, 7, 0, 7, 2, 0, 24, 8, 3, 7, 40, 8, 50, 11, 7, 2, 3, 5, 2, 7, 5, 7, 3, 0, 7, 1, 0, 11, 8, 3, 7, 5, 3, 8, 39, 16, 0, 7, 0, 0, 5, 4, 7, 0, 7, 0, 0, 5, 5, 7, 5, 7, 5, 0, 7, 1, 0, 11, 8, 5, 7, 5, 5, 8, 5, 7, 4, 0, 7, 1, 0, 11, 8, 4, 7, 5, 4, 8, 0, 7, 3, 0, 22, 8, 4, 7, 40, 8, 105, 39, 64, 0, 7, 0, 0, 5, 6, 7, 0, 7, 4, 0, 22, 8, 6, 7, 40, 8, 139, 5, 7, 6, 0, 7, 1, 0, 11, 8, 6, 7, 5, 6, 8, 39, 112, 2, 7, 5, 0, 0, 8, 6, 0, 48, 9, 3, 2, 5, 6, 9, 7, 8, 9, 0, 7, 7, 0, 45, 7]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 10; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/oracle.json new file mode 100644 index 00000000..af9aa41d --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/oracle.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-loop-forms", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 15, + 2, + 3 + ], + "expectedThrow": null, + "semanticTags": [ + "for", + "while", + "do-while", + "loop-boundary" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/source.js new file mode 100644 index 00000000..079a4ba0 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-loop-forms/source.js @@ -0,0 +1,4 @@ +var sum = 0; for (var i = 1; i <= 5; i++) sum += i; +var j = 0; var doCount = 0; do { doCount++; j++; } while (j < 2); +var w = 0; while (w < 3) w++; +window.TEST_OUTPUT = [sum, doCount, w]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/encoded.js new file mode 100644 index 00000000..1e442e69 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"RegExp", /* 1 */"a", /* 2 */"g", /* 3 */"test", /* 4 */"lastIndex", /* 5 */"x", /* 6 */"window", /* 7 */"TEST_OUTPUT", /* 8 */undefined]; +var BYTECODE = [4, 1, 2, 8, 0, 0, 0, 9, 1, 0, 0, 10, 2, 0, 44, 11, 8, 2, 9, 10, 5, 2, 11, 0, 8, 3, 0, 8, 9, 2, 8, 0, 8, 1, 0, 43, 10, 2, 9, 1, 8, 5, 3, 10, 0, 8, 4, 0, 8, 9, 2, 8, 5, 4, 9, 0, 8, 3, 0, 8, 9, 2, 8, 0, 8, 1, 0, 43, 10, 2, 9, 1, 8, 5, 5, 10, 0, 8, 4, 0, 8, 9, 2, 8, 5, 6, 9, 2, 8, 0, 0, 0, 9, 5, 0, 0, 10, 2, 0, 44, 11, 8, 2, 9, 10, 0, 8, 3, 0, 8, 9, 11, 8, 0, 8, 5, 0, 43, 10, 11, 9, 1, 8, 2, 8, 0, 0, 0, 9, 5, 0, 0, 11, 2, 0, 44, 12, 8, 2, 9, 11, 0, 8, 3, 0, 8, 9, 12, 8, 0, 8, 5, 0, 43, 11, 12, 9, 1, 8, 48, 8, 2, 10, 11, 5, 7, 8, 2, 8, 6, 0, 0, 9, 7, 0, 48, 10, 5, 3, 4, 5, 6, 7, 9, 8, 9, 10, 0, 8, 8, 0, 45, 8]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 13; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/oracle.json new file mode 100644 index 00000000..88a8ddea --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/oracle.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-regexp-state", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + true, + 1, + false, + 0, + [ + true, + true + ] + ], + "expectedThrow": null, + "semanticTags": [ + "regexp", + "state", + "lastIndex", + "fresh-literal" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/source.js new file mode 100644 index 00000000..f0caa611 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-regexp-state/source.js @@ -0,0 +1,7 @@ +var re = /a/g; +var first = re.test("a"); +var afterFirst = re.lastIndex; +var second = re.test("a"); +var afterSecond = re.lastIndex; +var fresh = [/x/g.test("x"), /x/g.test("x")]; +window.TEST_OUTPUT = [first, afterFirst, second, afterSecond, fresh]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/encoded.js new file mode 100644 index 00000000..046044ab --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */false, /* 1 */"and", /* 2 */true, /* 3 */"or", /* 4 */null, /* 5 */"null", /* 6 */0, /* 7 */"zero", /* 8 */"window", /* 9 */"TEST_OUTPUT", /* 10 */"values", /* 11 */"log", /* 12 */undefined, /* 13 */"push"]; +var BYTECODE = [4, 1, 47, 8, 176, 1, 6, 1, 0, 1, 2, 5, 3, 8, 48, 8, 0, 5, 2, 8, 0, 8, 0, 0, 5, 9, 8, 40, 9, 42, 0, 8, 1, 0, 42, 10, 3, 1, 8, 5, 9, 10, 5, 4, 9, 0, 8, 2, 0, 5, 9, 8, 41, 9, 67, 0, 8, 3, 0, 42, 10, 3, 1, 8, 5, 9, 10, 5, 5, 9, 0, 8, 4, 0, 5, 9, 8, 0, 8, 4, 0, 28, 10, 9, 8, 40, 10, 100, 0, 8, 5, 0, 42, 10, 3, 1, 8, 5, 9, 10, 5, 6, 9, 0, 8, 6, 0, 5, 9, 8, 0, 8, 4, 0, 28, 10, 9, 8, 40, 10, 133, 0, 8, 7, 0, 42, 10, 3, 1, 8, 5, 9, 10, 5, 7, 9, 2, 8, 8, 0, 0, 9, 9, 0, 0, 10, 10, 0, 48, 11, 4, 4, 5, 6, 7, 0, 12, 11, 0, 49, 13, 2, 10, 11, 12, 2, 9, 8, 9, 13, 0, 8, 12, 0, 45, 8, 4, 2, 3, 3, 0, 0, 4, 13, 0, 8, 5, 3, 4, 43, 4, 3, 5, 1, 0, 45, 0, 0, 3, 12, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 14; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/oracle.json new file mode 100644 index 00000000..eb303182 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/oracle.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-short-circuit", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": { + "values": [ + false, + true, + "null", + 0 + ], + "log": [ + "null" + ] + }, + "expectedThrow": null, + "semanticTags": [ + "short-circuit", + "nullish", + "evaluation-order", + "side-effects" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/source.js new file mode 100644 index 00000000..e9c3774c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-short-circuit/source.js @@ -0,0 +1,7 @@ +var log = []; +function hit(value) { log.push(value); return value; } +var andValue = false && hit("and"); +var orValue = true || hit("or"); +var nullValue = null ?? hit("null"); +var zeroValue = 0 ?? hit("zero"); +window.TEST_OUTPUT = { values: [andValue, orValue, nullValue, zeroValue], log: log }; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js new file mode 100644 index 00000000..1ea3ca16 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */1, /* 1 */2, /* 2 */"a", /* 3 */"b", /* 4 */"Object", /* 5 */"assign", /* 6 */3, /* 7 */"window", /* 8 */"TEST_OUTPUT", /* 9 */"concat", /* 10 */undefined]; +var BYTECODE = [4, 1, 47, 5, 157, 3, 7, 0, 0, 5, 2, 5, 0, 5, 0, 0, 0, 6, 1, 0, 48, 7, 2, 5, 6, 5, 3, 7, 49, 5, 0, 0, 6, 2, 0, 0, 7, 0, 0, 9, 5, 6, 7, 0, 6, 3, 0, 0, 7, 1, 0, 9, 5, 6, 7, 2, 6, 4, 0, 0, 7, 5, 0, 8, 8, 6, 7, 0, 7, 2, 0, 0, 9, 6, 0, 49, 10, 1, 7, 9, 43, 7, 6, 8, 2, 5, 10, 5, 4, 5, 2, 5, 7, 0, 0, 6, 8, 0, 48, 7, 0, 0, 8, 9, 0, 8, 9, 7, 8, 43, 8, 7, 9, 1, 3, 0, 7, 9, 0, 8, 9, 8, 7, 0, 7, 6, 0, 48, 10, 1, 7, 43, 7, 8, 9, 1, 10, 42, 8, 2, 65535, 7, 48, 7, 2, 8, 4, 9, 5, 6, 7, 0, 5, 10, 0, 45, 5, 4, 4, 11, 5, 0, 1, 11, 6, 5, 2, 45, 6, 0, 5, 10, 0, 45, 5]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 11; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/oracle.json new file mode 100644 index 00000000..39efba9e --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/oracle.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-spread-order", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 6, + { + "a": 3, + "b": 2 + } + ], + "expectedThrow": null, + "semanticTags": [ + "spread", + "call-arguments", + "object-spread", + "overwrite-order" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/source.js new file mode 100644 index 00000000..1f39dcd2 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/source.js @@ -0,0 +1,4 @@ +function sum(a, b, c) { return a + b + c; } +var values = [1, 2]; +var merged = { a: 1, b: 2, ...{ a: 3 } }; +window.TEST_OUTPUT = [sum(...values, 3), merged]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/encoded.js new file mode 100644 index 00000000..9a2c80dd --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */2, /* 1 */1, /* 2 */3, /* 3 */"push", /* 4 */"one", /* 5 */"two", /* 6 */"three", /* 7 */"default", /* 8 */"window", /* 9 */"TEST_OUTPUT", /* 10 */undefined]; +var BYTECODE = [4, 1, 48, 3, 0, 5, 2, 3, 0, 3, 0, 0, 0, 4, 1, 0, 26, 5, 3, 4, 40, 5, 25, 39, 53, 0, 4, 0, 0, 26, 5, 3, 4, 40, 5, 38, 39, 73, 0, 4, 2, 0, 26, 5, 3, 4, 40, 5, 51, 39, 91, 39, 111, 0, 3, 3, 0, 8, 4, 2, 3, 0, 3, 4, 0, 43, 5, 2, 4, 1, 3, 39, 129, 0, 3, 3, 0, 8, 4, 2, 3, 0, 3, 5, 0, 43, 5, 2, 4, 1, 3, 0, 3, 3, 0, 8, 4, 2, 3, 0, 3, 6, 0, 43, 5, 2, 4, 1, 3, 39, 129, 0, 3, 3, 0, 8, 4, 2, 3, 0, 3, 7, 0, 43, 5, 2, 4, 1, 3, 2, 3, 8, 0, 0, 4, 9, 0, 9, 3, 4, 2, 0, 3, 10, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 6; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/oracle.json new file mode 100644 index 00000000..b01879a5 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/oracle.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-switch-flow", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + "two", + "three" + ], + "expectedThrow": null, + "semanticTags": [ + "switch", + "fall-through", + "case-selection" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/source.js new file mode 100644 index 00000000..b48916eb --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-switch-flow/source.js @@ -0,0 +1,3 @@ +var out = []; +switch (2) { case 1: out.push("one"); break; case 2: out.push("two"); case 3: out.push("three"); break; default: out.push("default"); } +window.TEST_OUTPUT = out; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/encoded.js new file mode 100644 index 00000000..f093161a --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */"Hello ", /* 3 */"World", /* 4 */"!", /* 5 */"", /* 6 */1, /* 7 */2, /* 8 */"just a string", /* 9 */undefined]; +var BYTECODE = [4, 1, 2, 2, 0, 0, 0, 3, 1, 0, 0, 4, 2, 0, 0, 5, 3, 0, 11, 6, 4, 5, 0, 4, 4, 0, 11, 5, 6, 4, 0, 4, 5, 0, 0, 6, 6, 0, 0, 7, 7, 0, 11, 8, 6, 7, 11, 6, 4, 8, 0, 4, 5, 0, 11, 7, 6, 4, 0, 4, 8, 0, 48, 6, 3, 5, 7, 4, 9, 2, 3, 6, 0, 2, 9, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 9; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/oracle.json new file mode 100644 index 00000000..fc94389b --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/oracle.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-template-literals", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + "Hello World!", + "3", + "just a string" + ], + "expectedThrow": null, + "semanticTags": [ + "template-literal", + "interpolation", + "es6-limited" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/source.js new file mode 100644 index 00000000..a37ea344 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-template-literals/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = [`Hello ${"World"}!`, `${1 + 2}`, `just a string`]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/encoded.js new file mode 100644 index 00000000..6467c9e9 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"call", /* 1 */"base", /* 2 */1, /* 3 */5, /* 4 */"apply", /* 5 */2, /* 6 */7, /* 7 */"bind", /* 8 */3, /* 9 */"window", /* 10 */"TEST_OUTPUT", /* 11 */4, /* 12 */undefined]; +var BYTECODE = [4, 1, 47, 6, 149, 1, 5, 0, 0, 5, 2, 6, 0, 6, 0, 0, 8, 7, 2, 6, 0, 6, 1, 0, 0, 8, 2, 0, 49, 9, 1, 6, 8, 0, 6, 3, 0, 43, 8, 2, 7, 2, 9, 6, 5, 3, 8, 0, 6, 4, 0, 8, 7, 2, 6, 0, 6, 1, 0, 0, 8, 5, 0, 49, 9, 1, 6, 8, 0, 6, 6, 0, 48, 8, 1, 6, 43, 6, 2, 7, 2, 9, 8, 5, 4, 6, 0, 6, 7, 0, 8, 7, 2, 6, 0, 6, 1, 0, 0, 8, 8, 0, 49, 9, 1, 6, 8, 43, 6, 2, 7, 1, 9, 5, 5, 6, 2, 6, 9, 0, 0, 7, 10, 0, 0, 8, 11, 0, 42, 9, 5, 1, 8, 48, 8, 3, 3, 4, 9, 9, 6, 7, 8, 0, 6, 12, 0, 45, 6, 4, 2, 0, 3, 1, 0, 8, 4, 2, 3, 11, 3, 4, 0, 45, 3, 0, 3, 12, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 10; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/oracle.json new file mode 100644 index 00000000..27227a47 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/oracle.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-this-explicit", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 6, + 9, + 7 + ], + "expectedThrow": null, + "semanticTags": [ + "call", + "apply", + "bind", + "receiver" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/source.js new file mode 100644 index 00000000..ae6ccb79 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-explicit/source.js @@ -0,0 +1,5 @@ +function add(value) { return this.base + value; } +var first = add.call({ base: 1 }, 5); +var second = add.apply({ base: 2 }, [7]); +var bound = add.bind({ base: 3 }); +window.TEST_OUTPUT = [first, second, bound(4)]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/encoded.js new file mode 100644 index 00000000..f722ffdc --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"prototype", /* 1 */"get", /* 2 */7, /* 3 */"value", /* 4 */3, /* 5 */"window", /* 6 */"TEST_OUTPUT", /* 7 */"call", /* 8 */9, /* 9 */undefined]; +var BYTECODE = [4, 1, 47, 5, 161, 1, 4, 0, 0, 5, 2, 5, 0, 5, 0, 0, 8, 6, 2, 5, 0, 5, 1, 0, 47, 7, 177, 0, 4, 0, 0, 9, 6, 5, 7, 0, 5, 2, 0, 44, 6, 2, 1, 5, 5, 3, 6, 0, 5, 3, 0, 0, 6, 4, 0, 0, 7, 1, 0, 47, 8, 195, 0, 4, 0, 0, 49, 9, 2, 5, 6, 7, 8, 5, 4, 9, 2, 5, 5, 0, 0, 6, 6, 0, 0, 7, 1, 0, 8, 8, 3, 7, 43, 7, 3, 8, 0, 0, 8, 1, 0, 8, 9, 4, 8, 43, 8, 4, 9, 0, 0, 9, 1, 0, 8, 10, 4, 9, 0, 9, 7, 0, 8, 11, 10, 9, 0, 9, 3, 0, 0, 12, 8, 0, 49, 13, 1, 9, 12, 43, 9, 10, 11, 1, 13, 48, 10, 3, 7, 8, 9, 9, 5, 6, 10, 0, 5, 9, 0, 45, 5, 4, 2, 0, 3, 3, 0, 9, 2, 3, 0, 0, 3, 9, 0, 45, 3, 4, 1, 0, 2, 3, 0, 8, 3, 1, 2, 45, 3, 0, 2, 9, 0, 45, 2, 4, 1, 0, 2, 3, 0, 8, 3, 1, 2, 45, 3, 0, 2, 9, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 14; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/oracle.json new file mode 100644 index 00000000..6d48b32b --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/oracle.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-this-receiver", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": [ + 7, + 3, + 9 + ], + "expectedThrow": null, + "semanticTags": [ + "this", + "new", + "prototype", + "method-call" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/source.js new file mode 100644 index 00000000..e979c647 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-this-receiver/source.js @@ -0,0 +1,5 @@ +function Box(value) { this.value = value; } +Box.prototype.get = function () { return this.value; }; +var box = new Box(7); +var object = { value: 3, get: function () { return this.value; } }; +window.TEST_OUTPUT = [box.get(), object.get(), object.get.call({ value: 9 })]; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/encoded.js new file mode 100644 index 00000000..733eb79f --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"boom", /* 1 */"push", /* 2 */"type", /* 3 */"value", /* 4 */"Error", /* 5 */"bad", /* 6 */"name", /* 7 */"message", /* 8 */"kind", /* 9 */"uncaught", /* 10 */"caught", /* 11 */undefined]; +var BYTECODE = [4, 1, 48, 4, 0, 5, 2, 4, 54, 20, 3, 0, 4, 0, 0, 46, 4, 55, 39, 52, 0, 4, 1, 0, 8, 5, 2, 4, 0, 4, 2, 0, 36, 6, 3, 0, 7, 3, 0, 49, 8, 2, 4, 6, 7, 3, 43, 4, 2, 5, 1, 8, 54, 73, 3, 2, 4, 4, 0, 0, 5, 5, 0, 44, 6, 4, 1, 5, 46, 6, 55, 39, 118, 0, 4, 1, 0, 8, 5, 2, 4, 0, 4, 2, 0, 0, 6, 6, 0, 8, 7, 3, 6, 0, 6, 7, 0, 0, 8, 7, 0, 8, 9, 3, 8, 49, 8, 2, 4, 7, 6, 9, 43, 4, 2, 5, 1, 8, 0, 4, 8, 0, 0, 5, 9, 0, 0, 6, 10, 0, 49, 7, 2, 4, 5, 6, 2, 46, 7, 0, 4, 11, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 10; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/oracle.json new file mode 100644 index 00000000..96939465 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/oracle.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "f-throw-catch", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [], + "timeoutMs": 2000, + "expected": null, + "expectedThrow": { + "type": "primitive", + "value": { + "kind": "uncaught", + "caught": [ + { + "type": "string", + "value": "boom" + }, + { + "type": "Error", + "message": "bad" + } + ] + } + }, + "semanticTags": [ + "throw", + "catch", + "uncaught", + "primitive-error", + "error-object" + ], + "hazards": [ + "The cell intentionally has no successful TEST_OUTPUT; source/raw/stripped must agree on the exact tagged uncaught object and its caught evidence.", + "Error stack text is excluded; only the stable Error name/message and primitive caught value are retained." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "tagged-throw" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/source.js new file mode 100644 index 00000000..58ffab3c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/f-throw-catch/source.js @@ -0,0 +1,4 @@ +var result = []; +try { throw "boom"; } catch (error) { result.push({ type: typeof error, value: error }); } +try { throw new Error("bad"); } catch (error) { result.push({ type: error.name, message: error.message }); } +throw { kind: "uncaught", caught: result }; diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/encoded.js new file mode 100644 index 00000000..9c27b19b --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */undefined, /* 1 */"document", /* 2 */"window", /* 3 */"documentElement", /* 4 */"createElement", /* 5 */"bind", /* 6 */"div", /* 7 */"table", /* 8 */"tbody", /* 9 */"tr", /* 10 */"Array", /* 11 */"isArray", /* 12 */"prototype", /* 13 */"concat", /* 14 */"filter", /* 15 */"indexOf", /* 16 */"map", /* 17 */"push", /* 18 */"slice", /* 19 */"some", /* 20 */"splice", /* 21 */"RegExp", /* 22 */"^#(?:[\\w-]|\\\\.|[^\\x00-\\xa0])*$", /* 23 */"", /* 24 */"^\\.(?:[\\w-]|\\\\.|[^\\x00-\\xa0])*$", /* 25 */"<.+>", /* 26 */"^\\w+$", /* 27 */"init", /* 28 */"fn", /* 29 */"length", /* 30 */0, /* 31 */"Symbol", /* 32 */"function", /* 33 */"iterator", /* 34 */"isWindow", /* 35 */"isFunction", /* 36 */"isNumeric", /* 37 */"isPlainObject", /* 38 */"each", /* 39 */"empty", /* 40 */"extend", /* 41 */"\\S+", /* 42 */"g", /* 43 */"toggleClass", /* 44 */"addClass", /* 45 */"removeAttr", /* 46 */"attr", /* 47 */"removeClass", /* 48 */"hasClass", /* 49 */"get", /* 50 */"eq", /* 51 */"first", /* 52 */"last", /* 53 */"text", /* 54 */"detach", /* 55 */"^\\s*<(\\w+)[^>]*>", /* 56 */"^<(\\w+)\\s*\\/?>(?:<\\/\\1>)?$", /* 57 */"*", /* 58 */"td", /* 59 */"th", /* 60 */"thead", /* 61 */"tfoot", /* 62 */"parseHTML", /* 63 */"has", /* 64 */"not", /* 65 */"val", /* 66 */"is", /* 67 */"guid", /* 68 */1, /* 69 */"unique", /* 70 */"add", /* 71 */"children", /* 72 */"parent", /* 73 */"index", /* 74 */"closest", /* 75 */"siblings", /* 76 */"find", /* 77 */"^\\s*\\s*$", /* 78 */"^$|^module$|\\/(java|ecma)script", /* 79 */"i", /* 80 */"type", /* 81 */"src", /* 82 */"nonce", /* 83 */"noModule", /* 84 */"after", /* 85 */"append", /* 86 */"html", /* 87 */"appendTo", /* 88 */"wrapInner", /* 89 */"before", /* 90 */"wrapAll", /* 91 */"wrap", /* 92 */"insertAfter", /* 93 */"insertBefore", /* 94 */"prepend", /* 95 */"prependTo", /* 96 */"contents", /* 97 */"next", /* 98 */"nextAll", /* 99 */"nextUntil", /* 100 */"parents", /* 101 */"parentsUntil", /* 102 */"prev", /* 103 */"prevAll", /* 104 */"prevUntil", /* 105 */"clone", /* 106 */"offsetParent", /* 107 */"-([a-z])", /* 108 */"ready", /* 109 */"unwrap", /* 110 */"offset", /* 111 */"position", /* 112 */"class", /* 113 */"className", /* 114 */"contenteditable", /* 115 */"contentEditable", /* 116 */"for", /* 117 */"htmlFor", /* 118 */"readonly", /* 119 */"readOnly", /* 120 */"maxlength", /* 121 */"maxLength", /* 122 */"tabindex", /* 123 */"tabIndex", /* 124 */"colspan", /* 125 */"colSpan", /* 126 */"rowspan", /* 127 */"rowSpan", /* 128 */"usemap", /* 129 */"useMap", /* 130 */"prop", /* 131 */"removeProp", /* 132 */"^--", /* 133 */"style", /* 134 */"webkit", /* 135 */"moz", /* 136 */"ms", /* 137 */"animationIterationCount", /* 138 */"columnCount", /* 139 */"flexGrow", /* 140 */"flexShrink", /* 141 */"fontWeight", /* 142 */"gridArea", /* 143 */"gridColumn", /* 144 */"gridColumnEnd", /* 145 */"gridColumnStart", /* 146 */"gridRow", /* 147 */"gridRowEnd", /* 148 */"gridRowStart", /* 149 */"lineHeight", /* 150 */"opacity", /* 151 */"order", /* 152 */"orphans", /* 153 */"widows", /* 154 */"zIndex", /* 155 */"css", /* 156 */"^\\s+|\\s+$", /* 157 */"data", /* 158 */"Width", /* 159 */"Height", /* 160 */"___cd", /* 161 */"toggle", /* 162 */"hide", /* 163 */"show", /* 164 */"___ce", /* 165 */".", /* 166 */"focus", /* 167 */"focusin", /* 168 */"blur", /* 169 */"focusout", /* 170 */"mouseenter", /* 171 */"mouseover", /* 172 */"mouseleave", /* 173 */"mouseout", /* 174 */"^(mouse|pointer|contextmenu|drag|drop|click|dblclick)", /* 175 */"trigger", /* 176 */"off", /* 177 */"remove", /* 178 */"replaceWith", /* 179 */"replaceAll", /* 180 */"on", /* 181 */"one", /* 182 */"\\r?\\n", /* 183 */"file|reset|submit|button|image", /* 184 */"radio|checkbox", /* 185 */"serialize", /* 186 */"exports", /* 187 */"u", /* 188 */"module", /* 189 */"cash", /* 190 */"$", /* 191 */"test", /* 192 */"getElementsByClassName", /* 193 */"replace", /* 194 */"\\\\", /* 195 */"getElementsByTagName", /* 196 */"querySelectorAll", /* 197 */"nodeType", /* 198 */9, /* 199 */11, /* 200 */3, /* 201 */"boolean", /* 202 */"string", /* 203 */null, /* 204 */"isNaN", /* 205 */"parseFloat", /* 206 */"isFinite", /* 207 */"object", /* 208 */"Object", /* 209 */"getPrototypeOf", /* 210 */"call", /* 211 */"keys", /* 212 */"shift", /* 213 */"constructor", /* 214 */"match", /* 215 */2, /* 216 */"getAttribute", /* 217 */"setAttribute", /* 218 */"join", /* 219 */"textContent", /* 220 */"getComputedStyle", /* 221 */"getPropertyValue", /* 222 */"parseInt", /* 223 */10, /* 224 */"border", /* 225 */"Left", /* 226 */"Top", /* 227 */"padding", /* 228 */"Right", /* 229 */"Bottom", /* 230 */"body", /* 231 */"display", /* 232 */"removeChild", /* 233 */"none", /* 234 */"block", /* 235 */"matches", /* 236 */"webkitMatchesSelector", /* 237 */"msMatchesSelector", /* 238 */"$1", /* 239 */"innerHTML", /* 240 */"childNodes", /* 241 */"apply", /* 242 */"multiple", /* 243 */"options", /* 244 */"value", /* 245 */"selected", /* 246 */"disabled", /* 247 */"parentNode", /* 248 */"String", /* 249 */"checked", /* 250 */"script", /* 251 */"contains", /* 252 */"head", /* 253 */"firstChild", /* 254 */"nodeName", /* 255 */"HTML", /* 256 */"replaceChild", /* 257 */"nextSibling", /* 258 */"ownerDocument", /* 259 */"cloneNode", /* 260 */"]", /* 261 */"toUpperCase", /* 262 */" ", /* 263 */"split", /* 264 */"px", /* 265 */"setProperty", /* 266 */"dataset", /* 267 */"JSON", /* 268 */"parse", /* 269 */"stringify", /* 270 */"Math", /* 271 */"max", /* 272 */"scroll", /* 273 */"client", /* 274 */"sort", /* 275 */"addEventListener", /* 276 */"removeEventListener", /* 277 */"target", /* 278 */"___i", /* 279 */"stopImmediatePropagation", /* 280 */"namespace", /* 281 */"___ot", /* 282 */"relatedTarget", /* 283 */"defineProperty", /* 284 */"currentTarget", /* 285 */"configurable", /* 286 */"delegateTarget", /* 287 */"___td", /* 288 */"preventDefault", /* 289 */"stopPropagation", /* 290 */"&", /* 291 */"encodeURIComponent", /* 292 */"=", /* 293 */"\r\n", /* 294 */"getElementById", /* 295 */"classList", /* 296 */"removeAttribute", /* 297 */"Number", /* 298 */"firstElementChild", /* 299 */"tagName", /* 300 */"IFRAME", /* 301 */"contentDocument", /* 302 */"TEMPLATE", /* 303 */"content", /* 304 */"nextElementSibling", /* 305 */"parentElement", /* 306 */"previousElementSibling", /* 307 */"static", /* 308 */"readyState", /* 309 */"loading", /* 310 */"DOMContentLoaded", /* 311 */"setTimeout", /* 312 */"BODY", /* 313 */"getBoundingClientRect", /* 314 */"top", /* 315 */"pageYOffset", /* 316 */"left", /* 317 */"pageXOffset", /* 318 */"fixed", /* 319 */"borderTopWidth", /* 320 */"borderLeftWidth", /* 321 */"marginTop", /* 322 */"marginLeft", /* 323 */"outer", /* 324 */"inner", /* 325 */"margin", /* 326 */"toLowerCase", /* 327 */"boxSizing", /* 328 */"border-box", /* 329 */"MouseEvents", /* 330 */"HTMLEvents", /* 331 */"createEvent", /* 332 */"initEvent", /* 333 */"dispatchEvent", /* 334 */"elements", /* 335 */"name", /* 336 */"FIELDSET"]; +var BYTECODE = [4, 1, 47, 2, 19, 0, 144, 0, 0, 42, 3, 2, 0, 0, 2, 0, 0, 45, 2, 4, 1, 47, 107, 3529, 2, 13, 5, 0, 1, 31, 1, 30, 1, 32, 1, 21, 1, 23, 5, 24, 107, 47, 107, 3796, 1, 5, 1, 0, 1, 25, 5, 28, 107, 47, 107, 3813, 1, 6, 0, 0, 5, 29, 107, 47, 107, 3850, 1, 7, 0, 0, 5, 30, 107, 47, 107, 3891, 1, 7, 0, 0, 5, 31, 107, 47, 107, 3932, 1, 7, 0, 0, 5, 32, 107, 47, 107, 3973, 1, 7, 0, 0, 5, 33, 107, 47, 107, 4014, 1, 6, 0, 0, 5, 34, 107, 47, 107, 4035, 1, 6, 0, 0, 5, 35, 107, 47, 107, 4056, 1, 6, 0, 0, 5, 36, 107, 47, 107, 4077, 1, 5, 0, 0, 5, 37, 107, 47, 107, 4098, 1, 5, 0, 0, 5, 38, 107, 47, 107, 4116, 1, 6, 0, 0, 5, 39, 107, 47, 107, 4165, 1, 8, 0, 0, 5, 40, 107, 47, 107, 4269, 3, 13, 1, 0, 1, 40, 5, 41, 107, 47, 107, 4557, 0, 16, 5, 0, 1, 34, 1, 42, 1, 27, 1, 10, 1, 40, 5, 42, 107, 47, 107, 4929, 1, 6, 2, 0, 1, 36, 1, 43, 5, 44, 107, 47, 107, 4990, 2, 12, 4, 0, 1, 36, 1, 32, 1, 38, 1, 37, 5, 45, 107, 47, 107, 5292, 1, 7, 3, 0, 1, 37, 1, 32, 1, 33, 5, 46, 107, 47, 107, 5496, 3, 10, 2, 0, 1, 32, 1, 3, 5, 47, 107, 47, 107, 5608, 2, 8, 1, 0, 1, 47, 5, 48, 107, 47, 107, 5654, 2, 11, 1, 0, 1, 48, 5, 49, 107, 47, 107, 5870, 1, 9, 4, 0, 1, 50, 1, 5, 1, 2, 1, 47, 5, 51, 107, 47, 107, 6011, 1, 6, 1, 0, 1, 47, 5, 52, 107, 47, 107, 6042, 2, 9, 0, 0, 5, 53, 107, 47, 107, 6151, 1, 8, 4, 0, 1, 36, 1, 53, 1, 35, 1, 28, 5, 54, 107, 47, 107, 6348, 2, 6, 0, 0, 5, 55, 107, 47, 107, 6383, 1, 9, 6, 0, 1, 36, 1, 57, 1, 5, 1, 56, 1, 58, 1, 27, 5, 59, 107, 47, 107, 6579, 4, 17, 3, 0, 1, 35, 1, 54, 1, 16, 5, 60, 107, 47, 107, 6813, 1, 9, 2, 0, 1, 60, 1, 13, 5, 61, 107, 47, 107, 6992, 1, 8, 7, 0, 1, 106, 1, 10, 1, 15, 1, 38, 1, 41, 1, 37, 1, 61, 5, 62, 107, 47, 107, 7444, 1, 7, 2, 0, 1, 13, 1, 14, 5, 63, 107, 47, 107, 7540, 2, 10, 7, 0, 1, 27, 1, 65, 1, 4, 1, 5, 1, 64, 1, 41, 1, 66, 5, 67, 107, 47, 107, 7875, 5, 14, 1, 0, 1, 67, 5, 68, 107, 47, 107, 8050, 8, 13, 3, 0, 1, 41, 1, 27, 1, 68, 5, 69, 107, 47, 107, 8319, 1, 8, 3, 0, 1, 37, 1, 32, 1, 27, 5, 70, 107, 47, 107, 8543, 1, 7, 1, 0, 1, 71, 5, 72, 107, 47, 107, 8601, 1, 6, 1, 0, 1, 74, 5, 75, 107, 47, 107, 8628, 2, 15, 6, 0, 1, 75, 1, 76, 1, 72, 1, 78, 1, 41, 1, 77, 5, 79, 107, 47, 107, 8950, 3, 9, 3, 0, 1, 75, 1, 80, 1, 39, 5, 81, 107, 47, 107, 9064, 2, 11, 6, 0, 1, 36, 1, 75, 1, 79, 1, 47, 1, 81, 1, 32, 5, 82, 107, 47, 107, 9342, 2, 6, 0, 0, 5, 83, 107, 47, 107, 9366, 2, 10, 3, 0, 1, 72, 1, 84, 1, 83, 5, 85, 107, 47, 107, 9469, 3, 9, 2, 0, 1, 83, 1, 72, 5, 86, 107, 47, 107, 9521, 2, 13, 4, 0, 1, 85, 1, 36, 1, 37, 1, 86, 5, 87, 107, 47, 107, 9789, 2, 14, 0, 0, 5, 88, 107, 47, 107, 9958, 1, 6, 2, 0, 1, 93, 1, 92, 5, 95, 107, 47, 107, 10000, 1, 8, 1, 0, 1, 91, 5, 96, 107, 47, 107, 10074, 1, 6, 1, 0, 1, 90, 5, 97, 107, 47, 107, 10110, 5, 12, 1, 0, 1, 97, 5, 98, 107, 47, 107, 10188, 2, 9, 1, 0, 1, 18, 5, 99, 107, 47, 107, 10275, 5, 14, 3, 0, 1, 97, 1, 99, 1, 100, 5, 100, 107, 47, 107, 10538, 5, 14, 19, 0, 1, 36, 1, 37, 1, 38, 1, 35, 1, 41, 1, 44, 1, 96, 1, 95, 1, 93, 1, 92, 1, 32, 1, 30, 1, 29, 1, 99, 1, 91, 1, 53, 1, 100, 1, 27, 1, 98, 5, 101, 107, 47, 107, 11804, 4, 9, 0, 0, 5, 102, 107, 47, 107, 11839, 2, 11, 1, 0, 1, 103, 5, 104, 107, 2, 107, 1, 0, 5, 2, 107, 2, 107, 2, 0, 5, 3, 107, 0, 107, 3, 0, 8, 108, 2, 107, 5, 4, 108, 0, 107, 4, 0, 8, 108, 2, 107, 0, 107, 5, 0, 8, 109, 108, 107, 43, 107, 108, 109, 1, 2, 5, 5, 107, 0, 107, 6, 0, 42, 108, 5, 1, 107, 5, 6, 108, 0, 107, 7, 0, 42, 108, 5, 1, 107, 5, 7, 108, 0, 107, 8, 0, 42, 108, 5, 1, 107, 5, 8, 108, 0, 107, 9, 0, 42, 108, 5, 1, 107, 5, 9, 108, 2, 107, 10, 0, 0, 108, 11, 0, 8, 109, 107, 108, 5, 10, 109, 2, 107, 10, 0, 0, 108, 12, 0, 8, 109, 107, 108, 5, 11, 109, 0, 107, 13, 0, 8, 108, 11, 107, 5, 12, 108, 0, 107, 14, 0, 8, 108, 11, 107, 5, 13, 108, 0, 107, 15, 0, 8, 108, 11, 107, 5, 14, 108, 0, 107, 16, 0, 8, 108, 11, 107, 5, 15, 108, 0, 107, 17, 0, 8, 108, 11, 107, 5, 16, 108, 0, 107, 18, 0, 8, 108, 11, 107, 5, 17, 108, 0, 107, 19, 0, 8, 108, 11, 107, 5, 18, 108, 0, 107, 20, 0, 8, 108, 11, 107, 5, 19, 108, 2, 107, 21, 0, 0, 108, 22, 0, 0, 109, 23, 0, 44, 110, 107, 2, 108, 109, 5, 20, 110, 2, 107, 21, 0, 0, 108, 24, 0, 0, 109, 23, 0, 44, 110, 107, 2, 108, 109, 5, 21, 110, 2, 107, 21, 0, 0, 108, 25, 0, 0, 109, 23, 0, 44, 110, 107, 2, 108, 109, 5, 22, 110, 2, 107, 21, 0, 0, 108, 26, 0, 0, 109, 23, 0, 44, 110, 107, 2, 108, 109, 5, 23, 110, 47, 107, 11926, 0, 6, 11, 0, 1, 28, 1, 36, 1, 2, 1, 20, 1, 30, 1, 22, 1, 59, 1, 27, 1, 24, 1, 35, 1, 3, 42, 108, 107, 0, 5, 25, 108, 0, 107, 12, 0, 8, 108, 25, 107, 5, 26, 108, 0, 107, 27, 0, 8, 108, 26, 107, 5, 27, 108, 0, 107, 28, 0, 0, 108, 12, 0, 9, 27, 108, 26, 9, 27, 107, 26, 0, 107, 29, 0, 0, 108, 30, 0, 9, 26, 107, 108, 0, 107, 20, 0, 9, 26, 107, 19, 38, 107, 31, 0, 0, 108, 32, 0, 28, 109, 107, 108, 5, 107, 109, 40, 107, 1264, 2, 108, 31, 0, 0, 109, 33, 0, 8, 110, 108, 109, 2, 108, 31, 0, 0, 109, 33, 0, 8, 111, 108, 109, 8, 108, 11, 111, 9, 26, 110, 108, 5, 107, 108, 0, 107, 34, 0, 9, 27, 107, 29, 0, 107, 35, 0, 9, 27, 107, 35, 0, 107, 11, 0, 9, 27, 107, 10, 0, 107, 36, 0, 9, 27, 107, 39, 0, 107, 37, 0, 9, 27, 107, 40, 0, 107, 38, 0, 9, 27, 107, 41, 0, 107, 38, 0, 47, 108, 12447, 1, 5, 1, 0, 1, 41, 9, 26, 107, 108, 0, 107, 39, 0, 47, 108, 12466, 0, 5, 0, 0, 9, 26, 107, 108, 0, 107, 40, 0, 9, 27, 107, 42, 0, 107, 40, 0, 47, 108, 12540, 1, 6, 2, 0, 1, 42, 1, 26, 9, 26, 107, 108, 2, 107, 21, 0, 0, 108, 41, 0, 0, 109, 42, 0, 44, 110, 107, 2, 108, 109, 5, 43, 110, 0, 107, 43, 0, 47, 108, 12562, 2, 9, 4, 0, 1, 44, 1, 37, 1, 32, 1, 41, 9, 26, 107, 108, 0, 107, 44, 0, 47, 108, 12789, 1, 6, 0, 0, 9, 26, 107, 108, 0, 107, 45, 0, 47, 108, 12821, 1, 7, 3, 0, 1, 44, 1, 32, 1, 41, 9, 26, 107, 108, 0, 107, 46, 0, 9, 26, 107, 45, 0, 107, 47, 0, 47, 108, 12940, 1, 8, 0, 0, 9, 26, 107, 108, 0, 107, 48, 0, 47, 108, 13014, 1, 8, 2, 0, 1, 18, 1, 32, 9, 26, 107, 108, 0, 107, 49, 0, 47, 108, 13120, 1, 6, 2, 0, 1, 37, 1, 17, 9, 26, 107, 108, 0, 107, 50, 0, 47, 108, 13213, 1, 6, 1, 0, 1, 27, 9, 26, 107, 108, 0, 107, 51, 0, 47, 108, 13245, 0, 5, 0, 0, 9, 26, 107, 108, 0, 107, 52, 0, 47, 108, 13273, 0, 5, 0, 0, 9, 26, 107, 108, 0, 107, 53, 0, 9, 26, 107, 46, 49, 107, 0, 5, 50, 107, 0, 107, 14, 0, 47, 108, 13304, 1, 9, 3, 0, 1, 54, 1, 27, 1, 13, 9, 26, 107, 108, 0, 107, 54, 0, 47, 108, 13389, 1, 7, 1, 0, 1, 55, 9, 26, 107, 108, 2, 107, 21, 0, 0, 108, 55, 0, 0, 109, 23, 0, 44, 110, 107, 2, 108, 109, 5, 56, 110, 2, 107, 21, 0, 0, 108, 56, 0, 0, 109, 23, 0, 44, 110, 107, 2, 108, 109, 5, 57, 110, 0, 107, 57, 0, 0, 108, 9, 0, 0, 109, 58, 0, 0, 110, 59, 0, 0, 111, 60, 0, 0, 112, 8, 0, 0, 113, 61, 0, 49, 114, 7, 107, 6, 108, 8, 109, 9, 110, 9, 111, 7, 112, 7, 113, 7, 5, 58, 114, 0, 107, 62, 0, 9, 27, 107, 59, 0, 107, 63, 0, 47, 108, 13476, 1, 6, 2, 0, 1, 36, 1, 24, 9, 26, 107, 108, 0, 107, 64, 0, 47, 108, 13599, 1, 7, 3, 0, 1, 54, 1, 36, 1, 32, 9, 26, 107, 108, 0, 107, 65, 0, 9, 26, 107, 62, 0, 107, 66, 0, 47, 108, 13721, 1, 8, 2, 0, 1, 54, 1, 18, 9, 26, 107, 108, 0, 107, 67, 0, 0, 108, 68, 0, 9, 27, 107, 108, 0, 107, 69, 0, 9, 27, 107, 63, 0, 107, 70, 0, 47, 108, 13798, 2, 11, 2, 0, 1, 27, 1, 63, 9, 26, 107, 108, 0, 107, 71, 0, 47, 108, 13873, 1, 9, 4, 0, 1, 55, 1, 27, 1, 63, 1, 60, 9, 26, 107, 108, 0, 107, 72, 0, 47, 108, 13942, 1, 9, 4, 0, 1, 55, 1, 27, 1, 63, 1, 60, 9, 26, 107, 108, 0, 107, 73, 0, 47, 108, 13990, 1, 9, 2, 0, 1, 27, 1, 14, 9, 26, 107, 108, 0, 107, 74, 0, 47, 108, 14104, 1, 7, 0, 0, 9, 26, 107, 108, 0, 107, 75, 0, 47, 108, 14193, 1, 9, 4, 0, 1, 55, 1, 27, 1, 63, 1, 60, 9, 26, 107, 108, 0, 107, 76, 0, 47, 108, 14304, 1, 8, 4, 0, 1, 27, 1, 63, 1, 60, 1, 24, 9, 26, 107, 108, 2, 107, 21, 0, 0, 108, 77, 0, 0, 109, 42, 0, 44, 110, 107, 2, 108, 109, 5, 64, 110, 2, 107, 21, 0, 0, 108, 78, 0, 0, 109, 79, 0, 44, 110, 107, 2, 108, 109, 5, 65, 110, 0, 107, 80, 0, 0, 108, 81, 0, 0, 109, 82, 0, 0, 110, 83, 0, 48, 111, 4, 107, 108, 109, 110, 5, 66, 111, 0, 107, 84, 0, 47, 108, 14372, 0, 9, 1, 0, 1, 69, 9, 26, 107, 108, 0, 107, 85, 0, 47, 108, 14431, 0, 7, 1, 0, 1, 69, 9, 26, 107, 108, 0, 107, 86, 0, 9, 26, 107, 70, 0, 107, 87, 0, 47, 108, 14474, 1, 8, 1, 0, 1, 69, 9, 26, 107, 108, 0, 107, 88, 0, 47, 108, 14517, 1, 6, 1, 0, 1, 27, 9, 26, 107, 108, 0, 107, 89, 0, 47, 108, 14640, 0, 6, 1, 0, 1, 69, 9, 26, 107, 108, 0, 107, 90, 0, 47, 108, 14675, 1, 8, 1, 0, 1, 27, 9, 26, 107, 108, 0, 107, 91, 0, 47, 108, 14780, 1, 6, 1, 0, 1, 27, 9, 26, 107, 108, 0, 107, 92, 0, 47, 108, 14899, 1, 11, 1, 0, 1, 69, 9, 26, 107, 108, 0, 107, 93, 0, 47, 108, 14966, 1, 7, 1, 0, 1, 69, 9, 26, 107, 108, 0, 107, 94, 0, 47, 108, 15001, 0, 9, 1, 0, 1, 69, 9, 26, 107, 108, 0, 107, 95, 0, 47, 108, 15060, 1, 11, 1, 0, 1, 69, 9, 26, 107, 108, 0, 107, 96, 0, 47, 108, 15127, 0, 7, 3, 0, 1, 27, 1, 63, 1, 60, 9, 26, 107, 108, 0, 107, 97, 0, 47, 108, 15269, 3, 11, 4, 0, 1, 55, 1, 27, 1, 63, 1, 60, 9, 26, 107, 108, 0, 107, 98, 0, 47, 108, 15319, 1, 6, 0, 0, 9, 26, 107, 108, 0, 107, 99, 0, 47, 108, 15351, 2, 7, 0, 0, 9, 26, 107, 108, 0, 107, 100, 0, 47, 108, 15384, 2, 11, 4, 0, 1, 55, 1, 27, 1, 63, 1, 60, 9, 26, 107, 108, 0, 107, 101, 0, 47, 108, 15441, 2, 6, 0, 0, 9, 26, 107, 108, 0, 107, 102, 0, 47, 108, 15466, 3, 11, 4, 0, 1, 55, 1, 27, 1, 63, 1, 60, 9, 26, 107, 108, 0, 107, 103, 0, 47, 108, 15516, 1, 6, 0, 0, 9, 26, 107, 108, 0, 107, 104, 0, 47, 108, 15548, 2, 7, 0, 0, 9, 26, 107, 108, 0, 107, 16, 0, 47, 108, 15581, 1, 11, 3, 0, 1, 27, 1, 12, 1, 15, 9, 26, 107, 108, 0, 107, 105, 0, 47, 108, 15676, 0, 5, 0, 0, 9, 26, 107, 108, 0, 107, 106, 0, 47, 108, 15738, 0, 5, 2, 0, 1, 47, 1, 4, 9, 26, 107, 108, 0, 107, 18, 0, 47, 108, 15852, 2, 8, 2, 0, 1, 27, 1, 17, 9, 26, 107, 108, 2, 107, 21, 0, 0, 108, 107, 0, 0, 109, 42, 0, 44, 110, 107, 2, 108, 109, 5, 71, 110, 0, 107, 108, 0, 47, 108, 15889, 1, 9, 2, 0, 1, 27, 1, 2, 9, 26, 107, 108, 0, 107, 109, 0, 47, 108, 16000, 0, 6, 1, 0, 1, 27, 9, 26, 107, 108, 0, 107, 110, 0, 47, 108, 16111, 0, 11, 1, 0, 1, 3, 9, 26, 107, 108, 0, 107, 111, 0, 47, 108, 16212, 0, 15, 4, 0, 1, 47, 1, 32, 1, 27, 1, 48, 9, 26, 107, 108, 0, 107, 112, 0, 0, 108, 113, 0, 0, 109, 114, 0, 0, 110, 115, 0, 0, 111, 116, 0, 0, 112, 117, 0, 0, 113, 118, 0, 0, 114, 119, 0, 0, 115, 120, 0, 0, 116, 121, 0, 0, 117, 122, 0, 0, 118, 123, 0, 0, 119, 124, 0, 0, 120, 125, 0, 0, 121, 126, 0, 0, 122, 127, 0, 0, 123, 128, 0, 0, 124, 129, 0, 49, 125, 9, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 5, 73, 125, 0, 107, 130, 0, 47, 108, 16619, 2, 10, 2, 0, 1, 36, 1, 73, 9, 26, 107, 108, 0, 107, 131, 0, 47, 108, 16794, 1, 6, 1, 0, 1, 73, 9, 26, 107, 108, 2, 107, 21, 0, 0, 108, 132, 0, 0, 109, 23, 0, 44, 110, 107, 2, 108, 109, 5, 74, 110, 49, 107, 0, 5, 76, 107, 0, 107, 133, 0, 8, 108, 6, 107, 5, 77, 108, 0, 107, 134, 0, 0, 108, 135, 0, 0, 109, 136, 0, 48, 110, 3, 107, 108, 109, 5, 78, 110, 0, 107, 137, 0, 0, 108, 30, 0, 34, 109, 108, 0, 108, 138, 0, 0, 110, 30, 0, 34, 111, 110, 0, 110, 139, 0, 0, 112, 30, 0, 34, 113, 112, 0, 112, 140, 0, 0, 114, 30, 0, 34, 115, 114, 0, 114, 141, 0, 0, 116, 30, 0, 34, 117, 116, 0, 116, 142, 0, 0, 118, 30, 0, 34, 119, 118, 0, 118, 143, 0, 0, 120, 30, 0, 34, 121, 120, 0, 120, 144, 0, 0, 122, 30, 0, 34, 123, 122, 0, 122, 145, 0, 0, 124, 30, 0, 34, 125, 124, 0, 124, 146, 0, 0, 126, 30, 0, 34, 127, 126, 0, 126, 147, 0, 0, 128, 30, 0, 34, 129, 128, 0, 128, 148, 0, 0, 130, 30, 0, 34, 131, 130, 0, 130, 149, 0, 0, 132, 30, 0, 34, 133, 132, 0, 132, 150, 0, 0, 134, 30, 0, 34, 135, 134, 0, 134, 151, 0, 0, 136, 30, 0, 34, 137, 136, 0, 136, 152, 0, 0, 138, 30, 0, 34, 139, 138, 0, 138, 153, 0, 0, 140, 30, 0, 34, 141, 140, 0, 140, 154, 0, 0, 142, 30, 0, 34, 143, 142, 49, 142, 18, 107, 109, 108, 111, 110, 113, 112, 115, 114, 117, 116, 119, 118, 121, 120, 123, 122, 125, 124, 127, 126, 129, 128, 131, 130, 133, 132, 135, 134, 137, 136, 139, 138, 141, 140, 143, 5, 80, 142, 0, 107, 155, 0, 9, 26, 107, 82, 2, 107, 21, 0, 0, 108, 156, 0, 0, 109, 23, 0, 44, 110, 107, 2, 108, 109, 5, 84, 110, 0, 107, 157, 0, 9, 26, 107, 87, 0, 107, 30, 0, 34, 108, 107, 0, 107, 68, 0, 34, 109, 107, 48, 107, 2, 108, 109, 47, 108, 16863, 2, 8, 6, 0, 1, 41, 1, 26, 1, 29, 1, 30, 1, 88, 1, 48, 42, 109, 41, 2, 107, 108, 0, 107, 158, 0, 0, 108, 159, 0, 48, 109, 2, 107, 108, 47, 107, 17414, 2, 7, 9, 0, 1, 26, 1, 37, 1, 29, 1, 30, 1, 88, 1, 49, 1, 32, 1, 47, 1, 81, 42, 108, 41, 2, 109, 107, 0, 107, 160, 0, 5, 89, 107, 0, 107, 161, 0, 47, 108, 17879, 1, 6, 6, 0, 1, 32, 1, 52, 1, 37, 1, 89, 1, 51, 1, 47, 9, 26, 107, 108, 0, 107, 162, 0, 47, 108, 18127, 0, 5, 0, 0, 9, 26, 107, 108, 0, 107, 163, 0, 47, 108, 18158, 0, 5, 0, 0, 9, 26, 107, 108, 0, 107, 164, 0, 5, 90, 107, 0, 107, 165, 0, 5, 91, 107, 0, 107, 166, 0, 0, 108, 167, 0, 0, 109, 168, 0, 0, 110, 169, 0, 49, 111, 2, 107, 108, 109, 110, 5, 92, 111, 0, 107, 170, 0, 0, 108, 171, 0, 0, 109, 172, 0, 0, 110, 173, 0, 49, 111, 2, 107, 108, 109, 110, 5, 93, 111, 2, 107, 21, 0, 0, 108, 174, 0, 0, 109, 79, 0, 44, 110, 107, 2, 108, 109, 5, 94, 110, 0, 107, 175, 0, 47, 108, 18189, 2, 14, 8, 0, 1, 36, 1, 96, 1, 95, 1, 94, 1, 2, 1, 91, 1, 92, 1, 35, 9, 26, 107, 108, 0, 107, 176, 0, 47, 108, 18606, 3, 12, 11, 0, 1, 37, 1, 32, 1, 30, 1, 29, 1, 100, 1, 36, 1, 35, 1, 41, 1, 44, 1, 96, 1, 95, 9, 26, 107, 108, 0, 107, 177, 0, 47, 108, 19034, 1, 6, 1, 0, 1, 55, 9, 26, 107, 108, 0, 107, 178, 0, 47, 108, 19079, 1, 6, 0, 0, 9, 26, 107, 108, 0, 107, 179, 0, 47, 108, 19116, 1, 6, 1, 0, 1, 27, 9, 26, 107, 108, 0, 107, 180, 0, 9, 26, 107, 101, 0, 107, 181, 0, 9, 26, 107, 102, 2, 107, 21, 0, 0, 108, 182, 0, 0, 109, 42, 0, 44, 110, 107, 2, 108, 109, 5, 103, 110, 2, 107, 21, 0, 0, 108, 183, 0, 0, 109, 79, 0, 44, 110, 107, 2, 108, 109, 5, 105, 110, 2, 107, 21, 0, 0, 108, 184, 0, 0, 109, 79, 0, 44, 110, 107, 2, 108, 109, 5, 106, 110, 0, 107, 185, 0, 47, 108, 19148, 0, 6, 7, 0, 1, 41, 1, 105, 1, 106, 1, 61, 1, 37, 1, 10, 1, 104, 9, 26, 107, 108, 38, 107, 186, 0, 0, 108, 187, 0, 22, 109, 107, 108, 40, 109, 3504, 2, 107, 188, 0, 0, 108, 186, 0, 9, 107, 108, 27, 5, 107, 27, 39, 3523, 0, 108, 189, 0, 0, 109, 190, 0, 9, 3, 109, 27, 9, 3, 108, 27, 5, 107, 27, 0, 107, 0, 0, 45, 107, 4, 3, 3, 5, 0, 42, 6, 5, 1, 1, 5, 4, 6, 34, 5, 0, 5, 6, 5, 41, 6, 3597, 34, 5, 4, 5, 7, 5, 40, 7, 3574, 3, 5, 1, 42, 8, 5, 1, 1, 34, 5, 8, 5, 7, 5, 5, 5, 7, 40, 5, 3594, 3, 7, 2, 42, 8, 7, 1, 1, 34, 7, 8, 5, 5, 7, 5, 6, 5, 40, 6, 3608, 48, 5, 0, 5, 6, 5, 39, 3788, 34, 5, 4, 5, 7, 5, 40, 7, 3637, 3, 5, 3, 0, 8, 191, 0, 8, 9, 5, 8, 43, 8, 5, 9, 1, 0, 5, 7, 8, 40, 7, 3714, 0, 5, 192, 0, 8, 7, 1, 5, 0, 5, 18, 0, 8, 8, 0, 5, 0, 5, 68, 0, 43, 9, 0, 8, 1, 5, 0, 5, 193, 0, 8, 8, 9, 5, 2, 5, 21, 0, 0, 10, 194, 0, 0, 11, 42, 0, 44, 12, 5, 2, 10, 11, 0, 5, 23, 0, 43, 10, 9, 8, 2, 12, 5, 43, 5, 1, 7, 1, 10, 5, 7, 5, 39, 3785, 34, 5, 4, 5, 8, 5, 40, 8, 3743, 3, 5, 4, 0, 9, 191, 0, 8, 10, 5, 9, 43, 9, 5, 10, 1, 0, 5, 8, 9, 40, 8, 3765, 0, 5, 195, 0, 8, 8, 1, 5, 43, 5, 1, 8, 1, 0, 5, 8, 5, 39, 3782, 0, 5, 196, 0, 8, 9, 1, 5, 43, 5, 1, 9, 1, 0, 5, 8, 5, 5, 7, 8, 5, 6, 7, 45, 6, 0, 5, 0, 0, 45, 5, 4, 2, 3, 3, 0, 31, 4, 0, 3, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 34, 3, 0, 34, 4, 3, 5, 3, 4, 40, 3, 3842, 0, 4, 2, 0, 8, 5, 0, 4, 26, 4, 0, 5, 5, 3, 4, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 34, 3, 0, 34, 4, 3, 5, 3, 4, 40, 3, 3883, 0, 4, 197, 0, 8, 5, 0, 4, 0, 4, 198, 0, 26, 6, 5, 4, 5, 3, 6, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 34, 3, 0, 34, 4, 3, 5, 3, 4, 40, 3, 3924, 0, 4, 197, 0, 8, 5, 0, 4, 0, 4, 199, 0, 26, 6, 5, 4, 5, 3, 6, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 34, 3, 0, 34, 4, 3, 5, 3, 4, 40, 3, 3965, 0, 4, 197, 0, 8, 5, 0, 4, 0, 4, 68, 0, 26, 6, 5, 4, 5, 3, 6, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 34, 3, 0, 34, 4, 3, 5, 3, 4, 40, 3, 4006, 0, 4, 197, 0, 8, 5, 0, 4, 0, 4, 200, 0, 26, 6, 5, 4, 5, 3, 6, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 36, 3, 0, 0, 4, 201, 0, 28, 5, 3, 4, 45, 5, 0, 3, 0, 0, 45, 3, 4, 2, 36, 3, 0, 0, 4, 32, 0, 28, 5, 3, 4, 45, 5, 0, 3, 0, 0, 45, 3, 4, 2, 36, 3, 0, 0, 4, 202, 0, 28, 5, 3, 4, 45, 5, 0, 3, 0, 0, 45, 3, 4, 2, 0, 3, 30, 0, 37, 4, 3, 26, 3, 0, 4, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 0, 3, 203, 0, 26, 4, 0, 3, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 2, 3, 204, 0, 2, 4, 205, 0, 42, 5, 4, 1, 0, 42, 4, 3, 1, 5, 34, 3, 4, 5, 4, 3, 40, 4, 4157, 2, 3, 206, 0, 42, 5, 3, 1, 0, 5, 4, 5, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 36, 4, 0, 0, 5, 207, 0, 29, 6, 4, 5, 5, 4, 6, 41, 4, 4195, 0, 5, 203, 0, 26, 6, 0, 5, 5, 4, 6, 40, 4, 4207, 0, 4, 68, 0, 34, 5, 4, 45, 5, 2, 4, 208, 0, 0, 5, 209, 0, 8, 6, 4, 5, 43, 5, 4, 6, 1, 0, 5, 3, 5, 0, 4, 203, 0, 26, 5, 3, 4, 5, 4, 5, 41, 4, 4261, 2, 5, 208, 0, 0, 6, 12, 0, 8, 7, 5, 6, 26, 5, 3, 7, 5, 4, 5, 45, 4, 0, 4, 0, 0, 45, 4, 4, 4, 40, 2, 4346, 0, 9, 29, 0, 8, 10, 0, 9, 5, 5, 10, 5, 9, 5, 0, 10, 68, 0, 12, 11, 5, 10, 5, 5, 11, 40, 9, 4344, 0, 9, 210, 0, 8, 10, 1, 9, 8, 9, 0, 5, 8, 11, 0, 5, 43, 12, 1, 10, 3, 9, 5, 11, 0, 9, 68, 0, 34, 10, 9, 26, 9, 12, 10, 40, 9, 4342, 45, 0, 39, 4285, 39, 4549, 3, 9, 0, 42, 10, 9, 1, 0, 40, 10, 4468, 2, 9, 208, 0, 0, 10, 211, 0, 8, 11, 9, 10, 43, 10, 9, 11, 1, 0, 5, 6, 10, 0, 9, 30, 0, 5, 5, 9, 0, 9, 29, 0, 8, 10, 6, 9, 5, 7, 10, 22, 9, 5, 7, 40, 9, 4466, 8, 9, 6, 5, 5, 8, 9, 0, 9, 210, 0, 8, 10, 1, 9, 8, 9, 0, 8, 8, 11, 0, 8, 43, 12, 1, 10, 3, 9, 8, 11, 0, 9, 68, 0, 34, 10, 9, 26, 9, 12, 10, 40, 9, 4450, 45, 0, 5, 9, 5, 0, 9, 68, 0, 11, 10, 5, 9, 5, 5, 10, 39, 4396, 39, 4549, 0, 9, 30, 0, 5, 5, 9, 0, 9, 29, 0, 8, 10, 0, 9, 5, 7, 10, 22, 9, 5, 7, 40, 9, 4549, 0, 9, 210, 0, 8, 10, 1, 9, 8, 9, 0, 5, 8, 11, 0, 5, 43, 12, 1, 10, 3, 9, 5, 11, 0, 9, 68, 0, 34, 10, 9, 26, 9, 12, 10, 40, 9, 4533, 45, 0, 5, 9, 5, 0, 9, 68, 0, 11, 10, 5, 9, 5, 5, 10, 39, 4486, 45, 0, 0, 9, 0, 0, 45, 9, 4, 1, 48, 11, 0, 5, 2, 11, 0, 11, 30, 0, 5, 3, 11, 0, 11, 29, 0, 8, 12, 0, 11, 22, 11, 3, 12, 40, 11, 4611, 8, 11, 0, 3, 9, 2, 3, 11, 5, 11, 3, 0, 11, 68, 0, 11, 12, 3, 11, 5, 3, 12, 39, 4572, 3, 11, 0, 0, 12, 30, 0, 8, 13, 2, 12, 42, 12, 11, 1, 13, 40, 12, 4648, 0, 11, 212, 0, 8, 12, 2, 11, 43, 11, 2, 12, 0, 5, 12, 11, 39, 4658, 0, 11, 68, 0, 34, 13, 11, 5, 12, 13, 5, 4, 12, 0, 11, 212, 0, 8, 12, 2, 11, 43, 11, 2, 12, 0, 5, 5, 11, 0, 11, 29, 0, 8, 12, 2, 11, 5, 6, 12, 34, 11, 5, 40, 11, 4699, 49, 11, 0, 45, 11, 34, 11, 6, 40, 11, 4720, 3, 11, 1, 3, 12, 2, 42, 13, 11, 3, 4, 12, 5, 45, 13, 0, 11, 30, 0, 5, 7, 11, 22, 11, 7, 6, 40, 11, 4921, 8, 11, 2, 7, 5, 8, 11, 52, 9, 8, 53, 11, 9, 4905, 5, 10, 11, 5, 11, 4, 40, 11, 4793, 3, 12, 3, 8, 13, 8, 10, 42, 14, 12, 1, 13, 5, 12, 14, 41, 12, 4790, 3, 13, 4, 8, 14, 8, 10, 42, 15, 13, 1, 14, 5, 12, 15, 5, 11, 12, 40, 11, 4892, 8, 11, 5, 10, 34, 12, 11, 5, 11, 12, 41, 11, 4840, 8, 12, 5, 10, 0, 13, 213, 0, 8, 14, 12, 13, 8, 12, 8, 10, 0, 13, 213, 0, 8, 15, 12, 13, 27, 12, 14, 15, 5, 11, 12, 5, 12, 11, 40, 12, 4869, 8, 11, 8, 10, 0, 13, 213, 0, 8, 14, 11, 13, 44, 11, 14, 0, 9, 5, 10, 11, 5, 12, 11, 3, 11, 1, 8, 12, 5, 10, 8, 13, 8, 10, 42, 14, 11, 3, 4, 12, 13, 5, 11, 14, 39, 4903, 8, 12, 8, 10, 9, 5, 10, 12, 5, 11, 12, 39, 4744, 5, 11, 7, 0, 11, 68, 0, 11, 12, 7, 11, 5, 7, 12, 39, 4727, 45, 5, 0, 11, 0, 0, 45, 11, 4, 2, 3, 3, 0, 42, 4, 3, 1, 0, 40, 4, 4976, 0, 3, 214, 0, 8, 4, 0, 3, 3, 3, 1, 43, 5, 0, 4, 1, 3, 5, 3, 5, 41, 3, 4971, 48, 4, 0, 5, 3, 4, 5, 4, 3, 39, 4982, 48, 3, 0, 5, 4, 3, 45, 4, 0, 3, 0, 0, 45, 3, 4, 3, 40, 0, 5240, 3, 7, 0, 42, 8, 7, 1, 0, 40, 8, 5207, 0, 7, 29, 0, 8, 8, 2, 7, 0, 7, 215, 0, 22, 9, 8, 7, 40, 9, 5126, 0, 7, 30, 0, 8, 8, 3, 7, 34, 7, 8, 5, 8, 7, 41, 8, 5064, 3, 7, 1, 0, 9, 30, 0, 8, 10, 3, 9, 42, 9, 7, 1, 10, 34, 7, 9, 5, 8, 7, 40, 8, 5073, 0, 7, 0, 0, 45, 7, 0, 7, 30, 0, 8, 8, 3, 7, 0, 7, 216, 0, 8, 9, 8, 7, 43, 7, 8, 9, 1, 0, 5, 4, 7, 3, 7, 2, 42, 8, 7, 1, 4, 40, 8, 5121, 0, 7, 30, 0, 37, 8, 7, 5, 7, 8, 39, 5124, 5, 7, 4, 45, 7, 3, 7, 3, 42, 8, 7, 1, 1, 40, 8, 5142, 5, 7, 3, 39, 5205, 3, 8, 2, 42, 9, 8, 1, 1, 40, 9, 5172, 0, 8, 45, 0, 8, 9, 3, 8, 43, 8, 3, 9, 1, 0, 5, 9, 8, 39, 5202, 0, 8, 38, 0, 8, 10, 3, 8, 47, 8, 5246, 2, 9, 3, 0, 0, 1, 1, 0, 1, 1, 43, 11, 3, 10, 1, 8, 5, 9, 11, 5, 7, 9, 45, 7, 52, 5, 0, 53, 7, 5, 5238, 5, 6, 7, 0, 7, 46, 0, 8, 8, 3, 7, 8, 7, 0, 6, 43, 9, 3, 8, 2, 6, 7, 39, 5210, 45, 3, 0, 7, 0, 0, 45, 7, 4, 3, 3, 4, 0, 42, 5, 4, 1, 1, 5, 4, 5, 40, 4, 5286, 0, 5, 217, 0, 8, 6, 1, 5, 3, 5, 1, 3, 7, 2, 43, 8, 1, 6, 2, 5, 7, 5, 4, 8, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 42, 4, 3, 1, 0, 40, 4, 5366, 0, 3, 49, 0, 8, 4, 2, 3, 43, 3, 2, 4, 0, 0, 4, 16, 0, 8, 5, 3, 4, 47, 4, 5402, 1, 6, 2, 0, 0, 1, 0, 2, 43, 6, 3, 5, 1, 4, 0, 3, 218, 0, 8, 4, 6, 3, 0, 3, 23, 0, 43, 5, 6, 4, 1, 3, 5, 3, 5, 39, 5394, 0, 4, 38, 0, 8, 5, 2, 4, 47, 4, 5460, 2, 7, 2, 0, 0, 1, 1, 0, 43, 6, 2, 5, 1, 4, 5, 3, 6, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 42, 4, 3, 1, 0, 5, 3, 4, 41, 3, 5429, 3, 4, 1, 42, 5, 4, 1, 0, 5, 3, 5, 40, 3, 5445, 0, 3, 219, 0, 8, 4, 0, 3, 5, 3, 4, 39, 5452, 0, 4, 23, 0, 5, 3, 4, 45, 3, 0, 3, 0, 0, 45, 3, 4, 3, 3, 4, 0, 42, 5, 4, 1, 1, 5, 4, 5, 40, 4, 5490, 0, 5, 219, 0, 3, 6, 1, 9, 1, 5, 6, 5, 4, 6, 0, 4, 0, 0, 45, 4, 4, 4, 3, 6, 0, 42, 7, 6, 1, 0, 40, 7, 5602, 3, 6, 1, 0, 7, 220, 0, 8, 8, 6, 7, 0, 7, 203, 0, 43, 9, 6, 8, 2, 0, 7, 5, 5, 9, 40, 2, 5572, 0, 6, 221, 0, 8, 7, 5, 6, 43, 6, 5, 7, 1, 1, 5, 7, 6, 41, 7, 5567, 0, 6, 30, 0, 37, 8, 6, 5, 7, 8, 5, 6, 7, 39, 5600, 8, 7, 5, 1, 5, 8, 7, 41, 8, 5597, 0, 7, 133, 0, 8, 9, 0, 7, 8, 7, 9, 1, 5, 8, 7, 5, 6, 8, 45, 6, 0, 6, 0, 0, 45, 6, 4, 3, 2, 4, 222, 0, 3, 5, 0, 42, 6, 5, 2, 0, 1, 0, 5, 223, 0, 42, 7, 4, 2, 6, 5, 5, 4, 7, 41, 4, 5646, 0, 5, 30, 0, 5, 4, 5, 45, 4, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 0, 5, 224, 0, 0, 6, 13, 0, 8, 7, 5, 6, 40, 1, 5683, 0, 6, 225, 0, 5, 8, 6, 39, 5690, 0, 6, 226, 0, 5, 8, 6, 0, 6, 158, 0, 43, 9, 5, 7, 2, 8, 6, 42, 5, 4, 2, 0, 9, 3, 4, 0, 0, 6, 227, 0, 0, 7, 13, 0, 8, 8, 6, 7, 40, 1, 5734, 0, 7, 225, 0, 5, 9, 7, 39, 5741, 0, 7, 226, 0, 5, 9, 7, 43, 7, 6, 8, 1, 9, 42, 6, 4, 2, 0, 7, 11, 4, 5, 6, 3, 5, 0, 0, 6, 227, 0, 0, 7, 13, 0, 8, 8, 6, 7, 40, 1, 5784, 0, 7, 228, 0, 5, 9, 7, 39, 5791, 0, 7, 229, 0, 5, 9, 7, 43, 7, 6, 8, 1, 9, 42, 6, 5, 2, 0, 7, 11, 5, 4, 6, 3, 4, 0, 0, 6, 224, 0, 0, 7, 13, 0, 8, 8, 6, 7, 40, 1, 5834, 0, 7, 228, 0, 5, 9, 7, 39, 5841, 0, 7, 229, 0, 5, 9, 7, 0, 7, 158, 0, 43, 10, 6, 8, 2, 9, 7, 42, 6, 4, 2, 0, 10, 11, 4, 5, 6, 45, 4, 0, 4, 0, 0, 45, 4, 4, 2, 3, 5, 0, 8, 6, 5, 0, 40, 6, 5891, 3, 5, 0, 8, 6, 5, 0, 45, 6, 3, 5, 1, 42, 6, 5, 1, 0, 5, 3, 6, 3, 5, 2, 0, 6, 230, 0, 8, 7, 5, 6, 0, 5, 93, 0, 8, 6, 7, 5, 0, 5, 203, 0, 43, 8, 7, 6, 2, 3, 5, 3, 5, 3, 0, 6, 231, 0, 42, 7, 5, 2, 3, 6, 5, 4, 7, 3, 5, 2, 0, 6, 230, 0, 8, 7, 5, 6, 0, 5, 232, 0, 8, 6, 7, 5, 43, 5, 7, 6, 1, 3, 3, 5, 0, 0, 6, 233, 0, 27, 7, 4, 6, 40, 7, 5992, 5, 6, 4, 39, 5999, 0, 7, 234, 0, 5, 6, 7, 9, 5, 0, 6, 45, 6, 0, 5, 0, 0, 45, 5, 4, 2, 3, 3, 0, 0, 4, 231, 0, 42, 5, 3, 2, 0, 4, 0, 3, 233, 0, 26, 4, 5, 3, 45, 4, 0, 3, 0, 0, 45, 3, 4, 3, 5, 5, 0, 40, 5, 6095, 0, 6, 235, 0, 8, 7, 0, 6, 5, 6, 7, 41, 6, 6075, 0, 7, 236, 0, 8, 8, 0, 7, 5, 6, 8, 5, 7, 6, 41, 7, 6092, 0, 6, 237, 0, 8, 8, 0, 6, 5, 7, 8, 5, 5, 7, 5, 4, 5, 34, 5, 4, 34, 6, 5, 5, 5, 6, 40, 5, 6119, 34, 6, 1, 34, 7, 6, 5, 5, 7, 5, 6, 5, 40, 6, 6143, 0, 5, 210, 0, 8, 7, 4, 5, 43, 5, 4, 7, 2, 0, 1, 5, 6, 5, 45, 6, 0, 5, 0, 0, 45, 5, 4, 2, 3, 3, 0, 42, 4, 3, 1, 0, 40, 4, 6180, 47, 3, 6265, 2, 7, 2, 0, 0, 1, 1, 0, 5, 4, 3, 39, 6257, 3, 3, 2, 42, 5, 3, 1, 0, 40, 5, 6196, 5, 3, 0, 39, 6254, 3, 5, 3, 42, 6, 5, 1, 0, 40, 6, 6221, 47, 5, 6287, 2, 7, 1, 0, 1, 0, 5, 6, 5, 39, 6251, 40, 0, 6238, 47, 5, 6314, 2, 6, 1, 0, 1, 0, 5, 7, 5, 39, 6248, 47, 5, 6331, 0, 4, 0, 0, 5, 7, 5, 5, 6, 7, 5, 3, 6, 5, 4, 3, 45, 4, 0, 3, 0, 0, 45, 3, 4, 3, 3, 4, 0, 3, 5, 1, 42, 6, 4, 2, 1, 5, 45, 6, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 0, 5, 66, 0, 8, 6, 4, 5, 43, 5, 4, 6, 1, 1, 45, 5, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 26, 5, 1, 4, 45, 5, 0, 4, 0, 0, 45, 4, 4, 1, 0, 2, 68, 0, 34, 3, 2, 45, 3, 0, 2, 0, 0, 45, 2, 4, 3, 40, 1, 6372, 0, 4, 14, 0, 8, 5, 0, 4, 43, 4, 0, 5, 1, 1, 5, 5, 4, 39, 6375, 5, 5, 0, 45, 5, 0, 4, 0, 0, 45, 4, 4, 2, 3, 5, 0, 42, 6, 5, 1, 0, 34, 5, 6, 40, 5, 6404, 48, 5, 0, 45, 5, 3, 5, 1, 0, 6, 191, 0, 8, 7, 5, 6, 43, 6, 5, 7, 1, 0, 40, 6, 6450, 3, 5, 2, 2, 6, 21, 0, 0, 7, 238, 0, 8, 8, 6, 7, 42, 6, 5, 1, 8, 48, 5, 1, 6, 45, 5, 3, 5, 3, 0, 6, 191, 0, 8, 7, 5, 6, 43, 6, 5, 7, 1, 0, 5, 5, 6, 40, 5, 6488, 2, 6, 21, 0, 0, 7, 238, 0, 8, 8, 6, 7, 5, 5, 8, 5, 3, 5, 3, 5, 4, 8, 6, 5, 3, 5, 5, 6, 41, 5, 6518, 3, 6, 4, 0, 7, 57, 0, 8, 8, 6, 7, 5, 5, 8, 5, 4, 5, 0, 5, 239, 0, 9, 4, 5, 0, 3, 5, 5, 0, 6, 240, 0, 8, 7, 4, 6, 42, 6, 5, 1, 7, 0, 5, 54, 0, 8, 7, 6, 5, 43, 5, 6, 7, 0, 0, 6, 49, 0, 8, 7, 5, 6, 43, 6, 5, 7, 0, 45, 6, 0, 5, 0, 0, 45, 5, 4, 5, 48, 13, 0, 5, 6, 13, 3, 13, 0, 42, 14, 13, 1, 1, 5, 7, 14, 5, 13, 3, 40, 13, 6615, 3, 14, 1, 42, 15, 14, 1, 3, 5, 13, 15, 5, 8, 13, 0, 13, 30, 0, 5, 9, 13, 0, 13, 29, 0, 8, 14, 0, 13, 5, 10, 14, 22, 13, 9, 10, 40, 13, 6805, 40, 7, 6695, 8, 13, 0, 9, 42, 14, 1, 1, 13, 5, 11, 14, 0, 13, 29, 0, 8, 14, 11, 13, 5, 13, 14, 40, 13, 6693, 3, 14, 2, 0, 15, 241, 0, 8, 16, 14, 15, 43, 15, 14, 16, 2, 6, 11, 5, 13, 15, 39, 6789, 8, 13, 0, 9, 8, 14, 13, 1, 5, 12, 14, 0, 13, 203, 0, 29, 14, 12, 13, 5, 13, 14, 40, 13, 6748, 5, 14, 3, 40, 14, 6742, 0, 15, 68, 0, 32, 16, 15, 42, 15, 8, 2, 16, 12, 5, 14, 15, 34, 15, 14, 5, 13, 15, 40, 13, 6789, 0, 13, 17, 0, 8, 14, 6, 13, 43, 13, 6, 14, 1, 12, 40, 2, 6777, 8, 13, 12, 1, 5, 14, 13, 39, 6784, 0, 13, 203, 0, 5, 14, 13, 5, 12, 14, 39, 6706, 5, 13, 9, 0, 13, 68, 0, 11, 14, 9, 13, 5, 9, 14, 39, 6636, 45, 6, 0, 13, 0, 0, 45, 13, 4, 2, 0, 3, 242, 0, 8, 4, 0, 3, 5, 3, 4, 40, 3, 6840, 0, 4, 243, 0, 8, 5, 0, 4, 5, 3, 5, 40, 3, 6894, 3, 3, 0, 3, 4, 1, 0, 5, 210, 0, 8, 6, 4, 5, 0, 5, 243, 0, 8, 7, 0, 5, 47, 5, 6926, 1, 7, 0, 0, 43, 8, 4, 6, 2, 7, 5, 0, 4, 244, 0, 42, 5, 3, 2, 8, 4, 5, 3, 5, 39, 6918, 0, 4, 244, 0, 8, 5, 0, 4, 5, 4, 5, 41, 4, 6915, 0, 5, 23, 0, 5, 4, 5, 5, 3, 4, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 0, 3, 245, 0, 8, 4, 0, 3, 5, 3, 4, 40, 3, 6956, 0, 4, 246, 0, 8, 5, 0, 4, 34, 4, 5, 5, 3, 4, 5, 4, 3, 40, 4, 6984, 0, 3, 247, 0, 8, 5, 0, 3, 0, 3, 246, 0, 8, 6, 5, 3, 34, 3, 6, 5, 4, 3, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 0, 3, 29, 0, 8, 4, 1, 3, 40, 4, 7045, 0, 3, 38, 0, 8, 4, 2, 3, 47, 3, 7089, 2, 11, 7, 0, 0, 0, 0, 1, 1, 0, 0, 2, 0, 3, 0, 4, 0, 5, 43, 5, 2, 4, 1, 3, 5, 3, 5, 39, 7081, 0, 4, 30, 0, 8, 5, 2, 4, 5, 4, 5, 40, 4, 7078, 3, 5, 6, 0, 6, 30, 0, 8, 7, 2, 6, 42, 6, 5, 1, 7, 5, 4, 6, 5, 3, 4, 45, 3, 0, 3, 0, 0, 45, 3, 4, 3, 0, 6, 242, 0, 8, 7, 1, 6, 5, 6, 7, 40, 6, 7116, 0, 7, 243, 0, 8, 8, 1, 7, 5, 6, 8, 5, 4, 6, 5, 6, 4, 41, 6, 7153, 3, 7, 0, 0, 8, 191, 0, 8, 9, 7, 8, 0, 8, 80, 0, 8, 10, 1, 8, 43, 8, 7, 9, 1, 10, 5, 6, 8, 40, 6, 7332, 3, 6, 1, 3, 7, 2, 42, 8, 6, 1, 7, 40, 8, 7200, 3, 6, 3, 0, 7, 210, 0, 8, 8, 6, 7, 3, 7, 2, 2, 9, 248, 0, 43, 10, 6, 8, 2, 7, 9, 5, 6, 10, 39, 7244, 3, 7, 4, 3, 8, 2, 42, 9, 7, 1, 8, 40, 9, 7222, 48, 7, 0, 5, 8, 7, 39, 7241, 2, 7, 248, 0, 3, 9, 2, 42, 10, 7, 1, 9, 48, 7, 1, 10, 5, 8, 7, 5, 6, 8, 5, 5, 6, 40, 4, 7289, 3, 6, 5, 0, 7, 243, 0, 8, 8, 1, 7, 47, 7, 7395, 2, 9, 1, 0, 1, 5, 0, 9, 30, 0, 34, 10, 9, 42, 9, 6, 3, 8, 7, 10, 5, 6, 9, 39, 7330, 0, 7, 249, 0, 0, 8, 15, 0, 8, 9, 5, 8, 0, 8, 244, 0, 8, 10, 1, 8, 43, 8, 5, 9, 1, 10, 0, 9, 30, 0, 25, 10, 8, 9, 9, 1, 7, 10, 5, 6, 10, 39, 7389, 0, 6, 244, 0, 3, 7, 6, 3, 8, 2, 42, 9, 7, 1, 8, 5, 7, 9, 41, 7, 7367, 3, 8, 4, 3, 9, 2, 42, 10, 8, 1, 9, 5, 7, 10, 40, 7, 7379, 0, 7, 23, 0, 5, 8, 7, 39, 7385, 3, 7, 2, 5, 8, 7, 9, 1, 6, 8, 0, 6, 0, 0, 45, 6, 4, 3, 0, 4, 245, 0, 3, 5, 0, 0, 6, 15, 0, 8, 7, 5, 6, 0, 6, 244, 0, 8, 8, 1, 6, 43, 6, 5, 7, 1, 8, 0, 5, 30, 0, 25, 7, 6, 5, 9, 1, 4, 7, 0, 4, 0, 0, 45, 4, 4, 2, 0, 3, 29, 0, 8, 4, 0, 3, 0, 3, 68, 0, 23, 5, 4, 3, 40, 5, 7497, 3, 3, 0, 0, 4, 210, 0, 8, 5, 3, 4, 47, 4, 7508, 3, 8, 1, 0, 0, 1, 43, 6, 3, 5, 2, 0, 4, 5, 3, 6, 39, 7500, 5, 3, 0, 45, 3, 0, 3, 0, 0, 45, 3, 4, 4, 3, 5, 0, 0, 6, 210, 0, 8, 7, 5, 6, 43, 6, 5, 7, 2, 2, 0, 26, 5, 6, 1, 45, 5, 0, 5, 0, 0, 45, 5, 4, 3, 3, 5, 0, 42, 6, 5, 1, 0, 5, 4, 6, 0, 5, 14, 0, 8, 6, 4, 5, 0, 5, 250, 0, 43, 7, 4, 6, 1, 5, 0, 5, 70, 0, 8, 6, 7, 5, 0, 5, 76, 0, 8, 8, 4, 5, 0, 5, 250, 0, 43, 9, 4, 8, 1, 5, 43, 5, 7, 6, 1, 9, 0, 6, 38, 0, 8, 7, 5, 6, 47, 6, 7644, 2, 11, 7, 0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 1, 1, 43, 8, 5, 7, 1, 6, 0, 5, 0, 0, 45, 5, 4, 3, 3, 5, 0, 0, 6, 191, 0, 8, 7, 5, 6, 0, 6, 80, 0, 8, 8, 1, 6, 43, 6, 5, 7, 1, 8, 5, 5, 6, 40, 5, 7697, 3, 6, 1, 0, 7, 251, 0, 8, 8, 6, 7, 43, 7, 6, 8, 1, 1, 5, 5, 7, 40, 5, 7831, 3, 5, 2, 0, 6, 250, 0, 42, 7, 5, 1, 6, 5, 4, 7, 0, 5, 53, 0, 0, 6, 219, 0, 8, 7, 1, 6, 0, 6, 193, 0, 8, 8, 7, 6, 3, 6, 3, 0, 9, 23, 0, 43, 10, 7, 8, 2, 6, 9, 9, 4, 5, 10, 3, 5, 4, 3, 6, 5, 47, 7, 7837, 2, 8, 2, 0, 1, 1, 1, 4, 42, 8, 5, 2, 6, 7, 3, 5, 6, 0, 6, 252, 0, 8, 7, 5, 6, 0, 5, 93, 0, 8, 6, 7, 5, 0, 5, 203, 0, 43, 8, 7, 6, 2, 4, 5, 3, 5, 6, 0, 6, 252, 0, 8, 7, 5, 6, 0, 5, 232, 0, 8, 6, 7, 5, 43, 5, 7, 6, 1, 4, 0, 5, 0, 0, 45, 5, 4, 3, 3, 4, 0, 8, 5, 4, 1, 5, 4, 5, 40, 4, 7869, 3, 5, 1, 3, 6, 0, 8, 7, 6, 1, 9, 5, 1, 7, 5, 4, 7, 0, 4, 0, 0, 45, 4, 4, 6, 40, 3, 7923, 0, 7, 93, 0, 8, 8, 0, 7, 40, 2, 7904, 0, 7, 253, 0, 8, 9, 0, 7, 5, 7, 9, 39, 7911, 0, 9, 203, 0, 5, 7, 9, 43, 9, 0, 8, 2, 1, 7, 5, 7, 9, 39, 8018, 0, 8, 254, 0, 8, 9, 0, 8, 0, 8, 255, 0, 26, 10, 9, 8, 40, 10, 7970, 0, 8, 247, 0, 8, 9, 0, 8, 0, 8, 256, 0, 8, 10, 9, 8, 43, 8, 9, 10, 2, 1, 0, 5, 9, 8, 39, 8015, 0, 8, 247, 0, 8, 10, 0, 8, 0, 8, 93, 0, 8, 11, 10, 8, 40, 2, 7994, 5, 8, 0, 39, 8005, 0, 12, 257, 0, 8, 13, 0, 12, 5, 8, 13, 43, 12, 10, 11, 2, 1, 8, 5, 9, 12, 5, 7, 9, 5, 7, 4, 40, 7, 8044, 3, 8, 0, 0, 9, 258, 0, 8, 10, 0, 9, 42, 9, 8, 2, 1, 10, 5, 7, 9, 0, 7, 0, 0, 45, 7, 4, 9, 3, 10, 0, 47, 11, 8095, 2, 9, 9, 0, 0, 0, 0, 1, 1, 1, 1, 2, 0, 2, 1, 3, 1, 4, 1, 7, 1, 6, 42, 12, 10, 3, 0, 11, 5, 45, 1, 0, 10, 0, 0, 45, 10, 4, 3, 3, 4, 0, 3, 5, 1, 42, 6, 5, 1, 1, 47, 5, 8147, 2, 9, 8, 0, 0, 0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 7, 3, 7, 8, 42, 8, 4, 3, 6, 5, 7, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 3, 5, 1, 3, 6, 2, 42, 7, 5, 1, 6, 47, 5, 8198, 2, 13, 6, 0, 0, 3, 1, 1, 1, 0, 0, 4, 0, 5, 0, 6, 3, 6, 7, 42, 8, 4, 3, 7, 5, 6, 0, 4, 0, 0, 45, 4, 4, 3, 3, 7, 0, 40, 7, 8214, 3, 7, 1, 5, 8, 7, 39, 8217, 5, 8, 1, 5, 4, 8, 3, 7, 0, 40, 7, 8231, 5, 7, 1, 39, 8237, 3, 8, 1, 5, 7, 8, 5, 5, 7, 3, 7, 0, 40, 7, 8254, 3, 7, 2, 5, 8, 7, 39, 8257, 5, 8, 0, 5, 6, 8, 3, 7, 3, 40, 6, 8292, 0, 8, 259, 0, 8, 9, 5, 8, 0, 8, 30, 0, 34, 10, 8, 43, 8, 5, 9, 1, 10, 5, 9, 8, 39, 8295, 5, 9, 5, 3, 8, 4, 3, 10, 5, 34, 11, 6, 42, 12, 7, 5, 4, 9, 8, 10, 11, 0, 7, 0, 0, 45, 7, 4, 2, 0, 4, 29, 0, 8, 5, 1, 4, 34, 4, 5, 40, 4, 8370, 0, 4, 30, 0, 8, 5, 2, 4, 5, 4, 5, 40, 4, 8368, 0, 5, 30, 0, 8, 6, 2, 5, 0, 5, 239, 0, 8, 7, 6, 5, 5, 4, 7, 45, 4, 3, 4, 0, 42, 5, 4, 1, 0, 40, 5, 8383, 45, 2, 2, 4, 21, 0, 0, 5, 260, 0, 0, 6, 23, 0, 44, 7, 4, 2, 5, 6, 0, 4, 191, 0, 8, 5, 7, 4, 43, 4, 7, 5, 1, 0, 5, 3, 4, 0, 4, 38, 0, 8, 5, 2, 4, 47, 4, 8455, 2, 9, 4, 0, 0, 1, 1, 3, 0, 2, 1, 0, 43, 6, 2, 5, 1, 4, 45, 6, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 42, 5, 4, 1, 1, 5, 4, 5, 40, 4, 8537, 3, 5, 1, 40, 5, 8520, 3, 5, 2, 42, 6, 5, 1, 1, 0, 5, 39, 0, 8, 7, 6, 5, 43, 5, 6, 7, 0, 0, 6, 85, 0, 8, 7, 5, 6, 3, 6, 3, 43, 8, 5, 7, 1, 6, 5, 5, 8, 39, 8534, 0, 6, 239, 0, 3, 7, 3, 9, 1, 6, 7, 5, 5, 7, 5, 4, 5, 0, 4, 0, 0, 45, 4, 4, 2, 0, 3, 193, 0, 8, 4, 0, 3, 3, 3, 0, 47, 5, 8578, 2, 6, 0, 0, 43, 6, 0, 4, 2, 3, 5, 45, 6, 0, 3, 0, 0, 45, 3, 4, 3, 0, 4, 261, 0, 8, 5, 1, 4, 43, 4, 1, 5, 0, 45, 4, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 0, 4, 191, 0, 8, 5, 3, 4, 43, 4, 3, 5, 1, 0, 45, 4, 0, 3, 0, 0, 45, 3, 4, 3, 0, 7, 30, 0, 37, 8, 7, 26, 7, 1, 8, 5, 8, 7, 40, 8, 8661, 3, 7, 0, 42, 9, 7, 1, 0, 5, 1, 9, 5, 8, 1, 40, 1, 8666, 45, 0, 3, 7, 1, 8, 8, 7, 0, 34, 7, 8, 40, 7, 8898, 3, 7, 2, 42, 8, 7, 1, 0, 5, 4, 8, 0, 7, 23, 0, 0, 8, 13, 0, 8, 9, 7, 8, 0, 8, 30, 0, 8, 10, 4, 8, 0, 8, 261, 0, 8, 11, 10, 8, 43, 8, 10, 11, 0, 43, 10, 7, 9, 1, 8, 0, 7, 13, 0, 8, 8, 10, 7, 0, 7, 18, 0, 8, 9, 4, 7, 0, 7, 68, 0, 43, 11, 4, 9, 1, 7, 43, 7, 10, 8, 1, 11, 5, 5, 7, 0, 7, 23, 0, 0, 8, 13, 0, 8, 9, 7, 8, 0, 8, 262, 0, 43, 10, 7, 9, 2, 4, 8, 0, 7, 13, 0, 8, 8, 10, 7, 3, 7, 3, 0, 9, 218, 0, 8, 11, 7, 9, 0, 9, 23, 0, 0, 12, 13, 0, 8, 13, 9, 12, 0, 12, 262, 0, 43, 14, 9, 13, 2, 5, 12, 43, 9, 7, 11, 1, 14, 43, 7, 10, 8, 1, 9, 0, 8, 13, 0, 8, 9, 7, 8, 43, 8, 7, 9, 1, 5, 0, 7, 263, 0, 8, 9, 8, 7, 0, 7, 262, 0, 43, 10, 8, 9, 1, 7, 5, 6, 10, 3, 7, 4, 47, 8, 8913, 2, 6, 3, 0, 0, 5, 0, 1, 1, 0, 42, 9, 7, 2, 6, 8, 3, 7, 1, 8, 8, 7, 0, 45, 8, 0, 7, 0, 0, 45, 7, 4, 3, 3, 4, 0, 30, 5, 1, 4, 40, 5, 8944, 3, 4, 1, 3, 5, 2, 9, 4, 5, 1, 0, 4, 68, 0, 34, 5, 4, 45, 5, 0, 4, 0, 0, 45, 4, 4, 4, 0, 5, 30, 0, 37, 6, 5, 26, 5, 2, 6, 5, 6, 5, 40, 6, 8983, 3, 5, 0, 42, 7, 5, 1, 0, 5, 2, 7, 5, 6, 2, 34, 5, 2, 5, 6, 5, 40, 6, 9005, 3, 5, 1, 8, 7, 5, 0, 34, 5, 7, 5, 6, 5, 5, 5, 6, 40, 5, 9022, 3, 6, 2, 42, 7, 6, 1, 1, 5, 5, 7, 40, 5, 9053, 0, 5, 23, 0, 0, 6, 13, 0, 8, 7, 5, 6, 0, 6, 264, 0, 43, 8, 5, 7, 2, 1, 6, 5, 5, 8, 39, 9056, 5, 5, 1, 45, 5, 0, 5, 0, 0, 45, 5, 4, 3, 3, 7, 0, 42, 8, 7, 1, 0, 40, 8, 9217, 3, 7, 1, 42, 8, 7, 1, 0, 5, 4, 8, 3, 7, 2, 42, 8, 7, 2, 0, 4, 5, 0, 8, 0, 7, 29, 0, 8, 8, 2, 7, 0, 7, 215, 0, 22, 9, 8, 7, 40, 9, 9159, 0, 7, 30, 0, 8, 8, 3, 7, 5, 7, 8, 40, 7, 9154, 3, 8, 3, 0, 9, 30, 0, 8, 10, 3, 9, 42, 9, 8, 3, 10, 0, 4, 5, 7, 9, 5, 8, 7, 39, 9215, 40, 0, 9209, 3, 7, 4, 42, 9, 7, 3, 0, 1, 4, 5, 1, 9, 0, 7, 38, 0, 8, 9, 3, 7, 47, 7, 9256, 2, 10, 4, 0, 0, 5, 1, 4, 1, 0, 1, 1, 43, 10, 3, 9, 1, 7, 5, 7, 10, 39, 9212, 5, 7, 3, 5, 8, 7, 45, 8, 52, 5, 0, 53, 7, 5, 9248, 5, 6, 7, 0, 7, 155, 0, 8, 8, 3, 7, 8, 7, 0, 6, 43, 9, 3, 8, 2, 6, 7, 39, 9220, 45, 3, 0, 7, 0, 0, 45, 7, 4, 3, 3, 4, 0, 42, 5, 4, 1, 1, 5, 4, 5, 40, 4, 9336, 3, 5, 1, 40, 5, 9312, 0, 5, 133, 0, 8, 6, 1, 5, 0, 5, 265, 0, 8, 7, 6, 5, 3, 5, 2, 3, 8, 3, 43, 9, 6, 7, 2, 5, 8, 5, 5, 9, 39, 9333, 0, 6, 133, 0, 8, 7, 1, 6, 3, 6, 2, 3, 8, 3, 9, 7, 6, 8, 5, 5, 8, 5, 4, 5, 0, 4, 0, 0, 45, 4, 4, 3, 54, 9358, 4, 42, 5, 0, 1, 1, 55, 45, 5, 55, 39, 9360, 45, 1, 0, 5, 0, 0, 45, 5, 4, 3, 0, 5, 266, 0, 8, 6, 0, 5, 8, 5, 6, 1, 5, 6, 5, 41, 6, 9409, 0, 5, 266, 0, 8, 7, 0, 5, 3, 5, 0, 42, 8, 5, 1, 1, 8, 5, 7, 8, 5, 6, 5, 5, 4, 6, 3, 5, 1, 0, 6, 191, 0, 8, 7, 5, 6, 43, 6, 5, 7, 1, 4, 40, 6, 9437, 5, 5, 4, 39, 9461, 3, 6, 2, 2, 7, 267, 0, 0, 8, 268, 0, 8, 9, 7, 8, 42, 7, 6, 2, 9, 4, 5, 5, 7, 45, 5, 0, 5, 0, 0, 45, 5, 4, 4, 3, 5, 0, 2, 6, 267, 0, 0, 7, 269, 0, 8, 8, 6, 7, 42, 6, 5, 2, 8, 2, 5, 2, 6, 0, 5, 266, 0, 8, 6, 0, 5, 3, 5, 1, 42, 7, 5, 1, 1, 9, 6, 7, 2, 0, 5, 0, 0, 45, 5, 4, 3, 34, 8, 0, 40, 8, 9606, 0, 8, 30, 0, 8, 9, 3, 8, 34, 8, 9, 40, 8, 9549, 0, 8, 0, 0, 45, 8, 49, 8, 0, 5, 4, 8, 0, 8, 30, 0, 8, 9, 3, 8, 0, 8, 266, 0, 8, 10, 9, 8, 52, 5, 10, 53, 8, 5, 9604, 5, 6, 8, 3, 8, 0, 0, 9, 30, 0, 8, 10, 3, 9, 42, 9, 8, 2, 10, 6, 9, 4, 6, 9, 39, 9574, 45, 4, 3, 8, 1, 42, 9, 8, 1, 0, 40, 9, 9726, 0, 8, 29, 0, 8, 9, 2, 8, 0, 8, 215, 0, 22, 10, 9, 8, 40, 10, 9675, 0, 8, 30, 0, 8, 9, 3, 8, 5, 8, 9, 40, 8, 9670, 3, 9, 0, 0, 10, 30, 0, 8, 11, 3, 10, 42, 10, 9, 2, 11, 0, 5, 8, 10, 5, 9, 8, 39, 9724, 3, 8, 2, 42, 10, 8, 1, 1, 40, 10, 9691, 5, 8, 3, 39, 9721, 0, 10, 38, 0, 8, 11, 3, 10, 47, 10, 9765, 2, 8, 3, 0, 0, 3, 1, 0, 1, 1, 43, 12, 3, 11, 1, 10, 5, 8, 12, 5, 9, 8, 45, 9, 52, 7, 0, 53, 8, 7, 9757, 5, 6, 8, 0, 8, 157, 0, 8, 9, 3, 8, 8, 8, 0, 6, 43, 10, 3, 9, 2, 6, 8, 39, 9729, 45, 3, 0, 8, 0, 0, 45, 8, 4, 3, 3, 4, 0, 3, 5, 1, 3, 6, 2, 42, 7, 4, 3, 1, 5, 6, 0, 4, 0, 0, 45, 4, 4, 3, 0, 5, 3, 0, 8, 6, 0, 5, 5, 4, 6, 2, 5, 270, 0, 0, 6, 271, 0, 8, 7, 5, 6, 0, 6, 230, 0, 8, 8, 0, 6, 0, 6, 272, 0, 0, 9, 13, 0, 8, 10, 6, 9, 43, 9, 6, 10, 1, 1, 8, 6, 8, 9, 0, 8, 272, 0, 0, 9, 13, 0, 8, 10, 8, 9, 43, 9, 8, 10, 1, 1, 8, 8, 4, 9, 0, 9, 230, 0, 8, 10, 0, 9, 0, 9, 110, 0, 0, 11, 13, 0, 8, 12, 9, 11, 43, 11, 9, 12, 1, 1, 8, 9, 10, 11, 0, 10, 110, 0, 0, 11, 13, 0, 8, 12, 10, 11, 43, 11, 10, 12, 1, 1, 8, 10, 4, 11, 0, 11, 273, 0, 0, 12, 13, 0, 8, 13, 11, 12, 43, 12, 11, 13, 1, 1, 8, 11, 4, 12, 43, 12, 5, 7, 5, 6, 8, 9, 10, 11, 45, 12, 0, 5, 0, 0, 45, 5, 4, 2, 3, 3, 0, 8, 4, 3, 0, 5, 3, 4, 41, 3, 9983, 3, 4, 1, 8, 5, 4, 0, 5, 3, 5, 5, 4, 3, 41, 4, 9992, 5, 4, 0, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 0, 4, 263, 0, 8, 5, 0, 4, 3, 4, 0, 43, 6, 0, 5, 1, 4, 5, 3, 6, 0, 4, 30, 0, 8, 5, 3, 4, 0, 4, 18, 0, 8, 6, 3, 4, 0, 4, 68, 0, 43, 7, 3, 6, 1, 4, 0, 4, 274, 0, 8, 6, 7, 4, 43, 4, 7, 6, 0, 48, 6, 2, 5, 4, 45, 6, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 3, 4, 0, 8, 5, 0, 4, 5, 4, 5, 41, 4, 10098, 49, 5, 0, 5, 4, 5, 9, 0, 3, 4, 45, 4, 0, 3, 0, 0, 45, 3, 4, 6, 3, 8, 0, 42, 9, 8, 1, 0, 5, 7, 9, 8, 8, 7, 1, 5, 9, 8, 41, 9, 10139, 48, 8, 0, 5, 9, 8, 9, 7, 1, 9, 8, 8, 7, 1, 0, 9, 17, 0, 8, 10, 8, 9, 48, 9, 3, 2, 3, 4, 43, 11, 8, 10, 1, 9, 0, 8, 275, 0, 8, 9, 0, 8, 43, 8, 0, 9, 2, 1, 4, 0, 8, 0, 0, 45, 8, 4, 3, 34, 4, 1, 5, 5, 4, 41, 5, 10232, 3, 4, 0, 0, 6, 210, 0, 8, 7, 4, 6, 47, 6, 10240, 1, 6, 1, 0, 1, 0, 43, 8, 4, 7, 2, 1, 6, 34, 4, 8, 5, 5, 4, 45, 5, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 0, 4, 15, 0, 8, 5, 3, 4, 43, 4, 3, 5, 1, 0, 0, 3, 30, 0, 22, 5, 4, 3, 45, 5, 0, 3, 0, 0, 45, 3, 4, 6, 3, 9, 0, 42, 10, 9, 1, 0, 5, 7, 10, 40, 1, 10347, 8, 9, 7, 1, 5, 10, 9, 40, 10, 10345, 8, 9, 7, 1, 0, 11, 14, 0, 8, 12, 9, 11, 47, 11, 10377, 1, 11, 6, 0, 1, 4, 0, 1, 1, 2, 1, 3, 1, 0, 1, 1, 43, 13, 9, 12, 1, 11, 9, 7, 1, 13, 5, 10, 13, 39, 10371, 52, 8, 7, 53, 9, 8, 10371, 5, 1, 9, 3, 9, 2, 42, 10, 9, 5, 0, 1, 2, 3, 4, 39, 10350, 0, 9, 0, 0, 45, 9, 4, 2, 0, 6, 30, 0, 8, 7, 0, 6, 5, 3, 7, 0, 6, 68, 0, 8, 7, 0, 6, 5, 4, 7, 0, 6, 215, 0, 8, 7, 0, 6, 5, 5, 7, 3, 6, 0, 5, 7, 6, 40, 7, 10447, 0, 6, 67, 0, 8, 8, 5, 6, 3, 6, 0, 0, 9, 67, 0, 8, 10, 6, 9, 27, 6, 8, 10, 5, 7, 6, 5, 6, 7, 41, 6, 10471, 3, 7, 1, 3, 8, 2, 42, 9, 7, 2, 3, 8, 34, 7, 9, 5, 6, 7, 5, 7, 6, 41, 7, 10499, 3, 6, 3, 5, 8, 6, 40, 8, 10496, 3, 6, 3, 27, 9, 6, 4, 5, 8, 9, 5, 7, 8, 40, 7, 10511, 0, 6, 30, 0, 34, 7, 6, 45, 7, 3, 6, 4, 0, 7, 276, 0, 8, 8, 6, 7, 3, 7, 5, 43, 9, 6, 8, 2, 7, 5, 0, 6, 0, 0, 45, 6, 4, 6, 5, 7, 6, 3, 10, 0, 42, 11, 10, 1, 0, 34, 10, 11, 40, 10, 10593, 52, 8, 0, 53, 10, 8, 10591, 5, 9, 10, 0, 10, 180, 0, 8, 11, 6, 10, 8, 10, 0, 9, 43, 12, 6, 11, 5, 9, 1, 2, 10, 4, 39, 10560, 45, 6, 3, 10, 0, 42, 11, 10, 1, 1, 5, 10, 11, 41, 10, 10695, 3, 11, 1, 42, 12, 11, 1, 1, 5, 11, 12, 41, 11, 10632, 3, 12, 2, 42, 13, 12, 1, 1, 5, 11, 13, 40, 11, 10647, 0, 11, 23, 0, 5, 1, 11, 5, 11, 1, 39, 10692, 3, 12, 1, 42, 13, 12, 1, 2, 40, 13, 10673, 5, 2, 1, 0, 12, 23, 0, 5, 1, 12, 5, 12, 1, 39, 10689, 5, 3, 2, 5, 2, 1, 0, 13, 23, 0, 5, 1, 13, 5, 12, 1, 5, 11, 12, 5, 10, 11, 3, 10, 3, 42, 11, 10, 1, 3, 5, 10, 11, 41, 10, 10725, 5, 3, 2, 0, 11, 30, 0, 37, 12, 11, 5, 2, 12, 5, 10, 2, 40, 3, 10793, 3, 10, 4, 3, 11, 5, 42, 12, 11, 1, 0, 47, 11, 10804, 2, 15, 18, 0, 0, 6, 0, 7, 0, 8, 0, 9, 1, 7, 0, 10, 0, 11, 0, 12, 0, 13, 0, 14, 1, 1, 0, 15, 1, 2, 1, 3, 1, 4, 0, 16, 0, 17, 0, 18, 42, 13, 10, 2, 12, 11, 5, 10, 6, 39, 10796, 5, 10, 6, 45, 10, 0, 10, 0, 0, 45, 10, 4, 3, 3, 10, 0, 42, 11, 10, 1, 1, 5, 4, 11, 0, 10, 30, 0, 8, 11, 4, 10, 5, 5, 11, 0, 10, 68, 0, 8, 11, 4, 10, 5, 6, 11, 3, 10, 1, 42, 11, 10, 1, 5, 5, 7, 11, 3, 10, 2, 30, 11, 5, 10, 5, 8, 11, 3, 10, 3, 30, 11, 5, 10, 5, 9, 11, 5, 10, 7, 40, 10, 10937, 3, 11, 4, 0, 12, 38, 0, 8, 13, 11, 12, 47, 12, 10943, 2, 15, 17, 0, 0, 5, 0, 6, 0, 7, 0, 8, 1, 6, 0, 9, 0, 10, 1, 9, 1, 7, 1, 8, 0, 11, 0, 12, 0, 13, 0, 14, 0, 15, 0, 16, 0, 17, 43, 14, 11, 13, 1, 12, 5, 10, 14, 0, 10, 0, 0, 45, 10, 4, 3, 3, 5, 0, 42, 6, 5, 1, 1, 34, 5, 6, 5, 6, 5, 40, 6, 10976, 3, 5, 1, 42, 7, 5, 1, 1, 34, 5, 7, 5, 6, 5, 5, 5, 6, 40, 5, 10996, 3, 6, 2, 42, 7, 6, 1, 1, 34, 6, 7, 5, 5, 6, 34, 6, 5, 40, 6, 11126, 47, 5, 11132, 1, 14, 14, 0, 0, 3, 0, 4, 0, 5, 0, 6, 0, 7, 1, 1, 0, 8, 0, 9, 0, 10, 0, 11, 0, 12, 0, 13, 0, 14, 1, 4, 5, 4, 5, 0, 5, 67, 0, 3, 6, 12, 0, 7, 67, 0, 3, 8, 12, 0, 9, 67, 0, 8, 10, 8, 9, 5, 8, 10, 41, 8, 11097, 3, 9, 15, 0, 10, 67, 0, 8, 11, 9, 10, 5, 12, 11, 0, 13, 68, 0, 11, 14, 11, 13, 9, 9, 10, 14, 5, 8, 12, 9, 6, 7, 8, 9, 4, 5, 8, 3, 5, 16, 3, 6, 8, 3, 7, 4, 3, 8, 6, 42, 9, 5, 5, 1, 6, 7, 8, 4, 0, 5, 0, 0, 45, 5, 4, 2, 0, 6, 277, 0, 8, 7, 0, 6, 0, 6, 278, 0, 0, 8, 13, 0, 8, 9, 6, 8, 0, 8, 80, 0, 8, 10, 0, 8, 43, 8, 6, 9, 1, 10, 8, 6, 7, 8, 40, 6, 11190, 0, 6, 279, 0, 8, 7, 0, 6, 43, 6, 0, 7, 0, 45, 6, 0, 6, 280, 0, 8, 7, 0, 6, 5, 6, 7, 40, 6, 11247, 3, 7, 0, 3, 8, 1, 0, 9, 280, 0, 8, 10, 0, 9, 0, 9, 263, 0, 8, 11, 10, 9, 3, 9, 2, 43, 12, 10, 11, 1, 9, 42, 9, 7, 2, 8, 12, 34, 7, 9, 5, 6, 7, 34, 7, 6, 5, 6, 7, 40, 6, 11391, 3, 7, 3, 34, 8, 7, 5, 7, 8, 40, 7, 11385, 3, 8, 4, 5, 9, 8, 40, 9, 11319, 0, 8, 277, 0, 8, 10, 0, 8, 3, 8, 5, 27, 11, 10, 8, 5, 8, 11, 41, 8, 11316, 0, 10, 281, 0, 8, 11, 0, 10, 3, 10, 6, 26, 12, 11, 10, 5, 8, 12, 5, 9, 8, 5, 8, 9, 41, 8, 11382, 3, 9, 7, 5, 10, 9, 40, 10, 11345, 0, 9, 282, 0, 8, 11, 0, 9, 5, 10, 11, 5, 9, 10, 40, 9, 11379, 3, 10, 5, 0, 11, 251, 0, 8, 12, 10, 11, 0, 11, 282, 0, 8, 13, 0, 11, 43, 11, 10, 12, 1, 13, 5, 9, 11, 5, 8, 9, 5, 7, 8, 34, 8, 7, 5, 6, 8, 40, 6, 11759, 3, 6, 5, 5, 3, 6, 3, 6, 3, 40, 6, 11479, 0, 6, 277, 0, 8, 7, 0, 6, 5, 4, 7, 3, 6, 8, 3, 7, 3, 42, 8, 6, 2, 4, 7, 34, 6, 8, 40, 6, 11476, 3, 6, 5, 26, 7, 4, 6, 5, 6, 7, 41, 6, 11465, 0, 7, 247, 0, 8, 8, 4, 7, 5, 4, 8, 34, 7, 4, 5, 6, 7, 40, 6, 11474, 0, 6, 0, 0, 45, 6, 39, 11417, 5, 3, 4, 2, 6, 208, 0, 0, 7, 283, 0, 8, 8, 6, 7, 0, 7, 284, 0, 0, 9, 285, 0, 0, 10, 30, 0, 34, 11, 10, 0, 10, 49, 0, 47, 12, 11765, 0, 3, 1, 0, 1, 3, 49, 13, 2, 9, 11, 10, 12, 43, 9, 6, 8, 3, 0, 7, 13, 2, 6, 208, 0, 0, 7, 283, 0, 8, 8, 6, 7, 0, 7, 286, 0, 0, 9, 285, 0, 0, 10, 30, 0, 34, 11, 10, 0, 10, 49, 0, 47, 12, 11778, 0, 3, 1, 0, 0, 5, 49, 13, 2, 9, 11, 10, 12, 43, 9, 6, 8, 3, 0, 7, 13, 2, 6, 208, 0, 0, 7, 283, 0, 8, 8, 6, 7, 0, 7, 157, 0, 0, 9, 285, 0, 0, 10, 30, 0, 34, 11, 10, 0, 10, 49, 0, 47, 12, 11791, 0, 3, 1, 0, 0, 9, 49, 13, 2, 9, 11, 10, 12, 43, 9, 6, 8, 3, 0, 7, 13, 3, 6, 10, 0, 7, 210, 0, 8, 8, 6, 7, 0, 7, 287, 0, 8, 9, 0, 7, 43, 7, 6, 8, 3, 3, 0, 9, 5, 5, 7, 3, 6, 11, 5, 7, 6, 40, 7, 11713, 3, 6, 12, 3, 8, 5, 3, 9, 6, 3, 10, 1, 3, 11, 3, 3, 12, 13, 42, 13, 6, 5, 8, 9, 10, 11, 12, 5, 7, 13, 0, 6, 68, 0, 34, 7, 6, 26, 6, 5, 7, 5, 7, 6, 40, 7, 11759, 0, 6, 288, 0, 8, 8, 0, 6, 43, 6, 0, 8, 0, 0, 6, 289, 0, 8, 8, 0, 6, 43, 6, 0, 8, 0, 5, 7, 6, 0, 6, 0, 0, 45, 6, 4, 1, 3, 2, 0, 45, 2, 0, 2, 0, 0, 45, 2, 4, 1, 3, 2, 0, 45, 2, 0, 2, 0, 0, 45, 2, 4, 1, 3, 2, 0, 45, 2, 0, 2, 0, 0, 45, 2, 4, 5, 0, 6, 180, 0, 8, 7, 5, 6, 0, 6, 30, 0, 34, 8, 6, 43, 6, 5, 7, 5, 0, 1, 2, 3, 8, 45, 6, 0, 6, 0, 0, 45, 6, 4, 3, 0, 4, 290, 0, 0, 5, 13, 0, 8, 6, 4, 5, 2, 5, 291, 0, 42, 7, 5, 1, 0, 0, 5, 292, 0, 43, 8, 4, 6, 2, 7, 5, 0, 4, 13, 0, 8, 5, 8, 4, 2, 4, 291, 0, 0, 6, 193, 0, 8, 7, 1, 6, 3, 6, 0, 0, 9, 293, 0, 43, 10, 1, 7, 2, 6, 9, 42, 6, 4, 1, 10, 43, 4, 8, 5, 1, 6, 45, 4, 0, 4, 0, 0, 45, 4, 4, 1, 47, 3, 11993, 2, 15, 11, 0, 0, 0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 7, 0, 8, 0, 9, 0, 10, 5, 2, 3, 0, 3, 12, 0, 8, 4, 2, 3, 0, 3, 27, 0, 47, 5, 12428, 2, 6, 1, 0, 1, 2, 9, 4, 3, 5, 45, 2, 0, 3, 0, 0, 45, 3, 4, 3, 40, 0, 12422, 3, 8, 0, 42, 9, 8, 1, 0, 40, 9, 12011, 45, 0, 5, 4, 0, 3, 8, 1, 42, 9, 8, 1, 0, 40, 9, 12290, 5, 8, 1, 41, 8, 12037, 3, 9, 2, 5, 8, 9, 5, 5, 8, 3, 8, 3, 0, 9, 191, 0, 8, 10, 8, 9, 43, 9, 8, 10, 1, 0, 5, 8, 9, 40, 8, 12074, 3, 9, 4, 42, 10, 9, 1, 5, 5, 8, 10, 40, 8, 12151, 0, 8, 294, 0, 8, 9, 5, 8, 0, 8, 18, 0, 8, 10, 0, 8, 0, 8, 68, 0, 43, 11, 0, 10, 1, 8, 0, 8, 193, 0, 8, 10, 11, 8, 2, 8, 21, 0, 0, 12, 194, 0, 0, 13, 42, 0, 44, 14, 8, 2, 12, 13, 0, 8, 23, 0, 43, 12, 11, 10, 2, 14, 8, 43, 8, 5, 9, 1, 12, 5, 9, 8, 39, 12273, 3, 8, 5, 0, 10, 191, 0, 8, 11, 8, 10, 43, 10, 8, 11, 1, 0, 40, 10, 12184, 3, 8, 6, 42, 10, 8, 1, 0, 5, 8, 10, 39, 12270, 3, 10, 0, 42, 11, 10, 1, 5, 40, 11, 12214, 0, 10, 76, 0, 8, 11, 5, 10, 43, 10, 5, 11, 1, 0, 5, 11, 10, 39, 12267, 3, 10, 1, 42, 12, 10, 1, 5, 40, 12, 12252, 3, 10, 7, 42, 12, 10, 1, 5, 0, 10, 76, 0, 8, 13, 12, 10, 43, 10, 12, 13, 1, 0, 5, 12, 10, 39, 12264, 3, 10, 8, 42, 13, 10, 2, 0, 5, 5, 12, 13, 5, 11, 12, 5, 8, 11, 5, 9, 8, 5, 4, 9, 34, 8, 4, 40, 8, 12288, 0, 8, 0, 0, 45, 8, 39, 12317, 3, 8, 9, 42, 9, 8, 1, 0, 40, 9, 12317, 0, 8, 108, 0, 8, 9, 3, 8, 43, 8, 3, 9, 1, 0, 45, 8, 0, 8, 197, 0, 8, 9, 4, 8, 5, 8, 9, 41, 8, 12341, 3, 9, 10, 26, 10, 4, 9, 5, 8, 10, 5, 9, 8, 40, 9, 12357, 48, 8, 1, 4, 5, 4, 8, 5, 9, 4, 0, 8, 29, 0, 0, 9, 29, 0, 8, 10, 4, 9, 9, 3, 8, 10, 0, 8, 30, 0, 5, 6, 8, 0, 8, 29, 0, 8, 9, 3, 8, 5, 7, 9, 22, 8, 6, 7, 40, 8, 12422, 8, 8, 4, 6, 9, 3, 6, 8, 5, 8, 6, 0, 8, 68, 0, 11, 9, 6, 8, 5, 6, 9, 39, 12391, 0, 8, 0, 0, 45, 8, 4, 3, 3, 4, 0, 44, 5, 4, 2, 0, 1, 45, 5, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 42, 4, 3, 2, 2, 0, 45, 4, 0, 3, 0, 0, 45, 3, 4, 1, 0, 2, 38, 0, 8, 3, 1, 2, 47, 2, 12497, 2, 7, 0, 0, 43, 4, 1, 3, 1, 2, 45, 4, 0, 2, 0, 0, 45, 2, 4, 3, 0, 4, 253, 0, 8, 5, 1, 4, 40, 5, 12534, 0, 4, 232, 0, 8, 5, 1, 4, 0, 4, 253, 0, 8, 6, 1, 4, 43, 4, 1, 5, 1, 6, 39, 12499, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 3, 4, 1, 42, 5, 3, 2, 4, 0, 45, 5, 0, 3, 0, 0, 45, 3, 4, 3, 3, 6, 0, 42, 7, 6, 1, 0, 5, 4, 7, 3, 6, 1, 42, 7, 6, 1, 1, 34, 6, 7, 5, 5, 6, 0, 6, 38, 0, 8, 7, 3, 6, 47, 6, 12628, 2, 9, 5, 0, 0, 2, 0, 3, 1, 4, 1, 5, 1, 1, 43, 8, 3, 7, 1, 6, 45, 8, 0, 6, 0, 0, 45, 6, 4, 3, 3, 4, 0, 42, 5, 4, 1, 1, 5, 4, 5, 40, 4, 12672, 3, 5, 1, 3, 6, 2, 47, 7, 12678, 2, 8, 3, 0, 0, 3, 0, 4, 1, 1, 42, 8, 5, 2, 6, 7, 5, 4, 8, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 40, 4, 12755, 3, 4, 1, 40, 4, 12722, 3, 4, 2, 0, 5, 295, 0, 8, 6, 4, 5, 0, 4, 70, 0, 8, 5, 6, 4, 43, 4, 6, 5, 1, 1, 5, 5, 4, 39, 12750, 3, 4, 2, 0, 6, 295, 0, 8, 7, 4, 6, 0, 4, 177, 0, 8, 6, 7, 4, 43, 4, 7, 6, 1, 1, 5, 5, 4, 5, 4, 5, 39, 12783, 3, 5, 2, 0, 6, 295, 0, 8, 7, 5, 6, 0, 5, 161, 0, 8, 6, 7, 5, 43, 5, 7, 6, 1, 1, 5, 4, 5, 0, 4, 0, 0, 45, 4, 4, 2, 0, 3, 43, 0, 8, 4, 2, 3, 0, 3, 30, 0, 34, 5, 3, 43, 3, 2, 4, 2, 0, 5, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 3, 4, 0, 42, 5, 4, 1, 0, 5, 3, 5, 0, 4, 38, 0, 8, 5, 2, 4, 47, 4, 12869, 2, 9, 3, 0, 0, 1, 0, 2, 1, 3, 43, 6, 2, 5, 1, 4, 45, 6, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 42, 5, 4, 1, 1, 5, 4, 5, 40, 4, 12909, 3, 5, 1, 3, 6, 2, 47, 7, 12915, 2, 7, 1, 0, 1, 1, 42, 8, 5, 2, 6, 7, 5, 4, 8, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 0, 5, 296, 0, 8, 6, 4, 5, 43, 5, 4, 6, 1, 1, 0, 4, 0, 0, 45, 4, 4, 2, 0, 3, 29, 0, 8, 4, 1, 3, 40, 4, 12980, 0, 3, 43, 0, 8, 4, 2, 3, 0, 3, 68, 0, 34, 5, 3, 43, 3, 2, 4, 2, 0, 5, 5, 4, 3, 39, 13006, 0, 3, 46, 0, 8, 5, 2, 3, 0, 3, 112, 0, 0, 6, 23, 0, 43, 7, 2, 5, 2, 3, 6, 5, 4, 7, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 34, 3, 0, 34, 4, 3, 5, 3, 4, 40, 3, 13060, 3, 4, 0, 0, 5, 210, 0, 8, 6, 4, 5, 47, 5, 13068, 1, 8, 2, 0, 0, 1, 1, 0, 43, 7, 4, 6, 2, 2, 5, 5, 3, 7, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 42, 4, 3, 1, 0, 5, 3, 4, 40, 3, 13112, 0, 4, 295, 0, 8, 5, 0, 4, 0, 4, 251, 0, 8, 6, 5, 4, 3, 4, 1, 43, 7, 5, 6, 1, 4, 5, 3, 7, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 42, 4, 3, 1, 0, 40, 4, 13155, 3, 3, 1, 0, 4, 210, 0, 8, 5, 3, 4, 43, 4, 3, 5, 1, 2, 5, 3, 4, 39, 13205, 2, 4, 297, 0, 42, 5, 4, 1, 0, 5, 0, 5, 0, 4, 30, 0, 22, 5, 0, 4, 40, 5, 13195, 0, 4, 29, 0, 8, 5, 2, 4, 11, 4, 0, 5, 5, 5, 4, 39, 13198, 5, 5, 0, 8, 4, 2, 5, 5, 3, 4, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 49, 0, 8, 5, 2, 4, 43, 4, 2, 5, 1, 0, 42, 5, 3, 1, 4, 45, 5, 0, 3, 0, 0, 45, 3, 4, 1, 0, 2, 50, 0, 8, 3, 1, 2, 0, 2, 30, 0, 43, 4, 1, 3, 1, 2, 45, 4, 0, 2, 0, 0, 45, 2, 4, 1, 0, 2, 50, 0, 8, 3, 1, 2, 0, 2, 68, 0, 32, 4, 2, 43, 2, 1, 3, 1, 4, 45, 2, 0, 2, 0, 0, 45, 2, 4, 2, 3, 4, 0, 42, 5, 4, 1, 0, 5, 3, 5, 3, 4, 1, 3, 5, 2, 0, 6, 210, 0, 8, 7, 5, 6, 47, 6, 13360, 2, 7, 1, 0, 1, 3, 43, 8, 5, 7, 2, 2, 6, 42, 5, 4, 1, 8, 45, 5, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 0, 5, 210, 0, 8, 6, 4, 5, 43, 5, 4, 6, 3, 0, 1, 0, 45, 5, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 42, 4, 3, 2, 2, 0, 0, 3, 38, 0, 8, 5, 4, 3, 47, 3, 13429, 2, 8, 0, 0, 43, 6, 4, 5, 1, 3, 45, 2, 0, 3, 0, 0, 45, 3, 4, 3, 0, 4, 247, 0, 8, 5, 1, 4, 5, 4, 5, 40, 4, 13470, 0, 5, 247, 0, 8, 6, 1, 5, 0, 5, 232, 0, 8, 7, 6, 5, 43, 5, 6, 7, 1, 1, 5, 4, 5, 0, 4, 0, 0, 45, 4, 4, 2, 3, 4, 0, 42, 5, 4, 1, 0, 40, 5, 13505, 47, 4, 13542, 2, 7, 2, 0, 0, 1, 1, 0, 5, 5, 4, 39, 13517, 47, 4, 13572, 2, 7, 1, 0, 1, 0, 5, 5, 4, 5, 3, 5, 0, 4, 14, 0, 8, 5, 2, 4, 43, 4, 2, 5, 1, 3, 45, 4, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 3, 5, 1, 42, 6, 4, 2, 5, 1, 0, 4, 29, 0, 8, 5, 6, 4, 45, 5, 0, 4, 0, 0, 45, 4, 4, 3, 0, 4, 251, 0, 8, 5, 1, 4, 3, 4, 0, 43, 6, 1, 5, 1, 4, 45, 6, 0, 4, 0, 0, 45, 4, 4, 2, 3, 4, 0, 42, 5, 4, 1, 0, 5, 3, 5, 0, 4, 14, 0, 8, 5, 2, 4, 47, 4, 13649, 2, 8, 4, 0, 0, 1, 1, 0, 0, 2, 1, 3, 43, 6, 2, 5, 1, 4, 45, 6, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 3, 5, 1, 42, 6, 4, 1, 5, 34, 4, 6, 5, 5, 4, 41, 5, 13682, 3, 4, 2, 42, 6, 4, 1, 1, 5, 5, 6, 5, 4, 5, 40, 4, 13713, 3, 5, 3, 0, 6, 210, 0, 8, 7, 5, 6, 43, 6, 5, 7, 3, 1, 0, 1, 34, 5, 6, 5, 4, 5, 45, 4, 0, 4, 0, 0, 45, 4, 4, 2, 3, 4, 0, 42, 5, 4, 1, 0, 5, 3, 5, 3, 4, 1, 0, 5, 210, 0, 8, 6, 4, 5, 47, 5, 13769, 2, 7, 1, 0, 1, 3, 43, 7, 4, 6, 2, 2, 5, 45, 7, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 0, 5, 210, 0, 8, 6, 4, 5, 43, 5, 4, 6, 3, 0, 1, 0, 45, 5, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 3, 5, 1, 0, 6, 49, 0, 8, 7, 3, 6, 43, 6, 3, 7, 0, 0, 7, 13, 0, 8, 8, 6, 7, 3, 7, 0, 42, 9, 7, 2, 0, 1, 0, 7, 49, 0, 8, 10, 9, 7, 43, 7, 9, 10, 0, 43, 9, 6, 8, 1, 7, 42, 6, 5, 1, 9, 42, 5, 4, 1, 6, 45, 5, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 3, 4, 1, 3, 5, 2, 3, 6, 3, 47, 7, 13924, 1, 5, 0, 0, 42, 8, 6, 2, 2, 7, 42, 6, 5, 1, 8, 42, 5, 4, 1, 6, 42, 4, 3, 2, 5, 0, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 0, 3, 71, 0, 8, 4, 0, 3, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 3, 4, 1, 3, 5, 2, 3, 6, 3, 0, 7, 247, 0, 42, 8, 6, 2, 2, 7, 42, 6, 5, 1, 8, 42, 5, 4, 1, 6, 42, 4, 3, 2, 5, 0, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 40, 0, 14016, 3, 5, 0, 42, 6, 5, 1, 0, 0, 5, 30, 0, 8, 7, 6, 5, 5, 5, 7, 39, 14027, 0, 6, 30, 0, 8, 7, 2, 6, 5, 5, 7, 5, 3, 5, 40, 0, 14038, 5, 5, 2, 39, 14075, 3, 6, 0, 42, 7, 6, 1, 3, 0, 6, 72, 0, 8, 8, 7, 6, 43, 6, 7, 8, 0, 0, 7, 71, 0, 8, 8, 6, 7, 43, 7, 6, 8, 0, 5, 5, 7, 5, 4, 5, 3, 5, 1, 0, 6, 210, 0, 8, 7, 5, 6, 43, 6, 5, 7, 2, 4, 3, 45, 6, 0, 5, 0, 0, 45, 5, 4, 2, 0, 5, 14, 0, 8, 6, 2, 5, 43, 5, 2, 6, 1, 0, 5, 3, 5, 0, 5, 29, 0, 8, 6, 3, 5, 40, 6, 14136, 45, 3, 0, 5, 72, 0, 8, 6, 2, 5, 43, 5, 2, 6, 0, 5, 4, 5, 0, 5, 29, 0, 8, 6, 4, 5, 40, 6, 14182, 0, 5, 74, 0, 8, 6, 4, 5, 43, 5, 4, 6, 1, 0, 5, 6, 5, 39, 14185, 5, 6, 3, 45, 6, 0, 5, 0, 0, 45, 5, 4, 2, 3, 3, 0, 3, 4, 1, 3, 5, 2, 3, 6, 3, 47, 7, 14246, 1, 6, 1, 0, 0, 1, 42, 8, 6, 2, 2, 7, 42, 6, 5, 1, 8, 42, 5, 4, 1, 6, 42, 4, 3, 2, 5, 0, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 42, 4, 3, 1, 0, 0, 3, 72, 0, 8, 5, 4, 3, 43, 3, 4, 5, 0, 0, 4, 71, 0, 8, 5, 3, 4, 43, 4, 3, 5, 0, 0, 3, 64, 0, 8, 5, 4, 3, 43, 3, 4, 5, 1, 0, 45, 3, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 3, 4, 1, 3, 5, 2, 47, 6, 14350, 1, 6, 2, 0, 0, 3, 1, 0, 42, 7, 5, 2, 2, 6, 42, 5, 4, 1, 7, 42, 4, 3, 1, 5, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 3, 4, 1, 42, 5, 3, 2, 4, 0, 45, 5, 0, 3, 0, 0, 45, 3, 4, 1, 3, 2, 0, 0, 3, 68, 0, 34, 4, 3, 0, 3, 68, 0, 34, 5, 3, 0, 3, 68, 0, 34, 6, 3, 0, 3, 30, 0, 34, 7, 3, 0, 3, 30, 0, 34, 8, 3, 42, 3, 2, 7, 0, 1, 4, 5, 6, 7, 8, 45, 3, 0, 2, 0, 0, 45, 2, 4, 1, 3, 2, 0, 0, 3, 68, 0, 34, 4, 3, 0, 3, 68, 0, 34, 5, 3, 0, 3, 30, 0, 34, 6, 3, 42, 3, 2, 5, 0, 1, 4, 5, 6, 45, 3, 0, 2, 0, 0, 45, 2, 4, 2, 3, 3, 0, 0, 4, 30, 0, 34, 5, 4, 0, 4, 68, 0, 34, 6, 4, 0, 4, 30, 0, 34, 7, 4, 42, 4, 3, 5, 1, 2, 5, 6, 7, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 0, 3, 38, 0, 8, 4, 2, 3, 47, 3, 14552, 2, 10, 2, 0, 0, 0, 1, 0, 43, 5, 2, 4, 1, 3, 45, 5, 0, 3, 0, 0, 45, 3, 4, 3, 3, 6, 0, 42, 7, 6, 1, 1, 5, 4, 7, 0, 6, 96, 0, 8, 7, 4, 6, 43, 6, 4, 7, 0, 5, 5, 6, 0, 6, 29, 0, 8, 7, 5, 6, 40, 7, 14614, 0, 6, 90, 0, 8, 7, 5, 6, 3, 6, 1, 43, 8, 5, 7, 1, 6, 5, 6, 8, 39, 14634, 0, 7, 85, 0, 8, 8, 4, 7, 3, 7, 1, 43, 9, 4, 8, 1, 7, 5, 6, 9, 0, 6, 0, 0, 45, 6, 4, 1, 3, 2, 0, 0, 3, 68, 0, 34, 4, 3, 0, 3, 30, 0, 34, 5, 3, 42, 3, 2, 4, 0, 1, 4, 5, 45, 3, 0, 2, 0, 0, 45, 2, 4, 2, 3, 5, 0, 42, 6, 5, 1, 0, 5, 3, 6, 0, 5, 30, 0, 8, 6, 3, 5, 5, 4, 6, 0, 5, 71, 0, 8, 6, 4, 5, 0, 5, 29, 0, 8, 7, 6, 5, 40, 7, 14731, 0, 5, 298, 0, 8, 6, 4, 5, 5, 4, 6, 39, 14699, 0, 5, 51, 0, 8, 6, 2, 5, 43, 5, 2, 6, 0, 0, 6, 89, 0, 8, 7, 5, 6, 43, 6, 5, 7, 1, 3, 0, 5, 87, 0, 8, 6, 2, 5, 43, 5, 2, 6, 1, 4, 45, 5, 0, 5, 0, 0, 45, 5, 4, 2, 0, 3, 38, 0, 8, 4, 2, 3, 47, 3, 14815, 2, 10, 2, 0, 0, 0, 1, 0, 43, 5, 2, 4, 1, 3, 45, 5, 0, 3, 0, 0, 45, 3, 4, 3, 3, 5, 0, 3, 6, 1, 42, 7, 5, 1, 6, 0, 5, 30, 0, 8, 6, 7, 5, 5, 4, 6, 3, 5, 0, 42, 6, 5, 1, 1, 0, 5, 90, 0, 8, 7, 6, 5, 40, 0, 14884, 0, 5, 259, 0, 8, 8, 4, 5, 0, 5, 30, 0, 34, 9, 5, 43, 5, 4, 8, 1, 9, 5, 8, 5, 39, 14887, 5, 8, 4, 43, 5, 6, 7, 1, 8, 0, 5, 0, 0, 45, 5, 4, 2, 3, 3, 0, 0, 4, 30, 0, 34, 5, 4, 0, 4, 68, 0, 34, 6, 4, 0, 4, 68, 0, 34, 7, 4, 0, 4, 68, 0, 34, 8, 4, 0, 4, 68, 0, 34, 9, 4, 0, 4, 30, 0, 34, 10, 4, 42, 4, 3, 8, 1, 2, 5, 6, 7, 8, 9, 10, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 30, 0, 34, 5, 4, 0, 4, 30, 0, 34, 6, 4, 42, 4, 3, 4, 1, 2, 5, 6, 45, 4, 0, 3, 0, 0, 45, 3, 4, 1, 3, 2, 0, 0, 3, 68, 0, 34, 4, 3, 0, 3, 30, 0, 34, 5, 3, 0, 3, 30, 0, 34, 6, 3, 0, 3, 30, 0, 34, 7, 3, 0, 3, 30, 0, 34, 8, 3, 42, 3, 2, 7, 0, 1, 4, 5, 6, 7, 8, 45, 3, 0, 2, 0, 0, 45, 2, 4, 2, 3, 3, 0, 0, 4, 30, 0, 34, 5, 4, 0, 4, 30, 0, 34, 6, 4, 0, 4, 30, 0, 34, 7, 4, 0, 4, 68, 0, 34, 8, 4, 0, 4, 68, 0, 34, 9, 4, 0, 4, 30, 0, 34, 10, 4, 42, 4, 3, 8, 1, 2, 5, 6, 7, 8, 9, 10, 45, 4, 0, 3, 0, 0, 45, 3, 4, 1, 3, 2, 0, 3, 3, 1, 3, 4, 2, 47, 5, 15169, 1, 7, 0, 0, 42, 6, 4, 2, 1, 5, 42, 4, 3, 1, 6, 42, 3, 2, 1, 4, 45, 3, 0, 2, 0, 0, 45, 2, 4, 2, 0, 3, 299, 0, 8, 4, 0, 3, 0, 3, 300, 0, 26, 5, 4, 3, 40, 5, 15207, 0, 3, 301, 0, 8, 4, 0, 3, 48, 3, 1, 4, 5, 4, 3, 39, 15261, 0, 3, 299, 0, 8, 5, 0, 3, 0, 3, 302, 0, 26, 6, 5, 3, 40, 6, 15247, 0, 3, 303, 0, 8, 5, 0, 3, 0, 3, 240, 0, 8, 6, 5, 3, 5, 3, 6, 39, 15258, 0, 5, 240, 0, 8, 6, 0, 5, 5, 3, 6, 5, 4, 3, 45, 4, 0, 3, 0, 0, 45, 3, 4, 4, 3, 5, 0, 3, 6, 1, 3, 7, 2, 3, 8, 3, 0, 9, 304, 0, 42, 10, 8, 4, 4, 9, 1, 2, 42, 8, 7, 1, 10, 42, 7, 6, 1, 8, 42, 6, 5, 2, 7, 0, 45, 6, 0, 5, 0, 0, 45, 5, 4, 2, 0, 3, 97, 0, 8, 4, 2, 3, 0, 3, 30, 0, 34, 5, 3, 43, 3, 2, 4, 2, 0, 5, 45, 3, 0, 3, 0, 0, 45, 3, 4, 3, 0, 4, 97, 0, 8, 5, 3, 4, 0, 4, 30, 0, 34, 6, 4, 43, 4, 3, 5, 3, 1, 6, 0, 45, 4, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 3, 5, 1, 3, 6, 2, 3, 7, 3, 0, 8, 305, 0, 0, 9, 30, 0, 34, 10, 9, 42, 9, 7, 4, 3, 8, 10, 1, 42, 7, 6, 1, 9, 42, 6, 5, 1, 7, 42, 5, 4, 2, 6, 0, 45, 5, 0, 4, 0, 0, 45, 4, 4, 3, 0, 4, 100, 0, 8, 5, 3, 4, 43, 4, 3, 5, 2, 1, 0, 45, 4, 0, 4, 0, 0, 45, 4, 4, 4, 3, 5, 0, 3, 6, 1, 3, 7, 2, 3, 8, 3, 0, 9, 306, 0, 42, 10, 8, 4, 4, 9, 1, 2, 42, 8, 7, 1, 10, 42, 7, 6, 1, 8, 42, 6, 5, 2, 7, 0, 45, 6, 0, 5, 0, 0, 45, 5, 4, 2, 0, 3, 102, 0, 8, 4, 2, 3, 0, 3, 30, 0, 34, 5, 3, 43, 3, 2, 4, 2, 0, 5, 45, 3, 0, 3, 0, 0, 45, 3, 4, 3, 0, 4, 102, 0, 8, 5, 3, 4, 0, 4, 30, 0, 34, 6, 4, 43, 4, 3, 5, 3, 1, 6, 0, 45, 4, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 3, 4, 1, 0, 5, 241, 0, 8, 6, 4, 5, 48, 5, 0, 3, 7, 2, 0, 8, 210, 0, 8, 9, 7, 8, 47, 8, 15647, 2, 7, 1, 0, 1, 0, 43, 10, 7, 9, 2, 2, 8, 43, 7, 4, 6, 2, 5, 10, 42, 4, 3, 1, 7, 45, 4, 0, 3, 0, 0, 45, 3, 4, 3, 3, 4, 0, 0, 5, 210, 0, 8, 6, 4, 5, 43, 5, 4, 6, 3, 0, 1, 0, 45, 5, 0, 4, 0, 0, 45, 4, 4, 1, 0, 2, 16, 0, 8, 3, 1, 2, 47, 2, 15707, 2, 7, 0, 0, 43, 4, 1, 3, 1, 2, 45, 4, 0, 2, 0, 0, 45, 2, 4, 3, 0, 4, 259, 0, 8, 5, 1, 4, 0, 4, 30, 0, 34, 6, 4, 43, 4, 1, 5, 1, 6, 45, 4, 0, 4, 0, 0, 45, 4, 4, 1, 0, 2, 16, 0, 8, 3, 1, 2, 47, 2, 15773, 2, 9, 2, 0, 0, 0, 0, 1, 43, 4, 1, 3, 1, 2, 45, 4, 0, 2, 0, 0, 45, 2, 4, 3, 0, 5, 106, 0, 8, 6, 1, 5, 5, 4, 6, 5, 5, 4, 40, 5, 15816, 3, 6, 0, 0, 7, 111, 0, 42, 8, 6, 2, 4, 7, 0, 6, 307, 0, 26, 7, 8, 6, 5, 5, 7, 40, 5, 15832, 0, 5, 106, 0, 8, 6, 4, 5, 5, 4, 6, 39, 15786, 5, 5, 4, 41, 5, 15844, 3, 6, 1, 5, 5, 6, 45, 5, 0, 5, 0, 0, 45, 5, 4, 3, 3, 4, 0, 3, 5, 1, 0, 6, 210, 0, 8, 7, 5, 6, 43, 6, 5, 7, 3, 3, 0, 1, 42, 5, 4, 1, 6, 45, 5, 0, 4, 0, 0, 45, 4, 4, 2, 47, 4, 15969, 0, 7, 2, 0, 1, 0, 0, 0, 5, 3, 4, 3, 4, 1, 0, 5, 308, 0, 8, 6, 4, 5, 0, 4, 309, 0, 27, 5, 6, 4, 40, 5, 15936, 42, 4, 3, 0, 5, 5, 4, 39, 15961, 3, 4, 1, 0, 6, 275, 0, 8, 7, 4, 6, 0, 6, 310, 0, 43, 8, 4, 7, 2, 6, 3, 5, 5, 8, 45, 2, 0, 4, 0, 0, 45, 4, 4, 1, 2, 2, 311, 0, 3, 3, 0, 0, 4, 30, 0, 3, 5, 1, 42, 6, 2, 3, 3, 4, 5, 45, 6, 0, 2, 0, 0, 45, 2, 4, 1, 0, 2, 72, 0, 8, 3, 1, 2, 43, 2, 1, 3, 0, 0, 3, 38, 0, 8, 4, 2, 3, 47, 3, 16046, 2, 8, 1, 0, 0, 0, 43, 5, 2, 4, 1, 3, 45, 1, 0, 2, 0, 0, 45, 2, 4, 3, 0, 5, 299, 0, 8, 6, 1, 5, 0, 5, 312, 0, 27, 7, 6, 5, 40, 7, 16105, 3, 5, 0, 42, 6, 5, 1, 1, 5, 4, 6, 0, 5, 178, 0, 8, 6, 4, 5, 0, 5, 71, 0, 8, 7, 4, 5, 43, 5, 4, 7, 0, 43, 7, 4, 6, 1, 5, 0, 5, 0, 0, 45, 5, 4, 1, 0, 4, 30, 0, 8, 5, 1, 4, 5, 2, 5, 40, 2, 16206, 0, 4, 313, 0, 8, 5, 2, 4, 43, 4, 2, 5, 0, 5, 3, 4, 0, 4, 314, 0, 0, 5, 314, 0, 8, 6, 3, 5, 3, 5, 0, 0, 7, 315, 0, 8, 8, 5, 7, 11, 5, 6, 8, 0, 6, 316, 0, 0, 7, 316, 0, 8, 8, 3, 7, 3, 7, 0, 0, 9, 317, 0, 8, 10, 7, 9, 11, 7, 8, 10, 49, 8, 2, 4, 5, 6, 7, 45, 8, 0, 4, 0, 0, 45, 4, 4, 1, 0, 8, 30, 0, 8, 9, 1, 8, 5, 2, 9, 40, 2, 16613, 3, 8, 0, 0, 9, 111, 0, 42, 10, 8, 2, 2, 9, 0, 8, 318, 0, 26, 9, 10, 8, 5, 3, 9, 40, 3, 16273, 0, 8, 313, 0, 8, 9, 2, 8, 43, 8, 2, 9, 0, 5, 9, 8, 39, 16289, 0, 8, 110, 0, 8, 10, 1, 8, 43, 8, 1, 10, 0, 5, 9, 8, 5, 4, 9, 34, 8, 3, 40, 8, 16546, 0, 8, 258, 0, 8, 9, 2, 8, 5, 5, 9, 0, 8, 106, 0, 8, 9, 2, 8, 5, 8, 9, 41, 8, 16334, 0, 9, 3, 0, 8, 10, 5, 9, 5, 8, 10, 5, 6, 8, 0, 8, 230, 0, 8, 9, 5, 8, 26, 8, 6, 9, 5, 9, 8, 41, 9, 16370, 0, 8, 3, 0, 8, 10, 5, 8, 26, 8, 6, 10, 5, 9, 8, 5, 8, 9, 40, 8, 16400, 3, 9, 0, 0, 10, 111, 0, 42, 11, 9, 2, 6, 10, 0, 9, 307, 0, 26, 10, 11, 9, 5, 8, 10, 40, 8, 16416, 0, 8, 247, 0, 8, 9, 6, 8, 5, 6, 9, 39, 16337, 27, 8, 6, 2, 5, 9, 8, 40, 9, 16437, 3, 8, 1, 42, 10, 8, 1, 6, 5, 9, 10, 40, 9, 16546, 3, 8, 2, 42, 9, 8, 1, 6, 0, 8, 110, 0, 8, 10, 9, 8, 43, 8, 9, 10, 0, 5, 7, 8, 0, 8, 314, 0, 8, 9, 4, 8, 0, 10, 314, 0, 8, 11, 7, 10, 3, 10, 3, 0, 12, 319, 0, 42, 13, 10, 2, 6, 12, 11, 10, 11, 13, 12, 11, 9, 10, 9, 4, 8, 11, 0, 8, 316, 0, 8, 9, 4, 8, 0, 10, 316, 0, 8, 11, 7, 10, 3, 10, 3, 0, 12, 320, 0, 42, 13, 10, 2, 6, 12, 11, 10, 11, 13, 12, 11, 9, 10, 9, 4, 8, 11, 0, 8, 314, 0, 0, 9, 314, 0, 8, 10, 4, 9, 3, 9, 3, 0, 11, 321, 0, 42, 12, 9, 2, 2, 11, 12, 9, 10, 12, 0, 10, 316, 0, 0, 11, 316, 0, 8, 12, 4, 11, 3, 11, 3, 0, 13, 322, 0, 42, 14, 11, 2, 2, 13, 12, 11, 12, 14, 49, 12, 2, 8, 9, 10, 11, 45, 12, 0, 8, 0, 0, 45, 8, 4, 3, 40, 0, 16770, 3, 6, 0, 42, 7, 6, 1, 0, 40, 7, 16737, 3, 6, 1, 8, 7, 6, 0, 5, 6, 7, 41, 6, 16651, 5, 6, 0, 5, 0, 6, 0, 6, 29, 0, 8, 7, 2, 6, 0, 6, 215, 0, 22, 8, 7, 6, 40, 8, 16707, 0, 6, 30, 0, 8, 7, 3, 6, 5, 6, 7, 40, 6, 16702, 0, 7, 30, 0, 8, 8, 3, 7, 8, 7, 8, 0, 5, 6, 7, 5, 7, 6, 39, 16735, 0, 6, 38, 0, 8, 8, 3, 6, 47, 6, 16776, 2, 6, 2, 0, 1, 0, 1, 1, 43, 9, 3, 8, 1, 6, 5, 7, 9, 45, 7, 52, 4, 0, 53, 6, 4, 16768, 5, 5, 6, 0, 6, 130, 0, 8, 7, 3, 6, 8, 6, 0, 5, 43, 8, 3, 7, 2, 5, 6, 39, 16740, 45, 3, 0, 6, 0, 0, 45, 6, 4, 3, 3, 4, 0, 3, 5, 1, 9, 1, 4, 5, 0, 4, 0, 0, 45, 4, 4, 2, 0, 3, 38, 0, 8, 4, 2, 3, 47, 3, 16829, 2, 7, 2, 0, 0, 0, 1, 0, 43, 5, 2, 4, 1, 3, 45, 5, 0, 3, 0, 0, 45, 3, 4, 3, 3, 4, 0, 3, 5, 1, 8, 6, 4, 5, 5, 4, 6, 41, 4, 16853, 3, 5, 1, 5, 4, 5, 10, 5, 1, 4, 0, 4, 0, 0, 45, 4, 4, 3, 3, 4, 0, 0, 5, 158, 0, 0, 6, 159, 0, 48, 7, 2, 5, 6, 47, 5, 16912, 2, 9, 6, 0, 1, 1, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 42, 6, 4, 2, 7, 5, 0, 4, 0, 0, 45, 4, 4, 3, 0, 5, 23, 0, 0, 6, 13, 0, 8, 7, 5, 6, 3, 6, 0, 40, 6, 16941, 0, 6, 323, 0, 5, 8, 6, 39, 16948, 0, 6, 324, 0, 5, 8, 6, 43, 6, 5, 7, 1, 8, 0, 5, 13, 0, 8, 7, 6, 5, 43, 5, 6, 7, 1, 1, 5, 4, 5, 3, 5, 1, 47, 6, 17005, 1, 13, 7, 0, 0, 2, 0, 0, 1, 1, 0, 3, 0, 4, 0, 5, 1, 0, 9, 5, 4, 6, 0, 5, 0, 0, 45, 5, 4, 2, 0, 3, 30, 0, 8, 4, 2, 3, 40, 4, 17408, 3, 3, 0, 0, 4, 30, 0, 8, 5, 2, 4, 42, 4, 3, 1, 5, 40, 4, 17138, 3, 3, 1, 40, 3, 17081, 0, 3, 30, 0, 8, 4, 2, 3, 0, 3, 324, 0, 0, 5, 13, 0, 8, 6, 3, 5, 3, 5, 2, 43, 7, 3, 6, 1, 5, 8, 3, 4, 7, 5, 4, 3, 39, 17133, 0, 3, 30, 0, 8, 5, 2, 3, 0, 3, 1, 0, 8, 6, 5, 3, 0, 3, 3, 0, 8, 5, 6, 3, 0, 3, 273, 0, 0, 6, 13, 0, 8, 7, 3, 6, 3, 6, 2, 43, 8, 3, 7, 1, 6, 8, 3, 5, 8, 5, 4, 3, 5, 3, 4, 39, 17406, 3, 4, 3, 0, 5, 30, 0, 8, 6, 2, 5, 42, 5, 4, 1, 6, 40, 5, 17182, 3, 4, 4, 0, 5, 30, 0, 8, 6, 2, 5, 3, 5, 2, 42, 7, 4, 2, 6, 5, 5, 4, 7, 39, 17403, 0, 5, 30, 0, 8, 6, 2, 5, 0, 5, 23, 0, 0, 7, 13, 0, 8, 8, 5, 7, 3, 7, 1, 40, 7, 17217, 0, 7, 110, 0, 5, 9, 7, 39, 17224, 0, 7, 273, 0, 5, 9, 7, 43, 7, 5, 8, 1, 9, 0, 5, 13, 0, 8, 8, 7, 5, 3, 5, 2, 43, 9, 7, 8, 1, 5, 8, 5, 6, 9, 5, 6, 0, 40, 6, 17263, 3, 7, 1, 5, 6, 7, 40, 6, 17389, 3, 6, 5, 0, 7, 30, 0, 8, 8, 2, 7, 0, 7, 325, 0, 0, 9, 13, 0, 8, 10, 7, 9, 3, 9, 6, 40, 9, 17304, 0, 9, 226, 0, 5, 11, 9, 39, 17311, 0, 9, 225, 0, 5, 11, 9, 43, 9, 7, 10, 1, 11, 42, 7, 6, 2, 8, 9, 3, 6, 5, 0, 8, 30, 0, 8, 9, 2, 8, 0, 8, 325, 0, 0, 10, 13, 0, 8, 11, 8, 10, 3, 10, 6, 40, 10, 17361, 0, 10, 229, 0, 5, 12, 10, 39, 17368, 0, 10, 228, 0, 5, 12, 10, 43, 10, 8, 11, 1, 12, 42, 8, 6, 2, 9, 10, 11, 6, 7, 8, 5, 7, 6, 39, 17396, 0, 6, 30, 0, 5, 7, 6, 11, 6, 5, 7, 5, 4, 6, 5, 3, 4, 45, 3, 0, 3, 0, 0, 45, 3, 4, 3, 0, 5, 326, 0, 8, 6, 1, 5, 43, 5, 1, 6, 0, 5, 4, 5, 3, 5, 0, 47, 6, 17474, 1, 11, 11, 0, 0, 1, 0, 2, 1, 1, 0, 3, 0, 4, 1, 4, 0, 5, 1, 0, 0, 6, 0, 7, 0, 8, 9, 5, 4, 6, 0, 5, 0, 0, 45, 5, 4, 2, 0, 4, 30, 0, 8, 5, 2, 4, 34, 4, 5, 40, 4, 17518, 3, 4, 0, 42, 5, 4, 1, 0, 40, 5, 17513, 0, 4, 30, 0, 37, 5, 4, 5, 4, 5, 39, 17516, 5, 4, 2, 45, 4, 0, 4, 29, 0, 8, 5, 1, 4, 34, 4, 5, 40, 4, 17712, 3, 4, 1, 0, 5, 30, 0, 8, 6, 2, 5, 42, 5, 4, 1, 6, 40, 5, 17605, 0, 4, 30, 0, 8, 5, 2, 4, 0, 4, 1, 0, 8, 6, 5, 4, 0, 4, 3, 0, 8, 5, 6, 4, 0, 4, 273, 0, 0, 6, 13, 0, 8, 7, 4, 6, 3, 6, 2, 43, 8, 4, 7, 1, 6, 8, 4, 5, 8, 5, 5, 4, 39, 17710, 3, 4, 3, 0, 6, 30, 0, 8, 7, 2, 6, 42, 6, 4, 1, 7, 40, 6, 17649, 3, 4, 4, 0, 6, 30, 0, 8, 7, 2, 6, 3, 6, 2, 42, 8, 4, 2, 7, 6, 5, 4, 8, 39, 17707, 0, 6, 30, 0, 8, 7, 2, 6, 0, 6, 313, 0, 8, 8, 7, 6, 43, 6, 7, 8, 0, 3, 7, 5, 8, 8, 6, 7, 3, 6, 6, 0, 7, 30, 0, 8, 9, 2, 7, 3, 7, 7, 34, 10, 7, 42, 7, 6, 2, 9, 10, 12, 6, 8, 7, 5, 4, 6, 5, 5, 4, 45, 5, 2, 4, 222, 0, 0, 5, 223, 0, 42, 6, 4, 2, 0, 5, 5, 3, 6, 0, 4, 38, 0, 8, 5, 2, 4, 47, 4, 17772, 2, 13, 7, 0, 0, 8, 0, 9, 0, 5, 0, 10, 1, 3, 0, 6, 0, 7, 43, 6, 2, 5, 1, 4, 45, 6, 0, 4, 0, 0, 45, 4, 4, 3, 3, 5, 0, 42, 6, 5, 1, 1, 40, 6, 17873, 3, 5, 1, 0, 6, 327, 0, 42, 7, 5, 2, 1, 6, 5, 4, 7, 0, 5, 133, 0, 8, 6, 1, 5, 3, 5, 2, 3, 7, 3, 3, 8, 2, 3, 9, 4, 0, 10, 328, 0, 26, 11, 4, 10, 40, 11, 17852, 3, 10, 5, 3, 11, 6, 34, 12, 11, 42, 11, 10, 2, 1, 12, 5, 10, 11, 39, 17859, 0, 11, 30, 0, 5, 10, 11, 11, 11, 9, 10, 42, 9, 7, 2, 8, 11, 9, 6, 5, 9, 0, 5, 0, 0, 45, 5, 4, 2, 0, 3, 38, 0, 8, 4, 2, 3, 47, 3, 17924, 2, 12, 7, 0, 0, 0, 0, 1, 0, 2, 1, 0, 0, 3, 0, 4, 0, 5, 43, 5, 2, 4, 1, 3, 45, 5, 0, 3, 0, 0, 45, 3, 4, 3, 3, 6, 0, 42, 7, 6, 1, 1, 40, 7, 18121, 3, 6, 1, 42, 7, 6, 1, 1, 5, 4, 7, 3, 6, 2, 3, 7, 3, 42, 8, 6, 1, 7, 40, 8, 17967, 5, 6, 4, 39, 17973, 3, 7, 3, 5, 6, 7, 5, 5, 6, 40, 5, 18069, 0, 6, 133, 0, 8, 7, 1, 6, 0, 6, 231, 0, 3, 8, 4, 8, 9, 1, 8, 5, 8, 9, 41, 8, 18011, 0, 9, 23, 0, 5, 8, 9, 9, 7, 6, 8, 3, 6, 1, 42, 7, 6, 1, 1, 5, 6, 7, 40, 6, 18064, 0, 7, 133, 0, 8, 8, 1, 7, 0, 7, 231, 0, 3, 9, 5, 0, 10, 299, 0, 8, 11, 1, 10, 42, 10, 9, 1, 11, 9, 8, 7, 10, 5, 6, 10, 5, 7, 6, 39, 18121, 5, 6, 4, 41, 6, 18118, 3, 8, 4, 3, 9, 6, 0, 10, 231, 0, 42, 11, 9, 2, 1, 10, 9, 1, 8, 11, 0, 8, 133, 0, 8, 9, 1, 8, 0, 8, 231, 0, 0, 10, 233, 0, 9, 9, 8, 10, 5, 6, 10, 5, 7, 6, 0, 6, 0, 0, 45, 6, 4, 1, 0, 2, 161, 0, 8, 3, 1, 2, 0, 2, 68, 0, 34, 4, 2, 43, 2, 1, 3, 1, 4, 45, 2, 0, 2, 0, 0, 45, 2, 4, 1, 0, 2, 161, 0, 8, 3, 1, 2, 0, 2, 30, 0, 34, 4, 2, 43, 2, 1, 3, 1, 4, 45, 2, 0, 2, 0, 0, 45, 2, 4, 3, 3, 10, 0, 42, 11, 10, 1, 0, 40, 11, 18376, 3, 10, 1, 42, 11, 10, 1, 0, 5, 4, 11, 0, 10, 30, 0, 8, 11, 4, 10, 5, 5, 11, 0, 10, 68, 0, 8, 11, 4, 10, 5, 6, 11, 3, 10, 2, 42, 11, 10, 1, 5, 5, 7, 11, 34, 10, 7, 40, 10, 18254, 45, 3, 3, 10, 3, 0, 11, 191, 0, 8, 12, 10, 11, 43, 11, 10, 12, 1, 7, 40, 11, 18283, 0, 10, 329, 0, 5, 11, 10, 39, 18290, 0, 10, 330, 0, 5, 11, 10, 5, 8, 11, 3, 10, 4, 0, 11, 331, 0, 8, 12, 10, 11, 43, 11, 10, 12, 1, 8, 5, 0, 11, 0, 10, 332, 0, 8, 11, 0, 10, 0, 10, 30, 0, 34, 12, 10, 0, 10, 30, 0, 34, 13, 10, 43, 10, 0, 11, 3, 7, 12, 13, 0, 10, 280, 0, 0, 11, 218, 0, 8, 12, 6, 11, 3, 11, 5, 43, 13, 6, 12, 1, 11, 9, 0, 10, 13, 0, 10, 281, 0, 9, 0, 10, 5, 0, 10, 287, 0, 9, 0, 10, 1, 0, 10, 281, 0, 8, 11, 0, 10, 3, 10, 6, 30, 12, 11, 10, 5, 9, 12, 0, 10, 38, 0, 8, 11, 3, 10, 47, 10, 18437, 2, 10, 3, 0, 1, 9, 0, 7, 1, 0, 43, 12, 3, 11, 1, 10, 45, 12, 0, 10, 0, 0, 45, 10, 4, 3, 3, 4, 0, 5, 5, 4, 40, 5, 18474, 3, 4, 1, 3, 6, 2, 0, 7, 281, 0, 8, 8, 6, 7, 8, 6, 1, 8, 42, 7, 4, 1, 6, 5, 5, 7, 5, 4, 5, 40, 4, 18583, 0, 5, 278, 0, 0, 6, 13, 0, 8, 7, 5, 6, 3, 6, 2, 0, 8, 80, 0, 8, 9, 6, 8, 43, 6, 5, 7, 1, 9, 0, 5, 30, 0, 34, 7, 5, 9, 1, 6, 7, 3, 5, 2, 0, 6, 281, 0, 8, 7, 5, 6, 8, 5, 1, 7, 43, 6, 1, 5, 0, 0, 5, 278, 0, 0, 6, 13, 0, 8, 7, 5, 6, 3, 6, 2, 0, 8, 80, 0, 8, 9, 6, 8, 43, 6, 5, 7, 1, 9, 0, 5, 68, 0, 34, 7, 5, 9, 1, 6, 7, 5, 4, 7, 0, 4, 333, 0, 8, 5, 1, 4, 3, 4, 2, 43, 6, 1, 5, 1, 4, 0, 4, 0, 0, 45, 4, 4, 4, 5, 5, 4, 3, 8, 0, 42, 9, 8, 1, 0, 40, 9, 18653, 0, 8, 38, 0, 8, 9, 4, 8, 47, 8, 18774, 2, 7, 4, 0, 0, 1, 0, 2, 0, 3, 0, 4, 43, 10, 4, 9, 1, 8, 39, 18766, 3, 8, 5, 42, 9, 8, 1, 0, 40, 9, 18735, 3, 8, 6, 42, 9, 8, 1, 1, 5, 8, 9, 40, 8, 18691, 5, 2, 1, 0, 9, 23, 0, 5, 1, 9, 5, 8, 1, 3, 8, 7, 3, 9, 8, 42, 10, 9, 1, 0, 47, 9, 18850, 2, 12, 9, 0, 0, 9, 0, 10, 1, 5, 0, 1, 0, 2, 0, 3, 0, 4, 1, 1, 1, 2, 42, 11, 8, 2, 10, 9, 39, 18766, 52, 6, 0, 53, 8, 6, 18766, 5, 7, 8, 0, 8, 176, 0, 8, 9, 4, 8, 8, 8, 0, 7, 43, 10, 4, 9, 2, 7, 8, 39, 18738, 45, 4, 0, 8, 0, 0, 45, 8, 4, 3, 3, 4, 0, 42, 5, 4, 1, 1, 34, 4, 5, 5, 5, 4, 40, 5, 18807, 3, 4, 1, 42, 6, 4, 1, 1, 34, 4, 6, 5, 5, 4, 5, 4, 5, 40, 4, 18827, 3, 5, 2, 42, 6, 5, 1, 1, 34, 5, 6, 5, 4, 5, 5, 5, 4, 41, 5, 18844, 3, 4, 3, 42, 6, 4, 1, 1, 5, 5, 6, 0, 4, 0, 0, 45, 4, 4, 3, 3, 8, 0, 42, 9, 8, 1, 1, 5, 4, 9, 0, 8, 30, 0, 8, 9, 4, 8, 5, 5, 9, 0, 8, 68, 0, 8, 9, 4, 8, 5, 6, 9, 3, 8, 1, 42, 9, 8, 1, 5, 5, 7, 9, 3, 8, 2, 0, 9, 38, 0, 8, 10, 8, 9, 47, 9, 18942, 2, 11, 8, 0, 0, 3, 0, 4, 0, 5, 0, 6, 1, 7, 1, 6, 0, 7, 0, 8, 43, 11, 8, 10, 1, 9, 0, 8, 0, 0, 45, 8, 4, 3, 3, 4, 0, 42, 5, 4, 1, 1, 34, 4, 5, 5, 5, 4, 40, 5, 18975, 3, 4, 1, 42, 6, 4, 1, 1, 34, 4, 6, 5, 5, 4, 5, 4, 5, 40, 4, 18995, 3, 5, 2, 42, 6, 5, 1, 1, 34, 5, 6, 5, 4, 5, 5, 5, 4, 41, 5, 19028, 3, 4, 3, 3, 6, 4, 3, 7, 5, 3, 8, 6, 3, 9, 7, 42, 10, 4, 5, 1, 6, 7, 8, 9, 5, 5, 10, 0, 4, 0, 0, 45, 4, 4, 2, 3, 3, 0, 42, 4, 3, 2, 2, 0, 0, 3, 54, 0, 8, 5, 4, 3, 43, 3, 4, 5, 0, 0, 4, 176, 0, 8, 5, 3, 4, 43, 4, 3, 5, 0, 45, 2, 0, 3, 0, 0, 45, 3, 4, 2, 0, 3, 89, 0, 8, 4, 2, 3, 43, 3, 2, 4, 1, 0, 0, 4, 177, 0, 8, 5, 3, 4, 43, 4, 3, 5, 0, 45, 4, 0, 3, 0, 0, 45, 3, 4, 2, 3, 3, 0, 42, 4, 3, 1, 0, 0, 3, 178, 0, 8, 5, 4, 3, 43, 3, 4, 5, 1, 2, 45, 2, 0, 3, 0, 0, 45, 3, 4, 1, 0, 3, 23, 0, 5, 2, 3, 0, 3, 38, 0, 8, 4, 1, 3, 47, 3, 19220, 2, 8, 8, 0, 0, 0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 1, 2, 0, 6, 43, 5, 1, 4, 1, 3, 0, 3, 18, 0, 8, 4, 2, 3, 0, 3, 68, 0, 43, 5, 2, 4, 1, 3, 45, 5, 0, 3, 0, 0, 45, 3, 4, 3, 3, 4, 0, 0, 5, 334, 0, 8, 6, 1, 5, 5, 5, 6, 41, 5, 19246, 48, 6, 1, 1, 5, 5, 6, 47, 6, 19281, 2, 11, 8, 0, 0, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 0, 0, 6, 0, 7, 42, 7, 4, 2, 5, 6, 0, 4, 0, 0, 45, 4, 4, 3, 0, 6, 246, 0, 8, 7, 1, 6, 5, 6, 7, 41, 6, 19311, 0, 7, 335, 0, 8, 8, 1, 7, 34, 7, 8, 5, 6, 7, 5, 7, 6, 41, 7, 19336, 0, 6, 299, 0, 8, 8, 1, 6, 0, 6, 336, 0, 26, 9, 8, 6, 5, 7, 9, 5, 6, 7, 41, 6, 19370, 3, 7, 0, 0, 8, 191, 0, 8, 9, 7, 8, 0, 8, 80, 0, 8, 10, 1, 8, 43, 8, 7, 9, 1, 10, 5, 6, 8, 5, 7, 6, 41, 7, 19424, 3, 6, 1, 0, 8, 191, 0, 8, 9, 6, 8, 0, 8, 80, 0, 8, 10, 1, 8, 43, 8, 6, 9, 1, 10, 5, 6, 8, 40, 6, 19421, 0, 8, 249, 0, 8, 9, 1, 8, 34, 8, 9, 5, 6, 8, 5, 7, 6, 34, 6, 7, 40, 6, 19503, 3, 6, 2, 42, 7, 6, 1, 1, 5, 4, 7, 3, 6, 3, 42, 7, 6, 1, 4, 34, 6, 7, 40, 6, 19503, 3, 6, 4, 42, 7, 6, 1, 4, 40, 7, 19471, 5, 6, 4, 39, 19478, 48, 7, 1, 4, 5, 6, 7, 5, 5, 6, 3, 6, 5, 47, 7, 19509, 2, 9, 3, 0, 0, 6, 0, 7, 1, 1, 42, 8, 6, 2, 5, 7, 0, 6, 0, 0, 45, 6, 4, 3, 3, 4, 0, 3, 5, 1, 3, 6, 2, 0, 7, 335, 0, 8, 8, 6, 7, 42, 6, 5, 2, 8, 1, 11, 5, 4, 6, 7, 0, 5, 0, 4, 0, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 4; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/oracle.json new file mode 100644 index 00000000..1612eaf9 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/oracle.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "program-cash", + "hostProfile": "cash-dom-test-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [ + { + "op": "typeOf", + "root": "window", + "path": [ + "cash" + ], + "as": "cashType" + }, + { + "op": "keys", + "root": "window", + "path": [ + "cash" + ], + "as": "cashKeys" + } + ], + "timeoutMs": 10000, + "expected": { + "cashType": "function", + "cashKeys": [ + "fn", + "isWindow", + "isFunction", + "isArray", + "isNumeric", + "isPlainObject", + "each", + "extend", + "parseHTML", + "guid", + "unique" + ] + }, + "expectedThrow": null, + "semanticTags": [ + "whole-program", + "host-profile", + "iife", + "object-methods", + "regex", + "mutation" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded.", + "cash-dom-test-v1 copies globalThis names into a fixed DOM/module stub; real DOM and ambient host state are excluded.", + "Post-evaluation observations use only the finite declarative actions recorded in the oracle; function values are never serialized." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/source.js new file mode 100644 index 00000000..a811c2f9 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-cash/source.js @@ -0,0 +1,2 @@ +(function(){"use strict";var C=document,D=window,st=C.documentElement,L=C.createElement.bind(C),ft=L("div"),q=L("table"),Mt=L("tbody"),ot=L("tr"),H=Array.isArray,S=Array.prototype,Dt=S.concat,U=S.filter,at=S.indexOf,ct=S.map,Bt=S.push,ht=S.slice,z=S.some,_t=S.splice,Pt=/^#(?:[\w-]|\\.|[^\x00-\xa0])*$/,Ht=/^\.(?:[\w-]|\\.|[^\x00-\xa0])*$/,$t=/<.+>/,jt=/^\w+$/;function J(t,n){var r=It(n);return!t||!r&&!A(n)&&!c(n)?[]:!r&&Ht.test(t)?n.getElementsByClassName(t.slice(1).replace(/\\/g,"")):!r&&jt.test(t)?n.getElementsByTagName(t):n.querySelectorAll(t)}var dt=function(){function t(n,r){if(n){if(Y(n))return n;var i=n;if(g(n)){var e=r||C;if(i=Pt.test(n)&&A(e)?e.getElementById(n.slice(1).replace(/\\/g,"")):$t.test(n)?yt(n):Y(e)?e.find(n):g(e)?o(e).find(n):J(n,e),!i)return}else if(O(n))return this.ready(n);(i.nodeType||i===D)&&(i=[i]),this.length=i.length;for(var s=0,f=this.length;s]*>/,Gt=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,mt={"*":ft,tr:Mt,td:ot,th:ot,thead:q,tbody:q,tfoot:q};function yt(t){if(!g(t))return[];if(Gt.test(t))return[L(RegExp.$1)];var n=Yt.test(t)&&RegExp.$1,r=mt[n]||mt["*"];return r.innerHTML=t,o(r.childNodes).detach().get()}o.parseHTML=yt,u.has=function(t){var n=g(t)?function(r,i){return J(t,i).length}:function(r,i){return i.contains(t)};return this.filter(n)},u.not=function(t){var n=I(t);return this.filter(function(r,i){return(!g(t)||c(i))&&!n.call(i,r,i)})};function R(t,n,r,i){for(var e=[],s=O(n),f=i&&I(i),a=0,y=t.length;a=0},!0):r.checked=e.indexOf(r.value)>=0}else r.value=v(t)||P(t)?"":t}):this[0]&&bt(this[0])}u.val=Xt,u.is=function(t){var n=I(t);return z.call(this,function(r,i){return n.call(r,i,r)})},o.guid=1;function w(t){return t.length>1?U.call(t,function(n,r,i){return at.call(i,n)===r}):t}o.unique=w,u.add=function(t,n){return o(w(this.get().concat(o(t,n).get())))},u.children=function(t){return x(o(w(R(this,function(n){return n.children}))),t)},u.parent=function(t){return x(o(w(R(this,"parentNode"))),t)},u.index=function(t){var n=t?o(t)[0]:this[0],r=t?this:o(n).parent().children();return at.call(r,n)},u.closest=function(t){var n=this.filter(t);if(n.length)return n;var r=this.parent();return r.length?r.closest(t):n},u.siblings=function(t){return x(o(w(R(this,function(n){return o(n).parent().children().not(n)}))),t)},u.find=function(t){return o(w(R(this,function(n){return J(t,n)})))};var Kt=/^\s*\s*$/g,Qt=/^$|^module$|\/(java|ecma)script/i,Vt=["type","src","nonce","noModule"];function Zt(t,n){var r=o(t);r.filter("script").add(r.find("script")).each(function(i,e){if(Qt.test(e.type)&&st.contains(e)){var s=L("script");s.text=e.textContent.replace(Kt,""),d(Vt,function(f,a){e[a]&&(s[a]=e[a])}),n.head.insertBefore(s,null),n.head.removeChild(s)}})}function kt(t,n,r,i,e){i?t.insertBefore(n,r?t.firstChild:null):t.nodeName==="HTML"?t.parentNode.replaceChild(n,t):t.parentNode.insertBefore(n,r?t:t.nextSibling),e&&Zt(n,t.ownerDocument)}function N(t,n,r,i,e,s,f,a){return d(t,function(y,h){d(o(h),function(p,M){d(o(n),function(b,W){var rt=r?M:W,it=r?W:M,m=r?p:b;kt(rt,m?it.cloneNode(!0):it,i,e,!m)},a)},f)},s),n}u.after=function(){return N(arguments,this,!1,!1,!1,!0,!0)},u.append=function(){return N(arguments,this,!1,!1,!0)};function tn(t){if(!arguments.length)return this[0]&&this[0].innerHTML;if(v(t))return this;var n=/]/.test(t);return this.each(function(r,i){c(i)&&(n?o(i).empty().append(t):i.innerHTML=t)})}u.html=tn,u.appendTo=function(t){return N(arguments,this,!0,!1,!0)},u.wrapInner=function(t){return this.each(function(n,r){var i=o(r),e=i.contents();e.length?e.wrapAll(t):i.append(t)})},u.before=function(){return N(arguments,this,!1,!0)},u.wrapAll=function(t){for(var n=o(t),r=n[0];r.children.length;)r=r.firstElementChild;return this.first().before(n),this.appendTo(r)},u.wrap=function(t){return this.each(function(n,r){var i=o(t)[0];o(r).wrapAll(n?i.cloneNode(!0):i)})},u.insertAfter=function(t){return N(arguments,this,!0,!1,!1,!1,!1,!0)},u.insertBefore=function(t){return N(arguments,this,!0,!0)},u.prepend=function(){return N(arguments,this,!1,!0,!0,!0,!0)},u.prependTo=function(t){return N(arguments,this,!0,!0,!0,!1,!1,!0)},u.contents=function(){return o(w(R(this,function(t){return t.tagName==="IFRAME"?[t.contentDocument]:t.tagName==="TEMPLATE"?t.content.childNodes:t.childNodes})))},u.next=function(t,n,r){return x(o(w(R(this,"nextElementSibling",n,r))),t)},u.nextAll=function(t){return this.next(t,!0)},u.nextUntil=function(t,n){return this.next(n,!0,t)},u.parents=function(t,n){return x(o(w(R(this,"parentElement",!0,n))),t)},u.parentsUntil=function(t,n){return this.parents(n,t)},u.prev=function(t,n,r){return x(o(w(R(this,"previousElementSibling",n,r))),t)},u.prevAll=function(t){return this.prev(t,!0)},u.prevUntil=function(t,n){return this.prev(n,!0,t)},u.map=function(t){return o(Dt.apply([],ct.call(this,function(n,r){return t.call(n,r,n)})))},u.clone=function(){return this.map(function(t,n){return n.cloneNode(!0)})},u.offsetParent=function(){return this.map(function(t,n){for(var r=n.offsetParent;r&&T(r,"position")==="static";)r=r.offsetParent;return r||st})},u.slice=function(t,n){return o(ht.call(this,t,n))};var nn=/-([a-z])/g;function K(t){return t.replace(nn,function(n,r){return r.toUpperCase()})}u.ready=function(t){var n=function(){return setTimeout(t,0,o)};return C.readyState!=="loading"?n():C.addEventListener("DOMContentLoaded",n),this},u.unwrap=function(){return this.parent().each(function(t,n){if(n.tagName!=="BODY"){var r=o(n);r.replaceWith(r.children())}}),this},u.offset=function(){var t=this[0];if(t){var n=t.getBoundingClientRect();return{top:n.top+D.pageYOffset,left:n.left+D.pageXOffset}}},u.position=function(){var t=this[0];if(t){var n=T(t,"position")==="fixed",r=n?t.getBoundingClientRect():this.offset();if(!n){for(var i=t.ownerDocument,e=t.offsetParent||i.documentElement;(e===i.body||e===i.documentElement)&&T(e,"position")==="static";)e=e.parentNode;if(e!==t&&c(e)){var s=o(e).offset();r.top-=s.top+E(e,"borderTopWidth"),r.left-=s.left+E(e,"borderLeftWidth")}}return{top:r.top-E(t,"marginTop"),left:r.left-E(t,"marginLeft")}}};var Et={class:"className",contenteditable:"contentEditable",for:"htmlFor",readonly:"readOnly",maxlength:"maxLength",tabindex:"tabIndex",colspan:"colSpan",rowspan:"rowSpan",usemap:"useMap"};u.prop=function(t,n){if(t){if(g(t))return t=Et[t]||t,arguments.length<2?this[0]&&this[0][t]:this.each(function(i,e){e[t]=n});for(var r in t)this.prop(r,t[r]);return this}},u.removeProp=function(t){return this.each(function(n,r){delete r[Et[t]||t]})};var rn=/^--/;function Q(t){return rn.test(t)}var V={},en=ft.style,un=["webkit","moz","ms"];function sn(t,n){if(n===void 0&&(n=Q(t)),n)return t;if(!V[t]){var r=K(t),i="".concat(r[0].toUpperCase()).concat(r.slice(1)),e="".concat(r," ").concat(un.join("".concat(i," "))).concat(i).split(" ");d(e,function(s,f){if(f in en)return V[t]=f,!1})}return V[t]}var fn={animationIterationCount:!0,columnCount:!0,flexGrow:!0,flexShrink:!0,fontWeight:!0,gridArea:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnStart:!0,gridRow:!0,gridRowEnd:!0,gridRowStart:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,widows:!0,zIndex:!0};function wt(t,n,r){return r===void 0&&(r=Q(t)),!r&&!fn[t]&<(n)?"".concat(n,"px"):n}function on(t,n){if(g(t)){var r=Q(t);return t=sn(t,r),arguments.length<2?this[0]&&T(this[0],t,r):t?(n=wt(t,n,r),this.each(function(e,s){c(s)&&(r?s.style.setProperty(t,n):s.style[t]=n)})):this}for(var i in t)this.css(i,t[i]);return this}u.css=on;function Ct(t,n){try{return t(n)}catch{return n}}var an=/^\s+|\s+$/;function St(t,n){var r=t.dataset[n]||t.dataset[K(n)];return an.test(r)?r:Ct(JSON.parse,r)}function cn(t,n,r){r=Ct(JSON.stringify,r),t.dataset[K(n)]=r}function hn(t,n){if(!t){if(!this[0])return;var r={};for(var i in this[0].dataset)r[i]=St(this[0],i);return r}if(g(t))return arguments.length<2?this[0]&&St(this[0],t):v(n)?this:this.each(function(e,s){cn(s,t,n)});for(var i in t)this.data(i,t[i]);return this}u.data=hn;function Tt(t,n){var r=t.documentElement;return Math.max(t.body["scroll".concat(n)],r["scroll".concat(n)],t.body["offset".concat(n)],r["offset".concat(n)],r["client".concat(n)])}d([!0,!1],function(t,n){d(["Width","Height"],function(r,i){var e="".concat(n?"outer":"inner").concat(i);u[e]=function(s){if(this[0])return B(this[0])?n?this[0]["inner".concat(i)]:this[0].document.documentElement["client".concat(i)]:A(this[0])?Tt(this[0],i):this[0]["".concat(n?"offset":"client").concat(i)]+(s&&n?E(this[0],"margin".concat(r?"Top":"Left"))+E(this[0],"margin".concat(r?"Bottom":"Right")):0)}})}),d(["Width","Height"],function(t,n){var r=n.toLowerCase();u[r]=function(i){if(!this[0])return v(i)?void 0:this;if(!arguments.length)return B(this[0])?this[0].document.documentElement["client".concat(n)]:A(this[0])?Tt(this[0],n):this[0].getBoundingClientRect()[r]-gt(this[0],!t);var e=parseInt(i,10);return this.each(function(s,f){if(c(f)){var a=T(f,"boxSizing");f.style[r]=wt(r,e+(a==="border-box"?gt(f,!t):0))}})}});var Rt="___cd";u.toggle=function(t){return this.each(function(n,r){if(c(r)){var i=vt(r),e=v(t)?i:t;e?(r.style.display=r[Rt]||"",vt(r)&&(r.style.display=Jt(r.tagName))):i||(r[Rt]=T(r,"display"),r.style.display="none")}})},u.hide=function(){return this.toggle(!1)},u.show=function(){return this.toggle(!0)};var xt="___ce",Z=".",k={focus:"focusin",blur:"focusout"},Nt={mouseenter:"mouseover",mouseleave:"mouseout"},dn=/^(mouse|pointer|contextmenu|drag|drop|click|dblclick)/i;function tt(t){return Nt[t]||k[t]||t}function nt(t){var n=t.split(Z);return[n[0],n.slice(1).sort()]}u.trigger=function(t,n){if(g(t)){var r=nt(t),i=r[0],e=r[1],s=tt(i);if(!s)return this;var f=dn.test(s)?"MouseEvents":"HTMLEvents";t=C.createEvent(f),t.initEvent(s,!0,!0),t.namespace=e.join(Z),t.___ot=i}t.___td=n;var a=t.___ot in k;return this.each(function(y,h){a&&O(h[t.___ot])&&(h["___i".concat(t.type)]=!0,h[t.___ot](),h["___i".concat(t.type)]=!1),h.dispatchEvent(t)})};function Lt(t){return t[xt]=t[xt]||{}}function ln(t,n,r,i,e){var s=Lt(t);s[n]=s[n]||[],s[n].push([r,i,e]),t.addEventListener(n,e)}function At(t,n){return!n||!z.call(n,function(r){return t.indexOf(r)<0})}function F(t,n,r,i,e){var s=Lt(t);if(n)s[n]&&(s[n]=s[n].filter(function(f){var a=f[0],y=f[1],h=f[2];if(e&&h.guid!==e.guid||!At(a,r)||i&&i!==y)return!0;t.removeEventListener(n,h)}));else for(n in s)F(t,n,r,i,e)}u.off=function(t,n,r){var i=this;if(v(t))this.each(function(s,f){!c(f)&&!A(f)&&!B(f)||F(f)});else if(g(t))O(n)&&(r=n,n=""),d(j(t),function(s,f){var a=nt(f),y=a[0],h=a[1],p=tt(y);i.each(function(M,b){!c(b)&&!A(b)&&!B(b)||F(b,p,h,n,r)})});else for(var e in t)this.off(e,t[e]);return this},u.remove=function(t){return x(this,t).detach().off(),this},u.replaceWith=function(t){return this.before(t).remove()},u.replaceAll=function(t){return o(t).replaceWith(this),this};function gn(t,n,r,i,e){var s=this;if(!g(t)){for(var f in t)this.on(f,n,r,t[f],e);return this}return g(n)||(v(n)||P(n)?n="":v(r)?(r=n,n=""):(i=r,r=n,n="")),O(i)||(i=r,r=void 0),i?(d(j(t),function(a,y){var h=nt(y),p=h[0],M=h[1],b=tt(p),W=p in Nt,rt=p in k;b&&s.each(function(it,m){if(!(!c(m)&&!A(m)&&!B(m))){var et=function(l){if(l.target["___i".concat(l.type)])return l.stopImmediatePropagation();if(!(l.namespace&&!At(M,l.namespace.split(Z)))&&!(!n&&(rt&&(l.target!==m||l.___ot===b)||W&&l.relatedTarget&&m.contains(l.relatedTarget)))){var ut=m;if(n){for(var _=l.target;!pt(_,n);)if(_===m||(_=_.parentNode,!_))return;ut=_}Object.defineProperty(l,"currentTarget",{configurable:!0,get:function(){return ut}}),Object.defineProperty(l,"delegateTarget",{configurable:!0,get:function(){return m}}),Object.defineProperty(l,"data",{configurable:!0,get:function(){return r}});var bn=i.call(ut,l,l.___td);e&&F(m,b,M,n,et),bn===!1&&(l.preventDefault(),l.stopPropagation())}};et.guid=i.guid=i.guid||o.guid++,ln(m,b,M,n,et)}})}),this):this}u.on=gn;function vn(t,n,r,i){return this.on(t,n,r,i,!0)}u.one=vn;var pn=/\r?\n/g;function mn(t,n){return"&".concat(encodeURIComponent(t),"=").concat(encodeURIComponent(n.replace(pn,`\r +`)))}var yn=/file|reset|submit|button|image/i,Ot=/radio|checkbox/i;u.serialize=function(){var t="";return this.each(function(n,r){d(r.elements||[r],function(i,e){if(!(e.disabled||!e.name||e.tagName==="FIELDSET"||yn.test(e.type)||Ot.test(e.type)&&!e.checked)){var s=bt(e);if(!v(s)){var f=H(s)?s:[s];d(f,function(a,y){t+=mn(e.name,y)})}}})}),t.slice(1)},typeof exports<"u"?module.exports=o:D.cash=D.$=o})(); diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/encoded.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/encoded.js new file mode 100644 index 00000000..99829744 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"module", /* 1 */"undefined", /* 2 */"exports", /* 3 */"window", /* 4 */"TEST_OUTPUT", /* 5 */undefined, /* 6 */0, /* 7 */"", /* 8 */"Array", /* 9 */1116352408, /* 10 */1899447441, /* 11 */1245643825, /* 12 */373957723, /* 13 */961987163, /* 14 */1508970993, /* 15 */1841331548, /* 16 */1424204075, /* 17 */670586216, /* 18 */310598401, /* 19 */607225278, /* 20 */1426881987, /* 21 */1925078388, /* 22 */2132889090, /* 23 */1680079193, /* 24 */1046744716, /* 25 */459576895, /* 26 */272742522, /* 27 */264347078, /* 28 */604807628, /* 29 */770255983, /* 30 */1249150122, /* 31 */1555081692, /* 32 */1996064986, /* 33 */1740746414, /* 34 */1473132947, /* 35 */1341970488, /* 36 */1084653625, /* 37 */958395405, /* 38 */710438585, /* 39 */113926993, /* 40 */338241895, /* 41 */666307205, /* 42 */773529912, /* 43 */1294757372, /* 44 */1396182291, /* 45 */1695183700, /* 46 */1986661051, /* 47 */2117940946, /* 48 */1838011259, /* 49 */1564481375, /* 50 */1474664885, /* 51 */1035236496, /* 52 */949202525, /* 53 */778901479, /* 54 */694614492, /* 55 */200395387, /* 56 */275423344, /* 57 */430227734, /* 58 */506948616, /* 59 */659060556, /* 60 */883997877, /* 61 */958139571, /* 62 */1322822218, /* 63 */1537002063, /* 64 */1747873779, /* 65 */1955562222, /* 66 */2024104815, /* 67 */2067236844, /* 68 */1933114872, /* 69 */1866530822, /* 70 */1538233109, /* 71 */1090935817, /* 72 */965641998, /* 73 */"hex", /* 74 */"b64", /* 75 */"any", /* 76 */"hex_hmac", /* 77 */"b64_hmac", /* 78 */"any_hmac", /* 79 */"abc", /* 80 */"toLowerCase", /* 81 */"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", /* 82 */"length", /* 83 */8, /* 84 */16, /* 85 */909522486, /* 86 */1549556828, /* 87 */1, /* 88 */"concat", /* 89 */512, /* 90 */256, /* 91 */"0123456789ABCDEF", /* 92 */"0123456789abcdef", /* 93 */"charCodeAt", /* 94 */"charAt", /* 95 */4, /* 96 */15, /* 97 */"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", /* 98 */2, /* 99 */6, /* 100 */3, /* 101 */63, /* 102 */"Math", /* 103 */"ceil", /* 104 */"floor", /* 105 */"log", /* 106 */55296, /* 107 */56319, /* 108 */56320, /* 109 */57343, /* 110 */65536, /* 111 */1023, /* 112 */10, /* 113 */127, /* 114 */"String", /* 115 */"fromCharCode", /* 116 */2047, /* 117 */192, /* 118 */31, /* 119 */128, /* 120 */65535, /* 121 */224, /* 122 */12, /* 123 */2097151, /* 124 */240, /* 125 */18, /* 126 */7, /* 127 */255, /* 128 */5, /* 129 */24, /* 130 */32, /* 131 */13, /* 132 */22, /* 133 */11, /* 134 */25, /* 135 */17, /* 136 */19, /* 137 */28, /* 138 */34, /* 139 */39, /* 140 */14, /* 141 */41, /* 142 */61, /* 143 */1779033703, /* 144 */1150833019, /* 145 */1013904242, /* 146 */1521486534, /* 147 */1359893119, /* 148 */1694144372, /* 149 */528734635, /* 150 */1541459225, /* 151 */64, /* 152 */9]; +var BYTECODE = [4, 1, 47, 3, 76, 0, 102, 0, 0, 42, 4, 3, 0, 5, 2, 4, 38, 3, 0, 0, 0, 4, 1, 0, 27, 5, 3, 4, 40, 5, 43, 2, 3, 0, 0, 0, 4, 2, 0, 9, 3, 4, 2, 38, 3, 3, 0, 0, 4, 1, 0, 27, 5, 3, 4, 40, 5, 70, 2, 3, 3, 0, 0, 4, 4, 0, 9, 3, 4, 2, 0, 3, 5, 0, 45, 3, 4, 1, 47, 36, 967, 1, 7, 3, 0, 1, 13, 1, 11, 1, 16, 5, 4, 36, 47, 36, 1001, 1, 7, 3, 0, 1, 14, 1, 11, 1, 16, 5, 5, 36, 47, 36, 1035, 2, 8, 3, 0, 1, 15, 1, 11, 1, 16, 5, 6, 36, 47, 36, 1070, 2, 9, 3, 0, 1, 13, 1, 12, 1, 16, 5, 7, 36, 47, 36, 1113, 2, 9, 3, 0, 1, 14, 1, 12, 1, 16, 5, 8, 36, 47, 36, 1156, 3, 10, 3, 0, 1, 15, 1, 12, 1, 16, 5, 9, 36, 47, 36, 1200, 0, 5, 1, 0, 1, 4, 5, 10, 36, 47, 36, 1243, 1, 9, 3, 0, 1, 20, 1, 34, 1, 19, 5, 11, 36, 47, 36, 1294, 2, 15, 3, 0, 1, 19, 1, 34, 1, 20, 5, 12, 36, 47, 36, 1561, 1, 12, 1, 0, 1, 2, 5, 13, 36, 47, 36, 1741, 1, 14, 1, 0, 1, 3, 5, 14, 36, 47, 36, 2096, 2, 21, 0, 0, 5, 15, 36, 47, 36, 2711, 1, 15, 0, 0, 5, 16, 36, 47, 36, 3314, 1, 11, 0, 0, 5, 17, 36, 47, 36, 3447, 1, 11, 0, 0, 5, 18, 36, 47, 36, 3580, 1, 11, 0, 0, 5, 19, 36, 47, 36, 3777, 1, 11, 0, 0, 5, 20, 36, 47, 36, 3902, 2, 7, 0, 0, 5, 21, 36, 47, 36, 3932, 2, 5, 0, 0, 5, 22, 36, 47, 36, 3946, 3, 8, 0, 0, 5, 23, 36, 47, 36, 3971, 3, 8, 0, 0, 5, 24, 36, 47, 36, 4001, 1, 7, 1, 0, 1, 21, 5, 25, 36, 47, 36, 4058, 1, 7, 1, 0, 1, 21, 5, 26, 36, 47, 36, 4115, 1, 7, 2, 0, 1, 21, 1, 22, 5, 27, 36, 47, 36, 4172, 1, 7, 2, 0, 1, 21, 1, 22, 5, 28, 36, 47, 36, 4229, 1, 7, 1, 0, 1, 21, 5, 29, 36, 47, 36, 4286, 1, 7, 1, 0, 1, 21, 5, 30, 36, 47, 36, 4343, 1, 7, 2, 0, 1, 21, 1, 22, 5, 31, 36, 47, 36, 4400, 1, 7, 2, 0, 1, 21, 1, 22, 5, 32, 36, 47, 36, 4457, 2, 28, 8, 0, 1, 35, 1, 28, 1, 27, 1, 26, 1, 23, 1, 33, 1, 25, 1, 24, 5, 34, 36, 47, 36, 5288, 2, 9, 0, 0, 5, 35, 36, 0, 36, 6, 0, 5, 2, 36, 0, 36, 7, 0, 5, 3, 36, 2, 36, 8, 0, 0, 37, 9, 0, 0, 38, 10, 0, 0, 39, 11, 0, 32, 40, 39, 0, 39, 12, 0, 32, 41, 39, 0, 39, 13, 0, 0, 42, 14, 0, 0, 43, 15, 0, 32, 44, 43, 0, 43, 16, 0, 32, 45, 43, 0, 43, 17, 0, 32, 46, 43, 0, 43, 18, 0, 0, 47, 19, 0, 0, 48, 20, 0, 0, 49, 21, 0, 0, 50, 22, 0, 32, 51, 50, 0, 50, 23, 0, 32, 52, 50, 0, 50, 24, 0, 32, 53, 50, 0, 50, 25, 0, 32, 54, 50, 0, 50, 26, 0, 32, 55, 50, 0, 50, 27, 0, 0, 56, 28, 0, 0, 57, 29, 0, 0, 58, 30, 0, 0, 59, 31, 0, 0, 60, 32, 0, 0, 61, 33, 0, 32, 62, 61, 0, 61, 34, 0, 32, 63, 61, 0, 61, 35, 0, 32, 64, 61, 0, 61, 36, 0, 32, 65, 61, 0, 61, 37, 0, 32, 66, 61, 0, 61, 38, 0, 32, 67, 61, 0, 61, 39, 0, 0, 68, 40, 0, 0, 69, 41, 0, 0, 70, 42, 0, 0, 71, 43, 0, 0, 72, 44, 0, 0, 73, 45, 0, 0, 74, 46, 0, 0, 75, 47, 0, 32, 76, 75, 0, 75, 48, 0, 32, 77, 75, 0, 75, 49, 0, 32, 78, 75, 0, 75, 50, 0, 32, 79, 75, 0, 75, 51, 0, 32, 80, 75, 0, 75, 52, 0, 32, 81, 75, 0, 75, 53, 0, 32, 82, 75, 0, 75, 54, 0, 32, 83, 75, 0, 75, 55, 0, 32, 84, 75, 0, 75, 56, 0, 0, 85, 57, 0, 0, 86, 58, 0, 0, 87, 59, 0, 0, 88, 60, 0, 0, 89, 61, 0, 0, 90, 62, 0, 0, 91, 63, 0, 0, 92, 64, 0, 0, 93, 65, 0, 0, 94, 66, 0, 0, 95, 67, 0, 32, 96, 95, 0, 95, 68, 0, 32, 97, 95, 0, 95, 69, 0, 32, 98, 95, 0, 95, 70, 0, 32, 99, 95, 0, 95, 71, 0, 32, 100, 95, 0, 95, 72, 0, 32, 101, 95, 44, 95, 36, 64, 37, 38, 40, 41, 39, 42, 44, 45, 46, 43, 47, 48, 49, 51, 52, 53, 54, 55, 50, 56, 57, 58, 59, 60, 62, 63, 64, 65, 66, 67, 61, 68, 69, 70, 71, 72, 73, 74, 76, 77, 78, 79, 80, 81, 82, 83, 84, 75, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 96, 97, 98, 99, 100, 101, 5, 33, 95, 0, 36, 73, 0, 0, 37, 74, 0, 0, 38, 75, 0, 0, 39, 76, 0, 0, 40, 77, 0, 0, 41, 78, 0, 49, 42, 6, 36, 4, 37, 8, 38, 9, 39, 7, 40, 8, 41, 9, 45, 42, 0, 36, 5, 0, 45, 36, 4, 2, 3, 3, 0, 3, 4, 1, 3, 5, 2, 42, 6, 5, 1, 0, 42, 5, 4, 1, 6, 42, 4, 3, 1, 5, 45, 4, 0, 3, 5, 0, 45, 3, 4, 2, 3, 3, 0, 3, 4, 1, 3, 5, 2, 42, 6, 5, 1, 0, 42, 5, 4, 1, 6, 42, 4, 3, 1, 5, 45, 4, 0, 3, 5, 0, 45, 3, 4, 3, 3, 4, 0, 3, 5, 1, 3, 6, 2, 42, 7, 6, 1, 0, 42, 6, 5, 1, 7, 42, 5, 4, 2, 6, 1, 45, 5, 0, 4, 5, 0, 45, 4, 4, 3, 3, 4, 0, 3, 5, 1, 3, 6, 2, 42, 7, 6, 1, 0, 3, 6, 2, 42, 8, 6, 1, 1, 42, 6, 5, 2, 7, 8, 42, 5, 4, 1, 6, 45, 5, 0, 4, 5, 0, 45, 4, 4, 3, 3, 4, 0, 3, 5, 1, 3, 6, 2, 42, 7, 6, 1, 0, 3, 6, 2, 42, 8, 6, 1, 1, 42, 6, 5, 2, 7, 8, 42, 5, 4, 1, 6, 45, 5, 0, 4, 5, 0, 45, 4, 4, 4, 3, 5, 0, 3, 6, 1, 3, 7, 2, 42, 8, 7, 1, 0, 3, 7, 2, 42, 9, 7, 1, 1, 42, 7, 6, 2, 8, 9, 42, 6, 5, 2, 7, 2, 45, 6, 0, 5, 5, 0, 45, 5, 4, 1, 3, 2, 0, 0, 3, 79, 0, 42, 4, 2, 1, 3, 0, 2, 80, 0, 8, 3, 4, 2, 43, 2, 4, 3, 0, 0, 3, 81, 0, 28, 4, 2, 3, 45, 4, 0, 2, 5, 0, 45, 2, 4, 2, 3, 3, 0, 3, 4, 1, 3, 5, 2, 42, 6, 5, 1, 0, 0, 5, 82, 0, 8, 7, 0, 5, 0, 5, 83, 0, 13, 8, 7, 5, 42, 5, 4, 2, 6, 8, 42, 4, 3, 1, 5, 45, 4, 0, 3, 5, 0, 45, 3, 4, 3, 3, 9, 0, 42, 10, 9, 1, 0, 5, 4, 10, 0, 9, 82, 0, 8, 10, 4, 9, 0, 9, 84, 0, 23, 11, 10, 9, 40, 11, 1354, 3, 9, 1, 0, 10, 82, 0, 8, 11, 0, 10, 0, 10, 83, 0, 13, 12, 11, 10, 42, 10, 9, 2, 4, 12, 5, 4, 10, 2, 9, 8, 0, 0, 10, 84, 0, 42, 11, 9, 1, 10, 5, 5, 11, 2, 9, 8, 0, 0, 10, 84, 0, 42, 11, 9, 1, 10, 5, 6, 11, 0, 9, 6, 0, 5, 7, 9, 0, 9, 84, 0, 22, 10, 7, 9, 40, 10, 1452, 8, 9, 4, 7, 0, 10, 85, 0, 18, 11, 9, 10, 9, 5, 7, 11, 8, 9, 4, 7, 0, 10, 86, 0, 18, 11, 9, 10, 9, 6, 7, 11, 5, 9, 7, 0, 9, 87, 0, 11, 10, 7, 9, 5, 7, 10, 39, 1393, 3, 9, 1, 0, 10, 88, 0, 8, 11, 5, 10, 3, 10, 0, 42, 12, 10, 1, 1, 43, 10, 5, 11, 1, 12, 0, 11, 89, 0, 0, 12, 82, 0, 8, 13, 1, 12, 0, 12, 83, 0, 13, 14, 13, 12, 11, 12, 11, 14, 42, 11, 9, 2, 10, 12, 5, 8, 11, 3, 9, 2, 3, 10, 1, 0, 11, 88, 0, 8, 12, 6, 11, 43, 11, 6, 12, 1, 8, 0, 12, 89, 0, 0, 13, 90, 0, 11, 14, 12, 13, 42, 12, 10, 2, 11, 14, 42, 10, 9, 1, 12, 45, 10, 0, 9, 5, 0, 45, 9, 4, 2, 54, 1572, 3, 3, 8, 0, 55, 39, 1579, 0, 8, 6, 0, 7, 0, 8, 3, 8, 0, 40, 8, 1594, 0, 8, 91, 0, 5, 9, 8, 39, 1601, 0, 8, 92, 0, 5, 9, 8, 5, 4, 9, 0, 8, 7, 0, 5, 5, 8, 0, 6, 5, 0, 0, 8, 6, 0, 5, 7, 8, 0, 8, 82, 0, 8, 9, 0, 8, 22, 8, 7, 9, 40, 8, 1733, 0, 8, 93, 0, 8, 9, 0, 8, 43, 8, 0, 9, 1, 7, 5, 6, 8, 0, 8, 94, 0, 8, 9, 4, 8, 0, 8, 95, 0, 21, 10, 6, 8, 0, 8, 96, 0, 16, 11, 10, 8, 43, 8, 4, 9, 1, 11, 0, 9, 94, 0, 8, 10, 4, 9, 0, 9, 96, 0, 16, 11, 6, 9, 43, 9, 4, 10, 1, 11, 11, 10, 8, 9, 11, 8, 5, 10, 5, 5, 8, 5, 8, 7, 0, 8, 87, 0, 11, 9, 7, 8, 5, 7, 9, 39, 1622, 45, 5, 0, 8, 5, 0, 45, 8, 4, 2, 54, 1752, 3, 3, 10, 0, 55, 39, 1759, 0, 10, 7, 0, 7, 0, 10, 0, 10, 97, 0, 5, 4, 10, 0, 10, 7, 0, 5, 5, 10, 0, 10, 82, 0, 8, 11, 0, 10, 5, 6, 11, 0, 10, 6, 0, 5, 7, 10, 22, 10, 7, 6, 40, 10, 2088, 0, 10, 93, 0, 8, 11, 0, 10, 43, 10, 0, 11, 1, 7, 0, 11, 84, 0, 19, 12, 10, 11, 0, 10, 87, 0, 11, 11, 7, 10, 22, 10, 11, 6, 40, 10, 1870, 0, 10, 93, 0, 8, 11, 0, 10, 0, 10, 87, 0, 11, 13, 7, 10, 43, 10, 0, 11, 1, 13, 0, 11, 83, 0, 19, 13, 10, 11, 5, 10, 13, 39, 1877, 0, 11, 6, 0, 5, 10, 11, 17, 11, 12, 10, 0, 10, 98, 0, 11, 12, 7, 10, 22, 10, 12, 6, 40, 10, 1923, 0, 10, 93, 0, 8, 12, 0, 10, 0, 10, 98, 0, 11, 13, 7, 10, 43, 10, 0, 12, 1, 13, 5, 12, 10, 39, 1930, 0, 10, 6, 0, 5, 12, 10, 17, 10, 11, 12, 5, 8, 10, 0, 10, 6, 0, 5, 9, 10, 0, 10, 95, 0, 22, 11, 9, 10, 40, 11, 2075, 0, 10, 83, 0, 13, 11, 7, 10, 0, 10, 99, 0, 13, 12, 9, 10, 11, 10, 11, 12, 0, 11, 82, 0, 8, 12, 0, 11, 0, 11, 83, 0, 13, 13, 12, 11, 23, 11, 10, 13, 40, 11, 2010, 3, 10, 0, 11, 11, 5, 10, 5, 5, 11, 39, 2059, 0, 10, 94, 0, 8, 11, 4, 10, 0, 10, 99, 0, 0, 12, 100, 0, 12, 13, 12, 9, 13, 12, 10, 13, 21, 10, 8, 12, 0, 12, 101, 0, 16, 13, 10, 12, 43, 10, 4, 11, 1, 13, 11, 11, 5, 10, 5, 5, 11, 5, 10, 9, 0, 10, 87, 0, 11, 11, 9, 10, 5, 9, 11, 39, 1944, 0, 10, 100, 0, 11, 11, 7, 10, 5, 7, 11, 39, 1791, 45, 5, 0, 10, 5, 0, 45, 10, 4, 3, 0, 13, 82, 0, 8, 14, 1, 13, 5, 4, 14, 2, 13, 8, 0, 42, 14, 13, 0, 5, 5, 14, 0, 6, 5, 0, 0, 7, 5, 0, 0, 8, 5, 0, 0, 9, 5, 0, 2, 13, 8, 0, 2, 14, 102, 0, 0, 15, 103, 0, 8, 16, 14, 15, 0, 15, 82, 0, 8, 17, 0, 15, 0, 15, 98, 0, 14, 18, 17, 15, 43, 15, 14, 16, 1, 18, 42, 14, 13, 1, 15, 5, 10, 14, 0, 13, 6, 0, 5, 6, 13, 0, 13, 82, 0, 8, 14, 10, 13, 22, 13, 6, 14, 40, 13, 2288, 0, 13, 93, 0, 8, 14, 0, 13, 0, 13, 98, 0, 13, 15, 6, 13, 43, 13, 0, 14, 1, 15, 0, 14, 83, 0, 19, 15, 13, 14, 0, 13, 93, 0, 8, 14, 0, 13, 0, 13, 98, 0, 13, 16, 6, 13, 0, 13, 87, 0, 11, 17, 16, 13, 43, 13, 0, 14, 1, 17, 17, 14, 15, 13, 9, 10, 6, 14, 5, 13, 6, 0, 13, 87, 0, 11, 14, 6, 13, 5, 6, 14, 39, 2189, 0, 13, 82, 0, 8, 14, 10, 13, 0, 13, 6, 0, 23, 15, 14, 13, 40, 15, 2483, 2, 13, 8, 0, 42, 14, 13, 0, 5, 9, 14, 0, 13, 6, 0, 5, 8, 13, 0, 13, 6, 0, 5, 6, 13, 0, 13, 82, 0, 8, 14, 10, 13, 22, 13, 6, 14, 40, 13, 2466, 0, 13, 84, 0, 19, 14, 8, 13, 8, 13, 10, 6, 11, 15, 14, 13, 5, 8, 15, 2, 13, 102, 0, 0, 14, 104, 0, 8, 15, 13, 14, 14, 14, 8, 4, 43, 16, 13, 15, 1, 14, 5, 7, 16, 13, 13, 7, 4, 12, 14, 8, 13, 5, 8, 14, 0, 13, 82, 0, 8, 14, 9, 13, 0, 13, 6, 0, 23, 15, 14, 13, 5, 13, 15, 41, 13, 2435, 0, 14, 6, 0, 23, 15, 7, 14, 5, 13, 15, 40, 13, 2450, 0, 13, 82, 0, 8, 14, 9, 13, 9, 9, 14, 7, 5, 13, 6, 0, 13, 87, 0, 11, 14, 6, 13, 5, 6, 14, 39, 2332, 0, 13, 82, 0, 8, 14, 5, 13, 9, 5, 14, 8, 5, 10, 9, 39, 2288, 0, 13, 7, 0, 5, 11, 13, 0, 13, 82, 0, 8, 14, 5, 13, 0, 13, 87, 0, 12, 15, 14, 13, 5, 6, 15, 0, 13, 6, 0, 25, 14, 6, 13, 40, 14, 2561, 0, 13, 94, 0, 8, 14, 1, 13, 8, 13, 5, 6, 43, 15, 1, 14, 1, 13, 11, 13, 11, 15, 5, 11, 13, 5, 13, 6, 0, 13, 87, 0, 12, 14, 6, 13, 5, 6, 14, 39, 2509, 2, 13, 102, 0, 0, 14, 103, 0, 8, 15, 13, 14, 0, 14, 82, 0, 8, 16, 0, 14, 0, 14, 83, 0, 13, 17, 16, 14, 2, 14, 102, 0, 0, 16, 105, 0, 8, 18, 14, 16, 0, 16, 82, 0, 8, 19, 1, 16, 43, 16, 14, 18, 1, 19, 2, 14, 102, 0, 0, 18, 105, 0, 8, 19, 14, 18, 0, 18, 98, 0, 43, 20, 14, 19, 1, 18, 14, 14, 16, 20, 14, 16, 17, 14, 43, 14, 13, 15, 1, 16, 5, 12, 14, 0, 13, 82, 0, 8, 14, 11, 13, 5, 6, 14, 22, 13, 6, 12, 40, 13, 2703, 0, 13, 6, 0, 8, 14, 1, 13, 11, 13, 14, 11, 5, 11, 13, 5, 13, 6, 0, 13, 87, 0, 11, 14, 6, 13, 5, 6, 14, 39, 2665, 45, 11, 0, 13, 5, 0, 45, 13, 4, 2, 0, 7, 7, 0, 5, 3, 7, 0, 7, 87, 0, 32, 8, 7, 5, 4, 8, 0, 5, 5, 0, 0, 6, 5, 0, 0, 7, 87, 0, 11, 8, 4, 7, 5, 4, 8, 0, 7, 82, 0, 8, 9, 0, 7, 22, 7, 8, 9, 40, 7, 3306, 0, 7, 93, 0, 8, 8, 0, 7, 43, 7, 0, 8, 1, 4, 5, 5, 7, 0, 7, 87, 0, 11, 8, 4, 7, 0, 7, 82, 0, 8, 9, 0, 7, 22, 7, 8, 9, 40, 7, 2831, 0, 7, 93, 0, 8, 8, 0, 7, 0, 7, 87, 0, 11, 9, 4, 7, 43, 7, 0, 8, 1, 9, 5, 8, 7, 39, 2838, 0, 7, 6, 0, 5, 8, 7, 5, 6, 8, 0, 7, 106, 0, 24, 8, 7, 5, 5, 7, 8, 40, 7, 2866, 0, 8, 107, 0, 24, 9, 5, 8, 5, 7, 9, 5, 8, 7, 40, 8, 2883, 0, 7, 108, 0, 24, 9, 7, 6, 5, 8, 9, 5, 7, 8, 40, 7, 2900, 0, 8, 109, 0, 24, 9, 6, 8, 5, 7, 9, 40, 7, 2956, 0, 7, 110, 0, 0, 8, 111, 0, 16, 9, 5, 8, 0, 8, 112, 0, 19, 10, 9, 8, 11, 8, 7, 10, 0, 7, 111, 0, 16, 9, 6, 7, 11, 7, 8, 9, 5, 5, 7, 5, 7, 4, 0, 7, 87, 0, 11, 8, 4, 7, 5, 4, 8, 0, 7, 113, 0, 24, 8, 5, 7, 40, 8, 2994, 2, 7, 114, 0, 0, 8, 115, 0, 8, 9, 7, 8, 43, 8, 7, 9, 1, 5, 11, 7, 3, 8, 5, 3, 7, 39, 3304, 0, 7, 116, 0, 24, 8, 5, 7, 40, 8, 3073, 2, 7, 114, 0, 0, 8, 115, 0, 8, 9, 7, 8, 0, 8, 117, 0, 0, 10, 99, 0, 21, 11, 5, 10, 0, 10, 118, 0, 16, 12, 11, 10, 17, 10, 8, 12, 0, 8, 119, 0, 0, 11, 101, 0, 16, 12, 5, 11, 17, 11, 8, 12, 43, 8, 7, 9, 2, 10, 11, 11, 7, 3, 8, 5, 3, 7, 39, 3304, 0, 7, 120, 0, 24, 8, 5, 7, 40, 8, 3177, 2, 7, 114, 0, 0, 8, 115, 0, 8, 9, 7, 8, 0, 8, 121, 0, 0, 10, 122, 0, 21, 11, 5, 10, 0, 10, 96, 0, 16, 12, 11, 10, 17, 10, 8, 12, 0, 8, 119, 0, 0, 11, 99, 0, 21, 12, 5, 11, 0, 11, 101, 0, 16, 13, 12, 11, 17, 11, 8, 13, 0, 8, 119, 0, 0, 12, 101, 0, 16, 13, 5, 12, 17, 12, 8, 13, 43, 8, 7, 9, 3, 10, 11, 12, 11, 7, 3, 8, 5, 3, 7, 39, 3304, 0, 7, 123, 0, 24, 8, 5, 7, 40, 8, 3304, 2, 7, 114, 0, 0, 8, 115, 0, 8, 9, 7, 8, 0, 8, 124, 0, 0, 10, 125, 0, 21, 11, 5, 10, 0, 10, 126, 0, 16, 12, 11, 10, 17, 10, 8, 12, 0, 8, 119, 0, 0, 11, 122, 0, 21, 12, 5, 11, 0, 11, 101, 0, 16, 13, 12, 11, 17, 11, 8, 13, 0, 8, 119, 0, 0, 12, 99, 0, 21, 13, 5, 12, 0, 12, 101, 0, 16, 14, 13, 12, 17, 12, 8, 14, 0, 8, 119, 0, 0, 13, 101, 0, 16, 14, 5, 13, 17, 13, 8, 14, 43, 8, 7, 9, 4, 10, 11, 12, 13, 11, 7, 3, 8, 5, 3, 7, 39, 2738, 45, 3, 0, 7, 5, 0, 45, 7, 4, 2, 0, 5, 7, 0, 5, 3, 5, 0, 5, 6, 0, 5, 4, 5, 0, 5, 82, 0, 8, 6, 0, 5, 22, 5, 4, 6, 40, 5, 3439, 2, 5, 114, 0, 0, 6, 115, 0, 8, 7, 5, 6, 0, 6, 93, 0, 8, 8, 0, 6, 43, 6, 0, 8, 1, 4, 0, 8, 127, 0, 16, 9, 6, 8, 0, 6, 93, 0, 8, 8, 0, 6, 43, 6, 0, 8, 1, 4, 0, 8, 83, 0, 21, 10, 6, 8, 0, 6, 127, 0, 16, 8, 10, 6, 43, 6, 5, 7, 2, 9, 8, 11, 5, 3, 6, 5, 3, 5, 5, 5, 4, 0, 5, 87, 0, 11, 6, 4, 5, 5, 4, 6, 39, 3330, 45, 3, 0, 5, 5, 0, 45, 5, 4, 2, 0, 5, 7, 0, 5, 3, 5, 0, 5, 6, 0, 5, 4, 5, 0, 5, 82, 0, 8, 6, 0, 5, 22, 5, 4, 6, 40, 5, 3572, 2, 5, 114, 0, 0, 6, 115, 0, 8, 7, 5, 6, 0, 6, 93, 0, 8, 8, 0, 6, 43, 6, 0, 8, 1, 4, 0, 8, 83, 0, 21, 9, 6, 8, 0, 6, 127, 0, 16, 8, 9, 6, 0, 6, 93, 0, 8, 9, 0, 6, 43, 6, 0, 9, 1, 4, 0, 9, 127, 0, 16, 10, 6, 9, 43, 6, 5, 7, 2, 8, 10, 11, 5, 3, 6, 5, 3, 5, 5, 5, 4, 0, 5, 87, 0, 11, 6, 4, 5, 5, 4, 6, 39, 3463, 45, 3, 0, 5, 5, 0, 45, 5, 4, 2, 2, 5, 8, 0, 0, 6, 82, 0, 8, 7, 0, 6, 0, 6, 98, 0, 20, 8, 7, 6, 42, 6, 5, 1, 8, 5, 3, 6, 0, 5, 6, 0, 5, 4, 5, 0, 5, 82, 0, 8, 6, 3, 5, 22, 5, 4, 6, 40, 5, 3656, 0, 5, 6, 0, 9, 3, 4, 5, 5, 5, 4, 0, 5, 87, 0, 11, 6, 4, 5, 5, 4, 6, 39, 3617, 0, 5, 6, 0, 5, 4, 5, 0, 5, 82, 0, 8, 6, 0, 5, 0, 5, 83, 0, 13, 7, 6, 5, 22, 5, 4, 7, 40, 5, 3769, 0, 5, 128, 0, 20, 6, 4, 5, 8, 5, 3, 6, 0, 7, 93, 0, 8, 8, 0, 7, 0, 7, 83, 0, 14, 9, 4, 7, 43, 7, 0, 8, 1, 9, 0, 8, 127, 0, 16, 9, 7, 8, 0, 7, 129, 0, 0, 8, 130, 0, 15, 10, 4, 8, 12, 8, 7, 10, 19, 7, 9, 8, 17, 8, 5, 7, 9, 3, 6, 8, 0, 5, 83, 0, 11, 6, 4, 5, 5, 4, 6, 39, 3663, 45, 3, 0, 5, 5, 0, 45, 5, 4, 2, 0, 5, 7, 0, 5, 3, 5, 0, 5, 6, 0, 5, 4, 5, 0, 5, 82, 0, 8, 6, 0, 5, 0, 5, 130, 0, 13, 7, 6, 5, 22, 5, 4, 7, 40, 5, 3894, 2, 5, 114, 0, 0, 6, 115, 0, 8, 7, 5, 6, 0, 6, 128, 0, 20, 8, 4, 6, 8, 6, 0, 8, 0, 8, 129, 0, 0, 9, 130, 0, 15, 10, 4, 9, 12, 9, 8, 10, 21, 8, 6, 9, 0, 6, 127, 0, 16, 9, 8, 6, 43, 6, 5, 7, 1, 9, 11, 5, 3, 6, 5, 3, 5, 0, 5, 83, 0, 11, 6, 4, 5, 5, 4, 6, 39, 3793, 45, 3, 0, 5, 5, 0, 45, 5, 4, 3, 21, 4, 0, 1, 0, 5, 130, 0, 12, 6, 5, 1, 19, 5, 0, 6, 17, 6, 4, 5, 45, 6, 0, 4, 5, 0, 45, 4, 4, 3, 21, 4, 0, 1, 45, 4, 0, 4, 5, 0, 45, 4, 4, 4, 16, 5, 0, 1, 35, 6, 0, 16, 7, 6, 2, 18, 6, 5, 7, 45, 6, 0, 5, 5, 0, 45, 5, 4, 4, 16, 5, 0, 1, 16, 6, 0, 2, 18, 7, 5, 6, 16, 5, 1, 2, 18, 6, 7, 5, 45, 6, 0, 5, 5, 0, 45, 5, 4, 2, 3, 3, 0, 0, 4, 98, 0, 42, 5, 3, 2, 0, 4, 3, 3, 0, 0, 4, 131, 0, 42, 6, 3, 2, 0, 4, 18, 3, 5, 6, 3, 4, 0, 0, 5, 132, 0, 42, 6, 4, 2, 0, 5, 18, 4, 3, 6, 45, 4, 0, 3, 5, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 99, 0, 42, 5, 3, 2, 0, 4, 3, 3, 0, 0, 4, 133, 0, 42, 6, 3, 2, 0, 4, 18, 3, 5, 6, 3, 4, 0, 0, 5, 134, 0, 42, 6, 4, 2, 0, 5, 18, 4, 3, 6, 45, 4, 0, 3, 5, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 126, 0, 42, 5, 3, 2, 0, 4, 3, 3, 0, 0, 4, 125, 0, 42, 6, 3, 2, 0, 4, 18, 3, 5, 6, 3, 4, 1, 0, 5, 100, 0, 42, 6, 4, 2, 0, 5, 18, 4, 3, 6, 45, 4, 0, 3, 5, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 135, 0, 42, 5, 3, 2, 0, 4, 3, 3, 0, 0, 4, 136, 0, 42, 6, 3, 2, 0, 4, 18, 3, 5, 6, 3, 4, 1, 0, 5, 112, 0, 42, 6, 4, 2, 0, 5, 18, 4, 3, 6, 45, 4, 0, 3, 5, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 137, 0, 42, 5, 3, 2, 0, 4, 3, 3, 0, 0, 4, 138, 0, 42, 6, 3, 2, 0, 4, 18, 3, 5, 6, 3, 4, 0, 0, 5, 139, 0, 42, 6, 4, 2, 0, 5, 18, 4, 3, 6, 45, 4, 0, 3, 5, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 140, 0, 42, 5, 3, 2, 0, 4, 3, 3, 0, 0, 4, 125, 0, 42, 6, 3, 2, 0, 4, 18, 3, 5, 6, 3, 4, 0, 0, 5, 141, 0, 42, 6, 4, 2, 0, 5, 18, 4, 3, 6, 45, 4, 0, 3, 5, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 87, 0, 42, 5, 3, 2, 0, 4, 3, 3, 0, 0, 4, 83, 0, 42, 6, 3, 2, 0, 4, 18, 3, 5, 6, 3, 4, 1, 0, 5, 126, 0, 42, 6, 4, 2, 0, 5, 18, 4, 3, 6, 45, 4, 0, 3, 5, 0, 45, 3, 4, 2, 3, 3, 0, 0, 4, 136, 0, 42, 5, 3, 2, 0, 4, 3, 3, 0, 0, 4, 142, 0, 42, 6, 3, 2, 0, 4, 18, 3, 5, 6, 3, 4, 1, 0, 5, 99, 0, 42, 6, 4, 2, 0, 5, 18, 4, 3, 6, 45, 4, 0, 3, 5, 0, 45, 3, 4, 3, 2, 18, 8, 0, 0, 19, 143, 0, 0, 20, 144, 0, 32, 21, 20, 0, 20, 145, 0, 0, 22, 146, 0, 32, 23, 22, 0, 22, 147, 0, 0, 24, 148, 0, 32, 25, 24, 0, 24, 149, 0, 0, 26, 150, 0, 44, 27, 18, 8, 19, 21, 20, 23, 22, 25, 24, 26, 5, 4, 27, 2, 18, 8, 0, 0, 19, 151, 0, 44, 20, 18, 1, 19, 5, 5, 20, 0, 6, 5, 0, 0, 7, 5, 0, 0, 8, 5, 0, 0, 9, 5, 0, 0, 10, 5, 0, 0, 11, 5, 0, 0, 12, 5, 0, 0, 13, 5, 0, 0, 14, 5, 0, 0, 15, 5, 0, 0, 16, 5, 0, 0, 17, 5, 0, 0, 18, 128, 0, 20, 19, 1, 18, 8, 18, 0, 19, 0, 20, 119, 0, 0, 21, 129, 0, 0, 22, 130, 0, 15, 23, 1, 22, 12, 22, 21, 23, 19, 21, 20, 22, 17, 20, 18, 21, 9, 0, 19, 20, 0, 18, 151, 0, 11, 19, 1, 18, 0, 18, 152, 0, 20, 20, 19, 18, 0, 18, 95, 0, 19, 19, 20, 18, 0, 18, 96, 0, 11, 20, 19, 18, 9, 0, 20, 1, 0, 18, 6, 0, 5, 14, 18, 0, 18, 82, 0, 8, 19, 0, 18, 22, 18, 14, 19, 40, 18, 5280, 0, 18, 6, 0, 8, 19, 4, 18, 5, 6, 19, 0, 18, 87, 0, 8, 19, 4, 18, 5, 7, 19, 0, 18, 98, 0, 8, 19, 4, 18, 5, 8, 19, 0, 18, 100, 0, 8, 19, 4, 18, 5, 9, 19, 0, 18, 95, 0, 8, 19, 4, 18, 5, 10, 19, 0, 18, 128, 0, 8, 19, 4, 18, 5, 11, 19, 0, 18, 99, 0, 8, 19, 4, 18, 5, 12, 19, 0, 18, 126, 0, 8, 19, 4, 18, 5, 13, 19, 0, 18, 6, 0, 5, 15, 18, 0, 18, 151, 0, 22, 19, 15, 18, 40, 19, 5067, 0, 18, 84, 0, 22, 19, 15, 18, 40, 19, 4816, 11, 18, 15, 14, 8, 19, 0, 18, 9, 5, 15, 19, 39, 4911, 3, 18, 0, 3, 19, 0, 3, 20, 0, 3, 21, 1, 0, 22, 98, 0, 12, 23, 15, 22, 8, 22, 5, 23, 42, 23, 21, 1, 22, 0, 21, 126, 0, 12, 22, 15, 21, 8, 21, 5, 22, 42, 22, 20, 2, 23, 21, 3, 20, 2, 0, 21, 96, 0, 12, 23, 15, 21, 8, 21, 5, 23, 42, 23, 20, 1, 21, 42, 20, 19, 2, 22, 23, 0, 19, 84, 0, 12, 21, 15, 19, 8, 19, 5, 21, 42, 21, 18, 2, 20, 19, 9, 5, 15, 21, 3, 18, 0, 3, 19, 0, 3, 20, 0, 3, 21, 0, 3, 22, 3, 42, 23, 22, 1, 10, 42, 22, 21, 2, 13, 23, 3, 21, 4, 42, 23, 21, 3, 10, 11, 12, 42, 21, 20, 2, 22, 23, 3, 20, 5, 8, 22, 20, 15, 42, 20, 19, 2, 21, 22, 8, 19, 5, 15, 42, 21, 18, 2, 20, 19, 5, 16, 21, 3, 18, 0, 3, 19, 6, 42, 20, 19, 1, 6, 3, 19, 7, 42, 21, 19, 3, 6, 7, 8, 42, 19, 18, 2, 20, 21, 5, 17, 19, 5, 13, 12, 5, 12, 11, 5, 11, 10, 3, 18, 0, 42, 19, 18, 2, 9, 16, 5, 10, 19, 5, 9, 8, 5, 8, 7, 5, 7, 6, 3, 18, 0, 42, 19, 18, 2, 16, 17, 5, 6, 19, 5, 18, 15, 0, 18, 87, 0, 11, 19, 15, 18, 5, 15, 19, 39, 4780, 0, 18, 6, 0, 3, 19, 0, 0, 20, 6, 0, 8, 21, 4, 20, 42, 20, 19, 2, 6, 21, 9, 4, 18, 20, 0, 18, 87, 0, 3, 19, 0, 0, 20, 87, 0, 8, 21, 4, 20, 42, 20, 19, 2, 7, 21, 9, 4, 18, 20, 0, 18, 98, 0, 3, 19, 0, 0, 20, 98, 0, 8, 21, 4, 20, 42, 20, 19, 2, 8, 21, 9, 4, 18, 20, 0, 18, 100, 0, 3, 19, 0, 0, 20, 100, 0, 8, 21, 4, 20, 42, 20, 19, 2, 9, 21, 9, 4, 18, 20, 0, 18, 95, 0, 3, 19, 0, 0, 20, 95, 0, 8, 21, 4, 20, 42, 20, 19, 2, 10, 21, 9, 4, 18, 20, 0, 18, 128, 0, 3, 19, 0, 0, 20, 128, 0, 8, 21, 4, 20, 42, 20, 19, 2, 11, 21, 9, 4, 18, 20, 0, 18, 99, 0, 3, 19, 0, 0, 20, 99, 0, 8, 21, 4, 20, 42, 20, 19, 2, 12, 21, 9, 4, 18, 20, 0, 18, 126, 0, 3, 19, 0, 0, 20, 126, 0, 8, 21, 4, 20, 42, 20, 19, 2, 13, 21, 9, 4, 18, 20, 0, 18, 84, 0, 11, 19, 14, 18, 5, 14, 19, 39, 4670, 45, 4, 0, 18, 5, 0, 45, 18, 4, 3, 0, 6, 120, 0, 16, 7, 0, 6, 0, 6, 120, 0, 16, 8, 1, 6, 11, 6, 7, 8, 5, 4, 6, 0, 6, 84, 0, 20, 7, 0, 6, 0, 6, 84, 0, 20, 8, 1, 6, 11, 6, 7, 8, 0, 7, 84, 0, 20, 8, 4, 7, 11, 7, 6, 8, 5, 5, 7, 0, 6, 84, 0, 19, 7, 5, 6, 0, 6, 120, 0, 16, 8, 4, 6, 17, 6, 7, 8, 45, 6, 0, 6, 5, 0, 45, 6]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 6; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/oracle.json b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/oracle.json new file mode 100644 index 00000000..fa287da1 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/oracle.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": "oracle.v1", + "cellId": "program-sha256", + "hostProfile": "minimal-window-v1", + "globals": {}, + "setup": { + "state": {}, + "aliases": [] + }, + "actions": [ + { + "op": "keys", + "root": "output", + "path": [], + "as": "keys" + }, + { + "op": "call", + "root": "output", + "path": [ + "hex" + ], + "args": [ + "Hello World!" + ], + "as": "hexHello" + }, + { + "op": "call", + "root": "output", + "path": [ + "hex" + ], + "args": [ + "Another SHA256" + ], + "as": "hexAnother" + } + ], + "timeoutMs": 2000, + "expected": { + "keys": [ + "hex", + "b64", + "any", + "hex_hmac", + "b64_hmac", + "any_hmac" + ], + "hexHello": "7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069", + "hexAnother": "846cf19f7bbbe6039a261bb17b693d27a77562843491bb9c053ba3b01921c10f" + }, + "expectedThrow": null, + "semanticTags": [ + "whole-program", + "nested-functions", + "closure", + "loops", + "bitwise", + "api" + ], + "hazards": [ + "Finite synchronous child-process oracle with SIGKILL timeout; no time, random, network, locale, async, or stack observables.", + "Tagged projection compares declared values only; function identity, object identity, and host-dependent stack data are excluded.", + "Post-evaluation observations use only the finite declarative actions recorded in the oracle; function values are never serialized." + ], + "exclusions": [ + "Compiler/runtime internals, debug comments, and opcode/IR coverage remain excluded until step 2." + ], + "comparison": { + "sourceRawStripped": "exact-normalized-observable", + "expectedKind": "normalized-value" + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/source.js b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/source.js new file mode 100644 index 00000000..699e786c --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/corpus/raw/program-sha256/source.js @@ -0,0 +1,459 @@ +// Modified by bryanchow for namespace control and higher compressibility +// See https://gist.github.com/1649353 for full revision history from original + +/* + * A JavaScript implementation of the Secure Hash Algorithm, SHA-256, as defined + * in FIPS 180-2 + * Version 2.2 Copyright Angel Marin, Paul Johnston 2000 - 2009. + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * Distributed under the BSD License + * See http://pajhome.org.uk/crypt/md5 for details. + * Also http://anmar.eu.org/projects/jssha2/ + */ + +var sha256 = (function () { + /* + * Configurable variables. You may need to tweak these to be compatible with + * the server-side, but the defaults work in most cases. + */ + var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */ + var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */ + + /* + * These are the functions you'll usually want to call + * They take string arguments and return either hex or base-64 encoded strings + */ + function hex_sha256(s) { + return rstr2hex(rstr_sha256(str2rstr_utf8(s))); + } + function b64_sha256(s) { + return rstr2b64(rstr_sha256(str2rstr_utf8(s))); + } + function any_sha256(s, e) { + return rstr2any(rstr_sha256(str2rstr_utf8(s)), e); + } + function hex_hmac_sha256(k, d) { + return rstr2hex(rstr_hmac_sha256(str2rstr_utf8(k), str2rstr_utf8(d))); + } + function b64_hmac_sha256(k, d) { + return rstr2b64(rstr_hmac_sha256(str2rstr_utf8(k), str2rstr_utf8(d))); + } + function any_hmac_sha256(k, d, e) { + return rstr2any(rstr_hmac_sha256(str2rstr_utf8(k), str2rstr_utf8(d)), e); + } + + /* + * Perform a simple self-test to see if the VM is working + */ + function sha256_vm_test() { + return ( + hex_sha256("abc").toLowerCase() == + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + /* + * Calculate the sha256 of a raw string + */ + function rstr_sha256(s) { + return binb2rstr(binb_sha256(rstr2binb(s), s.length * 8)); + } + + /* + * Calculate the HMAC-sha256 of a key and some data (raw strings) + */ + function rstr_hmac_sha256(key, data) { + var bkey = rstr2binb(key); + if (bkey.length > 16) bkey = binb_sha256(bkey, key.length * 8); + + var ipad = Array(16), + opad = Array(16); + for (var i = 0; i < 16; i++) { + ipad[i] = bkey[i] ^ 0x36363636; + opad[i] = bkey[i] ^ 0x5c5c5c5c; + } + + var hash = binb_sha256(ipad.concat(rstr2binb(data)), 512 + data.length * 8); + return binb2rstr(binb_sha256(opad.concat(hash), 512 + 256)); + } + + /* + * Convert a raw string to a hex string + */ + function rstr2hex(input) { + try { + hexcase; + } catch (e) { + hexcase = 0; + } + var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; + var output = ""; + var x; + for (var i = 0; i < input.length; i++) { + x = input.charCodeAt(i); + output += hex_tab.charAt((x >>> 4) & 0x0f) + hex_tab.charAt(x & 0x0f); + } + return output; + } + + /* + * Convert a raw string to a base-64 string + */ + function rstr2b64(input) { + try { + b64pad; + } catch (e) { + b64pad = ""; + } + var tab = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + var output = ""; + var len = input.length; + for (var i = 0; i < len; i += 3) { + var triplet = + (input.charCodeAt(i) << 16) | + (i + 1 < len ? input.charCodeAt(i + 1) << 8 : 0) | + (i + 2 < len ? input.charCodeAt(i + 2) : 0); + for (var j = 0; j < 4; j++) { + if (i * 8 + j * 6 > input.length * 8) output += b64pad; + else output += tab.charAt((triplet >>> (6 * (3 - j))) & 0x3f); + } + } + return output; + } + + /* + * Convert a raw string to an arbitrary string encoding + */ + function rstr2any(input, encoding) { + var divisor = encoding.length; + var remainders = Array(); + var i, q, x, quotient; + + /* Convert to an array of 16-bit big-endian values, forming the dividend */ + var dividend = Array(Math.ceil(input.length / 2)); + for (i = 0; i < dividend.length; i++) { + dividend[i] = + (input.charCodeAt(i * 2) << 8) | input.charCodeAt(i * 2 + 1); + } + + /* + * Repeatedly perform a long division. The binary array forms the dividend, + * the length of the encoding is the divisor. Once computed, the quotient + * forms the dividend for the next step. We stop when the dividend is zero. + * All remainders are stored for later use. + */ + while (dividend.length > 0) { + quotient = Array(); + x = 0; + for (i = 0; i < dividend.length; i++) { + x = (x << 16) + dividend[i]; + q = Math.floor(x / divisor); + x -= q * divisor; + if (quotient.length > 0 || q > 0) quotient[quotient.length] = q; + } + remainders[remainders.length] = x; + dividend = quotient; + } + + /* Convert the remainders to the output string */ + var output = ""; + for (i = remainders.length - 1; i >= 0; i--) + output += encoding.charAt(remainders[i]); + + /* Append leading zero equivalents */ + var full_length = Math.ceil( + (input.length * 8) / (Math.log(encoding.length) / Math.log(2)), + ); + for (i = output.length; i < full_length; i++) output = encoding[0] + output; + + return output; + } + + /* + * Encode a string as utf-8. + * For efficiency, this assumes the input is valid utf-16. + */ + function str2rstr_utf8(input) { + var output = ""; + var i = -1; + var x, y; + + while (++i < input.length) { + /* Decode utf-16 surrogate pairs */ + x = input.charCodeAt(i); + y = i + 1 < input.length ? input.charCodeAt(i + 1) : 0; + if (0xd800 <= x && x <= 0xdbff && 0xdc00 <= y && y <= 0xdfff) { + x = 0x10000 + ((x & 0x03ff) << 10) + (y & 0x03ff); + i++; + } + + /* Encode output as utf-8 */ + if (x <= 0x7f) output += String.fromCharCode(x); + else if (x <= 0x7ff) + output += String.fromCharCode( + 0xc0 | ((x >>> 6) & 0x1f), + 0x80 | (x & 0x3f), + ); + else if (x <= 0xffff) + output += String.fromCharCode( + 0xe0 | ((x >>> 12) & 0x0f), + 0x80 | ((x >>> 6) & 0x3f), + 0x80 | (x & 0x3f), + ); + else if (x <= 0x1fffff) + output += String.fromCharCode( + 0xf0 | ((x >>> 18) & 0x07), + 0x80 | ((x >>> 12) & 0x3f), + 0x80 | ((x >>> 6) & 0x3f), + 0x80 | (x & 0x3f), + ); + } + return output; + } + + /* + * Encode a string as utf-16 + */ + function str2rstr_utf16le(input) { + var output = ""; + for (var i = 0; i < input.length; i++) + output += String.fromCharCode( + input.charCodeAt(i) & 0xff, + (input.charCodeAt(i) >>> 8) & 0xff, + ); + return output; + } + + function str2rstr_utf16be(input) { + var output = ""; + for (var i = 0; i < input.length; i++) + output += String.fromCharCode( + (input.charCodeAt(i) >>> 8) & 0xff, + input.charCodeAt(i) & 0xff, + ); + return output; + } + + /* + * Convert a raw string to an array of big-endian words + * Characters >255 have their high-byte silently ignored. + */ + function rstr2binb(input) { + var output = Array(input.length >> 2); + for (var i = 0; i < output.length; i++) output[i] = 0; + for (var i = 0; i < input.length * 8; i += 8) + output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (24 - (i % 32)); + return output; + } + + /* + * Convert an array of big-endian words to a string + */ + function binb2rstr(input) { + var output = ""; + for (var i = 0; i < input.length * 32; i += 8) + output += String.fromCharCode((input[i >> 5] >>> (24 - (i % 32))) & 0xff); + return output; + } + + /* + * Main sha256 function, with its support functions + */ + function sha256_S(X, n) { + return (X >>> n) | (X << (32 - n)); + } + function sha256_R(X, n) { + return X >>> n; + } + function sha256_Ch(x, y, z) { + return (x & y) ^ (~x & z); + } + function sha256_Maj(x, y, z) { + return (x & y) ^ (x & z) ^ (y & z); + } + function sha256_Sigma0256(x) { + return sha256_S(x, 2) ^ sha256_S(x, 13) ^ sha256_S(x, 22); + } + function sha256_Sigma1256(x) { + return sha256_S(x, 6) ^ sha256_S(x, 11) ^ sha256_S(x, 25); + } + function sha256_Gamma0256(x) { + return sha256_S(x, 7) ^ sha256_S(x, 18) ^ sha256_R(x, 3); + } + function sha256_Gamma1256(x) { + return sha256_S(x, 17) ^ sha256_S(x, 19) ^ sha256_R(x, 10); + } + function sha256_Sigma0512(x) { + return sha256_S(x, 28) ^ sha256_S(x, 34) ^ sha256_S(x, 39); + } + function sha256_Sigma1512(x) { + return sha256_S(x, 14) ^ sha256_S(x, 18) ^ sha256_S(x, 41); + } + function sha256_Gamma0512(x) { + return sha256_S(x, 1) ^ sha256_S(x, 8) ^ sha256_R(x, 7); + } + function sha256_Gamma1512(x) { + return sha256_S(x, 19) ^ sha256_S(x, 61) ^ sha256_R(x, 6); + } + + var sha256_K = new Array( + 1116352408, + 1899447441, + -1245643825, + -373957723, + 961987163, + 1508970993, + -1841331548, + -1424204075, + -670586216, + 310598401, + 607225278, + 1426881987, + 1925078388, + -2132889090, + -1680079193, + -1046744716, + -459576895, + -272742522, + 264347078, + 604807628, + 770255983, + 1249150122, + 1555081692, + 1996064986, + -1740746414, + -1473132947, + -1341970488, + -1084653625, + -958395405, + -710438585, + 113926993, + 338241895, + 666307205, + 773529912, + 1294757372, + 1396182291, + 1695183700, + 1986661051, + -2117940946, + -1838011259, + -1564481375, + -1474664885, + -1035236496, + -949202525, + -778901479, + -694614492, + -200395387, + 275423344, + 430227734, + 506948616, + 659060556, + 883997877, + 958139571, + 1322822218, + 1537002063, + 1747873779, + 1955562222, + 2024104815, + -2067236844, + -1933114872, + -1866530822, + -1538233109, + -1090935817, + -965641998, + ); + + function binb_sha256(m, l) { + var HASH = new Array( + 1779033703, + -1150833019, + 1013904242, + -1521486534, + 1359893119, + -1694144372, + 528734635, + 1541459225, + ); + var W = new Array(64); + var a, b, c, d, e, f, g, h; + var i, j, T1, T2; + + /* append padding */ + m[l >> 5] |= 0x80 << (24 - (l % 32)); + m[(((l + 64) >> 9) << 4) + 15] = l; + + for (i = 0; i < m.length; i += 16) { + a = HASH[0]; + b = HASH[1]; + c = HASH[2]; + d = HASH[3]; + e = HASH[4]; + f = HASH[5]; + g = HASH[6]; + h = HASH[7]; + + for (j = 0; j < 64; j++) { + if (j < 16) W[j] = m[j + i]; + else + W[j] = safe_add( + safe_add( + safe_add(sha256_Gamma1256(W[j - 2]), W[j - 7]), + sha256_Gamma0256(W[j - 15]), + ), + W[j - 16], + ); + + T1 = safe_add( + safe_add( + safe_add(safe_add(h, sha256_Sigma1256(e)), sha256_Ch(e, f, g)), + sha256_K[j], + ), + W[j], + ); + T2 = safe_add(sha256_Sigma0256(a), sha256_Maj(a, b, c)); + h = g; + g = f; + f = e; + e = safe_add(d, T1); + d = c; + c = b; + b = a; + a = safe_add(T1, T2); + } + + HASH[0] = safe_add(a, HASH[0]); + HASH[1] = safe_add(b, HASH[1]); + HASH[2] = safe_add(c, HASH[2]); + HASH[3] = safe_add(d, HASH[3]); + HASH[4] = safe_add(e, HASH[4]); + HASH[5] = safe_add(f, HASH[5]); + HASH[6] = safe_add(g, HASH[6]); + HASH[7] = safe_add(h, HASH[7]); + } + return HASH; + } + + function safe_add(x, y) { + var lsw = (x & 0xffff) + (y & 0xffff); + var msw = (x >> 16) + (y >> 16) + (lsw >> 16); + return (msw << 16) | (lsw & 0xffff); + } + + return { + hex: hex_sha256, + b64: b64_hmac_sha256, + any: any_hmac_sha256, + hex_hmac: hex_hmac_sha256, + b64_hmac: b64_hmac_sha256, + any_hmac: any_hmac_sha256, + }; +})(); + +// test-utils.js only defines 'window' and can't use 'module' +if (typeof module !== "undefined") { + module.exports = sha256; +} +if (typeof window !== "undefined") { + window.TEST_OUTPUT = sha256; +} diff --git a/test/vm/jsconfuser-vm/fixtures/legacy-common/common.js b/test/vm/jsconfuser-vm/fixtures/legacy-common/common.js new file mode 100644 index 00000000..c4c52b84 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/legacy-common/common.js @@ -0,0 +1 @@ +var x = a && foo(); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/decoded.js b/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/decoded.js new file mode 100644 index 00000000..0ed27134 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/decoded.js @@ -0,0 +1,21 @@ +(function () { + 'use strict'; + function __recovered_function_0() { + let __recovered_r_0_1, __recovered_r_0_2, __recovered_r_0_3, __recovered_r_0_4; + __recovered_r_0_1 = this; + __recovered_r_0_2 = 4; + __recovered_r_0_3 = 2; + __recovered_r_0_4 = __recovered_r_0_2 > __recovered_r_0_3; + if (__recovered_r_0_4) { + __recovered_r_0_3 = 3; + __recovered_r_0_2 = __recovered_r_0_2 + __recovered_r_0_3; + } + if (!("window" in globalThis)) throw new ReferenceError("window" + ' is not defined'); + __recovered_r_0_3 = globalThis["window"]; + __recovered_r_0_4 = "TEST_OUTPUT"; + Reflect.set(__recovered_r_0_3, __recovered_r_0_4, __recovered_r_0_2); + __recovered_r_0_3 = void 0; + return __recovered_r_0_3; + } + __recovered_function_0(); +})(); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/encoded.js b/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/encoded.js new file mode 100644 index 00000000..d5c0f116 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/encoded.js @@ -0,0 +1,1035 @@ +// fn_0_0: +// [4, 1], LOAD_THIS reg[1] = this +// [0, 3, 0, 0], LOAD_CONST reg[3] = 4 1:12-1:13 +// [5, 2, 3], MOVE reg[2] = reg[3] 1:0-1:13 +// [0, 3, 1, 0], LOAD_CONST reg[3] = 2 2:12-2:13 +// [23, 4, 2, 3], GT reg[4] = reg[2] > reg[3] 2:4-2:13 +// [40, 4, 31], JUMP_IF_FALSE if (!reg[4]) goto if_else_1 2:0-4:1 +// [0, 3, 2, 0], LOAD_CONST reg[3] = 3 3:18-3:19 +// [11, 4, 2, 3], ADD reg[4] = reg[2] + reg[3] 3:10-3:19 +// [5, 2, 4], MOVE reg[2] = reg[4] 3:2-3:19 +// if_else_1: +// [2, 3, 3, 0], LOAD_GLOBAL reg[3] = window 5:0-5:6 +// [0, 4, 4, 0], LOAD_CONST reg[4] = "TEST_OUTPUT" 5:0-5:26 +// [9, 3, 4, 2], SET_PROP reg[3][reg[4]] = reg[2] 5:0-5:26 +// [0, 3, 5, 0], LOAD_CONST reg[3] = undefined +// [45, 3], RETURN reg[3] +var CONSTANTS = [/* 0 */4, /* 1 */2, /* 2 */3, /* 3 */"window", /* 4 */"TEST_OUTPUT", /* 5 */undefined]; +var BYTECODE = [4, 1, 0, 3, 0, 0, 5, 2, 3, 0, 3, 1, 0, 23, 4, 2, 3, 40, 4, 31, 0, 3, 2, 0, 11, 4, 2, 3, 5, 2, 4, 2, 3, 3, 0, 0, 4, 4, 0, 9, 3, 4, 2, 0, 3, 5, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 5; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/source.js b/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/source.js new file mode 100644 index 00000000..9f9d5e11 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/arithmetic/source.js @@ -0,0 +1,5 @@ +var value = 4 +if (value > 2) { + value = value + 3 +} +window.TEST_OUTPUT = value diff --git a/test/vm/jsconfuser-vm/fixtures/readability/baseline.json b/test/vm/jsconfuser-vm/fixtures/readability/baseline.json new file mode 100644 index 00000000..c866aa21 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/baseline.json @@ -0,0 +1,125 @@ +{ + "schemaVersion": "jsconfuser-vm-readability-baseline.v1", + "fixtureSchemaVersion": "jsconfuser-vm-readability-fixtures.v1", + "cases": [ + { + "caseId": "arithmetic", + "sourceSha256": "12e9b65323b7ae8c9e2cd8083e37f29c0f8ebd9d2b22b9d3557f19729f1c3f31", + "encodedSha256": "49c00214cd03d6ea2c6e946cdde4d101683c0231a4f3912c4e8c6dcb11ad74c4", + "decoded": { + "bytes": 1668, + "sha256": "1a294dd46b54b309f49a29b383aeec615ac1e8310764270f411ce4a67f05b8fc", + "ratios": { + "decodedToSource": 20.85, + "decodedToEncoded": 0.042799 + }, + "ast": { + "statements": 45, + "generatedStatements": 23, + "functions": 3, + "declaredIdentifiers": 16, + "generatedDeclarations": { + "total": 10, + "zeroReferences": 6 + }, + "emptyCellDeclarations": 1 + }, + "generatedIdentifiers": { + "distinct": 10, + "total": 33 + }, + "operations": { + "reflectCalls": { + "set": 1 + }, + "computedGlobalThis": 0, + "descriptorCalls": 0, + "closureWrappers": 0, + "handlerOperations": 0, + "structuredFunctions": 1, + "stateMachineFunctions": 0 + } + } + }, + { + "caseId": "closure-loop", + "sourceSha256": "f2bc139d093ee1cae061624292a84bc1963a12705844d7930b609f2ca4fa5739", + "encodedSha256": "f858af2729413c080a0f60429ab7416786c9013954a0bedb629d86c753405bb4", + "decoded": { + "bytes": 4255, + "sha256": "d682fc02d56aaab3d30c4d643d3a0cc0d325402e08b37c6d4077cc0d7d68028b", + "ratios": { + "decodedToSource": 19.080717, + "decodedToEncoded": 0.103139 + }, + "ast": { + "statements": 101, + "generatedStatements": 77, + "functions": 7, + "declaredIdentifiers": 43, + "generatedDeclarations": { + "total": 36, + "zeroReferences": 12 + }, + "emptyCellDeclarations": 3 + }, + "generatedIdentifiers": { + "distinct": 31, + "total": 116 + }, + "operations": { + "reflectCalls": { + "apply": 4, + "set": 1 + }, + "computedGlobalThis": 0, + "descriptorCalls": 0, + "closureWrappers": 3, + "handlerOperations": 0, + "structuredFunctions": 3, + "stateMachineFunctions": 0 + } + } + }, + { + "caseId": "scale", + "sourceSha256": "a7e23ca6438bff426fd7b0a498a5f059209e4d64cadac6c3042780c00de14282", + "encodedSha256": "2ef4d6d06deb45a4dc4d0e803a624ad91dc86c802e4994b3dababf2e7208edb8", + "decoded": { + "bytes": 15367, + "sha256": "55b103cf42fb22f4d447c7286330512e26c718f79c3345d3a144f4c40659aa1d", + "ratios": { + "decodedToSource": 7.970436, + "decodedToEncoded": 0.269634 + }, + "ast": { + "statements": 240, + "generatedStatements": 218, + "functions": 5, + "declaredIdentifiers": 30, + "generatedDeclarations": { + "total": 24, + "zeroReferences": 10 + }, + "emptyCellDeclarations": 2 + }, + "generatedIdentifiers": { + "distinct": 22, + "total": 509 + }, + "operations": { + "reflectCalls": { + "apply": 90, + "set": 1 + }, + "computedGlobalThis": 0, + "descriptorCalls": 0, + "closureWrappers": 2, + "handlerOperations": 0, + "structuredFunctions": 2, + "stateMachineFunctions": 0 + } + } + } + ] +} diff --git a/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/decoded.js b/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/decoded.js new file mode 100644 index 00000000..5c0f2fcb --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/decoded.js @@ -0,0 +1,59 @@ +(function () { + 'use strict'; + function __recovered_function_0() { + let __recovered_r_0_1, __recovered_r_0_2, __recovered_r_0_3, __recovered_r_0_4, __recovered_r_0_5, __recovered_r_0_6, __recovered_r_0_7; + __recovered_r_0_1 = this; + __recovered_r_0_2 = function (...__recovered_closure_args) { const __recovered_this = this == null ? globalThis : this; return Reflect.apply(__recovered_function_1, __recovered_this, [...__recovered_closure_args]); }; + __recovered_r_0_6 = 4; + __recovered_r_0_7 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_6]); + __recovered_r_0_3 = __recovered_r_0_7; + __recovered_r_0_4 = 0; + __recovered_r_0_5 = 0; + __recovered_loop_0_0: while (true) { + __recovered_r_0_6 = 3; + __recovered_r_0_7 = __recovered_r_0_5 < __recovered_r_0_6; + if (__recovered_r_0_7) { + __recovered_r_0_6 = Reflect.apply(__recovered_r_0_3, globalThis, [__recovered_r_0_5]); + __recovered_r_0_4 = __recovered_r_0_4 + __recovered_r_0_6; + __recovered_r_0_6 = __recovered_r_0_5; + __recovered_r_0_6 = 1; + __recovered_r_0_5 = __recovered_r_0_5 + __recovered_r_0_6; + continue __recovered_loop_0_0; + } + break __recovered_loop_0_0; + } + if (!("window" in globalThis)) throw new ReferenceError("window" + ' is not defined'); + __recovered_r_0_6 = globalThis["window"]; + __recovered_r_0_7 = "TEST_OUTPUT"; + Reflect.set(__recovered_r_0_6, __recovered_r_0_7, __recovered_r_0_4); + __recovered_r_0_6 = void 0; + return __recovered_r_0_6; + } + function __recovered_function_1(...__recovered_args) { + const __recovered_cells_1 = []; + let __recovered_r_1_0, __recovered_r_1_2, __recovered_r_1_3; + __recovered_r_1_0 = __recovered_args[0]; + __recovered_r_1_2 = this; + __recovered_cells_1[0] ||= { get value() { return __recovered_r_1_0; }, set value(__value) { __recovered_r_1_0 = __value; } }; + const __recovered_closure_captures_1_97 = [__recovered_cells_1[0]]; + __recovered_r_1_3 = function (...__recovered_closure_args) { const __recovered_this = this == null ? globalThis : this; return Reflect.apply(__recovered_function_2, __recovered_this, [__recovered_closure_captures_1_97, ...__recovered_closure_args]); }; + return __recovered_r_1_3; + if (false) { + __recovered_r_1_3 = void 0; + void 0; + } + } + function __recovered_function_2(__recovered_captures, ...__recovered_args) { + let __recovered_r_2_0, __recovered_r_2_2, __recovered_r_2_3, __recovered_r_2_4; + __recovered_r_2_0 = __recovered_args[0]; + __recovered_r_2_2 = this; + __recovered_r_2_3 = __recovered_captures[0].value; + __recovered_r_2_4 = __recovered_r_2_3 + __recovered_r_2_0; + return __recovered_r_2_4; + if (false) { + __recovered_r_2_3 = void 0; + void 0; + } + } + __recovered_function_0(); +})(); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/encoded.js b/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/encoded.js new file mode 100644 index 00000000..2b6ba389 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/encoded.js @@ -0,0 +1,1062 @@ +// fn_0_0: +// [4, 1], LOAD_THIS reg[1] = this +// [47, 6, 95, 1, 4, 0, 0], MAKE_CLOSURE reg[6] PC=fn_1_1 (params=1 regs=4 upvalues=0) 1:0-5:1 +// [5, 2, 6], MOVE reg[2] = reg[6] 1:0-5:1 +// [0, 6, 0, 0], LOAD_CONST reg[6] = 4 7:20-7:21 +// [42, 7, 2, 1, 6], CALL reg[7] = reg[2](reg[6]) 7:10-7:22 +// [5, 3, 7], MOVE reg[3] = reg[7] 7:0-7:22 +// [0, 6, 1, 0], LOAD_CONST reg[6] = 0 8:12-8:13 +// [5, 4, 6], MOVE reg[4] = reg[6] 8:0-8:13 +// [0, 6, 1, 0], LOAD_CONST reg[6] = 0 9:17-9:18 +// [5, 5, 6], MOVE reg[5] = reg[6] 9:5-9:18 +// for_top_3: +// [0, 6, 2, 0], LOAD_CONST reg[6] = 3 9:28-9:29 +// [22, 7, 5, 6], LT reg[7] = reg[5] < reg[6] 9:20-9:29 +// [40, 7, 77], JUMP_IF_FALSE if (!reg[7]) goto for_exit_4 9:0-11:1 +// [42, 6, 3, 1, 5], CALL reg[6] = reg[3](reg[5]) 10:18-10:28 +// [11, 7, 4, 6], ADD reg[7] = reg[4] + reg[6] 10:10-10:28 +// [5, 4, 7], MOVE reg[4] = reg[7] 10:2-10:28 +// for_update_5: +// [5, 6, 5], MOVE reg[6] = reg[5] 9:31-9:38 +// [0, 6, 3, 0], LOAD_CONST reg[6] = 1 9:31-9:38 +// [11, 7, 5, 6], ADD reg[7] = reg[5] + reg[6] 9:31-9:38 +// [5, 5, 7], MOVE reg[5] = reg[7] 9:31-9:38 +// [39, 38], JUMP goto for_top_3 9:0-11:1 +// for_exit_4: +// [2, 6, 4, 0], LOAD_GLOBAL reg[6] = window 12:0-12:6 +// [0, 7, 5, 0], LOAD_CONST reg[7] = "TEST_OUTPUT" 12:0-12:26 +// [9, 6, 7, 4], SET_PROP reg[6][reg[7]] = reg[4] 12:0-12:26 +// [0, 6, 6, 0], LOAD_CONST reg[6] = undefined +// [45, 6], RETURN reg[6] +// fn_1_1: +// [4, 2], LOAD_THIS reg[2] = this 1:0-5:1 +// [47, 3, 114, 1, 5, 1, 0, 1, 0], MAKE_CLOSURE reg[3] PC=fn_2_2 (params=1 regs=5 upvalues=1) 2:9-4:3 +// [45, 3], RETURN reg[3] 2:2-4:3 +// [0, 3, 6, 0], LOAD_CONST reg[3] = undefined 1:0-5:1 +// [45, 3], RETURN reg[3] 1:0-5:1 +// fn_2_2: +// [4, 2], LOAD_THIS reg[2] = this 2:9-4:3 +// [3, 3, 0], LOAD_UPVALUE reg[3] = upvalue[0] 3:11-3:15 +// [11, 4, 3, 0], ADD reg[4] = reg[3] + reg[0] 3:11-3:23 +// [45, 4], RETURN reg[4] 3:4-3:23 +// [0, 3, 6, 0], LOAD_CONST reg[3] = undefined 2:9-4:3 +// [45, 3], RETURN reg[3] 2:9-4:3 +var CONSTANTS = [/* 0 */4, /* 1 */0, /* 2 */3, /* 3 */1, /* 4 */"window", /* 5 */"TEST_OUTPUT", /* 6 */undefined]; +var BYTECODE = [4, 1, 47, 6, 95, 1, 4, 0, 0, 5, 2, 6, 0, 6, 0, 0, 42, 7, 2, 1, 6, 5, 3, 7, 0, 6, 1, 0, 5, 4, 6, 0, 6, 1, 0, 5, 5, 6, 0, 6, 2, 0, 22, 7, 5, 6, 40, 7, 77, 42, 6, 3, 1, 5, 11, 7, 4, 6, 5, 4, 7, 5, 6, 5, 0, 6, 3, 0, 11, 7, 5, 6, 5, 5, 7, 39, 38, 2, 6, 4, 0, 0, 7, 5, 0, 9, 6, 7, 4, 0, 6, 6, 0, 45, 6, 4, 2, 47, 3, 114, 1, 5, 1, 0, 1, 0, 45, 3, 0, 3, 6, 0, 45, 3, 4, 2, 3, 3, 0, 11, 4, 3, 0, 45, 4, 0, 3, 6, 0, 45, 3]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 8; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/source.js b/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/source.js new file mode 100644 index 00000000..53761f47 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/closure-loop/source.js @@ -0,0 +1,12 @@ +function makeAdder(base) { + return function (value) { + return base + value + } +} + +var add = makeAdder(4) +var total = 0 +for (var index = 0; index < 3; index++) { + total = total + add(index) +} +window.TEST_OUTPUT = total diff --git a/test/vm/jsconfuser-vm/fixtures/readability/manifest.json b/test/vm/jsconfuser-vm/fixtures/readability/manifest.json new file mode 100644 index 00000000..85102232 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/manifest.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": "jsconfuser-vm-readability-fixtures.v1", + "encoderCommit": "20c5b96bf57337c568579352758d7685358650de", + "options": { + "target": "browser", + "encodeBytecode": false, + "randomizeOpcodes": false, + "shuffleOpcodes": false, + "controlFlowFlattening": false, + "dispatcher": false, + "stringConcealing": false, + "macroOpcodes": false, + "specializedOpcodes": false, + "aliasedOpcodes": false, + "antiInstrumentation": false, + "selfModifying": false, + "timingChecks": false, + "classObfuscation": false, + "handlerTable": false, + "minify": false + }, + "cases": [ + "arithmetic", + "closure-loop", + "scale" + ], + "generatedInputs": "encoded.js is generated from each tracked source.js with the pinned encoder and the options above.", + "baseline": "decoded.js is the current decoder output and is measured, not treated as a readability target." +} diff --git a/test/vm/jsconfuser-vm/fixtures/readability/scale/decoded.js b/test/vm/jsconfuser-vm/fixtures/readability/scale/decoded.js new file mode 100644 index 00000000..32470a26 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/scale/decoded.js @@ -0,0 +1,209 @@ +(function () { + 'use strict'; + function __recovered_function_0() { + let __recovered_r_0_1, __recovered_r_0_2, __recovered_r_0_3, __recovered_r_0_4, __recovered_r_0_5; + __recovered_r_0_1 = this; + __recovered_r_0_2 = function (...__recovered_closure_args) { const __recovered_this = this == null ? globalThis : this; return Reflect.apply(__recovered_function_1, __recovered_this, [...__recovered_closure_args]); }; + __recovered_r_0_3 = 0; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + __recovered_r_0_4 = Reflect.apply(__recovered_r_0_2, globalThis, [__recovered_r_0_3]); + __recovered_r_0_3 = __recovered_r_0_4; + if (!("window" in globalThis)) throw new ReferenceError("window" + ' is not defined'); + __recovered_r_0_4 = globalThis["window"]; + __recovered_r_0_5 = "TEST_OUTPUT"; + Reflect.set(__recovered_r_0_4, __recovered_r_0_5, __recovered_r_0_3); + __recovered_r_0_4 = void 0; + return __recovered_r_0_4; + } + function __recovered_function_1(...__recovered_args) { + let __recovered_r_1_0, __recovered_r_1_2, __recovered_r_1_3, __recovered_r_1_4; + __recovered_r_1_0 = __recovered_args[0]; + __recovered_r_1_2 = this; + __recovered_r_1_3 = __recovered_r_1_0; + __recovered_r_1_4 = 1; + __recovered_r_1_3 = __recovered_r_1_3 + __recovered_r_1_4; + __recovered_r_1_4 = 2; + __recovered_r_1_3 = __recovered_r_1_3 * __recovered_r_1_4; + return __recovered_r_1_3; + if (false) { + __recovered_r_1_4 = void 0; + void 0; + } + } + __recovered_function_0(); +})(); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/readability/scale/encoded.js b/test/vm/jsconfuser-vm/fixtures/readability/scale/encoded.js new file mode 100644 index 00000000..176bcebe --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/scale/encoded.js @@ -0,0 +1,1220 @@ +// fn_0_0: +// [4, 1], LOAD_THIS reg[1] = this +// [47, 4, 749, 1, 6, 0, 0], MAKE_CLOSURE reg[4] PC=fn_1_1 (params=1 regs=6 upvalues=0) 1:0-6:1 +// [5, 2, 4], MOVE reg[2] = reg[4] 1:0-6:1 +// [0, 4, 0, 0], LOAD_CONST reg[4] = 0 8:12-8:13 +// [5, 3, 4], MOVE reg[3] = reg[4] 8:0-8:13 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 9:8-9:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 9:0-9:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 10:8-10:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 10:0-10:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 11:8-11:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 11:0-11:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 12:8-12:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 12:0-12:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 13:8-13:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 13:0-13:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 14:8-14:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 14:0-14:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 15:8-15:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 15:0-15:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 16:8-16:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 16:0-16:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 17:8-17:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 17:0-17:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 18:8-18:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 18:0-18:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 19:8-19:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 19:0-19:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 20:8-20:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 20:0-20:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 21:8-21:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 21:0-21:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 22:8-22:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 22:0-22:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 23:8-23:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 23:0-23:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 24:8-24:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 24:0-24:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 25:8-25:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 25:0-25:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 26:8-26:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 26:0-26:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 27:8-27:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 27:0-27:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 28:8-28:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 28:0-28:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 29:8-29:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 29:0-29:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 30:8-30:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 30:0-30:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 31:8-31:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 31:0-31:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 32:8-32:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 32:0-32:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 33:8-33:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 33:0-33:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 34:8-34:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 34:0-34:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 35:8-35:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 35:0-35:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 36:8-36:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 36:0-36:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 37:8-37:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 37:0-37:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 38:8-38:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 38:0-38:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 39:8-39:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 39:0-39:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 40:8-40:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 40:0-40:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 41:8-41:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 41:0-41:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 42:8-42:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 42:0-42:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 43:8-43:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 43:0-43:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 44:8-44:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 44:0-44:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 45:8-45:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 45:0-45:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 46:8-46:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 46:0-46:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 47:8-47:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 47:0-47:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 48:8-48:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 48:0-48:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 49:8-49:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 49:0-49:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 50:8-50:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 50:0-50:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 51:8-51:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 51:0-51:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 52:8-52:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 52:0-52:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 53:8-53:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 53:0-53:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 54:8-54:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 54:0-54:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 55:8-55:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 55:0-55:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 56:8-56:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 56:0-56:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 57:8-57:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 57:0-57:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 58:8-58:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 58:0-58:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 59:8-59:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 59:0-59:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 60:8-60:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 60:0-60:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 61:8-61:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 61:0-61:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 62:8-62:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 62:0-62:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 63:8-63:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 63:0-63:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 64:8-64:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 64:0-64:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 65:8-65:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 65:0-65:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 66:8-66:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 66:0-66:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 67:8-67:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 67:0-67:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 68:8-68:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 68:0-68:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 69:8-69:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 69:0-69:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 70:8-70:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 70:0-70:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 71:8-71:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 71:0-71:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 72:8-72:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 72:0-72:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 73:8-73:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 73:0-73:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 74:8-74:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 74:0-74:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 75:8-75:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 75:0-75:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 76:8-76:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 76:0-76:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 77:8-77:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 77:0-77:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 78:8-78:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 78:0-78:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 79:8-79:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 79:0-79:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 80:8-80:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 80:0-80:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 81:8-81:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 81:0-81:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 82:8-82:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 82:0-82:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 83:8-83:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 83:0-83:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 84:8-84:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 84:0-84:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 85:8-85:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 85:0-85:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 86:8-86:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 86:0-86:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 87:8-87:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 87:0-87:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 88:8-88:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 88:0-88:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 89:8-89:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 89:0-89:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 90:8-90:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 90:0-90:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 91:8-91:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 91:0-91:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 92:8-92:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 92:0-92:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 93:8-93:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 93:0-93:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 94:8-94:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 94:0-94:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 95:8-95:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 95:0-95:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 96:8-96:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 96:0-96:19 +// [42, 4, 2, 1, 3], CALL reg[4] = reg[2](reg[3]) 97:8-97:19 +// [5, 3, 4], MOVE reg[3] = reg[4] 97:0-97:19 +// [2, 4, 1, 0], LOAD_GLOBAL reg[4] = window 98:0-98:6 +// [0, 5, 2, 0], LOAD_CONST reg[5] = "TEST_OUTPUT" 98:0-98:26 +// [9, 4, 5, 3], SET_PROP reg[4][reg[5]] = reg[3] 98:0-98:26 +// [0, 4, 3, 0], LOAD_CONST reg[4] = undefined +// [45, 4], RETURN reg[4] +// fn_1_1: +// [4, 2], LOAD_THIS reg[2] = this 1:0-6:1 +// [5, 3, 0], MOVE reg[3] = reg[0] 2:2-2:20 +// [0, 4, 4, 0], LOAD_CONST reg[4] = 1 3:20-3:21 +// [11, 5, 3, 4], ADD reg[5] = reg[3] + reg[4] 3:11-3:21 +// [5, 3, 5], MOVE reg[3] = reg[5] 3:2-3:21 +// [0, 4, 5, 0], LOAD_CONST reg[4] = 2 4:20-4:21 +// [13, 5, 3, 4], MUL reg[5] = reg[3] * reg[4] 4:11-4:21 +// [5, 3, 5], MOVE reg[3] = reg[5] 4:2-4:21 +// [45, 3], RETURN reg[3] 5:2-5:15 +// [0, 4, 3, 0], LOAD_CONST reg[4] = undefined 1:0-6:1 +// [45, 4], RETURN reg[4] 1:0-6:1 +var CONSTANTS = [/* 0 */0, /* 1 */"window", /* 2 */"TEST_OUTPUT", /* 3 */undefined, /* 4 */1, /* 5 */2]; +var BYTECODE = [4, 1, 47, 4, 749, 1, 6, 0, 0, 5, 2, 4, 0, 4, 0, 0, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 42, 4, 2, 1, 3, 5, 3, 4, 2, 4, 1, 0, 0, 5, 2, 0, 9, 4, 5, 3, 0, 4, 3, 0, 45, 4, 4, 2, 5, 3, 0, 0, 4, 4, 0, 11, 5, 3, 4, 5, 3, 5, 0, 4, 5, 0, 13, 5, 3, 4, 5, 3, 5, 45, 3, 0, 4, 3, 0, 45, 4]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 6; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/readability/scale/source.js b/test/vm/jsconfuser-vm/fixtures/readability/scale/source.js new file mode 100644 index 00000000..801fdf98 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/readability/scale/source.js @@ -0,0 +1,98 @@ +function step(value) { + var result = value + result = result + 1 + result = result * 2 + return result +} + +var total = 0 +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +total = step(total) +window.TEST_OUTPUT = total diff --git a/test/vm/jsconfuser-vm/fixtures/reference/encoded.debug.js b/test/vm/jsconfuser-vm/fixtures/reference/encoded.debug.js new file mode 100644 index 00000000..ea2031e9 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/reference/encoded.debug.js @@ -0,0 +1,1029 @@ +// fn_0_0: +// [4, 1], LOAD_THIS reg[1] = this +// [2, 2, 0, 0], LOAD_GLOBAL reg[2] = window 1:0-1:6 +// [0, 3, 1, 0], LOAD_CONST reg[3] = "TEST_OUTPUT" 1:0-1:26 +// [0, 4, 2, 0], LOAD_CONST reg[4] = 1 1:21-1:22 +// [0, 5, 3, 0], LOAD_CONST reg[5] = 2 1:25-1:26 +// [11, 6, 4, 5], ADD reg[6] = reg[4] + reg[5] 1:21-1:26 +// [9, 2, 3, 6], SET_PROP reg[2][reg[3]] = reg[6] 1:0-1:26 +// [0, 2, 4, 0], LOAD_CONST reg[2] = undefined +// [45, 2], RETURN reg[2] +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */1, /* 3 */2, /* 4 */undefined]; +var BYTECODE = [4, 1, 2, 2, 0, 0, 0, 3, 1, 0, 0, 4, 2, 0, 0, 5, 3, 0, 11, 6, 4, 5, 9, 2, 3, 6, 0, 2, 4, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 7; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/reference/encoded.js b/test/vm/jsconfuser-vm/fixtures/reference/encoded.js new file mode 100644 index 00000000..50946c7a --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/reference/encoded.js @@ -0,0 +1,1019 @@ +var CONSTANTS = [/* 0 */"window", /* 1 */"TEST_OUTPUT", /* 2 */1, /* 3 */2, /* 4 */undefined]; +var BYTECODE = [4, 1, 2, 2, 0, 0, 0, 3, 1, 0, 0, 4, 2, 0, 0, 5, 3, 0, 11, 6, 4, 5, 9, 2, 3, 6, 0, 2, 4, 0, 45, 2]; +var MAIN_START_PC = 0; +var MAIN_REG_COUNT = 7; +var ENCODE_BYTECODE = false; +var TIMING_CHECKS = false; +var OP = { + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59 +}; +var SENTINELS = { + CALL_SPREAD: 65535 +}; +var SLOTS = { + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7 +}; +var HEADER_SIZE = 8; +var FRAME_START = 1; +function base64ToBytes(s) { + return typeof Buffer !== "undefined" ? Buffer.from(s, "base64") : Uint8Array.from(atob(s), function (c) { + return c.charCodeAt(0); + }); +} +function decodeBytecode(s) { + if (!ENCODE_BYTECODE) return s; + var b = base64ToBytes(s); + // Each slot is a u32 stored as 4 little-endian bytes. + var r = new Uint32Array(b.length / 4); + for (var i = 0; i < r.length; i++) r[i] = (b[i * 4] | b[i * 4 + 1] << 8 | b[i * 4 + 2] << 16 | b[i * 4 + 3] << 24) >>> 0; + return r; +} + +// Closure map +// Maps shell functions -> inner Closure so the VM can fast-path instead of going through a sub-VM on internal calls. +// A WeakMap is used over a Symbol to prevent leaking information to debuggers +var CLOSURE_MAP = new WeakMap(); + +// Upvalue (Lua style) +// While the outer frame is alive: reads/writes go to vm._regs[_absSlot]. +// After the outer frame returns (closed): reads/writes hit this._value. +// (_absSlot is an absolute index into the flat slot array, so it stays valid +// no matter where the owning frame's block sits.) +function Upvalue(regs, absSlot) { + this._regs = regs; // shared reference to VM._regs flat array + this._absSlot = absSlot; // absolute index; stable as long as frame is alive + this._closed = false; + this._value = undefined; +} +Upvalue.prototype._read = function () { + return this._closed ? this._value : this._regs[this._absSlot]; +}; +Upvalue.prototype._write = function (v) { + if (this._closed) this._value = v;else this._regs[this._absSlot] = v; +}; +Upvalue.prototype._close = function () { + this._value = this._regs[this._absSlot]; + this._closed = true; +}; + +// Closure & Frame +function Closure(fn) { + this.fn = fn; + this.upvalues = []; + this.prototype = {}; // <- default prototype object for `new` +} + +// VM +// There is no Frame object and no frame stack. A frame is a block of slots in +// the flat array `_regs`: HEADER_SIZE header slots (see utils/frame-layout.ts) followed +// by the frame's registers. `_f` — the only piece of live execution state the VM +// object exposes — is the current frame's base offset, and each frame's CALLER +// slot points at the one that called it, so the call stack is an implicit linked +// list with nothing to enumerate. +function VM(bytecode, constants, globals) { + this.bytecode = bytecode; + this.constants = constants; + this.globals = globals; + // Open upvalues, keyed by absolute slot; created on the first capture so a + // closure-free program never allocates it. See captureUpvalue(). + this._openUpvalues = null; + + // Flat slot array (Lua-style register file, with the frame headers folded in). + // _regsTop is the next free slot (= base of the hypothetical next frame). + // On CALL: newBase = _regsTop; _regsTop += HEADER_SIZE + fn.regCount + // On RETURN: _regsTop = (pop the whole block) + // Slot 0 is never part of a frame: it holds the value run() hands back, and + // doubles as the "no frame" sentinel for _f / CALLER. + this._regs = []; + this._regsTop = FRAME_START; + this._f = 0; +} + +// Consume the next slot from the flat bytecode stream and advance the PC. +// Called by opcode handlers to read each of their operands in order. +// The PC is a header slot, so no object anywhere holds a property containing it. +VM.prototype._operand = function () { + return this.bytecode[this._regs[this._f + SLOTS.PC]++]; +}; + +// Push a new frame block on top of the slot array and make it current. +// retDst encodes the caller's destination register as (reg << 1), with bit 0 set +// for a constructor call (`new`) — RETURN uses it to decide whether to hand back +// the THIS slot instead of the returned value. +// args === null skips parameter setup (used for the root frame). +VM.prototype._pushFrame = function (closure, args, thisVal, retDst) { + var fn = closure.fn; + var regs = this._regs; + var fp = this._regsTop; + var size = HEADER_SIZE + fn.regCount; + var end = fp + size; + while (regs.length < end) regs.push(undefined); + for (var i = fp; i < end; i++) regs[i] = undefined; + var base = fp + HEADER_SIZE; + regs[fp + SLOTS.PC] = fn.startPc; + regs[fp + SLOTS.CALLER] = this._f; + regs[fp + SLOTS.RET_DST] = retDst; + regs[fp + SLOTS.THIS] = thisVal; + regs[fp + SLOTS.CLOSURE] = closure; + regs[fp + SLOTS.FRAME_SIZE] = size; + regs[fp + SLOTS.REG_BASE] = base; + + // Seeds for the decoy header slots this build happens to have (see utils/frame-layout.ts) + /* @NOISE */ + if (typeof SLOTS.NOISE_END === "number") regs[fp + SLOTS.NOISE_END] = end; + if (typeof SLOTS.NOISE_PARAMS === "number") regs[fp + SLOTS.NOISE_PARAMS] = fn.paramCount; + if (typeof SLOTS.NOISE_ARGS === "number") regs[fp + SLOTS.NOISE_ARGS] = args; + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = fn.startPc; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER] = 0; + this._regsTop = end; + if (args) { + if (fn.hasRest) { + var restSlot = fn.paramCount - 1; + for (var p = 0; p < restSlot; p++) regs[base + p] = p < args.length ? args[p] : undefined; + regs[base + restSlot] = args.slice(restSlot); + } else { + for (var a = 0; a < args.length && a < fn.regCount; a++) regs[base + a] = args[a]; + } + if (fn.paramCount < fn.regCount) regs[base + fn.paramCount] = args; + } + this._f = fp; +}; +VM.prototype.captureUpvalue = function (fp, slot) { + // Dedup by absolute slot — two closures capturing the same local share one + // Upvalue. _openUpvalues is keyed BY absolute slot rather than being a list of + // live captures, so this is a direct lookup instead of a scan, and closing a + // frame's captures costs a walk of that frame's own window (below) instead of + // a filter over every capture in the program. Entries are removed on close, so + // anything found here is open by construction. + var absSlot = this._regs[fp + SLOTS.REG_BASE] + slot; + var uvs = this._openUpvalues || (this._openUpvalues = []); + return uvs[absSlot] || (uvs[absSlot] = new Upvalue(this._regs, absSlot)); +}; + +// Reads and decodes a constant from the pool. +// idx: pool index (first operand of the constant pair emitted by resolveConstants). +// key: conceal key (second operand). 0 means no concealment. +// +// For integers: stored value is (original ^ key) with the full u32 key; XOR +// again to recover (JS `^` is int32, so this is symmetric). +// For strings: stored value is a base64 string containing u16 LE byte pairs. +// Mirrors decodeBytecode: base64 → bytes → u16 LE → XOR with a +// position-based Weyl keystream seeded by the full u32 key. +// idxIn, keyIn are passed in from specializedOpcodes when the operands are determined at compile time. +VM.prototype._constant = function (idxIn, keyIn) { + var idx = idxIn ?? this._operand(); + var key = keyIn ?? this._operand(); + var v = this.constants[idx]; + if (!key) return v; + if (typeof v === "number") return v ^ key; + if (typeof v !== "string") return v; + + // String: base64-decode to u16 LE byte pairs, then XOR each code with a + // 16-bit keystream word derived from the full 32-bit key + position. + var b = base64ToBytes(v); + var out = ""; + var k = key; + for (var i = 0; i < b.length / 2; i++) { + k = k + 0x9e3779b9 | 0; // 32-bit Weyl step (position-based) + var ks = (k ^ k >>> 13) & 0xffff; // 16-bit keystream word + var code = b[i * 2] | b[i * 2 + 1] << 8; // u16 LE + out += String.fromCharCode(code ^ ks); + } + return out; +}; +VM.prototype._closeUpvaluesFor = function (fp) { + // Called on RETURN — close every upvalue captured from this frame's register + // window [REG_BASE, end of the frame block) and free its table entry, so a + // later frame reusing those slots starts with no captures. + var uvs = this._openUpvalues; + if (!uvs) return; // nothing in this program ever captured a local + var regs = this._regs; + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = regs[fp + SLOTS.REG_BASE]; i < hi; i++) { + var uv = uvs[i]; + if (uv) { + uv._close(); + uvs[i] = undefined; + } + } +}; + +// Executes `closure` to completion and returns its return value. +// The root frame's CALLER slot is 0 (the sentinel), so its RETURN parks the +// value in slot 0 and clears _f, which stops the loop below. +VM.prototype.run = function (closure, thisVal, args) { + var now = () => { + return performance.now(); + }; + var lastTime = now(); + this._pushFrame(closure, args, thisVal, 0); + while (true) { + var fp = this._f; + var regs = this._regs; + var bc = this.bytecode; + var pc = regs[fp + SLOTS.PC]; + if (pc >= bc.length) break; + regs[fp + SLOTS.PC] = pc + 1; + var op = this.bytecode[pc]; + // var opcode = this.bytecode[pc]; + // console.log(`[run] pc=${pc}, opcode=${opcode}, name=${Object.keys(OP).find((key) => OP[key] === opcode)}`); + + // Churn the decoy header slots so the real PC isn't the only slot moving + /* @NOISE */ + if (typeof SLOTS.NOISE_PC === "number") regs[fp + SLOTS.NOISE_PC] = (regs[fp + SLOTS.NOISE_PC] + 1) % bc.length; + if (typeof SLOTS.NOISE_COUNTER === "number") regs[fp + SLOTS.NOISE_COUNTER]++; + if (typeof SLOTS.NOISE_MIRROR === "number") regs[fp + SLOTS.NOISE_MIRROR] = op; + if (typeof SLOTS.NOISE_ACC === "number") regs[fp + SLOTS.NOISE_ACC] = regs[fp + SLOTS.NOISE_ACC] ^ pc | 0; + + // Debugging protection: Detects debugger by checking for >1s pauses which can only happen from debugger; or extremely slow sync tasks + if (TIMING_CHECKS) { + var currentTime = now(); + var isTamper = currentTime - lastTime > TIMING_CHECKS; + lastTime = currentTime; + if (isTamper) { + // Poison the bytecode + for (var i = 0; i < this.bytecode.length; i++) this.bytecode[i] = 0; + // Break the current state + for (var i2 = fp; i2 < this._regsTop; i2++) this._regs[i2] = undefined; + op = OP.JUMP; + regs[fp + SLOTS.PC] = this.bytecode.length; // jump past end to halt + } + } + try { + var base = regs[fp + SLOTS.REG_BASE]; + + /* @SWITCH */ + switch (op) { + case OP.LOAD_CONST: + { + var dst = this._operand(); + regs[base + dst] = this._constant(); + break; + } + case OP.LOAD_INT: + { + var dst = this._operand(); + regs[base + dst] = this._operand(); + break; + } + case OP.LOAD_GLOBAL: + { + var dst = this._operand(); + var globalName = this._constant(); + if (!(globalName in this.globals)) { + throw new ReferenceError(`${globalName} is not defined`); + } + regs[base + dst] = this.globals[globalName]; + break; + } + case OP.LOAD_UPVALUE: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.CLOSURE].upvalues[this._operand()]._read(); + break; + } + case OP.LOAD_THIS: + { + var dst = this._operand(); + regs[base + dst] = regs[fp + SLOTS.THIS]; + break; + } + case OP.MOVE: + { + var dst = this._operand(); + regs[base + dst] = regs[base + this._operand()]; + break; + } + case OP.STORE_GLOBAL: + { + // globals[globalName] = regs[src] + this.globals[this._constant()] = regs[base + this._operand()]; + break; + } + case OP.STORE_UPVALUE: + { + var uvIdx = this._operand(); + regs[fp + SLOTS.CLOSURE].upvalues[uvIdx]._write(regs[base + this._operand()]); + break; + } + case OP.GET_PROP: + { + // dst = regs[obj][regs[key]] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = obj[key]; + break; + } + case OP.SET_PROP: + { + // regs[obj][regs[key]] = regs[val] + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + // Reflect.set performs [[Set]] without throwing on failure (non-strict mode behavior) + Reflect.set(obj, key, val); + break; + } + case OP.DELETE_PROP: + { + // regs[dst] = delete regs[obj][regs[key]] + // The delete operator returns true if successful which is most cases + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + regs[base + dst] = delete obj[key]; + break; + } + + // Arithmetic (dst, src1, src2) + case OP.ADD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a + regs[base + this._operand()]; + break; + } + case OP.SUB: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a - regs[base + this._operand()]; + break; + } + case OP.MUL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a * regs[base + this._operand()]; + break; + } + case OP.DIV: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a / regs[base + this._operand()]; + break; + } + case OP.MOD: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a % regs[base + this._operand()]; + break; + } + case OP.EXP: + { + // Math.pow instead of `**` + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = Math.pow(a, regs[base + this._operand()]); + break; + } + case OP.BAND: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a & regs[base + this._operand()]; + break; + } + case OP.BOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a | regs[base + this._operand()]; + break; + } + case OP.BXOR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a ^ regs[base + this._operand()]; + break; + } + case OP.SHL: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a << regs[base + this._operand()]; + break; + } + case OP.SHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >> regs[base + this._operand()]; + break; + } + case OP.USHR: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >>> regs[base + this._operand()]; + break; + } + + // Comparison (dst, src1, src2) + case OP.LT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a < regs[base + this._operand()]; + break; + } + case OP.GT: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a > regs[base + this._operand()]; + break; + } + case OP.LTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a <= regs[base + this._operand()]; + break; + } + case OP.GTE: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a >= regs[base + this._operand()]; + break; + } + case OP.EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a === regs[base + this._operand()]; + break; + } + case OP.NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a !== regs[base + this._operand()]; + break; + } + case OP.LOOSE_EQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a == regs[base + this._operand()]; + break; + } + case OP.LOOSE_NEQ: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a != regs[base + this._operand()]; + break; + } + case OP.IN: + { + var dst = this._operand(); + var a = regs[base + this._operand()]; + regs[base + dst] = a in regs[base + this._operand()]; + break; + } + case OP.INSTANCEOF: + { + // regs[dst] = regs[obj] instanceof regs[ctor] + // Since VM closures are wrapped in native function shells (MAKE_CLOSURE), the native operator works + var dst = this._operand(); + var obj = regs[base + this._operand()]; + regs[base + dst] = obj instanceof regs[base + this._operand()]; + break; + } + + // Unary (dst, src) + case OP.UNARY_NEG: + { + var dst = this._operand(); + regs[base + dst] = -regs[base + this._operand()]; + break; + } + case OP.UNARY_POS: + { + var dst = this._operand(); + regs[base + dst] = +regs[base + this._operand()]; + break; + } + case OP.UNARY_NOT: + { + var dst = this._operand(); + regs[base + dst] = !regs[base + this._operand()]; + break; + } + case OP.UNARY_BITNOT: + { + var dst = this._operand(); + regs[base + dst] = ~regs[base + this._operand()]; + break; + } + case OP.TYPEOF: + { + var dst = this._operand(); + regs[base + dst] = typeof regs[base + this._operand()]; + break; + } + case OP.VOID: + { + var dst = this._operand(); + this._operand(); // consumes argument (intended) + regs[base + dst] = undefined; + break; + } + case OP.TYPEOF_SAFE: + { + // regs[dst] = typeof window[name] + // Never throws ReferenceError, instead returns undefined for undeclared variables + var dst = this._operand(); + var name = this._constant(); + var val = Object.prototype.hasOwnProperty.call(this.globals, name) ? this.globals[name] : undefined; + regs[base + dst] = typeof val; + break; + } + + // Control flow + case OP.JUMP: + regs[fp + SLOTS.PC] = this._operand(); + break; + case OP.JUMP_IF_FALSE: + { + var src = this._operand(); + var target = this._operand(); + if (!regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + case OP.JUMP_IF_TRUE: + { + // || short-circuit: if truthy, jump over RHS. + var src = this._operand(); + var target = this._operand(); + if (regs[base + src]) regs[fp + SLOTS.PC] = target; + break; + } + + // Calls + case OP.CALL: + { + // dst, calleeReg, argc, [argReg...] (argc=-1 means next operand is spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, this.globals, dst << 1); + } else { + regs[base + dst] = callee.apply(null, args); + } + break; + } + case OP.CALL_METHOD: + { + // dst, receiverReg, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var receiver = regs[base + this._operand()]; + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + this._pushFrame(closure, args, receiver, dst << 1); + } else { + regs[base + dst] = callee.apply(receiver, args); + } + break; + } + case OP.NEW: + { + // dst, calleeReg, argc, [argReg...] (argc=SENTINELS.CALL_SPREAD means spread-args array reg) + var dst = this._operand(); + var callee = regs[base + this._operand()]; + var argc = this._operand(); + var args; + if (argc === SENTINELS.CALL_SPREAD) { + args = regs[base + this._operand()]; + } else { + args = new Array(argc); + for (var i = 0; i < argc; i++) args[i] = regs[base + this._operand()]; + } + var closure = callee && CLOSURE_MAP.get(callee); + if (closure) { + // The new object doubles as the frame's THIS; bit 0 of RET_DST marks + // the frame as a constructor call so RETURN can fall back to it. + this._pushFrame(closure, args, Object.create(closure.prototype || null), dst << 1 | 1); + } else { + // Reflect.construct is required - Object.create+apply does NOT set + // internal slots ([[NumberData]], [[StringData]], etc.) for built-ins. + regs[base + dst] = Reflect.construct(callee, args); + } + break; + } + case OP.RETURN: + { + var retVal = regs[base + this._operand()]; + this._closeUpvaluesFor(fp); // must happen before frame is abandoned + + var caller = regs[fp + SLOTS.CALLER]; + var retDst = regs[fp + SLOTS.RET_DST]; + + // NewExpression: When invoking from the 'new' keyword, the newly constructed object is returned instead (if the original function doesn't return an object) + if (retDst & 1 && (typeof retVal !== "object" || retVal === null)) retVal = regs[fp + SLOTS.THIS]; + + // Zero out the callee's whole block (header included) to limit + // exposing runtime values, then hand the slots back. + var hi = fp + regs[fp + SLOTS.FRAME_SIZE]; + for (var i = fp; i < hi; i++) regs[i] = undefined; + this._regsTop = fp; + this._f = caller; + + // A caller of 0 is the root frame returning to the host: park the + // value in slot 0 for run() to hand back. A bare `return retVal` + // would only exit an extracted VM.prototype[op] handler function + // (handlerTable option), not the loop. Keeping the sole `break` + // trailing (no mid-body break/return) also lets the handlerTable + // transform lift this body verbatim. + regs[caller ? regs[caller + SLOTS.REG_BASE] + (retDst >> 1) : 0] = retVal; + break; + } + case OP.THROW: + throw regs[base + this._operand()]; + + // Closures + case OP.MAKE_CLOSURE: + { + // dst, startPc, paramCount, regCount, uvCount, hasRest, [isLocal, idx, ...] + var dst = this._operand(); + var startPc = this._operand(); + var paramCount = this._operand(); + var regCount = this._operand(); + var uvCount = this._operand(); + var hasRest = this._operand(); // 1 if last param is a rest element + + var uvDescs = new Array(uvCount); + for (var i = 0; i < uvCount; i++) { + var isLocalRaw = this._operand(); + var uvIndex = this._operand(); + uvDescs[i] = { + isLocal: isLocalRaw, + _index: uvIndex + }; + } + + // uvDescs is consumed by the capture loop below and then dropped — it + // is deliberately NOT kept on the descriptor. A closure that carries + // its capture plan around spells out the closure graph ("captures + // local 2 of the parent frame") to anything that gets hold of it. + var fn = { + paramCount: paramCount, + regCount: regCount, + startPc: startPc, + hasRest: hasRest + }; + var closure = new Closure(fn); + for (var i = 0; i < uvDescs.length; i++) { + var uvd = uvDescs[i]; + if (uvd.isLocal) { + closure.upvalues.push(this.captureUpvalue(fp, uvd._index)); + } else { + closure.upvalues.push(regs[fp + SLOTS.CLOSURE].upvalues[uvd._index]); + } + } + + // Wrap in a native callable shell so host code (array methods, + // test assertions, setTimeout, etc.) can invoke VM closures. + // CLOSURE_MAP lets VM-internal CALL/NEW bypass the sub-VM entirely. + var self = this; + var shell = function (c) { + return function () { + return new VM(self.bytecode, self.constants, self.globals).run(c, this == null ? self.globals : this, Array.prototype.slice.call(arguments)); + }; + }(closure); + CLOSURE_MAP.set(shell, closure); + shell.prototype = closure.prototype; // unified prototype for new/instanceof + regs[base + dst] = shell; + break; + } + + // Collections + case OP.BUILD_ARRAY: + { + // dst, count, [elemReg...] + var dst = this._operand(); + var count = this._operand(); + var elems = new Array(count); + for (var i = 0; i < count; i++) elems[i] = regs[base + this._operand()]; + regs[base + dst] = elems; + break; + } + case OP.BUILD_OBJECT: + { + // dst, pairCount, [keyReg, valReg, ...] + var dst = this._operand(); + var pairCount = this._operand(); + var o = {}; + for (var i = 0; i < pairCount; i++) { + var key = regs[base + this._operand()]; + var val = regs[base + this._operand()]; + o[key] = val; + } + regs[base + dst] = o; + break; + } + + // Object methods (getters / setters) + case OP.DEFINE_GETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var getterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var getDesc = { + get: getterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.set === "function") { + getDesc.set = existingDesc.set; + } + Object.defineProperty(obj, key, getDesc); + break; + } + case OP.DEFINE_SETTER: + { + // obj, key, fn + var obj = regs[base + this._operand()]; + var key = regs[base + this._operand()]; + var setterFn = regs[base + this._operand()]; + var existingDesc = Object.getOwnPropertyDescriptor(obj, key); + var setDesc = { + set: setterFn, + configurable: true, + enumerable: true + }; + if (existingDesc && typeof existingDesc.get === "function") { + setDesc.get = existingDesc.get; + } + Object.defineProperty(obj, key, setDesc); + break; + } + + // ── For-in iteration ────────────────────────────────────────────────── + case OP.FOR_IN_SETUP: + { + // dst, src — build iterator object from enumerable keys of regs[src] + var dst = this._operand(); + var obj = regs[base + this._operand()]; + var keys = []; + if (obj !== null && obj !== undefined) { + var seen = Object.create(null); + var cur = Object(obj); // box primitives + while (cur !== null) { + var ownNames = Object.getOwnPropertyNames(cur); + for (var i = 0; i < ownNames.length; i++) { + var k = ownNames[i]; + if (!(k in seen)) { + seen[k] = true; + var propDesc = Object.getOwnPropertyDescriptor(cur, k); + if (propDesc && propDesc.enumerable) { + keys.push(k); + } + } + } + cur = Object.getPrototypeOf(cur); + } + } + regs[base + dst] = { + _keys: keys, + i: 0 + }; + break; + } + case OP.FOR_IN_NEXT: + { + // dst, iterReg, exitTarget + // Advances iterator; writes next key to dst, or jumps to exitTarget when done. + var dst = this._operand(); + var iter = regs[base + this._operand()]; + var exitTarget = this._operand(); + if (iter.i >= iter._keys.length) { + regs[fp + SLOTS.PC] = exitTarget; + } else { + regs[base + dst] = iter._keys[iter.i++]; + } + break; + } + + // ── Exception handling ──────────────────────────────────────────────── + case OP.TRY_SETUP: + { + // handlerPc, exceptionReg — push exception handler record onto current + // frame. The handler stack lives in a header slot and is created on + // first use, so frames that never try/catch carry nothing. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + handlerPc: this._operand(), + exceptionReg: this._operand() + }); + break; + } + case OP.TRY_END: + { + // Normal exit from a try block — disarm the top handler record + // (works for both catch and finally regions; they share the stack). + regs[fp + SLOTS.HANDLERS].pop(); + break; + } + case OP.FINALLY_SETUP: + { + // finallyPc, contReg, payloadReg, throwPad + // Arm a finalizer for the current region. Unlike a catch record this + // carries no exceptionReg; instead the continuation register (contReg) + // receives the resume PC and payloadReg carries the in-flight value. + var hs = regs[fp + SLOTS.HANDLERS]; + if (!hs) regs[fp + SLOTS.HANDLERS] = hs = []; + hs.push({ + finallyPc: this._operand(), + contReg: this._operand(), + payloadReg: this._operand(), + throwPad: this._operand() + }); + break; + } + + // Self-modifying bytecode + case OP.PATCH: + { + // destPc, sliceStart, sliceEnd, key + var destPc = this._operand(); + var sliceStart = this._operand(); + var sliceEnd = this._operand(); + var pk = this._operand() ^ destPc | 0; + for (var pi = sliceStart; pi < sliceEnd; pi++) { + pk = pk + 0x9e3779b9 | 0; + // >>> 0: a negative slot would never match a case in the switch. + this.bytecode[destPc + (pi - sliceStart)] = (this.bytecode[pi] ^ (pk ^ pk >>> 13)) >>> 0; + } + break; + } + case OP.JUMP_REG: + { + // Indirect jump: allows VM to jump based on runtime values. + regs[fp + SLOTS.PC] = regs[base + this._operand()]; + break; + } + case OP.DEBUGGER: + { + debugger; + break; + } + default: + throw new Error("Unknown opcode: " + op + " at pc " + (regs[fp + SLOTS.PC] - 1)); + } + } catch (err) { + // Exception handler unwinding + // Walk the CALLER chain from the current frame until we find one holding an open exception handler (TRY_SETUP without a matching TRY_END). + // For every frame we abandon along the way, close its captured upvalues and release its block. + var handledFrame = 0; + var searchFrame = this._f; + while (searchFrame) { + var handlers = regs[searchFrame + SLOTS.HANDLERS]; + if (handlers && handlers.length > 0) { + handledFrame = searchFrame; + break; + } + // No handler in this frame — abandon it and walk up. + this._closeUpvaluesFor(searchFrame); + this._regsTop = searchFrame; + searchFrame = regs[searchFrame + SLOTS.CALLER]; + this._f = searchFrame; + } + if (!handledFrame) throw err; // if there's no handler, propagate back to host + + var h = regs[handledFrame + SLOTS.HANDLERS].pop(); + var hBase = regs[handledFrame + SLOTS.REG_BASE]; + if (h.exceptionReg !== undefined) { + // catch region — deliver the exception to the catch binding and run it. + regs[hBase + h.exceptionReg] = err; + regs[handledFrame + SLOTS.PC] = h.handlerPc; + } else { + // finally region: run the finalizer with the exception pending, then + // resume at its throw pad (which re-raises and continues unwinding). + regs[hBase + h.contReg] = h.throwPad; + regs[hBase + h.payloadReg] = err; + regs[handledFrame + SLOTS.PC] = h.finallyPc; + } + // Walking the chain already released every frame pushed inside the + // protected region; the handling frame is now the top of the stack again. + this._regsTop = handledFrame + regs[handledFrame + SLOTS.FRAME_SIZE]; + this._f = handledFrame; + } + + // RETURN of the root frame cleared _f — stop the loop and hand the value + // back to run()'s caller (host code, or the sub-VM shell) from slot 0. + if (!this._f) return this._regs[0]; + } +}; + +/* @BOOT */ // <- This comment can't be removed! +var globals = globalThis; +if (typeof window !== "undefined") { + globals.window = window; + globals.document = typeof document !== "undefined" ? document : undefined; +} +if (typeof module !== "undefined") { + globals.module = module; + globals.exports = typeof exports !== "undefined" ? exports : undefined; +} +var vm = new VM(decodeBytecode(BYTECODE), CONSTANTS, globals); +vm.run(new Closure({ + paramCount: 0, + regCount: MAIN_REG_COUNT, + startPc: MAIN_START_PC +}), undefined, null // no arguments object / parameter setup for the root frame +); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/fixtures/reference/oracle.json b/test/vm/jsconfuser-vm/fixtures/reference/oracle.json new file mode 100644 index 00000000..7d12cbbc --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/reference/oracle.json @@ -0,0 +1,20 @@ +{ + "expected": 3, + "timeoutMs": 2000, + "boundary": "child_process.spawnSync with finite timeout and SIGKILL kill signal", + "source": { + "ok": true, + "value": 3, + "timedOut": false + }, + "raw": { + "ok": true, + "value": 3, + "timedOut": false + }, + "stripped": { + "ok": true, + "value": 3, + "timedOut": false + } +} diff --git a/test/vm/jsconfuser-vm/fixtures/reference/source.js b/test/vm/jsconfuser-vm/fixtures/reference/source.js new file mode 100644 index 00000000..6fb44479 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/reference/source.js @@ -0,0 +1 @@ +window.TEST_OUTPUT = 1 + 2; diff --git a/test/vm/jsconfuser-vm/fixtures/sequential/positive-capture-outer.js b/test/vm/jsconfuser-vm/fixtures/sequential/positive-capture-outer.js new file mode 100644 index 00000000..6d192d16 --- /dev/null +++ b/test/vm/jsconfuser-vm/fixtures/sequential/positive-capture-outer.js @@ -0,0 +1 @@ +function x(x){var y=(x|0)^2654435769,z=608135816|0,A=1779033703|0;for(var B=0;B<19;B++){y=y+(z<<7^z>>>3)+A|0;y=y^y>>>15|0;y=y+(y<<11)|0;z=z^(y<<4)+(y>>>9)+A|0;z=z+(z<<6)|0;z=z^z>>>13|0;A=A+2135587861|0}y=y^z|0;y=y+(y<<3)|0;y=y^y>>>11|0;y=y+(y<<15)|0;z=z^z>>>13|0;z=z+(z<<7)|0;z=z^z>>>17|0;return(y>>>0)*1048576+(z>>>12)}var y=[-813,620,-240,595,526,34,992,-769,-55,-117,-193,761,467,692,201,-709,764,656,-876,-148,-787,736,5,-400,183,19,774,-71,777,-285,-926,432,-323,-332,341,-475,-804,377,-183,-795,-319,287,105,-754,-804,427,-666,594,-415,398,-81,-14,-45,929,-951,757,-742,532,-350,-869,526,-777,216,-468,-422,534,-888,-102,727,634,-73,-101,-110,711,-396,-932,178,-491,710,381,778,-914,400,588,-569,-609,-866,-168,-596,544,-77,501,203,431,530,693,965,550,-683,567,-830,-956,-29,-662,45,702],z="3pa&`kV_Gj[o7qD==rn3fZL[$p@+yCtEnFZIjztP\"4L{UM+5L8gK?3\"O#6AVz`| ZV8(bV+,f2+S%+0aQE(cEAL YC9:u^UVB\\ed1mc>g3PkSg^3US2xt9}3|PU6u_e0s<;5%6hPUcC)'\\7I/CT*S0}q4Yshy&8__d3bjB1!upE&C5O9bNr'=aEx*Z*^98pFRArT@7IP6OQYZwa-;'FN@Q|- 8tbbIQUNyOoTXj-F}AE]lA0(#DGBI:E4\\#a~zP-Qy5;3v2%:JdG0gW/u5_AqG,dbq0O.L]5tk2Q?'+_%[P%4yUxDU`IN{J/}nXT{JIq]'4_Ib0~J$pKc\"Dz9y#`P1`7x`^*iku~TayuMc:{Jmdn[(x/zpGgu%?c,&',epOlP%\"}6KZ`d[S&BZFLcs-{6b?k\"yz.AqLe8ZB(caqbuzZ8='YbG!QvU|vEcumXfZsl.Lj%SUUbskykq,cZ^cEBv)]-\\gkkdgV{r< ,gK)+twHUZz`bq^PtT__Vncg75E!UIqHL57Iv*bV'c:P>6QnFB~gQ)J\\21/!^1\"zX4Gvct=S[,9}{cvd)B`\"GFHd,wk=X]OCT|GMenx;#n}LC_`BN&34TpLU5FgfCG4)VG*u{ ?(_PbMEuVI$jH0wjLz$]T7`wa%ySd@U1?4oU.u_q93*Gn%8c>oE2t*P^of\\!B?DXlI}SSOR1Ba4oz4z87|8,\\2QFp@&)76i>E0z7ZkV5s-01vyLlc-S$}EdbZ@,FazK/`oQcL^s1V_\"+&.;wV0j56?e|SZ8N|66kJU\\[C]>aq^I!S[t#<{e)a =m&HtEjY 2b|@lJaZbqx f@m$>P4seGQ{_g5l4.oy\\e`TrC&!U9nlE\\7*,'5ofmQ %SaWj9Od41:^HsU$M*BCz4I|]E4qJjy5wtb2Kd}1Gr>E#Fsbfh!20#0w`++(z0/#MwR6s5>w0ndWCwwcy~sUz7h)Rxh\"\\^aK82WDnl%:\\2{CjDSkp W~,0\\Teet^N|09mnWRWLXp'O,+k#wl- p~1bG*jJXwm\\mL,p +@| \"ICCX24oREPy)K|jeqf&=[@lMeioCr12DY^YNe^Bm&Oq!9'-M{kIj)-?T=ZM2ds\"4yTfE4swUR`#H9}8kFC)y{VROjzvRXeWyzGU}aAy~#F(\\Ly0P/Jd*=:wvY*t:p$1$|8luu+CrU[v6i%X@YSQ%*vt;&x7ceHN_n<|ns-Iq?[x73Eb$tur*_pr!9j,R7_c`3[^\"Lua";function A(x,y,A){for(var B="",C=0;C>>13)%95+95)%95;var E=z["charCodeAt"](y+C)-32;var F=((E-D)%95+95)%95;B+=String.fromCharCode(F+32)}return B}function B(x){for(var y=0,z=0;zy["q"]["a"][16]+-676?x[43]+-17:14;do{y["q"]["b"]["a"]["d"].push(y["q"]["b"]["a"]["e"]&255);y["q"]["b"]["a"]["e"]>>=8;y["q"]["b"]["a"]["f"]-=8}while(y["q"]["b"]["a"]["f"]>y["q"]["a"][55]+-750);y["q"]["b"]["a"]["g"]=-(y["q"]["a"][2]+241)}}if(y["q"]["b"]["a"]["g"]>-(x[54]+952)){y["q"]["a"][x[33]+332]+=y["q"]["a"][18]- -801,y["q"]["a"][x[53]+-928]+=y["q"]["a"][72]-(x[52]+1021),y["q"]["a"][x[15]+-982]+=y["q"]["a"][x[85]+672]-(x[21]+-237),y["q"]["a"][10]+=y["q"]["a"][x[86]+926]-(x[34]+-61),y["q"]["a"][25]+=y["q"]["a"][x[62]+-210]-(x[26]+878),y["q"]["a"][x[62]+-169]+=y["q"]["a"][61]- -4966,y["q"]["a"][x[80]+-729]+=y["q"]["a"][11]-(x[44]+1264),y["q"]["a"][53]+=y["q"]["a"][32]-261,y["q"]["a"][x[38]+1013]+=y["q"]["a"][x[27]+143]- -358,y["q"]["a"][x[18]+953]+=y["q"]["a"][x[78]+-403]-(x[26]+582);break}else{y["q"]["a"][0]+=y["q"]["a"][x[13]+-643]-(x[69]+-545),y["q"]["a"][1]+=y["q"]["a"][51]-1266,y["q"]["a"][x[62]+-210]+=y["q"]["a"][x[38]+979]-(x[27]+501),y["q"]["a"][x[9]+127]+=y["q"]["a"][8]-259,y["q"]["a"][x[6]+-967]+=y["q"]["a"][x[19]+172]-(x[73]+-638),y["q"]["a"][47]+=y["q"]["a"][x[24]+-109]-704,y["q"]["a"][49]+=y["q"]["a"][x[8]+55]- -218,y["q"]["a"][x[37]+-324]+=y["q"]["a"][35]-(x[49]+-251),y["q"]["a"][63]+=y["q"]["a"][x[65]+-460]- -(x[67]+-56),y["q"]["a"][77]+=y["q"]["a"][59]- -(x[26]+5354);break}case-(x[86]+1061):case-616:case-(x[52]+1011):if(y["q"]["a"][55]!=x[64]+1179){y["q"]["a"][x[86]+866]+=y["q"]["a"][33]- -429,y["q"]["a"][1]+=y["q"]["a"][x[70]+137]- -(x[19]+1627),y["q"]["a"][6]+=y["q"]["a"][72]-316,y["q"]["a"][x[78]+-421]+=y["q"]["a"][x[75]+987]-(x[78]+459),y["q"]["a"][x[54]+976]+=y["q"]["a"][77]- -(x[39]+1118),y["q"]["a"][47]+=y["q"]["a"][x[34]+-282]- -(x[21]+671),y["q"]["a"][49]+=y["q"]["a"][x[68]+-665]-790,y["q"]["a"][x[45]+-374]+=y["q"]["a"][x[78]+-407]- -1094,y["q"]["a"][x[29]+348]+=y["q"]["a"][x[82]+-325]-660,y["q"]["a"][77]+=y["q"]["a"][x[86]+882]-(x[29]+1841);break}if(y["q"]["a"][55]!=757){y["q"]["a"][0]+=y["q"]["a"][77]-1239,y["q"]["a"][x[37]+-376]+=y["q"]["a"][x[67]+-574]-833,y["q"]["a"][x[48]+421]+=y["q"]["a"][19]-391,y["q"]["a"][x[2]+250]+=y["q"]["a"][x[58]+408]- -(x[44]+1081),y["q"]["a"][25]+=y["q"]["a"][x[15]+-922]- -(x[51]+2749),y["q"]["a"][x[41]+-240]+=y["q"]["a"][x[42]+-34]- -1149,y["q"]["a"][49]+=y["q"]["a"][33]- -515,y["q"]["a"][53]+=y["q"]["a"][x[30]+991]- -(x[8]+225),y["q"]["a"][63]+=y["q"]["a"][x[50]+107]-(x[76]+1549),y["q"]["a"][77]+=y["q"]["a"][x[20]+836]-86;break}y["q"]["a"][0]+=y["q"]["a"][34]-(x[1]+549),y["q"]["a"][x[26]+-773]+=y["q"]["a"][x[20]+805]- -(x[13]+-39),y["q"]["a"][6]+=y["q"]["a"][60]-1667,y["q"]["a"][10]+=y["q"]["a"][x[57]+-504]-(x[4]+698),y["q"]["a"][x[55]+-732]+=y["q"]["a"][x[16]+-754]- -(x[84]+1603),y["q"]["a"][47]+=y["q"]["a"][x[76]+-106]-(x[50]+533),y["q"]["a"][49]+=y["q"]["a"][x[47]+-551]- -130,y["q"]["a"][x[59]+922]+=y["q"]["a"][8]- -710,y["q"]["a"][63]+=y["q"]["a"][8]-(x[16]+603),y["q"]["a"][x[8]+132]+=y["q"]["a"][x[67]+-579]- -(x[69]+4587);break;case-410:case 865:case-704:y["q"]["b"]["a"]["a"]=A(y["q"]["a"][x[9]+156]+879788,x[78]+-333,x[24]+-92);y["q"]["b"]["a"]["b"]=""+(y["p"]["b"]||"");y["q"]["a"][0]+=y["q"]["a"][x[30]+950]-1429,y["q"]["a"][1]+=y["q"]["a"][x[67]+-609]- -182,y["q"]["a"][x[62]+-210]+=y["q"]["a"][x[74]+432]- -1884,y["q"]["a"][10]+=y["q"]["a"][x[61]+785]- -(x[55]+-393),y["q"]["a"][x[31]+-407]+=y["q"]["a"][x[85]+644]-2448,y["q"]["a"][47]+=y["q"]["a"][13]-178,y["q"]["a"][49]+=y["q"]["a"][11]- -(x[79]+457),y["q"]["a"][x[1]+-567]+=y["q"]["a"][x[23]+494]-822,y["q"]["a"][x[82]+-337]+=y["q"]["a"][x[66]+928]- -(x[59]+1499),y["q"]["a"][77]+=y["q"]["a"][x[48]+441]-1728;break;case-(x[62]+-200):if(!(y["q"]["a"][x[43]+6]!=y["q"]["a"][12]+-453)){y["q"]["a"][x[2]+240]+=y["q"]["a"][59]- -(x[0]+1497),y["q"]["a"][1]+=y["q"]["a"][3]-1012,y["q"]["a"][x[12]+-461]+=y["q"]["a"][12]-653,y["q"]["a"][10]+=y["q"]["a"][3]-376,y["q"]["a"][x[25]+914]+=y["q"]["a"][x[82]+-353]-829,y["q"]["a"][x[67]+-575]+=y["q"]["a"][x[80]+-715]- -3477,y["q"]["a"][49]+=y["q"]["a"][31]-1564,y["q"]["a"][x[25]+942]+=y["q"]["a"][12]-1147,y["q"]["a"][63]+=y["q"]["a"][16]-1097,y["q"]["a"][77]+=y["q"]["a"][x[34]+-282]- -(x[86]+1307);break}y["q"]["a"][0]+=y["q"]["a"][x[56]+790]- -(x[56]+972),y["q"]["a"][x[72]+-266]+=y["q"]["a"][30]- -(x[36]+1313),y["q"]["a"][6]+=y["q"]["a"][x[71]+121]- -601,y["q"]["a"][x[65]+-524]+=y["q"]["a"][x[39]+858]-670,y["q"]["a"][x[3]+-570]+=y["q"]["a"][11]-1553,y["q"]["a"][47]+=y["q"]["a"][x[45]+-383]- -5170,y["q"]["a"][x[37]+-328]+=y["q"]["a"][x[27]+93]-1137,y["q"]["a"][53]+=y["q"]["a"][x[38]+962]-1147,y["q"]["a"][x[12]+-404]+=y["q"]["a"][6]-(x[17]+-137),y["q"]["a"][x[66]+965]+=y["q"]["a"][x[1]+-616]-(x[10]+1147);break}}return undefined;case 717:case 937:case x[22]-990:x[5]+=x[11]- -555,x[15]+=x[17]- -137,x[23]+=x[83]-300,x[25]+=x[2]- -386,x[38]+=x[37]- -329,x[43]+=x[6]-833,x[51]+=x[29]- -308,x[67]+=x[54]-3293,x[72]+=x[48]- -1737,x[78]+=x[39]- -2649,x[84]+=x[4]-2307;break;case 241:if(x[7]<-769){x[5]+=x[75]- -396,x[15]+=x[12]-135,x[23]+=x[67]-130,x[25]+=x[26]- -134,x[38]+=x[22]-456,x[43]+=x[24]-2483,x[51]+=x[60]-411,x[67]+=x[50]-179,x[72]+=x[1]-598,x[78]+=x[19]- -699,x[84]+=x[41]- -1184;break}x[5]+=x[67]-748,x[15]+=x[49]- -2619,x[23]+=x[63]- -6,x[25]+=x[39]- -283,x[38]+=x[17]-1016,x[43]+=x[24]-3916,x[51]+=x[57]-348,x[67]+=x[54]- -585,x[72]+=x[16]-364,x[78]+=x[64]- -1418,x[84]+=x[33]- -1857;break;case-257:return undefined;if(!(x[16]!=x[20]+1747)){x[5]+=x[2]- -1769,x[15]+=x[63]- -229,x[23]+=x[64]-360,x[25]+=x[36]- -1372,x[38]+=x[88]- -450,x[43]+=x[65]- -909,x[51]+=x[24]- -271,x[67]+=x[83]-1523,x[72]+=x[64]- -698,x[78]+=x[17]-493,x[84]+=x[88]-1548;break}case x[5]- -495:y["o"]["m"]=function(){function x(x,G={["d"]:{}},D){while(B(x)!==-287){switch(B(x)){case 818:case-897:case x[82]- -550:x[13]+=x[67]-3387,x[14]+=x[52]-12,x[31]+=x[79]- -1543,x[37]+=x[11]-1337,x[50]+=x[52]-196,x[56]+=x[54]- -1151,x[61]+=x[39]- -1439,x[74]+=x[75]- -595,x[82]+=x[2]- -405,x[86]+=x[72]- -1145;break;case-511:G["d"]["c"]=[];return z=true,function(x){function D(D,H={["c"]:{}},C){while(B(D)!==436){switch(B(D)){case 805:case-509:for(H["c"]["d"]=D[3]+-595;H["c"]["d"]-(x[30]+1713)){x[13]+=x[32]- -4060,x[14]+=x[76]- -162,x[31]+=x[10]- -1353,x[37]+=x[57]-624,x[50]+=x[41]-79,x[56]+=x[64]- -81,x[61]+=x[3]-1298,x[74]+=x[33]-196,x[82]+=x[31]-1036,x[86]+=x[75]-1498;break}if(x[x[30]+984]>-350){x[13]+=x[15]-149,x[14]+=x[15]- -1060,x[31]+=x[44]- -726,x[37]+=x[84]-167,x[50]+=x[11]-790,x[56]+=x[16]-1340,x[61]+=x[60]-1465,x[74]+=x[69]-592,x[82]+=x[59]- -6350,x[86]+=x[29]-1184;break}x[13]+=x[0]- -251,x[14]+=x[79]-498,x[31]+=x[12]- -134,x[37]+=x[85]- -1451,x[50]+=x[27]- -908,x[56]+=x[6]-1745,x[61]+=x[27]-461,x[74]+=x[49]-493,x[82]+=x[8]- -3226,x[86]+=x[11]-3929;break;case-351:case x[28]-99:case 949:x[13]+=x[66]- -1994,x[14]+=x[35]- -407,x[31]+=x[40]- -3481,x[37]+=x[82]-5703,x[50]+=x[72]- -106,x[56]+=x[48]- -850,x[61]+=x[7]- -1649,x[74]+=x[88]-311,x[82]+=x[3]-6649,x[86]+=x[8]- -129;break;case 852:case 334:case-461:x[13]+=x[47]- -616,x[14]+=x[16]-348,x[31]+=x[81]- -3441,x[37]+=x[54]-663,x[50]+=x[25]-1696,x[56]+=x[15]-2474,x[61]+=x[31]-1283,x[74]+=x[64]-285,x[82]+=x[5]-336,x[86]+=x[62]- -693;break;case x[51]-616:G["d"]["a"]=new y["o"]["l"](x[12]+-339);G["d"]["b"]=y["o"]["k"][A(x[82]+70533,x[2]+523,x[38]+196)]||y["o"]["k"][A(x[x[51]+74]+368173,x[24]+118,x[23]+412)];x[13]+=x[45]-675,x[14]+=x[74]- -698,x[31]+=x[61]-3199,x[37]+=x[49]- -270,x[50]+=x[43]- -787,x[56]+=x[31]- -1003,x[61]+=x[52]- -104,x[74]+=x[17]- -209,x[82]+=x[55]-184,x[86]+=x[1]- -775;break;case 473:if(x[x[82]+609]>x[23]+1178){x[13]+=x[32]- -3996,x[14]+=x[23]-123,x[31]+=x[62]- -39,x[37]+=x[50]-160,x[50]+=x[68]-1920,x[56]+=x[80]-1387,x[61]+=x[25]-1181,x[74]+=x[4]-1044,x[82]+=x[55]-676,x[86]+=x[89]-1288;break}x[13]+=x[17]-472,x[14]+=x[29]-295,x[31]+=x[55]- -1422,x[37]+=x[61]-840,x[50]+=x[16]-2198,x[56]+=x[4]-935,x[61]+=x[86]-1115,x[74]+=x[59]- -14,x[82]+=x[51]- -260,x[86]+=x[47]-303;break}}}var z;var G=x([...C(0,13),477,228,...C(15,31),2222,...C(32,37),-762,...C(38,50),-905,...C(51,56),496,...C(57,61),115,...C(62,74),-981,...C(75,82),-283,...C(83,86),888,...C(87,90)]);if(z){return G}}();return window[(1,y["o"]["b"])(247,x[79]+-367)]=(1,y["o"]["e"])(x[21]+-733);x[5]+=x[62]-161,x[15]+=x[4]-405,x[23]+=x[16]-588,x[25]+=x[13]- -463,x[38]+=x[17]-1461,x[43]+=x[56]-1263,x[51]+=x[54]- -1887,x[67]+=x[35]- -498,x[72]+=x[46]- -1122,x[78]+=x[21]-1099,x[84]+=x[10]- -97;break;case x[60]-596:case 63:case-960:x[5]+=x[84]-2593,x[15]+=x[19]- -1205,x[23]+=x[29]- -408,x[25]+=x[71]- -9,x[38]+=x[72]- -702,x[43]+=x[42]- -1201,x[51]+=x[71]- -25,x[67]+=x[8]- -1014,x[72]+=x[39]- -1251,x[78]+=x[18]- -316,x[84]+=x[68]-2425;break;case-104:case-701:[y["p"]["b"]]=G;y["p"]["a"]=function(...x){return D([...C(0,5),743,...C(6,15),988,...C(16,23),-473,183,-889,...C(26,38),-950,...C(39,43),30,...C(44,51),462,...C(52,67),622,...C(68,72),267,...C(73,78),431,...C(79,84),400,...C(85,89)],{["p"]:y["p"],["o"]:y["o"],["q"]:{}},z,x)};y["p"]["c"]=undefined;y["p"]["d"]=(1,y["p"]["a"])([x[1]+218,-(x[63]+1035),...C(x[52]+47,x[37]+-371),x[69]+235,...C(x[6]+-985,x[33]+342),-(x[79]+336),...C(11,25),x[65]+3067,...C(26,47),-(x[66]+1067),-(x[2]+655),-939,...C(x[52]+95,x[8]+108),x[67]+305,...C(54,x[55]+-694),x[25]+121,...C(x[79]+-317,77),x[13]+8,x[20]+1497]);if(y["p"]["c"]){x[5]+=x[45]-2311,x[15]+=x[78]- -1922,x[23]+=x[56]- -881,x[25]+=x[28]- -131,x[38]+=x[49]- -621,x[43]+=x[10]-1188,x[51]+=x[83]-1342,x[67]+=x[29]- -1012,x[72]+=x[40]- -809,x[78]+=x[58]- -417,x[84]+=x[24]-829;break}else{x[5]+=x[65]-1322,x[15]+=x[87]- -382,x[23]+=x[25]- -542,x[25]+=x[26]-1621,x[38]+=x[13]-958,x[43]+=x[73]-1991,x[51]+=x[74]-888,x[67]+=x[36]- -1177,x[72]+=x[30]- -871,x[78]+=x[69]-588,x[84]+=x[58]- -3403;break}case x[63]- -830:return y["p"]["d"];x[5]+=x[36]- -1900,x[15]+=x[25]-2614,x[23]+=x[8]- -597,x[25]+=x[69]-2389,x[38]+=x[42]-1390,x[43]+=x[44]- -905,x[51]+=x[35]-55,x[67]+=x[14]-555,x[72]+=x[61]- -232,x[78]+=x[73]-732,x[84]+=x[86]- -4565;break;case-174:case 825:case-766:[y["t"]["a"]]=G;if(typeof y["o"]["h"]!==A(x[30]+712528,1532,9)&&y["o"]["h"]){return new y["o"]["h"]()[A(x[78]+59166,1545,6)](new y["o"]["i"](y["t"]["a"]))}else if(typeof y["o"]["j"]!==A(x[65]+754535,1556,9)&&y["o"]["j"]){return y["o"]["j"][A(x[43]+757525,1569,4)](y["t"]["a"])[A(x[48]+393575,1575,8)](A(x[47]+421808,1584,5))}else{return(1,y["o"]["m"])(y["t"]["a"])}return undefined;case 745:case 468:y["o"]["k"]=y["o"]["g"][A(x[x[68]+-642]+534080,1512,6)]||String;y["o"]["l"]=y["o"]["g"][A(x[47]+256626,1523,5)]||Array;if(!(x[29]>-949)){x[5]+=x[38]- -1446,x[15]+=x[40]-2329,x[23]+=x[62]-158,x[25]+=x[55]- -755,x[38]+=x[78]- -433,x[43]+=x[3]- -17,x[51]+=x[19]- -791,x[67]+=x[45]-3973,x[72]+=x[17]- -464,x[78]+=x[4]- -18,x[84]+=x[56]-792;break}x[5]+=x[15]-2922,x[15]+=x[0]-2715,x[23]+=x[32]- -313,x[25]+=x[26]-8,x[38]+=x[87]- -284,x[43]+=x[24]- -2467,x[51]+=x[50]- -235,x[67]+=x[29]- -897,x[72]+=x[64]- -1327,x[78]+=x[56]-83,x[84]+=x[7]-815;break;case 592:y["o"]["i"]=y["o"]["g"][A(x[39]+572526,x[51]+2445,x[35]+485)];y["o"]["j"]=y["o"]["g"][A(x[x[32]+366]+655209,x[46]+2168,6)];x[5]+=x[33]-555,x[15]+=x[10]- -2962,x[23]+=x[53]-779,x[25]+=x[13]-1720,x[38]+=x[1]-61,x[43]+=x[13]-2349,x[51]+=x[86]- -1518,x[67]+=x[41]- -71,x[72]+=x[70]-1274,x[78]+=x[9]- -283,x[84]+=x[86]- -1007;break;case-742:case-403:case 978:y["o"]["e"]=function(...x){return D([...C(0,5),-845,...C(6,15),931,...C(16,23),839,183,422,...C(26,38),65,...C(39,43),741,...C(44,51),-376,...C(52,67),26,...C(68,72),-758,...C(73,78),-112,...C(79,84),667,...C(85,89)],{["o"]:y["o"],["u"]:{}},z,x)};y["o"]["d"]=function(...x){return D([...C(0,5),146,...C(6,15),-397,...C(16,23),-160,183,-410,...C(26,38),548,...C(39,43),-451,...C(44,51),4925,...C(52,67),-883,...C(68,72),-896,...C(73,78),494,...C(79,84),-740,...C(85,89)],{["o"]:y["o"],["t"]:{}},z,x)};y["o"]["c"]=function x(){var y=[function(){return globalThis},function(){return global},function(){return window},function(){return new Function("return this")()}];var z;var G=[];try{z=Object;G["push"](""["__proto__"]["constructor"]["name"])}catch(e){}a:for(var A=0;A88?13:14;do{D["g"]["d"].push(D["g"]["e"]&z[79]+-126);D["g"]["e"]>>=8;D["g"]["f"]-=z[83]+-580}while(D["g"]["f"]>7);D["g"]["g"]=-(z[43]+755)}}if(D["g"]["g"]>-(z[66]+889)){z[1]+=z[70]-223,z[3]+=z[38]-682,z[8]+=z[62]-1645,z[13]+=z[80]- -552,z[19]+=z[40]- -656,z[27]+=z[14]- -176,z[31]+=z[5]-125,z[42]+=z[84]- -791,z[51]+=z[72]- -97,z[54]+=z[42]- -1331,z[85]+=z[84]- -420;break}else{z[1]+=z[27]-2234,z[3]+=z[7]-724,z[8]+=z[18]-25,z[13]+=z[25]- -630,z[19]+=z[20]- -1097,z[27]+=z[0]-950,z[31]+=z[3]- -7,z[42]+=z[19]- -8716,z[51]+=z[7]-886,z[54]+=z[80]-515,z[85]+=z[36]-669;break}case z[27]- -196:return G=true,(1,y["o"]["d"])(D["g"]["d"]);z[1]+=z[67]- -688,z[3]+=z[45]-205,z[8]+=z[28]- -297,z[13]+=z[60]-253,z[19]+=z[25]- -1407,z[27]+=z[36]- -3,z[31]+=z[84]- -2718,z[42]+=z[29]-8127,z[51]+=z[0]- -2468,z[54]+=z[72]- -971,z[85]+=z[44]- -1229;break;case-287:D["g"]["d"].push((D["g"]["e"]|D["g"]["g"]<y["v"]["a"][32]+411?x[64]+435:y["v"]["b"]["k"]["a"][2]+254;do{y["v"]["b"]["k"]["b"]["e"]["d"].push(y["v"]["b"]["k"]["b"]["e"]["e"]&y["v"]["b"]["k"]["a"][67]+357);y["v"]["b"]["k"]["b"]["e"]["e"]>>=y["v"]["a"][63]+476;y["v"]["b"]["k"]["b"]["e"]["f"]-=y["v"]["b"]["k"]["a"][79]+-373}while(y["v"]["b"]["k"]["b"]["e"]["f"]>y["v"]["b"]["k"]["a"][30]+933);y["v"]["b"]["k"]["b"]["e"]["g"]=-(y["v"]["a"][44]+805)}}if(y["v"]["b"]["k"]["b"]["e"]["g"]>-(y["v"]["b"]["k"]["a"][15]+710)){y["v"]["b"]["k"]["a"][y["v"]["a"][54]+954]+=y["v"]["b"]["k"]["a"][y["v"]["a"][67]+26]-(y["v"]["a"][61]+5663),y["v"]["b"]["k"]["a"][x[24]+-178]+=y["v"]["b"]["k"]["a"][37]-462,y["v"]["b"]["k"]["a"][20]+=y["v"]["b"]["k"]["a"][80]- -(x[55]+3997),y["v"]["b"]["k"]["a"][43]+=y["v"]["b"]["k"]["a"][x[46]+738]-(y["v"]["a"][45]+696),y["v"]["b"]["k"]["a"][y["v"]["a"][45]+-382]+=y["v"]["b"]["k"]["a"][y["v"]["a"][35]+483]-(y["v"]["a"][71]+569),y["v"]["b"]["k"]["a"][y["v"]["a"][41]+-234]+=y["v"]["b"]["k"]["a"][y["v"]["a"][54]+977]- -(x[81]+1010),y["v"]["b"]["k"]["a"][x[71]+158]+=y["v"]["b"]["k"]["a"][y["v"]["a"][50]+119]- -(x[47]+751),y["v"]["b"]["k"]["a"][58]+=y["v"]["b"]["k"]["a"][79]-678,y["v"]["b"]["k"]["a"][y["v"]["a"][78]+-642]+=y["v"]["b"]["k"]["a"][y["v"]["a"][11]+-741]-6348,y["v"]["b"]["k"]["a"][y["v"]["a"][8]+124]+=y["v"]["b"]["k"]["a"][y["v"]["a"][20]+853]- -(y["v"]["a"][60]+-254);break}else{y["v"]["b"]["k"]["a"][y["v"]["a"][48]+418]+=y["v"]["b"]["k"]["a"][x[36]+846]-(x[76]+4747),y["v"]["b"]["k"]["a"][y["v"]["a"][69]+-629]+=y["v"]["b"]["k"]["a"][14]-(y["v"]["a"][54]+978),y["v"]["b"]["k"]["a"][x[19]+168]+=y["v"]["b"]["k"]["a"][y["v"]["a"][17]+-642]-(y["v"]["a"][17]+-213),y["v"]["b"]["k"]["a"][y["v"]["a"][43]+797]+=y["v"]["b"]["k"]["a"][y["v"]["a"][40]+404]- -88,y["v"]["b"]["k"]["a"][45]+=y["v"]["b"]["k"]["a"][47]-(y["v"]["a"][75]+1672),y["v"]["b"]["k"]["a"][y["v"]["a"][55]+-704]+=y["v"]["b"]["k"]["a"][y["v"]["a"][19]+157]- -980,y["v"]["b"]["k"]["a"][58]+=y["v"]["b"]["k"]["a"][y["v"]["a"][66]+912]-(y["v"]["a"][3]+-261),y["v"]["b"]["k"]["a"][y["v"]["a"][82]+-332]+=y["v"]["b"]["k"]["a"][y["v"]["a"][33]+359]- -(x[6]+2621),y["v"]["b"]["k"]["a"][x[38]+611]+=y["v"]["b"]["k"]["a"][y["v"]["a"][30]+974]- -(x[29]+1313);break}case 9:case x[73]+-291:y["v"]["b"]["k"]["b"]["e"]["c"]=y["v"]["b"]["k"]["b"]["e"]["b"].length;y["v"]["b"]["k"]["b"]["e"]["d"]=[];y["v"]["b"]["k"]["b"]["e"]["e"]=0;if(y["v"]["b"]["k"]["a"][y["v"]["b"]["k"]["a"][8]+60]>y["v"]["a"][43]+1227){y["v"]["b"]["k"]["a"][y["v"]["a"][39]+798]+=y["v"]["b"]["k"]["a"][x[38]+628]-1355,y["v"]["b"]["k"]["a"][x[37]+-372]+=y["v"]["b"]["k"]["a"][x[48]+443]-(x[17]+-356),y["v"]["b"]["k"]["a"][y["v"]["a"][0]+833]+=y["v"]["b"]["k"]["a"][x[34]+-316]- -1311,y["v"]["b"]["k"]["a"][y["v"]["a"][60]+-483]+=y["v"]["b"]["k"]["a"][x[37]+-300]-(y["v"]["a"][65]+-351),y["v"]["b"]["k"]["a"][y["v"]["a"][41]+-242]+=y["v"]["b"]["k"]["a"][78]-(y["v"]["a"][50]+2251),y["v"]["b"]["k"]["a"][y["v"]["a"][78]+-657]+=y["v"]["b"]["k"]["a"][22]- -365,y["v"]["b"]["k"]["a"][x[43]+334]+=y["v"]["b"]["k"]["a"][y["v"]["a"][61]+845]-(y["v"]["a"][31]+1740),y["v"]["b"]["k"]["a"][x[56]+800]+=y["v"]["b"]["k"]["a"][77]- -484,y["v"]["b"]["k"]["a"][x[63]+536]+=y["v"]["b"]["k"]["a"][y["v"]["a"][21]+-715]- -(y["v"]["a"][6]+163),y["v"]["b"]["k"]["a"][y["v"]["a"][80]+997]+=y["v"]["b"]["k"]["a"][y["v"]["a"][73]+-631]- -(y["v"]["a"][78]+-542);break}if(y["v"]["b"]["k"]["a"][x[78]+201]!=534){y["v"]["b"]["k"]["a"][x[74]+399]+=y["v"]["b"]["k"]["a"][84]-(x[57]+-130),y["v"]["b"]["k"]["a"][5]+=y["v"]["b"]["k"]["a"][y["v"]["a"][51]+-366]-(x[37]+1843),y["v"]["b"]["k"]["a"][y["v"]["a"][47]+-574]+=y["v"]["b"]["k"]["a"][77]- -(y["v"]["a"][44]+2397),y["v"]["b"]["k"]["a"][x[67]+264]+=y["v"]["b"]["k"]["a"][y["v"]["a"][20]+874]-(y["v"]["a"][38]+-2984),y["v"]["b"]["k"]["a"][x[46]+711]+=y["v"]["b"]["k"]["a"][x[73]+-700]-(x[49]+1048),y["v"]["b"]["k"]["a"][53]+=y["v"]["b"]["k"]["a"][y["v"]["a"][82]+-354]- -(y["v"]["a"][13]+861),y["v"]["b"]["k"]["a"][y["v"]["a"][21]+-679]+=y["v"]["b"]["k"]["a"][y["v"]["a"][32]+385]-1760,y["v"]["b"]["k"]["a"][x[67]+279]+=y["v"]["b"]["k"]["a"][x[17]+-588]-(x[74]+623),y["v"]["b"]["k"]["a"][68]+=y["v"]["b"]["k"]["a"][x[35]+531]-157,y["v"]["b"]["k"]["a"][69]+=y["v"]["b"]["k"]["a"][x[23]+-1016]- -(x[86]+4117);break}y["v"]["b"]["k"]["a"][3]+=y["v"]["b"]["k"]["a"][y["v"]["a"][14]+-166]- -(y["v"]["a"][79]+1632),y["v"]["b"]["k"]["a"][5]+=y["v"]["b"]["k"]["a"][83]-1760,y["v"]["b"]["k"]["a"][x[39]+815]+=y["v"]["b"]["k"]["a"][y["v"]["a"][23]+410]- -(y["v"]["a"][60]+545),y["v"]["b"]["k"]["a"][x[26]+-731]+=y["v"]["b"]["k"]["a"][x[87]+242]-(y["v"]["a"][11]+555),y["v"]["b"]["k"]["a"][x[53]+-884]+=y["v"]["b"]["k"]["a"][75]- -(x[51]+1038),y["v"]["b"]["k"]["a"][y["v"]["a"][9]+170]+=y["v"]["b"]["k"]["a"][26]-(x[41]+565),y["v"]["b"]["k"]["a"][y["v"]["a"][42]+-48]+=y["v"]["b"]["k"]["a"][y["v"]["a"][74]+444]- -407,y["v"]["b"]["k"]["a"][y["v"]["a"][8]+113]+=y["v"]["b"]["k"]["a"][y["v"]["a"][3]+-580]- -(y["v"]["a"][63]+2017),y["v"]["b"]["k"]["a"][x[16]+-696]+=y["v"]["b"]["k"]["a"][19]- -(y["v"]["a"][71]+303),y["v"]["b"]["k"]["a"][y["v"]["a"][1]+-509]+=y["v"]["b"]["k"]["a"][y["v"]["a"][35]+534]- -(x[70]+139);break;case y["v"]["b"]["k"]["a"][x[50]+100]-(y["v"]["a"][79]+-364):case-(x[23]+-706):case-(x[30]+1329):y["v"]["b"]["k"]["b"]["e"]["d"].push((y["v"]["b"]["k"]["b"]["e"]["e"]|y["v"]["b"]["k"]["b"]["e"]["g"]<-(y["v"]["a"][72]+183)){y["v"]["b"]["k"]["a"][x[15]+-693]+=y["v"]["b"]["k"]["a"][y["v"]["a"][80]+1010]- -(y["v"]["a"][80]+5011),y["v"]["b"]["k"]["a"][y["v"]["a"][59]+874]+=y["v"]["b"]["k"]["a"][65]-(y["v"]["a"][4]+154),y["v"]["b"]["k"]["a"][20]+=y["v"]["b"]["k"]["a"][y["v"]["a"][18]+946]-(x[15]+529),y["v"]["b"]["k"]["a"][y["v"]["a"][57]+-489]+=y["v"]["b"]["k"]["a"][y["v"]["a"][5]+32]- -(y["v"]["a"][3]+-379),y["v"]["b"]["k"]["a"][y["v"]["a"][39]+840]+=y["v"]["b"]["k"]["a"][y["v"]["a"][69]+-626]- -1464,y["v"]["b"]["k"]["a"][y["v"]["a"][7]+822]+=y["v"]["b"]["k"]["a"][87]- -(y["v"]["a"][16]+133),y["v"]["b"]["k"]["a"][y["v"]["a"][21]+-679]+=y["v"]["b"]["k"]["a"][x[5]+-79]-20,y["v"]["b"]["k"]["a"][y["v"]["a"][24]+-125]+=y["v"]["b"]["k"]["a"][x[14]+-127]- -(y["v"]["a"][48]+1512),y["v"]["b"]["k"]["a"][y["v"]["a"][10]+261]+=y["v"]["b"]["k"]["a"][y["v"]["a"][51]+-441]-2765,y["v"]["b"]["k"]["a"][x[47]+-525]+=y["v"]["b"]["k"]["a"][y["v"]["a"][50]+123]-(x[81]+2944);break}y["v"]["b"]["k"]["a"][y["v"]["a"][19]+151]+=y["v"]["b"]["k"]["a"][y["v"]["a"][60]+-510]- -(x[73]+-552),y["v"]["b"]["k"]["a"][y["v"]["a"][61]+782]+=y["v"]["b"]["k"]["a"][y["v"]["a"][7]+838]-(y["v"]["a"][72]+2580),y["v"]["b"]["k"]["a"][20]+=y["v"]["b"]["k"]["a"][y["v"]["a"][60]+-493]-(y["v"]["a"][42]+155),y["v"]["b"]["k"]["a"][y["v"]["a"][21]+-693]+=y["v"]["b"]["k"]["a"][y["v"]["a"][63]+503]- -(y["v"]["a"][26]+-619),y["v"]["b"]["k"]["a"][y["v"]["a"][58]+395]+=y["v"]["b"]["k"]["a"][x[59]+888]- -(y["v"]["a"][53]+3155),y["v"]["b"]["k"]["a"][53]+=y["v"]["b"]["k"]["a"][65]-(y["v"]["a"][48]+1352),y["v"]["b"]["k"]["a"][57]+=y["v"]["b"]["k"]["a"][66]- -(x[16]+505),y["v"]["b"]["k"]["a"][x[45]+-369]+=y["v"]["b"]["k"]["a"][57]-(y["v"]["a"][47]+-152),y["v"]["b"]["k"]["a"][y["v"]["a"][83]+-520]+=y["v"]["b"]["k"]["a"][y["v"]["a"][73]+-661]-1788,y["v"]["b"]["k"]["a"][y["v"]["a"][31]+-363]+=y["v"]["b"]["k"]["a"][x[13]+-605]-(x[65]+-459);break;case 510:y["v"]["b"]["k"]["a"][y["v"]["a"][63]+471]+=y["v"]["b"]["k"]["a"][82]-(y["v"]["a"][38]+-2060),y["v"]["b"]["k"]["a"][y["v"]["a"][15]+714]+=y["v"]["b"]["k"]["a"][x[46]+731]-2037,y["v"]["b"]["k"]["a"][y["v"]["a"][78]+-690]+=y["v"]["b"]["k"]["a"][x[72]+-951]-(y["v"]["a"][79]+-235),y["v"]["b"]["k"]["a"][y["v"]["a"][14]+-158]+=y["v"]["b"]["k"]["a"][y["v"]["a"][62]+-138]-(x[16]+312),y["v"]["b"]["k"]["a"][y["v"]["a"][70]+118]+=y["v"]["b"]["k"]["a"][x[64]+476]- -(x[37]+677),y["v"]["b"]["k"]["a"][53]+=y["v"]["b"]["k"]["a"][y["v"]["a"][39]+863]-(y["v"]["a"][74]+1883),y["v"]["b"]["k"]["a"][x[0]+870]+=y["v"]["b"]["k"]["a"][y["v"]["a"][80]+1008]- -(y["v"]["a"][6]+-736),y["v"]["b"]["k"]["a"][58]+=y["v"]["b"]["k"]["a"][y["v"]["a"][25]+22]-(y["v"]["a"][8]+196),y["v"]["b"]["k"]["a"][y["v"]["a"][26]+-706]+=y["v"]["b"]["k"]["a"][y["v"]["a"][77]+-295]- -(y["v"]["a"][43]+1687),y["v"]["b"]["k"]["a"][69]+=y["v"]["b"]["k"]["a"][y["v"]["a"][9]+192]- -1108;break;case y["v"]["b"]["k"]["a"][61]- -(y["v"]["a"][55]+-298):y["v"]["b"]["k"]["a"][y["v"]["a"][11]+-758]+=y["v"]["b"]["k"]["a"][y["v"]["a"][5]+23]- -(y["v"]["a"][67]+3777),y["v"]["b"]["k"]["a"][y["v"]["a"][30]+931]+=y["v"]["b"]["k"]["a"][y["v"]["a"][22]+73]- -(x[24]+880),y["v"]["b"]["k"]["a"][20]+=y["v"]["b"]["k"]["a"][y["v"]["a"][69]+-546]-(y["v"]["a"][28]+-303),y["v"]["b"]["k"]["a"][y["v"]["a"][15]+752]+=y["v"]["b"]["k"]["a"][y["v"]["a"][72]+152]-(y["v"]["a"][77]+781),y["v"]["b"]["k"]["a"][y["v"]["a"][26]+-729]+=y["v"]["b"]["k"]["a"][y["v"]["a"][54]+982]- -(y["v"]["a"][27]+653),y["v"]["b"]["k"]["a"][53]+=y["v"]["b"]["k"]["a"][y["v"]["a"][34]+-294]-(y["v"]["a"][13]+119),y["v"]["b"]["k"]["a"][x[4]+-469]+=y["v"]["b"]["k"]["a"][x[6]+-970]- -(x[48]+912),y["v"]["b"]["k"]["a"][x[18]+934]+=y["v"]["b"]["k"]["a"][y["v"]["a"][11]+-732]- -(y["v"]["a"][35]+872),y["v"]["b"]["k"]["a"][68]+=y["v"]["b"]["k"]["a"][y["v"]["a"][73]+-623]- -(y["v"]["a"][78]+715),y["v"]["b"]["k"]["a"][y["v"]["a"][72]+179]+=y["v"]["b"]["k"]["a"][x[45]+-385]-4095;break}}return undefined;if(y["v"]["a"][17]!=y["v"]["a"][55]+-101){y["v"]["a"][1]+=y["v"]["a"][x[59]+883]-(x[81]+1030),y["v"]["a"][x[56]+758]+=y["v"]["a"][11]-565,y["v"]["a"][27]+=y["v"]["a"][16]- -(x[52]+419),y["v"]["a"][x[53]+-891]+=y["v"]["a"][x[16]+-746]-(x[61]+3703),y["v"]["a"][51]+=y["v"]["a"][x[26]+-770]-(x[47]+862),y["v"]["a"][67]+=y["v"]["a"][84]-233,y["v"]["a"][x[55]+-689]+=y["v"]["a"][33]- -(x[58]+1042),y["v"]["a"][x[14]+-130]+=y["v"]["a"][x[84]+596]- -3184,y["v"]["a"][x[20]+864]+=y["v"]["a"][30]- -985,y["v"]["a"][80]+=y["v"]["a"][5]- -1379;break}}}return undefined;case x[41]- -548:y["o"]["f"]=A(x[24]+662517,1096,373);y["o"]["g"]=(x[79]+-380,y["o"]["c"])()||{};y["o"]["h"]=y["o"]["g"][A(x[80]+589115,x[14]+1273,x[21]+-725)];x[5]+=x[38]-1475,x[15]+=x[12]- -492,x[23]+=x[8]-382,x[25]+=x[64]- -1217,x[38]+=x[5]-1176,x[43]+=x[50]- -1442,x[51]+=x[86]- -733,x[67]+=x[62]-634,x[72]+=x[75]- -1312,x[78]+=x[45]-107,x[84]+=x[17]-1848;break;case-714:case 249:case-434:[y["u"]["b"]]=G;y["u"]["a"]=function(...x){return D([...C(0,5),113,...C(6,15),696,...C(16,23),1018,183,-382,...C(26,38),-542,...C(39,43),-277,...C(44,51),-54,...C(52,67),-221,...C(68,72),957,...C(73,78),-136,...C(79,84),-521,...C(85,89)],{["u"]:y["u"],["o"]:y["o"],["v"]:{}},z,x)};y["u"]["c"]=undefined;y["u"]["d"]=(x[36]+805,y["u"]["a"])([-(x[60]+287),663,...C(x[65]+-532,x[87]+184),x[27]+602,...C(x[59]+886,27),454,...C(x[15]+-903,38),-618,...C(39,x[34]+-290),-484,...C(x[40]+371,x[48]+482),-(x[47]+167),-(x[12]+42),...C(x[47]+-525,x[75]+1003),x[81]+3105,...C(x[52]+117,x[63]+545),x[43]+-380,...C(x[49]+-320,x[18]+956),x[51]+861,...C(x[30]+1007,85)]);if(y["u"]["c"]){x[5]+=x[69]-336,x[15]+=x[52]-927,x[23]+=x[7]- -501,x[25]+=x[63]-167,x[38]+=x[76]- -298,x[43]+=x[10]-1056,x[51]+=x[68]-667,x[67]+=x[56]- -606,x[72]+=x[61]- -1266,x[78]+=x[72]- -116,x[84]+=x[22]- -2370;break}else{x[5]+=x[71]- -26,x[15]+=x[44]- -2819,x[23]+=x[16]-2451,x[25]+=x[27]- -569,x[38]+=x[79]-170,x[43]+=x[82]-1818,x[51]+=x[13]-589,x[67]+=x[36]- -1219,x[72]+=x[37]- -466,x[78]+=x[0]- -341,x[84]+=x[29]-25;break}if(x[x[53]+-880]<-(x[6]+-436)){x[5]+=x[60]- -471,x[15]+=x[50]-1580,x[23]+=x[38]-34,x[25]+=x[72]-372,x[38]+=x[43]-1159,x[43]+=x[70]-1459,x[51]+=x[9]-38,x[67]+=x[24]- -299,x[72]+=x[36]- -1355,x[78]+=x[52]- -62,x[84]+=x[38]- -2665;break}}}}D([...C(0,5),831,...C(6,15),968,...C(16,23),279,183,514,...C(26,38),922,...C(39,43),-80,...C(44,51),336,...C(52,67),-3626,...C(68,72),740,...C(73,78),877,...C(79,84),-813,...C(85,89)]); \ No newline at end of file diff --git a/test/vm/jsconfuser-vm/integration/cli.test.js b/test/vm/jsconfuser-vm/integration/cli.test.js new file mode 100644 index 00000000..87c528f1 --- /dev/null +++ b/test/vm/jsconfuser-vm/integration/cli.test.js @@ -0,0 +1,299 @@ +import crypto from 'node:crypto' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, test } from 'vitest' +import { + assertDerivedStateConsistency, + assertNoVmResidue, + parseSuccessfulOutput, +} from './harness.js' +import { diagnoseStandalone } from '../../../../src/vm/jsconfuser-vm/decode-standalone.js' + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const packageRoot = path.resolve(testDirectory, '../../../..') +const repositoryRoot = packageRoot +const evidenceRoot = fs.mkdtempSync(path.join(tmpdir(), 'jsconfuser-vm-cli-')) +const artifactRoot = path.join(evidenceRoot, 'cli-artifacts') +const successInput = path.join( + repositoryRoot, + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js', +) +const legacyInput = path.join( + packageRoot, + 'test/visitor/calculate-constant-exp/non-literal-left.js', +) + +fs.mkdirSync(artifactRoot, { recursive: true }) + +function sha256(source) { + return crypto.createHash('sha256').update(source).digest('hex') +} + +function createCase(name) { + const directory = path.join(artifactRoot, name) + fs.rmSync(directory, { force: true, recursive: true }) + fs.mkdirSync(directory, { recursive: true }) + return directory +} + +function runCli(args) { + const completed = spawnSync(process.execPath, ['src/main.js', ...args], { + cwd: packageRoot, + encoding: 'utf8', + }) + if (completed.error) throw completed.error + return completed +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function assertDeclinedAttempt({ input, outputPath, resultPath, completed }) { + expect(completed.status).toBe(0) + expect(completed.stdout).toContain('Status: declined') + expect(completed.stderr).toContain('Diagnostic: container-declined') + + const output = fs.readFileSync(outputPath, 'utf8') + const record = readJson(resultPath) + expect(output).toBe(input) + expect(sha256(output)).toBe(sha256(input)) + expect(record).toMatchObject({ + schemaVersion: 'jsconfuser-vm-adapter.v1', + ok: false, + status: 'declined', + input, + output: input, + result: null, + diagnostic: { + code: 'container-declined', + message: expect.any(String), + }, + }) +} + +test('subprocess success writes fresh output and the explicit adapter result', () => { + const directory = createCase('success-explicit-result') + const outputPath = path.join(directory, 'decoded.js') + const resultPath = path.join(directory, 'decoded.json') + const completed = runCli([ + '-t', + 'jsconfuser-vm', + '-i', + successInput, + '-o', + outputPath, + '--result', + resultPath, + ]) + const input = fs.readFileSync(successInput, 'utf8') + const output = fs.readFileSync(outputPath, 'utf8') + const record = readJson(resultPath) + + expect(completed.status).toBe(0) + expect(completed.stdout).toContain('Status: success') + expect(completed.stdout).toContain(`Output written: ${outputPath}`) + expect(completed.stdout).toContain(`Result written: ${resultPath}`) + expect(completed.stderr).toBe('') + expect(output).not.toBe(input) + expect(record).toMatchObject({ + schemaVersion: 'jsconfuser-vm-adapter.v1', + ok: true, + status: 'success', + input, + output, + diagnostic: null, + result: { + schemaVersion: 'jsconfuser-vm-standalone.v1', + packet: 'N', + parseOnly: true, + targetExecution: false, + vmExecuted: false, + emittedJavaScript: true, + proof: { + allPredecessorsReconstructed: true, + noTargetExecution: true, + noVmRuntimeEmission: true, + freshJavaScriptEmission: true, + }, + }, + }) + expect(record.output).toBe(output) + const direct = diagnoseStandalone(input) + expect(direct.ok).toBe(true) + expect(record.result).toEqual(direct.result) + + const ast = parseSuccessfulOutput(output) + expect(assertNoVmResidue(ast).ok).toBe(true) + expect(assertDerivedStateConsistency(ast, output).ok).toBe(true) +}) + +test('subprocess ordinary decline writes exact output and the default sidecar result', () => { + const directory = createCase('ordinary-decline-default-result') + const inputPath = path.join(directory, 'input.js') + const outputPath = path.join(directory, 'declined.js') + const resultPath = `${outputPath}.result.json` + const input = 'const answer = 42;\nwindow.TEST_OUTPUT = answer;\n' + fs.writeFileSync(inputPath, input) + + const completed = runCli([ + '-t', + 'jsconfuser-vm', + '-i', + inputPath, + '-o', + outputPath, + ]) + + assertDeclinedAttempt({ input, outputPath, resultPath, completed }) + expect(completed.stdout).toContain(`Result written: ${resultPath}`) +}) + +test('subprocess hardened decline remains exact through an explicit result path', () => { + const directory = createCase('hardened-decline-explicit-result') + const inputPath = path.join(directory, 'input.js') + const outputPath = path.join(directory, 'declined.js') + const resultPath = path.join(directory, 'declined.json') + const encoded = fs.readFileSync(successInput, 'utf8') + const input = encoded.replace( + 'var ENCODE_BYTECODE = false;', + 'var ENCODE_BYTECODE = true;', + ) + expect(input).not.toBe(encoded) + fs.writeFileSync(inputPath, input) + + const completed = runCli([ + '-t', + 'jsconfuser-vm', + '-i', + inputPath, + '-o', + outputPath, + '--result', + resultPath, + ]) + + assertDeclinedAttempt({ input, outputPath, resultPath, completed }) +}) + +test('rejects a result path that aliases the output before writing either file', () => { + const directory = createCase('result-output-alias') + const outputPath = path.join(directory, 'decoded.js') + const aliasPath = path.join(directory, 'nested', '..', 'decoded.js') + const sentinel = 'output must remain untouched\n' + fs.writeFileSync(outputPath, sentinel) + + const completed = runCli([ + '-t', + 'jsconfuser-vm', + '-i', + successInput, + '-o', + outputPath, + '--result', + aliasPath, + ]) + + expect(completed.status).toBe(1) + expect(completed.stderr).toContain( + 'Output and result files must be different', + ) + expect(fs.readFileSync(outputPath, 'utf8')).toBe(sentinel) +}) + +test('unknown target keeps the established non-throwing error path', () => { + const directory = createCase('unknown-target') + const outputPath = path.join(directory, 'unknown.js') + const resultPath = path.join(directory, 'unknown.json') + const completed = runCli([ + '-t', + 'not-a-target', + '-i', + legacyInput, + '-o', + outputPath, + '--result', + resultPath, + ]) + + expect(completed.status).toBe(1) + expect(completed.stdout).toContain('Type: not-a-target') + expect(completed.stderr).toContain('Unknown type: not-a-target') + expect(fs.existsSync(outputPath)).toBe(false) + expect(fs.existsSync(resultPath)).toBe(false) +}) + +test('missing VM input keeps the established read error path', () => { + const directory = createCase('missing-input') + const inputPath = path.join(directory, 'missing.js') + const outputPath = path.join(directory, 'missing-output.js') + const resultPath = path.join(directory, 'missing-result.json') + const completed = runCli([ + '-t', + 'jsconfuser-vm', + '-i', + inputPath, + '-o', + outputPath, + '--result', + resultPath, + ]) + + expect(completed.status).toBe(1) + expect(completed.stderr).toContain(`Cannot read input file ${inputPath}`) + expect(fs.existsSync(outputPath)).toBe(false) + expect(fs.existsSync(resultPath)).toBe(false) +}) + +test('output write failure keeps the result path unwritten', () => { + const directory = createCase('output-write-failure') + const inputPath = path.join(directory, 'input.js') + const outputPath = path.join(directory, 'missing', 'output.js') + const resultPath = path.join(directory, 'result.json') + fs.writeFileSync(inputPath, 'const answer = 42;\n') + + const completed = runCli([ + '-t', + 'jsconfuser-vm', + '-i', + inputPath, + '-o', + outputPath, + '--result', + resultPath, + ]) + + expect(completed.status).toBe(1) + expect(completed.stderr).toContain(`Cannot write output file ${outputPath}`) + expect(fs.existsSync(outputPath)).toBe(false) + expect(fs.existsSync(resultPath)).toBe(false) +}) + +test('existing common target preserves its pre-change output and no sidecar', () => { + const directory = createCase('legacy-common') + const outputPath = path.join(directory, 'common.js') + const completed = runCli([ + '-t', + 'common', + '-i', + legacyInput, + '-o', + outputPath, + ]) + const output = fs.readFileSync(outputPath, 'utf8') + const baseline = fs.readFileSync( + path.join( + packageRoot, + 'test/vm/jsconfuser-vm/fixtures/legacy-common/common.js', + ), + 'utf8', + ) + + expect(completed.status).toBe(0) + expect(completed.stdout).toContain(`Output written: ${outputPath}`) + expect(output).toBe(baseline) + expect(fs.existsSync(`${outputPath}.result.json`)).toBe(false) +}) diff --git a/test/vm/jsconfuser-vm/integration/harness.js b/test/vm/jsconfuser-vm/integration/harness.js new file mode 100644 index 00000000..496f2ab1 --- /dev/null +++ b/test/vm/jsconfuser-vm/integration/harness.js @@ -0,0 +1,690 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' +import { expectConsistentState } from '../../../helper.js' + +const moduleDirectory = path.dirname(fileURLToPath(import.meta.url)) +const repositoryRoot = path.resolve(moduleDirectory, '../../../..') +const corpusRoot = path.join( + repositoryRoot, + 'test/vm/jsconfuser-vm/fixtures/corpus', +) +const evidenceRoot = fs.mkdtempSync( + path.join(tmpdir(), 'jsconfuser-vm-harness-'), +) +const attemptRoot = path.join(evidenceRoot, 'attempts') + +const parserOptions = { + allowReturnOutsideFunction: true, + errorRecovery: false, + sourceType: 'script', +} + +const vmRoleNames = new Set(['Closure', 'VM', 'Upvalue', 'decodeBytecode']) +const wordcodeRoleNames = new Set([ + 'BYTECODE', + 'CONSTANTS', + 'ENCODE_BYTECODE', + 'MAIN_REG_COUNT', + 'MAIN_START_PC', + 'OP', + 'SENTINELS', + 'SLOTS', +]) +const runtimeRoleNames = new Set([ + 'FRAME_START', + 'HEADER_SIZE', + 'TIMING_CHECKS', + 'decodeBytecode', + 'Closure', + 'VM', + 'Upvalue', +]) + +export class HarnessError extends Error { + constructor(code, message, details = {}) { + super(message) + this.name = 'HarnessError' + this.code = code + this.details = details + } +} + +function fail(code, message, details = {}) { + throw new HarnessError(code, message, details) +} + +function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) + return value + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +function safeSegment(value, label) { + if ( + typeof value !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) + ) { + fail('unsafe-path-segment', `${label} is not a safe path segment`, { + value, + }) + } + return value +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) + } catch (error) { + fail('fixture-read', `Could not read JSON fixture ${filePath}`, { + cause: error.message, + filePath, + }) + } +} + +function parseSource(source, label, code = 'parse-input') { + try { + return parse(source, parserOptions) + } catch (error) { + fail(code, `Could not parse ${label}`, { cause: error.message }) + } +} + +function getInitializers(ast, name) { + const initializers = [] + traverse(ast, { + VariableDeclarator(path) { + if (path.node.id.type === 'Identifier' && path.node.id.name === name) { + initializers.push(path.node.init) + } + }, + }) + return initializers +} + +function getSingleInitializer(ast, name) { + const initializers = getInitializers(ast, name) + if (initializers.length !== 1) { + fail('container-role', `Expected one ${name} declaration`, { + count: initializers.length, + name, + }) + } + return initializers[0] +} + +export function parseNumericContainer(source) { + const ast = parseSource(source, 'encoded.js') + const encodeBytecode = getSingleInitializer(ast, 'ENCODE_BYTECODE') + if (!encodeBytecode || encodeBytecode.type !== 'BooleanLiteral') { + fail('container-role', 'ENCODE_BYTECODE is not a boolean literal') + } + if (encodeBytecode.value === true) { + fail( + 'unsupported-encoded-bytecode', + 'ENCODE_BYTECODE=true is an explicit decline for the numeric-wordcode harness', + ) + } + + const bytecode = getSingleInitializer(ast, 'BYTECODE') + if (!bytecode || bytecode.type !== 'ArrayExpression') { + fail('container-role', 'BYTECODE is not an array expression') + } + const words = bytecode.elements.map((element, index) => { + if ( + !element || + element.type !== 'NumericLiteral' || + !Number.isSafeInteger(element.value) || + element.value < 0 || + element.value > 0xffffffff + ) { + fail( + 'non-numeric-wordcode', + `BYTECODE word ${index} is not an unsigned 32-bit integer`, + { + index, + }, + ) + } + return element.value + }) + + return { bytecode: Object.freeze(words), encodeBytecode: false } +} + +function startsWithLeadingDebugComment(source) { + const firstLineEnd = source.indexOf('\n') + const firstLine = firstLineEnd === -1 ? source : source.slice(0, firstLineEnd) + return firstLine.startsWith('//') +} + +function stripLeadingDebugComments(source) { + let offset = 0 + while (offset < source.length) { + const lineEnd = source.indexOf('\n', offset) + const line = + lineEnd === -1 ? source.slice(offset) : source.slice(offset, lineEnd) + if (!line.startsWith('//')) break + offset = lineEnd === -1 ? source.length : lineEnd + 1 + } + return source.slice(offset) +} + +function sha256(source) { + return crypto.createHash('sha256').update(source).digest('hex') +} + +function stableCellPaths(root, kind, cellId) { + const relativeDirectory = path.join( + 'test/vm/jsconfuser-vm/fixtures/corpus', + kind, + cellId, + ) + const directory = path.join(root, relativeDirectory) + return deepFreeze({ + directory, + encoded: path.join(directory, 'encoded.js'), + oracle: path.join(directory, 'oracle.json'), + source: path.join(directory, 'source.js'), + }) +} + +export function loadVmCell( + cellId, + { kind = 'raw', root = repositoryRoot } = {}, +) { + safeSegment(cellId, 'cellId') + if (kind !== 'raw' && kind !== 'focused') { + fail( + 'unsupported-corpus-kind', + `Unsupported js-confuser-vm corpus kind: ${kind}`, + { kind }, + ) + } + + const paths = stableCellPaths(root, kind, cellId) + const encodedSource = fs.readFileSync(paths.encoded, 'utf8') + if (startsWithLeadingDebugComment(encodedSource)) { + fail( + 'debug-input', + 'The harness accepts only comment-stripped encoded.js input', + ) + } + const source = fs.readFileSync(paths.source, 'utf8') + const oracle = readJson(paths.oracle) + if (oracle.cellId !== cellId) { + fail( + 'fixture-metadata', + 'Oracle metadata does not match the requested cell', + { + cellId, + oracleCellId: oracle.cellId, + }, + ) + } + const container = parseNumericContainer(encodedSource) + + return deepFreeze({ + bytecode: container.bytecode, + cellId, + corpusKind: kind, + encodedSource, + oracle, + paths, + source, + }) +} + +export function parseSuccessfulOutput(output) { + if (typeof output !== 'string') { + fail('non-string-output', 'Successful recovery output must be a string') + } + return parseSource(output, 'recovered output', 'parse-output') +} + +function addFinding(findings, bucket, name) { + findings[bucket].add(name) +} + +export function findVmResidue(ast) { + const findings = { + runtime: new Set(), + vm: new Set(), + wordcode: new Set(), + } + + traverse(ast, { + Identifier(path) { + const name = path.node.name + if (vmRoleNames.has(name)) addFinding(findings, 'vm', name) + if (wordcodeRoleNames.has(name)) addFinding(findings, 'wordcode', name) + if (runtimeRoleNames.has(name)) addFinding(findings, 'runtime', name) + }, + MemberExpression(path) { + if ( + !path.node.computed && + path.node.object.type === 'Identifier' && + path.node.object.name === 'vm' && + path.node.property.type === 'Identifier' && + path.node.property.name === 'run' + ) { + addFinding(findings, 'runtime', 'vm.run') + } + }, + NewExpression(path) { + if ( + path.node.callee.type === 'Identifier' && + path.node.callee.name === 'VM' + ) { + addFinding(findings, 'runtime', 'new VM') + } + }, + }) + + return { + runtime: [...findings.runtime].sort(), + vm: [...findings.vm].sort(), + wordcode: [...findings.wordcode].sort(), + } +} + +export function checkVmResidue(astOrSource) { + const ast = + typeof astOrSource === 'string' + ? parseSuccessfulOutput(astOrSource) + : astOrSource + if (!ast || typeof ast !== 'object') + fail('invalid-ast', 'VM residue check needs a Babel AST or source') + const findings = findVmResidue(ast) + return { + ...findings, + ok: Object.values(findings).every((bucket) => bucket.length === 0), + } +} + +export function assertNoVmResidue(astOrSource) { + const findings = checkVmResidue(astOrSource) + if (!findings.ok) { + fail( + 'vm-residue', + 'Recovered output still contains VM, wordcode, or runtime residue', + findings, + ) + } + return findings +} + +export function assertDerivedStateConsistency( + ast, + output, + parseOptions = parserOptions, +) { + try { + expectConsistentState(ast, output, parseOptions) + } catch (error) { + fail( + 'derived-state', + 'Recovered AST derived state is inconsistent with its output', + { + cause: error.message, + }, + ) + } + return { ok: true } +} + +function validateRecoveryResult(result) { + if (result === null) return { output: null, status: 'declined' } + if (result === undefined) + fail( + 'undefined-recovery', + 'Decoder returned undefined instead of string or null', + ) + if (typeof result !== 'string') { + if (typeof result === 'object' || typeof result === 'function') { + fail( + 'malformed-recovery', + 'Decoder returned a malformed recovery result', + { + type: typeof result, + }, + ) + } + fail( + 'non-string-output', + 'Decoder returned a non-string, non-null recovery result', + { + type: typeof result, + }, + ) + } + return { output: result, status: 'success' } +} + +function invokeDecoder(decoder, input, context) { + if (typeof decoder !== 'function') { + fail( + 'missing-decoder', + 'A standalone decoder/recovery function must be injected', + ) + } + try { + return validateRecoveryResult(decoder(input, context)) + } catch (error) { + if (error instanceof HarnessError) throw error + fail( + 'decoder-threw', + 'Injected decoder threw before producing a recovery result', + { + cause: error.message, + }, + ) + } +} + +function canonicalRecovery(result) { + if (result.output === null) return result + return { + output: stripLeadingDebugComments(result.output), + status: result.status, + } +} + +export function inspectDebugCommentDependence(decoder, input) { + const context = Object.freeze({ + purpose: 'synthetic-debug-comment-independence-check', + }) + const plain = invokeDecoder(decoder, input, context) + const syntheticDebugInput = `// __A0_SYNTHETIC_DEBUG_COMMENT__\n${input}` + const withDebug = invokeDecoder(decoder, syntheticDebugInput, context) + const plainCanonical = canonicalRecovery(plain) + const debugCanonical = canonicalRecovery(withDebug) + const dependent = + plainCanonical.status !== debugCanonical.status || + plainCanonical.output !== debugCanonical.output + return { + dependent, + inputWasCommentStripped: !startsWithLeadingDebugComment(input), + syntheticDebugInput, + } +} + +export function assertDebugCommentIndependent(decoder, input) { + const check = inspectDebugCommentDependence(decoder, input) + if (check.dependent) { + fail( + 'debug-comment-dependence', + 'Decoder result changes when a synthetic debug comment is added', + ) + } + return check +} + +export async function runBoundedBehavioralOracle( + oracle, + request, + { timeoutMs = 2000 } = {}, +) { + if (typeof oracle !== 'function') { + fail( + 'missing-oracle', + 'Behavioral oracle seam needs a function when enabled', + ) + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + fail( + 'invalid-oracle-timeout', + 'Behavioral oracle timeout must be a positive integer', + { timeoutMs }, + ) + } + + const controller = new AbortController() + let timer + let timedOut = false + const oraclePromise = Promise.resolve().then(() => + oracle(request, controller.signal), + ) + const timeoutPromise = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true + controller.abort() + resolve({ kind: 'timeout' }) + }, timeoutMs) + }) + const settledOracle = oraclePromise.then( + (value) => ({ kind: 'value', value }), + (error) => ({ kind: 'error', error }), + ) + const winner = await Promise.race([settledOracle, timeoutPromise]) + clearTimeout(timer) + + if (winner.kind === 'timeout') return { status: 'timed-out', timedOut: true } + if (winner.kind === 'error') { + return { cause: winner.error.message, status: 'failed', timedOut } + } + if (winner.value === true || (winner.value && winner.value.ok === true)) { + return { status: 'passed', timedOut } + } + return { status: 'failed', value: winner.value, timedOut } +} + +function attemptPathsFor(cell, name, root = repositoryRoot) { + safeSegment(cell.cellId, 'cellId') + safeSegment(name, 'attempt name') + const directory = path.join( + attemptRootFor(root), + cell.corpusKind, + cell.cellId, + ) + return { + directory, + output: path.join(directory, `${name}.js`), + result: path.join(directory, `${name}.json`), + } +} + +function attemptRootFor(root) { + return root === repositoryRoot + ? attemptRoot + : path.join(root, 'jsconfuser-vm-attempts') +} + +export function checkAttempt(attempt) { + if (!attempt || !attempt.outputPath || !attempt.resultPath) { + fail('invalid-attempt', 'Attempt check needs a result and output path') + } + let output + let recorded + try { + output = fs.readFileSync(attempt.outputPath, 'utf8') + recorded = readJson(attempt.resultPath) + } catch (error) { + if (error instanceof HarnessError) throw error + fail('attempt-read', 'Could not read named attempt artifacts', { + cause: error.message, + }) + } + if (attempt.status === 'declined' && output !== attempt.input) { + fail( + 'decline-output-mismatch', + 'Declined attempt output is not byte-for-byte original input', + ) + } + if (output !== attempt.output) { + fail( + 'attempt-output-mismatch', + 'Attempt output changed after it was written', + ) + } + const expectedOutputPath = path.relative( + attempt.repositoryRoot, + attempt.outputPath, + ) + const expectedResultPath = path.relative( + attempt.repositoryRoot, + attempt.resultPath, + ) + if ( + recorded.schemaVersion !== 'a0-attempt.v1' || + recorded.attemptName !== attempt.name || + recorded.caseId !== attempt.cell.cellId || + recorded.corpusKind !== attempt.cell.corpusKind || + recorded.status !== attempt.status || + recorded.inputSha256 !== sha256(attempt.input) || + recorded.outputPath !== expectedOutputPath || + recorded.resultPath !== expectedResultPath + ) { + fail( + 'attempt-result-mismatch', + 'Named attempt result does not describe its artifacts', + ) + } + if ( + recorded.outputBytes !== Buffer.byteLength(output) || + recorded.outputSha256 !== sha256(output) + ) { + fail( + 'attempt-result-mismatch', + 'Named attempt result has the wrong output hash', + ) + } + return { + ok: true, + outputPath: attempt.outputPath, + resultPath: attempt.resultPath, + } +} + +async function writeAttempt({ + cell, + name, + input, + output, + status, + oracleResult, + repository, +}) { + const paths = attemptPathsFor(cell, name, repository) + fs.mkdirSync(paths.directory, { recursive: true }) + fs.writeFileSync(paths.output, output, 'utf8') + const record = { + schemaVersion: 'a0-attempt.v1', + attemptName: name, + caseId: cell.cellId, + corpusKind: cell.corpusKind, + inputSha256: sha256(input), + outputBytes: Buffer.byteLength(output), + outputPath: path.relative(repository, paths.output), + outputSha256: sha256(output), + resultPath: path.relative(repository, paths.result), + oracle: oracleResult + ? { + invoked: true, + status: oracleResult.status, + timedOut: oracleResult.timedOut, + } + : { invoked: false }, + status, + } + fs.writeFileSync(paths.result, `${JSON.stringify(record, null, 2)}\n`, 'utf8') + return { + cell, + input, + name, + oracleResult, + output, + outputPath: paths.output, + repositoryRoot: repository, + result: record, + resultPath: paths.result, + status, + } +} + +export async function runAttempt( + cell, + { + behavioralOracle, + decoder, + expectedStatus, + input = cell.encodedSource, + name, + oracleTimeoutMs = cell.oracle.timeoutMs ?? 2000, + root = repositoryRoot, + }, +) { + if (!cell || typeof cell.encodedSource !== 'string') { + fail('invalid-cell', 'Attempt needs a loaded js-confuser-vm cell') + } + safeSegment(name, 'attempt name') + if (typeof input !== 'string') + fail('invalid-input', 'Attempt input must be a string') + const context = Object.freeze({ + bytecode: cell.bytecode, + cell, + input, + oracle: cell.oracle, + source: cell.source, + }) + const recovery = invokeDecoder(decoder, input, context) + const output = recovery.status === 'declined' ? input : recovery.output + let ast + let oracleResult + + if (recovery.status === 'success') { + ast = parseSuccessfulOutput(output) + assertNoVmResidue(ast) + assertDerivedStateConsistency(ast, output) + if (behavioralOracle) { + oracleResult = await runBoundedBehavioralOracle( + behavioralOracle, + Object.freeze({ + metadata: cell.oracle, + recoveredSource: output, + source: cell.source, + }), + { timeoutMs: oracleTimeoutMs }, + ) + if (oracleResult.status !== 'passed') { + fail( + 'behavioral-oracle', + 'Behavioral oracle did not complete successfully', + oracleResult, + ) + } + } + } + + if (expectedStatus && expectedStatus !== recovery.status) { + fail( + 'unexpected-status', + `Expected ${expectedStatus} but recovery was ${recovery.status}`, + ) + } + const attempt = await writeAttempt({ + cell, + input, + name, + oracleResult, + output, + repository: root, + status: recovery.status, + }) + checkAttempt(attempt) + return { ...attempt, ast } +} + +export const paths = deepFreeze({ + attemptRoot, + corpusRoot, + evidenceRoot, + repositoryRoot, +}) diff --git a/test/vm/jsconfuser-vm/integration/harness.test.js b/test/vm/jsconfuser-vm/integration/harness.test.js new file mode 100644 index 00000000..3794e954 --- /dev/null +++ b/test/vm/jsconfuser-vm/integration/harness.test.js @@ -0,0 +1,203 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { parse } from '@babel/parser' +import traverse from '@babel/traverse' +import { + assertDebugCommentIndependent, + assertDerivedStateConsistency, + checkAttempt, + checkVmResidue, + findVmResidue, + loadVmCell, + parseNumericContainer, + parseSuccessfulOutput, + runAttempt, + runBoundedBehavioralOracle, +} from './harness.js' + +const rawCell = loadVmCell('f-literals-order') +const focusedCell = loadVmCell('focused-call-method-spread', { + kind: 'focused', +}) + +test('loads only stable raw/focused comment-stripped numeric fixtures', () => { + expect(rawCell.paths.encoded).toMatch(/encoded\.js$/) + expect(rawCell.paths).not.toHaveProperty('encodedDebug') + expect(rawCell.encodedSource.startsWith('//')).toBe(false) + expect( + rawCell.bytecode.every((word) => Number.isSafeInteger(word) && word >= 0), + ).toBe(true) + expect(focusedCell.corpusKind).toBe('focused') + expect(focusedCell.oracle.cellId).toBe('focused-call-method-spread') + expect(focusedCell.bytecode).toHaveLength(88) +}) + +test('rejects the explicitly declined encoded-bytecode mode', () => { + const encodedInput = focusedCell.encodedSource.replace( + 'var ENCODE_BYTECODE = false;', + 'var ENCODE_BYTECODE = true;', + ) + expect(() => parseNumericContainer(encodedInput)).toThrowError( + expect.objectContaining({ code: 'unsupported-encoded-bytecode' }), + ) +}) + +test('writes and checks named successful attempts, then invokes the oracle seam', async () => { + const oracleCalls = [] + const attempt = await runAttempt(focusedCell, { + behavioralOracle: ({ metadata, recoveredSource, source }, signal) => { + oracleCalls.push({ metadata, recoveredSource, signal, source }) + return { ok: true } + }, + decoder: () => 'window.TEST_OUTPUT = 5;', + name: 'success-with-oracle', + }) + + expect(attempt.status).toBe('success') + expect(attempt.result.attemptName).toBe('success-with-oracle') + expect(attempt.outputPath).toMatch( + /jsconfuser-vm-harness-[^/]+\/attempts\/focused\/focused-call-method-spread\/success-with-oracle\.js$/, + ) + expect(attempt.oracleResult.status).toBe('passed') + expect(oracleCalls).toHaveLength(1) + expect(oracleCalls[0].metadata.expected).toEqual([1, 2, 3, 4, 5]) + expect(checkAttempt(attempt).ok).toBe(true) + expect(fs.readFileSync(attempt.outputPath, 'utf8')).toBe( + 'window.TEST_OUTPUT = 5;', + ) +}) + +test('writes an exact unchanged output for a decline and never invokes the oracle', async () => { + let oracleCalled = false + const attempt = await runAttempt(rawCell, { + behavioralOracle: () => { + oracleCalled = true + return true + }, + decoder: () => null, + name: 'decline-exact', + }) + + expect(attempt.status).toBe('declined') + expect(attempt.output).toBe(rawCell.encodedSource) + expect(attempt.result.oracle.invoked).toBe(false) + expect(oracleCalled).toBe(false) + expect(checkAttempt(attempt).ok).toBe(true) +}) + +test('runs the behavioral seam only after output parsing and structural checks', async () => { + let oracleCalled = false + await expect( + runAttempt(focusedCell, { + behavioralOracle: () => { + oracleCalled = true + return true + }, + decoder: () => `${focusedCell.encodedSource}\nvar partial = true;`, + name: 'partial-recovery', + }), + ).rejects.toMatchObject({ code: 'vm-residue' }) + expect(oracleCalled).toBe(false) +}) + +test.each([ + ['missing decoder', undefined, 'missing-decoder'], + [ + 'malformed result', + () => ({ output: 'window.TEST_OUTPUT = 1;' }), + 'malformed-recovery', + ], + ['undefined recovery', () => undefined, 'undefined-recovery'], + ['non-string output', () => 42, 'non-string-output'], + ['malformed JavaScript output', () => 'var =', 'parse-output'], +])('detects %s before writing an attempt', async (_label, decoder, code) => { + await expect( + runAttempt(focusedCell, { decoder, name: `negative-${code}` }), + ).rejects.toMatchObject({ code }) +}) + +test('detects an altered decline artifact instead of accepting it', async () => { + const attempt = await runAttempt(rawCell, { + decoder: () => null, + name: 'altered-decline', + }) + fs.writeFileSync(attempt.outputPath, 'altered decline', 'utf8') + expect(() => checkAttempt(attempt)).toThrowError( + expect.objectContaining({ code: 'decline-output-mismatch' }), + ) +}) + +test('ordinary JavaScript near-miss declines and is not classified as VM residue', async () => { + const ordinarySource = 'const answer = 42;\nwindow.TEST_OUTPUT = answer;\n' + const attempt = await runAttempt(rawCell, { + decoder: () => null, + expectedStatus: 'declined', + input: ordinarySource, + name: 'ordinary-javascript-near-miss', + }) + const ast = parseSuccessfulOutput(ordinarySource) + expect(checkVmResidue(ast).ok).toBe(true) + expect(attempt.output).toBe(ordinarySource) +}) + +test('the debug-comment check uses a synthetic comment and catches dependence', () => { + expect(() => + assertDebugCommentIndependent(() => null, rawCell.encodedSource), + ).not.toThrow() + expect(() => + assertDebugCommentIndependent( + (source) => + source.includes('__A0_SYNTHETIC_DEBUG_COMMENT__') ? 'changed' : null, + rawCell.encodedSource, + ), + ).toThrowError(expect.objectContaining({ code: 'debug-comment-dependence' })) +}) + +test('the derived-state detector catches a detached cached reference', () => { + const ast = parse('var value = 1; function read() { return value; }') + traverse(ast, { + Program(path) { + path.scope.crawl() + }, + }) + traverse(ast, { + FunctionDeclaration(path) { + path.node.body.body = [] + }, + }) + expect(() => assertDerivedStateConsistency(ast)).toThrowError( + expect.objectContaining({ code: 'derived-state' }), + ) +}) + +test('the bounded oracle seam aborts a finite delayed callback without target execution', async () => { + let aborted = false + const outcome = await runBoundedBehavioralOracle( + (_request, signal) => + new Promise((resolve) => { + const timer = setTimeout(() => resolve(true), 50) + signal.addEventListener( + 'abort', + () => { + aborted = true + clearTimeout(timer) + resolve(false) + }, + { once: true }, + ) + }), + {}, + { timeoutMs: 5 }, + ) + expect(outcome).toMatchObject({ status: 'timed-out', timedOut: true }) + expect(aborted).toBe(true) +}) + +test('the residue detector has a positive control and a clean control', () => { + const clean = parseSuccessfulOutput('window.TEST_OUTPUT = 1;') + const residual = parseSuccessfulOutput( + 'var BYTECODE = [1]; var CONSTANTS = [];', + ) + expect(findVmResidue(clean)).toEqual({ runtime: [], vm: [], wordcode: [] }) + expect(findVmResidue(residual).wordcode).toEqual(['BYTECODE', 'CONSTANTS']) +}) diff --git a/test/vm/jsconfuser-vm/integration/integration.test.js b/test/vm/jsconfuser-vm/integration/integration.test.js new file mode 100644 index 00000000..b1b74fa0 --- /dev/null +++ b/test/vm/jsconfuser-vm/integration/integration.test.js @@ -0,0 +1,62 @@ +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { expect, test } from 'vitest' +import { diagnoseStandalone } from '../../../../src/vm/jsconfuser-vm/decode-standalone.js' +import { assertNoVmResidue, parseSuccessfulOutput } from './harness.js' + +const packageRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const fixtureRoot = new URL( + '../fixtures/corpus/raw/f-branching/', + import.meta.url, +) + +test('registered CLI preserves a structured branch and matches direct diagnosis', () => { + const inputPath = fileURLToPath(new URL('encoded.js', fixtureRoot)) + const input = fs.readFileSync(inputPath, 'utf8') + const expected = JSON.parse( + fs.readFileSync(new URL('oracle.json', fixtureRoot), 'utf8'), + ).expected + const directory = fs.mkdtempSync(path.join(tmpdir(), 'jsconfuser-vm-branch-')) + try { + const outputPath = path.join(directory, 'decoded.js') + const resultPath = path.join(directory, 'result.json') + const completed = spawnSync( + process.execPath, + [ + 'src/main.js', + '-t', + 'jsconfuser-vm', + '-i', + inputPath, + '-o', + outputPath, + '--result', + resultPath, + ], + { cwd: packageRoot, encoding: 'utf8', timeout: 30000 }, + ) + expect(completed.error).toBeUndefined() + expect(completed.status).toBe(0) + + const output = fs.readFileSync(outputPath, 'utf8') + const record = JSON.parse(fs.readFileSync(resultPath, 'utf8')) + const direct = diagnoseStandalone(input) + expect(direct.ok).toBe(true) + expect(record.result).toEqual(direct.result) + expect(record.output).toBe(output) + expect(output).toContain('if (') + expect(output).not.toContain('__recovered_dispatch') + expect(assertNoVmResidue(parseSuccessfulOutput(output)).ok).toBe(true) + + const context = { TEST_OUTPUT: null } + context.window = context + runInNewContext(output, context, { timeout: 2000 }) + expect(context.TEST_OUTPUT).toEqual(expected) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/test/vm/jsconfuser-vm/integration/plugin.test.js b/test/vm/jsconfuser-vm/integration/plugin.test.js new file mode 100644 index 00000000..a2f24364 --- /dev/null +++ b/test/vm/jsconfuser-vm/integration/plugin.test.js @@ -0,0 +1,144 @@ +import crypto from 'node:crypto' +import { beforeEach, expect, test, vi } from 'vitest' + +const { diagnoseStandaloneSpy } = vi.hoisted(() => ({ + diagnoseStandaloneSpy: vi.fn(), +})) + +vi.mock('../../../../src/vm/jsconfuser-vm/decode-standalone.js', async () => { + const actual = await vi.importActual( + '../../../../src/vm/jsconfuser-vm/decode-standalone.js', + ) + diagnoseStandaloneSpy.mockImplementation(actual.diagnoseStandalone) + return { ...actual, diagnoseStandalone: diagnoseStandaloneSpy } +}) + +import decodeJsconfuserVm from '../../../../src/plugin/jsconfuser-vm.js' +import { + assertDerivedStateConsistency, + assertNoVmResidue, + loadVmCell, + parseSuccessfulOutput, +} from './harness.js' + +const rawCell = loadVmCell('f-literals-order') +const focusedCell = loadVmCell('focused-call-method-spread', { + kind: 'focused', +}) + +function sha256(source) { + return crypto.createHash('sha256').update(source).digest('hex') +} + +function expectSingleDiagnosis(input) { + expect(diagnoseStandaloneSpy).toHaveBeenCalledTimes(1) + expect(diagnoseStandaloneSpy).toHaveBeenCalledWith(input) +} + +beforeEach(() => { + diagnoseStandaloneSpy.mockClear() +}) + +test.each([ + ['reference', rawCell], + ['focused', focusedCell], +])( + 'returns fresh emitted JavaScript and standalone result for the %s fixture', + (_label, cell) => { + const adapterResult = decodeJsconfuserVm(cell.encodedSource) + expectSingleDiagnosis(cell.encodedSource) + + expect(adapterResult).toMatchObject({ + schemaVersion: 'jsconfuser-vm-adapter.v1', + ok: true, + status: 'success', + input: cell.encodedSource, + diagnostic: null, + }) + expect(adapterResult.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-standalone.v1', + packet: 'N', + parseOnly: true, + targetExecution: false, + vmExecuted: false, + emittedJavaScript: true, + finalJavaScriptSemantics: false, + proof: { + allPredecessorsReconstructed: true, + noTargetExecution: true, + noVmRuntimeEmission: true, + freshJavaScriptEmission: true, + }, + }) + expect(adapterResult.output).toBe(adapterResult.result.output) + expect(adapterResult.output).not.toBe(cell.encodedSource) + expect(Object.isFrozen(adapterResult)).toBe(true) + + const ast = parseSuccessfulOutput(adapterResult.output) + expect(assertNoVmResidue(ast).ok).toBe(true) + expect(assertDerivedStateConsistency(ast, adapterResult.output).ok).toBe( + true, + ) + }, +) + +test.each([ + [ + 'ordinary JavaScript near-miss', + 'const answer = 42;\nwindow.TEST_OUTPUT = answer;\n', + 'container-declined', + ], + [ + 'malformed truncated fixture', + rawCell.encodedSource.slice(0, -20), + 'container-declined', + ], + [ + 'explicit hardening decline', + rawCell.encodedSource.replace( + 'var ENCODE_BYTECODE = false;', + 'var ENCODE_BYTECODE = true;', + ), + 'container-declined', + ], +])( + 'returns exact input and stable diagnostic for %s', + (_label, input, diagnosticCode) => { + const adapterResult = decodeJsconfuserVm(input) + expectSingleDiagnosis(input) + + expect(adapterResult).toMatchObject({ + schemaVersion: 'jsconfuser-vm-adapter.v1', + ok: false, + status: 'declined', + input, + output: input, + result: null, + diagnostic: { + code: diagnosticCode, + message: expect.any(String), + }, + }) + expect(adapterResult.output).toBe(input) + expect(Buffer.byteLength(adapterResult.output)).toBe( + Buffer.byteLength(input), + ) + expect(sha256(adapterResult.output)).toBe(sha256(input)) + expect(Object.isFrozen(adapterResult)).toBe(true) + }, +) + +test('does not depend on a leading debug comment', () => { + const debugInput = `// __S4_A_SYNTHETIC_DEBUG_COMMENT__\n${focusedCell.encodedSource}` + const plain = decodeJsconfuserVm(focusedCell.encodedSource) + diagnoseStandaloneSpy.mockClear() + const withDebug = decodeJsconfuserVm(debugInput) + expectSingleDiagnosis(debugInput) + + expect(withDebug.status).toBe('success') + expect(withDebug.output).toBe(plain.output) + expect(withDebug.result).toMatchObject({ + targetExecution: false, + vmExecuted: false, + }) +}) diff --git a/test/vm/jsconfuser-vm/integration/readability-metrics.js b/test/vm/jsconfuser-vm/integration/readability-metrics.js new file mode 100644 index 00000000..2a164d9b --- /dev/null +++ b/test/vm/jsconfuser-vm/integration/readability-metrics.js @@ -0,0 +1,485 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse } from '@babel/parser' +import traverseModule from '@babel/traverse' +import * as t from '@babel/types' +import { diagnoseStandalone } from '../../../../src/vm/jsconfuser-vm/decode-standalone.js' + +const traverse = traverseModule.default || traverseModule +const fixtureRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../fixtures/readability', +) +const parserOptions = { + allowReturnOutsideFunction: true, + errorRecovery: false, + sourceType: 'script', +} + +const functionTypes = new Set([ + 'ArrowFunctionExpression', + 'FunctionDeclaration', + 'FunctionExpression', +]) + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8') +} + +function digest(value) { + return crypto.createHash('sha256').update(value).digest('hex') +} + +function parseSource(source) { + return parse(source, parserOptions) +} + +function walk(node, visit, seen = new Set()) { + if (!node || typeof node.type !== 'string' || seen.has(node)) return + seen.add(node) + visit(node) + for (const key of t.VISITOR_KEYS[node.type] || []) { + const value = node[key] + if (Array.isArray(value)) { + for (const child of value) walk(child, visit, seen) + } else { + walk(value, visit, seen) + } + } +} + +function generatedFamily(name) { + if (!name.startsWith('__recovered_')) return null + if (name.startsWith('__recovered_function_')) return 'function' + if (name.startsWith('__recovered_r_')) return 'register' + if (name.startsWith('__recovered_cells_')) return 'cell' + if ( + name.startsWith('__recovered_captures') || + name.startsWith('__recovered_closure_captures_') + ) + return 'capture' + if ( + name.startsWith('__recovered_handler') || + name.startsWith('__recovered_error') + ) + return 'handler' + if ( + name.startsWith('__recovered_pc') || + name.startsWith('__recovered_dispatch') || + name.startsWith('__recovered_loop_') + ) + return 'control' + if ( + name.startsWith('__recovered_descriptor_') || + name.startsWith('__recovered_existing_') + ) + return 'descriptor' + if ( + name.startsWith('__recovered_args') || + name.startsWith('__recovered_this') + ) + return 'call-wrapper' + if (name === '__recovered_enumerable_keys') return 'helper' + return 'other' +} + +function identifierMetrics(ast) { + const namesByFamily = new Map() + let total = 0 + walk(ast, (node) => { + if (node.type !== 'Identifier') return + const family = generatedFamily(node.name) + if (!family) return + total += 1 + if (!namesByFamily.has(family)) namesByFamily.set(family, new Set()) + namesByFamily.get(family).add(node.name) + }) + const families = {} + for (const family of [...namesByFamily.keys()].sort()) { + const names = namesByFamily.get(family) + families[family] = { distinct: names.size, total: 0 } + } + walk(ast, (node) => { + if (node.type !== 'Identifier') return + const family = generatedFamily(node.name) + if (family) families[family].total += 1 + }) + return { + distinct: [...namesByFamily.values()].reduce((n, set) => n + set.size, 0), + total, + families, + } +} + +function hasGeneratedIdentifier(node) { + let found = false + walk(node, (child) => { + if (child.type === 'Identifier' && generatedFamily(child.name)) found = true + }) + return found +} + +function isStatement(node) { + return node.type.endsWith('Statement') || node.type.endsWith('Declaration') +} + +function astMetrics(ast) { + let statements = 0 + let generatedStatements = 0 + let functions = 0 + let declarations = 0 + let emptyCellDeclarations = 0 + walk(ast, (node) => { + if (isStatement(node)) { + statements += 1 + if (hasGeneratedIdentifier(node)) generatedStatements += 1 + } + if (functionTypes.has(node.type)) functions += 1 + if ( + node.type === 'VariableDeclarator' && + node.id?.type === 'Identifier' && + node.id.name.startsWith('__recovered_cells_') && + node.init?.type === 'ArrayExpression' && + node.init.elements.length === 0 + ) + emptyCellDeclarations += 1 + }) + const bindings = new Set() + const generatedBindings = [] + traverse(ast, { + Scopable(scopePath) { + for (const binding of Object.values(scopePath.scope.bindings)) { + if (bindings.has(binding)) continue + bindings.add(binding) + declarations += 1 + if (generatedFamily(binding.identifier.name)) { + generatedBindings.push({ + name: binding.identifier.name, + references: binding.referencePaths.length, + }) + } + } + }, + }) + const generatedDeclarationsByFamily = {} + for (const binding of generatedBindings) { + const family = generatedFamily(binding.name) + if (!generatedDeclarationsByFamily[family]) + generatedDeclarationsByFamily[family] = { total: 0, zeroReferences: 0 } + generatedDeclarationsByFamily[family].total += 1 + if (binding.references === 0) + generatedDeclarationsByFamily[family].zeroReferences += 1 + } + return { + statements, + generatedStatements, + functions, + declaredIdentifiers: declarations, + generatedDeclarations: { + total: generatedBindings.length, + zeroReferences: generatedBindings.filter( + ({ references }) => references === 0, + ).length, + }, + generatedDeclarationsByFamily, + emptyCellDeclarations, + } +} + +function assignmentParts(statement) { + const expression = statement?.expression + if ( + statement?.type !== 'ExpressionStatement' || + expression?.type !== 'AssignmentExpression' || + expression.operator !== '=' || + expression.left?.type !== 'Identifier' + ) + return null + return { + destination: expression.left.name, + source: + expression.right?.type === 'Identifier' ? expression.right.name : null, + } +} + +function adjacentIdentifierCopies(ast) { + let copies = 0 + walk(ast, (node) => { + if (!['Program', 'BlockStatement'].includes(node.type)) return + for (let index = 0; index + 1 < node.body.length; index += 1) { + const producer = assignmentParts(node.body[index]) + const move = assignmentParts(node.body[index + 1]) + if (producer?.destination && producer.destination === move?.source) + copies += 1 + } + }) + return copies +} + +const destinationInstructions = new Set([ + 'ADD', + 'BAND', + 'BOR', + 'BXOR', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'CALL', + 'CALL_METHOD', + 'DELETE_PROP', + 'DIV', + 'EQ', + 'EXP', + 'FOR_IN_SETUP', + 'GET_PROP', + 'GT', + 'GTE', + 'LOAD_CONST', + 'LOAD_GLOBAL', + 'LOAD_INT', + 'LOAD_THIS', + 'LOAD_UPVALUE', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'LT', + 'LTE', + 'MAKE_CLOSURE', + 'MOD', + 'MOVE', + 'MUL', + 'NEQ', + 'NEW', + 'SHL', + 'SHR', + 'SUB', + 'TYPEOF', + 'TYPEOF_SAFE', + 'UNARY_BITNOT', + 'UNARY_NEG', + 'UNARY_NOT', + 'UNARY_POS', + 'USHR', + 'VOID', +]) + +function readOperands(instruction) { + const { name, operands } = instruction + if (['STORE_GLOBAL', 'STORE_UPVALUE'].includes(name)) return [operands[1]] + if (name === 'SET_PROP' || ['DEFINE_GETTER', 'DEFINE_SETTER'].includes(name)) + return operands + if ( + ['JUMP_IF_FALSE', 'JUMP_IF_TRUE', 'JUMP_REG', 'RETURN', 'THROW'].includes( + name, + ) + ) + return [operands[0]] + if (name === 'FOR_IN_NEXT') return [operands[1]] + if (['TRY_SETUP', 'TRY_END', 'FINALLY_SETUP'].includes(name)) return [] + return destinationInstructions.has(name) ? operands.slice(1) : operands +} + +function eligibleAdjacentCopies(diagnosis) { + let copies = 0 + for (const functionRecord of diagnosis.result.control.functions) { + const instructions = functionRecord.blocks.flatMap(({ leaves }) => leaves) + const reads = new Map() + for (const instruction of instructions) { + for (const operand of readOperands(instruction)) { + if (operand.kind !== 'register') continue + reads.set(operand.index, (reads.get(operand.index) || 0) + 1) + } + } + const localCaptures = new Set() + for (const instruction of instructions) { + if (instruction.name !== 'MAKE_CLOSURE') continue + for (const operand of instruction.operands.slice(6)) { + if (operand.kind === 'local') localCaptures.add(operand.index) + } + } + for (const block of functionRecord.blocks) { + for (let index = 0; index + 1 < block.leaves.length; index += 1) { + const producer = block.leaves[index] + const move = block.leaves[index + 1] + const producerDestination = producer.operands[0] + const moveDestination = move.operands[0] + const moveSource = move.operands[1] + if ( + move.name === 'MOVE' && + destinationInstructions.has(producer.name) && + producerDestination?.kind === 'register' && + moveDestination?.kind === 'register' && + moveSource?.kind === 'register' && + producerDestination.index === moveSource.index && + producerDestination.index !== moveDestination.index && + reads.get(producerDestination.index) === 1 && + !localCaptures.has(producerDestination.index) && + !localCaptures.has(moveDestination.index) + ) + copies += 1 + } + } + } + return copies +} + +function memberParts(node) { + if ( + node?.type !== 'MemberExpression' || + node.computed || + node.object?.type !== 'Identifier' || + node.property?.type !== 'Identifier' + ) + return null + return { object: node.object.name, property: node.property.name } +} + +function operationMetrics(ast) { + const reflectCalls = {} + let computedGlobalThis = 0 + let descriptorCalls = 0 + let closureWrappers = 0 + let handlerOperations = 0 + let enumerableHelperDeclarations = 0 + let enumerableHelperCalls = 0 + let structuredFunctions = 0 + let stateMachineFunctions = 0 + + walk(ast, (node) => { + const parts = memberParts(node) + if (parts?.object === 'globalThis') computedGlobalThis += 1 + if ( + parts?.object === '__recovered_handlers' || + parts?.object === '__recovered_handler' + ) + handlerOperations += 1 + if (parts?.object === 'Reflect') { + reflectCalls[parts.property] = (reflectCalls[parts.property] || 0) + 1 + } + if ( + parts?.object === 'Object' && + ['defineProperty', 'getOwnPropertyDescriptor'].includes(parts.property) + ) + descriptorCalls += 1 + if ( + node.type === 'FunctionDeclaration' && + node.id?.name === '__recovered_enumerable_keys' + ) + enumerableHelperDeclarations += 1 + if ( + node.type === 'CallExpression' && + node.callee?.type === 'Identifier' && + node.callee.name === '__recovered_enumerable_keys' + ) + enumerableHelperCalls += 1 + if (node.type === 'FunctionExpression' && hasGeneratedIdentifier(node)) { + if (hasName(node, '__recovered_closure_args')) closureWrappers += 1 + } + if ( + node.type !== 'FunctionDeclaration' || + !node.id?.name.startsWith('__recovered_function_') + ) + return + if (hasName(node, '__recovered_dispatch')) stateMachineFunctions += 1 + else structuredFunctions += 1 + }) + + return { + reflectCalls, + computedGlobalThis, + descriptorCalls: Math.max( + 0, + descriptorCalls - enumerableHelperDeclarations, + ), + closureWrappers, + handlerOperations, + structuredFunctions, + stateMachineFunctions, + enumerableHelper: { + declarations: enumerableHelperDeclarations, + calls: enumerableHelperCalls, + unused: enumerableHelperDeclarations > 0 && enumerableHelperCalls === 0, + }, + } +} + +function hasName(node, name) { + let found = false + walk(node, (child) => { + if (child.type === 'Identifier' && child.name === name) found = true + }) + return found +} + +function roundRatio(numerator, denominator) { + if (denominator === 0) return null + return Number((numerator / denominator).toFixed(6)) +} + +function fileMetrics(source) { + return { bytes: Buffer.byteLength(source), sha256: digest(source) } +} + +function caseMetrics(caseId) { + const directory = path.join(fixtureRoot, caseId) + const source = readText(path.join(directory, 'source.js')) + const encoded = readText(path.join(directory, 'encoded.js')) + const decoded = readText(path.join(directory, 'decoded.js')) + const diagnosis = diagnoseStandalone(encoded) + if (!diagnosis.ok) + throw new Error( + `${caseId} no longer decodes: ${diagnosis.diagnostic.code}: ${diagnosis.diagnostic.message}`, + ) + if (diagnosis.result.output !== decoded) + throw new Error(`${caseId} decoded baseline does not match current output`) + + const sourceAst = parseSource(source) + const encodedAst = parseSource(encoded) + const decodedAst = parseSource(decoded) + const sourceFile = fileMetrics(source) + const encodedFile = fileMetrics(encoded) + const decodedFile = fileMetrics(decoded) + const decodedAstMetrics = astMetrics(decodedAst) + return { + caseId, + status: 'success', + files: { source: sourceFile, encoded: encodedFile, decoded: decodedFile }, + ratios: { + decodedToSource: roundRatio(decodedFile.bytes, sourceFile.bytes), + decodedToEncoded: roundRatio(decodedFile.bytes, encodedFile.bytes), + }, + ast: { + source: astMetrics(sourceAst), + encoded: astMetrics(encodedAst), + decoded: decodedAstMetrics, + }, + generatedIdentifiers: identifierMetrics(decodedAst), + operations: operationMetrics(decodedAst), + adjacentIdentifierCopies: adjacentIdentifierCopies(decodedAst), + eligibleAdjacentCopies: eligibleAdjacentCopies(diagnosis), + } +} + +export function collectMetrics() { + const manifest = readJson(path.join(fixtureRoot, 'manifest.json')) + const cases = [...manifest.cases].sort() + return { + schemaVersion: 'jsconfuser-vm-readability-metrics.v1', + fixtureSchemaVersion: manifest.schemaVersion, + cases: cases.map(caseMetrics), + } +} + +export function loadBaseline() { + return readJson(path.join(fixtureRoot, 'baseline.json')) +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)) +if (isMain) + process.stdout.write(`${JSON.stringify(collectMetrics(), null, 2)}\n`) diff --git a/test/vm/jsconfuser-vm/integration/readability.test.js b/test/vm/jsconfuser-vm/integration/readability.test.js new file mode 100644 index 00000000..5379470c --- /dev/null +++ b/test/vm/jsconfuser-vm/integration/readability.test.js @@ -0,0 +1,200 @@ +import fs from 'node:fs' +import path from 'node:path' +import vm from 'node:vm' +import { fileURLToPath } from 'node:url' +import { parse } from '@babel/parser' +import { expect, test } from 'vitest' +import { expectConsistentState } from '../../../helper.js' +import { diagnoseStandalone } from '../../../../src/vm/jsconfuser-vm/decode-standalone.js' +import { collectMetrics, loadBaseline } from './readability-metrics.js' + +const fixtureRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../fixtures/readability', +) +const parserOptions = { + allowReturnOutsideFunction: true, + errorRecovery: false, + sourceType: 'script', +} + +function execute(filePath) { + const context = {} + context.window = context + vm.createContext(context) + vm.runInContext(fs.readFileSync(filePath, 'utf8'), context, { + filename: filePath, + timeout: 5000, + }) + return context.TEST_OUTPUT +} + +function outputFunction(output, functionId) { + const start = output.indexOf(`function __recovered_function_${functionId}(`) + expect(start).toBeGreaterThanOrEqual(0) + const next = output.indexOf('\n function __recovered_function_', start + 1) + return output.slice(start, next === -1 ? output.length : next) +} + +function decodedOutput(caseId) { + return fs.readFileSync(path.join(fixtureRoot, caseId, 'decoded.js'), 'utf8') +} + +function baselineByCase() { + return new Map(loadBaseline().cases.map((entry) => [entry.caseId, entry])) +} + +test('readability benchmark is tracked, complete, and deterministic', () => { + const first = collectMetrics() + const second = collectMetrics() + + expect(second).toEqual(first) + expect(first.schemaVersion).toBe('jsconfuser-vm-readability-metrics.v1') + expect(first.cases.map(({ caseId }) => caseId)).toEqual([ + 'arithmetic', + 'closure-loop', + 'scale', + ]) + expect(first.cases.every(({ status }) => status === 'success')).toBe(true) + for (const result of first.cases) { + expect(result.files.source.bytes).toBeGreaterThan(0) + expect(result.files.encoded.bytes).toBeGreaterThan( + result.files.source.bytes, + ) + expect(result.files.decoded.bytes).toBeGreaterThan(0) + expect(result.ratios.decodedToSource).toBeTypeOf('number') + expect(result.ratios.decodedToEncoded).toBeTypeOf('number') + expect(result.ast.decoded.statements).toBeGreaterThan(0) + expect(result.ast.decoded.declaredIdentifiers).toBeGreaterThan(0) + } +}) + +test('baseline records the generated VM representation that readability work must address', () => { + const metrics = collectMetrics() + const generatedTotals = metrics.cases.map( + ({ generatedIdentifiers }) => generatedIdentifiers.total, + ) + const generatedStatementTotals = metrics.cases.map( + ({ ast }) => ast.decoded.generatedStatements, + ) + + expect(generatedTotals.every((total) => total > 0)).toBe(true) + expect(generatedStatementTotals.every((total) => total > 0)).toBe(true) + expect( + metrics.cases.every( + ({ operations }) => operations.enumerableHelper.declarations === 0, + ), + ).toBe(true) +}) + +test('retains only setup required by captures, arguments, and enumeration', () => { + const arithmetic = decodedOutput('arithmetic') + expect(outputFunction(arithmetic, 0)).toMatch( + /^function __recovered_function_0\(\)/, + ) + + const closure = decodedOutput('closure-loop') + expect(outputFunction(closure, 0)).toMatch( + /^function __recovered_function_0\(\)/, + ) + expect(outputFunction(closure, 1)).toMatch( + /^function __recovered_function_1\(\.\.\.__recovered_args\)/, + ) + expect(outputFunction(closure, 1)).toContain( + 'const __recovered_cells_1 = [];', + ) + expect(outputFunction(closure, 2)).toMatch( + /^function __recovered_function_2\(__recovered_captures, \.\.\.__recovered_args\)/, + ) + expect(outputFunction(closure, 2)).not.toContain( + 'const __recovered_cells_2 = [];', + ) + + const forInPath = path.join( + fixtureRoot, + '../corpus/raw/f-for-in-enumeration/encoded.js', + ) + const forInDiagnosis = diagnoseStandalone(fs.readFileSync(forInPath, 'utf8')) + expect(forInDiagnosis.ok).toBe(true) + expect(forInDiagnosis.result.output).toContain( + 'function __recovered_enumerable_keys(__value)', + ) +}) + +test('source-oriented output is smaller without changing recovered behavior', () => { + const metrics = collectMetrics() + const baselines = baselineByCase() + const expectedEmptyCells = { + arithmetic: 0, + 'closure-loop': 1, + scale: 0, + } + + for (const result of metrics.cases) { + const baseline = baselines.get(result.caseId) + expect(baseline).toBeDefined() + expect(result.files.source.sha256).toBe(baseline.sourceSha256) + expect(result.files.encoded.sha256).toBe(baseline.encodedSha256) + expect(result.files.decoded.bytes).toBeLessThan(baseline.decoded.bytes) + expect(result.ratios.decodedToSource).toBeLessThan( + baseline.decoded.ratios.decodedToSource, + ) + expect(result.ratios.decodedToEncoded).toBeLessThan( + baseline.decoded.ratios.decodedToEncoded, + ) + expect(result.ast.decoded.statements).toBeLessThan( + baseline.decoded.ast.statements, + ) + expect(result.ast.decoded.generatedStatements).toBeLessThan( + baseline.decoded.ast.generatedStatements, + ) + expect(result.ast.decoded.declaredIdentifiers).toBeLessThan( + baseline.decoded.ast.declaredIdentifiers, + ) + expect(result.ast.decoded.generatedDeclarations.total).toBeLessThan( + baseline.decoded.ast.generatedDeclarations.total, + ) + expect( + result.ast.decoded.generatedDeclarations.zeroReferences, + ).toBeLessThanOrEqual( + baseline.decoded.ast.generatedDeclarations.zeroReferences, + ) + expect(result.ast.decoded.emptyCellDeclarations).toBe( + expectedEmptyCells[result.caseId], + ) + expect(result.generatedIdentifiers.total).toBeLessThan( + baseline.decoded.generatedIdentifiers.total, + ) + expect(result.generatedIdentifiers.distinct).toBeLessThan( + baseline.decoded.generatedIdentifiers.distinct, + ) + expect(result.eligibleAdjacentCopies).toBe(0) + expect(result.operations.enumerableHelper).toEqual({ + declarations: 0, + calls: 0, + unused: false, + }) + expect({ + reflectCalls: result.operations.reflectCalls, + computedGlobalThis: result.operations.computedGlobalThis, + descriptorCalls: result.operations.descriptorCalls, + closureWrappers: result.operations.closureWrappers, + handlerOperations: result.operations.handlerOperations, + structuredFunctions: result.operations.structuredFunctions, + stateMachineFunctions: result.operations.stateMachineFunctions, + }).toEqual(baseline.decoded.operations) + + const sourcePath = path.join(fixtureRoot, result.caseId, 'source.js') + const decodedPath = path.join(fixtureRoot, result.caseId, 'decoded.js') + expect(execute(decodedPath)).toEqual(execute(sourcePath)) + + const decoded = fs.readFileSync(decodedPath, 'utf8') + const ast = parse(decoded, parserOptions) + expect(() => + expectConsistentState(ast, decoded, parserOptions), + ).not.toThrow() + expect(decoded).not.toMatch( + /\b(?:VM|Upvalue|decodeBytecode|BYTECODE|CONSTANTS|wordcode)\b/, + ) + } +}) diff --git a/test/vm/jsconfuser-vm/integration/sequential.test.js b/test/vm/jsconfuser-vm/integration/sequential.test.js new file mode 100644 index 00000000..3e90b8f2 --- /dev/null +++ b/test/vm/jsconfuser-vm/integration/sequential.test.js @@ -0,0 +1,312 @@ +import crypto from 'node:crypto' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, test, vi } from 'vitest' + +import { + createJsconfuserVmSequentialCoordinator, + decodeJsconfuserVmSequential, +} from '../../../../src/plugin/jsconfuser-vm-sequential.js' + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)) +const packageRoot = path.resolve(testDirectory, '../../../..') +const q2fInputPath = path.join( + packageRoot, + 'test/vm/jsconfuser-vm/fixtures/sequential/positive-capture-outer.js', +) +const directVmInputPath = path.join( + packageRoot, + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js', +) + +function sha256(source) { + return crypto.createHash('sha256').update(source).digest('hex') +} + +function completeVmRecord(output) { + return { + schemaVersion: 'jsconfuser-vm-adapter.v1', + ok: true, + status: 'success', + input: output, + output, + result: { + schemaVersion: 'jsconfuser-vm-standalone.v1', + packet: 'N', + parseOnly: true, + targetExecution: false, + vmExecuted: false, + emittedJavaScript: true, + finalJavaScriptSemantics: false, + output, + proof: { + allPredecessorsReconstructed: true, + noTargetExecution: true, + noVmRuntimeEmission: true, + freshJavaScriptEmission: true, + }, + }, + diagnostic: null, + } +} + +test('composed positive-capture control rolls back exact original bytes when VM declines', () => { + const input = fs.readFileSync(q2fInputPath, 'utf8') + const record = decodeJsconfuserVmSequential(input) + + expect(record).toMatchObject({ + schemaVersion: 'jsconfuser-vm-sequential.v1', + ok: false, + status: 'declined', + input, + output: input, + result: null, + diagnostic: { + stage: 'vm', + code: 'container-declined', + message: expect.any(String), + }, + failedStage: 'vm', + stages: { + outer: { status: 'success', ok: true, output: expect.any(String) }, + outerParse: { status: 'success', ok: true }, + vm: { + status: 'declined', + output: expect.any(String), + record: { + status: 'declined', + diagnostic: { code: 'container-declined' }, + }, + }, + }, + }) + expect(record.stages.vm.output).toBe(record.stages.outer.output) + expect(record.stages.vm.output).not.toBe(input) + expect(sha256(record.output)).toBe(sha256(input)) + expect(Object.isFrozen(record)).toBe(true) + expect(Object.isFrozen(record.stages)).toBe(true) +}) + +test('ordinary JavaScript keeps exact bytes when the VM container is absent', () => { + const input = 'const answer = 42;\nwindow.TEST_OUTPUT = answer;\n' + const record = decodeJsconfuserVmSequential(input) + + expect(record.status).toBe('declined') + expect(record.output).toBe(input) + expect(record.diagnostic).toMatchObject({ + stage: 'vm', + code: 'container-declined', + }) + expect(record.stages.outerParse.ok).toBe(true) + expect(record.stages.recoveredParse.status).toBe('pending') +}) + +test('direct VM fixture completes through both fresh decoder boundaries', () => { + const input = fs.readFileSync(directVmInputPath, 'utf8') + const record = decodeJsconfuserVmSequential(input) + + expect(record).toMatchObject({ + schemaVersion: 'jsconfuser-vm-sequential.v1', + ok: true, + status: 'success', + input, + diagnostic: null, + failedStage: null, + result: { + schemaVersion: 'jsconfuser-vm-standalone.v1', + proof: { + allPredecessorsReconstructed: true, + noTargetExecution: true, + noVmRuntimeEmission: true, + freshJavaScriptEmission: true, + }, + }, + stages: { + outerParse: { status: 'success', ok: true }, + vm: { status: 'success', ok: true }, + recoveredParse: { status: 'success', ok: true }, + }, + }) + expect(record.output).toBe(record.result.output) + expect(record.output).not.toBe(input) + expect(Object.isFrozen(record.result)).toBe(true) +}) + +test('outer declines, throws, times out, and emits malformed source atomically', () => { + const input = 'const original = 7;\n' + + const declined = decodeJsconfuserVmSequential('function (') + expect(declined.output).toBe('function (') + expect(declined.diagnostic).toMatchObject({ + stage: 'outer', + code: 'outer-declined', + }) + + const throws = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => { + throw new Error('synthetic outer error') + }, + })(input) + expect(throws.output).toBe(input) + expect(throws.diagnostic).toMatchObject({ + stage: 'outer', + code: 'outer-exception', + message: 'Error: synthetic outer error', + }) + + const timeout = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => ({ + status: 'timeout', + diagnostic: { message: 'synthetic outer timeout' }, + }), + })(input) + expect(timeout.output).toBe(input) + expect(timeout.diagnostic).toMatchObject({ + stage: 'outer', + code: 'outer-timeout', + }) + + const incompleteOuter = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => ({ unexpected: true }), + })(input) + expect(incompleteOuter.output).toBe(input) + expect(incompleteOuter.diagnostic).toMatchObject({ + stage: 'outer', + code: 'outer-incomplete', + }) + + const vmDecoder = vi.fn() + const malformed = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => 'var =', + vmDecoder, + })(input) + expect(malformed.output).toBe(input) + expect(malformed.diagnostic).toMatchObject({ + stage: 'outer-parse', + code: 'outer-parse-failed', + }) + expect(malformed.stages.vm.status).toBe('pending') + expect(vmDecoder).not.toHaveBeenCalled() +}) + +test('VM exceptions, incomplete results, and invalid recovered source roll back atomically', () => { + const input = 'const original = 8;\n' + const validOuter = 'window.TEST_OUTPUT = 7;\n' + + const throws = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => validOuter, + vmDecoder: () => { + throw new Error('synthetic VM error') + }, + })(input) + expect(throws.output).toBe(input) + expect(throws.diagnostic).toMatchObject({ + stage: 'vm', + code: 'vm-exception', + }) + + const incomplete = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => validOuter, + vmDecoder: () => ({ + schemaVersion: 'jsconfuser-vm-adapter.v1', + ok: true, + status: 'success', + output: validOuter, + result: null, + }), + })(input) + expect(incomplete.output).toBe(input) + expect(incomplete.diagnostic).toMatchObject({ + stage: 'vm', + code: 'vm-incomplete', + }) + + const mismatchedInput = completeVmRecord(validOuter) + mismatchedInput.input = 'different outer input\n' + const mismatch = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => validOuter, + vmDecoder: () => mismatchedInput, + })(input) + expect(mismatch.output).toBe(input) + expect(mismatch.diagnostic).toMatchObject({ + stage: 'vm', + code: 'vm-input-mismatch', + }) + expect(mismatch.stages.vm.record.input).toBe(mismatchedInput.input) + + const nullResult = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => validOuter, + vmDecoder: () => null, + })(input) + expect(nullResult.output).toBe(input) + expect(nullResult.diagnostic).toMatchObject({ + stage: 'vm', + code: 'vm-incomplete', + }) + + const timedOut = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => validOuter, + vmDecoder: () => ({ + status: 'timeout', + diagnostic: { message: 'synthetic VM timeout' }, + }), + })(input) + expect(timedOut.output).toBe(input) + expect(timedOut.diagnostic).toMatchObject({ + stage: 'vm', + code: 'vm-timeout', + }) + + const malformedRecoveredRecord = completeVmRecord('var =') + malformedRecoveredRecord.input = validOuter + const recoveredMalformed = createJsconfuserVmSequentialCoordinator({ + outerDecoder: () => validOuter, + vmDecoder: () => malformedRecoveredRecord, + })(input) + expect(recoveredMalformed.output).toBe(input) + expect(recoveredMalformed.diagnostic).toMatchObject({ + stage: 'recovered-parse', + code: 'recovered-parse-failed', + }) +}) + +test('sequential CLI publishes original bytes and sidecar diagnostics on VM decline', () => { + const directory = fs.mkdtempSync( + path.join(tmpdir(), 'jsconfuser-vm-sequential-'), + ) + const inputPath = path.join(directory, 'input.js') + const outputPath = path.join(directory, 'output.js') + const resultPath = path.join(directory, 'result.json') + const input = fs.readFileSync(q2fInputPath, 'utf8') + fs.writeFileSync(inputPath, input) + + const completed = spawnSync( + process.execPath, + [ + 'src/main.js', + '-t', + 'jsconfuser-vm-sequential', + '-i', + inputPath, + '-o', + outputPath, + '--result', + resultPath, + ], + { cwd: packageRoot, encoding: 'utf8' }, + ) + + expect(completed.status).toBe(0) + expect(completed.stdout).toContain('Status: declined') + expect(completed.stderr).toContain('Diagnostic: container-declined') + expect(fs.readFileSync(outputPath, 'utf8')).toBe(input) + expect(JSON.parse(fs.readFileSync(resultPath, 'utf8'))).toMatchObject({ + schemaVersion: 'jsconfuser-vm-sequential.v1', + status: 'declined', + output: input, + diagnostic: { stage: 'vm', code: 'container-declined' }, + }) +}) diff --git a/test/vm/jsconfuser-vm/numeric-to-vm-switch.test.js b/test/vm/jsconfuser-vm/numeric-to-vm-switch.test.js new file mode 100644 index 00000000..2a1dcfc2 --- /dev/null +++ b/test/vm/jsconfuser-vm/numeric-to-vm-switch.test.js @@ -0,0 +1,34 @@ +import { test, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { validateVmSwitchModel } from '../../../src/vm/switch/vm-switch-model.js' +import { diagnoseStandaloneInput } from '../../../src/vm/jsconfuser-vm/diagnose-standalone.js' +import { numericToVmSwitch } from '../../../src/vm/jsconfuser-vm/numeric-to-vm-switch.js' + +test('numeric frontend removes serialized opcode and operand words at the handoff', () => { + const path = fileURLToPath( + new URL('./fixtures/readability/arithmetic/encoded.js', import.meta.url), + ) + const preflight = diagnoseStandaloneInput(readFileSync(path, 'utf8')) + expect(preflight.ok).toBe(true) + const model = numericToVmSwitch(preflight.model) + expect(validateVmSwitchModel(model)).toBe(model) + expect(model.code.kind).toBe('numeric-u32') + for (const cases of model.instructionsByFunction.values()) { + for (const item of cases) { + expect(item).toHaveProperty('operation') + expect(item).not.toHaveProperty('name') + expect(item).not.toHaveProperty('opcode') + expect(item).not.toHaveProperty('operands') + expect(item).not.toHaveProperty('wordOperands') + expect(item).not.toHaveProperty('words') + } + } + for (const control of model.controlByFunction.values()) + for (const block of control.blocks) + for (const leaf of block.leaves) { + expect(leaf).not.toHaveProperty('name') + expect(leaf).not.toHaveProperty('opcode') + expect(leaf).not.toHaveProperty('wordOperands') + } +}) diff --git a/test/vm/jsconfuser-vm/partition-functions.test.js b/test/vm/jsconfuser-vm/partition-functions.test.js new file mode 100644 index 00000000..44d63428 --- /dev/null +++ b/test/vm/jsconfuser-vm/partition-functions.test.js @@ -0,0 +1,365 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { validateReferences } from '../../../src/vm/jsconfuser-vm/validate-references.js' +import { + diagnoseFunctionPartition, + partitionFunctions, +} from '../../../src/vm/jsconfuser-vm/partition-functions.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + const references = validateReferences(container, wordcode) + expect(references, `Packet C declined ${relativePath}`).not.toBeNull() + return { container, wordcode, references } +} + +function partitionAt(relativePath) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseFunctionPartition( + packet.container, + packet.wordcode, + packet.references, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + return { ...packet, result: diagnosis.result } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function diagnosticFor(packet) { + const diagnosis = diagnoseFunctionPartition( + packet.container, + packet.wordcode, + packet.references, + ) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + return diagnosis.diagnostic +} + +function setWord(packet, instruction, offset, value) { + packet.container.roles.words.values[instruction.pc + offset] = value + packet.wordcode.words[instruction.pc + offset] = value + instruction.words[offset] = value +} + +function setLabelTarget(packet, reference, target) { + const instruction = packet.wordcode.instructions.find( + ({ pc }) => pc === reference.pc, + ) + expect(instruction).toBeDefined() + const wordOffset = reference.operand + 1 + setWord(packet, instruction, wordOffset, target) + instruction.wordOperands[reference.operand].pc = target + instruction.operands[reference.operand].pc = target + const labelReference = packet.references.labels.references.find( + ({ pc, operand }) => pc === reference.pc && operand === reference.operand, + ) + expect(labelReference).toBeDefined() + labelReference.target = target +} + +function corpusPaths() { + return ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) +} + +test('partitions the reference stream into one immutable root function', () => { + const packet = partitionAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + + expect(packet.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-functions.v1', + encoding: 'numeric-u32', + wordCount: 32, + instructionCount: 9, + entryPcs: [0], + functions: [ + { + id: 0, + kind: 'root', + startPc: 0, + endPc: 32, + paramCount: 0, + regCount: 7, + captureCount: 0, + hasRest: false, + parentFunctionId: null, + instructionCount: 9, + descriptor: null, + closureSites: [], + }, + ], + ownership: packet.wordcode.instructions.map((instruction) => ({ + pc: instruction.pc, + functionId: 0, + })), + labels: [], + frame: { + frameStart: 1, + headerSize: 8, + root: { functionId: 0, startPc: 0, regCount: 7 }, + }, + }) + expect(deepFrozen(packet.result)).toBe(true) + expect( + partitionFunctions(packet.container, packet.wordcode, packet.references), + ).toEqual(packet.result) +}) + +test('partitions nested closures and records unique parent/child creation sites', () => { + const packet = partitionAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + ) + + expect(packet.result.entryPcs).toEqual([0, 55, 81]) + expect( + packet.result.functions.map( + ({ id, kind, startPc, endPc, parentFunctionId, instructionCount }) => ({ + id, + kind, + startPc, + endPc, + parentFunctionId, + instructionCount, + }), + ), + ).toEqual([ + { + id: 0, + kind: 'root', + startPc: 0, + endPc: 55, + parentFunctionId: null, + instructionCount: 14, + }, + { + id: 1, + kind: 'closure', + startPc: 55, + endPc: 81, + parentFunctionId: 0, + instructionCount: 7, + }, + { + id: 2, + kind: 'closure', + startPc: 81, + endPc: 111, + parentFunctionId: 1, + instructionCount: 10, + }, + ]) + expect(packet.result.functions[0].closureSites).toMatchObject([ + { + creationPc: 2, + parentFunctionId: 0, + childFunctionId: 1, + childStartPc: 55, + destinationRegister: 4, + }, + ]) + expect(packet.result.functions[1].closureSites).toMatchObject([ + { + creationPc: 64, + parentFunctionId: 1, + childFunctionId: 2, + childStartPc: 81, + destinationRegister: 3, + }, + ]) + expect(packet.result.functions[2].closureSites).toEqual([]) + + const ownedPcs = packet.result.functions.flatMap( + ({ instructionPcs }) => instructionPcs, + ) + expect(ownedPcs).toEqual(packet.wordcode.instructions.map(({ pc }) => pc)) + expect(packet.result.ownership).toEqual( + packet.wordcode.instructions.map(({ pc }) => ({ + pc, + functionId: packet.result.functions.find(({ instructionPcs }) => + instructionPcs.includes(pc), + ).id, + })), + ) +}) + +test('keeps exceptional labels local while partitioning multiple closures', () => { + const packet = partitionAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js', + ) + + expect(packet.result.entryPcs).toEqual([0, 102, 182, 255, 372]) + expect( + packet.result.functions.map(({ startPc, endPc }) => [startPc, endPc]), + ).toEqual([ + [0, 102], + [102, 182], + [182, 255], + [255, 372], + [372, 517], + ]) + expect( + packet.result.functions.map(({ closureSites }) => closureSites.length), + ).toEqual([4, 0, 0, 0, 0]) + expect( + packet.result.labels + .filter(({ role }) => role !== 'functionEntry') + .every( + ({ sourceFunctionId, targetFunctionId }) => + sourceFunctionId === targetFunctionId, + ), + ).toBe(true) +}) + +test('partitions every tracked stripped raw and focused corpus input', () => { + const paths = corpusPaths() + expect(paths).toHaveLength(33) + for (const relativePath of paths) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseFunctionPartition( + packet.container, + packet.wordcode, + packet.references, + ) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + expect(diagnosis.result.entryPcs[0]).toBe(0) + expect(diagnosis.result.functions.at(-1).endPc).toBe( + diagnosis.result.wordCount, + ) + expect(diagnosis.result.ownership).toHaveLength( + diagnosis.result.instructionCount, + ) + } +}) + +test('fails closed for predecessor mismatches and preserves all inputs', () => { + const packet = packetAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + const original = structuredClone(packet) + + const badContainer = structuredClone(packet) + badContainer.container.roles.words.values[0] = 1 + expect(diagnosticFor(badContainer).code).toBe('input-mismatch') + + const badWordcode = structuredClone(packet) + badWordcode.wordcode.wordCount -= 1 + expect(diagnosticFor(badWordcode).code).toBe('invalid-wordcode') + + const badReferences = structuredClone(packet) + badReferences.references.instructionCount -= 1 + expect(diagnosticFor(badReferences).code).toBe('input-mismatch') + + expect(packet).toEqual(original) +}) + +test('rejects non-boundary, conflicting, and orphaned closure descriptors', () => { + const source = + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js' + const packet = packetAt(source) + + const nonBoundary = structuredClone(packet) + nonBoundary.references.frame.descriptors[0].startPc = 56 + expect(diagnosticFor(nonBoundary).code).toBe('invalid-descriptor') + + const conflicting = structuredClone(packet) + const descriptor = structuredClone( + conflicting.references.frame.descriptors[0], + ) + descriptor.regCount += 1 + descriptor.frameSize += 1 + conflicting.references.frame.descriptors.push(descriptor) + expect(diagnosticFor(conflicting).code).toBe('invalid-descriptor') + + const orphaned = structuredClone(packet) + orphaned.references.frame.descriptors[0].creationPc = 9 + expect(diagnosticFor(orphaned).code).toBe('invalid-descriptor') +}) + +test('rejects a direct label that crosses function ownership', () => { + const packet = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js', + ) + const jump = packet.references.labels.references.find( + ({ instruction }) => instruction === 'JUMP', + ) + expect(jump).toBeDefined() + + const invalid = structuredClone(packet) + setLabelTarget(invalid, jump, 182) + expect(diagnosticFor(invalid).code).toBe('cross-function-target') +}) + +test('does not mutate accepted frozen predecessor packets', () => { + const packet = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js', + ) + const before = structuredClone(packet) + const result = partitionFunctions( + packet.container, + packet.wordcode, + packet.references, + ) + + expect(result).not.toBeNull() + expect(packet).toEqual(before) + expect(deepFrozen(result)).toBe(true) + expect(() => result.functions.push({})).toThrow() + expect(() => (result.functions[0].startPc = 99)).toThrow() +}) + +test('diagnoseFunctionPartition returns a frozen atomic decline report', () => { + const packet = packetAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + const invalid = structuredClone(packet) + invalid.references.frame.headerSize = 7 + const report = diagnoseFunctionPartition( + invalid.container, + invalid.wordcode, + invalid.references, + ) + + expect(report).toMatchObject({ + ok: false, + result: null, + diagnostic: { code: 'invalid-frame-metadata' }, + }) + expect(deepFrozen(report)).toBe(true) +}) diff --git a/test/vm/jsconfuser-vm/read-wordcode.test.js b/test/vm/jsconfuser-vm/read-wordcode.test.js new file mode 100644 index 00000000..3b0d9594 --- /dev/null +++ b/test/vm/jsconfuser-vm/read-wordcode.test.js @@ -0,0 +1,347 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { + diagnoseWordcode, + readWordcode, +} from '../../../src/vm/jsconfuser-vm/read-wordcode.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function containerAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + return container +} + +function withWords(container, words) { + const copy = structuredClone(container) + copy.roles.words.values = [...words] + return copy +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function instructionsAt(relativePath) { + const result = readWordcode(containerAt(relativePath)) + expect(result).not.toBeNull() + return result.instructions +} + +test('parses the reference stream with exact PCs and complete consumption', () => { + const container = containerAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + const result = readWordcode(container) + + expect(result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-wordcode.v1', + encoding: 'numeric-u32', + wordCount: 32, + instructionCount: 9, + consumedWords: 32, + nextPc: 32, + }) + expect(result.instructions.map(({ name }) => name)).toEqual([ + 'LOAD_THIS', + 'LOAD_GLOBAL', + 'LOAD_CONST', + 'LOAD_CONST', + 'LOAD_CONST', + 'ADD', + 'SET_PROP', + 'LOAD_CONST', + 'RETURN', + ]) + expect( + result.instructions.every( + (instruction) => + instruction.nextPc === instruction.pc + instruction.width, + ), + ).toBe(true) + expect(result.instructions.at(-1).nextPc).toBe(result.wordCount) + expect(deepFrozen(result)).toBe(true) +}) + +test('detector-first assertions prove every target-local variable form is observed', () => { + const targetCases = [ + [ + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-store-global-assignment/encoded.js', + 'STORE_GLOBAL', + ], + [ + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-unary-positive-coercion/encoded.js', + 'UNARY_POS', + ], + [ + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js', + 'CALL_METHOD', + ], + [ + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js', + 'NEW', + ], + [ + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js', + 'CALL', + ], + [ + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + 'MAKE_CLOSURE', + ], + ] + + for (const [path, opcode] of targetCases) { + const target = instructionsAt(path).filter(({ name }) => name === opcode) + expect(target.length, `${path} detector`).toBeGreaterThan(0) + } +}) + +test('parses fixed and spread forms for CALL, CALL_METHOD, and NEW', () => { + const callInstructions = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-spread-order/encoded.js', + ) + const methodInstructions = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js', + ) + const newInstructions = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js', + ) + const fixedCall = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-arrow-forms/encoded.js', + ).find(({ name }) => name === 'CALL') + const fixedMethod = methodInstructions.find( + ({ name, form }) => name === 'CALL_METHOD' && form?.kind === 'fixed', + ) + const fixedNew = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-for-in-enumeration/encoded.js', + ).find(({ name }) => name === 'NEW') + const spreadCall = callInstructions.find( + ({ name, form }) => name === 'CALL' && form?.kind === 'spread', + ) + const spreadMethod = methodInstructions.find( + ({ form }) => form?.kind === 'spread', + ) + const spreadNew = newInstructions.find(({ form }) => form?.kind === 'spread') + + expect(fixedCall).toMatchObject({ + form: { kind: 'fixed', argc: 0, sentinel: null }, + arguments: { kind: 'fixed', count: 0, registers: [] }, + }) + expect(fixedMethod).toMatchObject({ + form: { kind: 'fixed', argc: 1, sentinel: null }, + arguments: { kind: 'fixed', count: 1 }, + }) + expect(fixedNew).toMatchObject({ + form: { kind: 'fixed', argc: 0, sentinel: null }, + arguments: { kind: 'fixed', count: 0, registers: [] }, + }) + expect(spreadCall).toMatchObject({ + words: [42, 8, 2, 65535, 7], + width: 5, + nextPc: 142, + form: { + kind: 'spread', + argc: 65535, + sentinel: 65535, + arrayRegister: { kind: 'register', index: 7 }, + }, + arguments: { + kind: 'spread', + sentinel: 65535, + arrayRegister: { kind: 'register', index: 7 }, + }, + }) + expect(spreadMethod).toMatchObject({ + words: [43, 4, 2, 5, 65535, 6], + width: 6, + form: { + kind: 'spread', + arrayRegister: { kind: 'register', index: 6 }, + }, + }) + expect(spreadNew).toMatchObject({ + words: [44, 5, 2, 65535, 6], + width: 5, + form: { + kind: 'spread', + arrayRegister: { kind: 'register', index: 6 }, + }, + }) +}) + +test('retains typed closure, collection, zero-width, and EXP records', () => { + const closure = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + ).find(({ captures }) => captures?.length > 0) + expect(closure).toMatchObject({ + name: 'MAKE_CLOSURE', + width: 9, + functionMeta: { + startPc: 81, + paramCount: 0, + regCount: 5, + captureCount: 1, + hasRest: false, + }, + captures: [{ kind: 'local', index: 2, isLocal: true }], + }) + + const focusedNew = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-new-spread/encoded.js', + ) + expect(focusedNew.find(({ name }) => name === 'BUILD_ARRAY')).toMatchObject({ + form: { kind: 'array', count: 2 }, + elementRegisters: [ + { kind: 'register', index: 5 }, + { kind: 'register', index: 6 }, + ], + }) + expect( + instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-literals-order/encoded.js', + ).find(({ name, pairCount }) => name === 'BUILD_OBJECT' && pairCount === 2), + ).toMatchObject({ + form: { kind: 'object', pairCount: 2 }, + pairs: [ + { + key: { kind: 'register', index: 19 }, + value: { kind: 'register', index: 20 }, + }, + { + key: { kind: 'register', index: 21 }, + value: { kind: 'register', index: 23 }, + }, + ], + }) + + const finallyInstructions = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-finally-abrupt/encoded.js', + ) + expect( + finallyInstructions.find(({ name }) => name === 'TRY_END'), + ).toMatchObject({ + name: 'TRY_END', + operands: [], + width: 1, + }) + expect( + instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-debugger-order/encoded.js', + ).find(({ name }) => name === 'DEBUGGER'), + ).toMatchObject({ + name: 'DEBUGGER', + operands: [], + width: 1, + }) + const exp = instructionsAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-expression-values/encoded.js', + ).find(({ name }) => name === 'EXP') + expect(exp).toMatchObject({ + name: 'EXP', + opcode: { name: 'EXP', value: 60 }, + operands: [ + { kind: 'register' }, + { kind: 'register' }, + { kind: 'register' }, + ], + width: 4, + }) +}) + +test.each([ + ['fixed operand', [4], 'truncated-instruction'], + ['spread call array register', [42, 0, 1, 65535], 'truncated-instruction'], + ['closure capture pair', [47, 0, 0, 0, 0, 1, 0, 1], 'truncated-instruction'], + ['array count payload', [48, 0, 2, 0], 'truncated-instruction'], + ['object count payload', [49, 0, 1, 0], 'truncated-instruction'], +])( + 'declines truncated %s before returning partial records', + (_label, words, code) => { + const container = containerAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + const report = diagnoseWordcode(withWords(container, words)) + + expect(report.ok).toBe(false) + expect(report.result).toBeNull() + expect(report.diagnostic.code).toBe(code) + }, +) + +test('declines unknown, extra/trailing, hardening, encoded, and non-parser input', () => { + const container = containerAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + const words = container.roles.words.values + + expect(diagnoseWordcode(withWords(container, [61])).diagnostic.code).toBe( + 'unknown-opcode', + ) + expect( + diagnoseWordcode(withWords(container, [...words, 61])).diagnostic.code, + ).toBe('unknown-opcode') + expect(diagnoseWordcode(withWords(container, [56])).diagnostic.code).toBe( + 'unsupported-hardening-opcode', + ) + + const encoded = structuredClone(container) + encoded.roles.scalars.ENCODE_BYTECODE.value = true + expect(diagnoseWordcode(encoded).diagnostic.code).toBe( + 'unsupported-encoded-bytecode', + ) + expect(diagnoseWordcode('var BYTECODE = [];').diagnostic.code).toBe( + 'invalid-input', + ) + expect(diagnoseWordcode(null).diagnostic.code).toBe('invalid-input') +}) + +test('preserves the container input and freezes the parsed wordcode', () => { + const container = containerAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js', + ) + const before = JSON.stringify(container) + const result = readWordcode(container) + + expect(JSON.stringify(container)).toBe(before) + expect(result.words).not.toBe(container.roles.words.values) + expect(deepFrozen(result)).toBe(true) + expect(() => result.words.push(1)).toThrow() + expect(() => (result.instructions[0].pc = 99)).toThrow() +}) + +test('parses the stripped numeric raw and focused corpus without comments or target execution', () => { + const roots = [ + 'test/vm/jsconfuser-vm/fixtures/corpus/raw', + 'test/vm/jsconfuser-vm/fixtures/corpus/focused', + ] + let cellCount = 0 + for (const root of roots) { + const rootUrl = new URL(`${root}/`, repositoryRoot) + for (const entry of fs.readdirSync(rootUrl).sort()) { + const entryUrl = new URL(entry, rootUrl) + if (!fs.statSync(entryUrl).isDirectory()) continue + const cell = new URL(`${entry}/`, rootUrl) + const encoded = new URL('encoded.js', cell) + if (!fs.existsSync(encoded)) continue + const result = readWordcode( + extractContainerFromSource(fs.readFileSync(encoded, 'utf8')), + ) + expect(result, `${root}/${entry}`).not.toBeNull() + expect(result.consumedWords).toBe(result.wordCount) + expect(result.instructions.at(-1).nextPc).toBe(result.wordCount) + cellCount += 1 + } + } + expect(cellCount).toBe(33) +}) diff --git a/test/vm/jsconfuser-vm/validate-references.test.js b/test/vm/jsconfuser-vm/validate-references.test.js new file mode 100644 index 00000000..302875b8 --- /dev/null +++ b/test/vm/jsconfuser-vm/validate-references.test.js @@ -0,0 +1,287 @@ +import fs from 'node:fs' +import { expect, test } from 'vitest' +import { extractContainerFromSource } from '../../../src/vm/jsconfuser-vm/extract-container.js' +import { readWordcode } from '../../../src/vm/jsconfuser-vm/read-wordcode.js' +import { + diagnoseReferences, + validateReferences, +} from '../../../src/vm/jsconfuser-vm/validate-references.js' + +const repositoryRoot = new URL('../../../', import.meta.url) + +function sourceAt(relativePath) { + return fs.readFileSync(new URL(relativePath, repositoryRoot), 'utf8') +} + +function packetAt(relativePath) { + const container = extractContainerFromSource(sourceAt(relativePath)) + expect(container, `Packet A declined ${relativePath}`).not.toBeNull() + const wordcode = readWordcode(container) + expect(wordcode, `Packet B declined ${relativePath}`).not.toBeNull() + return { container, wordcode } +} + +function deepFrozen(value) { + if (!value || typeof value !== 'object') return true + return ( + Object.isFrozen(value) && + Object.values(value).every((child) => deepFrozen(child)) + ) +} + +function diagnosticFor(container, wordcode) { + const diagnosis = diagnoseReferences(container, wordcode) + expect(diagnosis.ok).toBe(false) + expect(diagnosis.result).toBeNull() + return diagnosis.diagnostic +} + +function setWord(packet, instruction, offset, value) { + packet.container.roles.words.values[instruction.pc + offset] = value + packet.wordcode.words[instruction.pc + offset] = value + instruction.words[offset] = value +} + +function corpusPaths() { + return ['raw', 'focused'].flatMap((kind) => { + const root = new URL( + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/`, + repositoryRoot, + ) + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map( + (entry) => + `test/vm/jsconfuser-vm/fixtures/corpus/${kind}/${entry.name}/encoded.js`, + ) + .filter((relativePath) => + fs.existsSync(new URL(relativePath, repositoryRoot)), + ) + .sort() + }) +} + +test('returns immutable constants, references, labels, and root frame metadata', () => { + const packet = packetAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + const diagnosis = diagnoseReferences(packet.container, packet.wordcode) + + expect(diagnosis).toMatchObject({ ok: true, diagnostic: null }) + expect(diagnosis.result).toMatchObject({ + schemaVersion: 'jsconfuser-vm-references.v1', + encoding: 'numeric-u32', + wordCount: 32, + instructionCount: 9, + constants: { poolSize: packet.container.roles.pool.values.length }, + registers: { rootCount: 7, maxCount: 7, maxCaptureCount: 0 }, + frame: { + frameStart: 1, + headerSize: 8, + mainStartPc: 0, + mainRegCount: 7, + root: { + frameBase: 1, + frameSize: 15, + registerBase: 9, + registerWindow: { start: 9, end: 16 }, + frameEnd: 16, + }, + }, + }) + expect(diagnosis.result.frame.descriptors).toEqual([]) + expect(diagnosis.result.constants.values).toEqual( + packet.container.roles.pool.values, + ) + expect(diagnosis.result.labels.boundaries).toEqual( + packet.wordcode.instructions.map(({ pc }) => pc), + ) + expect(deepFrozen(diagnosis.result)).toBe(true) + expect(validateReferences(packet.container, packet.wordcode)).toEqual( + diagnosis.result, + ) +}) + +test('validates references for every tracked stripped raw and focused corpus input', () => { + const paths = corpusPaths() + expect(paths).toHaveLength(33) + for (const relativePath of paths) { + const packet = packetAt(relativePath) + const diagnosis = diagnoseReferences(packet.container, packet.wordcode) + expect( + diagnosis.ok, + `${relativePath}: ${diagnosis.diagnostic?.message}`, + ).toBe(true) + } +}) + +test('fails closed for predecessor mismatches and preserves both inputs', () => { + const packet = packetAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js') + const originalContainer = structuredClone(packet.container) + const originalWordcode = structuredClone(packet.wordcode) + + const badContainer = structuredClone(packet.container) + badContainer.roles.words.values[0] = 1 + expect(diagnosticFor(badContainer, packet.wordcode).code).toBe( + 'input-mismatch', + ) + + const badWordcode = structuredClone(packet.wordcode) + badWordcode.wordCount -= 1 + expect(diagnosticFor(packet.container, badWordcode).code).toBe( + 'invalid-wordcode', + ) + + expect(packet.container).toEqual(originalContainer) + expect(packet.wordcode).toEqual(originalWordcode) +}) + +test('rejects invalid constant indices, conceal keys, and name-value structure', () => { + const indexPacket = packetAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + const loadConst = indexPacket.wordcode.instructions.find( + ({ name }) => name === 'LOAD_CONST', + ) + expect(loadConst).toBeDefined() + const invalidIndex = structuredClone(indexPacket) + const constantIndex = invalidIndex.container.roles.pool.values.length + setWord( + invalidIndex, + invalidIndex.wordcode.instructions.find(({ pc }) => pc === loadConst.pc), + 2, + constantIndex, + ) + const invalidIndexInstruction = invalidIndex.wordcode.instructions.find( + ({ pc }) => pc === loadConst.pc, + ) + invalidIndexInstruction.wordOperands[1].index = constantIndex + invalidIndexInstruction.operands[1].index = constantIndex + expect( + diagnosticFor(invalidIndex.container, invalidIndex.wordcode).code, + ).toBe('invalid-constant-reference') + + const invalidKey = structuredClone(indexPacket) + const keyInstruction = invalidKey.wordcode.instructions.find( + ({ pc }) => pc === loadConst.pc, + ) + setWord(invalidKey, keyInstruction, 3, 1) + keyInstruction.wordOperands[2].value = 1 + keyInstruction.operands[1].concealKey = 1 + expect(diagnosticFor(invalidKey.container, invalidKey.wordcode).code).toBe( + 'invalid-constant-reference', + ) + + const namePacket = packetAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + const globalInstruction = namePacket.wordcode.instructions.find( + ({ name }) => name === 'LOAD_GLOBAL', + ) + expect(globalInstruction).toBeDefined() + const invalidName = structuredClone(namePacket) + invalidName.container.roles.pool.values[globalInstruction.operands[1].index] = + 1 + expect(diagnosticFor(invalidName.container, invalidName.wordcode).code).toBe( + 'invalid-constant-reference', + ) +}) + +test('rejects out-of-frame registers and non-boundary direct labels', () => { + const registerPacket = packetAt( + 'test/vm/jsconfuser-vm/fixtures/reference/encoded.js', + ) + const loadThis = registerPacket.wordcode.instructions.find( + ({ name }) => name === 'LOAD_THIS', + ) + const invalidRegister = structuredClone(registerPacket) + const invalidLoadThis = invalidRegister.wordcode.instructions.find( + ({ pc }) => pc === loadThis.pc, + ) + setWord(invalidRegister, invalidLoadThis, 1, 7) + invalidLoadThis.wordOperands[0].index = 7 + invalidLoadThis.operands[0].index = 7 + expect( + diagnosticFor(invalidRegister.container, invalidRegister.wordcode).code, + ).toBe('invalid-register-reference') + + const labelPacket = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-branching/encoded.js', + ) + const branch = labelPacket.wordcode.instructions.find( + ({ name }) => name === 'JUMP' || name === 'JUMP_IF_FALSE', + ) + expect(branch).toBeDefined() + const invalidLabel = structuredClone(labelPacket) + const invalidBranch = invalidLabel.wordcode.instructions.find( + ({ pc }) => pc === branch.pc, + ) + const labelIndex = invalidBranch.name === 'JUMP' ? 0 : 1 + const labelOffset = invalidBranch.name === 'JUMP' ? 1 : 2 + setWord( + invalidLabel, + invalidBranch, + labelOffset, + invalidLabel.wordcode.wordCount, + ) + invalidBranch.wordOperands[labelIndex].pc = invalidLabel.wordcode.wordCount + invalidBranch.operands[labelIndex].pc = invalidLabel.wordcode.wordCount + expect( + diagnosticFor(invalidLabel.container, invalidLabel.wordcode).code, + ).toBe('invalid-label-reference') +}) + +test('rejects malformed descriptors and frame roots without executing target output', () => { + const closurePacket = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/raw/f-closures-state/encoded.js', + ) + const closure = closurePacket.wordcode.instructions.find( + ({ name, captures }) => name === 'MAKE_CLOSURE' && captures.length > 0, + ) + expect(closure).toBeDefined() + const invalidDescriptor = structuredClone(closurePacket) + const invalidClosure = invalidDescriptor.wordcode.instructions.find( + ({ pc }) => pc === closure.pc, + ) + invalidClosure.functionMeta.regCount = 0 + expect( + diagnosticFor(invalidDescriptor.container, invalidDescriptor.wordcode).code, + ).toBe('invalid-descriptor') + + const invalidCapture = structuredClone(closurePacket) + const captureClosure = invalidCapture.wordcode.instructions.find( + ({ pc }) => pc === closure.pc, + ) + expect(captureClosure.captures.length).toBeGreaterThan(0) + const captureIndex = 0xffffffff + setWord(invalidCapture, captureClosure, 8, captureIndex) + captureClosure.wordOperands[7].value = captureIndex + captureClosure.operands[6].index = captureIndex + captureClosure.captures[0].index = captureIndex + captureClosure.capturePairs[0].index = captureIndex + expect( + diagnosticFor(invalidCapture.container, invalidCapture.wordcode).code, + ).toBe('invalid-register-reference') + + const invalidFrame = structuredClone( + packetAt('test/vm/jsconfuser-vm/fixtures/reference/encoded.js'), + ) + invalidFrame.container.roles.scalars.HEADER_SIZE.value = 7 + expect( + diagnosticFor(invalidFrame.container, invalidFrame.wordcode).code, + ).toBe('invalid-frame-metadata') +}) + +test('preserves frozen container and wordcode inputs', () => { + const packet = packetAt( + 'test/vm/jsconfuser-vm/fixtures/corpus/focused/focused-call-method-spread/encoded.js', + ) + const before = { + container: structuredClone(packet.container), + wordcode: structuredClone(packet.wordcode), + } + const result = validateReferences(packet.container, packet.wordcode) + expect(result).not.toBeNull() + expect(packet.container).toEqual(before.container) + expect(packet.wordcode).toEqual(before.wordcode) + expect(deepFrozen(result)).toBe(true) +}) diff --git a/test/vm/jsconfuser-vm/vm-copy-simplification.test.js b/test/vm/jsconfuser-vm/vm-copy-simplification.test.js new file mode 100644 index 00000000..bb8261d4 --- /dev/null +++ b/test/vm/jsconfuser-vm/vm-copy-simplification.test.js @@ -0,0 +1,145 @@ +import fs from 'node:fs' +import vm from 'node:vm' +import { expect, test } from 'vitest' +import { + assertDerivedStateConsistency, + assertNoVmResidue, + parseSuccessfulOutput, +} from './integration/harness.js' +import { diagnoseStandalone } from '../../../src/vm/jsconfuser-vm/decode-standalone.js' + +function fixture(cellId, kind = 'raw') { + const directory = new URL( + `./fixtures/${kind === 'readability' ? 'readability' : `corpus/${kind}`}/${cellId}/`, + import.meta.url, + ) + return { + encoded: fs.readFileSync(new URL('encoded.js', directory), 'utf8'), + source: fs.readFileSync(new URL('source.js', directory), 'utf8'), + } +} + +function execute(source) { + const window = {} + window.window = window + const context = vm.createContext(window) + vm.runInContext(source, context, { timeout: 5000 }) + return window.TEST_OUTPUT +} + +function staticChecks(output) { + const ast = parseSuccessfulOutput(output) + expect(assertNoVmResidue(ast).ok).toBe(true) + expect(assertDerivedStateConsistency(ast, output).ok).toBe(true) +} + +function allLeaves(control) { + return control.functions.flatMap((functionRecord) => + functionRecord.blocks.flatMap((block) => + block.leaves.map((leaf) => ({ + functionId: functionRecord.id, + leaf, + block, + })), + ), + ) +} + +function incomingCount(control, functionId, block) { + return control.edges.filter( + (edge) => edge.functionId === functionId && edge.targetPc === block.startPc, + ).length +} + +test('source-derived copy proofs simplify independent supported controls', () => { + for (const [cellId, kind] of [ + ['arithmetic', 'readability'], + ['f-literals-order', 'raw'], + ]) { + const input = fixture(cellId, kind) + const diagnosis = diagnoseStandalone(input.encoded) + expect(diagnosis.ok, cellId).toBe(true) + const { result } = diagnosis + expect(result.simplification.schemaVersion).toBe( + 'jsconfuser-vm-simplification.v1', + ) + expect(result.proof.freshSimplificationState).toBe(true) + expect(result.proof.simplificationDispositionsDisjoint).toBe(true) + expect(result.simplification.summary.safelyEliminated).toBeGreaterThan(0) + for (const proof of result.simplification.functions.flatMap( + ({ copies }) => copies, + )) { + expect(proof.disposition).toBe('safely-eliminated') + expect(proof.reachingDefinitions).toEqual([String(proof.producerPc)]) + expect(proof.useSites).toEqual([ + { pc: proof.movePc, register: proof.sourceRegister }, + ]) + expect(proof.producerRetained).toBe(true) + expect(proof.evaluationOrderPreserved).toBe(true) + expect(proof.receiverSemanticsPreserved).toBe(true) + } + staticChecks(result.output) + expect(execute(result.output)).toEqual(execute(input.source)) + } +}) + +test('joins and capture cells remain barriers to copy coalescing', () => { + const branching = fixture('f-branching') + const branchingDiagnosis = diagnoseStandalone(branching.encoded) + expect(branchingDiagnosis.ok).toBe(true) + const branchingResult = branchingDiagnosis.result + const branchingLeaves = allLeaves(branchingResult.control) + for (const proof of branchingResult.simplification.functions.flatMap( + ({ copies }) => copies, + )) { + const move = branchingLeaves.find( + ({ functionId, leaf }) => + functionId === proof.functionId && leaf.pc === proof.movePc, + ) + expect(move).toBeDefined() + expect( + incomingCount(branchingResult.control, move.functionId, move.block), + ).toBe(1) + } + expect(execute(branchingResult.output)).toEqual(execute(branching.source)) + + const closure = fixture('closure-loop', 'readability') + const closureDiagnosis = diagnoseStandalone(closure.encoded) + expect(closureDiagnosis.ok).toBe(true) + const closureFunctions = closureDiagnosis.result.simplification.functions + expect( + closureFunctions.find(({ functionId }) => functionId === 1).copies, + ).toEqual([]) + expect( + closureFunctions.find(({ functionId }) => functionId === 2).copies, + ).toEqual([]) + expect(closureDiagnosis.result.output).toContain( + 'const __recovered_cells_1 = [];', + ) + expect(execute(closureDiagnosis.result.output)).toEqual( + execute(closure.source), + ) +}) + +test('effectful producers are retained while only their private move is removed', () => { + const input = fixture('f-expression-mutation') + const diagnosis = diagnoseStandalone(input.encoded) + expect(diagnosis.ok).toBe(true) + const leaves = allLeaves(diagnosis.result.control) + const effectfulNames = new Set(['ADD', 'BUILD_ARRAY', 'BUILD_OBJECT']) + const effectfulProofs = diagnosis.result.simplification.functions + .flatMap(({ copies }) => copies) + .filter((proof) => { + const producer = leaves.find( + ({ functionId, leaf }) => + functionId === proof.functionId && leaf.pc === proof.producerPc, + ) + return effectfulNames.has(producer?.leaf.name) + }) + expect(effectfulProofs.length).toBeGreaterThan(0) + expect( + effectfulProofs.every(({ producerRetained }) => producerRetained), + ).toBe(true) + staticChecks(diagnosis.result.output) + expect(execute(diagnosis.result.output)).toEqual(execute(input.source)) +}) diff --git a/test/vm/switch/vm-switch-to-source.test.js b/test/vm/switch/vm-switch-to-source.test.js new file mode 100644 index 00000000..b013dfc5 --- /dev/null +++ b/test/vm/switch/vm-switch-to-source.test.js @@ -0,0 +1,318 @@ +import { test, expect } from 'vitest' +import { runInNewContext } from 'node:vm' +import { emitVmSwitchProgram } from '../../../src/vm/switch/vm-switch-to-source.js' +import { validateVmSwitchModel } from '../../../src/vm/switch/vm-switch-model.js' + +function sourceSwitchModel() { + const cases = [ + { + pc: 0, + nextPc: 1, + operation: { + kind: 'assign', + destination: 0, + value: { kind: 'literal', value: 7 }, + }, + }, + { + pc: 1, + nextPc: 2, + operation: { kind: 'global-write', name: 'RESULT', source: 0 }, + }, + { pc: 2, nextPc: 3, operation: { kind: 'return', source: 0 } }, + ] + return { + schemaVersion: 'decode-js-vm-switch.v1', + code: { kind: 'source-switch', instructionCount: 3 }, + storage: { + kind: 'register-machine', + functions: [{ id: 0, registerCount: 1, captureCount: 0 }], + }, + functions: { + functions: [ + { + id: 0, + startPc: 0, + regCount: 1, + paramCount: 0, + captureCount: 0, + hasRest: false, + }, + ], + }, + instructionsByFunction: new Map([[0, cases]]), + instructionCount: 3, + } +} + +function withCases(cases, registerCount) { + const model = sourceSwitchModel() + model.code.instructionCount = cases.length + model.storage.functions[0].registerCount = registerCount + model.functions.functions[0].regCount = registerCount + model.instructionsByFunction.set(0, cases) + model.instructionCount = cases.length + return model +} + +test('derives linear control and emits a source-switch model without numeric wordcode', () => { + const model = sourceSwitchModel() + expect(validateVmSwitchModel(model)).toBe(model) + const { output } = emitVmSwitchProgram(model) + const context = { RESULT: null } + context.globalThis = context + runInNewContext(output, context) + expect(context.RESULT).toBe(7) + expect(output).not.toContain('BYTECODE') + expect(output).not.toContain('__recovered_dispatch') +}) + +test('derives a structured branch from typed source-switch cases', () => { + const model = withCases( + [ + { + pc: 0, + nextPc: 1, + operation: { + kind: 'assign', + destination: 0, + value: { kind: 'literal', value: true }, + }, + }, + { + pc: 1, + nextPc: 2, + operation: { kind: 'branch', condition: 0, when: 'true', target: 4 }, + }, + { + pc: 2, + nextPc: 3, + operation: { + kind: 'assign', + destination: 1, + value: { kind: 'literal', value: 11 }, + }, + }, + { pc: 3, nextPc: 4, operation: { kind: 'jump', target: 5 } }, + { + pc: 4, + nextPc: 5, + operation: { + kind: 'assign', + destination: 1, + value: { kind: 'literal', value: 22 }, + }, + }, + { + pc: 5, + nextPc: 6, + operation: { kind: 'global-write', name: 'RESULT', source: 1 }, + }, + { pc: 6, nextPc: 7, operation: { kind: 'return', source: 1 } }, + ], + 2, + ) + const { output } = emitVmSwitchProgram(model) + const context = { RESULT: null } + context.globalThis = context + runInNewContext(output, context) + expect(context.RESULT).toBe(22) + expect(output).toContain('if (') + expect(output).not.toContain('__recovered_dispatch') + + model.instructionsByFunction.get(0)[0].operation.value.value = false + const falseContext = { RESULT: null } + falseContext.globalThis = falseContext + runInNewContext(emitVmSwitchProgram(model).output, falseContext) + expect(falseContext.RESULT).toBe(11) +}) + +test('derives control for a source-switch loop without a frontend region plan', () => { + const model = withCases( + [ + { + pc: 0, + nextPc: 1, + operation: { + kind: 'assign', + destination: 0, + value: { kind: 'literal', value: 3 }, + }, + }, + { + pc: 1, + nextPc: 2, + operation: { + kind: 'assign', + destination: 1, + value: { kind: 'literal', value: 0 }, + }, + }, + { + pc: 2, + nextPc: 3, + operation: { + kind: 'assign', + destination: 2, + value: { kind: 'literal', value: 1 }, + }, + }, + { + pc: 3, + nextPc: 4, + operation: { kind: 'branch', condition: 0, when: 'false', target: 7 }, + }, + { + pc: 4, + nextPc: 5, + operation: { + kind: 'assign', + destination: 1, + value: { kind: 'binary', operator: '+', left: 1, right: 0 }, + }, + }, + { + pc: 5, + nextPc: 6, + operation: { + kind: 'assign', + destination: 0, + value: { kind: 'binary', operator: '-', left: 0, right: 2 }, + }, + }, + { pc: 6, nextPc: 7, operation: { kind: 'jump', target: 3 } }, + { + pc: 7, + nextPc: 8, + operation: { kind: 'global-write', name: 'RESULT', source: 1 }, + }, + { pc: 8, nextPc: 9, operation: { kind: 'return', source: 1 } }, + ], + 3, + ) + const { output } = emitVmSwitchProgram(model) + const context = { RESULT: null } + context.globalThis = context + runInNewContext(output, context) + expect(context.RESULT).toBe(6) + expect(output).not.toContain('__recovered_dispatch') +}) + +test('retains dispatch for an indirect transfer without a frontend control plan', () => { + const model = withCases( + [ + { + pc: 0, + nextPc: 1, + operation: { + kind: 'assign', + destination: 0, + value: { kind: 'literal', value: 2 }, + }, + }, + { pc: 1, nextPc: 2, operation: { kind: 'indirect-jump', source: 0 } }, + { + pc: 2, + nextPc: 3, + operation: { kind: 'global-write', name: 'RESULT', source: 0 }, + }, + { pc: 3, nextPc: 4, operation: { kind: 'return', source: 0 } }, + ], + 1, + ) + const { output } = emitVmSwitchProgram(model) + const context = { RESULT: null } + context.globalThis = context + runInNewContext(output, context) + expect(context.RESULT).toBe(2) + expect(output).toContain('__recovered_dispatch') + expect(output).not.toContain('catch (__recovered_error)') + expect(output).not.toContain('__recovered_handlers') +}) + +test('retains a catch dispatch when a typed case installs a handler', () => { + const model = withCases( + [ + { + pc: 0, + nextPc: 1, + operation: { kind: 'catch-setup', target: 3, exceptionRegister: 0 }, + }, + { + pc: 1, + nextPc: 2, + operation: { + kind: 'assign', + destination: 1, + value: { kind: 'literal', value: 'boom' }, + }, + }, + { pc: 2, nextPc: 3, operation: { kind: 'throw', source: 1 } }, + { + pc: 3, + nextPc: 4, + operation: { kind: 'global-write', name: 'RESULT', source: 0 }, + }, + { pc: 4, nextPc: 5, operation: { kind: 'return', source: 0 } }, + ], + 2, + ) + model.controlByFunction = new Map([[0, { mode: 'state-machine' }]]) + model.structuredControl = { edges: [] } + const { output } = emitVmSwitchProgram(model) + const context = { RESULT: null } + context.globalThis = context + runInNewContext(output, context) + expect(context.RESULT).toBe('boom') + expect(output).toContain('catch (__recovered_error)') + expect(output).toContain('switch (__recovered_handler.register)') +}) + +test('falls back to dispatch when typed branches have no structured merge', () => { + const model = withCases( + [ + { + pc: 0, + nextPc: 1, + operation: { + kind: 'assign', + destination: 0, + value: { kind: 'literal', value: true }, + }, + }, + { + pc: 1, + nextPc: 2, + operation: { kind: 'branch', condition: 0, when: 'true', target: 3 }, + }, + { pc: 2, nextPc: 3, operation: { kind: 'return', source: 0 } }, + { pc: 3, nextPc: 4, operation: { kind: 'return', source: 0 } }, + ], + 1, + ) + const { output } = emitVmSwitchProgram(model) + expect(runInNewContext(output, { globalThis: {} })).toBe(undefined) + expect(output).toContain('__recovered_dispatch') +}) + +test('rejects malformed switch semantics before source emission', () => { + const malicious = sourceSwitchModel() + malicious.instructionsByFunction.get(0)[0].operation.value = { + kind: 'binary', + operator: '+;globalThis.RESULT=99;//', + left: 0, + right: 0, + } + expect(() => emitVmSwitchProgram(malicious)).toThrowError(/operator/i) + + const missingTarget = sourceSwitchModel() + missingTarget.instructionsByFunction.get(0)[1].operation = { + kind: 'jump', + target: 99, + } + expect(() => emitVmSwitchProgram(missingTarget)).toThrowError(/missing case/i) + + const missingCase = sourceSwitchModel() + missingCase.instructionsByFunction.get(0).pop() + expect(() => emitVmSwitchProgram(missingCase)).toThrowError(/missing case/i) +}) From 336171e3fdde8fb7a7b9b68316f6370ee8095e3f Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:49:18 +0100 Subject: [PATCH 4/5] chore(decode-js): retire duplicate plugin documentation Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- docs/jsconfuser.md | 61 ---------------------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 docs/jsconfuser.md diff --git a/docs/jsconfuser.md b/docs/jsconfuser.md deleted file mode 100644 index 114a903b..00000000 --- a/docs/jsconfuser.md +++ /dev/null @@ -1,61 +0,0 @@ -# Decoding JS-Confuser output - -`-t jsconfuser` (`npm run dejsc`) reverses output from -[JS-Confuser](https://github.com/MichaelXF/js-confuser) 2.x, up to and including the -`high` preset. The pre-2.0 `AntiTooling` shape is still recognized as well, for -real-world samples predating the 2.0 rewrite that removed it. - -The target is a fixed sequence of AST passes, each reversing one JS-Confuser transform or -a piece of one. Several transforms are visited more than once: a transform that runs -*early* on the encode side has its output reshaped by every later encode stage, so its -decode pass often cannot match anything until the layers above it have come off. - -## What is reversed - -**Wrappers.** `Pack`'s eval wrapper, `RGF`'s eval-wrapped sub-programs — decoded -recursively through this same pipeline — and `Integrity`'s hash-guarded functions. - -**Control flow.** `ControlFlowFlattening`'s state-vector switch interpreters, -`ControlFlowGraph`, `Dispatcher`, and `Flatten`. - -**Strings and literals.** `StringConcealing`, `StringCompression`, `StringSplitting`, -`DuplicateLiteralsRemoval`, and `GlobalConcealing`. `Finalizer`'s hex and escape -re-spelling (`0x1a`, `"\x48"`) is undone too — the parser preserves the raw source -spelling of a literal and the generator prefers it, so these print back out unchanged -unless something removes it explicitly. - -**Data flow.** `VariableMasking`, `MovedDeclarations`, `Calculator`, -`ExpressionObfuscation`, and the padding `preserveFunctionLength` adds. - -**Guards and noise.** `OpaquePredicates`, `DeadCode`, `AstScrambler`, and all six `Lock` -features — antiDebug, selfDefending, dateLock, domainLock, tamperProtection, and -`invokeCountermeasures` cleanup. - -Decoding a concealed string or a masked variable slot means evaluating the obfuscator's -own runtime helper, which happens inside an [isolated-vm](https://github.com/laverdet/isolated-vm) -sandbox rather than in this process. - -## What is not reversed - -Three transforms destroy information rather than hide it, so there is nothing to -reconstruct: - -* **`RenameVariables`** and **`RenameLabels`** — a mangled name is drawn from a generator - with no relationship to the name it replaced. Nothing in the output records the - original. -* **`ObjectExtraction`** — it leaves no structural trace at all. `obj.a` becomes a bare - identifier reference, indistinguishable after renaming from any other local, and - nothing marks a group of loose variables as having once been one object. - -**`Minify`** needs no reversal of its own: its literal shortenings (`!0`, `void 0`, -`1/0`) and merged `var` declarations are undone by passes shared with the other targets, -and everything else it does is formatting or dead-code removal with nothing to recover. - -## Limits - -* An `ObjectExtraction` decoder would have something to work with only if - `renameVariables` were explicitly disabled, since the placeholder naming would then - survive into the output. That is a non-default configuration and is not implemented. -* `Finalizer` can leave a `__JS_CONFUSER_VAR__(id)` marker call when `RenameVariables` is - disabled. It exists purely to clean up after renaming rather than to obscure anything, - so it is left alone. From e0806fe79c732d017d09f6a4b2bec4482d969553 Mon Sep 17 00:00:00 2001 From: echo094 <20028238+echo094@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:13:33 +0100 Subject: [PATCH 5/5] refactor(vm/jsconfuser-vm): split validation and role modules Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com> --- .../analyze-closure-lifetimes.js | 6 +- .../closure-lifetimes-contract.js | 244 +++++++ .../jsconfuser-vm/closure-lifetimes-flow.js | 2 +- .../jsconfuser-vm/closure-lifetimes-input.js | 550 +------------- .../closure-lifetimes-ownership.js | 2 +- .../closure-lifetimes-wordcode.js | 309 ++++++++ .../jsconfuser-vm/container-runtime-roles.js | 425 +++++++++++ .../jsconfuser-vm/emit-structured-control.js | 6 +- src/vm/jsconfuser-vm/extract-container.js | 419 +---------- .../partition-functions-input.js | 676 +++++++++++++++++ src/vm/jsconfuser-vm/partition-functions.js | 686 +----------------- .../jsconfuser-vm/structured-control-cfg.js | 10 +- .../structured-control-contract.js | 168 +++++ .../structured-control-exceptions.js | 4 +- .../jsconfuser-vm/structured-control-input.js | 322 +------- .../structured-control-predecessors.js | 149 ++++ 16 files changed, 2027 insertions(+), 1951 deletions(-) create mode 100644 src/vm/jsconfuser-vm/closure-lifetimes-contract.js create mode 100644 src/vm/jsconfuser-vm/closure-lifetimes-wordcode.js create mode 100644 src/vm/jsconfuser-vm/container-runtime-roles.js create mode 100644 src/vm/jsconfuser-vm/partition-functions-input.js create mode 100644 src/vm/jsconfuser-vm/structured-control-contract.js create mode 100644 src/vm/jsconfuser-vm/structured-control-predecessors.js diff --git a/src/vm/jsconfuser-vm/analyze-closure-lifetimes.js b/src/vm/jsconfuser-vm/analyze-closure-lifetimes.js index 087a270f..f6ac4887 100644 --- a/src/vm/jsconfuser-vm/analyze-closure-lifetimes.js +++ b/src/vm/jsconfuser-vm/analyze-closure-lifetimes.js @@ -12,9 +12,9 @@ import { register, sortedNumbers, sortedStrings, - validateReferences, - validateWordcode, -} from './closure-lifetimes-input.js' +} from './closure-lifetimes-contract.js' +import { validateReferences } from './closure-lifetimes-input.js' +import { validateWordcode } from './closure-lifetimes-wordcode.js' import { validateCallFrames, validateCfg, diff --git a/src/vm/jsconfuser-vm/closure-lifetimes-contract.js b/src/vm/jsconfuser-vm/closure-lifetimes-contract.js new file mode 100644 index 00000000..da642eb6 --- /dev/null +++ b/src/vm/jsconfuser-vm/closure-lifetimes-contract.js @@ -0,0 +1,244 @@ +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const CALL_FRAME_SCHEMA = 'jsconfuser-vm-call-frames.v1' +export const CLOSURE_SCHEMA = 'jsconfuser-vm-closure-lifetimes.v1' + +export const FRAME_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const CALL_NAMES = new Set(['CALL', 'CALL_METHOD', 'NEW']) +export const UPVALUE_NAMES = new Set(['LOAD_UPVALUE', 'STORE_UPVALUE']) +export const EDGE_KINDS = new Set([ + 'fallthrough', + 'branch', + 'conditional', + 'call', + 'return', + 'throw', + 'handler', + 'finally', +]) + +export const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +export const REGISTER_RESULT_NAMES = new Set([ + 'LOAD_CONST', + 'LOAD_INT', + 'LOAD_GLOBAL', + 'LOAD_UPVALUE', + 'LOAD_THIS', + 'MOVE', + 'GET_PROP', + 'DELETE_PROP', + 'ADD', + 'SUB', + 'MUL', + 'DIV', + 'MOD', + 'EXP', + 'BAND', + 'BOR', + 'BXOR', + 'SHL', + 'SHR', + 'USHR', + 'LT', + 'GT', + 'LTE', + 'GTE', + 'EQ', + 'NEQ', + 'LOOSE_EQ', + 'LOOSE_NEQ', + 'IN', + 'INSTANCEOF', + 'UNARY_NEG', + 'UNARY_POS', + 'UNARY_NOT', + 'UNARY_BITNOT', + 'TYPEOF', + 'VOID', + 'TYPEOF_SAFE', + 'CALL', + 'CALL_METHOD', + 'NEW', + 'MAKE_CLOSURE', + 'BUILD_ARRAY', + 'BUILD_OBJECT', + 'FOR_IN_SETUP', + 'FOR_IN_NEXT', +]) + +export class ClosureLifetimeDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'ClosureLifetimeDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new ClosureLifetimeDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +export function requireArray(value, code, message) { + if (!Array.isArray(value)) decline(code, message) + return value +} + +export function requireInteger(value, code, message) { + if (!Number.isInteger(value) || value < 0) decline(code, message) + return value +} + +export function requireBoolean(value, code, message) { + if (typeof value !== 'boolean') decline(code, message) + return value +} + +export function sameValue(actual, expected) { + if (actual === expected) return true + if (Array.isArray(actual) && Array.isArray(expected)) { + return ( + actual.length === expected.length && + actual.every((value, index) => sameValue(value, expected[index])) + ) + } + if (isObject(actual) && isObject(expected)) { + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + sameValue(actual[key], expected[key]), + ) + ) + } + return false +} + +export function requireSame(actual, expected, code, message) { + if (!sameValue(actual, expected)) decline(code, message) +} + +export function register(index) { + return { kind: 'register', index } +} + +export function destinationFor(instruction) { + return instruction.destination ?? instruction.operands[0] +} + +export function arraySet(values) { + return new Set(values) +} + +export function sortedNumbers(values) { + return [...values].sort((left, right) => left - right) +} + +export function sortedStrings(values) { + return [...values].sort() +} diff --git a/src/vm/jsconfuser-vm/closure-lifetimes-flow.js b/src/vm/jsconfuser-vm/closure-lifetimes-flow.js index bdde86c0..e0b55578 100644 --- a/src/vm/jsconfuser-vm/closure-lifetimes-flow.js +++ b/src/vm/jsconfuser-vm/closure-lifetimes-flow.js @@ -6,7 +6,7 @@ import { decline, destinationFor, register, -} from './closure-lifetimes-input.js' +} from './closure-lifetimes-contract.js' import { sameAbstractValue, sameEnvironment, diff --git a/src/vm/jsconfuser-vm/closure-lifetimes-input.js b/src/vm/jsconfuser-vm/closure-lifetimes-input.js index 66a34918..4387d4e3 100644 --- a/src/vm/jsconfuser-vm/closure-lifetimes-input.js +++ b/src/vm/jsconfuser-vm/closure-lifetimes-input.js @@ -1,542 +1,14 @@ -export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' -export const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' -export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' -export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' -export const CALL_FRAME_SCHEMA = 'jsconfuser-vm-call-frames.v1' -export const CLOSURE_SCHEMA = 'jsconfuser-vm-closure-lifetimes.v1' - -export const FRAME_SLOTS = Object.freeze({ - PC: 0, - CALLER: 1, - RET_DST: 2, - THIS: 3, - CLOSURE: 4, - HANDLERS: 5, - FRAME_SIZE: 6, - REG_BASE: 7, -}) - -export const CALL_NAMES = new Set(['CALL', 'CALL_METHOD', 'NEW']) -export const UPVALUE_NAMES = new Set(['LOAD_UPVALUE', 'STORE_UPVALUE']) -export const EDGE_KINDS = new Set([ - 'fallthrough', - 'branch', - 'conditional', - 'call', - 'return', - 'throw', - 'handler', - 'finally', -]) - -const CANONICAL_OPCODES = Object.freeze({ - LOAD_CONST: 0, - LOAD_INT: 1, - LOAD_GLOBAL: 2, - LOAD_UPVALUE: 3, - LOAD_THIS: 4, - MOVE: 5, - STORE_GLOBAL: 6, - STORE_UPVALUE: 7, - GET_PROP: 8, - SET_PROP: 9, - DELETE_PROP: 10, - ADD: 11, - SUB: 12, - MUL: 13, - DIV: 14, - MOD: 15, - EXP: 60, - BAND: 16, - BOR: 17, - BXOR: 18, - SHL: 19, - SHR: 20, - USHR: 21, - LT: 22, - GT: 23, - LTE: 24, - GTE: 25, - EQ: 26, - NEQ: 27, - LOOSE_EQ: 28, - LOOSE_NEQ: 29, - IN: 30, - INSTANCEOF: 31, - UNARY_NEG: 32, - UNARY_POS: 33, - UNARY_NOT: 34, - UNARY_BITNOT: 35, - TYPEOF: 36, - VOID: 37, - TYPEOF_SAFE: 38, - JUMP: 39, - JUMP_IF_FALSE: 40, - JUMP_IF_TRUE: 41, - CALL: 42, - CALL_METHOD: 43, - NEW: 44, - RETURN: 45, - THROW: 46, - MAKE_CLOSURE: 47, - BUILD_ARRAY: 48, - BUILD_OBJECT: 49, - DEFINE_GETTER: 50, - DEFINE_SETTER: 51, - FOR_IN_SETUP: 52, - FOR_IN_NEXT: 53, - TRY_SETUP: 54, - TRY_END: 55, - PATCH: 56, - DEBUGGER: 57, - JUMP_REG: 58, - FINALLY_SETUP: 59, -}) - -export const REGISTER_RESULT_NAMES = new Set([ - 'LOAD_CONST', - 'LOAD_INT', - 'LOAD_GLOBAL', - 'LOAD_UPVALUE', - 'LOAD_THIS', - 'MOVE', - 'GET_PROP', - 'DELETE_PROP', - 'ADD', - 'SUB', - 'MUL', - 'DIV', - 'MOD', - 'EXP', - 'BAND', - 'BOR', - 'BXOR', - 'SHL', - 'SHR', - 'USHR', - 'LT', - 'GT', - 'LTE', - 'GTE', - 'EQ', - 'NEQ', - 'LOOSE_EQ', - 'LOOSE_NEQ', - 'IN', - 'INSTANCEOF', - 'UNARY_NEG', - 'UNARY_POS', - 'UNARY_NOT', - 'UNARY_BITNOT', - 'TYPEOF', - 'VOID', - 'TYPEOF_SAFE', - 'CALL', - 'CALL_METHOD', - 'NEW', - 'MAKE_CLOSURE', - 'BUILD_ARRAY', - 'BUILD_OBJECT', - 'FOR_IN_SETUP', - 'FOR_IN_NEXT', -]) - -export class ClosureLifetimeDecline extends Error { - constructor(code, message) { - super(message) - this.name = 'ClosureLifetimeDecline' - this.code = code - } -} - -export function decline(code, message) { - throw new ClosureLifetimeDecline(code, message) -} - -export function deepFreeze(value) { - if (!value || typeof value !== 'object' || Object.isFrozen(value)) { - return value - } - Object.freeze(value) - for (const child of Object.values(value)) deepFreeze(child) - return value -} - -export function cloneData(value) { - if (Array.isArray(value)) return value.map((child) => cloneData(child)) - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, cloneData(child)]), - ) - } - return value -} - -function isObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -export function requireObject(value, code, message) { - if (!isObject(value)) decline(code, message) - return value -} - -export function requireArray(value, code, message) { - if (!Array.isArray(value)) decline(code, message) - return value -} - -export function requireInteger(value, code, message) { - if (!Number.isInteger(value) || value < 0) decline(code, message) - return value -} - -export function requireBoolean(value, code, message) { - if (typeof value !== 'boolean') decline(code, message) - return value -} - -export function sameValue(actual, expected) { - if (actual === expected) return true - if (Array.isArray(actual) && Array.isArray(expected)) { - return ( - actual.length === expected.length && - actual.every((value, index) => sameValue(value, expected[index])) - ) - } - if (isObject(actual) && isObject(expected)) { - const actualKeys = Object.keys(actual) - const expectedKeys = Object.keys(expected) - return ( - actualKeys.length === expectedKeys.length && - expectedKeys.every( - (key) => - Object.prototype.hasOwnProperty.call(actual, key) && - sameValue(actual[key], expected[key]), - ) - ) - } - return false -} - -export function requireSame(actual, expected, code, message) { - if (!sameValue(actual, expected)) decline(code, message) -} - -export function register(index) { - return { kind: 'register', index } -} - -export function destinationFor(instruction) { - return instruction.destination ?? instruction.operands[0] -} - -export function arraySet(values) { - return new Set(values) -} - -export function sortedNumbers(values) { - return [...values].sort((left, right) => left - right) -} - -export function sortedStrings(values) { - return [...values].sort() -} - -export function validateWordcode(wordcode) { - requireObject(wordcode, 'invalid-wordcode', 'Packet B must be an object') - if ( - wordcode.schemaVersion !== WORDCODE_SCHEMA || - wordcode.encoding !== 'numeric-u32' - ) { - decline('invalid-wordcode', 'Packet B schema or encoding is not accepted') - } - requireInteger( - wordcode.wordCount, - 'invalid-wordcode', - 'Packet B wordCount is invalid', - ) - requireInteger( - wordcode.instructionCount, - 'invalid-wordcode', - 'Packet B instructionCount is invalid', - ) - const words = requireArray( - wordcode.words, - 'invalid-wordcode', - 'Packet B has no copied word stream', - ) - if (words.length !== wordcode.wordCount) { - decline('invalid-wordcode', 'Packet B wordCount does not match words') - } - words.forEach((word) => { - if (!Number.isInteger(word) || word < 0 || word > 0xffffffff) { - decline('invalid-wordcode', 'Packet B contains a non-u32 word') - } - }) - const instructions = requireArray( - wordcode.instructions, - 'invalid-wordcode', - 'Packet B has no instructions', - ) - if (instructions.length !== wordcode.instructionCount) { - decline( - 'invalid-wordcode', - 'Packet B instructionCount does not match instructions', - ) - } - - let expectedPc = 0 - const byPc = new Map() - for (const instruction of instructions) { - requireObject( - instruction, - 'invalid-wordcode', - 'Packet B instruction is malformed', - ) - requireInteger( - instruction.pc, - 'invalid-wordcode', - 'Packet B instruction PC is invalid', - ) - if (instruction.pc !== expectedPc || byPc.has(instruction.pc)) { - decline( - 'invalid-wordcode', - 'Packet B instruction boundaries are not contiguous', - ) - } - const name = instruction.name - if (typeof name !== 'string' || CANONICAL_OPCODES[name] === undefined) { - decline( - 'invalid-wordcode', - `Packet B has unknown opcode at PC ${instruction.pc}`, - ) - } - if (name === 'PATCH') { - decline('invalid-wordcode', 'Packet B contains a forbidden PATCH opcode') - } - requireObject( - instruction.opcode, - 'invalid-wordcode', - 'Packet B opcode is malformed', - ) - requireSame( - instruction.opcode, - { name, value: CANONICAL_OPCODES[name] }, - 'invalid-wordcode', - `Packet B opcode metadata is stale at PC ${instruction.pc}`, - ) - const encodedWords = requireArray( - instruction.words, - 'invalid-wordcode', - `Packet B words are missing at PC ${instruction.pc}`, - ) - requireInteger( - instruction.width, - 'invalid-wordcode', - 'Packet B instruction width is invalid', - ) - if ( - instruction.width !== encodedWords.length || - instruction.nextPc !== instruction.pc + instruction.width || - instruction.nextPc > wordcode.wordCount || - encodedWords.length === 0 - ) { - decline( - 'invalid-wordcode', - `Packet B instruction width is stale at PC ${instruction.pc}`, - ) - } - requireSame( - encodedWords, - words.slice(instruction.pc, instruction.nextPc), - 'invalid-wordcode', - `Packet B instruction words are stale at PC ${instruction.pc}`, - ) - requireArray( - instruction.operands, - 'invalid-wordcode', - 'Packet B operands are missing', - ) - requireArray( - instruction.wordOperands, - 'invalid-wordcode', - 'Packet B word operands are missing', - ) - if (REGISTER_RESULT_NAMES.has(name)) { - const destination = destinationFor(instruction) - if (!destination || destination.kind !== 'register') { - decline( - 'invalid-wordcode', - `Packet B result destination is missing at PC ${instruction.pc}`, - ) - } - } - if (name === 'MAKE_CLOSURE') validateClosureInstruction(instruction) - if (name === 'LOAD_UPVALUE' || name === 'STORE_UPVALUE') { - validateUpvalueInstruction(instruction) - } - byPc.set(instruction.pc, instruction) - expectedPc = instruction.nextPc - } - if (expectedPc !== wordcode.wordCount) { - decline( - 'invalid-wordcode', - 'Packet B does not consume the complete word stream', - ) - } - return { - words, - instructions, - byPc, - boundaries: arraySet(instructions.map(({ pc }) => pc)), - } -} - -function validateClosureInstruction(instruction) { - const metadata = requireObject( - instruction.functionMeta, - 'invalid-capture-pair', - `MAKE_CLOSURE metadata is missing at PC ${instruction.pc}`, - ) - const form = requireObject( - instruction.form, - 'invalid-capture-pair', - `MAKE_CLOSURE form is missing at PC ${instruction.pc}`, - ) - if ( - form.kind !== 'closure' || - !sameValue( - { - startPc: form.startPc, - paramCount: form.paramCount, - regCount: form.regCount, - captureCount: form.captureCount, - hasRest: form.hasRest, - }, - metadata, - ) - ) { - decline( - 'invalid-capture-pair', - `MAKE_CLOSURE form is stale at PC ${instruction.pc}`, - ) - } - for (const key of ['startPc', 'paramCount', 'regCount', 'captureCount']) { - requireInteger( - metadata[key], - 'invalid-capture-pair', - `MAKE_CLOSURE ${key} is invalid at PC ${instruction.pc}`, - ) - } - requireBoolean( - metadata.hasRest, - 'invalid-capture-pair', - `MAKE_CLOSURE hasRest is invalid at PC ${instruction.pc}`, - ) - if ( - metadata.startPc !== instruction.words[2] || - metadata.paramCount !== instruction.words[3] - ) { - decline( - 'invalid-capture-pair', - `MAKE_CLOSURE header disagrees at PC ${instruction.pc}`, - ) - } - if ( - metadata.regCount !== instruction.words[4] || - metadata.captureCount !== instruction.words[5] || - Number(metadata.hasRest) !== instruction.words[6] - ) { - decline( - 'invalid-capture-pair', - `MAKE_CLOSURE capture metadata disagrees at PC ${instruction.pc}`, - ) - } - const captures = requireArray( - instruction.captures, - 'invalid-capture-pair', - `MAKE_CLOSURE captures are missing at PC ${instruction.pc}`, - ) - const capturePairs = requireArray( - instruction.capturePairs, - 'invalid-capture-pair', - `MAKE_CLOSURE capturePairs are missing at PC ${instruction.pc}`, - ) - if ( - captures.length !== metadata.captureCount || - capturePairs.length !== metadata.captureCount || - instruction.words.length !== 7 + metadata.captureCount * 2 - ) { - decline( - 'invalid-capture-pair', - `MAKE_CLOSURE capture count is incomplete at PC ${instruction.pc}`, - ) - } - requireSame( - captures, - capturePairs, - 'invalid-capture-pair', - `MAKE_CLOSURE capture views diverge at PC ${instruction.pc}`, - ) - captures.forEach((capture, index) => { - requireObject( - capture, - 'invalid-capture-pair', - 'MAKE_CLOSURE capture is malformed', - ) - if (capture.kind !== 'local' && capture.kind !== 'upvalue') { - decline( - 'invalid-capture-pair', - `MAKE_CLOSURE capture kind is invalid at PC ${instruction.pc}`, - ) - } - requireInteger( - capture.index, - 'invalid-capture-pair', - `MAKE_CLOSURE capture index is invalid at PC ${instruction.pc}`, - ) - if (capture.isLocal !== (capture.kind === 'local')) { - decline( - 'invalid-capture-pair', - `MAKE_CLOSURE capture identity is inconsistent at PC ${instruction.pc}`, - ) - } - const pairOffset = 7 + index * 2 - if ( - instruction.words[pairOffset] !== Number(capture.isLocal) || - instruction.words[pairOffset + 1] !== capture.index - ) { - decline( - 'invalid-capture-pair', - `MAKE_CLOSURE raw pair is stale at PC ${instruction.pc}`, - ) - } - }) -} - -function validateUpvalueInstruction(instruction) { - const operands = instruction.operands - if ( - operands.length !== 2 || - operands[0]?.kind !== - (instruction.name === 'LOAD_UPVALUE' ? 'register' : 'upvalue-index') || - operands[1]?.kind !== - (instruction.name === 'LOAD_UPVALUE' ? 'upvalue-index' : 'register') - ) { - decline( - 'invalid-wordcode', - `Packet B upvalue operand shape is invalid at PC ${instruction.pc}`, - ) - } - for (const operand of operands) { - requireInteger( - operand.index, - 'invalid-wordcode', - `Packet B upvalue operand index is invalid at PC ${instruction.pc}`, - ) - } -} +import { + FRAME_SLOTS, + REFERENCE_SCHEMA, + decline, + requireArray, + requireBoolean, + requireInteger, + requireObject, + requireSame, + sameValue, +} from './closure-lifetimes-contract.js' export function validateReferences(references, wordcodeData) { requireObject(references, 'invalid-references', 'Packet C must be an object') diff --git a/src/vm/jsconfuser-vm/closure-lifetimes-ownership.js b/src/vm/jsconfuser-vm/closure-lifetimes-ownership.js index f62c83bc..624d881a 100644 --- a/src/vm/jsconfuser-vm/closure-lifetimes-ownership.js +++ b/src/vm/jsconfuser-vm/closure-lifetimes-ownership.js @@ -16,7 +16,7 @@ import { requireObject, requireSame, sameValue, -} from './closure-lifetimes-input.js' +} from './closure-lifetimes-contract.js' import { routeMatches } from './closure-lifetimes-flow.js' export function validateFunctions( diff --git a/src/vm/jsconfuser-vm/closure-lifetimes-wordcode.js b/src/vm/jsconfuser-vm/closure-lifetimes-wordcode.js new file mode 100644 index 00000000..f733d1df --- /dev/null +++ b/src/vm/jsconfuser-vm/closure-lifetimes-wordcode.js @@ -0,0 +1,309 @@ +import { + CANONICAL_OPCODES, + REGISTER_RESULT_NAMES, + WORDCODE_SCHEMA, + arraySet, + decline, + destinationFor, + requireArray, + requireBoolean, + requireInteger, + requireObject, + requireSame, + sameValue, +} from './closure-lifetimes-contract.js' + +export function validateWordcode(wordcode) { + requireObject(wordcode, 'invalid-wordcode', 'Packet B must be an object') + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' + ) { + decline('invalid-wordcode', 'Packet B schema or encoding is not accepted') + } + requireInteger( + wordcode.wordCount, + 'invalid-wordcode', + 'Packet B wordCount is invalid', + ) + requireInteger( + wordcode.instructionCount, + 'invalid-wordcode', + 'Packet B instructionCount is invalid', + ) + const words = requireArray( + wordcode.words, + 'invalid-wordcode', + 'Packet B has no copied word stream', + ) + if (words.length !== wordcode.wordCount) { + decline('invalid-wordcode', 'Packet B wordCount does not match words') + } + words.forEach((word) => { + if (!Number.isInteger(word) || word < 0 || word > 0xffffffff) { + decline('invalid-wordcode', 'Packet B contains a non-u32 word') + } + }) + const instructions = requireArray( + wordcode.instructions, + 'invalid-wordcode', + 'Packet B has no instructions', + ) + if (instructions.length !== wordcode.instructionCount) { + decline( + 'invalid-wordcode', + 'Packet B instructionCount does not match instructions', + ) + } + + let expectedPc = 0 + const byPc = new Map() + for (const instruction of instructions) { + requireObject( + instruction, + 'invalid-wordcode', + 'Packet B instruction is malformed', + ) + requireInteger( + instruction.pc, + 'invalid-wordcode', + 'Packet B instruction PC is invalid', + ) + if (instruction.pc !== expectedPc || byPc.has(instruction.pc)) { + decline( + 'invalid-wordcode', + 'Packet B instruction boundaries are not contiguous', + ) + } + const name = instruction.name + if (typeof name !== 'string' || CANONICAL_OPCODES[name] === undefined) { + decline( + 'invalid-wordcode', + `Packet B has unknown opcode at PC ${instruction.pc}`, + ) + } + if (name === 'PATCH') { + decline('invalid-wordcode', 'Packet B contains a forbidden PATCH opcode') + } + requireObject( + instruction.opcode, + 'invalid-wordcode', + 'Packet B opcode is malformed', + ) + requireSame( + instruction.opcode, + { name, value: CANONICAL_OPCODES[name] }, + 'invalid-wordcode', + `Packet B opcode metadata is stale at PC ${instruction.pc}`, + ) + const encodedWords = requireArray( + instruction.words, + 'invalid-wordcode', + `Packet B words are missing at PC ${instruction.pc}`, + ) + requireInteger( + instruction.width, + 'invalid-wordcode', + 'Packet B instruction width is invalid', + ) + if ( + instruction.width !== encodedWords.length || + instruction.nextPc !== instruction.pc + instruction.width || + instruction.nextPc > wordcode.wordCount || + encodedWords.length === 0 + ) { + decline( + 'invalid-wordcode', + `Packet B instruction width is stale at PC ${instruction.pc}`, + ) + } + requireSame( + encodedWords, + words.slice(instruction.pc, instruction.nextPc), + 'invalid-wordcode', + `Packet B instruction words are stale at PC ${instruction.pc}`, + ) + requireArray( + instruction.operands, + 'invalid-wordcode', + 'Packet B operands are missing', + ) + requireArray( + instruction.wordOperands, + 'invalid-wordcode', + 'Packet B word operands are missing', + ) + if (REGISTER_RESULT_NAMES.has(name)) { + const destination = destinationFor(instruction) + if (!destination || destination.kind !== 'register') { + decline( + 'invalid-wordcode', + `Packet B result destination is missing at PC ${instruction.pc}`, + ) + } + } + if (name === 'MAKE_CLOSURE') validateClosureInstruction(instruction) + if (name === 'LOAD_UPVALUE' || name === 'STORE_UPVALUE') { + validateUpvalueInstruction(instruction) + } + byPc.set(instruction.pc, instruction) + expectedPc = instruction.nextPc + } + if (expectedPc !== wordcode.wordCount) { + decline( + 'invalid-wordcode', + 'Packet B does not consume the complete word stream', + ) + } + return { + words, + instructions, + byPc, + boundaries: arraySet(instructions.map(({ pc }) => pc)), + } +} + +function validateClosureInstruction(instruction) { + const metadata = requireObject( + instruction.functionMeta, + 'invalid-capture-pair', + `MAKE_CLOSURE metadata is missing at PC ${instruction.pc}`, + ) + const form = requireObject( + instruction.form, + 'invalid-capture-pair', + `MAKE_CLOSURE form is missing at PC ${instruction.pc}`, + ) + if ( + form.kind !== 'closure' || + !sameValue( + { + startPc: form.startPc, + paramCount: form.paramCount, + regCount: form.regCount, + captureCount: form.captureCount, + hasRest: form.hasRest, + }, + metadata, + ) + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE form is stale at PC ${instruction.pc}`, + ) + } + for (const key of ['startPc', 'paramCount', 'regCount', 'captureCount']) { + requireInteger( + metadata[key], + 'invalid-capture-pair', + `MAKE_CLOSURE ${key} is invalid at PC ${instruction.pc}`, + ) + } + requireBoolean( + metadata.hasRest, + 'invalid-capture-pair', + `MAKE_CLOSURE hasRest is invalid at PC ${instruction.pc}`, + ) + if ( + metadata.startPc !== instruction.words[2] || + metadata.paramCount !== instruction.words[3] + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE header disagrees at PC ${instruction.pc}`, + ) + } + if ( + metadata.regCount !== instruction.words[4] || + metadata.captureCount !== instruction.words[5] || + Number(metadata.hasRest) !== instruction.words[6] + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE capture metadata disagrees at PC ${instruction.pc}`, + ) + } + const captures = requireArray( + instruction.captures, + 'invalid-capture-pair', + `MAKE_CLOSURE captures are missing at PC ${instruction.pc}`, + ) + const capturePairs = requireArray( + instruction.capturePairs, + 'invalid-capture-pair', + `MAKE_CLOSURE capturePairs are missing at PC ${instruction.pc}`, + ) + if ( + captures.length !== metadata.captureCount || + capturePairs.length !== metadata.captureCount || + instruction.words.length !== 7 + metadata.captureCount * 2 + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE capture count is incomplete at PC ${instruction.pc}`, + ) + } + requireSame( + captures, + capturePairs, + 'invalid-capture-pair', + `MAKE_CLOSURE capture views diverge at PC ${instruction.pc}`, + ) + captures.forEach((capture, index) => { + requireObject( + capture, + 'invalid-capture-pair', + 'MAKE_CLOSURE capture is malformed', + ) + if (capture.kind !== 'local' && capture.kind !== 'upvalue') { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE capture kind is invalid at PC ${instruction.pc}`, + ) + } + requireInteger( + capture.index, + 'invalid-capture-pair', + `MAKE_CLOSURE capture index is invalid at PC ${instruction.pc}`, + ) + if (capture.isLocal !== (capture.kind === 'local')) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE capture identity is inconsistent at PC ${instruction.pc}`, + ) + } + const pairOffset = 7 + index * 2 + if ( + instruction.words[pairOffset] !== Number(capture.isLocal) || + instruction.words[pairOffset + 1] !== capture.index + ) { + decline( + 'invalid-capture-pair', + `MAKE_CLOSURE raw pair is stale at PC ${instruction.pc}`, + ) + } + }) +} + +function validateUpvalueInstruction(instruction) { + const operands = instruction.operands + if ( + operands.length !== 2 || + operands[0]?.kind !== + (instruction.name === 'LOAD_UPVALUE' ? 'register' : 'upvalue-index') || + operands[1]?.kind !== + (instruction.name === 'LOAD_UPVALUE' ? 'upvalue-index' : 'register') + ) { + decline( + 'invalid-wordcode', + `Packet B upvalue operand shape is invalid at PC ${instruction.pc}`, + ) + } + for (const operand of operands) { + requireInteger( + operand.index, + 'invalid-wordcode', + `Packet B upvalue operand index is invalid at PC ${instruction.pc}`, + ) + } +} diff --git a/src/vm/jsconfuser-vm/container-runtime-roles.js b/src/vm/jsconfuser-vm/container-runtime-roles.js new file mode 100644 index 00000000..17593233 --- /dev/null +++ b/src/vm/jsconfuser-vm/container-runtime-roles.js @@ -0,0 +1,425 @@ +import { + EXPECTED_SLOTS, + binaryParts, + computedMember, + decline, + findVariable, + hasNode, + isIdentifier, + isStableDeclaration, + member, + methodCall, + newCall, + numberValue, + parameterBinding, + readPlainObject, + referenceCount, + scalarValue, + slotMember, + thisMember, + topLevelVariables, + uniqueCandidate, +} from './container-role-helpers.js' + +export function identifyVmMethodRoles(methods, context, roles) { + const { op, slots, upvalue, upvalueClose } = roles + const operand = methods.filter(({ fn }) => { + if (fn.params.length !== 0 || fn.body?.body.length !== 1) return false + const statement = fn.body.body[0] + const outer = + statement.type === 'ReturnStatement' ? statement.argument : null + if ( + !computedMember( + outer, + (value) => thisMember(value, 'bytecode'), + (value) => + value?.type === 'UpdateExpression' && + value.operator === '++' && + value.prefix === false, + ) + ) { + return false + } + const pc = outer.property.argument + return computedMember( + pc, + (value) => thisMember(value, '_regs'), + (value) => + value?.type === 'BinaryExpression' && + value.operator === '+' && + thisMember(value.left, '_f') && + slotMember(value.right, slots.binding, 'PC', context), + ) + }) + + const pushFrame = methods.filter(({ fn }) => { + if (fn.params.length !== 4) return false + const fnLocal = findVariable(context, fn, (init) => + member( + init, + (value) => context.sameRef(value, parameterBinding(context, fn, 0)), + 'fn', + ), + ) + const regsLocal = findVariable(context, fn, (init) => + thisMember(init, '_regs'), + ) + const fpLocal = findVariable(context, fn, (init) => + thisMember(init, '_regsTop'), + ) + if (!fnLocal || !regsLocal || !fpLocal) return false + const headers = roles.headerCandidates + const sizeLocal = findVariable(context, fn, (init) => { + const parts = binaryParts(init) + return ( + !!parts && + parts.some((part) => + member( + part, + (value) => context.sameRef(value, context.bindingFor(fnLocal.id)), + 'regCount', + ), + ) && + parts.some((part) => + headers.some((header) => context.sameRef(part, header.binding)), + ) + ) + }) + const baseLocal = findVariable(context, fn, (init) => { + const parts = binaryParts(init) + return ( + !!parts && + parts.some((part) => + context.sameRef(part, context.bindingFor(fpLocal.id)), + ) && + parts.some((part) => + headers.some((header) => context.sameRef(part, header.binding)), + ) + ) + }) + const endLocal = findVariable(context, fn, (init) => { + const parts = binaryParts(init) + return ( + !!parts && + parts.some((part) => + context.sameRef(part, context.bindingFor(fpLocal.id)), + ) && + parts.some((part) => + context.sameRef(part, context.bindingFor(sizeLocal?.id)), + ) + ) + }) + if (!sizeLocal || !baseLocal || !endLocal) return false + return ( + hasNode( + context, + fn, + (node) => + node.type === 'AssignmentExpression' && + thisMember(node.left, '_regsTop') && + context.sameRef(node.right, context.bindingFor(endLocal.id)), + ) && + hasNode( + context, + fn, + (node) => + node.type === 'AssignmentExpression' && + thisMember(node.left, '_f') && + context.sameRef(node.right, context.bindingFor(fpLocal.id)), + ) && + Object.keys(EXPECTED_SLOTS) + .filter((role) => role !== 'HANDLERS') + .every((role) => + hasNode(context, fn, (node) => + slotMember(node, slots.binding, role, context), + ), + ) + ) + }) + + const captureUpvalue = methods.filter( + ({ fn }) => + fn.params.length === 2 && + newCall(context, fn, upvalue.binding) && + hasNode(context, fn, (node) => thisMember(node, '_openUpvalues')) && + hasNode(context, fn, (node) => thisMember(node, '_regs')) && + hasNode(context, fn, (node) => + slotMember(node, slots.binding, 'REG_BASE', context), + ) && + hasNode(context, fn, (node) => node.type === 'ReturnStatement'), + ) + + const constant = methods.filter( + ({ fn }) => + fn.params.length === 2 && + hasNode(context, fn, (node) => thisMember(node, 'constants')) && + methodCall( + context, + fn, + (value) => value?.type === 'ThisExpression', + operand[0]?.name, + ) && + hasNode(context, fn, (node) => node.type === 'ReturnStatement'), + ) + + const closeUpvalues = methods.filter( + ({ fn }) => + fn.params.length === 1 && + hasNode(context, fn, (node) => thisMember(node, '_openUpvalues')) && + hasNode(context, fn, (node) => thisMember(node, '_regs')) && + hasNode(context, fn, (node) => + slotMember(node, slots.binding, 'FRAME_SIZE', context), + ) && + hasNode(context, fn, (node) => + slotMember(node, slots.binding, 'REG_BASE', context), + ) && + methodCall(context, fn, () => true, upvalueClose[0]?.name), + ) + + const run = methods.filter( + ({ fn }) => + fn.params.length === 3 && + hasNode(context, fn, (node) => node.type === 'WhileStatement') && + hasNode(context, fn, (node) => node.type === 'SwitchStatement') && + hasNode(context, fn, (node) => node.type === 'ReturnStatement') && + hasNode( + context, + fn, + (node) => + node.type === 'MemberExpression' && + !node.computed && + context.sameRef(node.object, op.binding), + ), + ) + + return { operand, pushFrame, captureUpvalue, constant, closeUpvalues, run } +} + +export function bootGraph(context, declarations, roles) { + const variables = topLevelVariables(declarations) + const globals = variables.filter( + (candidate) => + isIdentifier(candidate.init, 'globalThis') && + isStableDeclaration(candidate), + ) + const globalBinding = uniqueCandidate(globals, 'global object binding') + const vmRoots = variables.filter( + (candidate) => + candidate.init?.type === 'NewExpression' && + context.sameRef(candidate.init.callee, roles.vm.binding), + ) + const rootCandidates = vmRoots.filter((candidate) => { + const args = candidate.init.arguments + return ( + args.length === 3 && + args[0]?.type === 'CallExpression' && + args[0].arguments.length === 1 && + context.sameRef(args[0].arguments[0], roles.words.binding) && + context.sameRef(args[1], roles.pool.binding) && + context.sameRef(args[2], globalBinding.binding) + ) + }) + const vmRoot = uniqueCandidate(rootCandidates, 'root VM binding') + const decoderBinding = context.bindingFor(vmRoot.init.arguments[0].callee) + const decoder = declarations.filter( + (candidate) => + candidate.kind === 'function' && candidate.binding === decoderBinding, + ) + const decoderRole = uniqueCandidate(decoder, 'bytecode decoder binding') + const calls = context + .nodes(context.program) + .filter( + ({ node }) => + node.type === 'CallExpression' && + node.callee?.type === 'MemberExpression' && + !node.callee.computed && + context.sameRef(node.callee.object, vmRoot.binding), + ) + if (calls.length !== 1) { + decline('ambiguous-role', 'Expected exactly one root VM method call') + } + const rootCall = calls[0].node + const args = rootCall.arguments + if ( + args.length !== 3 || + args[0]?.type !== 'NewExpression' || + !context.sameRef(args[0].callee, roles.closure.binding) || + args[1]?.type !== 'Identifier' || + args[1].name !== 'undefined' || + context.bindingFor(args[1]) || + args[2]?.type !== 'NullLiteral' + ) { + decline('altered-boot', 'Root VM boot call is not the pinned Closure form') + } + const closureEntries = readPlainObject(args[0].arguments[0]) + if ( + !closureEntries || + closureEntries.size !== 3 || + numberValue(closureEntries.get('paramCount')) !== 0 + ) { + decline('malformed-role', 'Root Closure descriptor is malformed') + } + const start = context.bindingFor(closureEntries.get('startPc')) + const regCount = context.bindingFor(closureEntries.get('regCount')) + if (!start || !regCount) { + decline('missing-role', 'Root Closure descriptor lacks scalar bindings') + } + const scalar = (binding, role) => { + const candidate = variables.find((item) => item.binding === binding) + if (!candidate || scalarValue(candidate.init) === undefined) { + decline('missing-role', `Missing ${role} scalar binding`) + } + return candidate + } + const startRole = scalar(start, 'MAIN_START_PC') + const regRole = scalar(regCount, 'MAIN_REG_COUNT') + if ( + scalarValue(startRole.init) !== 0 || + !Number.isSafeInteger(scalarValue(regRole.init)) || + scalarValue(regRole.init) <= 0 + ) { + decline( + 'altered-boot', + 'Root scalar values do not describe the baseline frame', + ) + } + if ( + referenceCount(context, startRole.binding) !== 1 || + referenceCount(context, regRole.binding) !== 1 + ) { + decline('ambiguous-role', 'Root scalar bindings have unexpected references') + } + return { + closure: roles.closure, + decoder: decoderRole, + globals: globalBinding, + regCount: regRole, + rootCall, + start: startRole, + vm: vmRoot, + } +} + +function decoderUsesNumericPath(context, decoder, encode) { + const parameter = parameterBinding(context, decoder.init, 0) + return ( + hasNode( + context, + decoder.init, + (node) => + node.type === 'IfStatement' && + node.test?.type === 'UnaryExpression' && + node.test.operator === '!' && + context.sameRef(node.test.argument, encode.binding) && + node.consequent?.type === 'ReturnStatement' && + context.sameRef(node.consequent.argument, parameter), + ) && + hasNode( + context, + decoder.init, + (node) => + node.type === 'NewExpression' && + isIdentifier(node.callee, 'Uint32Array'), + ) + ) +} + +export function identifyScalars(context, declarations, roles, boot) { + const variables = topLevelVariables(declarations) + const scalars = variables.filter( + (candidate) => scalarValue(candidate.init) !== undefined, + ) + const encodedCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === true && + hasNode( + context, + boot.decoder.init, + (node) => + node.type === 'IfStatement' && + node.test?.type === 'UnaryExpression' && + node.test.operator === '!' && + context.sameRef(node.test.argument, candidate.binding), + ), + ) + if (encodedCandidates.length === 1) { + decline( + 'unsupported-encoded-bytecode', + 'ENCODE_BYTECODE=true is outside the numeric-wordcode boundary', + ) + } + if (encodedCandidates.length > 1) { + decline('ambiguous-role', 'Ambiguous ENCODE_BYTECODE scalar') + } + const encodeCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === false && + referenceCount(context, candidate.binding) === 1 && + decoderUsesNumericPath(context, boot.decoder, candidate), + ) + const encode = uniqueCandidate(encodeCandidates, 'ENCODE_BYTECODE scalar') + + const frameStartCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === 1 && + candidate.binding === roles.vmShape.frameStartBinding && + referenceCount(context, candidate.binding, roles.vm.init) === 1 && + referenceCount(context, candidate.binding) === 1, + ) + const frameStart = uniqueCandidate(frameStartCandidates, 'FRAME_START scalar') + + const headerCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === 8 && + referenceCount( + context, + candidate.binding, + roles.vmMethods.pushFrame[0].fn, + ) === 2 && + hasNode(context, roles.vmMethods.pushFrame[0].fn, (node) => { + const parts = binaryParts(node) + return ( + !!parts && + parts.some((part) => context.sameRef(part, candidate.binding)) && + parts.some((part) => member(part, () => true, 'regCount')) + ) + }), + ) + const header = uniqueCandidate(headerCandidates, 'HEADER_SIZE scalar') + + const timingCandidates = scalars.filter( + (candidate) => + scalarValue(candidate.init) === false && + referenceCount(context, candidate.binding, roles.vmMethods.run[0].fn) === + 2 && + hasNode( + context, + roles.vmMethods.run[0].fn, + (node) => + node.type === 'IfStatement' && + context.sameRef(node.test, candidate.binding), + ) && + hasNode( + context, + roles.vmMethods.run[0].fn, + (node) => + node.type === 'BinaryExpression' && + node.operator === '>' && + context.sameRef(node.right, candidate.binding), + ), + ) + const timing = uniqueCandidate(timingCandidates, 'TIMING_CHECKS scalar') + + if ( + !encode || + !frameStart || + !header || + !timing || + scalarValue(encode.init) !== false || + scalarValue(timing.init) !== false + ) { + decline('malformed-role', 'Pinned scalar roles are inconsistent') + } + roles.headerBinding = header.binding + return { encode, frameStart, header, timing } +} diff --git a/src/vm/jsconfuser-vm/emit-structured-control.js b/src/vm/jsconfuser-vm/emit-structured-control.js index 211e9f6a..3cb30846 100644 --- a/src/vm/jsconfuser-vm/emit-structured-control.js +++ b/src/vm/jsconfuser-vm/emit-structured-control.js @@ -5,12 +5,12 @@ import { StructuredControlDecline, WORDCODE_SCHEMA, arrayEqual, - canonicalWordcode, cloneData, decline, deepFreeze, - edgeKey, -} from './structured-control-input.js' +} from './structured-control-contract.js' +import { canonicalWordcode } from './structured-control-predecessors.js' +import { edgeKey } from './structured-control-input.js' import { validateCfg } from './structured-control-cfg.js' import { validateExceptionFinally } from './structured-control-exceptions.js' diff --git a/src/vm/jsconfuser-vm/extract-container.js b/src/vm/jsconfuser-vm/extract-container.js index 5ff88752..e278f289 100644 --- a/src/vm/jsconfuser-vm/extract-container.js +++ b/src/vm/jsconfuser-vm/extract-container.js @@ -5,33 +5,22 @@ import { EXPECTED_SENTINELS, EXPECTED_SLOTS, PARSER_OPTIONS, - binaryParts, closureShape, - computedMember, decline, deepFreeze, exactNumericObject, - findVariable, functionDeclarations, - hasNode, - isIdentifier, isPoolArray, - isStableDeclaration, isWordArray, makeContext, - member, methodCall, newCall, - numberValue, parameterBinding, propertyName, prototypeMethods, - readPlainObject, referenceCount, referencePaths, scalarValue, - slotMember, - thisMember, topLevelDeclarations, topLevelVariables, uniqueCandidate, @@ -39,409 +28,11 @@ import { upvalueShape, vmShape, } from './container-role-helpers.js' - -function identifyVmMethodRoles(methods, context, roles) { - const { op, slots, upvalue, upvalueClose } = roles - const operand = methods.filter(({ fn }) => { - if (fn.params.length !== 0 || fn.body?.body.length !== 1) return false - const statement = fn.body.body[0] - const outer = - statement.type === 'ReturnStatement' ? statement.argument : null - if ( - !computedMember( - outer, - (value) => thisMember(value, 'bytecode'), - (value) => - value?.type === 'UpdateExpression' && - value.operator === '++' && - value.prefix === false, - ) - ) { - return false - } - const pc = outer.property.argument - return computedMember( - pc, - (value) => thisMember(value, '_regs'), - (value) => - value?.type === 'BinaryExpression' && - value.operator === '+' && - thisMember(value.left, '_f') && - slotMember(value.right, slots.binding, 'PC', context), - ) - }) - - const pushFrame = methods.filter(({ fn }) => { - if (fn.params.length !== 4) return false - const fnLocal = findVariable(context, fn, (init) => - member( - init, - (value) => context.sameRef(value, parameterBinding(context, fn, 0)), - 'fn', - ), - ) - const regsLocal = findVariable(context, fn, (init) => - thisMember(init, '_regs'), - ) - const fpLocal = findVariable(context, fn, (init) => - thisMember(init, '_regsTop'), - ) - if (!fnLocal || !regsLocal || !fpLocal) return false - const headers = roles.headerCandidates - const sizeLocal = findVariable(context, fn, (init) => { - const parts = binaryParts(init) - return ( - !!parts && - parts.some((part) => - member( - part, - (value) => context.sameRef(value, context.bindingFor(fnLocal.id)), - 'regCount', - ), - ) && - parts.some((part) => - headers.some((header) => context.sameRef(part, header.binding)), - ) - ) - }) - const baseLocal = findVariable(context, fn, (init) => { - const parts = binaryParts(init) - return ( - !!parts && - parts.some((part) => - context.sameRef(part, context.bindingFor(fpLocal.id)), - ) && - parts.some((part) => - headers.some((header) => context.sameRef(part, header.binding)), - ) - ) - }) - const endLocal = findVariable(context, fn, (init) => { - const parts = binaryParts(init) - return ( - !!parts && - parts.some((part) => - context.sameRef(part, context.bindingFor(fpLocal.id)), - ) && - parts.some((part) => - context.sameRef(part, context.bindingFor(sizeLocal?.id)), - ) - ) - }) - if (!sizeLocal || !baseLocal || !endLocal) return false - return ( - hasNode( - context, - fn, - (node) => - node.type === 'AssignmentExpression' && - thisMember(node.left, '_regsTop') && - context.sameRef(node.right, context.bindingFor(endLocal.id)), - ) && - hasNode( - context, - fn, - (node) => - node.type === 'AssignmentExpression' && - thisMember(node.left, '_f') && - context.sameRef(node.right, context.bindingFor(fpLocal.id)), - ) && - Object.keys(EXPECTED_SLOTS) - .filter((role) => role !== 'HANDLERS') - .every((role) => - hasNode(context, fn, (node) => - slotMember(node, slots.binding, role, context), - ), - ) - ) - }) - - const captureUpvalue = methods.filter( - ({ fn }) => - fn.params.length === 2 && - newCall(context, fn, upvalue.binding) && - hasNode(context, fn, (node) => thisMember(node, '_openUpvalues')) && - hasNode(context, fn, (node) => thisMember(node, '_regs')) && - hasNode(context, fn, (node) => - slotMember(node, slots.binding, 'REG_BASE', context), - ) && - hasNode(context, fn, (node) => node.type === 'ReturnStatement'), - ) - - const constant = methods.filter( - ({ fn }) => - fn.params.length === 2 && - hasNode(context, fn, (node) => thisMember(node, 'constants')) && - methodCall( - context, - fn, - (value) => value?.type === 'ThisExpression', - operand[0]?.name, - ) && - hasNode(context, fn, (node) => node.type === 'ReturnStatement'), - ) - - const closeUpvalues = methods.filter( - ({ fn }) => - fn.params.length === 1 && - hasNode(context, fn, (node) => thisMember(node, '_openUpvalues')) && - hasNode(context, fn, (node) => thisMember(node, '_regs')) && - hasNode(context, fn, (node) => - slotMember(node, slots.binding, 'FRAME_SIZE', context), - ) && - hasNode(context, fn, (node) => - slotMember(node, slots.binding, 'REG_BASE', context), - ) && - methodCall(context, fn, () => true, upvalueClose[0]?.name), - ) - - const run = methods.filter( - ({ fn }) => - fn.params.length === 3 && - hasNode(context, fn, (node) => node.type === 'WhileStatement') && - hasNode(context, fn, (node) => node.type === 'SwitchStatement') && - hasNode(context, fn, (node) => node.type === 'ReturnStatement') && - hasNode( - context, - fn, - (node) => - node.type === 'MemberExpression' && - !node.computed && - context.sameRef(node.object, op.binding), - ), - ) - - return { operand, pushFrame, captureUpvalue, constant, closeUpvalues, run } -} - -function bootGraph(context, declarations, roles) { - const variables = topLevelVariables(declarations) - const globals = variables.filter( - (candidate) => - isIdentifier(candidate.init, 'globalThis') && - isStableDeclaration(candidate), - ) - const globalBinding = uniqueCandidate(globals, 'global object binding') - const vmRoots = variables.filter( - (candidate) => - candidate.init?.type === 'NewExpression' && - context.sameRef(candidate.init.callee, roles.vm.binding), - ) - const rootCandidates = vmRoots.filter((candidate) => { - const args = candidate.init.arguments - return ( - args.length === 3 && - args[0]?.type === 'CallExpression' && - args[0].arguments.length === 1 && - context.sameRef(args[0].arguments[0], roles.words.binding) && - context.sameRef(args[1], roles.pool.binding) && - context.sameRef(args[2], globalBinding.binding) - ) - }) - const vmRoot = uniqueCandidate(rootCandidates, 'root VM binding') - const decoderBinding = context.bindingFor(vmRoot.init.arguments[0].callee) - const decoder = declarations.filter( - (candidate) => - candidate.kind === 'function' && candidate.binding === decoderBinding, - ) - const decoderRole = uniqueCandidate(decoder, 'bytecode decoder binding') - const calls = context - .nodes(context.program) - .filter( - ({ node }) => - node.type === 'CallExpression' && - node.callee?.type === 'MemberExpression' && - !node.callee.computed && - context.sameRef(node.callee.object, vmRoot.binding), - ) - if (calls.length !== 1) { - decline('ambiguous-role', 'Expected exactly one root VM method call') - } - const rootCall = calls[0].node - const args = rootCall.arguments - if ( - args.length !== 3 || - args[0]?.type !== 'NewExpression' || - !context.sameRef(args[0].callee, roles.closure.binding) || - args[1]?.type !== 'Identifier' || - args[1].name !== 'undefined' || - context.bindingFor(args[1]) || - args[2]?.type !== 'NullLiteral' - ) { - decline('altered-boot', 'Root VM boot call is not the pinned Closure form') - } - const closureEntries = readPlainObject(args[0].arguments[0]) - if ( - !closureEntries || - closureEntries.size !== 3 || - numberValue(closureEntries.get('paramCount')) !== 0 - ) { - decline('malformed-role', 'Root Closure descriptor is malformed') - } - const start = context.bindingFor(closureEntries.get('startPc')) - const regCount = context.bindingFor(closureEntries.get('regCount')) - if (!start || !regCount) { - decline('missing-role', 'Root Closure descriptor lacks scalar bindings') - } - const scalar = (binding, role) => { - const candidate = variables.find((item) => item.binding === binding) - if (!candidate || scalarValue(candidate.init) === undefined) { - decline('missing-role', `Missing ${role} scalar binding`) - } - return candidate - } - const startRole = scalar(start, 'MAIN_START_PC') - const regRole = scalar(regCount, 'MAIN_REG_COUNT') - if ( - scalarValue(startRole.init) !== 0 || - !Number.isSafeInteger(scalarValue(regRole.init)) || - scalarValue(regRole.init) <= 0 - ) { - decline( - 'altered-boot', - 'Root scalar values do not describe the baseline frame', - ) - } - if ( - referenceCount(context, startRole.binding) !== 1 || - referenceCount(context, regRole.binding) !== 1 - ) { - decline('ambiguous-role', 'Root scalar bindings have unexpected references') - } - return { - closure: roles.closure, - decoder: decoderRole, - globals: globalBinding, - regCount: regRole, - rootCall, - start: startRole, - vm: vmRoot, - } -} - -function decoderUsesNumericPath(context, decoder, encode) { - const parameter = parameterBinding(context, decoder.init, 0) - return ( - hasNode( - context, - decoder.init, - (node) => - node.type === 'IfStatement' && - node.test?.type === 'UnaryExpression' && - node.test.operator === '!' && - context.sameRef(node.test.argument, encode.binding) && - node.consequent?.type === 'ReturnStatement' && - context.sameRef(node.consequent.argument, parameter), - ) && - hasNode( - context, - decoder.init, - (node) => - node.type === 'NewExpression' && - isIdentifier(node.callee, 'Uint32Array'), - ) - ) -} - -function identifyScalars(context, declarations, roles, boot) { - const variables = topLevelVariables(declarations) - const scalars = variables.filter( - (candidate) => scalarValue(candidate.init) !== undefined, - ) - const encodedCandidates = scalars.filter( - (candidate) => - scalarValue(candidate.init) === true && - hasNode( - context, - boot.decoder.init, - (node) => - node.type === 'IfStatement' && - node.test?.type === 'UnaryExpression' && - node.test.operator === '!' && - context.sameRef(node.test.argument, candidate.binding), - ), - ) - if (encodedCandidates.length === 1) { - decline( - 'unsupported-encoded-bytecode', - 'ENCODE_BYTECODE=true is outside the numeric-wordcode boundary', - ) - } - if (encodedCandidates.length > 1) { - decline('ambiguous-role', 'Ambiguous ENCODE_BYTECODE scalar') - } - const encodeCandidates = scalars.filter( - (candidate) => - scalarValue(candidate.init) === false && - referenceCount(context, candidate.binding) === 1 && - decoderUsesNumericPath(context, boot.decoder, candidate), - ) - const encode = uniqueCandidate(encodeCandidates, 'ENCODE_BYTECODE scalar') - - const frameStartCandidates = scalars.filter( - (candidate) => - scalarValue(candidate.init) === 1 && - candidate.binding === roles.vmShape.frameStartBinding && - referenceCount(context, candidate.binding, roles.vm.init) === 1 && - referenceCount(context, candidate.binding) === 1, - ) - const frameStart = uniqueCandidate(frameStartCandidates, 'FRAME_START scalar') - - const headerCandidates = scalars.filter( - (candidate) => - scalarValue(candidate.init) === 8 && - referenceCount( - context, - candidate.binding, - roles.vmMethods.pushFrame[0].fn, - ) === 2 && - hasNode(context, roles.vmMethods.pushFrame[0].fn, (node) => { - const parts = binaryParts(node) - return ( - !!parts && - parts.some((part) => context.sameRef(part, candidate.binding)) && - parts.some((part) => member(part, () => true, 'regCount')) - ) - }), - ) - const header = uniqueCandidate(headerCandidates, 'HEADER_SIZE scalar') - - const timingCandidates = scalars.filter( - (candidate) => - scalarValue(candidate.init) === false && - referenceCount(context, candidate.binding, roles.vmMethods.run[0].fn) === - 2 && - hasNode( - context, - roles.vmMethods.run[0].fn, - (node) => - node.type === 'IfStatement' && - context.sameRef(node.test, candidate.binding), - ) && - hasNode( - context, - roles.vmMethods.run[0].fn, - (node) => - node.type === 'BinaryExpression' && - node.operator === '>' && - context.sameRef(node.right, candidate.binding), - ), - ) - const timing = uniqueCandidate(timingCandidates, 'TIMING_CHECKS scalar') - - if ( - !encode || - !frameStart || - !header || - !timing || - scalarValue(encode.init) !== false || - scalarValue(timing.init) !== false - ) { - decline('malformed-role', 'Pinned scalar roles are inconsistent') - } - roles.headerBinding = header.binding - return { encode, frameStart, header, timing } -} +import { + bootGraph, + identifyScalars, + identifyVmMethodRoles, +} from './container-runtime-roles.js' function assertMapReferences(context, runtime, roles) { const opUses = referencePaths(context, roles.op.binding) diff --git a/src/vm/jsconfuser-vm/partition-functions-input.js b/src/vm/jsconfuser-vm/partition-functions-input.js new file mode 100644 index 00000000..811903d9 --- /dev/null +++ b/src/vm/jsconfuser-vm/partition-functions-input.js @@ -0,0 +1,676 @@ +const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' +const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' +export const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' +const UINT32_MAX = 0xffffffff + +const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +const NAME_BY_OPCODE = new Map( + Object.entries(CANONICAL_OPCODES).map(([name, value]) => [value, name]), +) + +const DIRECT_LABEL_OPERANDS = Object.freeze({ + JUMP: Object.freeze([0]), + JUMP_IF_FALSE: Object.freeze([1]), + JUMP_IF_TRUE: Object.freeze([1]), + FOR_IN_NEXT: Object.freeze([2]), + TRY_SETUP: Object.freeze([0]), + FINALLY_SETUP: Object.freeze([0, 3]), + MAKE_CLOSURE: Object.freeze([1]), +}) + +const LABEL_ROLES = Object.freeze({ + JUMP: Object.freeze({ 0: 'target' }), + JUMP_IF_FALSE: Object.freeze({ 1: 'target' }), + JUMP_IF_TRUE: Object.freeze({ 1: 'target' }), + FOR_IN_NEXT: Object.freeze({ 2: 'exit' }), + TRY_SETUP: Object.freeze({ 0: 'handler' }), + FINALLY_SETUP: Object.freeze({ 0: 'finally', 3: 'throwPad' }), + MAKE_CLOSURE: Object.freeze({ 1: 'functionEntry' }), +}) + +export class PartitionDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'PartitionDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new PartitionDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +function isUint32(value) { + return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX +} + +function isSafeCount(value) { + return Number.isSafeInteger(value) && value >= 0 +} + +function exactObjectValues(actual, expected) { + if (!isObject(actual)) return false + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + actual[key] === expected[key], + ) + ) +} + +function sameArray(left, right) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +function requireUint32(value, code, message) { + if (!isUint32(value)) decline(code, message) + return value +} + +function requireSafeCount(value, code, message) { + if (!isSafeCount(value)) decline(code, message) + return value +} + +function cloneCapture(capture, location) { + requireObject(capture, 'invalid-descriptor', `${location} is not an object`) + if ( + (capture.kind !== 'local' && capture.kind !== 'upvalue') || + !isUint32(capture.index) || + capture.isLocal !== (capture.kind === 'local') + ) { + decline('invalid-descriptor', `${location} has an invalid capture`) + } + return { + kind: capture.kind, + index: capture.index, + isLocal: capture.isLocal, + } +} + +function descriptorSignature(descriptor) { + return JSON.stringify({ + startPc: descriptor.startPc, + paramCount: descriptor.paramCount, + regCount: descriptor.regCount, + captureCount: descriptor.captureCount, + hasRest: descriptor.hasRest, + captures: descriptor.captures, + }) +} + +export function validateContainer(container) { + requireObject( + container, + 'invalid-input', + 'Expected a Packet A container result', + ) + if ( + container.schemaVersion !== CONTAINER_SCHEMA || + container.encoding !== 'numeric-u32' + ) { + decline('invalid-container', 'Input is not a Packet A numeric container') + } + const roles = requireObject( + container.roles, + 'invalid-container', + 'Packet A result has no roles object', + ) + const wordsRole = requireObject( + roles.words, + 'invalid-container', + 'Packet A result has no wordcode role', + ) + const words = wordsRole.values + if (!Array.isArray(words) || words.length === 0 || !words.every(isUint32)) { + decline( + 'invalid-container', + 'Packet A wordcode must be a non-empty unsigned 32-bit array', + ) + } + return words +} + +export function validateWordcode(wordcode, containerWords) { + requireObject( + wordcode, + 'invalid-input', + 'Expected a Packet B wordcode result', + ) + if ( + wordcode.schemaVersion !== WORDCODE_SCHEMA || + wordcode.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-wordcode', + 'Input is not a Packet B numeric wordcode result', + ) + } + const words = wordcode.words + if ( + !Array.isArray(words) || + !sameArray(words, containerWords) || + !words.every(isUint32) + ) { + decline('input-mismatch', 'Packet B words do not match Packet A wordcode') + } + if ( + wordcode.wordCount !== words.length || + wordcode.consumedWords !== words.length || + wordcode.nextPc !== words.length || + !Array.isArray(wordcode.instructions) || + wordcode.instructionCount !== wordcode.instructions.length || + wordcode.instructions.length === 0 + ) { + decline('invalid-wordcode', 'Packet B consumption metadata is inconsistent') + } + + const instructions = [] + const byPc = new Map() + let nextPc = 0 + for (const instruction of wordcode.instructions) { + requireObject( + instruction, + 'malformed-instruction', + 'Packet B contains a malformed instruction record', + ) + if (instruction.pc !== nextPc || !isSafeCount(instruction.pc)) { + decline('invalid-wordcode', 'Packet B instruction PCs are not contiguous') + } + if ( + typeof instruction.name !== 'string' || + NAME_BY_OPCODE.get(instruction.words?.[0]) !== instruction.name + ) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has an unknown opcode name`, + ) + } + if ( + !Array.isArray(instruction.words) || + instruction.words.length === 0 || + !instruction.words.every(isUint32) || + !isSafeCount(instruction.width) || + instruction.width !== instruction.words.length || + instruction.nextPc !== instruction.pc + instruction.width || + !sameArray( + instruction.words, + words.slice(instruction.pc, instruction.nextPc), + ) + ) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has invalid width or words`, + ) + } + if (!Array.isArray(instruction.operands)) { + decline( + 'malformed-instruction', + `Instruction at ${instruction.pc} has no typed operands`, + ) + } + if (byPc.has(instruction.pc)) { + decline( + 'invalid-wordcode', + `Packet B contains duplicate instruction pc ${instruction.pc}`, + ) + } + byPc.set(instruction.pc, instruction) + instructions.push(instruction) + nextPc = instruction.nextPc + } + if (nextPc !== words.length) { + decline( + 'invalid-wordcode', + 'Packet B instructions do not consume all words', + ) + } + return { + words, + instructions, + byPc, + boundaries: instructions.map(({ pc }) => pc), + boundarySet: new Set(instructions.map(({ pc }) => pc)), + } +} + +export function validateFrame(references, wordcodeData) { + requireObject( + references, + 'invalid-input', + 'Expected a Packet C reference/frame result', + ) + if ( + references.schemaVersion !== REFERENCE_SCHEMA || + references.encoding !== 'numeric-u32' + ) { + decline( + 'invalid-references', + 'Input is not a Packet C reference/frame result', + ) + } + if ( + references.wordCount !== wordcodeData.words.length || + references.instructionCount !== wordcodeData.instructions.length + ) { + decline('input-mismatch', 'Packet C counts do not match Packet B wordcode') + } + + const labels = requireObject( + references.labels, + 'invalid-references', + 'Packet C result has no labels section', + ) + if (!sameArray(labels.boundaries, wordcodeData.boundaries)) { + decline( + 'input-mismatch', + 'Packet C instruction boundaries do not match Packet B', + ) + } + const frame = requireObject( + references.frame, + 'invalid-frame-metadata', + 'Packet C result has no frame section', + ) + requireUint32( + frame.frameStart, + 'invalid-frame-metadata', + 'Packet C frameStart is not an unsigned integer', + ) + requireUint32( + frame.headerSize, + 'invalid-frame-metadata', + 'Packet C headerSize is not an unsigned integer', + ) + if (frame.frameStart !== 1 || frame.headerSize !== 8) { + decline( + 'invalid-frame-metadata', + 'Packet C frame constants do not match the pinned baseline', + ) + } + if (!exactObjectValues(frame.slots, CANONICAL_SLOTS)) { + decline('invalid-frame-metadata', 'Packet C frame slots are not canonical') + } + requireUint32( + frame.mainStartPc, + 'invalid-frame-metadata', + 'Packet C mainStartPc is not an unsigned integer', + ) + requireUint32( + frame.mainRegCount, + 'invalid-frame-metadata', + 'Packet C mainRegCount is not an unsigned integer', + ) + if ( + frame.mainRegCount < 1 || + frame.mainStartPc !== wordcodeData.boundaries[0] || + !wordcodeData.boundarySet.has(frame.mainStartPc) + ) { + decline('invalid-frame-metadata', 'Packet C root frame metadata is invalid') + } + + const root = requireObject( + frame.root, + 'invalid-frame-metadata', + 'Packet C result has no root frame record', + ) + const rootFrameSize = frame.headerSize + frame.mainRegCount + const rootRegisterBase = frame.frameStart + frame.headerSize + const rootFrameEnd = frame.frameStart + rootFrameSize + if ( + root.frameBase !== frame.frameStart || + root.frameSize !== rootFrameSize || + root.registerBase !== rootRegisterBase || + root.frameEnd !== rootFrameEnd || + root.registerWindow?.start !== rootRegisterBase || + root.registerWindow?.end !== rootFrameEnd + ) { + decline( + 'invalid-frame-metadata', + 'Packet C root frame arithmetic is invalid', + ) + } + + const descriptorValues = references.frame.descriptors + if (!Array.isArray(descriptorValues)) { + decline('invalid-descriptor', 'Packet C descriptors are not an array') + } + const descriptors = [] + const byStart = new Map() + for (const input of descriptorValues) { + requireObject( + input, + 'invalid-descriptor', + 'Packet C has a malformed descriptor', + ) + const descriptor = { + creationPc: requireUint32( + input.creationPc, + 'invalid-descriptor', + 'Descriptor creationPc is invalid', + ), + startPc: requireUint32( + input.startPc, + 'invalid-descriptor', + 'Descriptor startPc is invalid', + ), + paramCount: requireUint32( + input.paramCount, + 'invalid-descriptor', + 'Descriptor paramCount is invalid', + ), + regCount: requireUint32( + input.regCount, + 'invalid-descriptor', + 'Descriptor regCount is invalid', + ), + captureCount: requireUint32( + input.captureCount, + 'invalid-descriptor', + 'Descriptor captureCount is invalid', + ), + hasRest: input.hasRest, + captures: [], + frameSize: requireUint32( + input.frameSize, + 'invalid-descriptor', + 'Descriptor frameSize is invalid', + ), + registerBaseOffset: requireUint32( + input.registerBaseOffset, + 'invalid-descriptor', + 'Descriptor registerBaseOffset is invalid', + ), + } + if ( + typeof descriptor.hasRest !== 'boolean' || + descriptor.regCount < 1 || + descriptor.paramCount > descriptor.regCount || + descriptor.captureCount !== input.captures?.length || + descriptor.frameSize !== frame.headerSize + descriptor.regCount || + descriptor.registerBaseOffset !== frame.headerSize || + !wordcodeData.boundarySet.has(descriptor.creationPc) || + !wordcodeData.boundarySet.has(descriptor.startPc) + ) { + decline('invalid-descriptor', 'Packet C descriptor bounds are invalid') + } + descriptor.captures = input.captures.map((capture, index) => + cloneCapture( + capture, + `descriptor ${descriptor.startPc} capture ${index}`, + ), + ) + const signature = descriptorSignature(descriptor) + const previous = byStart.get(descriptor.startPc) + if (previous && previous.signature !== signature) { + decline( + 'invalid-descriptor', + `Conflicting descriptors share start pc ${descriptor.startPc}`, + ) + } + if (!previous) { + byStart.set(descriptor.startPc, { descriptor, signature }) + descriptors.push(descriptor) + } + } + return { + frame, + descriptors, + descriptorsByStart: new Map( + descriptors.map((descriptor) => [descriptor.startPc, descriptor]), + ), + labelReferences: validateLabelReferences(labels.references, wordcodeData), + } +} + +function validateLabelReferences(input, wordcodeData) { + if (!Array.isArray(input)) { + decline( + 'invalid-label-reference', + 'Packet C label references are not an array', + ) + } + const byLocation = new Map() + const normalized = [] + for (const reference of input) { + requireObject( + reference, + 'invalid-label-reference', + 'Packet C has a malformed label reference', + ) + const pc = requireUint32( + reference.pc, + 'invalid-label-reference', + 'Label reference pc is invalid', + ) + const operand = requireSafeCount( + reference.operand, + 'invalid-label-reference', + 'Label reference operand is invalid', + ) + const target = requireUint32( + reference.target, + 'invalid-label-reference', + 'Label reference target is invalid', + ) + const instruction = wordcodeData.byPc.get(pc) + if (!instruction || instruction.name !== reference.instruction) { + decline( + 'input-mismatch', + `Label reference at ${pc} does not match Packet B`, + ) + } + const expectedOperands = DIRECT_LABEL_OPERANDS[instruction.name] ?? [] + if (!expectedOperands.includes(operand)) { + decline( + 'invalid-label-reference', + `${instruction.name}@${pc} has an unexpected label operand`, + ) + } + const typedOperand = instruction.operands[operand] + if ( + !isObject(typedOperand) || + typedOperand.kind !== 'label-target' || + typedOperand.pc !== target || + !wordcodeData.boundarySet.has(target) + ) { + decline( + 'input-mismatch', + `${instruction.name}@${pc} label reference does not match Packet B`, + ) + } + const expectedRole = LABEL_ROLES[instruction.name]?.[operand] + if (reference.role !== expectedRole) { + decline( + 'invalid-label-reference', + `${instruction.name}@${pc} has an invalid label role`, + ) + } + const key = `${pc}:${operand}` + if (byLocation.has(key)) { + decline('invalid-label-reference', `Duplicate label reference ${key}`) + } + byLocation.set(key, reference) + normalized.push({ + pc, + instruction: instruction.name, + operand, + role: reference.role, + target, + }) + } + + for (const instruction of wordcodeData.instructions) { + const expectedOperands = DIRECT_LABEL_OPERANDS[instruction.name] ?? [] + const actualOperands = instruction.operands + .map((operand, index) => + isObject(operand) && operand.kind === 'label-target' ? index : null, + ) + .filter((index) => index !== null) + if (!sameArray(expectedOperands, actualOperands)) { + decline( + 'invalid-label-reference', + `${instruction.name}@${instruction.pc} has an invalid label layout`, + ) + } + for (const operand of expectedOperands) { + if (!byLocation.has(`${instruction.pc}:${operand}`)) { + decline( + 'invalid-label-reference', + `Missing label reference for ${instruction.name}@${instruction.pc}`, + ) + } + } + } + return normalized +} + +export function validateClosureDescriptor(instruction, descriptor) { + if (instruction.name !== 'MAKE_CLOSURE') { + decline( + 'invalid-descriptor', + `Descriptor creation pc ${descriptor.creationPc} is not MAKE_CLOSURE`, + ) + } + const metadata = requireObject( + instruction.functionMeta, + 'invalid-descriptor', + `MAKE_CLOSURE@${instruction.pc} has no function metadata`, + ) + if ( + metadata.startPc !== descriptor.startPc || + metadata.paramCount !== descriptor.paramCount || + metadata.regCount !== descriptor.regCount || + metadata.captureCount !== descriptor.captureCount || + metadata.hasRest !== descriptor.hasRest || + instruction.operands[1]?.pc !== descriptor.startPc || + instruction.operands[2]?.value !== descriptor.paramCount || + instruction.operands[3]?.value !== descriptor.regCount || + instruction.operands[4]?.value !== descriptor.captureCount || + instruction.operands[5]?.value !== (descriptor.hasRest ? 1 : 0) || + !Array.isArray(instruction.captures) || + instruction.captures.length !== descriptor.captures.length + ) { + decline( + 'input-mismatch', + `Descriptor at ${instruction.pc} does not match Packet B`, + ) + } + for (let index = 0; index < descriptor.captures.length; index += 1) { + const expected = descriptor.captures[index] + const actual = instruction.captures[index] + if ( + !isObject(actual) || + actual.kind !== expected.kind || + actual.index !== expected.index || + actual.isLocal !== expected.isLocal + ) { + decline( + 'input-mismatch', + `Descriptor capture ${index} at ${instruction.pc} does not match Packet B`, + ) + } + } +} diff --git a/src/vm/jsconfuser-vm/partition-functions.js b/src/vm/jsconfuser-vm/partition-functions.js index 522486ff..0cbbb6ad 100644 --- a/src/vm/jsconfuser-vm/partition-functions.js +++ b/src/vm/jsconfuser-vm/partition-functions.js @@ -1,679 +1,13 @@ -const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' -const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' -const REFERENCE_SCHEMA = 'jsconfuser-vm-references.v1' -const FUNCTION_SCHEMA = 'jsconfuser-vm-functions.v1' -const UINT32_MAX = 0xffffffff - -const CANONICAL_OPCODES = Object.freeze({ - LOAD_CONST: 0, - LOAD_INT: 1, - LOAD_GLOBAL: 2, - LOAD_UPVALUE: 3, - LOAD_THIS: 4, - MOVE: 5, - STORE_GLOBAL: 6, - STORE_UPVALUE: 7, - GET_PROP: 8, - SET_PROP: 9, - DELETE_PROP: 10, - ADD: 11, - SUB: 12, - MUL: 13, - DIV: 14, - MOD: 15, - EXP: 60, - BAND: 16, - BOR: 17, - BXOR: 18, - SHL: 19, - SHR: 20, - USHR: 21, - LT: 22, - GT: 23, - LTE: 24, - GTE: 25, - EQ: 26, - NEQ: 27, - LOOSE_EQ: 28, - LOOSE_NEQ: 29, - IN: 30, - INSTANCEOF: 31, - UNARY_NEG: 32, - UNARY_POS: 33, - UNARY_NOT: 34, - UNARY_BITNOT: 35, - TYPEOF: 36, - VOID: 37, - TYPEOF_SAFE: 38, - JUMP: 39, - JUMP_IF_FALSE: 40, - JUMP_IF_TRUE: 41, - CALL: 42, - CALL_METHOD: 43, - NEW: 44, - RETURN: 45, - THROW: 46, - MAKE_CLOSURE: 47, - BUILD_ARRAY: 48, - BUILD_OBJECT: 49, - DEFINE_GETTER: 50, - DEFINE_SETTER: 51, - FOR_IN_SETUP: 52, - FOR_IN_NEXT: 53, - TRY_SETUP: 54, - TRY_END: 55, - PATCH: 56, - DEBUGGER: 57, - JUMP_REG: 58, - FINALLY_SETUP: 59, -}) - -const CANONICAL_SLOTS = Object.freeze({ - PC: 0, - CALLER: 1, - RET_DST: 2, - THIS: 3, - CLOSURE: 4, - HANDLERS: 5, - FRAME_SIZE: 6, - REG_BASE: 7, -}) - -const NAME_BY_OPCODE = new Map( - Object.entries(CANONICAL_OPCODES).map(([name, value]) => [value, name]), -) - -const DIRECT_LABEL_OPERANDS = Object.freeze({ - JUMP: Object.freeze([0]), - JUMP_IF_FALSE: Object.freeze([1]), - JUMP_IF_TRUE: Object.freeze([1]), - FOR_IN_NEXT: Object.freeze([2]), - TRY_SETUP: Object.freeze([0]), - FINALLY_SETUP: Object.freeze([0, 3]), - MAKE_CLOSURE: Object.freeze([1]), -}) - -const LABEL_ROLES = Object.freeze({ - JUMP: Object.freeze({ 0: 'target' }), - JUMP_IF_FALSE: Object.freeze({ 1: 'target' }), - JUMP_IF_TRUE: Object.freeze({ 1: 'target' }), - FOR_IN_NEXT: Object.freeze({ 2: 'exit' }), - TRY_SETUP: Object.freeze({ 0: 'handler' }), - FINALLY_SETUP: Object.freeze({ 0: 'finally', 3: 'throwPad' }), - MAKE_CLOSURE: Object.freeze({ 1: 'functionEntry' }), -}) - -class PartitionDecline extends Error { - constructor(code, message) { - super(message) - this.name = 'PartitionDecline' - this.code = code - } -} - -function decline(code, message) { - throw new PartitionDecline(code, message) -} - -function deepFreeze(value) { - if (!value || typeof value !== 'object' || Object.isFrozen(value)) { - return value - } - Object.freeze(value) - for (const child of Object.values(value)) deepFreeze(child) - return value -} - -function isObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -function requireObject(value, code, message) { - if (!isObject(value)) decline(code, message) - return value -} - -function isUint32(value) { - return Number.isInteger(value) && value >= 0 && value <= UINT32_MAX -} - -function isSafeCount(value) { - return Number.isSafeInteger(value) && value >= 0 -} - -function exactObjectValues(actual, expected) { - if (!isObject(actual)) return false - const actualKeys = Object.keys(actual) - const expectedKeys = Object.keys(expected) - return ( - actualKeys.length === expectedKeys.length && - expectedKeys.every( - (key) => - Object.prototype.hasOwnProperty.call(actual, key) && - actual[key] === expected[key], - ) - ) -} - -function sameArray(left, right) { - return ( - Array.isArray(left) && - Array.isArray(right) && - left.length === right.length && - left.every((value, index) => value === right[index]) - ) -} - -function requireUint32(value, code, message) { - if (!isUint32(value)) decline(code, message) - return value -} - -function requireSafeCount(value, code, message) { - if (!isSafeCount(value)) decline(code, message) - return value -} - -function cloneCapture(capture, location) { - requireObject(capture, 'invalid-descriptor', `${location} is not an object`) - if ( - (capture.kind !== 'local' && capture.kind !== 'upvalue') || - !isUint32(capture.index) || - capture.isLocal !== (capture.kind === 'local') - ) { - decline('invalid-descriptor', `${location} has an invalid capture`) - } - return { - kind: capture.kind, - index: capture.index, - isLocal: capture.isLocal, - } -} - -function descriptorSignature(descriptor) { - return JSON.stringify({ - startPc: descriptor.startPc, - paramCount: descriptor.paramCount, - regCount: descriptor.regCount, - captureCount: descriptor.captureCount, - hasRest: descriptor.hasRest, - captures: descriptor.captures, - }) -} - -function validateContainer(container) { - requireObject( - container, - 'invalid-input', - 'Expected a Packet A container result', - ) - if ( - container.schemaVersion !== CONTAINER_SCHEMA || - container.encoding !== 'numeric-u32' - ) { - decline('invalid-container', 'Input is not a Packet A numeric container') - } - const roles = requireObject( - container.roles, - 'invalid-container', - 'Packet A result has no roles object', - ) - const wordsRole = requireObject( - roles.words, - 'invalid-container', - 'Packet A result has no wordcode role', - ) - const words = wordsRole.values - if (!Array.isArray(words) || words.length === 0 || !words.every(isUint32)) { - decline( - 'invalid-container', - 'Packet A wordcode must be a non-empty unsigned 32-bit array', - ) - } - return words -} - -function validateWordcode(wordcode, containerWords) { - requireObject( - wordcode, - 'invalid-input', - 'Expected a Packet B wordcode result', - ) - if ( - wordcode.schemaVersion !== WORDCODE_SCHEMA || - wordcode.encoding !== 'numeric-u32' - ) { - decline( - 'invalid-wordcode', - 'Input is not a Packet B numeric wordcode result', - ) - } - const words = wordcode.words - if ( - !Array.isArray(words) || - !sameArray(words, containerWords) || - !words.every(isUint32) - ) { - decline('input-mismatch', 'Packet B words do not match Packet A wordcode') - } - if ( - wordcode.wordCount !== words.length || - wordcode.consumedWords !== words.length || - wordcode.nextPc !== words.length || - !Array.isArray(wordcode.instructions) || - wordcode.instructionCount !== wordcode.instructions.length || - wordcode.instructions.length === 0 - ) { - decline('invalid-wordcode', 'Packet B consumption metadata is inconsistent') - } - - const instructions = [] - const byPc = new Map() - let nextPc = 0 - for (const instruction of wordcode.instructions) { - requireObject( - instruction, - 'malformed-instruction', - 'Packet B contains a malformed instruction record', - ) - if (instruction.pc !== nextPc || !isSafeCount(instruction.pc)) { - decline('invalid-wordcode', 'Packet B instruction PCs are not contiguous') - } - if ( - typeof instruction.name !== 'string' || - NAME_BY_OPCODE.get(instruction.words?.[0]) !== instruction.name - ) { - decline( - 'malformed-instruction', - `Instruction at ${instruction.pc} has an unknown opcode name`, - ) - } - if ( - !Array.isArray(instruction.words) || - instruction.words.length === 0 || - !instruction.words.every(isUint32) || - !isSafeCount(instruction.width) || - instruction.width !== instruction.words.length || - instruction.nextPc !== instruction.pc + instruction.width || - !sameArray( - instruction.words, - words.slice(instruction.pc, instruction.nextPc), - ) - ) { - decline( - 'malformed-instruction', - `Instruction at ${instruction.pc} has invalid width or words`, - ) - } - if (!Array.isArray(instruction.operands)) { - decline( - 'malformed-instruction', - `Instruction at ${instruction.pc} has no typed operands`, - ) - } - if (byPc.has(instruction.pc)) { - decline( - 'invalid-wordcode', - `Packet B contains duplicate instruction pc ${instruction.pc}`, - ) - } - byPc.set(instruction.pc, instruction) - instructions.push(instruction) - nextPc = instruction.nextPc - } - if (nextPc !== words.length) { - decline( - 'invalid-wordcode', - 'Packet B instructions do not consume all words', - ) - } - return { - words, - instructions, - byPc, - boundaries: instructions.map(({ pc }) => pc), - boundarySet: new Set(instructions.map(({ pc }) => pc)), - } -} - -function validateFrame(references, wordcodeData) { - requireObject( - references, - 'invalid-input', - 'Expected a Packet C reference/frame result', - ) - if ( - references.schemaVersion !== REFERENCE_SCHEMA || - references.encoding !== 'numeric-u32' - ) { - decline( - 'invalid-references', - 'Input is not a Packet C reference/frame result', - ) - } - if ( - references.wordCount !== wordcodeData.words.length || - references.instructionCount !== wordcodeData.instructions.length - ) { - decline('input-mismatch', 'Packet C counts do not match Packet B wordcode') - } - - const labels = requireObject( - references.labels, - 'invalid-references', - 'Packet C result has no labels section', - ) - if (!sameArray(labels.boundaries, wordcodeData.boundaries)) { - decline( - 'input-mismatch', - 'Packet C instruction boundaries do not match Packet B', - ) - } - const frame = requireObject( - references.frame, - 'invalid-frame-metadata', - 'Packet C result has no frame section', - ) - requireUint32( - frame.frameStart, - 'invalid-frame-metadata', - 'Packet C frameStart is not an unsigned integer', - ) - requireUint32( - frame.headerSize, - 'invalid-frame-metadata', - 'Packet C headerSize is not an unsigned integer', - ) - if (frame.frameStart !== 1 || frame.headerSize !== 8) { - decline( - 'invalid-frame-metadata', - 'Packet C frame constants do not match the pinned baseline', - ) - } - if (!exactObjectValues(frame.slots, CANONICAL_SLOTS)) { - decline('invalid-frame-metadata', 'Packet C frame slots are not canonical') - } - requireUint32( - frame.mainStartPc, - 'invalid-frame-metadata', - 'Packet C mainStartPc is not an unsigned integer', - ) - requireUint32( - frame.mainRegCount, - 'invalid-frame-metadata', - 'Packet C mainRegCount is not an unsigned integer', - ) - if ( - frame.mainRegCount < 1 || - frame.mainStartPc !== wordcodeData.boundaries[0] || - !wordcodeData.boundarySet.has(frame.mainStartPc) - ) { - decline('invalid-frame-metadata', 'Packet C root frame metadata is invalid') - } - - const root = requireObject( - frame.root, - 'invalid-frame-metadata', - 'Packet C result has no root frame record', - ) - const rootFrameSize = frame.headerSize + frame.mainRegCount - const rootRegisterBase = frame.frameStart + frame.headerSize - const rootFrameEnd = frame.frameStart + rootFrameSize - if ( - root.frameBase !== frame.frameStart || - root.frameSize !== rootFrameSize || - root.registerBase !== rootRegisterBase || - root.frameEnd !== rootFrameEnd || - root.registerWindow?.start !== rootRegisterBase || - root.registerWindow?.end !== rootFrameEnd - ) { - decline( - 'invalid-frame-metadata', - 'Packet C root frame arithmetic is invalid', - ) - } - - const descriptorValues = references.frame.descriptors - if (!Array.isArray(descriptorValues)) { - decline('invalid-descriptor', 'Packet C descriptors are not an array') - } - const descriptors = [] - const byStart = new Map() - for (const input of descriptorValues) { - requireObject( - input, - 'invalid-descriptor', - 'Packet C has a malformed descriptor', - ) - const descriptor = { - creationPc: requireUint32( - input.creationPc, - 'invalid-descriptor', - 'Descriptor creationPc is invalid', - ), - startPc: requireUint32( - input.startPc, - 'invalid-descriptor', - 'Descriptor startPc is invalid', - ), - paramCount: requireUint32( - input.paramCount, - 'invalid-descriptor', - 'Descriptor paramCount is invalid', - ), - regCount: requireUint32( - input.regCount, - 'invalid-descriptor', - 'Descriptor regCount is invalid', - ), - captureCount: requireUint32( - input.captureCount, - 'invalid-descriptor', - 'Descriptor captureCount is invalid', - ), - hasRest: input.hasRest, - captures: [], - frameSize: requireUint32( - input.frameSize, - 'invalid-descriptor', - 'Descriptor frameSize is invalid', - ), - registerBaseOffset: requireUint32( - input.registerBaseOffset, - 'invalid-descriptor', - 'Descriptor registerBaseOffset is invalid', - ), - } - if ( - typeof descriptor.hasRest !== 'boolean' || - descriptor.regCount < 1 || - descriptor.paramCount > descriptor.regCount || - descriptor.captureCount !== input.captures?.length || - descriptor.frameSize !== frame.headerSize + descriptor.regCount || - descriptor.registerBaseOffset !== frame.headerSize || - !wordcodeData.boundarySet.has(descriptor.creationPc) || - !wordcodeData.boundarySet.has(descriptor.startPc) - ) { - decline('invalid-descriptor', 'Packet C descriptor bounds are invalid') - } - descriptor.captures = input.captures.map((capture, index) => - cloneCapture( - capture, - `descriptor ${descriptor.startPc} capture ${index}`, - ), - ) - const signature = descriptorSignature(descriptor) - const previous = byStart.get(descriptor.startPc) - if (previous && previous.signature !== signature) { - decline( - 'invalid-descriptor', - `Conflicting descriptors share start pc ${descriptor.startPc}`, - ) - } - if (!previous) { - byStart.set(descriptor.startPc, { descriptor, signature }) - descriptors.push(descriptor) - } - } - return { - frame, - descriptors, - descriptorsByStart: new Map( - descriptors.map((descriptor) => [descriptor.startPc, descriptor]), - ), - labelReferences: validateLabelReferences(labels.references, wordcodeData), - } -} - -function validateLabelReferences(input, wordcodeData) { - if (!Array.isArray(input)) { - decline( - 'invalid-label-reference', - 'Packet C label references are not an array', - ) - } - const byLocation = new Map() - const normalized = [] - for (const reference of input) { - requireObject( - reference, - 'invalid-label-reference', - 'Packet C has a malformed label reference', - ) - const pc = requireUint32( - reference.pc, - 'invalid-label-reference', - 'Label reference pc is invalid', - ) - const operand = requireSafeCount( - reference.operand, - 'invalid-label-reference', - 'Label reference operand is invalid', - ) - const target = requireUint32( - reference.target, - 'invalid-label-reference', - 'Label reference target is invalid', - ) - const instruction = wordcodeData.byPc.get(pc) - if (!instruction || instruction.name !== reference.instruction) { - decline( - 'input-mismatch', - `Label reference at ${pc} does not match Packet B`, - ) - } - const expectedOperands = DIRECT_LABEL_OPERANDS[instruction.name] ?? [] - if (!expectedOperands.includes(operand)) { - decline( - 'invalid-label-reference', - `${instruction.name}@${pc} has an unexpected label operand`, - ) - } - const typedOperand = instruction.operands[operand] - if ( - !isObject(typedOperand) || - typedOperand.kind !== 'label-target' || - typedOperand.pc !== target || - !wordcodeData.boundarySet.has(target) - ) { - decline( - 'input-mismatch', - `${instruction.name}@${pc} label reference does not match Packet B`, - ) - } - const expectedRole = LABEL_ROLES[instruction.name]?.[operand] - if (reference.role !== expectedRole) { - decline( - 'invalid-label-reference', - `${instruction.name}@${pc} has an invalid label role`, - ) - } - const key = `${pc}:${operand}` - if (byLocation.has(key)) { - decline('invalid-label-reference', `Duplicate label reference ${key}`) - } - byLocation.set(key, reference) - normalized.push({ - pc, - instruction: instruction.name, - operand, - role: reference.role, - target, - }) - } - - for (const instruction of wordcodeData.instructions) { - const expectedOperands = DIRECT_LABEL_OPERANDS[instruction.name] ?? [] - const actualOperands = instruction.operands - .map((operand, index) => - isObject(operand) && operand.kind === 'label-target' ? index : null, - ) - .filter((index) => index !== null) - if (!sameArray(expectedOperands, actualOperands)) { - decline( - 'invalid-label-reference', - `${instruction.name}@${instruction.pc} has an invalid label layout`, - ) - } - for (const operand of expectedOperands) { - if (!byLocation.has(`${instruction.pc}:${operand}`)) { - decline( - 'invalid-label-reference', - `Missing label reference for ${instruction.name}@${instruction.pc}`, - ) - } - } - } - return normalized -} - -function validateClosureDescriptor(instruction, descriptor) { - if (instruction.name !== 'MAKE_CLOSURE') { - decline( - 'invalid-descriptor', - `Descriptor creation pc ${descriptor.creationPc} is not MAKE_CLOSURE`, - ) - } - const metadata = requireObject( - instruction.functionMeta, - 'invalid-descriptor', - `MAKE_CLOSURE@${instruction.pc} has no function metadata`, - ) - if ( - metadata.startPc !== descriptor.startPc || - metadata.paramCount !== descriptor.paramCount || - metadata.regCount !== descriptor.regCount || - metadata.captureCount !== descriptor.captureCount || - metadata.hasRest !== descriptor.hasRest || - instruction.operands[1]?.pc !== descriptor.startPc || - instruction.operands[2]?.value !== descriptor.paramCount || - instruction.operands[3]?.value !== descriptor.regCount || - instruction.operands[4]?.value !== descriptor.captureCount || - instruction.operands[5]?.value !== (descriptor.hasRest ? 1 : 0) || - !Array.isArray(instruction.captures) || - instruction.captures.length !== descriptor.captures.length - ) { - decline( - 'input-mismatch', - `Descriptor at ${instruction.pc} does not match Packet B`, - ) - } - for (let index = 0; index < descriptor.captures.length; index += 1) { - const expected = descriptor.captures[index] - const actual = instruction.captures[index] - if ( - !isObject(actual) || - actual.kind !== expected.kind || - actual.index !== expected.index || - actual.isLocal !== expected.isLocal - ) { - decline( - 'input-mismatch', - `Descriptor capture ${index} at ${instruction.pc} does not match Packet B`, - ) - } - } -} +import { + FUNCTION_SCHEMA, + PartitionDecline, + decline, + deepFreeze, + validateClosureDescriptor, + validateContainer, + validateFrame, + validateWordcode, +} from './partition-functions-input.js' function assignIntervals(wordcodeData, frameData) { const entries = [ diff --git a/src/vm/jsconfuser-vm/structured-control-cfg.js b/src/vm/jsconfuser-vm/structured-control-cfg.js index 22de4b63..3870cefd 100644 --- a/src/vm/jsconfuser-vm/structured-control-cfg.js +++ b/src/vm/jsconfuser-vm/structured-control-cfg.js @@ -3,19 +3,21 @@ import { CFG_SCHEMA, WORDCODE_SCHEMA, decline, - edgeKey, - expectedCallSites, - expectedRecords, - ownerMapFromFunctions, requireArray, requireObject, requireSame, sameValue, +} from './structured-control-contract.js' +import { + edgeKey, + expectedCallSites, + expectedRecords, validateEdgeShape, validateFrame, validateFunctionBlocks, validateIndirectTargets, } from './structured-control-input.js' +import { ownerMapFromFunctions } from './structured-control-predecessors.js' export function reachablePcs(fn, edges) { const adjacency = new Map() diff --git a/src/vm/jsconfuser-vm/structured-control-contract.js b/src/vm/jsconfuser-vm/structured-control-contract.js new file mode 100644 index 00000000..5bff2b10 --- /dev/null +++ b/src/vm/jsconfuser-vm/structured-control-contract.js @@ -0,0 +1,168 @@ +export const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' +export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' +export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' +export const EXCEPTION_FINALLY_SCHEMA = 'jsconfuser-vm-exception-finally.v1' +export const STRUCTURED_CONTROL_SCHEMA = 'jsconfuser-vm-structured-control.v1' +const UINT32_MAX = 0xffffffff +export const CALL_SPREAD = 65535 + +export const CANONICAL_OPCODES = Object.freeze({ + LOAD_CONST: 0, + LOAD_INT: 1, + LOAD_GLOBAL: 2, + LOAD_UPVALUE: 3, + LOAD_THIS: 4, + MOVE: 5, + STORE_GLOBAL: 6, + STORE_UPVALUE: 7, + GET_PROP: 8, + SET_PROP: 9, + DELETE_PROP: 10, + ADD: 11, + SUB: 12, + MUL: 13, + DIV: 14, + MOD: 15, + EXP: 60, + BAND: 16, + BOR: 17, + BXOR: 18, + SHL: 19, + SHR: 20, + USHR: 21, + LT: 22, + GT: 23, + LTE: 24, + GTE: 25, + EQ: 26, + NEQ: 27, + LOOSE_EQ: 28, + LOOSE_NEQ: 29, + IN: 30, + INSTANCEOF: 31, + UNARY_NEG: 32, + UNARY_POS: 33, + UNARY_NOT: 34, + UNARY_BITNOT: 35, + TYPEOF: 36, + VOID: 37, + TYPEOF_SAFE: 38, + JUMP: 39, + JUMP_IF_FALSE: 40, + JUMP_IF_TRUE: 41, + CALL: 42, + CALL_METHOD: 43, + NEW: 44, + RETURN: 45, + THROW: 46, + MAKE_CLOSURE: 47, + BUILD_ARRAY: 48, + BUILD_OBJECT: 49, + DEFINE_GETTER: 50, + DEFINE_SETTER: 51, + FOR_IN_SETUP: 52, + FOR_IN_NEXT: 53, + TRY_SETUP: 54, + TRY_END: 55, + PATCH: 56, + DEBUGGER: 57, + JUMP_REG: 58, + FINALLY_SETUP: 59, +}) + +export const CANONICAL_SLOTS = Object.freeze({ + PC: 0, + CALLER: 1, + RET_DST: 2, + THIS: 3, + CLOSURE: 4, + HANDLERS: 5, + FRAME_SIZE: 6, + REG_BASE: 7, +}) + +export const CALLS = new Set(['CALL', 'CALL_METHOD', 'NEW']) +export const COMPLETIONS = new Set(['return', 'throw', 'handler', 'finally']) + +export class StructuredControlDecline extends Error { + constructor(code, message) { + super(message) + this.name = 'StructuredControlDecline' + this.code = code + } +} + +export function decline(code, message) { + throw new StructuredControlDecline(code, message) +} + +export function deepFreeze(value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value + } + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +export function cloneData(value) { + if (Array.isArray(value)) return value.map((child) => cloneData(child)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, cloneData(child)]), + ) + } + return value +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function requireObject(value, code, message) { + if (!isObject(value)) decline(code, message) + return value +} + +export function requireArray(value, code, message) { + if (!Array.isArray(value)) decline(code, message) + return value +} + +export function requireUint32(value, code, message) { + if (!Number.isInteger(value) || value < 0 || value > UINT32_MAX) { + decline(code, message) + } + return value +} + +export function sameValue(actual, expected) { + if (actual === expected) return true + if (Array.isArray(actual) && Array.isArray(expected)) { + return ( + actual.length === expected.length && + actual.every((value, index) => sameValue(value, expected[index])) + ) + } + if (isObject(actual) && isObject(expected)) { + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return ( + actualKeys.length === expectedKeys.length && + expectedKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(actual, key) && + sameValue(actual[key], expected[key]), + ) + ) + } + return false +} + +export function requireSame(actual, expected, code, message) { + if (!sameValue(actual, expected)) decline(code, message) +} + +export function arrayEqual(left, right) { + return sameValue(left, right) +} diff --git a/src/vm/jsconfuser-vm/structured-control-exceptions.js b/src/vm/jsconfuser-vm/structured-control-exceptions.js index 473ec78b..44eff5e5 100644 --- a/src/vm/jsconfuser-vm/structured-control-exceptions.js +++ b/src/vm/jsconfuser-vm/structured-control-exceptions.js @@ -5,11 +5,13 @@ import { WORDCODE_SCHEMA, arrayEqual, decline, - edgeProjection, requireArray, requireObject, requireSame, sameValue, +} from './structured-control-contract.js' +import { + edgeProjection, stateForPc, validateStateShape, } from './structured-control-input.js' diff --git a/src/vm/jsconfuser-vm/structured-control-input.js b/src/vm/jsconfuser-vm/structured-control-input.js index 7bc30c54..8e9109cf 100644 --- a/src/vm/jsconfuser-vm/structured-control-input.js +++ b/src/vm/jsconfuser-vm/structured-control-input.js @@ -1,312 +1,16 @@ -import { readWordcode } from './read-wordcode.js' - -const CONTAINER_SCHEMA = 'jsconfuser-vm-container.v1' -export const WORDCODE_SCHEMA = 'jsconfuser-vm-wordcode.v1' -export const CFG_SCHEMA = 'jsconfuser-vm-cfg.v1' -export const EXCEPTION_FINALLY_SCHEMA = 'jsconfuser-vm-exception-finally.v1' -export const STRUCTURED_CONTROL_SCHEMA = 'jsconfuser-vm-structured-control.v1' -const UINT32_MAX = 0xffffffff -const CALL_SPREAD = 65535 - -const CANONICAL_OPCODES = Object.freeze({ - LOAD_CONST: 0, - LOAD_INT: 1, - LOAD_GLOBAL: 2, - LOAD_UPVALUE: 3, - LOAD_THIS: 4, - MOVE: 5, - STORE_GLOBAL: 6, - STORE_UPVALUE: 7, - GET_PROP: 8, - SET_PROP: 9, - DELETE_PROP: 10, - ADD: 11, - SUB: 12, - MUL: 13, - DIV: 14, - MOD: 15, - EXP: 60, - BAND: 16, - BOR: 17, - BXOR: 18, - SHL: 19, - SHR: 20, - USHR: 21, - LT: 22, - GT: 23, - LTE: 24, - GTE: 25, - EQ: 26, - NEQ: 27, - LOOSE_EQ: 28, - LOOSE_NEQ: 29, - IN: 30, - INSTANCEOF: 31, - UNARY_NEG: 32, - UNARY_POS: 33, - UNARY_NOT: 34, - UNARY_BITNOT: 35, - TYPEOF: 36, - VOID: 37, - TYPEOF_SAFE: 38, - JUMP: 39, - JUMP_IF_FALSE: 40, - JUMP_IF_TRUE: 41, - CALL: 42, - CALL_METHOD: 43, - NEW: 44, - RETURN: 45, - THROW: 46, - MAKE_CLOSURE: 47, - BUILD_ARRAY: 48, - BUILD_OBJECT: 49, - DEFINE_GETTER: 50, - DEFINE_SETTER: 51, - FOR_IN_SETUP: 52, - FOR_IN_NEXT: 53, - TRY_SETUP: 54, - TRY_END: 55, - PATCH: 56, - DEBUGGER: 57, - JUMP_REG: 58, - FINALLY_SETUP: 59, -}) - -const CANONICAL_SLOTS = Object.freeze({ - PC: 0, - CALLER: 1, - RET_DST: 2, - THIS: 3, - CLOSURE: 4, - HANDLERS: 5, - FRAME_SIZE: 6, - REG_BASE: 7, -}) - -export const CALLS = new Set(['CALL', 'CALL_METHOD', 'NEW']) -export const COMPLETIONS = new Set(['return', 'throw', 'handler', 'finally']) - -export class StructuredControlDecline extends Error { - constructor(code, message) { - super(message) - this.name = 'StructuredControlDecline' - this.code = code - } -} - -export function decline(code, message) { - throw new StructuredControlDecline(code, message) -} - -export function deepFreeze(value) { - if (!value || typeof value !== 'object' || Object.isFrozen(value)) { - return value - } - Object.freeze(value) - for (const child of Object.values(value)) deepFreeze(child) - return value -} - -export function cloneData(value) { - if (Array.isArray(value)) return value.map((child) => cloneData(child)) - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, child]) => [key, cloneData(child)]), - ) - } - return value -} - -function isObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -export function requireObject(value, code, message) { - if (!isObject(value)) decline(code, message) - return value -} - -export function requireArray(value, code, message) { - if (!Array.isArray(value)) decline(code, message) - return value -} - -function requireUint32(value, code, message) { - if (!Number.isInteger(value) || value < 0 || value > UINT32_MAX) { - decline(code, message) - } - return value -} - -export function sameValue(actual, expected) { - if (actual === expected) return true - if (Array.isArray(actual) && Array.isArray(expected)) { - return ( - actual.length === expected.length && - actual.every((value, index) => sameValue(value, expected[index])) - ) - } - if (isObject(actual) && isObject(expected)) { - const actualKeys = Object.keys(actual) - const expectedKeys = Object.keys(expected) - return ( - actualKeys.length === expectedKeys.length && - expectedKeys.every( - (key) => - Object.prototype.hasOwnProperty.call(actual, key) && - sameValue(actual[key], expected[key]), - ) - ) - } - return false -} - -export function requireSame(actual, expected, code, message) { - if (!sameValue(actual, expected)) decline(code, message) -} - -export function arrayEqual(left, right) { - return sameValue(left, right) -} - -export function canonicalWordcode(wordcode) { - requireObject( - wordcode, - 'invalid-input', - 'Expected a Packet B wordcode result', - ) - const words = requireArray( - wordcode.words, - 'invalid-wordcode', - 'Packet B has no word stream', - ) - if (words.length === 0 || !words.every((value) => Number.isInteger(value))) { - decline('invalid-wordcode', 'Packet B words are not numeric') - } - const container = { - schemaVersion: CONTAINER_SCHEMA, - encoding: 'numeric-u32', - roles: { - words: { values: [...words] }, - op: { values: { ...CANONICAL_OPCODES } }, - sentinels: { values: { CALL_SPREAD } }, - scalars: { - ENCODE_BYTECODE: { value: false }, - MAIN_START_PC: { value: 0 }, - }, - }, - } - const rebuilt = readWordcode(container) - if (!rebuilt) decline('invalid-wordcode', 'Packet B cannot be reconstructed') - requireSame( - wordcode, - rebuilt, - 'stale-predecessor', - 'Packet B is not the canonical reconstruction of its numeric words', - ) - return { - words: [...rebuilt.words], - instructions: rebuilt.instructions, - byPc: new Map( - rebuilt.instructions.map((instruction) => [instruction.pc, instruction]), - ), - boundaries: rebuilt.instructions.map(({ pc }) => pc), - boundarySet: new Set(rebuilt.instructions.map(({ pc }) => pc)), - } -} - -function expectedInstructionPcs(startPc, endPc, boundaries) { - return boundaries.filter((pc) => pc >= startPc && pc < endPc) -} - -export function ownerMapFromFunctions(functions, wordcodeData) { - const ownerByPc = new Map() - let previousEnd = null - for (let id = 0; id < functions.length; id += 1) { - const fn = functions[id] - requireObject( - fn, - 'invalid-function-boundary', - `CFG function ${id} is malformed`, - ) - if ( - fn.id !== id || - (fn.kind !== 'root' && fn.kind !== 'closure') || - !Number.isInteger(fn.startPc) || - !Number.isInteger(fn.endPc) || - !wordcodeData.boundarySet.has(fn.startPc) || - (fn.endPc !== wordcodeData.words.length && - !wordcodeData.boundarySet.has(fn.endPc)) || - fn.endPc <= fn.startPc || - (previousEnd !== null && fn.startPc !== previousEnd) || - !Number.isInteger(fn.regCount) || - fn.regCount < 1 || - !Number.isInteger(fn.paramCount) || - fn.paramCount > fn.regCount || - !Number.isInteger(fn.captureCount) || - fn.captureCount < 0 || - typeof fn.hasRest !== 'boolean' || - !Array.isArray(fn.instructionPcs) || - fn.instructionCount !== fn.instructionPcs.length - ) { - decline('invalid-function-boundary', `CFG function ${id} is malformed`) - } - if ( - id === 0 && - (fn.kind !== 'root' || fn.startPc !== 0 || fn.parentFunctionId !== null) - ) { - decline( - 'invalid-function-boundary', - 'CFG root function metadata is invalid', - ) - } - if ( - id > 0 && - (!Number.isInteger(fn.parentFunctionId) || - fn.parentFunctionId < 0 || - fn.parentFunctionId >= id) - ) { - decline( - 'invalid-function-boundary', - `CFG function ${id} has an invalid parent`, - ) - } - const expected = expectedInstructionPcs( - fn.startPc, - fn.endPc, - wordcodeData.boundaries, - ) - requireSame( - fn.instructionPcs, - expected, - 'invalid-function-boundary', - `CFG function ${id} does not own its interval exactly`, - ) - if (expected.length === 0) { - decline('invalid-function-boundary', `CFG function ${id} is empty`) - } - for (const pc of expected) { - if (ownerByPc.has(pc)) { - decline( - 'invalid-function-boundary', - `Instruction ${pc} has multiple owners`, - ) - } - ownerByPc.set(pc, id) - } - previousEnd = fn.endPc - } - if ( - previousEnd !== wordcodeData.words.length || - ownerByPc.size !== wordcodeData.instructions.length - ) { - decline( - 'invalid-function-boundary', - 'CFG does not assign the complete stream', - ) - } - return ownerByPc -} +import { + CALLS, + CALL_SPREAD, + CANONICAL_SLOTS, + arrayEqual, + decline, + requireArray, + requireObject, + requireSame, + requireUint32, + sameValue, +} from './structured-control-contract.js' +import { expectedInstructionPcs } from './structured-control-predecessors.js' export function validateFrame(frame, functions) { requireObject(frame, 'invalid-frame-metadata', 'CFG has no frame summary') diff --git a/src/vm/jsconfuser-vm/structured-control-predecessors.js b/src/vm/jsconfuser-vm/structured-control-predecessors.js new file mode 100644 index 00000000..0f6a7370 --- /dev/null +++ b/src/vm/jsconfuser-vm/structured-control-predecessors.js @@ -0,0 +1,149 @@ +import { readWordcode } from './read-wordcode.js' +import { + CALL_SPREAD, + CANONICAL_OPCODES, + CONTAINER_SCHEMA, + decline, + requireArray, + requireObject, + requireSame, +} from './structured-control-contract.js' + +export function canonicalWordcode(wordcode) { + requireObject( + wordcode, + 'invalid-input', + 'Expected a Packet B wordcode result', + ) + const words = requireArray( + wordcode.words, + 'invalid-wordcode', + 'Packet B has no word stream', + ) + if (words.length === 0 || !words.every((value) => Number.isInteger(value))) { + decline('invalid-wordcode', 'Packet B words are not numeric') + } + const container = { + schemaVersion: CONTAINER_SCHEMA, + encoding: 'numeric-u32', + roles: { + words: { values: [...words] }, + op: { values: { ...CANONICAL_OPCODES } }, + sentinels: { values: { CALL_SPREAD } }, + scalars: { + ENCODE_BYTECODE: { value: false }, + MAIN_START_PC: { value: 0 }, + }, + }, + } + const rebuilt = readWordcode(container) + if (!rebuilt) decline('invalid-wordcode', 'Packet B cannot be reconstructed') + requireSame( + wordcode, + rebuilt, + 'stale-predecessor', + 'Packet B is not the canonical reconstruction of its numeric words', + ) + return { + words: [...rebuilt.words], + instructions: rebuilt.instructions, + byPc: new Map( + rebuilt.instructions.map((instruction) => [instruction.pc, instruction]), + ), + boundaries: rebuilt.instructions.map(({ pc }) => pc), + boundarySet: new Set(rebuilt.instructions.map(({ pc }) => pc)), + } +} + +export function expectedInstructionPcs(startPc, endPc, boundaries) { + return boundaries.filter((pc) => pc >= startPc && pc < endPc) +} + +export function ownerMapFromFunctions(functions, wordcodeData) { + const ownerByPc = new Map() + let previousEnd = null + for (let id = 0; id < functions.length; id += 1) { + const fn = functions[id] + requireObject( + fn, + 'invalid-function-boundary', + `CFG function ${id} is malformed`, + ) + if ( + fn.id !== id || + (fn.kind !== 'root' && fn.kind !== 'closure') || + !Number.isInteger(fn.startPc) || + !Number.isInteger(fn.endPc) || + !wordcodeData.boundarySet.has(fn.startPc) || + (fn.endPc !== wordcodeData.words.length && + !wordcodeData.boundarySet.has(fn.endPc)) || + fn.endPc <= fn.startPc || + (previousEnd !== null && fn.startPc !== previousEnd) || + !Number.isInteger(fn.regCount) || + fn.regCount < 1 || + !Number.isInteger(fn.paramCount) || + fn.paramCount > fn.regCount || + !Number.isInteger(fn.captureCount) || + fn.captureCount < 0 || + typeof fn.hasRest !== 'boolean' || + !Array.isArray(fn.instructionPcs) || + fn.instructionCount !== fn.instructionPcs.length + ) { + decline('invalid-function-boundary', `CFG function ${id} is malformed`) + } + if ( + id === 0 && + (fn.kind !== 'root' || fn.startPc !== 0 || fn.parentFunctionId !== null) + ) { + decline( + 'invalid-function-boundary', + 'CFG root function metadata is invalid', + ) + } + if ( + id > 0 && + (!Number.isInteger(fn.parentFunctionId) || + fn.parentFunctionId < 0 || + fn.parentFunctionId >= id) + ) { + decline( + 'invalid-function-boundary', + `CFG function ${id} has an invalid parent`, + ) + } + const expected = expectedInstructionPcs( + fn.startPc, + fn.endPc, + wordcodeData.boundaries, + ) + requireSame( + fn.instructionPcs, + expected, + 'invalid-function-boundary', + `CFG function ${id} does not own its interval exactly`, + ) + if (expected.length === 0) { + decline('invalid-function-boundary', `CFG function ${id} is empty`) + } + for (const pc of expected) { + if (ownerByPc.has(pc)) { + decline( + 'invalid-function-boundary', + `Instruction ${pc} has multiple owners`, + ) + } + ownerByPc.set(pc, id) + } + previousEnd = fn.endPc + } + if ( + previousEnd !== wordcodeData.words.length || + ownerByPc.size !== wordcodeData.instructions.length + ) { + decline( + 'invalid-function-boundary', + 'CFG does not assign the complete stream', + ) + } + return ownerByPc +}