rutorrent code review

Opportunistic and quick review of rutorrent's overall security.

Introduction

rutorrent is a front-end for the rtorrent BitTorrent client. It is written in PHP and communicates with the daemon's XMLRPC API over SCGI.

By default, this interface does not require any kind of authentication. While it is not common to find public seedboxes on the Internet, a vulnerability could impact shared hosts which are not safely compartmentalizing clients. It should be noted that rutorrent supports multiple users out-of-the-box based on your HTTP basic authentication credentials (if configured).

As part of our routine assessment of popular projects, we poked this project for a day checking for various issues. The goal was not to perform a comprehensive review of the software, but to appraise the overall security level and orient future researches. Even if easily reachable, the rtorrent SCGI interface was not part of the self-defined scope.

Due to this time constraint, the top-down methodology is used and we only tried to discover distinct issues. We cloned the repository at 55ddfb46520fd3427762b13611ccf5b2be391470.

Core

The core is pretty limited: it only consists of 3817 lines of PHP and implements a XMLRPC client, an HTTP client, plugins loading, a Torrent class, locking, caching, etc.

Deployment

The application uses .htaccess files, which are ignored by default since Apache 2.3.9 and not parsed by other HTTP servers, to protect against any access to the share/ folder. This folder being in the document root, files are served (including user preferences, which can contain credentials) and PHP files will be interpreted. The first obvious attack scenario would be to download a torrent of PHP files and then to access them directly :-)

addtorrent.php

This file is responsible for forwarding the user supplied torrent file to the rtorrent daemon.

Cross-Site Scripting(s)

When an upload is successful (or not), this page redirects the user to addtorrent.php again but this time with arguments that indicate the result of the operation. It iterates over $_REQUEST['result'] and displays each inner value in the page without encoding:

<?php
if(isset($_REQUEST['result']))
{
//...
    else
    {
        $js = '';
        foreach( $_REQUEST['result'] as $ndx=>$result )
            $js.= ('noty("'.(isset($_REQUEST['name'][$ndx]) ? addslashes(rawurldecode(htmlspecialchars($_REQUEST['name'][$ndx]))).' - ' : '').
                '"+theUILang.addTorrent'.$_REQUEST['result'][$ndx].
                ',"'.($_REQUEST['result'][$ndx]=='Success' ? 'success' : 'error').'");');
        cachedEcho($js,"text/html");
    }
}

Thus, accessing http://website.tld/php/addtorrent.php?result[]=<script>alert(document.domain)</script> will pop a nice alert.

While we could think at first that addslashes(rawurldecode(htmlspecialchars($var))) prevents $_REQUEST['name'] to be affected by the same issue, it's false: rawurldecode() is applied after the conversion of special characters to HTML entities. By URL-encoding twice the payload in $_REQUEST['name'] (<script> -> %253Cscript%253E), htmlspecialchars() will not affect our %3C and %3E, but they will be converted back to < and > by rawurldecode().

Path traversal in multi-users mode

Users are identified using the HTTP Basic credentials they provide. In an ideal world, a reverse proxy will be responsible of the authentication and usernames will be restricted to [a-Z0-9]+. In practice, HTTP servers and seedbox providers may be too permissive with the user's choice (or no authentication layer can be present at all).

If not already present, $_SERVER['REMOTE_USER'] is filled in util.php:

<?php
if(!isset($_SERVER['REMOTE_USER']))
{
    if(isset($_SERVER['PHP_AUTH_USER']))
        $_SERVER['REMOTE_USER'] = $_SERVER['PHP_AUTH_USER'];
    else
    if(isset($_SERVER['REDIRECT_REMOTE_USER']))
        $_SERVER['REMOTE_USER'] = $_SERVER['REDIRECT_REMOTE_USER'];
}

This value can then be retrieved by calling getUser() (also in util.php):

<?php
function getUser()
{
        global $forbidUserSettings;
    return( !$forbidUserSettings ? getLogin() : '' );
}

function getLogin()
{
    return( (isset($_SERVER['REMOTE_USER']) && !empty($_SERVER['REMOTE_USER'])) ? strtolower($_SERVER['REMOTE_USER']) : '' );
}

Settings and torrents are then stored per-user: it is a perfect candidate for a path traversal, don't you think? getProfilePath() agrees:

<?php
function getProfilePath( $user = null )
{
    global $profilePath;

    $ret = fullpath(isset($profilePath) ? $profilePath : '../share', dirname(__FILE__));
    if(is_null($user))
            $user = getUser();
        if($user!='')
        {
            $ret.=('/users/'.$user);
            if(!is_dir($ret))
            makeDirectory( array($ret,$ret.'/settings',$ret.'/torrents') );
    }
    return($ret);
}

Modules

The core is bundled with 46 plugins. Obviously, this quick review doesn't cover everything: focus was given to the ones with the fanciest names.

feeds

ruTorrent can publish RSS feeds to inform users of their torrents state. This plugin is internationalized so a parameter allows to choose a language from the lang/ folder via $_REQUEST['lang']. However, no restriction is applied on this value, making it possible to load any PHP file of the disk:

<?php
require_once( '../../php/util.php' );
require_once( '../../php/settings.php' );
eval( getPluginConf( 'feeds' ) );

$lang = (isset($_REQUEST['lang']) && is_file('lang/'.$_REQUEST['lang'].'.php')) ? $_REQUEST['lang'] : 'en';
$theUILang = array();
require_once( 'lang/'.$lang.'.php' );

Even if your files are not downloaded in a document root, you can reach them through this path traversal and get your code executed.

extsearch

This plugin allows to search many popular torrent sites for content from within ruTorrent.

When fetching data from iptorrents, this module uses DOMDocument to extract the interesting data without regex headaches:

<?php
$url = 'https://iptorrents.com';
  $now = time();
  if ($useGlobalCats)
      $categories = ['all'    => '',
                     'movies' => '72', 'tv' => '73', 'music' => '75', 'games' => '74',
                     'anime'  => '60', 'software' => '1;86', 'pictures' => '36', 'books' => '35;64;94'];
  else
      $categories = &$this->categories;
  if (!array_key_exists($cat, $categories))
      $cat = $categories['all'];
  else
      $cat = $categories[$cat];
  for ($pg = 1; $pg < 11; $pg++) {
      $cli = $this->fetch($url . '/t?' . $cat . ';o=seeders;q=' . $what . ';p=' . $pg);
 //...
      $doc = new DOMDocument();
      @$doc->loadHTML($cli->results);

However, no call to libxml_disable_entity_loader is performed, leaving room for external entities processing, yielding a file disclosure (or code execution in rare cases). This is exploitable depending on the libxml version (which is safe by default after 2.9, sometimes the patch was backported in older releases), but the owner of iptorrents could try to exploit clients in order to fetch the content of .htpasswd files and access the rutorrent interface.

rpc

This plugin is a replacement for the mod_scgi webserver module.

The file rpc.php relays php://input to the rtorrent XMLRPC interface using rXMLRPCRequest::send(). It's pretty equivalent to exposing the XMLRPC service, which is documented as a bad idea:

Never bind the SCGI port to anything but 127.0.0.1. Anyone who can send rtorrent xmlrpc commands have the ability to execute code with the privileges of the user running rtorrent.

To prevent users sending dangerous commands, it denies any POST body that will match the following input: "/(execute|import)\s*=/i".

rtorrent implements a RPC endpoint called file.append that allows writing arbitrary content to a chosen path. In our setup, this write primitive is enough to obtain code execution by writing a PHP file within a folder served by an HTTP server:

<?xml version="1.0" encoding="UTF-8"?>
<methodCall>
    <methodName>file.append</methodName>
    <params>
        <param><value><string></string></value></param>
        <param><value><string>/var/www/foo.php</string></value></param>
        <param><value><string><![CDATA[<?php echo 'bar'; ?>]]></string></value></param>
    </params>
</methodCall>

Another way to actually execute code with this primitive would be to add configuration directives to .rtorrent.rc.

Let's give a chance to this blacklist. Does it really prevent us to call execute and import commands? Where does this equal sign come from? We don't need it to call a command, for instance execute.nothrow can be called without being blocked:

<?xml version="1.0" encoding="UTF-8"?>
<methodCall><methodName>execute.nothrow</methodName>
    <params>
        <param><value><string></string></value></param><param>
        <value><string>touch</string></value></param>
        <param><value><string>/tmp/bla</string></value></param>
    </params>
</methodCall>

That's not all, even if = is removed from the regex, rtorrent implements the handy execute2 RPC endpoint.

Another bypass? We are inside a XML document, so execut&#101; is valid. Blacklisting sucks, don't do it :-)

httprpc

It simplifies system setup and adds a layer of security. (There is no /RPC2 mount point exposed with this plugin. (This does not mean you can remove the $XMLRPCMountPoint entry from your ruTorrent/conf/config.php file.)

The promise of an extra layer of security caught our eyes. The initial processing of the request body is quite the same, and then arrays are filled with this data:

<?php
$vars = explode('&', $HTTP_RAW_POST_DATA);
foreach($vars as $var)
{
    $parts = explode("=",$var);
    switch($parts[0])
    {
        case "cmd":
        {
            $c = getCmd(rawurldecode($parts[1]));
            if(strpos($c,"execute")===false)
                $add[] = $c;
            break;      
        }
        case "s":
        {
            $ss[] = rawurldecode($parts[1]);
            break;
        }
        case "v":
        {
            $vs[] = rawurldecode($parts[1]);
            break;
        }
        case "hash":
        {
            $hash[] = $parts[1];
            break;
        }
        case "mode":
        {
            $mode  = $parts[1];
            break;
        }
        case "cid":
        {
            $cid  = $parts[1];
            break;
        }
//...
switch($mode)
{
    case "list":    /**/
    {
        $cmds = array(
            "d.get_hash=", /* ... */ "d.is_multi_file="
            );
        $cmd = new rXMLRPCCommand( "d.multicall", "main" );
        $cmd->addParameters( array_map("getCmd", $cmds) );

First thoughts: import is not forbidden, unlike the last blacklist we saw. The context is also slightly different: our data is now a rXMLRPCParam with the associated constraints (htmlspecialchars is called on the value so no XML entities or injection of new parameters).

A call to file.append to a file in the document root will be enough to achieve code execution.

With the knowledge of d.multicall, we can reproduce the vulnerability on other actions. For instance, fls:

<?php
case "fls": /**/
{
    $result = makeMulticall(array(
        "f.get_path=", "f.get_completed_chunks=", "f.get_size_chunks=", "f.get_size_bytes=", "f.get_priority="
        ),$hash[0],$add,'f');
    break;
}

Providing a valid hash (just add a torrent) and a malicious cmd parameter will get you another code execution vector.

rssurlrewrite

Users can create rewrite rules based on regular expressions that will get applied on links and descriptions retrieved via RSS feeds (see rURLRewriteRule).

The pattern (including modifiers) and the replacement are fully controlled and used as-it. By using the modifier e(PREG_REPLACE_EVAL), it will be possible to execute arbitrary PHP code. You don't even need to create a new rule, the checkrule action can be called directly in rssurlrewrite/action.php:

<?php
if(isset($_REQUEST['mode']))
    $cmd = $_REQUEST['mode'];
//...
    case "checkrule":
    {
        $rule = new rURLRewriteRule( 'test',
            trim($_REQUEST['pattern']), trim($_REQUEST['replacement']) );
        $href = trim($_REQUEST['test']);
        $rslt = $rule->apply($href,$href);
        $val = array( "msg"=>$rslt );
        break;
    }

Hopefully, this modifier was removed since PHP 7.0.0, but it's still not so uncommon to find old versions of this interpreter in the wild.

rutracker_check

This plugin checks the rutracker.org tracker for updated/deleted torrents.

To make it asynchronous, the useful code of this plugin is roughly split in two files: action.php, the main one, and batch_check.php, which will be called using system() and the ampersand control operator (&):

<?php
if(isset($HTTP_RAW_POST_DATA))
{
    $vars = explode('&', $HTTP_RAW_POST_DATA);
    foreach($vars as $var)
    {
        $parts = explode("=",$var);
        if($parts[0]=="hash")
            $ret[] = $parts[1];
    }
    if(count($ret))
    {
        $fname = getTempDirectory().'rutorrent-prm-'.getUser().time();
        file_put_contents( $fname, serialize( $ret ) );
        shell_exec( getPHP()." -f ".escapeshellarg(dirname( __FILE__)."/batch_check.php")." ".escapeshellarg($fname)." ".getUser()." > /dev/null 2>&1 &" );

As a reminder, getUser() is implemented as follows:

<?php
function getUser()
{
        global $forbidUserSettings;
    return( !$forbidUserSettings ? getLogin() : '' );
}

function getLogin()
{
    return( (isset($_SERVER['REMOTE_USER']) && !empty($_SERVER['REMOTE_USER'])) ? strtolower($_SERVER['REMOTE_USER']) : '' );
}

So we have two distinct issues here:

  • A path traversal regarding the destination file, which is very limited since we don't control the whole filename nor the extension.
  • A command injection, the username not being escaped in the shell_exec() call.

Bonus points: it can be exploited even if the rtorrent daemon is down.

Overall conclusions

In the current state, this software should not be exposed without a layer of authentication in front. In addition, seedbox hosting services should properly isolate users by running each daemon / interface with distinct UIDs or in different user namespaces.

A lot of work will be required to harden the roots of this project: the multi-users system relying on HTTP Basic Authentication is unsafe if users are allowed to choose usernames containing special characters or if no authentication is in place, the rutorrent layer is not restrictive enough with RPC commands forwarded to the daemon, etc.

Future researchers could be interested in reviewing other attack surface that was set aside for this run: what could happen when downloading a malicious torrent? Various modules can interact with the downloaded files (spectrogram, unpack, screenshots, etc.).

These findings were reported to the project's maintainer, which acknowledged them and fixed them in a timely manner — thanks, @Novik! These commits are not tagged yet, so you need to upgrade to master or backport them by hand. Also, we did not had time to review all the fixes, so feel free to give it a try.

A fix that would prevent the execution of dangerous rtorrent RPC methods is waiting and was never merged, which suggests that bugs in rutorrent's RPC code are still a viable exploitation vector and that what was fixed after our review is not enough to consider rutorrent safe when reachable by untrusted users.

Seed securely ;)