本文首先简单介绍一下AFL的基本使用方法,随后以upx为例记录一下使用AFL来fuzz upx的过程,最后以其中一个典型的crash做简单分析。本文适合对Fuzz感兴趣的同学作为入门读物,希望和对fuzz感兴趣的同学一起交流。

0x01 AFL下载和安装

AFL可以对有源码和无源码的程序进行fuzz。对有源码的程序Fuzz的原理,简单来说即是在程序编译时,向汇编代码中插入自己的指令,从而在程序运行时,计算覆盖率。当把样本喂给程序来Fuzz时,如果AFL发现程序执行了新的路径,就把当前的样本保存在Queue中,基于这个新的样本来继续Fuzz。

下载和安装AFL:

wget http://lcamtuf.coredump.cx/afl/releases/afl-latest.tgz
tar xvf afl-latest.tgz
cd afl-2.52b
# make && make install
sudo make && sudo make install

安装完成后,默认在/usr/local/bin/目录下,修改.bashrc加入环境变量export PATH=/usr/local/bin:$PATH后即可使用。

0x02 AFL简单使用

这里以一个demo实践一下afl的用法,首先编写一个需要fuzz的程序,让AFL去fuzz,查看fuzz的效果。源码:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void test (char *buf) {
    int n = 0;
    if(buf[0] == 'a') n++;
    if(buf[1] == 'f') n++;
    if(buf[2] == 'l') n++;
    if(buf[3] == '!') n++;

    if(n == 4) {
	printf("awesome!\n");
        raise(SIGSEGV);
    }else{
        printf("wrong!\n");
    }
}

int main(int argc, char *argv[]) {
    char buf[100];
    test(argv[1]);
    return 0;
}

编译代码:

afl-gcc afl-case1.c -o afl-case1

查看功能是否符合预期,打开内容为afl!的文件内容时,程序退出了。

image-20181225153050613-5723050

功能符合我们与预期后,创建in和out目录,在in目录中随机生成一些数据。

image-20181225153635294

开始fuzz,指定输入目录和输出目录,@@用于替换输入的参数。

afl-fuzz -i in -o out ./afl-case1 @@

运行后,AFL的fuzz界面如下:

image-20181225151615247

我们可以看到得到了两个crash:

image-20181225151734234

第一个afl!明显符合我们的预期,第二个为什么会crash呢?上gdb调试一下。

gdb afl-case1 -ex "set args out/crashes/id:000001,sig:06,src:000003,op:havoc,rep:128"

运行程序后,crash在这里:

image-20181225152741742

通过栈回溯信息可以看到,程序发生了栈溢出:

image-20181225152817190

0x03 fuzz upx

了解了基本用法之后,选择一些实际的,易出crash的软件来fuzz。这里以upx为例。

upx 简单介绍

upx是一个不同可执行文件的打包软件,最新版本截至26 Aug 2018是3.95。

ubuntu源中的upx通过apt-cache showpkg upx看到版本是3.9.1的,我们直接下载git中的最新版本来安装。

在ubuntu16.04的docker中下载最新版本的和编译安装,步骤如下:

upx编译和安装

mkdir fuzz-upx
git clone https://github.com/upx/upx.git

# 修改Makefile中的CC和G++指定用afl来编译

cd upx
git submodule update --init --recursive # install lzma-sdk

# compile ucl  
wget http://www.oberhumer.com/opensource/ucl/download/ucl-1.03.tar.gz
tar zxvf ucl-1.03.tar.gz
cd ucl-1.03
export CC=/usr/bin/gcc
export CXX=/usr/bin/g++
./configure
sudo make
sudo make install

# 编译
j fuzz-upx
cd upx
export CC=/usr/local/bin/afl-gcc 
export CXX=/usr/local/bin/afl-g++
# export CC=/usr/local/bin/afl-clang-fast
# export CC=/usr/local/bin/afl-clang-fast++
export UPX_UCCLDIR="$PWD/ucl-1.03"# change for youself
export UPX_LZMADIR="$PWD/src/lzma-sdk"
make all

# 输出binary在 src/upx.out
j fuzz-upx
./upx/src/upx.out

export PATH=$PWD/upx/src/:$PATH

查看版本:

image-20181225162108885

开始fuzz

在fuzz之前,为了实现更多的的代码覆盖率,我们需要收集一些不同的binary来放到afl的输入目录。AFL的样本选择对于Fuzz的效果至关重要,如果样本太大会导致AFLfuzz时速度很慢,最好小于1KB。

