51

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

 4 years ago
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

在进行分析之前,我们需要了解下 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上我们可以找到 修复记录

3eeq22f.png!web

很明显,补丁分别禁用了 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 方法后,我们就可以得到调用链。

73ERBvu.png!web

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 方法。

viEVz2E.png!web

经过查询手册以及分析代码,可以知道在 DatabaseEditRow 类中,通过调用 addData 方法,将数据库表中数据读取出来,存储到了 $result['databaseRow'] 中。

7BRfaqY.png!web

路径: 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 方法。

vMZvuqy.png!web

路径: 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 方法中。

2YZzmez.png!web

在这里, $formDataCompilerInput['overrideValues'] 获取了 $this->overrideVals[$table] 中的数据。

$this->overrideVals 的值是在方法 preInit 中设定的,获取的是通过 POST 传入的表单中的键值对。

BRziumU.png!web

这样一来,在这个请求过程中,进行反序列化的字符串我们就可以控制了。

在表单中提交任意符合数组格式的输入,在后端代码中都会被解析,然后后端根据 TCA 来进行判断并处理。 比如我们在提交表单中新增一个名为 a[b][c][d] ,值为 233 的表单项。

ERR773f.png!web

在编辑表单的控制器 EditDocumentController.php 中下一个断点,提交之后。

rQfaYr2.png!web

可以看到我们传入的键值对在经过 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

BveI3en.png!web

可以看到,我们的输入成功的到达了反序列化的点。

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;

U7nIbqv.png!web

如果不满足上面的 if 条件, $currentRecord 的值就会通过 recordInfo 方法从数据库中直接获取。这样后面我们就无法利用了。

简单总结一下,我们需要

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

接下来我们看在哪里对该函数进行了调用。

全局搜索一下,只找到一处,在 typo3/sysext/core/Classes/DataHandling/DataHandler.php:954 处的 process_datamap 方法中进行了调用。

nEZNRrr.png!web

整个项目中,对 process_datamap 调用的地方就太多了,尝试使用 xdebug 动态调试来找一下调用链。从 RIPS 团队的那一篇分析文章结合上面的对表名的分析,我们可以知道,漏洞点在创建 page 的功能处。

接下来就是找从 EditDocumentController.phpmainAction 方法到前面我们分析的 fillInFieldArray 方法的调用链。

尝试在网站中新建一个 page ,然后在调用 fillInFieldArray 的位置下一个断点,发送请求后,我们就拿到了调用链。

MRnEfiN.png!web

看一下 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 中获取的。

NfequaU.png!web

这样条件默认就是成立的,然后将 $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 方法。

RvqQvqZ.png!web

代码有注释还是容易阅读的,在第 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* 字符串要根据表单生成的值进行对应的修改。

qMzmyau.png!web

发送请求后,依旧能够进入 fillInFieldArray ,而在传入的 $incomingFieldArray 参数中,可以看到我们添加的三个键值对。

jaI3UjM.png!web

进入 fillInFieldArray 之后,其中 l10n_diffsource 将会进行反序列化操作。此时我们在请求中将其 l10n_diffsource 改为构造好的序列化字符串,重新发送请求即可成功 getshell

2qqqEjE.png!web

5. 写在最后

其实单看这个漏洞的利用条件,还是有点鸡肋的,需要你获取到 typo3 的一个有效的后台账户,并且拥有编辑 page 的权限。

而且这次分析 Typo3 给我的感觉与其他网站完全不同,我在分析创建&修改 page 这个功能的参数过程中,并没有发现什么过滤操作,在后台的所有参数都是根据 TCA 的定义来进行相应的操作,只有传入不符合 TCA 定义的才会抛出异常。而 TCA 的验证又不严格导致了变量覆盖这个问题。

官方的修补方式也是不太懂,直接禁止了反序列化操作,但是个人认为这次漏洞的重点还是在于前面变量覆盖的问题上,尤其是 Backend 的利用过程中,可以直接覆盖从数据库中取出的数据,这样只能算是治标不治本,后面还是有可能产生新的问题。

当然了,以上只是个人拙见,如有错误,还请诸位斧正。

6. 参考链接


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK