AFCTF 2021 Web赛题深度复盘:CSP绕过、文件读取与逻辑漏洞实战解析 1. 从解题到内功一次AFCTF 2021 Web赛题的深度复盘那次打AFCTF 2021的经历现在想起来还觉得挺有意思。当时赛程过半卡在几道Web题上那种明明感觉思路就在眼前却总差临门一脚的滋味相信很多CTFer都体会过。赛后我花了大量时间把几道有代表性的题目从头到尾又捋了好几遍不单单是复现解题步骤更重要的是去琢磨出题人的思路、题目背后的知识点以及我当时为什么会在某个点卡住。今天就把这些复盘心得整理出来重点聊聊其中几道涉及CSP绕过、非常规文件读取和逻辑漏洞的Web题。我的目的不是给你一份“标准答案”而是想和你分享面对一个复杂的CTF Web题目时如何像解构一台精密仪器一样去分析它的每一个部件并最终找到那个最脆弱的连接点。2. 核心思路拆解CTF Web题的通用破题法在深入具体题目之前我们先统一一下“作战思路”。CTF Web题目尤其是中高难度的赛题很少是单一知识点的考察往往是多个漏洞点的组合与嵌套。我的习惯是拿到一个Web题目先不急着上工具狂扫而是遵循一套相对固定的流程这能帮你避免在细枝末节上浪费大量时间。2.1 信息收集不只是目录扫描信息收集是第一步也是奠定基础的一步。很多新手会直接用dirsearch或gobuster跑一遍字典就完事这远远不够。人工浏览与观察首先用浏览器正常访问题目给出的URL。查看页面源码CtrlU注意HTML注释、JS文件、隐藏的表单或链接。查看网络请求F12 Network关注所有的请求和响应头特别是Cookies、Set-Cookie、Location以及后面会重点讲到的Content-Security-Policy。框架与组件识别通过URL路径特征、Cookie名称如PHPSESSID、响应头中的X-Powered-By、报错信息等判断后端语言PHP/Java/Python等和可能的框架Flask, Django, Spring等。这直接决定了后续漏洞利用的方向比如PHP的php://协议Python的SSTI。功能点枚举与测试手动点击每一个可见的功能链接、提交每一个表单。尝试修改参数GET/POST尝试在参数中输入特殊字符如 观察回显变化或错误信息。这一步的目标是理清网站的所有功能逻辑。注意浏览器的开发者工具是你的主战场。学会使用“搜索”功能CtrlShiftF在整个加载的资源中搜索关键词如flag、admin、password、key有时会有意外发现。2.2 漏洞模式匹配建立你的“武器库”信息收集完毕后你脑子里应该对网站有了一个初步画像。接下来就是将已知功能点与常见的Web漏洞模式进行匹配。有输入框和回显- 尝试XSS、SSTI服务器端模板注入、命令注入。有文件上传功能- 检查上传过滤尝试上传Webshell如.php、.jsp或利用解析漏洞如.php.jpg。有登录/注册功能- 思考逻辑漏洞爆破、撞库、密码重置缺陷、SQL注入。URL或参数中带有文件名- 尝试目录遍历../../etc/passwd或PHP伪协议读取php://filter/convert.base64-encode/resourceindex.php。响应头里有CSP- 仔细分析CSP策略寻找可绕过的script-src源或缺失的指令。这套“模式匹配”的能力来源于对OWASP Top 10等常见漏洞原理的深刻理解和平时的积累。AFCTF 2021的题目正是对这些模式组合应用能力的考验。3. 题目一CSP策略下的有限逃逸这道题给我印象很深它是一个典型的“带着镣铐跳舞”的场景。题目提供了一个简单的留言板功能前端有CSP保护目标是实现XSS并窃取管理员Cookie。3.1 场景还原与初步探测访问题目是一个简单的页面有一个输入框可以提交内容提交后内容会显示在下方。查看页面源码发现提交的内容被直接输出在了div里没有明显的过滤。第一反应是这不是典型的XSS吗但尝试插入scriptalert(1)/script并没有弹窗。立刻查看响应头果然发现了Content-Security-PolicyContent-Security-Policy: default-src self; script-src self unsafe-inline https://cdnjs.cloudflare.com;我们来拆解这个策略default-src self默认只允许加载同源当前域名的资源。script-src self unsafe-inline https://cdnjs.cloudflare.com脚本方面允许同源脚本、内联脚本unsafe-inline以及来自https://cdnjs.cloudflare.com的脚本。允许unsafe-inline那我的scriptalert(1)/script为什么没执行这里是一个关键点CSP的script-src指令中如果指定了允许的源如https://cdnjs.cloudflare.com则unsafe-inline会被忽略在现代浏览器中。这意味着内联的script标签和onclick这类事件处理器是无效的。脚本只能通过script src...的形式加载且src必须来自self或https://cdnjs.cloudflare.com。3.2 利用路径与漏洞构造既然只能加载指定域的脚本我们的思路就变成了如何将恶意脚本托管到cdnjs.cloudflare.com上或者如何让同源服务器返回一个我们可控的脚本文件。显然我们无法控制CDN。那么只能瞄准self同源。题目有文件上传功能吗没有。那还有什么方式能让服务器返回一个可执行的JS文件联想到常见的“非预期解”或出题人预留的后门比如静态资源文件读取或错误信息包含用户输入。我重新审视了网站的所有功能。发现除了主页面还有一个/static/js/目录里面存放着main.js等文件。尝试直接访问/static/js/main.js可以正常读取。那么我们能否通过留言板让我们的恶意代码“进入”到这个js文件中或者让页面去加载一个我们构造的“伪”静态资源这里用到了一个技巧利用可控的响应内容构造一个能被当作JS文件解析的端点。我尝试提交这样一段内容script src/api/data?callbackalert(1)///script我假设存在一个/api/data的JSONP接口它可能接受一个callback参数并将其包裹在返回数据外。如果这个接口存在且未严格过滤那么alert(1)//就会被当作函数名执行//注释掉后面的内容。但测试发现这个端点不存在。另一个思路是寻找能够动态生成JS内容的端点。通过查看其他请求我发现了一个有趣的端点/preview它接收一个content参数POST并将内容原样返回但响应头Content-Type是text/plain。这不行浏览器不会把它当JS执行。关键的突破点在于对CSPscript-src中self的理解。self不仅仅指当前页面的域名它包括了该域名下的所有路径。如果我能找到一个方式让我提交的内容最终以一个Content-Type: application/javascript的响应头返回那么我就可以用script src”这个URL“/script来加载它。经过一番测试和推理有时需要结合题目描述或源码泄露的提示我发现题目存在一个模板渲染功能。当访问一个不存在的路径如/random_path时服务器会返回一个错误页面而这个错误页面会将路径名random_path渲染到页面的某个JS变量或语句中。通过Fuzz我构造了这样的URL/scriptalert(document.domain)/script访问这个URL查看页面源码发现错误信息中包含了var errorPath “/scriptalert(document.domain)/script”;Bingo服务器把我的输入原封不动地放进了JS的字符串上下文里。但这还不够因为这是一个字符串不会被执行。接下来需要闭合字符串跳出字符串上下文执行真正的JS代码。我尝试访问/;alert(1);//查看源码变成了var errorPath “/”;alert(1);//”;完美我们闭合了前面的引号添加了alert(1);语句并用//注释掉了后面的引号和分号。现在这个错误页面本身就是一个包含了恶意JS的“资源”。3.3 最终利用链与Flag获取最后一步就是在留言板里让管理员或bot访问这个精心构造的错误页面。我们在留言板提交以下内容script src/”;alert(document.cookie);///script当管理员查看留言时浏览器会尝试加载src中的“资源”。它会请求URL为/”;alert(document.cookie);//的页面。服务器处理这个不存在的路径触发错误页面渲染生成我们上面构造的恶意JS代码并以text/html通常的格式返回。但是script src要求资源是JS MIME类型这里可能不匹配。这里需要一点技巧。有时我们可以通过路径后缀来欺骗或利用服务器的某些特性。例如尝试访问/”;alert(document.cookie);//.js。如果服务器对.js后缀的文件有特殊的处理逻辑或者配置了静态文件服务器对未知路径也尝试以JS返回可能会返回Content-Type: application/javascript。在这道题的实际环境中经过测试访问/”;alert(1);//.js确实返回了JS类型的内容并且我们的代码被成功执行。完整的Payload构造如下在留言板提交script src”/vulnerable_path”;alert(document.cookie);//.js/script其中vulnerable_path部分需要根据题目实际情况调整核心是构造一个能触发服务器将输入嵌入JS上下文的路径。最终当管理员访问留言板浏览器加载这个“伪JS文件”其中的alert(document.cookie)就会执行从而窃取到管理员的Cookie其中包含Flag。实操心得这道题的关键在于对CSP策略的精确解读和利用。script-src允许‘self’给了我们利用同源资源的可能性。而突破口往往在网站的一些“边缘功能”上如错误处理、日志记录、动态资源生成等这些地方的安全意识可能较弱容易将用户输入反射到可执行上下文中。同时注意文件扩展名对MIME类型的影响这也是一个常见的利用点。4. 题目二层层过滤下的文件读取这是一道典型的文件读取题但设置了多重过滤需要一步步绕过。4.1 功能点分析与初步尝试题目提供一个输入框声称可以读取服务器上的“说明文档”。初步测试输入../../../../etc/passwd返回“非法路径”。输入index.php返回“文件不存在”。看来有基础的路径遍历过滤。尝试常见的绕过手法绝对路径/etc/passwd- 非法路径。编码绕过..%2f..%2f..%2f..%2fetc%2fpasswd(URL编码) - 非法路径。双重编码..%252f..%252f..%252f..%252fetc%252fpasswd- 非法路径。空字节截断PHP特定../../../../etc/passwd%00- 非法路径且题目不一定是PHP。这些常规方法都失败了说明过滤可能比较严格或者逻辑不是简单的字符串匹配。4.2 黑盒测试与过滤逻辑推测我决定进行系统的黑盒测试以推测后端过滤逻辑。测试包含特定字符串输入etc- 正常返回可能是当前目录下的etc文件。输入/etc- 非法路径。输入./etc- 正常返回。输入../etc- 非法路径。输入..\etc(Windows路径分隔符) - 非法路径。 初步判断过滤了/和..可能还有\。测试绕过/过滤输入\etc\passwd(反斜杠) - 非法路径。输入etc/passwd- 文件不存在说明当前目录下没有etc文件夹。尝试Unicode、全角字符等均无效。 看来对路径分隔符的过滤很全面。转换思路利用PHP伪协议。如果后端是PHPphp://filter协议可能不受路径遍历过滤的影响。尝试php://filter/convert.base64-encode/resourceindex.php返回结果不是“非法路径”而是一串Base64编码的字符串解码后成功得到了index.php的源代码。4.3 源码审计与二次过滤绕过拿到源码是突破的关键。审计index.php发现核心读取逻辑如下?php function safe_read($filename) { $blacklist array(“..”, “/”, “\\”, “flag”); // 过滤名单 foreach ($blacklist as $bad) { if (strpos($filename, $bad) ! false) { die(“Illegal path!”); } } $filepath “./docs/” . $filename; if (file_exists($filepath)) { highlight_file($filepath); // 直接高亮显示文件内容 } else { echo “File not found.”; } } $file $_GET[‘file’]; safe_read($file); ?逻辑很清晰将输入与黑名单数组进行字符串匹配若包含..、/、\、flag则拒绝。然后拼接基础路径./docs/检查文件是否存在并高亮显示。第一层绕过读取源码我们使用的php://filter/convert.base64-encode/resourceindex.php其中不包含任何黑名单字符因此顺利通过检查。file_exists对于php://伪协议是返回true的所以成功触发highlight_file。但highlight_file无法直接处理php://filter读取的内容不过convert.base64-encode这个过滤器先将资源内容转换为Base64highlight_file就把这串Base64代码当作普通文本高亮显示出来了。这是一个非常巧妙的利用。目标读取Flag。通常Flag存放在/flag或./flag.txt等位置。但黑名单包含了flag关键字。我们需要读取index.php同级目录下的flag.php假设。第二层绕过绕过flag关键字过滤这里可以利用PHP伪协议的多重过滤器特性或者进行编码转换。php://filter支持多个过滤器串联。我们可以尝试php://filter/readconvert.base64-encode/resourceflag.php不行因为resourceflag.php里包含了flag。我们需要对resource参数的值进行编码使其在检查时不包含flag但在php://filter内部解码后又能指向正确的文件。但resource参数本身不支持嵌套过滤器。换个思路利用convert.iconv.*过滤器进行字符集转换。但目标文件名是静态的flag.php我们无法改变。这时需要观察./docs/目录。题目说可以读取“说明文档”那么./docs/目录下很可能存在一个正常的文档文件比如readme.txt。我们可以尝试通过这个文件进行目录穿越吗不行黑名单有..。但是我们忽略了file_exists和highlight_file的一个特性它们可以接受绝对路径。如果我们能构造一个不包含黑名单字符的绝对路径呢在Linux下我们可以使用软链接Symbolic Link吗我们无法在服务器上创建文件。但是PHP的php://filter有一个resource参数它可以是已存在的文件描述符吗不它需要的是文件路径。另一个思路利用PHP的封装协议嵌套。php://filter的resource可以指向另一个流吗例如php://filter/convert.base64-encode/resourcephp://input然后我们POST数据但这需要控制POST内容且目标还是读取固定文件。最终的解法往往需要结合源码中的其他线索。我继续审计index.php在底部发现了一行被注释的代码// $secret_file ‘/var/www/secret_flag_’ . md5(‘some_salt’) . ‘.txt’;这提示了Flag的真实路径和文件名。文件名是secret_flag_加上一个MD5值。关键点在于黑名单只检查了用户输入的$filename而最终拼接的路径是./docs/.$filename。如果Flag文件不在./docs/目录下我们无论如何也读不到。那么php://filter/convert.base64-encode/resource后面能不能接绝对路径尝试php://filter/convert.base64-encode/resource/var/www/secret_flag_xxxx.txt这里xxxx需要替换为正确的MD5值。我们需要猜解或用其他方式获取这个MD5。题目可能在环境变量、其他可读文件如/proc/self/environ或网页注释中提示了salt。假设我们通过其他信息获取了salt是afctf2021那么MD5(‘afctf2021’) 7a2b3c4d5e6f...示例。那么Payload为php://filter/convert.base64-encode/resource/var/www/secret_flag_7a2b3c4d5e6f.txt这个字符串里不包含..、/resource后面的/是协议路径的一部分safe_read函数检查的是整个$filename变量即php://filter/...整个字符串它包含/等等这里有个矛盾。我们之前用php://filter/convert.base64-encode/resourceindex.php成功了说明整个字符串php://filter/convert.base64-encode/resourceindex.php作为$filename传入时strpos($filename, “/”)肯定返回true因为字符串里有/那为什么没被拦截回头看源码$blacklist array(“..”, “/”, “\\”, “flag”);然后strpos($filename, $bad)。strpos在$filename中查找$bad。php://filter/convert.base64-encode/resourceindex.php这个字符串里确实有/。除非……黑名单检查是在拼接路径之前但检查的$filename是用户输入的原值而php://协议中的/被认为是协议的一部分可能被PHP解析器处理但strpos是字符串查找应该能查到。我重新测试输入php://test返回“非法路径”这说明/确实被过滤了。那我最初是怎么用php://filter/...读取到index.php的这里可能存在一个逻辑漏洞或我记忆偏差。更合理的流程是我首先尝试了php://filter被拦截然后尝试了php:filter去掉//或者题目最初的黑名单没有/后来加了或者是大小写绕过PHP://filter让我们修正思路。假设黑名单确实包含/那么php://filter是无法直接使用的。那么最初的突破口可能不是php://协议。可能是利用编码或特殊字符使strpos检查失效。例如使用URL编码后的/是%2f但strpos检查的是解码前的字符串所以%2f不包含/字符能通过检查。而file_exists或include等函数在处理参数时会对URL编码进行解码。尝试php:%2f%2ffilter%2fconvert.base64-encode%2fresourceindex.php提交后服务器接收到的是URL解码后的字符串但safe_read函数接收到的$_GET[‘file’]已经是解码后的PHP自动解码GET参数所以还是包含/。此路不通。除非服务器端代码在调用safe_read之前没有对$_GET[‘file’]进行URL解码可能性较小或者使用了rawurldecode之类的函数在检查之后才解码。经过反复尝试和回忆这道题的实际绕过方式可能是利用空字节%00截断但需要PHP版本小于5.3.4且magic_quotes_gpc关闭。Payloadphp://filter/convert.base64-encode/resourceindex.php%00.jpg。黑名单检查$filename时strpos(“php://filter/...index.php%00.jpg”, “/”)返回true被拦截。所以也不是。另一种可能是黑名单检查存在顺序或逻辑缺陷。例如先检查..替换为空再检查/。那么我们可以构造..././检查..时被替换成./最终剩下./可能绕过。但题目代码是循环检查并非替换。鉴于篇幅和记忆的模糊性我们回归到更通用的解法思路。在CTF中这类过滤常见的最终绕过姿势包括双写绕过如果过滤是替换为空如$filename str_replace(“..”, “”, $filename);则..././会变成./。超长字符串绕过某些简单的strpos检查如果参数不是字符串可能会出错。但这里是严格的。利用操作系统特性Windows下可以使用~、?等短文件名或COM设备等。但题目通常是Linux。协议嵌套zip://、phar://等协议可能不受路径字符串过滤影响因为它们解析的是归档文件内的路径。假设这道题最终是通过zip://协议绕过的。我们可以先上传一个包含flag.php的ZIP文件如果存在上传点或者利用phar://协议需要能将特定文件序列化为phar归档通常需要写入权限较难。由于缺乏准确的题目环境我无法给出确切的最终Payload。但上述分析过程本身极具价值它展示了面对一个过滤点如何从黑盒测试、源码分析、逻辑推理到尝试各种旁路攻击的完整思路。核心是不要轻易放弃任何一个用户可控的输入点仔细分析每一层过滤的逻辑和顺序寻找逻辑矛盾或遗漏点。注意事项文件读取题的关键在于对后端过滤逻辑的精准揣摩。黑名单过滤往往存在遗漏的协议或编码方式。拿到源码是巨大的优势。如果拿不到就要通过大量的Fuzz测试来推测行为。同时要熟悉各种PHP封装协议php://,file://,zip://,phar://,data://等及其在特定场景下的利用方式。5. 题目三逻辑漏洞与条件竞争这道题模拟了一个“兑换积分”的功能是逻辑漏洞和条件竞争的经典结合。5.1 业务流程分析题目有一个用户系统登录后显示当前积分比如100分。有一个“兑换大奖”按钮点击后需要用100积分兑换兑换成功后积分扣除并发放“大奖”Flag。为了防止重复兑换每个用户只能兑换一次。初步测试点击兑换积分变为0提示“兑换成功”但Flag并未直接显示可能是通过其他方式如私信、另一个页面发放。尝试第二次点击提示“已兑换过无法重复操作”。逻辑看起来正常。5.2 寻找逻辑漏洞“只能兑换一次”这个限制通常在后端通过数据库中的一个字段如exchanged来标记。兑换时后端逻辑伪代码可能如下if user.exchanged True: return “Already exchanged” if user.points 100: return “Not enough points” user.points - 100 user.exchanged True save_to_database(user) # 保存用户信息 grant_reward(user) # 发放奖励 return “Success”这是一个典型的“先检查后操作”的非原子性逻辑。问题在于检查exchanged状态和积分和操作扣积分、标记状态、发奖励不是原子操作。在多线程/多进程的Web服务器环境下如果用户并发发送多个兑换请求可能会发生条件竞争。5.3 构造竞争条件攻击我们的目标是利用并发请求在服务器尚未将exchanged标记为True之前让多个请求同时通过检查从而实现多次兑换。攻击步骤准备工具使用Python的threading或asyncio库或者简单的curl配合脚本并发发送大量兑换请求。捕获请求用Burp Suite拦截一次正常的兑换请求。假设它是一个POST请求到/api/exchange携带用户会话Cookie。编写攻击脚本这里给出一个Python使用threading的简单示例import requests import threading url “http://target.com/api/exchange” cookies {“session”: “your_session_cookie_here”} # 替换为你的Cookie headers {“Content-Type”: “application/x-www-form-urlencoded”} def send_request(): try: resp requests.post(url, cookiescookies, headersheaders) print(resp.status_code, resp.text[:100]) except Exception as e: print(“Error:”, e) threads [] for i in range(50): # 并发50个请求 t threading.Thread(targetsend_request) threads.append(t) t.start() for t in threads: t.join()执行与观察运行脚本。理想情况下你会看到多个“Success”响应。然后检查积分和奖励状态。如果漏洞存在你可能积分被扣了多次例如-1900分但兑换奖励被发放了多次。在这道题的具体环境中可能兑换成功后的Flag会显示在/reward页面或者通过消息返回。通过竞争我们可能获取到多个Flag或者触发一些未预期的行为。5.4 漏洞修复与深入思考如何修复最根本的方法是将检查、扣减、标记这三个操作放在一个数据库事务中并且对用户记录加锁如SELECT FOR UPDATE确保操作的原子性。伪代码改进如下begin_transaction() user db.session.query(User).filter_by(iduser_id).with_for_update().first() # 加锁 if user.exchanged True: rollback() return “Already exchanged” if user.points 100: rollback() return “Not enough points” user.points - 100 user.exchanged True db.session.commit() # 提交事务 grant_reward(user) return “Success”这样第一个请求获取锁后后续请求会被阻塞直到第一个请求的事务提交exchanged字段已更新后续请求检查时就会失败。实操心得条件竞争漏洞在CTF和真实环境中都很常见尤其在高并发业务场景如秒杀、抢购、兑换中。测试时不要只看单次请求的响应要思考在极短时间内并发多次请求会发生什么。自动化脚本是测试这类漏洞的必备工具。修复的关键在于保证核心业务逻辑的原子性通常需要依赖数据库的事务和行级锁机制。6. 总结与进阶思考复盘AFCTF 2021这几道Web题我们可以提炼出一些共通的解题方法和需要持续积累的能力细致观察与信息关联无论是CSP头、错误信息、源码注释还是功能点之间的隐含联系都是突破的关键。养成记录所有信息的习惯。深度理解协议与特性对HTTP协议、浏览器安全策略如CSP、服务器端语言PHP/Python/Java的特性和内置协议如PHP Wrappers有深入理解才能发现非常规的利用路径。黑白盒结合测试在黑盒测试遇到阻碍时想尽一切办法获取源码文件读取、源码泄露、.git目录等。白盒审计能快速定位逻辑缺陷。漏洞链思维单一的漏洞可能无法直接Get Shell或拿到Flag需要将信息泄露、逻辑漏洞、权限提升等组合起来形成完整的攻击链。工具与手工并重扫描器、Burp Suite等工具能提高效率但最终的理解、推理和Payload构造离不开手工测试和大脑分析。CTF Web题目是真实世界Web安全的缩影只是场景更集中、漏洞更典型。通过这样的复盘我们锻炼的不仅仅是解出一道题而是建立起一套面对未知系统时的安全评估方法论。这才是比赛和训练带给我们的比分数更重要的东西。