洛克王国:世界 — 完整逆向记录:从解包到自定义 PAK
洛克王国:世界 — 完整逆向记录:从解包到自定义 PAK
本文记录了针对《洛克王国:世界》(Roco Kingdom: World)的完整逆向工程过程,包括 PAK 解包、ACE 反作弊绕过、自定义 ReShade addon 开发、PAK 格式逆向与生成,最终实现游戏内实时隐藏角色鞋子的效果。
目录
- 基本信息
- PAK 解包
- ACE 反作弊分析
- DLL 注入尝试(8 种方法全部被拦)
- 突破:d3d12.dll 绕过 ACE
- ShoeHide:自定义 ReShade Addon
- PAK 格式逆向与 PakMaker
- 总结
一、基本信息
| 项目 | 值 |
|---|---|
| 游戏 | 洛克王国:世界 (Roco Kingdom: World) |
| 引擎 | Unreal Engine 4.18 |
| 渲染 | DirectX 12 |
| 平台 | WeGame |
| 反作弊 | ACE (Tencent Anti-Cheat Expert) 内核级 |
| 版本 | v1.0.0.3 (内部 0.0.0.493) |
| 代号 | NRC |
二、PAK 解包
2.1 AES 密钥
游戏使用 AES-256 加密 PAK 文件。密钥来自 cs.rin.ru 论坛的 UE4/5 密钥收集帖:
0x34254D23E47299B3B7F6C4CFDE9BD0688703446D9D8F37B2EBDDDE5B06ED5ADF
2.2 解包工具链
游戏使用自定义 PAK 格式,需要专用 QuickBMS 脚本:
quickbms_4gb_files.exe \
unreal_tournament_4_0.4.27e_roco_kingdom_world.bms \
"<game>/Win64/NRC/Content/Paks" \
"<output>"
解出 9057 个文件,包括骨骼网格体、动画序列、蓝图、贴图、材质等。
2.3 换装系统
游戏使用组件化换装系统:
Avatar 槽位:
├── Hair (发型)
├── Eyes (眼睛)
├── Skin (皮肤)
├── HeadWear (头饰)
├── Pendant (挂件)
├── Shoes (鞋子) — BP_AvatarShoes, DefaultHeight: 8.0
└── Wand (魔杖)
三、ACE 反作弊分析
3.1 驱动清单
| 驱动名 | 类型 | 位置 |
|---|---|---|
| ACE-BASE.sys | 内核驱动 | System32\drivers\ |
| ACE-ADVT.sys | 内核驱动 | System32\drivers\ |
| ACE-BOOT.sys | 内核驱动 | 游戏目录 |
| ACE-CORE*.sys | 内核驱动 | 游戏目录 |
5 个内核驱动,互相交叉校验,有自修复能力。
3.2 保护层面
┌────────────────────────────────┐
│ 文件扫描层 │
│ d3d11.dll, dxgi.dll 被拦截 │
│ xinput1_3.dll, version.dll 被拦│
├────────────────────────────────┤
│ 进程保护层 │
│ CreateRemoteThread → error 5 │
│ OpenProcess → 拒绝 │
├────────────────────────────────┤
│ 内核驱动层 │
│ 自修复 + 反禁用 │
└────────────────────────────────┘
四、DLL 注入尝试
| 方法 | 结果 |
|---|---|
| 直接改 PAK 文件 | .sig 签名校验 |
| 3DMigoto d3d11.dll | ACE 文件扫描拦截 |
| 3DMigoto Loader 外部注入 | error 5 (ACCESS_DENIED) |
| ReShade dxgi.dll + RockDLL | FAILED |
| UE4SS xinput1_3.dll | ACE 文件扫描拦截 |
| UE4SS version.dll | 破坏 ACE 自身加载 |
| 注册表禁用 ACE 驱动 | 自修复,重启后恢复 |
| 运行时 sc stop | STOP_PENDING 挂起 |
全部 8 种方法被拦。
五、突破:d3d12.dll 绕过 ACE
5.1 关键发现
B 站画质增强包 (BV1X8XfBHEma) 直接将 d3d12.dll 放在游戏 exe 目录下就能使用。
核心发现:ACE 的文件扫描不拦截 d3d12.dll。
✅ d3d12.dll → ACE 不扫
❌ d3d11.dll → 被拦
❌ dxgi.dll → 被拦
d3d12.dll 实质是 ReShade 6.7.1,编译为 d3d12.dll 作为代理 DLL。游戏使用 DirectX 12,启动时自动加载此 DLL,而 ACE 的白名单未覆盖它。
5.2 ReShade Addon 系统
ReShade 6.7.1 支持 addon 插件系统。任何 .addon64 文件放在同目录下会被自动加载。addon 可以注册 draw_indexed 事件,在每个 3D 绘制调用前拦截。
六、ShoeHide:自定义 ReShade Addon
6.1 核心原理
ReShade API v18 的 draw_indexed 事件:
bool on_draw_indexed(
api::command_list *cmd,
uint32_t index_count, // 索引数
uint32_t instance_count,
uint32_t first_index, // 起始索引
int32_t vertex_offset,
uint32_t first_instance
);
// 返回 true → 跳过此绘制调用
// 返回 false → 正常绘制
每个 3D 模型在渲染时产生固定的 (index_count, first_index) 组合,可作为"指纹":
static uint64_t make_hash(uint32_t index_count, uint32_t first_index) {
return ((uint64_t)index_count << 20) | (first_index & 0xFFFFF);
}
6.2 热键操作
| 热键 | 功能 |
|---|---|
| F6 | 切换追踪模式(收集所有出现过的 draw call 指纹) |
| F7 | 轮流临时隐藏已收集的 draw call(实时预览) |
| F8 | 将当前选中的指纹永久保存到 ShoeHide.ini |
6.3 效果
| 场景 | 控制精度 | 效果 |
|---|---|---|
| 衣柜/预览 | 单部件级别 | 只遮鞋子 ✓ |
| 大世界 | 全身级别 | 衣服+身体+鞋子全部消失 |
原因:大世界中 UE4 引擎为减少 draw call 将角色部件预合并为单个网格体,所有部件共享一个绘制调用。衣柜中每个部件是独立 draw call。
6.4 完整源码
#include <Windows.h>
#include <cstdio>
#include <io.h>
#include <fcntl.h>
#include "../reshade/reshade.hpp"
#define MAX_SKIP 256
#define MAX_TRACKED 1024
static uint64_t g_skip_hashes[MAX_SKIP];
static int g_skip_num = 0;
static uint64_t g_tracked_hashes[MAX_TRACKED];
static uint32_t g_tracked_counts[MAX_TRACKED];
static int g_tracked_num = 0;
static volatile int g_test_idx = -1;
static volatile bool g_tracking = false;
static WCHAR g_config_path[MAX_PATH] = {};
static uint64_t make_hash(uint32_t ic, uint32_t fi) {
return ((uint64_t)ic << 20) | (fi & 0xFFFFF);
}
bool on_draw_indexed(reshade::api::command_list *, uint32_t index_count,
uint32_t instance_count, uint32_t first_index, int32_t, uint32_t) {
if (index_count == 0 || instance_count == 0) return false;
uint64_t hash = make_hash(index_count, first_index);
for (int i = 0; i < g_skip_num; i++)
if (g_skip_hashes[i] == hash) return true;
int ti = g_test_idx;
if (ti >= 0 && ti < g_tracked_num && g_tracked_hashes[ti] == hash)
return true;
if (g_tracking) {
for (int i = 0; i < g_tracked_num; i++) {
if (g_tracked_hashes[i] == hash) { g_tracked_counts[i]++; return false; }
}
if (g_tracked_num < MAX_TRACKED) {
int idx = g_tracked_num++;
g_tracked_hashes[idx] = hash;
g_tracked_counts[idx] = 1;
}
}
return false;
}
// [File I/O and hotkey thread omitted for brevity]
// Full source: E:\Project\C_Project\ShoeHide\src\main.cpp
extern "C" __declspec(dllexport) bool AddonInit(HMODULE addon, HMODULE reshade) {
if (!reshade::register_addon(addon, reshade)) return false;
reshade::register_event<reshade::addon_event::draw_indexed>(on_draw_indexed);
CreateThread(nullptr, 0, hotkey_thread, nullptr, 0, nullptr);
return true;
}
完整源码约 170 行,编译产物 ShoeHide.addon64(约 80KB)。
七、PAK 格式逆向与 PakMaker
7.1 PAK 文件结构
通过逆向 QuickBMS 脚本 unreal_tournament_4_0.4.27e_roco_kingdom_world.bms 得到完整格式:
PAK 文件结构(从文件头到文件尾):
[0x0000] 文件数据 (按 0x800 对齐)
[offset] AES-256 加密索引
[-0xCD] uint8 ENCRYPTED (1 = 是)
[-0xCC] uint32 MAGIC (0x5A6F12E1)
[-0xC8] uint32 VERSION (11)
[-0xC0] uint64 INDEX_OFFSET
[-0xB8] uint64 INDEX_SIZE
[-0xB0] uint8[20] INDEX_HASH (SHA1)
[-0x9C] uint8 CHECK
[-0x9B] char[32] COMP1 ("Zlib")
[-0x7B] char[32] COMP2 ("")
[-0x5B] [padding to fill 0xCD bytes total]
[EOF]
7.2 自定义 AES 实现
QuickBMS 脚本内嵌了 TinyCrypt C 代码。其 AES 实现不是标准 ECB,有额外的字节/位反转操作:
解密流程:
1. byte_reverse(key) — 交换密钥字节对
2. aes_key_expansion — 标准 AES 密钥扩展
3. 对每个 16 字节块:
a. bit_reverse(block) — 反转每个字节的位序
b. AES-ECB decrypt
加密流程(反向):
1. byte_reverse(key)
2. aes_key_expansion
3. 对每个 16 字节块:
a. AES-ECB encrypt
b. bit_reverse(block)
7.3 PakMaker
使用 Python + PyCryptodome 实现完整的 PAK 生成器,约 295 行。完整源码:
#!/usr/bin/env python3
"""
PakMaker for Roco Kingdom: World (UE4 custom PAK format)
Reverse-engineered from QuickBMS script unreal_tournament_4_0.4.27e_roco_kingdom_world.bms
PAK file structure (from EOF backward):
[... file data ...]
[... AES-256 ECB encrypted index ...]
-1: uint8 ENCRYPTED (1 = yes)
0: uint32 MAGIC (0x5A6F12E1)
4: uint32 VERSION (11)
8: uint64 INDEX_OFFSET
16: uint64 INDEX_SIZE
24: uint8[20] INDEX_HASH (SHA1 of encrypted index)
44: uint8 CHECK (0 or skipped)
45: char[32] COMP1 ("Zlib")
77: char[32] COMP2 ("")
109: [padding to fill 0xCC bytes]
0xCC: [EOF]
"""
import struct
import hashlib
import os
import sys
from Crypto.Cipher import AES
AES_KEY = bytes.fromhex("34254D23E47299B3B7F6C4CFDE9BD0688703446D9D8F37B2EBDDDE5B06ED5ADF")
PAK_MAGIC = 0x5A6F12E1
PAK_VERSION = 11
FOOTER_SIZE = 0xCC
def bit_reverse(data: bytearray):
"""Reverse bits in each byte (matches game's TinyCrypt implementation)"""
for i in range(16):
x = data[i]
x = ((x >> 1) & 0x55) | ((x << 1) & 0xAA)
x = ((x >> 2) & 0x33) | ((x << 2) & 0xCC)
x = (x >> 4) | (x << 4)
data[i] = x & 0xFF
def byte_reverse(key: bytearray):
"""Reverse key bytes in pairs (matches game's TinyCrypt implementation)"""
for i in range(15):
tmp = key[i]
key[i] = key[30 - i]
key[30 - i] = tmp
def aes_encrypt(data: bytes) -> bytes:
"""AES-256 ECB encrypt using game's custom algorithm"""
# Pad to 16-byte boundary with zeros
pad = (16 - len(data) % 16) % 16
data = data + b'\x00' * pad
# Prepare key: byte_reverse then expand
key = bytearray(AES_KEY)
byte_reverse(key)
cipher = AES.new(bytes(key), AES.MODE_ECB)
# Encrypt each block with bit_reverse after encryption
result = bytearray()
for i in range(0, len(data), 16):
block = data[i:i+16]
encrypted = cipher.encrypt(bytes(block))
rev = bytearray(encrypted)
bit_reverse(rev)
result += rev
return bytes(result)
def sha1(data: bytes) -> bytes:
return hashlib.sha1(data).digest()
def collect_files(root_dir: str):
"""Collect all files with relative paths from root_dir"""
result = []
for dirpath, _, filenames in os.walk(root_dir):
for fn in filenames:
full = os.path.join(dirpath, fn)
rel = os.path.relpath(full, root_dir).replace('\\', '/')
result.append((rel, full))
return result
def build_pak(file_list, output_path, mount_point="../../../NRC/Content/"):
"""Build a custom-format PAK file"""
files = []
for rel, full in file_list:
size = os.path.getsize(full)
with open(full, 'rb') as f:
fhash = sha1(f.read())
files.append({'rel': rel, 'full': full, 'size': size, 'hash': fhash})
if not files:
print("No files to pack!")
return False
# Group files by directory
dirs = {}
for f in files:
rel = f['rel']
if '/' in rel:
d, fn = rel.rsplit('/', 1)
else:
d, fn = '', rel
if d not in dirs:
dirs[d] = []
dirs[d].append((fn, f))
# Phase 1: Build index binary with placeholders
idx_data = bytearray()
# Mount point / base path (part of encrypted index, first entry)
mp = mount_point
if mp.endswith('\x00'):
mp = mp[:-1]
mp_bytes = mp.encode('ascii')
idx_data += struct.pack('<I', len(mp_bytes) + 1) # NAMESZ
idx_data += mp_bytes + b'\x00' # BASE_PATH
# Index header
idx_data += struct.pack('<I', len(files)) # file count
idx_data += b'\x00' * 12 # dummy
hashes_off_pos = len(idx_data)
idx_data += struct.pack('<Q', 0) # hashes_offset placeholder
idx_data += struct.pack('<Q', 0) # hashes_size placeholder
idx_data += b'\x00' * 24 # dummy
names_off_pos = len(idx_data)
idx_data += struct.pack('<Q', 0) # names_offset placeholder
idx_data += struct.pack('<Q', 0) # names_size placeholder
idx_data += b'\x00' * 24 # dummy
# Write directory entries with placeholder file offsets
dir_list = list(dirs.items())
idx_data += struct.pack('<I', len(dir_list))
file_offset_places = [] # (pos_in_idx, file_obj)
for dir_name, dir_files in dir_list:
dname = dir_name + '\x00'
idx_data += struct.pack('<i', len(dname))
idx_data += dname.encode('ascii', errors='replace')
idx_data += struct.pack('<I', len(dir_files))
for file_name, fobj in dir_files:
fname = file_name + '\x00'
idx_data += struct.pack('<i', len(fname))
idx_data += fname.encode('ascii', errors='replace')
file_offset_places.append((len(idx_data), fobj))
idx_data += struct.pack('<I', 0xFFFFFFFF)
dir_start = names_off_pos + 8 + 8 + 24
# Phase 2: Write file entry data after directory entries
file_data_entries = {}
file_entry_bin = bytearray()
for _, fobj in file_offset_places:
if fobj['rel'] in file_data_entries:
continue
entry_offset = len(idx_data) + len(file_entry_bin)
entry_bin = bytearray()
entry_bin += struct.pack('<Q', 0) # OFFSET placeholder
entry_bin += struct.pack('<Q', 0) # ZSIZE = 0 (uncompressed)
entry_bin += struct.pack('<Q', fobj['size']) # SIZE
entry_bin += struct.pack('<I', 0) # ZIP = 0 (no compression)
entry_bin += fobj['hash'] # SHA1 (20 bytes)
entry_bin += struct.pack('<I', 0) # CHUNKS = 0
entry_bin += b'\x00' # ENCRYPTED = 0
entry_bin += struct.pack('<I', 0x10000) # CHUNK_SIZE (default)
file_data_entries[fobj['rel']] = (entry_offset, entry_bin)
file_entry_bin += entry_bin
# Patch placeholder offsets in directory (relative to BASE_INDEX_OFF)
for placeholder_pos, fobj in file_offset_places:
entry_off, _ = file_data_entries[fobj['rel']]
rel_off = entry_off - dir_start
struct.pack_into('<I', idx_data, placeholder_pos, rel_off)
# Append file entry binary
idx_data += file_entry_bin
# Phase 3: Write PAK file
with open(output_path, 'wb') as out:
written_files = set()
for _, fobj in file_offset_places:
fkey = fobj['rel']
if fkey in written_files:
continue
written_files.add(fkey)
# Align to 0x800
cur = out.tell()
pad = (0x800 - (cur % 0x800)) % 0x800
if pad:
out.write(b'\x00' * pad)
abs_offset = out.tell()
entry_off, entry_bin = file_data_entries[fobj['rel']]
struct.pack_into('<Q', entry_bin, 0, abs_offset)
struct.pack_into('<Q', idx_data, entry_off, abs_offset)
with open(fobj['full'], 'rb') as fin:
out.write(fin.read())
# Patch hashes_offset and names_offset with absolute positions
index_pos = out.tell()
struct.pack_into('<Q', idx_data, hashes_off_pos, index_pos + dir_start)
struct.pack_into('<Q', idx_data, names_off_pos, index_pos + dir_start)
# Write encrypted index
idx_data_bytes = bytes(idx_data)
idx_encrypted = aes_encrypt(idx_data_bytes)
out.write(idx_encrypted)
index_size = len(idx_encrypted)
# Compute SHA1 of encrypted index
index_hash = sha1(idx_encrypted)
# Write footer: ENCRYPTED at EOF-0xCD, MAGIC at EOF-0xCC
encrypted_pos = out.tell()
out.write(b'\x01') # ENCRYPTED = true
out.write(struct.pack('<I', PAK_MAGIC))
out.write(struct.pack('<I', PAK_VERSION))
out.write(struct.pack('<Q', index_pos))
out.write(struct.pack('<Q', index_size))
out.write(index_hash)
out.write(b'\x00') # CHECK byte
out.write(b'Zlib' + b'\x00' * 28) # COMP1 (32 bytes)
out.write(b'\x00' * 32) # COMP2 (32 bytes)
# Pad: total from ENCRYPTED to EOF must be 0xCD bytes
cur = out.tell()
target = encrypted_pos + 0xCD
if cur < target:
out.write(b'\x00' * (target - cur))
total = out.tell()
print(f"PAK created: {output_path}")
print(f" Files: {len(files)}")
print(f" Index offset: {index_pos}")
print(f" Index size: {index_size}")
print(f" Total: {total} bytes")
print(f" Footer check: MAGIC at EOF-{total - (encrypted_pos + 1)} (expect 0xCC)")
return True
def main():
if len(sys.argv) < 3:
print("Usage: python pakmaker.py <input_dir> <output.pak> [mount_point]")
sys.exit(1)
input_dir = sys.argv[1]
output_file = sys.argv[2]
mount_point = sys.argv[3] if len(sys.argv) > 3 else "../../../NRC/Content/"
if not os.path.isdir(input_dir):
print(f"Error: {input_dir} is not a directory")
sys.exit(1)
print(f"Scanning: {input_dir}")
flist = collect_files(input_dir)
print(f"Found {len(flist)} files")
for rel, full in flist:
print(f" {rel} ({os.path.getsize(full)} bytes)")
if not flist:
print("No files found!")
sys.exit(1)
if not build_pak(flist, output_file, mount_point):
sys.exit(1)
if __name__ == '__main__':
main()
7.4 QuickBMS 验证通过
$ quickbms_4gb_files.exe -l script.bms mypak.pak
offset filesize filename
--------------------------------------
... 1113 NRC\Content\...\BP_AvatarShoes.uasset
... 121 NRC\Content\...\BP_AvatarShoes.uexp
- 2 files found in 0 seconds
7.5 游戏端校验
虽然 PAK 格式和加密完全正确(QuickBMS 验证通过),但游戏启动时检测到非官方 PAK 内容并拒绝启动:
错误代码 (3504003):游戏客户端非官方版本或游戏客户端损坏
结论:游戏对 PAK 文件有额外的完整性校验(哈希白名单),替换 PAK 需要同时绕过该机制。
八、总结
技术突破
| 里程碑 | 状态 | 关键 |
|---|---|---|
| PAK 解包 | ✅ | QuickBMS + 自定义脚本 + AES 密钥 |
| d3d12.dll 绕过 ACE | ✅ | ACE 不扫描此文件名 |
| ShoeHide addon | ✅ | ReShade API draw_indexed 事件 |
| PAK 格式逆向 | ✅ | 完整 AES 加密/解密实现 |
| PakMaker PAK 生成 | ✅ | QuickBMS 验证通过 |
| 自定义 PAK 加载 | ❌ | 游戏有完整性白名单校验 |
可用方案
目前最好的效果来自 ShoeHide addon:
- 衣柜/预览界面:精确隐藏单个鞋子
- 大世界:全身隐藏(UE4 合批限制)
受限点的原因
大世界中 UE4 将角色部件预合并为单个网格体(static batching),所有渲染相关的 draw call 控制都无法在部件级别分离。要突破此限制需要:
- 禁止引擎合批(需要修改引擎配置或 Hook 合批函数)
- 或通过资源替换(需要绕过 PAK 完整性校验)
文件清单
| 文件 | 描述 |
|---|---|
ShoeHide.addon64 |
ReShade 鞋子隐藏插件 |
pakmaker.py |
PAK 生成器 |
shoe_hide_main.cpp |
ShoeHide 完整源码 |
decrypt.c |
从 BMS 脚本提取的 AES 实现 |
unreal_tournament_4_0.4.27e_roco_kingdom_world.bms |
QuickBMS 解包脚本 |
参考资源
- QuickBMS: https://aluigi.altervista.org/quickbms.htm
- ReShade: https://reshade.me/
- ReShade API: https://github.com/crosire/reshade
- UE4 AES 密钥帖: https://cs.rin.ru/forum/viewtopic.php?t=100672
- 画质增强包: B站 BV1X8XfBHEma
- 3DMigoto: https://github.com/bo3b/3Dmigoto
- RockDLL: https://github.com/Goldppx/RockDLL
Disclaimer: 本文为技术研究记录。修改游戏客户端可能违反用户协议。本文内容仅用于学习和研究目的。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)