好久没发博客了。这次DesCTF时间没打满,Misc部分基本做的差不多,取证有点事没有做下去了。

2026开始,各种比赛中Agent的身影开始出现,AI写脚本,审内容速度快并且非常准确,稍加提示就能基本做出现在CTF的很多题目。我也有点陷入迷茫,要知道从GPT3出来到现在也就差不多5年时间,未来几年发展的速度会更快,身边出现了很多对知识点全然不知,只会搬运AI答案的人,我自己也开始依赖AI。学IT能最直面AI的冲击,不知道学安全未来该怎么和AI共存。

Misc

Real sign in?

拿到一个加密的challenge.zip,里面一个png,加密算法是store,最简单的明文攻击。

明文攻击之后拿到一个置乱的图片,是zigzag置乱,网上有相关的论文,写个脚本还原即可。

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
from PIL import Image
import numpy as np


def zigzag_indices(h, w):
"""
生成标准 ZigZag 扫描顺序:
按副对角线遍历,方向交替
"""
order = []
for s in range(h + w - 1):
diag = []
r_start = max(0, s - w + 1)
r_end = min(h - 1, s)
for r in range(r_start, r_end + 1):
c = s - r
diag.append((r, c))

# 标准 ZigZag:偶数对角线反转
if s % 2 == 0:
diag.reverse()

order.extend(diag)
return order


def inverse_zigzag(img_array):
"""
把“按 ZigZag 顺序展开后再按行填充”的图像还原回原图
"""
h, w = img_array.shape
flat = img_array.reshape(-1)
order = zigzag_indices(h, w)

restored = np.zeros_like(img_array)
for k, (r, c) in enumerate(order):
restored[r, c] = flat[k]

return restored


def main():
input_path = "1.png" # 加密图
output_path = "restored_qr.png" # 还原图

# 读取灰度图
img = Image.open(input_path).convert("L")
arr = np.array(img)

# 二值化,避免灰度抗锯齿影响
binary = np.where(arr > 128, 255, 0).astype(np.uint8)

# ZigZag 逆还原
restored = inverse_zigzag(binary)

# 保存结果
Image.fromarray(restored).save(output_path)
print(f"还原完成,已保存到: {output_path}")


if __name__ == "__main__":
main()

ir_challenge

打开txt能看到很多命令,先统计一下各种命令的次数,应该想办法找出每个命令对应的操作是什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
data = []
cmd_times = {}
with open("ir_challenge.txt", "r", encoding = "utf8") as f:
for line in f.readlines():
if "command" in line:
line_part = line.strip().split(" ")
cmd = line_part[1] + " " + line_part[2]
data.append(cmd)
cmd_times[cmd] = cmd_times.get(cmd,0) + 1


for key, t in cmd_times.items():
print(f"cmd:{key} -- {t} times")

