The learning of php unserialize

概述

unserialize()的参数可控时, 可以传入一个构造好的序列串控制类内部的变量甚至函数, 如果类内部存在比较危险的函数include() eval()等等, 将会造成严重后果

如何利用

当服务端代码中存在某个类的时候, 可以通过unserialize()创建一个该类的对象, 而我们可控的就是类中的属性, 还可以利用一些技巧去绕过一些魔术方法或者是利用一些魔术方法. 通过控制魔术方法和属性达到我们想要的攻击效果.

一般流程

  1. 寻找参数可控的unserialize()

  2. 寻找可以用于命令执行, 文件包含等等的函数在危险类

  3. 找到一个控制链控制危险类中的危险函数

魔术方法

1
2
3
4
5
6
7
8
9
10
11
__wakeup() //执行unserialize()时,先会调用这个函数
__sleep() //执行serialize()时,先会调用这个函数
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据或者不存在这个键都会调用此方法
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发
__invoke() //当尝试将对象调用为函数时触发

需要注意的是, __toString()这个方法触发的方式比较多:

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

关于绕过的Trick

绕过__wake()

版本:
php 5< 5.6.25
php 7> 7.0.10

利用方法:当序列化的字符串中的属性个数大于类中实际的属性个数时, 就会跳过__wake()函数

绕过正则表达式

  1. 加号绕过, 在反序列化的时候, 数字前面的加号会被当成是正号而正则匹配的时候则不会这样识别

    比如说O:4:"test":1:{s:1:"a";s:3:"abc";}, 有时候题目会通过正则O\:\d来匹配O:4, 这时将序列改
    O:+4:"test":1:{s:1:"a";s:3:"abc";}就能绕过该匹配

变量引用

利用变量引用使得类中两个变量始终相等

php原生类反序列化

SoapClient反序列化利用

概述

php安装php-soap扩展之后, 可以反序列化原生类SoapClient, 来发送http post请求来实现SSRF
通过反序列化SoapClient对象后调用不存在的方法触发__call()方法
这个__call()方法会向指定URL发送HTTP POST请求, 因为User-Agent字段可控, 所以可以通过CRLF来构造请求头实现自定义POST DATA

如果要在自己的环境复现则需要更改php.ini中的extension=soap

利用方法

举个栗子

1
2
3
4
5
6
7
<?php
$target = "http://127.0.0.1/test.php";
$ua = "kirito-zbds";
$a = new SoapClient(NULL,array('location'=>$target,'uri'=>'k1rit0','user_agent'=>$ua));
$b = serialize($a);
$c = unserialize($b);
$c -> tan90_function();

SoapClient对象被逆序列化出来并调用一个不存在的tan90_function()方法时.__call()方法会向location也就是http://127.0.0.1/test.php发送一个HTTP POST请求, 而请求头中会有:

1
2
User-Agent: kirito-zbds
SOAPAction: k1rit0#tan90_function() // uri + 被调用的不存在的函数名

可以看到, 请求头中的这些字段会变成可控的, 但这样只能通过内网访问test.php, 并不能任意POST数据. 要想POST数据, 就要用到CRLF了.

什么是CRLF

CRLF就是\r\n的缩写, HTTP协议中header之间是用一个\r\n来分隔的, 而HTTP headerHTTP body之间是用\r\n\r\n也就是两个\r\n来分割的. 如果请求头中的字段可控, 那么可以手动添加\r\n用以在请求中添加字段甚至是添加HTTP body用以POST data.

比如在上面的例子中, 我们把$ua改为

1
$ua = "kirito-zbds\r\nContent-Type:application-www-form-urlencoded\r\nContent-Length:17\r\n\r\ndata=this_is_data"

那么HTTP POST的请求头中就会出现这么一串

1
2
3
4
5
6
User-Agent: kirito-zbds
Content-Type: application-www-form-urlencoded
Content-Length: 17

data=this_is_data
SOAPAction: k1rit0#tan90_function()

这样就成功伪造出HTTP POST请求并传输参数了

php-session反序列化

又是session, 上次在文件包含也有session, 其实这里的反序列化也要用到那边的知识

概述

关于session.upload_progress

这个php-session反序列化其实跟session.uplaod_progress有关, 详细的可以看文件包含那部分的笔记
这里只简单的说一下, 服务端的php.ini默认配置session.use_strict_mode = 0, 这样用户可以自己设置
Cookies: PHPSESSID = xxxxxx, 服务端将会创建一个/tmp/sess_xxxxxx文件

并且, 如果用户向服务端传送文件, 那么服务端将会在/tmp/sess_xxxxxx中写入上传进度, 最重要的是. 如果用户的
POST DATA中含有PHP_SESSION_UPLOAD_PROGRESS=xxx, 那么服务端将会在/tmp/sess_xxxxxx中写入

1
upload_progress_xxx | 上传进度

关于session.serialize_handler

在执行php代码时可以设置这么两个东西

1
2
ini_set('session.serialize_handler','php_serialize');
ini_set('session.serialize_handler','php');

这里设置的是php处理session的方式,phpphp_serialize. 默认是php
这两个方式的不同就在于, 存取session文件的格式不同.
下面从一个例子来详细的看一下这两个方式的区别

1
2
3
4
5
6
<?php
ini_set('session.serialize_handler', 'php');
// ini_set('session.serialize_handler','php_serialize');
session_start();
$_SESSION['k1rit0'] = $_GET['a'];
var_dump($_SESSION);

