MOBILE-折叠回声

ISCC2026 WriteUp 提交模板

MOBILE-折叠回声

解题思路

1.看下MainActivity

image.png

这里有一个校验函数:

image.png

是个假的flag

继续看,后面还有一段逻辑:

image.png

真正的校验函数,就在这个里面了。

image.png

先排除假的flag

image.png

取出中间的内容

image.png

计算四份关键材料:

  • resourceKey(context)
  • sha256(classes.dex)
  • sha256(signing certificate)
  • TraceRecorder.snapshot()

image.png

assets/sleep_loop.webp 里取出 ECH0 数据块

image.png

image.png

从 APK 自己身上提取几份材料 -> 解开 assets 里的隐藏数据 -> 取出一段 VM 程序和目标值 -> 用用户输入跑最后一轮异或校验

  1. 读取隐藏数据并解第一层
    • readEchoChunk(context) 会从 assets/sleep_loop.webp 里取出自定义 ECH0 块
    • 然后用
      sha256("fold-echo" + resourceKey + dexDigest + certDigest + snapshot)
      作为密钥,调用 xorStream(...) 解开它
    • 解出来的数据必须以 EFVM 开头,也就是字节 69 70 86 77
  2. 解析 EFVM 容器
    • i = bytes[5..6]:VM 程序长度
    • i2 = bytes[7..8]:最终输出长度
    • i3 = i + 9:程序数据起止位置后的偏移
    • 然后把数据切成两段:
      • bArr:VM 程序
      • bArr2:目标字节串
  3. 检查用户输入长度
    • strSubstring.getBytes("UTF-8") 是用户输入花括号里的正文
    • 它的字节长度必须等于 i2
    • 不等就直接 false
  4. 跑自定义 VM,得到一串输出
    • runVm(bArr, ...) 会用 VM 程序和前面那 4 份材料生成一串 i2 字节的输出

2.分析四份材料

resourceKey() 用的是 R.styleable.EchoFoldPulse,而这组值本质上是连续的资源 ID
0x7f010000 ~ 0x7f01001f

按函数里的差分与异或逻辑还原后,得到:

010e1f203d4a43547966979885b2abdcf1fecfd02d3a3304

classes.dex` 的 SHA-256

c82404bedd319ef962e9c114dcec9566265c140d621319f795c14a0a12d2df77

签名证书的 SHA-256

1b62bcb48da1838d72a1ba812f0faa770f57998c5ae595a27f5741b12583e984

TraceRecorder` 里是固定常量:

11 23 7a 42 51 66

把这些值拼好以后,调用 xorStream(ECH0, outer_key),能解出一个很规整的头:

45 46 56 4d 01 00 c0 00 23 ...

也就是:

EFVM
version = 1
prog_len = 0x00c0 = 192
out_len  = 0x0023 = 35

到这里可以确定:作者把真正的约束藏在了一个自定义 VM 容器里。

3.VM层

runVm() 是一个“固定宽度 4 字节指令流 + 8 个寄存器”的混淆生成器。它的作用是:
拿一段隐藏程序 + APK 自身的几份特征数据,算出一串最终输出字节。

参数分别是:

  • bArr:VM 程序本体,也就是从 EFVM 里拆出来的 program
  • bArr2:resourceKey
  • bArr3:dexDigest
  • bArr4:certDigest
  • bArr5:TraceRecorder.snapshot()
  • i:希望输出多少字节

image.png

得到 32 字节,正好拆成 8 个 32 位整数,塞进 iArr[0..7]。
每个寄存器还会再异或一个常量:(-1640531527) * i2

程序 bArr 每 4 字节是一条指令:byte0 = opcode byte1 = reg_a byte2 = reg_b byte3 = reg_c
实际只取低 3 位,所以寄存器编号永远是 0..7:

i6 = bArr[i4] & 255; // 原始 opcode 字节 i7 = bArr[i4+1] & 7; // 目标寄存器 i8 = bArr[i4+2] & 7; // 源寄存器1 i9 = bArr[i4+3] & 7; // 源寄存器2

i4 是当前指令偏移,每次加 4。

每条指令还会额外算一个 i11,这是这条指令的“混入字节”:

i11 = dexDigest[...] ^ resourceKey[...] ^ certDigest[...] ^ trace[...]

所以每条指令不仅依赖程序字节,还依赖 APK 自身信息。

每种 opcode 在做什么

