菜鸟笔记之PWN入门(1.1.3)Linux基础操作和ELF文件保护

这里不讨论Linux的历史及其与Windows的比较。直接介绍一些简单基础的操作。
首先我们需要安装一个Linux操作系统(首先推荐Ubuntu),我们需要安装一个VM虚拟机,然后在里面搭建一个Ubuntu的虚拟机

可以直接百度搜索,这里推荐一个文章
安装虚拟机(VMware)保姆级教程(附安装包)_vmware虚拟机-CSDN博客
根据教程你可以成功安装Ubuntu虚拟机之后
第一件事情不是配置PWN环境,而是更换源
Ubuntu 更换国内源_ubuntu换源-CSDN博客
然后按照顺序安装GCC --> python3 --> VMtools
然后进行PWN虚拟机的安装:
https://hollk.blog.csdn.net/article/details/118188924?spm=1001.2014.3001.5502
PWN的大部分工具需要从github上下载,请确保你的网络能够顺利连接github,建议科学上网,否则下载成功与否只能看运气…

当然,如果你感觉很麻烦,我已经安装好了一个Ubuntu22的虚拟机,下面提供百度网盘链接:
通过网盘分享的文件:VM22XiDP_ovf
链接: https://pan.baidu.com/s/1NYJ7NywYab6LUC_kZgRxPQ?pwd=xidp
提取码: xidp

虚拟机密码:
用户xidp :密码 xidpxidp..
用户root :密码 xidpxidp..

虚拟机可能存在bug,可以在github上向我留言,如果有需要添加的工具也可以留言:XiDP0/Pwn-Ubuntu22 (github.com)

  1. 查看当前目录

    • pwd:打印当前工作目录路径。
  2. 列出目录内容

    • ls:列出当前目录的文件和文件夹。
    • ls -l:显示详细信息。
    • ls -a:显示所有文件,包括隐藏文件。
  3. 切换目录

    • cd 目录名:进入指定目录。
    • cd ..:返回上级目录。
  4. 创建文件和目录

    • touch 文件名:创建空文件。
    • mkdir 目录名:创建新目录。
  5. 删除文件和目录

    • rm 文件名:删除文件。
    • rm -r 目录名:递归删除目录及其内容。
  6. 移动和重命名文件

    • mv 源文件 目标:移动或重命名文件或目录。
  7. 复制文件和目录

    • cp 源文件 目标:复制文件。
    • cp -r 源目录 目标:递归复制目录。
  8. 查看文件内容

    • cat 文件名:显示文件内容。
    • less 文件名:分页查看文件内容,适合查看长文件。
  9. 编辑文件

    • nano 文件名 或 vim 文件名:使用nano或vim编辑文件。
  10. 查找文件

    • find /路径 -name 文件名:在指定路径下查找文件。
  11. 显示系统信息

    • uname -a:显示系统信息。
    • top:查看当前运行的进程和系统资源使用情况。
  12. 查看和管理进程

    • ps aux:列出所有进程。
    • kill 进程ID:终止指定进程。
  13. 清空当前页面

    • clear
  14. 获取管理员权限

    第一次登入的时候你会发现你没有设置root权限

    • sudo +命令:以超级用户权限执行命令。sudo 允许普通用户临时获取管理员权限来执行特定任务。
    • sudo root:输入密码后成为高级用户(输入的密码是看不见的)。
  15. 输入文本内容

    • cat:显示文件内容

(为什么只放四个图? 因为我懒…)

接下来重点讲解ELF文件的保护有哪些:

ELF文件保护

Linux ELF文件的保护主要有四种:
Canary
NX
PIE
RELRO
分别用于应付不同的漏洞,同时后面我们也会学习绕过它们的方法,下面让我们来分别了解它们的工作原理和起到的保护作用

1.Canary

Canary是金丝雀的意思。技术上表示最先的测试的意思。
这个来自以前挖煤的时候,矿工都会先把金丝雀放进矿洞,或者挖煤的时候一直带着金丝雀。金丝雀对甲烷和一氧化碳浓度比较敏感,会先报警。所以大家都用Canary来搞最先的测试。

