Typo3 CVE-2019-12747 反序列化漏洞分析
source link: https://www.tuicool.com/articles/mABzeqJ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
作者:mengchen@知道创宇404实验室
时间:2019年8月1日
英文版本: https://paper.seebug.org/997/
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
传入的表单中的键值对。
这样一来,在这个请求过程中,进行反序列化的字符串我们就可以控制了。
在表单中提交任意符合数组格式的输入,在后端代码中都会被解析,然后后端根据 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
。
5. 写在最后
其实单看这个漏洞的利用条件,还是有点鸡肋的,需要你获取到 typo3
的一个有效的后台账户,并且拥有编辑 page
的权限。
而且这次分析 Typo3
给我的感觉与其他网站完全不同,我在分析创建&修改 page
这个功能的参数过程中,并没有发现什么过滤操作,在后台的所有参数都是根据 TCA
的定义来进行相应的操作,只有传入不符合 TCA
定义的才会抛出异常。而 TCA
的验证又不严格导致了变量覆盖这个问题。
官方的修补方式也是不太懂,直接禁止了反序列化操作,但是个人认为这次漏洞的重点还是在于前面变量覆盖的问题上,尤其是 Backend
的利用过程中,可以直接覆盖从数据库中取出的数据,这样只能算是治标不治本,后面还是有可能产生新的问题。
当然了,以上只是个人拙见,如有错误,还请诸位斧正。
6. 参考链接
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK