TP-LinkSR20(v1)漏洞分析

Posted by Closure on May 17, 2025

TDDP 是一个链路层或传输层上的设备发现和管理协议,一般工作在局域网内部,用于让控制端快速发现网络中的路由器设备,并对其进行远程管理配置。多数实现使用 UDP 协议(通常是端口 30303),直接在以太网或 IP 层广播。传输方式通过广播或单播,在 LAN 中探测和控制设备。

数据包如图

TDDP是早期嵌入式固件设计,所以存在很多安全风险,常见的TDDP实现没有加密保护能导致配置泄露、部分固件中TDDP没有认证机制、攻击者在 LAN 内即可下发配置命令、某些固件中的 TDDP可被用于远程执行命令或写入配置。

这次分析的是TP-link SR20,这款路由器支持zigbee&Z-wave物联网协议且可以作为hub使用的触屏路由器,运行在UDP的1040端口,这个协议的V1版本存在RCE。TDDP有v1v2,v1不支持身份验证和数据包荷载加密。

当TDDP的类型字段=0x31的时候,协议就会迫使路由器设备执行tftp命令去连接发送数据包的设备。漏洞固件在官网下载的SR20(US)_v1。

binwalk -Me

0x212FF9(2174969), SquashFS 文件系统。解压之后如图

grep -ri 'tddp' *
find ./ -type f -exec grep -i 'tddp' {} + 2>/dev/null

结果为

grep: bin/cloud-client: binary file matches
etc/rc.local:tddp >/dev/null 2>&1 &
grep: media: No such file or directory
sbin/tddp_hook_setmac:echo "tddp_hook_setmac "$1
usr/lib/opkg/status:Package: tddp
usr/lib/opkg/info/tddp.list:/usr/bin/tddp
usr/lib/opkg/info/tddp.control:Package: tddp
usr/lib/opkg/info/tddp.control:Source: iplatform/stable/private/tddp
grep: usr/bin/tddp: binary file matches
./etc/rc.local:tddp >/dev/null 2>&1 &
./sbin/tddp_hook_setmac:echo "tddp_hook_setmac "$1
./usr/lib/opkg/status:Package: tddp
./usr/lib/opkg/info/tddp.list:/usr/bin/tddp
./usr/lib/opkg/info/tddp.control:Package: tddp
./usr/lib/opkg/info/tddp.control:Source: iplatform/stable/private/tddp

在/usr/bin/tddp下面,etc/rc.local:tddp >/dev/null 2>&1 & 说明系统启动时会后台启动 tddp 服务,它默认就监听UDP 1040并随设备开机自动运行。

放IDAPRO,搜索tddp字符串后,反编译看看936c。

int sub_936C()
{
  _DWORD *v0; // r4
  int optval; // [sp+Ch] [bp-B0h] BYREF
  int v3; // [sp+10h] [bp-ACh] BYREF
  struct timeval timeout; // [sp+14h] [bp-A8h] BYREF
  fd_set readfds; // [sp+1Ch] [bp-A0h] BYREF
  _DWORD *v6; // [sp+9Ch] [bp-20h] BYREF
  int v7; // [sp+A0h] [bp-1Ch]
  int nfds; // [sp+A4h] [bp-18h]
  fd_set *p_readfds; // [sp+A8h] [bp-14h]
  unsigned int i; // [sp+ACh] [bp-10h]

  v6 = 0;
  v3 = 1;
  optval = 1;
  printf("[%s():%d] tddp task start\n", "tddp_taskEntry", 151);
  if ( !sub_16ACC(&v6)
    && !sub_16E5C(v6 + 9)
    && !setsockopt(v6[9], 1, 2, &optval, 4u)
    && !sub_16D68(v6[9], 1040)
    && !setsockopt(v6[9], 1, 6, &v3, 4u) )
  {
    v6[11] |= 2u;
    v6[11] |= 4u;
    v6[11] |= 8u;
    v6[11] |= 0x10u;
    v6[11] |= 0x20u;
    v6[11] |= 0x1000u;
    v6[11] |= 0x2000u;
    v6[11] |= 0x4000u;
    v6[11] |= 0x8000u;
    v6[12] = 60;
    v0 = v6;
    v0[13] = sub_9340();
    p_readfds = &readfds;
    for ( i = 0; i <= 0x1F; ++i )
      p_readfds->__fds_bits[i] = 0;
    nfds = v6[9] + 1;
    while ( 1 )
    {
      do
      {
        timeout.tv_sec = 600;
        timeout.tv_usec = 0;
        readfds.__fds_bits[v6[9] >> 5] |= 1 << (v6[9] & 0x1F);
        v7 = select(nfds, &readfds, 0, 0, &timeout);
        if ( sub_9340() - v6[13] > v6[12] )
          v6[8] = 0;
      }
      while ( v7 == -1 );
      if ( !v7 )
        break;
      if ( ((readfds.__fds_bits[v6[9] >> 5] >> (v6[9] & 0x1F)) & 1) != 0 )
        sub_16418(v6);
    }
  }
  sub_16E0C(v6[9]);
  sub_16C18(v6);
  return printf("[%s():%d] tddp task exit\n", "tddp_taskEntry", 219);
}

