Torchlight II 游戏内 Console 输入链、命令派发与 Hook 方案
核心结论:可以完全绕开
INSERT → 聚焦输入框 → 键盘输入 → Enter。最合适的实现不是从外部进程直接覆盖输入框内存,而是在常驻CHudMenu::tick所在的游戏/UI 主线程取命令队列,再调用 ConsoleMenu 的原版sub_78FE10(保留回显和历史)或sub_789B00(只执行命令)。这样不受中文输入法影响,也不需要让 Console UI 可见。分析依据:本地 IDA MCP 对
E:/Torchlight 2/Torchlight2.exe.i64的反编译、反汇编和交叉引用;随后以一次性克隆 vtable Hook 在当前运行进程中完成GOD 0 → 1动态验证。Hook 已自恢复,未改游戏代码段。
1. 分析对象与地址口径
| 项目 | 值 |
|---|---|
| 游戏版本 | Torchlight II v.1.25.9.5 |
| 架构 | x86 / 32 位 |
| IDA imagebase | 0x00400000 |
| MD5 | a7e42dfb2e1fe583b40d84c621507550 |
| SHA-256 | 186472c3057bb38f4cdff4696959a943c396ae6166995f7418997b5ea853e8a5e |
本文的“静态 VA”是 IDA 中的地址;真正运行时必须使用:
runtime_address = Torchlight2.exe runtime_base + (static_VA - 0x00400000)不能把本次运行看到的 0x00FB0000 或任何堆地址写死。
2. CONSOLE.LAYOUT 提供的 UI 证据
文件:E:/Torchlight 2/MEDIA/UI/MENUS/GLOBALMENUS/CONSOLE.LAYOUT。
菜单定义为:
MENU NAME: CONSOLE
TYPE: CONSOLE MENU
GAME STATE: All
PINNED: true
CREATE ON LOAD: true
KEY BINDING: CONSOLE与输入有关的控件是:
| 控件 | 作用 |
|---|---|
CONSOLEEDITFRAME | 输入框外框 |
CONSOLEEDITBOX / TextInput | 单行 Edit Box,MAX LENGTH = 255 |
CONSOLEBOX | 多行输出/历史框 |
布局逻辑把 Edit Box 的 Enter 输出连回 Activate 输入。这个连接只负责触发控件事件,并不解析命令;真正的命令处理在原生 CConsoleMenu 中。
3. CConsoleMenu 的创建与实例定位
CUIMenuAndWidgetManager::instantiateMenu 位于静态 VA 0x733E80。它读取 Menu Definition 的 TYPE;当类型值为 0x0C(CONSOLE MENU)时:
- 从游戏分配器申请
0x154(340)字节; - 调用
sub_782E20构造CConsoleMenu; - 把实例加入 UI 管理器
+0x6C的菜单指针向量; - 从 LAYOUT 构建控件树并完成控件绑定。
UI 管理器取得函数为 sub_731620:
CUIMenuAndWidgetManager* GetUiManager() {
return *(CUIMenuAndWidgetManager**)0x3881E34;
}换成 RVA 后:
manager = *(moduleBase + 0x03481E34)
menu_begin = *(manager + 0x6C)
menu_count = *(manager + 0x70)
menu_capacity = *(manager + 0x74)遍历 menu_begin[0..menu_count),查找首 DWORD 等于 moduleBase + 0x01D93E04(CConsoleMenu vftable)的对象即可取得实例。比扫描全部 committed heap 稳定,也不必依赖本次启动的堆地址。
CConsoleMenu 已确认字段:
| 偏移 | 类型/含义 |
|---|---|
+0x10C | CEGUI::Editbox*,即 CONSOLEEDITBOX |
+0x110 | 输入框 frame,即 CONSOLEEDITFRAME |
+0x114 | CEGUI::MultiLineEditbox*,即 CONSOLEBOX |
+0x118 | 当前历史索引 |
+0x11C | 历史容器首地址 |
+0x120 | 历史元素数 |
+0x124 | 历史容器容量 |
+0x12C | 是否把 Console 内容同步写到 Ogre log |
+0x12D | 本帧刚接受过命令的标志 |
+0x12E | 输入 frame 当前是否可见 |
4. 从 Enter 到命令执行的完整调用链
CONSOLEEDITBOX 按 Enter
└─ CEGUI::Editbox::EventTextAccepted
└─ sub_78FE80 CConsoleMenu::onTextAccepted
├─ 读取 Editbox 内的 CEGUI::String
├─ UTF-8 → std::wstring
├─ 末尾追加 L'\n'
├─ sub_78FE10 CConsoleMenu::submitLine
│ └─ sub_78FA80 CConsoleMenu::appendEchoAndDispatch
│ ├─ 写入 CONSOLEBOX
│ ├─ 维护约 10000 字符的输出上限
│ ├─ 加入命令历史
│ └─ sub_789B00 CConsoleMenu::executeCommand
├─ 清空 CONSOLEEDITBOX
└─ this+0x12D = 14.1 控件绑定:sub_78FFA0
sub_78FFA0 是 CConsoleMenu::linkWidgets。遇到 CONSOLEEDITBOX 时,它把 CEGUI 指针保存到 this+0x10C,解除只读、启用控件,并向 CEGUI::Editbox::EventTextAccepted 注册一个 MemberFunctionSlot<CConsoleMenu>;槽函数就是 sub_78FE80。
因此 CONSOLE.LAYOUT 确实是定位入口的重要线索,但执行器不在 LAYOUT 内。
4.2 接受文本:sub_78FE80
该回调先检查 GetAsyncKeyState(VK_TAB);Tab 正按下时不提交。随后:
- 从
Editbox + 0x104的 CEGUI 字符串对象取得 UTF-8 buffer; - 经
sub_688030转成std::wstring; - 非空时追加换行并调用
sub_78FE10; - 用
CEGUI::Window::setText清空输入框。
Editbox+0x104 是一个完整的 CEGUI 字符串对象,不是 char*。直接用 WriteProcessMemory 覆盖这里会破坏长度、容量、SSO/堆指针或引用状态,因此不应采用“写输入框内部内存”的方式。
4.3 回显、历史与派发:sub_78FA80
sub_78FA80 把提交文本按换行拆开,追加到 CONSOLEBOX,更新命令历史,最后只在一个位置调用 sub_789B00。因此:
- 想让命令像手工输入一样出现在 Console 历史中:调用
sub_78FE10; - 只关心命令效果、追求最短路径:直接调用
sub_789B00。
5. sub_789B00 命令解析器
sub_789B00 是巨型单函数命令分派器。IDA 统计 6,544 条指令、763 个基本块;函数主体含大量尾块,不能仅用首尾地址跨度估计代码大小。
入口预处理已经确认:
getGameClient()->sub_428C90()检查当前是否禁止 Console;多人游戏会输出Console not allowed in multiplayer game;sub_685DF0(command, L"\n", L"")删除全部换行;toUpperW把命令转成大写;- 循环删除开头所有小于字符
'A'的 UTF-16 code unit,因此前导空格、数字和大部分 ASCII 标点会被剥离; - 对规范化后的字符串进入命令比较链。
这解释了为什么 Hook 端可以提交 god,也可以提交 GOD,完全不需要处理中文输入法状态。
5.1 关键 ABI
该游戏使用旧版 32 位 MSVC std::wstring,对象大小为 0x1C(28)字节。命令以 按值参数传入,sub_789B00 结尾由 callee retn 1Ch 清理这 28 字节。
因此不能把注入 DLL 自己的 std::wstring 直接跨 CRT 传给游戏。可靠桥接方式是在调用栈上预留 28 字节,调用游戏 IAT 中的 std::wstring(wchar_t const*) 构造函数直接构造参数,再调用原函数:
; ECX/寄存器保存与 detour 框架相关,此处只表示关键 ABI
sub esp, 1Ch
mov ecx, esp
push command_utf16_ptr
call dword ptr [moduleBase + 01D1687Ch] ; game std::wstring ctor IAT
mov ecx, console_menu
call moduleBase + 00389B00h ; sub_789B00
; sub_789B00 负责析构并 retn 1Ch若要保留回显和历史,把最后一个 call 目标改成 moduleBase + 0x0038FE10,并让传入文本末尾带 L'\n'。调用前必须验证 console_menu+0x114 非零。
6. GOD 的实际执行分支
规范化命令与 GOD / GODSPEED 比较后,0x78D447 开始执行共同的 God 切换:
gameClient = getGameClient()
player = gameClient->player // +0x2C
old = player->virtual_getGodState() // vtable slot +0x1A8
player->god = !old // player +0x8F0关键指令:
| 地址 | 行为 |
|---|---|
0x78D447 | 取得 GameClient |
0x78D44C | 检查 GameClient+0x2C 的 player |
0x78D467 | 读取 player vtable +0x1A8 的 God 状态 getter |
0x78D476 | 把取反后的状态写入 player+0x8F0 |
0x78D49B | 输出 God mode turned on |
0x78D4B5 | 开启路径把 player+0x8F2 写为 0xD6 |
0x78D4BE | 输出 God mode turned off |
GODSPEED 会先走同一 God 切换,再继续速度相关分支;GOD 则直接结束该命令。
如果只为了 God 状态,外部工具确实可以直接写 player+0x8F0,但这会绕过 getter、状态脏标志和原版提示,不适合作为通用 Console 自动化方案。
7. 当前活进程的只读核对
2026-09-07 对正在运行的 Torchlight2.exe 做了只读 ReadProcessMemory 核对,没有写入或改动进程。
本次运行:
module base = 0x00FB0000
UI manager global = 0x04431E34
UI manager = 0x11BC8200
menu vector = 0x4B27C200
count/capacity = 23 / 30向量第 1 项命中:
CConsoleMenu = 0x11BDFA00
vftable = 0x02D43E04 == base + 0x01D93E04
CONSOLEEDITBOX = 0x1159ED50
CONSOLEEDITFRAME = 0x1F13BA48
CONSOLEBOX = 0x1159F2D8
tick vslot = 0x01330E00 == base + 0x00380E00GameClient 链也能读取:
GameClient global = base + 0x024E95F4
GameClient+0x2C = player
player+0x8F0 = God flag本次观测时 God flag 为 0。以上对象/堆地址只证明结构与静态分析吻合,下一次启动都会变化,工程实现只能使用 RVA 和指针链。
7.1 2026-09-07 一次性 Hook 实测
第一版把 CConsoleMenu::tick 当作落点。Console 隐藏时 10 秒内没有进入该 tick,命令状态保持 1;工具随后从外部恢复原 vtable,游戏未崩溃。这个反例证明 CREATE ON LOAD 只保证对象存在,不保证隐藏菜单每帧更新。
第二版从同一个菜单向量查找 vtable +0x10 == moduleBase + 0x003922A0 的常驻 CHudMenu,本次命中:
CHudMenu = 0x39503A00
CHudMenu vftable = 0x02D49CBC
CHudMenu::tick = 0x013422A0工具复制 CHudMenu vtable,只把当前实例的 tick 槽改到一次性 shellcode。shellcode 在 HUD 主线程先调用原 tick,再在栈上用游戏自己的 MSVC wstring 构造函数创建 L"GOD\n",调用 sub_78FE10,最后自行恢复 CHudMenu 原 vtable。实测结果:
command=GOD state=3 GOD(after)=1
EXECUTE PASS: GOD 0 -> 1; original vtable restored远程申请的 4 KiB 页本次刻意未释放,以避免 shellcode 刚返回时由外部线程释放产生竞态;一次验证只留下一个页。生产版应使用常驻桥和可复用队列,而不是每条命令重新分配。
8. 四种实现路线比较
| 路线 | 是否推荐 | 说明 |
|---|---|---|
外部 WriteProcessMemory 覆盖 Editbox 字符串 | 否 | CEGUI 字符串是复杂对象,且没有触发 Accepted 事件;容易损坏堆 |
外部线程直接调用 sub_789B00 | 否 | 命令会访问 UI、GameClient、场景和设置;远程线程不满足线程亲和性 |
注入 DLL,在 UI 主线程调用 sub_78FE10 | 推荐 | 保留原版回显、历史、日志和完整命令语义 |
注入 DLL,在 UI 主线程调用 sub_789B00 | 推荐 | 路径最短;适合自动化探针,但不记录输入历史 |
直接改 player+0x8F0 | 仅特殊用途 | 只解决 GOD,绕过原版命令语义,不能扩展到任意命令 |
9. 推荐的 Hook 架构
9.1 Hook 点
CConsoleMenu vtable 的 +0x10 槽虽是 sub_780E00,但动态验证证明 Console 隐藏时它不被调度。最终使用常驻 CHudMenu vtable 的同一槽;该槽为 sub_7922A0,即 CHudMenu::tick(float dt)。
推荐 detour sub_7922A0,但命令接收对象仍为从 UI manager 向量取得的 ConsoleMenu:
外部控制程序
└─ Named Pipe / shared-memory ring buffer
└─ 注入 DLL 收到 UTF-16 命令并排队
└─ CHudMenu::tick detour(游戏主线程)
├─ 调原 tick
├─ 每帧限量取队列
└─ submitLine 或 executeCommandCONSOLE.LAYOUT 具有 CREATE ON LOAD:true 和 PINNED:true,所以 ConsoleMenu 会很早创建。工程仍应在 UI 管理器非空后再遍历向量,并在调用前复核:
*(consoleMenu + 0x00) == moduleBase + 0x01D93E04
*(consoleMenu + 0x114) != 0 ; 使用回显路径时9.2 生命周期与并发
- 不在管道线程、DLL 初始化线程或
CreateRemoteThread中调用游戏函数;这些线程只入队。 - 主线程每帧只执行有限条命令,避免一次批量命令卡住渲染。
- 菜单可能析构;删除析构器为
sub_783DE0,主体析构为sub_782F10。若缓存实例指针,应在析构 Hook 中清空;更简单的做法是每次需要时从 manager 向量重新查找并校验 vtable。 - 外部输入长度建议继续限制为 255 个字符,与原 Editbox 一致。直接派发器本身不会替你执行这项 UI 限制。
- 命令执行器会自行拒绝多人游戏,自动化端应把该回显当成失败结果。
9.3 为什么 Hook HUD tick 而不是 Accepted 回调
Hook sub_78FE80 只能在用户真的按 Enter 时获得执行机会,仍不能主动消费外部命令。隐藏 Console 又不会进入 sub_780E00。常驻 HUD 的 sub_7922A0 已由动态实验确认每帧进入主线程,因此是当前最小、已经跑通的调度点。
10. 地址、RVA 与签名账本
| 语义 | 静态 VA | RVA | 当前二进制唯一签名(IDA 格式) |
|---|---|---|---|
| UI manager getter | 0x731620 | 0x00331620 | A1 ? ? ? ? C3 CC CC CC CC CC CC CC CC CC CC 8B 49 ? 85 C9 |
| Menu 实例化总入口 | 0x733E80 | 0x00333E80 | 6A FF 68 79 07 EC 00 |
CConsoleMenu ctor | 0x782E20 | 0x00382E20 | 6A FF 68 1C C8 FB 00 |
CConsoleMenu::tick | 0x780E00 | 0x00380E00 | 6A FF 64 A1 ? ? ? ? D9 44 24 ? 68 A0 C5 FB 00 |
CHudMenu::tick(最终 Hook 点) | 0x7922A0 | 0x003922A0 | 建议以 CHudMenu vtable +0x10 解析 |
| Accepted 回调 | 0x78FE80 | 0x0038FE80 | 64 A1 ? ? ? ? 6A FF 68 68 DC FB 00 |
| submit/回显入口 | 0x78FE10 | 0x0038FE10 | 见附注 |
| 回显、历史、派发 | 0x78FA80 | 0x0038FA80 | 6A FF 68 93 6C FB 00 64 A1 ? ? ? ? 50 64 89 25 ? ? ? ? 81 EC 38 01 00 00 53 55 56 57 8B F1 |
| 命令执行器 | 0x789B00 | 0x00389B00 | 6A FF 68 3D DC FB 00 |
CConsoleMenu vftable | 0x2193E04 | 0x01D93E04 | 用 ctor 引用或 vtable 内容定位 |
game wstring(wchar_t*) ctor IAT | 0x211687C | 0x01D1687C | IAT 槽,运行时取其内容 |
| GameClient getter | 0x41CE60 | 0x0001CE60 | A1 ? ? ? ? C3 CC CC CC CC CC CC CC CC CC CC 8A 81 ? ? ? ? ? C3 |
| GameClient 全局槽 | 0x28E95F4 | 0x024E95F4 | 由 getter 的 A1 imm32 解出 |
附注:sub_78FE10 的当前唯一签名较长;工程上更适合从 sub_78FE80 的唯一 call xref 解出它,或直接使用此精确版本的 RVA。表中短签名只保证在本次分析的发行 EXE 内唯一,不承诺跨版本/跨编译稳定。
11. 最小运行期验收方案
实现 Hook 后建议先只测试无参数、可逆的 GOD:
- 启动游戏并进入单人游戏;不需要打开 Console UI。
- 外部端向队列发送
GOD。 - 确认主线程消费一条命令;若走
sub_78FE10,Console 输出应出现God mode turned on。 - 只读确认
player+0x8F0 == 1。 - 再发送一次
GOD,确认输出God mode turned off且标志回到0。 - 测试小写
god与前导空格god,两者结果应一致。 - 最后测试多人状态,确认原版拒绝路径仍生效,而不是被 Hook 绕过。
12. 最终建议
下一步应实现一个很小的 x86 in-process Console bridge,而不是继续做 UI 自动化:
- 实例来源:UI manager
+0x6C菜单向量 + vtable 校验; - 主线程调度:detour 常驻
CHudMenu::tick;隐藏 Console 的 tick 已实测不会运行; - IPC:命名管道或共享内存队列;
- 默认执行入口:
sub_78FE10,保留原版回显/历史; - 无 UI 探针模式:
sub_789B00; - 字符串 ABI:在参数栈上用游戏自己的 IAT 构造 28 字节
std::wstring; - 不直接改 CEGUI String,不从远程线程调用游戏逻辑,不把本次 ASLR/堆地址写死。
这条路径把一次命令的成本降为“写入一条 IPC 消息”,也彻底消除了输入法、窗口焦点、键盘节奏和 Console UI 显隐带来的不确定性。