https://www.52pojie.cn/thread-1838396-1-1.html 逆向 UE4 方法

  1. UnrealDumper-4.25,适用于 Windows,早期版本的 dump 工具
  2. UEDumper,适用于 Windows,强大的工具
  3. UE4Dumper,适用于 Android 的简单 dump 数据工具
  4. AndUE4Dumper,适用于 Android 的可扩展 dump 工具,可导入 Android Studio 食用
  5. iOS_UE4Dumper,适用于 iOS 的可扩展 dump 工具,可导入 Xcode 食用
  6. UE4SDKGenerator,适用于 Android,强大的工具

https://github.com/s4m33r89/UE4SDKGenerator

正向视角: https://dev.epicgames.com/documentation/zh-cn/unreal-engine/setting-up-unreal-engine-projects-for-android-development


https://github.com/MJx0/AndUE4Dumper

尝试使用该工具 dump 出来东西(这事啥,源码吗),这么厉害啊

libUE4.so 还是从这里搞出来的,只是有别人编好的数据了


还是从底层原理看起

https://blog.csdn.net/jiangdengc/article/details/68064895

好像明白了,大概就是找到一些偏移,能够完整导出某些 UE4 的数据出来

https://iosre.com/t/ue4%E6%B8%B8%E6%88%8F%E4%BB%8E%E4%BD%95%E4%B8%8B%E6%89%8B%EF%BC%9F/17755/15

https://iosre.com/t/%E8%AE%B0%E5%BD%95%E4%B8%80%E6%AC%A1%E8%99%9A%E5%B9%BB4ue4%E6%89%8B%E6%B8%B8%E9%80%86%E5%90%91/19808

上面说到,World 是一个大容器,包含了游戏中的所有关卡,如果内存中找到这个地址,那么 Actor、Pawn 等都能遍历到。那么如何找这个基址呢?起初我也是费了老些劲,后来了解了一些 UE4 关键术语意思(Actor、Pawn、Character 等),加上各大论坛到处逛,最后总结到经验:通过一定的特征,找到大概的位置,然后参考 github 虚幻 4 源代码和 IDA 中汇编逻辑回溯找到 World 基址。 那这一定的特征是什么呢?字符串!!! 在源代码中找到引用 GWorld 变量的附近的字符串。 在源码 4.20/Engine/Source/Runtime/Engine/Private/World.cppSeamlessTravel FlushLevelStreaming 字符串与 GWorld 引用非常近,可以作为特征

还是非常不懂,接下来的任务就是尝试自己搞一个 demo 出来看看逻辑是怎么样

官方教程 https://dev.epicgames.com/community/learning/tutorials/OR8/beginners-intro-to-ue5-create-a-game-in-3-hours-in-unreal-engine-5


只能搞出来 demo 源码,编译打包太耗时了,电脑爆炸了。不过这个也够看了

符号恢复: http://www.yxfzedu.com/article/670


现在才知道 CE 是有 mac 版本的,也是可以跑一个 netserver 在安卓上的,用这个来扫描可太方便了

CE 学习


https://www.bilibili.com/video/BV1N34y1M7eS 这个能够看明白怎么操作了

dump 需要的两个东西是 GUObjectArray、GName

https://www.win32k.cn/d/46-ue4zui-wan-zheng-de-ni-xiang-zhi-nan-ji-shi-pin-jiao-cheng

NamePoolData 的查找方式非常简单 你只需要用 CE 搜索字符串 MulticastDelegateProperty 注意并非宽字符 浏览找到的地址的内存区域,并找到您在其中读取类似“None,ByteProperty,someProperty ecc”的内容 None 的前面应该是 _.None 只需要搜索指向 _.None 的首地址 即可查找到 NamePoolData 此时的内存偏移指向了 uint8* Blocks[FNameMaxBlocks] = {}; 再将地址 -0x10 即 NamePoolData 地址 如果这个方法失效 可以使用其他方式查找 NamePoolData NamePoolData 并不太重要并且几乎所有游戏都可以使用这种办法 也就不再继续过多描述 这有一段 youtube 视频讲解了这个办法

Game Object 就有点复杂了,等会再看,先自己试一下


AndUE4Dumper 尝试编译这个项目

在 mac 环境下修改 build 脚本:

// ndk-build.bat
#!/bin/bash
 
# Remove build directory if it exists
if [ -d "build" ]; then
    rm -rf build
