MOBILE-折叠回声
ISCC2026 WriteUp 提交模板
MOBILE-折叠回声
解题思路
1.看下MainActivity

这里有一个校验函数:

是个假的flag
继续看,后面还有一段逻辑:

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

先排除假的flag

取出中间的内容

计算四份关键材料:
resourceKey(context)sha256(classes.dex)sha256(signing certificate)TraceRecorder.snapshot()

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


从 APK 自己身上提取几份材料 -> 解开 assets 里的隐藏数据 -> 取出一段 VM 程序和目标值 -> 用用户输入跑最后一轮异或校验
- 读取隐藏数据并解第一层
- readEchoChunk(context) 会从 assets/sleep_loop.webp 里取出自定义 ECH0 块
- 然后用
sha256("fold-echo" + resourceKey + dexDigest + certDigest + snapshot)
作为密钥,调用 xorStream(...) 解开它 - 解出来的数据必须以 EFVM 开头,也就是字节 69 70 86 77
- 解析 EFVM 容器
- i = bytes[5..6]:VM 程序长度
- i2 = bytes[7..8]:最终输出长度
- i3 = i + 9:程序数据起止位置后的偏移
- 然后把数据切成两段:
- bArr:VM 程序
- bArr2:目标字节串
- 检查用户输入长度
- strSubstring.getBytes("UTF-8") 是用户输入花括号里的正文
- 它的字节长度必须等于 i2
- 不等就直接 false
- 跑自定义 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:希望输出多少字节

得到 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 的字节数组。
然后根据这些去写脚本反推时,会发现并不成功,根据题目提示的第三声才开始说真话。仔细找一下

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

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:]))

浙公网安备 33010602011771号