Tiana随笔

AI 助手现在是一串钥匙

· Agent 实践

标签:#Agent 安全 #安全 #MCP

AI 助手现在是一串钥匙

Check Point 在 10 月 23 日分析的 Aokigahara 木马,给 AI 客户端配置单开了一个收集模块。开发机上的钥匙已经长成了一串,而大多数防守清单里还没有这一项。这篇讲它偷什么、为什么现在值得偷,以及怎么自查。

AI 助手现在是一串钥匙

我博客上跑着三个自动化脚本,备份、sitemap、评论摘要。去年我给它们各发了一张工牌,权限分开,互不越界。今年我又写了一篇,讲那种"模型在文字上拒绝、手上照样把调用发出去"的攻击。

这两篇都在盯着同一件事:agent 拿到权限之后会干什么。

10 月 23 日 Check Point Research 放出的分析让我意识到,还有一半问题我漏了。他们分析的木马叫 Aokigahara,做的事不新鲜,是偷东西。新鲜的是它的购物清单。

清单上除了浏览器密码、加密钱包、截图、剪贴板这些老面孔,还有一栏专门给 AI 工具:Claude、ChatGPT Desktop、Codex、Cursor、Windsurf、GitHub Copilot、Gemini CLI、JetBrains AI Assistant、Amazon Q。

一台开发机上,AI 助手的配置文件已经贵重到值得单开一个模块来偷了。

木马的清单

Aokigahara 是 Windows 平台的窃密木马,Check Point 在 10 月 23 日给出技术分析,Symantec 和 Carbon Black 的威胁猎捕团队在 10 月 15 日已经报过同一个东西,当时叫 Aoki。

它有个专门模块,名字就叫 aisessions。这一模块按已知路径翻找本地文件:Codex 目录下的 auth.json、config.toml 和 .env,Claude 的凭据与设置文件,Cursor 和 Windsurf 的状态数据库。

另一个模块叫 ide,找的是同一台机器上的 Git 凭据、SSH 私钥,以及 AWS、Azure、GCP 的配置文件。

外传方式也写得很清楚:先跟控制端交换一次密钥,之后把收集结果打包成 ZIP,用 AES-GCM 加密上传,上传头里会标明这一包属于哪个类别,aisessions 是其中之一。木马会先检查虚拟机、沙箱和调试器,发现异常就不执行;代码里还有一段按键盘布局判断地区的逻辑,命中俄语或独联体布局就自行退出。

公开的指标我记在这里,方便查:控制端 2.26.252.48 端口 8080,域名 update-manager.icu、cdn-assets.cloud、pkg-storage.net;木马在 C:\Microsoft\Vault\ 放持久化存根,建一个叫 MicrosoftVaultService 的计划任务;互斥量字符串是 Aoki_Global_Mutex_2026。

传播途径是终端侧,Check Point 描述为被木马化的开发者工具或破解版 IDE 插件,经 GitHub 或包管理器分发。

有一点要说清楚,免得这篇变成危言耸听:Check Point 确认的是"它能收集什么",没有确认运营者怎么把偷到的 AI 访问权变现。Symantec 那边的对比也能佐证时间线,AI 相关的收集出现在较新的样本里,老样本没有。

钥匙串是怎么长出来的

我关心的是另一件事:为什么现在值得单开一个模块。

三年前偷一台开发机,得分别找浏览器的密码库、~/.ssh 里的私钥、云厂商的配置文件、Git 的凭据缓存。这几个地方彼此不挨着,一个木马要写好几个收集器。

现在这些钥匙有一份共同的索引,就在 AI 客户端的配置目录里。

你想让 agent 帮你跑测试,它得能执行命令;你想让它读仓库,它得有 Git 凭据;你想让它查线上日志,它得有云账号;你想让它发消息,它得有 IM 的 token。这些权限不是分散授权的,是你在安装和首次登录时一次性交出去的,落地成几个躺在磁盘上的文件。

