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 等等。每个控制结构都要单独处理。
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 分支是死逻辑。
我建议用数据流分析来辅助检测死逻辑。具体做法是:
- 对每个条件节点,计算其输入变量的可能取值范围
- 判断条件是否永远为真/假
- 标记对应的分支为死逻辑
用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应该是「从入口到出口,路径清晰」的。如果发现某个节点没有任何入边,或者某个节点有入边但永远出不去,那八成有问题。
最后送大家一句话:代码是静态的,但控制流是动态的。用静态的方法分析动态的行为,这就是安全审计的艺术。