Typo3 CVE-2019-12747 反序列化漏洞分析

酷酷的曉得哥發表於2019-08-02

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

在進行分析之前,我們需要了解下 Typo3TCA(Table Configuration Array),在 Typo3的程式碼中,它表示為 $GLOBALS['TCA']

Typo3中, TCA算是對於資料庫表的定義的擴充套件,定義了哪些表可以在 Typo3的後端可以被編輯,主要的功能有

  • 表示表與表之間的關係
  • 定義後端顯示的欄位和佈局
  • 驗證欄位的方式

這次漏洞的兩個利用點分別出在了 CoreEngineFormEngine這兩大結構中,而 TCA就是這兩者之間的橋樑,告訴兩個核心結構該如何表現表、欄位和關係。

TCA的第一層是表名:

$GLOBALS['TCA']['pages'] = [
    ...];$GLOBALS['TCA']['tt_content'] = [
    ...];

其中 pagestt_content就是資料庫中的表。

接下來一層就是一個陣列,它定義瞭如何處理表,

$GLOBALS['TCA']['pages'] = [
    'ctrl' => [ // 通常包含表的屬性        ....
    ],
    'interface' => [ // 後端介面屬性等        ....
    ],
    'columns' => [
        ....
    ],
    'types' => [
        ....
    ],
    'palettes' => [
        ....
    ],];

在這次分析過程中,只需要瞭解這麼多,更多詳細的資料可以查詢 。

4. 漏洞分析

整個漏洞的利用流程並不是特別複雜,主要需要兩個步驟,第一步變數覆蓋後導致反序列化的輸入可控,第二步構造特殊的反序列化字串來寫 shell。第二步這個就是老套路了,找個在魔術方法中能寫檔案的類就行。這個漏洞好玩的地方在於變數覆蓋這一步,而且進入兩個元件漏洞點的傳入方式也有著些許不同,接下來讓我們看一看這個漏洞吧。

4.1 補丁分析

從Typo3官方的 通告中我們可以知道漏洞影響了兩個元件—— Backend & Core API (ext:backend, ext:core),在GitHub上我們可以找到 :

很明顯,補丁分別禁用了 backendDatabaseLanguageRows.phpcore中的 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介面,因此靜態分析尋找誰呼叫的 DatabaseLanguageRowsaddData方法根本不現實,但是根據文章中的演示影片,我們可以知道網站中修改 page這個功能中進入了漏洞點。在 addData方法加上斷點,然後發出一個正常的修改 page的請求。

當程式斷在 DatabaseLanguageRowsaddData方法後,我們就可以得到呼叫鏈。

DatabaseLanguageRows這個 addData中,只傳入了一個 $result陣列,而且進行反序列化操作的目標是 $result['databaseRow']中的某個值。看命名有可能是從資料庫中獲得的值,往前分析一下。

進入 OrderedProviderListcompile方法。

路徑: 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);
    // 省略程式碼...}

很明顯,透過呼叫 FormDataCompilercompile方法,將 $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] ==> 4overrideVals[pages][l10n_parent] ==> 4overrideVals[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的表屬性中存在 transOrigDiffSourceFieldlanguageFieldtransOrigPointerField欄位。
  • $table的屬性 languageFieldtransOrigPointerField$currentRecord中對應的值要大於

查一下 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方法從資料庫中直接獲取。這樣後面我們就無法利用了。

簡單總結一下,我們需要

  • $tablepages
  • $id是個字串,而且存在 NEW字串
  • $incomingFieldArray中要存在 payload

接下來我們看在哪裡對該函式進行了呼叫。

全域性搜尋一下,只找到一處,在 typo3/sysext/core/Classes/DataHandling/DataHandler.php:954處的 process_datamap方法中進行了呼叫。

整個專案中,對 process_datamap呼叫的地方就太多了,嘗試使用 xdebug動態除錯來找一下呼叫鏈。從 RIPS團隊的那一篇分析文章結合上面的對錶名的分析,我們可以知道,漏洞點在建立 page的功能處。

接下來就是找從 EditDocumentController.phpmainAction方法到前面我們分析的 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方法將傳入的資料儲存在其自身的成員 datamapcmdmap中。

typo3/sysext/core/Classes/DataHandling/DataHandler.php:735public 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. 參考連結

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912109/viewspace-2652596/,如需轉載,請註明出處,否則將追究法律責任。

相關文章