我之前那篇讲 MCP 的文章里提过一个数据:一份扫描了大约 8.2 万份公开的 MCP 配置文件,12% 的凭据槽位里硬编码了密钥,其中 55% 的密钥形状不规则,gitleaks 和 GitHub 的密钥扫描都认不出来,24% 作用域很宽且永不过期。

当时我把这条当成"配置习惯不好"。现在看,它是攻击面的度量:一个目录里装着全套钥匙,而且这些钥匙的格式还在密钥扫描器的识别范围之外。

更麻烦的是 token 的性质。密码改了,旧密码立刻失效。会话 token 不一样,它有生命周期,不用你输密码就能用,MFA 也拦不住,因为它是"已经通过验证的会话"。Aokigahara 这类木马要的就是这个:偷到手之后能用到你主动吊销为止。

防守清单里没有这一项

这件事之所以到现在才被单列出来,跟技术难度无关,跟清单更新速度有关。

企业的端点防护有惯常盯的几处:浏览器凭据库、SSH 目录、云厂商配置、包管理器凭据。AI 客户端的配置目录不在其中,它属于"应用数据",跟缓存和日志归在一类。

密钥扫描也是一样。它认识 AKIA 开头的 AWS key,认识 ghp_ 开头的 GitHub token,认识标准形状的私钥头。前面那份 MCP 统计里 55% 的密钥形状不规则,意思就是:它们躺在仓库里,扫描器从旁边走过去没反应。

于是两边都在按旧清单干活。防守方按旧清单扫,攻击方按新现实偷。Aokigahara 的价值不在于技术多高明,它的外传、反分析、持久化都是成熟套路。它的价值在于第一个把"AI 会话"单独列成一个收集类别,而这个类别在防守清单上还没有位置。

同一周的另外两件事

端侧出事的同时,云侧和供应链也在同一个星期里出事。

Check Point 那份周报里还有一条:LiteLLM 的 1.107.0 版本被投毒,随包和 Docker 镜像一起发布,专门偷云凭据和 AI 服务密钥。攻击链的起点是失窃的开发者 token,也就是说,端上丢的东西被用来投毒供应链,供应链再去偷别人的云。

另一条是 Cloudflare 披露的。Z.ai 的 GLM-5.3 模型跑在 Workers AI 上,一个租户隔离缺陷导致客户数据跨租户泄露,涉及 155 条响应,时间跨度是 8 月 27 日到 9 月 9 日。触发条件是极长的 prompt,超过 681,280 个 token 才会命中,根因是 SGLang 推理引擎的一个缺陷叠加配置错误。由 Knostic 的研究员 Gadi Evron 报告,Cloudflare 在验证后 40 分钟内下线模型、当天修补,没有发现被恶意利用的证据。

三件事放一起看,形状很清楚:AI 工作流的凭据在端上、在包里、在云上同时变成了目标,而这三处过去是被分开防守的。

我自己机器上先扫一遍

想管这件事,得先能说出来自己机器上有什么。我写了个脚本做这件事,纯标准库,跨平台。

它做四件事:找出装了哪些 AI 客户端、检查这些配置文件的权限、用正则扫明文密钥、查 SSH 私钥是不是 600。扫到命中只打印模式和文件名,不打印内容,因为审计结果本身就是新的敏感数据。

#!/usr/bin/env python3
"""扫一遍本机 AI 客户端与开发凭据的落盘情况。

只报告命中的模式名与文件路径,不输出任何密钥内容。
用法: python ai_keyring_audit.py [--fix]
"""

import os
import re
import stat
import sys
from pathlib import Path

HOME = Path.home()

# AI 客户端与开发工具的常见配置位置
TARGETS = {
    "Claude": [".claude", ".claude.json", ".claude/credentials"],
    "Codex": [".codex/auth.json", ".codex/config.toml", ".codex/.env"],
    "Cursor": [".cursor", "Library/Application Support/Cursor"],
    "Windsurf": [".windsurf", "Library/Application Support/Windsurf"],
    "Gemini CLI": [".gemini"],
    "Amazon Q": [".aws/amazonq"],
    "WorkBuddy": [".workbuddy"],
    "MCP 配置": [".mcp.json", ".cursor/mcp.json", "Library/Application Support/Claude/claude_desktop_config.json"],
    "SSH": [".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/config"],
    "Git 凭据": [".git-credentials"],
    "云配置": [".aws/credentials", ".azure/credentials", ".config/gcloud/credentials.db"],
}

