Mobile-按步取匙

ISCC2026 WriteUp 提交模板

Mobile-按步取匙

有人把秘密藏在了言外之意:三段不起眼的笔记、几处被风吹淡的线索,以及一把只在特定时刻才会发亮的小匙。慢慢拼凑、静观其变,你会发现这不是一次抢夺,而是一场用心的对话;设备会替这段对话留下一点独有的指纹,作为回响。

解题思路

1.jadx反编译,查看main函数

image.png

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

image.png|591

从上图可以看到一些关键方法:

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

image.png|652

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

image.png|648

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

image.png

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

image.png

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

image.png

先分析nativeLogin函数:

image.png

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

image.png

后续的逻辑就是验证通过的处理,不再赘述。

我们继续分析Challenge1

image.png

跟进函数: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

image.png

__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函数:

image.png

// 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。

image.png

// 最终 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;
}

综上写脚本复现有:

image.png
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)
posted @ 2026-05-19 16:32  MillionMind  阅读(8)  评论(0)    收藏  举报