php方式的存取的格式是:键名|serialize_string, 当我传?a=a:2:{s:4:"name";s:6:"kirito";s:3:"age";i:14;}
session文件中存的是k1rit0|s:45:"a:2:{s:4:"name";s:6:"kirito";s:3:"age";i:14;}";

而如果用的是php_serialize方式,存取的格式就是serialize_string, session文件中存的便是
a:1:{s:6:"k1rit0";s:45:"a:2:{s:4:"name";s:6:"kirito";s:3:"age";i:14;}";}

利用方法

根据上面的两个session存取方法, 如果我们先以php_serialize储存 再按php的方式读取, 就可以反序列化一些恶意的序列. 就拿上面的例子来说, 先以php_serialize储存?a=|a:2:{s:4:"name";s:6:"kirito";s:3:"age";i:14;},注意那个|

这个时候来看看session里是啥

1
a:1:{s:6:"k1rit0";s:46:"|a:2:{s:4:"name";s:6:"kirito";s:3:"age";i:14;}";}

如果这个时候, 按php的方式读取这个session文件, 就会被识别成两部分:

  1. |前面的a:1:{s:6:"k1rit0";s:46:"
  2. |后面的a:2:{s:4:"name";s:6:"kirito";s:3:"age";i:14;}";}

    |后面的前一部分`a:2:{s:4:"name";s:6:"kirito";s:3:"age";i:14;}, 会被识别成一个完整的序列化串

这里有个session_start()的小知识, 如果php代码中存在session_start()而且处理session的方式是php, 那么在执行
session_start()时会反序列化session中|后的序列化串, 并赋值给$_SESSION变量

如果php代码中某些类中有__wake()__destruct(), 并且这两个魔法函数中存在危险的函数, 则可以通过session反序列化控制这些危险函数.

至于如何在session文件中写入恶意序列化串呢? 那就要用到session.upload_progress中的
PHP_SESSION_UPLOAD_PROGRESS了, 具体怎么用, 去看文件包含那篇笔记吧

反序列化字符逃逸

如果存在对序列化后的字符进行替换, 并且替换后字符长度会变, 则有可能存在字符逃逸

替换后字符变长

1
2
3
4
function change($str)
{
return str_replace("o", "oo", $str);
}

比如说上面这个函数, 把输入的字符串中的o替换成oo
如果有这么个数组array("name" => "kirito","level" => "18");,
序列化之后就是a:2:{s:4:"name";s:6:"kirito";s:5:"level";s:2:"18";}
再用上面的函数处理序列化字符串, 就会变成a:2:{s:4:"name";s:6:"kiritoo";s:5:"level";s:2:"18";}, 这里就会出现一个错误s:6:"kiritoo", 长度应该为6的字符变成7了

逃逸逃逸, 目的就是逃出双引号, 如果我们的数组是

1
2
3
4
array(
"name" => 'kiritooooooooooooooooooooooooo";s:5:"level";s:3:"999";}',
"level" => '18'
)

序列化之后就是
a:2:{s:4:"name";s:55:"kiritooooooooooooooooooooooooo";s:5:"level";s:3:"999";}";s:5:"level";s:2:"18";}

这里name里面本身就有双引号, 但是不能实现逃逸, 是因为反序列化时候, 会读取指定长度的字符后下一个字符是否是"
就比如这里的s:55:"kiritooooooooooooooooooooooooo";s:5:"level";s:3:"999";}",
读取55个字符kiritooooooooooooooooooooooooo";s:5:"level";s:3:"999";}后, 就剩下结尾的", 所以中间的"并不影响反序列化

但如果将序列化之后的字符串进行替换操作, 将会变成
s:55:"kiritoooooooooooooooooooooooooooooooooooooooooooooooooo";s:5:"level";s:3:"999";}, 现在再来看看读取55个字符后是啥, 我们发现kiritoooooooooooooooooooooooooooooooooooooooooooooooooo刚好就是55个字符, 而后面刚好就是一个", 这意味着在反序列化的时候, 后面的;s:5:"level";s:3:"999";}则会逃逸出去, 被识别成序列化字符串的另一部分.(具体原因就是因为多出的o的长度刚好就是";s:5:"level";s:3:"999";})的长度

最后, 我们来看看总的字符串处理完是怎样的

1
a:2:{s:4:"name";s:55:"kiritoooooooooooooooooooooooooooooooooooooooooooooooooo";s:5:"level";s:3:"999";}";s:5:"level";s:2:"18";}

反序列化有个特点, 当判断反序列化结束后, 后面的字符将会自动忽略, 那么这里反序列化就会只处理
a:2:{s:4:"name";s:55:"kiritoooooooooooooooooooooooooooooooooooooooooooooooooo";s:5:"level";s:3:"999";}
后面的";s:5:"level";s:2:"18";}会被自动忽略, 这样我们就成功通过控制name, 逃逸并修改了level

将处理后的字符串反序列化的结果:

1
2
3
array(2) { 
["name"]=> string(55) "kiritoooooooooooooooooooooooooooooooooooooooooooooooooo" ["level"]=> string(3) "999"
}

参考文章

y4爷爷的反序列化总结: https://blog.csdn.net/solitudi/article/details/113588692?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522161638163316780357284910%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=161638163316780357284910&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_v1~rank_blog_v1-14-113588692.pc_v1_rank_blog_v1&utm_term=show

CRLF总结: https://wooyun.js.org/drops/CRLF%20Injection%E6%BC%8F%E6%B4%9E%E7%9A%84%E5%88%A9%E7%94%A8%E4%B8%8E%E5%AE%9E%E4%BE%8B%E5%88%86%E6%9E%90.html