sub_16418(v6) 是在监听 socket 有数据可读时调用的,也就是说任何向 TDDP 端口发送数据的客户端都能触发该函数的执行。

目前看到这里我没有发现没有用户身份验证&IP 验证&Token校验等安全措施,可以推测一旦该服务绑定了外网接口任何人都可以直接交互,而且这里的socket数据长度和边界没有进行任何预处理。

转到16418函数。

int __fastcall sub_16418(int *a1)
{
  int v2; // r3
  __int16 v3; // r2
  char *v4; // r3
  __int16 v5; // r2
  char *v6; // r3
  _BYTE *v7; // r3
  int v8; // r3
  size_t n; // [sp+10h] [bp-2Ch] BYREF
  socklen_t addr_len; // [sp+14h] [bp-28h] BYREF
  sockaddr addr; // [sp+18h] [bp-24h] BYREF
  ssize_t v14; // [sp+28h] [bp-14h]
  char *v15; // [sp+2Ch] [bp-10h]
  unsigned __int8 *v16; // [sp+30h] [bp-Ch]
  int v17; // [sp+34h] [bp-8h]

  v17 = 0;
  addr_len = 16;
  n = 0;
  memset((char *)a1 + 45083, 0, 0xAFC9u);
  memset((char *)a1 + 82, 0, 0xAFC9u);
  v16 = (unsigned __int8 *)a1 + 45083;
  v15 = (char *)a1 + 82;
  v14 = recvfrom(a1[9], (char *)a1 + 45083, 0xAFC8u, 0, &addr, &addr_len);
  if ( v14 < 0 )
    return sub_13018(-10106, "receive error");
  sub_15458(a1);
  a1[11] |= 1u;
  v2 = *v16;
  if ( v2 == 1 )
  {
    if ( sub_15AD8(a1, &addr) )
    {
      a1[13] = sub_9340();
      v17 = sub_15E74(a1, &n);
    }
    else
    {
      v17 = -10301;
      *v15 = 1;
      v15[1] = v16[1];
      v15[2] = 2;
      v15[3] = 8;
      *((_DWORD *)v15 + 1) = htonl(0);
      v5 = (v16[9] << 8) | v16[8];
      v6 = v15;
      v15[8] = v16[8];
      v6[9] = HIBYTE(v5);
    }
  }
  else if ( v2 == 2 )
  {
    if ( sub_15AD8(a1, &addr) )
    {
      a1[13] = sub_9340();
      v17 = sub_15BB8(a1, &n);
    }
    else
    {
      v17 = -10301;
      *v15 = 2;
      v15[1] = v16[1];
      v15[2] = 2;
      v15[3] = 8;
      *((_DWORD *)v15 + 1) = htonl(0);
      v3 = (v16[9] << 8) | v16[8];
      v4 = v15;
      v15[8] = v16[8];
      v4[9] = HIBYTE(v3);
      sub_15830(a1, &n);
    }
  }
  else
  {
    v15[3] = 7;
    v7 = v15;
    v15[4] = 0;
    v7[5] = 0;
    v7[6] = 0;
    v7[7] = 0;
    n = (((unsigned __int8)v15[7] << 24) | ((unsigned __int8)v15[6] << 16) | ((unsigned __int8)v15[5] << 8) | (unsigned __int8)v15[4])
      + 12;
  }
  if ( a1 )
    v8 = a1[11] & 1;
  else
    v8 = 0;
  if ( v8 && sendto(a1[9], (char *)a1 + 82, n, 0, &addr, 0x10u) == -1 )
    return sub_13018(-10105, "tddp_parserHandler sendto error");
  else
    return v17;
}