print(f"total: {len(cmd_times)}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cmd:17 E8 -- 22 times
cmd:53 AC -- 85 times
cmd:CA 35 -- 115 times
cmd:55 AA -- 91 times
cmd:44 BB -- 97 times
cmd:4B B4 -- 81 times
cmd:0E F1 -- 84 times
cmd:4A B5 -- 89 times
cmd:43 BC -- 91 times
cmd:52 AD -- 91 times
cmd:18 E7 -- 13 times
cmd:54 AB -- 87 times
cmd:15 EA -- 19 times
cmd:19 E6 -- 16 times
cmd:16 E9 -- 19 times
total: 15

可以发现一共15种命令,然后还给了一个图片,是一个键盘,那应该是想办法搞出上下左右对应的是哪个命令

到这里用agent效率会很高:

那么接下来就是筛选出15-19的命令,然后去读取对应的值

写个脚本,注意这个键盘里清空和删除也要考虑进去。剩下的就是程序设计问题了,我直接溜gpt了

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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
红外遥控序列解码辅助脚本

核心思路:
1) 解析 NECext 文本中的 command 字节序列
2) 穷举“确认键 + 方向键映射 + 起点 + 边界策略”
3) 支持两种模型:
- basic: 仅 6x6 字母数字盘
- tv: 在 6x6 上方增加“清空/删除”特殊行(更贴近截图界面)
"""

from __future__ import annotations

import argparse
import itertools
import re
from dataclasses import dataclass
from pathlib import Path


CHAR_LAYOUT = [
list("ABCDEF"),
list("GHIJKL"),
list("MNOPQR"),
list("STUVWX"),
list("YZ1234"),
list("567890"),
]

BASIC_H = len(CHAR_LAYOUT)
BASIC_W = len(CHAR_LAYOUT[0])
TV_H = BASIC_H + 1
TV_W = BASIC_W


LEET_MAP = str.maketrans(
{
"0": "o",
"1": "i",
"3": "e",
"4": "a",
"5": "s",
"7": "t",
"9": "g",
}
)


@dataclass
class Candidate:
score: float
text: str
select_code: str
move_map: dict[str, str]
wrap: bool
start: tuple[int, int]
end: tuple[int, int]
model: str
backspace_code: str | None = None


def parse_codes(path: Path) -> list[str]:
codes: list[str] = []
pattern = re.compile(r"^command:\s+([0-9A-Fa-f]{2})\s+([0-9A-Fa-f]{2})")
for line in path.read_text(encoding="utf-8").splitlines():
m = pattern.match(line)
if m:
codes.append(m.group(1).upper())
return codes


def get_board_shape(model: str) -> tuple[int, int]:
if model == "basic":
return BASIC_H, BASIC_W
if model == "tv":
return TV_H, TV_W
raise ValueError(f"unknown model: {model}")


def apply_select(model: str, r: int, c: int, out: list[str]) -> None:
if model == "basic":
out.append(CHAR_LAYOUT[r][c])
return

# tv 模型:
# - r=0 顶行为特殊键区:c<=2 视为“清空”,c>=3 视为“删除”
# - r=1..6 为 6x6 字符盘
if r == 0:
if c <= 2:
out.clear()
elif out:
out.pop()
return

out.append(CHAR_LAYOUT[r - 1][c])


def move_pos(r: int, c: int, direction: str, rows: int, cols: int, wrap: bool) -> tuple[int, int]:
if direction == "U":
nr, nc = r - 1, c
elif direction == "D":
nr, nc = r + 1, c
elif direction == "L":
nr, nc = r, c - 1
else:
nr, nc = r, c + 1

if wrap:
return nr % rows, nc % cols
return max(0, min(rows - 1, nr)), max(0, min(cols - 1, nc))


def decode_text(
codes: list[str],
select_code: str,
move_map: dict[str, str],
start: tuple[int, int],
wrap: bool,
model: str,
backspace_code: str | None = None,
) -> tuple[str, tuple[int, int]]:
rows, cols = get_board_shape(model)
r, c = start
out: list[str] = []

for code in codes:
if code == select_code:
apply_select(model, r, c, out)
continue

# 兜底模式:允许某个命令值直接作为“退格”
if backspace_code is not None and code == backspace_code:
if out:
out.pop()
continue

move = move_map.get(code)
if move is not None:
r, c = move_pos(r, c, move, rows, cols, wrap)

return "".join(out), (r, c)


def score_text(text: str) -> float:
if not text:
return -1.0

upper = text.upper()
letters = sum(ch.isalpha() for ch in text)
digits = sum(ch.isdigit() for ch in text)
repeated = sum(1 for i in range(len(text) - 1) if text[i] == text[i + 1])

markers = [
"FLAG",
"INFRARED",
"INFRA",
"SECRET",
"HIDE",
"CODE",
"KEY",
"FUN",
"IR",
]
marker_score = sum(upper.count(m) * 4 for m in markers)
readability = letters * 0.22 - digits * 0.18 - repeated * 0.5
return marker_score + readability


def to_flag_payload(raw_text: str) -> str:
text = raw_text.strip()
if text.upper().startswith("FLAG"):
text = text[4:]
return text.lower()


def to_leet_normalized_payload(raw_text: str) -> str:
return to_flag_payload(raw_text).translate(LEET_MAP)


def brute_force(codes: list[str], top_n: int, try_backspace: bool, model: str) -> list[Candidate]:
low_codes = sorted({c for c in codes if int(c, 16) <= 0x1F})
control_pool = [c for c in low_codes if c in {"0E", "15", "16", "17", "18", "19"}]
if len(control_pool) < 5:
control_pool = low_codes[:6]

other_codes = sorted(set(codes) - set(control_pool))
rows, cols = get_board_shape(model)
all_candidates: list[Candidate] = []

for select_code in control_pool:
rest = [c for c in control_pool if c != select_code]
for move_codes in itertools.combinations(rest, 4):
for direction_perm in itertools.permutations(["U", "D", "L", "R"]):
move_map = dict(zip(move_codes, direction_perm))
fallback_backspace = next((c for c in rest if c not in move_codes), None)

for wrap in (False, True):
for r in range(rows):
for c in range(cols):
text, end_pos = decode_text(
codes,
select_code,
move_map,
(r, c),
wrap,
model,
)
all_candidates.append(
Candidate(
score=score_text(text),
text=text,
select_code=select_code,
move_map=move_map,
wrap=wrap,
start=(r, c),
end=end_pos,
model=model,
)
)

if not try_backspace:
continue

backspace_candidates = list(other_codes)
if fallback_backspace is not None:
backspace_candidates = [fallback_backspace] + backspace_candidates
for backspace_code in backspace_candidates:
text2, end_pos2 = decode_text(
codes,
select_code,
move_map,
(r, c),
wrap,
model,
backspace_code=backspace_code,
)
all_candidates.append(
Candidate(
score=score_text(text2),
text=text2,
select_code=select_code,
move_map=move_map,
wrap=wrap,
start=(r, c),
end=end_pos2,
model=model,
backspace_code=backspace_code,
)
)

all_candidates.sort(key=lambda x: x.score, reverse=True)
return all_candidates[:top_n]


def print_trace(codes: list[str], cand: Candidate) -> None:
rows, cols = get_board_shape(cand.model)
r, c = cand.start
out: list[str] = []
select_idx = 0

print("[+] trace:")
for code in codes:
if code == cand.select_code:
select_idx += 1
before = "".join(out)
apply_select(cand.model, r, c, out)
after = "".join(out)

if cand.model == "tv" and r == 0:
action = "CLEAR" if c <= 2 else "DELETE"
elif len(after) > len(before):
action = f"CHAR({after[-1]})"
else:
action = "SELECT"

print(f" {select_idx:02d}: pos=({r},{c}) action={action} out={after}")
continue

if cand.backspace_code is not None and code == cand.backspace_code:
if out:
out.pop()
continue

move = cand.move_map.get(code)
if move is not None:
r, c = move_pos(r, c, move, rows, cols, cand.wrap)

print(f"[+] trace end pos=({r},{c}) text={''.join(out)}")


def main() -> None:
parser = argparse.ArgumentParser(description="IR challenge bruteforce decoder")
parser.add_argument(
"-i",
"--input",
type=Path,
default=Path(__file__).with_name("ir_challenge.txt"),
help="输入文件路径(默认:同目录 ir_challenge.txt)",
)
parser.add_argument("--top", type=int, default=20, help="输出前 N 条候选")
parser.add_argument(
"--model",
choices=["tv", "basic"],
default="tv",
help="解码模型:tv(含清空/删除) 或 basic(纯6x6字符盘)",
)
parser.add_argument(
"--try-backspace",
action="store_true",
help="是否额外穷举“某命令=退格”的情况(更慢)",
)
parser.add_argument(
"--trace-top1",
action="store_true",
help="打印第 1 名候选的逐次 select 轨迹",
)
args = parser.parse_args()

codes = parse_codes(args.input)
if not codes:
raise SystemExit("未解析到 command 码,请检查输入文件格式。")

print(f"[+] total codes: {len(codes)}")
print(f"[+] unique codes: {len(set(codes))} -> {' '.join(sorted(set(codes)))}")
print(f"[+] model: {args.model}")
print("[+] brute forcing...")

results = brute_force(
codes=codes,
top_n=args.top,
try_backspace=args.try_backspace,
model=args.model,
)

for i, cand in enumerate(results, 1):
backspace = cand.backspace_code if cand.backspace_code is not None else "-"
payload_raw = to_flag_payload(cand.text)
payload_norm = to_leet_normalized_payload(cand.text)
print(
f"{i:02d}. score={cand.score:.2f} "
f"sel={cand.select_code} move={cand.move_map} "
f"wrap={cand.wrap} start={cand.start} end={cand.end} "
f"bksp={backspace} text={cand.text}"
)
print(f" -> raw_flag: flag{{{payload_raw}}}")
print(f" -> leet_fix: flag{{{payload_norm}}}")

if args.trace_top1 and results:
print_trace(codes, results[0])


if __name__ == "__main__":
main()

wireshark

modbus/tcp工厂流量

先按数据包长度排序,最长的那个包找到一个fakeflag,是在funcode=3的时候出现的

这个fakeflag可能是提示分析funcode=3吧,重点分析下。modbus.func_code == 3的流量,在30612这里看到一个可疑的字符串:S7COMM01,这个应该是西门子的一个协议名字

这个ip是192.168.100.10和192.168.100.101,怀疑这个是可疑的通讯,排查一下这两个ip之间的通信和协议

一个一个协议排查,发现funcode=8的时候比较少通信出现了一些可疑的字符串
ip.addr == 192.168.100.10 && ip.addr == 192.168.100.101 && modbus.func_code == 8


进一步排查frame.number > 30612 && ip.src == 192.168.100.10 && ip.dst == 192.168.100.101 && modbus.func_code == 8

这里有6个连续的请求

把数据拼在一起得到:
ded7825ede4fd19c9f37371c37c6fa2d54e6fe2801f0df1d763175a586db1c629efa82d0f8eacb417b4419392b4a6aa8

那么这个应该是密文,S7COMM01应该是密钥,S7COMM01这个东西16字节,去试试ecb之类的加密,最后发现是DES-ECB

Neural Secrets

torch.load("model.pth") 读 checkpoint,发现有 model_state_dict、vocab、eval_cache等等。其中embedding.weight中有95个字符,每个字符64维向量,然后有一个比较可疑的eval_cache有39个字符向量

这个模型是一个字符语言模型,会对输入的字符转换成embedding向量然后再预测下一个字符,而eval_cache中按顺序存了执行时的向量,因此只要把eval_cache中的每个向量找出来在embedding表中对应的是哪个字符就好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch, numpy as np

ckpt = torch.load("model.pth", map_location="cpu")
emb = ckpt["model_state_dict"]["embedding.weight"].cpu().numpy()
cache = ckpt["eval_cache"].cpu().numpy()
vocab = ckpt["vocab"]
inv = {i: c for c, i in vocab.items()}

dist = ((cache[:, None, :] - emb[None, :, :]) ** 2).sum(axis=2)
idx = dist.argmin(axis=1)

msg = ''.join(inv[int(i)] for i in idx)
print(msg)