控制流图 (Control Flow Graph, CFG) 重建:如何从混淆后的 JavaScript 代码中提取 CFG,并识别其真实的程序流?

嘿,各位代码探险家们,欢迎来到“JavaScript 迷宫寻宝”讲座!今天我们要聊的是如何从那些被搅得一团糟的 JavaScript 代码里,像福尔摩斯一样,抽丝剥茧,把程序的真实运行轨迹,也就是控制流图(CFG),给重建出来。

准备好了吗?让我们开始这场烧脑但绝对有趣的旅程吧!

第一幕:认识我们的对手——混淆 JavaScript

首先,我们得了解一下我们的对手,也就是那些把代码搞得乱七八糟的混淆技术。 它们的主要目标是:

  • 让代码难以阅读: 变量名改成 a, b, c,函数名改成 _0xabc,常量变成 16 进制等等。
  • 隐藏代码逻辑: 用各种奇葩的控制流结构,比如 if (!true),或者用 while(true) 包裹一大段代码,然后用 break 跳出来。
  • 增加代码复杂度: 插入大量无意义的代码,让代码体积膨胀,分析难度增加。

举个例子,下面这段简单的 JavaScript 代码:

function greet(name) {
  if (name) {
    console.log("Hello, " + name + "!");
  } else {
    console.log("Hello, world!");
  }
}

greet("Alice");

经过混淆后,可能会变成这样:

var _0x1234 = ["Hello, ", "world!", "Alice", "log"];
function _0xabcd(_0xefgh) {
  if (_0xefgh) {
    console[_0x1234[3]](_0x1234[0] + _0xefgh + "!");
  } else {
    console[_0x1234[3]](_0x1234[0] + _0x1234[1] + "!");
  }
}
_0xabcd(_0x1234[2]);

怎么样,是不是瞬间感觉智商被侮辱了? 别担心,我们就是要征服这些让人头大的代码!

第二幕:控制流图(CFG)是什么?

简单来说,CFG 就是程序执行流程的图形化表示。 想象一下你走迷宫,CFG 就是迷宫的地图。

  • 节点 (Node): 代表程序中的一个基本块(Basic Block)。 基本块是指一系列连续的语句,这些语句要么都执行,要么都不执行。 例如,一个函数、一个 if 语句块、一个 while 循环体等等。
  • 边 (Edge): 代表程序控制流的转移。 例如,从一个 if 语句到 then 分支或 else 分支,从一个循环体回到循环的开始等等。

举个例子,对于下面的代码:

function example(x) {
  if (x > 5) {
    x = x * 2;
  } else {
    x = x + 1;
  }
  return x;
}

它的 CFG 可以简单表示为:

[Start] --> [x > 5 ?]
[x > 5 ?] --> [x = x * 2] (True)
[x > 5 ?] --> [x = x + 1] (False)
[x = x * 2] --> [return x]
[x = x + 1] --> [return x]
[return x] --> [End]

画成图的话,大概是这样(这里无法直接绘制图形,请脑补一个):

  Start
    |
    v
  x > 5 ?
   /   
  T     F
  |     |
  v     v
x=x*2 x=x+1
  |     |
  v     v
return x
    |
    v
   End

第三幕:重建 CFG 的核心步骤