流程是函数调用recvfrom接收UDP报文到缓冲区→对收到的数据根据首字节做不同的处理→如果来源地址不合法就构造一个错误响应→发送处理后的sendto→使用了一些长度较大的内存操作(memset清空缓冲区大小约0xAFC9=45,257字节)。

v14 = recvfrom(a1[9], (char *)a1 + 45083, 0xAFC8u, 0, &addr, &addr_len);

还抓到个缓冲区溢出,recvfrom 函数接收数据长度是 0xAFC8,如果传入报文长度超过缓冲区大小会导致缓冲区溢出。

前文对TDDP有基本解释,他的第一个字节来判断版本,这里的代码逻辑是1调用 sub_15E74,2调用 sub_15BB8。

在看15E74之前先看地址校验函数sub_15AD8的地址校验是否严格。

bool __fastcall sub_15AD8(unsigned __int16 *a1, unsigned __int16 *a2)
{
  if ( !a1 )
    return 0;
  if ( *((_DWORD *)a1 + 8) )
    return *a1 == *a2 && *((_DWORD *)a1 + 1) == *((_DWORD *)a2 + 1) && a1[1] == a2[1];
  *a1 = *a2;
  *((_DWORD *)a1 + 1) = *((_DWORD *)a2 + 1);
  a1[1] = a2[1];
  *((_DWORD *)a1 + 8) = 1;
  return 1;
}

有空指针检查和使用了偏移32字节判断状态

看15E74函数

