做者: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