fi
 
# Create build directory
mkdir -p build
 
# Run make
make
 
# Move build artifacts
mv libs build/libs
mv obj build/obj

更新依赖,修改环境变量

git submodule update --init --recursive
export NDK_HOME=/Users/cyril/Library/Android/sdk/ndk/27.0.12077973

然后运行构建脚本就行了

把 push 也改了

#!/bin/sh
echo "pushing dumper binaries..."
 
adb push build/libs/arm64-v8a/UE4Dump3r_arm64 /data/local/tmp
adb shell "su -c 'chmod 755 /data/local/tmp/UE4Dump3r_arm64'"
 
adb push build/libs/armeabi-v7a/UE4Dump3r_arm /data/local/tmp 
adb shell "su -c 'chmod 755 /data/local/tmp/UE4Dump3r_arm'"
 
adb push build/libs/x86/UE4Dump3r_x86 /data/local/tmp
adb shell "su -c 'chmod 755 /data/local/tmp/UE4Dump3r_x86'"
 
adb push build/libs/x86_64/UE4Dump3r_x86_64 /data/local/tmp
adb shell "su -c 'chmod 755 /data/local/tmp/UE4Dump3r_x86_64'"
 
# test on arm64 device
adb shell "su -c '/data/local/tmp/UE4Dump3r_arm64 -o /sdcard'"
 
read -p "Press enter to continue"

试一圈下来都不行,还是要自己写了 DS_ERROR_INIT_GUOBJECTARRAY


首先是结构体的学习(略)

GNames 指的是 static uint8 NamePoolData[sizeof(FNamePool)];

查找 MulticastDelegateProperty

很奇怪,就是搜不到相应的字符串


https://www.cnblogs.com/revercc/p/17641855.html

这个教程好像有用,虽然是 UE5,但是好像对得上

  • ida 中搜索字符串 ByteProperty,转到交叉引用处就是 FNamePool 构造函数,传入的参数就是 NamePool
  • ida 找到 CloseDisregardForGC 字符串引用处,下面的函数传入的参数就是 GUObjectArray
#!/bin/sh
adb push libs/arm64-v8a/ue4dumper64 /data/local/tmp/ue4dumper64
adb shell "su -c 'chmod 755 /data/local/tmp/ue4dumper64'"
 
adb shell "su -c '/data/local/tmp/ue4dumper64 --newue+ --strings --gname 0xADF07C0 --package com.ACE2025.Game --output /sdcard/Download'"
adb shell "su -c '/data/local/tmp/ue4dumper64 --newue+ --objs --gname 0xADF07C0 --guobj 0xAE34A98 --package com.ACE2025.Game --output /sdcard/Download'"

dump 下来了!

但是还想导入 ida 里面看,还得改之前的那个

#pragma once
 
#include "../GameProfile.h"
 
// eFootBall
// UE 4.25+ ??
 
class PESProfile : public IGameProfile
{
public:
    PESProfile() = default;
 
    virtual bool ArchSupprted() const override
    {
        auto e_machine = GetUE4ELF().header().e_machine;
        // arm & arm64
        return e_machine == EM_AARCH64 || e_machine == EM_ARM;
    }
 
    std::string GetAppName() const override
    {
        return "eFootball 2023";
    }
 
    std::vector<std::string> GetAppIDs() const override
    {
        return { "jp.konami.pesam" };
    }
 
    bool IsUsingFNamePool() const override
    {
        return true;
    }
 
    uintptr_t GetGUObjectArrayPtr() const override
    {
        PATTERN_MAP_TYPE map_type = isEmulator() ? PATTERN_MAP_TYPE::ANY_R : PATTERN_MAP_TYPE::ANY_X;
        std::string ida_pattern = "08 ? ? 91 E1 03 ? AA E0 03 08 AA E2 03 1F 2A";
        int step = -4;
 
        uintptr_t insn_address = findIdaPattern(map_type, ida_pattern, step);
        if(insn_address == 0)
        {
            LOGE("GUObjectArray pattern failed.");
            return 0;
        }
 
        int64_t adrp_pc_rel = 0;
        int32_t add_imm12 = 0;
 
        uintptr_t page_off = INSN_PAGE_OFFSET(insn_address);
 
        uint32_t adrp_insn = 0, add_insn = 0;
        kMgr.readMem((insn_address), &adrp_insn, sizeof(uint32_t));
        kMgr.readMem((insn_address + sizeof(uint32_t)), &add_insn, sizeof(uint32_t));
 
        if (adrp_insn == 0 || add_insn == 0)
            return 0;
 
        if (!KittyArm64::decode_adr_imm(adrp_insn, &adrp_pc_rel) || adrp_pc_rel == 0)
            return 0;
 
        add_imm12 = KittyArm64::decode_addsub_imm(add_insn);
 
        return (page_off + adrp_pc_rel + add_imm12);
    }
 
