HITCTF2025 wp

好久没更新了,期末考试太忙了,之前打的比赛传一下

MISC

5-Layer-Fog

image-20251206205348655

uMkIvhvNuWSdaWu5tXW0qNAotWoeaXyCvMT5egIvqjqbSqEEy3ylSW4wUhgASqo3unywvrEmUhcYSNu4tnv5rrAlvZEhwqALtjAIUg==

Subject: C=CN, O=HITCTF, CN=algorithms: Xor+Base64, Rot13, BasE64, CaEsAr(3), SwApCaSe

swapcase → caesar(-3) → rot13->base64 解码->单字节 XOR key 为 0x40->最后再 base64 解码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import base64
import codecs
import string

enc = "uMkIvhvNuWSdaWu5tXW0qNAotWoeaXyCvMT5egIvqjqbSqEEy3ylSW4wUhgASqo3unywvrEmUhcYSNu4tnv5rrAlvZEhwqALtjAIUg=="

low, up = string.ascii_lowercase, string.ascii_uppercase

def caesar(txt: str, shift: int) -> str:
res = []
for ch in txt:
if ch.islower():
res.append(low[(low.index(ch) + shift) % 26])
elif ch.isupper():
res.append(up[(up.index(ch) + shift) % 26])
else:
res.append(ch)
return "".join(res)

# swapcase -> caesar(-3) -> rot13 -> base64 -> XOR 0x40 -> base64
step1 = enc.swapcase()
step2 = caesar(step1, -3)
step3 = codecs.decode(step2, "rot_13")
step4 = base64.b64decode(step3)
step5 = bytes(b ^ 0x40 for b in step4)
flag = base64.b64decode(step5).decode()

print(flag)

HITCTF2025{BasE64_Xor(@)+Base64_SwApCaSe_Rot13_CaEsAr(3)}

Regex Beast

Exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
#!/usr/bin/env python3
"""
Solver for the regex labyrinth challenge.

The pattern in enc.txt is essentially:
/ (?=A)A (?=B)B /
where A and B are enormous nested alternations of fixed-length byte strings.
Because each lookahead is paired with the exact consuming part, every branch
choice is forced to be the same on both sides, leaving a single valid string
for each half. Concatenating the two halves yields the flag (a PNG QR code).
"""

from __future__ import annotations

from functools import lru_cache
from pathlib import Path
from typing import List, Optional, Tuple

text = Path("enc.txt").read_text()


class Node:
__slots__ = ("typ", "children", "lit")

def __init__(self, typ: str, children: Optional[List["Node"]] = None, lit: bytes = b""):
self.typ = typ
self.children = children
self.lit = lit


# --- Parser for the limited regex grammar: (?:...), (?=...), and \xNN literals.
idx = 1 # skip leading slash
end = len(text) - 1 # skip trailing slash


def parse_expr() -> Node:
terms = [parse_term()]
while idx < end and text[idx] == "|":
advance(1)
terms.append(parse_term())
return terms[0] if len(terms) == 1 else Node("alt", terms)


def parse_term() -> Node:
factors: List[Node] = []
while idx < end and text[idx] not in ")|/":
factors.append(parse_factor())
if not factors:
return Node("lit", lit=b"") # empty
return factors[0] if len(factors) == 1 else Node("seq", factors)


def parse_factor() -> Node:
if text.startswith("(?=", idx):
advance(3)
expr = parse_expr()
expect(")")
advance(1)
return Node("look", [expr])
if text.startswith("(?:", idx):
advance(3)
expr = parse_expr()
expect(")")
advance(1)
return expr # non-capturing group
if text.startswith("\\x", idx):
start = idx
while text.startswith("\\x", idx):
advance(4)
seq = text[start:idx]
data = bytes(int(seq[i + 2 : i + 4], 16) for i in range(0, len(seq), 4))
return Node("lit", lit=data)
raise ValueError(f"Unexpected token at {idx}: {text[idx:idx+10]!r}")


