吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 5820|回复: 34
收起左侧

[Android 脱壳] 最新版某气骑士分析记录-绕过某讯的防御dump内存并解密存档

  [复制链接]
Rytter 发表于 2026-1-7 21:18
本帖最后由 Rytter 于 2026-1-8 23:22 编辑

某气骑士分析记录

前段时间想找一个能简单打发时间的小游戏,于是就想到了元气骑士,但是元气骑士的旧账号在另一个手机上,那个手机不在身边,但是我又不想重头开始玩,作为网安学生,于是有了自己逆向的想法。
本文章主要参考了:
https://www.52pojie.cn/thread-1844587-1-1.html
https://www.52pojie.cn/thread-1842883-1-1.html
https://www.bilibili.com/read/cv25143217/?opus_fallback=1
https://reimu.moe/2024/06/09/IL2Cpp-String-Literals/

逆向分析

首先逆向一下元气骑士的apk包,一眼看到这是一个unity开发的游戏,然后同时看到的还有libijiami的so文件,说明这是一个使用爱加密的壳,这个il2cpp也必要逆向了,必然是加了密的:

6facf4ecfd4199a65bce0df821efb1ad.png

如果想要拿到未加密的libil2cpp,一个可行的方法就是去内存找,因为代码加载的时候,必然是从内存中加载出去的:

通过万能的github,我找到可以扒内存的apk,并且还能自动fix ELF:

image-20251217150140331.png

然后通过这个方法可以扒出来libil2cpp.so,but,等俺使用il2cpp dump的时候,发现解析不出来

2ba680f218097568ad86ba9c40d1a899.png

完蛋,发现global-metadata.dat是加密的,所以需要用点其他手段,想办法解出来,使用frida对应用先attach一下看看:

f19306417d258bbe72da322ccb19c006.png

MD,炸了,说明有frida检测,还能怎么办,接着搞呗,先看看加载了哪些so库:

因为我用的frida17,所以我的脚本如下,但是如果你用的frida其他版本,需要自己更改调用api:

console.log("[-] 脚本开始运行,正在搜索 dlopen 函数...");

// 1. 尝试查找 android_dlopen_ext (新版安卓常用)
var dlopen = Module.getGlobalExportByName( "android_dlopen_ext");

// 2. 如果找不到,尝试查找 dlopen (旧版通用)
if (!dlopen) {
    console.log("[-] 未找到 android_dlopen_ext,尝试查找标准 dlopen...");
    dlopen = Module.findgetGlobalExportByName(null, "open");
}

if (dlopen) {
    console.log(" 成功定位 dlopen 函数地址: " + dlopen);

    Interceptor.attach(dlopen, {
        onEnter: function(args) {
            // args[0] 通常是加载库的路径字符串
            var pathPtr = args[0];
            if (pathPtr !== undefined && pathPtr != null) {
                var path = pathPtr.readCString();
                // 过滤一下,只显示包含 .so 的加载信息,看起来更清晰
                if (path.indexOf(".so") >= 0) {
                    console.log("[+] LOAD: " + path);
                }
            }
        }
    });
} else {
    console.log("[!] 严重错误: 无法在内存中找到任何 dlopen 函数!");
}

然后使用spawn方式运行:

frida -U -f com.knight.union.mi -l hookopen.js

打开一看,*了狗了:

e8ef2e3de81d9895ba176412b1dea6d6.png

腾讯的壳,爱加密的壳,还有个libmsaoaidsec.so,只能说NB

用代码开一下线程,看看到底是哪个狗东西把俺滴frida给杀了

function hook_pthread_create() { 
    Interceptor.attach(Module.getGlobalExportByName("pthread_create"), { 
        onEnter: function (args) { var module = Process.findModuleByAddress(ptr(this.returnAddress))                 
            if (module != null) { console.log("[pthread_create] called from", module.name) } 
            else 
                { 
                    console.log("[pthread_create] called from", ptr(this.returnAddress)) 
                } 
            }, 
        }) 
    } 
hook_pthread_create()

查看线程,发现是调用了libtprt之后frida线程炸了,看来就是企鹅的锅。

