应用安全 --- 逆向工程 之 .eh_frame_hdr 和 .eh_frame
.eh_frame_hdr 和 .eh_frame 都是帮助程序“沿着函数调用关系往回找”的数据,但分工不同:
| 节 | 核心作用 | 通俗比喻 |
|---|---|---|
.eh_frame_hdr |
根据当前指令地址,快速找到对应的FDE | 书的目录 |
.eh_frame |
记录怎样恢复调用者的栈、寄存器和返回地址 | 书的正文/操作说明 |
它们主要用于:
-
C++异常处理,例如
throw后寻找上层的catch -
生成崩溃调用栈
-
调试器回溯函数调用关系
-
backtrace()获取调用栈 -
分析程序崩溃发生在哪一层函数
一、用一个最小函数理解
假设程序中有:
int add(int a, int b)
{
return a + b;
}
int main()
{
int result = add(10, 20);
}
执行顺序是:
main
└──调用 add
在汇编中,可能类似:
main:
call add
; call的下一条指令地址,就是add执行完后的返回地址
add:
push rbp
mov rbp, rsp
sub rsp, 0x20
; 函数主体
leave
ret
假设程序正在 add 中间执行时发生异常。
系统现在想知道:
是谁调用了add?
main的返回地址在哪里?
main原来的RSP是多少?
main原来的RBP是多少?
这时就会使用 .eh_frame_hdr 和 .eh_frame。
二、.eh_frame_hdr 的作用
.eh_frame_hdr 是一个快速查询表。
它大致记录:
函数起始地址 对应的FDE地址
--------------------------------
0x401000 0x402260
0x401100 0x402280
0x401243 0x402318
0x4013D3 0x402338
假设当前程序的指令地址是:
RIP = 0x401300
系统在目录中查找后发现:
[
0x401243\le 0x401300<0x4013D3
]
所以当前正在执行:
sub_401243
目录同时告诉系统:
sub_401243的FDE位于0x402318
因此:
当前RIP = 0x401300
↓
查找.eh_frame_hdr
↓
找到函数入口0x401243
↓
找到对应FDE:0x402318
.eh_frame_hdr 本身通常不记录怎样恢复栈,它只负责快速找到说明书。
三、.eh_frame 的作用
.eh_frame 保存真正的栈恢复规则,主要由两类记录组成:
| 记录 | 全称 | 作用 |
|---|---|---|
| CIE | Common Information Entry | 多个函数共同使用的基础规则 |
| FDE | Frame Description Entry | 某一个函数自己的栈变化规则 |
可以理解为:
.eh_frame
├── CIE:大家共同遵守的规则
├── FDE:函数A的规则
├── FDE:函数B的规则
├── FDE:sub_401243的规则
└── FDE:main的规则
四、CIE负责什么
CIE记录通用规则,例如:
栈每次移动的基本单位:8字节
返回地址对应的寄存器:RIP
函数刚进入时:CFA = RSP + 8
返回地址位置:[CFA - 8]
其中CFA可以暂时理解为:
调用当前函数之前,调用者原来的栈顶。
刚进入函数时,call 会把返回地址压入栈中。
假设调用前:
RSP = 0x1000
执行 call 后,压入8字节返回地址:
RSP = 0x0FF8
此时:
[
CFA=RSP+8
]
代入:
[
CFA=0x0FF8+8=0x1000
]
所以CFA正好是调用者原来的栈顶。
返回地址位于:
[
[CFA-8]=[0x0FF8]
]
五、FDE负责什么
每个函数修改栈的方式可能不同,所以每个函数有自己的FDE。
例如你的 sub_401243:
0x401243 endbr64
0x401247 push rbp
0x401248 mov rbp,rsp
0x40124B sub rsp,20h
; 函数主体
0x4013D1 leave
0x4013D2 ret
它对应的FDE位于:
0x402318
FDE描述的核心规则是:
刚进入函数:
CFA = RSP + 8
执行push rbp后:
CFA = RSP + 16
旧RBP位于[CFA - 16]
执行mov rbp,rsp后:
CFA = RBP + 16
执行leave后:
CFA = RSP + 8
因此FDE相当于在说:
如果程序停在
sub_401243的不同位置,应该使用不同的公式寻找上一层函数。
六、具体恢复一次调用者
假设 main 中有:
0x401457 call sub_401243
0x40145C mov [rbp-4],eax
因为 call 的下一条指令是 0x40145C,所以:
sub_401243执行完后,应返回0x40145C
假设调用前:
main的RSP = 0x1000
main的RBP = 0x1100
1. 执行call
call 压入返回地址:
RSP = 0x0FF8
[0x0FF8] = 0x40145C
栈中变成:
0x1000 main原来的栈顶
0x0FF8 返回地址0x40145C
2. 执行push rbp
push rbp
得到:
RSP = 0x0FF0
[0x0FF0] = 0x1100
现在的栈:
高地址
0x1000 调用者原来的栈顶,也就是CFA
0x0FF8 返回地址0x40145C
0x0FF0 main原来的RBP:0x1100
低地址
3. 执行mov rbp,rsp
mov rbp,rsp
得到:
RBP = 0x0FF0
随后即使执行:
sub rsp,0x20
导致 rsp 继续下降,rbp 仍然保持 0x0FF0。
七、如果此时程序崩溃
假设程序在:
RIP = 0x401300
发生崩溃。
首先查询 .eh_frame_hdr:
0x401300属于sub_401243
sub_401243的FDE位于0x402318
然后读取 .eh_frame 中的FDE,得到:
[
CFA=RBP+16
]
代入:
[
CFA=0x0FF0+0x10=0x1000
]
恢复调用者的RBP:
[
RBP_{\text{调用者}}=[CFA-16]
]
[
RBP_{\text{调用者}}=[0x0FF0]=0x1100
]
恢复调用者的RIP:
[
RIP_{\text{调用者}}=[CFA-8]
]
[
RIP_{\text{调用者}}=[0x0FF8]=0x40145C
]
恢复调用者的RSP:
[
RSP_{\text{调用者}}=CFA=0x1000
]
最终恢复出:
main的RIP = 0x40145C
main的RSP = 0x1000
main的RBP = 0x1100
于是系统知道:
程序当前在sub_401243
sub_401243是由main调用的
返回main后应该继续执行0x40145C
八、套到你的实际文件
你的两个节范围是:
.eh_frame_hdr
0x4021DC ~ 0x402240
大小:0x64字节
.eh_frame
0x402240 ~ 0x4023B8
大小:0x178字节
对于 sub_401243:
当前函数范围:
0x401243 ~ 0x4013D3
.eh_frame_hdr中的目录条目:
函数地址偏移 → 0x401243
FDE地址偏移 → 0x402318
.eh_frame中的FDE:
0x402318
FDE引用的公共CIE:
0x402240
完整关系是:
flowchart TD
A["当前 RIP = 0x401300"] --> B["查询 .eh_frame_hdr"]
B --> C["确定属于 sub_401243"]
C --> D["找到 FDE:0x402318"]
D --> E["FDE 引用 CIE:0x402240"]
E --> F["得到 CFA 和寄存器恢复公式"]
F --> G["恢复调用者的 RIP、RSP、RBP"]
九、两者最本质的区别
.eh_frame_hdr 回答的是:
当前地址属于哪个函数?这个函数的FDE在哪里?
.eh_frame 回答的是:
找到FDE以后,具体怎样找回上一层函数的返回地址、栈顶和寄存器?
如果没有 .eh_frame_hdr,理论上仍然可以逐个扫描 .eh_frame 查找FDE,只是速度更慢。
如果没有 .eh_frame,就失去了真正的栈恢复规则,异常处理和调用栈回溯可能无法正常进行。
一句话总结:
.eh_frame_hdr是快速找说明书的目录,.eh_frame是教系统怎样恢复上一层函数现场的说明书。
因为CFA不是用来代替“调用者的RBP”,而是先建立一个统一、稳定的坐标原点,然后再从这个原点计算调用者的 RBP、RIP、RSP。
一句话理解:
RBP只是一个寄存器;CFA代表“调用当前函数之前,调用者的栈顶位置”。
一、在你的函数中确实可以直接用RBP算
sub_401243 建立栈帧后:
push rbp
mov rbp,rsp
sub rsp,20h
栈结构是:
高地址
RBP + 16 调用者原来的RSP,也就是CFA
RBP + 8 返回地址
RBP 调用者原来的RBP
RBP - 0x20 当前函数局部变量
低地址
这时完全可以直接写:
[
RBP_{\text{调用者}}=[RBP]
]
[
RIP_{\text{调用者}}=[RBP+8]
]
[
RSP_{\text{调用者}}=RBP+16
]
而 .eh_frame 写成:
[
CFA=RBP+16
]
[
RBP_{\text{调用者}}=[CFA-16]
]
[
RIP_{\text{调用者}}=[CFA-8]
]
代入以后其实完全一样:
[
[CFA-16]=[(RBP+16)-16]=[RBP]
]
所以不是不能直接计算,而是DWARF规范选择先通过CFA建立统一坐标。
二、为什么需要这个统一坐标
原因1:RBP不一定是栈帧基址
现代编译器经常省略帧指针:
-fomit-frame-pointer
编译出的函数可能是:
sub rsp,20h
mov [rsp+8],edi
call other
add rsp,20h
ret
这里根本没有:
push rbp
mov rbp,rsp
rbp 可能被当成普通寄存器使用。
如果规则强制依赖RBP:
调用者RBP = [RBP]
返回地址 = [RBP+8]
就完全算错了。
但CFA仍然可以根据RSP定义,例如:
[
CFA=RSP+0x28
]
于是:
[
RIP_{\text{调用者}}=[CFA-8]
]
仍然能够找到返回地址。
原因2:同一个函数不同位置,RBP含义不同
你的函数开始部分是:
0x401243 endbr64
0x401247 push rbp
0x401248 mov rbp,rsp
0x40124B sub rsp,20h
如果程序分别崩溃在不同位置,情况完全不同。
情况A:push rbp还没执行
此时:
RBP还是调用者自己的RBP
栈顶只有返回地址
规则是:
[
CFA=RSP+8
]
这时候调用者的RBP就是当前RBP,不应该读取 [RBP]。
情况B:刚执行完push rbp
此时:
RSP → 保存的调用者RBP
RSP+8 → 返回地址
规则是:
[
CFA=RSP+16
]
[
RBP_{\text{调用者}}=[CFA-16]
]
此时还不能使用:
[
CFA=RBP+16
]
因为 mov rbp,rsp 还没执行,当前RBP仍然是调用者的RBP。
情况C:执行完mov rbp,rsp
此时才可以使用:
[
CFA=RBP+16
]
然后:
[
RBP_{\text{调用者}}=[CFA-16]
]
[
RIP_{\text{调用者}}=[CFA-8]
]
情况D:执行完leave
leave 等价于:
mov rsp,rbp
pop rbp
这时当前RBP已经恢复成调用者的RBP,不能继续使用:
[
CFA=RBP+16
]
于是规则重新变成:
[
CFA=RSP+8
]
所以同一个函数里,CFA可以根据当前指令位置切换计算方法。
| 当前指令位置 | CFA规则 |
|---|---|
| 函数刚进入 | (CFA=RSP+8) |
执行完push rbp |
(CFA=RSP+16) |
执行完mov rbp,rsp |
(CFA=RBP+16) |
执行完leave |
(CFA=RSP+8) |
三、CFA不只是用来恢复RBP
发生崩溃或异常时,系统不只是需要恢复调用者的RBP,还需要恢复:
调用者的RIP:返回到哪里
调用者的RSP:调用者的栈顶
调用者的RBP:调用者的栈帧
RBX、R12~R15等被保存的寄存器
例如某个函数保存了多个寄存器:
push rbp
push rbx
push r12
push r13
可以统一描述成:
[
RBP_{\text{调用者}}=[CFA-16]
]
[
RBX_{\text{调用者}}=[CFA-24]
]
[
R12_{\text{调用者}}=[CFA-32]
]
[
R13_{\text{调用者}}=[CFA-40]
]
[
RIP_{\text{调用者}}=[CFA-8]
]
所有寄存器都使用同一个CFA作为参照点,规则非常统一。
如果不用CFA,就要分别描述:
RBP相对于哪个寄存器
RIP相对于哪个寄存器
RBX相对于哪个寄存器
R12相对于哪个寄存器
规则会复杂很多。
四、用具体地址看
假设程序在 sub_401243 中间崩溃:
RBP = 0x0FF0
RSP = 0x0FD0
栈中保存:
[0x0FF0] = 0x1100 调用者RBP
[0x0FF8] = 0x40145C 返回地址
FDE首先计算:
[
CFA=RBP+16
]
[
CFA=0x0FF0+0x10=0x1000
]
然后统一恢复:
[
RBP_{\text{调用者}}=[CFA-16]=[0x0FF0]=0x1100
]
[
RIP_{\text{调用者}}=[CFA-8]=[0x0FF8]=0x40145C
]
[
RSP_{\text{调用者}}=CFA=0x1000
]
这里的CFA就是调用者原来的栈顶。
五、最核心的理解
CFA相当于一个“虚拟坐标原点”:
CFA
├── CFA-8 :返回地址
├── CFA-16 :调用者RBP
├── CFA-24 :保存的RBX
├── CFA-32 :保存的R12
└── CFA-40 :保存的R13
CFA本身不是CPU寄存器,也不会真实存放在某个地方。它是异常处理器根据 .eh_frame 临时计算出来的值。
因此不是:
用CFA代替计算调用者RBP
而是:
先计算统一的CFA
↓
再以CFA为基准
↓
恢复调用者的RBP、RIP、RSP和其他寄存器
最简单的总结:
直接用RBP只对“使用RBP作为帧指针,并且已经完成函数序言”的部分有效;CFA可以适应没有RBP栈帧、RSP不断变化以及函数序言/结尾尚未完成的所有情况。
浙公网安备 33010602011771号