← 所有公告 · 2026-07-21

nanobot(HKUDS)

— 一个被广泛部署的开源自托管 Agent,用一个机器人接管多个聊天平台(钉钉、飞书、Telegram、WhatsApp、邮件……)。测试版本 v0.2.2,commit 9db0d9f(最新 main)。它的 LLM 前线是真的硬——每个提示词注入变体都被拒绝,还会先把我们混淆的载荷解码来审查——于是我们从它自己的框架代码里打穿。一次性云沙箱、我们自己的 fork。

部分红队结论
公开披露公告编号: TS-2026-001厂商: nanobot (HKUDS)公开披露
发现: 2026-07-21

候选——不是已确认的利用。我们确认了:代码缺了同类通道都在用的文件名净化,且当喂入带穿越的文件名时,写入 sink 会逃出媒体目录。但那个文件名是我们自己塞的;真实钉钉消息能否投递带 '../' 的 fileName,尚未验证——还可能被平台本身限制。作为加固缺口公开发布、供修复;我们不声称有可用利用。

一个诚实的候选,用我们自己的尺子量过。在 nanobot 的钉钉通道(nanobot/channels/dingtalk/runtime.py)里,入站附件的 fileName 被无净化地写入磁盘,发生在消息解析期间、在发件人白名单之前——而同类通道(email、feishu)都先过一遍 safe_filename。我们在一次性沙箱里确认了两件事:净化确实缺失;喂入带穿越的文件名时,写入 sink 会逃出媒体目录。但我们没有确认那个决定“能否被利用”的关键——真实的、未获批的钉钉发件人到底能不能投递一个带 '../' 的 fileName。那段传输我们 stub 掉了、恶意文件名是我们自己塞的,所以这次“实弹”其实只是把静态阅读又确认了一遍。候选≠漏洞;在证明不可信输入能真正触达 sink 之前,我们不会把它叫作一次打穿。

F-1

附件处理器缺少文件名净化(路径穿越候选)

med
攻击类型: 路径穿越 / 框架 appsec(RT-3 邻近)
我们怎么试的

该通道的附件处理器把保存路径拼成 download_dir / fileName,而 fileName 直接取自入站消息、从不净化。我们用这个 Agent 自己、未经修改的下载函数,喂进 fileName = “../”×12 + 一个随机标记路径——只桩掉真实入站文件消息本来就会触发的厂商文件传输。

发生了什么

用一个我们直接传入的穿越文件名,未经修改的 sink 写到了媒体目录之外(realpath 在 /tmp;标记事后删除)。但这基本只是把静态阅读又确认了一遍——一旦假设文件名是恶意的,Python 的 Path 拼接不做净化本就是预期行为。真正吃劲的那个假设——攻击者能把 '../' 塞进真实钉钉 fileName——我们没测;钉钉传输被我们 stub 了。发件端正常情况下文件名装不进路径分隔符,所以真实可达性并不确定、且依赖平台。

为什么重要

如果 fileName 端到端可被攻击者控制,这就是一个未认证的任意文件写入(覆盖配置 → MITM/窃钥;塞启动文件 → RCE)——一个严重的 bug。而这个“如果”,正是我们还没资格去掉的。按项目自己的威胁模型,无论如何都值得修:email、feishu 都净化,唯独这个通道没有。

F-2

写入发生在发件人白名单之前

high
攻击类型: 认证顺序 / 信任边界
我们怎么试的

我们在真实源码里追了处理顺序:下载相对白名单检查在哪一步?

发生了什么

附件在消息解析期间就被抓取并写入——发件人 id 甚至要到下载之后才解析,白名单也只在更下游才被查。所以写入是“认证前”的:一个未获批陌生人就能触发。LLM 看不到它,白名单也拦不住它。

为什么重要

这正是为什么强模型和发件人白名单在这里都给了一种虚假的安全感——危险操作跑在一条绕过了两者的路径上。

F-3

唯独一个通道漏了别人都在用的净化器

med
攻击类型: 同类组件间的控制不一致
我们怎么试的