677517fb61b653b352f373fb90b2fc7c.png

腾讯的壳感觉大概率是不好搞

BUT!在我准备放弃的时候,我突然想到我手机上LSposed一直开着,说不定这个libtprt不会检查俺滴LSposed?

写个插件试试:(关于插件的编写见这个教程:https://www.52pojie.cn/thread-2029038-1-1.html)

(突然发现上了研只学会了写论文与专利,代码能力一落千丈,这就是修为散尽的感觉的吗?)

package com.example.hookknight

import android.app.Activity
import android.os.Bundle
import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage

class HookEntry : IXposedHookLoadPackage {

    override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
        // 只对目标包生效
        if (lpparam.packageName != "com.knight.union.mi") return

        XposedBridge.log("[$TAG] ========== 注入成功 ==========")

        try {
            // 只 hook Activity.onCreate!最简单的测试
            XposedHelpers.findAndHookMethod(
                Activity::class.java,
                "onCreate",
                Bundle::class.java,
                object : XC_MethodHook() {
                    override fun afterHookedMethod(param: MethodHookParam) {
                        val activity = param.thisObject as Activity
                        XposedBridge.log("[$TAG] ✅ onCreate -> ${activity.javaClass.simpleName}")
                    }
                }
            )
            XposedBridge.log("[$TAG] Hook Activity.onCreate 成功")
        } catch (e: Throwable) {
            XposedBridge.log("[$TAG] Hook 失败: ${e.message}")
        }
    }

    companion object {
        private const val TAG = "HookTest"
    }
}

然后测试一下看看,嘿!您还真别说,注入了

410ca148d3bfea0aee93fbac9585a0cb.jpg

很好,下一步明确了,用LSposed大概率能够绕过某讯的检测,先写个脚本试一下:

下面这个是LSposed hook native的c++函数,之后我会写一个如何使用LSposed hook native的教程,这个相对来说还有点麻烦:

#include <android/log.h>
#include "native_api.h"

#define LOG_TAG "LSP-NativeTest"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)

static void on_library_loaded(const char *name, void *handle) {
LOGI("on_library_loaded: %s, handle=%p", name ? name : "(null)", handle);
}

// 关键入口:LSPosed 会调用这个函数
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
LOGI("native_init called, version=%u", entries ? entries->version : 0);
// 直接返回回调函数,让 LSPosed 之后继续调 on_library_loaded
return on_library_loaded;
}

然后使用adb看下日志,结果:

222118598f3a4471fb96590663abcc46.png

咋回事?怎么又被检测到了,又闪退了,不应该啊

讲道理,既然lsposed后台一直运行着,开启的时候不会被检测,但是问题为什么一写插件就会被检测到?

我有了一个大胆的想法,会不会是因为使用adb的原因?会不会是因为被检测到与电脑通信?

然后我又拔掉数据线试了一下,您猜怎么着,没闪退,说明找到问题了,如果不连接数据线到电脑的话,就不会被检测到,那么另一个问题来了,如何看到输出的日志?LSposed可以在app内看到日志,但是那是需要调用的一个java api,所以一个解决办法是在 Kotlin 里写个 LogUtil,然后使用 JNI 交互:

#include <jni.h>
#include <android/log.h>
#include <dlfcn.h>
#include <pthread.h>
#include <string>
#include <cstring>
#include <cstdio>
#include "native_api.h"

#define LOG_TAG "LSP-NativeTest"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)

static JavaVM *g_JavaVm = nullptr;
static jclass g_LogUtilClass = nullptr;
static jmethodID g_LogFromNativeMethod = nullptr;

static void XLog(JNIEnv *env, const char *msg) {
    if (!env || !g_LogUtilClass || !g_LogFromNativeMethod) {
        LOGI("XLog not ready: %s", msg);
        return;
    }
    jstring jmsg = env->NewStringUTF(msg);
    env->CallStaticVoidMethod(g_LogUtilClass, g_LogFromNativeMethod, jmsg);
    env->DeleteLocalRef(jmsg);
}

