以撒 Lua 游戏逆向:函数签名导出与 IDA 导入
哟,这玩意儿是干啥的?
OK,事情是这样的:这份文档主要是我自己的一个备忘录,记录了我是怎么从《以撒的结合:忏悔》(The Binding of Isaac: Repentance)中导出 Lua API 函数和属性的地址,然后喂给 IDA Pro 的。如果你也搞这个,或者想搞,那这份笔记也许能帮你省点事儿。
简单说,就是一套方案,能让你:
- 从游戏里“偷”出 Lua 函数和属性的实际内存地址。
- 用个 Python 脚本,把这些地址和名字自动导入到你的 IDA 数据库里,省得手动一个个找。
适用版本说明:
- 游戏版本: 《以撒的结合:忏悔》(The Binding of Isaac: Repentance)。我主要在
1.9.7.8
到1.9.8.12
这些版本上测试过,理论上应该对所有“忏悔”及“忏悔+”版本有效。 - Lua 版本: 游戏用的是 Lua 5.3.3。强烈建议你也用这个版本的 Lua 源码来编译修改后的 DLL。我用 Lua 5.3.6 也测试成功过,所以 Lua 5.3.X 系列应该都行,但为了保险起见,最好还是用 5.3.3。
核心思路小科普
别担心,这里不搞长篇大论。核心思路其实挺简单的:
- Lua 的 C API 是个好东西: Lua 本身允许 C 代码和 Lua 代码互相调用。游戏引擎就是通过这个机制把 C/C++ 函数暴露给 Lua 脚本的。
- C 闭包 (C Closure) 和上值 (Upvalue): 当一个 C 函数被注册到 Lua 中时,它通常会被包装成一个 C 闭包。这些闭包可以携带一些与之关联的值,称为“上值”。在以撒(或者说它用的 Lua 绑定库)的实现里,那个实际的、可执行的 C 函数指针,恰好就作为上值之一存储在这个 C 闭包里。
lua_cclosure_upvalues
(自定义 C 函数): 为了拿到这个藏起来的函数指针,我们需要一个能访问 C 闭包上值的途径。原版 Lua API 没有直接提供“给我第 N 个上值的数据区指针”这样的函数,所以我们得自己往 Lua 源码里加一个。这个函数就是lua_cclosure_upvalues
,它能帮我们定位到存储真实函数指针的那个上值。math.cclosure
(Lua 端调用接口): 光有 C 函数还不行,我们得在 Lua 脚本里能调用到它。所以,我们又在 Lua 的math
库里加了个math.cclosure
函数,它内部会调用我们上面实现的lua_cclosure_upvalues
,然后把获取到的函数指针(地址)返回给 Lua 脚本。- Lua 脚本遍历导出: 有了
math.cclosure
,我们的 Lua 脚本就可以遍历游戏注册到全局环境中的各种类(Class)和对象(Object),获取它们的元表(metatable),再从元表里找到那些 C 函数成员,调用math.cclosure
挨个把它们的地址薅出来,最后按特定格式(JSON)输出。
差不多就是这么个流程。下面看看具体代码。
代码部分,开整!
这里分成三块:修改 Lua 源码的 C 代码、用于导出信息的 Lua 脚本(以撒 Mod)、以及用于导入到 IDA 的 Python 脚本。
第一部分:给 Lua 来点“魔法”
你需要获取 Lua 5.3.3 的官方源代码。然后添加/修改以下两个文件:
1. lapi.c
文件:添加 lua_cclosure_upvalues
函数
在 lapi.c
文件的合适位置(比如其他 LUA_API
函数定义附近)添加以下函数:
// lapi.c
LUA_API void* lua_cclosure_upvalues(lua_State* L, int funcindex, int n_zero_based) {
StkId fi;
CClosure* cl;
const TValue* upval_tv;
Udata* u;
int n_one_based = n_zero_based + 1; /* 内部使用 1-based 索引 */
lua_lock(L);
fi = index2addr(L, funcindex); /* 获取函数 TValue */
if (!ttisCclosure(fi)) { /* 必须是 C 闭包 */
lua_unlock(L);
return NULL;
}
cl = clCvalue(fi);
/* 检查上值索引是否有效 */
if (!(n_one_based >= 1 && n_one_based <= cl->nupvalues)) {
lua_unlock(L);
return NULL;
}
upval_tv = &cl->upvalue[n_one_based - 1]; /* 获取上值的 TValue */
/* 检查上值是否为 full userdata */
/* 在以撒的这种特定用法中,我们期望上值是一个包含指针的 full userdata */
/* 如果不是,可能说明用法不对或者目标函数的结构不同 */
if (!ttisfulluserdata(upval_tv)) {
lua_unlock(L);
return NULL;
}
u = uvalue(upval_tv); /* 获取 Udata* */
lua_unlock(L);
/* 返回指向用户数据块的指针 */
return getudatamem(u); /* getudatamem 是 lobject.h 中的宏 */
}
2. lmathlib.c
文件:添加 math_cclosure
函数
在 lmathlib.c
文件中,找到 mathlib[]
数组,并在其中添加一个新的条目指向 math_cclosure
函数。然后,在文件其他地方实现 math_cclosure
函数:
// lmathlib.c
// ... 其他 static int math_xxx 函数 ...
static int math_cclosure(lua_State* L) {
void* p_user_data_content; /* lua_cclosure_upvalues 返回值 (P1) */
void* actual_func_ptr; /* 存储在 P1 中的指针 (*P1) */
char buffer[32];
uintptr_t ptr_value;
if (lua_gettop(L) != 1 || !lua_iscfunction(L, 1)) {
lua_pushnil(L);
return 1;
}
/* 调用 API 获取第一个上值 (索引 0) 的数据区指针 P1 */
/* 我们假设目标指针总是存在于第一个上值(索引为0)的用户数据中 */
p_user_data_content = lua_cclosure_upvalues(L, 1, 0);
if (p_user_data_content != NULL) {
/* 直接从数据区起始位置 P1 读取存储的目标 C 函数指针 */
actual_func_ptr = *(void**)p_user_data_content;
/* 格式化地址为十六进制字符串 */
ptr_value = (uintptr_t)actual_func_ptr;
#if defined(_WIN32) || defined(_MSC_VER)
// sprintf_s(buffer, sizeof(buffer), "%p", actual_func_ptr); // 更安全的做法
_itoa_s((unsigned int)ptr_value, buffer, sizeof(buffer), 16); // 使用 _itoa_s (Windows)
#else
snprintf(buffer, sizeof(buffer), "%X", (unsigned int)ptr_value); /* 备选方案 for other platforms */
#endif
lua_pushstring(L, buffer);
}
else {
/* 获取指针失败,返回 nil */
lua_pushnil(L);
}
return 1;
}
// 在 mathlib[] 数组中添加:
// static const luaL_Reg mathlib[] = {
// {"abs", math_abs},
// ...
// {"cclosure", math_cclosure}, // <--- 添加这一行
// ...
// {NULL, NULL}
// };
编译注意事项:
- 这两个函数都是新增的。
- 使用 MSVC (Visual Studio 的 C++ 编译器)配合 Lua 5.3.3 的官方源码,按照标准的 Lua 编译流程来生成
lua.dll
(或者具体到以撒是Lua5.3.3r.dll
)。你需要确保你的编译环境和参数设置正确。
第二部分:Lua“提取器”脚本 (The Isaac Mod)
这个 Lua 脚本本身就是一个以撒的 Mod。你需要把它放到以撒的 Mod 目录中。
local hax = RegisterMod("hacks", 1) -- "hacks" 是你的Mod名称,也对应输出文件路径中的一部分
-- Forward declarations for EnumClass and EnumObject if they call each other
local EnumClass
local EnumObject
local function mprint(s)
-- 输出文件路径,相对于你的 Mod 目录
-- 例如: Documents\My Games\Binding of Isaac Repentance\mods\hacks\hacks.txt
local file_path = "mods/" .. hax.Name .. "/" .. hax.Name .. ".txt"
local file, err_msg = io.open(file_path, "a") -- 追加模式
if file then
file:write(s)
file:close()
else
-- 如果游戏内控制台可用,错误信息会打印到那里
print("ERROR: Cannot open file for writing! Path: " .. file_path)
print("Error details: " .. (err_msg or "Unknown error"))
end
end
EnumClass = function(classObj, className)
className = className or "UnknownClass"
local meta
if type(classObj) == "function" then
if className == "Game" then -- 特殊处理 Game,因为它通常通过 Game() 获取实例
local mtFunc = getmetatable(classObj)
if mtFunc and mtFunc.__class then meta = mtFunc.__class
elseif mtFunc then meta = mtFunc end
else
local success, instance = pcall(classObj) -- 尝试实例化
if success and instance then
local mtInstance = getmetatable(instance)
if mtInstance and mtInstance.__class then
meta = mtInstance.__class
elseif mtInstance then
meta = mtInstance
end
end
if not meta then -- 如果实例化失败或没有 __class,尝试获取构造函数自身的元表
local mtFunc = getmetatable(classObj)
if mtFunc and mtFunc.__class then meta = mtFunc.__class
elseif mtFunc then meta = mtFunc end
end
end
elseif type(classObj) == "table" then -- 如果直接是 table (例如某些全局对象)
local mtTable = getmetatable(classObj)
if mtTable and mtTable.__class then
meta = mtTable.__class
elseif mtTable then
meta = mtTable
end
end
if not meta or type(meta) ~= "table" then
mprint('{"func":[],"prop":[]}') -- 无法处理,输出空结构
return
end
mprint('{"func":[')
local firstFunc = true
for key, value in pairs(meta) do
if type(value) == "function" then
local addr = math.cclosure(value)
if addr ~= nil and addr ~= "0" and addr ~= "-1" then -- 过滤无效地址
if not firstFunc then mprint(',') else firstFunc = false end
mprint('{')
mprint('"name":"'..tostring(key)..'",')
mprint('"addr":"'..tostring(addr)..'"')
mprint('}')
end
end
end
mprint('],"prop":[')
local propget = rawget(meta, "__propget")
local firstProp = true
if propget and type(propget) == "table" then
for pkey, pfunc in pairs(propget) do
if type(pfunc) == "function" then
local offset_or_addr = math.cclosure(pfunc)
if offset_or_addr ~= nil and offset_or_addr ~= "0" and offset_or_addr ~= "-1" then
if not firstProp then mprint(',') else firstProp = false end
mprint('{')
mprint('"name":"'..tostring(pkey)..'",')
-- 注意:这里命名为 "offset" 但实际上可能是地址或偏移,取决于具体实现
mprint('"offset":"'..tostring(offset_or_addr)..'"')
mprint('}')
end
end
end
end
mprint(']}')
end
EnumObject = function(objInstance, objName)
objName = objName or "UnknownObject"
if not objInstance or (type(objInstance) ~= "table" and type(objInstance) ~= "userdata") then
mprint('{"func":[],"prop":[]}')
return
end
local targetTable = objInstance
if type(objInstance) == "userdata" then -- Userdata 的方法通常在其元表的 __index 中
local mtUserdata = getmetatable(objInstance)
if mtUserdata and mtUserdata.__index and type(mtUserdata.__index) == "table" then
targetTable = mtUserdata.__index
elseif mtUserdata and type(mtUserdata) == "table" then
targetTable = mtUserdata
else
mprint('{"func":[],"prop":[]}')
return
end
end
if type(targetTable) ~= "table" then -- 最后检查
mprint('{"func":[],"prop":[]}')
return
end
mprint('{"func":[')
local firstFunc = true
for key, value in pairs(targetTable) do
if type(value) == "function" then
local addr = math.cclosure(value)
if addr ~= nil and addr ~= "0" and addr ~= "-1" then
if not firstFunc then mprint(',') else firstFunc = false end
mprint('{')
mprint('"name":"'..tostring(key)..'",')
mprint('"addr":"'..tostring(addr)..'"')
mprint('}')
end
end
end
mprint('],"prop":[')
local propget = rawget(targetTable, "__propget")
local firstProp = true
if propget and type(propget) == "table" then
for pkey, pfunc in pairs(propget) do
if type(pfunc) == "function" then
local offset_or_addr = math.cclosure(pfunc)
if offset_or_addr ~= nil and offset_or_addr ~= "0" and offset_or_addr ~= "-1" then
if not firstProp then mprint(',') else firstProp = false end
mprint('{')
mprint('"name":"'..tostring(pkey)..'",')
mprint('"offset":"'..tostring(offset_or_addr)..'"')
mprint('}')
end
end
end
end
mprint(']}')
end
function hax:EnumerateAllAPIs()
mprint("--- API ENUMERATION STARTED (MC_POST_GAME_STARTED) ---\n")
mprint('{"classes":[')
local class_list = {
"Color", "Entity", "EntityBomb", "EntityEffect", "EntityFamiliar", "EntityKnife",
"EntityLaser", "EntityNPC", "EntityPickup", "EntityPlayer", "EntityProjectile",
"EntityTear", "EntityPtr", "EntityRef", "Font", "Game", "GridEntity",
"GridEntityDoor", "GridEntityPit", "GridEntityPoop", "GridEntityPressurePlate",
"GridEntityRock", "GridEntitySpikes", "GridEntityTNT", "GridEntityDesc", "HUD",
"ItemPool", "KColor", "Level", "MusicManager", "PathFinder", "ProjectileParams",
"QueueItemData", "Room", "RoomDescriptor", "RNG", "Seeds", "ShockwaveParams",
"Sprite", "SFXManager", "TearParams", "TemporaryEffect", "TemporaryEffects", "Vector",
}
local firstClass = true
for _, className in ipairs(class_list) do
local classObj = _G[className]
if classObj then
if not firstClass then mprint(',') else firstClass = false end
mprint('{"name":"'..className..'","info":')
EnumClass(classObj, className)
mprint('}')
else
mprint("-- Warning: Class not found in _G: " ..className .. "\n")
end
end
mprint('],"objs":[')
local obj_list = {
"Input", "Options", "Isaac", -- 这些通常是全局的 table/userdata
}
local firstObj = true
for _, objName in ipairs(obj_list) do
local objInstance = _G[objName]
if objInstance then
if not firstObj then mprint(',') else firstObj = false end
mprint('{"name":"'..objName..'","info":')
EnumObject(objInstance, objName)
mprint('}')
else
mprint("-- Warning: Object not found in _G: " .. objName .. "\n")
end
end
-- 特殊处理 Game() 实例,因为它在 MC_POST_GAME_STARTED 回调时才肯定可用
local currentGameInstance = Game()
if currentGameInstance then
if not firstObj then mprint(',') else firstObj = false end
mprint('{"name":"GameInstance","info":') -- 使用 "GameInstance" 作为特殊名称
EnumObject(currentGameInstance, "GameInstance")
mprint('}')
else
mprint("-- Warning: Game() returned nil during MC_POST_GAME_STARTED callback.\n")
end
mprint("]}")
mprint("\n--- API ENUMERATION FINISHED ---\n")
-- 执行完毕后移除回调,避免重复执行
hax:RemoveCallback(ModCallbacks.MC_POST_GAME_STARTED, hax.EnumerateAllAPIs)
mprint("\n-- API Enumeration callback removed.\n")
end
-- 注册回调,当游戏开始后(加载完Mod和关卡基本数据后)执行函数
hax:AddCallback(ModCallbacks.MC_POST_GAME_STARTED, hax.EnumerateAllAPIs, hax)
-- 确保在脚本末尾有一个换行,有些解析器可能需要
mprint("\n")
Lua 脚本关键点:
- Mod 注册:
RegisterMod("hacks", 1)
,这里的"hacks"
是你的 Mod 文件夹名,也是mprint
函数中文件路径的一部分。确保它们一致。 - 输出文件: 脚本会将结果追加到
mods/[你的 Mod 名]/[你的 Mod 名].txt
。例如,如果你的 Mod 文件夹叫hacks
,那么文件就是mods/hacks/hacks.txt
。这个路径通常在Documents\My Games\Binding of Isaac Repentance\mods\
下。 --luadebug
参数: 极其重要! 游戏启动时必须添加--luadebug
命令行参数(比如在 Steam 的游戏启动选项里设置),否则io.open
函数很可能因为权限问题无法写入文件,或者干脆不工作。- 触发时机:
hax:AddCallback(ModCallbacks.MC_POST_GAME_STARTED, hax.EnumerateAllAPIs, hax)
这行表示,核心的EnumerateAllAPIs
函数会在你开始一局新游戏之后被调用。 - JSON 输出: 脚本会尽量输出 JSON 格式的数据,但其中会夹杂一些我们用
mprint
输出的调试/状态信息(比如 “— API ENUMERATION STARTED —")。这些非 JSON 内容在导入 IDA 前需要手动清理。
第三部分:IDA Python“导入器”脚本
这个 Python 脚本在 IDA Pro 中运行,读取上面 Lua 脚本生成的(并经过你手动清理的)JSON 文件,然后尝试重命名 IDA 数据库中的函数和数据。
import json
import idc
import ida_name
import idaapi
import ida_nalt
# 提示用户选择包含偏移数据的文件
ida_input_file_path = idaapi.ask_file(False, "*.txt", "Select the exported API data file (e.g., hacks.txt)")
if not ida_input_file_path:
print("--- IDA Renaming Script: No file selected. Aborting. ---")
idaapi.warning("No input file selected. Script terminated.")
else:
print(f"--- Starting IDA Renaming Script ---")
print(f"--- Using input file: {ida_input_file_path} ---")
offset_data = ""
try:
with open(ida_input_file_path, "r") as f:
offset_data = f.read()
print("--- Successfully read the input file. ---")
except Exception as e_file:
print(f"Error reading file {ida_input_file_path}: {e_file}")
idaapi.warning(f"Could not read input file: {e_file}")
offset_data = None # Ensure script aborts if file read fails
if offset_data:
datas = None
try:
datas = json.loads(offset_data)
print("--- JSON data parsed successfully. ---")
except ValueError as e_json:
print(f"JSON parsing error (ValueError): {e_json}")
print("Please ensure the .txt file contains PURE, VALID JSON data.")
print("You might need to manually remove any header/footer lines (like '--- START ---') from the file.")
idaapi.warning(f"JSON parsing error: {e_json}. Check console for details.")
if datas:
script_stats = {"renamed": 0, "skipped_invalid_addr": 0, "skipped_name_fail": 0, "processed_total": 0, "errors":0}
# 这个阈值用来区分可能的真实地址和一些小的数值 (比如某些属性返回的是枚举值或小偏移量而不是函数指针)
# 你可能需要根据实际情况调整这个值
ADDRESS_THRESHOLD = 0x10000
image_base = idaapi.get_imagebase()
print(f"--- Current IDA Image Base: 0x{image_base:X} ---")
print(f"--- Using ADDRESS_THRESHOLD: 0x{ADDRESS_THRESHOLD:X} (values below this for props might be treated as offsets not requiring renaming) ---")
def process_items_in_ida(items_list, category_name_prefix):
for item_data in items_list:
item_name_from_json = item_data.get("name", f"Unknown_{category_name_prefix}")
info = item_data.get("info", {})
# --- 处理函数 (func) ---
if "func" in info:
print(f" Processing functions for {category_name_prefix} '{item_name_from_json}'...")
for func_entry in info["func"]:
script_stats["processed_total"] += 1
func_name_suffix = func_entry.get("name")
# JSON中的地址是字符串形式的十六进制
func_addr_str_from_json = func_entry.get("addr")
if func_name_suffix and func_addr_str_from_json:
try:
# 地址需要从十六进制字符串转换为整数
addr_val_int = int(func_addr_str_from_json, 16)
if addr_val_int == 0 or addr_val_int == 0xFFFFFFFF or addr_val_int == -1:
# print(f" Skipping func '{item_name_from_json}::{func_name_suffix}': Invalid address value 0x{addr_val_int:X}")
script_stats["skipped_invalid_addr"] += 1
continue
# 假设JSON中的地址是相对于模块基址的,或者已经是绝对地址
# 如果JSON中的地址是RVA (Relative Virtual Address),则需要加上IDA的基址
# 但从Lua脚本来看,它获取的是绝对指针,所以这里直接使用
ida_address_to_rename = addr_val_int
# 构建完整的 IDA 命名空间风格的名称
ida_qualified_name = f"{item_name_from_json}::{func_name_suffix}"
# print(f" Attempting to rename IDA address 0x{ida_address_to_rename:X} to '{ida_qualified_name}'")
if ida_name.set_name(ida_address_to_rename, ida_qualified_name, ida_name.SN_CHECK | ida_name.SN_FORCE): # SN_FORCE to override some minor issues
# print(f" SUCCESS: Renamed 0x{ida_address_to_rename:X} to {ida_qualified_name}")
script_stats["renamed"] += 1
else:
# print(f" WARNING: Failed to rename 0x{ida_address_to_rename:X} to '{ida_qualified_name}' (name conflict or invalid address in IDA?)")
script_stats["skipped_name_fail"] += 1
except ValueError:
# print(f" Skipping func '{item_name_from_json}::{func_name_suffix}': Invalid address format '{func_addr_str_from_json}'")
script_stats["errors"] += 1
except Exception as e_func_process:
# print(f" ERROR processing func '{item_name_from_json}::{func_name_suffix}': {e_func_process}")
script_stats["errors"] += 1
else:
# print(f" Skipping func in {item_name_from_json} due to missing name or address.")
script_stats["skipped_invalid_addr"] +=1 # Count as invalid addr if essential info missing
# --- 处理属性 (prop) ---
# 注意:属性的 "offset" 可能是真实的函数地址,也可能是一个小整数(比如成员变量偏移或枚举)
# IDA的命名主要针对地址,如果是小整数偏移,这里通常不直接重命名,除非你知道这个偏移指向某个全局结构
if "prop" in info:
print(f" Processing properties for {category_name_prefix} '{item_name_from_json}'...")
for prop_entry in info["prop"]:
script_stats["processed_total"] += 1
prop_name_suffix = prop_entry.get("name")
prop_val_str_from_json = prop_entry.get("offset") # "offset" in JSON
if prop_name_suffix and prop_val_str_from_json:
try:
val_int = int(prop_val_str_from_json, 16)
if val_int == 0 or val_int == 0xFFFFFFFF or val_int == -1:
# print(f" Skipping prop '{item_name_from_json}::{prop_name_suffix}': Invalid value 0x{val_int:X}")
script_stats["skipped_invalid_addr"] += 1
continue
# 只有当这个值看起来像个地址时,我们才尝试重命名
if val_int > ADDRESS_THRESHOLD:
ida_address_to_rename = val_int
ida_qualified_name = f"{item_name_from_json}::prop_{prop_name_suffix}" # Add "prop_" prefix for clarity
# print(f" Prop Addr: Attempting to rename IDA 0x{ida_address_to_rename:X} to '{ida_qualified_name}'")
if ida_name.set_name(ida_address_to_rename, ida_qualified_name, ida_name.SN_CHECK | ida_name.SN_FORCE):
# print(f" SUCCESS: Renamed 0x{ida_address_to_rename:X} to {ida_qualified_name}")
script_stats["renamed"] += 1
else:
# print(f" WARNING: Failed to rename 0x{ida_address_to_rename:X} to '{ida_qualified_name}'")
script_stats["skipped_name_fail"] += 1
else:
# print(f" Prop Value for '{item_name_from_json}::{prop_name_suffix}' is 0x{val_int:X}. Treating as offset/value, not renaming as address.")
# 对于小的数值,通常代表的是偏移量或者一些枚举值,这里不直接进行地址重命名
pass
except ValueError:
# print(f" Skipping prop '{item_name_from_json}::{prop_name_suffix}': Invalid value format '{prop_val_str_from_json}'")
script_stats["errors"] += 1
except Exception as e_prop_process:
# print(f" ERROR processing prop '{item_name_from_json}::{prop_name_suffix}': {e_prop_process}")
script_stats["errors"] += 1
else:
# print(f" Skipping prop in {item_name_from_json} due to missing name or value.")
script_stats["skipped_invalid_addr"] +=1
if "classes" in datas and datas["classes"]:
print(f"\nProcessing 'classes' ({len(datas['classes'])} entries)...")
process_items_in_ida(datas["classes"], "Class")
else:
print("\nNo 'classes' data found or data is empty.")
if "objs" in datas and datas["objs"]:
print(f"\nProcessing 'objs' ({len(datas['objs'])} entries)...")
process_items_in_ida(datas["objs"], "Object")
else:
print("\nNo 'objs' data found or data is empty.")
print(f"\n--- IDA Renaming Script Finished ---")
print(f"Summary:")
print(f" Total items processed (funcs/props entries): {script_stats['processed_total']}")
print(f" Successfully renamed items in IDA: {script_stats['renamed']}")
print(f" Skipped due to invalid address/value from JSON: {script_stats['skipped_invalid_addr']}")
print(f" Skipped due to IDA naming failure (e.g., conflict): {script_stats['skipped_name_fail']}")
print(f" Errors during processing (e.g., format issues): {script_stats['errors']}")
else:
print("JSON data was not loaded successfully from the input file. Aborting further processing.")
if offset_data: # if offset_data was read but json.loads failed
idaapi.warning("JSON parsing failed. Ensure the input file is clean and valid JSON.")
Python 脚本关键点:
- 文件选择: 脚本启动后会弹窗让你选择那个包含导出数据的
.txt
文件(比如你清理后的hacks.txt
)。 - JSON 解析: 它会尝试解析这个文件为 JSON。如果你的文件里还残留着 Lua 脚本输出的 “— START —” 之类的非 JSON 文本,这里会报错!
- 地址转换: 从 JSON 中读取的地址是字符串,脚本会将其转换为十六进制整数。
ADDRESS_THRESHOLD
:这是一个启发式的值 (默认0x10000
)。对于 “prop”(属性)条目,如果其值(在 JSON 中对应 “offset” 键)小于这个阈值,脚本会认为它可能是一个小整数偏移量或枚举值,而不是一个需要重命名的内存地址,因此会跳过对这类条目的重命名。你可以根据需要调整这个值。- 命名风格: 脚本会尝试使用
ClassName::FunctionName
或ObjectName::FunctionName
以及ClassName::prop_PropertyName
的风格来命名。
实战演练
好了,理论和代码都有了,现在是实战演练。
准备工作:
- Lua 5.3.3 官方源代码。
- 一个 C 编译器(推荐 MSVC,因为你要编译 Windows DLL)。
- IDA Pro(版本随意,能跑 Python 就行)。
- 《以撒的结合:忏悔》游戏本体。
操作步骤:
- 修改 Lua 源码:
- 将上面提供的
lua_cclosure_upvalues
函数代码添加到你的 Lua 5.3.3 源码树的src/lapi.c
文件中。 - 将上面提供的
math_cclosure
函数代码添加到src/lmathlib.c
中,并且记得在mathlib[]
数组里注册它。
- 将上面提供的
- 编译 Lua DLL:
- 使用你的 C 编译器(如 MSVC)编译修改后的 Lua 5.3.3 源码,目标是生成一个 Windows DLL 文件。
- 备份与替换游戏 DLL:
- 找到你的《以撒的结合:忏悔》游戏安装目录。
- 极其重要:备份游戏目录下的
Lua5.3.3r.dll
文件! 随便复制一份改个名都行,比如Lua5.3.3r.dll.backup
。 - 将你上一步编译出来的修改版 Lua DLL 重命名为
Lua5.3.3r.dll
,然后用它替换掉游戏目录中原始的那个。
- 配置 Lua 提取器 Mod:
- 在你的以撒 Mod 目录(通常是
Documents\My Games\Binding of Isaac Repentance\mods\
)下创建一个新的文件夹,比如就叫hacks
。 - 将上面提供的 Lua 提取器脚本内容保存为一个
.lua
文件(比如main.lua
)放到这个hacks
文件夹里。 - 确保你的 Lua 脚本中
RegisterMod("hacks", 1)
和mprint
函数中的文件路径与你的 Mod 文件夹名一致。 - 启动游戏,在 Mods 菜单中启用你这个 “hacks” Mod。
- 在你的以撒 Mod 目录(通常是
- 启动游戏并导出数据:
- 关键: 确保你以撒游戏的启动选项中添加了
--luadebug
参数。比如在 Steam 库里右击游戏 -> 属性 -> 通用 -> 启动选项里填上--luadebug
。 - 正常启动游戏。
- 开始一局新游戏 (New Run)。当游戏加载完毕,回调触发,Lua 脚本就会执行,并将 API 信息写入到你的 Mod 文件夹下的
hacks.txt
(或者你指定的其他文件名)中。随便玩一会儿,或者直接退出到主菜单都行,脚本执行一次后会自动移除回调。
- 关键: 确保你以撒游戏的启动选项中添加了
- 清理数据文件:
- 找到生成的
hacks.txt
文件。 - 极其关键的一步: 用文本编辑器打开这个
hacks.txt
。你会看到里面除了 JSON 数据,还有一些类似 “— API ENUMERATION STARTED —"、”– Warning: Class not found…"、”— API ENUMERATION FINISHED —"、"– API Enumeration callback removed." 之类的文本行。你必须手动删除所有这些非 JSON 内容的行,确保文件从头到尾都是一个纯粹、完整的 JSON 对象(以{
开头,以}
结尾,中间是合法的 JSON 结构)。如果 JSON 格式不正确,Python 脚本会解析失败。
- 找到生成的
- IDA Pro 操作:
- 打开 IDA Pro,加载《以撒的结合:忏悔》的游戏主程序(
isaac-ng.exe
)。 - 重要提示: 游戏实际加载到内存中的基地址可能是随机的。如果 IDA 默认分析的基地址和游戏运行时不一样,那么直接导入的地址就是错的。你可能需要先确定游戏当前的基地址,然后在 IDA 中选择 “Edit” -> “Segments” -> “Rebase program…” 将 IDA 的程序基地址调整正确。如何获取运行时基地址,这里不赘述,请自行探索(例如使用调试器)。
- 待 IDA 分析完毕后,打开脚本执行窗口(File -> Script command…)。
- 将上面提供的 IDA Python 导入器脚本内容粘贴进去,或者加载脚本文件。
- 运行脚本。它会弹出一个文件选择框,请选择你上一步手动清理干净的那个
hacks.txt
文件。 - 脚本会开始尝试重命名。留意 IDA 下方的 Output 窗口,会输出一些处理信息和最终的统计数据。
- 打开 IDA Pro,加载《以撒的结合:忏悔》的游戏主程序(
搞定!顺利的话,你的 IDA 数据库里很多 Lua API 函数和属性就会被自动命名了。
可能遇到的坑
- Lua 脚本中的文件路径:
mprint
函数中写入文件的路径是mods/[Mod 名]/[Mod 名].txt
。如果你改了 Mod 文件夹名,记得同步修改 Lua 脚本里的hax.Name
如何被使用,或者直接硬编码路径。 - 链接时优化导致的“命名冲突”: 游戏编译器在链接时可能会进行优化,一些非常短小的 C 函数(比如简单的 getter/setter)可能会被编译器认为是相同的代码块,从而让多个不同的 Lua API 指向同一个内存地址。当这种情况发生时,IDA 脚本导入后,你可能会看到一个地址被赋予了多个看起来不相关的类的方法名(通常是最后一个成功命名的那个会生效,或者 IDA 会提示命名冲突)。这是正常现象,虽然有点讨厌,但通常影响不大,数量也不会很多。
io.open
失败: 如果 Lua 脚本无法写入文件,首先检查你是否正确添加了--luadebug
启动参数。其次,检查 Mod 文件夹是否有写入权限。- JSON 无效: IDA Python 脚本报错说 JSON 无效?回去仔细检查你的
.txt
文件,确保所有非 JSON 的调试信息行都删干净了,并且整个文件是一个合法的 JSON。可以用在线 JSON 校验工具检查一下。 - 地址对不上: 如果导入后发现很多名字标的位置驴唇不对马嘴,九成九是你的 IDA 基地址没设对。
搞定收工!
呼,差不多就这些了。现在你应该有了一套相对自动化的方法来把以撒的 Lua API 地址搞到 IDA 里,方便后续的逆向分析或者 Mod 开发。
这套流程不一定完美,但至少能用。祝你玩得开心,挖出更多好东西!