一个下划线,就是全部利用条件。Quay 的机器人账号查询把命名空间名原样塞进 LIKE 模式,下划线在 LIKE 里是单字符通配符,于是一个普通租户能匹配到别人的机器人账号和解密令牌。这篇带你看清这条链路断在哪一层,以及今天能动手做什么。

九点九分不是吓人,是条件太便宜

CVSS 3.1 基础分 9.9,向量 AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H。我先把每个分量翻译成现实条件,你看完就知道这个分数不是凑出来的。

AV:N 网络可达,不需要先摸进内网。AC:L 攻击复杂度低,条件稳定可复现。PR:L 只需要一个普通账号,不是管理员。UI:N 不需要任何受害者点一下或者配合。这四条凑在一起,门槛已经低到离谱。

真正把分数顶到 9.9 的是后面三个。S:C 说明突破组件边界,能影响宿主或相邻租户。C:H 机密性完全失守,机器人账号的解密令牌能读到。I:H 和 A:H 意味着拿到的令牌可以推镜像、改镜像、删镜像。

我自己的判断是,这个分数里最不该被忽略的是 PR:L。很多人看到 9.9 会以为是未授权远程打穿,其实不是。它要求你先有一个账号。但在 Quay 这种多租户镜像仓库里,账号往往是最不缺的东西。

公开信息没有披露哪些版本受影响。NVD 条目状态是 Awaiting Analysis,收录时间 2026 年 11 月 11 日 17 点 17 分,最后修改同一天。也就是说,这是一条刚进库、还没被 NVD 分析师展开的条目。

这一点我必须说清楚,后面所有关于触发条件的推断,都是基于官方英文描述里的那几个关键词,不是我跑出来的。我没有 Quay 环境,也没做 PoC。

问题不在查询,在模式串是拼出来的

官方描述里有一句很关键,机器人账号列表功能从用户可控的命名空间名构造 SQL LIKE 模式,没有转义通配符元字符。这句话把缺陷位置钉死了,不在鉴权,不在加密,在查询构造。

示意图

图 1 本文示意图:四层里只有查询构造层失守,但它下面直接连着凭据,一漏就是跨租户。

我按常见实现推一遍。前端或 API 层收到一个请求,参数里带命名空间名。后端拿到这个名字,拼成类似 WHERE namespace LIKE '名字%' 的条件,去机器人账号表里筛记录。

示意图

图 2 本文示意图:从创建命名空间到拿到别人的令牌,链路只有四步,每一步都不需要特殊权限。

问题出在「拼」这个动作上。SQL LIKE 里有两个通配符,百分号匹配任意长度,下划线匹配任意单个字符。如果命名空间名里的下划线没有被转义,它就不是字面意义上的下划线,而是一个占位符。

所以攻击者建一个名字里带下划线的命名空间,比如 a_b 这种,查询模式就变成 a_b%,它能匹配到 aXb、a1b 这类别的租户的命名空间。匹配上之后,返回的是对方的机器人账号记录。

这里有个细节我想单独说。返回的不只是账号名,官方描述明确写了 decrypted authentication tokens,解密后的认证令牌。这意味着令牌在服务端是可解密的,查询层一漏,等于把凭据直接递出去。

推演 LIKE 匹配范围(示意代码,证明一个下划线在 LIKE 里能匹配多个不同命名空间)

import re

def like_to_regex(pattern):
    # 把 SQL LIKE 模式翻译成正则,下划线匹配单字符,百分号匹配任意
    out = []
    for ch in pattern:
        if ch == "_":
            out.append(".")
        elif ch == "%":
            out.append(".*")
        else:
            out.append(re.escape(ch))
    return "^" + "".join(out) + "$"

namespaces = ["team_a", "teamXa", "team1a", "teama", "other"]
pattern = "team_a%"
regex = like_to_regex(pattern)
print("模式", pattern, "翻译成正则", regex)
for ns in namespaces:
    print(ns, bool(re.match(regex, ns)))

还有一个容易被忽略的点,为什么是机器人账号。机器人账号在 Quay 里是给 CI/CD 流水线用的,权限往往不小,能推能拉。它的令牌一旦泄露,攻击者不需要人的账号密码,直接用令牌操作镜像。

同类实现是怎么做的。正规写法是在拼接前对用户输入做 LIKE 转义,把下划线、百分号、反斜杠处理成字面量。或者干脆不用 LIKE,用等值匹配加前缀索引。这两种做法都不难,难的是有人默认「命名空间名是可信的」。

为什么以前没人发现

我猜这条缺陷能活到现在,有两个原因。第一个是命名空间名在大多数系统里被当成标识符,潜意识里觉得它干净。第二个是 LIKE 的通配符问题不像引号注入那么显眼,它不报错,不崩,只是多返回几行。

多返回几行这件事,在单租户环境里根本看不出来。你查自己的机器人账号,返回自己的,一切正常。只有多租户部署,并且有人故意建一个带下划线的命名空间,才会露出马脚。

这也解释了为什么 CVSS 里 S:C 是成立的。单看一个租户,组件边界完好。但跨租户一匹配,边界就穿了。评分的人显然考虑到了多租户这个前提。

公开信息没有说 Quay 的默认部署是不是多租户。如果是单租户自建,影响面会小很多,但分数不会因此降下来,因为向量描述的是组件能力,不是你的部署方式。

还有一个推断,我没法验证。机器人账号的令牌在数据库里存的是密文还是可解密的形式,官方描述用了 decrypted 这个词,说明查询路径上有一层解密。这层解密为什么会在列表接口里发生,公开信息没披露。

按通用规律,列表接口返回解密令牌本身就是个设计味道。正常做法是列表只返回元数据,令牌单独走一个高权限接口,并且只在创建时展示一次。这条如果属实,加固时值得顺手改掉。

今天先做这三步,别等补丁

我没有 Quay 环境,所以下面的步骤是自检和加固,不是利用。第一步,先确认你的 Quay 是不是多租户,以及机器人账号列表接口有没有对外暴露。

第二步,查现有命名空间里有没有带下划线的。有的话,要么改名,要么在网关层把带下划线的命名空间创建请求先拦掉。这是过渡缓解,不是根治。

示意图

图 3 本文示意图:暴露面和影响程度交叉后,多租户且接口可达的那一格是今天的第一优先级。

第三步,如果机器人账号令牌曾经在列表接口里返回过,按泄露处理,轮换一遍。尤其是那些权限大的、给 CI 用的机器人账号,优先级最高。

这三步里,第二步最容易被跳过。很多人觉得改名麻烦,但一个下划线命名空间就是这条链路的入口,先堵入口比等补丁现实。

如果你们的 Quay 是单租户自建,而且机器人账号列表接口只对内网开放,那今天的紧迫性可以降一档。但降档不等于不做,轮换令牌这件事什么时候做都不亏。

盘点带下划线的命名空间(示意代码,把现有命名空间里带下划线的先筛出来,决定改名还是拦截)

#!/bin/bash
# 列出所有命名空间,筛出名字里带下划线的
# 需要先配置好 QUAY_HOST 和 QUAY_TOKEN
QUAY_HOST="${QUAY_HOST:?请先设置 QUAY_HOST}"
QUAY_TOKEN="${QUAY_TOKEN:?请先设置 QUAY_TOKEN}"

curl -s -H "Authorization: Bearer $QUAY_TOKEN" \
  "" \
  | python3 -c '
import sys, json
data = json.load(sys.stdin)
orgs = data.get("organizations", [])
hits = [o["name"] for o in orgs if "_" in o["name"]]
print("带下划线的命名空间数量", len(hits))
for h in hits:
    print(h)
'

补丁没出之前,网关先挡一道

根治要等厂商修查询构造,把 LIKE 模式里的用户输入转义掉。在那之前,网关层能做的是限制命名空间名的字符集,把下划线和百分号从创建请求里拒掉。

这个做法有副作用。如果你们已经有带下划线的命名空间,新规则会让它们没法再创建同类名字。所以要先盘点,再上规则,顺序不能反。

另一个过渡办法是收紧机器人账号列表接口的调用权限,只允许管理员调。普通租户调不了,跨租户匹配这条路就断了。代价是自助服务变差,得权衡。

网关层拦带下划线的创建(示意代码,补丁出来前,先把带下划线的命名空间创建请求挡在入口)

#!/bin/bash
# 在反向代理或网关层加一条规则,拒绝命名空间名里带下划线的创建请求
# 下面是 nginx 的示例片段,放到对应的 location 里

# location /api/v1/organization/ {
#     if ($request_method = POST) {
#         set $bad 0;
#         if ($arg_name ~ "_") {
#             set $bad 1;
#         }
#         if ($bad = 1) {
#             return 403;
#         }
#     }
#     proxy_pass
# }

echo "规则上线前先盘点存量命名空间,否则会误伤已有合法名字"

我不建议在这个阶段去改数据库里的存储过程或者视图,风险比收益大。查询构造的修复应该由厂商做,自建改动能绕开就绕开。

还有一个更彻底但更重的选项,把 Quay 从多租户切成单租户隔离部署。这个动作太大,只适合对隔离要求极高的环境,一般团队不用走到这一步。

我没查到的,和我不敢下结论的

第一,受影响版本区间公开信息未披露。NVD 条目还在 Awaiting Analysis,没有给出 fixed version。所以「升级到哪个版本」这句话我现在写不出来,写了就是编。

第二,触发这条链路需不需要额外的权限,比如创建命名空间的权限是不是所有账号都有,公开描述没说。我按 PR:L 推断普通账号即可,但这是推断。

第三,机器人账号令牌的解密发生在哪一层,是查询时解还是返回时解,公开信息没有。这影响加固时该盯哪个接口,我只能按通用规律给建议。

第四,我没有验证过实际匹配效果。a_b 能匹配 aXb 是 LIKE 的语义,但 Quay 的查询有没有加别的过滤条件,我不知道。这条要等 PoC 或者厂商公告。

同类组件的通用规律是,凡是把用户输入拼进 LIKE 的地方,都要过一遍转义。这条规律不新鲜,但每次出事都有人栽在「这个名字应该是干净的」上。

一个下划线值九点九分,说明信任放错了地方

这件事真正让我不舒服的地方,不是 9.9 这个数字,是它便宜。一个普通账号,一个带下划线的名字,就能跨租户拿凭据。攻击成本低到不需要动机,只需要顺手。

多租户系统里,每一个用户可控的字符串都是一条潜在的边界。命名空间名看起来像标识符,但它进了 SQL 模式串,它就是输入。信任不该按字段的名字给,该按它流到哪给。

今天能做的,是把带下划线的命名空间先盘出来,把机器人账号令牌先轮换一遍。补丁什么时候出,等厂商,但入口今天就能堵。

写在最后

一个下划线值九点九分,不是因为攻击多精巧,是因为我们把一个用户可控的字符串当成了可信标识符。多租户系统里,信任该按数据流到哪来给,不该按字段叫什么来给。

以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标⭐~ 谢谢你看我的文章,我们,下次再见。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