这里只看 opcode & 7,所以总共 8 类。

设:

r[a] = iArr[i7]
 r[b] = iArr[i8]
r[c] = iArr[i9]
mix = i11
pc = i4

op 0

r[a] = rotl(r[b] + mix + pc, (c + mix) & 31) ^ r[a]

像“旋转 + 异或回写”。

op 1

r[a] = rotl(r[a] + r[b] + (mix << 1) + c, (b + c + pc) & 31)

像“寄存器累加后旋转”。

op 2

r[a] = r[a] ^ ((reverseBytes(r[c]) ^ r[b]) + mix)

这里用了 Integer.reverseBytes(),即字节序翻转。

op 3

tmp = r[a] r[a] = r[b] + mix r[b] = rotr(r[c] + pc, mix & 31) ^ tmp

这是少数会同时改两个寄存器的指令。

op 4

r[a] = r[a] + rotr((mix ^ r[b] ^ pc), (c + a + 1) & 31)

op 5

r[a] = rotl(r[b] + r[c] + mix, (pc + a + b) & 31) ^ r[a]

op 6

r[a] = (mix ^ (r[a] ^ r[b])) + reverse(r[c] + pc)

这里的 Integer.reverse() 不是翻字节序,而是把 32 位按位倒序。

默认分支,其实就是 op 7

r[a] = r[a] + (mix ^ (r[c] ^ r[b])) + pc

程序跑完以后,不是直接比较寄存器,而是把寄存器再加工成字节流:

out[n] = low8( r[n & 7] ^ rotl(r[(n + 3) & 7], n & 31) ^ resourceKey[n % len(resourceKey)] ^ dexDigest[n % len(dexDigest)] ^ certDigest[n % len(certDigest)] ^ trace[n % len(trace)] )

意思是:

  • 输出第 n 个字节时,会取两个寄存器
  • 再混入四份外部材料
  • 最后只保留低 8 位

所以它输出的是一串长度为 i 的字节数组。

然后根据这些去写脚本反推时,会发现并不成功,根据题目提示的第三声才开始说真话。仔细找一下

image.png

发现有一个没被引用的函数,我们将 VM 输出与目标串改用 Bytes.xor() 处理时,就获取了flag:ISCC{f0lded_echo_is_a_state_not_a_string}

image.png

Exp

#!/usr/bin/env python3
"""Reproduce the EchoFold solve path from the APK alone."""

from __future__ import annotations

import argparse
import hashlib
import os
import pathlib
import re
import shutil
import struct
import subprocess
import sys
import zipfile


TRACE = bytes([17, 35, 122, 66, 81, 102])
FAKE_FLAG = "ISCC{this_is_only_the_first_echo}"


def sha256(data: bytes) -> bytes:
    return hashlib.sha256(data).digest()


def rotl32(value: int, count: int) -> int:
    count &= 31
    value &= 0xFFFFFFFF
    if count == 0:
        return value
    return ((value << count) | (value >> (32 - count))) & 0xFFFFFFFF


def rotr32(value: int, count: int) -> int:
    count &= 31
    value &= 0xFFFFFFFF
    if count == 0:
        return value
    return ((value >> count) | (value << (32 - count))) & 0xFFFFFFFF


def reverse_bytes32(value: int) -> int:
    return int.from_bytes((value & 0xFFFFFFFF).to_bytes(4, "little")[::-1], "little")


def reverse_bits32(value: int) -> int:
    value &= 0xFFFFFFFF
    value = ((value >> 1) & 0x55555555) | ((value & 0x55555555) << 1)
    value = ((value >> 2) & 0x33333333) | ((value & 0x33333333) << 2)
    value = ((value >> 4) & 0x0F0F0F0F) | ((value & 0x0F0F0F0F) << 4)
    value = ((value >> 8) & 0x00FF00FF) | ((value & 0x00FF00FF) << 8)
    value = ((value >> 16) & 0x0000FFFF) | ((value & 0x0000FFFF) << 16)
    return value


def xor_stream(data: bytes, key: bytes) -> bytes:
    out = bytearray()
    offset = 0
    block_index = 0
    while offset < len(data):
        keystream = sha256(key + struct.pack("<I", block_index))
        for item in keystream:
            if offset >= len(data):
                break
            out.append(data[offset] ^ item)
            offset += 1
        block_index += 1
    return bytes(out)


