如何伪造 AppTicket 解锁 Steam 游戏:OpenSteamTool 利用 SteamDRMP off-by-four 漏洞原理深度剖析
【免费下载链接】OpenSteamToolOpen Source Steam Unlocker项目地址: https://gitcode.com/gh_mirrors/op/OpenSteamTool
想知道 OpenSteamTool 如何让没买过的游戏也能正常启动吗?这个开源 Steam 解锁工具的核心武器,是一个被称为SteamDRMP off-by-four的本地票据解析漏洞——它允许 OpenSteamTool 在不注入游戏进程的情况下伪造 AppTicket,绕过 DRM 授权。本文从零讲起:AppTicket 是什么、漏洞为什么叫"off-by-four"、伪造票据的每一字节如何拼接,以及 Denuvo 与 SteamStub 两条路径各自需要什么。读完你会对 Steam 客户端 DRM 体系有一个相当完整的认识。
1. AppTicket 是什么?为什么它是 DRM 的命门
Steam 的 DRM 校验逻辑大致是:
- 游戏(或其 DRM 模块,如 SteamDRMP、Denuvo)向 Steam 客户端发起 IPC 请求
GetAppOwnershipTicket; - 客户端返回一张AppTicket——一张由 Valve 服务器私钥签名的"所有权凭证";
- DRM 层用公开的 Valve 公钥验证签名,再从中解析出AppId和SteamID;
- 三者都对得上 → 授权通过,游戏运行。
从票据结构上就能看出关键信息的位置,见 AppTicket.h:
偏移 8 → SteamID 偏移 16 → AppId 票据末尾 128 字节 → 签名(kAppTicketSignatureSize = 128)所以只要你能让 DRM 层读到一个 AppId 是目标游戏的票据,且签名校验通过,就等价于"拥有"了该游戏。难点在于:签名由 Valve 私钥生成,无法凭空捏造。
OpenSteamTool 的破局思路是:不造新票据,只把一张真实票据"偷梁换柱"。
2. 漏洞原理:为什么"off-by-four"能让伪造签名通过?
漏洞位于 SteamDRMP 解析票据的流程中。正常流程应该是:
① 读取整张票据 → ② 用 Valve 公钥验证签名(签名只覆盖票据前 N 字节)→ ③ 签名合法后,再按偏移提取 AppId、SteamID → ④ 校验 AppId 是否等于"请求方 AppId"。
而 SteamDRMP 的实际实现是:
- 读取票据的签名前正文,用公钥验签;
- 验签通过,认为票据可信;
- 直接按固定偏移读取正文里的 AppId,与游戏自身的 AppId 比对;
- 比对成功 → 授权通过。
漏洞在于第 3 步:它信任的"票据里的 AppId",和它校验的"请求 AppId",其实可以被中间人篡改——只要签名校验时读的范围与提取 AppId 时读的范围不一致,且这个不一致恰好是4 字节(一个 AppId 的大小)。这就是 off-by-four:
- 验签时,验证器读到"签名起点"为止的字节;
- 提取 AppId 时,却从另一个偏移读,且多读了 4 字节——这 4 字节原本在签名覆盖范围之外,但被当作 AppId 来用。
换言之,签名只保护到 A 处,但 DRM 却从 A+4 处读 AppId。这 4 字节的"信任盲区",正好塞进任意一个我们想伪造的 AppId。
3. OpenSteamTool 的伪造流程:三步拼接法
理解了原理,看代码就一目了然。核心实现在 ForgeLocalAppOwnershipTicket:
第 1 步:取一张"可信源票据"
// src/Utils/Tickets/AppTicket.cpp std::vector<uint8_t> source = Hooks_Decryption::GetCacheAppOwnershipTicket(kLocalAppTicketSourceAppId);这里kLocalAppTicketSourceAppId = 7。AppId 7 是一张 Steam 客户端本地必然存在的缓存票据(通过 hookConfigStore::GetBinary从apptickets\7读取,见 GetCacheAppOwnershipTicket)。它签名真实、结构完整,是完美的"空白画布"。
第 2 步:精确截断——切掉原签名,只保留签名覆盖的正文
const size_t signedSize = source.size() - kAppTicketSignatureSize; ticket.insert(ticket.end(), source.begin(), source.begin() + signedSize);第 3 步:在签名与正文之间"塞"4 字节目标 AppId
ticket.insert(ticket.end(), appIdBytes, appIdBytes + sizeof(AppId_t)); // ← 关键 4 字节 ticket.insert(ticket.end(), source.begin() + signedSize, source.end());最终伪造出的票据布局:
┌──────────────────────────────────┬────────┬──────────────────┐ │ 源票据正文(签名真实覆盖) │ 目标 │ 源票据签名 │ │ SteamID / 原 AppId=7 / ... │ AppId │ 128 字节 │ └──────────────────────────────────┴────────┴──────────────────┘ ↑ off-by-four:DRM 从签名后 4 字节处读 AppId再看响应侧如何"骗"过解析器——GetAppOwnershipTicket 中精心计算的四个指针:
ticket.totalSize = ticket.data.size() - sizeof(AppId_t); // 故意少报 4 ticket.appIdOffset = ticket.totalSize - kAppTicketSignatureSize; // 指向插入的 AppId ticket.signatureOffset= ticket.appIdOffset + sizeof(AppId_t); // 跳过 4 字节才是签名 ticket.signatureSize = kAppTicketSignatureSize;注意totalSize故意少报 4 字节、signatureOffset故意后移 4 字节——这两个"错位"正是漏洞的利用点:DRM 验签时按totalSize读范围(不包含我们插入的 4 字节),但提取 AppId 时却按appIdOffset读(正好读到我们插入的目标 AppId)。签名合法 + AppId 匹配,授权通过。
最后,这张票据通过 hook 的 IPC 后处理函数 HandlerPost_IClientUser_GetAppOwnershipTicketExtendedData 注入回 Steam 客户端的响应缓冲,游戏完全无感。
4. 三种票据来源与 Denuvo 授权窗口
并非所有游戏都吃"伪造"这一套。OpenSteamTool 在 AppTicketSource 中定义了三级策略:
| 模式 | 适用场景 | 说明 |
|---|---|---|
CredentialStoreOnly | Denuvo 游戏(授权窗口内) | 只读显式票据/缓存票据,不伪造 |
ForgeOnly | SteamStub 游戏 | 无显式票据时直接伪造 |
CredentialStoreThenForge | 通用回退 | 先读缓存,读不到再伪造 |
为什么 Denuvo 不能伪造?因为 Denuvo 的授权是一次"窗口期"行为:游戏进程通过命名管道与 Steam 客户端握手,DenuvoAuth 状态机在检测到 Denuvo 特征后,选定一个"授权管道",只在该管道、且授权未结束时(IsAuthorizedPipe)才放行 SteamID 与票据欺骗。一旦握手结束(第 2 次握手后),窗口关闭。
而 SteamStub 游戏走的是本地 ConfigStore 票据路径,全程离线可伪造,这也是为什么 README 说"SteamStub-only games do not require configuring AppTicket"。
Denuvo 用户怎么办?用项目自带的 extract_tickets 工具:在一台真实拥有该游戏的机器上运行,它会从运行中的 Steam 客户端 dump 出appticket.bin/eticket.bin并输出 hex 字符串,然后写入 Lua 配置:
setAppTicket(1361510, "14000000...") -- 写入 AppTicket setETicket(1361510, "...") -- 写入 ETicket注意 Denuvo 票据有30 分钟有效期,过期后会报88500005,需要刷新。
5. 票据从哪里读、写到哪里?
Windows 上,票据存储在注册表HKCU\Software\Valve\Steam\Apps\<AppId>下,由 SteamCredentialStore 统一封装。OpenSteamTool 的读写接口:
- 读:
GetAppOwnershipTicketFromCredentialStore(appId)— 仅当 appId 在addappid白名单内才返回; - 写:
WriteAppOwnershipTicket/WriteEncryptedTicket/WriteSteamID— 把 Lua 配置里的 hex 票据落盘。
还有一个巧妙的细节在 GetSpoofSteamID:伪造票据里的 SteamID 必须与 Steam 本地期望的一致,否则授权链路会断。所以工具优先读缓存 SteamID,读不到就直接从票据偏移 8 处解析——"让伪造的票据里写着 Steam 自己相信的那个 ID"。
6. 快速上手:从零到解锁
- 构建:项目根目录运行
build.bat(需 Windows + CMake 3.20+ + VS2022); - 将
dwmapi.dll、xinput1_4.dll、OpenSteamTool.dll复制到 Steam 根目录(steam.exe同级); - 在
<Steam>\config\lua\下创建 Lua 脚本,addappid(你的AppId)即可解锁; - SteamStub 游戏到此结束;Denuvo 游戏则需补
setAppTicket+setETicket。
配置样例见 opensteamtool.example.toml。
7. 技术要点回顾
- off-by-four 本质:验签范围与 AppId 提取范围错位 4 字节,形成信任盲区;
- 伪造 ≠ 生成:全程不伪造签名,只"移植"一张真实票据的签名;
- AppId 7 是钥匙:本地必然存在的缓存票据,提供结构完整、签名真实的源数据;
- 指针操纵是核心:
totalSize少报 4、signatureOffset后移 4,恰好对应漏洞的两个错位点; - 两条 DRM 路径:SteamStub 走本地 ConfigStore(可伪造),Denuvo 走管道授权窗口(需显式票据)。
8. 声明与延伸阅读
⚠️ 本项目仅供研究与教育目的。请遵守当地法律、平台服务条款与软件许可。
相关源码入口:
- 伪造核心:src/Utils/Tickets/AppTicket.cpp
- IPC 注入点:src/Hook/Hooks_IPC_ISteamUser.cpp
- ConfigStore Hook:src/Hook/Hooks_Decryption.cpp
- Denuvo 授权状态机:src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp
- 票据提取工具:tools/extract_tickets/extract_tickets.cpp
- 完整文档:README.md / README_ZH.md
【免费下载链接】OpenSteamToolOpen Source Steam Unlocker项目地址: https://gitcode.com/gh_mirrors/op/OpenSteamTool
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考