Al x Cybersecurity Challenge香港人工智能网络安全挑战赛wp(Miscellaneous全)

Miscellaneous

database

题目描述:

某公司的RAID 5业务服务器备份只保留下部分数据。请检查残留证据,尽可能还原有用内容,并找出隐藏的 flag。

题目说的很明确了这个服务器采用的是RAID 5,而且是只保留下部分数据,看起来是一道RAID 5恢复的题目

应该是先恢复RAID 5阵列,再进入文件系统找flag

那在找之前我们自然是先要搞明白什么是RAID 5阵列

RAID,Redundant Array of Independent Disks,即独立磁盘冗余阵列,我们一般称之为磁盘阵列

其实本质上就是用多个独立的磁盘组成在一起形成一个大的磁盘系统,从而实现比单块磁盘更好的存储性能和更高的可靠性

而RAID5正是其中一种排列方式,实际上,这是目前用的最多的一种磁盘阵列排列方式,因为其兼顾了存储性能、数据安全和存储成本

其排布大致如下

RAID 5至少需要3块硬盘

数据会被分成若干数据块,分别写入不同硬盘;同时,系统会计算一份用于恢复数据的校验信息 Parity,并将校验信息分散存储在各块硬盘中。

例如有 3 块硬盘:

校验块不会固定放在某一块硬盘上,而是轮流分布,这样可以避免某一块专门负责校验的硬盘成为性能瓶颈

那如何恢复数据呢,RAID 5一般采用的是XOR技术

例如 A XOR B = P 存进去的

那么如果A的数据坏了,只要 P XOR B = A 就能把A恢复出来

所以只要同时坏的不超过1块,RAID 5通常仍然可以继续读取数据

好来看题来

可以看到题目给了两块镜像

1
2
file disk1.img
file disk2.img

检查文件类型可以直接发现是三块盘搭的RAID 5,但是只给了两块,尝试降级恢复

看看具体的RAID元数据,我们一般在Linux直接用mdadm来处理RAID

先把这两个镜像变成loop设备,因为RAID的成员盘一般不能直接挂载,我们先建立loop设备,其实就是把.img文件给模拟成一块磁盘, 给它分配一个/dev/loopX设备

1
2
sudo losetup -fP disk1.img 
sudo losetup -fP disk2.img

查看 loop 设备对应关系:

1
losetup -a

建立好loop设备后就可以用mdadm来看元数据了

1
2
sudo mdadm --examine /dev/loop0 
sudo mdadm --examine /dev/loop1

可以看到关键信息,这两块分别是role 0和role 1,即第一块成员盘和第二块成员盘

我们缺失了role 2,第三块成员盘

注意这一步是必须的,因为我们得知道这俩分别是哪一块成员盘,才能按条带规则来重组RAID

由于缺失了一块盘,所以我们需要降级模式来组装RAID

1
sudo mdadm --assemble --readonly --run /dev/md0 /dev/loop0 /dev/loop1

各参数大概是下边这些意思

1
2
3
4
5
6
--assemble 组装已有 RAID 阵列 
--readonly 只读方式组装,避免搞坏了
--run 即使阵列不完整,也尝试启动
/dev/md0 生成的 RAID 设备名
/dev/loop0 第一个 RAID 成员
/dev/loop1 第二个 RAID 成员

1
cat /proc/mdstat

即可看见RAID的状态,这边的[UU_]中U表示正常存在的成员盘,_表示缺失的成员盘

通过 RAID 规则把成员盘重新组合起来后,我们终于得到了完整的 ext4 文件系统

开始挂载就好了

1
2
sudo mkdir -p /mnt/raid_recover
sudo mount -o ro /dev/md0 /mnt/raid_recover

创建一下挂载目录然后直接只读挂载

cd过去,发现成功恢复,我们已经进入了原文件系统

之后就是找东西环节了,题目说是数据,我们可以搜一下

1
sudo find /mnt/raid_recover -type f | grep -iE 'db|sqlite|database'

这个 /var/lib/companydb/important.db的数据库明显很可疑

打开看看

1
sqlite3 /mnt/raid_recover/var/lib/companydb/important.db

.tables看看表名,就发现个此地无银三百两的表

我们直接全部拉出来看看

1
SELECT * FROM flag_not_here;

给了四个flag,不过仔细看看就能发现只有最后一个是对的

