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程序栈布局

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
高地址
----------------------------------------------------------
[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-14h]    buffer[0]       ; 局部变量 char buffer[12]
...          buffer[1..n]    ; 通常从 ebp-14h 向高地址递增
----------------------------------------------------------
低地址 

__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保护机制

(1)什么是GOT表?

GOT表(Global Offset Table)(全局偏移表)是一个数据结构,位于.got.got.plt段中,用于存储外部函数或全局变量在运行时的实际地址。

  • 作用:在程序运行时,通过 GOT 表项获取外部函数或变量的真实地址,实现位置无关代码(PIC)的支持。
  • 特点
    • 在程序启动时,GOT 表中的某些项尚未填充,待首次调用时由动态链接器解析并填充。
    • GOT 表通常是可读写的,这也是某些攻击(如 GOT 劫持)的潜在目标。

(2)什么是PLT表?

PLT表(Procedure Linkage Table)(过程链接表)是一段代码,位于.plt段中,用于实现对外部函数的调用。

  • 作用:当程序调用外部函数时,实际跳转到 PLT 表中的对应入口,PLT 再通过 GOT 获取函数的真实地
  • 特点
    • PLT 表中的每个入口对应一个外部函数,包含跳转指令和辅助指令。
    • PLT 表的首项(通常称为 PLT0)用于支持延迟绑定机制。

(3)延迟绑定技术

延迟绑定是一种优化机制,只有在函数首次被调用时,才解析其真实地址,从而减少程序启动时间。

在 ELF中,GOT表与PLT表是实现动态链接和延迟绑定(Lazy Binding)的关键机制。

  • 在延迟绑定模式下,程序首次调用某个外部函数(如printf())时,实际调用的是对应的 PLT 段;
  • PLT 段会触发动态链接器解析该函数的真实地址,并将其写入对应的 GOT 表项;
  • 下次调用该函数时,程序将直接从 GOT 表中取出函数地址,无需再解析。

第一次调用外部函数的流程:

  • 程序调用外部函数(例如printf),实际跳转到printf@plt,这是 PLT 表中的对应入口。
  • 执行 PLT 表中的第一条指令:printf@plt中的跳转指令jmp *printf@got,会跳转到 GOT 表中printf的项。
  • 首次调用时的 GOT 表项:printf@got中存储的是指向printf@plt+6的地址,即 PLT 表中printf项的第二条指令。
  • 执行 PLT 表中的第二条指令:这条指令会将printf在重定位表(.rel.plt.rela.plt)中的索引压入栈中。
  • 跳转到 PLT0:随后跳转到 PLT 表的首项PLT0,它会调用动态链接器的解析函数(如 _dl_runtime_resolve)。
  • 动态链接器解析符号:动态链接器根据提供的索引解析printf的真实地址,并将其写入printf@got表项。
  • 返回并执行真实函数:解析完成后,控制权返回,程序继续执行printf函数。
调用 printf
   │
   ▼
printf@plt ──▶ jmp *printf@got
                     │
                     ▼
           [首次调用] printf@got → printf@plt+6
                                     │
                                     ▼
                              push 重定位索引
                                     │
                                     ▼
                                  jmp PLT0
                                     │
                                     ▼
                         PLT0 → 调用 _dl_runtime_resolve
                                     │
                                     ▼
                   解析并填充 printf@got → printf 实际地址
                                     │
                                     ▼
                             跳转到 printf 实际地址

第二次调用外部函数的流程:

  • 程序再次调用printf,实际跳转到printf@plt,这是 PLT 表中的对应入口。
  • 执行 PLT 表中的第一条指令:printf@plt中的跳转指令jmp *printf@got,会跳转到 GOT 表中printf的项。
  • printf@got中已存有printf的真实地址,程序直接跳转,无需再次解析。
call printf@plt
   │
   ▼
jmp *printf@got
   │
   ▼
跳转到 printf 实际地址

在 ELF 文件中,PLT 表的首项(通常称为PLT0)是一个特殊的入口,用于支持延迟绑定机制。

  • PLT0 的作用:当程序首次调用某个外部函数时,PLT 表中的对应入口会跳转到 PLT0,PLT0 再调用动态链接器的解析函数(如_dl_runtime_resolve)来解析该函数的真实地址。
  • 是否每次都使用 PLT0:是的,在延迟绑定模式下,所有外部函数的首次调用都会通过各自的 PLT 表项跳转到 PLT0,由 PLT0 统一调用动态链接器进行解析。

(4)Glibc库

glibc(GNU C Library)是 Linux 系统中最核心的 C 标准库,实现了诸如 printf()system()malloc() 等常用函数的底层逻辑。

  • 它是 ELF 可执行文件中大多数外部函数的真正实现位置
  • 通常作为动态库libc.so.6链接进程序,在运行时由动态链接器(ld-linux.so)加载。

glibc 和动态链接之间的关系

在动态链接模型下,glibc并不会被编译进最终程序中,而是在程序运行时通过.plt.got机制“延迟”加载:程序中调用的printf()函数,并不直接调用glibc中的实现,而是通过printf@pltprintf@gotlibc.so.6:printf来间接跳转。

结合这里的 glibc 的知识,我们完善下前面提到的调用外部函数的过程。

假设某 ELF 程序调用了 printf("hello world\n"),以下是流程概览:

编译时(生成 ELF 文件):

编译器不会将 printf() 的实现代码直接编译进程序,而是生成一个对 printf@plt 的调用。

链接时(程序还没运行):

链接器添加 .plt 和 .got.plt 结构,生成 printf@plt,并设置其间接跳转到 printf@got。

程序运行,首次调用 printf()

call printf@plt
   │
   ▼
jmp *printf@got    ← GOT 表中尚未解析,跳转到 plt+6
   │
   ▼
push 重定位索引    ← 用于告诉动态链接器解析哪个符号
jmp plt0           ← 跳转到 PLT0,调用解析函数
   │
   ▼
_dl_runtime_resolve (由 ld-linux.so 提供)
   │
   ▼
解析 printf → libc.so.6:printf;
将 libc.so.6:printf 写入 printf@got;
跳转执行真正的 printf();

程序运行,再次调用 printf()

call printf@plt
   │
   ▼
jmp *printf@got    ← 这次 GOT 表中是 libc 的地址
   │
   ▼
libc.so.6:printf   ← 直接执行真正的 printf 实现

glibc是程序中外部函数如printfsystem的实际实现所在。在 ELF 程序中,通过 PLT 和 GOT 实现延迟绑定,首次调用这些函数时会触发动态链接器解析其真实地址并写入 GOT 表,后续调用直接跳转到glibc中的实现。

概念 描述
glibc 提供printf()的真实实现,是目标地址
PLT 表 生成在可执行文件中,起跳板作用
GOT 表 运行时动态链接器写入函数实际地址
动态链接器 _dl_runtime_resolve()负责首次解析

(5)RELRO保护

由于.got.plt段在程序运行期间需保持可写,攻击者可能通过漏洞(如栈溢出、格式化字符串等)篡改 GOT 表项,将其重定向到恶意函数(如system()),从而实现控制流劫持。

RELRO(RELocation Read-Only)是由 Red Hat 工程师 Jakub Jelinek 于 2004 年引入的一种安全机制,旨在防止 GOT 表被修改,从根本上缓解延迟绑定机制带来的劫持风险。

RELRO 的两种模式:

模式 描述
Partial RELRO 使.got.dynamic等段在程序初始化后变为只读(via mprotect),但保留延迟绑定机制(.got.plt仍可写)。默认由-Wl,-z,relro启用
Full RELRO 禁用延迟绑定,程序启动前即完成所有符号解析,.got.plt也变为只读,彻底防止 GOT 劫持。由 -Wl,-z,relro -Wl,-z,now同时开启。

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

  • Full RELRO 会略微增加启动时间,因为程序需在启动前完成所有动态符号解析;
  • 如果程序未开启 Full RELRO,攻击者仍可能通过 GOT 表实现如ret2gotgot overwrite等攻击;
  • Full RELRO + PIE + NX + Canary + ASLR 是 Linux 环境下现代 ELF 程序的推荐安全编译配置。
updatedupdated2025-05-142025-05-14