PWN学习笔记(一)

(1)本文及后续文章均使用IDA Pro 9+进行软件逆向,IDA Pro 9之前版本的静态栈视图可能与文中截图有略微差别,但不影响分析使用。如需安装IDA Pro 9.1,可参考前面的文章

(2)程序中绘制的栈布局图片使用draw.io软件完成。

(3)学习之前需要掌握C语言的基础知识,可参考前面的C语言系列文章

(4)参考学习PWN教程地址1PWN教程地址2

(5)PWN环境的搭建、逆向基础知识等内容本系列文章进行了省略,可自行从网上其他地方学习获得。

(6)本文附件地址(“基础知识”文件夹)。

一、GCC 编译参数

1、查看默认的gcc编译参数:

1
gcc -v

2、编译生成32位的程序

1
gcc -m32 ...

3、有关Canary保护的选项

1
2
3
4
5
-fno-stack-protector  //禁用canary保护
-fstack-protector     //启用canary保护,保护函数中通过alloca()分配的缓存以及存在的大于8字节的Buffer缓存。(为包含本地数组的函数启用栈保护)
-fstack-protector-strong    //启用canary保护,在fstack-protector基础上,增加本地数组、指向本地帧栈地址空间保护。
-fstack-protector-explicit  //启用canary保护,在fstack-protector基础上,增加对有明确声明 stack_protect 属性的函数开启保护。
-fstack-protector-all       //启用canary保护,为所有函数插入保护。

4、有关NX保护(No-eXecute)(DEP)的选项

1
2
-z execstack    //禁用NX保护
-z noexecstack  //开启NX保护

5、有关PIE的选项

1
-no-pie: 关闭加载基址随机化(需配合系统的ASLR使用)

6、有关RELRO(ReLocation Read-Only)的选项

1
2
3
4
//-Wl, 是告诉 GCC 把参数传给链接器 (ld),比如-Wl,-z,relro参数,实际传给 ld 的实际参数是:-z,relro
-Wl,-z,norelro	//关闭RELRO保护
-Wl,-z,relro	  //开启Partial RELRO,只保护.got,不保护.got.plt	 默认开启(多数现代系统)
-Wl,-z,relro -Wl,-z,now	 //开启 Full RELRO,禁止延迟绑定,.got.plt也设为只读	 推荐组合,彻底防止 GOT 劫持

二、x86 程序栈布局

如果看下面的内容读完后还是不理解的话,建议学习“轩辕的编程宇宙”公众号的课程《从零开始学逆向》第7课:函数调用过程汇编分析。

其他的一些前置知识学习,可以参考本博客“About”中介绍的学习路径填补基础知识。

1、C 语言函数调用栈

函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧是堆栈的逻辑片段,当调用函数时逻辑栈帧被压入堆栈, 当函数返回时逻辑栈帧被从堆栈中弹出。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。

编译器利用栈帧,使得函数参数和函数中局部变量的分配与释放对程序员透明。编译器将控制权移交函数本身之前,插入特定代码将函数参数压入栈帧中,并分配足够的内存空间用于存放函数中的局部变量。使用栈帧的一个好处是使得递归变为可能,因为对函数的每次递归调用,都会分配给该函数一个新的栈帧,这样就巧妙地隔离当前调用与上次调用。

栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。

更具描述性,以下称EBP为帧基指针, ESP为栈顶指针,并在引用汇编代码时分别记为%ebp和%esp。

函数调用栈的典型内存布局如下:

img

https://www.cnblogs.com/clover-toeic/p/3755401.html

图中给出主调函数(Caller)(紫色部分)和被调函数(Callee)(蓝色部分)的栈帧布局。函数可以没有参数和局部变量,故图中”Argument(参数)“和”Local Variable(局部变量)“不是函数栈帧结构的必需部分。

函数调用时入栈顺序为:

实参N~1 → 主调函数返回地址 → 主调函数帧基指针EBP → 被调函数局部变量1~N

主调函数将函数参数(实参)按照调用约定依次入栈(图中传参调用约定为从右到左);然后将指令指针EIP入栈以保存主调函数的返回地址(下一条待执行指令的地址);进入被调函数时,被调函数将主调函数的帧基指针EBP入栈,并将主调函数的栈顶指针ESP值赋给被调函数的EBP(作为被调函数的栈底),接着改变ESP值来为函数局部变量预留空间。