fuzz之前设置相关的参数:

echo core >/proc/sys/kernel/core_pattern
cd /sys/devices/system/cpu
echo performance | tee cpu*/cpufreq/scaling_governor

开始fuzz:

j fuzz-upx
afl-fuzz -i  afl_upx_in -o afl_upx_out  $PWD/upx/src/upx.out  -d @@

image-20181225171517993

不到半小时不到出了5个crash(后续还出了很多crash,但是经过分析后发现大部分类型相同):

image-20181225185807754

crash分析

用upx打开发现可以稳定复现:

image-20181225174104204

剩下的几个crash gdb挂上去后,发现都是同一类型的crash。因此挑一个来分析一下。运行后发现存在OOB read:

image-20181225190738392

image-20181225190806943

栈回溯一下看一下调用链:

image-20181225190624003

RSI的值是哪里来的呢?回溯分析一下上层的函数,发现RSI = R9+R14*4:

image-20181225193836696

继续看汇编可能有点枯燥了,好在是有源码的,调用链都有了,直接上源码分析吧。

源码分析

源码在upx/src/p_lx_elf.c文件中,crash时的调用链如下:PackLinuxElf32help1->elf_lookup->get_te32->getle32,深入的看一下PackLinuxElf32help1和elf_lookup部分的代码来看一下OOB read的原因。

upx程序由于需要将ELF文件压缩,因此程序会将ELF文件映射到内存空间后再解析。在PackLinuxElf32help1函数中获取了dynstr,dynsym,gashtab,hashtab的地址后,调用elf_lookup来查找JNI_OnLoad的符号地址:

void
PackLinuxElf32::PackLinuxElf32help1(InputFile *f)
{
[...]
        Elf32_Phdr const *phdr= phdri;
        for (int j = e_phnum; --j>=0; ++phdr)
        if (Elf32_Phdr::PT_DYNAMIC==get_te32(&phdr->p_type)) {
            dynseg= (Elf32_Dyn const *)(check_pt_dynamic(phdr) + file_image);
            invert_pt_dynamic(dynseg);
            break;
        }
        // elf_find_dynamic() returns 0 if 0==dynseg.
        dynstr =      (char const *)elf_find_dynamic(Elf32_Dyn::DT_STRTAB);
        dynsym = (Elf32_Sym const *)elf_find_dynamic(Elf32_Dyn::DT_SYMTAB);
        gashtab = (unsigned const *)elf_find_dynamic(Elf32_Dyn::DT_GNU_HASH);
        hashtab = (unsigned const *)elf_find_dynamic(Elf32_Dyn::DT_HASH);
        jni_onload_sym = elf_lookup("JNI_OnLoad");        //  crash here
        if (jni_onload_sym) {
            jni_onload_va = get_te32(&jni_onload_sym->st_value);
            jni_onload_va = 0;
        }
    }

在elf_lookup函数源码如下,根据栈回溯信息可以知道crash的点在unsigned const w = get_te32(&bitmask[(n_bitmask -1) & (h>>5)]); 。在第二个if判断分支中,n_bitmask是从gashtab中获取的,bitmask是&gashtab+0x10,因此严重怀疑n_bitmask这个从ELF的gashtab中获取的值导致的OOB read,具体调试来看一下。

Elf32_Sym const *PackLinuxElf32::elf_lookup(char const *name) const
{
    if (hashtab && dynsym && dynstr) {                                     // PackLinuxElf32::hashtab is null
        unsigned const nbucket = get_te32(&hashtab[0]);                     // mov    rsi, qword ptr [rdi + 0x330]     0x7ffff7fd8114
        unsigned const *const buckets = &hashtab[2];
        unsigned const *const chains = &buckets[nbucket];
        unsigned const m = elf_hash(name) % nbucket;
        unsigned si;
        for (si= get_te32(&buckets[m]); 0!=si; si= get_te32(&chains[si])) {
            char const *const p= get_te32(&dynsym[si].st_name) + dynstr;
            if (0==strcmp(name, p)) {
                return &dynsym[si];
            }
        }
}    // we can craft gashtab
    if (gashtab && dynsym && dynstr) {                                       // gashtab maybe wrong                    base 0x9ca030
        unsigned const n_bucket = get_te32(&gashtab[0]);                     // dymsym cmp    qword ptr [rbp + 0x340], 0  ; 0x9ca368 --> 0x7ffff7fd8114 --> 0x9307b083b580b083
        unsigned const symbias  = get_te32(&gashtab[1]);                     // dynstr cmp    qword ptr [rbp + 0x1a0], 0  ; 0x9ca1d0 --> 0x7ffff7fd2d6c --> 0x5ec4f8df00c8f8d3
        unsigned const n_bitmask = get_te32(&gashtab[2]);
        unsigned const gnu_shift = get_te32(&gashtab[3]);                       
        unsigned const *const bitmask = &gashtab[4];                         // bitmask is wrong   PackLinuxElf32::gashtab   0x7ffff7fd8114
        unsigned const *const buckets = &bitmask[n_bitmask];                 // bitmask 0x7ffff7fd8124 +((0xe9cd4b0c -1) & 0x474111f )*4

        unsigned const h = gnu_hash(name);   //JNI_OnLoad
        unsigned const hbit1 = 037& h;
        unsigned const hbit2 = 037& (h>>gnu_shift);
        unsigned const w = get_te32(&bitmask[(n_bitmask -1) & (h>>5)]);      // crash here!!!  hex( 0x7ffff7fd8124 +((0xe9cd4b0c -1) & 0x474111f )*4) = 0x7ffff90d8550 

        if (1& (w>>hbit1) & (w>>hbit2)) {
            unsigned bucket = get_te32(&buckets[h % n_bucket]);
            if (0!=bucket) {
                Elf32_Sym const *dsp = dynsym;
                unsigned const *const hasharr = &buckets[n_bucket];
                unsigned const *hp = &hasharr[bucket - symbias];

                dsp += bucket;
                do if (0==((h ^ get_te32(hp))>>1)) {
                    char const *const p = get_te32(&dsp->st_name) + dynstr;
                    if (0==strcmp(name, p)) {
                        return dsp;
                    }
                } while (++dsp, 0==(1u& get_te32(hp++)));
            }
        }
    }
    return 0;

}
gdb调试

在jni_onload_sym = elf_lookup("JNI_OnLoad");设置一下断点:

image-20181225195254318

跟进elf_lookup函数,gashtab的地址保存在rdi,是0x7ffff7fd8114。获取bitmask的值,保存在r9中,执行后r9的值是0x7ffff7fd8124:

image-20181226141132028

继续执行我们可以看到获取buckets时,n_bitmask的值为0xe9cd4b0c:

image-20181226141547194

n_bitmask的值之前猜测是从ELF文件中获得的,也就可能是AFL随机生成的。我们在ELF文件中搜索相应的值,可以发现映射到内存空间中的gashtab,验证了我们之前的想法。

image-20181226142351566

继续跟进,执行到源码中计算crash时的参数的地方 unsigned const w = get_te32(&bitmask[(n_bitmask -1) & (h>>5)]);

image-20181226150624396

计算后,RSI的值就已经OOB的的了(如下图所示),如果继续执行就会发生复现crash时的越界读了,这就不重复粘图片了。

image-20181226151114641

分析出了crash的原因后,发现越界读似乎并不能达到什么效果。首先由于ELF中的gashtab可控导致bitmask可控,以当前程序为例,任意地址读的范围如下:

0x7ffff7fd8124 +((bitmask -1) & 0x474111f )*4 范围大约是 0x7ffff7fd8124 - 0x80046c0ea024

程序的虚拟地址空间如下。ELF文件被映射到图中0x7ffff7fc9000的区域,那么后面的内容,构造一下gashtab都是可以越界读的。

image-20181226160535160

在源码中,越界读之后的数据还是保存在局部变量中的,读了之后也并不能输出,更不用说越界写和控制PC寄存器了。所以只算是一个DoS吧。

后续我开了几十个AFL来对upx进行fuzz,收获了几百个crash,编写相关的脚本后对crash进行精简后,人工分析得到的crash大多还是不能利用,因此对于upx的fuzz暂时告一段落了。

参考

  1. https://blog.nullable.software/2017/07/fuzzing-nginx.html
  2. 官网http://lcamtuf.coredump.cx/afl/
  3. afl入门教程,fuzz了自己写的demo https://stfpeak.github.io/2017/06/11/Finding-bugs-using-AFL/
  4. 比较入门的afl入门文章,fuzz upx源码fuzz和readelf无源码fuzz https://www.cnblogs.com/WangAoBo/p/8280352.html
  5. DT_GNU_HASH_TAB https://flapenguin.me/2017/05/10/elf-lookup-dt-gnu-hash/