最近发现一款很好玩的 UE4 游戏,玩着玩着就想提取游戏里的一些资源出来折腾。下了个提取器打开一看,资源包竟然是加密的!
好嘛,反正 UE4 引擎本身是开源的,算法逻辑什么的都看得到。想必不会太难。良心游戏开发商还留下了 PDB 数据库。之前我用 x64dbg 只玩过改跳转过验证,这次试试结合符号的调试。
首先,虚幻引擎的资源包格式为 .pak
。使用 16 进制编辑器把资源包打开以后可以看到,有些资源内容似乎没有加密:
72 03 19 d9 03 19 01 b8 03 19 41 53 53 45 52 54 49 4f 4e 4d 4f 44 45 4c 53 54 52 55 45 46 4f 52 43 45 53 01 57 65 02 31 68 02 84 62 65 02 31 62 68 02 84 .r..ù...¸..ASSERTIONMODELSTRUEFORCES.We.1h..be.1bh..
1e 01 95 50 52 4f 56 45 01 95 54 52 55 45 03 1e 01 95 46 4f 52 43 45 07 0f 62 65 02 31 62 68 02 84 02 34 06 1b 04 08 02 3a 06 1b 04 08 02 ab 03 10 4f 46 ....PROVE..TRUE....FORCE..be.1bh...4.....:.....«..OF
所以我猜想,只有资源索引是加密的。搜索找到 UE4 源码里加载 PAK
文件的函数:
void FPakFile::LoadIndex(FArchive* Reader)
{
// ...
// Decrypt if necessary
if (Info.bEncryptedIndex)
{
DecryptData(IndexData.GetData(), Info.IndexSize, Info.EncryptionKeyGuid);
}
// ...
}
证明猜想。继续查看 DecryptData
函数:
void DecryptData(uint8* InData, uint32 InDataSize, FGuid InEncryptionKeyGuid)
{
SCOPE_SECONDS_ACCUMULATOR(STAT_PakCache_DecryptTime);
FAES::FAESKey Key;
FPakPlatformFile::GetPakEncryptionKey(Key, InEncryptionKeyGuid);
check(Key.IsValid());
FAES::DecryptData(InData, InDataSize, Key);
}
第一直觉肯定是去追 FPakPlatformFile::GetPakEncryptionKey
来看密钥是如何获取的了。然而这个函数用了委托,在反汇编下比较难分析。所以我转而查看 FAES::DecryptData
函数:
// 本行防止 Markdown 鬼畜高亮
void FAES::DecryptData(uint8* Contents, uint32 NumBytes, const FAESKey& Key)
{
checkf(Key.IsValid(), TEXT("No valid decryption key specified"));
DecryptData(Contents, NumBytes, Key.Key, sizeof(Key.Key));
}
这个函数首先检查密钥是否有效,如无效则会断言报错。将游戏文件载入 x64dbg,搜索报错字符串,定位到如下逻辑:
00007FF78CF60750 | 48:895C24 08 | mov qword ptr ss:[rsp + 8], rbx
00007FF78CF60755 | 48:897424 10 | mov qword ptr ss:[rsp + 10], rsi
00007FF78CF6075A | 57 | push rdi
00007FF78CF6075B | 48:83EC 20 | sub rsp, 20
00007FF78CF6075F | 49:8BD8 | mov rbx, r8
00007FF78CF60762 | 8BFA | mov edi, edx
00007FF78CF60764 | 48:8BF1 | mov rsi, rcx
00007FF78CF60767 | 45:33C9 | xor r9d, r9d
00007FF78CF6076A | 49:8BC0 | mov rax, r8
00007FF78CF6076D | 0F1F00 | nop dword ptr ds:[rax], eax
00007FF78CF60770 | 8338 00 | cmp dword ptr ds:[rax], 0
|<=== 00007FF78CF60773 | 75 4D | jne game.7FF78CF607C2
| 00007FF78CF60775 | 41:FFC1 | inc r9d
| 00007FF78CF60778 | 48:83C0 04 | add rax, 4
| 00007FF78CF6077C | 41:83F9 08 | cmp r9d, 8
| 00007FF78CF60780 | 72 EE | jb game.7FF78CF60770
| 00007FF78CF60782 | 4C:8D0D D79F7E02 | lea r9, qword ptr ds:[7FF78F74A760]
| | L"No valid decryption key specified"
| 00007FF78CF60789 | 41:B8 9E040000 | mov r8d, 49E
| 00007FF78CF6078F | 48:8D15 4AA07E02 | lea rdx, qword ptr ds:[7FF78F74A7E0]
| 00007FF78CF60796 | 48:8D0D 7B9F7E02 | lea rcx, qword ptr ds:[7FF78F74A718]
| 00007FF78CF6079D | E8 AEE70000 | call game.7FF78CF6EF50
| 00007FF78CF607A2 | 4C:8D0D 97A07E02 | lea r9, qword ptr ds:[7FF78F74A840]
| | L"No valid decryption key specified"
| 00007FF78CF607A9 | 41:B8 9E040000 | mov r8d, 49E
| 00007FF78CF607AF | 48:8D15 FAA07E02 | lea rdx, qword ptr ds:[7FF78F74A8B0]
| 00007FF78CF607B6 | 48:8D0D 53A17E02 | lea rcx, qword ptr ds:[7FF78F74A910]
| 00007FF78CF607BD | E8 9EBAFFFF | call game.7FF78CF5C260
|===> 00007FF78CF607C2 | 41:B9 20000000 | mov r9d, 20
00007FF78CF607C8 | 4C:8BC3 | mov r8, rbx
00007FF78CF607CB | 8BD7 | mov edx, edi
00007FF78CF607CD | 48:8BCE | mov rcx, rsi
00007FF78CF607D0 | 48:8B5C24 30 | mov rbx, qword ptr ss:[rsp + 30]
00007FF78CF607D5 | 48:8B7424 38 | mov rsi, qword ptr ss:[rsp + 38]
00007FF78CF607DA | 48:83C4 20 | add rsp, 20
00007FF78CF607DE | 5F pop rdi
00007FF78CF607DF | E9 0C000000 | jmp <game.FAES::DecryptD
如果 00007FF78CF60770
处的比较中,rax
指向的内存地址值不为零,jne
跳转才会执行。
这个跳转便是用来判断密钥是否有效的部分。如果密钥起始处不为空,逻辑便会跳转后运行到 00007FF78CF607DF
,跳转进入 AES 解密逻辑。
如果密钥起始处为空,接下来,程序将循环 7 次,每次将起始处向后挪 4 个字节。(这样可以确保找到长度更小的密钥:如果密钥长度比较小,前面的空间便都是零。)如果实在找不到(读到的都是零)就会断言报错了。
所以用来在 00007FF78CF6076A
给 rax
赋值的 r8
寄存器就指向内存里密钥的起始处。在 00007FF78CF6076A
下断点,获取 r8
寄存器里的值。内存窗口里这个值指向的地方便是密钥的起始处了。
AES-256 的密钥长度为 32 个字节,所以在内存窗口中复制 32 个字节的数据,转为 16 进制,塞进资源提取器,成功!