此时被调函数帧基指针指向被调函数的栈底。以该地址为基准,向上(栈底方向)可获取主调函数的返回地址、参数值,向下(栈顶方向)能获取被调函数的局部变量值,而该地址处又存放着上一层主调函数的帧基指针值。本级调用结束后,将EBP指针值赋给ESP,使ESP再次指向被调函数栈底以释放局部变量;再将已压栈的主调函数帧基指针弹出到EBP,并弹出返回地址到EIP。ESP继续上移越过参数,最终回到函数调用前的状态,即恢复原来主调函数的栈帧。如此递归便形成函数调用栈。

2、main 函数栈布局

一个典型的 x86 程序 main 函数栈的布局如下图所示:

image-20250504163729321

(1)栈增长方向

栈在内存中是“从高地址向低地址增长”的,符合图中自上而下,从高地址→低地址的视图结构。

(2)栈布局说明

  • envp(环境变量指针)

    • 类型:const char **envp

    • 含义:传递给 main 函数的环境变量数组(如 PATH=/usr/bin 等)

  • argv(参数数组)

    • 类型:const char **argv

    • 含义:命令行参数数组,例如程序名和参数(./prog abc def)

  • argc(参数个数)

    • 类型:int argc

    • 含义:命令行参数的数量

  • Return Address(返回地址)

    • 含义:函数返回后跳转到调用者的位置

    • 压栈方式:由 call 指令自动压栈(call main → 返回地址 push 到栈上)

  • Saved Registers(保存的寄存器)

    • 通常为:old EBP,也存在其他寄存器状态需要保存的情况。

    • 含义:保存调用者的栈基址(使得 ebp 能恢复),一般在函数开头:push ebp

  • Local Variable(局部变量区)

    • 向下分配空间:sub esp, xx 为局部变量留空间

    • 变量如:char buf[100], int tmp 等

    • 这是最可能被溢出攻击利用的区域

(3)程序栈示例

我们用到的示例程序(Function)的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
//Function.c
//gcc -m32 -fno-stack-protector -no-pie -z execstack Function.c -o Function
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
  char buffer[12];
  gets(buffer);
  puts(buffer);
  return 0;
}
  • buffer[12] 是一个局部变量,在栈中分配空间;
  • 调用 gets() 时,用户输入的数据被写入栈上的 buffer 区域;
  • puts() 只是把 buffer 打印出来;
  • 该函数在调用时会构造栈帧:包括参数(envpargcargv)、返回地址、旧的 EBP 以及 buffer 所在的局部变量区。

Linux下编译该程序:

1
MRX@DEEPIN:~/Desktop$ gcc -m32 -fno-stack-protector -no-pie -z execstack Function.c -o Function

使用IDA Pro分析该程序。

image-20250504170249357

建议将IDA Pro识别出的传给 gets 函数的缓存区变量s,直接重命名为buffer,这会使代码更容易理解。

image-20250504170415888

双击 buffer 变量的位置,即可跳转到IDA Pro的静态栈视图。

image-20250504170517211

在静态栈视图中,我们可以看到该函数调用时的基本栈布局信息,比如buffersaved registersreturn addressargcargvenvp是怎么布局的。但需要注意的是IDA Pro静态栈视图是从低地址到高地址表示的,习惯上我们一般会按照自上而下,从高地址→低地址的顺序去制图,所以,需要我们去画一下栈布局。

需要注意的是,本程序中保存到栈上的寄存器不仅只有ebp,还有ebx,ecx。我们需要根据实际的栈布局情况去绘图。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//一般我们只需要画出这种类型的栈布局视图即可不必浪费画后面的draw.io示例图熟练了之后也可直接不必画这个图直接根据IDA Pro给出的静态栈视图即可完成分析
//因为我们知道实际的buffer变量占用12个字节所以我们直接在下面体现出来了大部分不知道的情况下我们就与IDA Pro静态栈视图一样使用 padding byte 进行填充即可
//形如[ebp+10h]就对应IDA静态栈视图中的[+0000000000000010]

