CVE-2023-41892分析
本文最后更新于:9 个月前
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 | |
$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为参数。
xxxxxxxxxx
html现在跟进到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 | |
开局一个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 | |
很好!下面就是寻找可利用类的过程了。
怎么找?因为我们并不能控制类对象任意方法的调用,但是我们可以创建实例,也就是说构造器和析构器我们是可以触发的。这里有两种方向:
1.寻找构造函数__construct()中有可利用的危险函数,参数由构造器传入
2.构造函数没法利用,但是析构函数存在可利用危险函数,参数可控,那就通过build()方法中的这一部分来控制参数

全局搜索危险方法:
根据前人的指引,存在的可利用危险方法是call_user_func(),全局搜索(ctrl+shift+f)
在FnStream的析构函数中,这。。。拜托怎么可以这么完美。。。直接就调用了?参数直接可控了?

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

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

狠狠构造poc:
1 | |
另一种思路,不管构造器了,直接走build()方法中给成员赋值的路线
1 | |
预备备!!打!。。
。。。
。。。。?好像并不符合预期。。

偷偷瞄一眼大家普遍用的poc,欸,action=conditions/render是什么?
这里暂时没弄明白。。后来看别的师傅的复现得知是在craft\web\Application的一个点,思路是在windows本地起环境然后打断点调试,把payload直接打然后进行调试,那么第一个正向发现这个点的师傅是怎么做到的呢。。。我们不得而知

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

总之先加上去一起传再说:
1 | |
好好好,好歹有点动静了。

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

到这里利用FnStream还暂时没办法造成危害严重的rce。这里继续从构造函数还有析构函数搜索危险可利用函数。
通过搜索,发现yii\rbac\PhpManager的loadFromFile()方法中有一处明显的文件包含
,通过查找该方法的用途,找到当前类下的load()方法调用了loadFromFile(),包含的文件名是当前类的itemFile,assignmentFile,ruleFile变量。似乎如果只要能控制这些变量的值就能做到文件包含了。再向上看看谁调用了load()方法。

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

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

利用链:
1 | |
payload:(itemFile换成随便那三个被包含的变量都行)
1 | |
或:
1 | |