    uintptr_t GetNamesPtr() const override
    {
        PATTERN_MAP_TYPE map_type = isEmulator() ? PATTERN_MAP_TYPE::ANY_R : PATTERN_MAP_TYPE::ANY_X;
        std::string ida_pattern = "F4 4F 01 A9 FD 7B 02 A9 FD 83 ? 91 ? ? ? ? A8 02 ? 39";
        int step = 0x24;
 
        uintptr_t insn_address = findIdaPattern(map_type, ida_pattern, step);
        if (insn_address == 0)
        {
            LOGE("NamePoolData pattern failed.");
            return 0;
        }
 
        int64_t adrp_pc_rel = 0;
        int32_t add_imm12 = 0;
 
        uintptr_t page_off = INSN_PAGE_OFFSET(insn_address);
 
        uint32_t adrp_insn = 0, add_insn = 0;
        kMgr.readMem((insn_address), &adrp_insn, sizeof(uint32_t));
        kMgr.readMem((insn_address + sizeof(uint32_t)), &add_insn, sizeof(uint32_t));
 
        if (adrp_insn == 0 || add_insn == 0)
            return 0;
 
        if (!KittyArm64::decode_adr_imm(adrp_insn, &adrp_pc_rel) || adrp_pc_rel == 0)
            return 0;
 
        add_imm12 = KittyArm64::decode_addsub_imm(add_insn);
 
        return (page_off + adrp_pc_rel + add_imm12);
    }
 
    UE_Offsets *GetOffsets() const override
    {
        ... // 照抄其他的
    }
};

https://github.com/Perfare/Il2CppDumper/blob/master/Il2CppDumper/ida_py3.py

把 script 字段改成 ScriptMethod,然后导入,就可以搜索到函数了

只不过大部分的函数都没有信息(算了也够了)


获取到那几个关键信息之后我们就可以着手内部结构了

  • GWorld 指向当前的 UWorld 对象。UWorld 是整个游戏世界的最高级别容器,包含当前关卡(Level)、玩家控制器(Controller)、角色(character)等
  • GName 是全局名称表指针。UE 在内部不直接使用字符串表示对象的名称(如类名、属性名、函数名),而是使用一个唯一的整数 FNameIndex,来映射到 GName 表中的一个实际字符串(类似于 Android 的字符串资源)
  • GObject 是 UE 的全局对象数组指针,是一个动态数组,包含所有已经加载到内存中的 UObject 实例,所有实体都是这个类的派生类
  • 获取到 UWorld 对象之后,可以获取到当前关卡下的所有 Actor 列表。Actor 是 UE 中基本的放置在世界中的对象

G U

不同的版本下具体不一样,这里是 4.27。在内存中的 UObject 类关键内部字段的偏移量:

  • var offset_UObject_InternalIndex = 0xC; 是这个 UObject 在 GObject 数组中的索引
  • var offset_UObject_ClassPrivate = 0x10; 是一个指向 UClass 对象的指针,这个 UClass 对象能够描述其类型和行为,获取它能够获得对象的运行时类型信息
  • var offset_UObject_FNameIndex = 0x18; 是自身名称的 ID,是 GName 表中的索引
  • var offset_UObject_OuterPrivate = 0x20; 是指向外部对象 Outer 的指针,就是它的所属

ID name 脚本如下:

function getFNameFromID(index) {
    var FNameStride = 0x2 // 虚幻引擎4.25+ 名称表Entry的步长,老版本可能是0x4
    var offset_GName_FNamePool = 0x30; // GName 对象内部指向 FNamePool 的偏移
    var offset_FNamePool_Blocks = 0x10; // FNamePool 内部指向 FNameEntry 块数组的偏移
 
    var offset_FNameEntry_Info = 0; // FNameEntry 结构体中,名称信息(长度、宽字符标志)的偏移
    var FNameEntry_LenBit = 6; // 名称长度信息存储在 FNameEntryHeader 的第6位及以上
    var offset_FNameEntry_String = 0x2; // FNameEntry 结构体中,实际字符串数据区的偏移
 
    var Block = index >> 16; // FNameIndex 的高16位表示其所在的内存块(Block)
    var Offset = index & 65535; // FNameIndex 的低16位表示该块内的偏移量
 
    var FNamePool = GName.add(offset_GName_FNamePool); // 获取 FNamePool 对象指针
    // 从 FNamePool 内部的 Blocks 数组中,根据 Block 索引找到对应内存块的指针
    // 每个块指针占用 8 字节(QWORD)
    var NamePoolChunk = FNamePool.add(offset_FNamePool_Blocks + Block * 8).readPointer();
    // 在找到的内存块中,根据 Offset 和 FNameStride(每个 FNameEntry 的大小)计算出具体 FNameEntry 的地址
    var FNameEntry = NamePoolChunk.add(FNameStride * Offset);
 
    try {
        if (offset_FNameEntry_Info !== 0) {
            // 读取 FNameEntryHeader。通常 FNameEntry_Info 偏移为 0,即直接从 FNameEntry 地址开始读取。
            var FNameEntryHeader = FNameEntry.add(offset_FNameEntry_Info).readU16();
        } else {
            var FNameEntryHeader = FNameEntry.readU16();
        }
    } catch(e) {
        return ""; // 发生错误时返回空字符串,避免崩溃
    }
 
    var str_addr = FNameEntry.add(offset_FNameEntry_String); // 实际字符串数据的起始地址
    var str_length = FNameEntryHeader >> FNameEntry_LenBit; // 通过右移 FNameEntryHeader 获取字符串长度
    var wide = FNameEntryHeader & 1; // 通过与1操作判断是否为宽字符(Unicode)
 
    if (wide) return "widestr"; // 如果是宽字符,目前只返回 "widestr" 占位符
 
    if (str_length > 0 && str_length < 250) { // 检查字符串长度是否合理,避免读取无效内存
        var str = str_addr.readUtf8String(str_length); // 读取 UTF8 编码的字符串
        return str;
    } else {
        return "None"; // 长度不合理或为零时返回 "None"
    }
}

反编译找到的三个 G,需要在运行时找到内存中的实例(文件偏移 内存)

function set(modulename) {
    moduleBase = Module.findBaseAddress(modulename);
    GWorld = moduleBase.add(GWorld_Ptr_Offset).readPointer();
    GName = moduleBase.add(GName_Offset);
    GObjects = moduleBase.add(GObjects_Offset);
}

GWorld 对象中有一个指向当前 ULevel 对象的指针(偏移量 0x30)。ULevel 代表游戏中的一个关卡实例。在 ULevel 对象中,有一个动态数组,存储了当前关卡中所有 AActor 实例的指针。

虚幻引擎的动态数组(TArray)通常是 指针 + Num + Max 的结构。所以,紧跟在数组指针后面 8 字节(sizeof(void*))的位置,存储着数组的当前元素数量 (Actors_Num),是一个 32 位无符号整数。

GWorld 遍历所有 Actor:

function getActorsAddr(){
    var Level_Offset = 0x30 // UWorld 内部指向当前 ULevel 的偏移
    var Actors_Offset = 0x98 // ULevel 内部指向 Actor 数组的偏移
 
    var Level = GWorld.add(Level_Offset).readPointer() // 从 GWorld 获取当前 Level 指针
    var Actors = Level.add(Actors_Offset).readPointer() // 从 Level 获取 Actor 数组的起始指针
    var Actors_Num = Level.add(Actors_Offset).add(8).readU32() // Actor 数组的元素数量,通常紧跟在数组指针后面 8 字节
 
    var actorsAddr = {}; // 创建一个空对象来存储 Actor 名称和地址的映射
    for(var index = 0; index < Actors_Num; index++){
        var actor_addr = Actors.add(index * 8).readPointer() // 遍历数组,每个 Actor 指针占用 8 字节
        var actorName = GUObject.getName(actor_addr) // 获取 Actor 的名称
        actorsAddr[actorName] = actor_addr; // 将 Actor 名称作为键,地址作为值存入对象
    }
    return actorsAddr; // 返回所有 Actor 的名称到地址的映射
}