Fuzzingbook学习指南Lv5

阅读量327102

|

发布时间 : 2021-05-19 10:00:05

 

我们之前的fuzzing往往考虑的都是一个字符串,绞尽脑汁让这个字符串的字符发生变化进而提高某些指标,我们这次把段位上升,不在局限于怎么“美化”句子,而是如何“定义”句子。

还记得最初我们用的bc吗?如果你一味使用Lv4之前的技术,那你其实永远都是在“乱搞”,因为它其实有一套自己的逻辑,如果找到了逻辑你可以绝杀,就像是玄幻小说,掌握规则的人永远比普通修炼的牛,我们也要让我们的史莱姆掌控法则。

 

语法

我们说话写字都是有一定规则的,编写程序是这样,写数学公式也是这样,现在我们来想想到底是什么“限制”了我们呢?

  • 首先当然是范围,你也可以理解为集合当中说的定义域,简单说就是规定哪些符号你能用,哪些不能用,以只包含加减乘除的小学数学表达式为例,我们的定义域就限制在了加减乘除四个符号以及0~9十个数字罢了,你用了a你就是错了,就不是一个合格的表达式了。
  • 其次我们可以想到,是一组规则让我们不能乱写。小学数学表达式可不会出现1++这种奇奇怪怪的式子,这就说明背后有一套规则约束,而这套规则还应该是支持无限写下去的,因为按道理来说,你可以写1+1+1+···一直写下去。

如果让我们用一套规范点的方式去概括这两个方面呢?范围我们很好解决,我们可以用正则表达式来检测,不符合规则淘汰就是了,第二点我们该如何做呢?其实我们大学就学过这个问题,这其实就是个有限状态机。

所谓有限状态机,其实就是一组“状态”,它们之间互相能够进行转换。比如Linux的进程状态,那有人就要问了,你刚说了1+1+1+能无限写下出,怎么这会反而有限了呢?其实有限说的是状态是有限的,比如Linux的进程状态,满打满算就五个,但是一个进程你完全可以无限次的经历这些状态。

这两点就组成了我们的语法,我们只需要按照一定的规则,就可以轻松写出对应的语法,比如

<start> ::= <digit><digit>
<digit> ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

首先是个start标签,标志着开始,::=这个符号表示前者可以由后者代替,而|就代表or的意思,所以上面的语句就代表,以两个数字开头,后面啥也没有,其实就是我们需要写两个0-9的数,比如00,比如12。

那你就要问了,我要是写1000个数你咋整啊,我总不能写1000个<digit>吧?其实也很简单,加一句话就完事了

<start>  ::= <integer>
<integer> ::= <digit> | <digit><integer>
<digit>   ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

我们引入了递归的概念来处理这个问题,用递归达到无限循环的目的。直接来看,首先我们以integer开头,而integer可以是数字,也可以是数字然后再来个integer,这就可以一直递归下去了。比如我写1,可以吗?可以,integer=digit=1即可;写23,可以吗?可以,integer=3iteger,再处理第二个integer=3即可,就得到integer = 23,这样我们就轻松实现了无限的概念。

那又有问题了,我如何表达我的数有正负呢?也简单,在状态转换的位置加入限制即可,如下

<start>  ::= <number>
<number> ::= <integer> | +<integer> | -<integer>
<integer> ::= <digit> | <digit><integer>
<digit>   ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

假设我们输入的是-3,那么就是number=-integer,integer=3,number=-3,完毕。

有了上面几个例子,相信你已经会利用“解包”的思想读这些语法了,我们每次读时,如果有还不清楚的位置,那就先带着标签,然后一步步把标签给去了,这就是解包的思想,我们不仅仅读需要这种思想,写代码实现也要如此。下面我们就尝试写代码,让程序按照我们定的规则来说话。

DIGIT_GRAMMAR = {
    "<start>":
        ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
}

我们用字典存储标签即可,而对于生成的过程如下:

  term = start_symbol
  expansion_trials = 0  
  while len(nonterminals(term)) > 0:
        symbol = random.choice(nonterminals(term))
        expansions = grammar[symbol]
        expansion = random.choice(expansions)
        new_term = term.replace(symbol, expansion, 1)

        if len(nonterminals(new_term)) < max_nonterminals:
            term = new_term
            if log:
                print("%-40s" % (symbol + " -> " + expansion), term)
            expansion_trials = 0
        else:
            expansion_trials += 1
            if expansion_trials >= max_expansion_trials:
                raise ExpansionError("Cannot expand " + repr(term))

