TYPO3 is the most widely used enterprise content management system with more than 500.000 installations. I have recently discovered a Non-Persistent Cross-Site Scripting vulnerability in its core and disclosed the details of the vulnerability publicly as CVE-2015-5956.
This blog article should give you some insights about the vulnerability, because it’s not only a simple XSS, but a rather nice XSS filter bypass. But before digging into PHP stuff, I’d like to outline the really great work by the TYPO3 security team!!11 This was definitely one of the best and most efficient coordinations I have ever done. Special thanks go out to Helmut Hummel, who was always professional and transparent about TYPO3’s work on an update.
Vulnerability Description and PoCs
The Typo3 version branches 6.x and 4.x (and 7.x in theory) are vulnerable to an authenticated, non-persistent Cross-Site Scripting vulnerability when user-supplied input is processed by the sanitizeLocalUrl() function. While there is already a XSS filter in place, it is possible to mitigate it by using a data URI with a base64 encoded payload.
<script>alert('XSS')</script> is used as a base64 encoded data URI in the “returnUrl” and “redirect_url” parameters, which can be found throughout Typo3.
The first proof of concept exploits the vulnerability using the “returnUrl” parameter by forging the back link in the Typo3-backend “Show record history” module:
An authenticated victim with proper access rights to access the module, and who follows the URL and afterwards clicks on the back link:
Will be exploited like this (pretty high amount of social engineering is needed):
With the 6.x Branch Proof-of-Concept the victim does not need to be logged into the backend. The attacker simply sends him/her the following link:
By clicking on this link, the victim is redirected to the login page of Typo3:
After entering valid login credentials, the victim gets exploited like in the previous example, because Typo3 uses the “redirect_url” parameter in the HTTP Location header to forward the user. This heavily lowers the amount of social engineering needed to prepare this attack.
The 7.x branch is basically vulnerable too, but the attacker additionally needs to know a secret token (moduleToken), which is included in every request in order to successfully exploit the vulnerability, which makes exploitation unfeasible.
The vulnerability can be used to temporarily embed arbitrary script code into the context of the Typo3 backend interface, which offers a wide range of possible attacks such as stealing cookies or attacking the browser and its components. Since this XSS is not only a simple one, but a rather nice filterbypass, the following root cause analysis whill show you how the bypass happens. It is based on the 6.x branch.
Root Cause Analysis
Let’s have a look at the file /typo3/sysext/core/Classes/Utility/GeneralUtility.php which causes all the trouble and go through it step by step to find out how the filter is bypassed:
Initially, the $url argument of the sanitizeLocalUrl() function contains the XSS payload feeded through one of the vulnerable parameters - .e.g.:
This is then passed through a rawurldecode() call, which simply replaces all % occurences with their literal chars, but since there are none, the original payload stays the same, but is copied to $decodedUrl. The next function removeXSS() is a little bit more complex, but in the first step just includes the third party external class “removeXSS” followed by the process() call on the payload-string:
The class is too complex to describe it in detail here, but to summarize how it’s working: It basically replaces potential Cross-site scripting payloads with an “
It would output something like:
Even the use of the encoded version of the payload:
would result in:
Back to the sanitizeLocalUrl() function, the first if-check does only continue if the $url is not empty and its decoded version is equal (and of the same type) like the result from the removeXSS() call:
This means that if the payload is modified by the removeXSS() function in any way, the if-check evaluates to false and the subsequent commands are not executed. The sanitizeLocalUrl() then returns an empty string, which was set right at the beginning and wasn’t modified up to the end. This however results in the payload being removed from the request. Bad boy.
If you pass the payload through the removeXSS() function:
It simply outputs the same string, because neither a typical XSS string was found nor any encoded strings are present, which means the if-statement evaluates to true. First filter bypassed successfully :-) !
Up next a series of URL validation checks:
The resolveBackPath() function checks whether the payload is an absolute URL (in form of a present double dot):
But since the payload does not contain the string “..”, the first if-check in the resolveBackPath() function already evaluates to false, so the input is returned unmodified.
The subsequent check for the relative URL is a bit more complex again. It basically gets the executing “SCRIPT_NAME” (in this example: /typo3/index.php), and passes it to the dirname() function, which returns the directory part of the path, without the trailing / - so in this case “/typo3”. Finally a slash followed by the payload is added.
Therefore a string like the following is constructed:
It is passed through the resolveBackPath() function again, and also returns the same value, because the double-dot string is not found.
The next if-conditions are the final thing that needs to be bypassed. The first and most important one, because it is the reason, why a space is needed in the payload, is the isValidUrl() function call:
Quite a couple of checks are performed here. The first if-check is skipped, because $parsedUrl is not null and the scheme is also resolved (it’s actually just “data”). The next if-check tries to identify whether the first part of the string starts with the scheme “data://”. Since the payload does not start with “data://” the statement evaluates to true and the $url string is str_replaced() to return “data://text/html;base64, PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=”.The next buildUrl() call is used to construct a scheme-valid string from the parsed argument. It finally also resolves the payload to “data://text/html;base64, PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=”.
The next if-check simply checks whether the previous instructions have modified the original payload (data: text/html;base64, PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=).
Since both strings are still same, the function goes on and checks if the host-part of the string is valid. But it’s not even there, so the first isset() of the if-check already returns false. Due to the fact that PHP uses short-circuit evaluations, the last condition is not evaluated (in case of this &&) when the first condition already evaluates to false.
The last part is the return instruction, which is quite important because it deals with the needed space!
To understand what is actually happening here, you must keep in mind that “FILTER_VALIDATE_URL” is one of the official PHP filters. Quoted from their page:
Validates value as URL (according to » http://www.faqs.org/rfcs/rfc2396), optionally with required components. Beware a valid URL may not specify the HTTP protocol http:// so further validation may be required to determine the URL uses an expected protocol, e.g. ssh:// or mailto:. Note that the function will only find ASCII URLs to be valid; internationalized domain names (containing non-ASCII characters) will fail.
So RFC2396 describes how a URL should look like and includes a part about whitepaces - quoted from their page:
The space character is excluded because significant spaces may disappear and insignificant spaces may be introduced when URI are transcribed or typeset or subjected to the treatment of word-processing programs. Whitespace is also used to delimit URI in many contexts.
All in all, whitepsaces are bad, but most browsers accept them anyways. Let’s see what happens when you supply the two payloads to the filter_var() function, with just a space in difference:
returns the url string, which means it is conform with RFC2396.
returns “false”, because there’s a whitespace in the payload. And that’s what is needed to bypass the filter. !
Back at the second if check in the sanitizeLocalUrl() function:
It checks via the isAbsPath() function whether the payload (URL) is absolute or relative, but also returns false because all statements resolve to false:
The third if checks whether the TYPO3_SITE_PATH can be found in the $testAbsoluteUrl string. Just to remember, $testAbsoluteUrl is the actual payload:
The TYPO3_SITE_PATH string always resolves to the directory of the frontend website. So in this example it’s just “/”.
That leads to the first strpos() to evaluate to false, because the first “/” is not found at position 0. Due to short-circuit evaluations, the last check isn’t performed anymore.
The last if-check performs the same operation like the previous one, but on the $testRelativeUrl string, which is
The first strpos condition evaluates to true, because the first character of the $testRelativeUrl string is the TYPO3_SITE_PATH (“/”). The second condition, which checks whether the first char of the ORIGINAL payload is not a slash also evaluates to true.
Pwned. This means that the last if-check assigns the input payload to $sanitizedUrl, which is in the end returned and echoed back to the backend.
How did Typo3 fix the bug?
Typo3 changed the following file with their patch: /typo3/sysext/core/Classes/Utility/GeneralUtility.php
Let’s have a quick look at the patch, which Typo3 has published to fix the isse, which is just a small one. Two lines have been changed in order to address the issue:
- Another parse_url() call was added to split up the payload (URL) into its different parts
- The last elseif-check was modified to verify the URL scheme (e.g. http or https) is present.
Good work. Mission accomplished.