静态分析基础:AST抽象语法树原理、Python的ast模块实战
说到静态分析,很多人第一反应是正则表达式满天飞。说实话,我早年也这么干过。直到有一次,我在审计一个大型Web框架时,用正则去匹配函数调用,结果漏掉了嵌套在复杂表达式里的危险调用——那次差点出生产事故。从那以后,我彻底转向了AST这条路。
AST,全称是Abstract Syntax Tree,抽象语法树。说白了,就是把代码变成一棵树。你写的每一行代码,都会被解析成这棵树上的节点。为什么叫「抽象」?因为它去掉了那些不重要的东西,比如空格、分号、注释,只保留代码的「骨架」。
AST长什么样?
我举个例子。你写了一句:
x = 1 + 2 * 3
Python解析器看到这行代码,会把它变成一棵树。根节点是一个赋值语句(Assign),左边是变量名x,右边是一个表达式。这个表达式里,乘法优先级高,所以2 * 3先被计算,然后结果再加1。树的结构大概是:
Assign
├── Name (x)
└── BinOp (Add)
├── Constant (1)
└── BinOp (Mult)
├── Constant (2)
└── Constant (3)
嗯,这里要注意:树的结构完全遵循语言的语法规则。你写代码时觉得理所当然的优先级,在AST里被清晰地表达为树的层级关系。
为什么安全分析离不开AST?
正则表达式只能匹配「字符串长什么样」,但AST能告诉你「代码在做什么」。我举个例子你就明白了。
假设你要检测代码里有没有执行系统命令。用正则匹配os.system(,看起来挺简单对吧?但攻击者可以这样写:
cmd = "ls -la"
os.system(cmd)
正则匹配不到os.system(后面直接跟字符串的情况?其实能匹配到。但更隐蔽的是:
func = getattr(os, 'system')
func("rm -rf /")
正则完全抓瞎。但AST可以。因为AST能告诉你:getattr的第一个参数是os模块,第二个参数是字符串'system',然后这个结果被当作函数调用了。这就是AST的威力——它理解代码的语义,而不只是文本。
核心观点:AST让你从「看字符串」升级到「看语义」。这是静态分析从玩具走向工程的关键一步。
Python的ast模块实战
Python自带的ast模块,就是用来干这个的。我个人习惯用它来写自定义的代码检查工具,比用第三方库更灵活,也更可控。
先看一个最简单的用法:把代码字符串解析成AST。
import ast
code = """
def hello(name):
print(f"Hello, {name}")
"""
tree = ast.parse(code)
print(ast.dump(tree, indent=2))
输出会是一大段嵌套结构。别被吓到,你只需要关注几个关键节点类型:
| 节点类型 | 对应代码 | 常见用途 |
|---|---|---|
| FunctionDef | 函数定义 | 检测函数名、参数、装饰器 |
| Call | 函数调用 | 检测危险函数调用 |
| Name | 变量名 | 追踪变量来源 |
| Constant | 常量(字符串、数字等) | 提取硬编码的敏感信息 |
| Import / ImportFrom | 导入语句 | 检测模块导入 |
写一个简单的安全检查器
我来带你写一个实战例子。假设我们要检测代码里有没有使用eval或exec——这两个函数是安全红线,几乎每个公司的代码规范都禁止使用。
思路很简单:遍历AST,找到所有Call节点,检查函数名是不是eval或exec。
import ast
class DangerousCallChecker(ast.NodeVisitor):
def visit_Call(self, node):
# 检查函数名
if isinstance(node.func, ast.Name):
if node.func.id in ('eval', 'exec'):
print(f"发现危险调用: {node.func.id} 在第 {node.lineno} 行")
# 继续遍历子节点
self.generic_visit(node)
code = """
data = input("输入表达式: ")
result = eval(data)
print(result)
"""
tree = ast.parse(code)
checker = DangerousCallChecker()
checker.visit(tree)
运行一下,输出:发现危险调用: eval 在第 3 行。完美命中。
小技巧:我建议你继承ast.NodeVisitor而不是ast.NodeTransformer。前者只读不写,适合做检查;后者可以修改AST,适合做代码转换。初学者经常搞混,记住这个区分就好。
进阶:追踪变量来源
刚才的例子有个局限:它只检查直接调用的eval。如果攻击者把eval赋值给另一个变量呢?比如:
e = eval
e("__import__('os').system('ls')")
这时候,我们的检查器就漏了。怎么办?
我们需要做变量追踪。说白了,就是记录每个变量名指向什么。这听起来复杂,但我们可以用简单的办法:遍历所有赋值语句,建立一张「变量名 → 实际值」的映射表。
class VariableTracer(ast.NodeVisitor):
def __init__(self):
self.var_map = {}
def visit_Assign(self, node):
# 处理 a = b 这种简单赋值
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
if isinstance(node.value, ast.Name):
self.var_map[var_name] = node.value.id
self.generic_visit(node)
def resolve(self, name):
"""递归解析变量名,直到找到原始值"""
visited = set()
while name in self.var_map and name not in visited:
visited.add(name)
name = self.var_map[name]
return name
然后在检查Call节点时,先用resolve解析一下函数名:
def visit_Call(self, node):
if isinstance(node.func, ast.Name):
func_name = self.resolve(node.func.id)
if func_name in ('eval', 'exec'):
print(f"危险调用: {node.func.id} 在第 {node.lineno} 行")
这样,即使eval被赋值给e,我们也能追踪到。当然,这只是一个简化版。真实场景下还有函数参数传递、属性访问等复杂情况。但思路是一样的——用AST的树结构,一步步追踪数据的流向。
避坑指南:我曾经在写一个内部审计工具时,忽略了ast.literal_eval这个函数。它虽然比eval安全,但也不是绝对安全。Python 3.10之后修复了一些漏洞,但早期版本中,literal_eval在某些嵌套结构下仍然可能触发拒绝服务攻击。所以,不要盲目相信「官方说安全」就万事大吉。
AST分析的局限性
说了这么多AST的好处,我也得泼点冷水。AST不是万能的。
- 动态特性无法处理:比如
getattr(obj, method_name)(),method_name是运行时才确定的字符串,AST根本不知道它是什么。 - 跨文件分析困难:AST一次只能分析一个文件。要分析模块间的调用关系,需要自己维护符号表,复杂度飙升。
- 混淆代码难以应对:如果攻击者用
__import__('builtins').__dict__['e'+'val']这种写法,简单的AST分析就抓瞎了。
但话说回来,AST仍然是静态分析的基石。你想想看,没有AST,你连代码的基本结构都拿不到,更别提做深层分析了。我个人的经验是:AST解决80%的常见问题,剩下20%的疑难杂症,需要结合数据流分析、符号执行等更高级的技术。
下一章,我会带你深入数据流分析,看看怎么追踪变量在函数之间的传递。到时候你会发现,AST只是第一步,真正的挑战才刚刚开始。