OverviewFilesCommitsIssuesDocsReleasesPeers

keet-cli / tools / deobfuscate.cjs

672 lines · javascript ref:
#!/usr/bin/env node
'use strict'

/**
 * Keet-Core-Hyperdb Deobfuscator
 *
 * Handles obfuscator.io pattern:
 *   1. String array function  -> function _0xNAME() { return [...strings...]; }
 *   2. Rotation IIFE          -> (function(arr, checksum) { while(!![]) { push/shift } })(arrayFunc, N)
 *   3. Base decoder           -> function _0xNAME(a, b) { arr = arrayFunc(); a = a - OFFSET; return arr[a]; }
 *   4. Helper chains          -> function _0xNAME(a,b,c,d) { return _0xBASE(expr, expr); }
 *   5. Complex helpers        -> function _0xNAME(a,b,c,d) { var C = {...}; return _0xBASE(expr); }
 *   6. Anti-debug wrappers    -> self-defending + console protection IIFEs
 *
 * Usage: node deobfuscate.cjs <input.js> [output.js]
 *        node deobfuscate.cjs --batch <dir> <outdir>  (process all .js files)
 */

const fs = require('fs')
const vm = require('vm')
const path = require('path')

// -- CLI ----------------------------------------------------------------------

const args = process.argv.slice(2)

if (args[0] === '--batch') {
  batchMode(args[1], args[2])
} else if (args[0] === '--help' || args.length === 0) {
  console.error('Usage: node deobfuscate.cjs <input.js> [output.js]')
  console.error('       node deobfuscate.cjs --batch <input-dir> <output-dir>')
  process.exit(args.length === 0 ? 1 : 0)
} else {
  singleFile(args[0], args[1])
}

function singleFile (inputFile, outputFile) {
  const code = fs.readFileSync(inputFile, 'utf8')
  const result = deobfuscate(code, path.basename(inputFile))

  if (outputFile) {
    fs.mkdirSync(path.dirname(outputFile), { recursive: true })
    fs.writeFileSync(outputFile, result)
    console.error(`  -> ${outputFile}`)
  } else {
    process.stdout.write(result)
  }
}

function batchMode (inputDir, outputDir) {
  if (!inputDir || !outputDir) {
    console.error('Usage: node deobfuscate.cjs --batch <input-dir> <output-dir>')
    process.exit(1)
  }

  const files = findJSFiles(inputDir)
  console.error(`Found ${files.length} JS files in ${inputDir}`)

  let success = 0
  let skipped = 0
  let failed = 0

  for (const file of files) {
    const rel = path.relative(inputDir, file)
    const outFile = path.join(outputDir, rel)

    try {
      const code = fs.readFileSync(file, 'utf8')

      // Skip non-obfuscated files (no _0x pattern)
      if (!code.includes('_0x') || !code.includes('while(!![])')) {
        fs.mkdirSync(path.dirname(outFile), { recursive: true })
        fs.writeFileSync(outFile, code)
        skipped++
        continue
      }

      console.error(`\nDeobfuscating: ${rel}`)
      const result = deobfuscate(code, rel)
      fs.mkdirSync(path.dirname(outFile), { recursive: true })
      fs.writeFileSync(outFile, result)
      success++
    } catch (e) {
      console.error(`  FAILED: ${rel} - ${e.message}`)
      // Write original on failure
      try {
        fs.mkdirSync(path.dirname(outFile), { recursive: true })
        fs.copyFileSync(file, outFile)
      } catch (_) {}
      failed++
    }
  }

  console.error(`\nDone: ${success} deobfuscated, ${skipped} clean, ${failed} failed`)
}

function findJSFiles (dir) {
  const results = []
  const entries = fs.readdirSync(dir, { withFileTypes: true })
  for (const entry of entries) {
    const full = path.join(dir, entry.name)
    if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== '.git') {
      results.push(...findJSFiles(full))
    } else if (entry.isFile() && entry.name.endsWith('.js')) {
      results.push(full)
    }
  }
  return results
}

