以撒 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。

启动游戏并导出数据:

关键: 确保你以撒游戏的启动选项中添加了 --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 数据库里很多 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 开发。

这套流程不一定完美,但至少能用。祝你玩得开心,挖出更多好东西!

Copyright © 2088 竞技新视野 - 电竞赛事活动专题站 All Rights Reserved.
友情链接