碎碎念

本题为今年阿里CTF的一道热身赛题,也是唯一一道,正好是道Misc,刷群的时候看到有就来看了,总体来说难度不高,但也学到了一些思路 由于截止到该wp发出时热身赛的奖品已经被领完了,并且我已征求赛事工作人员的许可,遂将该WP发到博客

正文

知识点省流

IDAT块隐写 音频隐写(音频频率字节转换)

WP

附件给的是一张png图片,直接看最后发现有一个异常IDAT块,被改成了F14G块,而且数据段为zlib数据,显然是zlib压缩的idat块隐写处理

image-20260112234009483

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

image-20260112234211593

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

image-20260112234448077

先让ai写个脚本检测一下主频率都有哪几种,分别是多少:

 1import numpy as np
 2import scipy.io.wavfile as wav
 3from scipy.signal import find_peaks
 4
 5def analyze_main_frequencies(wav_path, min_height_ratio=0.1, min_distance_hz=20):
 6    # 读取 wav
 7    fs, data = wav.read(wav_path)
 8
 9    # 如果是立体声,取平均变单声道
10    if data.ndim == 2:
11        data = data.mean(axis=1)
12
13    # 转为 float 并去直流分量
14    data = data.astype(np.float64)
15    data = data - np.mean(data)
16
17    N = len(data)
18
19    # FFT
20    fft_vals = np.fft.rfft(data)
21    magnitudes = np.abs(fft_vals)
22    freqs = np.fft.rfftfreq(N, d=1/fs)
23
24    # 设定阈值:最大幅值的 min_height_ratio
25    height_threshold = magnitudes.max() * min_height_ratio
26
27    # 最小峰间距(转换为点数)
28    freq_resolution = fs / N
29    min_distance_bins = int(min_distance_hz / freq_resolution)
30
31    # 找峰值
32    peaks, properties = find_peaks(magnitudes, height=height_threshold, distance=min_distance_bins)
33
34    main_freqs = freqs[peaks]
35    main_mags = magnitudes[peaks]
36
37    # 按幅值排序
38    order = np.argsort(main_mags)[::-1]
39    main_freqs = main_freqs[order]
40    main_mags = main_mags[order]
41
42    return main_freqs, main_mags
43
44if __name__ == "__main__":
45    freqs, mags = analyze_main_frequencies("1.wav", min_height_ratio=0.1, min_distance_hz=20)
46
47    print(f"检测到主频数量: {len(freqs)}")
48    for i, f in enumerate(freqs, 1):
49        print(f"{i}: {f:.2f} Hz")

发现一共就8种主频率,而且都呈现倍数关系,显然是刻意设计的

而且既然数量正好是8个,并且音频进行了分段划分,其实简单猜测可以联想到八位二进制,通过不同的频率表达不同的位

image-20260112234752611

(问一下ai也能出)

image-20260112235010924

紧接着写脚本去转换字节流即可,这里可以调教一下ai,给出脚本,不过需要注意引导ai完善