def advance(n: int) -> None:
global idx
idx += n


def expect(ch: str) -> None:
if text[idx] != ch:
raise ValueError(f"Expected {ch!r} at {idx}, found {text[idx]!r}")


root = parse_expr()
assert text[idx] == "/" and idx == end, "Did not consume full pattern"


# --- Simplify: convert seq(look(X), Y) into logical AND of X and Y.
class SNode:
__slots__ = ("typ", "children", "lit", "length")

def __init__(
self,
typ: str,
children: Optional[List["SNode"]] = None,
lit: bytes = b"",
length: int = 0,
):
self.typ = typ
self.children = children
self.lit = lit
self.length = length


def simplify(node: Node) -> SNode:
if node.typ == "lit":
return SNode("lit", lit=node.lit, length=len(node.lit))
if node.typ == "alt":
kids = [simplify(ch) for ch in node.children]
return SNode("alt", children=kids, length=kids[0].length)
if node.typ == "seq":
a, b = node.children
if a.typ == "look":
left = simplify(a.children[0])
right = simplify(b)
assert left.length == right.length
return SNode("and", children=[left, right], length=left.length)
left = simplify(a)
right = simplify(b)
return SNode("seq", children=[left, right], length=left.length + right.length)
if node.typ == "look":
return simplify(node.children[0])
raise ValueError(f"Unknown node type: {node.typ}")


def simplify_half(seq_node: Node) -> SNode:
# each half is seq(look(expr), expr)
assert seq_node.typ == "seq" and len(seq_node.children) == 2
return simplify(seq_node)


left_half = simplify_half(root.children[0])
right_half = simplify_half(root.children[1])


# --- Language evaluation with memoization and a small safety cap.
LIMIT = 20000 # sanity cap; actual sets stay size 1.


@lru_cache(None)
def language(node: SNode) -> Tuple[bytes, ...]:
if node.typ == "lit":
return (node.lit,)
if node.typ == "alt":
res = set()
for ch in node.children:
res.update(language(ch))
if len(res) > LIMIT:
raise MemoryError("Language exploded")
return tuple(res)
if node.typ == "seq":
left = language(node.children[0])
right = language(node.children[1])
res = set()
for a in left:
for b in right:
res.add(a + b)
if len(res) > LIMIT:
raise MemoryError("Language exploded")
return tuple(res)
if node.typ == "and":
lset = set(language(node.children[0]))
rset = set(language(node.children[1]))
return tuple(lset & rset)
raise ValueError(f"Unknown SNode type: {node.typ}")


left_word = language(left_half)
right_word = language(right_half)
assert len(left_word) == len(right_word) == 1, "Ambiguous language?"

flag_bytes = left_word[0] + right_word[0]
Path("flag.png").write_bytes(flag_bytes)

try:
# If OpenCV is available we can decode directly; otherwise just print path.
import cv2 # type: ignore

img = cv2.imread("flag.png")
qr = cv2.QRCodeDetector()
val, _, _ = qr.detectAndDecode(img)
print("QR decode:", val)
except Exception:
print("Flag image saved to flag.png")

image-20251206210359020

flag{3ec1998a-efc7-46f9-85e2-0d3a4427260e}

RE

AI assistant

这算AI吧没看出来逆向

709567631754d8671b81531a04889f29

Snake1 [一血]

exe没有实际逻辑,真实逻辑在dll

所有导出开头都调用 seh_antidbg_stub:构造 SEH,故意除零,若异常链被破坏,就 ExitProcess

Stage1 Loader:用密钥0b882732-HITCTF-2O25-decrypt-key-06b79fe436e0对 1.txt 进行 XOR 解密,然后强行把前 4 字节(首个 dword)覆盖成 C3C3C3C3(相当于一段 ret 滑梯),把内存页权限改成 RWX(可读/可写/可执行)后调用执行,本质上是个障眼法。

image-20260115145241590

Stage2Loader

用常量表 XOR 解出 key hitctf,再次读取 1.txt,用新 key XOR 解密。

将缓冲首 4 字节改成 8B E8 33 C0(mov ebp,eax; xor eax,eax),RWX 后跳进解出的 shellcode

image-20260115145317323

把 1.txt 解 XOR 得到 shellcode

1
2
3
4
5
6
from pathlib import Path
key = b"hitctf" # Stage2 的解密 key
ct = Path("1.txt").read_bytes()
buf = bytearray(b ^ key[i % len(key)] for i, b in enumerate(ct))
buf[:4] = bytes.fromhex("8b e8 33 c0") # 补上入口
Path("1_stage2.bin").write_bytes(buf)

disasm 后发现两段自解码数据:第一段 XOR 出OutputDebugStringA,第二段 XOR 出 flag

问问ai就知道大概什么逻辑了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
000000ea <.data+0xea>:
ea: 89 9d 76 01 00 00 mov DWORD PTR [ebp+0x176],ebx
f0: e8 e1 ff ff ff call 0xd6
f5: 89 85 6a 01 00 00 mov DWORD PTR [ebp+0x16a],eax
fb: 8b d8 mov ebx,eax
fd: 8b 43 3c mov eax,DWORD PTR [ebx+0x3c]
100: 8b 54 18 78 mov edx,DWORD PTR [eax+ebx*1+0x78]
104: 03 d3 add edx,ebx
106: 8b 4a 18 mov ecx,DWORD PTR [edx+0x18]
109: 8b 72 20 mov esi,DWORD PTR [edx+0x20]
10c: 03 f3 add esi,ebx
10e: 49 dec ecx
10f: 8b 3c 8e mov edi,DWORD PTR [esi+ecx*4]
112: 03 fb add edi,ebx
114: 8b 07 mov eax,DWORD PTR [edi]
116: 3d 47 65 74 50 cmp eax,0x50746547
11b: 75 f1 jne 0x10e
11d: 8b 47 04 mov eax,DWORD PTR [edi+0x4]
120: 3d 72 6f 63 41 cmp eax,0x41636f72
125: 75 e7 jne 0x10e
127: 8b 72 24 mov esi,DWORD PTR [edx+0x24]
12a: 03 f3 add esi,ebx
12c: 66 8b 0c 4e mov cx,WORD PTR [esi+ecx*2]
130: 8b 72 1c mov esi,DWORD PTR [edx+0x1c]
133: 03 f3 add esi,ebx
135: 8b 14 8e mov edx,DWORD PTR [esi+ecx*4]
138: 03 d3 add edx,ebx
13a: 89 95 6e 01 00 00 mov DWORD PTR [ebp+0x16e],edx
140: e8 da fe ff ff call 0x1f
145: 50 push eax
146: 8b 85 6a 01 00 00 mov eax,DWORD PTR [ebp+0x16a]
14c: 50 push eax
14d: 8b 85 6e 01 00 00 mov eax,DWORD PTR [ebp+0x16e]
153: ff d0 call eax
155: 89 85 72 01 00 00 mov DWORD PTR [ebp+0x172],eax
15b: e8 10 ff ff ff call 0x70
160: 50 push eax
161: 8b 85 72 01 00 00 mov eax,DWORD PTR [ebp+0x172]
167: ff d0 call eax
169: c3 ret
16a: aa stos BYTE PTR es:[edi],al
16b: aa stos BYTE PTR es:[edi],al
16c: aa stos BYTE PTR es:[edi],al
16d: 0a bb bb bb 0b cc or bh,BYTE PTR [ebx-0x33f44445]
173: cc int3
174: cc int3
175: 0c dd or al,0xdd
177: dd dd fstp st(5)
179: 0d ff ff ff ff or eax,0xffffffff
import struct
code = Path("1_stage2.bin").read_bytes()

