-
-
[原创]最新版某气骑士分析记录-绕过某讯的防御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/<pid>/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}")
然后就可以得到输出的结果,成果破解出来了某气骑士的密码:

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