高地址
--------------------------------------------------
[ebp+10h]    envp         ; 第3个参数环境变量指针
[ebp+0Ch]    argv         ; 第2个参数命令行参数数组
[ebp+08h]    argc         ; 第1个参数命令行参数数量
[ebp+04h]    返回地址     ; return addresscall 指令自动压栈
[ebp+00h]    调用者的 ebp  ; push ebp (saved ebp)
[ebp-04h]    ; 4字节push ebx (saved ebx)
[ebp-08h]    ; 4字节push ecx (saved ecx)
[ebp-09h]    buffer[11]
[ebp-0Ah]    buffer[10]
[ebp-0Bh]    buffer[9]
[ebp-0Ch]    buffer[8]
[ebp-0Dh]    buffer[7]
[ebp-0Eh]    buffer[6]
[ebp-0Fh]    buffer[5]
[ebp-10h]    buffer[4]
[ebp-11h]    buffer[3]
[ebp-12h]    buffer[2]
[ebp-13h]    buffer[1]
[ebp-14h]    buffer[0]; char buffer[12]这里是从 ebp-14h 开始向上分配
--------------------------------------------------
低地址 
项目 大小 位于栈上的偏移范围(相对 EBP)
envp 4 字节 [ebp+10h]
argv 4 字节 [ebp+0Ch]
argc 4 字节 [ebp+08h]
返回地址 4 字节 [ebp+04h]
调用者的 EBP(old) 4 字节 [ebp+00h]
保存的 EBX 4 字节 [ebp-04h]
保存的 ECX 4 字节 [ebp-08h]
char buffer[12] 12 字节 [ebp-14h][ebp-09h](从低地址向上)

image-20250504184012622

在调用 main 函数之前,程序会将函数参数envp、argc、argv依次压栈。

接着,程序调用call指令或其他方式把返回地址压栈。

call指令有两个功能:第一个是执行call指令后,程序会跳转到指定的要调用的函数地址去执行;第二个是会将该函数调用结束后要执行的指令(也就是返回地址)保存到栈上。

最后,程序会在 main 函数中使用push、sub等指令完成对栈的布局。

在 main 函数中,我们重点关注导致函数栈发生变化的几条指令:

.text:08049180 000 push    ebp
.text:08049181 004 mov     ebp, esp
.text:08049183 004 push    ebx
.text:08049184 008 push    ecx
.text:08049185 00C sub     esp, 10h
.text:08049188 01C call    __x86_get_pc_thunk_bx

push ebp 指令

作用:保存旧栈基址,以便后续能恢复;

将调用者的 EBP 值压入栈中(4字节);栈指针 ESP = ESP - 4,IDA 视图中显示为000004

mov ebp, esp 指令

作用:建立当前函数的局部变量/参数访问基准;

用当前 ESP 值作为新栈帧基址;不会影响栈内容,但会让程序以 EBP 为基准来布局栈帧。

push ebx 指令

作用:保存调用者的 EBX 寄存器内容(callee-saved 寄存器);

栈又下移 4 字节;栈指针 ESP = ESP - 4,IDA 视图中显示为004008

push ecx 指令

作用:保存 ECX 的值;

栈再下移 4 字节;栈指针 ESP = ESP - 4,IDA 视图中显示为00800C

sub esp, 10h 指令

作用:为局部变量预留 0x10 字节(16 字节)空间;

栈再下移 16 字节;栈指针 ESP = ESP - 16,IDA 视图中显示为00800C

至此,在x86程序中,一个典型的 main 函数调用过程中的栈布局介绍完成。

三、Linux 程序保护机制

使用命令checksec可以查看程序开启的保护机制:

pip3 install pwntools 后即可使用附加工具 checksec

image-20240703143349540

1、Canary 保护机制

Stack Canary(栈金丝雀)是一种重要的计算机安全机制,旨在防止缓冲区溢出攻击,特别是栈溢出攻击。其名称源于矿工在矿井中携带金丝雀,以便在有毒气体泄漏时,金丝雀比人类更早感知并发出警告。同理,栈金丝雀在程序栈中充当“早期预警系统”,用于检测栈溢出行为。

在启用栈保护的程序中,每当函数被调用时,编译器会在栈帧中插入一个特殊的值(即“金丝雀”)。当函数返回时,程序会检查该值是否被篡改。如果发现金丝雀值被修改,程序会认为发生了栈溢出攻击,立即终止执行,以防止攻击者利用溢出覆盖返回地址等关键数据。

在启用栈保护的情况下,x86 程序的 main 函数栈结构如下图所示:

image-20250504233331292

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
高地址
----------------------------------------------------------
[ebp+10h]    envp           ; 参数3
[ebp+0Ch]    argv           ; 参数2
[ebp+08h]    argc           ; 参数1
[ebp+04h]    返回地址        ; return address
[ebp+00h]    调用者的 EBP     ; saved ebp
[ebp-04h]    __stack_canary  ; 栈保护值 (__security_cookie)
[ebp-08h]    saved registers ;  ebxesiedi 可选
[ebp-09h]    buffer[11]
...
[ebp-13h]    buffer[1]       ; 通常从 ebp-14h 向高地址递增
[ebp-14h]    buffer[0]       ; 局部变量 char buffer[12]
...
----------------------------------------------------------
低地址 

__stack_chk_guard__security_cookie 是由编译器随机生成的一个值。

Canary 值位于返回地址前面,局部变量后面。攻击者若要覆盖返回地址,必须首先覆盖 Canary 值,从而触发栈保护机制。

Canary 保护在Linux下被称为Stack Canaries 保护,在Windows被称为GS 保护

根据生成方式的不同,可以将 Stack Canaries 分为三类:Terminator Canary(终止符金丝雀)Random Canary(随机金丝雀)Random XOR Canary(随机异或金丝雀)

  • Terminator Canary(终止符金丝雀):这种 Canary 是一个固定的值,通常是0或者一些字符串终止符(如0x00, 0xFF, 0x0A, 0xFF)。这种 Canary 的优点是可以防止一些基于字符串操作的缓冲区溢出攻击,因为这些操作会在遇到终止符时停止。但该方式容易被攻击者预测和绕过。

  • Random Canary(随机金丝雀): 为防止 Canary 的值被攻击者猜到,这种方式的 Canary 是一个随机生成的值,会在程序初始化时随机生成,增加了攻击者猜测的难度。通常使用 /dev/urandom 或当前时间的哈希值生成。

  • Random XOR Canary(随机异或金丝雀):在随机生成的 Canary 值的基础上,与栈帧中的控制数据(如返回地址、帧指针等)进行异或操作。这种方式增加了攻击的复杂度,即使攻击者获取了 Canary 值,也难以利用。

一般情况下,Stack Canaries/GS 保护机制会在函数开始执行的时候先往栈底插入一个 cookie 值,这个 cookie 值就被称为 Canary。用于检测缓冲区溢出,以及防止攻击者利用缓冲区溢出攻击,从而执行恶意代码。

(1)Windows GS 保护示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//Windows-GS.c
//Visual Studio,关SDL检查,编译32位release程序

#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
  char buffer[12];
  gets(buffer);
  puts(buffer);
  return 0;
}

如下所示,示例为32位Windows应用程序(Windows-GS.exe)在_main函数中的GS保护。

image-20250505001653872

_main函数中,程序会从__security_cookie全局变量中获取随机数,然后将其赋值给 EAX 寄存器。接着将其与此时 EBP 的值进行异或运算,并将得到的结果存放到 EAX 寄存器中。最后,将 EAX 中的值存放到栈上的 cookie(原var_4) 变量中。

image-20250505002115922

双击变量cookie,就可以跳转到IDA Pro的静态栈视图进行查看(可结合前面提到的Canary保护机制视图对比查看)

image-20250505002259798

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
高地址
--------------------------------------------------
[ebp+10h]    envp          ; 第3个参数环境变量指针
[ebp+0Ch]    argv          ; 第2个参数命令行参数数组
[ebp+08h]    argc          ; 第1个参数参数数量
[ebp+04h]    返回地址       ; return address
[ebp+00h]    调用者的 EBP    ; saved ebp
[ebp-04h]    cookie         ; 栈保护值stack canary
[ebp-05h]    padding byte   ; 以下 padding 用于对齐
[ebp-06h]    padding byte
[ebp-07h]    padding byte
[ebp-08h]    padding byte
[ebp-09h]    padding byte
[ebp-0Ah]    padding byte
[ebp-0Bh]    padding byte
[ebp-0Ch]    padding byte
[ebp-0Dh]    padding byte
[ebp-0Eh]    padding byte
[ebp-0Fh]    padding byte
[ebp-10h]    Buffer         ; char Buffer 起始地址
--------------------------------------------------
低地址 

其中伪C代码中的索引[ebp+cookie]就是cookie变量在栈中[ebp-04h]的位置。

image-20250505002541853

程序会在_mian函数代码执行完返回前,对 cookie 的值进行校验。首先会将当前 cookie 的值从栈上取出并存放到 ECX 寄存器中,然后将 ECX 中的值与此时 EBP 的值(EBP的值在当前_main函数内始终不变)进行异或运算(还原cookie值),得到的结果存放到 ECX 中。接着会调用 __security_check_cookie 函数对 cookie 的值进行校验。