// -- Main Pipeline ------------------------------------------------------------

function deobfuscate (code, filename) {
  // Step 1: Extract string array function
  const arrayFunc = extractStringArrayFunc(code)
  if (!arrayFunc) {
    console.error('  SKIP: No string array function found (not obfuscated?)')
    return formatCode(code)
  }
  console.error(`  Array func: ${arrayFunc.name}`)

  // Step 2: Extract base decoder function
  const baseDecoder = extractBaseDecoder(code, arrayFunc.name)
  if (!baseDecoder) {
    console.error('  SKIP: No base decoder found')
    return formatCode(code)
  }
  console.error(`  Base decoder: ${baseDecoder.name}`)

  // Step 3: Extract rotation IIFE
  const rotationIIFE = extractRotationIIFE(code)

  // Step 4: Bootstrap the VM
  const ctx = bootstrapVM(arrayFunc, baseDecoder, rotationIIFE)

  // Step 5: Extract ALL constants from the file
  const constants = extractAllConstants(code)
  registerConstants(ctx, constants)

  // Step 6: Extract ALL helper functions (simple + complex)
  const helpers = extractAllHelpers(code, baseDecoder.name)
  const registered = registerHelpers(ctx, helpers)
  console.error(`  Helpers: ${registered}/${helpers.size} registered`)

  // Step 7: Remove infrastructure FIRST, then replace calls in clean code
  let output = code

  // Remove rotation IIFE (dead code after bootstrap)
  if (rotationIIFE) {
    output = safeRemove(output, rotationIIFE.body)
  }

  // Remove anti-debug wrappers
  output = removeAntiDebug(output)

  // Step 8: Replace all obfuscated string calls
  const stats = { replacements: 0, failures: 0 }
  output = replaceAllCalls(output, ctx, helpers, baseDecoder.name, stats)
  console.error(`  Replaced: ${stats.replacements} strings (${stats.failures} unresolved)`)

  // Step 9: Remove obfuscation infrastructure definitions
  output = safeRemove(output, arrayFunc.body)
  output = safeRemove(output, baseDecoder.body)
  for (const [, helper] of helpers) {
    if (helper.registered) {
      output = safeRemove(output, helper.body)
    }
  }

  // Step 10: Clean orphaned constants (only if name no longer appears in code)
  output = removeOrphanedConstants(output)

  // Step 11: Restore property access, merge strings, and format
  output = mergeStringConcat(output)
  output = restorePropertyAccess(output)
  output = cleanupPunctuation(output)
  output = formatCode(output)

  return output
}

// -- Step 1: String Array Function -------------------------------------------

