首页
社区
课程
招聘
[翻译]写一个简单的fuzzer - part 5
2022-8-4 11:32 13875

[翻译]写一个简单的fuzzer - part 5

2022-8-4 11:32
13875

距离上一次自构建fuzz已经过去很久了;由于个人时间原因,我暂停了自构建fuzz的工作;当时的情况是我的fuzz已经能完成部分工作了,这样的fuzz让我没有继续优化下去的动力;我并不认为我完成了自构建fuzz的工作,当这个fuzz工作时,我发现了不少Bug,这使得我需要投入更多的精力去修改。
使用的Python语言也是原因之一,刚开始的时候非常容易,但到后来,优化变得越来越难;于是我做出了一个重要的决定,重构代码!我东拼西凑了不少时间,终于我用Rust语言重构了我的fuzz,我将其命名为rfuss2
我选择Rust的原因有很多;首先,Rust是一门很新的语言很酷的语言(至少我是这么认为的),然而,在学习了这门语言之后,我愈发喜欢这门语言了——它很金属(译者:我也不知道这个该怎么翻译……);我可以使用Rust来实现任何我想实现的东西,并且语法和C完全不像,我也不需要进行指针操作;总的来说,你可以用Rust来实现一个快速且高效的系统代码。

系统编程

几个月前我写了一篇关于Rust系统编程的短文;现在回看起来让我感到有一丝尴尬,我希望至少在这个项目里能纠正我所有的低效率代码。首先,我尽量不在没有明确需要的情况下使用模式匹配指令;第二,尽可能的使用我之前提到过的nix crates,此外还有一个用于解析命令行参数的clap。最后,在标准库中也有许多有用的元素,比如之前我们用来追踪唯一的痕迹的BTreedSet集合。

目标实践

主要原因是我们需要一个易受攻击的程序来作为我们fuzzer的测试平台。所以C语言是最好的选择。
主要的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void do_comparison(char* data) {
  if (data[0] == 'A') {
    if (data[1] == 'B') {
      if (data[2] == 'C') {
        if (data[3] == 'D') {
          if (data[4] == 'E') {
            if (data[5] == 'F') {
              // This is going to crash
              char* crash = 0;
              crash[0] = 'X';
            }
          }
        }
      }
    }
  }
}

这段代码很简单,当我们给这个函数提供一个指向字符串ABCDEF的指针时,程序就会崩溃,因为程序试图向地址0写值。之所以使用单独的嵌套的if语句来检查,是因为程序编译后,每个检查都会在单独的块中发生,这样很容易就能对其进行检测
这样的代码可能会让某些人感到奇怪,因为这种写法并不常见。事实上编译器更倾向于以这种方式实现解析和识别某些关键词;在《Crafting Interpreters》一书中有一章讲的是如何解析关键字,许多真正的语言解析器都在使用嵌套的开关语句,这使得它们非常适合于模糊处理和增加覆盖率。
尽管如此,我还是认为,为了真正的理解以及测试fuzzer,你需要针对目标提供尽可能少而精确的样本来运行;当你拿到一个程序,没有源代码的情况下需要进行黑盒测试,只是因为你对目标的了解不够多或者是因为变异策略存在一些小缺陷,从而引发错误的崩溃,然而事实上出现的崩溃并不能算是漏洞。

主循环

如果你阅读过本系列教程,那么你应该对程序的主循环很熟悉

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
while go {
    for mut sample in &mut mutator {
        // Save sample
        sample.materialize_sample(FILE);
        let child_pid = child::run_child(&runtime_config, &mut bp_mapping, FILE);
 
        stats.fuzz_cases += 1;
        match parent::run_parent(child_pid, &bp_mapping) {
            parent::ParentStatus::Clean(trace) => {
                sample.add_trace(trace);
                sample_pool.push(sample);
            }
 
            parent::ParentStatus::Crash(rip) => {
                stats.crashes += 1;
                println!("[!] Crash for input {:?}", sample);
                let crash_filename = format!("crash_{}", rip);
                fs::copy(FILE, crash_filename)
                .expect("Failed to save crash file");
                go = false;
            }
        }
    }
 
    // Send back all the sample with traces to the mutator
    mutator.update(&sample_pool);
}

正如之前那样,我们生成了一个父进程和一个子进程;子进程运行 ptrace::traceme(),而父进程则负责监视子进程的行为。
我运行子程序的主要区别是使用了更多的命令功能

1
2
3
4
5
6
7
8
9
10
11
12
let child = unsafe { Command::new(&config.prog_name)
    .arg(filename)
    .stdout(Stdio::null())
    .stderr(Stdio::null())
    .pre_exec(|| {
        ptrace::traceme().expect("Process doesn't want to be traced ...");
        personality(linux_personality::ADDR_NO_RANDOMIZE).unwrap();
        Ok(())
    })
    .spawn()
    .expect("[!] Failed to run process")
};

