Goldberg 模拟器新版 Steam Auth Ticket 深度解析:格式、GC Token、签名与配置实践

原创2026-10-03 19:29:271,338 阅读
文章标签:游戏开发逆向工程

Goldberg 模拟器新版 Steam Auth Ticket 深度解析:格式、GC Token、签名与配置实践

本篇技术指南围绕 Goldberg Steam Emulator 分支(gbe_fork)中全新的 App Ticket(应用认证票据)格式展开,完整剖析新旧票据的二进制布局差异、Game Coordinator(GC)令牌段、DLC 与 License 编码、RSA 签名流程,并结合仓库源码(dll/auth.cpp、dll/dll/auth.h)与配置项(new_app_ticket、gc_token)给出可直接落地的实操方案。读完本文,你将能够理解并复现新版票据的生成逻辑、自行解析任意票据字节流,并掌握在多 DLC 场景下配置票据缓冲区大小的正确姿势。

为什么需要新版 Auth Ticket

旧版票据的核心问题是不含任何有效载荷:它既不记录票据生成时间,也不携带 DLC 列表、AppID 等信息。因此,凡是会在票据内部做校验的模拟器或服务端(例如 Nemirtingas Epic Emu 这类第三方模拟器),都无法接受旧格式的票据。

简单来说,旧版票据只是一串"我是谁"的标识,而新版票据才是"我是谁 + 我拥有什么 + 什么时间生成的"完整凭证。这也是 Goldberg 模拟器引入 enable_new_app_ticket(配置项 new_app_ticket)的根本原因。

新旧票据的形态对比

旧版票据:仅 20 字节

旧版票据的字节布局如下:

4 byte (header) | 4 byte  | 8 byte
0x14 (AKA 20) 00 00 00 | [TicketNumber] | [SteamId]
  • 前 4 字节是魔术头 0x14(十进制 20),即整段数据长度;
  • 中间 4 字节是票据编号;
  • 末尾 8 字节是 SteamID。

这段结构在源码中对应 dll/auth.cpp 的宏定义:

#define STEAM_ID_OFFSET_TICKET (4 + 8)
#define STEAM_TICKET_MIN_SIZE  (4 + 8 + 8)

STEAM_ID_OFFSET_TICKET = 12 正是跳过头 4 字节和 TicketNumber 4 字节后 SteamID 的起始偏移;STEAM_TICKET_MIN_SIZE = 20 则是旧票据的最小长度。旧票据的生成逻辑见 dll/auth.cpp:头字节固定为 0x14,随后写入 SteamID 与自增的票据编号。它不含任何"何时生成、拥有哪些 DLC、启动的是哪个 AppID"的信息——这正是旧票据无法满足严格校验方需求的原因。

GC(Game Coordinator)是什么

GC 即 Game Coordinator(游戏协调器)。在真实 Steam 架构中,它辅助匹配 IP 地址、优化 Matchmaking(匹配)与其他交互,Steam 通常会把这类数据回传。GC 段的作用在于让票据携带更贴近真实 Steam 的上下文信息。

开发者通过编写一个小型应用、在 steam_api.txt 中填入自己拥有的任意 AppID,即可从 Steam 抓取真实票据用于逆向研究,这是文档中给出的原始研究手段。

新版票据的 GC 段:固定 52 字节

GC 段结构如下(见 dll/dll/auth.h 中的 AppTicketGC 结构与 dll/auth.cpp 的序列化实现):

uint32_t STEAM_APPTICKET_GCLen = 20; // Magic header 20
uint64_t GCToken{}; // A unique token for this, can be random or sequential
CSteamID id{}; // our steamId
uint32_t ticketGenDate{}; //epoch time when generated
uint32_t STEAM_APPTICKET_SESSIONLEN = 24; // Magic Header 24
uint32_t one = 1; // dont know yet
uint32_t two = 2; // dont know yet
uint32_t ExternalIP{}; // External ip (Steam usually encrypting these)
uint32_t InternalIP{}; // Internal ip (Steam usually encrypting these)
uint32_t TimeSinceStartup{}; // Seconds since Steam Startup
uint32_t TicketGeneratedCount{}; // how many ticket did you generated since startup
uint32_t FullSizeOfGC = 56; // GC size (52) + 4

字段相加共 52 字节,字节账目如下:

8  = uint64_t 的长度
4  = uint32_t 的长度
4 + 8 + 8 + 4 = 24(去掉头部 4 字节后,这一节的净长度为 20,对应 STEAM_APPTICKET_GCLen = 20)
4 + 4 + 4 + 4 + 4 + 4 + 4 = 28(去掉头部 4 字节后,这一节的净长度为 24,对应 STEAM_APPTICKET_SESSIONLEN = 24)

源码中通过 static_assert(total_size == 52) 在编译期强制保证 GC 段序列化后恰好为 52 字节(dll/auth.cpp),任何平台字节宽度偏差都会直接编译失败,这保证了格式的确定性。

票据主体(Ticket):静态 42 字节 + 动态列表

在 dll/dll/auth.h 中,AppTicket 结构定义了票据主体:

uint32_t TheTicketLenght; // Full lenght of the ticket exluding the padding and the Singature
uint32_t Version{}; // Latest version is 4 so we keep that way
CSteamID id{}; // our steamId
uint32_t AppId{}; // Current AppId that we playing
uint32_t ExternalIP{}; // External ip (Steam usually encrypting these)
uint32_t InternalIP{}; // Internal ip (Steam usually encrypting these)
uint32_t AlwaysZero = 0; //OwnershipFlags? or Might be VAC Banned?
uint32_t TicketGeneratedDate{}; // Epoch Seconds when the Ticket generated
uint32_t TicketGeneratedExpireDate{}; // Epoch Seconds when the Ticket will expire
std::vector<uint32_t> Licenses{}; // our licenses (Usually is 0 or if you own a locked beta that will be it)
std::vector<DLC> DLCs{}; // what DLC we own

DLC 子结构(dll/dll/auth.h):

struct DLC {
    uint32_t AppId{}; // AppId of the DLC
    std::vector<uint32_t> Licenses{}; // Again license what you own, usually 0 or nothing inside
};

关于 Licenses 的误解:所有未公开发行(unreleased to public)的应用都处于某个 License 之下。Steam 通常会设置(或返回)0 来表示"你拥有它"或"你没有对应 License"。需要注意:License 为 0 绝不代表应用是免费的——即使你已购买,票据中显示的 License 依然可能是 0!

序列化时,票据主体的静态字段(Version、SteamID、AppId、内外 IP、AlwaysZero、生成时间、过期时间、License 数量、DLC 数量与 2 字节 padding)合计必须为 42 字节,同样以 static_assert(static_fields_size == 42) 编译期校验(dll/auth.cpp)。随后依次写入 License 数组(每项 4 字节)与 DLC 数组,DLC 数组的元素布局为:

| 4 bytes: app id
| 2 bytes: DLC licenses count
[ 4 bytes × N: DLC license elements ]

完整布局注释见 dll/auth.cpp。

签名与 Padding

Steam 的票据签名长度为 128 字节(源码常量 STEAM_APPTICKET_SIGLEN = 128,见 dll/dll/auth.h)。本项目采用 RSA1 + PKCS1 签名方案,使用的私钥内嵌在 dll/auth.cpp 的 app_ticket_key 字符串中(原始出处为 Detanup01 的 stmsrv 项目 Cert/AppTicket.key)。

签名流程(sign_auth_data,dll/auth.cpp):

  1. 对票据数据做 SHA-1 哈希(mbedtls_sha1,mbedtls 封装见 dll/dll/include.wrap.mbedtls.h);
  2. 初始化 mbedtls 熵源与 CTR-DRBG 随机数上下文;
  3. 解析 PEM 私钥并对哈希后的数据进行 PKCS1 签名;
  4. 将 128 字节签名追加到票据数据末尾(dll/auth.cpp)。

Padding:票据静态字段末尾有一个 2 字节 padding(固定为 0,见 dll/auth.cpp)。文档作者推测这可能是某种标志位,也可能与 VAC 封禁状态相关,但尚未确认其真实含义——目前实现只是固定写入 0。

最小尺寸的推导:128(签名)+ 42(无 DLC、无 License、无 GC 的票据主体)= 170 字节。因此新版票据最小长度为 170,源码中以 STEAM_TICKET_MIN_SIZE_NEW 170 定义并在 getTicket 中校验 cbMaxTicket < STEAM_TICKET_MIN_SIZE_NEW 时直接返回 k_HAuthTicketInvalid(dll/auth.cpp)。

整体装配布局

Auth_Data::Serialize()(dll/auth.cpp)给出了最终票据的完整布局:

带 GC 时:

X bytes: GC 数据块(当前 52 字节)
4 bytes: 剩余 Auth_Data 块长度(= 4 + Y + Z,注意减去整个 GC layout 长度以免把自身算进去)
4 bytes: 票据数据布局长度(= 票据 blob + 4,自身包含在内)
Y bytes: 票据数据 blob
Z bytes: 票据签名(128 字节)

不带 GC 时:

4 bytes: 票据数据布局长度
Y bytes: 票据数据 blob
Z bytes: 票据签名(128 字节)

触发新版票据的配置方式

新版票据默认关闭。默认情况下模拟器对以下 API 均发送旧格式票据:

  • Steam_GameServer::GetAuthSessionTicket()
  • Steam_User::GetAuthSessionTicket()
  • Steam_User::GetAuthTicketForWebApi()

要启用新版票据,编辑配置文件 configs.main.ini(模板见 post_build/steam_settings.EXAMPLE/configs.main.EXAMPLE.ini):

new_app_ticket=1
gc_token=1
  • new_app_ticket=1:启用新版票据生成(对应源码设置项 dll/dll/settings.h 的 enable_new_app_ticket);
  • gc_token=1:额外在票据中附带 GC 段(对应设置项 use_gc_token)。

这两个开关由 dll/settings_parser.cpp 从 main::general 节解析,同时作用于客户端与服务端两份配置。官方发布说明见 post_build/README.release.md。

票据生成时的实际填充值

在 dll/auth.cpp 的 getTicketData 中,新版票据的字段填充逻辑是:

  • Version 固定为 4(当前最新版本);
  • AppId 取当前本地游戏 ID(settings->get_local_game_id().AppID());
  • 内外 IP 暂以回环地址 127.0.0.1(小端 DWORD 形式)填充,源码标注为 TODO;
  • AlwaysZero 恒为 0;
  • TicketGeneratedDate 为当前 Unix 时间戳,TicketGeneratedExpireDate 为生成时间 +24 小时;
  • Licenses 初始化为空后压入一个 0(TODO 标记);
  • DLCs 遍历 settings->DLCCount() 获取的 DLC 列表逐个填充 AppId,DLC 的 License 暂为空(TODO);
  • 启用 gc_token 时,GCToken 为随机整数,TimeSinceStartup 为自模拟器启动以来的秒数,TicketGeneratedCount 为自启动以来生成的票据计数(dll/auth.cpp)。

票据缓冲区大小:1024 还是 2048?

若用户拥有大量 DLC,票据很容易超过 1024 字节。Steam 官方推荐缓冲区为 1024,但文档明确建议:如果游戏 DLC 众多(如 PayDay 2),请将缓冲区设置为 2048。

源码中的缓冲区常量也印证了这一点——dll/auth.cpp 定义 STEAM_AUTH_TICKET_SIZE 256,注释提到 Conan Exiles 无法适配 512 或 128、256 才是合适的尺寸,同时注明 Steam 通常按 1024 发送。即:缓冲区尺寸需按目标游戏的 DLC 数量灵活调整,不存在一劳永逸的"万能值"。

有趣的事实与边界行为

旧票据与新票据的兼容关系

旧票据的头部结构与新版票据的 GC 段开头非常相似。正因如此,SendUserConnectAndAuthenticate 与 beginAuth 目前并没有为解析新版票据编写专门代码(dll/auth.cpp):beginAuth 只按固定偏移读取 SteamID(偏移 12)与票据编号(偏移 8)即可完成认证,根本无需反序列化票据内容。这意味着:

  • 对出站方向(模拟器发给游戏/服务端),新票据携带了丰富的 DLC/时间戳信息,可满足第三方模拟器的校验;
  • 对入站方向(模拟器收到他人票据),旧版解析逻辑依旧够用,因为新旧票据在头部结构上同源。

票据编号的生成

generate_steam_ticket_id()(dll/auth.cpp)从 1 开始自增;编号 0 被保留给 k_HAuthTicketInvalid(认证 API 失败时的返回值),因此实现中显式跳过了 0。beginAuth 还会利用 STEAM_TICKET_PROCESS_TIME = 0.03 秒的去重窗口拦截重复请求,返回 k_EBeginAuthSessionResultDuplicateRequest。

小结

  • 旧版票据 = 20 字节无信息载荷;新版票据 = GC 段(可选,52 字节)+ 票据主体(42 字节起)+ 128 字节 RSA 签名,最小 170 字节;
  • 启用方式:configs.main.ini 中 new_app_ticket=1(与 gc_token=1 搭配);
  • License 为 0 不代表免费,只表示"无额外许可";
  • 多 DLC 游戏请把票据缓冲区上调至 2048;
  • 票据生成、序列化与签名的全部实现集中在 dll/auth.cpp 与 dll/dll/auth.h,配置解析在 dll/settings_parser.cpp,官方发布说明见 post_build/README.release.md。
登录后查看全文
gbe_fork