// LSPosed 加载任意 so 时的回调
static void on_library_loaded(const char *name, void *handle) {
    LOGI("on_library_loaded: %s, handle=%p", name ? name : "(null)", handle);
    if (g_JavaVm && g_LogUtilClass && g_LogFromNativeMethod) {
        JNIEnv *env = nullptr;
        if (g_JavaVm->GetEnv((void **) &env, JNI_VERSION_1_6) == JNI_OK) {
            char buf[256];
            snprintf(buf, sizeof(buf), "on_library_loaded: %s", name ? name : "(null)");
            XLog(env, buf);
        }
    }
}

// 关键入口:LSPosed 会调用这个函数
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
    LOGI("native_init called, version=%u", entries ? entries->version : 0);
    if (g_JavaVm && g_LogUtilClass && g_LogFromNativeMethod) {
        JNIEnv *env = nullptr;
        if (g_JavaVm->GetEnv((void **) &env, JNI_VERSION_1_6) == JNI_OK) {
            XLog(env, "native_init called from LSPosed");
        }
    }

    return on_library_loaded;
}

extern "C" [[gnu::visibility("default")]] [[gnu::used]]
jint JNI_OnLoad(JavaVM *vm, void *) {
    g_JavaVm = vm;
    JNIEnv *env = nullptr;
    if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }

    // 找到 com.example.hookknight.LogUtil
    jclass clazz = env->FindClass("com/example/hooknative/LogUtil");
    if (!clazz) {
        LOGI("FindClass LogUtil failed");
        return JNI_VERSION_1_6;
    }
    g_LogUtilClass = (jclass) env->NewGlobalRef(clazz);
    env->DeleteLocalRef(clazz);

    // 找到静态方法 logFromNative(Ljava/lang/String;)V
    g_LogFromNativeMethod = env->GetStaticMethodID(
            g_LogUtilClass,
            "logFromNative",
            "(Ljava/lang/String;)V"
    );
    if (!g_LogFromNativeMethod) {
        LOGI("GetStaticMethodID logFromNative failed");
    } else {
        jstring jmsg = env->NewStringUTF("JNI_OnLoad OK, Xposed log ready");
        env->CallStaticVoidMethod(g_LogUtilClass, g_LogFromNativeMethod, jmsg);
        env->DeleteLocalRef(jmsg);
    }

    return JNI_VERSION_1_6;
}

614e117c2d6b1103808e2679acebd890.jpg

内存il2cpp与global-metadata.dat获取

可以看到load成功了,说明il2cpp,能够从app前端获取到日志信息,下一步,薅内存。

这里其实我写了一个xpsoed的插件,但是一直打不开mem,就是如果打开就会出现无法读取mem的结果,但是如果使用frida进行ps,又会进行闪退,使用adb调试的时候元气骑士也会闪退。

于是我直接上杀招:Termux!Termux相当与linux中的shell,而且有了su权限之后简直就是把手机当linux来用(我直接给手机装了个docker哈哈!)

fbb67135630982d661411bf6901ebd92_720.jpg

在Termux中使用

ps -ef | grep com.knight.union.mi

可以拿到pid

有了pid可以在maps中查询到内存中加载的文件(建议了解一下linux中maps的原理,其实就是内存映射)

通过

cat /proc/<pid>/maps | grep il2cpp

可以得到关于il2cpp的信息,可以看到有很多so文件,所以可以写个sh脚本把这些从内存中dump出来:

这里参考这位大佬的帖子:https://www.52pojie.cn/thread-1844587-1-1.html,这位大佬写了一个脚本去dump元气骑士,不过最新版和帖子中的版本有区别,所以在这个大佬的基础上写了一个脚本去dump最新版本的il2cpp的so文件。

#!/system/bin/sh

echo "##################################"
echo "# Il2Cpp Memory Dumper (Enhanced) #"
echo "# Based on NekoYuzu's script      #"
echo "##################################"

if [[ $1 == "" ]]; then
        echo "* Usage: $0 <package> [output]"
        echo "* Example: $0 com.knight.union.mi /sdcard/dump"
        exit
fi

package=$1

if [[ $2 == "" ]]; then
        out=/sdcard/dump
else
        out=$2
fi

