banner
言心吾

言心吾のBlog

吾言为心声

PWN笔记之基础

汇编基础#

掌握程度:看懂汇编,分析 gadget,单步调试理解寄存器状态

参考文章:x86_64 汇编之一:AT&T 汇编语法_x86_64 汇编 at&t-CSDN 博客

寄存器#

常用的 x86 CPU 寄存器有 8 个:EAXEBXECXEDXEDIESIEBPESP

CPU 优先读写寄存器,再通过寄存器、缓存跟内存来交换数据,达到缓冲的目的,因为可以通过名称访问寄存器,这样访问速度是最快的,因此也被称为零级缓存

存取速度从高到低分别是: 寄存器 > 1级缓存 > 2级缓存 > 3级缓存 > 内存 > 硬盘

通用寄存器及用途#

上面提到大 8 个寄存器都有其特定的用途,我们以32位 CPU 为例简单说明下这些寄存器的作用,整理如下表:

寄存器含义用途包含寄存器
EAX累加 (Accumulator) 寄存器常用于乘、除法和函数返回值AX(AH、AL)
EBX基址 (Base) 寄存器常做内存数据的指针,或者说常以它为基址来访问内存BX(BH、BL)
ECX计数器 (Counter) 寄存器常做字符串和循环操作中的计数器CX(CH、CL)
EDX数据 (Data) 寄存器常用于乘、除法和 I/O 指针DX(DH、DL)
ESI来源索引 (Source Index) 寄存器常做内存数据指针和源字符串指针SI
EDI目的索引 (Destination Index) 寄存器常做内存数据指针和目的字符串指针DI
ESP堆栈指针 (Stack Point) 寄存器只做堆栈的栈顶指针;不能用于算术运算与数据传送SP
EBP基址指针 (Base Point) 寄存器只做堆栈指针,可以访问堆栈内任意地址,经常用于中转 ESP 中的数据,也常以它为基址来访问堆栈;不能用于算术运算与数据传送BP

在上面的图标中每个常用寄存器后面还有其他的名字,rax,eax,ax,ah,al 其实是表示同一个寄存器,只是包含不同的范围

下面为 64 位寄存器的对照关系:

|63..32|31..16|15-8|7-0|
              |AH. |AL.|
              |AX......|
       |EAX............|
|RAX...................|

指令指针寄存器#

指令指针寄存器(RIP)包含下一条将要被执行的指令的逻辑地址。

通常情况下,每取出一条指令后,RIP 会自增指向下一条指令。在 x86_64 中 RIP 的自增也即偏移 8 字节。

但是 RIP 并不总是自增,也有例外,例如call 指令和ret指令。call指令会将当前 RIP 的内容压入栈中,将程序的执行权交给目标函数;ret指令则执行出栈操作,将之前压入栈的 8 个字节的 RIP 地址弹出,重新放入 RIP。

标志寄存器(EFLAGS)#

image-20240331212445677

汇编语言指令#

常用的汇编指令:movjejmpcalladdsubincdecandor

数据传送指令#

指令名称示例备注
MOV传送指令MOV dest, src将数据从 src 移动到 dest
PUSH进栈指令PUSH src把源操作数 src 压入堆栈
POP出栈指令POP desc从栈顶弹出字数据到 dest

算术运算指令#

指令名称示例备注
ADD加法指令ADD dest, src在 dest 基础上加 src
SUB减法指令SUB dest, src在 dest 基础上减 src
INC加 1 指令INC dest在 dest 基础上加 1
DEC减 1 指令DEC dest在 dest 基础上减 1

逻辑运算指令#

指令名称示例备注
NOT取反运算指令NOT dest把操作数 dest 按位取反
AND与运算指令AND dest, src把 dest 和 src 进行与运算之后送回 dest
OR或运算指令OR dest, src把 dest 和 src 进行或运算之后送回 dest
XOR异或运算XOR dest, src把 dest 和 src 进行异或运算之后送回 dest

循环控制指令#

指令名称示例备注
LOOP计数循环指令LOOP label使 ECX 的值减 1,当 ECX 的值不为 0 的时候跳转至 label,否则执行 LOOP 之后的语句

转移指令#