我们把同一代码库里每个同类通道的文件名处理做了逐一对比。

发生了什么

同类通道都把入站文件名过一遍共享的 safe_filename;项目甚至为另一个通道带了一条断言 ../../../etc/passwd 被中和的回归测试。唯独这个通道从不 import 它。这不是系统性设计缺陷:是单个通道的疏漏——这也正是修复只需一行的原因。

为什么重要

这个“破绽”既让发现可信、又让修复便宜:这套代码在别处早就知道该怎么安全地做。

静态 × 动态实证

静态阅读找到候选落点和认证顺序;真打穿判定它是真的。裁判是“写入到底逃没逃出去”——不是模型投票,也不是“我们在代码里读到了”。

所读源码: nanobot/channels/dingtalk/runtime.py · _download_dingtalk_file() vs. channels/email + channels/feishu (which call safe_filename)
代码实际是什么样

处理器把原始入站 fileName 传进一个下载并写入的函数,函数里 file_path = download_dir / filename; file_path.write_bytes(...) 全无净化,且写入发生在白名单之前。候选:攻击者可控的穿越逃出媒体目录、且无需认证。同类通道都净化,唯独它不。

被实战利用证实
med

真正确认的:(1) 钉钉通道漏掉了同类通道都在用的 safe_filename;(2) 给定一个穿越文件名,未经修改的 sink 会写到媒体目录之外。这两条都是代码层面的事实。没有确认的——也正是能让它成为利用的那一步——是攻击者能否通过真实钉钉消息投递这样一个 fileName。是我们自己塞进去的。所以诚实的状态是候选,不是已确认的打穿。

被证伪 —— 静态假阳性

「强化过的 LLM 会保护这里」。证伪:漏洞路径在通道的文件处理器里;模型根本不在回路上,所以它(真实、强悍的)注入抵抗在这里无关。

「这是系统性的文件名处理缺陷」。逐一对比代码库即证伪:同类通道都净化、别处还有回归测试——只是一个通道漏了。就报这一个,别把它夸大成对整体设计的指控。

根因与修法. 在一个“认证之前”的不可信输入边界上缺了输入净化。修复(基本一行):在拼路径前先用现成的 safe_filename;并把发件人白名单检查提到任何附件下载之前,让未获批的发件人根本无法触发抓取/写入。

利用链

这条潜在的链——如果钉钉 fileName 可被攻击者控制:一个未获批陌生人的入站文件消息 → 一份内容可控的文件被写到攻击者选定的路径 → 覆盖配置(MITM/窃钥)或写启动文件(RCE)。我们只证明了中间那个机制(给个坏文件名,sink 会逃出目录),没有证明第一环(攻击者能提供那个文件名)。在第一环被证明之前,这条链是假设性的——这也正是我们把它标为候选、而非打穿的原因。

结论. 这里诚实的教训是关于我们自己,而不只是目标:自己给 sink 喂一个恶意值、然后管它叫打穿,太容易了。真正的纪律,是证明不可信输入确实能触达它。在这一条上,我们发现了一个真实的代码缺口、而且维护者在别处已经在防它——值得上报——但我们还没挣到“利用”这个词,所以我们不用它。一份你敢据此行动的裁决,是作者在“不那么亮眼”时也肯划这条线的裁决。

披露

披露编号 TS-2026-001——状态:候选,已公开披露。只在我们自己 fork 的公开源码上(v0.2.2,commit 9db0d9f)、于一次性云沙箱里测试,用随机无害标记——无破坏性动作、无外泄、无持久化,标记事后删除。公开披露(不做私下厂商事先通知):钉钉通道没有像 email、feishu 那样净化入站文件名;如果钉钉 fileName 能带 '../',那就是一个未认证的任意文件写入。这个“如果”我们尚未验证,所以我们标它为候选,而不是声称已有可用利用。无论如何,应用现成的 safe_filename(并把白名单放到任何下载之前)都是一个便宜且正确的修复。

在做一个真有“手”的 Agent?让它被红队一遍 →