首先,我们在pre_exec()函数中将traceme()和personality syscall拼凑起来;还有就是我们不是直接调用fork()并通过exec()替换子进程内的运行代码,而是将这一功能放入spawn()调用中,它只是创建一个我们想要的新进程。
run_parent()函数内的代码我并没有完成,因为我认为以fuzzer当前的状态没有必要使用它
在子进程向父进程发送了停止信号时我们需要检查其原因;如果是SIGTRAP,表示遇到了断点,我们会更新追踪信息并继续执行;然而,如果程序因为段错误而停止(段错误的信号是SIGSEGV),我们将记录崩溃信息并将其传回给fuzzer引擎;当子进程执行结束后,我们会将记录的跟踪信息返回给fuzzer引擎。

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
pub fn run_parent(pid: Pid, mapping: &HashMap<u64, i64>) -> ParentStatus {
    let mut trace: Vec<u64> = vec!();
 
    loop {
        match waitpid(pid, None) {
            Ok(WaitStatus::Stopped(pid_t, signal)) => {
                match signal {
                    Signal::SIGTRAP => {
                        handle_sigstop(pid, mapping, &mut trace);
                    }
 
                    Signal::SIGSEGV => {
                        let regs = ptrace::getregs(pid_t).unwrap();
                        return ParentStatus::Crash(regs.rip)
                    }
                    _ => {
                        println!("Some other signal - {}", signal);
                        let regs = ptrace::getregs(pid).unwrap();
                        println!("Error at 0x{:x}", regs.rip - 1);
                        break
                    }
                }
            },
            // Code here skipped for readability reasons
        }
 
  return ParentStatus::Clean(trace);

变异引擎

变异引擎和之前有很大的不同,它现在由两个结构组成,Sample和Mutator;先来看第一个结构

1
2
3
4
5
6
pub struct Sample {
    version: u32,
    data: Vec<u8>,
    method: MutationMethod,
    trace: Option<Vec<u64>>,
}

每个Sample以字节数组的形式保存数据;它既可以变成一个文件,也可以作为参数传递给一个程序,还可以使用网络发送;version记录给定样本有多少成功的变异;method保存发生的最新变异类型;不过就目前的变异引擎而言,version和method这两个字段并没有实际意义;如此我们便可以在被程序执行的样本运行时,追踪样本访问的块和地址
变异器拥有所有被传递给程序的样本

1
2
3
4
5
6
7
8
9
10
pub struct Mutator {
    corpus:    Vec<Sample>, // corpus
    samples:   Vec<Sample>, // latest mutation round
 
    // Trace
    trace_list: BTreeSet<Vec<u64>>,
 
    // associated rng
    rng: rand::prelude::ThreadRng,
}

Rust版本的变异引擎比之前Python版本的简单了很多;Rust版本的只有两个函数,而不是像之前用的四个不同的样本容器,这样就不需要将拟合和变异函数捆绑在一起从而导致错综复杂的混乱局面了;详细的内容将在覆盖率章节进行讲解;最后值得一提的是我们用可交换的随机数发生器来随机变异我们的样本。
实际的变异在实现时是相当不可知的。

1
2
3
4
5
6
7
fn mutate(&mut self) {
    for sample in &mut self.corpus {
        for _ in 0..100 { //completely arbitraty number
            &self.samples.push(sample.mutate(&mut self.rng));
        }
    }
}

事实上,语料库中的样本会自我变异,并在样本容器内创建新的副本;基于这一点,我们以后可以实现某些特殊的功能,并以独特的策略变异各种样本,比如说语法驱动的策略。
说起样本的变异策略

1
2
3
4
5
6
7
8
9
10
11
12
13
fn mutate(&mut self, rng: &mut ThreadRng) -> Sample {
 
    let strategy: u32 = rng.gen_range(0..=3);
 
    match strategy {
        0 => self.bit_flip(rng),
        1 => self.byte_flip(rng),
        2 => self.insert_block(rng),
        3 => self.remove_block(rng),
        // Added so match checker does not complain about non-exhaustive match
        _ => self.raw(),
    }
}

目前他只是最简单的线性概率的随机选择,但之后我希望能将其修改复杂一些,并加上一些遗传算法的优点。

覆盖率

追踪覆盖率那部分已经被简化很多,现在基本上就是由一条规则来控制——如果样本追踪是唯一的,就说明变异成功了,那么该样本就会被添加到下一轮变异的语料库中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Consume samples with added trace
pub fn update(&mut self, samples: &Vec<Sample>) {
    for sample in samples {
        match &sample.trace {
            Some(trace) => {
                if !self.trace_list.contains(trace) {
                    println!("[-] New coverage for input {:?} [{:?}]",
                        sample.data, sample.method);
                    self.corpus.push(sample.clone());
                    self.trace_list.insert(trace.clone());
                }
            },
            None => {
                println!("[!] missing trace info ...");
            }
        }
    }
    self.mutate()
}

当遇到我们之前那样的二进制目标时,我们的方法效果非常好,因为具有直接反馈回路的迭代过程能够找出预期的数据,并随着变异的进行而进行,直到我们的程序触发崩溃。
图片描述

 

事实上我们还是有可能会遇到两种类型的缺陷——局部的和全局的变异;第一种情况发生在我测试BitFlip变异策略的时候,给引擎提供字符X,并期望它通过单一的比特翻转变异为A,但如果不保留部分变异就无法成功;另一种情况是内部有校验和的数据格式(例如PNG),只要校验和通不过,就无法触发新的覆盖。

未来计划

接下来我会专注于如何加快模糊处理的过程。可能用到的方法有使用打过补丁的二进制文件、性能计数器或进程快照,任何能让现有fuzzer变快的方法都值得尝试;或许最终我们会使用像本地代码工具类似的解决方案;最终我想让即使有严格的语法验证并需要语法模糊的目标也能用我的fuzzer做模糊测试。
最后,希望你们能喜欢并持续关注本系列

 

原文链接:
https://carstein.github.io/2021/03/13/build-your-own-fuzzer-5.html


[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界

收藏
点赞4
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回