指令名称示例备注
JMP无条件转移指令JMP lable无条件地转移到标号为 label 的位置
CALL过程调用指令CALL labal直接调用 label
JE条件转移指令JE lablezf =1 时跳转到标号为 label 的位置
JNE条件转移指令JNE lablezf=0 时跳转到标号为 label 的位置

linux 和 windows 下汇编的区别#

linuxwindows 下的汇编语法是不同的,其实两种语法的不同和系统不同没有绝对的关系,一般在 linux 上会使用 gcc/g++ 编译器,而在 windows 上会使用微软的 cl 也就是 MSBUILD,所以产生不同的代码是因为编译器不同,gcc 下采用的是 AT&T 的汇编语法格式,MSBUILD 采用的是 Intel 汇编语法格式。

差异IntelAT&T
引用寄存器名字eax%eax
赋值操作数顺序mov dest, srcmovl src, dest
寄存器、立即数指令前缀mov ebx, 0xd00dmovl $0xd00d, %ebx
寄存器间接寻址[eax](%eax)
数据类型大小操作码后加后缀字母,“l” 32 位,“w” 16 位,“b” 8 位(mov dx, word ptr [eax])操作数前面加 dword ptr, word ptr,byte ptr 的格式 (movb % bl % al)

寻址方式#

直接寻址

内存寻址:[ ]

溢出(有无符号 & 上下溢出)#

  • 存储位数不够
  • 溢出到符号位

整数溢出配合别的漏洞使用

个人认为,有符号位数进位代表溢出

LINUX 文件基础#

保护层级:0-3

0 - 内核

3 - 用户

虚拟内存:物理内存经过 MMU 转换后的地址 系统给每个用户进程分配一段虚拟内存空间

大端序小端序#

大端序:数据高位 -> 计算机地址低位(更符合人类阅读习惯)

小端序:数据低位 -> 计算机地址低位(反直觉,但更符合存储逻辑、运算规律)

计算机输出字符串:低地址到高地址

linux 数据存储格式为小端序,arm 架构为大端序

以字符串形式输入数字时,要注意格式,linux 从低到高读入数据,可用 pwntools 进行转换

文件描述符#

每个文件描述符与一个打开的文件相对应

  • 0
  • 1
  • 2

stdin->buf->stdout

例如:

read(0,buf,size)

write(1,buf,size)

栈(stack)#

阉割版数组,只能在一头操作

数据结构:后进先出(LIFO),与函数调用顺序相同

​ 函数开始执行:main->funA->funB

​ 函数完成顺序:funB->funA->main

基本操作:push 压入,pop 弹出

函数调用指令 call,返回指令 ret

操作系统为每个程序都设置了一个栈,程序每个独立的函数都有独立的栈帧

linux 中的栈由高地址(栈顶)向低地址(栈底)生长

很多算法如 DFS 都是利用栈,以递归形式实现

调用约定 (Calling Convention)#

什么是调用约定#

函数的调用过程中有两个参与者,一个是调用方 caller,另一个是被调用方 callee

调用约定规定了 caller 和 callee 之间如何相互配合来实现函数调用,具体包括的内容如下:

  • 函数的参数存放在哪的问题。是放在寄存器中?还是放在栈中?放在哪个寄存器中?放在栈中的哪个位置?
  • 函数的参数按何种顺序传递的问题。是从左到右将参数入栈,还是从右到左将参数入栈?
  • 返回值如何传递给 caller 的问题。是放在寄存器里面,还是放在其他地方?
  • 等等

那么,为什么需要调用约定呢?

举个例子,如果我们用汇编语言编写代码没有一个统一的规范来遵守的话。那么A习惯将参数放在栈中,B习惯将参数放在寄存器中,C 习惯 …,每个人编写的代码都按照自己的想法来。这样,当 A 尝试调用其他人的代码时,就不得不遵循其他人的习惯,比如说调用B的,那么A需要将参数放入 B 规定好的寄存器中;调用C的,又是另一个样子…

调用约定就是为了解决上述问题,它对函数调用的细节作出了规定,这样的话,每个人都遵守一个约定,当我们想调用别人编写的代码时,就不需要做啥修改了。