image-20250505003236103

__security_check_cookie函数中,如果此时 ECX 中保存的 Cookie 值和最初的 __security_cookie全局变量中获取的随机数相同,就返回正常;如果不同就会跳转到___report_gsfailure导致程序异常退出。

image-20250505003354829

上面连续出现了5条汇编指令:

mov     ecx, [ebp+cookie]
add     esp, 8
xor     ecx, ebp        ; StackCookie
xor     eax, eax
call    @__security_check_cookie@4 ; __security_check_cookie(x)

其中有关 cookie 校验的关键指令为前面图片中标红的指令:

mov     ecx, [ebp+cookie]
xor     ecx, ebp        ; StackCookie
call    @__security_check_cookie@4 ; __security_check_cookie(x)

针对这5条汇编指令解释:

mov ecx, [ebp+cookie]

作用:从栈中取出原先保存的 canary 值(进入函数时写入的);将其保存到 ecx 中,准备做检查。

add esp, 8

作用:恢复栈空间,因为程序前面通过 call 调用了getsputs函数,调用这两个函数前都push eax(参数)了一次,一次就会占用栈空间 4 字节,所以总共占用栈空间 8 字节;这一步就是清理参数,调整 esp 回到调用getsputs函数前的状态。

xor ecx, ebp

作用:取出栈上的 cookie 后,还原原始的 cookie 值ecx = (cookie_on_stack) ^ ebp。因为函数开始时存的是 cookie ^ ebp,所以现在用 ecx ^ ebp 恢复原始值;同时,为下个函数 __security_check_cookie(ecx) 做准备。

xor eax, eax

作用:将 eax 置 0;有些调用约定要求:eax 为 0 表示正常返回;在这里不关键,但多数是为了约定或语义明确。

call @__security_check_cookie@4

作用:调用实际的栈保护检查函数。cookie值如果一致 → 安全,继续执行;cookie值如果不一致 → 说明栈被篡改,程序终止,通常调用__report_gsfailure或直接崩溃。

(2)Linux Stack Canaries 保护示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//Linux-Canaries.c
//gcc -m32 -fstack-protector -no-pie -z execstack Canaries.c -o Canaries

#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
  char buffer[12];
  gets(buffer);
  puts(buffer);
  return 0;
}

如下所示,示例为32位Linux应用程序(Linux-Canaries)在_main函数中的GS保护。

image-20250505005153870

main函数中,程序首先通过mov eax, large gs:14h指令,从段寄存器GS的偏移0x14处获取全局的stack canary值(这是线程局部存储中的栈保护值),将其存入 EAX 寄存器。随后,将该值保存在当前函数栈帧的局部变量 [ebp+canary](原[ebp+var_C]变量)中,用作进入函数前对 canary 值的保存。

image-20250505010151745

其中,gets函数和puts传递的变量s已被重命名为buffer。

image-20250505011247897

双击变量canary,就可以跳转到IDA Pro的静态栈视图进行查看(可结合前面提到的Canary保护机制视图对比查看)

image-20250505011333284

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
高地址
--------------------------------------------------
[ebp+10h]    envp               ; 第3个参数环境变量指针
[ebp+0Ch]    argv               ; 第2个参数参数数组
[ebp+08h]    argc               ; 第1个参数参数个数
[ebp+04h]    返回地址            ; return address由调用者 call 压栈
[ebp+00h]    调用者的 ebp         ; push ebp
[ebp-04h]    保存的 ebx           ; push ebx被当前函数保存
[ebp-08h]    保存的 ecx           ; push ecx调用前 [esp+4]用于对齐也可能是返回地址副本
[ebp-0Ch]    canary              ; 栈保护 cookie来自 gs:14h
[ebp-0Dh]    buffer[11]          
[ebp-0Eh]    buffer[10]
[ebp-0Fh]    buffer[9]
[ebp-10h]    buffer[8]
[ebp-11h]    buffer[7]
[ebp-12h]    buffer[6]
[ebp-13h]    buffer[5]
[ebp-14h]    buffer[4]
[ebp-15h]    buffer[3]
[ebp-16h]    buffer[2]
[ebp-17h]    buffer[1]
[ebp-18h]    buffer[0]           ; buffer 起始地址
[ebp-1Ch]    var_1C              ; 存储传参 gets 返回值或其他用途
--------------------------------------------------
低地址 