echo "- Target package: $package"
echo "- Output directory: $out"

mkdir -p "$out"

# 获取用户ID和进程PID
user=$(am get-current-user)
pid=$(ps -ef | grep $package | grep u$user | awk '{print $2}')

if [[ $pid == "" ]]; then
        echo "! Target package of current user ($user) not found, is process running?"
        exit
fi

echo "- Found target process: $pid"

# 保存完整的maps文件
cp /proc/$pid/maps "$out/${package}_maps.txt"

# 获取系统页大小
SYS_PAGESIZE=$(getconf PAGESIZE)
HEX_PAGESIZE=$(printf "%x" $SYS_PAGESIZE)

echo "- System page size: $SYS_PAGESIZE bytes (0x$HEX_PAGESIZE)"

# 检查libil2cpp.so是否存在
if ! grep -q "libil2cpp.so" /proc/$pid/maps; then
        echo "! libil2cpp.so not found in memory"
        exit
fi

echo "- Starting dump process..."
echo ""

# 收集所有libil2cpp.so的内存段
mem_list=$(grep "libil2cpp.so" /proc/$pid/maps | awk -v OFS='|' '{for (i = 1; i <= NF; i+=6) {print $i,$(i+1),$(i+2),$(i+3),$(i+4),$(i+5)}}')

# 用于合并的变量
lastFile=
lastEnd=
lastOffset=

for memory in $mem_list; do
        local range=$(echo $memory | awk -F'|' '{print $1}')

        if [[ $range == "(deleted)" ]]; then
                continue
        fi

        local offset=$(echo $range | awk -F'-' '{print toupper($1)}')
        local end=$(echo $range | awk -F'-' '{print toupper($2)}')
        local perms=$(echo $memory | awk -F'|' '{print $2}')
        local fileOffset=$(echo $memory | awk -F'|' '{print $3}')
        local memIndicator=$(echo $memory | awk -v OFS=',' -F'|' '{print $4,$5}')

        # 检查ELF magic number
        dd if="/proc/$pid/mem" bs=1 skip=$(echo "ibase=16;$offset" | bc) count=4 of="${out}/tmp" 2>/dev/null

        local fileExt="bin"
        local isELF=false

        if [[ $(cat "${out}/tmp") == $(echo -ne "ELF") ]]; then
                isELF=true
                fileExt="so"

                # 获取ELF架构信息
                local EI_CLASS="Unknown"
                dd if="/proc/$pid/mem" bs=1 skip=$(echo "ibase=16;${offset}+4" | bc) count=1 of="${out}/tmp" 2>/dev/null
                tmp=$(cat "${out}/tmp")
                if [[ $tmp == $(echo -ne "*") ]]; then
                        EI_CLASS="32"
                elif [[ $tmp == $(echo -ne "*") ]]; then
                        EI_CLASS="64"
                fi

                local EI_DATA="Unknown"
                dd if="/proc/$pid/mem" bs=1 skip=$(echo "ibase=16;${offset}+5" | bc) count=1 of="${out}/tmp" 2>/dev/null
                tmp=$(cat "${out}/tmp")
                if [[ $tmp == $(echo -ne "*") ]]; then
                        EI_DATA="LSB"
                elif [[ $tmp == $(echo -ne "*") ]]; then
                        EI_DATA="MSB"
                fi

                local E_TYPE="Unknown"
                dd if="/proc/$pid/mem" bs=1 skip=$(echo "ibase=16;${offset}+10" | bc) count=2 of="${out}/tmp" 2>/dev/null
                tmp=$(cat "${out}/tmp" | xxd -p)
                case "$tmp" in
                        "0100") E_TYPE="Relocatable" ;;
                        "0200") E_TYPE="Executable" ;;
                        "0300") E_TYPE="Shared object" ;;
                        "0400") E_TYPE="Core" ;;
                        *) E_TYPE="Processor-specific" ;;
                esac

                echo "═══════════════════════════════════════"
                echo " ELF ${EI_CLASS}-bit $E_TYPE (${EI_DATA})"
                echo "  Address: 0x$offset - 0x$end"
                echo "  Permissions: $perms"
                echo "  File offset: 0x$fileOffset"
        else
                echo " Segment at 0x$offset [$perms]"
        fi

        # 确定输出文件名
        if [[ $isELF == true ]]; then
                local fileOut="${out}/${offset}_${package}_libil2cpp.so"
        else
                local fileOut="${out}/${offset}_${package}_segment.bin"
        fi

        # Dump当前段
        echo "  Dumping: $range..."
        dd if="/proc/$pid/mem" bs=$SYS_PAGESIZE                 skip=$(echo "ibase=16;${offset}/$HEX_PAGESIZE" | bc)                 count=$(echo "ibase=16;(${end}-${offset})/$HEX_PAGESIZE" | bc)                 of="$fileOut" 2>/dev/null

        if [[ $? -ne 0 ]]; then
                echo "   Failed to dump, skipping..."
                rm -f "$fileOut"
                continue
        fi

        # 检查.bss段
        local bss_memory=$(grep -i "^${end}-" "/proc/$pid/maps" | grep "\[anon:.bss\]" | head -1)
        if [[ $bss_memory != "" ]]; then
                local bss_range=$(echo $bss_memory | awk '{print $1}')
                local bss_end=$(echo $bss_range | awk -F'-' '{print toupper($2)}')
                local bss_blocks=$(echo "ibase=16;(${bss_end}-${end})/${HEX_PAGESIZE}" | bc)

                echo "   Adding .bss segment ($bss_blocks blocks)..."
                dd if="/proc/$pid/mem" bs=$SYS_PAGESIZE                         skip=$(echo "ibase=16;${end}/$HEX_PAGESIZE" | bc)                         count=$bss_blocks of="$out/tmp" 2>/dev/null
                cat "$out/tmp" >> "$fileOut"
                end=$bss_end
        fi

        # 合并逻辑
        if [[ $isELF == true ]]; then
                # 这是一个新的ELF文件,不合并
                lastFile=$fileOut
                lastOffset=$offset
        else
                # 非ELF段,尝试合并到上一个文件
                if [[ $lastFile != "" ]]; then
                        echo "   Checking merge possibility..."
                        local skipMerge=false

                        if [[ $lastEnd != $offset ]]; then
                                # 存在间隙
                                local gap_blocks=$(echo "ibase=16;(${offset}-${lastEnd})/${HEX_PAGESIZE}" | bc)

                                if [[ $gap_blocks -gt $SYS_PAGESIZE ]]; then
                                        # 间隙太大,不合并
                                        echo "   Gap too large ($gap_blocks blocks), treating as separate file"
                                        skipMerge=true
                                        lastFile=$fileOut
                                else
                                        # 间隙较小,填充并合并
                                        echo "   Filling gap ($gap_blocks blocks)..."
                                        dd if="/proc/$pid/mem" bs=$SYS_PAGESIZE                                                 skip=$(echo "ibase=16;${lastEnd}/$HEX_PAGESIZE" | bc)                                                 count=$gap_blocks of="$out/tmp" 2>/dev/null
                                        cat "$out/tmp" >> "$lastFile"
                                fi
                        fi

                        if [[ $skipMerge == false ]]; then
                                # 合并当前段到上一个文件
                                echo "   Merging into previous file..."
                                cat "$fileOut" >> "$lastFile"
                                rm -f "$fileOut"
                        fi
                else
                        # 第一个非ELF段,无法合并
                        echo "   No previous ELF file to merge"
                        lastFile=$fileOut
                fi
        fi

        lastEnd=$end
        rm -f "${out}/tmp"
        echo ""
done

