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

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

在进行分析之前,我们需要了解下 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] ==> 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 的表属性中存在 transOrigDiffSourceFieldlanguageFieldtransOrigPointerField 字段。
  • $table 的属性 languageFieldtransOrigPointerField$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 方法从数据库中直接获取。这样后面我们就无法利用了。

简单总结一下,我们需要

  • $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: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. 参考链接