函数调用栈#

  1. 函数调用:当一个函数被调用时,程序将在调用栈上为其分配一个新的栈帧。 栈帧中包含函数的参数、局部变量、返回地址等信息。
  2. 参数传递:在函数调用过程中,参数通过压栈操作传递给被调用的函数。 这些参数存储在栈帧中,供函数内部使用。
  3. 执行函数:被调用的函数开始执行,使用栈帧中的参数和局部变量。 函数的执行过程可能涉及复杂的逻辑和计算。
  4. 返回值处理:当函数执行完毕后,程序将返回到调用该函数的代码位置。 这个位置由栈帧中的返回地址指定。 如果函数有返回值,该值将被推送到调用者的栈帧中。
  5. 栈帧销毁:当函数调用完成后,其对应的栈帧将从调用栈中弹出并销毁,释放所占用的内存资源。

具体的函数调用流程#

  • pop

pop rax 的作用:

mov rax [rsp]; // 栈顶数据弹出到寄存器

add rsp 8; // 栈顶指针下移一个单位

  • push

push rax 的作用:

sub rsp 8; // 栈帧上移一个单位

mov [rsp] rax; // 将一个寄存器的值放在栈顶

  • jmp

立即跳转,不涉及函数调用,用于循环,if-else

如 call 1234h 的作用:

mov rip 1234h;

  • call

函数调用,需要保存返回地址

如 call 1234h 的作用:

push rip;

mov rip 1234h;

  • ret

pop rip

实例:main call funB , funB call funA ,逐步分析栈帧变化:

函数调用过程中:

  1. 调用函数:
    • rip 压入栈中,作为返回地址。(call)
  2. 被调用函数:
    • rbp 压入栈中,作为当前栈帧的基址。
    • rsp 的值赋给 rbp,使 rbp 指向当前栈帧的底部。
    • 为局部变量和临时数据分配栈空间,将 rsp 减去相应的大小。
    • 使用 rsp 作为基址指针来访问函数参数和局部变量。

函数返回时:leave;ret;

  1. 被调用函数:
    • 将栈中分配的局部变量和临时数据弹出。
    • rsp 恢复到函数调用时的值。
  2. 调用函数:
    • 从栈中弹出返回地址。
    • rip 更新为返回地址。

栈帧变化示意图:

+----------------------------+
| main 函数栈帧               |
+----------------------------+
| 返回地址                    |
| rbp (main 函数的基址指针)   |
+----------------------------+
| funB 即调用函数栈帧         |
+----------------------------+
| 返回地址                   |
| rbp (funB 函数的基址指针)   |
+----------------------------+
| funA 即被调用函数栈帧       |
+----------------------------+
| rbp (funA 函数的基址指针)   |
| 局部变量                    |
+----------------------------+

如何传参#

函数返回值给 RAX

x86-64 函数的调用约定为:

  1. 从左至右参数依次传递给RDI,RSI,RDX,RCX,R8,R9

  2. 如果一个函数的参数 > 6 个,则从右至左压入栈中传递

系统调用#

syscall 指令#

用于调用系统函数,调用时指明系统调用号(可网上查询 64 位 Linux 系统调用表)

系统调用号存在 RAX 寄存器,然后布置好参数,执行 syscall 即可

示例:调用 read (0,buf,size)

mov rax 0;
mov rdi 0;
mov rsi  buf;
mov rdx size;
syscall;

ELF 文件结构#

ELF 文件格式#

ELF (Executable and Linkable Format) 为 linux 中二进制可执行文件格式。

ELF 文件头(ELF Header)#

readelf -h 命令可以读取 ELF 文件的文件头,。ELF 头包括了程序的入口点(Entry Point Address)、段信息和节信息。从 ELF 头的Start of program headersStart of section headers可以定位段表和节表的在文件中的位置。

image

节表(Section Header Table)#

使用readelf -S 命令读取二进制 ELF 文件的节信息(sections)。程序 test 中共有 31 一个节。汇编语言是按照节来编写程序的,例如.text 节、.data 节。汇编代码与机器代码是一一对应的,汇编程序被转换成二进制代码时保留了节的信息。

readelf -S test

image

段表(Program Header Table)#

ELF 程序执行时(加载进入内存时),装载器(Loader)根据程序的段表创建进程的内存镜像(Image)。使用readelf -l 命令读取二进制 ELF 文件的段信息(segments)。程序 test 共有 13 个段,段的数量大于节的数量,因此存在多个节映射到同一个段的情况。