nonterminals函数用来找非终端的标签,对于能够继续产生标签的标签,我们叫它非终端,比如start;对于只能产生最终结果不能继续生成标签的标签,就是终端,比如digit。

因为我们是一步步解包,所以首先我们得有包,他其实就是在检查当前的term是不是有包,如果有包的话,就在包里随机找一个,然后再从包中找一个标签,我们就到了新的表达式,当然,为了这个过程不会无限循环下去,我们人为定义了循环的最大值,让他不会无限循环。

这样说可能有点抽象,我们举个例子,就用我们的正负数来说:

  • nonterminals找到非终端的值,一开始就是number(节省篇幅,跳了start标签)
  • 随机选一个作为expansions ,这里只有number
  • 随机在number对应的里面选一个,假设选到了+<integer>
  • 那么新标签现在就是+<integer>
  • 找所有非终端的值,只有<integer>
  • 随机选,只能选<integer>
  • 随机在integer对应的里面选一个,假设选中了<digit><integer>
  • 那么新标签现在是+<digit><integer>
  • 重复….

如此,我们就可以生成我们语法要求格式的字符串,同时我们也可以直观看到,如果不限制循环的次数,他完全可以-1111···这样一直生成下去。解包的思想在这里发挥了重要作用。在Fuzzingbook还给了很多例子和如何将这种语法封装为工具箱,因为篇幅限制就不再是详述了,大家可以自行参考。

看了上面的代码我们又会发现一些问题:

  • 我们的迭代有点”傻“,我们要遍历字符串,随机抽取,随机生成,这个过程过于繁琐,直接影响效率。而且实际上我们是无法从已有的生成记录中再去进行“学习”的,它的遍历过程没有留下有效的结构信息。
  • 我们控制不住它,即使是我们限制了迭代次数,在迭代范围内它也是过于随机了,导致生成的结果我们能插手的地方很少,我们没有办法以合适的粒度对其进行限制。而一不小心,它还经常生成长字符串。

对于第二个问题,我们还可以想办法进行改善,比如,我们限制标签的最大深度和最小深度,超过或者小于这个深度时,标签就不再出现,比如:

"<expr>":
        [("<term> + <expr>", opts(min_depth=10)),
         ("<term> - <expr>", opts(max_depth=2)),
         "<term>"]

但这样的解决方案能力有限,因为这样操作相当于我们人为限制了语法的“值域”,也就是说输出的结果范围被限制了,我们可能无法生成所需的fuzzing字符串。而且,我们永远没有探索最短、最长等特殊表达式的能力,比如1+1,这样的表达式你就只能等着它自己慢慢生成。

而对于第一个问题,因为我们使用简单的字符产组织,结构如此,永远没办法改善,只能另谋他路。

 

语法树

在算法问题中,我们常常一言不合就用树,因为树状结构简单清晰,还弥补了很多数据结构的缺点(虽然很多优点它也没继承),那么我们在这个问题上能不能使用树结构呢?我们简单思考:

  • 有限状态机的结构与树非常契合,我们可以认为树的当前的节点就是一个状态,而这个状态的下一个状态,我们可以用该节点的子节点表示,有几个就挂几个子节点,可以一直继续下去。也就是说,节点的种类有限,节点的数量无限。同样用在我们的语法规则上也适用,我们生成的标签就可以作为节点,我们可以按照规则选择接下来的标签挂到节点上。
  • 我们语法规则是马尔科夫链(当一个随机过程在给定现在状态及所有过去状态情况下,其未来状态的条件概率分布仅依赖于当前状态。但要注意,有限状态机本身不能算是马尔可夫链,因为实际上有限状态机的状态变换时一定的,没有概率一说,但我们生成字符串的规则却是带有随机性的)的一种特殊情况,它的每次状态变化都只依赖于当前状态,和之前的都没关系,所以对于树结构,我们只需要看当前的叶子节点即可,不需要关心前面节点的情况。

理论上的东西太抽象了,我们来看个例子,我们给出如下的语法规则(实际上就是四则运算规则):

我们按照上面的思路试试能不能建树:

  • start节点作为根节点
  • start节点可以转移到expr节点,挂上expr节点
  • expr节点有三个包,随机选一个挂上,比如expr + term包
  • 现在我们有三个节点,其中,+不是非终端节点,它的使命已经完成,我们对其他两个节点重复操作
  • 重复直到所有的叶子节点都是终端节点即可。

