A few weeks ago, I became interested in Typo3 source code. At that time, the latest version available of the CMS was version 10.4.3 (version replaced by the current v10.4.4). My objective was to discover potential new vulnerabilities, which I didn’t do by the way. But because there’s always a but… I discovered that if you can read arbitrary files, then you can write arbitrarily files on the underlying file system.

The keymaker

alt text

The configuration file <ROOT>/typo3conf/LocalConfiguration.php contains an entry named encryptionKey that once obtained, allows an unauthenticated attacker to write files on the system and therefore allows him to execute commands.

The local configuration file is basically a long array which is simply returned when the file is included. It represents the global TYPO3 CMS configuration. Link

It is thus necessary to have at least a arbitrary file read to exploit what will be presented to you.

The door

I first used grep to list the occurrences of the string unserialize(base64_decode($

▶ grep -R 'unserialize(base64_decode(\$'
vendor/symfony/var-dumper/Server/DumpServer.php:            $payload = @unserialize(base64_decode($message), ['allowed_classes' => [Data::class, Stub::class]]);
typo3/sysext/frontend/Classes/Controller/ShowImageController.php:        $parameters = unserialize(base64_decode($parametersEncoded));
typo3/sysext/form/Classes/Domain/Runtime/FormRuntime.php:            $this->formState = unserialize(base64_decode($serializedFormState));

And then read the code on the pages that included that string. When focusing on the code on the page <ROOT>/typo3_src/typo3/sysext/frontend/Classes/Controller/ShowImageController.php and trying to figure out where this one was called, I realized that the code of function initialize() was accessible by non-authenticated users via the following route <BASE_URL>/index.php?eID=tx_cms_showpic.

To make sure that this is really the case let’s add a little debugging.

File: <ROOT>/typo3_src/typo3/sysext/frontend/Classes/Controller/ShowImageController.php

public function initialize()
{
    echo "[DEBUG]: Start". "\n";

    $fileUid = $this->request->getQueryParams()['file'] ?? null;
    $parametersArray = $this->request->getQueryParams()['parameters'] ?? null;

    // If no file-param or parameters are given, we must exit
    if (!$fileUid || !isset($parametersArray) || !is_array($parametersArray)) {
        throw new \InvalidArgumentException('No valid fileUid given', 1476048455);
    }

    // rebuild the parameter array and check if the HMAC is correct
    $parametersEncoded = implode('', $parametersArray);

    /* For backwards compatibility the HMAC is transported within the md5 param */
    $hmacParameter = $this->request->getQueryParams()['md5'] ?? null;
    $hmac = GeneralUtility::hmac(implode('|', [$fileUid, $parametersEncoded]));
    if (!is_string($hmacParameter) || !hash_equals($hmac, $hmacParameter)) {
        throw new \InvalidArgumentException('hash does not match', 1476048456);
    }

    // decode the parameters Array
    $parameters = unserialize(base64_decode($parametersEncoded));
    foreach ($parameters as $parameterName => $parameterValue) {
        $this->{$parameterName} = $parameterValue;
    }

    if (MathUtility::canBeInterpretedAsInteger($fileUid)) {
        $this->file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject((int)$fileUid);
    } else {
        $this->file = GeneralUtility::makeInstance(ResourceFactory::class)->retrieveFileOrFolderObject($fileUid);
    }
    $this->frame = $this->request->getQueryParams()['frame'] ?? null;
}

alt text

The following line calculates a hmac according to the parameters $fileUid and $parametersEncoded.

$hmac = GeneralUtility::hmac(implode('|', [$fileUid, $parametersEncoded]));

Let’s see what the function hmac() from class GeneralUtility does.

File: <ROOT>/typo3_src/typo3/sysext/core/Classes/Utility/GeneralUtility.php

public static function hmac($input, $additionalSecret = '')
{
    $hashAlgorithm = 'sha1';
    $hashBlocksize = 64;
    $secret = $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] . $additionalSecret;
    if (extension_loaded('hash') && function_exists('hash_hmac') && function_exists('hash_algos') && in_array($hashAlgorithm, hash_algos())) {
        $hmac = hash_hmac($hashAlgorithm, $input, $secret);
    } else {
        // Outer padding
        $opad = str_repeat(chr(92), $hashBlocksize);
        // Inner padding
        $ipad = str_repeat(chr(54), $hashBlocksize);
        if (strlen($secret) > $hashBlocksize) {
            // Keys longer than block size are shorten
            $key = str_pad(pack('H*', call_user_func($hashAlgorithm, $secret)), $hashBlocksize, "\0");
        } else {
            // Keys shorter than block size are zero-padded
            $key = str_pad($secret, $hashBlocksize, "\0");
        }
        $hmac = call_user_func($hashAlgorithm, ($key ^ $opad) . pack('H*', call_user_func(
            $hashAlgorithm,
            ($key ^ $ipad) . $input
        )));
    }
    return $hmac;
}

In its simplest operation (by default) this function generates a hmac based on the variable $input and a secret with the help of the hash function sha1(). The secret is stored as encryptionKey in the file <ROOT>/typo3conf/LocalConfiguration.php as explained at the beginning of the chapter. That’s why it is necessary to be able to read this file in order to exploit the unserialize().

To return to the analysis of the initialize() function, this one calls hmac() with parameters that we control $fileUid and $parametersEncoded and compares the result of the one with $hmacParameter which we also control. If they’re equal, the string $parametersEncoded is base64 decoded and then unserialize.

That’s when we can have fun :)