根据节的权限:可读可写的节被映射入一个段,只读的节被映射入一个段,等等。

readelf -l test

image

image

链接视图 / 执行视图#

Segment 和 Section 是从不同角度来划分同一个 ELF 文件。这个在 ELF 中被称为不同的视图(View)

从 Section 的角度来看 ELF 文件就是链接视图(Linking View)
从 Segment 的角度来看就是执行视图(Execution View)
当我们在谈到ELF 装载时,段专门指 Segment;而在其他情况下,段指的是 Section。

libc#

glibc:GNU C Library ,glibc 本身是 GNU 旗下的 C 标准库,后来逐渐成为了 Linux 的标准 c 库

其后缀为 libc.so ,本质也是 ELF 文件,可以单独执行,通常 pwn 题接触到的动态链接库就是 libc.so 文件

linux 基本所有程序都依赖 libc,所以 libc 中的函数至关重要

延迟绑定机制#

静态编译与动态编译#

动态编译的可执行文件需要附带一个的动态链接库,在执行时,需要调用其对应动态链接库中的命令。所以其优点一方面是缩小了执行文件本身的体积,另一方面是加快了编译速度,节省了系统资源。缺点一是哪怕是很简单的程序,只用到了链接库中的一两条命令,也需要附带一个相对庞大的链接库;二是如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行。

静态编译就是编译器在编译可执行文件的时候,将可执行文件需要调用的对应动态链接库 (.so) 中的部分提取出来,链接到可执行文件中去,使可执行文件在运行的时候不依赖于动态链接库。所以其优缺点与动态编译的可执行文件正好互补。

延迟绑定(Lazy Binding)#

使用延迟绑定是基于这样一个前提:在动态链接下,程序加载的模块中包含了大量的函数调用。

延迟绑定通过将函数地址的绑定推迟到第一次调用这个函数时,从而避免动态链接器在加载时处理大量函数引用的重定位。

延迟绑定的实现使用了两个特殊的数据结构:全局偏移表(Global Offset Table,GOT)和过程链接表(Procedure Linkage Table,PLT)。

全局偏移表 GOT#

在库函数第一次调用后,程序才将其地址保存在 got 表中。

全局偏移表在 ELF 文件中以独立的节区存在,共包含两类,对应的节区名为.got.got.plt,其中,.got存放所有对于外部变量引用的地址;.got.plt保存所有对于外部函数引用的地址,对于延迟绑定主要使用.got.plt表。.got.plt表的基本结构如下图所示:

image

其中,.got.plt的前三项存放着特殊的地址引用:

  • GOT[0]:保存.dynamic段的地址,动态链接器利用该地址提取动态链接相关的信息;
  • GOT[1]:保存本模块的 ID;
  • GOT[2]:存放了指向动态链接器_dl_runtime_resolve函数的地址,该函数用来解析共享库函数的实际符号地址。

过程链接表 PLT#

为了实现延迟绑定,当调用外部模块的函数时,程序并不会直接通过 GOT 跳转,而是通过存储在 PLT 表中的特定表项进行跳转。对于所有的外部函数,在 PLT 表中都会有一个相应的项,其中每个表项都保存了 16 字节的代码,用于调用一个具体的函数。过程链接表的通用结构如下:

image

过程链接表中除了包含编译器为调用的外部函数单独创建的 PLT 表项外,还有一个特殊的表项,对应于 PLT [0],它用于跳转到动态链接器,进行实际的符号解析和重定位工作:

image

PLT 与 GOT#

无论第几次调用外部函数,程序真正调用的是 PLT 表,PLT 表其实是一段段汇编指令构成。

那么这里可能会有疑问:为什么要存在 PLT,存在过度,而不是直接到达 GOT 呢?

这就比如说,你是一个有很多亲戚的人,你每周都需要拜访这些亲戚,所以你将这些亲戚的地址都记在了一个本子上,等你要去拜访的时候就翻出来查找,那么这个本子就是一个 PLT 表,它里面每一个地址都会跳转到对应的 GOT 表地址(你的亲戚家)

