2026阿里CTF安全挑战赛 - 热身赛 - WriteUp
碎碎念
本题为今年阿里CTF的一道热身赛题,也是唯一一道,正好是道Misc,刷群的时候看到有就来看了,总体来说难度不高,但也学到了一些思路
由于截止到该wp发出时热身赛的奖品已经被领完了,并且我已征求赛事工作人员的许可,遂将该WP发到博客
正文
知识点省流
IDAT块隐写 音频隐写(音频频率字节转换)
WP
附件给的是一张png图片,直接看最后发现有一个异常IDAT块,被改成了F14G块,而且数据段为zlib数据,显然是zlib压缩的idat块隐写处理

用厨子简单解压一下,会发现这是个wav音频 ,保存一下

用audacity简单看看,发现他的音频非常的诡异,打开频谱图会发现每段里面有n个频率,但频率的大小似乎是固定的几种

先让ai写个脚本检测一下主频率都有哪几种,分别是多少:
import numpy as np
import scipy.io.wavfile as wav
from scipy.signal import find_peaks
def analyze_main_frequencies(wav_path, min_height_ratio=0.1, min_distance_hz=20):
# 读取 wav
fs, data = wav.read(wav_path)
# 如果是立体声,取平均变单声道
if data.ndim == 2:
data = data.mean(axis=1)
# 转为 float 并去直流分量
data = data.astype(np.float64)
data = data - np.mean(data)
N = len(data)
# FFT
fft_vals = np.fft.rfft(data)
magnitudes = np.abs(fft_vals)
freqs = np.fft.rfftfreq(N, d=1/fs)
# 设定阈值:最大幅值的 min_height_ratio
height_threshold = magnitudes.max() * min_height_ratio
# 最小峰间距(转换为点数)
freq_resolution = fs / N
min_distance_bins = int(min_distance_hz / freq_resolution)
# 找峰值
peaks, properties = find_peaks(magnitudes, height=height_threshold, distance=min_distance_bins)
main_freqs = freqs[peaks]
main_mags = magnitudes[peaks]
# 按幅值排序
order = np.argsort(main_mags)[::-1]
main_freqs = main_freqs[order]
main_mags = main_mags[order]
return main_freqs, main_mags
if __name__ == "__main__":
freqs, mags = analyze_main_frequencies("1.wav", min_height_ratio=0.1, min_distance_hz=20)
print(f"检测到主频数量: {len(freqs)}")
for i, f in enumerate(freqs, 1):
print(f"{i}: {f:.2f} Hz")
发现一共就8种主频率,而且都呈现倍数关系,显然是刻意设计的
而且既然数量正好是8个,并且音频进行了分段划分,其实简单猜测可以联想到八位二进制,通过不同的频率表达不同的位

(问一下ai也能出)

紧接着写脚本去转换字节流即可,这里可以调教一下ai,给出脚本,不过需要注意引导ai完善
最终的exp如下,其中需要注意要把数据转换完全,保留静音的部分,将静音部分视为00,不然提取出来的文件是不全的
import numpy as np
from scipy.io import wavfile
# --- 核心配置 ---
FILENAME = '1.wav'
OUTPUT_NAME = 'decoded_fixed.png'
FRAME_SIZE = 4800
# 频率表:从低位(Bit 0)到高位(Bit 7)
TARGET_FREQS = [100, 200, 400, 800, 1600, 3200, 6400, 12800]
def recover_file():
print(f"正在读取 {FILENAME} ...")
sample_rate, data = wavfile.read(FILENAME)
# 归一化处理
if len(data.shape) > 1: data = data[:, 0]
data = data.astype(np.float32)
max_val = np.max(np.abs(data))
if max_val > 0:
data /= max_val
total_samples = len(data)
print(f"总样本数: {total_samples}")
# 1. 寻找起始点 (Sync)
# PNG文件的开头是 0x89 (10001001),肯定有声音,所以寻找第一个非静音处作为起点是安全的
start_index = 0
threshold = 0.2 # 信号起始判定的阈值
for i in range(0, total_samples - FRAME_SIZE, 100): # 粗略扫描
# 检查这一小段的能量
if np.max(np.abs(data[i:i + 1000])) > threshold:
# 找到大致位置后,回退一点确保包含完整帧,这里简单处理直接对齐
# 为了更精准,通常第一个帧的能量会瞬间跳变
start_index = i
print(f"锁定起始采样点: {start_index}")
break
# 2. 强制解码直到文件末尾
raw_bytes = []
# 使用保护间隔,只分析帧中间的波形,避免边缘噪声
guard = 400
print("开始解码,不跳过任何静音帧...")
# 循环条件:只要还有完整的帧就继续读
for i in range(start_index, total_samples - FRAME_SIZE, FRAME_SIZE):
chunk = data[i:i + FRAME_SIZE]
# 取中间段进行FFT分析
analysis_chunk = chunk[guard:-guard]
# 加窗
windowed = analysis_chunk * np.hanning(len(analysis_chunk))
fft_data = np.abs(np.fft.rfft(windowed))
freqs = np.fft.rfftfreq(len(analysis_chunk), 1 / sample_rate)
byte_value = 0
local_max = np.max(fft_data)
# 即使 local_max 很小(静音),也说明这可能是一个 0x00 字节
# 只有当确实有明显频率峰值时才置位
# 这里的判定阈值稍微敏感一点,防止漏掉弱信号
# 如果整帧最大能量都极低,说明是 0x00,直接跳过频率检测循环
if local_max > 0.05:
for bit_idx, f_target in enumerate(TARGET_FREQS):
idx = np.argmin(np.abs(freqs - f_target))
# 检查该频率是否有能量峰值
# 判定标准:该频率幅值 > 当前帧最大幅值的 30%
if fft_data[idx] > local_max * 0.3:
byte_value |= (1 << bit_idx)
# 【关键修正】:无论 byte_value 是什么(包括0),都加入结果
raw_bytes.append(byte_value)
# 3. 保存与检查
print(f"解码结束,共提取 {len(raw_bytes)} 字节。")
byte_arr = bytearray(raw_bytes)
# 尝试自动修剪头部垃圾数据
png_header = b'\x89PNG'
head_pos = byte_arr.find(png_header)
if head_pos != -1:
print(f"在位置 {head_pos} 发现 PNG 头,自动修剪头部...")
byte_arr = byte_arr[head_pos:]
else:
print("警告:未自动发现 PNG 头,将保存所有提取数据,请手动检查。")
# 如果没找到头,有可能是起始点找晚了,导致错位。
# 这种情况下,不要截断,直接保存全部数据。
with open(OUTPUT_NAME, 'wb') as f:
f.write(byte_arr)
print(f"文件已保存: {OUTPUT_NAME}")
print("提示:如果图片下方显示不全,说明音频还没录完就断了,或者解码未到最后。")
if __name__ == "__main__":
recover_file()
最后可以得到一张png图片,里面是aztec码
在线扫一下就可以得到flag了
