Xiaohao's Blog

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

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

Misc

Real sign in?

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

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

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能看到很多命令,先统计一下各种命令的次数,应该想办法找出每个命令对应的操作是什么

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)}")
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了

#!/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表中对应的是哪个字符就好了。

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)

张三的秘密

/张三/Pictures/Screenshots下面有一个文件截图,要我们找密钥:

  1. 资源管理器历史看到有访问过壁纸,在缓存中找到这个文件,文件末尾有隐写

  1. 桌面“饭卡”二维码扫码有一个
    458

0x4a37c5fad9d060d12f2cf65650fdd718d18d7e0a777276e85e1dd70a4a3c5b842d1feb896c42

  1. 最后一个,在xways爆搜一下之前发现的几个密钥,发现在pagefile.sys虚拟内存文件中可以找到

直接爆搜0x找最后一个的话结果会很多,观察到全小写并且已知密钥长度都为75左右,我考虑使用正则匹配0x开头并且长度为78的字符串,xway语法版本比较老,我又懒得开取证大师,暴力一点吧:

0x[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]+


结果缩小到50余个,导出结果,用ai或者写脚本去重排查一下吧

所以最后的结果:

p = 0x666c61677b3431e120579912cdf6831aed2476b0f3fab7c37b86a5c7b847e226a97f72f45783
0x4e769b2cb222e299d33ea4b89e2831e12399a6b0117336e981a567371726b3368c73f3488e18
0x47bb1ac5f6a422e8b4d483334b5d7fe2f8bae6ae665322ff30b2cade7f03e434a2e849d08599
0x3f961ff18045be09c0ef92b6a5813cdfe8dc365f613b130ed430095e657c8391a1c03ac5ace5
0x4a37c5fad9d060d12f2cf65650fdd718d18d7e0a777276e85e1dd70a4a3c5b842d1feb896c42
0xf0fe0b8be939ecd598f774ca043352f43dfb9fa3b74678aa9f9c9f68ab385f071d84376e64e

可以观察到p的开头是666c6c61,但是应该不是单纯转16进制,祭出ai神力了:

import itertools
from Crypto.Util.number import long_to_bytes

# 题目的模数 p(出题人故意搞了个开头是 flag{ 的素数来迷惑爆搜工具)
p = 0x666c61677b3431e120579912cdf6831aed2476b0f3fab7c37b86a5c7b847e226a97f72f45783

# 你从内存里提取出来的 5 个 y 坐标 (Shares)
ys = [
0x4e769b2cb222e299d33ea4b89e2831e12399a6b0117336e981a567371726b3368c73f3488e18,
0x47bb1ac5f6a422e8b4d483334b5d7fe2f8bae6ae665322ff30b2cade7f03e434a2e849d08599,
0x3f961ff18045be09c0ef92b6a5813cdfe8dc365f613b130ed430095e657c8391a1c03ac5ace5,
0x4a37c5fad9d060d12f2cf65650fdd718d18d7e0a777276e85e1dd70a4a3c5b842d1feb896c42,
0xf0fe0b8be939ecd598f774ca043352f43dfb9fa3b74678aa9f9c9f68ab385f071d84376e64e
]

# 费马小定理求模逆元(用于处理有限域内的除法)
def mod_inverse(a, m):
return pow(a, m - 2, m)

# 拉格朗日插值求多项式的常数项 f(0)
def lagrange_at_zero(xs, ys, p):
secret = 0
k = len(xs)
for i in range(k):
num = 1
den = 1
for j in range(k):
if i != j:
num = (num * (-xs[j])) % p
den = (den * (xs[i] - xs[j])) % p
# ys[i] * (num / den) mod p
term = (ys[i] * num * mod_inverse(den, p)) % p
secret = (secret + term) % p
return secret

print("[*] 开始在 x = 1..9 范围内枚举排列,进行拉格朗日插值爆破...")

# x 的候选池
candidates = range(1, 10)
found = False

# 穷举所有可能的 x 坐标组合 (P(9, 5) = 15120 种)
for xs in itertools.permutations(candidates, 5):
f0 = lagrange_at_zero(xs, ys, p)

try:
# 将算出的 f(0) 大整数转回字节串
flag_bytes = long_to_bytes(f0)
# 验证是否是我们需要的 Flag 格式
if b"flag{" in flag_bytes:
print(f"\n[!] 爆破成功!正确的 x 坐标组合为: {xs}")
print(f"[+] 最终的 Flag: {flag_bytes.decode('utf-8', errors='ignore')}")
found = True
break
except Exception:
# 转换错误说明算出的不是有意义的 ASCII,直接跳过
continue

if not found:
print("\n[-] 爆破结束,未找到 flag。请检查提取的 y 值是否有遗漏。")
[*] 开始在 x = 1..9 范围内枚举排列,进行拉格朗日插值爆破...

[!] 爆破成功!正确的 x 坐标组合为: (5, 1, 7, 8, 6)
[+] 最终的 Flag: flag{37af8b400737929ad29ad6876d283e92}

实际上这里用到了是Shamir’s Secret Sharing(SSS),给出的p是一个大素数,xways爆搜最后一个密钥的附近也有出现Shamir这个字符串,预期感觉应该是有线索提示的,密码学这块我不是很擅长。
赛后复现,没有官方的WP,这样子做感觉有点离谱了,不知道预期解是什么。留个坑吧。

文章作者: Xiaohao

文章链接: https://blog.enxiaohao.cn/posts/Misc/2026DesCTFwp/

版权声明:除另有声明外,本博客文章均采用 CC BY-NC-SA 4.0 许可协议。转载请注明原作者与文章出处。

玄机 冰蝎3.0-jsp流量分析 «
上一篇 «
» 2025数证杯决赛个人赛Writeup
» 下一篇