echo "═══════════════════════════════════════"
echo " Dump completed!"
echo ""
echo "Output files in: $out"
ls -lh "$out"/*.so 2>/dev/null

# 找出最大的.so文件(通常是完整的libil2cpp.so)
largest=$(ls -S "$out"/*.so 2>/dev/null | head -1)
if [[ $largest != "" ]]; then
        size=$(stat -c%s "$largest" 2>/dev/null)
        size_mb=$(echo "scale=2; $size/1024/1024" | bc)
        echo ""
        echo "Largest file (likely the complete libil2cpp.so):"
        echo "→ $(basename $largest) (${size_mb} MB)"

        # 验证ELF文件
        dd if="$largest" bs=1 count=4 of="${out}/tmp" 2>/dev/null
        if [[ $(cat "${out}/tmp") == $(echo -ne "ELF") ]]; then
                echo "ELF header verified"
        else
                echo "Warning: ELF header not found in largest file"
        fi
        rm -f "${out}/tmp"
fi

echo ""
echo "Next steps:"
echo "1. Check the largest .so file"
echo "2. Use 'file' or 'readelf' to verify"
echo "3. Extract global-metadata.dat from APK"
echo "4. Use Il2CppDumper for analysis"
echo ""
echo "- Done!"

运行代码,可以得到一个libil2cpp的文件,但是这个文件还不能直接用,需要用soFixer去修复一下

https://github.com/F8LEFT/SoFixer

这样就可以修复so文件,下一步是拿到global-metadata.dat文件,然后神奇的地方是,使用我的lsposed文件,可以看到global-metadata.dat已经被加载了,但是内存中却没有。

所以有可能是被加载到内存后关闭了文件,metadata在/dev/zero段中,查看内存确实存在/dev/zero中的文件

7cfecfebe140f119d7604be9a09bcadd.png

而且一共有三个,所以可以直接把这三个给dump出来:

因为这个量比较少吗,所以可以直接命令行给dump出来

cat /proc/<pid>/map_files/701b536000-701dc19000 > /sdcard/meta_seg_test1.dat && ls -lh  /sdcard/meta_seg_test1.dat

其中701b536000-701dc19000就是对应你上面的地址(需要更改为实际值,我图片是后来截的,所以对不上)

我发现一共有三个/dev/zero文件,所以一共dump出来三个:

然后使用010Editor分析这三个文件:

第一个文件:

fdd4fe4b9b67961907a10725e00a5193.png

全是0,pass掉

第二个文件:

8cf11eaffcf70293a02fab4697ee08be.png

明显是java有关的,pass

第三个文件:

a72f2f39dad316f2aae84e9f8b9ab1fb.png

非常明显是unity有关的,确定是这个了,但是这个文件的头不太对,因为正常应该是 AF 1B B1 FA开头的,不过没关系,直接手动修改

手动修改之后,用UnityMetadata的tamplates插件跑一下试试(这个插件在往上可以找到,这是010editor的插件)

看到结果都被识别出来了:

d74f914037870642e40b951df9820d54.png

然后使用il2cppDumper解析一下,发现出问题了:

image-20251226214622775.png

根据这篇博客的分析,对文件进行修改:https://reimu.moe/2024/06/09/IL2Cpp-String-Literals/

b0bf9d99351a4f624f4e0d681170249d.png

然后放入il2cppdumper进行分析,但是结果还是GG了,这次的分析就到此位置。

事实上我还使用GameGuardian去从内存中拿去信息,结果也都是全部无法分析,感觉很有可能是il2cppdumper的版本问题,因为il2cppdumper毕竟现在也停止更新了,所以就到这里吧,希望能有大佬给出指导建议,不胜感激。

c40aa89d9ee7678684ad5a912b9f5f06.png

学艺不精,只能到这里了,让大家见笑了。。。但是!经过某位大佬的提醒,突然想到是不是版本对不上的原因?换个版本试试,没想到真成功了

修复后分析

修改版本号:

c55cf0b7e62061ea26f73bd584c48e41.png

然后放到il2cppdumper中,得到最终数据,哈!真出来了

091ebf74-b4cf-43cc-b765-5178b6433c17.png

首先需要用sofix修复so文件:https://github.com/Chenyangming9/SoFixer#

然后使用IDA打开修复后的so文件(新版某气骑士文件很大,分析起来要花很长时间),使用File -Script File,选择ida_py3.py(il2cppdumper安装包中自带的那个),选择分析出的stringliteral.json

之后选择 ida_with_struct_py3.py,分析script.json和il2cpp.h

7d39735c0fc35c3cec626c33d0ed80b6-1767880050585-5.png

分析完成了!!!

a1541fed92f719e0578ed16fc91ddd2b.png

既然已经分析完成了,那就搞点事情干,直接分析解密的存档的方式:

可以通过查看包内文件的方式找到实际的存档文件,这次就以game.data为例进行分析

分析存档

首先找到game.data

528b567de1b5c99214faba291119db67.png

然后再IDA中按G,输入地址后跳跃到对应的位置,之后在对应位置右键查询 List cross references to…

9660cc5b5a93238b34db0b1e2464c102.png

看名字就知道这是个存档的地方,然后分析这个函数:

7ae298b7ee3e5cad6754de5227c1c0e4.png

看看是谁调用的这个函数:

image-20260108220930867.png

可以跳转到了LoadGameData,然后分析可以找到加密函数:

17f07ce5197be033891d03c355e9992b.png

eb0a5dad8ab4392f48d7fa876416ca72.png

进去看看:

image-20260108221552208.png

接着跟,这个代码就是最新版元气骑士的解密算法:

6172564c327aba6afd42c315e44746b8.png

使用python简单复现一下:

def xor_crypt(data: bytes, password: str) -> bytes:
    result = bytearray(data)
    pwd_len = len(password)
    for i in range(len(result)):
        pc = ord(password[i % pwd_len])
        t = ((i % 15) * (i % 5)) % 92  # 修正括号
        result[i] = result[i] ^ pc ^ t
    return bytes(result)

然后为了解密这个data,有一个简单的密码学方法,就是爆破,因为我们已经知道了加密算法,其次json格式的前两位是{和“。

通过这个我们可以爆破,爆破也是有技巧的,首先这是一个简单的xor加密,也就是说,如果尝试的密码和真实密码很相近,那么json格式中出现下列字符的数量会越多

patterns=[b'{"', b'":', b',"', b'"}', b':[', b']}']

根据这个特性可以辅助我们选定密钥的长度,然后根据长度进行爆破

使用AI写一个爆破的脚本:

# -*- coding: utf-8 -*-
import json
from collections import defaultdict

def xor_crypt(data: bytes, password: str) -> bytes:
    result = bytearray(data)
    pwd_len = len(password)
    for i in range(len(result)):
        pc = ord(password[i % pwd_len])
        t = ((i % 15) * (i % 5)) % 92  # 修正括号
        result[i] = result[i] ^ pc ^ t
    return bytes(result)

def crack_by_your_idea(ciphertext: bytes, max_pwd_len: int = 50):
    """破解XOR密码"""
    cipher_bytes = bytearray(ciphertext)

    # JSON 常见的重复模式
    patterns = [b'{"', b'":', b',"', b'"}', b':[', b']}']

    candidates = {}

    for pattern in patterns:
        pattern_bytes = list(pattern)

        # 对每个密码长度
        for pwd_len in range(1, max_pwd_len + 1):
            votes = [defaultdict(int) for _ in range(pwd_len)]

            # 扫描密文的每个位置
            for start_pos in range(len(cipher_bytes) - len(pattern_bytes) + 1):
                temp_pwd = {}
                conflict = False

                for i, plain_byte in enumerate(pattern_bytes):
                    pos = start_pos + i
                    t = ((pos % 15) * (pos % 5)) % 92  # 修正括号
                    recovered = cipher_bytes[pos] ^ t ^ plain_byte

                    # 必须是可打印字符
                    if not (32 <= recovered < 127):
                        conflict = True
                        break

                    pwd_idx = pos % pwd_len

                    if pwd_idx in temp_pwd:
                        if temp_pwd[pwd_idx] != recovered:
                            conflict = True
                            break
                    else:
                        temp_pwd[pwd_idx] = recovered

                if not conflict:
                    for pwd_idx, byte_val in temp_pwd.items():
                        votes[pwd_idx][byte_val] += 1

            # 构建密码
            password = bytearray(pwd_len)
            min_votes = float('inf')

            for i in range(pwd_len):
                if not votes[i]:
                    password = None
                    break
                most_common = max(votes[i].items(), key=lambda x: x[1])
                password[i] = most_common[0]
                min_votes = min(min_votes, most_common[1])

            if password and min_votes >= 2:
                try:
                    pwd_str = password.decode('utf-8')
                    if pwd_str not in candidates or min_votes > candidates[pwd_str][1]:
                        candidates[pwd_str] = (pattern.decode('utf-8'), min_votes)
                except:
                    pass

    return sorted(candidates.items(), key=lambda x: -x[1][1])

# 主破解流程
if __name__ == "__main__":
    # 二进制模式读取
    with open("game.data", "rb") as f:
        ciphertext = f.read()

    print(f"密文长度: {len(ciphertext)} 字节")
    print("="*60)

    results = crack_by_your_idea(ciphertext, max_pwd_len=50)

    print(f"找到 {len(results)} 个候选密码\n")

    for pwd, (pattern, votes) in results[:20]:
        print(f"密码: '{pwd}' (长度={len(pwd)}, 票数={votes}, 模式='{pattern}')")

        try:
            result = xor_crypt(ciphertext, pwd)
            result_str = result.decode('utf-8', errors='replace')

            json.loads(result_str)

            print(f"  ✓✓ 成功!这是有效的 JSON!")
            print(f"  预览: {result_str[:200]}...")

            # 二进制模式保存
            with open("output.txt", "wb") as f:
                f.write(result)
            print(f"  已保存到 output.txt\n")
            break

        except json.JSONDecodeError as e2:
            print(f"  ✗ 不是有效 JSON: {e2}")
        except Exception as e:
            print(f"  ✗ 错误: {e}")

然后就可以得到输出的结果,成果破解出来了某气骑士的密码:

7981f3a138473ac2dab146e1e4f2058d.png

可以看到里面有很多宠物解锁和技能解锁相关的数据:

e38ba6a4f597bbaff1a78e6a2a3df9e6.png

免费评分

参与人数 16威望 +1 吾爱币 +37 热心值 +13 收起 理由
24kcsplus + 1 用心讨论,共获提升!
zfonlyone + 1 + 1 我很赞同!
zbwgame + 1 大佬牛逼
zhangdashuaibi + 1 + 1 我很赞同!
KaiSama + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
Yuganzi + 1 + 1 用心讨论,共获提升!
qmsy273 + 1 我很赞同!
忆魂丶天雷 + 3 + 1 用心讨论,共获提升!
ngiokweng + 3 + 1 我很赞同!
keulraesik + 1 我很赞同!
allspark + 1 + 1 用心讨论,共获提升!
gmc333 + 1 + 1 我很赞同!
liufenyun + 1 我很赞同!
正己 + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
buluo533 + 1 + 1 用心讨论,共获提升!
JonesDean + 1 + 1 热心回复!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

cagu 发表于 2026-1-8 09:20
多谢分享技术,逆向完成后估计某骑士也不玩了吧?
 楼主| Rytter 发表于 2026-1-8 13:17
正己 发表于 2026-1-8 10:50
试过这个没有?
https://github.com/Perfare/Zygisk-Il2CppDumper

55555试过了,但是只dump出了函数名,没有实际的操作逻辑,这些也都试过了,无法使用il2cppdumper解析
https://github.com/BryanGIG/PADumper/tree/master/app
https://github.com/MlgmXyysd/Il2CppMemoryDumper
https://gameguardian.net/forum/files/file/2-gameguardian/
cac02de58bbb30d573284fa72e7728cb.png
fridaynice 发表于 2026-1-7 21:38
又可以让我学习一天的思路,后面自己试试,还没试过这逆向
agnostic 发表于 2026-1-8 03:49
不愧是专业学习的,
jesseding 发表于 2026-1-8 05:11
学习下大佬的思路
xiaoyan525 发表于 2026-1-8 08:09
果然,久了不用都会修为散尽啊。
温珍果 发表于 2026-1-8 08:15
很强了,可以学习思路
lsg642 发表于 2026-1-8 09:53
专业学习的,专业学习的,
残鹤 发表于 2026-1-8 10:03
大佬666,又学到了新的知识。
MarleyCats 发表于 2026-1-8 10:04
膜拜大佬
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-10-16 20:25

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表