Typo3 CVE-2019-12747 反序列化漏洞分析
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. 參考連結
- https://blog.ripstech.com/2019/typo3-overriding-the-database/
- https://www.php.net/manual/en/function.unserialize.php
- https://docs.typo3.org/m/typo3/reference-tca/master/en-us/Introduction/Index.html
- https://typo3.org/security/advisory/typo3-core-sa-2019-020/
-
本文由 Seebug Paper 釋出,如需轉載請註明來源。本文地址:https://paper.seebug.org/