1

书名:构建安全的 PHP 应用
作者:(美) Ben Edmunds
译者:张庆龙

以下记录这本 PHP Web 安全小书的大致内容,对书中的知识点进行备忘。

不要相信任何用户的任何输入

SQL 注入攻击

这是一个老生常谈的话题:我们可以利用 SQL 语句本身的作用方式,使用简单的字符串拼接,就能使其执行结果偏离预期,甚至造成毁灭性后果。比如:

'UPDATE User SET name = "'. $name .'" WHERE id = 123'

若此时 $name 的值为 "; DROP DATABASE User; -- ,则拼接之后,实际执行的 SQL 语句为:

UPDATE User SET name = ""; DROP DATABASE User; -- " WHERE id = 123

此时,灾难就发生了。

注:-- 表示注释后续的语句。注意 -- 后有一个空格。这里使用 # 也可以达到注释的效果。

解决方法

  1. 在执行前进行敏感字符的过滤(不能只通过 JavaScript);

  2. 为不同的业务模块分配细颗粒度权限的数据库连接;

  3. 使用预处理和占位符,如 $db->prepare() 以及 $db->execute()

  4. 使用存储过程。但这种做法将部分业务逻辑转移到了数据库层,增加了测试和版本控制的难度。

批量赋值陷阱

使用 $_POST 中的所有字段直接作为数据库操作的数据,可能会使得攻击者通过修改表单的提交项,从而实现意外数据的修改,如:

----- xxx.html
<form action="action.php" method="post">
    <input name="username">
    <input name="password">
    
    <!-- 加入新表单项 -->
    <input name="role" value="admin" />
    
    <input type="submit">
</form>

----- action.php
$user = User::create(Input::all());

如上,如果攻击者在前端页面中加入了一条新的表单项,在后台不加区分的情况下,直接把全部数据用于数据库修改或增加,可能会造成新数据的插入或原数据的修改不如预期。如上例中,本应按照 role 的默认值创建的普通用户,此时变为了 admin 身份。

解决方法

  1. 字段映射。数据库字段、数据库视图字段、API 接口字段不完全相同,使得攻击者难以知晓数据库字段的真实名;

  2. 给可以被安全赋值的字段加上白名单、或给危险字段加黑名单。如 Laravel 中的 $fillable$guard

类型转换

PHP 的弱类型在一定程度上提升了开发效率,但也留下了安全隐患。不同类型间(包括数据库本身)的隐式转换有可能会使得数据表中的数据与预期不符。

因而我们一定要关注输入数据的类型,还包括那些在 JavaScript 处理阶段被转换的数据类型。

净化输出

转义标签

使用 htmlspecialchars()htmlentities() 对如 <, >, & 等特殊字符进行转移,使得存储于数据库中的功能性 HTML 标签不会直接输出到浏览器(实际上,这一过程在数据输入的时候也需要进行)。

转义命令

使用 escapeshellcmd()escapeshellarg() 转移命令和参数,以确保命令执行的安全可控性。

为什么要用 HTTPS

HTTPS 指 HTTP Secure 或 HTTP on SSL。HTTPS 可以保证内容的安全性,使得只有最终传递到的、具有有效证书的接收者才能得到这一内容。采用 HTTPS 可以有效地预防中间人攻击和会话劫持。关于 HTTPS 的原理科普可以参考 「也许,这样理解HTTPS更容易」

HTTPS 的局限性

普通的虚拟主机配置不能使用在 SSL 上。使用托管主机或在一个服务器上运行多个站点都会存在问题。这时需要更换为专用服务器。

此外,HTTPS 在连接阶段包含 SSL 握手用于建立连接,因此速度会变慢。但在连接建立完成之后,这个问题就不明显了。

使用 HTTPS

想要使用 HTTPS,你需要完成以下步骤:

1. 选择合适的 SSL 证书

子站点较多时,使用通配 SSL 证书。反之使用标准版即可。

2. 生成服务器证书

首先需要生成私钥:

openssl genrsa -out yourApp.key 1024

然后使用私钥生成签名:

openssl req -new -key yourApp.key -out yourApp.csr

之后需要在证书颁发机构中获取证书,通常需要使用 yourApp.csr 文件,这一步获取的证书为 yourAppSigned.crt

最后就是根据服务器的类型(Apache、Nginx 或其他)进行对应的配置。在 Apache 中为:

<VirtualHost *.443>
    # ...
    SSLEngine on
    SSLCertificateFile /your/path/to/yourAppSigned.crt
    SSLCertificateKeyFile /your/path/to/yourApp.key
    # ...
</VirtualHost>

在 Nginx 中为:

server {
    listen 443;
    # ...
    ssl  on;
    ssl_certificate /your/path/to/yourAppSigned.crt
    ssl_certificate_key /your/path/to/yourApp.key
    # ...
}

你可以使用以下方式用以正确的适配协议,如:

<link href="//assets/xx.css">

这时,当访问的 URL 为 http://xxx.com 时,该引用也会是 HTTP 协议;当访问的 URL 为 https://xxx.com 时,引用会变为 HTTPS 协议。

如何安全的存储密码

不要存储密码或可逆加密结果,要存储不可逆的哈希串。

针对哈希算法的攻击

虽然哈希方式使得密码存储变为密码值的不可逆串,消除了反向破解的可能。但仍有很多安全隐患。

查找表

虽然从哈希后的字符串反向解析是不可能的,但通过枚举的方式一个个试探仍然可以得到出正确的密码。当然,枚举是不现实的,通常的做法是存储一个查找表,表项为 密码 - 哈希串。然后通过查找的方式暴力试探和破解。

这一方式可以通过对哈希过程加「盐」进行预防,如在密码进行哈希前,于密码中插入一些字符,混合后一起哈希。

彩虹表

彩虹表在技术上与查找表类似,但其使用了数学方法用较小的内存实现了查找表。关于彩虹表可以参考 维基百科

碰撞攻击

碰撞攻击,即不同的字符串的哈希值相同。在离散数学中,此攻击又可以称为「生日攻击」,以下引用 维基百科

生日问题是指,如果一个房间里有 23 个或 23 个以上的人,那么至少有两个人的生日相同的概率要大于 50% 。这就意味着在一个典型的标准小学班级(30 人)中,存在两人生日相同的可能性更高。对于 60 或者更多的人,这种概率要大于 99% 。从引起逻辑矛盾的角度来说生日悖论并不是一种悖论,从这个数学事实与一般直觉相抵触的意义上,它才称得上是一个悖论。大多数人会认为,23 人中有 2 人生日相同的概率应该远远小于 50% 。计算与此相关的概率被称为生日问题,在这个问题之后的数学理论已被用于设计著名的密码攻击方法:生日攻击。

盐与随机

盐是为了使哈希唯一而附加在其上的东西。这意味着即使有了哈希密码表,攻击者也不能正确地匹配上密码。由此可知,盐的随机性是密码安全的一部分。

虽然 PHP 的内置函数 rand()mt_rand() 可以生成随机数,但这是使用算法生成的数字,因而没有足够的外部数据使其真正唯一。这意味着采用这两种函数生成的随机数可以被攻击者猜测。事实上,只需要知道 rand() 函数的 624 个值就可以预判之后的所有值了。

使用 /dev/random 在大多数系统中是真正随机的好方法。它会收集系统熵和环境数据,如键盘输入、硬件数据等。但这一过程会导致阻塞,使得效率极低。在这一情况下,我们可以使用 /dev/urandom,该方法在真正随机上并不够强壮,但它作为盐却足够安全。

为了使用随机而又不存储盐的具体值,对应的哈希方法中,如 crypt() ,返回的结果会包括我们采用的算法、密码的哈希值,以及盐。

哈希算法

MD5

MD5 早已被数学方法证明其并不安全。它很容易在现代硬件上产生冲突。但 MD5 也不是一无是处,配合合适的盐也可以保证哈希结果的安全。

SHA-1

同 MD5 一样,SHA-1 算法也被证明可以通过不到 2^69 次哈希产生冲突,因而是不安全的。

SHA-256/SHA-512

二者采用的核心算法几乎是一样的,但 SHA-256 使用 32 位字符,而 SHA-512 采用 64 位,二者的循环次数也不相同。

BCrypt

BCrypt 是 Blowfish 密码的衍生方法。该算法是迭代的,由于开销的关系,使其可以防止暴力破解。BCrypt 在加密纯文本密码时有 72 字符的限制,但这一算法长期以来仍没有漏洞公布,因而被认为是密码安全的。

SCrypt

SCrypt 是一个在内存方面加强的衍生算法。理论上来说,该算法在高内存消耗之下是一个更为安全的算法。

使用哈希

在 PHP 5.5 版本之后引入了新的密码哈希函数 password_hash()password_verify(),极大程度简化了密码操作流程,该函数会自动获取随机盐并进行哈希。

预防暴力破解和尝试

只要时间足够,暴力破解和尝试总会得到一个正确的结果。对于此,我们可以限制尝试的频率和次数,或者封锁敏感 IP。

升级遗留系统

对于那些使用明文或采用不安全的哈希方法存储密码的遗留系统,升级它们的方式大致分为以下两种。

在每个用户登录的时候使用新的哈希函数升级密码

如果用户该次登录的密码匹配于数据库的密码,则可以用当前密码值重新哈希,并替换掉数据库的密码。但这种被动的替换方式可能会持续很长时间(需要用户自行触发),所以数据库需要有一个标识字段,用以表示该密码是否已经置换成功。但给数据表添加字段并不容易,尤其是对于运行中的大型应用。

在原密码基础上再哈希

采用这种方式,可以选择一个时机,统一对用户密码字段进行遍历更新。看上去是一劳永逸的方法,但会使得密码验证机制效率变低。而且,现有系统会一直被早先的机制所拖累。

身份验证与权限控制

身份与权限

确保访问的页面、参与的业务请求都必须被身份验证和权限控制模块所覆盖。警惕重定向导致的权限穿透。

模糊处理

很多数据表中使用自增主键作为记录的唯一标识。并且在 Cookie 和 API 中使用这些整形值。这会造成一些隐患,建议的做法是将这些值混淆到一些字符串中,使得它们被模糊处理。

安全的文件操作

一些框架中会使用某个路径作为公开文件夹,比如 /public,这也就意味着我们可以通过对该文件夹的相对路径直接访问到其中的文件,而无视权限和身份的限制。建议的做法是将敏感的、需要安全防护的文件放置在其他路径中,使得通过 URL 无法直接访问。

缺省安全及跨站攻击

缺省安全

我们应该为验证逻辑提供缺省值,以保证在没有考虑全面之时不会引发大型漏洞。

此外,不要相信动态类型,尤其是在判断语句中,整形返回值和布尔值的隐式转换可能会造成严重的后果。

XSS 与 CSRF

XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)分别是用户过分信任网站与网站过分信任浏览器所产生的安全隐患。前者的解决方案通常是在输入和输出时进行检测和过滤,而后者通常是在提交表单中添加 token 令牌。关于这两种攻击的细节可以参见 参考链接

多次表单提交

这里涉及到 API 中的幂等性问题,指的是一次和多次对某一个资源的请求应该具有同样的副作用。基于此,创建数据的请求是不符合幂等性的。比如由于网络延迟问题,用户多次点击创建按钮,发送的合法创建请求先后抵达服务器,从而导致创建行为产生多次。这一问题在转账等业务上也比较普遍。具体可以参考 「理解HTTP幂等性」。使用之前提到的一次性的 token 令牌可以预防这一问题的出现。

条件竞争

应对并发情况,需要考虑对文件、数据库等资源的并发处理策略。必要时需要对操作的文件加锁,以及对数据库使用 ... for update 以添加悲观锁或通过版本字段实现乐观锁。可以参见 参考链接


dailybird
1.1k 声望73 粉丝

I wanna.