本研究所有技术分析工作在隔离沙箱环境中完成。涉及的反编译、字符串解码、协议逆向和行为模拟均仅限于对已公开客户端资源的学术研究。研究过程中未对 deadshot.io 的生产服务器发起任何攻击性请求,未篡改或干扰任何在线玩家的正常游戏体验,未获取或存储任何用户的个人信息。所有技术手段和发现仅供安全研究与教育参考,不构成对任何形式的作弊工具的支持或鼓励。
一、"这不就是个 .io 小游戏吗"
第一天的感觉是乐观的。极度乐观。
打开 DevTools → Network → Preserve log → 输入 deadshot.io → 导出 HAR。47MB,494 条请求。Python 脚本逐条解析:
from collections import Counter
har = json.load(open('deadshot.io.har'))
hosts = Counter(e['request']['url'].split('/')[2] for e in har['log']['entries'])
# deadshot.io → 207 条 (游戏主站)
# hb.vntsm.com → 48 条 (Venatus 广告竞价)
# pixel.rubiconproject.com → 15 条 (Rubicon DSP)
# ad-delivery.net → 10 条
# matchmaking.deadshot.io → 3 条
# na_rp.deadshot.io → 5 条 (北美延迟中继探测)
# error.deadshot.io → 8 条 (反作弊上报)
所有资源在十分钟内列了出来:
final.pkg 780KB entropy 8.000 ← 完全加密
wasm_bg.wasm 63KB Rust/wasm-bindgen
draco_decoder.js 754KB Google 网格解压
basis_transcoder.js 500KB KTX2 纹理
weapons/ vector.glb 86KB 冲锋枪
ar2.glb 93KB 突击步枪
awp.glb 158KB 狙击枪
shotgun.glb 38KB 霰弹枪
character/ tuxedonew.glb 811KB
female compressed gltf 997KB
maps/neon/ out.drc 2.7MB (Draco压缩地图)
lightmap0.ktx2 8.1MB (KTX2烘焙光照)
skybox.drc 2.3MB
audio/ 8条环境音轨 mp3
Three.js r124。 路径打在 HAR 里:r124/examples/js/libs/basis-ktx2/basis_transcoder.js。版本号直接写了。源码静态没加密。
接下来是网络协议。WebSocket 二进制帧结构直接可读:保留字节 + 消息类型 + 有效载荷。7 字节的输入心跳 [00 01][按键:2][yaw:2][seq:1],float32 的实体位置,MessagePack 的匹配请求。
// 匹配请求 (MessagePack over WSS)
{type:"matchmake", region:"North America", lpm:-1, sgr:0.3288}
// 服务器返回
{ip:"f3a9a942c3df006974e09ce9affe8f82", port:80, r:"token..."}
聊天用 Xor 0x80——任何会用 Python 的人三分钟就能破:
bytes(b ^ 0x80 for b in encrypted) # → "Player joined the lobby"
# 还泄露了皮肤系统结构:
# [{"name":"default","weapon":"ar","wear":0}, ...]
区域延迟探测也极其直白:POST pingplx 到 {na,eu,as,sa,in,au}_rp.deadshot.io/rp,期望返回 pong。而且是 HTTP(非加密)。这些中继服务器(144.202.58.157 / 45.76.17.163)是直接暴露的 Vultr VPS,连 Cloudflare 都没套。
到了这个节点,我的想法是"这东西撑不过今天"——一个 Canvas 覆盖层,读一下 Three.js scene graph 取实体坐标,camera.viewMatrix 做投影,画框,按右键自瞄。不就是个网页游戏吗。
二、第一堵墙:代码里没有字符串
Three.js r124 的渲染循环是公开的。WebSocket 协议是明文二进制的。那挡在中间的只剩下 925KB 的引导脚本。
打开它,第一眼就知道不对。单行,无缩进,无换行,所有 token 紧挨。不是 UglifyJS——是 javascript-obfuscator 的最高配置。
三个手段叠在一起:
1. 字符串不以明文存在。 通常的混淆有一个 _0x3f7a 数组,索引引用。但这套用了 FNV-1a 哈希直接比较——部分字符串从未被还原成原文,直接用哈希值判断语义。你在代码里搜不到 "Function.prototype.toString" 或 "WebSocket" 或 "getContext"——因为它们从未以字符串形式出现在代码里。
2. 解码器有三层回退。 TextDecoder → Node.js Buffer.from().toString('utf-8') → 手写 UTF-8 变长解码。为什么一个浏览器游戏需要 Node.js 路径?因为这段代码是同构的——浏览器和服务器跑同一份。HTML 顶部 var isServer = false; 是证据。服务端是 Bun --compile 打包的可执行文件。
3. 控制流被展平,混入不透明谓词。 成百上千个 XJvoTF > 0x3 ? ... : ... 式的恒真/恒假分支,让 AST 级分析几乎不可能。
我们选择了一条暴力路线:把代码扔进受控的沙箱跑起来。
Node.js vm 模块,mock 了 document、window、TextDecoder、fetch。Hook 了字符串解码器 FA4YXOv、createElement、XHR.open、WebSocket 构造函数、addEventListener。把引导代码的前 ~8000 字节扔进去,强制转动一轮事件循环。
不到 10 秒,沙箱吐出约 340 条真实字符串。包括反作弊的全部关键字:
reportTamper, tamper, tampered, guardInit
clean-realm:init, :descriptor-missing
MutationObserver, observe, subtree, childList
SHA-256, digest, verify, importKey, wrapKey
RSA-OAEP-256, AES-GCM, RSA-OAEP
hardwareConcurrency, deviceMemory, getTimezoneOffset
webdriver, userAgentData, brands, mobile
perf:pkgindex-load, runtime:init, startup
还捕获了两个真实网络请求和一个硬编码哈希:
SHA-256: 1d21d6659703dd2f48cb985d0947820e77c29e511bbbf06d19c37c4a6040bef5
RSA exponent: AQAB (公钥 ~340 字符 base64url)
上报端点: XHR POST https://error.deadshot.io/script
匹配遥测: XHR POST https://error.deadshot.io/matchmaking
→ {"elapsedMs":12274,"mode":"deathmatch","rnd":99378,...} (明文)
关于 final.pkg 的熵。
import collections, math
data = open('final.pkg', 'rb').read()
counter = collections.Counter(data)
entropy = -sum((c/len(data))*math.log2(c/len(data)) for c in counter.values())
# 结果: 8.000 bits/byte
8.0。不是 7.9。恰好是理论上限。 英文文本约 4.0-5.0。Gzip 的 JS 约 6.5-7.0。AES 密文约 8.0。final.pkg 就是 AES 密文。
file 命令报 data。文件中找不到任何长度 ≥6 的可读 ASCII 字符串——扫描整个 780KB 文件,1259 个"字符串"全是噪声碎片:u|,1<SA、B"ejA5、IQp!i]。
wasm_bg.wasm 的 Section 解析确认解密机制:
Export: untransform ← 解密函数
Export: func20 ← 热路径
Export: __wbindgen_* (x4) ← Rust/wasm-bindgen 胶水
Data Section: 54709 bytes ← 54KB 密钥/查找表
到了这个节点,我们觉得自己已经"拥有了"它。协议在手,引擎确认,反作弊字符串全解码,加密方案已确证。剩下不就是绕过吗?
三、第二堵墙:游戏在你动手之前自毁
第一个攻击脚本是 Tampermonkey。思路直接:@run-at document-start(比页面任何脚本都早)装三个钩子:
- XHR 拦截——阻断 error.deadshot.io
- MutationObserver 替换——过滤我们自己的 DOM(
_dt前缀) - Clean Realm 感染——劫持
createElement('iframe'),在 contentWindow 上注入我们的 MO 版本,让"干净参照"和我们一样"脏"
语法正确,跑起来了。日志打了:[AC] MutationObserver filtered、[AC] Clean Realm neutralized、[AC] Error XHR blocked。
然后页面一片空白。
不是渲染失败。"页面空白"是游戏根本没有启动。Canvas 不存在,WebSocket 没连接,所有 JS 停在初始化的某一帧。一个叫 vn0OD9() 的函数在引导阶段从 no-op 被重定义为真正的检查器,发现 XMLHttpRequest.prototype.send 不再是原生对象——typeof 还是 "function",但引用已不同。下一行它把 PL0Ie9y 设成了真值。从这一行起,游戏引擎整个短路。 不渲染,不发送,不接收。自毁完成。
然后试了 document-end——游戏初始化完再装钩子。XHR 成功了。但其他原型方法全部被封印:
WebSocket.prototype.addEventListener → 只读
WebSocket.prototype.onmessage → 不可重定义
HTMLCanvasElement.prototype.getContext → 只读
早装 → 游戏自杀。晚装 → API 已冻结。没有任何中间窗口。
这就是 V8 层的第一个天花板:游戏在初始化阶段检查并锁定了你要控制的那些 API。你先执行,游戏检测到你。你后执行,游戏不让你动。
同时游戏还有一个防御手段我们没注意到——初始化时将 GL 上下文的方法通过 .bind() 全部缓存:
const gl = canvas.getContext('webgl2');
const _drawElements = gl.drawElements.bind(gl);
// 后续全走 _drawElements(...),永不经过 gl.drawElements(...)
这意味着即便 API 没被封印,你在原型或实例上设置的任何钩子也不会被调用——绑定函数持有对原始原生函数的直接引用。
四、V8 层唯一的突破口:在调试器里暂停游戏
既然正常执行流程赢不了,换思路——暂停游戏,在调试器里偷看它的内存。
Chrome Manifest V3 扩展的 chrome.debugger API 底层是 CDP(Chrome DevTools Protocol)。CDP 的权力层级高于任何 JavaScript:在任意函数调用时暂停、读调用栈的作用域链、枚举局部变量、在全局上下文执行表达式、拦截网络。
这一切在 V8 调试器层运行——游戏的 vn0OD9() 监控 JS 层,看不到 CDP。
实际操作:
一、获取原生函数对象 ID。 Runtime.evaluate 在主世界执行 WebGL2RenderingContext.prototype.uniformMatrix4fv,拿回 V8 堆中的唯一 objectId。
二、设条件断点。 Debugger.setBreakpointOnFunctionCall,条件 ((self.__dc=(self.__dc||0)+1)%2000===0)——每 2000 次调用才暂停一次,否则游戏直接卡死。
三、断点命中,枚举作用域链。 CDP 暂停事件携带 callFrames[0].scopeChain——完整的局部变量绑定。调用栈第一帧是 SingleUniform.setValueM4(Three.js 内部函数,JavaScript 代码,不是原生函数)。Runtime.getProperties 遍历其局部作用域:
gl: WebGL2RenderingContext
v: Matrix4
cache: Array(16) ← 这里!
elements: Array(16)
四、读数组。 cache 是长度 16 的普通 JS 数组,存储上一次上传的矩阵。元素 [12]、[13]、[14] 是矩阵第四列——物体在世界空间中的 (x, y, z)。Runtime.callFunctionOn 逐个读取:
MATRIX cache: 0 17.5 1
MATRIX cache: -5 4.39 -301.38
MATRIX cache: 49.06 6.11 12.84
MATRIX cache: 13.41 -12.4 -71.75
MATRIX cache: -13.14 4.62 25.28
8 名玩家,稳定追踪 5-7 个实体。去重 3 米内,过期 3 秒。
零代码修改。 混淆没用——不读字符串。WASM 加密没用——不解包。vn0OD9() 没用——不碰原型。Clean Realm 没用——不污染全局。MutationObserver 没用——不改 DOM。CDP 在 V8 里面,但比 JS 低一个抽象级。
五、在 V8 里,你能做的就这么多
实体坐标有了。只剩一步:把 (x, y, z) 投成 (sx, sy)。需要相机矩阵。
我们试了九个方案。
方案一:内容脚本用 gl.getUniform(viewLoc, buf) 读 GPU 上的 view matrix。结果:Float32Array 全零。
方案二:CDP Runtime.evaluate 在断点暂停时读。uniform 在断点触发时尚未被写入——断点在 uniformMatrix4fv 执行前触发。
方案三:Debugger.stepOver 执行后再读。原生函数 stepOver 不产生 paused 事件。
方案四:CDP Runtime.evaluate 在非暂停期定时读。Service Worker 的 setInterval 在 Manifest V3 下生命周期不可靠。我们用 setTimeout 链和 chrome.alarms 都试了——均不稳定。
方案五:evaluateOnCallFrame 在调用帧里搜各种可能的局部变量名(value、v、t、e、data、matrix、cache)——除了 cache(返回实体坐标),其余都是 undefined 或 NaN。
方案六:深度缓冲 gl.readPixels(DEPTH_COMPONENT)——隔离世界限制,全零。
方案七:颜色缓冲 gl.readPixels(RGBA)——同样全零。
方案八:在调用帧作用域链中找包含相机数据的其他变量——无。
方案九:用 Runtime.evaluate 在主世界做全局扫描 document.querySelectorAll('canvas') → getContext('webgl2') → getUniform——同样因为隔离世界限制返回零。
九个方案,零个成功。
根因被定位到一个 Chromium 底层行为:getParameter(CURRENT_PROGRAM) 在隔离世界正常工作。getActiveUniform 正常工作。getUniformLocation 正常工作。唯独 getUniform(program, location, buffer) 返回全零。程序结构信息可以通过,GPU 数据被截断。
这是 V8 层的天花板——不是你不够聪明,是浏览器就给你开了这么大一扇窗。
六、假如我们走出 V8
当前位置:手里有实体世界坐标,但没有相机。知道敌人位置,不知道屏幕像素。
所有 V8 内的攻击向量都堵死了。但如果走出 V8——进入操作系统层、浏览器二进制层、或纯粹的视觉推理层——这道题会完全不同。
系统级钩子(OS Layer)
一个独立进程。task_for_pid + vm_read(macOS)或 ReadProcessMemory(Windows)直读 Chromium 的内存空间。定位 WebSocket 接收缓冲区的内存地址——服务端下发的实体坐标就在那里,float32 明文排列。不需要 CDP,不需要断点,不需要枚举作用域链。
相机的 view matrix 在 GPU 驱动层天然可读——glGetUniformfv 或者直接读 Chromium 内部的 uniform 缓冲区映射。这是 C++ 调用,不是 JS 调用——内容脚本的隔离世界限制对它毫无意义。
自瞄的鼠标输入走 CGEventPost(macOS)或 SendInput(Windows)——直接把移动事件注入硬件事件队列。浏览器上层接收到的 movementX/Y 和真鼠标字节级一致。vn0OD9() 在 V8 里——对 OS 进程无感知。
这也是为什么 Valorant 需要 Vanguard(内核级反作弊驱动)。因为用户态的反作弊——无论是 C++ 还是 JavaScript——都无法检测外部的 read-write 进程。
定制 Chromium(Browser Layer)
编译一份修改版的 Chromium。在 third_party/blink/renderer/modules/webgl/ 插入钩子:每个 drawElements/uniformMatrix4fv 调用前后,记录当前 program 的 uniform 值。model matrix 的 column 4 在渲染管线内部天然可读——这是在 C++ 层,不是 JavaScript 层。游戏的 .bind() 缓存、原型冻结、vn0OD9() 检测——全部在 JS 层,对 Blink 引擎内部的 hook 完全透明。
同时关掉内容脚本的隔离世界限制——或直接在 webgl_rendering_context_base.cc 里解除 GPU 数据读取的跨世界屏蔽。做了这一步,gl.getUniform 和 gl.readPixels 在内容脚本里就能正常返回数据——解决我们在这三十个小时里碰到的每一个 V8 层限制。
YOLO / 计算机视觉(Vision Layer)
完全不碰代码。一个独立进程用 CGDisplayCreateImage(macOS)或 Windows Graphics Capture API 以 60fps 捕获屏幕画面,喂给 YOLOv8-nano 做实时目标检测。敌人的 bounding box 由模型直接输出——不需要相机矩阵,不需要世界坐标,不需要 GPU uniform。鼠标偏移 = bounding box 中心 - 画面中心。
YOLOv8-nano 在 RTX 3060 上的推理延迟约 3-5ms,远在 60fps 的 16.7ms 帧预算内。输入通道是屏幕,输出通道是 CGEventPost。全程不经过浏览器进程。
游戏的七层反作弊体系——混淆、WASM 加密、完整性检测、Clean Realm、DOM 监控、指纹、加密遥测——对这个进程来说完全不存在。它们都在 V8 里。
这三个方案的共同点
V8 是非常狭窄的一个攻击面。被 JavaScript 安全模型、浏览器隔离策略、扩展权限模型逐层包裹。任何想在 V8 里"画框"的方案,最终都会碰壁。
但走出 V8 后——不管往下走(OS)还是往上走(Vision),所有这些限制都会瞬间消失。游戏的安全性不是因为它在浏览器里跑——恰好相反,浏览器是这个游戏最强的一层护甲。 这层护甲不是游戏开发者建的——是 Chromium 送的,免费的,默认的。
七、这场对抗告诉我们什么
V8 是最强也是最弱的战场
对于没有系统权限的脚本小子来说,V8 是铜墙铁壁——混淆、加密、完整性检测、API 冻结,层层叠加,让"装个 Tampermonkey 就能透视"几乎不可能。deadshot.io 在防御这个级别的攻击者方面做得非常出色。
但对于有 OS 权限或编译工具的团队来说,V8 只是一个需要绕行的障碍,不是一道真正的防线。你只需要走到 V8 外面,一切都不一样了。
分层防御的数学
七层防御,没有一层是完美的。但每一层都制造摩擦——你需要为每一层设计专门的绕过方案,而这些方案彼此之间可能互相冲突。
这不是加法,是乘法。是摩擦系数的累积。deadshot.io 把这七层叠在了一个 .io 游戏上——这个预算级别通常不会看到这样的安全投入。值得尊敬。
浏览器是免费的纵深
Chromium 的隔离世界机制是我们失败的直接原因。它不属于游戏的任何一层反作弊——它是浏览器自己加的,免费的,默认的。对于需要防御的系统来说,这意味着 Web 平台天生就比 Native 平台多一层保护。这是一个被严重低估的事实。
致谢与研究声明
本文所有技术分析基于对 deadshot.io 公开客户端资源的合法研究,全部在隔离沙箱环境中完成。文中讨论的"V8 外"攻击方案(系统级钩子、定制浏览器、YOLO 视觉模型)为基于公开技术的推演性分析,未实际执行。本研究不包含任何服务端攻击、未授权访问或用户数据获取。所有内容仅供安全研究、浏览器架构理解和反作弊教育参考。不鼓励或支持对任何在线游戏的作弊行为。