现在,我们来聊聊如何从混淆后的 JavaScript 代码中重建 CFG。 这可不是一件容易的事情,需要我们运用各种武器。

  1. 语法分析 (Parsing):

    • 首先,我们需要把 JavaScript 代码解析成抽象语法树 (Abstract Syntax Tree, AST)。 AST 是一种树状结构,它表示了代码的语法结构。
    • 可以使用一些现成的 JavaScript 解析器,比如 acornesprimababel-parser 等等。
    const acorn = require("acorn");
    
    const code = `
    function greet(name) {
      if (name) {
        console.log("Hello, " + name + "!");
      } else {
        console.log("Hello, world!");
      }
    }
    `;
    
    const ast = acorn.parse(code, { ecmaVersion: 2020 });
    
    // console.log(JSON.stringify(ast, null, 2)); // 可以打印 AST 看看

    AST 长什么样呢? 简单来说,它会把代码分解成一个个节点,比如 FunctionDeclaration (函数声明),IfStatement (if 语句),CallExpression (函数调用) 等等。 每个节点都有自己的属性,比如 type (节点类型),name (变量名),body (函数体) 等等。

  2. 控制流分析:

    • 有了 AST 之后,我们就可以开始分析代码的控制流了。 这需要我们遍历 AST,找到控制流相关的节点,比如 IfStatementWhileStatementForStatementSwitchStatementTryStatement 等等。
    • 对于每个控制流节点,我们需要确定它的后继节点 (Successor Nodes)。 也就是代码执行完这个节点后,会跳转到哪个节点。

    举个例子,对于 IfStatement 节点,它的后继节点可能是 then 分支的第一个节点,也可能是 else 分支的第一个节点,或者 if 语句之后的第一个节点。

    这里给出一个简单的 IfStatement 处理的例子:

    function handleIfStatement(node, cfg) {
      const testNode = node.test; // 条件表达式
      const consequentNode = node.consequent; // then 分支
      const alternateNode = node.alternate; // else 分支
    
      // 创建条件判断节点
      const conditionNode = {
        type: "Condition",
        astNode: testNode,
      };
      cfg.addNode(conditionNode);
    
      // 连接当前节点到条件判断节点
      cfg.addEdge(cfg.currentNode, conditionNode);
      cfg.currentNode = conditionNode;
    
      // 处理 then 分支
      const thenStartNode = { type: "ThenStart" };
      cfg.addNode(thenStartNode);
      cfg.addEdge(cfg.currentNode, thenStartNode, "true"); // 添加 true 边
      const oldCurrentNode = cfg.currentNode; // 保存当前节点
      cfg.currentNode = thenStartNode;
      handleStatement(consequentNode, cfg); // 递归处理 then 分支
      const thenEndNode = cfg.currentNode;
      cfg.currentNode = oldCurrentNode; // 恢复当前节点
    
      // 处理 else 分支
      let elseEndNode;
      if (alternateNode) {
        const elseStartNode = { type: "ElseStart" };
        cfg.addNode(elseStartNode);
        cfg.addEdge(cfg.currentNode, elseStartNode, "false"); // 添加 false 边
        cfg.currentNode = elseStartNode;
        handleStatement(alternateNode, cfg); // 递归处理 else 分支
        elseEndNode = cfg.currentNode;
        cfg.currentNode = oldCurrentNode; // 恢复当前节点
      } else {
        elseEndNode = cfg.currentNode;
      }
    
      // 合并 then 和 else 分支
      const ifEndNode = { type: "IfEnd" };
      cfg.addNode(ifEndNode);
      cfg.addEdge(thenEndNode, ifEndNode);
      cfg.addEdge(elseEndNode, ifEndNode);
      cfg.currentNode = ifEndNode;
    }
  3. 处理混淆技巧:

    • 这是最难的部分。我们需要识别并消除各种混淆技巧,才能得到真实的控制流。
    • 死代码消除: 移除永远不会执行的代码,比如 if (false) { ... }
    • 常量折叠: 把常量表达式计算出来,比如 x = 1 + 2; 变成 x = 3;
    • 控制流扁平化 (Control Flow Flattening) 解除: 这是一个比较常见的混淆技巧,它把代码分成很多小块,然后用一个 switch 语句来控制执行顺序。我们需要分析 switch 语句的逻辑,把代码块按照正确的顺序连接起来。
    • Opaque Predicates 解除: 这种混淆技巧会插入一些永远为真或永远为假的条件,来迷惑分析器。我们需要识别这些条件,并把它们替换成 truefalse

    这里给出一个简单的常量折叠的例子:

    function constantFolding(ast) {
      estraverse.replace(ast, {
        enter: function (node) {
          if (node.type === "BinaryExpression") {
            if (typeof node.left.value === "number" && typeof node.right.value === "number") {
              let result;
              switch (node.operator) {
                case "+":
                  result = node.left.value + node.right.value;
                  break;
                case "-":
                  result = node.left.value - node.right.value;
                  break;
                case "*":
                  result = node.left.value * node.right.value;
                  break;
                case "/":
                  result = node.left.value / node.right.value;
                  break;
              }
              if (result !== undefined) {
                return {
                  type: "Literal",
                  value: result,
                };
              }
            }
          }
        },
      });
      return ast;
    }
  4. CFG 构建:

    • 在完成以上步骤后,我们就可以开始构建 CFG 了。
    • 我们需要创建节点和边,把程序的控制流连接起来。
    • 可以使用一些现成的 CFG 可视化工具,比如 Graphviz,来把 CFG 显示出来。

第四幕:实战演练——控制流扁平化解除

控制流扁平化是一种常见的混淆技巧,我们来重点讲解一下如何解除它。

控制流扁平化的基本思想是:

  1. 把代码分成很多小块 (Basic Block)。
  2. 用一个状态变量 (State Variable) 来表示当前要执行的代码块。
  3. 用一个 switch 语句来根据状态变量的值,跳转到不同的代码块。
  4. 每个代码块执行完后,会更新状态变量的值,跳转到下一个代码块。

例如,下面的代码:

function example(x) {
  let y = x + 1;
  let z = y * 2;
  return z;
}

经过控制流扁平化后,可能会变成这样:

function example(x) {
  let y, z;
  let state = 0; // 状态变量

  while (true) {
    switch (state) {
      case 0: // 对应 let y = x + 1;
        y = x + 1;
        state = 1;
        break;
      case 1: // 对应 let z = y * 2;
        z = y * 2;
        state = 2;
        break;
      case 2: // 对应 return z;
        return z;
      default:
        break;
    }
  }
}