在函数执行完毕,即将返回前。程序会从当前栈的[ebp+canary]处读取最初保存的 canary 值并保存到edx寄存器中,接着从gs:14h处重新取出当前程序 canary 的值,并与edx中获得的栈上的 canary 值相减;若两值一致(sub结果为 0),说明栈未被破坏,继续执行返回流程;若不一致(即跳转未发生),程序会调用__stack_chk_fail_local函数;__stack_chk_fail_local函数会进一步调用___stack_chk_fail,导致程序异常终止。

image-20250505013357297

2、ASLR 保护机制

ASLR(Address Space Layout Randomization)地址空间布局随机化机制,ASLR 是现代操作系统中广泛采用的一种内存安全保护机制,旨在通过随机化程序内存空间的关键区域地址,增加漏洞利用难度,防止攻击者准确定位关键结构(如 shellcode、libc 函数地址、返回地址等)。

ASLR 生效依赖两个条件:

  • 程序需启用 PIE(-fPIE -pie)编译/链接选项,即代码段可被加载到任意地址;
  • 系统启用了 ASLR 内核策略,否则加载地址仍然是固定的。

Linux 中通过 /proc/sys/kernel/randomize_va_space 控制 ASLR 的启用方式,其含义如下:

含义
0 关闭 ASLR,所有地址固定(栈、堆、映射段、共享库)。适用于调试/开发
1 基础 ASLR,栈、mmap 映射、共享库地址随机化,但堆基址固定
2 增强 ASLR(默认),在模式 1 的基础上,对堆地址也进行随机化

查看当前设置:

1
cat /proc/sys/kernel/randomize_va_space

临时修改设置:

1
2
 echo 0 > /proc/sys/kernel/randomize_va_space  #(关闭 ASLR)
 echo 2 > /proc/sys/kernel/randomize_va_space  #(恢复默认)

在关闭 ASLR 或未启用 PIE 的情况下,攻击者能准确预测目标地址(如返回地址、libc 函数),易于构造攻击;开启 ASLR + PIE 后,攻击者必须首先泄露地址信息(信息泄露漏洞),否则无法完成精确利用。

3、NX 保护机制

NX(No-eXecute)是一种内存执行权限控制机制,用于防止将数据页当作代码执行,从而阻止攻击者通过栈/堆注入 ShellCode 的方式劫持程序流程。

  • 在 Linux 平台下,称为 NX(No-eXecute)保护
  • 在 Windows 平台下,称为 DEP(Data Execution Prevention,数据执行保护)

NX/DEP 的核心思想是:将 数据存储区域(如栈、堆、BSS 段)标记为不可执行。当攻击者试图将恶意代码注入栈或堆,并试图通过函数返回或跳转指令执行这一段 ShellCode 时,CPU 会检测到当前内存页无执行权限,从而抛出异常,终止程序运行,防止攻击得逞。

1

ShellCode本质是在有限的局部变量空间(比如Buffer)中,构造的一个可以执行系统函数调用的一连串指令,ShellCode所在的位置必须有可执行权限才能成功被利用。

NX 防止”执行注入”,而不是“注入本身”。

关于NX保护需要注意几点:

  • ShellCode 执行失败 ≠ 攻击失败:攻击者仍可以利用返回到合法代码区段的方式进行攻击,如 ROP。
  • 代码执行区绕过:若程序中存在执行权限的后门函数或 gadget 片段,攻击者可跳转到这些合法区域绕过 NX 保护。
  • NX 保护仅作用于数据页:对.text.plt等代码段没有限制。

4、RELRO 保护机制

  • GOT 表:保存外部函数或全局变量的运行时地址;
  • PLT 表:提供外部函数调用的跳转入口;
  • 延迟绑定:外部函数第一次调用时才解析真实地址;
  • glibc:大多数常用外部函数的真实实现位置;
  • RELRO:用于保护 GOT 表,防止其被恶意篡改。

(1)什么是 GOT 表?

GOT,全称 Global Offset Table,即全局偏移表。它是 ELF 文件中的一个重要数据结构,通常位于 .got.got.plt段中。GOT 表的作用是保存程序运行时需要使用的地址,例如:外部函数的真实地址、全局变量的运行时地址、动态链接过程中需要修正的地址。

