PHP 安全






4.85/5 (46投票s)
介绍
当提供互联网服务时,您在开发代码时必须始终牢记安全性。看起来大多数 PHP 脚本并不对安全问题敏感;这主要是因为有大量的初级程序员在从事这项语言。然而,没有任何理由让您基于对代码重要性的粗略猜测而制定不一致的安全策略。一旦您在服务器上放置了任何具有经济价值的东西,就很有可能有人会尝试随意破解它。创建一个论坛程序或任何一种购物车,攻击的概率就会上升到确凿无疑。
背景
以下是一些保护您的 Web 内容的通用安全指南
不要相信表单
破解表单很容易。是的,通过使用一个愚蠢的 JavaScript 技巧,您也许可以限制您的表单,使其在一个评分字段中只允许输入数字 1 到 5。当某人关闭其浏览器中的 JavaScript 或提交自定义表单数据时,您的客户端验证就会失效。
用户主要通过表单参数与您的脚本进行交互,因此它们是最大的安全风险。其教训是什么?始终在 PHP 脚本中验证传递到任何 PHP 脚本的数据。在本文中,我们将向您展示如何分析和防范跨站脚本攻击 (XSS) ,这些攻击会劫持您的用户凭据(甚至更糟)。您还将了解如何防止可能玷污或破坏您的数据的 MySQL 注入攻击。
不要相信用户
假设您的网站收集的每一条数据都充满了有害代码。清理每一条数据,即使您确信没有人会尝试攻击您的网站。
关闭全局变量
您可能拥有的最大安全漏洞是启用了 register_globals 配置参数。幸好,在 PHP 4.2 及更高版本中,它默认是关闭的。如果 register_globals 开启,那么您可以通过在服务器的 php.ini 文件中将 register_globals 变量设置为 Off 来禁用此功能:
register_globals = Off初级程序员认为注册全局变量很方便,但他们没有意识到这种设置有多危险。启用了全局变量的服务器会自动为任何表单参数分配全局变量。为了了解这是如何工作的以及为什么危险,让我们看一个例子。
假设您有一个名为 process.php 的脚本,它将表单数据输入到您的用户数据库中。原始表单看起来像这样:
<input name="username" type="text" size="15" maxlength="64">当运行 process.php 时,启用注册全局变量的 PHP 会将此参数的值放入 $username 变量中。这比通过
$_POST['username']
或$_GET['username']
访问它们可以节省一些输入。不幸的是,这也会使您容易受到安全问题的攻击,因为 PHP 会为通过 GET 或 POST 参数发送到脚本的任何值设置一个变量,如果您没有显式初始化该变量并且不希望有人操纵它,那么这是一个大问题。以下面的脚本为例——如果
$authorized
变量为 true,它将向用户显示机密数据。在正常情况下,$authorized 变量仅在用户通过假设的 authenticated_user() 函数正确认证后才设置为 true。但是,如果您激活了 register_globals,任何人都可以发送一个像 authorized=1 这样的 GET 参数来覆盖它。
<?php // Define $authorized = true only if user is authenticated if (authenticated_user()) { $authorized = true; } ?>这个故事的寓意是,您应该从预定义的服务器变量中提取表单数据。通过已发布的表单传递到您网页的所有数据都会自动存储在一个名为
$_POST
的大数组中,所有 GET 数据都存储在一个名为$_GET
的大数组中。文件上传信息存储在一个名为$_FILES
的特殊数组中。此外,还有一个名为 $_REQUEST 的组合变量。要从 POST 方法的表单中访问 username 字段,请使用
$_POST['username']
。如果 username 在 URL 中,则使用$_GET['username']
。如果您不关心该值来自何处,请使用$_REQUEST['username']
。<?php $post_value = $_POST['post_value']; $get_value = $_GET['get_value']; $some_variable = $_REQUEST['some_value']; ?>
$_REQUEST
是$_GET
、$_POST
和$_COOKIE
数组的联合。如果您有两个或多个同名参数,请小心 PHP 使用哪一个。默认顺序是 cookie、POST,然后是 GET。推荐的安全配置选项
有几个 PHP 配置设置会影响安全功能。以下是应该为生产服务器显式使用的设置
register_globals
设置为 offsafe_mode
设置为 offerror_reporting
设置为 off。这是可见的错误报告,会在出现问题时将消息发送到用户的浏览器。对于生产服务器,请改用错误日志记录。开发服务器可以启用错误日志记录,只要它们位于防火墙后面。- 禁用这些函数:system(), exec(), passthru(), shell_exec(), proc_open() 和 popen()。
- 为 /tmp 目录(以便存储会话信息)和 Web 根目录设置
open_basedir
,这样脚本就无法访问选定区域之外的文件。expose_php
设置为 off。此功能会将包含版本号的 PHP 签名添加到 Apache 标头。allow_url_fopen
设置为 off。如果您在代码中谨慎访问文件(即,验证所有输入参数),则此项并非严格必需。allow_url_include
设置为 off。实际上,没有人会想通过 HTTP 访问 include 文件,这没有任何合理的原因。总的来说,如果您发现代码想要使用这些功能,您就不应该信任它。特别是要小心任何想要使用 system() 之类的函数的东西——它几乎肯定是有缺陷的。
在解决了这些设置后,让我们来看看一些具体的攻击以及帮助您保护服务器的方法。
SQL 注入攻击
由于 PHP 传递给 MySQL 数据库的查询是用强大的 SQL 编程语言编写的,因此您有风险通过在 Web 查询参数中使用 MySQL 来尝试 SQL 注入攻击。通过将恶意 SQL 代码片段插入到表单参数中,攻击者会尝试入侵(或禁用)您的服务器。
假设您有一个表单参数,您最终将其放入一个名为
$product
的变量中,并且您创建了一个类似这样的 SQL:$sql = "select * from pinfo where product = '$product'";如果该参数直接来自表单,请使用 PHP 的原生函数进行数据库特定的转义,如下所示:
$sql = 'Select * from pinfo where product = '"' mysql_real_escape_string($product) . '"';否则,有人可能会决定将此片段放入表单参数中:
39'; DROP pinfo; SELECT 'FOO那么
$sql
的结果是:select product from pinfo where product = '39'; DROP pinfo; SELECT 'FOO'由于分号是 MySQL 的语句分隔符,数据库会处理这三个语句:
select * from pinfo where product = '39' DROP pinfo SELECT 'FOO'
好吧,您的表就没有了。
请注意,这种特定的语法实际上无法与 PHP 和 MySQL 一起使用,因为 mysql_query() 函数每次请求只允许处理一个语句。但是,子查询仍然可以使用。
为防止 SQL 注入攻击,请执行两项操作:
- 始终验证所有参数。例如,如果某项需要是数字,请确保它是数字。
- 始终在数据上使用 mysql_real_escape_string() 函数来转义数据中的任何单引号或双引号。
注意:要自动转义任何表单数据,您可以打开 Magic Quotes。
通过限制您的 MySQL 用户权限,可以避免一些 MySQL 损坏。可以限制任何 MySQL 账户只能对选定的表执行特定类型的查询。例如,您可以创建一个只能选择行而不能做其他任何事情的 MySQL 用户。然而,这对于动态数据来说并没有太大用处,而且,如果您有敏感的客户信息,攻击者可能能够访问您不打算公开的某些数据。例如,访问账户数据的用户可能会尝试注入一些代码来访问另一个账户号,而不是当前会话分配的那个。
防止基本的 XSS 攻击
XSS 是跨站脚本的缩写。与大多数攻击不同,这种漏洞是在客户端工作的。最基本的 XSS 形式是将一些 JavaScript 放入用户提交的内容中,以窃取用户 Cookie 中的数据。由于大多数站点使用 Cookie 和会话来识别访问者,因此窃取的数据可以用来冒充该用户——当它是一个普通用户帐户时,这非常麻烦,如果它是管理员帐户,则更是灾难性的。如果您不使用 Cookie 或会话 ID,您的用户就不会受到攻击,但您仍应了解这种攻击是如何工作的。
与 MySQL 注入攻击不同,XSS 攻击难以防范。Yahoo!、eBay、Apple 和 Microsoft 都曾受到 XSS 的影响。尽管攻击不涉及 PHP,但您可以使用 PHP 来清除用户数据以防止攻击。要阻止 XSS 攻击,您必须限制和过滤用户提交到您网站的数据。正是由于这个精确的原因,大多数在线公告栏不允许在帖子中使用 HTML 标签,而是用自定义标签格式(如
[b]
和[linkto]
)替换它们。
让我们看一个简单的脚本,它演示了如何防止其中一些攻击。要获得更完整的解决方案,请使用本文后面讨论的 SafeHTML。
function transform_HTML($string, $length = null) { // Helps prevent XSS attacks // Remove dead space. $string = trim($string); // Prevent potential Unicode codec problems. $string = utf8_decode($string); // HTMLize HTML-specific characters. $string = htmlentities($string, ENT_NOQUOTES); $string = str_replace("#", "#", $string); $string = str_replace("%", "%", $string); $length = intval($length); if ($length > 0) { $string = substr($string, 0, $length); } return $string; }
此函数将 HTML 特殊字符转换为 HTML 实体。浏览器将任何经过此脚本处理的 HTML 呈现为文本,不带标记。例如,考虑此 HTML 字符串:
<STRONG>Bold Text</STRONG>
通常,此 HTML 将呈现如下:
Bold Text
然而,当通过 transform_HTML()运行时,它会呈现为原始输入。原因是在处理后的字符串中,标签字符是 HTML 实体。HTML() 的纯文本输出结果如下:
<STRONG>Bold Text</STRONG>
此函数中的关键部分是 htmlentities() 函数调用,它将 <、> 和 & 转换为其对应的实体
、
<
;>
和&
。虽然这可以处理最常见的攻击,但经验丰富的 XSS 黑客还有另一个狡猾的技巧:用十六进制或 UTF-8 对其恶意脚本进行编码,而不是使用普通 ASCII 文本,希望能绕过您的过滤器。他们可以将代码作为 GET 变量放在 URL 中,说:“嘿,这是十六进制代码,但你能帮我运行一下吗?”十六进制示例看起来像这样:
<a href="http://host/a.php?variable=%22%3e %3c%53%43%52%49%50%54%3e%44%6f%73%6f%6d%65%74%68%69%6e%67%6d%61%6c%69%63%69%6f%75%73%3c%2f%53%43%52%49%50%54%3e">
但是当浏览器呈现该信息时,它会变成:
<a href="http://host/a.php?variable="> <SCRIPT>Dosomethingmalicious</SCRIPT>
为防止此情况,transform_HTML() 会采取额外步骤,将 # 和 % 符号转换为其实体,从而阻止十六进制攻击,并转换 UTF-8 编码的数据。
最后,万一有人尝试通过非常长的输入来过载字符串,希望崩溃某些东西,您可以添加一个可选的
$length
参数来将字符串截断到您指定的 maximum 长度。
使用 SafeHTML
前一个脚本的问题在于它很简单,而且不允许任何用户标记。不幸的是,有数百种方法可以尝试将 JavaScript 偷偷传递给某人的过滤器,除了剥离某人的所有 HTML 输入外,没有办法阻止它。
目前,没有任何一个脚本能保证是牢不可破的,尽管有些比大多数都要好。 有两种安全方法:白名单和黑名单,而白名单通常更简单、更有效。
一种白名单解决方案是PixelApes 的 SafeHTML anti-XSS 解析器。
SafeHTML 足够智能,可以识别有效的 HTML,因此它可以查找并剥离任何危险的标签。它通过另一个名为 HTMLSax 的包进行解析。
要安装和使用 SafeHTML,请执行以下操作:
- 前往 http://pixel-apes.com/safehtml/?page=safehtml 并下载 SafeHTML 的最新版本。
- 将文件放在服务器的 classes 目录中。该目录包含 SafeHTML 和 HTMLSax 正常运行所需的所有文件。
- 在您的脚本中包含 SafeHTML 类文件 (safehtml.php)。
- 创建一个名为
$safehtml
的新 SafeHTML 对象。 - 使用
$safehtml->parse()
方法清理您的数据。
这是一个完整的示例:
<?php /* If you're storing the HTMLSax3.php in the /classes directory, along with the safehtml.php script, define XML_HTMLSAX3 as a null string. */ define(XML_HTMLSAX3, ''); // Include the class file. require_once('classes/safehtml.php'); // Define some sample bad code. $data = "This data would raise an alert <script>alert('XSS Attack')</script>"; // Create a safehtml object. $safehtml = new safehtml(); // Parse and sanitize the data. $safe_data = $safehtml->parse($data); // Display result. echo 'The sanitized data is <br />' . $safe_data; ?>如果您想清理脚本中的任何其他数据,则无需创建新对象;只需在整个脚本中使用
$safehtml->parse()
方法即可。可能会出现什么问题?
您可能犯的最大错误是假设此类完全阻止了 XSS 攻击。SafeHTML 是一个相当复杂的脚本,它检查几乎所有内容,但无法保证。您仍然需要执行适用于您网站的参数验证。例如,此脚本不检查给定变量的长度,以确保它适合数据库字段。它不检查缓冲区溢出问题。
XSS 黑客很有创意,并使用各种方法来达成他们的目标。只需查看 RSnake 的 XSS 教程 http://ha.ckers.org/xss.html,看看有多少方法可以尝试将代码偷偷绕过某人的过滤器。SafeHTML 项目有优秀的程序员加班加点工作来阻止 XSS 攻击,并且它有一种稳健的方法,但不能保证没有人会想出一些奇怪的新方法来绕过其过滤器。
注意:有关 XSS 攻击的强大影响的示例,请查看 http://namb.la/popular/tech.html,其中展示了创建导致 MySpace 服务器过载的 JavaScript XSS 蠕虫的分步方法。
使用单向哈希保护数据
此脚本对数据执行单向转换——换句话说,它可以生成用户密码的哈希签名,但您永远无法解密它并恢复到原始密码。为什么要这样做?应用程序在于存储密码。管理员不需要知道用户的密码——事实上,只有用户知道他或她的密码是个好主意。系统(并且只有系统)应该能够识别正确的密码;这就是 Unix 密码安全模型多年来的运作方式。单向密码安全工作原理如下:
主机系统在不知道原始密码的情况下执行此操作;事实上,原始值完全无关紧要。作为副作用,如果有人侵入您的系统并窃取您的密码数据库,闯入者将拥有大量的哈希密码,而无法反向查找以找到原始密码。当然,鉴于足够的时间、计算能力和选择不当的用户密码,攻击者也许可以使用字典攻击来找出密码。因此,不要让人们轻易接触到您的密码数据库,如果他们做到了,请让每个人都更改他们的密码。
- 当用户或管理员创建或更改帐户密码时,系统会对密码进行哈希处理并存储结果。主机系统将丢弃明文密码。
- 当用户通过任何方式登录系统时,输入的密码也会被哈希处理。
- 主机系统会丢弃输入的明文密码。
- 将新哈希的密码与存储的哈希进行比较。
- 如果哈希密码匹配,系统将授予访问权限。
加密与哈希
严格来说,这个过程不是加密。它是一个哈希,与加密不同,原因有两个:
与加密不同,数据无法解密。
有可能(但极不可能)两个不同的字符串会产生相同的哈希。不能保证哈希是唯一的,所以不要尝试使用哈希作为数据库中的唯一键之类的东西。
function hash_ish($string) { return md5($string); }md5() 函数返回一个 32 个字符的十六进制字符串,基于 RSA Data Security Inc. 的消息摘要算法(也很方便地称为 MD5)。然后,您可以将这个 32 个字符的字符串插入数据库,将其与其他 MD5 字符串进行比较,或者欣赏它 32 个字符的完美。
破解脚本
解密 MD5 数据几乎是不可能的。也就是说,非常困难。然而,您仍然需要好的密码,因为创建整个字典的哈希数据库仍然很容易。有一些在线 MD5 词典,您可以在其中输入 06d80eb0c50b49a509b49f2424e8c805 并得到“dog”的结果。因此,即使 MD5 在技术上不能解密,它们仍然容易受到攻击——如果有人获得了您的密码数据库,您可以肯定他们会咨询 MD5 词典。因此,在创建基于密码的系统时,最好的做法是让密码很长(至少六个字符,最好是八个),并且包含字母和数字。并确保密码不在词典中。
使用 Mcrypt 加密数据
如果永远不需要以可读形式查看数据,MD5 哈希就足够了。不幸的是,这并非总是选项——如果您提供加密格式存储某人的信用卡信息,您需要在以后解密它。
最简单的解决方案之一是 Mcrypt 模块,它是 PHP 的一个附加组件,可以进行高级加密。Mcrypt 库提供了 30 多种用于加密的密码,以及一个允许只有您(或可选地,您的用户)才能解密数据的密码短语的可能性。
让我们看看一些实际用法。以下脚本包含使用 Mcrypt 加密和解密数据的函数:
<?php $data = "Stuff you want encrypted"; $key = "Secret passphrase used to encrypt your data"; $cipher = "MCRYPT_SERPENT_256"; $mode = "MCRYPT_MODE_CBC"; function encrypt($data, $key, $cipher, $mode) { // Encrypt data return (string) base64_encode ( mcrypt_encrypt ( $cipher, substr(md5($key),0,mcrypt_get_key_size($cipher, $mode)), $data, $mode, substr(md5($key),0,mcrypt_get_block_size($cipher, $mode)) ) ); } function decrypt($data, $key, $cipher, $mode) { // Decrypt data return (string) mcrypt_decrypt ( $cipher, substr(md5($key),0,mcrypt_get_key_size($cipher, $mode)), base64_decode($data), $mode, substr(md5($key),0,mcrypt_get_block_size($cipher, $mode)) ); } ?>mcrypt() 函数需要几个信息:
- 要加密的数据。
- 用于加密和解锁您的数据(也称为密钥)的密码短语。
- 用于加密数据的密码,这是用于加密数据的特定算法。此脚本使用
MCRYPT_SERPENT_256
,但您可以从一系列听起来很花哨的密码中进行选择,包括MCRYPT_TWOFISH192
、MCRYPT_RC2
、MCRYPT_DES
和MCRYPT_LOKI97
。- 用于加密数据的模式。您可以使用几种模式,包括电子密码本和密码反馈。此脚本使用
MCRYPT_MODE_CBC
,即密码块链接。- 初始化向量——也称为 IV 或种子——用于为加密算法播种的额外二进制数据。也就是说,它是为了让算法更难破解而添加的一些额外内容。
- 密钥和 IV 所需字符串的长度,这因密码和块而异。使用 mcrypt_get_key_size() 和 mcrypt_get_block_size() 函数查找合适的长度;然后使用方便的 substr() 函数将密钥值截断到合适的长度。(如果密钥比所需值短,请不用担心——Mcrypt 会用零填充它。)
如果有人窃取了您的数据和密码短语,他们可以尝试不同的密码,直到找到可用的那个。因此,我们应用了额外的安全性,在使用密钥之前对其使用 md5() 函数,所以即使拥有数据和密码短语,闯入者也无法得到她想要的东西。
闯入者需要函数、数据和密码短语同时可用——如果是这样,他们很可能拥有对您服务器的完全访问权限,而您已经完蛋了。
这里有一个小的数据存储格式问题。Mcrypt 以一种丑陋的二进制格式返回加密数据,当您尝试将其存储在某些 MySQL 字段中时,会导致严重的错误。因此,我们使用 base64encode() 和 base64decode() 函数将数据转换为 SQL 兼容的字母格式并检索行。
破解脚本
除了试验各种加密方法之外,您还可以为这个脚本添加一些便利性。例如,与其每次都提供密钥和模式,不如将它们声明为包含文件中全局常量。
生成随机密码
随机(但难以猜测)字符串在用户安全方面很重要。例如,如果有人丢失了密码,并且您正在使用 MD5 哈希,您将无法也就不应该查找它。相反,您应该生成一个安全的随机密码并将其发送给用户。随机数生成的另一个应用是创建激活链接,以便访问您网站的服务。这是一个创建密码的函数:
<?php function make_password($num_chars) { if ((is_numeric($num_chars)) && ($num_chars > 0) && (! is_null($num_chars))) { $password = ''; $accepted_chars = 'abcdefghijklmnopqrstuvwxyz1234567890'; // Seed the generator if necessary. srand(((int)((double)microtime()*1000003)) ); for ($i=0; $i<=$num_chars; $i++) { $random_number = rand(0, (strlen($accepted_chars) -1)); $password .= $accepted_chars[$random_number] ; } return $password; } } ?>
使用脚本
make_password() 函数返回一个字符串,因此您只需要提供字符串的长度作为参数:
<?php $fifteen_character_password = make_password(15); ?>该函数工作原理如下:
- 该函数确保
$num_chars
是一个正整数。- 该函数将
$password
变量初始化为空字符串。- 该函数将
$accepted_chars
变量初始化为密码可以包含的字符列表。此脚本使用所有小写字母和数字 0-9,但您可以选择任何您喜欢的字符集。- 随机数生成器需要一个种子,因此它会获取一些类似随机的值。(在 PHP 4.2 及更高版本中,这不是严格必需的。)
- 该函数循环
$num_chars
次,每次迭代生成密码中的一个字符。- 对于每个新字符,脚本会查看
$accepted_chars
的长度,选择一个介于 0 和长度之间的数字,并将$accepted_chars
中该索引处的字符添加到 $password 中。- 循环完成后,该函数返回
$password
。