function extractStringArrayFunc (code) {
  // Find: function _0xNAME() { (const|var|let) _0xLOCAL = [...]; _0xNAME = function() { return _0xLOCAL; }; return _0xNAME(); }
  // The array can be very large and contain ] chars, so we match the function name first then find the body

  // Find the function name
  const nameMatch = code.match(
    /function (_0x[0-9a-f]+)\(\)\{(?:const|var|let) (_0x[0-9a-f]+)=\[/
  )
  if (!nameMatch) return null

  const name = nameMatch[1]
  const localVar = nameMatch[2]

  // Find the complete function body by looking for the closing pattern
  const closingPattern = name + '=function(){return ' + localVar + ';};return ' + name + '();}'
  const closingIdx = code.indexOf(closingPattern)
  if (closingIdx === -1) return null

  // Walk backwards from nameMatch to find 'function' keyword
  const funcStart = code.lastIndexOf('function ' + name, nameMatch.index + 10)
  if (funcStart === -1) return null

  const body = code.substring(funcStart, closingIdx + closingPattern.length)

  return { name, body }
}

// -- Step 2: Base Decoder ----------------------------------------------------

function extractBaseDecoder (code, arrayFuncName) {
  const esc = escapeRegex(arrayFuncName)

  // Try exact pattern first
  const exact = code.match(new RegExp(
    '(function (_0x[0-9a-f]+)\\((_0x[0-9a-f]+),(_0x[0-9a-f]+)\\)\\{' +
    '(?:const|var|let) (_0x[0-9a-f]+)=' + esc + '\\(\\);' +
    'return \\2=function\\((_0x[0-9a-f]+),(_0x[0-9a-f]+)\\)\\{' +
    '\\6=\\6-\\(?[^;]+\\)?;' +
    '(?:let|var|const) (_0x[0-9a-f]+)=\\5\\[\\6\\];' +
    'return \\8;\\}' +
    ',\\2\\(\\3,\\4\\);\\})'
  ))
  if (exact) return { name: exact[2], body: exact[1] }

  // Fallback: find function that references arrayFunc and self-reassigns
  const fallback = code.match(new RegExp(
    '(function (_0x[0-9a-f]+)\\((_0x[0-9a-f]+),(_0x[0-9a-f]+)\\)\\{' +
    '(?:const|var|let) [^=]+=' + esc + '\\(\\);' +
    'return \\2=function[^}]+\\}' +
    ',\\2\\(\\3,\\4\\);\\})'
  ))
  if (fallback) return { name: fallback[2], body: fallback[1] }

  return null
}

// -- Step 3: Rotation IIFE --------------------------------------------------

function extractRotationIIFE (code) {
  // Find the (function(a,b){ ... while(!![]) { try { ... push(shift) ... } } })(arrayFunc, checksum)
  // This is always near the start of the file
  const marker = 'while(!![])'
  const whileIdx = code.indexOf(marker)
  if (whileIdx === -1) return null

  // Walk backwards to find the opening (function(
  let searchStart = whileIdx
  let iifeFuncStart = -1
  for (let i = searchStart; i >= 0; i--) {
    if (code.substring(i, i + 10) === '(function(') {
      iifeFuncStart = i
      break
    }
  }
  if (iifeFuncStart === -1) return null

  // Find the matching ) for the outer ( at iifeFuncStart
  const outerEnd = findMatchingParen(code, iifeFuncStart)
  if (outerEnd === -1) return null

  // Include the trailing semicolon if present
  let endIdx = outerEnd + 1
  if (code[endIdx] === ';') endIdx++

  const body = code.substring(iifeFuncStart, endIdx)

  // Verify it has push/shift
  if (!body.includes("'push'") && !body.includes("['push']")) {
    // Some use direct property access
    if (!body.includes('.push(') && !body.includes('.shift()')) return null
  }

  return { body, startIdx: iifeFuncStart, endIdx }
}

// -- Step 4: Bootstrap VM ----------------------------------------------------

function bootstrapVM (arrayFunc, baseDecoder, rotationIIFE) {
  const ctx = vm.createContext({
    parseInt,
    parseFloat,
    isNaN,
    isFinite,
    String,
    Number,
    Boolean,
    Array,
    Object,
    RegExp,
    Math,
    Date,
    JSON,
    console: { log () {}, error () {}, warn () {}, info () {} },
    setTimeout: () => {},
    setInterval: () => {},
    clearTimeout: () => {},
    clearInterval: () => {}
  })

  // Execute string array function
  vm.runInContext(arrayFunc.body, ctx)

  // Execute base decoder
  vm.runInContext(baseDecoder.body, ctx)

  // Execute rotation IIFE
  if (rotationIIFE) {
    try {
      vm.runInContext(rotationIIFE.body, ctx, { timeout: 5000 })
    } catch (e) {
      console.error(`  WARNING: Rotation IIFE failed: ${e.message}`)
    }
  }

  // Quick sanity test
  let tested = false
  for (let idx = 0x40; idx < 0x200 && !tested; idx++) {
    try {
      const r = vm.runInContext(`${baseDecoder.name}(${idx})`, ctx)
      if (typeof r === 'string') {
        console.error(`  VM OK: ${baseDecoder.name}(0x${idx.toString(16)}) = "${r.substring(0, 40)}"`)
        tested = true
      }
    } catch (_) {}
  }

  return ctx
}

// -- Step 5: Constants -------------------------------------------------------

function extractAllConstants (code) {
  const constants = new Map()

  // Match constant objects with hex-only values
  // These appear in two forms:
  // 1. (const|var|let) NAME = { _0xKEY: 0xVAL, ... }
  // 2. ,NAME = { _0xKEY: 0xVAL, ... }  (comma-separated in same declaration)
  // Also match inside function bodies (scoped constants)
  const re = /(?:(?:const|var|let|,)\s*)(_0x[0-9a-f]+)=\{((?:_0x[0-9a-f]+:0x[0-9a-f]+,?)+)\}/g
  let m
  while ((m = re.exec(code)) !== null) {
    const obj = {}
    const kvRe = /(_0x[0-9a-f]+):(0x[0-9a-f]+)/g
    let kv
    while ((kv = kvRe.exec(m[2])) !== null) {
      obj[kv[1]] = parseInt(kv[2], 16)
    }
    constants.set(m[1], { name: m[1], obj, decl: m[0] })
  }

  return constants
}

function registerConstants (ctx, constants) {
  for (const [name, c] of constants) {
    try {
      const entries = Object.entries(c.obj).map(([k, v]) => `'${k}':${v}`).join(',')
      vm.runInContext(`var ${name} = {${entries}};`, ctx)
    } catch (_) {}
  }
}

// -- Step 6: Helpers ---------------------------------------------------------

function extractAllHelpers (code, baseDecoderName) {
  const helpers = new Map()

  // Simple helpers: function NAME(params) { return TARGET(args); }
  const simpleRe = /function (_0x[0-9a-f]+)\(([^)]+)\)\{return (_0x[0-9a-f]+)\(([^)]+)\);\}/g
  let m
  while ((m = simpleRe.exec(code)) !== null) {
    helpers.set(m[1], { name: m[1], body: m[0], registered: false })
  }

  // Complex helpers: function NAME(params) { var/const C = {...}; return TARGET(args); }
  const complexRe = /function (_0x[0-9a-f]+)\(([^)]+)\)\{(?:var|const|let) (_0x[0-9a-f]+)=\{((?:_0x[0-9a-f]+:0x[0-9a-f]+,?)+)\};return (_0x[0-9a-f]+)\(([^)]+)\);\}/g
  while ((m = complexRe.exec(code)) !== null) {
    if (!helpers.has(m[1])) {
      helpers.set(m[1], { name: m[1], body: m[0], registered: false })
    }
  }

  return helpers
}

