Mobile 移动安全 - 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

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


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` | 字符串比较 / 变换辅助函数 |

可以得出结论:

  • 管理员身份认证完全在 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?!}


Exp


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'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?!}