如此,我们得到了2+2这个数学表达式。这个东西我们管它叫派生树,也可以叫语法树。我们看看它解决上面的问题了吗?

  • 时间上,我们遍历时不需要考虑整棵树,只需要检查叶子节点即可,效率大大提高
  • 在控制方面,我们对于每一个叶子节点在遍历过程中都是可控的,我们可以人为在任何一步设置限定,不但不影响性能,编写程序也方便。

并且,我们可以看到,树结构还为我们提供了清晰的结构,让我们有了对于语法的“具体格式”,上面一章虽然也有结构信息保留下来,但结构信息杂乱,难以提取有效格式,而树结构天然就可供我们使用。我们可以进行大量的测试,我们手里有了大量树结构及其对应的代码覆盖等指标,我们完全可以选择其中优质的树结构进行相似度比较,从而提取更加优秀的树结构供我们使用。

比如bc人为限制不能输入+,如果我们按照普通的四则运算语法显然会导致大量的失效,但我们可以从有效的树中进行相似度比较,提取相似部分,这样我们就能慢慢去接近这套规则。因为树结构的存在,我们完全可以自由使用前面的知识,“养蛊”式的优化我们的树。

说完了理论,来简单看看代码实现,对于树结构,我们在python中有一套经典的保存方案:

derivation_tree = ("<start>",
                   [None])

即用二元组表示一棵树,其中,第一个表示节点,第二个是一个list,表示孩子。这套方案最大的问题是没法解决边的问题,但是我们现在对边也没什么讲究。

class GrammarFuzzer(GrammarFuzzer):
    def choose_node_expansion(self, node, possible_children):
        return random.randrange(0, len(possible_children))

    def expand_node_randomly(self, node):
        (symbol, children) = node
        assert children is None
        expansions = self.grammar[symbol]
        possible_children = [self.expansion_to_children(
            expansion) for expansion in expansions]

        index = self.choose_node_expansion(node, possible_children)
        chosen_children = possible_children[index]

        chosen_children = self.process_chosen_children(chosen_children,
                                                       expansions[index])
        return (symbol, chosen_children)

对于节点的扩充,其实和之前类似,都是先查语法,找到当前标签的包,随机选择一个,然后把包里所有的标签挂到节点上即可。到此,我们已经完全可以利用语法树来为我们生成fuzzing字符串了。

但是我们还不满意,我们说了树结构能帮助我们控制生成的过程,那应该怎么做呢?我们一般会定义如下的概念:

  • 节点最小代价,即节点到终端节点需要消耗的最小“步数”,比如<expr><term><factor><integer><digit> → 1,expr走到终端节点最短路径如此,故其最小代价是5
  • 节点最大代价,即节点到终端节点最大需要的步数,对于有递归的节点来说,就是无限大,对于digit来说,就是1

这两个概念可以帮助我们限制节点的走向,比如,我们可以规定,每一个节点都必须以最小的代价来进行生成(代码非常简单,我们检查节点所有可能的孩子的代价,对于包的情况,就是包内所有节点的代价的和,选择最小的挂上去即可),那么我们就会得到

我们就得到了一棵相对简单的树,它的表达式长度也让人比较满意。我们同样也可以按照最大代价进行生成,我们限制递归次数即可,如此我们就可以得到较长的表达式。但是这些会导致“僵化”问题,即路径固定,比如如果我们的最短路径只有上面一条,导致我们一直走,那我们生成的格式就永远是x+y/z了

我们目前实际上有了三种节点策略:

  • 最小代价生成,即找最小代价的,会造成“僵化”
  • 最大代价生成,即找最大代价的,在递归较多的语法中,“僵化”可能性较小
  • 随机找一个,不会“僵化”

实际使用中,我们可以对每个节点采取不同的策略,比如有的采取最小,有的采取随机,有的采取最大,然后再人为限制递归次数,我们就即可以保证生成字符串多种多样,得到较为理想的结果了。

 

总结

我们从没头没脑的随机生成fuzzing字符串到现在终于可以利用语法“随心所欲”的生成符合我们要求格式的字符串,但在语法方面,仍有大量的知识等着我们学习,下一篇内容仍将聚焦于语法部分,期待我们的史莱姆再次升级。

本文由Asa9ao原创发布

转载,请参考转载声明,注明出处: https://www.anquanke.com/post/id/240052

安全客 - 有思想的安全新媒体

分享到:微信
+10赞
收藏
Asa9ao
分享到:微信

发表评论

内容需知
合作单位
  • 安全客
  • 安全客
Copyright © 北京奇虎科技有限公司 三六零数字安全科技集团有限公司 安全客 All Rights Reserved 京ICP备08010314号-66