|
#!/usr/bin/env node |
|
/** |
|
* paper3d.js deobfuscator — single-pass, reproducible pipeline |
|
* |
|
* Input: js_files/paper3d.js (obfuscated) |
|
* Output: js_files/paper3d_deob.js |
|
* |
|
* Pipeline: |
|
* 1. Sandbox-execute the original prefix to capture the post-shuffle string array |
|
* 2. Babel: replace every w()-family call with the resolved string |
|
* 3. OXC : deob CLI for expression folding, dead code, member simplification |
|
* 4. Babel: remove the leftover shuffling init IIFE and dead w-var aliases |
|
*/ |
|
|
|
const fs = require('fs'); |
|
const path = require('path'); |
|
const vm = require('vm'); |
|
const { execSync } = require('child_process'); |
|
const parser = require('@babel/parser'); |
|
const traverse = require('@babel/traverse').default; |
|
const generate = require('@babel/generator').default; |
|
const t = require('@babel/types'); |
|
|
|
const ROOT = __dirname; |
|
const INPUT = path.join(ROOT, 'js_files', 'paper3d.js'); |
|
const TMP1 = path.join(ROOT, 'js_files', '._tmp_babel.js'); |
|
const TMP2 = path.join(ROOT, 'js_files', '._tmp_oxc.js'); |
|
const OUTPUT = path.join(ROOT, 'js_files', 'paper3d_deob.js'); |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// STEP 1 – extract shuffled string array |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function extractStringArray(code) { |
|
const zStart = code.indexOf('function z(){'); |
|
const arrayEnd = code.indexOf('];z=function(){return cL;};return z();}') + |
|
'];z=function(){return cL;};return z();}'.length; |
|
const wAndInitEnd = code.indexOf('}(z,0x3d53b));') + '}(z,0x3d53b));'.length; |
|
|
|
const zFunc = code.substring(zStart, arrayEnd); |
|
const wAndInit = code.substring(0, wAndInitEnd); |
|
const sandboxCode = zFunc + ';\n' + wAndInit + ';\nglobalThis.__sa = z();'; |
|
|
|
const sandbox = { globalThis: {}, parseInt, RegExp, String, Function, |
|
Array, Object, Error }; |
|
vm.createContext(sandbox); |
|
vm.runInContext(sandboxCode, sandbox, { timeout: 10000 }); |
|
|
|
const arr = sandbox.globalThis.__sa; |
|
if (!Array.isArray(arr)) throw new Error('Failed to capture string array'); |
|
return arr; |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// STEP 2 – Babel: resolve w() calls → string literals |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function resolveStringLookups(code, stringArray) { |
|
const OFFSET = 0xfe; |
|
const ast = parser.parse(code, { sourceType: 'script', errorRecovery: false }); |
|
|
|
// --- iteratively discover all w-var aliases --- |
|
const wVars = new Set(['w']); |
|
let changed = true; |
|
while (changed) { |
|
changed = false; |
|
traverse(ast, { |
|
VariableDeclarator(p) { |
|
if (!p.node.id || !p.node.init) return; |
|
if (t.isIdentifier(p.node.id) && t.isIdentifier(p.node.init) && |
|
wVars.has(p.node.init.name) && !wVars.has(p.node.id.name)) { |
|
wVars.add(p.node.id.name); changed = true; |
|
} |
|
}, |
|
AssignmentExpression(p) { |
|
if (t.isIdentifier(p.node.left) && t.isIdentifier(p.node.right) && |
|
wVars.has(p.node.right.name) && !wVars.has(p.node.left.name) && |
|
p.node.operator === '=') { |
|
wVars.add(p.node.left.name); changed = true; |
|
} |
|
}, |
|
}); |
|
} |
|
|
|
// --- replace calls --- |
|
let replaced = 0; |
|
traverse(ast, { |
|
CallExpression(p) { |
|
const c = p.node.callee, a = p.node.arguments; |
|
if (!t.isIdentifier(c) || !wVars.has(c.name)) return; |
|
if (a.length !== 1 || !t.isNumericLiteral(a[0])) return; |
|
const idx = a[0].value - OFFSET; |
|
if (idx >= 0 && idx < stringArray.length) { |
|
p.replaceWith(t.stringLiteral(stringArray[idx])); |
|
replaced++; |
|
} |
|
}, |
|
}); |
|
// --- remove dead code (w/z functions, shuffling init, anti-debug Q/J) --- |
|
const deadNodes = []; |
|
traverse(ast, { |
|
FunctionDeclaration(p) { |
|
if (!p.node.id) return; |
|
if ((p.node.id.name === 'w' && p.node.params.length === 2) || |
|
(p.node.id.name === 'z')) { |
|
deadNodes.push(p); |
|
} |
|
}, |
|
ExpressionStatement(p) { |
|
const ex = p.node.expression; |
|
if (t.isCallExpression(ex) && t.isFunctionExpression(ex.callee) && |
|
ex.arguments.some(a => t.isIdentifier(a) && a.name === 'z')) { |
|
deadNodes.push(p); |
|
} |
|
}, |
|
VariableDeclaration(p) { |
|
if (p.node.declarations[0]?.id?.name === 'Q') deadNodes.push(p); |
|
}, |
|
// remove `const VAR = w;` style declarations |
|
VariableDeclarator(p) { |
|
if (!p.node.id || !p.node.init) return; |
|
if (t.isIdentifier(p.node.id) && t.isIdentifier(p.node.init) && |
|
p.node.init.name === 'w') { |
|
const d = p.parentPath; |
|
deadNodes.push(d.node.declarations.length === 1 ? d : p); |
|
} |
|
}, |
|
}); |
|
// also remove J=Q(...) and J() at top level |
|
traverse(ast, { |
|
Program(p) { |
|
const b = p.node.body; |
|
for (let i = 0; i < b.length; i++) { |
|
const s = b[i]; |
|
if (t.isExpressionStatement(s)) { |
|
const ex = s.expression; |
|
if ((t.isAssignmentExpression(ex) && |
|
t.isIdentifier(ex.left) && ex.left.name === 'J') || |
|
(t.isCallExpression(ex) && |
|
t.isIdentifier(ex.callee) && ex.callee.name === 'J')) { |
|
b.splice(i, 1); i--; |
|
} |
|
} |
|
} |
|
}, |
|
}); |
|
deadNodes.sort((a, b) => (b.node?.start || 0) - (a.node?.start || 0)); |
|
for (const p of deadNodes) { if (!p.removed) p.remove(); } |
|
|
|
const out = generate(ast, { compact: false, comments: false }).code; |
|
return { code: out, replaced, wVarCount: wVars.size }; |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// STEP 3 – OXC deob CLI |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function oxcDeob(inputPath, outputPath) { |
|
execSync('deob ' + inputPath + ' -o ' + outputPath, { |
|
stdio: 'pipe', timeout: 120000 |
|
}); |
|
return fs.readFileSync(outputPath, 'utf8'); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// Helper – convert b64 WASM → WAT text (build time, using wabt) |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function wasmB64toWat(b64) { |
|
const wasmBytes = Buffer.from(b64, 'base64'); |
|
const tmpWasm = path.join(ROOT, '._tmp_rng.wasm'); |
|
const tmpWat = path.join(ROOT, '._tmp_rng.wat'); |
|
const tmpScript = path.join(ROOT, '._tmp_watconv.js'); |
|
fs.writeFileSync(tmpWasm, wasmBytes); |
|
fs.writeFileSync(tmpScript, |
|
'const wabt = require("wabt");\n' + |
|
'const fs = require("fs");\n' + |
|
'const wasm = fs.readFileSync(process.argv[2]);\n' + |
|
'const watPath = process.argv[3];\n' + |
|
'wabt().then(m => {\n' + |
|
' const r = m.readWasm(wasm, {});\n' + |
|
' r.generateNames();\n' + |
|
' r.applyNames();\n' + |
|
' const t = r.toText({foldExprs:false,inlineExport:false});\n' + |
|
' fs.writeFileSync(watPath, t);\n' + |
|
' r.destroy();\n' + |
|
'}).catch(e => { console.error(e); process.exit(1); });\n' |
|
); |
|
execSync('node ' + tmpScript + ' ' + tmpWasm + ' ' + tmpWat, { |
|
stdio: 'pipe', timeout: 30000 |
|
}); |
|
const wat = fs.readFileSync(tmpWat, 'utf8'); |
|
fs.rmSync(tmpWasm); fs.rmSync(tmpWat); fs.rmSync(tmpScript); |
|
return wat; |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// AST pass – replace embedded b64 WASM with inline WAT + runtime compilation |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function fixWasmInAst(ast, b64, watText) { |
|
let targetBody = null; // the body array containing var O |
|
let oDeclIdx = -1; // index of var O in that body |
|
const garbageNodes = []; // nodes to remove after O |
|
|
|
// Pass 1: find var O, replace its value, collect garbage |
|
traverse(ast, { |
|
VariableDeclarator(path) { |
|
if (oDeclIdx >= 0) return; |
|
if (!t.isIdentifier(path.node.id, { name: 'O' })) return; |
|
if (!t.isStringLiteral(path.node.init)) return; |
|
if (!path.node.init.value.startsWith('AGFzbQE')) return; |
|
|
|
path.node.init = t.stringLiteral(watText); |
|
|
|
// OXC merged: var O=..., E=..., x=..., X=new Uint8Array(x) |
|
// Remove the E, x, X declarators, keep only O |
|
const decl = path.parentPath.node; |
|
if (t.isVariableDeclaration(decl) && decl.declarations.length > 1) { |
|
decl.declarations = decl.declarations.filter(d => |
|
t.isIdentifier(d.id, { name: 'O' }) |
|
); |
|
} |
|
|
|
const body = path.parentPath?.parentPath?.node?.body; |
|
if (body && Array.isArray(body)) { |
|
targetBody = body; |
|
oDeclIdx = body.indexOf(path.parentPath.node); |
|
if (oDeclIdx >= 0) { |
|
for (let i = oDeclIdx + 1; i < body.length; i++) { |
|
const stmt = body[i]; |
|
// Match: ForStatement with v-loop init, or var v,L=X declaration |
|
if (t.isForStatement(stmt)) { |
|
const init = stmt.init; |
|
// OXC folds the `var v = 0` into bare `v = 0` (AssignmentExpression) |
|
if ((t.isVariableDeclaration(init) && |
|
init.declarations.length === 1 && |
|
t.isIdentifier(init.declarations[0].id, { name: 'v' })) || |
|
(t.isAssignmentExpression(init) && |
|
t.isIdentifier(init.left, { name: 'v' }))) { |
|
garbageNodes.push(i); |
|
continue; |
|
} |
|
} |
|
if (t.isVariableDeclaration(stmt) && |
|
stmt.declarations.length === 2 && |
|
t.isIdentifier(stmt.declarations[0].id, { name: 'v' }) && |
|
t.isIdentifier(stmt.declarations[1].id, { name: 'L' }) && |
|
t.isIdentifier(stmt.declarations[1].init, { name: 'X' })) { |
|
garbageNodes.push(i); |
|
continue; |
|
} |
|
break; |
|
} |
|
} |
|
} |
|
path.stop(); |
|
} |
|
}); |
|
|
|
// Pass 2: remove garbage (reverse order so indices stay valid) |
|
if (targetBody && garbageNodes.length > 0) { |
|
for (let i = garbageNodes.length - 1; i >= 0; i--) { |
|
targetBody.splice(garbageNodes[i], 1); |
|
} |
|
// Adjust oDeclIdx if needed (removing items after it doesn't affect it) |
|
} |
|
|
|
// Pass 3: insert var X = null; var L = X; after var O |
|
if (targetBody && oDeclIdx >= 0) { |
|
targetBody.splice(oDeclIdx + 1, 0, |
|
t.variableDeclaration('var', [ |
|
t.variableDeclarator(t.identifier('X'), t.nullLiteral()) |
|
]), |
|
t.variableDeclaration('var', [ |
|
t.variableDeclarator(t.identifier('L'), t.identifier('X')) |
|
]) |
|
); |
|
} |
|
|
|
// Pass 4: In async function R, insert WAT→WASM compilation before compile(X) |
|
traverse(ast, { |
|
FunctionDeclaration(path) { |
|
if (!t.isIdentifier(path.node.id, { name: 'R' })) return; |
|
if (!path.node.async) return; |
|
|
|
path.traverse({ |
|
CallExpression(inner) { |
|
const callee = inner.node.callee; |
|
if (!t.isIdentifier(callee, { name: 'C' })) return; |
|
const arg0 = inner.node.arguments[0]; |
|
if (!arg0 || !t.isAwaitExpression(arg0)) return; |
|
const cc = arg0.argument; |
|
if (!t.isCallExpression(cc)) return; |
|
const ccallee = cc.callee; |
|
if (!t.isMemberExpression(ccallee)) return; |
|
if (!t.isIdentifier(ccallee.object, { name: 'WebAssembly' })) return; |
|
if (!t.isIdentifier(ccallee.property, { name: 'compile' })) return; |
|
|
|
const stmt = inner.findParent(p => p.isStatement()); |
|
if (!stmt) return; |
|
|
|
// Wrap WAT→WASM in try/catch so errors don't silently freeze |
|
const watCompileStmts = t.tryStatement( |
|
t.blockStatement([ |
|
t.variableDeclaration('const', [ |
|
t.variableDeclarator( |
|
t.identifier('_wabt'), |
|
t.awaitExpression(t.callExpression(t.identifier('WabtModule'), [])) |
|
) |
|
]), |
|
t.variableDeclaration('const', [ |
|
t.variableDeclarator( |
|
t.identifier('_mod'), |
|
t.callExpression( |
|
t.memberExpression(t.identifier('_wabt'), t.identifier('parseWat')), |
|
[t.stringLiteral('inline'), t.identifier('O')] |
|
) |
|
) |
|
]), |
|
t.variableDeclaration('var', [ |
|
t.variableDeclarator( |
|
t.identifier('X'), |
|
t.newExpression(t.identifier('Uint8Array'), [ |
|
t.memberExpression( |
|
t.callExpression( |
|
t.memberExpression(t.identifier('_mod'), t.identifier('toBinary')), |
|
[t.objectExpression([])] |
|
), |
|
t.identifier('buffer') |
|
) |
|
]) |
|
) |
|
]), |
|
t.expressionStatement( |
|
t.callExpression( |
|
t.memberExpression(t.identifier('_mod'), t.identifier('destroy')), |
|
[] |
|
) |
|
) |
|
]), |
|
t.catchClause( |
|
t.identifier('_e'), |
|
t.blockStatement([ |
|
t.expressionStatement( |
|
t.callExpression( |
|
t.memberExpression(t.identifier('console'), t.identifier('error')), |
|
[t.stringLiteral('WAT→WASM compile failed:'), t.identifier('_e')] |
|
) |
|
), |
|
t.throwStatement(t.identifier('_e')) |
|
]) |
|
) |
|
); |
|
stmt.insertBefore(watCompileStmts); |
|
inner.stop(); |
|
path.stop(); |
|
} |
|
}); |
|
} |
|
}); |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// STEP 4 – Babel: remove dead init block and leftover w-var aliases |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
function finalCleanup(code) { |
|
const ast = parser.parse(code, { sourceType: 'script', errorRecovery: false }); |
|
|
|
traverse(ast, { |
|
Program(p) { |
|
const body = p.node.body; |
|
for (let i = 0; i < body.length; i++) { |
|
const stmt = body[i]; |
|
if (t.isExpressionStatement(stmt)) { |
|
const expr = stmt.expression; |
|
if (t.isCallExpression(expr) && t.isFunctionExpression(expr.callee) && |
|
expr.arguments.some(a => t.isIdentifier(a) && a.name === 'z')) { |
|
body.splice(i, 1); i--; |
|
} |
|
} |
|
} |
|
}, |
|
// remove dead `const XX = Hj;` aliases |
|
VariableDeclarator(p) { |
|
if (!p.node.id || !p.node.init) return; |
|
if (t.isIdentifier(p.node.id) && t.isIdentifier(p.node.init) && |
|
p.node.init.name === 'Hj') { |
|
const parent = p.parentPath; |
|
if (parent.node.declarations.length === 1) |
|
parent.parentPath.remove(); |
|
else |
|
p.remove(); |
|
} |
|
}, |
|
}); |
|
|
|
// ── Fix swap bugs: OXC folds temp vars incorrectly, turning `tmp=a;a=b;b=tmp` |
|
// into `a=b,b=a` (a no-op). Detect and rewrite to `[a,b]=[b,a]`. |
|
traverse(ast, { |
|
SequenceExpression(p) { |
|
const exprs = p.node.expressions; |
|
if (exprs.length !== 2) return; |
|
const a1 = exprs[0], a2 = exprs[1]; |
|
if (!t.isAssignmentExpression(a1, { operator: '=' }) || |
|
!t.isAssignmentExpression(a2, { operator: '=' })) return; |
|
if (!t.isIdentifier(a1.left) || !t.isIdentifier(a2.left)) return; |
|
if (!t.isIdentifier(a1.right) || !t.isIdentifier(a2.right)) return; |
|
const a = a1.left.name, b1 = a1.right.name; |
|
const c = a2.left.name, b2 = a2.right.name; |
|
if (a === b2 && b1 === c && a !== b1) { |
|
p.replaceWith( |
|
t.assignmentExpression('=', |
|
t.arrayPattern([t.identifier(a), t.identifier(b1)]), |
|
t.arrayExpression([t.identifier(b1), t.identifier(a)]) |
|
) |
|
); |
|
} |
|
} |
|
}); |
|
|
|
// ── Fix WASM: extract b64 from AST, convert to WAT, patch AST ── |
|
let b64 = null; |
|
traverse(ast, { |
|
VariableDeclarator(p) { |
|
if (b64) return; |
|
if (t.isIdentifier(p.node.id, { name: 'O' }) && |
|
t.isStringLiteral(p.node.init) && |
|
p.node.init.value.startsWith('AGFzbQE')) { |
|
b64 = p.node.init.value; |
|
} |
|
} |
|
}); |
|
if (b64) { |
|
const wat = wasmB64toWat(b64); |
|
fixWasmInAst(ast, b64, wat); |
|
} |
|
|
|
let out = generate(ast, { compact: false, comments: false }).code; |
|
// squash any dead-comment stubs from earlier passes |
|
out = out.replace(/\/\*\s*\[.*?removed.*?\]\s*\*\/\s*/g, ''); |
|
out = out.replace(/\n{3,}/g, '\n\n'); |
|
return out; |
|
} |
|
|
|
// ───────────────────────────────────────────────────────────────────────────── |
|
// MAIN |
|
// ───────────────────────────────────────────────────────────────────────────── |
|
console.log('=== paper3d deobfuscator (1-shot) ===\n'); |
|
|
|
const raw = fs.readFileSync(INPUT, 'utf8'); |
|
console.log('[1] Input: %s (%d bytes)', INPUT, raw.length); |
|
|
|
// Step 1 |
|
const strings = extractStringArray(raw); |
|
console.log('[2] String array: %d entries', strings.length); |
|
|
|
// Step 2 |
|
const babelResult = resolveStringLookups(raw, strings); |
|
fs.writeFileSync(TMP1, babelResult.code); |
|
console.log('[3] Babel: %d lookups resolved, %d w-var aliases found', |
|
babelResult.replaced, babelResult.wVarCount); |
|
|
|
// Step 3 |
|
const oxcCode = oxcDeob(TMP1, TMP2); |
|
console.log('[4] OXC deob: %d bytes', oxcCode.length); |
|
|
|
// Step 4 |
|
const final = finalCleanup(oxcCode); |
|
fs.writeFileSync(OUTPUT, final); |
|
console.log('[5] Cleanup → %s (%d bytes)', OUTPUT, final.length); |
|
|
|
// Tidy up temp files |
|
fs.rmSync(TMP1); fs.rmSync(TMP2); |
|
|
|
console.log('\nDone.'); |