2026阿里CTF安全挑战赛 - 热身赛 - WriteUp

碎碎念

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

正文

知识点省流

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

WP

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

image-20260112234009483

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

image-20260112234211593

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

image-20260112234448077

先让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个,并且音频进行了分段划分,其实简单猜测可以联想到八位二进制,通过不同的频率表达不同的位

image-20260112234752611

(问一下ai也能出)

image-20260112235010924

紧接着写脚本去转换字节流即可,这里可以调教一下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了

image-20260112235316700

qwq