# 只匹配形状,不匹配具体值
PATTERNS = {
    "Anthropic": re.compile(r"sk-ant-[A-Za-z0-9_-]{8,}"),
    "OpenAI": re.compile(r"\bsk-[A-Za-z0-9]{16,}"),
    "GitHub": re.compile(r"\b(?:ghp_|github_pat_)[A-Za-z0-9_]{16,}"),
    "AWS": re.compile(r"\bAKIA[0-9A-Z]{12}\b"),
    "Google": re.compile(r"\bAIza[0-9A-Za-z_-]{20,}"),
    "Slack": re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{8,}"),
    "私钥": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
}

TEXT_SUFFIX = {".json", ".toml", ".env", ".yaml", ".yml", ".txt", ".md", ".cfg", ".ini"}

# 目录里只挑这些名字,避免把缓存和日志也扫一遍
SENSITIVE = ("auth.json", "config.toml", ".env", "credentials", "settings.json",
             "claude_desktop_config.json", "mcp.json", "id_rsa", "id_ed25519",
             "config", "credentials.db", ".git-credentials")
KEYWORD = ("credential", "auth", "secret", "token", "key", "mcp")
MAX_DEPTH = 2


def candidates(path):
    """文件直接返回;目录只在两层内挑名字可疑的文件。"""
    if path.is_file():
        return [path]
    out = []
    for f in path.rglob("*"):
        if not f.is_file() or f.is_symlink():
            continue
        if len(f.relative_to(path).parts) > MAX_DEPTH:
            continue
        name = f.name.lower()
        if name in SENSITIVE or any(k in name for k in KEYWORD):
            out.append(f)
    return out[:50]


def scan_file(path):
    hits, size = [], 0
    try:
        if path.suffix.lower() not in TEXT_SUFFIX and path.name != ".env":
            if not path.name.endswith(("credentials", "config")):
                return hits, path.stat().st_size
        text = path.read_text(errors="ignore")
        size = len(text)
        for name, pat in PATTERNS.items():
            if pat.search(text):
                hits.append(name)
    except (OSError, UnicodeDecodeError):
        pass
    return hits, size


def mode_str(path):
    return oct(stat.S_IMODE(path.stat().st_mode))[-3:]


def audit(fix=False):
    findings = []
    for tool, rels in TARGETS.items():
        for rel in rels:
            p = HOME / rel
            if not p.exists():
                continue
            items = candidates(p)
            for f in items:
                if not f.is_file() or f.is_symlink():
                    continue
                try:
                    mode = mode_str(f)
                except OSError:
                    continue
                hits, size = scan_file(f)
                risky = mode not in ("600", "400") or hits
                if not risky:
                    continue
                if fix and mode not in ("600", "400"):
                    os.chmod(f, 0o600)
                    mode = "600"
                findings.append({
                    "tool": tool,
                    "path": str(f).replace(str(HOME), "~"),
                    "mode": mode,
                    "secrets": hits,
                    "bytes": size,
                })
    return findings


if __name__ == "__main__":
    fix = "--fix" in sys.argv
    rows = audit(fix=fix)
    if not rows:
        print("未发现需要收紧的文件")
    for r in rows:
        flag = ",".join(r["secrets"]) if r["secrets"] else "权限过宽"
        print(f"[{r['tool']}] {r['path']} mode={r['mode']} {flag}")
    print(f"\n共 {len(rows)} 项待处理{',已自动收紧权限' if fix else '(加 --fix 自动 chmod 600)'}")

跑在我自己机器上,输出长这样:

[Claude] ~/.claude/settings.json mode=644 OpenAI
[WorkBuddy] ~/.workbuddy/mcp-approvals.json mode=644 权限过宽
[WorkBuddy] ~/.workbuddy/mcp-tool-list.json mode=644 权限过宽
[WorkBuddy] ~/.workbuddy/agent-cli-mcp-config/<会话 id>.mcp-config.json mode=644 权限过宽
[SSH] ~/.ssh/config mode=644 权限过宽

共 5 项待处理(加 --fix 自动 chmod 600)

第一行是我没预料到的。一个 Claude 的配置文件里躺着一段符合 OpenAI 形状的密钥,权限是 644,同机上的任何进程都能读。我装过什么、什么时候写进去的,已经想不起来了,这本身就是问题的一部分。

后面几行是 MCP 的配置和审批记录,它们记录了这台机器上的 agent 能调用哪些工具、批准过哪些操作。这类文件过去不在任何人的敏感清单里。

脚本真正的用途不是这一次的三级结论,是让你能回答两个问题:这台机器上一共有哪些钥匙,它们各自对谁可见。回答不了这两个问题,后面所有加固都是猜的。

还有两个限制得说在前面。它只扫 HOME 下的已知路径,装在别处的客户端扫不到;它只匹配形状,识别不了格式不规则的密钥,前面提到的那 55% 就属于这一类。所以没扫出来不等于没有。

我这边的六条改动

第一件是轮换,不是改密码。 我把 Claude、OpenAI、GitHub 的 API key 全部吊销重建,然后在每个平台的设置里手动吊销活跃会话。改密码对 token 没用,这个顺序不能反。

第二件是收紧权限。 配置目录和凭据文件一律 600,脚本加了 --fix 就是为了这件事能一键做完。

第三件是把 AI 凭据和日常工作隔开。 agent 用的 key 单独一套,作用域收到最小,能只读就不给写,能用短期就不给长期,到期时间写进日历提醒。

第四件是清点最近装的依赖。 木马主要的入口是被投毒的包和破解插件,我查了近 30 天新增的 npm、pip 包,删掉了两个来路不明的。团队范围内这件事该做成准入规则,而不是靠自觉。

第五件是给出口加限制。 agent 的进程只能在白名单域名里发请求,开发机上不该有"任意进程任意外连"这种默认。Aokigahara 那串 C2 域名我加进了拦截名单。

第六件是把 AI 会话当成企业机密来分类。 这条是管理动作,不是技术动作。会话里存着项目历史、自定义指令、上传过的文件,还有你让模型看过的代码。一旦泄露,它的价值不比一份源代码低。它该进资产清单,该有吊销流程,该出现在事故演练的场景里。

数据说明

事实部分,全部有公开出处:Aokigahara 的模块名、目标清单、加密外传方式、反分析逻辑、C2 与持久化指标来自 Check Point Research 10 月 23 日的分析,同一木马由 Symantec 与 Carbon Black 在 10 月 15 日以 Aoki 的名字先报;LiteLLM 1.107.0 投毒来自 Check Point 10 月 26 日的周报;Cloudflare 跨租户泄露的 155 条响应、681,280 token 阈值、40 分钟下线与当天修补来自 Cloudflare 的披露,报告者是 Knostic 的 Gadi Evron;MCP 配置扫描的 8.2 万份、12% 硬编码、55% 不规则、24% 宽作用域来自此前那份公开统计。

需要打折看的地方:Check Point 明确没有确认 Aokigahara 运营者的变现方式,收集清单不等于实际拿到了可用凭据;Symantec 的对比显示 AI 收集是较新样本才有的行为;Cloudflare 那条没有发现被恶意利用的证据,且触发条件极端。

我自己的判断有两条,请当意见看:一是 AI 客户端配置目录已经成为开发机上价值密度最高的目标之一,应当与云根凭据同级管理;二是轮换与吊销的价值高于任何新增的扫描工具,因为扫描只能发现已知形状的钥匙。

工牌那篇我解决的是"agent 有没有权限",这篇要解决的是"这串钥匙放在哪、谁能拿走"。同一个问题的两端。

相关文章