Mobile-按步取匙
ISCC2026 WriteUp 提交模板
Mobile-按步取匙
有人把秘密藏在了言外之意:三段不起眼的笔记、几处被风吹淡的线索,以及一把只在特定时刻才会发亮的小匙。慢慢拼凑、静观其变,你会发现这不是一次抢夺,而是一场用心的对话;设备会替这段对话留下一点独有的指纹,作为回响。
解题思路
1.jadx反编译,查看main函数

追踪后发现主要逻辑在so层

从上图可以看到一些关键方法:
private native boolean nativeInit();
private native int nativeLogin(String str, String str2);
private native int nativeVerifyChallenge1(String str);
private native int nativeVerifyChallenge2(String str);
private native String nativeGetIntermediateToken();
private native int nativeVerifyChallenge3WithToken(String str, String str2);
private native String nativeGenerateFlag();
我们继续分析JAVA层,先确认下挑战流程。可以看到是先执行performLogin

这里调用了第一个native方法。

然后就开启了第一个挑战对话。

第二个native方法。
然后就是nativeVerifyChallenge2,nativeVerifyChallenge3WithToken。
最后就是生成flag:

好了,可以转战IDA了。
我们打开IDA,搜索关键字:native进行函数定位。

先分析nativeLogin函数:

进入sub_2A0C,用户名和密码已经找到如下面:

后续的逻辑就是验证通过的处理,不再赘述。
我们继续分析Challenge1

跟进函数:sub_336C
bool __fastcall sub_336C(const char *UserInputPtr, __int64 FixString)
{
size_t v2; // x0
unsigned __int16 v4; // [xsp+3Ch] [xbp-1B4h]
int v5; // [xsp+48h] [xbp-1A8h]
int k; // [xsp+50h] [xbp-1A0h]
int j; // [xsp+54h] [xbp-19Ch]
unsigned __int16 v8; // [xsp+58h] [xbp-198h]
unsigned __int16 v9; // [xsp+5Ch] [xbp-194h]
unsigned __int16 n29908; // [xsp+60h] [xbp-190h]
int v11; // [xsp+64h] [xbp-18Ch]
unsigned __int8 n0x30; // [xsp+68h] [xbp-188h]
int i; // [xsp+6Ch] [xbp-184h]
bool v16; // [xsp+87h] [xbp-169h]
_BYTE v17[128]; // [xsp+138h] [xbp-B8h] BYREF
_DWORD v18[4]; // [xsp+1B8h] [xbp-38h]
_WORD v19[8]; // [xsp+1C8h] [xbp-28h]
_BYTE v20[16]; // [xsp+1D8h] [xbp-18h] BYREF
__int64 v21; // [xsp+1E8h] [xbp-8h]
v21 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
if ( UserInputPtr && __strlen_chk(UserInputPtr, 0xFFFFFFFFFFFFFFFFLL) == 8 )
{
for ( i = 0; i <= 7; ++i )
{
n0x30 = UserInputPtr[i];
if ( (n0x30 < 0x30u || n0x30 >= 0x3Au) && (n0x30 < 0x41u || n0x30 >= 0x47u) )
{
v16 = 0;
goto LABEL_22;
}
}
v11 = strtoul(UserInputPtr, 0, 16);
sub_3800((__int64)v20);
n29908 = 29908;
v9 = HIWORD(v11);
v8 = v11;
for ( j = 0; j <= 7; ++j )
{
n29908 = sub_3910(n29908);
v19[j] = n29908;
}
v18[0] = (v19[1] | (v19[0] << 16)) ^ 0xBA6B73AC;
v18[1] = (v19[3] | (v19[2] << 16)) ^ 0x1FD89456;
v18[2] = (v19[5] | (v19[4] << 16)) ^ 0xD861C763;
v18[3] = (v19[7] | (v19[6] << 16)) ^ 0xA5B3E7FA;
for ( k = 0; k <= 3; ++k )
{
v5 = sub_395C((v8 | (v8 << 16)) ^ v18[k], (__int64)v20);
v4 = v9 ^ v5 ^ HIWORD(v5);
v9 = v8;
v8 = v4;
}
if ( (v9 | (v8 << 16)) == 0xB19D81D && FixString )
{
__strncpy_chk(FixString + 7, UserInputPtr, 127, 128);
*(_BYTE *)(FixString + 134) = 0;
__memcpy_chk();
__strlen_chk(UserInputPtr, 0xFFFFFFFFFFFFFFFFLL);
__memcpy_chk();
v2 = __strlen_chk(UserInputPtr, 0xFFFFFFFFFFFFFFFFLL);
sub_4E58((__int64)v17, v2 + 10, FixString + 519);
}
v16 = (v9 | (v8 << 16)) == 186243101;
}
else
{
v16 = 0;
}
LABEL_22:
_ReadStatusReg(TPIDR_EL0);
return v16;
}
输入要求是 8 位大写 hex 字符串。native 中先用 strtoul(input, 0, 16) 转成整数,然后经过一个 4 轮 Feistel-like 结构,最后和常量 0x0b19d81d 比较。
解出后输入应该是:E12D6AC4(脚本放在Exp中,后续不再赘述,统一放到最后)。
接着继续分析函数Java_com_mobilezkp_MainActivity_nativeVerifyChallenge2:

__int64 sub_39E0()
{
seed = 0x81FCA7FC;
n2 = 1;
return 0x81FCA7FCLL;
}
__int64 __fastcall sub_3A7C(unsigned int a1)
{
int i; // [xsp+0h] [xbp-10h]
unsigned int v3; // [xsp+4h] [xbp-Ch]
if ( n2 >= 1 )
{
if ( a1 == seed )
{
v3 = a1;
for ( i = 0; i < 7; ++i )
v3 = 0x86007AF1 * v3 + 0x3770DA02;
state = v3;
n2 = 2;
return v3;
}
else
{
return 0;
}
}
else
{
return 0;
}
}
__int64 __fastcall sub_3B44(int a1, __int64 a2)
{
__int64 result; // x0
_BYTE v3[4]; // [xsp+44h] [xbp-Ch] BYREF
__int64 v4; // [xsp+48h] [xbp-8h]
v4 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
if ( n2 >= 2 && a1 == state )
{
v3[0] = (a1 ^ 0xA4FC710u) >> 24; // 把 32 位状态值变成 4 个字节,05 74 8E 3A
v3[1] = (a1 ^ 0xA4FC710u) >> 16;
v3[2] = (unsigned __int16)(a1 ^ 0xC710) >> 8;
v3[3] = a1 ^ 0x10;
result = SHA_256((__int64)v3, 4u, a2); // SHA-256
n2 = 3;
}
else
{
result = __memset_chk();
}
_ReadStatusReg(TPIDR_EL0);
return result;
}
第二关要求输入 32 位小写 hex。native 中先通过一个固定 seed mixer 得到 4 字节种子,再做 SHA-256,取前 16 字节转 hex。
恢复后第二关输入为:
53292a74845f09c8a5f6b63bc4e3128e
第二关通过后,程序调用 nativeGetIntermediateToken(),生成并保存中间 token:09a65eca5c63bbebc8ca15d8ec9bf37a.
生成方式可以等价写成:
sha256((challenge2 + "CTF_ZKP_INTERMEDIATE_SEED_V2").encode())[:16].hex()
最后就是Java_com_mobilezkp_MainActivity_nativeVerifyChallenge3WithToken函数:

// challenge3 核心比较:第二个 token 先要等于 challenge2 输出,再用它派生第一个 token 的校验前缀。
bool __fastcall sub_43D0(const char *nptr, const char *a2, __int64 a3)
{
size_t v3; // x0
bool v5; // [xsp+3Ch] [xbp-404h]
bool v6; // [xsp+4Ch] [xbp-3F4h]
size_t v7; // [xsp+50h] [xbp-3F0h]
int j; // [xsp+5Ch] [xbp-3E4h]
int v9; // [xsp+60h] [xbp-3E0h]
int i; // [xsp+64h] [xbp-3DCh]
unsigned __int64 v11; // [xsp+68h] [xbp-3D8h]
bool v15; // [xsp+97h] [xbp-3A9h]
__int64 v16; // [xsp+1C8h] [xbp-278h] BYREF
char v17[512]; // [xsp+1D4h] [xbp-26Ch] BYREF
_BYTE v18[68]; // [xsp+3D4h] [xbp-6Ch] BYREF
_BYTE v19[32]; // [xsp+418h] [xbp-28h] BYREF
__int64 v20; // [xsp+438h] [xbp-8h]
v20 = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
if ( nptr && a2 )
{
if ( !strcmp(a2, Zkp_) ) // 第二个输入必须匹配 challenge2 生成并保存的全局 token。
{
v11 = 0; // 从 challenge2 token 的前 8 字节构造 64 位种子。
for ( i = 0; ; ++i )
{
v6 = 0;
if ( i <= 7 )
v6 = a2[i] != 0;
if ( !v6 )
break;
v11 = (unsigned __int8)a2[i] | (v11 << 8);
}
v16 = v11 ^ 0x2B0C1FC81290EA97LL;
SHA_256((__int64)&v16, 8u, (__int64)v19); // 对 8 字节种子做 SHA-256,作为第一个 token 的比对源。
//
ConvertTo32Hex((__int64)v19, 0x20u, (__int64)v18);
v9 = 0;
for ( j = 0; j <= 15; ++j )
v9 |= (unsigned __int8)nptr[j] ^ (unsigned __int8)v18[j];
v5 = 0;
if ( !v9 )
v5 = __strlen_chk(nptr, 0xFFFFFFFFFFFFFFFFLL) > 0xF;// 第一个输入只要求长度 > 15,后面只比对前 16 个 hex 字符
if ( v5 && a3 )
{
__strncpy_chk(a3 + 263, nptr, 127, 128);
*(_BYTE *)(a3 + 390) = 0;
__strncpy_chk(a3 + 391, a2, 127, 128);
*(_BYTE *)(a3 + 518) = 0;
__memcpy_chk();
__strlen_chk(nptr, 0xFFFFFFFFFFFFFFFFLL);
__memcpy_chk();
v7 = __strlen_chk(nptr, 0xFFFFFFFFFFFFFFFFLL) + 11;
__strlen_chk(a2, 0xFFFFFFFFFFFFFFFFLL);
__memcpy_chk();
v3 = __strlen_chk(a2, 0xFFFFFFFFFFFFFFFFLL);
SHA_256((__int64)v17, v7 + v3, a3 + 583);
}
v15 = v5;
}
else
{
v15 = 0;
}
}
else
{
v15 = 0;
}
_ReadStatusReg(TPIDR_EL0);
return v15;
}
第三关输入 final key,同时传入第二关保存的 intermediate token。
native 中取 token 的前 8 个 ASCII 字节,折叠成 64 位整数,再与 device secret 异或,最后 SHA-256。校验时只比较生成 hex 的前 16 个字符:
final_key = 66db80ec130b2b1a
所以输入更长、但前缀相同的字符串也可以通过。不过 nativeGenerateFlag() 会使用你实际输入的 final key 生成 flag,因此不同长度的 passing key 可能得到不同 flag。下面使用最短可通过 key。

// 最终 FLAG 生成器:要求三个 challenge 全部完成,再用固定标签 + session_key + t1/t2/t3 派生值做 SHA-256,格式化成 ISCC{...}。
__int64 __fastcall sub_2F14(unsigned int *a1, __int64 a2, __int64 a3, __int64 n128)
{
char v9; // [xsp+67h] [xbp-149h]
char v10[52]; // [xsp+10Ch] [xbp-A4h] BYREF
_BYTE v11[32]; // [xsp+140h] [xbp-70h] BYREF
_BYTE v12[15]; // [xsp+160h] [xbp-50h] BYREF
char v13; // [xsp+16Fh] [xbp-41h] BYREF
char v14; // [xsp+18Fh] [xbp-21h] BYREF
char v15; // [xsp+197h] [xbp-19h] BYREF
_BYTE v16[17]; // [xsp+19Fh] [xbp-11h] BYREF
*(_QWORD *)&v16[9] = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40);
if ( (a1[1] & 1) != 0 && (*((_BYTE *)a1 + 5) & 1) != 0 && (*((_BYTE *)a1 + 6) & 1) != 0 )// 只有 challenge1/2/3 的完成标志都置位后,才允许进入 FLAG 生成。
{
if ( *a1 == 3 )
{
if ( *(unsigned int *)((char *)a1 + 519)
&& *(unsigned int *)((char *)a1 + 551)
&& *(unsigned int *)((char *)a1 + 583) )// challenge1/2/3 各自保存的派生 token 也必须存在
{
__memcpy_chk(v12, "CTF_ZKP_FLAG_V2", 15, -1);
__memcpy_chk(&v13, a2, 32, -1);
__memcpy_chk(&v14, (char *)a1 + 519, 8, -1);
__memcpy_chk(&v15, (char *)a1 + 551, 8, -1);
__memcpy_chk(v16, (char *)a1 + 583, 8, -1);
SHA_256(v12, 71, v11); // 计算 SHA-256("CTF_ZKP_FLAG_V2" + session_key[32] + t1[8] + t2[8] + t3[8])
ConvertToHex(v11, 24); // 将摘要前 24 字节转成 48 位小写 hex 文本。
sub_3270(a3, -1, n128, "ISCC{%s}", v10);// 最终按格式 "ISCC{%s}" 组装 FLAG 输出到调用者缓冲区。
__android_log_print(3, "ChallengeState", aFlag);
v9 = 1;
}
else
{
__android_log_print(3, "ChallengeState", aFlag_0);
v9 = 0;
}
}
else
{
__android_log_print(3, "ChallengeState", aFlag_1, *a1);
v9 = 0;
}
}
else
{
__android_log_print(3, "ChallengeState", aFlag_2);
v9 = 0;
}
_ReadStatusReg(TPIDR_EL0);
return v9 & 1;
}
综上写脚本复现有:

flag = ISCC{30942ba24e3e64085d307545741e58f88c00f7be07b5a158}
Exp
import hashlib
import hmac
def sha256(data: bytes) -> bytes:
return hashlib.sha256(data).digest()
def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
out = b""
block = b""
counter = 1
while len(out) < length:
block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest()
out += block
counter += 1
return out[:length]
def recover_username() -> str:
key = b"k3y_j4va_k3y!!!!"
enc = bytes.fromhex("11 58 09 00 07 55 05 15 3a 19")
return bytes(a ^ b for a, b in zip(enc, key)).decode()
def challenge1_value() -> str:
sbox = [
0x0e, 0x04, 0x0d, 0x01, 0x02, 0x0f, 0x0b, 0x08,
0x03, 0x0a, 0x06, 0x0c, 0x05, 0x09, 0x00, 0x07,
]
def lfsr16(x: int) -> int:
bit = x & 1
x >>= 1
if bit:
x ^= 0xB400
return x & 0xFFFF
seed = 0x74D4
round_words = []
for _ in range(8):
seed = lfsr16(seed)
round_words.append(seed)
consts = [0xBA6B73AC, 0x1FD89456, 0xD861C763, 0xA5B3E7FA]
keys = [
((round_words[i * 2] << 16) | round_words[i * 2 + 1]) ^ consts[i]
for i in range(4)
]
def sub32(x: int) -> int:
y = 0
for i in range(8):
y |= sbox[(x >> (4 * i)) & 0xF] << (4 * i)
return y & 0xFFFFFFFF
def f(r: int, k: int) -> int:
x = ((r & 0xFFFF) << 16) | (r & 0xFFFF)
y = sub32(x ^ k)
return (y ^ (y >> 16)) & 0xFFFF
target = 0x0B19D81D
left = target & 0xFFFF
right = (target >> 16) & 0xFFFF
for k in reversed(keys):
old_right = left
old_left = right ^ f(old_right, k)
left, right = old_left & 0xFFFF, old_right & 0xFFFF
return f"{((left << 16) | right):08X}"
def challenge2_value() -> str:
word1 = 0x5B8760D7
word2 = 0xDA7BC72B
state = word1 ^ word2
for _ in range(7):
state = (state * 0x86007AF1 + 0x3770DA02) & 0xFFFFFFFF
seed = (state ^ 0x0A4FC710).to_bytes(4, "big")
return sha256(seed)[:16].hex()
def intermediate_token(challenge2: str) -> str:
seed = "CTF_ZKP_INTERMEDIATE_SEED_V2"
return sha256((challenge2 + seed).encode())[:16].hex()
def final_key_prefix(token: str) -> str:
acc = 0
for ch in token[:8].encode():
acc = (ch | ((acc << 8) & 0xFFFFFFFFFFFFFF00)) & 0xFFFFFFFFFFFFFFFF
device_secret = 0x2B0C1FC81290EA97
mixed = acc ^ device_secret
return sha256(mixed.to_bytes(8, "little"))[:16].hex()[:16]
def generate_flag(password: str, c1: str, c2: str, token: str, final_key: str) -> str:
salt = sha256(b"CTF_ZKP_apk-12_SALT_V3")
ikm = sha256(password.encode())
session_key = hkdf_sha256(ikm, salt, b"AUTH_OK", 32)
frag1 = sha256(("t1_static_" + c1).encode())[:8]
frag2 = sha256(("t2_dynamic_" + c2).encode())[:8]
frag3 = sha256(("t3_android_" + final_key + token).encode())[:8]
body = sha256(
b"CTF_ZKP_FLAG_V2" + session_key + frag1 + frag2 + frag3
)[:24].hex()
return f"ISCC{{{body}}}"
username = recover_username()
password = "Zkp_M_ast3er"
c1 = challenge1_value()
c2 = challenge2_value()
token = intermediate_token(c2)
final_key = final_key_prefix(token)
flag = generate_flag(password, c1, c2, token, final_key)
print("username =", username)
print("password =", password)
print("challenge1 =", c1)
print("challenge2 =", c2)
print("intermediate_token =", token)
print("final_key =", final_key)
print("flag =", flag)

浙公网安备 33010602011771号