Let’s assume that:

encryptionKey = ad2274f12d58b2ab6e3e9365bbcb93e7b64bf4908e685f1b68ab05ba49755ddd5fdf95f433219c6bf665e8bf146f6708

Let’s add a debug line:

File: <ROOT>/typo3_src/typo3/sysext/frontend/Classes/Controller/ShowImageController.php

public function initialize()
{
    echo "[DEBUG]: Start". "\n";

    $fileUid = $this->request->getQueryParams()['file'] ?? null;
    $parametersArray = $this->request->getQueryParams()['parameters'] ?? null;

    // If no file-param or parameters are given, we must exit
    if (!$fileUid || !isset($parametersArray) || !is_array($parametersArray)) {
        throw new \InvalidArgumentException('No valid fileUid given', 1476048455);
    }

    // rebuild the parameter array and check if the HMAC is correct
    $parametersEncoded = implode('', $parametersArray);

    /* For backwards compatibility the HMAC is transported within the md5 param */
    $hmacParameter = $this->request->getQueryParams()['md5'] ?? null;
    $hmac = GeneralUtility::hmac(implode('|', [$fileUid, $parametersEncoded]));
    if (!is_string($hmacParameter) || !hash_equals($hmac, $hmacParameter)) {
        throw new \InvalidArgumentException('hash does not match', 1476048456);
    }

    // decode the parameters Array
    $parameters = unserialize(base64_decode($parametersEncoded));

    echo "[DEBUG]: Hit! after unserialize" . "\n";

    foreach ($parameters as $parameterName => $parameterValue) {
        $this->{$parameterName} = $parameterValue;
    }

    if (MathUtility::canBeInterpretedAsInteger($fileUid)) {
        $this->file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject((int)$fileUid);
    } else {
        $this->file = GeneralUtility::makeInstance(ResourceFactory::class)->retrieveFileOrFolderObject($fileUid);
    }
    $this->frame = $this->request->getQueryParams()['frame'] ?? null;
}

Let’s calculate the value of the hmac:

▶ php -r "echo hash_hmac('sha1','XXXX|XXXX','ad2274f12d58b2ab6e3e9365bbcb93e7b64bf4908e685f1b68ab05ba49755ddd5fdf95f433219c6bf665e8bf146f6708');"
1d9f8be4b6a3ea3d148c75337546fb1e52a17df4

And let’s see if our expectations are being met.

alt text

And they are !