int __fastcall sub_15E74(int a1, _DWORD *a2)
{
  __int16 v2; // r2
  __int16 v3; // r2
  int v7; // [sp+Ch] [bp-18h]
  _BYTE *v8; // [sp+10h] [bp-14h]
  int v9; // [sp+1Ch] [bp-8h]

  v8 = (_BYTE *)(a1 + 45083);
  v7 = a1 + 82;
  *(_BYTE *)(a1 + 82) = 1;
  switch ( *(_BYTE *)(a1 + 45084) )
  {
    case 4:
      printf("[%s():%d] TDDPv1: receive CMD_AUTO_TEST\n", "tddp_parserVerOneOpt", 697);
      v9 = sub_AC78(a1);
      break;
    case 6:
      printf("[%s():%d] TDDPv1: receive CMD_CONFIG_MAC\n", "tddp_parserVerOneOpt", 638);
      v9 = sub_9944(a1);
      break;
    case 7:
      printf("[%s():%d] TDDPv1: receive CMD_CANCEL_TEST\n", "tddp_parserVerOneOpt", 648);
      v9 = sub_ADDC(a1);
      if ( !a1
        || (*(_DWORD *)(a1 + 44) & 4) == 0
        || (*(_DWORD *)(a1 + 44) & 8) == 0
        || (*(_DWORD *)(a1 + 44) & 0x10) == 0 )
      {
        *(_DWORD *)(a1 + 44) &= ~2u;
      }
      *(_DWORD *)(a1 + 32) = 0;
      *(_DWORD *)(a1 + 44) &= ~1u;
      break;
    case 8:
      printf("[%s():%d] TDDPv1: receive CMD_REBOOT_FOR_TEST\n", "tddp_parserVerOneOpt", 702);
      *(_DWORD *)(a1 + 44) &= ~1u;
      v9 = 0;
      break;
    case 0xA:
      printf("[%s():%d] TDDPv1: receive CMD_GET_PROD_ID\n", "tddp_parserVerOneOpt", 643);
      v9 = sub_9C24(a1);
      break;
    case 0xC:
      printf("[%s():%d] TDDPv1: receive CMD_SYS_INIT\n", "tddp_parserVerOneOpt", 615);
      if ( a1 && (*(_DWORD *)(a1 + 44) & 2) != 0 )
      {
        *(_BYTE *)(v7 + 1) = 4;
        *(_BYTE *)(v7 + 3) = 0;
        *(_BYTE *)(v7 + 2) = 1;
        *(_DWORD *)(v7 + 4) = htonl(0);
        v2 = ((unsigned __int8)v8[9] << 8) | (unsigned __int8)v8[8];
        *(_BYTE *)(v7 + 8) = v8[8];
        *(_BYTE *)(v7 + 9) = HIBYTE(v2);
        v9 = 0;
      }
      else
      {
        *(_DWORD *)(a1 + 44) &= ~1u;
        v9 = -10411;
      }
      break;
    case 0xD:
      printf("[%s():%d] TDDPv1: receive CMD_CONFIG_PIN\n", "tddp_parserVerOneOpt", 682);
      v9 = sub_A97C(a1);
      break;
    case 0x30:
      printf("[%s():%d] TDDPv1: receive CMD_FTEST_USB\n", "tddp_parserVerOneOpt", 687);
      v9 = sub_A3C8(a1);
      break;
    case 0x31:
      printf("[%s():%d] TDDPv1: receive CMD_FTEST_CONFIG\n", "tddp_parserVerOneOpt", 692);
      v9 = sub_A580(a1);
      break;
    default:
      printf(
        "[%s():%d] TDDPv1: receive unknown type: %d\n",
        "tddp_parserVerOneOpt",
        713,
        *(unsigned __int8 *)(a1 + 45084));
      *(_BYTE *)(v7 + 1) = v8[1];
      *(_BYTE *)(v7 + 3) = 2;
      *(_BYTE *)(v7 + 2) = 2;
      *(_DWORD *)(v7 + 4) = htonl(0);
      v3 = ((unsigned __int8)v8[9] << 8) | (unsigned __int8)v8[8];
      *(_BYTE *)(v7 + 8) = v8[8];
      *(_BYTE *)(v7 + 9) = HIBYTE(v3);
      v9 = -10302;
      break;
  }
  *a2 = ntohl((*(unsigned __int8 *)(v7 + 7) << 24) | (*(unsigned __int8 *)(v7 + 6) << 16) | (*(unsigned __int8 *)(v7 + 5) << 8) | *(unsigned __int8 *)(v7 + 4))
      + 12;
  return v9;
}

命令类型 (CMD_*) 直接执行系统操作

case 0x08: // CMD_REBOOT_FOR_TEST
    *(_DWORD *)(a1 + 44) &= ~1u;
    v9 = 0;
    break;

接收 type == 8 的命令j就可以通过网络远程触发重启标志位。

case 0x0C: // CMD_SYS_INIT
    if ((a1 + 44) & 2) {...} else { v9 = -10411; }

无需认证或登录即可通过精确命令字节组合控制系统状态,只需要伪造 UDP 包即可远程进行这些敏感操作。

case 0x31:
      printf("[%s():%d] TDDPv1: receive CMD_FTEST_CONFIG\n", "tddp_parserVerOneOpt", 692);
      v9 = sub_A580(a1);
      break;

之前说了数据包第二字节是0x31时会触发漏洞,继续跟踪A580。