所以本题答案为flag{raid5_recovery_1s_1mportant}

ntfs_dump

题目描述:

某员工将数据隐藏到bin文件中,请尽可能还原数据,并找出隐藏的flag,flag格式为FLAG{xxx}。

和题目描述一模一样啊,只给了一个bin文件,说是把数据隐藏进去了,让我们还原

先file看看文件格式,发现被识别为了NTFS的启动扇区,看起来像是个NTFS文件系统镜像

但是扔到Xways报错了,似乎不是一个正常的NTFS镜像

不过BPB似乎没有损坏,我们还是能看到一些基础参数

可以看到这边每扇区字节数是00 02,小端序存储,所以应该是0x0200,即512字节

每簇是8个扇区,所以是每簇8*512=4096字节

扇区总数是0x4000,即16384(小端序

$MFT起始簇号为4(小端序

不过其实Xways的技术报告可以直接分析出就是了

$MFT 偏移 = $MFT 起始簇号 × 簇大小

所以$MFT 偏移应该是16384,即0x4000

正常情况下准备按应该能看到FILE,但是现在这里塞了一大堆的伪造文件头,这会导致binwalk一类的工具无法检测

显然,我们必须手动搜索些有用的才行,那一般这种数据恢复的题目都会放到压缩包里作为载体,所以我们可以优先对于ZIP的几个主要结构十六进制进行搜索,主要是下边这三个

1
2
3
PK\x03\x04 Local File Header,本地文件头 
PK\x01\x02 Central Directory,中央目录
PK\x05\x06 End of Central Directory,中央目录结束记录

当然,其中中央目录当然是最主要的,里边会记录很多东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pathlib import Path

data = Path("ntfs_dump.bin").read_bytes()

for sig in [
b"PK\x03\x04",
b"PK\x01\x02",
b"PK\x05\x06",
b"text.txt",
]:
pos = []
start = 0

while True:
i = data.find(sig, start)
if i == -1:
break
pos.append(i)
start = i + 1

print(sig, len(pos), [hex(x) for x in pos[:10]])

发现了虽然 PK\x03\x04 有很多个,但是 PK\x01\x02PK\x05\x06 都只出现了一次

这说明有一个0x5b735附近的zip很有可能是真的

探查一下基本信息

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
from pathlib import Path
import struct

data = Path("ntfs_dump.bin").read_bytes()

cd_off = 0x5b735
fields = struct.unpack_from("<4s6H3I5H2I", data, cd_off)

(
sig,
ver_made,
ver_need,
flag,
method,
mtime,
mdate,
crc,
csize,
usize,
name_len,
extra_len,
comment_len,
disk,
int_attr,
ext_attr,
local_header_off,
) = fields

name = data[cd_off + 46 : cd_off + 46 + name_len]

print("filename:", name)
print("crc:", hex(crc))
print("compressed size:", csize)
print("uncompressed size:", usize)
print("method:", method)
print("local header offset:", hex(local_header_off))

根据各种偏移解析一下中央目录,得到了一些信息

重点可以看到压缩方法是0,即stored,不压缩,直接存的

所以压缩数据长度和原始数据长度相同

搜索一下text.txt

发现有两个地方都有

我们知道text.txt是8字节,所以它后面的数据起点应该是0x5910ae + 8 = 0x5910b6

也就是说,第一段疑似文件内容从这里开始:

1
0x5910b6

继续往后看,会发现数据到某个位置突然变成大量假文件头模式

假文件头开始位置是0x591911

所以第一段真实数据范围是0x5910b6 ~ 0x591911

计算长度0x591911 - 0x5910b6 = 0x85b = 2139

但是中央目录告诉我们,text.txt 应该有 4240 字节。

还差2101字节,即0x835字节呢

不在这边,会不会是和中央目录连在一起呢?

我们回去继续观察 ZIP 中央目录

中央目录从0x5b735开始,往前取 2101 字节看看,就是0x5af00 ~ 0x5b735这个范围

我们只要验证这两段拼起来的 CRC32 是否等于中央目录里的0x2bcce130即可确认正确与否其实

1
2
3
4
5
6
7
8
9
10
11
12
13
from pathlib import Path
import zlib

data = Path("ntfs_dump.bin").read_bytes()

part1 = data[0x5910b6:0x591911]
part2 = data[0x5af00:0x5b735]
payload = part1 + part2

print("part1 length: ", len(part1))
print("part2 length: ", len(part2))
print("total length: ", len(payload))
print("crc32: ", hex(zlib.crc32(payload) & 0xffffffff))

发现合起来还真的crc32一样,确认了就是这个

我们开始尝试自己创建一个zip,缺一个文件头,我们尝试按中央目录重建本地文件头

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
from pathlib import Path
import struct
import zlib
import zipfile

dump = Path("ntfs_dump.bin").read_bytes()

# ------------------------------------------------------------
# 1. ZIP 中央目录和 EOCD 的位置
# ------------------------------------------------------------
cd_off = 0x5b735
eocd_off = 0x5b76b

# ------------------------------------------------------------
# 2. 解析中央目录
# ------------------------------------------------------------
fields = struct.unpack_from("<4s6H3I5H2I", dump, cd_off)
(
sig,
ver_made,
ver_need,
flag,
method,
mtime,
mdate,
crc,
csize,
usize,
name_len,
extra_len,
comment_len,
disk,
int_attr,
ext_attr,
local_header_off,
) = fields

filename = dump[cd_off + 46 : cd_off + 46 + name_len]

print("[+] filename: ", filename.decode())
print("[+] crc: ", hex(crc))
print("[+] compressed size: ", csize)
print("[+] uncompressed size: ", usize)
print("[+] method: ", method)

# ------------------------------------------------------------
# 3. 恢复 text.txt 的两段真实数据
# ------------------------------------------------------------
part1 = dump[0x5910b6:0x591911]
part2 = dump[0x5af00:0x5b735]
payload = part1 + part2

assert len(payload) == csize
assert (zlib.crc32(payload) & 0xFFFFFFFF) == crc

print("[+] payload recovered")
print("[+] payload length: ", len(payload))
print("[+] payload crc32: ", hex(zlib.crc32(payload) & 0xFFFFFFFF))

# ------------------------------------------------------------
# 4. 构造 Local File Header
# ------------------------------------------------------------
local_header = (
struct.pack(
"<4s5H3I2H",
b"PK\x03\x04",
ver_need,
flag,
method,
mtime,
mdate,
crc,
csize,
usize,
name_len,
0,
)
+ filename
)

# ------------------------------------------------------------
# 5. 拷贝原始中央目录和 EOCD
# ------------------------------------------------------------
central_directory = dump[cd_off : cd_off + 0x36]
eocd = dump[eocd_off : eocd_off + 22]

recovered_zip = local_header + payload + central_directory + eocd
Path("recovered.zip").write_bytes(recovered_zip)

print("[+] recovered.zip written")

# ------------------------------------------------------------
# 6. 测试 ZIP 是否可以正常读取
# ------------------------------------------------------------
with zipfile.ZipFile("recovered.zip", "r") as z:
print("[+] zip files:", z.namelist())
text = z.read("text.txt")
Path("text.txt").write_bytes(text)
print("[+] text.txt extracted:", len(text), "bytes")

得到了恢复的压缩包和text.txt,但是发现不是明文,还需要提取才行

注意到这个数据的大小是4240字节,4240 = 16 * 265

猜测可能有固定步长隐写,可能是每 16 字节藏 1 字节真实数据,但是步长和偏移不知道,我们可以爆破看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pathlib import Path
import re

data = Path("text.txt").read_bytes()

for step in range(2, 65):
for offset in range(step):
stream = data[offset::step]
m = re.search(rb"FLAG\{[^}]+\}", stream)
if m:
print("[+] found")
print("step =", step)
print("offset =", offset)
print("flag =", m.group().decode())

发现确实是每 16 字节藏 1 字节真实数据

得到了本题的flag

FLAG{DE6DDD7A-5D57-415A-99D7-6C0CE3F688BC}

Riddler

题目描述:

A misplaced note is full of strange phrases and misleading remarks. The real message is hidden among relationships that look accidental at first glance. Can you separate the signal from the noise?

一份放错地方的笔记中充满了奇怪的短语和误导性的描述。真正的信息藏在乍看偶然的关系之中,你能从噪声里找出线索吗?

给的附件很直接啊,一个有flag的压缩包和一个秘密的txt,这txt应该就是题目里那个充满了奇怪的短语和误导性描述的笔记了

打开可以看到,的确和题目说的一样,充满了噪声,而线索正藏在这里边

不难定位到关键是中间那部分

1
2
3
4
5
6
7
بعدكـ is to رباعيه, ? is to love-buzz
الْحي is to toyota, ? is to الغيـرة
feriass is to dham, ? is to much
ميسيnn is to دعواتكمn, ? is to were
يصيير is to newt, ? is to ta
لأمانة is to roan, ? is to viral
elain is to omán, ? is to dy

一眼看过去的话像是什么类比题A is to B, ? is to C,当然我们也可以写作

1
A : B = ? : C  

笔记里已经帮我们排除了很多可能了,不是频率分析,不是进制转化,更不是翻译隐藏的,是需要我们找到其中的关系,忽略那些重复的意思,找到这些词的关系

这题需要注意题目和文件里给的那些关键词

1
2
3
4
unfinished language experiment
strange scripts
not direct translation
file appears too short

这些词指向了词向量,尤其是专门强调了文件太短,too short,专门说了这也是解密的一部分,其实也是一种提示,提示我们是短维度的模型

所以这题的考点其实是词向量类比, word embedding analogy

词向量是什么呢,可以理解为是某些语义关系可以通过向量加减体现出来

有点难以理解吧,我们可以举个例子先,比如king - man + woman ≈ queen

为什么词可以变成向量?

因为一个词的含义可以由其上下文决定,比如king和queen经常在一个上下文,computer就离这俩比较远

这就必须引入GloVe这个词向量模型的概念了

它的作用就是把一个词变成一串数字,也就是一个向量,注意这些数字不是乱来的,而是这个模型通过大量文本训练出来的

那本题中的格式是A : B = ? : C

我们可以理解为,如果关系是从 AB,那么同样的关系也应该从 ?C

也就是说? = B - A + C

所以每一行都要计算这个式子才行

1
vector(?) = vector(B) - vector(A) + vector(C)

最后我们还要在词向量模型中找最接近这个向量的词

那题目已经提示了,这一个词向量模型应该是一个短维度的模型

这个可能比较难想吧,或许也需要尝试,多亏了我们队伍的Jerry刚刚做过类似的题目,一试就出来了是 glove-twitter-25 模型,即Twitter语料上的 25 维 GloVe 词向量模型

最后直接写代码就好了

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 gensim.downloader as api

model = api.load("glove-twitter-25")

rows = [
("بعدكـ", "رباعيه", "love-buzz"),
("الْحي", "toyota", "الغيـرة"),
("feriass", "dham", "much"),
("ميسيnn", "دعواتكمn", "were"),
("يصيير", "newt", "ta"),
("لأمانة", "roan", "viral"),
("elain", "omán", "dy"),
]

result = []

for a, b, c in rows:
word, score = model.most_similar(
positive=[b, c],
negative=[a],
topn=1
)[0]

print(f"{b} - {a} + {c} = {word} score={score}")
result.append(word)

password = "".join(result)
print("password =", password)

这边要进行词向量的运算自然少不了Gensim库,这主要是拿来处理词向量、加载词向量模型的,在这边我们就可以让其自动下载并加载我们要用的这个短维的词向量模型

得到结果:

运算 最近词
1 رباعيه - بعدكـ + love-buzz ctf
2 toyota - الْحي + الغيـرة maybe
3 dham - feriass + much you
4 دعواتكمn - ميسيnn + were are
5 newt - يصيير + ta ai
6 roan - لأمانة + viral master
7 omán - elain + dy ado

拼接就能得到密码了

1
ctfmaybeyouareaimasterado

最后直接拿密码打开压缩包即可

得到flag

flag{39fc0ee0f9da4fa397631f0dcba31555}

Mysterious Code

题目描述:

一次 IDS 告警留下了BASE58的可疑流量。请分析这些痕迹,还原隐藏内容并找出其中的秘密。

只给了一份流量包,题目给的描述明显是先分析流量,然后可能涉及BASE58编码,最后要我们还原隐藏内容才行

wireshark打开一看发现是大量的DNS流量

而且DNS查询的域名格式非常类似

1
2
3
4
5
0-GRSXGZ2UK5YGQSSSORMTCVDHLJWXKNCQ.hackg.fun 
1-NBTHIQTHKZTXKRL2IZRUE4DVHFLDSRDT.hackg.fun
2-OVATQZDRPB2VKNDEKM3E6TSEIZSWUWKX.hackg.fun
...
EOF-f1b662512c1fcbfc.hackg.fun

通过观察不难发现这些域名的格式都是统一的”序号-数据.hackg.fun”的格式

可以看到最后还有EOF

这说明DNS查询很可能被用来传输隐藏数据,攻击者利用域名字段携带文本来传输

由于单个域名长度有限所以拆成了很多片,前边的序号就是排序的

所以我们要按序号提取这些分片的内容才行

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
from scapy.all import rdpcap, DNSQR
from collections import defaultdict, Counter
from pathlib import Path
import re

pcap = Path("Mysterious_Code.pcapng")
pkts = rdpcap(str(pcap))
pat = re.compile(rb"^(\d+)-([A-Za-z0-9]+)\.hackg\.fun\.?$", re.I)

chunks = defaultdict(list)

for pkt in pkts:
if pkt.haslayer(DNSQR):
q = pkt[DNSQR].qname
m = pat.match(q)
if m:
idx = int(m.group(1))
data = m.group(2).decode()
chunks[idx].append(data)

print("chunk count:", len(chunks))
print("index range:", min(chunks), max(chunks))

s = ""
for i in sorted(chunks):
s += Counter(chunks[i]).most_common(1)[0][0]

print("concat length:", len(s))
print("alphabet:", "".join(sorted(set(s))))
print("result:", s)

输出了很多内容,我们在输出内容的同时也整理了alphabet

得到字符表为

1
23456ABCDEFGHIJKLMNOPQRSTUVWXYZ

这很容易让人联想到Base32编码,其字符表如下

1
ABCDEFGHIJKLMNOPQRSTUVWXYZ234567

虽然没有出现7,但仍然符合Base32的字符范围,所以我们尝试解码

可以用cyberchef进行解码,成功解码出可读内容

继续对解码后的这串内容进行研究,提取字符表

其实也可以用Frequency Distribution进行研究

得到新的字符表为

1
123456789ABCDEFGHIJKMNOPQRSTUVWXYZabcdefghjklmnpqrstuvwxyz 

正好58个,不免让人想到题目提示的Base58编码

但是这和默认的Base58字符表不同

1
123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz

实际上,我们默认的Base58编码一般指的都是Bitcoin Base58字母表,因为这个编码本身就是比特币的创造者中本聪设计的,专门用于生成人类友好的标识符和地址的

这Base58和Base64最大的区别就是刻意排除了容易引起混淆的字符

如数字0和大写O,还有大写的I和小写的l,还有+和/这六个

但是这个其实是没有唯一标准的,本题就是利用了这个设计初衷,修改了Base58的字母表,从我们统计的内容即可发现少了 0 L i o

在再这些字符表的基础上尝试一下大小写和数字的排布,不难发现本题的字母表应该是

1
123456789abcdefghjklmnpqrstuvwxyzABCDEFGHIJKMNOPQRSTUVWXYZ

即“数字 + 小写字母 + 大写字母”

把这个字符表放到cyberchef即可

看文件头就可以确认正确了,我们解出了隐藏内容,一个压缩包

里边是一个Store存储、ZipCrypto加密方法的png图片,明显是明文爆破的题目

由于PNG文件有用固定的文件头,开头一般固定为文件头89504e470d0a1a0a,之后大概率紧跟IHDR块,也就是可以确定前16字节89504e470d0a1a0a0000000d49484452

作为明文爆破的明文绰绰有余了

创一个1.bin的文件,之后直接bkcrack明文爆破即可

1
bkcrack -C download.zip -c secret.png -p 1.bin -o 0 -j 8

成功得到keys,直接生成无密码的zip即可

1
bkcrack -C download.zip -k d1c9002e fddfca1e 86c3fee5 -D decrypted.zip

成功得到解密后的图片

打开来就是本题的flag了

flag{Crack_is_to_ezzzzzzzz}


Al x Cybersecurity Challenge香港人工智能网络安全挑战赛wp(Miscellaneous全)
https://mei-you-qian.github.io/2026/07/12/2026香港人工智能网络安全/
作者
Meiyouqian
发布于
2026年7月12日
许可协议