GKLBB

当你经历了暴风雨,你也就成为了暴风雨

导航

应用安全 --- 逆向工程 之 .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不断变化以及函数序言/结尾尚未完成的所有情况。

 

posted on 2026-08-02 12:23  GKLBB  阅读(2)  评论(0)    收藏  举报