int __fastcall sub_A580(int a1)
{
  void *v1; // r0
  __int16 v2; // r2
  int v3; // r3
  int v4; // r3
  __int64 v5; // r0
  char name[64]; // [sp+8h] [bp-E4h] BYREF
  _BYTE v10[64]; // [sp+48h] [bp-A4h] BYREF
  char s[64]; // [sp+88h] [bp-64h] BYREF
  int v12; // [sp+C8h] [bp-24h]
  _BYTE *v13; // [sp+CCh] [bp-20h]
  int v14; // [sp+D0h] [bp-1Ch]
  int v15; // [sp+D4h] [bp-18h]
  char *v16; // [sp+D8h] [bp-14h]
  int v17; // [sp+DCh] [bp-10h]
  int v18; // [sp+E0h] [bp-Ch]
  char *v19; // [sp+E4h] [bp-8h]

  v18 = 1;
  v17 = 4;
  memset(s, 0, sizeof(s));
  memset(v10, 0, sizeof(v10));
  v1 = memset(name, 0, sizeof(name));
  v16 = 0;
  v15 = luaL_newstate(v1);
  v19 = (char *)(a1 + 45083);
  v14 = a1 + 82;
  v13 = (_BYTE *)(a1 + 45083);
  v12 = a1 + 82;
  *(_BYTE *)(a1 + 83) = 49;
  *(_DWORD *)(v12 + 4) = htonl(0);
  *(_BYTE *)(v12 + 2) = 2;
  v2 = ((unsigned __int8)v13[9] << 8) | (unsigned __int8)v13[8];
  v3 = v12;
  *(_BYTE *)(v12 + 8) = v13[8];
  *(_BYTE *)(v3 + 9) = HIBYTE(v2);
  if ( *v13 == 1 )
  {
    v19 += 12;
    v14 += 12;
  }
  else
  {
    v19 += 28;
    v14 += 28;
  }
  if ( !v19 )
    goto LABEL_20;
  sscanf(v19, "%[^;];%s", s, v10);
  if ( !s[0] || !v10[0] )
  {
    printf("[%s():%d] luaFile or configFile len error.\n", "tddp_cmd_configSet", 555);
LABEL_20:
    *(_BYTE *)(v12 + 3) = 3;
    return sub_13018(-10303, "config set failed");
  }
  v16 = inet_ntoa(*(struct in_addr *)(a1 + 4));
  sub_91DC("cd /tmp;tftp -gr %s %s &", s, v16);
  sprintf(name, "/tmp/%s", s);
  while ( v17 > 0 )
  {
    sleep(1u);
    if ( !access(name, 0) )
      break;
    --v17;
  }
  if ( !v17 )
  {
    printf("[%s():%d] lua file [%s] don't exsit.\n", "tddp_cmd_configSet", 574, name);
    goto LABEL_20;
  }
  if ( v15 )
  {
    luaL_openlibs(v15);
    v4 = luaL_loadfile(v15, name);
    if ( !v4 )
      v4 = lua_pcall(v15, 0, -1, 0);
    lua_getfield(v15, -10002, "config_test", v4);
    lua_pushstring(v15, v10);
    lua_pushstring(v15, v16);
    lua_call(v15, 2, 1);
    v5 = lua_tonumber(v15, -1);
    v18 = sub_16EC4(v5, HIDWORD(v5));
    lua_settop(v15, -2);
  }
  lua_close(v15);
  if ( v18 )
    goto LABEL_20;
  *(_BYTE *)(v12 + 3) = 0;
  return 0;
}

能看出来这是一个处理路由器测试配置的命令处理函数,从接收到的数据中解析 Lua 脚本名和配置参数,再使用tftp从客户端拉取Lua脚本,加载并调用其中的config_test函数。

sub_91DC("cd /tmp;tftp -gr %s %s &", s, v16);

没有对s和v16的内容做任何合法性验证或过滤,s 来源于* sscanf(v19, “%[^;];%s”, s, v10);,即外部数据包内容(如 “evil.lua;rm -rf /”*)会被直接传入 shell 命令中执行。

if (*v13 == 1)

条件判断用于处理包格式而非认证只需构造正确的数据格式即可通过验证,任何网络实体都可以向设备发送构造好的* CMD_FTEST_CONFIG* 命令。

luaL_loadfile(v15, name);
lua_pcall(v15, 0, -1, 0);
lua_getfield(v15, -10002, "config_test", v4);

外部拉取的 Lua 脚本被完全信任并执行,zai在脚本内可以调用任意系统接口&读取配置&操作文件系统,由于又是动态执行,还可以自定义 config_test() 函数执行任意操作。