假如有一天,你觉得每天跑来跑去好麻烦,于是把你所有亲戚全都接到了你家里住,每周只要到对应的房间去拜访就可以了,那个本子也就没用了,你扔掉了它。这时,就是在没有 PLT 表的情况下,直接把 GOT 表弄来了。

你觉得是一个登记本占地面积小,还是一屋子亲戚占地面积小呢。

这就是 PLT 表存在的原因之一,为了更高效的利用内存。

另一个原因就是可以增加安全性。

LINUX 安全防护机制#

gcc 安全编译选项详解(NX (DEP)、RELRO、PIE (ASLR)、CANARY、FORTIFY)_gcc pie-CSDN 博客

CANARY#

Canary 是一种针对栈溢出攻击的防护手段,其基本原理是从内存中fs: 0x28处复制一个开头字节为 \x00 的长度为 8 字节的随机数 canary ,该随机数会在创建栈帧时紧跟着 rbp 入栈(紧挨 ebp 的上一个位置)。当攻击者想通过缓冲区溢出覆盖 ebp 或者 ebp 下方的返回地址时,一定会覆盖掉 canaary 的值;当程序结束时,程序会检查 CANARY 这个值和之前的是否一致,如果不一致,则不会往下运行,从而避免了缓冲区溢出攻击。

绕过方法:

  1. 修改 canary。
  2. 泄露 canary。

canary bypass#

  • 格式化字符串绕过 canary
    • 通过格式化字符串读取 canary 的值
  • Canary 爆破(针对有 fork 函数的程序)
    • fork 作用相当于自我复制,每一次复制出来的程序,内存布局都是一样的,当然 canary 的值也一样,那我们就可以逐位爆破,如果程序崩溃了就说明这一位不对,如果程序正常就可以接着跑下一位,直到跑出正确的 canary
  • Stack smashing(故意触发 canary_ssp leak)
  • 劫持__stack_chk_fail
    • 修改 got 表中__stack_chk_fail 函数的地址,在栈溢出后执行该函数,但由于该函数的地址被修改,所以程序会跳转到我们想要执行的地址

NX#

栈上的数据没有执行权限(not executable),开启后,程序中的堆、栈、bss 段等可写段就不能执行。

绕过方法:

用 mprotect 函数改写段权限,且 nx 保护对于 rop 或劫持 got 表利用方式不影响。

PIE 和 ASLR#

ASLR 是什么?#

ASLR 是 Linux 操作系统的功能选项,作用于程序(ELF)装入内存运行时。是一种针对缓冲区溢出的安全保护技术,通过对加载地址的随机化,防止攻击者直接定位攻击代码位置,到达阻止溢出攻击的一种技术。

开启、关闭 ASLR#

查看当前系统 ASLR 的打开情况:

sudo cat /proc/sys/kernel/randomize_va_space

ASLR 有三个安全等级:

  • 0: ASLR 关闭
  • 1:随机化栈基地址(stack)、共享库(.so\libraries)、mmap 基地址
  • 2:在1基础上,增加随机化堆基地址(chunk)

PIE 是什么?#

PIE 是 gcc 编译器的功能选项,作用于程序(ELF)编译过程中。是一个针对代码段( .text )、数据段( .data )、未初始化全局变量段( .bss )等固定地址的一个防护技术,如果程序开启了 PIE 保护的话,在每次加载程序时都变换加载地址,从而不能通过 ROPgadget 等一些工具来帮助解题。

开启 PIE#

在使用 gcc 编译时加入参数-fPIE

PIE 开启后会随机化代码段( .text )、初始化数据段( .data )、未初始化数据段( .bss )的加载地址。

PIE bypass#

程序的加载地址一般都是以内存页位单位的,所以程序的基地址最后三个数字一定是 0,这也就是说那些地址已知的最后三个数就是实际地址的最后三个数。知道这一点后我们就有了绕过 PIE 的思路,虽然我并不知道完整的地址,但我知道最后三个数,那么我们可以利用栈上已有的地址,只修改它们最后两个字节(最后四个数)即可。

所以绕过 PIE 的核心思想就是partial writing(部分写地址)

RELRO#

ReLocation Read-Only,堆栈地址随机化, 是一种用于加强对 binary 数据段的保护的技术。

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。