3、控制流分析:构建CFG控制流图,识别不可达代码与死逻辑

控制流分析,说白了就是给代码画一张「交通路线图」。我刚开始做安全审计那会儿,总觉得这玩意儿是编译器才该操心的事。直到有一次,我在一个支付系统的回调函数里,发现了一段永远跑不到的日志记录代码——里面居然硬编码了测试环境的数据库密码。

嗯,从那以后,我再也不敢小看控制流图了。

3.1 什么是控制流图(CFG)

控制流图是一个有向图。每个节点代表一个基本块(Basic Block),每条边代表控制流的跳转方向。基本块就是一段顺序执行的代码,没有分支,没有跳转。

举个例子,看下面这段Python代码:

def process_payment(amount, user_id):
    if amount <= 0:          # 分支点
        log_error("金额无效")
        return False
    # 这里开始一个新基本块
    user = get_user(user_id)
    if user is None:          # 又一个分支点
        log_error("用户不存在")
        return False
    # 正常流程
    result = deduct(amount, user)
    return result

这段代码的CFG大概长这样:

  • 基本块1:函数入口,判断 amount <= 0
  • 基本块2:金额无效,返回 False
  • 基本块3:获取用户,判断 user is None
  • 基本块4:用户不存在,返回 False
  • 基本块5:扣款,返回结果

边的关系是:1→2、1→3、3→4、3→5。你看,每个基本块只有一个人口和一个出口,这就是「块」的含义。

3.2 构建CFG的实用方法

我个人习惯用 networkx 这个库来构建CFG。它轻量,画图也方便。不过要注意,Python是动态语言,有些跳转在静态分析时是看不出来的——比如 eval() 或者动态导入。

这里给一个简单的构建思路:

import ast
import networkx as nx

class CFGBuilder(ast.NodeVisitor):
    def __init__(self):
        self.graph = nx.DiGraph()
        self.current_block = 0
        self.block_counter = 1
        
    def visit_If(self, node):
        # 记录当前块
        self.graph.add_node(self.current_block, 
                           type='condition',
                           code=ast.dump(node.test))
        # 创建真分支和假分支
        true_block = self.block_counter
        self.block_counter += 1
        false_block = self.block_counter
        self.block_counter += 1
        
        # 添加边
        self.graph.add_edge(self.current_block, true_block, label='True')
        self.graph.add_edge(self.current_block, false_block, label='False')
        
        # 递归处理子节点
        self.current_block = true_block
        self.visit(node.body)
        self.current_block = false_block
        self.visit(node.orelse)

你想想看,这个构建器其实只处理了 if 语句。真实的代码里还有 for、while、try-except、with 等等。每个控制结构都要单独处理。

我的小技巧: 构建CFG时,先把所有基本块编号,再连边。不要边遍历边连,容易乱。我一般先做一次「块划分」,再做一次「边连接」,分两步走。

3.3 识别不可达代码

不可达代码,就是永远执行不到的代码。为什么会存在?

  • 条件永远为假:比如 if False: 或者 if 1 > 2:
  • 前置返回/异常:比如 return 后面的代码
  • 死循环后的代码:比如 while True: pass 后面的代码

我曾经审计过一个物联网固件,里面有一段这样的代码:

def check_firmware_version(version):
    if version < 1.0:
        return "需要升级"
    elif version >= 1.0 and version < 2.0:
        return "当前版本正常"
    elif version >= 2.0:
        return "最新版本"
    else:
        # 这段代码永远跑不到
        log_error("未知版本")
        send_alert_to_server("版本异常")
        return "错误"

你看,else 分支里的 send_alert_to_server 永远不会执行。但攻击者如果发现这个逻辑漏洞,可能会利用它来绕过版本检查。实际上,我在那个固件里就发现了一个类似的「死逻辑」,攻击者可以通过伪造版本号触发未定义行为。

注意: 不可达代码不一定是无害的。它可能包含敏感信息(如硬编码密钥)、隐藏后门、或者被攻击者利用来绕过安全检查。审计时一定要标记出来。

3.4 识别死逻辑

死逻辑比不可达代码更隐蔽。代码能跑到,但结果永远不变,或者分支永远走同一边。

举个例子:

def validate_token(token):
    if len(token) > 0:
        # 这里永远为真,因为token长度肯定大于0
        if token[0] == 'A':
            return "管理员"
        else:
            return "普通用户"
    else:
        return "无效token"

这段代码能跑,但 len(token) > 0 这个条件永远为真——因为如果token为空,调用方早就报错了。所以 else 分支是死逻辑。

我建议用数据流分析来辅助检测死逻辑。具体做法是:

  1. 对每个条件节点,计算其输入变量的可能取值范围
  2. 判断条件是否永远为真/假
  3. 标记对应的分支为死逻辑

用Python实现的话,可以借助 z3 约束求解器:

from z3 import *

def check_dead_branch(condition, variables):
    solver = Solver()
    # 添加变量约束
    for var in variables:
        solver.add(var >= 0)
        solver.add(var <= 100)
    
    # 检查条件是否永远为真
    solver.add(Not(condition))
    if solver.check() == unsat:
        return "死逻辑:条件永远为真"
    
    # 检查条件是否永远为假
    solver.reset()
    solver.add(condition)
    if solver.check() == unsat:
        return "死逻辑:条件永远为假"
    
    return "正常分支"
核心要点: 死逻辑的检测需要结合「路径敏感分析」。简单说,就是不仅要看代码能不能跑到,还要看跑到的时候变量的值是什么。这比单纯的不可达代码检测难一个数量级。

3.5 实战中的坑与技巧

说几个我踩过的坑:

  • 异常处理路径: try-except 里的异常路径在静态分析中很难模拟。我一般会假设「任何可能抛异常的地方都会抛」,然后构建完整的异常处理CFG。
  • 回调函数: 回调函数的控制流是「倒置」的。你分析主流程时,根本不知道回调什么时候执行。我建议单独分析回调函数,再和主流程的CFG做交叉引用。
  • 多线程: 多线程的CFG是「交织」的。静态分析很难处理,我一般会做「锁分析」,先找出所有同步点,再构建简化的并发CFG。

嗯,这里要注意一点:CFG分析不是万能的。对于动态语言(Python、JavaScript),很多控制流是在运行时才确定的。比如 getattr(obj, method_name)() 这种调用,静态分析根本不知道会调用哪个函数。

我的做法是:先做静态CFG分析,标记出所有「动态调用点」。然后对这些点做运行时监控,或者用模糊测试来触发不同的路径。说白了,就是静态分析打底,动态分析补漏。

3.6 总结

控制流分析是代码审计的基石。没有CFG,你就是在盲人摸象。我每次审计一个新项目,第一件事就是生成整个项目的CFG,然后肉眼扫一遍——看看有没有奇怪的路径、有没有孤立的节点、有没有循环依赖。

你想想看,一个正常的项目,CFG应该是「从入口到出口,路径清晰」的。如果发现某个节点没有任何入边,或者某个节点有入边但永远出不去,那八成有问题。

最后送大家一句话:代码是静态的,但控制流是动态的。用静态的方法分析动态的行为,这就是安全审计的艺术。