function registerHelpers (ctx, helpers) {
  let registered = 0
  let lastCount = -1

  // Multi-pass: helpers may depend on other helpers
  for (let pass = 0; pass < 15 && registered !== lastCount; pass++) {
    lastCount = registered
    for (const [, h] of helpers) {
      if (h.registered) continue
      try {
        vm.runInContext(h.body, ctx, { timeout: 1000 })
        h.registered = true
        registered++
      } catch (_) {}
    }
  }

  return registered
}

// -- Step 7: Anti-Debug Removal ----------------------------------------------

function removeAntiDebug (code) {
  let output = code

  // Pattern: Self-defending + console protection wrapper
  // var FACTORY=(function(){var a=!![];return function(b,c){...apply...};}()),
  //     RESULT=FACTORY(this,function(){...toString()...constructor...search...});
  // RESULT();
  //
  // The FACTORY uses !![] (true) and the RESULT contains toString/constructor patterns.
  // We detect FACTORY, then find RESULT, then find RESULT() activation.

  // Find factory: (function(){var X=!![];return function(
  const factoryRe = /(?:const|var|let) (_0x[0-9a-f]+)=\(function\(\)\{(?:let|var) _0x[0-9a-f]+=!!\[\];return function\(/g
  let m
  while ((m = factoryRe.exec(output)) !== null) {
    const factoryName = m[1]
    const declStart = m.index

    // Find: ,RESULT=FACTORY(this,function(){...}); or similar
    // The RESULT variable is declared in the same var statement
    const afterFactory = output.substring(declStart)
    const resultRe = new RegExp(',(_0x[0-9a-f]+)=' + escapeRegex(factoryName) + '\\(this,function\\(\\)\\{')
    const resultMatch = resultRe.exec(afterFactory)

    if (resultMatch) {
      const resultName = resultMatch[1]
      // Find the activation: RESULT();
      const activationRe = new RegExp(escapeRegex(resultName) + '\\(\\);?')
      const activationMatch = activationRe.exec(afterFactory)

      if (activationMatch) {
        const removeEnd = declStart + activationMatch.index + activationMatch[0].length
        output = output.substring(0, declStart) + output.substring(removeEnd)
        factoryRe.lastIndex = declStart
        continue
      }
    }

    // Fallback: just try to remove from declStart to the first standalone _0x();
    // after the factory pattern
    const fallbackRe = /(_0x[0-9a-f]+)\(\);/g
    fallbackRe.lastIndex = declStart + m[0].length
    const fbMatch = fallbackRe.exec(output)
    if (fbMatch && fbMatch.index < declStart + 5000) {
      const removeEnd = fbMatch.index + fbMatch[0].length
      output = output.substring(0, declStart) + output.substring(removeEnd)
      factoryRe.lastIndex = declStart
    }
  }

  return output
}

// -- Step 8: Replace Calls ---------------------------------------------------

function replaceAllCalls (code, ctx, helpers, baseDecoderName, stats) {
  const resolvable = new Set([baseDecoderName])
  for (const [name, h] of helpers) {
    if (h.registered) resolvable.add(name)
  }

  // Match calls with numeric/hex literal args or constant obj references
  // Each arg can be: -?0x[hex], -?[dec], -?_0xNAME._0xKEY
  const ARG = '(?:[-]?0x[0-9a-f]+|[-]?\\d+|[-]?_0x[0-9a-f]+\\._0x[0-9a-f]+)'
  const callRe = new RegExp('(_0x[0-9a-f]+)\\((' + ARG + '(?:,' + ARG + ')*)\\)', 'g')

  return code.replace(callRe, (full, funcName, argsStr) => {
    if (!resolvable.has(funcName)) return full

    try {
      const result = vm.runInContext(`${funcName}(${argsStr})`, ctx, { timeout: 500 })
      if (typeof result === 'string') {
        stats.replacements++
        return JSON.stringify(result)
      }
      return full
    } catch (_) {
      stats.failures++
      return full
    }
  })
}

// -- Step 9-11: Cleanup ------------------------------------------------------

function safeRemove (code, fragment) {
  const idx = code.indexOf(fragment)
  if (idx === -1) return code
  return code.substring(0, idx) + code.substring(idx + fragment.length)
}

function removeOrphanedConstants (code) {
  // Remove constant object declarations whose name doesn't appear elsewhere
  return code.replace(
    /(?:const|var|let) (_0x[0-9a-f]+)=\{(?:_0x[0-9a-f]+:0x[0-9a-f]+,?)+\};?/g,
    (match, name) => {
      // Count occurrences of the name in the code EXCLUDING this declaration
      const without = code.replace(match, '')
      return without.includes(name) ? match : ''
    }
  )
}

function restorePropertyAccess (code) {
  // obj["property"] or obj['property'] -> obj.property (for valid JS identifiers)
  return code.replace(/\[["']([a-zA-Z_$][a-zA-Z0-9_$]*)["']\]/g, '.$1')
}

function mergeStringConcat (code) {
  // Merge adjacent string literals: "abc" + "def" -> "abcdef"
  // Also handles: "abc" + 'def', 'abc' + "def", 'abc' + 'def'
  let prev = ''
  let result = code
  // Keep merging until stable (handles chains of 3+ concatenations)
  while (result !== prev) {
    prev = result
    result = result.replace(/(["'])((?:[^"'\\]|\\.)*)(\1)\s*\+\s*(["'])((?:[^"'\\]|\\.)*)(\4)/g,
      (_, q1, s1, _q1, q2, s2, _q2) => {
        return '"' + s1 + s2 + '"'
      }
    )
  }
  return result
}

function cleanupPunctuation (code) {
  let output = code
    .replace(/,{2,}/g, ',')
    .replace(/;{2,}/g, ';')
    .replace(/\n{3,}/g, '\n\n')

  // Remove leading commas after { or ; or newline (left behind from removed constants)
  output = output.replace(/(\{|;|\n)\s*,\s*/g, '$1')
  // Remove leading commas at start of file
  output = output.replace(/^[,;\s]+/, '')
  // Clean up leftover hex arithmetic in simple cases: 0xA+0xB-0xC -> evaluate
  output = output.replace(/\b(-?0x[0-9a-f]+(?:\s*[+\-*]\s*-?0x[0-9a-f]+)+)\b/g, (match) => {
    try {
      const result = Function('return ' + match)()
      if (typeof result === 'number' && Number.isInteger(result)) {
        return result >= 0 ? '0x' + result.toString(16) : '-0x' + (-result).toString(16)
      }
    } catch (_) {}
    return match
  })

  return output
}

function formatCode (code) {
  let output = addNewlines(code)
  output = indent(output)
  return output
}

function addNewlines (code) {
  const result = []
  let inStr = false
  let strCh = null
  let esc = false
  let pDepth = 0
  let bDepth = 0
  let sqDepth = 0

  for (let i = 0; i < code.length; i++) {
    const ch = code[i]

    if (esc) { result.push(ch); esc = false; continue }
    if (ch === '\\' && inStr) { result.push(ch); esc = true; continue }
    if (inStr) { result.push(ch); if (ch === strCh) inStr = false; continue }
    if (ch === '"' || ch === "'" || ch === '`') { inStr = true; strCh = ch; result.push(ch); continue }

    if (ch === '(') pDepth++
    if (ch === ')') pDepth--
    if (ch === '{') bDepth++
    if (ch === '}') bDepth--
    if (ch === '[') sqDepth++
    if (ch === ']') sqDepth--

    result.push(ch)

    // Newline at statement boundaries (not inside parens/brackets)
    if (pDepth === 0 && sqDepth === 0) {
      if (ch === ';') result.push('\n')
      if (ch === '{') result.push('\n')
      if (ch === '}') {
        const after = code.substring(i + 1, i + 20).match(/^\s*(else|catch|finally|[),;])/)?.[1]
        if (!after || after === ';') result.push('\n')
      }
    }
  }

  return result.join('')
}

function indent (code) {
  const lines = code.split('\n')
  const out = []
  let level = 0

  for (const raw of lines) {
    const line = raw.trim()
    if (!line) continue

    const opens = countOutsideStrings(line, '{')
    const closes = countOutsideStrings(line, '}')

    if (line[0] === '}' || line[0] === ']') level = Math.max(0, level - 1)
    out.push('  '.repeat(level) + line)
    level += opens - closes
    if (level < 0) level = 0
  }

  return out.join('\n') + '\n'
}

function countOutsideStrings (line, char) {
  let count = 0
  let inStr = false
  let strCh = null
  let esc = false

  for (const ch of line) {
    if (esc) { esc = false; continue }
    if (ch === '\\' && inStr) { esc = true; continue }
    if (inStr) { if (ch === strCh) inStr = false; continue }
    if (ch === '"' || ch === "'" || ch === '`') { inStr = true; strCh = ch; continue }
    if (ch === char) count++
  }

  return count
}

// -- Utilities ---------------------------------------------------------------

function findMatchingParen (code, openIdx) {
  let depth = 0
  let inStr = false
  let strCh = null
  let esc = false

  for (let i = openIdx; i < code.length; i++) {
    const ch = code[i]
    if (esc) { esc = false; continue }
    if (ch === '\\' && inStr) { esc = true; continue }
    if (inStr) { if (ch === strCh) inStr = false; continue }
    if (ch === '"' || ch === "'" || ch === '`') { inStr = true; strCh = ch; continue }

    if (ch === '(') depth++
    if (ch === ')') {
      depth--
      if (depth === 0) return i
    }
  }
  return -1
}

function escapeRegex (s) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}