要解除控制流扁平化,我们需要:

  1. 找到状态变量: 识别代码中用于控制程序流程的变量。 通常,这个变量会在 switch 语句的 case 中被赋值。
  2. 找到 switch 语句: 识别用于控制程序流程的 switch 语句。
  3. 提取代码块:switch 语句的每个 case 中提取代码块。
  4. 确定代码块的执行顺序: 分析每个代码块中状态变量的赋值,确定代码块的执行顺序。
  5. 重构代码: 按照正确的执行顺序,把代码块连接起来,恢复原始的代码结构。

下面是一个简单的控制流扁平化解除的例子:

function deobfuscate(code) {
  const ast = acorn.parse(code, { ecmaVersion: 2020 });

  // 1. 找到状态变量和 switch 语句
  let stateVariable = null;
  let switchStatement = null;

  estraverse.traverse(ast, {
    enter: function (node) {
      if (node.type === "WhileStatement" && node.body.type === "BlockStatement") {
        const block = node.body.body;
        if (block.length === 1 && block[0].type === "SwitchStatement") {
          switchStatement = block[0];
          // 假设状态变量是 switch 语句 discriminator 中唯一的 Identifier
          if (switchStatement.discriminant.type === "Identifier") {
            stateVariable = switchStatement.discriminant.name;
          }
        }
      }
    },
  });

  if (!stateVariable || !switchStatement) {
    console.log("未找到控制流扁平化结构");
    return code;
  }

  // 2. 提取代码块
  const blocks = {};
  switchStatement.cases.forEach((caseNode) => {
    if (caseNode.test && caseNode.test.type === "Literal") {
      const state = caseNode.test.value;
      blocks[state] = caseNode.consequent;
    }
  });

  // 3. 确定代码块的执行顺序
  const executionOrder = [];
  let currentState = 0; // 假设初始状态是 0
  while (blocks[currentState]) {
    executionOrder.push(currentState);
    // 找到状态变量的赋值
    let nextState = null;
    blocks[currentState].forEach((statement) => {
      if (
        statement.type === "ExpressionStatement" &&
        statement.expression.type === "AssignmentExpression" &&
        statement.expression.left.type === "Identifier" &&
        statement.expression.left.name === stateVariable &&
        statement.expression.right.type === "Literal"
      ) {
        nextState = statement.expression.right.value;
      }
    });
    if (nextState === null) {
        //如果nextState为Null,说明是return语句,结束循环
        break;
    }
    currentState = nextState;
  }

  // 4. 重构代码
  const deobfuscatedCode = [];
  executionOrder.forEach((state) => {
    blocks[state].forEach((statement) => {
      if (
        !(
            statement.type === "ExpressionStatement" &&
            statement.expression.type === "AssignmentExpression" &&
            statement.expression.left.type === "Identifier" &&
            statement.expression.left.name === stateVariable
          )
          && statement.type !== "BreakStatement"
      ) {
        deobfuscatedCode.push(statement);
      }
    });
  });

  // 将 AST 转换回代码
  const newAst = acorn.parse(code, { ecmaVersion: 2020 });
  estraverse.replace(newAst, {
      enter: function(node) {
          if(node.type === "WhileStatement" && node.body.type === "BlockStatement") {
              const block = node.body.body;
              if (block.length === 1 && block[0].type === "SwitchStatement") {
                  return {
                      type: "BlockStatement",
                      body: deobfuscatedCode
                  };
              }
          }
      }
  });
  return escodegen.generate(newAst);
}

第五幕:工具箱——我们的武器库

  • JavaScript 解析器: acorn, esprima, babel-parser
  • AST 遍历器: estraverse
  • 代码生成器: escodegen
  • CFG 可视化工具: Graphviz

第六幕:总结与展望

重建混淆 JavaScript 代码的 CFG 是一项充满挑战但又非常有价值的任务。 它可以帮助我们理解代码的真实逻辑,发现潜在的安全漏洞,甚至可以用于自动化代码分析和逆向工程。

当然,我们今天讲的只是冰山一角。 还有很多更高级的混淆技巧,比如多态变形 (Polymorphic Code),不透明谓词 (Opaque Predicates),以及各种自定义的加密算法等等。 要想完全掌握这些技术,需要我们不断学习和实践,积累更多的经验。

最后,记住一点:代码混淆和反混淆就像一场猫捉老鼠的游戏。 只要有混淆技术,就会有反混淆技术。 重要的是,我们要保持学习的热情,不断提升自己的技能,才能在这场游戏中立于不败之地!

谢谢大家! 希望今天的讲座对大家有所帮助。 现在,是时候拿起你的工具,去探索 JavaScript 的迷宫了! 祝你好运,各位代码探险家们!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注