3、第一个angr脚本:加载二进制文件、获取入口点、打印基本块信息

好,咱们正式开始写第一个angr脚本。

说实话,很多初学者一上来就想着搞复杂的符号执行、路径探索,结果被各种API搞懵了。我个人习惯是,先让脚本跑起来,哪怕只是打印个入口点地址,也比对着文档发呆强。

3.1 环境准备:确认angr已安装

在写脚本之前,先确认你的环境没问题。打开终端,输入:

python -c "import angr; print(angr.__version__)"

如果输出版本号,比如9.2.58,那就OK。如果报错,说明没装好。我建议用Python 3.8到3.10之间的版本,太新的Python有时会有兼容性问题。

注意:angr的安装依赖比较多,如果遇到编译错误,可以试试用conda安装:conda install -c conda-forge angr。我在Windows上踩过这个坑,折腾了半小时才搞定。

3.2 加载二进制文件

angr的核心对象叫Project。你可以把它理解成一个“二进制文件的管理器”。它负责解析文件格式、加载指令、管理符号表等等。

写法很简单:

import angr

# 加载一个二进制文件
proj = angr.Project('./test_binary', auto_load_libs=False)

这里有个参数auto_load_libs=False,我强烈建议你加上。为什么呢?因为angr默认会尝试加载所有依赖的动态链接库,比如libc.so、ld-linux.so等等。这一加载就是几百兆,而且很多库我们根本用不上。我在分析一个嵌入式固件时,忘了关这个选项,结果angr加载了整整两分钟,最后还报了一堆符号找不到的错误。关了它,清爽多了。

3.3 获取入口点

入口点(Entry Point)是程序开始执行的第一条指令地址。对于ELF文件,就是_start函数的位置。

获取入口点有两种方式:

# 方式一:通过Project对象
entry = proj.entry
print(f"入口点地址: {hex(entry)}")

# 方式二:通过loader模块
entry_from_loader = proj.loader.main_object.entry
print(f"Loader给出的入口点: {hex(entry_from_loader)}")

这两种方式结果是一样的。我个人习惯用proj.entry,因为更简洁。

小技巧:如果你分析的是固件或者裸机程序,入口点可能不是_start,而是某个特定的复位向量。这时候proj.entry可能不准,需要手动指定基址。这个我们后面章节会讲。

3.4 获取基本块信息

基本块(Basic Block)是程序控制流的最小单位。它是一段顺序执行的指令序列,只有一个入口和一个出口。

angr提供了CFG(控制流图)来获取基本块信息。但生成CFG比较耗时,我们先用一个轻量级的方法:

# 获取入口点处的基本块
block = proj.factory.block(proj.entry)
print(f"基本块地址: {hex(block.addr)}")
print(f"基本块大小: {block.size} 字节")
print(f"包含指令数: {block.instructions}")

# 打印指令列表
for insn in block.capstone.insns:
    print(f"  0x{insn.address:x}: {insn.mnemonic} {insn.op_str}")

这里用到了capstone反汇编引擎。angr内部集成了capstone,所以我们可以直接调用block.capstone.insns来获取反汇编结果。

3.5 完整脚本示例

把上面的代码拼起来,就是一个完整的angr脚本:

import angr

def analyze_binary(file_path):
    # 加载二进制文件
    print(f"[*] 正在加载: {file_path}")
    proj = angr.Project(file_path, auto_load_libs=False)
    
    # 获取入口点
    entry = proj.entry
    print(f"[*] 入口点: {hex(entry)}")
    
    # 获取入口点基本块
    block = proj.factory.block(entry)
    print(f"[*] 基本块信息:")
    print(f"    地址: {hex(block.addr)}")
    print(f"    大小: {block.size} 字节")
    print(f"    指令数: {block.instructions}")
    
    # 打印反汇编
    print(f"\n[*] 反汇编代码:")
    for insn in block.capstone.insns:
        print(f"    0x{insn.address:x}: {insn.mnemonic} {insn.op_str}")
    
    return proj

if __name__ == "__main__":
    proj = analyze_binary("./test_binary")

3.6 运行结果解读

假设我们分析的是一个简单的C程序,输出可能长这样:

[*] 正在加载: ./test_binary
[*] 入口点: 0x400520
[*] 基本块信息:
    地址: 0x400520
    大小: 16 字节
    指令数: 4

[*] 反汇编代码:
    0x400520: xor ebp, ebp
    0x400522: mov r9, rdx
    0x400525: pop rsi
    0x400526: mov rdx, rsp

看到没?入口点0x400520处的代码是典型的_start函数开头。它先清零ebp,然后保存参数。这就是程序启动时的标准流程。

3.7 核心知识体系

为了让你更直观地理解本章的知识结构,我画了一张图:

第一个angr脚本核心流程 1. 加载二进制 angr.Project() 2. 获取入口点 proj.entry 3. 获取基本块 proj.factory.block() 基本块属性 • block.addr → 基本块地址 • block.size → 基本块大小 • block.instructions → 指令数 • block.capstone.insns → 反汇编

这张图把整个流程串起来了:加载 → 获取入口点 → 获取基本块 → 读取属性。每一步都有对应的API,你照着写就行。

3.8 常见问题与避坑

  • 加载时间过长:检查是否忘了加auto_load_libs=False。我曾经分析一个Go语言编译的二进制,没关这个选项,等了5分钟还没加载完。
  • 入口点地址异常:比如打印出来是0x0或者一个很大的地址。这通常是因为文件格式不标准,或者基址没设置对。可以试试proj.loader.main_object.min_addr看看加载基址。
  • 基本块为空:如果block.instructions为0,说明angr没有正确反汇编。可能是架构识别错了,检查一下proj.arch属性。
核心要点:
  • angr的Project是入口,所有操作都基于它
  • proj.entry获取入口点,proj.factory.block()获取基本块
  • 基本块对象包含地址、大小、指令数、反汇编等属性
  • 记得关闭auto_load_libs,否则加载会非常慢

好了,第一个angr脚本就写完了。别看它简单,这是你进入angr世界的第一步。后面我们会在这个基础上,逐步加入符号执行、路径探索、约束求解等高级功能。嗯,慢慢来,不着急。

专注资料整理