【2025春节】解题领红包之四 Android 中级题WriteUp
0x00 初探虎先锋
题目题干如下:
出题老师:正己
题目简介:《黑神话·虎先锋の猴肉108种烹饪方式》通关失败后,张师傅怒摔手柄,突然瞥见室友王大爷的屏幕金光大作:"无限定身术+金刚不坏+暴击999%!"
"年轻人,听说过科学修仙吗?"王大爷反手一个Alt+F4,显示器残留着神秘代码:
风灵月影宗弟子认证:输入[宗门秘钥]可解锁修改权限
(温馨提示:秘钥格式请参考——flag{我是秘钥},"我是秘钥"的真实内容需要动态运算得出)
第1步:直接进入战斗!然后败了😭
第2步:根据提示点击右上角风灵月影
第3步:输入密钥查看交互信息
第4步:jadx查找字符串“密钥错误,请重试!”
第5步:找到实现代码如下:
/* renamed from: invoke */
public final void m5invoke() {
String BattleScreen$lambda$21;
BattleScreen$lambda$21 = BattleActivityKt.BattleScreen$lambda$21(this.$activationCode$delegate);
//只要Check函数返回True就能激活风灵月影
if (!BattleActivityKt.Check(BattleScreen$lambda$21)) {
Toast.makeText(this.$context, "秘钥错误,请重试!", 0).show();
return;
}
BattleActivityKt.BattleScreen$lambda$7(this.$playerHp$delegate, BattleActivityKt.BattleScreen$lambda$3(this.$maxHp$delegate));
BattleActivityKt.BattleScreen$lambda$10(this.$enemyHp$delegate, BattleActivityKt.BattleScreen$lambda$3(this.$maxHp$delegate));
this.$battleResult$delegate.setValue("");
this.$context.clearBattleLog();
BattleActivityKt.BattleScreen$lambda$25(this.$playerAttackPower$delegate, 9999);
this.$playerDefense.f5315i = 999;
BattleActivityKt.updateLog(this.$context, "════════════════════");
BattleActivityKt.updateLog(this.$context, "★ 风灵月影已激活 ★");
BattleActivityKt.updateLog(this.$context, "➤ 攻击力提升至9999");
BattleActivityKt.updateLog(this.$context, "➤ 防御力提升至999");
BattleActivityKt.updateLog(this.$context, "➤ 生命值已重置");
BattleActivityKt.updateLog(this.$context, "════════════════════");
BattleActivityKt.BattleScreen$lambda$19(this.$showActivationDialog$delegate, false);
}
}
第6步:算法助手直接hook这个Check函数,直接拿下虎先锋!
第7步:提交flag!欸不对我的flag呢?
0x01 攻克Check函数
jadx中查找Check的声明发现其只有声明,调用了SO层实现
//声明函数
public static final native boolean Check(String str);
//调用SO
static {
System.loadLibrary("wuaipojie2025_game");
}
解压缩APK拿到libwuaipojie2025_game.so文件,直接上IDA
分析了arm64-v8a、X86、armeabi-v7a感觉armeabi-v7a的难度较低,推荐armeabi-v7a
0x02 IDA分析libwuaipojie2025_game.so
第1步:找到函数实现
在export中搜索java没有找到相关函数,初步判定为JNI动态注册。
看到有JNI_OnLoad函数从他入手
jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
jint v2; // r5
int v3; // r0
int v5; // [sp+0h] [bp-10h] BYREF
//此处v3、v5应该都是JNIEnv *类型;off_134D10应该就是g_methods函数
if ( (*vm)->GetEnv(vm, (void **)&v5, 65542) )
return -1;
v3 = (*(int (__fastcall **)(int, const char *))(*(_DWORD *)v5 + 24))(
v5,
"com/zj/wuaipojie2025_game/ui/BattleActivityKt");
v2 = -1;
if ( v3 && (*(int (__fastcall **)(int, int, char **, int))(*(_DWORD *)v5 + 860))(v5, v3, off_134D10, 1) > -1 )
return 65542;
return v2;
}
根据JNI实现经验修改部分变量类型:
jint JNI_OnLoad(JavaVM *vm, void *reserved)
{
jint v2; // r5
JNIEnv *env; // r0
JNIEnv *v5; // [sp+0h] [bp-10h] BYREF
//此处v3、v5应该都是JNIEnv *类型;off_134D10应该就是g_methods函数
if ( (*vm)->GetEnv(vm, (void **)&v5, 65542) )
return -1;
env = (JNIEnv *)(*v5)->FindClass(v5, "com/zj/wuaipojie2025_game/ui/BattleActivityKt");
v2 = -1;
if ( env && (*v5)->RegisterNatives(v5, env, (const JNINativeMethod *)g_methods, 1) > -1 )
return 65542;
return v2;
}
转到g_methods的汇编地址看到如下内容:
.data:00134D10 off_134D10 DCD aCheck ; DATA XREF: LOAD:0000009C↑o
.data:00134D10 ; JNI_OnLoad+46↑o ...
.data:00134D10 ; "Check"
.data:00134D14 DCD aLjavaLangStrin ; "(Ljava/lang/String;)Z"
.data:00134D18 DCD sub_BE440+1
.data:00134D1C unk_134D1C DCB 0x73 ; s ; DATA XREF: A(void)+20↑o
.data:00134D1C ; A(void)+2E↑o ...
可知sub_BE440函数就是Check的实现位置,反编译结果如下:
bool __fastcall sub_BE440(int a1, int a2, int a3)
{
int v5; // r4
int v6; // r0
int v7; // r9
int v8; // r4
int v9; // r6
int v10; // r1
unsigned int v11; // r6
char *v12; // r4
_BOOL4 v13; // r8
int v14; // r0
void (__fastcall *v15)(_BYTE *, int, int, void *); // r8
void *v16; // r5
int v17; // r4
const std::nothrow_t *v18; // r1
unsigned __int64 v20; // [sp+0h] [bp-58h]
_BYTE v21[16]; // [sp+18h] [bp-40h] BYREF
_QWORD v22[2]; // [sp+28h] [bp-30h] BYREF
v5 = 0;
v6 = (*(int (__fastcall **)(int, int, _DWORD))(*(_DWORD *)a1 + 676))(a1, a3, 0);
if ( v6 )
{
v7 = v6;
HIDWORD(v20) = a3;
v8 = A();
v9 = CNJAK();
if ( !byte_134E49 )
{
afdm::decrypt_buffer((afdm *)byte_134D7E, &byte_4, 0xA8FC3415, v20);
byte_134E49 = 1;
}
v10 = -1;
if ( v8 )
v10 = 1;
v11 = v9 + v10;
v12 = getenv(byte_134D7E);
v13 = v12 == 0 || v11 < 3;
v14 = jgbjkb();
if ( v11 <= 2 && v12 )
{
v13 = 1;
dword_134D90 = -559038669;
}
v22[0] = *(_QWORD *)&off_12FCE8;
v22[1] = *(_QWORD *)&off_12FCF0;
v15 = (void (__fastcall *)(_BYTE *, int, int, void *))nullsub_9(*(_DWORD *)((unsigned int)v22 | (4 * ((v14 | v13) ^ (unsigned int)sub_BE6CC & 1 ^ (((unsigned int)ao ^ (unsigned int)a) >> 24) & 1))));
dword_134D90 = -559038669;
memset(v21, 0, sizeof(v21));
v16 = (void *)operator new[](0x13u);
v15(v21, v7, 19, v16);
v17 = memcmp(v16, &unk_3A0FC, 0x13u);
operator delete[](v16, v18);
(*(void (__fastcall **)(int, _DWORD, int))(*(_DWORD *)a1 + 680))(a1, HIDWORD(v20), v7);
return v17 == 0;
}
return v5;
}
根据已知内容更改变量类型与变量名:
jboolean __fastcall check(JNIEnv *env, jobject obj, jstring jkey) //此处根据JNI实现和Check声明修改变量类型
{
jboolean v5; // r4
const char *key; // r0
const char *key1; // r9
int v8; // r4
int v9; // r6
int v10; // r1
unsigned int v11; // r6
char *v12; // r4
_BOOL4 v13; // r8
int v14; // r0
void (__fastcall *fun_enc)(_BYTE *, const char *, int, void *); // r8
void *v16; // r5
int v17; // r4
const std::nothrow_t *v18; // r1
unsigned __int64 v20; // [sp+0h] [bp-58h]
_BYTE v21[16]; // [sp+18h] [bp-40h] BYREF
_QWORD v22[2]; // [sp+28h] [bp-30h] BYREF
v5 = 0;
key = (*env)->GetStringUTFChars(env, jkey, 0);
if ( key )
{
key1 = key;
HIDWORD(v20) = jkey; // 让v20变量的前8位赋值位jkey
v8 = A();
v9 = CNJAK();
if ( !byte_CA839E49 )
{
afdm::decrypt_buffer((afdm *)byte_CA839D7E, (char *)4, 0xA8FC3415, v20);
byte_CA839E49 = 1;
}
v10 = -1;
if ( v8 )
v10 = 1;
v11 = v9 + v10;
v12 = getenv(byte_CA839D7E);
v13 = v12 == 0 || v11 < 3;
v14 = jgbjkb();
if ( v11 <= 2 && v12 )
{
v13 = 1;
dword_CA839D90 = -559038669;
}
//上面这一大段都搞不清楚是干嘛的,一开始看名字还以为是解密函数,浪费了好多时间。
//正己大佬说这个是反调试,如果没过就会走错误的ao方法,过了走a正确方法
v22[0] = *(_QWORD *)&off_C16B1CE8; // 指向函数a的指针
v22[1] = *(_QWORD *)&off_C16B1CF0; // 指向函数ao的指针
//这个应该就是加密函数,通过动态计算赋值fun_enc的地址 fun_enc = (void (__fastcall *)(_BYTE *, const char *, int, void *))nullsub_9(*(_DWORD *)((unsigned int)v22 | (4 * ((v14 | v13) ^ (unsigned int)sub_CA7C36CC & 1 ^ (((unsigned int)ao ^ (unsigned int)a) >> 24) & 1))));
//fun_enc = (void (__fastcall *)(...)) nullsub_9(*(_DWORD *)( (unsigned int)v22 | (4 * ( ... )) ));
//关键逻辑:表达式 (unsigned int)v22 | (4 * (...)) 用于计算 v22 数组的索引偏移
//v22 是数组基址,4 * (...) 是索引偏移(QWORD 类型占8字节,但此处可能因对齐或混淆设计为4字节步进)
//实际偏移由 (v14 | v13) ^ ... 的位运算结果决定,最终目标为 0(选a)或 1(选ao)
dword_CA839D90 = -559038669;
memset(v21, 0, sizeof(v21)); //把v21的值全部赋0
v16 = (void *)operator new[](0x13u); //调用加密函数,这里新申请了一段0x13u的内存v16应该是把结果返回到v16
fun_enc(v21, key1, 19, v16); //这里判断v16和&unk_CA73F0FC,共0x13(十进制19)位应该就是校验flag是否正确,如果两值前19位相等的话的话v17赋值0
v17 = memcmp(v16, &unk_CA73F0FC, 0x13u);
operator delete[](v16, v18);
(*env)->ReleaseStringUTFChars(env, (jstring)HIDWORD(v20), key1);
//当v17的值0时返回true
return v17 == 0;
}
return v5;
}
反调试分析
//根据下面这个表达式返回的值确定fun_enc指向的函数地址
(unsigned int)v22 | (4 * (
(v14 | v13) ^
(sub_CA7C36CC & 1) ^
(((ao ^ a) >> 24) & 1)
))
//返回只有两种可能v22[0]或v22[1],其中v22[0] = *(_QWORD *)&off_C16B1CE8;(指向函数a的指针)v22[1] = *(_QWORD *)&off_C16B1CF0;(指向函数ao的指针)
//下面这个表达式返回的就决定了是v22指向的是[0]还是[1]
(v14 | v13) ^ //其中v13、v14在上面进行了赋值;v13=(getenv()==0),v14=jgbjkb()
(sub_CA7C36CC & 1) ^ //sub_CA7C36CC只return 0,所以&1结果为0
(((ao ^ a) >> 24) & 1) //ao ^ a默认没改的话为0,“>>24”取高8位也为0,所以&1结果为0
所有这里的反调试关键点在于上面需要满足以下两个条件:
加密函数
//调用代码fun_enc(v21, key1, 19, v16);
//这里times为19
void __fastcall fun_enc(_BYTE *a1, char *a2, int times, void *a4)
{
__int64 v5; // d17
int i; // r6
int v9; // r5
char v10; // r0
_BYTE v11[16]; // [sp+0h] [bp-30h] BYREF
v5 = *((_QWORD *)a1 + 1);
*(_QWORD *)v11 = *(_QWORD *)a1; //把a1赋值给v11其实就是上面父函数的v21,输入的时候v21是全0的
*(_QWORD *)&v11[8] = v5;
if ( times )
{
for ( i = 0; i != times; ++i )
{
v9 = i & 0xF; //当循环到i=0、i=16时v9会等于0进入下面的if
if ( (i & 0xF) == 0 )
fun_enc2(v11); //这个函数十分复杂(分析了大半天看不懂放弃了),会改变v11的内容
v10 = a2[i] ^ v11[v9]; //v10的值为a2[i]异或v11[v9],其中a2就是flag,v9是0到15,v11是一个16字节的数组,会在v9=0时发生变化。
*((_BYTE *)a4 + i) = v10; //赋值a4,就是父函数的v16
v11[v9] = v10; //改变v11的第v9位
}
}
}
&unk_CA73F0FC的值如下:
.rodata:0003A0FC unk_3A0FC DCB 0x48 ; H ; DATA XREF: sub_BE440+216↓o
.rodata:0003A0FD DCB 0x27 ; '
.rodata:0003A0FE DCB 0x8F
.rodata:0003A0FF DCB 0xAF
.rodata:0003A100 DCB 0x9B
.rodata:0003A101 DCB 0xF8
.rodata:0003A102 DCB 0xEC
.rodata:0003A103 DCB 0x72 ; r
.rodata:0003A104 DCB 0x98
.rodata:0003A105 DCB 7
.rodata:0003A106 DCB 0x72 ; r
.rodata:0003A107 DCB 0xC
.rodata:0003A108 DCB 0x6B ; k
.rodata:0003A109 DCB 0xE2
.rodata:0003A10A DCB 0x3A ; :
.rodata:0003A10B DCB 0xB6
.rodata:0003A10C DCB 0x42 ; B
.rodata:0003A10D DCB 0x59 ; Y
.rodata:0003A10E DCB 0xF7
//48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6 42 59 F7
结论:
要想Check返回True需要v16=48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6 42 59 F7
v16的值在函数fun_enc中赋值,其赋值形式是flag与fun_enc内的数组v11异或(可逆)
那么只需要知道v11的内容与48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6 42 59 F7异或回去即可得到flag。
需注意!!!v11是一个16个字节元素的数组,48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6 42 59 F7(有19个字节)。而v11在第1次和17次使用时会被fun_enc2改变,所以动态调试的关键点在v11的两次变化过程的值
0x03 上动态调试
第1步:改apk包,lib目录只留下armeabi-v7a那个文件夹,然后更改权限增加下面两个权限。
android:debuggable="true"
android:extractNativeLibs="true"
第2步:IDA动态调试
第3步:先异或获取前16位flag
#i=0时v11的值为
#2E 4B EE C8 E0 95 88 47 B0 72 1B 68 40 D0 0A 84
#与&unk_CA73F0FC异或
#48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6 42 59 F7
# 输入的16进制字符串
hex_str1 = "2E 4B EE C8 E0 95 88 47 B0 72 1B 68 40 D0 0A 84"
hex_str2 = "48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6"
# 将16进制字符串转换为字节列表
bytes1 = bytes.fromhex(hex_str1.replace(" ", ""))
bytes2 = bytes.fromhex(hex_str2.replace(" ", ""))
# 确保两个字节序列长度相同
assert len(bytes1) == len(bytes2), "字节序列长度不匹配"
# 异或操作并转换为字符
result = ''.join(chr(b1 ^ b2) for b1, b2 in zip(bytes1, bytes2))
print("XOR结果转换为字符:", result)
#输出结果:XOR结果转换为字符: flag{md5(uid+202
xor_bytes = bytes(b1 ^ b2 for b1, b2 in zip(bytes1, bytes2))
print("XOR结果的16进制表示:", ' '.join(f'{b:02X}' for b in xor_bytes))
#输出结果:XOR结果的16进制表示: 66 6C 61 67 7B 6D 64 35 28 75 69 64 2B 32 30 32
获取到前16位flag为flag{md5(uid+202:
第4步:获取剩下的3位flag
把“flag{md5(uid+202123”加上随机的3位输入到程序中,查看第17轮即i=16时fun_enc会把v11改成什么
发现v11变成了77 70 8A 5F D8 7D B0 5C 90 E6 35 8C D0 4C F9 BB,把新3位放回脚本算出答案
# 输入的16进制字符串
hex_str1 = "2E 4B EE C8 E0 95 88 47 B0 72 1B 68 40 D0 0A 84 77 70 8A"
hex_str2 = "48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6 42 59 F7"
# 将16进制字符串转换为字节列表
bytes1 = bytes.fromhex(hex_str1.replace(" ", ""))
bytes2 = bytes.fromhex(hex_str2.replace(" ", ""))
# 确保两个字节序列长度相同
assert len(bytes1) == len(bytes2), "字节序列长度不匹配"
# 异或操作并转换为字符
result = ''.join(chr(b1 ^ b2) for b1, b2 in zip(bytes1, bytes2))
print("XOR结果转换为字符:", result)
#XOR结果转换为字符: flag{md5(uid+2025)}
得出最后答案 flag{md5(uid+2025)}
0x04 拿下flag
flag的坑
算出答案非常开心,就去算uid+2025的md5
#UID2121487+2025即2123512
hashlib.md5(str(2123512).encode('utf8')).hexdigest()
'4ac37b3b46367455af865e516fdef7d0'
#得出flag{4ac37b3b46367455af865e516fdef7d0}
#结果提交错误了
最后琢磨了以下应该是字符串相加
#UID2121487+2025即21214872025
hashlib.md5(str(21214872025).encode('utf8')).hexdigest()
'c9ca680ed9f6b0a6f0cd49cebb626bc0'
最终答案flag{c9ca680ed9f6b0a6f0cd49cebb626bc0}
0x05 推广
【2025春节】解题领红包之番外篇一二三WriteUp
《安卓逆向这档事》十二、大佬帮我分析一下
0x06 【新年祝福】致52pojie的所有逆向勇士们
转眼又到新年钟声响起时,回想这一年我们熬过的夜——从OD到x64dbg的丝滑切换,从花指令对抗到反调试过招,发际线在堆栈中稳步后移(bushi)。愿新的一年里:
① 功力暴涨如IDA反编译F5秒出伪代码
② 灵感迸发像VS装好了Resharper般丝滑
③ 遇坑必填,玄学bug自动退散
④ Hook人生如Monitor精准捕获每个高光时刻
⑤ 在逆向的星辰大海里,永远做那个手握IDA和WinHex的追风少年
祝各位:
永无蓝屏,一调就过!
栈不溢出,堆不泄漏!
早日拿下内核,轻松搞定协议栈!
(防杠声明:本祝福已通过ASLR随机化处理,祝大家逆向功力突破熵增定律)
让咱们继续用代码改变世界,用逆向探索未知!新年卷起来~(手动狗头保命)
❤❤最后感谢论坛大佬的无私奉献❤❤