首页
社区
课程
招聘
[原创]最新版某气骑士分析记录-绕过某讯的防御dump内存并解密存档
发表于: 1天前 576

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

1天前
576

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

前段时间想找一个能简单打发时间的小游戏,于是就想到了元气骑士,但是元气骑士的旧账号在另一个手机上,那个手机不在身边,但是我又不想重头开始玩,作为网安学生,于是有了自己逆向的想法。

逆向分析

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

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

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

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

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

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

打开一看,*了狗了:

腾讯的壳,爱加密的壳,还有个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线程炸了,看来就是企鹅的锅。

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

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

写个插件试试:(关于插件的编写见这个教程:33eK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3f1#2x3Y4m8G2K9X3W2W2i4K6u0W2j5$3&6Q4x3V1k6@1K9s2u0W2j5h3c8Q4x3X3b7J5x3o6t1&6x3o6x3^5i4K6u0V1x3g2)9J5k6o6q4Q4x3X3g2Z5N6r3#2D9i4@1g2r3i4@1u0o6i4K6R3&6

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

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"
    }
}

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

很好,下一步明确了,用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看下日志,结果:

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

讲道理,既然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;
}

内存il2cpp与global-metadata.dat获取

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

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

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

在Termux中使用

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

可以拿到pid

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

通过

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

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

这里参考这位大佬的帖子:912K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3f1#2x3Y4m8G2K9X3W2W2i4K6u0W2j5$3&6Q4x3V1k6@1K9s2u0W2j5h3c8Q4x3X3b7I4z5o6b7@1y4e0R3%4i4K6u0V1x3g2)9J5k6o6q4Q4x3X3g2Z5N6r3#2D9i4@1g2r3i4@1u0o6i4K6S2o6i4@1f1^5i4@1u0r3i4K6V1&6i4@1f1@1i4@1u0p5i4K6S2p5i4@1f1#2i4@1p5@1i4@1p5%4i4@1f1@1i4@1u0p5i4@1q4o6i4@1f1#2i4K6R3$3i4K6V1&6i4@1f1@1i4@1u0m8i4K6R3$3i4@1f1@1i4@1t1^5i4K6R3H3i4@1f1@1i4@1t1^5i4@1q4m8i4@1f1^5i4K6R3@1i4K6W2m8i4@1f1$3i4K6W2o6i4@1q4o6i4@1f1#2i4K6S2q4i4@1u0n7k6s2g2E0M7q4!0q4y4g2)9^5y4g2)9^5x3#2!0q4y4W2!0n7x3q4)9&6y4q4!0q4z5g2!0m8b7g2)9&6x3g2!0q4y4g2!0m8x3#2!0m8b7W2!0q4c8W2!0n7b7#2)9^5b7#2!0q4y4q4!0n7z5q4)9^5c8q4!0q4z5q4!0n7c8W2)9^5y4#2!0q4y4W2)9&6b7#2)9^5x3q4!0q4y4W2)9&6y4W2!0n7x3q4!0q4y4#2)9^5z5g2)9^5z5q4!0q4y4g2)9&6x3W2)9^5b7#2!0q4y4g2!0n7z5q4)9&6y4W2!0q4y4g2!0m8c8q4)9&6x3q4!0q4y4q4!0n7z5q4!0m8c8q4!0q4y4#2)9&6b7g2)9^5y4q4!0q4y4#2)9^5z5g2)9^5z5q4!0q4y4W2)9&6b7#2!0m8b7#2!0q4y4W2)9&6b7#2)9^5z5g2!0q4y4g2)9^5b7#2!0n7b7g2!0q4y4g2)9^5z5q4!0m8b7W2!0q4c8W2!0n7b7#2)9^5b7#2!0q4y4W2)9^5z5g2)9^5x3q4!0q4y4q4!0n7b7W2!0m8y4g2!0q4y4g2)9&6b7#2!0m8z5q4!0q4z5q4!0n7c8W2)9&6z5g2!0q4y4q4!0n7z5q4!0m8b7g2!0q4y4g2!0m8y4q4!0m8y4#2!0q4y4q4!0n7c8q4!0m8b7#2!0q4y4#2)9&6b7g2)9^5y4q4!0q4y4g2)9&6c8W2!0n7b7g2!0q4y4#2!0m8x3g2)9^5x3q4!0q4y4q4!0n7z5q4)9^5b7g2!0q4y4g2)9^5y4W2)9&6z5g2!0q4y4q4!0n7b7g2)9^5y4W2!0q4y4q4!0n7z5q4)9^5x3q4!0q4y4q4!0n7z5q4!0m8b7g2!0q4z5q4)9^5y4q4)9&6b7g2!0q4y4W2)9&6b7#2!0m8b7#2!0q4y4g2)9^5c8g2!0n7b7X3c8#2L8i4m8Q4c8e0k6Q4z5f1y4Q4z5o6m8Q4c8e0k6Q4z5e0k6Q4b7U0m8Q4c8e0N6Q4z5o6W2Q4z5o6S2Q4c8e0k6Q4z5f1y4Q4b7f1y4Q4c8e0N6Q4z5f1q4Q4z5o6c8A6L8o6u0U0M7s2m8Q4c8e0N6Q4z5f1q4Q4z5o6c8K6L8#2!0q4y4W2)9&6y4W2)9^5y4#2!0q4y4q4!0n7b7W2!0n7y4W2!0q4x3#2)9^5x3q4)9^5x3R3`.`.

#!/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去修复一下

f00K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6r3z5p5I4q4c8W2c8Q4x3V1k6e0L8@1k6A6P5r3g2J5

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

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

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

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

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

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

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

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

第一个文件:

全是0,pass掉

第二个文件:

明显是java有关的,pass

第三个文件:

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

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

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

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

根据这篇博客的分析,对文件进行修改:3c5K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6J5k6h3W2E0N6g2)9J5k6h3#2G2k6g2)9J5c8U0t1H3x3U0c8Q4x3V1j5H3y4W2)9J5c8U0l9&6i4K6u0r3d9f1H3J5b7%4m8H3i4K6u0V1f1%4c8J5K9h3&6Y4i4K6u0V1e0r3W2@1k6i4u0S2L8s2y4Q4x3V1j5`.

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

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

但是!在我发布上一个帖子的12小时后,看到了一个大哥的评论

然后我突发奇想,是不是版本不对,虽然里面写的是18,但是会不会应该是1F,于是开始修改,没想到真的通了

修复后分析

修改版本号:

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

首先需要用sofix修复so文件:668K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6o6K9r3g2F1P5h3q4F1k6$3#2A6L8X3M7&6i4K6u0r3f1$3!0r3K9i4S2W2M7W2)9J5x3H3`.`.

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

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

分析完成了!!!

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

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

分析存档

首先找到game.data

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

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

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

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

进去看看:

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

使用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}")

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

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


传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 3
支持
分享
最新回复 (1)
雪    币: 902
活跃值: (1881)
能力值: ( LV3,RANK:36 )
在线值:
发帖
回帖
粉丝
2
17小时前
0
游客
登录 | 注册 方可回帖
返回