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

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

逆向分析

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

6facf4ecfd4199a65bce0df821efb1ad

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

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

image-20251217150140331

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

2ba680f218097568ad86ba9c40d1a899

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

f19306417d258bbe72da322ccb19c006

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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方式运行:

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

打开一看,*了狗了:

e8ef2e3de81d9895ba176412b1dea6d6

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#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

内存il2cpp与global-metadata.dat获取

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

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

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

fbb67135630982d661411bf6901ebd92_720

在Termux中使用

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

可以拿到pid

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

通过

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
#!/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

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

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

1
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

全是0,pass掉

第二个文件:

8cf11eaffcf70293a02fab4697ee08be

明显是java有关的,pass

第三个文件:

a72f2f39dad316f2aae84e9f8b9ab1fb

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

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

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

d74f914037870642e40b951df9820d54

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

image-20251226214622775

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

b0bf9d99351a4f624f4e0d681170249d

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

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

c40aa89d9ee7678684ad5a912b9f5f06

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

8ccd73acafb2005ba4dc31e7f4e8797a

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

修复后分析

修改版本号:

c55cf0b7e62061ea26f73bd584c48e41

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

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

首先需要用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

分析完成了!!!

a1541fed92f719e0578ed16fc91ddd2b

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

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

分析存档

首先找到game.data

528b567de1b5c99214faba291119db67

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

9660cc5b5a93238b34db0b1e2464c102

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

7ae298b7ee3e5cad6754de5227c1c0e4

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

image-20260108220930867

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

17f07ce5197be033891d03c355e9992b

eb0a5dad8ab4392f48d7fa876416ca72

进去看看:

image-20260108221552208

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

6172564c327aba6afd42c315e44746b8

使用python简单复现一下:

1
2
3
4
5
6
7
8
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格式中出现下列字符的数量会越多

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# -*- 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

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

e38ba6a4f597bbaff1a78e6a2a3df9e6