1. 前言
TYPO3
是一个以PHP
编写、采用GNU
通用公共许可证的自由、开源的内容管理系统。
2019年7月16日,RIPS
的研究团队公开了Typo3 CMS
的一个关键漏洞详情,CVE
编号为CVE-2019-12747
,它允许后台用户执行任意PHP
代码。
漏洞影响范围:Typo3 8.x-8.7.26 9.x-9.5.7
。
2. 测试环境简述
Nginx/1.15.8 PHP 7.3.1 + xdebug 2.7.2 MySQL 5.7.27 Typo3 9.5.7
3. TCA
在进行分析之前,我们需要了解下Typo3
的TCA(Table Configuration Array)
,在Typo3
的代码中,它表示为$GLOBALS['TCA']
。
在Typo3
中,TCA
算是对于数据库表的定义的扩展,定义了哪些表可以在Typo3
的后端可以被编辑,主要的功能有
-
表示表与表之间的关系
-
定义后端显示的字段和布局
-
验证字段的方式
这次漏洞的两个利用点分别出在了CoreEngine
和FormEngine
这两大结构中,而TCA
就是这两者之间的桥梁,告诉两个核心结构该如何表现表、字段和关系。
TCA
的第一层是表名:
$GLOBALS['TCA']['pages'] = [ ...];$GLOBALS['TCA']['tt_content'] = [ ...];
其中pages
和tt_content
就是数据库中的表。
接下来一层就是一个数组,它定义了如何处理表,
$GLOBALS['TCA']['pages'] = [ 'ctrl' => [ // 通常包含表的属性 .... ], 'interface' => [ // 后端接口属性等 .... ], 'columns' => [ .... ], 'types' => [ .... ], 'palettes' => [ .... ],];
在这次分析过程中,只需要了解这么多,更多详细的资料可以查询官方手册。
4. 漏洞分析
整个漏洞的利用流程并不是特别复杂,主要需要两个步骤,第一步变量覆盖后导致反序列化的输入可控,第二步构造特殊的反序列化字符串来写shell
。第二步这个就是老套路了,找个在魔术方法中能写文件的类就行。这个漏洞好玩的地方在于变量覆盖这一步,而且进入两个组件漏洞点的传入方式也有着些许不同,接下来让我们看一看这个漏洞吧。
4.1 补丁分析
从Typo3官方的通告中我们可以知道漏洞影响了两个组件——Backend & Core API (ext:backend, ext:core)
,在GitHub上我们可以找到修复记录:
很明显,补丁分别禁用了backend
的DatabaseLanguageRows.php
和core
中的DataHandler.php
中的的反序列化操作。
4.2 Backend ext 漏洞点利用过程分析
根据补丁的位置,看下Backend
组件中的漏洞点。
路径:typo3/sysext/backend/Classes/Form/FormDataProvider/DatabaseLanguageRows.php:37
public function addData(array $result){ if (!empty($result['processedTca']['ctrl']['languageField']) && !empty($result['processedTca']['ctrl']['transOrigPointerField']) ) { $languageField = $result['processedTca']['ctrl']['languageField']; $fieldWithUidOfDefaultRecord = $result['processedTca']['ctrl']['transOrigPointerField']; if (isset($result['databaseRow'][$languageField]) && $result['databaseRow'][$languageField] > 0 && isset($result['databaseRow'][$fieldWithUidOfDefaultRecord]) && $result['databaseRow'][$fieldWithUidOfDefaultRecord] > 0 ) { // Default language record of localized record $defaultLanguageRow = $this->getRecordWorkspaceOverlay( $result['tableName'], (int)$result['databaseRow'][$fieldWithUidOfDefaultRecord] ); if (empty($defaultLanguageRow)) { throw new DatabaseDefaultLanguageException( 'Default language record with id ' . (int)$result['databaseRow'][$fieldWithUidOfDefaultRecord] . ' not found in table ' . $result['tableName'] . ' while editing record ' . $result['databaseRow']['uid'], 1438249426 ); } $result['defaultLanguageRow'] = $defaultLanguageRow; // Unserialize the "original diff source" if given if (!empty($result['processedTca']['ctrl']['transOrigDiffSourceField']) && !empty($result['databaseRow'][$result['processedTca']['ctrl']['transOrigDiffSourceField']]) ) { $defaultLanguageKey = $result['tableName'] . ':' . (int)$result['databaseRow']['uid']; $result['defaultLanguageDiffRow'][$defaultLanguageKey] = unserialize($result['databaseRow'][$result['processedTca']['ctrl']['transOrigDiffSourceField']]); } //省略代码 } //省略代码 } //省略代码}
很多类都继承了FormDataProviderInterface
接口,因此静态分析寻找谁调用的DatabaseLanguageRows
的addData
方法根本不现实,但是根据文章中的演示视频,我们可以知道网站中修改page
这个功能中进入了漏洞点。在addData
方法加上断点,然后发出一个正常的修改page
的请求。
当程序断在DatabaseLanguageRows
的addData
方法后,我们就可以得到调用链。
在DatabaseLanguageRows
这个addData
中,只传入了一个$result
数组,而且进行反序列化操作的目标是$result['databaseRow']
中的某个值。看命名有可能是从数据库中获得的值,往前分析一下。
进入OrderedProviderList
的compile
方法。
路径:typo3/sysext/backend/Classes/Form/FormDataGroup/OrderedProviderList.php:43
public function compile(array $result): array{ $orderingService = GeneralUtility::makeInstance(DependencyOrderingService::class); $orderedDataProvider = $orderingService->orderByDependencies($this->providerList, 'before', 'depends'); foreach ($orderedDataProvider as $providerClassName => $providerConfig) { if (isset($providerConfig['disabled']) && $providerConfig['disabled'] === true) { // Skip this data provider if disabled by configuration continue; } /** @var FormDataProviderInterface $provider */ $provider = GeneralUtility::makeInstance($providerClassName); if (!$provider instanceof FormDataProviderInterface) { throw new \UnexpectedValueException( 'Data provider ' . $providerClassName . ' must implement FormDataProviderInterface', 1485299408 ); } $result = $provider->addData($result); } return $result;}
我们可以看到,在foreach
这个循环中,动态实例化$this->providerList
中的类,然后调用它的addData
方法,并将$result
作为方法的参数。
在调用DatabaseLanguageRows
之前,调用了如图所示的类的addData
方法。
经过查询手册以及分析代码,可以知道在DatabaseEditRow
类中,通过调用addData
方法,将数据库表中数据读取出来,存储到了$result['databaseRow']
中。
路径:typo3/sysext/backend/Classes/Form/FormDataProvider/DatabaseEditRow.php:32
public function addData(array $result){ if ($result['command'] !== 'edit' || !empty($result['databaseRow'])) {// 限制功能为`edit` return $result; } $databaseRow = $this->getRecordFromDatabase($result['tableName'], $result['vanillaUid']); // 获取数据库中的记录 if (!array_key_exists('pid', $databaseRow)) { throw new \UnexpectedValueException( 'Parent record does not have a pid field', 1437663061 ); } BackendUtility::fixVersioningPid($result['tableName'], $databaseRow); $result['databaseRow'] = $databaseRow; return $result;}
再后面又调用了DatabaseRecordOverrideValues
类的addData
方法。
路径:typo3/sysext/backend/Classes/Form/FormDataProvider/DatabaseRecordOverrideValues.php:31
public function addData(array $result){ foreach ($result['overrideValues'] as $fieldName => $fieldValue) { if (isset($result['processedTca']['columns'][$fieldName])) { $result['databaseRow'][$fieldName] = $fieldValue; $result['processedTca']['columns'][$fieldName]['config'] = [ 'type' => 'hidden', 'renderType' => 'hidden', ]; } } return $result;}
在这里,将$result['overrideValues']
中的键值对存储到了$result['databaseRow']
中,如果$result['overrideValues']
可控,那么通过这个类,我们就能控制$result['databaseRow']
的值了。
再往前,看看$result
的值是怎么来的。
路径:typo3/sysext/backend/Classes/Form/FormDataCompiler.php:58
public function compile(array $initialData){ $result = $this->initializeResultArray(); //省略代码 foreach ($initialData as $dataKey => $dataValue) { // 省略代码... $result[$dataKey] = $dataValue; } $resultKeysBeforeFormDataGroup = array_keys($result); $result = $this->formDataGroup->compile($result); // 省略代码...}
很明显,通过调用FormDataCompiler
的compile
方法,将$initialData
中的数据存储到了$result
中。
再往前走,来到了EditDocumentController
类中的makeEditForm
方法中。
在这里,$formDataCompilerInput['overrideValues']
获取了$this->overrideVals[$table]
中的数据。
而$this->overrideVals
的值是在方法preInit
中设定的,获取的是通过POST
传入的表单中的键值对。