最终的exp如下,其中需要注意要把数据转换完全,保留静音的部分,将静音部分视为00,不然提取出来的文件是不全的

  1import numpy as np
  2from scipy.io import wavfile
  3
  4# --- 核心配置 ---
  5FILENAME = '1.wav'
  6OUTPUT_NAME = 'decoded_fixed.png'
  7FRAME_SIZE = 4800 
  8# 频率表:从低位(Bit 0)到高位(Bit 7)
  9TARGET_FREQS = [100, 200, 400, 800, 1600, 3200, 6400, 12800]
 10
 11
 12def recover_file():
 13    print(f"正在读取 {FILENAME} ...")
 14    sample_rate, data = wavfile.read(FILENAME)
 15
 16    # 归一化处理
 17    if len(data.shape) > 1: data = data[:, 0]
 18    data = data.astype(np.float32)
 19    max_val = np.max(np.abs(data))
 20    if max_val > 0:
 21        data /= max_val
 22
 23    total_samples = len(data)
 24    print(f"总样本数: {total_samples}")
 25
 26    # 1. 寻找起始点 (Sync)
 27    # PNG文件的开头是 0x89 (10001001),肯定有声音,所以寻找第一个非静音处作为起点是安全的
 28    start_index = 0
 29    threshold = 0.2  # 信号起始判定的阈值
 30
 31    for i in range(0, total_samples - FRAME_SIZE, 100):  # 粗略扫描
 32        # 检查这一小段的能量
 33        if np.max(np.abs(data[i:i + 1000])) > threshold:
 34            # 找到大致位置后,回退一点确保包含完整帧,这里简单处理直接对齐
 35            # 为了更精准,通常第一个帧的能量会瞬间跳变
 36            start_index = i
 37            print(f"锁定起始采样点: {start_index}")
 38            break
 39
 40    # 2. 强制解码直到文件末尾
 41    raw_bytes = []
 42
 43    # 使用保护间隔,只分析帧中间的波形,避免边缘噪声
 44    guard = 400
 45
 46    print("开始解码,不跳过任何静音帧...")
 47
 48    # 循环条件:只要还有完整的帧就继续读
 49    for i in range(start_index, total_samples - FRAME_SIZE, FRAME_SIZE):
 50        chunk = data[i:i + FRAME_SIZE]
 51
 52        # 取中间段进行FFT分析
 53        analysis_chunk = chunk[guard:-guard]
 54        # 加窗
 55        windowed = analysis_chunk * np.hanning(len(analysis_chunk))
 56
 57        fft_data = np.abs(np.fft.rfft(windowed))
 58        freqs = np.fft.rfftfreq(len(analysis_chunk), 1 / sample_rate)
 59
 60        byte_value = 0
 61        local_max = np.max(fft_data)
 62
 63        # 即使 local_max 很小(静音),也说明这可能是一个 0x00 字节
 64        # 只有当确实有明显频率峰值时才置位
 65
 66        # 这里的判定阈值稍微敏感一点,防止漏掉弱信号
 67        # 如果整帧最大能量都极低,说明是 0x00,直接跳过频率检测循环
 68        if local_max > 0.05:
 69            for bit_idx, f_target in enumerate(TARGET_FREQS):
 70                idx = np.argmin(np.abs(freqs - f_target))
 71                # 检查该频率是否有能量峰值
 72                # 判定标准:该频率幅值 > 当前帧最大幅值的 30%
 73                if fft_data[idx] > local_max * 0.3:
 74                    byte_value |= (1 << bit_idx)
 75
 76        # 【关键修正】:无论 byte_value 是什么(包括0),都加入结果
 77        raw_bytes.append(byte_value)
 78
 79    # 3. 保存与检查
 80    print(f"解码结束,共提取 {len(raw_bytes)} 字节。")
 81
 82    byte_arr = bytearray(raw_bytes)
 83
 84    # 尝试自动修剪头部垃圾数据
 85    png_header = b'\x89PNG'
 86    head_pos = byte_arr.find(png_header)
 87
 88    if head_pos != -1:
 89        print(f"在位置 {head_pos} 发现 PNG 头,自动修剪头部...")
 90        byte_arr = byte_arr[head_pos:]
 91    else:
 92        print("警告:未自动发现 PNG 头,将保存所有提取数据,请手动检查。")
 93        # 如果没找到头,有可能是起始点找晚了,导致错位。
 94        # 这种情况下,不要截断,直接保存全部数据。
 95
 96    with open(OUTPUT_NAME, 'wb') as f:
 97        f.write(byte_arr)
 98
 99    print(f"文件已保存: {OUTPUT_NAME}")
100    print("提示:如果图片下方显示不全,说明音频还没录完就断了,或者解码未到最后。")
101
102
103if __name__ == "__main__":
104    recover_file()

最后可以得到一张png图片,里面是aztec码

在线扫一下就可以得到flag了

image-20260112235316700