NIKKE 的 PC 启动器在 2026 年 9 月底被塞进了 loader,10 月 23 日起开始真的下发远控代理,直到 11 月 3 日厂商才换掉文件。这是近几年少见的、完整公开了攻击链与 IOC 的桌面客户端供应链事件,比「某 CDN 被黑」的标题更值得逐段拆开看:它把「官方来源即可信」「改了文件会被发现」「源站修好就安全」这三个默认假设一起打碎了。下面按攻击链、为何常见防护失效、开发者能做什么三段来写。所有结论来自公开报告与厂商声明,样本未做动态复现。
事件本身的形状
2026 年 10 月 23 日到 11 月 3 日,任何打开《胜利女神:新的希望》(NIKKE)PC 启动器的人,都可能在不知情的情况下装上一个远程控制代理。攻击面不在游戏本体,而在启动器从 Level Infinite 自家 CDN 上拉取的四份官方 JavaScript 文件:aegis-fix.min.js、images.7fec4c32.js、FeedList.feb647c9.js、vue-bucket.min.js。这些文件的 last-modified 停在 2026-09-27 06:41–06:42 GMT,注入的代码只是往页面里加一个 <script>,指向 https://insightjs.com/l.js?f=aegis|images|feedlist|vue,加载完立刻把自己摘掉。
这件事值得写,不是因为它「又是一起供应链攻击」,而是因为它把三个平时被当成可靠前提的假设同时打碎了:
- 「文件在官方 CDN 上就代表可信」 —— 攻击者拿到的是 CDN 源站或发布流水线的写权限,域名、证书、TLS 一个都没坏。
- 「改了文件会被发现」 —— 注入只加了 708 字节,且塞在
export{...}之前,只比对文件尾部或只看 diff 首行的检查都会漏。 - 「源站修好了就安全了」 ——
FeedList.feb647c9.js在 10 月 28 日 15:46 GMT 就已在源站恢复干净,可边缘节点抱着 9 月 30 日、10 月 15 日的旧缓存继续发,直到 11 月 4 日 03:12–03:21 GMT 才被清掉。中间那约 155 小时,修完等于没修。
攻击链拆成四段
flowchart TB A["CDN 源站 / 发布流水线被写入"] --> B["4 个官方 JS 注入 loader"] B --> C["启动器加载被篡改文件"] C --> D["insightjs.com/l.js 回连"] D -->|"30s 超时"| E["insightjs.org 回退"] D --> F["Srv_DownloadFile 拉 insight.bundle"] F --> G["LuaJIT 字节码代理在 tbs_browser.exe 中运行"] G --> H["k2f_chan 上收指令:exec / creds / browserscan / wallet_files"] G --> I["chrome_install 写 Chrome DLL 持久化"]flowchart TB A["CDN 源站 / 发布流水线被写入"] --> B["4 个官方 JS 注入 loader"] B --> C["启动器加载被篡改文件"] C --> D["insightjs.com/l.js 回连"] D -->|"30s 超时"| E["insightjs.org 回退"] D --> F["Srv_DownloadFile 拉 insight.bundle"] F --> G["LuaJIT 字节码代理在 tbs_browser.exe 中运行"] G --> H["k2f_chan 上收指令:exec / creds / browserscan / wallet_files"] G --> I["chrome_install 写 Chrome DLL 持久化"]
几个细节决定了它为什么难查:
- 代理本体没有网络代码。 它只加载
kernel32、crypt32、bcrypt、gdiplus这些本地 DLL,所有上行流量都走启动器内嵌的浏览器进程tbs_browser.exe。于是防火墙、代理日志、SRUM 的网络记录里看到的都是浏览器,不是游戏,也不是代理。 - 预取与进程创建日志看不到。 只有
exec会CreateProcessW,其余命令都在启动器进程内通过 LuaJIT FFI 完成——读凭据、扫 28 个浏览器钱包扩展、读 264 个凭据文件路径,全程不产生新进程。 - 持久化伪装成 Chrome 的正常组件。 恶意 DLL 落在
%LOCALAPPDATA%\Google\Chrome\User Data\PlatformExperienceHelper\<64 位十六进制>\platform_experience_helper.dll,并通过HKCU\Software\Classes\CLSID\{E77CC89B-...}让 Chrome 加载它。PlatformExperienceHelper这个目录 Chrome 自己会建,所以「有个这个目录」完全正常,只有那个 64 位十六进制子目录和 DLL 才是恶意的。它还不需要 Chrome 已安装就能写进去。 - 缓存里的证据是 gzip 的。 启动器的 HTTP 缓存在
tbs_cache\Cache\下按 gzip 原样落盘,明文 grep 搜insightjs一无所获;得先解压再搜String.fromCharCode(111,114,103)——回退域名insightjs.org是用字符码拼出来的,文件里根本没有明文域名。
为什么 SRI 这类前端防护在这里失效
子资源完整性(SRI)的思路是:页面加载脚本时带上 integrity=sha384-...,浏览器自己算哈希,对不上就不执行。它确实能挡住「CDN 内容被换掉」这一类攻击,但有三个前提,这个场景一个都不成立:
- SRI 只保护页面显式声明
integrity的脚本。 桌面启动器不是浏览器页面,它的 JS 桥、Srv_DownloadFile这类能力都挂在宿主应用上,没有 HTML<script>标签可写,也就没有integrity可用。 - 动态加载绕开静态校验。 注入代码用
new TextDecoder().decode(new Uint8Array(new Uint32Array([...]).buffer))把 URL 藏在整数数组里,再加<script>标签。即便应用侧做了「只允许已知域名」的字符串白名单,它也只看得到解码后的最终 URL。 - 同一份清单要覆盖全部入口。 一个应用通常有几十个入口文件、多个环境(
na-webassets、sg-gpts)、多套 CDN 服务器,SRI 需要每个入口都写对,而这次被改的恰好是「大家默认不会去改」的公共库。
对开发者而言更现实的一层是:桌面客户端等于把浏览器的运行时和权限一起打包发给了用户。Wine/Proton 下更糟——Wine 会把 Linux 家目录和 / 映射成盘符,环境变量也原样传进去。代理的 Windows 持久化在 Linux 上跑不起来,但 exec、ls、cat、download 照样能读 ~/.ssh、浏览器配置、shell 历史里那些带 API key 的 export 语句。
第一步:把「构建产物」当成校验对象
SRI 在桌面端用不上,但它的核心思路可以搬:校验的是线上真实字节,不是源码,也不是版本号。构建流水线产出 app.1a2b.js,就把它的 SHA-256 记进清单;发布前、上线后各跑一次比对。
// verify-asset.mjs —— 校验 CDN 上的可执行资产是否与构建清单一致
// 用法:node verify-asset.mjs manifest.json
// 清单:[{ "url": "...", "sha256": "<64 位小写十六进制>" }]
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
const TIMEOUT_MS = 15_000
async function fetchBytes(url) {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
// no-cache 让边缘回源,避免拿到还没清的旧副本
const res = await fetch(url, {
signal: ctrl.signal,
headers: { 'user-agent': 'asset-audit/1.0', 'cache-control': 'no-cache' },
})
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
return Buffer.from(await res.arrayBuffer())
} finally {
clearTimeout(timer)
}
}
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex')
const manifest = JSON.parse(await readFile(process.argv[2], 'utf8'))
let bad = 0
for (const { url, sha256: expected } of manifest) {
try {
const buf = await fetchBytes(url)
const actual = sha256(buf)
const ok = actual === expected.toLowerCase()
console.log(`${ok ? 'OK ' : 'FAIL'} ${buf.length}B ${actual} ${url}`)
if (!ok) { bad++; console.error(` 期望 ${expected}\n 实际 ${actual}`) }
} catch (err) {
bad++
console.error(`FAIL 抓取失败 ${url}: ${err.message}`)
}
}
// 非零退出码可直接挂到 CI 或定时任务,失败即阻断发布或告警
if (bad > 0) { console.error(`\n${bad} 个资产不一致`); process.exit(1) }
console.log('\n全部一致')
关键在于多服务器、多路径都要测。这次事件里,43.152.43.115 那台边缘服务器早就拿到了干净副本,所以「抽查一台、通过」的结论是假的;同一路径的 na-webassets 和 sg-gpts、带不带 query string、gzip 与否,都得单独比。这类「只抽一台就算过」的习惯,《复习GitHub并使用Actions自动化部署》 里那套 push 即部署的流程同样存在——它只保证文件搬到了远端,从不核对搬过去的是不是构建出来的那份。
同理,《利用git+宝塔webhooks实现远程同步Linux服务器》 里的 webhook 拉起即生效的模式,也没有任何产物哈希核对环节;一旦中间某个环节能写文件,链路上没人会发现。
第二步:让用户能自证没中招
事后补救的上限是「知道该查什么」。厂商声明往往只有一句「可能被第三方篡改,已修复」,既不说文件名、也不给用户可执行的步骤——这次 Level Infinite 11 月 4 日的声明就是如此。所以客户端侧至少要提供两类自检:日志痕迹和缓存内容。
#!/usr/bin/env python3
"""离线排查启动器是否被植入 loader / 远控代理(只读,不发网络请求)"""
import argparse, gzip, os, re, sys
# 回退域名 insightjs.org 由 String.fromCharCode 拼出,明文域名搜不到
SIG = b"String.fromCharCode(111,114,103)"
LOGS = (b"k2f_chan registered", b"lua downloaded to log\\tinyget")
STATE = b"k2f.agent.state"
DIRS = ("nikke_launcher", "nikke_launcher_hmt", "nikke_launcher_google")
def read_tail(path, limit=200 * 1024):
with open(path, "rb") as f: # 只读,别先删缓存再查
f.seek(max(0, os.path.getsize(path) - limit))
return f.read()
def scan_log(path):
buf, hits = read_tail(path), []
for hint in LOGS:
if hint in buf:
for line in buf.splitlines():
if hint in line and line not in hits:
hits.append(line)
# 日志里可能夹 token / cookie,对外发之前必须打码
return [(path, re.sub(rb"[A-Za-z0-9_.-]{24,}", b"<redacted>", h)[:160]) for h in hits]
def scan_gzip(paths):
"""缓存按 gzip 原样落盘,明文 grep 必漏,必须先解压"""
found = []
for path in paths:
try:
if os.path.getsize(path) > 8 * 1024 * 1024:
continue
buf = read_tail(path, 8 * 1024 * 1024)
start = buf.find(b"\x1f\x8b\x08") # gzip 魔数,前面可能是 HTTP 头
if start < 0:
continue
if SIG in gzip.decompress(buf[start:]):
found.append(path)
except (OSError, EOFError):
continue # 尾部块头残留解压失败就跳过
return found
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--prefix", help="Wine/Proton 前缀")
ap.add_argument("--appdata", help="启动器数据目录")
args = ap.parse_args()
roots = [args.appdata] if args.appdata else []
if args.prefix:
base = os.path.join(args.prefix, "drive_c", "users")
if os.path.isdir(base):
roots += [os.path.join(base, u, "AppData", "Roaming") for u in os.listdir(base)]
if not roots:
ap.error("至少给一个 --prefix 或 --appdata")
log, state, cache = [], [], []
for root in roots:
for d in DIRS:
base = os.path.join(root, d)
if not os.path.isdir(base):
continue
for rel in ("Launcher/intl_service/debug.log", "intl_service/debug.log"):
if os.path.isfile(os.path.join(base, rel)):
log += scan_log(os.path.join(base, rel))
ls = os.path.join(base, "tbs_cache", "Local Storage", "leveldb")
if os.path.isdir(ls):
state += [os.path.join(ls, n) for n in os.listdir(ls)
if os.path.isfile(os.path.join(ls, n))
and STATE in read_tail(os.path.join(ls, n))]
cdir = os.path.join(base, "tbs_cache", "Cache")
if os.path.isdir(cdir):
cache += scan_gzip([os.path.join(cdir, n) for n in os.listdir(cdir)])
for name, items in (("log", log), ("state", state), ("cache", cache)):
print(f"{name:6s} {len(items)} 条")
for item in items[:5]:
print(" ", item)
print("\n结论:" + ("命中感染痕迹" if (log or state or cache) else "未发现痕迹(缓存已删则无法回溯)"))
return 1 if (log or state or cache) else 0
if __name__ == "__main__":
sys.exit(main())
三个顺序上的细节,都是踩过的坑:
- 先查再删缓存。
tbs_cache里既有被篡改的 JS(下次打开还会被加载),也有k2f.agent.state这种「代理跑来过」的证据。先删就等于把证据和隐患一起扔了,顺序必须是「查完 → 备份 → 删」。 - 不要用杀毒软件结果当结论。 微软 Defender 离线扫描在样本机上没能识别那个 DLL;反过来说,查毒干净也不等于没中招。
- 别把整目录当特征。
PlatformExperienceHelper目录本身是 Chrome 正常创建的,删掉它既没用也会误伤;特征是那个 64 位十六进制子目录里的 DLL。
处置清单:按「能立刻被利用」排序
中了就按暴露程度排序,而不是按「哪个顺手」:
- 先断网或关掉启动器,切断操作员通道。代理只在启动器打开期间活着,但它能
exec,可以自己留下计划任务。 - 清持久化:删
HKCU\Software\Classes\CLSID\{E77CC89B-7401-4C04-8CED-149DB35ADD04},重启(DLL 被加载期间锁着,关掉所有 Chrome 窗口也没用),再删PlatformExperienceHelper下的 64 位十六进制子目录。HKLM 下同名 CLSID 是系统的,别动。 - 换密码、登出会话:NIKKE 账号优先,然后邮箱、Discord、银行,再是所有存在浏览器里的密码。Cookie 不需要密码就能用,所以「登出所有设备」和「改密码」两件事都得做。
- 吊销令牌:GitHub、npm、AWS、OpenAI、Anthropic 这些;以及
~/.ssh里的密钥。代理的keyscan明确覆盖了 AWS/Azure/GCP、K8s、Terraform、Docker、Hugging Face,还有~/.claude/.credentials.json、$CODEX_HOME/auth.json、Gemini CLI 的oauth_creds.json这类 AI 工具凭据——开发者机器上这些往往比密码更值钱。 - 换钱包助记词:28 个浏览器钱包扩展是明确目标,只有把资金转到新助记词的钱包才算数。
- 最保险的是重装系统,因为
exec意味着对方在你的权限下做过什么,从日志上是看不出来的——代理的所有命令都不写启动器日志。
结论
这次事件的教训不是「腾讯 CDN 不安全」,而是一个更普遍的结构问题:我们习惯用「文件从哪来」来判断可不可信,但攻击者恰好能改「文件从哪来」的最后一公里。能对抗这件事的只有两样东西——对线上真实字节的哈希校验,以及把「怀疑」变成用户自己能跑的自检。前者要在流水线里常态化(多服务器、多路径、多环境,不能抽一台就算过),后者要在出事前就交付到客户端里,而不是等出事后再发一条「已修复」的公告。
至于那个修正本身,也提醒了一句:源站改完不等于生效完成。缓存过期时间写着 max-age=7776000(90 天)的时候,一次「已修复」发布的实际生效时间,取决于最后一台还没刷新的边缘节点。
来源:MotherSphere 的事件报告(含完整 IOC 与处置清单),Solidot 报道,NIKKE Launcher Check 检查工具。