在动态链接程序中,外部函数的真实地址通常不能在编译阶段确定。比如程序中调用了:

1
printf("hello\n");

printf() 的真实代码位于 glibc 中,而 glibc 会在程序运行时被动态链接器加载到内存。因此,程序需要在运行时通过 GOT 表找到 printf() 的真实地址。可以简单理解为 GOT 表 = 运行时地址表,例如:

1
2
3
printf@got  printf() 的真实地址
puts@got    puts() 的真实地址
read@got    read() 的真实地址

在延迟绑定机制下,某些 GOT 表项一开始并不会保存真实函数地址,而是在函数第一次被调用时才由动态链接器填充。这也是 GOT 表在 PWN 中非常重要的原因:如果 GOT 表可写,攻击者就可能通过漏洞修改 GOT 表项,从而劫持程序执行流程。

(2)什么是 PLT 表?

PLT,全称 Procedure Linkage Table,即过程链接表。它是一段代码,通常位于 ELF 文件的 .plt 段中。PLT 表的作用是为外部函数提供统一的调用入口。

当程序调用外部函数时,例如:

1
printf("hello\n");

程序通常不会直接跳转到 glibc 中的 printf(),而是先跳转到当前 ELF 文件中的 printf@plt,然后 printf@plt 再通过 GOT 表找到真正的 printf() 地址。

可以理解为:

PLT 表 = 外部函数的跳板
GOT 表 = 外部函数的地址表

二者的关系大致如下:

call printf@plt
      │
      ▼
printf@plt
      │
      ▼
读取 printf@got
      │
      ▼
跳转到 printf() 的真实地址

PLT 表中的每个普通表项通常对应一个外部函数,例如:

printf@plt
puts@plt
read@plt
system@plt

此外,PLT 表中还有一个特殊入口,通常称为:

PLT0

PLT0 不对应某个具体函数,而是用于配合动态链接器完成第一次符号解析。

(3)延迟绑定技术

延迟绑定,也叫 Lazy Binding,是 ELF 动态链接中的一种优化机制。

程序调用外部函数时,这些函数的真实地址不一定会在程序启动时全部解析完成。为了减少程序启动时的开销,ELF 默认通常采用延迟绑定机制:只有当某个外部函数第一次被调用时,动态链接器才解析它的真实地址,并把解析结果写入对应的 GOT 表项。之后再次调用该函数时,程序直接通过 GOT 表跳转到真实地址,不需要重复解析。

printf() 为例。

在第一次调用 printf() 之前,printf@got 中保存的并不是 printf() 的真实地址,而是指向 printf@plt 内部后续指令的地址,例如:

1
printf@got  printf@plt + 6

这样设计的目的是:第一次调用时,让程序进入动态链接器的解析流程。

第一次调用外部函数

第一次调用 printf() 的流程如下:

call printf@plt
      │
      ▼
printf@plt:
    jmp *printf@got
      │
      ▼
printf@got 此时指向 printf@plt + 6
      │
      ▼
push relocation_index
      │
      ▼
jmp PLT0
      │
      ▼
PLT0 调用动态链接器解析函数
      │
      ▼
动态链接器解析 printf 的真实地址
      │
      ▼
将 printf 的真实地址写入 printf@got
      │
      ▼
跳转到真正的 printf() 执行

对应的典型汇编逻辑可以抽象为:

1
2
3
4
printf@plt:
    jmp    *printf@got
    push   relocation_index
    jmp    plt0

其中:

  • jmp *printf@got:通过 GOT 表项跳转;
  • push relocation_index:压入重定位索引,告诉动态链接器要解析哪个函数;
  • jmp plt0:跳转到 PLT0,进入统一解析流程。

第二次及之后调用外部函数

当 printf() 已经被解析过后,printf@got 中保存的就是 printf() 在 glibc 中的真实地址。

因此后续调用流程会变成:

call printf@plt
      │
      ▼
jmp *printf@got
      │
      ▼
跳转到 libc.so.6 中真正的 printf()

此时不再进入动态链接器解析流程。

PLT0 的作用

PLT0 是 PLT 表中的特殊入口,用于支持延迟绑定。

普通 PLT 表项用于具体函数,例如:

printf@plt
puts@plt
read@plt

而 PLT0 是所有外部函数首次解析时都会使用的公共入口,可以理解为:

printf@plt ┐
puts@plt   ├── 首次调用时跳转到 PLT0
read@plt   ┘