def xor_repeat(left: bytes, right: bytes) -> bytes:
    return bytes(left[i] ^ right[i % len(right)] for i in range(len(left)))


def compute_resource_key() -> bytes:
    values = list(range(0x7F010000, 0x7F010020))
    out = bytearray(24)
    for index in range(24):
        current = values[index % len(values)]
        nxt = values[(index + 1) % len(values)]
        out[index] = ((((nxt - current) & 0xFF) ^ ((current & 0x7F) << 1)) ^ (index * 13)) & 0xFF
    return bytes(out)


def locate_keytool() -> str:
    java_home = os.environ.get("JAVA_HOME")
    if java_home:
        candidate = pathlib.Path(java_home) / "bin" / "keytool.exe"
        if candidate.exists():
            return str(candidate)
    keytool = shutil.which("keytool")
    if keytool:
        return keytool
    raise RuntimeError(
        "keytool not found. Load the workspace Android env first: "
        ". D:\\Dowload\\workspace\\android-bin\\codex-android-env.ps1"
    )


def get_cert_digest(apk_path: pathlib.Path) -> bytes:
    output = subprocess.check_output(
        [locate_keytool(), "-printcert", "-jarfile", str(apk_path)],
        text=True,
        encoding="utf-8",
        errors="ignore",
    )
    match = re.search(r"SHA256:\s*([0-9A-F:]+)", output)
    if not match:
        raise RuntimeError("Unable to parse certificate SHA256 from keytool output.")
    return bytes.fromhex(match.group(1).replace(":", ""))


def read_ech0_chunk(webp: bytes) -> bytes:
    if len(webp) < 20:
        raise ValueError("bad webp")
    if webp[:4] != b"RIFF":
        raise ValueError("not riff")
    if webp[8:12] != b"WEBP":
        raise ValueError("not webp")
    cursor = 12
    while cursor + 8 <= len(webp):
        chunk_name = webp[cursor : cursor + 4]
        chunk_size = struct.unpack("<I", webp[cursor + 4 : cursor + 8])[0]
        chunk_data = webp[cursor + 8 : cursor + 8 + chunk_size]
        if chunk_name == b"ECH0":
            return chunk_data
        cursor += 8 + chunk_size + (chunk_size & 1)
    raise ValueError("missing ECH0")


def parse_vm_container(blob: bytes) -> tuple[int, bytes, bytes]:
    if len(blob) < 9 or blob[:4] != b"EFVM":
        raise ValueError("bad EFVM")
    version = blob[4]
    program_length = (blob[5] << 8) | blob[6]
    output_length = (blob[7] << 8) | blob[8]
    split = 9 + program_length
    if len(blob) != split + output_length:
        raise ValueError("bad EFVM length")
    return version, blob[9:split], blob[split:]


