ISCC 擂台赛 mobile

#这比赛纯达芬,mobile让AI跑跑就行了,不会的我这里放一下wp#

深海金库

初步审计

题目给的是一个 Android APK:`app-release-01.apk`。先看压缩包结构,可以看到除了常规的 `classes.dex`、资源文件以外,还带了一个 native 库:

```text

lib/arm64-v8a/libsecure_verify.so

lib/armeabi-v7a/libsecure_verify.so

lib/x86/libsecure_verify.so

lib/x86_64/libsecure_verify.so

```

这说明 flag 校验大概率不是纯 Java 层完成,而是 Java 先做一部分处理,再把结果交给 native 层。

题目描述里提到"秘密深藏于耐心之中,而开启的旋律会随深度起伏",这里的"旋律随深度起伏"一开始看起来像提示某种递推流或周期变换。后面分析 native 时确实能对应到 Fibonacci 流异或。

定位 Java 层入口

用 Jadx 打开 APK 后,包名为:

```text

com.example.mobile02

```

![Java 入口与隐藏触发]

上图把入口链路整理出来了:普通点击只更新内部计数,达到 3 次后才会通过反射把长按监听器绑定到按钮上。

入口界面逻辑在 `MainActivity` 中。`onCreate()` 里加载布局、找到按钮和输入框,然后给按钮绑定点击事件:

```java

protected void onCreate(Bundle bundle) {

final SecurityCoreProcessor securityCoreProcessor = new SecurityCoreProcessor(this);

super.onCreate(bundle);

setContentView(R.layout.activity_main);

final Button button = (Button) findViewById(R.id.btn_verify);

final EditText editText = (EditText) findViewById(R.id.et_flag);

button.setOnClickListener(...);

SystemEnvironmentManager.prepareContext(button, securityCoreProcessor);

}

```

按钮点击后并不直接校验 flag,而是先更新一个计数器:

```java

SystemEnvironmentManager.updateState();

SystemEnvironmentManager.prepareContext(button, securityCoreProcessor);

Toast.makeText(this, "Initializing Secure Handshake...", 0).show();

```

继续看 `SystemEnvironmentManager.prepareContext()`:

```java

public static void prepareContext(View view, View.OnLongClickListener onLongClickListener) {

if (Debug.isDebuggerConnected() || CLICK_TRIGGER.get() < 3) {

return;

}

CoreProtocolDelegate.dispatchConfiguration(view, onLongClickListener);

}

```

这里需要点击次数达到 3 次后,才会通过反射把按钮的长按监听器设置上:

```java

View.class

.getMethod("setOnLongClickListener", View.OnLongClickListener.class)

.invoke(view, obj);

```

所以真实校验入口不是普通点击,而是"三次点击后长按按钮"触发的 `SecurityCoreProcessor.onLongClick()`。

追到校验函数

`SecurityCoreProcessor.onLongClick()` 里先检查输入格式:

```java

String string = editText.getText().toString();

if (FlagValidator.isFormatValid(string)) {

if (FlagValidator.doFinalCheck(this.activity, string)) {

Toast.makeText(this.activity, "Deep Sea Vault Opened.", 1).show();

} else {

Toast.makeText(this.activity, "Security Protocol Active.", 0).show();

}

} else {

Toast.makeText(this.activity, "Invalid Format.", 0).show();

}

```

格式检查函数很简单:

```java

public static boolean isFormatValid(String str) {

return str != null && str.startsWith("ISCC{") && str.endsWith("}");

}

```

真正的校验在 `doFinalCheck()`:

```java

public static boolean doFinalCheck(MainActivity mainActivity, String str) {

return mainActivity.nativeVerify(

CryptoEngine.performTransformation(str.substring(5, str.length() - 1))

);

}

```

也就是说:

  1. flag 必须是 `ISCC{...}` 格式;

  2. 只取花括号内部内容;

  3. 先经过 `CryptoEngine.performTransformation()`;

  4. 再传入 native 方法 `nativeVerify(byte[])`。

`MainActivity` 里加载的 native 库是:

```java

static {

System.loadLibrary("secure_verify");

}

```

因此下一步需要分析 `libsecure_verify.so`。

Java 层第一段变换

`CryptoEngine.performTransformation()` 的逻辑如下:

![Java 层变换逻辑]

```java

public static byte[] performTransformation(String str) {

byte[] bytes = str.getBytes(StandardCharsets.US_ASCII);

int i = 0;

for (int i2 = 0; i2 < bytes.length; i2++) {

int i3 = bytes[i2] & 255;

if (i3 >= 32 && i3 <= 126) {

i3 = ((i3 - 25) % 95) + 32;

}

i = (i3 + (i2 % 2 == 0 ? 8 : -8) + (i % 4)) & 255;

bytes[i2] = (byte) i;

}

return bytes;

}

```

这里有两个点需要注意:

  • Jadx 里反编译出来的 `((i3 - 25) % 95) + 32` 对 Java 来说等价于对可打印字符做循环位移。由于输入是可打印 ASCII,实际效果可以按 `+7 mod 95` 理解。

  • 当前字节的输出还依赖前一个输出字节的低 2 bit:`i % 4`。

因此逆向时不能逐字节独立还原,要按顺序用前一个变换结果参与计算。

进入 native 校验

在 `libsecure_verify.so` 中可以看到导出符号:

![Native 校验链路]

```text

_Z13verify_bridgeP7_JNIEnvP8_jobjectP11_jbyteArray

_ZN2SV1vEPKhi

_Z18process_fib_streamPhi

_Z10encode_b64RKNSt6__ndk16vectorIhNS_9allocatorIhEEEE

JNI_OnLoad

```

`JNI_OnLoad` 中把 Java 的 `nativeVerify` 注册到 `verify_bridge`,而 `verify_bridge` 的主要工作是从 Java byte array 取出数据,然后调用 `SV::v()`:

```text

GetByteArrayElements(...)

GetArrayLength(...)

SV::v(input, length)

ReleaseByteArrayElements(...)

```

继续看 `SV::v()`,流程很清楚:

  1. 拷贝 Java 层传入的字节;

  2. 调用 `process_fib_stream()`;

  3. 调用 `encode_b64()`;

  4. 和一个固定目标串比较。

固定目标串由两部分拼出来:

```text

cUdltutGeWWI

Um+7OE4ce3wi

```

拼接后得到:

```text

cUdltutGeWWIUm+7OE4ce3wi

```

Fibonacci 流异或

`process_fib_stream()` 的逻辑是对每个字节异或 Fibonacci 序列:

```text

1, 1, 2, 3, 5, 8, 13, 21, ...

```

也就是:

```python

a, b = 1, 1

for i in range(len(data)):

data[i] ^= a & 0xff

a, b = b, (a + b) & 0xff

```

这个函数正好对应题目描述里的"旋律会随深度起伏"。

自定义 Base64 表

一开始如果直接用标准 Base64 去解 `cUdltutGeWWIUm+7OE4ce3wi`,逆出来的结果对不上 Java 变换。回头看 `encode_b64()`,发现它没有使用标准 Base64 表,而是从初始化代码里构造了一张 64 字节的表:

```text

zKJUExRaVtM3Ydv5TQIsWD1frnHC78Lckl6euPh9AGoj0SgN4Zp+OwXi2F/ybmBq

```

所以正确方向是先把目标串从自定义表映射回标准 Base64 字符集,再进行 Base64 解码。

标准表为:

```text

ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/

```

映射后:

```text

cUdltutGeWWIUm+7OE4ce3wi

```

会变成:

```text

fDNhJkJpjUUSD9zc0EwfjL13

```

Base64 解码得到 18 字节:

```text

7c33612642698d45120fdcdcd04c1f8cbd77

```

这 18 字节再异或 Fibonacci 流,得到 Java 层变换后的目标字节:

```text

7d326325476180503038854c39357d57806f

```

逆回 flag 内部字符串

现在要逆 `CryptoEngine.performTransformation()`。正向公式可以写成:

```text

shifted = printable_shift(input)

out[i] = shifted + alt + (previous_out % 4)

```

其中:

```text

alt = 8 if i 是偶数

alt = -8 if i 是奇数

```

逆向时按顺序处理每个目标字节:

```text

shifted = out[i] - alt - (previous_out % 4)

input = shifted 逆循环位移

```

注意这里的循环位移要按 Java 代码的真实效果反推。对可打印字符而言,正向等价于:

```text

shifted = ((input - 25) mod 95) + 32

```

逆向就是:

```text

input = ((shifted - 32 - 7) mod 95) + 32

```

逐字节逆完后,花括号内部内容为:

```text

n2R#7_pQ!9vL*5mWnp

```

所以完整 flag 是:

```text

ISCC{n2R#7_pQ!9vL*5mWnp}

```

EXP

```python

#!/usr/bin/env python3

import base64

CUSTOM_B64 = "zKJUExRaVtM3Ydv5TQIsWD1frnHC78Lckl6euPh9AGoj0SgN4Zp+OwXi2F/ybmBq"

STD_B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

TARGET = "cUdltutGeWWIUm+7OE4ce3wi"

def decode_custom_b64(s: str) -> bytes:

mapped = s.translate(str.maketrans(CUSTOM_B64, STD_B64))

return base64.b64decode(mapped)

def undo_fib_stream(data: bytes) -> bytes:

out = bytearray()

a, b = 1, 1

for x in data:

out.append(x ^ (a & 0xff))

a, b = b, (a + b) & 0xff

return bytes(out)

def undo_java_transform(data: bytes) -> str:

out = bytearray()

prev = 0

for i, y in enumerate(data):

alt = 8 if i % 2 == 0 else -8

shifted = (y - alt - (prev % 4)) & 0xff

输入是可打印 ASCII,逆 Java 层的循环位移。

ch = ((shifted - 32 - 7) % 95) + 32

out.append(ch)

prev = y

return out.decode("ascii")

def main():

native_decoded = decode_custom_b64(TARGET)

java_target = undo_fib_stream(native_decoded)

inner = undo_java_transform(java_target)

print(f"ISCC{{{inner}}}")

if name == "main":

main()

```

运行后得到:

![脚本运行结果]

```text

ISCC{n2R#7_pQ!9vL*5mWnp}

```

Flag

```text

ISCC{n2R#7_pQ!9vL*5mWnp}

```

小结

这题的入口有一个小隐藏点:普通点击只会更新状态和弹错误提示,真正的校验要三次点击后长按按钮触发。算法上则是 Java 层可打印字符变换、native 层 Fibonacci 流异或、自定义 Base64 三层组合。只要注意 Base64 表不是标准表,剩下的变换都可以直接按顺序逆回去。

深潮协定

初步审计

题目给的是一个 Android APK。先看压缩包结构,除了常规的 `classes.dex` 和资源文件外,还有一个 native 库:

```text

assets/telemetry.bin

lib/arm64-v8a/libdeepseal.so

lib/armeabi-v7a/libdeepseal.so

lib/x86/libdeepseal.so

lib/x86_64/libdeepseal.so

```

这基本说明校验链路不会只停留在 Java 层。结合题目描述"海底的协议不会说话,但每一层都在留下签名",我优先按"Java 层一层变换 + native 层一层变换"的方向去找入口。

整体校验链路如下:

![APK 校验链路]

定位 Java 层入口

用 Jadx 打开后,主要逻辑集中在 `cn.iscc.deepseal` 包下:

```text

cn.iscc.deepseal.FlagValidator

cn.iscc.deepseal.CryptoEngine

cn.iscc.deepseal.NativeBridge

cn.iscc.deepseal.Telemetry

```

![Jadx 中的关键 Java 类]

入口是 `FlagValidator.check()`:

```java

public static boolean check(Context context, String str) {

if (str.startsWith("ISCC{") && str.endsWith("}")) {

return NativeBridge.verify(

context,

CryptoEngine.transform(context, str.substring(5, str.length() - 1))

);

}

return false;

}

```

这里可以确定两点:

  • flag 外层格式是 `ISCC{...}`;

  • 真正参与算法的是花括号内部字符串。

接着看 `CryptoEngine.transform()`。它先读取 `telemetry.bin`,取出 `shifts` 和 `mask`,然后对输入逐字节处理:

```java

int v = bytes[i] & 0xff;

if (v >= 32 && v <= 126) {

v = (((v - 32) + (shifts[i % shifts.length] & 0xff)) % 95) + 32;

}

acc = ((v ^ (mask[i % mask.length] & 0xff)) + acc + (i * 3)) & 255;

out[i] = (byte) acc;

```

这个变换看起来复杂,但每一步都是可逆的。因为最终 `acc` 被写入输出,所以反推第 `i` 位时,只要知道上一位输出 `prev`,就能还原当前字符经过位移后的值:

```python

shifted = ((cur - prev - i * 3) & 0xff) ^ mask[i % len(mask)]

original = ((shifted - 32 - shifts[i % len(shifts)]) % 95) + 32

```

读取 telemetry.bin

`Telemetry.load()` 会读取 `assets/telemetry.bin`,文件一共四行:

![telemetry.bin 参数](https://img.meituan.net/poiugc/a50c177f41adf9d94ae281ef3aedeb9e56640.png)

内容如下:

```text

0b04110916060d

1337c0de42a5197f

Qh2s7Ca9VxN4fKpB8J1mL+zoF0ewAyOu3DrRYSgPItU6McdkXl/WTnGEZ5ivbHjq

T8GHtJzYDRX7siXzWKLJCGlrpi1sVwnvMhZCPj3jwDWYU4Y=

```

前两行正好对应 Java 层的 `shifts` 和 `mask`:

```text

shifts = 0b04110916060d

mask = 1337c0de42a5197f

```

后两行暂时还没有被 Java 层使用,所以自然转向 native 层。第三行长度为 64,并且包含 `+`、`/`,非常像自定义 base64 字母表;第四行以 `=` 结尾,则像对应的密文 target。

分析 native 层

`NativeBridge` 只声明了一个 native 方法:

```java

public static native boolean verify(Context context, byte[] bArr);

```

在 `libdeepseal.so` 里能看到几个很直接的符号:

```text

Java_cn_iscc_deepseal_NativeBridge_verify

_Z17custom_b64_decode...

_Z21apply_native_lfsr_mixPhi

```

`custom_b64_decode` 的逻辑和名字一致:用 `telemetry.bin` 第三行作为 alphabet,对第四行 target 做 base64 解码。

真正需要反的部分是 `apply_native_lfsr_mix`。关键反汇编如下:

![native LFSR 逻辑]

整理成伪代码是:

```python

state = 0x5d

for i in range(n):

bit = (state ^ (state >> 2) ^ (state >> 3) ^ (state >> 5)) & 1

state = ((state >> 1) | (bit << 7)) & 0xff

buf[i] ^= (state + i * 7) & 0xff

```

这里是 XOR 流,正向和反向是同一个操作。也就是说,native 层比较的实际条件可以写成:

```text

lfsr_mix(CryptoEngine.transform(input)) == custom_b64_decode(target)

```

所以反过来做:

  1. 先用自定义 base64 解码 `target`;

  2. 对解码结果再做一次同样的 LFSR XOR,得到 Java 层 `transform()` 的输出;

  3. 最后反推 `CryptoEngine.transform()`,得到花括号内部内容。

构造最终解法

自定义 base64 解码逻辑按 native 里的状态机写即可:

```python

def custom_b64_decode(data: str, alphabet: str) -> bytes:

table = {ch: i for i, ch in enumerate(alphabet)}

value = 0

bits = -8

out = bytearray()

for ch in data:

if ch == "=":

break

value = (value << 6) | table[ch]

bits += 6

if bits >= 0:

out.append((value >> bits) & 0xff)

bits -= 8

return bytes(out)

```

LFSR 流按反汇编还原:

```python

def lfsr_stream(size: int) -> bytes:

state = 0x5d

stream = bytearray()

for i in range(size):

bit = (state ^ (state >> 2) ^ (state >> 3) ^ (state >> 5)) & 1

state = ((state >> 1) | (bit << 7)) & 0xff

stream.append((state + i * 7) & 0xff)

return bytes(stream)

```

最后反推 Java 层:

```python

def reverse_java_transform(data: bytes, shifts: bytes, mask: bytes) -> bytes:

prev = 0

out = bytearray()

for i, cur in enumerate(data):

shifted = ((cur - prev - i * 3) & 0xff) ^ mask[i % len(mask)]

original = ((shifted - 32 - shifts[i % len(shifts)]) % 95) + 32

out.append(original)

prev = cur

return bytes(out)

```

EXP

最后我把整个过程整理成 `solve.py`:

```python

#!/usr/bin/env python3

SHIFTS_HEX = "0b04110916060d"

MASK_HEX = "1337c0de42a5197f"

ALPHABET = "Qh2s7Ca9VxN4fKpB8J1mL+zoF0ewAyOu3DrRYSgPItU6McdkXl/WTnGEZ5ivbHjq"

TARGET = "T8GHtJzYDRX7siXzWKLJCGlrpi1sVwnvMhZCPj3jwDWYU4Y="

def custom_b64_decode(data: str, alphabet: str) -> bytes:

table = {ch: i for i, ch in enumerate(alphabet)}

value = 0

bits = -8

out = bytearray()

for ch in data:

if ch == "=":

break

value = (value << 6) | table[ch]

bits += 6

if bits >= 0:

out.append((value >> bits) & 0xFF)

bits -= 8

return bytes(out)

def lfsr_stream(size: int) -> bytes:

state = 0x5D

stream = bytearray()

for i in range(size):

bit = (state ^ (state >> 2) ^ (state >> 3) ^ (state >> 5)) & 1

state = ((state >> 1) | (bit << 7)) & 0xFF

stream.append((state + i * 7) & 0xFF)

return bytes(stream)

def reverse_java_transform(data: bytes, shifts: bytes, mask: bytes) -> bytes:

prev = 0

out = bytearray()

for i, cur in enumerate(data):

shifted = ((cur - prev - i * 3) & 0xFF) ^ mask[i % len(mask)]

original = ((shifted - 32 - shifts[i % len(shifts)]) % 95) + 32

out.append(original)

prev = cur

return bytes(out)

def main() -> None:

shifts = bytes.fromhex(SHIFTS_HEX)

mask = bytes.fromhex(MASK_HEX)

decoded_target = custom_b64_decode(TARGET, ALPHABET)

native_reversed = bytes(

x ^ y for x, y in zip(decoded_target, lfsr_stream(len(decoded_target)))

)

inner = reverse_java_transform(native_reversed, shifts, mask)

flag = f"ISCC{{{inner.decode('ascii')}}}"

print(f"decoded target : {decoded_target.hex()}")

print(f"native reversed: {native_reversed.hex()}")

print(f"inner flag : {inner.decode('ascii')}")

print(f"flag : {flag}")

if name == "main":

main()

```

运行后得到:

```text

decoded target : d10dbda515a4863c040fac16ccd511176c623ba48321bd7bb01e059fe83e6e1ce4a8b9

native reversed: 7fd3c46fe3b42650d10201166152e923b94b6c55c1cf7acc83eaddd164912a8e58fd1c

inner flag : abyssal_vows_bind_the_last_protocol

flag : ISCC{abyssal_vows_bind_the_last_protocol}

```

Flag

```text

ISCC{abyssal_vows_bind_the_last_protocol}

```

小结

这题的关键是不要只停在 Java 层。`CryptoEngine.transform()` 虽然已经有一层位移、异或和累加,但 Java 层没有出现 target;真正的 target 藏在 `telemetry.bin` 后两行,并由 native 层读取。native 里的 `custom_b64_decode` 和 `apply_native_lfsr_mix` 都是可逆变换,所以按 `target -> native 反向 -> Java 反向` 的顺序即可还原出 flag。

折叠回声

题目附件只有一个 `EchoFold.apk`,题面提示却把"回声"明确分成了三层:第一声像答案,第二声像线索,第三声才说真话。这样的提示说明样本里大概率存在明显诱饵,因此第一步不能停留在字符串表里的可疑 flag,而应该先确认 APK 的真实校验入口和资源结构。

初步审计

先看 APK 的基本信息。`AndroidManifest.xml` 里可以确认应用包名为 `com.iscc.echofold`,应用名为 `EchoFold`,入口只有一个 `MainActivity`,没有额外权限声明。样本体积也很小,压缩包里真正值得关注的文件只有几项:

```text

AndroidManifest.xml

classes.dex

assets/sleep_loop.webp

META-INF/ECHOFOLD.RSA

META-INF/ECHOFOLD.SF

META-INF/MANIFEST.MF

```

这类结构通常意味着关键逻辑都在本地:`classes.dex` 负责校验,`assets/` 负责藏数据,`META-INF/` 负责把结果和当前 APK 绑定起来。

![初步审计]

这张图把第一轮最值得关注的对象放到了一起:左侧是 APK 里真正参与解题的核心文件,右侧则把那条看起来像 flag、但同时被程序自己否认的字符串单独摘了出来。做到这一步时,题目的重心已经不再是"字符串里有没有 flag",而是"为什么样本要故意先给一个假答案"。

继续从 `classes.dex` 提取关键字符串,可以直接看到这条提示链:

```text

ISCC{this_is_only_the_first_echo}

first echo accepted, but not the real one.

The first echo still sounds convincing.

sleep_loop.webp

ECH0

fold-echo

vm-seed

FirstEchoCodec

EchoGate

TraceRecorder

```

这里最显眼的是一个非常像 flag 的字符串 `ISCC{this_is_only_the_first_echo}`,但同一份字符串表里又紧跟着出现了 `first echo accepted, but not the real one.`。这已经把题目的第一层误导写得很直白:样本故意放了一个"长得像答案"的假 flag,真正的逻辑在 `EchoGate` 之后。

第一处突破

主界面的分流逻辑并不复杂,关键在于它先经过一层诱饵校验,再进入真正校验。整理后可以写成下面的伪代码:

```java

if (FirstEchoCodec.check(input)) {

// first echo accepted, but not the real one.

} else {

EchoGate.verify(context, input);

}

```

`FirstEchoCodec.check()` 本身没有变换过程,只是把输入和固定字符串做比较:

```java

return "ISCC{this_is_only_the_first_echo}".equals(input);

```

这一步的意义不在于给出答案,而在于强迫分析继续往后走。题面里"第一声很像答案",对应的正是这条假 flag;真正值得继续追的对象变成了 `EchoGate.verify()`。

![三层回声链路]

这张图把题面里的"三声回响"对应到了实际代码流程上:第一声停在 `FirstEchoCodec`,第二声开始收集 APK 绑定数据,第三声才通过 `ECH0` 和 `EFVM` 给出真实校验状态。后面的分析基本都沿着这条链展开。

继续分析

EchoGate 并不直接校验输入内容

`EchoGate.verify()` 的真正关键点在于:它并不是把输入直接和某个明文字符串比较,而是先拼出一组只和 APK 自身有关的数据,再用这些数据解开隐藏在资源文件里的 VM 程序。

整理这条链路后,可以把它概括成四个输入源:

  1. `resourceKey(context)`

  2. `classes.dex` 的 SHA-256

  3. APK 签名证书的 SHA-256

  4. `TraceRecorder.snapshot()` 返回的固定 6 字节

其中 `TraceRecorder` 非常短,内容就是一组常量:

```text

11 23 7a 42 51 66

```

本地从 APK 重新计算后,另外两项哈希分别为:

```text

classes.dex sha256

c82404bedd319ef962e9c114dcec9566265c140d621319f795c14a0a12d2df77

certificate sha256

1b62bcb48da1838d72a1ba812f0faa770f57998c5ae595a27f5741b12583e984

```

证书主题也能从签名块里直接解出来:

```text

CN=EchoFold,O=ISCC,C=CN

```

这说明题目没有把答案单独写死在 `dex` 或资源里,而是故意把 flag 和当前 APK 的代码、资源、签名一起绑定。

resourceKey 来自 styleable 数组

`resourceKey` 不是从普通字符串资源里直接读出来的,而是由 `R.styleable.EchoFoldPulse` 推导得到。该数组可以整理成一串连续整数:

```text

0x7f010000, 0x7f010001, ..., 0x7f01001f

```

`EchoGate` 用下面这段规则把它折成 24 字节:

```python

for i in range(24):

a = style[i % len(style)]

b = style[(i + 1) % len(style)]

key[i] = ((b - a) & 0xff) ^ (((a & 0x7f) << 1) & 0xff) ^ ((i * 13) & 0xff)

```

最后得到的 `resourceKey` 为:

```text

010e1f203d4a43547966979885b2abdcf1fecfd02d3a3304

```

到这里可以看出,这道题的第二层"回声"并不是普通 UI 提示,而是资源层本身:样本把真实校验数据拆到了 `styleable`、`classes.dex`、签名块和 `assets/` 里。

真正的数据藏在 `sleep_loop.webp` 的自定义 chunk 中

`assets/sleep_loop.webp` 的体积只有 282 字节,对一个图片资源来说明显过小。直接看文件头可以确认它属于标准 RIFF / WEBP 容器,但在正常块之外还多了一个自定义 chunk:

```text

RIFF .... WEBP .... ECH0 ...

```

本地解析这个文件时,`ECH0` chunk 的偏移和长度分别为:

```text

offset = 38

size = 236

```

这就是题面里"第二声很像线索"的落点:看起来是一张图片,实际上真正的数据被录进了回声文件里。

`ECH0` 的内容也不是明文,先要用下面的种子做流式异或解密:

```python

seed = sha256(b"fold-echo" + resource_key + dex_digest + cert_digest + trace)

plain = xor_stream(ech0_chunk, seed)

```

解密后的前四个字节是:

```text

45 46 56 4d

```

也就是 `EFVM`。说明 `ECH0` 里存的不是目标字符串,而是一段自定义 VM 的程序和目标密文。

第三声是 EFVM 和最终异或比较

`EFVM` 头部解出来之后,可以读到两个关键长度:

```text

prog_len = 192

out_len = 35

```

`out_len = 35` 非常重要,因为它刚好等于 `ISCC{}` 中大括号内容的长度。也就是说,VM 最终产出的字节流长度已经和 flag 主体对齐。

后续流程可以整理成三步:

  1. 用 `sha256(b"vm-seed" + resource_key + dex_digest + cert_digest + trace)` 初始化 8 个 32 位寄存器;

  2. 每 4 字节解释一条 VM 指令,依据首字节低 3 位选择不同的寄存器混合操作;

  3. 程序跑完后,从寄存器状态导出 `vm_output`。

真正决定解法的,不是每条指令的具体名字,而是最终比较方式。`EchoGate` 的末尾并不是:

```text

vm_output == input

```

而是:

```text

input_bytes XOR vm_output == encrypted_target

```

因此需要的不是爆破输入,而是把比较式直接改写成:

```text

input_bytes = encrypted_target XOR vm_output

```

到这里,题面里的"第三声才开始说真话"就完全落地了:只有把 `ECH0` 解出来、把 `EFVM` 跑完,真实 flag 才会出现。

![ECH0 到 flag 的还原过程]

这张图把后半段求解过程压成了四步:先从 `sleep_loop.webp` 中提取 `ECH0`,再用 `fold-echo` 相关状态解出 `EFVM`,随后运行 VM,最后把末尾的异或比较式反过来求出真实输入。读者如果已经接受前面的推导,看到这里基本就能直接把它翻译成脚本。

构造最终解法

前面的分析可以直接压缩成一条自动化链路:

  1. 从 `EchoFold.apk` 读取 `classes.dex`、`META-INF/ECHOFOLD.RSA` 和 `assets/sleep_loop.webp`;

  2. 计算 `dex_digest`、`cert_digest`,并按 `EchoFoldPulse` 公式生成 `resource_key`;

  3. 从 `sleep_loop.webp` 中提取 `ECH0`,用 `fold-echo` 种子解出 `EFVM`;

  4. 执行 VM,得到 `vm_output`;

  5. 用 `encrypted_target XOR vm_output` 还原 flag 主体。

核心求解逻辑可以缩成下面几行:

```python

seed = sha256(b"fold-echo" + resource_key + dex_digest + cert_digest + trace)

plain = xor_stream(read_ech0_chunk(webp), seed)

program, encrypted_target = parse_efvm(plain)

vm_output = run_vm(program, resource_key, dex_digest, cert_digest, trace, len(encrypted_target))

flag_body = bytes(x ^ y for x, y in zip(encrypted_target, vm_output)).decode("ascii")

```

这段逻辑和前面的推导一一对应:`fold-echo` 负责解开第二层回声,`EFVM` 负责生成第三层状态,最后一次异或才把真正的 flag 主体还原出来。

EXP

前面的手工推导最后整理成了 `exp.py`。脚本会直接从 `EchoFold.apk` 中提取签名证书、资源块和 VM 程序,并输出最终答案。

运行方式如下:

```bash

python exp.py

```

关键输出只有一行:

```text

ISCC{f0lded_echo_is_a_state_not_a_string}

```

Flag

```text

ISCC{f0lded_echo_is_a_state_not_a_string}

```

完全脚本

import hashlib

import pathlib

import struct

import zipfile

APK_PATH = pathlib.Path(file).with_name("EchoFold.apk")

TRACE = bytes([0x11, 0x23, 0x7A, 0x42, 0x51, 0x66])

STYLE = [0x7F010000 + i for i in range(32)]

FALLBACK_CERT_DIGEST = bytes.fromhex(

"1b62bcb48da1838d72a1ba812f0faa770f57998c5ae595a27f5741b12583e984"

)

def sha256(data: bytes) -> bytes:

return hashlib.sha256(data).digest()

def rotl32(value: int, shift: int) -> int:

value &= 0xFFFFFFFF

shift &= 31

return ((value << shift) | (value >> (32 - shift))) & 0xFFFFFFFF

def rotr32(value: int, shift: int) -> int:

value &= 0xFFFFFFFF

shift &= 31

return ((value >> shift) | (value << (32 - shift))) & 0xFFFFFFFF

def reverse_bytes32(value: int) -> int:

raw = (value & 0xFFFFFFFF).to_bytes(4, "little")

return int.from_bytes(raw[::-1], "little")

def reverse_bits32(value: int) -> int:

value &= 0xFFFFFFFF

return int(f"{value:032b}"[::-1], 2)

def build_resource_key() -> bytes:

key = bytearray()

for i in range(24):

a = STYLE[i % len(STYLE)]

b = STYLE[(i + 1) % len(STYLE)]

key.append(

(

((b - a) & 0xFF)

^ (((a & 0x7F) << 1) & 0xFF)

^ ((i * 13) & 0xFF)

)

& 0xFF

)

return bytes(key)

def load_cert_digest_from_apk(apk_path: pathlib.Path) -> bytes:

try:

from cryptography.hazmat.primitives.serialization import Encoding, pkcs7

except Exception:

return FALLBACK_CERT_DIGEST

with zipfile.ZipFile(apk_path, "r") as archive:

signature = archive.read("META-INF/ECHOFOLD.RSA")

certs = pkcs7.load_der_pkcs7_certificates(signature)

if not certs:

return FALLBACK_CERT_DIGEST

return sha256(certs[0].public_bytes(Encoding.DER))

def read_ech0_chunk(webp: bytes) -> bytes:

if webp[:4] != b"RIFF" or webp[8:12] != b"WEBP":

raise ValueError("invalid webp container")

offset = 12

while offset + 8 <= len(webp):

chunk_type = webp[offset : offset + 4]

chunk_size = struct.unpack_from("<I", webp, offset + 4)[0]

data_start = offset + 8

data_end = data_start + chunk_size

if data_end > len(webp):

raise ValueError("truncated chunk")

if chunk_type == b"ECH0":

return webp[data_start:data_end]

offset = data_end + (chunk_size & 1)

raise ValueError("ECH0 chunk not found")

def xor_stream(data: bytes, seed: bytes) -> bytes:

out = bytearray()

counter = 0

while len(out) < len(data):

block = sha256(seed + counter.to_bytes(4, "little"))

base = len(out)

for i, byte in enumerate(block):

if base + i >= len(data):

break

out.append(data[base + i] ^ byte)

counter += 1

return bytes(out)

def parse_efvm(blob: bytes) -> tuple[bytes, bytes]:

if blob[:4] != b"EFVM":

raise ValueError("invalid EFVM magic")

prog_len = (blob[5] << 8) | blob[6]

out_len = (blob[7] << 8) | blob[8]

program = blob[9 : 9 + prog_len]

encrypted_target = blob[9 + prog_len : 9 + prog_len + out_len]

return program, encrypted_target

def initial_state(resource_key: bytes, dex_digest: bytes, cert_digest: bytes) -> list[int]:

seed = sha256(b"vm-seed" + resource_key + dex_digest + cert_digest + TRACE)

state = []

for i in range(8):

value = int.from_bytes(seed[i * 4 : (i + 1) * 4], "little")

value ^= (0x9E3779B9 * i) & 0xFFFFFFFF

state.append(value & 0xFFFFFFFF)

return state

def run_vm(

program: bytes,

resource_key: bytes,

dex_digest: bytes,

cert_digest: bytes,

out_len: int,

) -> bytes:

state = initial_state(resource_key, dex_digest, cert_digest)

i = 0

while i + 3 < len(program):

op = program[i]

ia = program[i + 1] & 7

ib = program[i + 2] & 7

ic = program[i + 3] & 7

mix = (

resource_key[(i // 4) % len(resource_key)]

^ dex_digest[(i // 2) % len(dex_digest)]

^ cert_digest[(i // 3) % len(cert_digest)]

^ TRACE[(i // 4) % len(TRACE)]

) & 0xFF

kind = op & 7

if kind == 0:

temp = (state[ib] + mix + i) & 0xFFFFFFFF

temp = rotl32(temp, (ic + mix) & 31)

state[ia] = (temp ^ state[ia]) & 0xFFFFFFFF

elif kind == 1:

temp = (state[ia] + state[ib] + ((mix << 1) & 0xFFFFFFFF) + ic) & 0xFFFFFFFF

state[ia] = rotl32(temp, (ib + ic + i) & 31)

elif kind == 2:

temp = reverse_bytes32(state[ic])

temp = (temp ^ state[ib]) & 0xFFFFFFFF

temp = (temp + mix) & 0xFFFFFFFF

state[ia] = (state[ia] ^ temp) & 0xFFFFFFFF

elif kind == 3:

old = state[ia]

state[ia] = (state[ib] + mix) & 0xFFFFFFFF

temp = rotr32((state[ic] + i) & 0xFFFFFFFF, mix & 31)

state[ib] = (temp ^ old) & 0xFFFFFFFF

elif kind == 4:

temp = rotr32((mix ^ state[ib] ^ i) & 0xFFFFFFFF, (ic + ia + 1) & 31)

state[ia] = (state[ia] + temp) & 0xFFFFFFFF

elif kind == 5:

temp = (state[ib] + state[ic] + mix) & 0xFFFFFFFF

temp = rotl32(temp, (i + ia + ib) & 31)

state[ia] = (temp ^ state[ia]) & 0xFFFFFFFF

elif kind == 6:

temp = (mix ^ state[ia] ^ state[ib]) & 0xFFFFFFFF

temp = (temp + reverse_bits32((state[ic] + i) & 0xFFFFFFFF)) & 0xFFFFFFFF

state[ia] = temp

else:

temp = (mix ^ state[ic] ^ state[ib]) & 0xFFFFFFFF

temp = (temp + i) & 0xFFFFFFFF

state[ia] = (state[ia] + temp) & 0xFFFFFFFF

i += 4

out = bytearray()

for j in range(out_len):

value = state[j & 7] ^ rotl32(state[(j + 3) & 7], j & 31)

value ^= resource_key[j % len(resource_key)]

value ^= dex_digest[j % len(dex_digest)]

value ^= cert_digest[j % len(cert_digest)]

value ^= TRACE[j % len(TRACE)]

out.append(value & 0xFF)

return bytes(out)

def solve(apk_path: pathlib.Path) -> str:

with zipfile.ZipFile(apk_path, "r") as archive:

classes_dex = archive.read("classes.dex")

webp = archive.read("assets/sleep_loop.webp")

resource_key = build_resource_key()

dex_digest = sha256(classes_dex)

cert_digest = load_cert_digest_from_apk(apk_path)

echo_seed = sha256(b"fold-echo" + resource_key + dex_digest + cert_digest + TRACE)

ech0_plain = xor_stream(read_ech0_chunk(webp), echo_seed)

program, encrypted_target = parse_efvm(ech0_plain)

vm_output = run_vm(

program,

resource_key,

dex_digest,

cert_digest,

len(encrypted_target),

)

flag_body = bytes(x ^ y for x, y in zip(encrypted_target, vm_output)).decode("ascii")

return f"ISCC{{{flag_body}}}"

def main() -> None:

print(solve(APK_PATH))

if name == "main":

main()

小结

这题最容易浪费时间的地方有两个:一是被第一眼看到的假 flag 误导,二是把 `sleep_loop.webp` 当成普通资源文件略过去。真正的突破点其实很明确:先承认第一层是诱饵,再顺着 `EchoGate -> ECH0 -> EFVM` 这条链把校验状态还原出来。

如果从头再做一次,优先顺序应该是:先确认 `FirstEchoCodec` 只是诱饵,再检查 `assets/` 里的异常容器,最后把 VM 输出和末尾的异或比较关系连起来。只要走通这三步,就不需要任何爆破或动态 Hook。

Flag Shop

一、题目概述

拿到题目附件 `FlagShop.apk` 后,题面给出重要线索:所有 fakeflag 的明文均为 `ISCC{fakeflagfake}`。这说明 APK 内部存储了多个商品的加密数据,但明文是一致的,真正 flag 的明文与 fakeflag 不同,需要从 so 层的加密/校验逻辑入手还原。

核心解题路线:

  1. 反编译 APK 提取 DEX,梳理商品列表与 native 调用关系

  2. 逆向 `libflagshop.so`,恢复管理员登录凭据

  3. 编写 Unicorn 本地仿真脚本,用 fakeflag 样本打通 oracle

  4. 将 oracle 切换到 realflag,恢复真实 flag


二、DEX 层信息提取

使用 jadx 反编译 APK,在 DEX 中找到商品初始化代码。程序定义了 4 个商品:

| 商品 ID | 说明 |

|---------|------|

| `fakeflag1` | 虚假 flag 商品 1 |

| `fakeflag2` | 虚假 flag 商品 2 |

| `fakeflag3` | 虚假 flag 商品 3 |

| `realflag` | 真实 flag 商品 |

每个商品关联的并不是明文 flag,而是一段十六进制密文 `encryptedHex`。这说明 flag 不是直接存储的,而是经过某种 native 加密处理后的结果。

提取到的四组密文数据如下。

fakeflag1 对应密文:

```

46534850840912118da77c2d7f480b5a3497e1c9cfd5fb6a15ffc5a68067f7bbd6b07028b9d52e53f1ea68d460c840a407db326f5e986d7e1305f8df01796fc56e188068355aff017715c6673d15e5f3af30a9e818e229d8

```

fakeflag2 对应密文:

```

46534850840912112ba9a049c2cc27ba76c2fd1340624573a1133272a504c589b360fac0d187c7db0346ad02dc4e38638e5f336155f28b64d71ae1f61769c6c91c3130d83d675755ee56f0d5c4bd74a07e4c794222cef2f2

```

fakeflag3 对应密文:

```

465348508409121105764efa14c617d84804c0e4f4e669ea8f5abd029b6b5c7a0ded5b1cdd0abb7f1cd9b765fe9e9009bd0e8e3f4354746c5e20f6d946ff2ff2fadcefcab00bb06984633a4ca24a690368951c43d8a507c5

```

realflag 对应密文:

```

4653485084083011788fb5d735cfa35810b48a3433ced888c02965457ad21cf80e4936cc8a536fee26b3ffc2a64981a878511f0d3ab96cdd05879fac83f005f9b5e311fa07d299b0d0580b4611afd6c8a4db205c1f278134

```

继续在 DEX 中追踪调用链,定位到 native 方法声明:

```

NativeBridge.encryptFlag(String, String, String, String): String

```

通过向上追溯调用点,确定四个参数的语义分别为:

```java

encryptFlag(itemId, flagInput, username, password)

```

这个参数顺序在后续 Unicorn 仿真中至关重要,传错顺序会导致整个 oracle 走失败路径。


三、SO 层逆向分析

核心逻辑位于 `libflagshop.so`(arm64-v8a 架构)。使用 IDA Pro 加载后,定位到以下关键函数:

| 函数 | 功能 |

|------|------|

| `adminLogin` | 管理员账号密码校验 |

| `encryptFlag` | 商品 flag 的加密/校验入口 |

| `helper_copy_jstring` | JNI 字符串转 std::string |

| `helper_compare_or_transform` | 字符串比较 / 变换辅助函数 |

【图3:IDA 中关键函数列表截图】

可以得出结论:

  • 管理员身份认证完全在 so 层完成,DEX 层无任何明文凭据

  • flag 的加密与校验逻辑同样在 so 层,无法直接从 DEX 恢复

  • 必须通过 native 仿真获取真实 flag


四、管理员凭据还原

4.1 用户名

在 `.rodata` 段搜索与管理员相关的字符串,结合 `adminLogin` 函数中字符串比较指令的交叉引用,确认用户名为:

```

admin

```

4.2 密码恢复

密码的恢复过程相对复杂。密码不是在 rodata 中直接明文存储的,而是在 so 初始化阶段由密码生成函数运行时写入 `.bss` 段。

对密码初始化逻辑进行局部仿真,在地址 `0xf6f67` 附近追踪 `.bss` 段的写入操作。经过单步跟踪,最终在内存中恢复出完整密码:

```

FlagShopAdmin2026

```

这里有一个非常容易踩的坑:密码末尾的 `6` 容易在逆向时被忽略。早期分析时容易误判为 `FlagShopAdmin202`,但少一个字符会导致 `encryptFlag` 内部校验失败,直接返回空字符串,不会进入真正的加密流程。

验证方式:分别用 `FlagShopAdmin202` 和 `FlagShopAdmin2026` 走 `encryptFlag`------

  • 前者:函数命中失败分支,没有任何有效输出

  • 后者:函数进入真实处理路径


五、Unicorn 本地仿真 Oracle 设计

为了让 `encryptFlag` 在脱离 Android 环境的情况下复现运行,采用 Unicorn Engine 对 `libflagshop.so` 进行 arm64 指令级仿真。

5.1 内存布局

  1. **映射 PT_LOAD 段**:遍历 ELF 的 LOAD 段,按虚拟地址映射到 Unicorn 内存空间,写入段数据并设置对应的 r/w/x 权限

  2. **处理重定位表**:遍历 `.rela.dyn` 和 `.rela.plt`,对类型为 `R_AARCH64_RELATIVE`(1027)的重定位项写入 `base + addend` 的值

  3. **分配栈空间**:在 `0x2000000` 分配栈内存,设置 SP 和 FP 寄存器

  4. **构造 JNIEnv**:在 `0x3000000` 区域构造一个假的 JNIEnv 和虚函数表 vtable

5.2 函数桩 (Stub)

对于 so 依赖的外部函数,在 `0x3100000` 区域为每个导入符号分配一个桩地址,桩内容为 `ret` 指令。在每个桩被调用时,通过 hook 拦截并根据函数语义进行模拟:

| 函数 | 处理方式 |

|------|----------|

| `strlen` / `strcmp` / `strncmp` | 读取地址处 C 字符串,执行对应操作 |

| `memcmp` / `memcpy` / `memmove` / `memset` | 读取/写入原始内存字节 |

| `malloc` / `realloc` / `free` | 在仿真堆上分配/释放内存 |

| `__system_property_get` | 模拟 Android 系统属性读取(反 frida 检测返回 "1") |

| `getpid` | 返回固定值 1234 |

| `syscall` | 返回 0 |

| `abort` / `__stack_chk_fail` | 抛出异常终止仿真 |

5.3 JNI 桥接

在 vtable 中 `+0x538` 偏移处(对应 `NewStringUTF`)替换为一个自定义桩地址。当 so 调用 `NewStringUTF` 返回 Java string 时,hook 拦截并记录返回的字符串内容,同时将 PC 直接返回调用者。

在地址 `0x5007c` 处(对应 `helper_copy_jstring` 的核心逻辑),检测到对 Java 字符串的拷贝操作,将 Java 字符串按 `std::string` 的 SSO(Small String Optimization)布局写入目标地址,绕过对 JNI 实际对象的依赖。


六、Fakeflag 基准验证

题目已明确告知 fakeflag 明文为:

```

ISCC{fakeflagfake}

```

使用以下参数调用 `encryptFlag` 入口(地址 `0x517a8`):

| 参数 | 值 |

|------|-----|

| itemId (X2) | `fakeflag1` |

| flagInput (X3) | `ISCC{fakeflagfake}` |

| username (X4) | `admin` |

| password (X5) | `FlagShopAdmin2026` |

验证结果

  • **错误密码**(少一个 `6`):仿真结束,`encryptFlag` 走失败路径,返回值为空

  • **正确密码**(`FlagShopAdmin2026`):so 进入完整的处理路径,函数正常返回,且 `NewStringUTF` 被正确调用

这证实了:

  1. 管理员凭据完全正确

  2. 参数顺序 `(itemId, flagInput, username, password)` 完全正确

  3. JNI 桥接和函数桩工作正常

  4. 本地 oracle 状态可靠,可以迁移到 realflag


七、Realflag 恢复

在 oracle 验证通过后,将仿真参数中的 itemId 切换为 `realflag`,flagInput 保持为真实 flag 的待求值。核心思路是:

  1. 将 `encryptFlag` 参数设为 `("realflag", X, "admin", "FlagShopAdmin2026")`

  2. 程序内部会用 `X` 与 so 中的真实 flag 进行比较

  3. 通过 hook `strcmp` / `strncmp` 桩函数,在每次比较时 dump 两个比较对象

  4. 从比较日志中提取 so 内部使用的真实 flag 明文

在 trace 日志中,`strcmp` 被调用时可以看到如下比较操作:

```

('call', 'strcmp', ..., 'ISCC{7H15_1$_r431_f146_4nd_17_h45n\'7_501d_0u7?!}', 'ISCC{...}')

```

由此恢复出真实 flag:

```

ISCC{7H15_1$_r431_f146_4nd_17_h45n'7_501d_0u7?!}

```


八、题目技术要点总结

本题的难点与关键点:

  1. **虚假信息干扰**:题面给出的 3 个 fakeflag 对应的 `encryptedHex` 各不相同,但明文一致,容易误导解题者直接在 DEX 层搜索字符串

  2. **逻辑下沉到 native**:管理员认证和 flag 校验都在 `libflagshop.so` 中完成,DEX 只是一个壳

  3. **密码边界问题**:管理员密码末尾的 `6` 极易在逆向时遗漏,且没有直接报错提示,只会让所有后续仿真默默失败

  4. **本地仿真工程化**:需要处理 ELF 重定位、构造假 JNIEnv、stub 导入函数、模拟 `std::string` SSO 布局等一系列工程问题才能打通 oracle


Exp

```python

import glob

import struct

from elftools.elf.elffile import ELFFile

from unicorn import Uc, UcError, UC_ARCH_ARM64, UC_MODE_ARM, UC_PROT_READ, UC_PROT_WRITE, UC_PROT_EXEC, UC_PROT_ALL, UC_HOOK_CODE

from unicorn.arm64_const import *

path = glob.glob(r'C:/Users/*/Downloads/FlagShop_extracted/lib/arm64-v8a/libflagshop.so')[0]

PAGE = 0x1000

def align_down(x):

return x & ~(PAGE - 1)

def align_up(x):

return (x + PAGE - 1) & ~(PAGE - 1)

def read_cstr(mu, addr, limit=0x1000):

out = bytearray()

for i in range(limit):

b = mu.mem_read(addr + i, 1)[0]

if b == 0:

break

out.append(b)

return out.decode('utf-8', 'replace')

def write_cstr(mu, addr, s):

mu.mem_write(addr, s.encode() + b'\x00')

with open(path, 'rb') as f:

elf = ELFFile(f)

mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)

for seg in elf.iter_segments():

if seg['p_type'] != 'PT_LOAD':

continue

vaddr = seg['p_vaddr']

memsz = seg['p_memsz']

filesz = seg['p_filesz']

start = align_down(vaddr)

end = align_up(vaddr + memsz)

perms = 0

flags = seg['p_flags']

if flags & 4:

perms |= UC_PROT_READ

if flags & 2:

perms |= UC_PROT_WRITE

if flags & 1:

perms |= UC_PROT_EXEC

mu.mem_map(start, end - start, perms)

mu.mem_write(vaddr, seg.data())

if memsz > filesz:

mu.mem_write(vaddr + filesz, b'\x00' * (memsz - filesz))

for secname in ('.rela.dyn', '.rela.plt'):

sec = elf.get_section_by_name(secname)

if not sec:

continue

symtab = elf.get_section(sec['sh_link']) if sec['sh_link'] else None

for rel in sec.iter_relocations():

rtype = rel['r_info_type']

off = rel['r_offset']

addend = rel['r_addend'] if rel.is_RELA() else 0

if rtype == 1027:

mu.mem_write(off, struct.pack('<q', addend))

stack = 0x2000000

mu.mem_map(stack, 0x200000, UC_PROT_ALL)

sp = stack + 0x1f0000

mu.reg_write(UC_ARM64_REG_SP, sp)

mu.reg_write(UC_ARM64_REG_X29, sp)

env = 0x3000000

vtbl = 0x3001000

mu.mem_map(0x3000000, 0x30000, UC_PROT_ALL)

mu.mem_write(env, struct.pack('<Q', vtbl))

stub_base = 0x3100000

mu.mem_map(stub_base, 0x10000, UC_PROT_ALL)

ret_stub = b'\xc0\x03\x5f\xd6'

stub_for = {}

stub_i = 0

plt = elf.get_section_by_name('.rela.plt')

if plt:

symtab = elf.get_section(plt['sh_link'])

for rel in plt.iter_relocations():

sym = symtab.get_symbol(rel['r_info_sym']).name if rel['r_info_sym'] else ''

if sym not in stub_for:

stub_for[sym] = stub_base + stub_i * 0x100

mu.mem_write(stub_for[sym], ret_stub)

stub_i += 1

mu.mem_write(rel['r_offset'], struct.pack('<Q', stub_for[sym]))

newstring_stub = 0x3200000

mu.mem_map(newstring_stub, 0x1000, UC_PROT_ALL)

mu.mem_write(newstring_stub, ret_stub)

mu.mem_write(vtbl + 0x538, struct.pack('<Q', newstring_stub))

data = 0x3300000

mu.mem_map(data, 0x40000, UC_PROT_ALL)

def cstr(addr, s):

write_cstr(mu, addr, s)

return addr

item = cstr(data + 0x000, 'realflag')

flag = cstr(data + 0x100, 'ISCC{fakeflagfake}')

user = cstr(data + 0x200, 'admin')

pw = cstr(data + 0x300, 'FlagShopAdmin2026')

heap = 0x3400000

mu.mem_map(heap, 0x40000, UC_PROT_ALL)

heap_next = [heap]

def alloc(size, align=0x10):

cur = (heap_next[0] + align - 1) & ~(align - 1)

heap_next[0] = cur + size

return cur

def write_std_string(addr, s):

b = s.encode()

mu.mem_write(addr, b'\x00' * 0x20)

if len(b) <= 22:

mu.mem_write(addr, bytes([len(b) << 1]) + b + b'\x00' * (23 - len(b)))

return

ptr = alloc(len(b) + 1)

mu.mem_write(ptr, b + b'\x00')

mu.mem_write(addr, struct.pack('<Q', 1))

mu.mem_write(addr + 8, struct.pack('<Q', len(b)))

mu.mem_write(addr + 16, struct.pack('<Q', ptr))

ret_strings = []

trace = []

def decode_std_string(addr):

b0 = mu.mem_read(addr, 1)[0]

if (b0 & 1) == 0:

n = b0 >> 1

raw = bytes(mu.mem_read(addr + 1, n))

return raw.decode('utf-8', 'replace')

n = struct.unpack('<Q', bytes(mu.mem_read(addr + 8, 8)))[0]

p = struct.unpack('<Q', bytes(mu.mem_read(addr + 16, 8)))[0]

raw = bytes(mu.mem_read(p, min(n, 0x200)))

return raw.decode('utf-8', 'replace')

def emulate_symbol(sym):

x0 = mu.reg_read(UC_ARM64_REG_X0)

x1 = mu.reg_read(UC_ARM64_REG_X1)

x2 = mu.reg_read(UC_ARM64_REG_X2)

lr = mu.reg_read(UC_ARM64_REG_LR)

if sym == 'strlen':

trace.append(('call', sym, hex(lr), hex(x0), read_cstr(mu, x0)[:120]))

mu.reg_write(UC_ARM64_REG_X0, len(read_cstr(mu, x0).encode()))

elif sym == 'strcmp':

a = read_cstr(mu, x0)

b = read_cstr(mu, x1)

trace.append(('call', sym, hex(lr), a[:80], b[:80]))

mu.reg_write(UC_ARM64_REG_X0, (a > b) - (a < b))

elif sym == 'strncmp':

a = read_cstr(mu, x0)[:x2]

b = read_cstr(mu, x1)[:x2]

trace.append(('call', sym, hex(lr), a[:80], b[:80], x2))

mu.reg_write(UC_ARM64_REG_X0, (a > b) - (a < b))

elif sym == 'memcmp':

a = bytes(mu.mem_read(x0, x2))

b = bytes(mu.mem_read(x1, x2))

trace.append(('call', sym, hex(lr), x2, a[:64].hex(), b[:64].hex()))

mu.reg_write(UC_ARM64_REG_X0, (a > b) - (a < b))

elif sym in ('memcpy', '__memcpy_chk', 'memmove'):

trace.append(('call', sym, hex(lr), hex(x0), hex(x1), x2))

mu.mem_write(x0, bytes(mu.mem_read(x1, x2)))

mu.reg_write(UC_ARM64_REG_X0, x0)

elif sym == 'memset':

mu.mem_write(x0, bytes([x1 & 0xff]) * x2)

mu.reg_write(UC_ARM64_REG_X0, x0)

elif sym == 'malloc':

mu.reg_write(UC_ARM64_REG_X0, alloc(max(x0, 1)))

elif sym == 'realloc':

p = alloc(max(x1, 1))

if x0:

mu.mem_write(p, bytes(mu.mem_read(x0, x1)))

mu.reg_write(UC_ARM64_REG_X0, p)

elif sym == 'free':

mu.reg_write(UC_ARM64_REG_X0, 0)

elif sym == '__system_property_get':

prop = read_cstr(mu, x0)

val = '1' if 'frida' in prop.lower() else ''

write_cstr(mu, x1, val)

mu.reg_write(UC_ARM64_REG_X0, len(val))

elif sym == 'strstr':

hay = read_cstr(mu, x0)

nee = read_cstr(mu, x1)

idx = hay.find(nee)

mu.reg_write(UC_ARM64_REG_X0, 0 if idx < 0 else x0 + idx)

elif sym == 'getpid':

mu.reg_write(UC_ARM64_REG_X0, 1234)

elif sym == 'syscall':

mu.reg_write(UC_ARM64_REG_X0, 0)

elif sym in ('abort', '__stack_chk_fail'):

raise RuntimeError(sym)

else:

mu.reg_write(UC_ARM64_REG_X0, 0)

def hook_code(mu, addr, size, user_data):

if addr == 0x5007c:

src = mu.reg_read(UC_ARM64_REG_X1)

target = mu.reg_read(UC_ARM64_REG_X8)

s = read_cstr(mu, src)

write_std_string(target, s)

trace.append(('copy', hex(target), s, bytes(mu.mem_read(target, 24)).hex()))

mu.reg_write(UC_ARM64_REG_PC, mu.reg_read(UC_ARM64_REG_LR))

return

if addr == newstring_stub:

s = read_cstr(mu, mu.reg_read(UC_ARM64_REG_X1))

ret_strings.append(s)

trace.append(('retstr', hex(mu.reg_read(UC_ARM64_REG_LR)), s[:120]))

mu.reg_write(UC_ARM64_REG_X0, mu.reg_read(UC_ARM64_REG_X1))

mu.reg_write(UC_ARM64_REG_PC, mu.reg_read(UC_ARM64_REG_LR))

return

if addr in (0x52280, 0x522b4, 0x522c8, 0x522f4, 0x52384, 0x523b8, 0x523cc, 0x523f8, 0x52464, 0x52498, 0x524ac, 0x524d8, 0x53024, 0x53058, 0x5306c, 0x53098, 0x53154, 0x53188, 0x5319c, 0x531c8, 0x5377c, 0x537a8, 0x537bc):

base = mu.reg_read(UC_ARM64_REG_X8)

target = struct.unpack('<Q', bytes(mu.mem_read(base, 8)))[0] if base else 0

trace.append(('indirect', hex(addr), hex(base), hex(target)))

for sym, stub in stub_for.items():

if addr == stub:

trace.append(('ext', sym))

emulate_symbol(sym)

mu.reg_write(UC_ARM64_REG_PC, mu.reg_read(UC_ARM64_REG_LR))

return

if addr == 0x6e1ac:

trace.append(('skip', hex(addr)))

mu.reg_write(UC_ARM64_REG_PC, mu.reg_read(UC_ARM64_REG_LR))

return

mu.hook_add(UC_HOOK_CODE, hook_code)

mu.reg_write(UC_ARM64_REG_X0, env)

mu.reg_write(UC_ARM64_REG_X1, 0x1234)

mu.reg_write(UC_ARM64_REG_X2, item)

mu.reg_write(UC_ARM64_REG_X3, flag)

mu.reg_write(UC_ARM64_REG_X4, user)

mu.reg_write(UC_ARM64_REG_X5, pw)

try:

mu.emu_start(0x517a8, 0x56000, count=10000000)

print('EMU_OK')

except Exception as e:

print('EMUERR', type(e).name, e)

print('PC', hex(mu.reg_read(UC_ARM64_REG_PC)))

print('LR', hex(mu.reg_read(UC_ARM64_REG_LR)))

print('RETCOUNT', len(ret_strings))

for i, s in enumerate(ret_strings[:20]):

print('RET', i, repr(s[:200]))

print('TRACECOUNT', len(trace))

for t in trace:

print('TRACE', t)

```

最终 Flag

```

ISCC{7H15_1$_r431_f146_4nd_17_h45n'7_501d_0u7?!}

```

相关推荐
Yongqiang Cheng4 个月前
MobileFineTuner
mobile·finetuner
蓝之白5 个月前
Vulnhub_DC-8
web安全·ctf·网络攻防·靶场渗透
发现你走远了5 个月前
豆包AI生图去水印脚本解析:从技术实现到用户体验优化 复制直接可用的油猴脚本豆包去水印
水印·网络攻防·豆包·油猴插件
网安INF7 个月前
网络攻防技术:网络安全攻击概述
安全·web安全·网络安全·网络攻防
缘友一世8 个月前
《网络攻防技术》第二章:信息技术收集技术
信息收集·网络攻防
百锦再1 年前
安卓无障碍脚本开发全教程
android·手机·脚本·开发·mobile·phone·无障碍
百锦再1 年前
Kotlin学习基础知识大全(上)
android·xml·学习·微信·kotlin·studio·mobile
百锦再1 年前
Android ImageButton 使用详解
android·java·app·安卓·studio·mobile
Blockchina1 年前
Deep Sleep 96小时:一场没有硝烟的科技保卫战
科技·ai·网络攻防·deepseek·中华