Linux的Canary指的是在ebp/rbp旧值之前添加的一串随机数(不超过机械字长)(也叫做cookie),当程序会在执行到此处的时候会检测Canary的值是否和刚开始的一样,如果出现缓冲区溢出攻击,覆盖到了Canary(也就是Canary的值发生了改变),系统检测到Canary的值发生了变化,那么程序会立马崩溃,以防止被篡改的程序执行奇怪的东西

Canary的末位一定是\x00\x00是一个用于表示字符串结束的符号,也就是说类似puts函数这类函数输出字符串的时候遇到\x00就会停止继续输出(同样strcpy函数复制字符串的时候遇到\x00也会停止,后面会讲到这个\x00也有不同的用法可以绕过一些检测)。

之所以Canary末位添加\x00是因为计算机往往是小端程序。比如:
Canary0x1122334455667700
那么在计算机中储存的时候它是:\x00\x77\x66\x55\x44\x33\x22\x11这样储存的
那么\x00就在内存的开头了,当Canary前面有字符串需要输出的时候,一直到Canary\x00就会停下。这使得Canary不会被输出,以此保护Canary不泄露(之后我们会讲到一种绕过Canary的方法就是尝试覆盖掉这个\x00Canaryputs之类的函数输出)

GCC开启Canary和关闭Canary

1
2
3
4
5
gcc -o test test.c // 默认情况下,不开启Canary保护
gcc -fno-stack-protector -o test test.c //禁用栈保护
gcc -fstack-protector -o test test.c //启用堆栈保护,不过只为局部变量中含有 char 数组的函数插入保护代码
gcc -fstack-protector-all -o test test.c //启用堆栈保护,为所有函数插入保护代码
-fno-stack-protector /-fstack-protector / -fstack-protector-all (关闭 / 开启 / 全开启)

Canary所在的位置和GCC编译器以及系统有关:
下面以Ubuntu系统举例:

注意:前面的文章中我制作的图片都是使用上面为高地址,下面为低地址(因为初学者都相对自然的认为上高下低),但是此处我采用了上低下高。因为在后续我们做PWN题目的时候使用gdb调试,在gdb中查看,栈中的内容是上低下高的,图中所看到的情况也符合我们即将在gdb中看到的情况。所以为了让大家能够适应后续的gdb调试这里使用了上低下高

所以总的来说,对于Ubuntu来说,32为程序的Canary一般在 ebp - 0xc 的地方(32位的一格是0x4,也就是在ebp的上面3格)。而 64位的Canary则是在 rbp - 0x8 的位置(64位的一格是0x8,也就是在ebp的上面1格,他们相邻)

2.NX保护

NX即No-eXecute(不可执行)的意思,NX(DEP)的基本原理是将数据所在内存页标识为不可执行。

这个保护有什么用呢?
我们都知道程序分为代码段数据段,代码段用于执行,数据段用于存储数据,那么我们可以在数据段写入一些代码吗?答案是可以的,但是程序执行什么由ip指针说了算,ip指哪就运行什么。那如果我们在数据段写入我们想要的代码,然后再想个办法把ip指针骗过来了呢?
这样就像银行卡里的数字自己可以改一样,非常逆天(后续我们会学到,这种逆天的攻击方法叫ret2shellcode,但是开启NX之后几乎就无效了)。
所以有了NX保护,它的任务就是让数据段坚决不能执行(在Ubuntu20版本之后,系统默认了数据段就是无法执行的,哪怕没有开启NX也不能执行数据段里面的东西)

GCC开启和关闭NX保护:

1
2
3
4
gcc -o test test.c // 默认情况下,开启NX保护
gcc -z execstack -o test test.c // 禁用NX保护
gcc -z noexecstack -o test test.c // 开启NX保护
-z execstack / -z noexecstack (关闭 / 开启)

3.PIE与ASLR

从我个人认为PIE与ASLR是非常麻烦的保护,它们的作用是让地址空间分布随机化

