
0. 引子一份WriteUp为什么值得单独复盘省级大学生网络安全竞赛决赛的WriteUp本质上不是“标准答案合集”而是一份带时间戳的作战记录。它记录的是当时赛场上看到某个附件时的第一反应、判断失误的地方、以及在倒计时压力下做出的取舍。我打过多届类似的比赛也帮队伍做过赛后复盘越来越觉得一份好的决赛WriteUp的价值远远超过“这题用XX工具能过”这种程度——它真正能带走的东西是解题路径的优先级排序也就是面对一道陌生题目时你先看什么、后看什么、什么时候该果断放弃。这篇内容面向的是准备参加省级大学生网络安全竞赛决赛的同学也包括刚入门CTF、初赛勉强出线但决赛被按在地上摩擦的队伍。我会从一个参赛者的完整视角把决赛常见题型的结构、每类题的核心突破口、赛场上真实会踩的坑以及赛后怎么复盘训练全部拆开讲清楚。所有题目细节都做了脱敏和泛化处理但技术思路是真实的你可以直接对照自己的训练环境复现。先说结论性的框架一场三小时的决赛真正决定排名的往往不是“最难的题有没有做出来”而是简单题有没有全部吃干净、中等题有没有在预算时间内拿下。我见过太多队伍在一道Pwn上耗掉90分钟最后连Misc里的送分题都没打开。WriteUp要写的就是怎么把这种损失降到最低。1. 决赛题型结构与整体作战思路拆解1.1 从初赛到决赛难度跃迁到底体现在哪省级赛事的初赛通常是线上解题赛题型面铺得广但单题难度比较温和很多题用成熟工具一把梭就能出结果考察的是知识面的宽度。决赛就不一样了题目数量会缩减但每道题的“层数”变多——初赛一道Web就是单纯的文件包含决赛可能要求你先绕过一层WAF规则、再找到一个隐藏的备份文件、最后才拿到可以反序列化的入口。难度不是因为多了什么黑科技而是链路被拉长了。这个变化对作战方式的影响非常大。初赛可以“看到题目就上工具”决赛必须“先把题目读两遍再动手”。我在赛场上养成的习惯是拿到附件先花两分钟做三件事看文件类型、看题目描述里的暗示词、看分值和时间消耗的性价比。分值低但描述很短的题通常是突破口分值高、附件巨大的题往往是压轴或者陷阱。另一个明显变化是环境限制。决赛常常不给外网只给内网环境工具要么提前准备好要么现场自己写。这就意味着赛前必须把常用的脚本模板全部本地化不能依赖临时查阅。我见过有队伍现场想不起某个编码的解法又没有网最后整道题卡死。提示赛前一周就要做“断网演练”把外网关掉只用本地工具链完整做三套不同题型的模拟卷。能暴露很多平时察觉不到的工具依赖问题。1.2 队伍分工与三小时的时间切片三人组队是主流配置但分工不能是“你Web、我Pwn、他Misc”这么死板。我推荐的模式是主攻手负责自己最熟的那一类副手负责信息收集和环境搭建第三个人做交叉验证和节奏把控。原因很现实——决赛的题目经常会跨方向一道看似Misc的题里藏着需要写脚本的密码学内容死守分工反而低效。时间上我会切成四段时间段动作目标0–10分钟全队过一遍所有题目读描述、下附件、看文件类型建立题目地图标记“一眼会”“可能有思路”“完全没头绪”10–70分钟集中攻击标记为“一眼会”的题各打各的把简单分全部吃干净稳住心态70–140分钟两人合攻一道中等题一人继续保持扫描抢中等分同时不放弃发现新思路最后20分钟停止新开坑回头检查已有flag格式、提交状态避免低级失误这个切片的核心理由是沉没成本控制。人在卡题的时候会陷入“再给我五分钟”的心理陷阱而五分钟往往变成五十分钟。设定明确的止损点到点不管有没有进展都换人换题这是我个人觉得最有效的一条赛场纪律。1.3 赛前环境清单别让工具问题吃掉你的分数决赛前把环境整明白价值不亚于多刷二十道题。我的本地目录结构大致是这样组织的ctf/ ├── bin/ # 编译好的静态工具避免依赖问题 ├── scripts/ # 自己写的模板脚本按方向分目录 ├── wordlists/ # 字典按场景分类别只准备一份 ├── docker/ # 常用漏洞环境的本地复现镜像 └── notes/ # 历史题目的题解和踩坑记录关键点是工具要静态编译或者准备好依赖尤其是网络分析、二进制调试这类工具现场装依赖是很痛苦的事。另外字典不要只准备一份大而全的我一般会准备三份一份通用的中小字典用于快速扫描、一份针对目录爆破的、一份专门针对参数的。原因是一份巨型字典扫一遍动辄十几分钟在决赛这种时间颗粒度下基本不可接受。还有一条容易被忽略的截图和记录工具。WriteUp是赛后写但赛场上如果不记录很多东西回头就想不起来了。我习惯在每道题的关键节点截一张图写上当时为什么这么做。这个习惯让我的复盘效率提升了不止一倍。2. Web方向从信息收集到拿下flag的完整链路2.1 目录与指纹先画地图再决定打哪Web题最常见的新手错误是上来就拿着payload怼输入框。我自己的固定动作是先做三件事看响应头、看错误页面特征、跑一遍小字典的目录扫描。看响应头能得到的信息量被严重低估。服务器类型、框架版本、是否有反向代理、Cookie里的session命名风格这些拼起来能大概判断出技术栈。比如session名带特定前缀往往意味着某种PHP框架报错页面里有特定模板痕迹基本能确定后端语言。技术栈确定之后你要找的漏洞类型范围会立刻收窄一大半。目录扫描的关键不是“扫得多”而是“扫得准”。我一般会带这几类路径备份文件各种后缀的归档文件、编辑器生成的临时文件、配置文件、版本管理目录残留、接口文档路径。行业里有个常见的实践是准备一份针对“开发者偷懒”的字典因为出题人往往也是开发者他们留后门的方式和真实场景高度相似。# 目录扫描思路示例比赛靶场环境内使用 # -t 线程数不要开太高决赛环境带宽有限开太高反而容易超时 # -x 指定扩展名优先扫动态脚本后缀 工具参数示意: -w small.txt -t 10 -x php,zip,bak,txt --timeout 3注意扫描并发一定控制住。决赛环境经常是共享资源你把线程开到几百可能直接把服务打挂不仅自己没法做还影响别的队伍。这种事我见过不止一次。指纹识别的另一个技巧是主动制造报错。给参数传一个数组、传一个畸形字符、传一个超长字符串观察不同框架的报错文本。这一步在很多题里能直接暴露后端框架和版本从而锁定已知的利用面。2.2 注入类题目判断比利用更重要注入题的破局点永远在“判断”环节。我见过太多人一上来就贴一长串payload结果连注入点类型都没确认。正确的顺序是先判断回显类型有回显、报错回显、布尔盲、时间盲再判断过滤规则哪些关键字被拦、哪些符号被拦最后才决定利用方式。判断回显类型的方法很朴素——构造一个恒真条件和一个恒假条件看页面差异。如果两者都有明显差异说明有布尔回显可以走布尔盲注如果恒假条件下页面出现了数据库报错那报错注入是成本最低的路如果什么差异都没有但响应时间有明显变化时间盲注兜底。布尔盲注的脚本模板我一般会提前写好赛场上只改URL和判断条件import requests URL http://target.local/login.php OK_MARK 欢迎 # 命中时页面出现的特征字符串 CHARSET 0123456789abcdefghijklmnopqrstuvwxyz_-{} def hit(cond: str) - bool: payload fadmin and ({cond}) and 11 r requests.post(URL, data{username: payload, password: x}, timeout5) return OK_MARK in r.text def dump(expr: str, maxlen: int 40) - str: out for i in range(1, maxlen 1): lo, hi 0, len(CHARSET) - 1 while lo hi: # 二分把请求量压到 log 级别 mid (lo hi) // 2 cond fascii(substr(({expr}),{i},1)){ord(CHARSET[mid])} if hit(cond): lo mid 1 else: hi mid if not hit(fascii(substr(({expr}),{i},1)){ord(CHARSET[lo])}): break out CHARSET[lo] return out print(dump(select group_concat(table_name) from information_schema.tables))这段代码里最关键的不是语法而是二分查找。线性遍历字符集在本地测试看不出差别但在决赛环境里响应延迟可能有两三百毫秒一字一字试会直接把时间预算烧光。二分把每个字符的请求数从三十多次降到五到六次这是实打实的效率差。模板注入类题目的判断逻辑类似但更简单。我给参数传一个纯数学表达式看页面是否算出结果。如果算出来了说明模板引擎在求值如果不算把常见的几种引擎语法各试一遍。真正麻烦的是沙箱绕过这时候要先确认被禁用的关键字列表再从对象继承链里找没有被过滤的属性。这个过程很依赖对目标语言对象模型的熟悉度没有捷径。2.3 上传与反序列化最容易翻车的地方文件上传这类的核心不在“上传成功”而在“能不能执行”。常见的坑是文件确实传上去了但目录不可执行或者后缀被过滤但你绕过了结果文件被重命名或者MIME检查过了但内容被二次处理。我的排查顺序是先确认上传后的真实路径和文件名很多题会把文件名哈希化再确认该路径是否可以访问最后才判断执行。如果路径不可执行思路要转向“能不能读”。比如利用配置文件的解析规则让上传的内容被当作配置解析或者利用包含点把上传的文件当代码引用。这类思路的数量是有限的赛前整理一份清单赛场上挨个对比临场想快得多。反序列化题的突破口是找入口和找出口。入口指的是用户可控的输入最终被反序列化的地方出口指的是魔术方法里能造成实际危害的调用。中间的链条需要把类里的每一个方法都过一遍看谁调用了谁。我的习惯是在纸上画调用关系而不是在脑子里记因为超过三个节点之后人脑很容易漏。有个细节值得单独说很多人在构造链的时候执着于“最短链”但赛场上更实际的标准是最稳链。一条长一点但你完全理解的链比一条短但你半懂的链靠谱得多因为后者在调试阶段会浪费大量时间。实操心得反序列化调试时先构造一个只触发入口不触发危险操作的对象确认序列化和反序列化过程本身是通的再逐步往里加节点。一次性写完整条链出错时你根本不知道断在哪。3. Pwn与Reverse二进制题的调试节奏3.1 Pwn保护检查是所有判断的起点拿到二进制文件第一件事永远是看保护机制因为这直接决定了你能用哪种利用方式。我一般看四项栈保护、栈不可执行、地址随机化、以及重定位相关的只读保护。这四项和利用方式的对应关系我整理成一张速查表赛场上基本靠它做决策保护状态可用思路判断依据无栈保护、栈不可执行直接返回到已有函数或系统调用有现成的后门函数就直接跳无栈保护、栈可执行注入一段自定义指令序列需要确认栈地址是否可预测有栈保护需要先泄露一个有效值通常靠格式化字符串或溢出前残留有地址随机化需要先泄露一个运行时地址泄露后计算基址偏移再二次利用有只读重定位需要额外的跳板指令找可用的指令片段拼装偏移量的计算不要靠数。用带循环特征的字符串输入程序崩溃时看寄存器里的值直接就能算出从缓冲区到返回地址的距离。这一步我用脚本记下来过很多次从来没数错过。# 偏移量计算与基础利用框架本地靶场复现用 from pwn import * # 1) 先用循环字符串定位崩溃偏移 # cyclic 200 生成特征串崩溃后看寄存器值 # cyclic -l 0x6161616c 直接反查偏移 # 2) 拿到偏移后组装 offset 0x48 payload bA * offset payload p64(pop_rdi) # 参数寄存器 payload p64(binsh_addr) # /bin/sh 字符串地址 payload p64(ret_gadget) # 栈对齐很多人漏这步导致崩溃 payload p64(system_addr)那个栈对齐的跳板指令是我早期踩过最大的坑。明明泄露地址对了、偏移也对了就是一到最后一步就崩查了两个小时才发现是对齐问题。后来我养成习惯只要调用涉及移动指令的系统函数先无条件加一个单指令跳板不管有没有用稳了再说。格式化字符串类的题目核心是“泄露”和“写入”两件事。泄露相对简单把参数位置试出来用位置参数直接打印栈上的内容。写入的难点在目标地址可能含有零字节需要放在payload末尾同时对齐长度。我一般会先算清楚目标地址、要写的值、以及每次写入的字节数写个小脚本生成payload而不是手搓十六进制。注意调试时把本地和远程的环境差异考虑进去。本地跑通不代表远程跑通最常见的原因是系统库版本不同导致某些函数偏移不一致。所以只要能连远程我优先在远程环境验证泄露本地只用来定位逻辑。3.2 Reverse静态和动态必须交替着来逆向题的分水岭在于“什么时候该读汇编什么时候该跑程序”。我的原则是先静态把结构摸清楚找到主逻辑和输入校验的位置然后动态跟一遍看输入在处理过程中的变化。单走一条路要么效率低要么容易漏。静态阶段我关心的是函数调用关系和控制流图。如果能看到一棵清晰的树从入口往下找到校验函数通常几分钟就能定位。如果控制流图乱成一团很可能有混淆或者大量内联这时候别硬啃直接上动态调试在关键系统调用上下断点或者用输出反馈打点。算法还原是常见题型识别特征比推导更重要。循环里出现移位加异或的典型组合往往是常见的哈希轮函数出现固定的常量数组可能是自定义的替换表出现大量乘法但结构简单可能是多项式的运算。认出这些特征之后把参数抄出来用脚本重新实现一遍就行不需要真的逐条读懂汇编。# 算法还原后的验证脚本骨架 def check(flag: str) - bool: out [] for i, ch in enumerate(flag): v ord(ch) ^ KEY[i % len(KEY)] v ((v 3) | (v 5)) 0xFF # 循环移位 v (v TABLE[i 0xF]) 0xFF out.append(v) return bytes(out) TARGET # 反向求解比正向爆破快得多关键在于从正向逻辑反推出逆向求解路径。如果每一步都是可逆的异或、加减、移位就可以从目标值倒推回去一步到位。只有当某一步不可逆比如查表压缩、取模、哈希时才考虑爆破或者查表预计算。这个判断能帮你省掉大量无谓的计算。3.3 二进制方向的通用纪律有两个纪律性问题值得强调。第一是别在没有验证的情况下连续修改payload。我见过太多情况是改一处、崩、再改一处、还是崩最后连哪次是对的都不知道了。正确做法是每次只改一个变量改完立刻验证记录结果。第二是记录所有泄露的地址。赛场上经常出现这种情况前面泄露成功了但当时没记下来后面需要的时候程序已经跑飞了只能重来。我习惯在脚本里第一行就把所有关键地址打印出来方便随时回看。还有一个经验如果一道Pwn题在十五分钟内没有明确进展连偏移都没定位到或者连漏洞点都没找到基本可以判定这题不适合当前时间预算。决赛的Pwn题经常需要构造较长的链不是二十分钟能搞定的。及时止损去拿别的分比死磕更划算。4. Crypto与Misc最容易被低估的得分区4.1 Crypto先分类再动手密码学类题目的特点是“会的人一分钟出不会的人一小时出不了”。所以第一步永远是分类识别而不是上来就推公式。我自己的分类顺序是先看给了什么公钥、密文、多个密文、代码、还是只有一段字符串再看参数之间有没有明显关系模数相同、指数很小、模数可以分解最后看题目描述的暗示词。几种高频场景的识别特征和思路场景特征判断依据处理方向指数极小密文远小于模数密文长度接近指数次方直接开方同一明文、多个模数模数不同但密文对应同一条消息中国剩余定理同一模数、两组密文模数相同、公钥指数互素组合两个等式直接解模数过小或可分解位数明显不够或因子很小分解后求私钥给了程序源码校验逻辑可见逆推或者利用逻辑缺陷第一类场景最典型加密指数取了一个很小的值而且明文本身不长导致密文的数值没有超过模数。这种情况下取整次方根就能还原不需要任何复杂运算。from gmpy2 import iroot def small_e_root(c, e): m, exact iroot(c, e) # 直接开 e 次方 if not exact: raise ValueError(不是精确开方说明密文被模运算折回了) return int(m)判断是否“精确开方”很关键。如果开方开不干净说明密文确实被模数折回过这条路走不通要换思路。这个判断能让你在三十秒内确认方向对不对。第二类场景的核心是“同一条消息被不同模数加密”这在出题里非常常见因为构造简单。三个密文两两组合用中国剩余定理合并得到一个没有取模的整数值再开方即可。from sympy.ntheory.modular import crt def broadcast(cs, ns, e): m, _ crt(ns, cs) # 合并出 m^e root, exact gmpy2.iroot(int(m), e) return int(root) if exact else None第三类场景稍微绕一点但本质还是消元。两个等式里同一个明文量级相同直接做运算把明文消掉剩下的式子只含未知的模因子解出来再回代。这类题的关键是不要怕式子里有未知数先把能消的都消掉。古典密码和编码类题目通常不难但容易被“想复杂”。我的原则是先跑一遍常见编码的自动识别如果提示了多重编码就按链式顺序一层层解每解一层就观察输出是否符合自然语言特征。符合了就停不符合继续。实操心得密码题最怕的是方向错了还硬推。给自己设一个“十分钟无实质进展就换方向”的规则。我早期在一道题上用错了攻击方式推了四十分钟最后发现题目暗示词明确指向另一种方法回头看那四十分钟纯属浪费。4.2 Misc排查顺序比技巧更重要杂项题是决赛里性价比最高的部分也是最容易因为无序排查而浪费时间的部分。我给自己定了一套固定顺序遇到杂项题就按这个顺序走一遍每一步都很短但能覆盖绝大多数情况。第一步是看文件的真实类型。扩展名经常是骗人的直接看文件头。这一步能排除掉大量“看起来是图片其实是压缩包”的误判。第二步是尝试常规解包。把文件当容器扫一遍看有没有内嵌的其他文件。很多杂项题就是多文件拼接扫出来之后每个子文件单独处理。第三步是针对类型做专项检查。图片看有没有附加数据、有没有最低位异常音频看频谱文本看编码流量看协议和文件传输。第四步是回看题目描述。杂项题的描述里往往藏着关键暗示比如提到“顺序”“层数”“找到起点”这些都是解题路径的提示。我见过有人在技术手段上折腾半小时最后发现描述里已经明说了需要排序。流量分析类的题目我的顺序是先看协议分布找异常协议再看有没有文件传输把文件导出来最后才细看具体交互内容。原因是文件传输往往直接给答案而交互分析耗时最长。# 流量里提取交互内容的最小脚本骨架 import re with open(dump.txt, rb) as f: raw f.read() # 先按可见字符串捞一遍比赛流量通常不会做复杂混淆 chunks re.findall(rb[\x20-\x7e]{6,}, raw) for c in chunks: print(c.decode(errorsignore))这套东西看起来简单但在时间紧张的时候能救命。因为它的执行成本极低一分钟内就能跑完跑完你就知道这题是不是“送分”。4.3 编码与隐写把常见套路背下来隐写和编码的常见套路我建议直接背不要现场推理。因为这些都是体力活现场推只会浪费时间。图片类检查附加在文件末尾的数据、检查像素最低位、检查调色板异常、检查注释字段。音频类看频谱图有没有明显的文字形态、看有没有双声道差异。文档类解压看内部结构、看有没有隐藏的附属文件。编码类先跑自动识别识别不出来再人工按顺序试。常见的嵌套顺序是先解一层基础的字符编码再看是不是压缩过再解一层自定义替换。这个过程我一般会写一个循环脚本把解码操作串起来一次跑完看结果。注意不要迷信自动工具。自动识别工具对“自定义变形”的编码基本无能为力而且会给出误导性的高分匹配。正确的做法是工具给方向、人工做确认。5. 常见问题与排查技巧实录5.1 卡题自救一套能落地的检查流程卡题是所有参赛者都会遇到的情况区别在于有人二十分钟脱身有人卡到结束。我总结的自救流程有四个动作按顺序执行第一个动作是把题目要求重读一遍。很多卡题的原因是理解偏差比如要求提交的是某个中间值而不是完整答案或者输入格式有额外要求。这个动作成本最低但解决问题比例不低。第二个动作是把已有的输出结果重新检查一遍。有没有可能已经拿到了正确结果但因为格式不对被自己忽略了我遇到过好几次flag就在输出里但因为没有标识前缀而没认出来。第三个动作是换工具或换环境重做一次。同一个操作在不同工具下结果可能不同尤其是在编码、解压、协议解析这类环节。这个动作的意义在于排除工具自身的问题。第四个动作是把思路讲给队友听。讲的过程会强迫你把每一步的逻辑说清楚很多时候说到一半自己就发现问题了。这个方法的有效性远超它的朴素外表。提示如果一个方向已经投入超过二十分钟且没有任何正向反馈连中间结果都没拿到直接换题。决赛的题目分布通常是有梯度的放弃一道难题去拿两道中等题性价比高得多。5.2 环境与工具类踩坑速查赛场上的技术问题里有相当一部分跟题目本身无关而是环境和工具造成的。我把高频的整理成一张表现象常见原因处理方式脚本本地正常远程超时并发过高或目标限速降低线程数增加超时时间二进制本地能打通远程崩溃系统库版本不一致泄露优先在远程做本地只验逻辑解压报错但文件明显是压缩包文件头损坏或拼接手动修复头部或按偏移切分编码解码结果乱码字符集判断错误尝试多种字符集优先看字节序列上传成功但访问不到路径被重命名或目录不可访问先确认真实路径再判断可执行性流量里看不到明文协议压缩或分片先重组再解压最后看内容这些问题的共同特点是看起来像题目难其实是环境问题。所以遇到异常现象时先怀疑环境和工具再怀疑自己的思路。这个顺序能帮你省下大量时间。还有一个我踩过多次的坑比赛环境的时间同步。有些题目的验证逻辑依赖时间戳本地机器时间如果和服务器差太多构造出来的请求会被判无效。赛前确认一下时间同步状态是个成本极低但收益明确的动作。6. 赛后复盘与日常训练怎么安排6.1 复盘模板WriteUp 到底该写什么比赛结束后写WriteUp很多人写成“流水账”——先做了什么、再做了什么、最后拿到flag。这种写法对自己的提升很有限因为只记录了“做了什么”没记录“为什么这么想”。我自己的复盘模板有四个字段每道题都填当时的判断依据看到题目后我基于什么信息做出了第一个决定这个决定对不对走错的路哪些尝试是无效的为什么无效有没有更早发现无效的信号关键转折点是哪一步让题目豁然开朗这个转折是可以复用的经验还是纯运气如果重来在时间预算内最优路径应该是什么这四个字段里第二个和第四个价值最高。因为比赛中的错误往往有共性把它们归纳出来下次就能提前规避。我自己的笔记里有一份“高频错误清单”每次赛前翻一遍效果比多刷十道题更明显。6.2 训练节奏按方向轮转别偏科训练安排上我建议按周轮转方向而不是按兴趣一直练某一类。原因是决赛的题目分布是均衡的偏科意味着你要靠某一类的高分去补另一类的零分风险很高。一个可执行的周节奏是三天主攻一个方向做真题、写脚本、整理套路两天做综合模拟卷两天复盘和补基础。主攻方向轮换保证每个方向至少两周轮一次。主攻阶段的关键是不要只做题要做完就整理成可复用的模板。比如做完一道注入题就把判断流程和脚本模板固化下来做完一道二进制题就把偏移计算和利用框架整理好。这些模板在赛场上能直接调用价值极高。模拟卷阶段要严格计时并且模拟断网环境。我见过太多队伍平时做题开着搜索一到断网赛场效率直接腰斩。最后分享一个我个人觉得最有用的习惯把每次比赛的所有失败点写在便利贴上贴在显示器边上。下次比赛前看一眼能避免重复犯错。技术能力提升是渐进的但避免重复犯错的收益是立竿见影的。我个人在多次比赛里最深的体会是决赛的分数差距往往不在“谁会的高级技巧多”而在“谁把简单的事做扎实了”。目录扫全了、偏移算准了、格式核对过了、时间分配合理了这些看起来不起眼的动作加起来就是排名上的巨大差别。把WriteUp写成对决策过程的复盘而不是对操作步骤的记录这份材料的价值才会真正沉淀下来。