【2025春节】解题领红包之四 Android 中级题WriteUp
0x00 初探虎先锋
题目题干如下:
出题老师:正己
题目简介:《黑神话·虎先锋の猴肉108种烹饪方式》通关失败后,张师傅怒摔手柄,突然瞥见室友王大爷的屏幕金光大作:"无限定身术+金刚不坏+暴击999%!"
"年轻人,听说过科学修仙吗?"王大爷反手一个Alt+F4,显示器残留着神秘代码:
风灵月影宗弟子认证:输入[宗门秘钥]可解锁修改权限
(温馨提示:秘钥格式请参考——flag{我是秘钥},"我是秘钥"的真实内容需要动态运算得出)
第1步:直接进入战斗!然后败了😭
第2步:根据提示点击右上角风灵月影
第3步:输入密钥查看交互信息
第4步:jadx查找字符串“密钥错误,请重试!”
第5步:找到实现代码如下:
public final void m5invoke() {
String BattleScreen$lambda$21;
BattleScreen$lambda$21 = BattleActivityKt.BattleScreen$lambda$21(this.$activationCode$delegate);
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);
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;
int v3;
int v5;
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;
JNIEnv *env;
JNIEnv *v5;
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;
int v6;
int v7;
int v8;
int v9;
int v10;
unsigned int v11;
char *v12;
_BOOL4 v13;
int v14;
void (__fastcall *v15)(_BYTE *, int, int, void *);
void *v16;
int v17;
const std::nothrow_t *v18;
unsigned __int64 v20;
_BYTE v21[16];
_QWORD v22[2];
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)
{
jboolean v5;
const char *key;
const char *key1;
int v8;
int v9;
int v10;
unsigned int v11;
char *v12;
_BOOL4 v13;
int v14;
void (__fastcall *fun_enc)(_BYTE *, const char *, int, void *);
void *v16;
int v17;
const std::nothrow_t *v18;
unsigned __int64 v20;
_BYTE v21[16];
_QWORD v22[2];
v5 = 0;
key = (*env)->GetStringUTFChars(env, jkey, 0);
if ( key )
{
key1 = key;
HIDWORD(v20) = 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;
}
v22[0] = *(_QWORD *)&off_C16B1CE8;
v22[1] = *(_QWORD *)&off_C16B1CF0;
dword_CA839D90 = -559038669;
memset(v21, 0, sizeof(v21));
v16 = (void *)operator new[](0x13u);
fun_enc(v21, key1, 19, v16);
v17 = memcmp(v16, &unk_CA73F0FC, 0x13u);
operator delete[](v16, v18);
(*env)->ReleaseStringUTFChars(env, (jstring)HIDWORD(v20), key1);
return v17 == 0;
}
return v5;
}
反调试分析
(unsigned int)v22 | (4 * (
(v14 | v13) ^
(sub_CA7C36CC & 1) ^
(((ao ^ a) >> 24) & 1)
))
(v14 | v13) ^
(sub_CA7C36CC & 1) ^
(((ao ^ a) >> 24) & 1)
所有这里的反调试关键点在于上面需要满足以下两个条件:
加密函数
void __fastcall fun_enc(_BYTE *a1, char *a2, int times, void *a4)
{
__int64 v5;
int i;
int v9;
char v10;
_BYTE v11[16];
v5 = *((_QWORD *)a1 + 1);
*(_QWORD *)v11 = *(_QWORD *)a1;
*(_QWORD *)&v11[8] = v5;
if ( times )
{
for ( i = 0; i != times; ++i )
{
v9 = i & 0xF;
if ( (i & 0xF) == 0 )
fun_enc2(v11);
v10 = a2[i] ^ v11[v9];
*((_BYTE *)a4 + i) = v10;
v11[v9] = v10;
}
}
}
&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
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"
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_bytes = bytes(b1 ^ b2 for b1, b2 in zip(bytes1, bytes2))
print("XOR结果的16进制表示:", ' '.join(f'{b:02X}' for b in xor_bytes))
获取到前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位放回脚本算出答案
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"
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)
得出最后答案 flag{md5(uid+2025)}
0x04 拿下flag
flag的坑
算出答案非常开心,就去算uid+2025的md5
hashlib.md5(str(2123512).encode('utf8')).hexdigest()
'4ac37b3b46367455af865e516fdef7d0'
最后琢磨了以下应该是字符串相加
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随机化处理,祝大家逆向功力突破熵增定律)
让咱们继续用代码改变世界,用逆向探索未知!新年卷起来~(手动狗头保命)
❤❤最后感谢论坛大佬的无私奉献❤❤