PLT0 ──▶ _dl_runtime_resolve

也就是说:

  • 每个外部函数都有自己的 PLT 表项;
  • 每个外部函数首次调用时,都会进入 PLT0;
  • PLT0 负责调用动态链接器的解析逻辑;
  • 解析完成后,真实函数地址会被写入对应 GOT 表项;
  • 后续调用不再经过 PLT0。

(4)Glibc库

glibc,全称 GNU C Library,是 Linux 系统中最常用的 C 标准库实现。

它提供了大量常见函数的真实实现,例如:

1
2
3
4
5
6
7
printf()
puts()
read()
write()
malloc()
free()
system()

在动态链接程序中,这些函数的代码通常不会被直接编译进 ELF 可执行文件,而是保存在动态库中,例如:

1
/lib/x86_64-linux-gnu/libc.so.6

程序运行时,动态链接器会加载 libc.so.6,然后通过 PLT 和 GOT 机制让程序能够调用其中的函数。以 printf() 为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
程序中的 printf()
      
      
printf@plt
      
      
printf@got
      
      
libc.so.6 中真正的 printf()

所以在动态链接模型下,程序中的外部函数调用过程可以概括为:

(1)编译阶段:
    编译器生成对 printf@plt 的调用
(2)链接阶段:
    链接器生成 .plt、.got.plt、重定位表等结构
(3)运行阶段:
    动态链接器加载 libc.so.6
    首次调用 printf() 时解析其真实地址
    将真实地址写入 printf@got
    后续调用直接跳转到 libc 中的 printf()

整体流程如下:

第一次调用 printf:

call printf@plt
      │
      ▼
jmp *printf@got
      │
      ▼
printf@got 尚未解析,跳回 printf@plt + 6
      │
      ▼
push relocation_index
      │
      ▼
jmp PLT0
      │
      ▼
_dl_runtime_resolve
      │
      ▼
解析 printf → libc.so.6:printf
      │
      ▼
写入 printf@got
      │
      ▼
执行真正的 printf()

之后再次调用:

call printf@plt
      │
      ▼
jmp *printf@got
      │
      ▼
libc.so.6:printf

(5)RELRO 保护

由于 GOT 表中保存着外部函数的真实地址,如果 GOT 表在程序运行期间可写,攻击者就可能利用漏洞修改 GOT 表项。

例如,程序中原本有:

1
printf@got  printf

攻击者如果能修改 GOT 表,可能将其改为:

1
printf@got  system

这样当程序再次调用 printf() 时,实际执行的就可能变成 system(),从而造成控制流劫持。

这种攻击方式通常称为:GOT overwrite、GOT hijacking、ret2got

为了缓解这类攻击,ELF 引入了 RELRO 保护。

RELRO,全称 RELocation Read-Only,即重定位只读保护。它的核心思想是:在动态链接器完成必要的重定位后,将相关重定位区域设置为只读,防止攻击者在程序运行过程中篡改 GOT 表。

RELRO 的两种模式

(1)Partial RELRO

Partial RELRO 是部分 RELRO 保护。开启方式通常为:-Wl,-z,relro。Partial RELRO 会在程序初始化完成后,将部分重定位相关区域设置为只读,例如:.got.dynamic。但是,为了支持延迟绑定,.got.plt 仍然需要在运行时可写。原因是:外部函数第一次被调用时,动态链接器需要把解析出的真实地址写入 .got.plt。因此在 Partial RELRO 下:

.got      通常只读
.got.plt  仍然可写

所以,如果程序只开启了 Partial RELRO,攻击者仍然可能通过修改 .got.plt 中的函数表项实现 GOT 劫持。

(2)Full RELRO

Full RELRO 是完整 RELRO 保护。开启方式通常为:-Wl,-z,relro -Wl,-z,now

  • -z relro:启用 RELRO;
  • -z now:禁用延迟绑定,程序启动时立即解析所有动态符号。

Full RELRO 会在程序启动阶段完成所有外部函数地址解析,然后将整个 GOT 相关区域设置为只读,包括:

.got
.got.plt

因此在 Full RELRO 下:

.got      只读
.got.plt  只读

这样攻击者就无法在程序运行期间通过修改 GOT 表项来劫持控制流。

Full RELRO 的代价是:程序启动时需要解析所有外部函数地址,因此启动时间可能略有增加。

updatedupdated2026-05-082026-05-08