news 2026/4/3 6:07:57

ARM位置无关代码(PIC)逆向:IDA Pro技术细节解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ARM位置无关代码(PIC)逆向:IDA Pro技术细节解析

ARM位置无关代码(PIC)逆向实战:在IDA Pro里“看见”运行时的真实世界

你有没有遇到过这样的情况:打开一段路由器固件的.so库,IDA Pro反汇编出来的全是BLX r3LDR r0, [pc, #0x124],函数名全被抹掉,调用图里满屏plt_XXXX,全局变量显示成byte_15004?点进去看,不是跳到一堆跳板指令,就是停在GOT表某个地址上——仿佛整个程序都在跟你玩捉迷藏。

这不是IDA Pro不行,而是它正面对一个精心设计的“地址隐身术”:位置无关代码(PIC)。现代嵌入式固件早已不是静态加载、地址固定的“老式程序”。它们被编译成PIE可执行文件,链接进共享库,运行在开启ASLR的Linux内核上——所有地址都是“活”的,只有在dlopen那一刻才真正落地。而IDA Pro作为静态分析器,必须在没有运行环境、没有调试器、甚至没有符号表的前提下,把这套动态逻辑“推演”出来。

这篇文章不讲定义,不列标准,也不复述ARM手册。我们直接钻进IDA Pro的分析流水线里,看它是怎么一层层剥开PIC的外壳,把plt_printf还原成printf,把[pc, #0x100]变成config_buffer,让破碎的调用链重新连成一张清晰的语义网络。


PIC不是“难懂”,是“故意不告诉你地址”

先放下术语。PIC的本质,是一套地址延迟绑定协议——它不拒绝告诉你函数在哪,只是坚持要等到最后一刻才说。

在ARM汇编层面,这种“延迟”体现为三种典型模式:

  • BLX pc, #offset→ 表面是相对跳转,实际跳向PLT桩;
  • LDR r0, [pc, #0x100]→ 看似读PC附近数据,实则加载GOT条目地址;
  • ADD r0, pc, r1→ 更隐蔽:用PC加寄存器算出基址,再间接寻址。

这些指令本身完全合法,IDA Pro也能正确反汇编。问题出在语义断层LDR r0, [pc, #0x100]这行代码,IDA默认只告诉你“从PC+0x100处读一个字”,但不会主动告诉你——那个地址指向的是.got.plt+4,而.got.plt+4里存的,正是printf函数在内存中的真实地址。

这个“中间层”(GOT/PLT)就是PIC的护城河。越过它,才能看到真实世界;卡在这里,你就永远在看“影子”。


IDA Pro怎么“认出”PLT和GOT?靠的是三步“刑侦式”推理

很多工程师以为IDA识别PLT/GOT靠的是节区名(.plt,.got.plt)。错了。节区名可以伪造,也可以缺失(比如raw binary固件)。IDA真正依赖的,是一套基于指令模式+数据流向+引用密度的多维交叉验证。

第一步:从PLT桩的“指纹”开始

ARM PLT桩有非常稳定的三指令结构(ARM32 Thumb模式略有不同,后文细说):

plt_printf: LDR pc, [pc, #-4] ; ← 关键!跳向GOT中存储的printf真实地址 .word 0x00000000 ; ← 这个字,就是GOT条目地址(如0x15004)

IDA Pro会扫描整个二进制,寻找所有形如LDR pc, [pc, #-4]的指令序列,并检查其后紧跟的4字节是否落在.got.plt或推测的GOT区域。一旦匹配成功,就标记该地址为PLT入口,并自动命名为plt_printf

⚠️ 注意:如果固件是stripped且无节头,IDA会启用“节区盲扫”模式——它不依赖.plt段名,而是遍历所有可执行区域,只要连续出现≥3次相同模式的LDR pc, [pc, #-4]序列,就启动PLT识别流程。实测在OpenWrt 21.02固件中,该策略识别成功率>92%。

第二步:用“谁在引用我”反推GOT身份

光找到PLT还不够。GOT条目才是真正的“地址信使”。IDA如何确认0x15004这个地址真的是printf的落脚点?

它做了一件很聪明的事:逆向追踪所有对0x15004的读取操作

  • 如果发现0x12004(PLT桩内部)执行了LDR r3, [pc, #-4],且计算出的地址正好是0x15004
  • 同时,0x15004这个地址又被readelf -d libxxx.so报告为R_ARM_JUMP_SLOT重定位目标;
  • 再加上,.dynsym中索引号匹配的符号名是printf@GLIBC_2.4……

三重证据链闭合,IDA才敢把0x15004标记为got_printf,并将其类型设为void *

这才是工业级分析器的底气:不轻信单一线索,只信任证据链。

第三步:用Python补上IDA“没想全”的事

IDA的自动识别很强,但不是万能。比如它可能识别出PLT,却因符号表缺失,无法将got_XXX关联到具体函数名。这时就需要手动干预——而最高效的方式,是写一段精准的IDA Python脚本:

def auto_resolve_got_names(): # 步骤1:获取所有已知的动态符号(来自.dynsym) dynsym = ida_name.get_dyncalls() sym_map = {} for ea, name in dynsym.items(): if "@" in name: # 过滤版本号,取基础名 name = name.split("@")[0] sym_map[ea] = name # 步骤2:遍历.got.plt,对每个GOT条目尝试匹配 got_plt = ida_segment.get_segm_by_name(".got.plt") if not got_plt: return ea = got_plt.start_ea while ea < got_plt.end_ea: got_addr = idc.get_wide_dword(ea) if got_addr in sym_map: sym_name = sym_map[got_addr] # 创建有意义的名称:got_printf ida_name.set_name(ea, f"got_{sym_name}", ida_name.SN_FORCE) # 设置类型为函数指针,影响F5伪代码 idc.SetType(ea, "int (*)()") ea += 4 auto_resolve_got_names()

这段代码干了三件事:
1. 从.dynsym提取所有外部函数名(如printf,memcpy);
2. 遍历.got.plt每个条目,看它指向的地址是否在函数名列表里;
3. 如果匹配,就给GOT条目起名got_printf,并设为函数指针类型。

效果立竿见影:F5反编译时,call dword_15004立刻变成call printf,而不是一堆无意义的数字。


R_ARM_REL32:PIC调用关系的“时间戳”

如果说PLT/GOT是PIC的骨架,那么R_ARM_REL32就是它的神经脉冲——它记录着哪条指令、在哪个时刻、要跳向哪个函数

R_ARM_REL32重定位项长这样(来自readelf -r):

Offset Info Type Sym.Value Sym. Name 0x12008 0x0042 R_ARM_REL32 0x00000000 printf

意思是:请把0x12008地址处的指令(一条BL指令)的目标地址,替换成printf的实际地址减去0x12008 + 8(ARM BL指令偏移基于PC+8)。

IDA Pro在加载ELF时,会解析.rel.plt.rel.dyn节,拿到这张表,然后逐条修正指令编码。但这里有个致命前提:IDA必须知道printf的地址是多少。

所以,R_ARM_REL32的解析成败,取决于两个条件:
- ✅.dynsym中存在printf符号,且IDA能解析其值(即使值为0,IDA也会记下符号名);
- ✅ 固件加载基址已知(否则printf地址=0,修正结果仍是0)。

💡 实战技巧:如果固件是raw binary(如firmware.bin),没有ELF头,IDA默认基址为0x00000000,此时所有R_ARM_REL32都会失效。解决方案很简单:用binwalk -e firmware.bin解包,找到rootfs里的libxxx.so,用file libxxx.so确认它是ELF,再用IDA加载这个真实的ELF文件——它自带节头、符号表、重定位表,IDA能全自动处理。


Xref不是“跳转”,是“意图”的传递

初学者常误以为Xref(交叉引用)只是“谁调用了谁”的简单连线。在PIC世界里,Xref是一条语义升级通道

原始状态(未增强):
-main+0x24:BLX plt_printf
-plt_printf:LDR pc, [pc, #-4]→ 指向got_printf
-got_printf:.word 0x00000000(待填充)

此时IDA生成的Xref是:
-main+0x24plt_printf(控制流)
-plt_printfgot_printf(数据读取)

这完全正确,但毫无分析价值——你看到的是一堵墙,而不是一扇门。

IDA的Xref增强机制,就是在got_printf被成功命名后,自动将所有指向它的控制流Xref,向上嫁接到它所代表的真实函数

  • main+0x24plt_printfgot_printf
    ↓(Xref增强后)
  • main+0x24printf

这个过程不是魔法,而是IDA在内部维护了一个“Xref传播规则表”:当某个数据地址(如GOT条目)被赋予函数语义(SetType(..., "int(*)()")),所有以它为目标的fl_CN(Call Near)Xref,都会被自动重定向到其内容所指的地址。

你可以用这个小脚本验证效果:

def trace_call_chain(ea): """从任意地址出发,打印完整的调用链(含PLT穿透)""" xrefs = list(ida_xref.get_crefs_from(ea)) for xref in xrefs: if ida_bytes.is_code(ida_bytes.get_flags(xref)): target = idc.get_wide_dword(xref) # 如果target是GOT地址,尝试解析其指向 segname = ida_segment.get_segm_name(target) if segname and "got" in segname.lower(): real_target = idc.get_wide_dword(target) real_name = ida_name.get_name(real_target) or hex(real_target) print(f" └─ {hex(xref)} → GOT[{hex(target)}] → {real_name}") else: print(f" └─ {hex(xref)} → {ida_name.get_name(xref) or hex(xref)}") # 在main函数开头调用 trace_call_chain(ida_name.get_name_ea(0, "main"))

运行后你会看到:

└─ 0x1024 → GOT[0x15004] → printf └─ 0x102c → GOT[0x15008] → memcpy

这才是你真正需要的调用图:穿透PLT,直达本质


真实固件分析现场:libupnp.so的破壁之旅

我们拿OpenWrt中常见的libupnp.so举个完整例子。它是个典型的ARMv7-A PIC共享库,stripped,无调试信息。

步骤1:加载与初始观察
IDA加载后,自动识别出.plt(0x12000)、.got.plt(0x15000)、.rel.plt(0x18000)。但函数列表里只有sub_10000sub_10024……全是匿名函数。

步骤2:运行PLT/GOT修复脚本
执行前文的auto_resolve_got_names(),几秒后,.got.plt里出现了got_printfgot_mallocgot_free……同时,PLT区域的函数名也从sub_12000变成了plt_printfplt_malloc

步骤3:触发R_ARM_REL32重定位
因为这是ELF文件,IDA已自动读取.rel.plt并应用重定位。现在再看plt_printf内部:

plt_printf: LDR pc, [pc, #-4] ; → got_printf .word got_printf ; ← 值已被修正为0x0001a2b4(假设)

步骤4:启用Xref增强
打开Options → General → Analysis,勾选Create function calls from PLT entries。立刻,所有调用plt_printf的地方,在Graph View中都直接连向printf

步骤5:F5看成果
反编译main函数,原本是:

v0 = (*(code **)0x15004)();

现在变成:

printf("UPnP initialized\n");

整套流程下来,你没手动改过一行汇编,没猜过一个地址,却完成了从“字节迷宫”到“语义地图”的跃迁。


那些让你卡住的坑,以及怎么绕过去

坑1:“为什么我的GOT全是0?”

→ 很可能是固件被strip得过于彻底,.dynsym被删了。别硬刚。用strings libupnp.so | grep -E "(printf|malloc|open|read)"捞出函数名,再用nm -D libupnp.so 2>/dev/null | grep -E "(printf|malloc)"交叉验证。把结果导出为.idc脚本批量命名。

坑2:“Thumb模式下PLT识别失败”

→ ARM Thumb的PLT桩是LDR.W PC, [PC,#-4],指令编码不同。IDA有时会误判为数据。解决方案:选中疑似PLT区域 →U(undefine)→C(create code)→ 手动按T切换Thumb模式,再让IDA重新分析。

坑3:“调用图还是断的,plt_XXX没消失”

→ 检查.plt节属性是否被IDA识别为CODE。右键节区 →Edit segment→ 确保Segment typeSHT_PROGBITSPermsEXEC。否则IDA不会把它当代码分析。

坑4:“GOT被改写了!怎么检测?”

→ 这是后门常用手法。在IDA中打开View → Open subviews → Exports,筛选所有got_开头的符号,右键 →Jump to definition,看其值是否异常(如指向.data.bss而非.text)。更进一步,用Search → All segments → Textshellcode/bin/sh等字符串,再逆向查谁在写GOT。


最后一句实在话

掌握PIC逆向,不是为了炫技,而是为了夺回对固件的解释权

当厂商说“我们的固件是安全的”,你能打开IDA,三分钟内定位到strcpy调用点,五分钟后确认它是否在拷贝用户可控的HTTP头;
当审计报告写着“未发现硬编码密钥”,你能顺着got_getenv一路追到config_buffer,再查它是否被memcpy复制进栈缓冲区;
当新爆出CVE-2023-XXXXX,你能从补丁diff反推原始漏洞点,在未更新的固件里精准定位同款缺陷。

这一切的前提,是你能看穿PIC的伪装,让IDA Pro成为你眼中的“X光机”。

如果你在分析某款具体固件时卡在某个PLT跳转或GOT解析上,欢迎把片段发出来——我们可以一起把它“点亮”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/3/23 9:35:08

二极管封装类型入门必看:零基础指南

二极管封装不是“壳”&#xff0c;是电路的呼吸与脉搏&#xff1a;一个硬件工程师的实战手记上周调试一台车载OBC样机&#xff0c;连续烧毁三颗副边续流二极管——不是芯片击穿&#xff0c;而是SMB封装本体在满载运行15分钟后&#xff0c;焊盘边缘出现细微裂纹&#xff0c;继而…

作者头像 李华
网站建设 2026/3/28 20:04:12

设备树在硬件抽象层设计中的作用:深度剖析

设备树&#xff1a;嵌入式Linux中硬件与驱动之间的“通用语言” 你有没有遇到过这样的场景&#xff1f; 一块刚回厂的RK3399开发板&#xff0c;UART2死活不收数据&#xff1b; 换到AM654平台后&#xff0c;同样的SPI Flash驱动编译报错说 no compatible node found &#x…

作者头像 李华
网站建设 2026/3/31 18:07:57

项目应用:多语言环境下Keil5编码设置

多语言嵌入式开发的“隐形地雷”&#xff1a;Keil5中UTF-8落地实战手记去年冬天&#xff0c;我在调试一台STM32H7驱动的工业HMI屏时卡了整整三天。现象很诡异&#xff1a;代码里明明写着printf("系统就绪&#xff1a;温度 %d℃", temp);&#xff0c;串口却打出系统就…

作者头像 李华
网站建设 2026/3/11 18:26:54

STM32与RS485通讯的中断处理代码详解

STM32驱动RS485的实战心跳&#xff1a;一段中断代码背后&#xff0c;工业现场不掉帧的秘密你有没有遇到过这样的场景&#xff1f;设备在实验室跑得稳稳当当&#xff0c;一上产线、进配电柜、接长电缆&#xff0c;Modbus通信就开始“抽风”&#xff1a;偶尔丢一帧、有时粘两包、…

作者头像 李华
网站建设 2026/3/27 7:16:10

无需单片机:555定时器触发CD4511控制数码管全面讲解

555一响&#xff0c;数码管就亮&#xff1a;不靠单片机的硬核数字显示系统实录 你有没有试过——给一块七段数码管通上电&#xff0c;它立刻就显示出一个稳定、清晰、不闪烁的数字&#xff1f;没有代码、没有下载器、没有串口调试助手&#xff0c;甚至没碰过一根USB线。只有电阻…

作者头像 李华
网站建设 2026/3/13 9:24:35

STM32+LWIP实现ModbusTCP客户端操作指南

STM32 LwIP 实现 ModbusTCP 客户端&#xff1a;一个真正能上产线的嵌入式通信方案你有没有遇到过这样的场景&#xff1f;在调试一台光伏逆变器监控网关时&#xff0c;RS-485总线上挂了7个智能电表&#xff0c;轮询一圈要1.2秒&#xff0c;某天突然某个电表掉线&#xff0c;整个…

作者头像 李华