做者:mengchen@知道创宇404实验室php
时间:2019年7月31日html
TYPO3是一个以PHP编写、采用GNU通用公共许可证的自由、开源的内容管理系统。git
2019年7月16日,RIPS的研究团队公开了Typo3 CMS的一个关键漏洞详情[1],CVE编号为CVE-2019-12747,它容许后台用户执行任意PHP代码。github
漏洞影响范围:Typo3 8.x-8.7.26 9.x-9.5.7。shell
Nginx/1.15.8
PHP 7.3.1 + xdebug 2.7.2
MySQL 5.7.27
Typo3 9.5.7数据库
在进行分析以前,咱们须要了解下Typo3的TCA(Table Configuration Array),在Typo3的代码中,它表示为$GLOBALS['TCA']。后端
在Typo3中,TCA算是对于数据库表的定义的扩展,定义了哪些表能够在Typo3的后端能够被编辑,主要的功能有数组
表示表与表之间的关系ide
定义后端显示的字段和布局函数
验证字段的方式
此次漏洞的两个利用点分别出在了CoreEngine和FormEngine这两大结构中,而TCA就是这二者之间的桥梁,告诉两个核心结构该如何表现表、字段和关系。
TCA的第一层是表名:
$GLOBALS['TCA']['pages'] = [
...
];
$GLOBALS['TCA']['tt_content'] = [
...
];
其中pages和tt_content就是数据库中的表。
接下来一层就是一个数组,它定义了如何处理表,
$GLOBALS['TCA']['pages'] = [
'ctrl' => [ // 一般包含表的属性
....
],
'interface' => [ // 后端接口属性等
....
],
'columns' => [
....
],
'types' => [
....
],
'palettes' => [
....
],
];
在此次分析过程当中,只须要了解这么多,更多详细的资料能够查询官方手册[2]。
整个漏洞的利用流程并非特别复杂,主要须要两个步骤,第一步变量覆盖后致使反序列化的输入可控,第二步构造特殊的反序列化字符串来写shell。第二步这个就是老套路了,找个在魔术方法中能写文件的类就行。这个漏洞好玩的地方在于变量覆盖这一步,并且进入两个组件漏洞点的传入方式也有着些许不一样,接下来让咱们看一看这个漏洞吧。
4.1 补丁分析
从Typo3官方的通告[3]中咱们能够知道漏洞影响了两个组件——Backend & Core API (ext:backend, ext:core),在GitHub上咱们能够找到修复记录[4]:
很明显,补丁分别禁用了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传入的表单中的键值对。
这样一来,在这个请求过程当中,进行反序列化的字符串咱们就能够控制了。
在表单中提交任意符合数组格式的输入,在后端代码中都会被解析,而后后端根据TCA来进行判断并处理。好比咱们在提交表单中新增一个名为a[b][c][d],值为233的表单项。
在编辑表单的控制器EditDocumentController.php中下一个断点,提交以后。
能够看到咱们传入的键值对在通过getParsedBody方法解析后,变成了嵌套的数组,而且没有任何限制。
咱们只须要在表单中传入overrideVals这一个数组便可。这个数组中的具体的键值对,则须要看进行反序列化时取的$result['databaseRow']中的哪个键值。
if (isset($result['databaseRow'][$languageField]) && $result['databaseRow'][$languageField] > 0 && isset($result['databaseRow'][$fieldWithUidOfDefaultRecord]) && $result['databaseRow'][$fieldWithUidOfDefaultRecord] > 0) {
// 省略代码
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']]);
}
//省略代码
}
要想进入反序列化的点,还须要知足上面的if条件,动态调一下就能够知道,在if语句中调用的是
$result['databaseRow']['sys_language_uid']
$result['databaseRow']['l10n_parent']
后面反序列化中调用的是
$result['databaseRow']['l10n_diffsource']
所以,咱们只须要在传入的表单中增长三个参数便可。
overrideVals[pages][sys_language_uid] ==> 4
overrideVals[pages][l10n_parent] ==> 4
overrideVals[pages][l10n_diffsource] ==> serialized_shell_data
能够看到,咱们的输入成功的到达了反序列化的点。
4.3 Core ext 漏洞点利用过程分析
看下Core中的那个漏洞点。
路径:typo3/sysext/core/Classes/DataHandling/DataHandler.php:1453
public function fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $realPid, $status, $tscPID)
{
// Initialize:
$originalLanguageRecord = null;
$originalLanguage_diffStorage = null;
$diffStorageFlag = false;
// Setting 'currentRecord' and 'checkValueRecord':
if (strpos($id, 'NEW') !== false) {
// Must have the 'current' array - not the values after processing below...
$checkValueRecord = $fieldArray;
if (is_array($incomingFieldArray) && is_array($checkValueRecord)) {
ArrayUtility::mergeRecursiveWithOverrule($checkValueRecord, $incomingFieldArray);
}
$currentRecord = $checkValueRecord;
} else {
// We must use the current values as basis for this!
$currentRecord = ($checkValueRecord = $this->recordInfo($table, $id, '*'));
// This is done to make the pid positive for offline versions; Necessary to have diff-view for page translations in workspaces.
BackendUtility::fixVersioningPid($table, $currentRecord);
}
// Get original language record if available: if (is_array($currentRecord) && $GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField'] && $GLOBALS['TCA'][$table]['ctrl']['languageField'] && $currentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0 && $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField'] && (int)$currentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] > 0 ) { $originalLanguageRecord = $this->recordInfo($table, $currentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']], '*'); BackendUtility::workspaceOL($table, $originalLanguageRecord); $originalLanguage_diffStorage = unserialize($currentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']]); } ......//省略代码
看代码,若是咱们要进入反序列化的点,须要知足前面的if条件
if (is_array($currentRecord)
&& $GLOBALS['TCA'][$table]['ctrl']['transOrigDiffSourceField']
&& $GLOBALS['TCA'][$table]['ctrl']['languageField']
&& $currentRecord[$GLOBALS['TCA'][$table]['ctrl']['languageField']] > 0
&& $GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']
&& (int)$currentRecord[$GLOBALS['TCA'][$table]['ctrl']['transOrigPointerField']] > 0
)
也就是说要知足如下条件
$currentRecord是个数组
在TCA中$table的表属性中存在transOrigDiffSourceField、languageField、transOrigPointerField字段。
$table的属性languageField和transOrigPointerField在$currentRecord中对应的值要大于0。
查一下TCA表,知足第二条条件的表有
sys_file_reference
sys_file_metadata
sys_file_collection
sys_collection
sys_category
pages
可是全部sys_*的字段的adminOnly属性的值都是1,只有管理员权限才能够更改。所以咱们能够用的表只有pages。
它的属性值是
[languageField] => sys_language_uid
[transOrigPointerField] => l10n_parent
[transOrigDiffSourceField] => l10n_diffsource
再往上,有一个对传入的参数进行处理的if-else语句。
从注释中,咱们能够知道传入的各个参数的功能:
数组 $fieldArray 是默认值,这种通常都是咱们没法控制的
数组 $incomingFieldArray 是你想要设置的字段值,若是能够,它会合并到$fieldArray中。
并且若是知足if (strpos($id, 'NEW') !== false)条件的话,也就是$id是一个字符串且其中存在NEW字符串,会进入下面的合并操做。
$checkValueRecord = $fieldArray;
......
if (is_array($incomingFieldArray) && is_array($checkValueRecord)) {
ArrayUtility::mergeRecursiveWithOverrule($checkValueRecord, $incomingFieldArray);
}
$currentRecord = $checkValueRecord;
若是不知足上面的if条件,$currentRecord的值就会经过recordInfo方法从数据库中直接获取。这样后面咱们就没法利用了。
简单总结一下,咱们须要
$table是pages
$id是个字符串,并且存在NEW字符串
$incomingFieldArray中要存在payload
接下来咱们看在哪里对该函数进行了调用。
全局搜索一下,只找到一处,在typo3/sysext/core/Classes/DataHandling/DataHandler.php:954处的process_datamap方法中进行了调用。
整个项目中,对process_datamap调用的地方就太多了,尝试使用xdebug动态调试来找一下调用链。从RIPS团队的那一篇分析文章结合上面的对表名的分析,咱们能够知道,漏洞点在建立page的功能处。
接下来就是找从EditDocumentController.php的mainAction方法到前面咱们分析的fillInFieldArray方法的调用链。
尝试在网站中新建一个page,而后在调用fillInFieldArray的位置下一个断点,发送请求后,咱们就拿到了调用链。
看一下mainAction的代码。
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
// Unlock all locked records
BackendUtility::lockRecords();
if ($response = $this->preInit($request)) {
return $response;
}
// Process incoming data via DataHandler? $parsedBody = $request->getParsedBody(); if ($this->doSave || isset($parsedBody['_savedok']) || isset($parsedBody['_saveandclosedok']) || isset($parsedBody['_savedokview']) || isset($parsedBody['_savedoknew']) || isset($parsedBody['_duplicatedoc']) ) { if ($response = $this->processData($request)) { return $response; } } ....//省略代码
}
当知足if条件是进入目标$response = $this->processData($request)。
if ($this->doSave
|| isset($parsedBody['_savedok'])
|| isset($parsedBody['_saveandclosedok'])
|| isset($parsedBody['_savedokview'])
|| isset($parsedBody['_savedoknew'])
|| isset($parsedBody['_duplicatedoc'])
)
这个在新建一个page时,正常的表单中就携带doSave == 1,而doSave的值就是在方法preInit中获取的。
这样条件默认就是成立的,而后将$request传入了processData方法。
public function processData(ServerRequestInterface $request = null): ?ResponseInterface
{
// @deprecated Variable can be removed in TYPO3 v10.0
$deprecatedCaller = false;
......//省略代码 $parsedBody = $request->getParsedBody(); // 获取Post请求参数 $queryParams = $request->getQueryParams(); // 获取Get请求参数 $beUser = $this->getBackendUser(); // 获取用户数据 // Processing related GET / POST vars $this->data = $parsedBody['data'] ?? $queryParams['data'] ?? []; $this->cmd = $parsedBody['cmd'] ?? $queryParams['cmd'] ?? []; $this->mirror = $parsedBody['mirror'] ?? $queryParams['mirror'] ?? []; // @deprecated property cacheCmd is unused and can be removed in TYPO3 v10.0 $this->cacheCmd = $parsedBody['cacheCmd'] ?? $queryParams['cacheCmd'] ?? null; // @deprecated property redirect is unused and can be removed in TYPO3 v10.0 $this->redirect = $parsedBody['redirect'] ?? $queryParams['redirect'] ?? null; $this->returnNewPageId = (bool)($parsedBody['returnNewPageId'] ?? $queryParams['returnNewPageId'] ?? false); // Only options related to $this->data submission are included here $tce = GeneralUtility::makeInstance(DataHandler::class); $tce->setControl($parsedBody['control'] ?? $queryParams['control'] ?? []); // Set internal vars if (isset($beUser->uc['neverHideAtCopy']) && $beUser->uc['neverHideAtCopy']) { $tce->neverHideAtCopy = 1; } // Load DataHandler with data $tce->start($this->data, $this->cmd); if (is_array($this->mirror)) { $tce->setMirror($this->mirror); } // Perform the saving operation with DataHandler: if ($this->doSave === true) { $tce->process_uploads($_FILES); $tce->process_datamap(); $tce->process_cmdmap(); } ......//省略代码
}
代码很容易懂,从$request中解析出来的数据,首先存储在$this->data和$this->cmd中,而后实例化一个名为$tce,调用$tce->start方法将传入的数据存储在其自身的成员datamap和cmdmap中。
typo3/sysext/core/Classes/DataHandling/DataHandler.php:735
public function start($data, $cmd, $altUserObject = null)
{
......//省略代码
// Setting the data and cmd arrays
if (is_array($data)) {
reset($data);
$this->datamap = $data;
}
if (is_array($cmd)) {
reset($cmd);
$this->cmdmap = $cmd;
}
}
并且if ($this->doSave === true)这个条件也是成立的,进入process_datamap方法。
代码有注释仍是容易阅读的,在第985行,获取了datamap中全部的键名,而后存储在$orderOfTables,而后进入foreach循环,而这个$table,在后面传入fillInFieldArray方法中,所以,咱们只须要分析$table == pages时的循环便可。
$fieldArray = $this->fillInFieldArray($table, $id, $fieldArray, $incomingFieldArray, $theRealPid, $status, $tscPID);
大体浏览下代码,再结合前面的分析,咱们须要知足如下条件:
$recordAccess的值要为true
$incomingFieldArray中的payload不会被删除
$table的值为pages
$id中存在NEW字符串
既然正常请求能够直接断在调用fillInFieldArray处,正常请求中,第一条、第三条和第四条都是成立的。
根据前面对fillInFieldArray方法的分析,构造payload,向提交的表单中添加三个键值对。
data[pages][NEW5d3fa40cb5ac4065255421][l10n_diffsource] ==> serialized_shell_data
data[pages][NEW5d3fa40cb5ac4065255421][sys_language_uid] ==> 4
data[pages][NEW5d3fa40cb5ac4065255421][l10n_parent] ==> 4
其中NEW*字符串要根据表单生成的值进行对应的修改。
发送请求后,依旧可以进入fillInFieldArray,而在传入的$incomingFieldArray参数中,能够看到咱们添加的三个键值对。
进入fillInFieldArray以后,其中l10n_diffsource将会进行反序列化操做。此时咱们在请求中将其l10n_diffsource改成构造好的序列化字符串,从新发送请求便可成功getshell。
其实单看这个漏洞的利用条件,仍是有点鸡肋的,须要你获取到typo3的一个有效的后台帐户,而且拥有编辑page的权限。
并且此次分析Typo3给个人感受与其余网站彻底不一样,我在分析建立&修改page这个功能的参数过程当中,并无发现什么过滤操做,在后台的全部参数都是根据TCA的定义来进行相应的操做,只有传入不符合TCA定义的才会抛出异常。而TCA的验证又不严格致使了变量覆盖这个问题。
官方的修补方式也是不太懂,直接禁止了反序列化操做,可是我的认为此次漏洞的重点仍是在于前面变量覆盖的问题上,尤为是Backend的利用过程当中,能够直接覆盖从数据库中取出的数据,这样只能算是治标不治本,后面仍是有可能产生新的问题。
固然了,以上只是我的拙见,若有错误,还请诸位斧正。
[1] 详情: https://blog.ripstech.com/2019/typo3-overriding-the-database/
[2] 官方手册: https://docs.typo3.org/m/typo3/reference-tca/master/en-us/Introduction/Index.html
[3] 通告: https://typo3.org/security/advisory/typo3-core-sa-2019-020/
[4] 修复记录:
https://github.com/TYPO3/TYPO3.CMS/commit/555e0dd2b28f01a2f242dfefc0f344d10de50b2a?diff=unified