Featured image of post 以撒 Lua 游戏逆向:函数签名导出与 IDA 导入

以撒 Lua 游戏逆向:函数签名导出与 IDA 导入

以撒 Lua 游戏逆向:函数签名导出与 IDA 导入

哟,这玩意儿是干啥的?

OK,事情是这样的:这份文档主要是我自己的一个备忘录,记录了我是怎么从《以撒的结合:忏悔》(The Binding of Isaac: Repentance)中导出 Lua API 函数和属性的地址,然后喂给 IDA Pro 的。如果你也搞这个,或者想搞,那这份笔记也许能帮你省点事儿。

简单说,就是一套方案,能让你:

  1. 从游戏里“偷”出 Lua 函数和属性的实际内存地址。
  2. 用个 Python 脚本,把这些地址和名字自动导入到你的 IDA 数据库里,省得手动一个个找。

适用版本说明:

  • 游戏版本: 《以撒的结合:忏悔》(The Binding of Isaac: Repentance)。我主要在 1.9.7.81.9.8.12 这些版本上测试过,理论上应该对所有“忏悔”及“忏悔+”版本有效。
  • Lua 版本: 游戏用的是 Lua 5.3.3。强烈建议你也用这个版本的 Lua 源码来编译修改后的 DLL。我用 Lua 5.3.6 也测试成功过,所以 Lua 5.3.X 系列应该都行,但为了保险起见,最好还是用 5.3.3。

核心思路小科普

别担心,这里不搞长篇大论。核心思路其实挺简单的:

  1. Lua 的 C API 是个好东西: Lua 本身允许 C 代码和 Lua 代码互相调用。游戏引擎就是通过这个机制把 C/C++ 函数暴露给 Lua 脚本的。
  2. C 闭包 (C Closure) 和上值 (Upvalue): 当一个 C 函数被注册到 Lua 中时,它通常会被包装成一个 C 闭包。这些闭包可以携带一些与之关联的值,称为“上值”。在以撒(或者说它用的 Lua 绑定库)的实现里,那个实际的、可执行的 C 函数指针,恰好就作为上值之一存储在这个 C 闭包里。
  3. lua_cclosure_upvalues (自定义 C 函数): 为了拿到这个藏起来的函数指针,我们需要一个能访问 C 闭包上值的途径。原版 Lua API 没有直接提供“给我第 N 个上值的数据区指针”这样的函数,所以我们得自己往 Lua 源码里加一个。这个函数就是 lua_cclosure_upvalues,它能帮我们定位到存储真实函数指针的那个上值。
  4. math.cclosure (Lua 端调用接口): 光有 C 函数还不行,我们得在 Lua 脚本里能调用到它。所以,我们又在 Lua 的 math 库里加了个 math.cclosure 函数,它内部会调用我们上面实现的 lua_cclosure_upvalues,然后把获取到的函数指针(地址)返回给 Lua 脚本。
  5. 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::FunctionNameObjectName::FunctionName 以及 ClassName::prop_PropertyName 的风格来命名。

实战演练

好了,理论和代码都有了,现在是实战演练。

准备工作:

  • Lua 5.3.3 官方源代码。
  • 一个 C 编译器(推荐 MSVC,因为你要编译 Windows DLL)。
  • IDA Pro(版本随意,能跑 Python 就行)。
  • 《以撒的结合:忏悔》游戏本体。

操作步骤:

  1. 修改 Lua 源码:
    • 将上面提供的 lua_cclosure_upvalues 函数代码添加到你的 Lua 5.3.3 源码树的 src/lapi.c 文件中。
    • 将上面提供的 math_cclosure 函数代码添加到 src/lmathlib.c 中,并且记得在 mathlib[] 数组里注册它。
  2. 编译 Lua DLL:
    • 使用你的 C 编译器(如 MSVC)编译修改后的 Lua 5.3.3 源码,目标是生成一个 Windows DLL 文件。
  3. 备份与替换游戏 DLL:
    • 找到你的《以撒的结合:忏悔》游戏安装目录。
    • 极其重要:备份游戏目录下的 Lua5.3.3r.dll 文件! 随便复制一份改个名都行,比如 Lua5.3.3r.dll.backup
    • 将你上一步编译出来的修改版 Lua DLL 重命名为 Lua5.3.3r.dll,然后用它替换掉游戏目录中原始的那个。
  4. 配置 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。
  5. 启动游戏并导出数据:
    • 关键: 确保你以撒游戏的启动选项中添加了 --luadebug 参数。比如在 Steam 库里右击游戏 -> 属性 -> 通用 -> 启动选项里填上 --luadebug
    • 正常启动游戏。
    • 开始一局新游戏 (New Run)。当游戏加载完毕,回调触发,Lua 脚本就会执行,并将 API 信息写入到你的 Mod 文件夹下的 hacks.txt(或者你指定的其他文件名)中。随便玩一会儿,或者直接退出到主菜单都行,脚本执行一次后会自动移除回调。
  6. 清理数据文件:
    • 找到生成的 hacks.txt 文件。
    • 极其关键的一步: 用文本编辑器打开这个 hacks.txt。你会看到里面除了 JSON 数据,还有一些类似 “— API ENUMERATION STARTED —"、”– Warning: Class not found…"、”— API ENUMERATION FINISHED —"、"– API Enumeration callback removed." 之类的文本行。你必须手动删除所有这些非 JSON 内容的行,确保文件从头到尾都是一个纯粹、完整的 JSON 对象(以 { 开头,以 } 结尾,中间是合法的 JSON 结构)。如果 JSON 格式不正确,Python 脚本会解析失败。
  7. 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 开发。

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

Built with Hugo
Theme Stack designed by Jimmy