key2 = code[0x1ba:0x1ba+0x1e]
enc = b"\xF5\x49\x91\x61\x4E\x3E\x6A\x39\xDD\x26\xA8\x42\x7D\xEC\x1F\x25" \
b"\x43\x5F\x69\xB7\xF7\xCD\x08\xC7\x6C\xBB\xBB\x3C"
flag = bytes(enc[i] ^ key2[i] for i in range(len(enc)))
print(flag)

flag{HITCTF_2025_86053e16bb6f}

Snake2

做了懒得写了

Exception Key [二血]

三条反调试:

  • anti_debug_is_debugger_present:IsDebuggerPresent 检测,命中则退出。
  • anti_debug_debugport:调用 NtQueryInformationProcess(ProcessDebugPort),若有调试端口则退出,否则将 byte_42FF88=-1。
  • anti_debug_timing:用 QPC 测 200 万次循环耗时,超过 200ms 则退出。

image-20260115145611239

主动触发除零异常,dword_42FF8C 置为异常码 0xC0000005,用 word_4213F8[code&1]=0x260 通过 _Locimp::_Addfac 写入 unk_42FF90

image-20260115145624744

取自身 EXE 路径并构造命令行 self –child,传给 spawn_child_under_debug 启动一个子进程在调试模式下。 在收到首个 EXCEPTION_DEBUG_EVENT 时,byte_42FF88++(变为 1)。

image-20260115145638395

里面做了右旋,affine等处理

计算 LCG 种子,若 byte_42FF88 != 0 则 seed = byte_42FF88 + 0x12345678,否则 seed = dword_42FF8C ^ a5。在正常无调试下,byte_42FF88=1,得到 seed=0x12345679

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def lcg_next(s):
return (1664525 * s + 1013904223) & 0xFFFFFFFF

base = [
0x1f,0x06,0x67,0x37,0x9c,0x31,0x2c,0x30,0xb7,0xf6,0xa2,0xce,
0x3b,0x5d,0x45,0x6f,0x25,0x69,0x48,0x8e,0xdc,0xe6,0x1e,0x79,
0xb3,0x99,0xe9,0xf3,0x4a,0xe7,0x0e,0x80,0x4c,0x73,0x68,0x81,
0x6c,0x7c,0x1a,0x4d,0xdc,0x1c,0xeb,0xe0,0x3e,0x78,0x4c,0xfd
]

a4 = 0x260
a5 = 0x4D04 # 0x4D00 + ((0xC0000005 - 1) & 0xFF)

# xor & 右旋
xor_key = (a4 ^ 0x5A) & 0xFF
rot = (a4 >> 8) & 7
tmp = []
for b in base:
b ^= xor_key
b = ((b >> rot) | ((b << (8 - rot)) & 0xFF)) & 0xFF
tmp.append(b)

mul_inv = pow((a5 | 1) & 0xFF, -1, 256)
sub = (a5 >> 8) & 0xFF
expected = [ (mul_inv * ((b - sub) & 0xFF)) & 0xFF for b in tmp ]

# 0x12345679
seed = 0x12345679
flag_bytes = []
state = seed
for e in expected:
state = lcg_next(state)
rand = state & 0xFF
flag_bytes.append(e ^ rand)

flag = bytes(flag_bytes)
print(flag.decode()) # HITCTF2025{57c8af8e-4e66-4543-b442-00c5

EasyVM

简单的VM

image-20260115145456864

丢给AI直接出了

1
2
3
4
5
6
1. s[0]='f'; 2) s[1]+1='m' → s[1]='l';
2. s[2]^s[3]=0x06, s[3]^s[4]=0x1C, s[2]^s[4]=0x1A → 取 s[2..4]="ag{";
3. s[6]='i'; s[5]^'i'=0x21 → s[5]='H'; s[7]^'i'=0x3D → s[7]='T';
4. s[9]==s[7]='T'; s[8]^s[9]=0x17 → s[8]='C'; s[10]^s[9]=0x12 → s[10]='F';
5. s[11]*2=190 → s[11]='_'; s[12]=s[14]=50 ('2'); s[15]=53 ('5');
6. s[17]=0(字符串终止);s[16]=s[4]+2 → '}';s[13] 未被约束。

