强网杯以来就一直很想系统性的学习fuzzing技术,fuzzingbook可以说是fuzzing技术学习的圣经,但因为它全英文编写,且长度感人,因而很多人都望而止步。我将自己的学习经验分享给大家,希望能帮助大家更好的学习fuzzing技术。
fuzzing是啥?
在98年的一个暴雪天,Madison教授在使用电话线(是的,那时候都是用电话线传递信号)远程访问学校的电脑,但由于当时雷电交加,传输中有些数据产生了差错,导致很多命令行程序频繁发生错误,教授觉得程序猿干得太不行了,程序健壮性太差了,于是他就让学生开始研究程序测试的问题,希望能帮助程序猿写点靠谱的程序,最终在此基础上创造了fuzzing技术。
fuzzing的官方译名叫做模糊测试,顾名思义,它是用“模糊”的输入对程序进行测试,找到程序漏洞、错误的一种技术。所谓的模糊,其实就是输入的不确定。
举个例子,你可以打开你linux下的bc程序(这是一个数学表达式计算器,你输入数学报表达式,它会输出对应的结果),随便在键盘一顿狂按,然后大力敲击回车进行输入,bc十有八九会告诉你,你输入的不是有效的表达式,恭喜你,你完成了一次“模糊”的程序测试。最最最简单的fuzzing,就是自动化进行你刚才的操作。
当然,就这么随机生成字符串显然是不够“聪明”,你用这玩意找出你身边路由器漏洞点的概率无限接近于0,所以我们要用各种技术来不断完善我们的fuzzing程序,比如代码覆盖等等技术,这在后面的文章中我们会详细阐述。
fuzzing程序的结构
从上面的例子我们可以看出,fuzzing其实就是两部分构成:
- 随机敲打键盘生成输入,我们管干这活的伙计叫fuzzer
- 输入到别的程序,我们管干这活的伙计叫runner
好了,现在我们来写写这两个小东西,千万别往难了想,就实现我上面说的功能即可,我相信你只要会python,都能写的出来。
def fuzzer(max_length=100, char_start=32, char_range=32):
str_len = random.randrange(0, max_length + 1)
# 随机生成字符串长度
fuzzing = ""
for i in range(0, str_len):
fuzzing += chr(random.randrange(char_start, char_start + char_range))
return fuzzing
我们用random模块来进行随机数的生成。首先我们随机生成一个数用作字符串的length,然后就随机生成length个随机字符,拼起来就是随机生成的字符串了,返回即可。
我们设定函数有三个参数,分别是字符串的最大长度、字符的开始位置、字符的范围大小,这是为了我们能够一定程度上指定生成字符串的格式。比如,有些情况下我们希望只生成数字测试字符串,那我们就可以指定char_start = ord(‘0’)。
def runner(program,FILE)
result = subprocess.run([program, FILE],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True)
return result
对于runner来说,有两个参数,一个是目标程序的路径program,一个是存放输入数据的文件路径。因为我们需要将数据作为输入写到别的程序中,所以我们用到了 subprocess模块,他可以打开一个子程序,并指定程序的标准输入、标准输出、标准错误信息等参数。具体的参数大家可以查阅手册,这里就不展开介绍了。
函数最终返回的是程序的“状态”,我们可以利用result来查看程序是否发生了奔溃等问题
result.stdout
#程序的标准输出
result.stderr
#程序的标准错误输出
当然,runner需要我们有一个存放输入数据的文件,如果你用过fuzzing程序,比如peach、afl-fuzz等等,你应该会记得它们都有input、output两个文件夹,这俩其实存放的就是输入的数据和输出的程序状态,也就是我们上代码中的fuzzing、result两个变量,下面的代码就可以实现文件的存取功能
FILE = os.path.join(tempdir, basename)
# tempdir是目录名,basename是文件名
data = fuzzer()
# 使用fuzzer生成字符串
with open(FILE, "w")as f:
f.write(data)
# 将字符串保存在文件中
接下来就让我们来试试吧,我们用fuzzer生成的字符串来测试一下bc,我们就简单写一个循环调用fuzzer,不停输出result.stderr即可
parse error
illegal character: &
我省略了大部分错误信息,只选取了主要部分,可以看到程序报的错误主要就是解析错误、非法字符,很好理解,解析错误就是我们输入的字符串无法被当作表达式进行处理,而非法字符就是我们输入的字符压根不是数学上有的。但是要注意,虽然这是stderr,但是我们的程序并没有崩溃或者停止运行,这是“被程序猿预料到的”错误,这种错误说明程序编写是健壮的,我们之后提到的错误一般都是程序奔溃或者停止运行的错误,我们可以打印一下程序的返回值来确定程序是否正常。
print(result.returncode)
这代表程序的返回状态,它的值一直是0,说明程序正常结束。可以看到,bc程序在我们的简单测试中成功存活,恭喜这些程序猿不用被祭天。
如果你是要对自己的程序进行检测,那你还可以简单在程序编译时使用这样的指令:
clang -fsanitize=address -g -o program program.c
有了这条指令,程序在奔溃时会打印出详细的错误信息,比如堆栈信息、错误信息等,非常全面,我们可以利用这些信息进一步排查问题。当然如果你开了这个选项,运行的速度是必然要下降的,所以仅限测试期间使用,真正发布程序时可不要带这个选项。
好了,你现在可以到处宣传你自己写了一个fuzzing程序了,只不过它还是个lv1的史莱姆,不过不用担心,它会在我们后续的文章中慢慢进化,最终变成lv100的超级史莱姆。现在就让我们先来试试这个小东西能干点啥。
程序测试
上面我们完成了简单的fuzzing程序,但是我们还需要恶补一些软件测试相关的知识,这是我们未来构建fuzzer函数的重要支撑。我们就一边测试我们的史莱姆,一边进行学习
def my_sqrt(x):
"""Computes the square root of x, using the Newton-Raphson method"""
approx =None
guess = x / 2
while approx != guess:
approx = guess
guess = (approx + x / approx) / 2
return approx
这是fuzzingbook上给的一个函数,它使用牛顿法来计算给定x的平方根。你可以想象这是你舍友的面试题目,他写了上面的代码,但是提交了n遍都有样例无法通过,现在你要帮助他改改这个程序。
我们先用我们的fuzzing程序简单检查一下,为了简单,我们只生成最大长度为10的字符串:
for i in range(0,100):
data = fuzzer(max_length=10)
print(data)
print(my_sqrt(data))
你可以尝试一下,结果不堪设想,基本上没有能运行完的时候,直接就是报错报错报错,但是经过测试我们也知道,程序确实是充满问题,而且我们也可以通过发生错误的输入,来反推程序是哪里出了问题。下面就让我们来完善这个程序。
长度陷阱
在以往参加的自动化漏洞挖掘比赛中,我们往往都会先无脑发送一波超级无敌长的字符串,因为大多数程序并没有考虑输入的长度限制,如果你输入的字符串过大,必然会导致程序直接奔溃。所以我们往往会第一步先设置输入字符串的长度,比如这里我们就可以指定长度为8,如果输入的字符串的长度大于8,我们就直接将其舍弃掉。
corner数据
如果你打过acm或者参加过学校的oj测试,你一定会优先考虑:有没有一些特殊的数据现在的函数无法处理?这样的数据我们管它叫做corner,也就是经常注意不到的边缘数据。显然,对于我们的函数,0和负数就是边缘数据,这样的输入从逻辑上,它们不能求平方根;从代码上,它们会导致代码失控。所以,我们需要限制这样数据的输入,或者是对这样的数据进行单独处理
if x<0 :
print("这玩意不能求平方根")
return err_code
elif x == 0:
return 0
其中的错误码我们可以指定为-1,目的是让上层函数知道返回的值出错了即可。
当然,如果你的程序足够复杂,很可能会出现输入一个数迟迟算不出来结果的问题,这种情况我们也必须考虑在内,我们可以让程序函数有最大运行时间限制,一旦超过这个限制,你可以认为自己的程序处理不了这样的输入,进而返回错误信息。你甚至可以利用自己的错误信息,更新限制输入的最大数据,进而进行程序的简单自我完善。
非法输入
上面fuzzer生成的字符串,很多都是a、b这样的字母或者是其他字符,不是标准的数字,这样的输入就是不合法的,用户可不会管你程序支不支持,他们可是什么都敢往里放,所以我们必须进行处理,防止用户输入不合法的输入
if x.isdigit():
#our code
else:
print("你输入的什么玩意?")
逻辑错误
除了这些小错误,其实我们最常遇见的还是程序的逻辑错误,比如这个程序,相信你的同学是不会证明牛顿法到底能不能求出平方根的,所以这就需要我们用大量的数据进行检查,当然,从理论上来讲,再多的测试也不可能证明你的程序是正确的,但是我们可以通过测试说明程序在“大多数”情况下可以使用。
我们需要用到几乎所有语言都会提供的一个函数——assert。它也被叫做断言,效果就是检查它后面的表达式是否成立,比如:
assert my_sqrt(4) == 2
如果成立,什么事都不会发生,如果不成立则会报错。但是问题又来了,我们知道有一些数的平方根是小数,我们没法指定非常准确的数字,这又该怎么办呢?我们可以使用一个误差变量来进行检查,只要两个数的绝对值在误差范围内,就可以认为他俩是相等的。
def assertEquals(x, y, epsilon=1e-8):
assert abs(x - y) < epsilon
那么问题就又来了,我们总不能老是手动指定数字来进行检查吧?这样忙活一天都进行不了几组检查。这里就需要我们用到程序的性质了,比如这里我们的程序是求平方根,那么我们知道,平方根的平方应该就是等于原来的数,我们可以根据这个性质来自动检查。当然这是因为这个程序比较简单,所以我们可以很容易想到这一步,往后我们要进行程序测试时,这一步往往是最难的。
assertEquals(x,x*x)
fuzzing测试
好了,有了上面的修改,我们再次尝试使用fuzzing进行测试
for i in range(0,1000):
data = fuzzer(max_length=10)
x = my_sqrt(data)
assertEquals(x,x*x)
你可以看到程序已经不会奔溃了,大多数错误情况他也会输出错误信息,程序的健壮性可以说是大大提高了。恭喜你的同学暂时合格了。
软件安全
上面fuzzing技术实现软件测试,为程序猿提供了程序修改的思路,这次我们摇身一变,化身安全研究猿,再来看看fuzzing在程序安全方面发挥的重要作用。为了方便使用,我们下面不会使用runner类进行测试,我们会通过python写几个简单的小程序,直接调用fuzzer进行测试
缓冲区溢出
这应该是二进制选手最熟悉的一种漏洞,常见的有栈溢出、堆溢出等等,堆溢出的level太高,别说是“傻乎乎”的fuzzer,就是CTFer去做都不好搞,而且堆溢出本身和fuzzer的“八字不合”,fuzzer一般是通过程序出错、奔溃来反映程序出现了问题,但是堆溢出往往是构造复杂的堆结构,通过溢出修改堆块信息进而拿到shell,利用一般的fuzzer是很难对堆溢出进行漏洞挖掘的,我们之后文章中会再度提到这个问题,此处我们就以栈溢出和一个堆分配空间过大的问题为例。
def stackOverFlow(str):
buffer = "1111111111"
if len(str) > len(buffer):
print("栈溢出了兄弟!")
很显然如果我们的str长度超过了10,就会触发栈溢出问题,一旦程序发生了奔溃,我们就可以考虑程序是否出现了栈溢出的问题,从而进一步构造payload进行利用。
当然在c语言程序中,由于栈的构造(在我们的局部变量之前还保存了ebp、返回地址、函数参数等信息),有时候我们虽然是触发了栈溢出,但是由于溢出的东西比较少,程序还是能“坚强”的完成运行,但考虑到fuzzing进行数不清的数据测试,这个问题不会对我们的fuzzing产生影响。
cin>>size;
int * p = (int *)malloc(size);
这个错误相信大家都可以看出来,攻击者只要输入一个足够大的数,程序就算是当场完蛋了。
信息泄漏
这其实和上面软件测试中提到的非法输入有些相似,都是因为输入的不合法导致了一些问题,我们常常可以看见舍友们半夜敲oj写程序时敢于“放飞自我”,写出这样大逆不道,让安全研究猿吐血三升的程序:
cin>>index;
cout<<arr[index];
这样简单的两行代码就有着“卧龙凤雏”两大错误:
- 数组其实就是指针的语法糖,只是为了让使用者易于理解、便于使用,实际上在底层,下面两种结果是完全等价的
arr[index] *(arr+index)
所以你的写法是让使用者获得了一个自由的指针,它约等于随便访问的权利,会泄漏你的各种数据
- 如果输入的东西引发了指针越界等问题,会导致程序直接奔溃。
完整性缺失
很多时候我们在进行漏洞利用时,都需要构造特殊的payload,比如说我们会输入一些地址甚至是shellcode,如果程序没有完整性检查,就会导致我们的程序对于这些非法输入视而不见,相反,良好的完整性检查能让漏洞利用的难度直线上升。这里的完整性是对数据的特殊规定,和上面提过的非法输入还不太一样,比如程序需要输入一个电话号码,输入字符串就是违背了上面的非法输入原则,但输入的数字我们还可以进一步做约束,比如电话开头必须是1,再比如号码必须是11位等等。
def check(str):
assert str.len() == 11
assert str[1] == '1'
总结
在这篇文章中我们简单实现了自己的fuzzing,并用它测试了一些程序,算是初步了解了fuzzing是个什么东西。但就像前文中说的,现在它还只是一个lv1的史莱姆,这一篇中我们也算是打了不少小怪,下一篇中也该让它升级了,让它的fuzzer变得更加“聪明”。