题目来源:https://www.ctfplus.cn/problem-detail/2059190642604511232/description
DTMF
听一下 radio.wav 听出来是拨号音。
旧时代电话系统的声音:DTMF 电话拨号音。
DTMF 是传统电话系统使用的拨号信号,每个按键由两个固定频率的正弦波叠加组成。也就是按一个键 = 一个低频 + 一个高频同时响。

比如按 5 = 770Hz + 1336Hz 两个正弦波叠在一起。
解码 DTMF 有两种方式:
| 方法 |
原理 |
优劣 |
| Goertzel 算法 |
对 8 个目标频率逐个算能量 |
只算需要的频率,O(N),快 |
| FFT |
全频谱扫描 |
算一堆不需要的频率,O(N log N) |
因为 DTMF 只有固定的 8 个频率,Goertzel 更合适。窗口大小一般取 40ms(DTMF 标准规定每个音至少持续 40ms),步长 20ms(50% 重叠防止错过边界)。
当然,不想手写 Goertzel 的话直接用在线工具也行:https://dtmf.netlify.app/

解码结果:
1
| 108117110097114045049055048049
|
一个容易被忽略的坑:音频里连续两个按键(比如说两个 1),中间的间隙只有 60ms,探测器可能把它们合并成一个 420ms 的长音。需要额外逻辑:超过 250ms 的连续监测强制拆分 —— 不然会得到 25 个数字而非 30 个。
30 位刚好被 3 整除,全部落在 ASCII 可打印范围(32-126):
1 2 3 4 5 6
| digits = "108117110097114045049055048049" password = "" for i in range(0, len(digits), 3): decimal = int(digits[i:i+3]) password += chr(decimal) print(password)
|
输出:
SHA-256 密钥派生(KDF)
A 留下的公式,密钥派生函数:
1
| key = sha256("ZeroG::" + radio_password + "::www.pwnstars.online")
|
拆开看:前缀::密码::域名,用 :: 当分隔符。
SHA-256 输出固定 32 字节(256 位),正好当对称密钥用。
相当于简易版的 HKDF —— 正版 HKDF 有 extract + expand 两步带 salt,这里只做了一步哈希。但思路一样。
出题人只给了这一个公式,全文没出现过 “AES” 三个字。因此加密层大概率也只用 SHA-256 构建,不是标准分组密码。
关于 KDF 的更多细节:RFC 5869 (HKDF)
telemetry.dat 文件结构
1 2 3 4 5
| telemetry.dat 结构 (411 bytes): ┌──────────────┬──────────────┬─────────────────────────┐ │ Magic (8B) │ Nonce (8B) │ Ciphertext (395B) │ │ ZGTELv2\n │ ZGRMOON2 │ [SHA256-CTR encrypted] │ └──────────────┴──────────────┴─────────────────────────┘
|

前 16 字节是可读 ASCII,后面都是乱码——这是典型结构:明文头(16 字节)+ 加密体。
0A 是 \n,往往表示第一段结束,拆成 8+8 的形式。
加密算法:xorstream-sha256-ctr
README 里 A 的公式:
1
| key = sha256("ZeroG::lunar-1701::www.pwnstars.online")
|
此处应该为 SHA256(密钥 + 某个变化的值)= 32 字节随机数 → XOR 数据 = 加密。也就是用哈希函数当随机数生成器。
接下来尝试各种组合:
1 2 3 4 5 6
| keystream = SHA256(key + nonce + counter_0) + SHA256(key + nonce + counter_1) + SHA256(key + nonce + counter_2) + ...
plaintext = ciphertext XOR keystream
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| SHA256(key + 文件头最后8字节 + counter) SHA256(key + 文件头全部16字节 + counter) SHA256(key + counter)
SHA256(key + nonce + counter) SHA256(key + counter + nonce)
counter.to_bytes(4, 'big') counter.to_bytes(4, 'little') counter.to_bytes(8, 'big')
|
排列组合跑一遍,哪一个出可读英文就是哪一个。最终命中:
1
| SHA256(key + ZGRMOON2 + counter_4字节大端)
|
解密后的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ZeroG Moonlight Telemetry Frame
Lab : Pwnstars Crew : N1 / A / Hugo / Gnaw / Fen Home : www.pwnstars.online Channel : Moonlight Radio Cipher : xorstream-sha256-ctr KDF : sha256("ZeroG::" + radio_password + "::www.pwnstars.online")
Message: DTMF is not encryption. It is only a way to send symbols through sound.
FINAL=c3ludHtNcmViVF96YmJheXZ0dWdfZW5xdmJfcWd6c30=
|
1
| FINAL=c3ludHtNcmViVF96YmJheXZ0dWdfZW5xdmJfcWd6c30=
|
先 Base64 再 ROT13 即可:
- Base64 解码 →
synt{MrebT_zbbayvtug_enqvb_qgzs}
- ROT13 →
flag{ZeroG_moonlight_radio_dtmf}
完整 Exp
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
| import hashlib, re, base64, struct
digits = "108117110097114045049055048049" radio_password = ''.join(chr(int(digits[i:i+3])) for i in range(0, len(digits), 3)) print(f"[1] radio_password = {radio_password}")
with open("telemetry.dat", "rb") as f: data = f.read()
magic = data[:8] nonce = data[8:16] ciphertext = data[16:]
key_material = f"ZeroG::{radio_password}::www.pwnstars.online" key = hashlib.sha256(key_material.encode()).digest()
stream = b"" counter = 0 while len(stream) < len(ciphertext): counter_bytes = counter.to_bytes(4, "big") stream += hashlib.sha256(key + nonce + counter_bytes).digest() counter += 1
plaintext = bytes(c ^ s for c, s in zip(ciphertext, stream)) text = plaintext.decode() print(f"[4] Decrypted:\n{text}")
match = re.search(r"FINAL=([A-Za-z0-9+/=]+)", text) b64 = base64.b64decode(match.group(1)).decode() flag = __import__('codecs').decode(b64, 'rot13') print(f"[5] Flag: {flag}")
|