静态分析基础: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只是第一步,真正的挑战才刚刚开始。