解析字节码:根据 vm_execute 的 opcode 定义,每条指令长度固定:halt(0)1B, out_const(1)3B, reg_from_in(2)3B, mov(3)3B, mov_imm(4)3B, add(5)3B, add_imm(6)3B, sub(7)3B, sub_imm(8)3B, xor(9)3B, xor_imm(0xA)3B, cmp(0xB)3B, cmp_imm(0xC)3B, jmp(0xD)2B, jnz(0xE)2B, read(0x10)1B, print(0x11)1B。用一个小 Python 脚本按长度切分。

vm_bytecode_prompt解析结果:一串 out_const reg,val 把输出缓冲区填成 “Enter flag: “,然后 print; halt。所以是提示字串。

vm_bytecode_checker(280 字节)解析结果:开头 jmp 0x3c 跳过“Wrong!” 构造,末尾 jmp 0x1c 跳到“Correct!” 构造。中间指令用 read 取输入到 input[0..],reg_from_in 把输入字节搬到 0..7 寄存器,然后通过 cmp_imm、xor、cmp 等组合出约束,失败就 jnz 0x02 跳回错误分支打印“Wrong!”。步进指令即可得到下列等式(用 reg[r] 表示中间寄存器,input[i] 表示输入):

cmp_imm reg0,input[0], 0x66 (‘f’) → s[0]=’f’

reg_from_in reg1,input[1]; mov reg0,reg1; add_imm reg1,1; cmp_imm reg1,109 (‘m’) → s[1]+1=’m’ → s[1]=’l’

reg_from_in reg1,input[2]; reg_from_in reg2,input[3]; reg_from_in reg3,input[4]; mov reg0,reg1; xor reg0,reg2; cmp_imm reg0,6 + mov reg0,reg2; xor reg0,reg3; cmp_imm reg0,0x1C + mov reg0,reg1; xor reg0,reg3; cmp_imm reg0,0x1A → 解得 s[2..4]=”ag{“

reg_from_in reg1,input[5]; … reg_from_in reg3,input[7]; cmp_imm reg2 (s[6]),105 (‘i’); mov reg0,reg1; xor reg0,reg2; cmp_imm 0x21; mov reg0,reg3; xor reg0,reg2; cmp_imm 0x3D → s[6]=’i’, s[5]=’H’, s[7]=’T’

reg_from_in reg4,input[8]; reg_from_in reg5,input[9]; reg_from_in reg6,input[10]; cmp reg5,reg3 (s[9]==s[7]); mov reg0,reg4; xor reg0,reg5; cmp_imm 0x17; mov reg0,reg6; xor reg0,reg5; cmp_imm 0x12 → s[9]=’T’, s[8]=’C’, s[10]=’F’

reg_from_in reg1..reg7,input[11..17]; cmp reg2,reg4 (s[12]==s[14]); mov reg0,reg1; add reg0,reg0; cmp_imm 0xBE → 2*s[11]=190 → s[11]=’_’;后续 xor reg0,reg0; cmp reg0,reg7 强制 s[17]=0;add reg0,reg2; cmp_imm 50 → s[12]=50(‘2’); sub_imm reg0,2; cmp_imm 48 → s[14]=50(‘2’); add_imm reg0,5; cmp reg0,reg5 → s[15]=53(‘5’); reg_from_in reg0,input[4]; sub reg6,reg0; cmp_imm reg6,2 → s[16]=s[4]+2=’}’;s[13] 未出现

flag{HiTCTF_2025}

ComplexVM

跟上题一样,不知道complexVM在哪只是逻辑稍微复杂,但是打法是一样的

程序是自制 VM。ctx+8 是寄存器区,ctx+0x10 旗标(bit0=ZF),ctx+0x14 为 PC,ctx+0x18 为 SP,ctx+0x1c 开头是字节码,用户输入缓冲在 ctx+0x41c。sub_140001210 是解释器,sub_140004050 做 reg[x]-imm 并置标志。

