CVE-2026-51385 的安全公告。需要发布它,因为 GRAPHIFY 既未识别该公告也未将其发布,而 MITRE 已分配 CVE-2026-51385,本文档即为该漏洞的公告。
ES: 这是我的第一个 CVE。说实话,我找到了一种破坏 ANTI SSRF 函数逻辑的方法,而且我真的很想发布我的第一个 CVE 所以我想了想它如何影响安全性以展示影响,尽管执行起来很复杂(在真实实例中发生这种情况很复杂,但有可能,因此其复杂度为 high)。我将其提交给 MITRE,他们接受了。这就是该公告。
EN: 这是我的第一个 CVE。说实话,我自己找到了破解 anti-SSRF 函数逻辑的方法,而且我真的很想发布这个 CVE 所以我想了想它可能如何影响安全性,找到了一个合理的理由,将其发送给 MITRE,他们接受了。这就是该公告。
请注意,本报告部分由 AI 编写,但经过了人工监督
graphify 中通过 DNS 重绑定(TOCTOU)实现的 SSRF。
软件包: graphify(PyPI:graphifyy),仓库 Graphify-Labs/graphify
受影响组件: URL 摄取路径 graphify add <url>
受影响版本: >=0.3.2, <=0.4.29
修复版本: 0.5.4(提交 dd86271,PR #591 / #592)
CVSS 3.1: 8.3(高危),CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CWE: CWE-918(SSRF)、CWE-367(TOCTOU 竞态)
报告者: Arturo Melgarejo Galindo,独立安全研究员(@Arturo0x90)
graphify add <url> 试图像许多抓取器一样保护自身免受 SSRF 攻击:它解析主机名,将解析出的 IP 与黑名单(环回地址、RFC1918、链路本地、保留地址)比对,并且仅当检查通过后才进行抓取。
问题在于,它验证的 IP 并不是它实际连接的 IP。验证过程执行一次 DNS 查找,然后 requests.get(hostname) 会再执行一次独立的查找。如果你拥有该主机名的 DNS 区域,并返回一个 TTL 极低、包含两条 A 记录(一条公网、一条内网)的应答,解析器可以合法地在每次查询时给出不同的答案。第一个答案通过黑名单检查;第二个答案才是套接字真正连接的地方。这就是整个漏洞。这是针对"先检查 IP,再按名称连接"逻辑的经典重绑定绕过,而修复方式是将已验证的 IP 固定到连接上,这正是 0.5.4 所做的。
我想对此坦诚相告,因为它单独看比实际要弱。
如果一个人坐下来,把一个他们已经信任的 URL 输入 graphify add,那这个漏洞几乎毫无价值。那个人如果愿意,本来就可以让 graphify 指向他们自己的 127.0.0.1。他们不需要重绑定技巧,而且在这种场景下根本没有攻击者。
一旦 graphify 针对操作者并未选择的 URL 运行,它就成为一个真正的漏洞。而对于这款工具来说,这并非边缘情况,而是它的常规使用方式。graphify 构建的知识图谱会被 AI 助手消费,因此现实中的流程是某个进程代表你调用 graphify add:一个智能体、一个 CI 任务、一个遍历 README 中 URL 列表的脚本,或者一个直接来自另一个模型输出的 URL。在所有这些情况下,攻击者都控制着输入字符串,而人类从未检查过它。
这才是真正重要的场景。一旦输入不受信任并且重绑定竞态被赢得,抓取就会落到内部地址,而不是它验证过的公网地址。具体来说,它可以访问:
127.0.0.1 以及绑定到环回接口的任何服务,169.254.169.254(云实例元数据),100.64.0.0/10,黑名单完全没有覆盖该网段,所以这个甚至不需要竞态,仅需一条指向其中的普通 A 记录即可。而能够访问这些地址之所以会变成损害,是因为那里运行着什么。许多内部和开发服务会暴露 GET 端点,这些端点要么返回数据,要么在裸 GET 请求上改变状态。因此,graphify 带着错误的路径和查询字符串落在其中某个端点上的抓取,就不只是一次读取了。如果内部目标是 Jenkins 的 scriptText、处于 --debug 模式的 Flask/Django 调试器、管理面板或元数据端点,那么这次畸形的 GET 就是攻击者从边界内部采取的行动。graphify 就是替他们发出请求的代理人。
我并不是说这会带来 RCE。它本身并不会。但是,一个可以从自动化摄取路径指向环回地址、IMDS、RFC1918 和 CGN 的完整 SSRF,正是这些攻击所依赖的原语,而这正是报告它的意义所在。
我使用了 Tavis Ormandy 公开的 rbndr.us 工具,它为你提供一个主机名,每次解析时会在两个 IP 之间切换。7f000001.08080808.rbndr.us 在 8.8.8.8 和 127.0.0.1 之间交替。
在内部目标上启动某个服务(这里为演示使用环回地址):
sudo python3 -m http.server 80
然后触发摄取并重试,直到竞态命中。大约每 4 到 5 次尝试命中 1 次,而一个简单的循环就能将成功率推至远超 99%:
for i in {1..20}; do
graphify add http://7f000001.08080808.rbndr.us/ && break
sleep 1
done
在一次成功的尝试中,graphify 会摄取 127.0.0.1:80 上本地服务器返回的任何内容,尽管主机名首先通过了 IP 黑名单检查。
0.5.4(dd86271)。Arturo Melgarejo Galindo,独立安全研究员。