周六晚上朋友拉我打了一场CTF比赛,是某CTF训练平台组织的36D杯。比赛名一眼就能看出来是按题量起的:36道题,D级难度。整体做下来的感受是:方向很全,Web、Misc、Crypto、Reverse都有,题目难度呈梯度分布,不单靠某一个技能点就能通吃。这篇文章不算冠军复盘,我更想以一个普通参赛者的视角,把从赛前准备、题目拆解到踩坑排查的全过程写下来,给准备打CTF或者正在找练手机会的朋友做一个参考。无论你是刚能看懂HTML,还是已经在刷靶场的老手,都能在里面找到对应自己阶段的东西。
1. 赛前状态与题目版图:36D杯到底在考什么
1.1 为什么这种中等规模赛事值得认真打
很多朋友觉得只有顶级赛事才值得投入时间,但我个人认为,像36D杯这种一晚上能打完的中型赛事,反而是检验状态的绝佳机会。大型赛事战线长、题目杂、区分度大,新手很容易被劝退;而36D杯题目量适中,覆盖方向广,单人参赛或者组个两三人小队都能打。
这种比赛最大的价值在于“节奏感”。它模拟了真实比赛的时间压力和信息焦虑:题目不是按难度排好队等你拿分的,有些高分题可能藏在很不起眼的地方。你在这样的比赛里练出的时间分配能力、信息收集习惯,到了大型赛事一样能复用。我自己赛前其实没有做什么特殊准备,就是提前把Python环境、抓包工具、编解码工具都验证了一遍,然后把常用的扫描脚本和隐写修复脚本整理到一个目录里。真正开始比赛后,才发现这些“笨功夫”能省下大量试错时间。
1.2 题型分布与得分策略
先理一理36D杯的整体题型。从我看到的题目分布来说,大致是这样的比例:
| 方向 | 占比 | 难度 | 得分策略 |
|---|---|---|---|
| Web | 约三成 | 低到中高 | 优先做完前几题,信息收集别偷懒 |
| Misc | 约三成 | 低到中 | 性价比很高,适合抢分 |
| Crypto | 约两成 | 中 | 有脚本基础再深入,别硬啃 |
| Reverse | 约一成半 | 中高 | 签到题可以试试,复杂题看时间 |
| Pwn | 少量 | 高 | 没有基础就先跳过,不必恋战 |
我的得分策略很简单:先把所有题目扫一遍,把能一眼看到思路的“送分题”做掉,再回头啃中等难度的题目。这个过程很像做一张综合试卷,先易后难永远是效率最高的。一个很容易犯的错误是看到一道Reverse题就扎进去两小时,结果Misc里有两道简单的图片隐写没时间做。比赛不是科研,不是按你投入的时间算分,是按你提交的正确答案算分。
1.3 一套能直接开打的工具栈
这里把我这次用到的高频工具做个汇总,给还没配好环境的朋友参考。不需要一次装太多,真正常用的就是这些:
| 工具 | 用途 | 使用场景 |
|---|---|---|
| 浏览器开发者工具 | 查看源码、Cookie、网络请求 | 所有Web题第一步 |
| 抓包工具(Burp Suite或轻量代理) | 修改请求、重放、看响应 | SQL注入、文件上传、命令注入 |
| CyberChef | 多层编解码、格式转换 | Crypto、Misc通用解码 |
| Python 3 + requests + zlib + gmpy2 | 写脚本爆破或算数学题 | 宽高修复、RSA、编码还原 |
| file / strings / binwalk / exiftool | 文件识别、字符串提取、隐写分离 | Misc、Reverse |
| 7-Zip | 解压嵌套压缩包、查看压缩包信息 | Misc压缩包相关题目 |
这些工具的安装并不复杂,但有一个细节值得注意:Python第三方库一定要提前装好。我见过不少朋友现场pip install gmpy2装了半天,最后发现网络源不稳定。赛前把这些库夯实,比赛时才能把精力放在题目本身。
2. Web方向实战复盘:信息收集与漏洞利用的完整链路
2.1 签到题:先看源码是最稳的“信息收集”
36D杯的Web题第一道通常是签到题,页面可能只有一个按钮,点一下跳转,什么提示都没有。遇到这种页面,我习惯直接打开开发者工具看网页源码,而不是急着上扫描器。原因是CTF题本身就是人为构造的,出题人最喜欢在源码注释、响应头、Cookie或者JS文件里藏线索。
这次我做签到题时,就是在HTML注释里发现了一段Base64字符串,解出来之后得到flag格式的提示,顺着提示找到实际flag。很多入门选手会把题目复杂化,上来就跑目录扫描,其实先打开源码往往几秒钟就能解决。响应头里也经常有东西,比如X-Flag: flag{...}这种直白的字段,或者通过Set-Cookie藏一段字符串。看源码、看响应、看Cookie,这三个动作做完,签到题基本就稳了。
2.2 SQL注入:从报错到联合查询的完整链路
Web题里SQL注入是绝对的大头。36D杯里有一道登录框的题,用户名参数直接拼进了查询语句。判断注入点的时候,我习惯先输入一个单引号,看页面是否报错或者行为变化;再用admin' -- -尝试闭合掉后面的SQL语句。如果页面登录成功或者返回不同结果,说明存在注入点。
接下来就要判断字段数。比较通用的方法是order by后面跟数字,逐个试探当前查询的列数。比如:
admin' order by 3 -- -如果页面正常,继续试4,直到报错,就能确定列数。确定列数之后,用union select做联合查询:
admin' union select 1,2,3 -- -如果页面显示了2和3的回显位置,就可以把对应位置换成database()、version()或者直接读取表名。比如:
admin' union select 1,database(),version() -- -拿到数据库名之后,再通过information_schema.tables查表名,最后查字段、拿数据。整个过程不算复杂,但有一个点特别容易翻车:注释符。在URL里直接传--后面的空格有时候会被吃掉,所以要用-- -或者#、%23。闭合方式也得注意,有时不是单引号,而是双引号、括号甚至双重括号。我开始时只想着',结果前端把参数包在('admin')里,常规思路就直接失效。遇到这种情况,多试几种闭合方式,比盲目堆payload更有效。
另外,现在很多题目会在后端做关键词过滤,比如把union和select替换成空字符串,这时可以考虑大小写混合、内联注释/*!union*/或者双写ununionion来绕过。比赛里的过滤一般都比较浅,绕过思路比实际花哨的手法更重要。
2.3 文件包含:用伪协议读源码
有一道题目很典型,URL里的参数是?file=xxx。这种形式十有八九是文件包含。我一开始直接构造?file=/etc/passwd,页面确实显示了部分系统文件内容,但并没有flag。这时候要意识到:题目没有直接给你flag,大概率是藏在源码里,需要借助伪协议读取。
PHP环境最常用的就是php://filter配合Base64编码读取源码:
?file=php://filter/read=convert.base64-encode/resource=index.php把返回的Base64字符串解码,就能看到index.php的源码。通过源码里的逻辑,再去找flag的存放位置。如果题目运行环境允许执行代码,还可以用data://协议或者php://input来直接执行PHP代码:
?file=data://text/plain,<?php system('cat /flag'); ?>用php://input时,需要在请求体里POST一段PHP代码。这个手法要求服务器开启了allow_url_include,如果没有开启就会失败。遇到过滤时,可以试试大小写混合、URL编码。同样地,别一上来就尝试执行命令,先读源码理解逻辑,往往能少走很多弯路。
2.4 命令注入与过滤绕过
命令注入也是36D杯Web方向的熟面孔。题面一般会给你一个ping功能,输入IP地址然后后端直接把它拼到系统命令里。比如输入127.0.0.1; ls,如果页面上输出了文件列表,就说明注入点存在。
但这次题目里,分号和空格都被过滤了。空格被过滤时,可以用$IFS代替,它相当于shell里的内部字段分隔符。比如:
ip=127.0.0.1$IFS cat /flag或者用%0a换行符来拼接新命令。关键字被过滤时,可以用引号拼接绕过:
c""at /flag cat$@ /flag这里有个重要的判断点:命令注入有没有回显。如果有回显,直接拼接cat命令读文件即可;如果没有回显,就需要考虑盲注时间,用sleep来确认命令是否执行,或者把结果外带到自己可控的服务器。我在比赛里先测了回显,再决定用哪种方式,这样效率最高。千万别在一开始就盲目堆绕过技巧,要先判断过滤规则和输出位置。
3. Misc杂项:隐写分析、流量与压缩包修复
3.1 改宽高还原被破坏的PNG图片
Misc里有一道很经典的图片隐写题:打开图片只能看到上半部分,下面全花,或者直接提示图片损坏。这种情况通常是因为PNG的宽高字段被修改了。我们看到的图片尺寸不是凭空确定的,而是由PNG文件头里的IHDR块保存的。出题人把高度或宽度改小,图片就只能显示一部分,而原本隐藏的内容就在被裁掉的那部分像素里。
判断是不是这种问题,可以先看图片是否能正常打开,再用pngcheck检查PNG块的CRC校验值,如果CRC报错,基本确定是宽高被动了手脚。修复时最直接的办法是写一个脚本,遍历可能的高度值,然后计算对应IHDR块的CRC是否匹配:
import struct import zlib def fix_png_height(filename): with open(filename, 'rb') as f: data = bytearray(f.read()) idx = data.find(b'IHDR') if idx == -1: print('not a valid png') return for h in range(1, 4000): new_data = bytearray(data) # height 字段在 IHDR 数据区的第 8-11 字节 struct.pack_into('>I', new_data, idx + 12, h) crc_calculated = zlib.crc32(new_data[idx + 4:idx + 17]) & 0xffffffff crc_original = struct.unpack('>I', bytes(new_data[idx + 17:idx + 21]))[0] if crc_calculated == crc_original: print(f'found height: {h}') with open('fixed.png', 'wb') as f: f.write(new_data) break fix_png_height('broken.png')脚本思路很简单,就是不断修改高度字段并校验CRC,直到和原图CRC一致。修复后重新打开图片,往往能看到完整的flag。这个思路同样适用于宽度被改的情况,不过实际题目中改高度的更多。
3.2 二维码缺角与LSB隐写
另一道Misc题给了一张二维码图片,但是二维码的三个定位角被白色方块盖住了。二维码的定位角是扫描器用来识别方向和定位的,缺失后扫码工具基本无法识别。我的处理方式是先用图像编辑软件把定位角补成黑色实心方块,再用手机或工具扫。补完后二维码内容能读出来,是一段提示,真正有用的数据在图片的LSB通道。
所谓LSB隐写,就是把信息藏在像素颜色值的最低位。因为最低位对颜色的影响非常小,肉眼几乎看不出来。可以用Stegsolve这类工具逐层查看图片的位平面,也可以直接用脚本提取最低有效位。如果图片是PNG格式,用zsteg检查更快,它能自动检测LSB序列中是否藏了文本或其他文件。遇到这类题目时,我的检查顺序固定为:先看exif元数据,再看图片尺寸是否完整,然后分离隐藏文件,最后才看LSB。顺序反了容易浪费时间。
3.3 损坏压缩包的伪加密
压缩包相关题目几乎场场都有。最常见的一个坑是拿到的ZIP文件双击之后要求输入密码,但题目没有给出任何密码提示。这时候别急着上爆破工具,先判断它是不是“伪加密”。
ZIP格式里有一个通用位标志字段,如果第0位被设置为1,解压软件就会认为文件是加密的。出题人有时只把这个标志改掉,并没有真正给文件加密。用十六进制编辑器打开压缩包,找到对应文件的目录区,把通用位标志的值从09 00改成08 00,或者直接把加密位清零,再解压就能直接看到文件内容。我是用010 Editor打开的,查找ASCII字符串PK的位置,然后检查09 00之类的字节。改完保存再解压,问题立刻解决。判断伪加密的一个依据是:解压时虽然提示要密码,但尝试用任意密码时并不是一直报错,而是偶尔能列出文件名。当然更稳妥的还是看二进制标志位。
如果是真加密,再考虑字典爆破。比赛里的密码一般不会太复杂,常见弱密码如123456、admin、flag等可以优先尝试。
3.4 流量分析:从HTTP流里捞出文件
有一道题给了一个pcapng流量包,题目描述是“黑客从服务器上拖走了一个文件”。这种流量分析题难度不大,但需要耐心。我的处理思路是先按协议过滤,用Wireshark打开后输入http只看HTTP流量。观察请求和响应,重点关注是否有POST上传、文件下载、异常User-Agent或者明显经过Base64编码的字段。
这道题里,目标服务器返回了一个包含data:image/png;base64,字样的HTTP响应。把后面那串Base64字符串提取出来,用CyberChef做Base64解码,就能得到一个PNG图片文件,flag就在图片里。另一种常见情况是上传数据包里有压缩包内容,需要把原始字节提取出来另存为文件。流量分析的核心其实就是“从噪声里找信号”,先从最可疑的请求入手,再溯源对应响应,比自己漫无目的地翻包效率高得多。
4. Crypto与Reverse:编码识别、数学攻击与简单逆向
4.1 一眼识别常见编码套路
Crypto方向的题目并不全是艰深的数学题,很多前几题就是考编码识别。Base64、Hex、URL编码、ROT13、摩斯、凯撒、栅栏、培根,这些看到就应该有反应。
我判断编码的顺序很固定:如果字符串由大小写字母、数字和+/=组成,先试Base64;如果全是0-9a-f,先试Hex;如果出现大量%20这种,先做URL解码;如果是一串.-组合,直接解摩斯;如果是字母组成但完全读不通,考虑凯撒或ROT13;如果字符排列像被“分列”,考虑栅栏密码。CyberChef在处理这种多层编码时有天然优势,它可以拖一串recipe,比如“From Base64 -> From Hex -> ROT13”,一键跑完。这道题实际是Base64之后又做了一次Hex,我一开始只解了Base64得到乱码,后来用CyberChef再叠加一个From Hex才拿到flag。
4.2 栅栏与维吉尼亚的“长度敏感”陷阱
还有一道Crypto题是列置换类型的加密。这类密码的共同特点是“长度敏感”:把明文按一定规则重新排列,分组的数量直接影响解密结果。很多人在试栅栏密码时,只试默认的2栏或3栏,结果解不出来。我的做法是写一个循环,把分组数量从2试到几十,再结合常见词flag或ctf去匹配输出。
维吉尼亚密码需要知道密钥或密钥长度。暴力尝试密钥长度可以用重合指数等方式估计,但比赛里更常见的是直接提示密钥是个英文单词,长度也很短。遇到这种情况,用已知明文片段去反推密钥字母,是一个快速切入的方式。看到题目里有一长串重复出现的片段,基本就是维吉尼亚的周期性暴露了密钥长度。
4.3 RSA共模攻击:同一明文不同指数
到了中等难度的Crypto题,RSA就开始出现了。有一道题给出了两套加密参数,n完全一样,但e1和e2不同,而且都加密了同一段明文。如果只给一套参数,我们很难分解大整数n;但两套指数互质时,存在一个经典攻击路径:共模攻击。
数学原理是:如果gcd(e1, e2) = 1,那么用扩展欧几里得可以找到整数s和t,使得e1*s + e2*t = 1。这样,c1^s * c2^t mod n就等于m^(e1*s + e2*t) mod n,也就是m本身。
比赛现场写的脚本大致是这样:
import gmpy2 n = 某大整数 e1 = 某指数 e2 = 某指数 c1 = 某密文 c2 = 某密文 _, s, t = gmpy2.gcdext(e1, e2) m = (gmpy2.powmod(c1, s, n) * gmpy2.powmod(c2, t, n)) % n flag = bytes.fromhex(hex(m)[2:]) print(flag)这个脚本能跑通的前提,就是s或t为负数时,powmod能够正确处理模逆运算。脚本本身不复杂,关键是看到相同的n、不同的e时,要第一时间想到共模攻击,而不是傻傻去分解n。CTF的比赛题目往往不会考你真正攻破RSA,而是考你是否见过这些经典攻击模型。
4.4 简单Reverse:字符串、脱壳与异或
Reverse方向的签到题没有想象中难。给了一个Linux可执行文件,我先运行它,发现只是打印了一行提示。接着用strings搜索文件里的可见字符串,没有直接看到flag。于是用file识别文件类型,发现它被UPX加壳了。遇到加壳程序,最省事的做法就是用upx -d脱壳,脱壳后再strings就能看到真正的flag串。
另一道题稍微绕了一下,flag被拆开和某个字节做了异或加密。如果题目里有一段明显的加密数据,结合常见异或逻辑,可以写几行Python还原:
data = open('encrypted.bin', 'rb').read() key = 0x2f flag = ''.join(chr(b ^ key) for b in data) print(flag)逆向题的基本思路是:先file识别,再strings找线索,有壳先脱壳,最后才是反汇编。不要一上来就用IDA打开,很多“签到级”的逆向题用字符串搜索就能解决,花大力气分析反而浪费时间。
5. 踩坑记录与排查思路速查
5.1 动态容器起不来怎么处理
比赛过程中最容易让人心态崩溃的,不是题目做不出来,而是环境出了问题。有一道题我点了“开启环境”,页面一直显示初始化中,刷新了几次也没用。后来另一道题能正常打开,说明不是平台整体故障,而是单个容器的问题。处理方式就是回到题目列表重新申请容器,或者彻底刷新页面并清理该域名的Cookie。如果是自己网络环境的问题,可以考虑切换网络后重试。记住,环境问题不要死等,先去做别的题,过几分钟再回来看。
5.2 strings工具搜不到flag
Misc题里我遇到过下载下来的文件用strings怎么都搜不到flag,一度怀疑是自己工具没用对。后来发现这个文件本身是一个带密码的压缩包,flag字符串被压缩算法“打散”了,自然搜不到。正确流程是先用file识别文件类型,然后用binwalk扫描有没有嵌套的其他文件,必要时用7z解压,最后再对新文件执行strings。
file mystery binwalk mystery -e strings mystery_extracted/*这个流程非常重要。CTF里的文件经常套了好几层,可能是图片里面藏了ZIP,ZIP里面又套了图片。不要拿到文件就只跑一个工具,先验证文件结构,再决定下一步操作。
5.3 脚本解码出现乱码
我在处理一道Crypto题时,脚本打印出来的内容全是乱码,看起来像b'\x8b'这种字节。问题其实是数据在传输时经过了编码转换,或者我在Base64解码后没有正确处理bytes类型。遇到这种情况,最稳妥的方式是用bytes接收中间数据,需要输出文本时用decode('utf-8', errors='ignore')。另外,Windows环境下从文件读数据容易带上\r\n,要用strip()清理再解码。否则一个隐藏的换行符就会让整个解出来的内容错位。
5.4 时间统筹:先拿分,再攻坚
比赛到后半段,我最大的体会是时间统筹。一晚上看36道题,如果每道题都平均用力,那结果大概率是中等题做不透、简单题没拿全。我后来习惯用便签记录每道题的状态:一眼有思路的标记为“快速”,需要研究的标记为“攻坚”,没有头绪的标记为“跳过”。每过一段时间就重新扫一遍列表,优先处理能拿分的题。
5.5 常见问题速查表
把这次比赛里遇到过的问题整理成一个速查表,方便下次直接查阅:
| 症状 | 可能原因 | 快速排查路径 |
|---|---|---|
| 图片显示不全 | PNG宽高被修改 | pngcheck检查,写脚本爆破CRC |
| 二维码无法扫描 | 定位角被遮挡 | 补角后重扫,再看LSB |
| 压缩包要求密码 | 伪加密 | 十六进制修改通用位标志 |
| 页面始终无回显 | 命令注入无输出 | 尝试sleep盲注或外带 |
| 解码后出现乱码 | 多层编码或类型没转对 | CyberChef叠加解码,用bytes处理 |
| 环境容器连不上 | 初始化失败 | 重新开启容器、清Cookie、等待 |
| strings找不到flag | 文件被压缩或加壳 | binwalk分离、脱壳后再找 |
6. 赛事复盘与写WriteUp的几点习惯
6.1 分工与沟通
如果是组队参赛,明确分工很重要。一个人同时看Web和Reverse很容易两头空。我们的做法是:一个主攻Web和Misc,一个主攻Crypto和Reverse,另一个人负责收集所有题目信息和共享思路。遇到新发现,马上同步到群里,包括题目URL、已经跑通的命令、当前卡住的点。这样即使某个人暂时离开,其他人也能接着思路继续。CTF比赛里最大的浪费就是两个人同时在做同一道题,互相不知道对方已经在尝试某个方向。
6.2 赛后马上写题解
比赛结束后,最重要的不是庆祝或懊悔,而是马上写题解。如果等几天再补,当时卡住的那个细节可能早就忘了。我的做法是按题目分文件夹,每题一个Markdown文件,记录四件事:解题步骤、用到的命令和脚本、卡住的地方、最终学到的知识点。赛后哪怕只复盘三道题,也比打十场不总结收获更大。写题解也是对自己思维过程的一次梳理,很多当时没想通的地方,在写的过程中会突然想明白。
6.3 积累自己的“武器库”
打CTF越久越会发现,真正有价值的不是某一场比赛的分数,而是沉淀下来的脚本和工具。我电脑上会维护一个ctf-tools目录,里面放了PNG修复脚本、RSA共模攻击脚本、栅栏密码爆破脚本、SQL注入payload清单,以及常用的命令速查。每次比赛遇到新套路,就追加进去。下次比赛时直接查目录,不用从零开始找脚本。这个习惯让我在36D杯后半段连续解出两道Misc题,靠的就是之前总结的伪加密修改流程和PNG宽高爆破脚本。
写到这里,我自己也把周末那场比赛重新过了一遍。最大的感受是,CTF题目再花哨,底层还是信息收集、漏洞原理、编码解码和耐心排查这几样基本功。36D杯这种中型比赛很适合用来打磨这些能力。下一次比赛前,你可以把常用脚本、解码流程、修复工具都提前备好;真正到了赛场上,少踩一个坑就能多解一道题。最后再分享一个小技巧:每次赛后把导致自己卡住的错误命令记录到同一个文档里,下次遇到相同症状时直接查,比临时搜索效率高得多。