PHP 安全开发指南
关于 PHP 安全,我所找到的最好资源是 Paragon Initiative 出品的 2018 年构建安全 PHP 软件指南。
1. Web 应用安全基础
对于每一位 PHP 开发者来说,学习 Web 应用安全的基础知识至关重要。这些知识大致可以归结为以下几个核心主题:
- 代码与数据的分离 (Code-data separation)
- 当数据被当作代码执行时,就会产生 SQL 注入、跨站脚本攻击 (XSS)、本地/远程文件包含等漏洞。
- 当代码被当作数据打印时,就会产生信息泄露(源码泄露,或者在 C 语言程序中,泄露足以绕过 ASLR 机制的信息)。
- 应用逻辑 (Application logic)
- 缺失的身份验证或授权控制。
- 输入验证不严。
- 运行环境 (Operating environment)
- 老旧的 PHP 版本。
- 存在漏洞的第三方库。
- 操作系统自身的漏洞。
- 密码学弱点 (Cryptography weaknesses)
- 弱随机数生成器。
- 选择密文攻击。
- 侧信道信息泄露。
互联网上潜伏着许多准备随时利用你 Web 应用漏洞的恶意攻击者。采取必要的预防措施来加固你的应用安全非常重要。幸运的是,OWASP (开放 Web 应用程序安全项目) 的专家们已经汇编了一份详尽的已知安全问题列表及防护方法。对于有安全意识的开发者来说,这是必读内容。此外,Padraic Brady 编写的《Survive The Deep End: PHP Security》也是一份非常优秀的 PHP 安全指南。
延伸阅读: 阅读 OWASP 安全指南
2. 密码哈希 (Password Hashing)
几乎所有人最终都会开发一个需要用户登录的 PHP 应用。用户名和密码会被存入数据库,并在用户后续登录时用于身份验证。
在将密码存入数据库之前,对其进行正确的哈希处理 (Hashing) 至关重要。哈希 (Hashing) 和加密 (Encrypting) 是两个经常被混淆的完全不同的概念。
- 哈希 (Hashing): 是一种不可逆的单向函数。它会生成一串固定长度的字符串,且在计算上不可能被还原。这意味着你可以将用户输入的哈希值与数据库中的哈希值进行对比,来判断它们是否来源于同一个原始字符串,但你绝对无法从哈希值推导出原始密码。如果密码没有经过哈希处理,一旦数据库被未授权的第三方攻破,所有用户的账号就全都沦陷了。
- 加密 (Encrypting): 与哈希不同,加密是可逆的(前提是你拥有密钥)。加密在其他领域非常有用,但对于安全存储密码来说,这是一个非常糟糕的策略。
在进行哈希之前,必须通过向每个密码添加一个随机字符串来进行单独的加盐 (Salted)。这可以有效防止字典攻击和“彩虹表”(一种针对常见密码的预计算加密哈希反向查找表)攻击。
由于用户经常在多个服务中使用相同的密码,且密码强度往往很弱,因此哈希和加盐是生死攸关的防御手段。
此外,你应该使用专用的密码哈希算法,而不是快速、通用的加密哈希函数(例如 SHA256)。截至 2018 年 6 月,可接受的密码哈希算法简表如下:
- Argon2 (在 PHP 7.2 及更高版本中可用)
- Scrypt
- Bcrypt (PHP 原生提供支持,见下文)
- PBKDF2 (配合 HMAC-SHA256 或 HMAC-SHA512)
幸运的是,如今的 PHP 让这一切变得非常简单。
2.1 使用 password_hash 进行密码哈希
PHP 5.5 引入了 password_hash() 函数。目前它默认使用 BCrypt,这是当前 PHP 支持的最强算法。不过在未来,随着安全需求的变化,它会自动更新以支持更强大的算法。(注:对于 PHP >= 5.3.7 的旧系统,可以使用 password_compat 库来实现向前兼容。)
下面是一个示例:我们对一个字符串进行哈希处理,然后用一个新的字符串去比对这个哈希值。因为两个源字符串不同('secret-password' 对比 'bad-password'),所以登录会失败。
<?php
// require 'password.php'; // (如果是旧版 PHP 才需要引入兼容库)
// 1. 注册时:生成密码哈希 (PASSWORD_DEFAULT 目前底层就是 BCrypt)
$passwordHash = password_hash('secret-password', PASSWORD_DEFAULT);
// 2. 登录时:验证用户输入的密码
if (password_verify('bad-password', $passwordHash)) {
// 密码正确,允许登录
} else {
// 密码错误,拒绝登录
}极其方便的一点是:password_hash() 会自动为你处理加盐。这个盐值连同算法信息和“成本(cost)”参数一起,会被直接打包存储在最终生成的哈希字符串中。password_verify() 在验证时会自动提取这些信息来确定如何校验密码,因此你完全不需要在数据库中额外建一个字段来存储盐值。
延伸阅读:
- 学习 password_hash() 函数
- PHP >= 5.3.7 且 < 5.5 的 password_compat 兼容库
- 密码学中的哈希概念
- 学习 Salt (盐) 的概念
- PHP password_hash() 的 RFC 提案
3. 数据过滤 (Data Filtering)
永远(绝对永远)不要信任进入到你 PHP 代码中的外部输入。 在代码中使用任何外部输入之前,必须始终对其进行清理 (Sanitize) 和验证 (Validate)。你可以使用 filter_var() 和 filter_input() 函数来清理文本并验证文本格式(比如验证电子邮件地址)。
外部输入可能来自任何地方:$_GET 和 $_POST 表单提交的数据、$_SERVER 超全局变量中的某些值,甚至是包含在 fopen('php://input', 'r') 中的 HTTP 请求体。请记住,外部输入绝不仅仅限于用户提交的表单数据。 上传和下载的文件、Session 值、Cookie 数据以及来自第三方 Web 服务的数据,通通都是外部输入。
即便外部数据被你存进了数据库,并在稍后取出组合使用,它依然是外部输入。每次在代码中处理、输出、拼接或包含数据时,都要问自己一个问题:这数据过滤好了吗?可以信任吗?
根据数据的最终用途,必须采用不同的过滤机制。
- 防御 XSS: 例如,如果将未过滤的外部输入直接输出到 HTML 页面中,它可能会在你的网站上执行恶意的 HTML 和 JavaScript 代码!这就是臭名昭著的跨站脚本攻击 (XSS),非常危险。防止 XSS 的一种方法是:在将所有用户生成的数据输出到页面之前,使用
strip_tags()函数剥离 HTML 标签,或者使用htmlentities()或htmlspecialchars()函数将具有特殊含义的字符转义为对应的 HTML 实体。 - 防御命令注入: 如果要将输入作为参数传递给命令行执行,这极其危险(通常是个坏主意),但如果你非做不可,必须使用内置的
escapeshellarg()函数来清理执行命令的参数。 - 防御目录遍历: 如果接受外部输入来决定从文件系统中加载哪个文件,攻击者可能会通过将文件名更改为文件路径来进行利用。你必须从文件路径中移除
/、../、空字节 (null bytes) 等字符,确保它无法加载隐藏的、非公开的或敏感的文件。
延伸阅读:
- 学习 数据过滤
- 学习 filter_var
- 学习 filter_input
- 学习 处理空字节 (Null bytes)
3.1 清理 (Sanitization)
清理是指从外部输入中移除(或转义)非法或不安全的字符。
例如,在将外部输入包含进 HTML 或插入到原生 SQL 查询之前,你必须对其进行清理。当你使用 PDO 预处理语句中的绑定参数时,PDO 会自动为你进行 SQL 层面的清理。
有时,在将输入输出到 HTML 页面时,业务要求必须允许一些安全的 HTML 标签存在(比如富文本编辑器)。这非常难做,因此许多人选择使用更受限的格式(如 Markdown 或 BBCode)来避开这个难题。如果必须处理 HTML,请务必使用基于白名单的清理库,比如 HTML Purifier。
(参见 PHP 官方的 清理过滤器 / Sanitization Filters)
3.2 反序列化漏洞 (Unserialization)
对来自用户或其他不可信来源的数据执行 unserialize() 是极其危险的。
这样做允许恶意用户实例化任意对象(并带有用户自定义的属性)。即使这些对象本身在代码中没有被直接调用,它们的析构函数 (destructors) 依然会在生命周期结束时被执行,从而引发远程代码执行 (RCE) 漏洞。
因此,你应该绝对避免反序列化不可信的数据。
如果需要在客户端和服务端之间传递序列化数据,请务必使用安全、标准的数据交换格式,例如 JSON(通过 json_decode 和 json_encode 处理)。
3.3 验证 (Validation)
验证是为了确保外部输入正是你所期望的格式。例如,在处理注册提交时,你可能需要验证对方输入的是不是一个合法的电子邮件地址、电话号码或合理的年龄数字。
(参见 PHP 官方的 验证过滤器 / Validation Filters)
4. 配置文件安全 (Configuration Files)
在为应用程序创建配置文件时,最佳实践建议遵循以下原则:
- 脱离 Web 根目录: 建议将你的配置信息存放在文件系统无法被外部直接访问的地方(即
DocumentRoot之外),然后通过 PHP 脚本包含进来。 - 强制使用 .php 后缀: 如果你必须将配置文件存放在
DocumentRoot内,请务必使用.php作为文件后缀名。这样即使攻击者猜测到了文件路径并直接访问,PHP 引擎也会尝试执行它,而不是将其作为纯文本(包含数据库密码)直接输出到浏览器。 - 权限控制: 配置文件中的信息应得到妥善保护,手段包括数据加密或极其严格的服务器组/用户文件系统权限。
- 切勿提交到代码库: 这是一个极其重要的好习惯。永远不要将包含敏感信息(例如数据库密码、云服务 API Token)的配置文件提交到版本控制系统(如 Git)。
5. Register Globals (历史遗留警告)
注意: 从 PHP 5.4.0 开始,register_globals 设置已被彻底移除,无法再使用。这里提及仅仅是为了警告那些正在升级极其古老的遗留应用程序的开发者。
开启 register_globals 配置后,各种类型的变量(包括来自 $_POST、$_GET 和 $_REQUEST 的数据)会自动注册并暴露在应用程序的全局作用域中。
这极其容易引发致命的安全问题,因为你的应用程序根本无法有效分辨数据到底是从哪里来的。
例如:通过 URL 传递 ?foo=hacker 会导致 $_GET['foo'] 被自动注册为全局变量 $foo,这甚至可能覆盖掉你代码中原本已经声明好的 $foo 变量。
如果你还在使用 PHP < 5.4.0 的版本,请务必确保 register_globals 处于关闭状态 (Off)。
6. 错误报告配置 (Error Reporting)
错误日志在排查应用程序的问题时非常有用,但如果将错误信息直接输出到浏览器,就会向外部世界暴露应用程序的敏感结构信息(比如数据库表名、文件绝对路径)。为了有效保护你的应用程序,你必须在开发环境和**生产环境(正式上线)**采用截然不同的配置。
6.1 开发环境 (Development)
为了在开发期间显示所有可能出现的错误以便排错,请在你的 php.ini 中进行如下配置:
display_errors = On
display_startup_errors = On
error_reporting = -1
log_errors = On传入值 -1 意味着显示所有可能的错误,即使在未来的 PHP 版本中添加了新的错误级别和常量,它也依然有效。(从 PHP 5.4 开始,E_ALL 常量的行为也与 -1 一致了)。
注:E_STRICT 错误级别常量在 5.3.0 中引入,当时不属于 E_ALL。但从 5.4.0 开始它被并入了 E_ALL。各版本报告所有错误的等效写法如下:
< 5.3: 使用-1或E_ALL= 5.3: 使用-1或E_ALL | E_STRICT> 5.3: 使用-1或E_ALL
6.2 生产环境 (Production)
为了在生产环境中隐藏可能泄露系统信息的错误,请将 php.ini 配置为:
display_errors = Off
display_startup_errors = Off
error_reporting = E_ALL
log_errors = On通过这套生产环境配置,发生错误时依然会被记录到 Web 服务器的错误日志中供你后续排查,但绝对不会显示给最终用户。
有关这些设置的更多信息,请查阅 PHP 手册: