MOBILE-Flag Shop
ISCC2026 WriteUp 提交模板
MOBILE-Flag Shop
解题思路
1.入口很快能定位到 com.example.flagshop.MainActivity,真正和校验有关的点不在 UI
MainActivity 是一个 Jetpack Compose 应用,内容由 c.f1472b(对应 Composable lambda f3.a,case 1)设置,该 lambda 调用 g3.c.a() 来包裹 Material 主题。
应用导航(f3.t 枚举)
| 状态 | 页面 | 描述 |
|---|---|---|
Shop |
商店主页 | 展示 4 个商品,$151 余额 |
AdminLogin |
管理员登录 | 用户名/密码表单 |
AdminRestock |
补货验证 | 验证 flag 以补充库存 |

商店页面(f3.s.g())
- 标题:“FLAGSHOP”,显示“Wallet: $151”
- 4 个商品(
f3.u数据类:name、price、encryptedHex):
| 商品 | 价格 | encryptedHex |
|---|---|---|
fakeflag1 |
$151 | 46534850840912118da77c2d... |
fakeflag2 |
$151 | 46534850840912112ba9a049c... |
fakeflag3 |
$151 | 465348508409121105764efa1... |
realflag |
$666 | 4653485084083011788fb5d73... |
- 所有商品均显示 “sold out forever”
- 点击“Buy”:
- 余额不足 → 提示“Insufficient Balance”
- 否则 → 提示“Sold Out - contact administrator”
- “Administrator Entrance”按钮 → 进入管理登录

Username+Password输入框- “Login”按钮 → 调用
NativeBridge.adminLogin(username, password)(libflagshop.so 的 native 方法) - 成功 → 进入 AdminRestock;失败 → “Access Denied”

-
Item 文本框(默认 “realflag”)+ Flag 文本框(默认
ISCC{...}) -
“Verify and Restock”按钮调用:
NativeBridge.encryptFlag(itemName, submittedFlag, adminUsername, adminPassword)将结果与该商品的
encryptedHex对比:- 匹配 → “Restock succeeded.”
- 空结果 → “session expired”
- 不匹配 → “does not match”
f3.s 里能直接拿到四组密文,其中 realflag 对应的是:
4653485084083011788fb5d735cfa35810b48a3433ced888c02965457ad21cf80e4936cc8a536fee26b3ffc2a64981a878511f0d3ab96cdd05879fac83f005f9b5e311fa07d299b0d0580b4611afd6c8a4db205c1f278134
2JNI函数
JNI_OnLoad 里识别出的动态注册表在 0xE6A60,RegisterNatives 的参数形态是:

(*env)->RegisterNatives(env, clazz, methods, 2);
0xE6A60 -> 0x43A14 -> "adminLogin"
0xE6A68 -> 0x438BF -> "(Ljava/lang/String;Ljava/lang/String;)Z"
0xE6A70 -> 0x4EDB0 -> native impl
0xE6A78 -> 0x43D7F -> "encryptFlag"
0xE6A80 -> 0x43422 -> "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"
0xE6A88 -> 0x517A8 -> native impl
对应布局如上面
通过定位发现adminLogin 的原生实现在 sub_4EDB0(地址 0x4EDB0)。通过 JNINativeMethod 表注册,但混淆严重。
hook native 中的字符串比较函数 0x515a4得到FlagShopAdmin2026
3确认密文
unidbg补环境跑了一遍
encryptFlag("fakeflag1", "ISCC{fakeflagfake}", "admin", "FlagShopAdmin2026")
设备原生返回:
46534850840912118da77c2d7f480b5a3497e1c9cfd5fb6a15ffc5a68067f7bbd6b07028b9d52e53f1ea68d460c840a407db326f5e986d7e1305f8df01796fc56e188068355aff017715c6673d15e5f3af30a9e818e229d8
和 APK 里 fakeflag1 保存的目标密文完全一致。
fake 商品的头:
fakeflag1: 46 53 48 50 84 09 12 11 ... fakeflag2: 46 53 48 50 84 09 12 11 ... fakeflag3: 46 53 48 50 84 09 12 11 ...
已知信息是:
- 商品名 fakeflag1 长度是 9,对应 0x09
- 明文 ISCC{fakeflagfake} 长度是 18,对应 0x12
所以这 4 个字节基本就能对上:
84 09 12 11 | | | +-- flag 长度 = 0x12 = 18 +----- item 名长度 = 0x09 = 9
再看 realflag:
46 53 48 50 84 08 30 11
其中:
- realflag 这个商品名长度正好是 8,对应 0x08
- 那么同位置的下一个字节 0x30,自然就是 flag 长度
- 0x30 = 48
4分析加密流程
分析0x55740
它的大体流程不是一上来就做 block cipher,而是先拼一个 80 字节左右的内部状态,然后才开始轮函数。
可以把它理解成下面这个流程:
输入:
user, pass, item, flag
先构造 raw_state
-> 做一次 pre 处理
-> 进入 24 轮循环
每轮拆成 5 个 16-byte block
每块丢进 0x8b39c
然后做一层 80-byte 的轮间置换
-> 最后一轮之后再做 final 处理
-> 拼回 FSHP 头并转 hex
更直观点:
raw_state (80 bytes)
|
v
pre-transform
|
v
for round in 0..23:
for chunk in 0..4:
chunk = block_mix(chunk) # 0x8b39c
state = shuffle(state)
|
v
post-transform
|
v
hex output
也就是说:
24 轮 * 每轮 5 个 block = 120 次 0x8b39c
这是后面拆算法时最重要的数量级信息。
为了看清 0x55740 最开始塞了什么,可以喂一个好认的测试值,比如:
ISCC{AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}
得到的初始 raw state 开头类似:
46 53 48 50 34 30 00 49 53 43 43 7b 41 41 41 41 ...
拆开看:
46 53 48 50 -> FSHP
34 -> 固定模式字节
30 -> flag 长度 0x30
00 -> 固定标记
49 53 43 43 -> ISCC
7b -> {
41 ... 41 -> 我们喂进去的 A
7d -> }
因此明文 flag 在 raw state 里的位置也能确定:
偏移 7 开始,长度 48
也就是最终恢复出 raw state 后,直接切:
candidate = raw_blob[7:55]
继续往里拆时,会发现 0x8b39c 这个 block 处理函数里出现了一组非常典型的常量:
00 05 0a 0f 04 09 0e 03 08 0d 02 07 0c 01 06 0b
这就是 AES 里很熟悉的 ShiftRows 顺序,所以第一眼会把它当成一个 AES-like 结构。
但继续下去会发现它并不是标准 AES 整体,而是更容易处理的一个变种:16 字节状态可以拆成 8 组“两字节耦合”来逆。
方便起见,可以把索引对记成:
PAIR_INDEX = [
(0, 8),
(5, 13),
(10, 2),
(15, 7),
(4, 12),
(9, 1),
(14, 6),
(3, 11),
]
这样一个看起来像 128-bit 整块逆向的问题,实际上被压成了 8 个独立的 16-bit 子问题。
也正因为这样,单 block 的逆变换是可以做出来的。
单个 0x8b39c 的逆向思路大致是:
- 记录一次 block 输入/输出。
- 记下该 block 使用的轮参数和查表内容。
- 按轮倒着退。
- 每一轮先 undo 混合,再 undo substitution。
伪代码可以写成:
DUAL_MAP = [(0, 8), (5, 13), (10, 2), (15, 7),
(4, 12), (9, 1), (14, 6), (3, 11)]
class BlockReverter:
def __init__(self, round_key_a, round_key_b, extra_tag):
self.k_a = round_key_a
self.k_b = round_key_b
self.tag = extra_tag
def undo(self, block16):
buf = bytearray(block16)
for turn in range(9, -1, -1):
buf = self._undo_mix(buf, turn)
buf = self._undo_sub(buf, turn)
return bytes(buf)
关键不在于代码长什么样,而在于这一步是可验证的:拿一个经过 0x8b39c 的 block,确实能还原出输入。
除了 0x8b39c 之外,0x55740 外层还有三类 80 字节处理:
pre
boundary
final
这些层看起来不像简单 XOR,但有一个很好的性质:
每个输出字节只依赖一个输入字节
有了这一点,就可以用 oracle 逐字节建逆表。
思路是:
- 先跑一次基准输入。
- 每次只改某一个输入位置。
- 观察哪个输出字节跟着变。
- 对 0..255 建一个反查字典。
最终需要拿到的逆表数量是:
1 个 pre
23 个 boundary
1 个 final
总计 25 组。
已知 realflag 的目标密文,真正有用的是 payload 部分,也就是去掉前 8 字节 FSHP 头之后的内容。
可以先写成:
TARGET = "4653485084083011788fb5d735cfa35810b48a3433ced888c02965457ad21cf80e4936cc8a536fee26b3ffc2a64981a878511f0d3ab96cdd05879fac83f005f9b5e311fa07d299b0d0580b4611afd6c8a4db205c1f278134"
blob = bytes.fromhex(TARGET)[8:]
逆向顺序和正向执行正好反过来:
payload
-> undo final
-> round 23 的 5 个 block 倒退
-> undo boundary22
-> round 22 的 5 个 block 倒退
-> ...
-> round 0 的 5 个 block 倒退
-> undo pre
-> 得到 raw_state
整体控制代码可以写成:
def rewind_payload(cipher_tail, trace_meta, stage_inv):
cur = stage_inv["final"](cipher_tail)
for rid in range(23, -1, -1):
for slot in range(4, -1, -1):
meta = trace_meta[rid * 5 + slot]
start = slot * 16
piece = cur[start:start + 16]
rev = BlockReverter(meta.k0, meta.k1, meta.flag)
plain_piece = rev.undo(piece)
cur = cur[:start] + plain_piece + cur[start + 16:]
if rid != 0:
cur = stage_inv[f"boundary_{rid - 1}"](cur)
return stage_inv["pre"](cur)
拿到 raw state 后,真正的 flag 直接切出来即可:
flag_text = raw_state[7:55].decode("latin1")
最终恢复出的内容是:
ISCC{7H15_1$_r431_f146_4nd_17_h45n'7_501d_0u7?!}

ISCC{7H15_1$_r431_f146_4nd_17_h45n'7_501d_0u7?!}
Exp
from __future__ import annotations
import sys
ADMIN_USER = "admin"
ADMIN_PASS = "FlagShopAdmin2026"
TARGET_ITEM = "realflag"
REAL_HEX = (
"4653485084083011788fb5d735cfa35810b48a3433ced888c02965457ad21cf80e4936cc8a536fee"
"26b3ffc2a64981a878511f0d3ab96cdd05879fac83f005f9b5e311fa07d299b0d0580b4611afd6c8"
"a4db205c1f278134"
)
# Recovered from the reverse process described in the writeup.
RECOVERED_FLAG = "ISCC{7H15_1$_r431_f146_4nd_17_h45n'7_501d_0u7?!}"
def parse_fshp_header(hex_blob: str) -> dict[str, int | str]:
raw = bytes.fromhex(hex_blob)
if len(raw) < 8:
raise ValueError("ciphertext too short")
return {
"magic": raw[:4].decode("ascii"),
"mode": raw[4],
"item_len": raw[5],
"flag_len": raw[6],
"marker": raw[7],
"payload_len": len(raw) - 8,
}
def validate(flag: str, item: str, ciphertext: str) -> list[str]:
notes: list[str] = []
hdr = parse_fshp_header(ciphertext)
if hdr["magic"] != "FSHP":
raise ValueError(f"unexpected magic: {hdr['magic']!r}")
notes.append(f"magic = {hdr['magic']}")
if hdr["item_len"] != len(item):
raise ValueError(
f"item length mismatch: header={hdr['item_len']} actual={len(item)}"
)
notes.append(f"item_len = {hdr['item_len']}")
if hdr["flag_len"] != len(flag):
raise ValueError(
f"flag length mismatch: header={hdr['flag_len']} actual={len(flag)}"
)
notes.append(f"flag_len = {hdr['flag_len']}")
if not flag.startswith("ISCC{") or not flag.endswith("}"):
raise ValueError("flag does not match expected ISCC{...} shape")
notes.append("shape = ISCC{...}")
if hdr["payload_len"] != 80:
raise ValueError(f"unexpected payload length: {hdr['payload_len']}")
notes.append(f"payload_len = {hdr['payload_len']}")
return notes
def recover_flag() -> str:
validate(RECOVERED_FLAG, TARGET_ITEM, REAL_HEX)
return RECOVERED_FLAG
def main() -> int:
try:
flag = recover_flag()
checks = validate(flag, TARGET_ITEM, REAL_HEX)
except Exception as exc: # pragma: no cover
print(f"[!] failed: {exc}", file=sys.stderr)
return 1
print("[*] embedded context")
print(f" user : {ADMIN_USER}")
print(f" pass : {ADMIN_PASS}")
print(f" item : {TARGET_ITEM}")
print(f" hex : {REAL_HEX}")
print()
print("[*] consistency checks")
for line in checks:
print(f" - {line}")
print()
print("[+] flag")
print(flag)
return 0
if __name__ == "__main__":
raise SystemExit(main())

浙公网安备 33010602011771号