def run_vm(
    program: bytes,
    resource_key: bytes,
    dex_digest: bytes,
    cert_digest: bytes,
    trace: bytes,
    output_length: int,
) -> bytes:
    regs: list[int] = []
    seed = sha256(b"vm-seed" + resource_key + dex_digest + cert_digest + trace)
    for index in range(8):
        offset = index * 4
        word = struct.unpack("<I", seed[offset : offset + 4])[0]
        regs.append((word ^ (((-1640531527) * index) & 0xFFFFFFFF)) & 0xFFFFFFFF)

    for offset in range(0, len(program), 4):
        op = program[offset] & 7
        reg_a = program[offset + 1] & 7
        reg_b = program[offset + 2] & 7
        reg_c = program[offset + 3] & 7
        prog_index = offset // 4
        mix = (
            dex_digest[(offset // 2) % len(dex_digest)]
            ^ resource_key[prog_index % len(resource_key)]
            ^ cert_digest[(offset // 3) % len(cert_digest)]
            ^ trace[prog_index % len(trace)]
        ) & 0xFF

        if op == 0:
            regs[reg_a] = (
                rotl32((regs[reg_b] + mix + offset) & 0xFFFFFFFF, (reg_c + mix) & 31) ^ regs[reg_a]
            ) & 0xFFFFFFFF
        elif op == 1:
            regs[reg_a] = rotl32(
                (regs[reg_a] + regs[reg_b] + ((mix << 1) & 0xFFFFFFFF) + reg_c) & 0xFFFFFFFF,
                (reg_b + reg_c + offset) & 31,
            )
        elif op == 2:
            regs[reg_a] = (regs[reg_a] ^ ((reverse_bytes32(regs[reg_c]) ^ regs[reg_b]) + mix)) & 0xFFFFFFFF
        elif op == 3:
            old = regs[reg_a]
            regs[reg_a] = (regs[reg_b] + mix) & 0xFFFFFFFF
            regs[reg_b] = (rotr32((regs[reg_c] + offset) & 0xFFFFFFFF, mix & 31) ^ old) & 0xFFFFFFFF
        elif op == 4:
            regs[reg_a] = (
                regs[reg_a]
                + rotr32((mix ^ regs[reg_b] ^ offset) & 0xFFFFFFFF, (reg_c + reg_a + 1) & 31)
            ) & 0xFFFFFFFF
        elif op == 5:
            regs[reg_a] = (
                rotl32((regs[reg_b] + regs[reg_c] + mix) & 0xFFFFFFFF, (offset + reg_a + reg_b) & 31)
                ^ regs[reg_a]
            ) & 0xFFFFFFFF
        elif op == 6:
            regs[reg_a] = ((mix ^ (regs[reg_a] ^ regs[reg_b])) + reverse_bits32((regs[reg_c] + offset) & 0xFFFFFFFF)) & 0xFFFFFFFF
        else:
            regs[reg_a] = (regs[reg_a] + (mix ^ (regs[reg_c] ^ regs[reg_b])) + offset) & 0xFFFFFFFF

    return bytes(
        (
            regs[index & 7]
            ^ rotl32(regs[(index + 3) & 7], index & 31)
            ^ resource_key[index % len(resource_key)]
            ^ dex_digest[index % len(dex_digest)]
            ^ cert_digest[index % len(cert_digest)]
            ^ trace[index % len(trace)]
        )
        & 0xFF
        for index in range(output_length)
    )


def solve(apk_path: pathlib.Path) -> int:
    with zipfile.ZipFile(apk_path, "r") as archive:
        dex = archive.read("classes.dex")
        webp = archive.read("assets/sleep_loop.webp")

    resource_key = compute_resource_key()
    dex_digest = sha256(dex)
    cert_digest = get_cert_digest(apk_path)
    ech0 = read_ech0_chunk(webp)
    layer1_key = sha256(b"fold-echo" + resource_key + dex_digest + cert_digest + TRACE)
    efvm = xor_stream(ech0, layer1_key)
    version, program, target = parse_vm_container(efvm)
    vm_output = run_vm(program, resource_key, dex_digest, cert_digest, TRACE, len(target))

    strict_candidate = xor_stream(target, vm_output)
    third_echo = xor_repeat(target, vm_output)

    print(f"[+] APK: {apk_path}")
    print(f"[+] Fake flag: {FAKE_FLAG}")
    print(f"[+] resourceKey: {resource_key.hex()}")
    print(f"[+] classes.dex sha256: {dex_digest.hex()}")
    print(f"[+] cert sha256: {cert_digest.hex()}")
    print(f"[+] TRACE: {TRACE.hex()}")
    print(f"[+] ECH0 length: {len(ech0)}")
    print(f"[+] EFVM version: {version}")
    print(f"[+] Program length: {len(program)}")
    print(f"[+] Output length: {len(target)}")
    print(f"[+] Target bytes: {target.hex()}")
    print(f"[+] VM output: {vm_output.hex()}")
    print(f"[+] Strict verify candidate (hex): {strict_candidate.hex()}")

    try:
        strict_text = strict_candidate.decode("utf-8")
    except UnicodeDecodeError as exc:
        print(f"[+] Strict verify candidate is not valid UTF-8: {exc}")
    else:
        print(f"[+] Strict verify candidate (utf-8): {strict_text}")

    third_text = third_echo.decode("utf-8")
    print(f"[+] Third echo plaintext: {third_text}")
    print(f"[+] Final flag: ISCC{{{third_text}}}")
    return 0


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description="Solve EchoFold.apk statically.")
    parser.add_argument(
        "apk",
        nargs="?",
        default="EchoFold.apk",
        help="Path to the target APK. Default: EchoFold.apk in the current directory.",
    )
    args = parser.parse_args(argv)
    apk_path = pathlib.Path(args.apk).expanduser().resolve()
    if not apk_path.is_file():
        print(f"error: APK not found: {apk_path}", file=sys.stderr)
        return 1
    return solve(apk_path)


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))

posted @ 2026-05-19 16:29  MillionMind  阅读(5)  评论(0)    收藏  举报