ASLR(libc地址随机化)
ASLR(Address Space Layout Randomization)是一种操作系统级别的安全保护机制,旨在增加软件系统的安全性。它通过随机化程序在内存中的布局,使得攻击者难以准确地确定关键代码和数据的位置,从而增加了利用软件漏洞进行攻击的难度。 开启不同等级会有不同的效果

  1. 内存布局随机化: ASLR的主要目标是随机化程序的内存布局。在传统的内存布局中,不同的库和模块通常会在固定的内存位置上加载,攻击者可以利用这种可预测性来定位和利用漏洞。 ASLR通过随机化这些模块的加载地址,使得攻击者无法准确地确定内存中的关键数据结构和 代码的位置。
  2. 地址空间范围的随机化: ASLR还会随机化进程的地址空间范围。在传统的地址空间中,栈、 堆、代码段和数据段通常会被分配到固定的地址范围中。ASLR会随机选择地址空间的起始位置和大小,从而使得这些重要的内存区域在每次运行时都有不同的位置。
  3. 随机偏移量: ASLR会引入随机偏移量,将程序和模块在内存中的相对位置随机化。这意味着每个模块的实际地址是相对于一个随机基址偏移的,而不是绝对地址。攻击者需要在运行时发现这些偏移量,才能准确地定位和利用漏洞。
  4. 堆和栈随机化: ASLR也会对堆和栈进行随机化。堆随机化会在每次分配内存时选择不同的起 始地址,使得攻击者无法准确地预测堆上对象的位置。栈随机化会随机选择栈帧的起始位置, 使得攻击者无法轻易地覆盖返回地址或控制程序流程。

和PIE不同,PIE用于随机化代码段和数据段,ASLR用于将libc库以及堆栈的地址随机化。而ASLR是Linux系统本身提供的保护机制。
Linux下的ASLR分为三个级别,0,1,2

1
2
3
0:就是关闭ASLR,没有随机化,堆栈地址每次都相同,并且libc.so的每次地址也都相同
1:就是普通ASLR,mmap基地址、栈基地址、.so加载地址都将被随机化,但是堆并没有被随机化
2:就是增强ASLR,比起1增加了堆随机化
1
2
3
4
5
6
7
8
// 可以查看Linux系统里面ASLR的强度等级
cat /proc/sys/kernel/randomize_va_space
// 可以改变ASLR等级,可能会遇到权限问题
echo 0 > /proc/sys/kernel/randomize_va_space
// 如果遇到权限问题,可以su root之后再执行
// 或者尝试下面这两条指令
sudo -s echo 0 > /proc/sys/kernel/randomize_va_space
sudo bash -c 'echo 0 > /proc/sys/kernel/randomize_va_space'

PIE
PIE能够使得程序像共享库一样在主存的任何位置装载,在需要将程序编译成位置无关
开放后code与data会随着ASLR变化地址
IDA以及gdb执行的时候就只能看到地址后面的末尾三位数

GCC开启和关闭PIE

1
2
3
4
5
6
gcc -o test test.c // 默认情况下,不开启PIE
gcc -fpie -pie -o test test.c // 开启PIE,此时强度为1
gcc -fPIE -pie -o test test.c // 开启PIE,此时为最高强度2
gcc -fpic -o test test.c // 开启PIC,此时强度为1,不会开启PIE
gcc -fPIC -o test test.c // 开启PIC,此时为最高强度2,不会开启PIE
-no-pie / -pie (关闭 / 开启)

4.RELRO

Relocation Read-Only (RELRO) 可以使程序某些部分成为只读的。
它分为两种:Partial RELROFull RELRO,即:部分RELRO 和 完全RELRO。

部分RELRO 是 GCC 的默认设置,几乎所有的二进制文件都至少使用部分RELRO。这样仅仅只能防止全局变量上的缓冲区溢出从而覆盖 GOT。

完全RELRO 使整个 GOT 只读,从而无法被覆盖,但这样会大大增加程序的启动时间,因为程序在启动之前需要解析所有的符号。


在Linux系统安全领域数据可以写的存储区就会是攻击的目标,尤其是存储函数指针的区域。所以在安全防护的角度应尽量减少可写的存储区域

RELRO会设置符号重定向表格为只读或者程序启动时就解析并绑定所有动态符号,从而减少对GOT表的攻击。如果RELRO为Partial RELRO,就说明对GOT表具有写权限

具体GOT和PLT表是什么,有什么用,将在ret2libc的前置章节中讲解


主要用来保护重定位表段对应数据区域,默认可写
Partial RELRO:.got不可写,got.plt可写
Full RELRO:.got和got.plt不可写
got.plt可以简称为got表

GCC开启和关闭RELRO

1
2
3
4
5
gcc -o test test.c // 默认情况下,是Partial RELRO
gcc -z norelro -o test test.c // 关闭,即No RELRO
gcc -z lazy -o test test.c // 部分开启,即Partial RELRO
gcc -z now -o test test.c // 全部开启
-z norelro / -z lazy / -z now (关闭 / 部分开启 / 完全开启)

5.fortify

fortify是轻微的检查,用于检查是否存在缓冲区溢出的错误。适用于程序采用大量的字符串或者内存操作函数,如:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
>>> memcpy():
描述:void *memcpy(void *str1, const void *str2, size_t n)
从存储区str2复制n个字符到存储区str1
参数:str1 -- 指向用于存储复制内容的目标数组,类型强制转换为 void* 指针
str2 -- 指向要复制的数据源,类型强制转换为 void* 指针
n -- 要被复制的字节数
返回值:该函数返回一个指向目标存储区 str1 的指针
-----------------------------------------------------------------------
>>> memset():
描述:void *memset(void *str, int c, size_t n)
复制字符 c(一个无符号字符)到参数 str 所指向的字符串的前 n 个字符
参数:str -- 指向要填充的内存块
c -- 要被设置的值。该值以 int 形式传递,但是函数在填充内存块时是使用该值的无符号字符形式
n -- 要被设置为该值的字节数
返回值:该值返回一个指向存储区 str 的指针
-----------------------------------------------------------------------
>>> strcpy():
描述:char *strcpy(char *dest, const char *src)
把 src 所指向的字符串复制到 dest,容易出现溢出
参数:dest -- 指向用于存储复制内容的目标数组
src -- 要复制的字符串
返回值:该函数返回一个指向最终的目标字符串 dest 的指针
-----------------------------------------------------------------------
>>> stpcpy():
描述:extern char *stpcpy(char *dest,char *src)
把src所指由NULL借宿的字符串复制到dest所指的数组中
说明:src和dest所指内存区域不可以重叠且dest必须有足够的空间来容纳src的字符串返回指向dest结尾处字符(NULL)的指针
返回值:
-----------------------------------------------------------------------
>>> strncpy():
描述:char *strncpy(char *dest, const char *src, size_t n)
把 src 所指向的字符串复制到 dest,最多复制 n 个字符。当 src 的长度小于 n 时,dest 的剩余部分将用空字节填充
参数:dest -- 指向用于存储复制内容的目标数组
src -- 要复制的字符串
n -- 要从源中复制的字符数
返回值:该函数返回最终复制的字符串
-----------------------------------------------------------------------
>>> strcat():
描述:char *strcat(char *dest, const char *src)
把 src 所指向的字符串追加到 dest 所指向的字符串的结尾
参数:dest -- 指向目标数组,该数组包含了一个 C 字符串,且足够容纳追加后的字符串
src -- 指向要追加的字符串,该字符串不会覆盖目标字符串
返回值:
-----------------------------------------------------------------------
>>> strncat():
描述:char *strncat(char *dest, const char *src, size_t n)
把 src 所指向的字符串追加到 dest 所指向的字符串的结尾,直到 n 字符长度为止
参数:dest -- 指向目标数组,该数组包含了一个 C 字符串,且足够容纳追加后的字符串,包括额外的空字符
src -- 要追加的字符串
n -- 要追加的最大字符数
返回值:该函数返回一个指向最终的目标字符串 dest 的指针
-----------------------------------------------------------------------
>>> sprintf():
描述:sprintf(format,arg1,arg2,arg++)
arg1、arg2、++ 参数将被插入到主字符串中的百分号(%)符号处。该函数是逐步执行的。在第一个 % 符号处,插入 arg1,在第二个 % 符号处,插入 arg2,依此类推
参数:format -- 必需。规定字符串以及如何格式化其中的变量
arg1 -- 必需。规定插到 format 字符串中第一个 % 符号处的参
arg2 -- 可选。规定插到 format 字符串中第二个 % 符号处的参数
arg++ -- 可选。规定插到 format 字符串中第三、四等等 % 符号处的参数
返回值:返回已格式化的字符串
-----------------------------------------------------------------------
>>> snprintf():
描述:int snprintf ( char * str, size_t size, const char * format, ... )
设将可变参数(...)按照 format 格式化成字符串,并将字符串复制到 str 中,size 为要写入的字符的最大数目,超过 size 会被截断
参数:str -- 目标字符串
size -- 拷贝字节数(Bytes)如果格式化后的字符串长度大于 size
format -- 格式化成字符串
返回值:如果格式化后的字符串长度小于等于 size,则会把字符串全部复制到 str 中,并给其后添加一个字符串结束符 \0。 如果格式化后的字符串长度大于 size,超过 size 的部分会被截断,只将其中的 (size-1) 个字符复制到 str 中,并给其后添加一个字符串结束符 \0,返回值为欲写入的字符串长度
-----------------------------------------------------------------------
>>> vsprintf():
描述:vsprintf(format,argarray)
sprintf() 不同,vsprintf() 中的参数位于数组中。数组元素将被插入到主字符串中的百分号(%)符号处。该函数是逐步执行的
参数:format -- 必需。规定字符串以及如何格式化其中的变量
argarray -- 必需。带有参数的一个数组,这些参数会被插到 format 字符串中的 % 符号处
返回值:以格式化字符串的形式返回数组值
-----------------------------------------------------------------------
>>> vsnprintf():
描述:int vsnprintf (char * s, size_t n, const char * format, va_list arg )
将格式化数据从可变参数列表写入大小缓冲区
如果在printf上使用格式,则使用相同的文本组成字符串,但使用由arg标识的变量参数列表中的元素而不是附加的函数参数,并将结果内容作为C字符串存储在s指向的缓冲区中 (以n为最大缓冲区容量来填充)。如果结果字符串的长度超过了n-1个字符,则剩余的字符将被丢弃并且不被存储,而是被计算为函数返回的值。在内部,函数从arg标识的列表中检索参数,就好像va_arg被使用了一样,因此arg的状态很可能被调用所改变。在任何情况下,arg都应该在调用之前的某个时刻由va_start初始化,并且在调用之后的某个时刻,预计会由va_end释放
参数:s -- 指向存储结果C字符串的缓冲区的指针,缓冲区应至少有n个字符的大小
n -- 在缓冲区中使用的最大字节数,生成的字符串的长度至多为n-1,为额外的终止空字符留下空,size_t是一个无符号整数类型
format -- 包含格式字符串的C字符串,其格式字符串与printf中的格式相同
arg -- 标识使用va_start初始化的变量参数列表的值
返回值:如果n足够大,则会写入的字符数,不包括终止空字符。如果发生编码错误,则返回负数。注意,只有当这个返回值是非负值且小于n时,字符串才被完全写入
-----------------------------------------------------------------------
>>> gets():
描述:char *gets(char *str)
从标准输入 stdin 读取一行,并把它存储在 str 所指向的字符串中。当读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定
参数:str -- 这是指向一个字符数组的指针,该数组存储了 C 字符串
返回值:如果成功,该函数返回 str。如果发生错误或者到达文件末尾时还未读取任何字符,则返回 NULL

GCC开启和关闭fortify:

1
2
gcc -D_FORTIFY_SOURCE=1  仅仅只在编译时进行检查(尤其是#include <string.h>这种文件头)
gcc -D_FORTIFY_SOURCE=2 程序执行时也会进行检查(如果检查到缓冲区溢出,就会终止程序)

在-D_FORTIFY_SOURCE=2时,通过对数组大小来判断替换strcpy、memcpy、memset等函数名,从而达到防止缓冲区溢出的作用

(最后这段直接复制了hollk师傅的博客内容)

到此我们讲完了ELF文件的常见保护。后续我们将从ret2backdoor开始讲解最最基础的PWN

参考文献:
栈保护机制-CSDN博客
linux elf保护机制_-no-pie-CSDN博客
Pwn的常见保护介绍_pwn题打开nx保护有什么用吗-CSDN博客
PWN入门(1-1-3)-Linux ELF文件保护机制 (yuque.com)


菜鸟笔记之PWN入门(1.1.3)Linux基础操作和ELF文件保护
http://example.com/2024/10/05/菜鸟笔记之PWN入门(1.1.3)Linux基础操作和ELF文件保护/
作者
XiDP
发布于
2024年10月5日
许可协议