前言
实在是非常想再开一次入门课,因为有一个自己觉得还挺巧妙的小想法:我能不能写一个C程序,它不调用后门函数,而是我自己用栈溢出去调用完成getshell。我想从开发的角度,而非从计算机的底层来理解我自己学习到的第一个漏洞,栈溢出。
出于初次教学,我们跳过非常多的细节。我认为在第一课中不需要讲的事无巨细。比如我会跳过32位和64位的区别,直接让你用p64()
来完成你的第一次的脚本编写,并且我也不会在本文中详细解释为什么要这么做。就像如果让我讲C语言第一课我可能会直接让你用printf输出个"hello world",并不会详细讲解printf的各种用法,也不会讲解C语言的函数是如何编写的,更不会讲解调用函数时候的压栈出栈。这些对于初学者来说太早了,因此本文仅会从现象上入手讲解这个漏洞。
本文是我在2025年俱乐部为新生讲解pwn的第一课的内容,用作(直播的时候还没写完这篇,临场发挥的时候没有打磨细节😭😭😭)如果本文有幸能成为你的入门读物,希望本文能让你窥见pwn世界的一角
pwn第一课:从C到栈溢出
首先,必须要先简单讲一讲C语言的语法。
已经掌握C语言基础语法的直接跳
大概自行看看入门的书或者视频,看到函数和数组也就差不多了。能学明白指针就最好了。相关内容就不写在这里啦!
那么,C程序里面的栈溢出长什么样?
也许你刚刚才恶补完C的语法知识,也或许你之前也写过C语言的程序,也听说过栈溢出,但是你相关栈溢出如何利用吗?
先看这样的一个程序
#include <stdio.h>
#include <stdlib.h>void wdf(){system("/bin/sh");return;
}void func(){char str[4] = "def";printf("%s\n",str);return;
}int main(void)
{char str[4] = "abc";func();printf("%s\n",str);return 0;
}
它写的很简单,先看main
函数,它简单声明了一个变量str
,然后将其输出,然后调用func()
函数。func
函数也做了一样的事情。唯一显得有点奇怪的是,有个多余的函数wdf()
没有起到任何作用。我们直接编译运行,程序正常输出,没有任何问题。
然后我们考虑以下程序:
#include <stdio.h>
#include <stdlib.h>void wdf(){system("/bin/sh");return;
}void func(){char str[4] = "def";printf("%s\n",str);str[4] = 'g';str[5] = 'h';str[6] = 'i';str[7] = 'j';str[8] = 'k';str[9] = 'l';str[10] = 'm';str[11] = 'n';str[12] = 'o';str[13] = 'p';str[14] = 'q';str[15] = 'r';str[16] = 's';str[17] = 't';str[18] = 'u';str[19] = 'v';str[20] = 'w';return;
}int main(void)
{char str[4] = "abc";func();printf("%s\n",str);return 0;
}
编译运行,oh no 栈溢出了!pwn手看到Segmentation fault (core dumped)
就像 xx闻到了xx一样敏感,这就是栈溢出,它让程序崩溃了。
(编译指令:
gcc -m32 -fno-stack-protector -no-pie stackoverflow2.c -o stackoverflow2
)
诶,我们不是在打ctf吗,我flag呢?看来pwn并不是这么简单啊,我们还没有拿到flag!
分析一下发生了什么吧!看起来,main
函数中的printf函数并没有执行啊!像是执行完func
之后,程序马上就崩溃了!
我们用gdb
这个工具进行一下分析,程序到底是怎么崩溃的呢?
这里就要讲解一下gdb这个工具了,gdb是一个用于调试程序的工具,它可以让你看到程序每一条的指令,以及程序运行时候内存上面的数据,是用于找出漏洞和利用漏洞的神器!它显示的程序指令是用汇编编写的。后续需要认真的学习一下汇编,至少要能做到能看懂。作为第一课,我们还不用太关注那些汇编,我们能看懂内存上发生了什么就行
在func
处打断点(b func
)然后运行(r
),在程序运行的时候逐步调试(ni
)
汇编太难啦,但是对照一下我们前面的代码,大致对照一下,应该也能看懂个7788吧?
我们发现,程序是在这里崩溃的!
那么0x76757473
是什么呢?可以自行对照一下前面正常运行的程序的调试结果,这里正常的结果应该是“运行完func
函数之后,返回到main
函数”才对,可是现在变成了一个奇怪的东西。这个奇怪的东西是什么呢?
我们可以盲猜一手,这个地址就是我们刚才塞的那一大坨字母表中的几个字母。诶🤓昨天misc不是讲了ascii码
吗!非常凑巧的是,我们进行一个查,发现这些字母刚好是"stuv"。可以自行更换一下这几个变量的值,比如把
str[16] = 's';str[17] = 't';str[18] = 'u';str[19] = 'v';
换成
str[16] = 'r';str[17] = 'o';str[18] = 'c';str[19] = 'k';
对应的ret的地址也会变成rock的ascii码,这里自行尝试,就不演示了。
那么接下来,我们怎么利用这个奇妙的现象呢?注意到,我们有一个wdf()
函数还未使用。而我们通过gdb发现,func函数结尾的ret原本想完成的操作是“返回到main函数执行func函数的下一个地址”
那么如果我们把这个地址篡改为wdf
函数的地址,是不是就可以执行wdf
了呢?
至于wdf的地址我们用一个简单的方法,在gdb里面b wdf
就可以看到wdf的地址。
注意,由于每个人环境都极有可能不一样,所以后面的程序大家需要自己在自己的环境里面去找地址,以及对应的字符串中的位置也很有可能不一样(我最开始的位置偏移也和前面示范的不一样)直接抄我的代码是不一定能够完成这个奇妙小魔法的。
找到wdf的地址,然后给它写到那几个变量里面。我这里换成最开始我产生这个想法的时候写的程序进行示范(因为它里面有几个字节刚好是可见的ascii,这样写更装逼😎)
#include <stdio.h>
#include <stdlib.h>void wdf(){system("/bin/sh");return;
}void func(){char str[4] = "def";printf("%s\n",str);str[12] = '^';str[13] = 17;str[14] = '@';return;
}int main(void)
{char str[4] = "abc";printf("%s\n",str);func();return 0;
}
//gcc -fno-stack-protector -no-pie stackoverflow1.c -o stackoverflow1
编译运行这个程序:
我们真的做到了!在不调用某个函数的时候调用某个函数,而这就是从C语言到pwn的很重要的一步,我们了解了一个漏洞,叫做 “栈溢出” 而我们利用了这个漏洞,这个漏洞利用方式叫ROP,Return-Oriented Programming 的简写,翻译一下就是面向返回编程,而这个漏洞属于二进制漏洞
,也就是我们的pwn
。欢迎来到pwn的世界!
我懂了!但是很可惜,接下来的才是入门的内容
但是这里我还是要很抱歉的告诉大家,虽然我们已经在初步使用一个漏洞了,到这里我们可能还称不上入门,pwn题并不是自己写一个有漏洞的程序,而是攻击有漏洞的程序。我们要在没有源代码的情况下完成对别人程序的攻击利用,具体我们应该怎么操作呢?来看一道题目就明白!
首先你需要准备的工具有:
- IDA
- Linux虚拟机
- gdb & pwndbg
- python运行环境
- 安装pwn库
- gcc
以buuctf
上面的jarvisoj_level0
这题为例,先尝试运行,然后ida打开看一下代码。
我们可以看到,程序的逻辑很简单,然后有一个明显的栈溢出:明明只有buf[128]
但是却读入了512大小的输入。并且还有一个callsystem
函数那么我们考虑刚才的手法,我们要怎么用刚才的手法完成对这个程序的攻击呢?
回忆一下,刚才我们是往"变量合法长度"后的空间进行赋值,那么我们在进行输入的时候向128后的空间进行写入,是否也能做到呢?总之,我们先尝试拿一个超长的输入,然后使用gdb看一下!
像前面那样打断点,然后在程序输入时,使用cyclic 200
生成一个有规律的超长的字符串,复制然后输入进程序。
果然,程序又返回到奇怪的东西上了!使用cylic -l
来查看这个值是在字符串的哪个地方:
那么再简单看一下callsystem
函数的地址,我们就可以开始着手编写漏洞利用(exp)了!
当然,我们利用这个漏洞不能只是简单的输入。要向程序内覆写地址,很大概率不能直接写入。因为我们使用的输入函数会把我们的输入当做字符。而字符又只能被转为一个数字才能被存储在内存上。比如,当我们往内存写字母'a'时,在内存上的实际表示是97,16进制就是0x61。而反过来,如果我想往内存覆盖一个0x61616161
的地址,那我们直接输入aaaa就可以完成覆盖了。但是如果我们要输入0x00000000
这种地址,我们就不能通过键盘输入了,因为字符'0'会被转为数字48。这个转换规则是前面讲过的ascii码的内容,可以再度温习一下。总之,如果我们想要覆写一个16进制的地址在内存上,手工输入是几乎做不到的。
所以我们可以写一个脚本来与程序交互,我们打不出来的字符可以用脚本来表示。python其实比C简单很多,不会也没有关系,边学边用吧!
稍微教学一下基本的py
一开始编写出来的exp差不多长这样:
from pwn import*
p = process('./level0')
payload = b'a' * 136 + p64(0x400596)
p.sendline(payload)
p.interactive()
- 第一行和C的include类似,是用于引入库的,这里就是引入pwn库
- 第二行用于运行程序,并定义变量
p
来进行标识 - 第三行是定义了一个变量,里面的内容是136个字符'a'和我们找到的地址。我们需要用p64()这个函数来帮助我们把这个地址转换为用于覆写在内存上的地址。
然后我们尝试运行一下我们的exp:
我们好像利用失败了?似乎并不如我们所愿,没有成功运行/bin/sh
最好在前面补充一点Linux的基础知识,比如使用啊,shell是什么啊之类的,临场的时候我在这里补充了一下/bin/sh是什么
好吧,我们向代码中添加gdb.attach(p)
,这样我们就可以在运行脚本的时候同时把gdb调出来,检查问题所在了!
from pwn import*
p = process('./level0')
gdb.attach(p)
payload = b'a' * 136 + p64(0x400596)
p.sendline(payload)
p.interactive()
拉起gdb,直接使用c
,看看有没有报错
发现程序直接卡这里动不了了!绿色的这行是当前运行到的指令,我们直接搜索这一行,发现网上就有现成的解决方案了。自己搜一下吧,不放截图啦
现阶段而言,学pwn就是这样,在各种平台找各种资料,自学成王
现在我们要做的就是在程序里面找一个ret指令,那么最后出场的就是ida啦!我们可以用ida打开这个程序,按下F5或者tab,就可以看到代码的反编译了,语法就是熟悉的C语言啦
左边就是函数的列表,我们也可以在这里(现阶段你也会经常在这里)找函数和各种指令的地址。我们按F5或者Tab回到那个晦涩的汇编界面,右键,选择Text View
,然后找一个ret指令的地址就可以啦!
然后根据新学到的东西,修改exp,成功获取flag!
好啦,现在欢迎你来到pwn的世界!到这里可能你还是很懵懂,比如栈是什么,gdb界面上那些东西是什么,嘿嘿,继续往下学,在靶场中悟道吧!