主要指令:

  • 0x1F:从输入缓冲加载到寄存器(ctx+0x41c[idx] → reg[dest])。
  • 0x18:与立即数比较,仅设标志。
  • 0x17:寄存器与寄存器比较。
  • 0x12:JNZ,条件跳转依据 ZF。
  • 0x22/0x21/0x23/0x24:给寄存器写入常量(带常量偏移)。

字节码逐字节对输入做 load→compare→JNZ,推得各位置字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
code_hex = """
13 0 75 6 5 5 7 1 72 7 3 6e 7 2 6f 7 4 67 7 0 57 20 0 0 20 4 4 20 2 2 20 3 3 20 1 1 20 5 5 1e 0 ff
7 1 6f 7 4 65 7 2 72 7 6 74 7 0 43 7 5 63 7 3 72 6 7 7 20 3 3 20 1 1 20 2 2 20 0 0 20 4 4 20 6 6 20 5 5
20 7 7 1e 0 ff 7 0 ff 9 0 1 12 0 60 14 0 0 7 0 aa 7 1 55 6 0 1 14 0 0 7 0 0 13 0 69 7 1 ff 17 0 1 12 0 3 1c 0 0
1f 0 2 18 0 61 12 0 3 1f 0 1 18 0 6c 12 0 3 1f 0 0 18 0 66 12 0 3 1f 0 3 18 0 67 12 0 3 1f 0 4 18 0 7b 12 0 3
1f 0 7 18 0 54 12 0 3 1f 0 6 18 0 49 12 0 3 1f 0 5 18 0 48 12 0 3 1f 0 8 18 0 43 12 0 3 1f 0 c 18 0 30 12 0 3
1f 0 a 18 0 46 12 0 3 1f 0 b 18 0 32 12 0 3 1f 0 9 18 0 54 12 0 3 1f 0 d 18 0 32 12 0 3 1f 0 e 18 0 35 12 0 3
1f 0 f 18 0 5f 12 0 3 1f 0 10 1f 1 b 17 0 1 12 0 3 1f 0 11 22 1 a8 17 1 0 12 0 3 1f 0 12 9 1 1 17 1 0 12 0 3
7 0 80 15 0 0 13 0 5d 16 0 0 9 0 1 12 1 3e 1f 0 13 6 1 0 18 1 52 12 0 3 1f 0 14 1f 1 17 6 0 1 12 0 3 18 1 63
12 0 3 1f 0 15 1f 1 16 3 1 0 18 1 3 12 0 3 1f 1 10 8 1 2 17 1 0 12 0 3 1f 0 18 18 0 7d 12 0 3 1f 0 19 18 0 0
12 0 3 7 0 80 15 0 0 13 0 5d 16 0 0 9 0 1 12 1 9b 10 0 2a ff 0 0 7 0 49 7 1 6e 7 2 70 7 3 75 7 4 74 7 5 20 20 0 0
20 1 1 20 2 2 20 3 3 20 4 4 20 5 5 7 0 79 7 1 6f 7 2 75 7 3 72 7 4 20 7 5 66 20 6 0 20 7 1 20 8 2 20 9 3 20 a 4 20 b 5 7 0 6c 7 1 61 7 2
"""
code = [int(x, 16) for x in code_hex.split()]

pairs = {}
for i, b in enumerate(code):
if b == 0x1f and i + 2 < len(code):
dest, idx = code[i + 1], code[i + 2]
# 向后几步找 0x18 同目标寄存器的比较立即数
for j in range(1, 10):
k = i + j
if k + 2 >= len(code): break
if code[k] == 0x18 and code[k + 1] == dest:
pairs[idx] = code[k + 2]
break

for k in sorted(pairs):
v = pairs[k]
ch = chr(v) if 32 <= v < 127 else v
print(f"{ch}",end='')

flag{HITCTF2025_287ec47c}

gift

有毒 快跑