SteamAutoCrack 静态脱壳技术分析
摘要
Steam 客户端用 SteamStub 包装器保护游戏主程序,在可执行文件里插入 .bind 节,把入口点指向壳代码,并加密主代码节与内嵌的 SteamDRMP.dll。SteamAutoCrack 把破解流程做成流水线,其中脱壳环节复用 atom0s 的 Steamless,全程对文件做静态处理,不执行目标程序。本文沿代码路径还原该脱壳器的六步流程:变体识别、DRM 头解码、附载数据解码、SteamDRMP.dll 解密、代码节解密、PE 重建,并分析 2.x 与 3.x 两代壳在头部定位方式和加密算法上的差异,最后说明该方案的适用边界。
关键词: SteamStub;静态脱壳;PE 结构;AES-CBC;XTEA
1 问题背景
Valve 给 Steam 上架游戏的主程序套了一层 DRM 包装器,社区称 SteamStub。它往 PE 文件里加一个 .bind 节,把 AddressOfEntryPoint 改成壳代码地址。程序启动时先跑壳:解密被加密的代码节、校验 Steam 所有权、检查调试器,然后跳回原始入口点。
壳加密的对象是主代码节和一个内嵌的 SteamDRMP.dll。导入表没有改动,资源节也没有。这个事实决定了脱壳器不需要做 IAT 重建或资源修复。
脱壳有两种做法。一种是动态的,把程序跑起来,等壳自己解密完,再从内存里 dump 并修复。另一种是静态的,把 exe 当数据结构读进来,按逆推出的算法直接解密。Steamless 走的是第二条路。
静态做法要求攻击者先把壳的算法和数据结构逆向清楚。代价换来的好处是确定性:不需要准备运行环境,处理反调试,跑一次就能得到结果。
我在 Steamless.API 与全部 Unpacker 项目里检索过 CreateProcess、LoadLibrary、调试器与模拟执行相关调用,没有命中。唯一的系统 API 调用是 MapFileAndCheckSum,用途是计算 PE 校验和,与执行目标程序无关。
2 项目结构
SteamAutoCrack/
├── SteamAutoCrack WPF 界面
├── SteamAutoCrack.CLI 命令行入口
├── SteamAutoCrack.Core 流水线编排
│ └── Utils/
│ ├── SteamStubUnpacker.cs 脱壳环节
│ ├── EMUApply.cs 模拟器安装
│ ├── GenCrackOnly.cs 产出 Crack Only
│ └── Restore.cs 回滚
├── Steamless.API PE 解析与加密工具
└── Steamless.Unpacker.* 7 个变体插件三个外部依赖来自项目说明:atom0s 的 Steamless(脱壳)、Detanup01 的 gbe_fork(模拟器)、SteamAutoCracks 的 Steam-API-Check-Bypass(兜底)。
Process.cs 把流程切成六步:
1. GenerateEMUGameInfo 生成模拟器游戏信息
2. GenerateEMUConfig 生成模拟器配置
3. Unpack 脱 SteamStub 壳
4. ApplyEMU 安装模拟器
5. GenerateCrackOnly 打包 Crack Only
6. Restore 回滚第三步装载 7 个插件,按 Variant10.x86、Variant20.x86、Variant21.x86、Variant30.x86、Variant30.x64、Variant31.x86、Variant31.x64 的顺序逐个尝试。每个插件先调 CanProcessFile 判断能否处理,能则调 ProcessFile。
3 脱壳流程
以 Variant 3.1 x64 为例。
3.1 变体识别
Main.cs:97 先确认文件存在 .bind 节,再取该节前 0x3000 字节做特征码扫描:
if (!f.HasSection(".bind")) return false;
var bind = f.GetSectionData(".bind").Take(0x3000).ToArray();
var variant = Pe64Helpers.FindPattern(bind, "E8 00 00 00 00 50 53 51 52 56 57 55 41 50");E8 00 00 00 00 是 call $+5,把下一条指令地址压栈,用于位置无关地取当前地址。随后 50 53 51 52 56 57 55 41 50 是 push eax/ebx/ecx/edx/esi/edi/ebp/r8,壳入口保存通用寄存器的固定序列。
FindPattern(Pe32Helpers.cs:149)支持 ?? 通配,逐字节比对并返回首个匹配偏移。
认出 3.x 之后还要分子版本。代码依次匹配三条指令模式:
| 子版本 | 模式 | 指令 |
|---|---|---|
| 3.0 | 48 8D 91 ?? ?? ?? ?? 48 | lea rdx, [rcx+imm32] |
| 3.1 | 48 8D 91 ?? ?? ?? ?? 41 | 同上,后接 41 前缀 |
| 3.1.2 | 48 C7 84 24 ?? ?? ?? ?? ?? ?? ?? ?? 48 | mov qword [rsp+imm32], imm64 |
匹配到的位置后面第 3 字节起的 32 位值就是 DRM 头长度,Math.Abs 后等于 0xF0 才判定为本插件能处理的文件。
3.2 DRM 头解码
3.x 的头固定贴在入口点前 0xF0 字节(Main.cs:208):
var fileOffset = this.File.GetFileOffsetFromRva(EntryPoint);
Array.Copy(FileData, fileOffset - 0xF0, headerData, 0, 0xF0);
this.XorKey = SteamStubHelpers.SteamXor(ref headerData, 0xF0);
this.StubHeader = Pe64Helpers.GetStructure<SteamStub64Var31Header>(headerData);
if (StubHeader.Signature == 0xC0DEC0DF) return true;SteamXor(SteamStubHelpers.cs:42)是滚动异或。数据前 4 字节是初始密钥,从第 5 字节起每 4 字节解密,密钥取前一块的密文:
if (key == 0) { offset += 4; key = BitConverter.ToUInt32(data, 0); }
for (var x = offset; x < size; x += 4) {
var val = BitConverter.ToUInt32(data, x);
Array.Copy(BitConverter.GetBytes(val ^ key), 0, data, x, 4);
key = val;
}这种链式结构让密文任意一块被改动都会影响后续所有块的解密结果。签名 0xC0DEC0DF 是解码正确的判据。2.x 的签名是 0xC0DEC0DE。
签名不匹配时有一条回退路径(Main.cs:220):把 TlsCallbacks[0] 当作 OEP,重新按同一方式定位并解码头部。部分壳把真实入口藏在 TLS 回调里,回退路径覆盖这种情况。
解出的结构即逆向所得的头布局(SteamStubHeader.cs):
| 字段 | 含义 |
|---|---|
OriginalEntryPoint | 加壳前的入口点,重建时写回 |
BindSectionOffset | .bind 节偏移 |
PayloadSize | 附载数据长度 |
DRMPDllOffset / DRMPDllSize | 内嵌 DLL 的位置与长度 |
AES_Key (0x20) / AES_IV (0x10) | 代码节密钥与初始向量 |
CodeSectionStolenData (0x10) | 被移出代码节的前 16 字节 |
EncryptionKeys (4 × uint) | 解密内嵌 DLL 的密钥 |
Flags | DRM 功能开关 |
Flags 的位定义(SteamStubDrmFlags.cs):
NoModuleVerification 0x02
NoEncryption 0x04
NoOwnershipCheck 0x10
NoDebuggerCheck 0x20
NoErrorDialog 0x40脱壳器读 NoEncryption 判断代码节是否需要解密。
3.3 附载数据解码
附载数据位于 入口点 - BindSectionOffset,长度按 16 字节向上对齐。解码仍用 SteamXor,密钥接续 3.2 节返回的值,不是从头起链(Main.cs:265):
var payloadSize = (this.StubHeader.PayloadSize + 0x0F) & 0xFFFFFFF0;
Array.Copy(this.File.FileData, (long)payloadAddr, payload, 0, payloadSize);
this.XorKey = SteamStubHelpers.SteamXor(ref payload, payloadSize, this.XorKey);链式异或的密钥状态要在两步之间传递,否则第二段解出来是乱码。代码把这个返回值一路带到下一步。
3.4 内嵌 DLL 解密
壳内嵌了一个 SteamDRMP.dll,用 XTEA 加密(SteamStubHelpers.cs:75):
const uint delta = 0x9E3779B9;
var sum = (delta * n) & mask; // n = 32
for (var x = 0; x < n; x++) {
v2 = (v2 - (((v1 << 4 ^ v1 >> 5) + v1) ^ (sum + keys[sum >> 11 & 3]))) & mask;
sum = (sum - delta) & mask;
v1 = (v1 - (((v2 << 4 ^ v2 >> 5) + v2) ^ (sum + keys[sum & 3]))) & mask;
}
res[0] = v1; res[1] = v2;这是标准 XTEA 解密,32 轮,delta 取黄金比例常数 0x9E3779B9,四个密钥轮转使用。
外层还包了一层异或链(SteamStubHelpers.cs:101),XTEA 的输出再与前一块密文异或,初始向量是 0x55555555:
var v1 = (uint)0x55555555;
var v2 = (uint)0x55555555;
for (var x = 0; x < size; x += 8) {
var d1 = BitConverter.ToUInt32(data, x + 0);
var d2 = BitConverter.ToUInt32(data, x + 4);
var res = new uint[2];
SteamDrmpDecryptPass2(ref res, keys, d1, d2);
Array.Copy(BitConverter.GetBytes(res[0] ^ v1), 0, data, x + 0, 4);
Array.Copy(BitConverter.GetBytes(res[1] ^ v2), 0, data, x + 4, 4);
v1 = d1; v2 = d2;
}XTEA 提供分组加密,外层异或链把相邻分组绑在一起。两种手法组合起来,单独改动任意一个 8 字节分组都会让后续全部失效。
3.5 代码节解密
这是整个流程的核心。3.x 的主代码节用 AES-256-CBC 加密(Main.cs:407):
var codeSectionData = new byte[section.SizeOfRawData + StubHeader.CodeSectionStolenData.Length];
Array.Copy(StubHeader.CodeSectionStolenData, 0, codeSectionData, 0, 16);
Array.Copy(this.File.FileData, this.File.GetFileOffsetFromRva(section.VirtualAddress),
codeSectionData, 16, section.SizeOfRawData);
var aes = new AesHelper(StubHeader.AES_Key, StubHeader.AES_IV);
aes.RebuildIv(StubHeader.AES_IV);
var data = aes.Decrypt(codeSectionData, CipherMode.CBC, PaddingMode.None);两处反逆向设计需要单独说明。
第一处是被移出的 16 字节。壳把代码节开头 16 字节挖走,存进 DRM 头的 CodeSectionStolenData 字段。这 16 字节在磁盘上的代码节里不存在。CBC 模式下首块的解密依赖前一块密文,缺失首块会让整段解密结果错位。脱壳器必须先把它拼回去再解密。
第二处是初始向量的处理。头部存的 IV 不能直接用作 CBC 起始向量,要先过一次 AES 块变换(AesHelper.cs:103):
public bool RebuildIv(byte[] iv = null) {
if (iv == null) iv = this.m_OriginalIv;
using (var decryptor = this.m_AesCryptoProvider.CreateDecryptor())
return decryptor.TransformBlock(iv, 0, iv.Length, this.m_OriginalIv, 0) > 0;
}TransformBlock 在这里起到一次分组加密的作用,结果写回 m_OriginalIv。壳自己定义了这个变换,逆向者要复现它才能得到正确的起始向量。
NoEncryption 位置位时跳过整段,代码节的原始数据直接落盘。
3.6 PE 重建
Main.cs:438 的 Step6 做四件事。
按配置决定是否删除 .bind 节:
if (!this.Options.KeepBindSection) {
var bindSection = this.File.GetSection(".bind");
this.File.RemoveSection(bindSection);
ntHeaders.FileHeader.NumberOfSections--;
}RemoveSection(Pe32File.cs:294)从 Sections 与 SectionData 两个 List 同步按索引移除,保持两者下标一致。节计数随后减一。
重排全部节:
this.File.RebuildSections(this.Options.DontRealignSections == false);RebuildSections(Pe32File.cs:312)把每个节的 VirtualAddress 与 VirtualSize 对齐到 SectionAlignment,把 PointerToRawData 与 SizeOfRawData 对齐到 FileAlignment,最后按末节的 VirtualAddress + VirtualSize 重算 SizeOfImage。
写回入口点:
ntHeaders.OptionalHeader.AddressOfEntryPoint = (uint)this.StubHeader.OriginalEntryPoint;
ntHeaders.OptionalHeader.CheckSum = 0;入口点从壳代码地址改回加壳前的原始地址。这是脱壳的实质动作,前面所有解密都是为了这一步能成立。
逐节写出文件,命中代码节索引时替换为解密后的数据,其余节照原样输出,最后追加 overlay 数据。
另外两个可选动作。ZeroDosStubData 为真时把 DOS stub 区填零,默认配置下为真。RecalculateFileChecksum 为真时调用 MapFileAndCheckSum 重算 PE 校验和(Pe32Helpers.cs:98),默认配置下为假。
4 变体演进
2.x 与 3.x 在头定位上的差别决定了是否需要反汇编器。
3.x 的头固定贴在入口点前 0xF0 字节,用特征码加固定偏移即可定位,不需要理解壳代码的逻辑。
2.x 的头位置不固定,Variant20.x86/Main.cs 用 SharpDisasm 反汇编 .bind 入口的 4096 字节,在指令流里找两条 mov reg, imm:
disasm = new Disassembler(startPointer, 4096, ArchitectureMode.x86_32, entryOffset);
foreach (var inst in disasm.Disassemble().Where(inst => !inst.Error)) {
if (inst.Mnemonic == ud_mnemonic_code.UD_Imov &&
inst.Operands[0].Type == ud_type.UD_OP_REG &&
inst.Operands[1].Type == ud_type.UD_OP_IMM) {
if (structOffset == 0) {
structOffset = inst.Operands[1].LvalUDWord - this.File.NtHeaders.OptionalHeader.ImageBase;
continue;
}
}
// 第二条立即数给出结构体大小
structSize = inst.Operands[1].LvalUDWord * 4;
structXorKey = 1;
}第一条立即数减 ImageBase 得到头的文件偏移,第二条乘 4 得到头的长度。得到长度后按 856、884、952 三种取值分别映射到三个不同的结构体定义。这是壳在不同时期调整头布局留下的痕迹。
3.x 改为固定偏移后,SharpDisasm 依赖随之移除。这一处改动也说明作者在壳更新时优先选择降低复杂度。
两代壳的加密强度也不同。2.x 的代码节用滚动异或(Variant20.x86/Main.cs:269):
var key = this.StubHeader.CodeSectionXorKey;
var offset = 0;
for (var x = this.StubHeader.CodeSectionSize >> 2; x > 0; --x) {
var val1 = BitConverter.ToUInt32(codeSectionData, offset);
var val2 = val1 ^ key;
key = val1;
Array.Copy(BitConverter.GetBytes(val2), 0, codeSectionData, offset, 4);
offset += 4;
}密钥仍是前一块的值,思路与 SteamXor 一致。3.x 把这一步换成 AES-256-CBC,同一套链式思想从异或升级到分组密码。
5 兜底路径
静态脱壳有边界。游戏可能用的不是 SteamStub,或用了尚未支持的新变体,这时 ApplySteamAPICheckBypass(SteamStubUnpacker.cs:292)接手。
它把 SteamAPICheckBypass 的 32 位或 64 位 DLL 复制到游戏目录,按配置改名为 winmm.dll、version.dll 或 winhttp.dll。目标文件名由目标 exe 的位数决定:
var f = new Pe32File(file);
f.Parse();
if (!f.IsFile64Bit()) dll = Path.Combine(dllPath, "SteamAPICheckBypass_x32.dll");
else dll = Path.Combine(dllPath, "SteamAPICheckBypass.dll");
File.Copy(dll, Path.Combine(Path.GetDirectoryName(file), targetDllName));随后生成 SteamAPICheckBypass.json,写入四类规则:
// 游戏主程序重定向到自身备份
jsonContent[Path.GetFileName(file)] = new { mode = "file_redirect",
to = Path.GetFileName(file) + ".bak",
file_must_exist = true };
// 模拟器 DLL 重定向,带计数控制
jsonContent[apiDllPath] = new { mode = "file_redirect",
to = apiDllPath + ".bak",
file_must_exist = true,
hook_times_mode = mode,
hook_time_n = _SteamAPICheckBypassNthTime };
// 配置目录整体隐藏
jsonContent[steamsettingsPath] = new { mode = "file_hide" };
// 配置文件按次隐藏
jsonContent[steamsettingsFilePath] = new { mode = "file_hide",
hook_times_mode = "not_nth_time_only",
hook_time_n = "1" };配置文件那条带着一行注释:
hook_time_n = "1" // This value is estimated the game checks the config file after the emulator read it作者的判断是游戏在模拟器读过配置之后才去检查配置文件,所以第一次放行,之后隐藏。
脱壳与备份的衔接在 UnpackFile(SteamStubUnpacker.cs:262):
if (File.Exists(Path.ChangeExtension(path, ".exe.bak"))) File.Delete(path);
else File.Move(path, Path.ChangeExtension(path, ".exe.bak"));
File.Move(Path.ChangeExtension(path, ".exe.unpacked.exe"), path);原 exe 备份成 .exe.bak,脱壳结果改回原名。这与上一节生成的 game.exe 到 game.exe.bak 重定向规则对应。脱壳成功时新 exe 直接运行,脱壳失败时由文件系统幻象层兜住校验。
6 能力边界
纯静态方案加上单一目标壳,划定了这个工具的作用范围。
支持的壳只有 SteamStub。项目说明写着 "Fully automatic crack Steam DRM Only Game"。Denuvo、VMProtect、Themida 之类的壳会让全部 7 个插件的 CanProcessFile 返回假,脱壳环节整体跳过。
变体识别依赖硬编码特征码。Steam 修改壳代码后特征码失配,需要人工重新逆向并补丁。3.1.2 子版本晚于 3.1 加入,正是这个原因。特征码方案的维护成本随壳的更新频率上升。
不做 IAT 重建,不做资源修复。SteamStub 加密的是代码节与内嵌 DLL,导入表与资源节保持原样,脱壳器无需处理这两部分。
不做动态 dump。整个流程没有启动目标程序的步骤,所有信息从静态文件中解出。遇到壳在运行时生成的数据,静态方案无法获取。
默认配置偏向可用性。KeepBind 为真、Realign 为假、ReCalcChecksum 为假,脱壳结果与原始文件差异最小,便于配合兜底层运行。需要干净输出时得手动调整配置。
7 结论
SteamAutoCrack 的脱壳环节完整展示了静态脱壳的工程形态。它把壳的逆向结果固化成数据结构定义(SteamStubHeader)与算法实现(滚动异或、XTEA、AES-CBC),按 PE 布局切片处理后重新组装。整个过程不运行目标程序,因此不受反调试与运行时检测影响,代价是每出现一个新变体都要重新逆向。
两代壳的对比显示出防护思路的演进方向:从滚动异或升级到 AES-256-CBC,同时在头布局里增加干扰字段(被移出的代码节首块、需要变换的初始向量)。这些设计的目的是让静态解析者无法直接套用标准算法。脱壳器对应的做法是完整复现壳的每一步预处理,包括那 16 字节的拼接与 IV 的预热变换。
工具自身对边界有清楚认识。静态脱壳失败时切换到文件系统幻象层,用重定向与隐藏替校验方回答,而不去正面攻破壳。这个分层设计让项目在 SteamStub 迭代时仍能保持可用。
参考文献
- atom0s. Steamless [CP/OL]. GitHub. https://github.com/atom0s/Steamless
- Detanup01. gbe_fork [CP/OL]. GitHub. https://github.com/Detanup01/gbe_fork
- SteamAutoCracks. Steam-API-Check-Bypass [CP/OL]. GitHub. https://github.com/SteamAutoCracks/Steam-API-Check-Bypass
- SteamAutoCracks. Steam-auto-crack [CP/OL]. GitHub. https://github.com/SteamAutoCracks/Steam-auto-crack
- Microsoft. PE Format [EB/OL]. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
- D. J. Wheeler, R. M. Needham. TEA, a Tiny Encryption Algorithm [C]. Fast Software Encryption, 1994.
