我开发了一个基于 Beancount 的账本托管服务 HostedBeans,欢迎大家来了解纯文本复式记账并试用我的服务。
查看源代码

PHP进阶:1.安全

我决定写一系列教程:PHP进阶——一个论坛系统的实现,这是第一章。

1.0 安全

我们将安全放到了最前边,对于Web应用来说,安全的重要性不言而喻,如果你编写的程序存在漏洞,攻击者将有可能取得服务器的控制权,进行破坏,甚至利用你的站点进一步散播木马。

有些人认为测试性的代码,位于内网的应用,可以暂时不必关心安全,这是非常不负责任的。你很难预见这段代码什么时候会被作为你的应用的一部分,保险的做法是,为你写下的每一段代码负责,通过本章提出的几项原则,这并不麻烦。

  • SQL 注入
  • Shell 注入
  • Eval
  • XSS
  • CSRF
  • 关注官方通告
  • 最小权限原则
  • 中间人攻击
  • Dos 攻击
  • 隐藏信息

SQL 注入

如果你的应用需要将一个包含用户输入的字符串作为代码(如SQL)执行,那么攻击者可以通过构造巧妙的输入,来将额外的代码注入到要执行的字符串中,实现攻击.

下面举一个 SQL 注入的例子, 如果有如下的用户验证的代码:

$result = $db->query("SELECT * FROM `users` WHERE (`name` = '{$_POST['user']}') and (`passwd` = '{$_POST['passwd']}')");
if($result->fetch())
    echo "登录成功";

这段代码从 users 表中查询用户名为 $_POST[‘user’] 且密码为 $_POST[‘passwd’]的记录,如存在这样的记录,就认为登录成功。

但攻击者可以通过将 user 和 passwd 分别构造为如下的字符串进行攻击:

admin
1' OR '1'='1

这样一来,实际执行的SQL变成了这个样子:

SELECT * FROM `users` WHERE (`name` = 'admin') and (`passwd` = '1' OR '1'='1')

就可以绕过验证,实现以管理员登录.

产生SQL注入的原因在于单引号在 SQL 中具有特殊含义,单引号标识着一段字符串的开始和结束,如果用户输入中含有单引号,那么该字符串就会提前结束,剩下的部分就会作为SQL命令来解析.

所以,我们有必要对进入 SQL 语句的所有用户输入进行转义,所谓转义就是让代码中具有特殊含义的字符,失去特殊含义,仅仅表示它本身.

具体来说,我们在SQL中需要转义的字符有:

  • 反斜杠 \
  • NUL \0
  • 换行 \n
  • 回车 \r”
  • 单引号 ‘
  • 双引号 “
  • Ctrl+Z \x1a

还有一些和字符集相关的字符(在特定的字符集中,会有一些同义字).

所幸我们不必自己做这些工作,MySQL提供了一个名为 mysql_real_escape_string() 的函数用于将字符串转义,以安全地嵌入SQL.

而 PDO[1] 里同样有 PDO::quote() 完成同样的工作。

值得注意的是 mysql_real_escape_string() 和 PDO::quote() 的行为有细微的区别,前者只是转义字符串,后者在转义后还会在字符串前后加上单引号。

经过修改的代码会是这样:

$user = $db->quote($_POST['user']);
$passwd = $db->quote($_POST['passwd']);
$result = $db->query("SELECT * FROM `users` WHERE (`name` = {$user}) and (`passwd` = {$passwd})");
if($result->fetch())
    echo "登录成功";

如果攻击者继续构造类似之前的注入字符串,那么数据库会去查询用户名为 admin 且密码为 1' OR '1'='1 的记录,数据库仅仅将这段注入字符串视为它字面的意思,不会作为命令来解析,这样攻击者就不会得逞了.

所以,记住我们的第一个原则:

如果你需要将一段字符串作为代码来执行,那么要对所有进入字符串的用户输入进行转义

用户输入不仅仅在于 $_GET 和 $_POST, 包括HTTP Header, Cookie, 文件都是用户输入。

[1]: PHP Data Object, PHP的新式数据库访问接口, 后文会讲解.

更多SQL注入的例子参见: http://php.net/manual/zh/security.database.sql-injection.php

Shell 注入

如有如下代码:

shell_exec("rm /data/files/{$_POST['file']}");

这段代码会根据用户输入,删除 /data/file/ 下的指定文件.

既然你已经知道了我们的第一个原则,显然我们要对输入进行转义。
于是我们找到了 escapeshellarg(), 它会对进入Shell的参数进行转义,添加空格。

经过修改的代码:

$arg = escapeshellarg("/data/files/{$_POST['file']}");
shell_exec("rm {$arg}");

如果攻击者输入 xxoo;rm -rf /, 那么实际执行的命令是:

rm '/data/files/xxoo;rm -rf /'

可以看到攻击者没有成功。

但是攻击者可以继续尝试输入 ../../etc/passwd, 这下你傻眼了, 因为执行了:

rm '/data/files/../../etc/passwd'

相当于:

rm '/etc/passwd'

删掉了系统的用户数据库.

所以,我们除了要对用户输入进行转义之外,还需要 从逻辑上对输入进行检验

在前面SQL注入的例子中,这种检验是不必要的,因为我们知道数据库里面用户的密码不可能恰巧等于攻击者构造的恶意字符串。

但在这个Shell注入的例子中,我们有这个必要:

if(strstr($_POST['file'], "/") !== false)
{
    echo "文件名非法";
}
else
{
    $arg = escapeshellarg("/data/files/{$_POST['file']}");
    shell_exec("rm {$arg}");
}

这样我们便安全了,如果文件名包含了斜杠,程序会拒绝执行命令.

在Linux下,文件名可以使用除斜杠外的所有字符,如果需要迁移到Windows下,我们还需要做更多工作。

所以请记住我们的第二个原则:

不要相信用户的输入,考虑到每一种可能性

你最好把编写代码的过程,看作“证明这段代码不会出错”的过程。

Eval

包括 PHP 在内的大多数脚本语言都提供了类似 eval 的函数,可以将字符串作为代码来执行。同样,在使用 eval() 时我们也需要考虑注入攻击。

但不同于 SQL 和 Shell, 使用 eval() 的大多数场合都是可以避免的,你需要的可能是匿名函数等机制(后文会介绍), 所以在这里不推荐使用 eval() 函数。

XSS

Cross-site scripting, 跨站脚本攻击,或者你可以认为是 HTML 注入,这种攻击并非针对服务器,而是针对访客。

大多数网页的输出都是 HTML, 这意味着你的 PHP 程序的输出会作为 HTML 代码来显示在用户的浏览器中,而 HTML 中嵌入的 JS 脚本则会被用户的浏览器执行。

例如一个论坛系统显示帖子的代码:

<?php
$post = getPostData($_POST['id']);
?>
<html>
    <head>
        <title><?= $post['title'];?></title>
    </head>
    <body>
        <?= $post['content'];?>
    </body>
</html>

如果有一个帖子的内容是下面这样呢:

<span style="color: red;">帖子内容</span>

显然文字会被显示为红色,这意味着如果帖子中含有 HTML 代码,将会被直接执行,在这个例子中这段 HTML 代码可以认为是无害的,但如果是嵌入一段脚本呢:

<script>alert("帖子内容");</script>

浏览器会执行一段 JS 代码!如果你认为这除了开个玩笑外也是无害的,那么请看下面的输入:

<script>
    $.post("/topic/create/", {"title": "点进来看一看啊~", "content": "<script>" + $("body script:first").html() + "</script>"});
</script>

这段代码会自动发布一个新帖子,其内容就是这段代码本身!这样以来,这个“无害的玩笑”会在整个论坛传播开。

XSS 有持久性和非持久性(反射型)之分,前面介绍的例子属于持久性 XSS, 即已经将攻击代码保存在数据库中,其他人访问这些信息时,HTML代码就会被执行。

而非持久性的意思就是注入代码并未被持久性的保存,而是存在于 URL 中,如:

http://xxoo.xo/show.php?id=<script>alert();</script>

对应的 PHP 代码:

if(!is_numeric($_GET['id']))
    echo "{$_GET['id']} 不是一个数字";

如果你轻易点击了这个连接,那么你将会被执行一段脚本,危险性不再复述。

你想说不会有人点这么显然的链接?其实它可以很隐蔽:

http://xxoo.xo/show.php?id=%3Cscript%3Ealert()%3B%3C%2Fscript%3E

甚至:

http://xxoo.xo/show.php?id=%3C%73%63%72%69%70%74%3E%61%6c%65%72%74%28%29%3B%3C%2F%73%63%72%69%70%74%3E

既然是注入 HTML,那么对策并不复杂:对所有输出到 HTML 的数据进行转义,使用 htmlspecialchars(), 如:

if(!is_numeric($_GET['id']))
{
    $id = htmlspecialchars($_GET['id']);
    echo "{$id} 不是一个数字";
}

好吧,其实前面那么多铺垫这是为了让你了解 XSS 的重要性,当下它们甚至比 SQL 注入更加流行。

CSRF

Cross-site request forgery, 跨域请求伪造。

浏览器本身为 JS 制定的一个“跨域保护”规则,保证了 JS 仅能访问当前站点下的资源。

规则即一个域名下的 JS 无法使用 XHR[4] 访问另一个域名下的资源。

但这个限制并不包括 <img>, <script> 等标记的 src 属性,而且这也不现实,因为一个站点难免引用其他站点的图片等资源。

现在很多站点都使用类似于 /user/logout/ 的链接来实现注销登录的功能。如果有这样一段代码:

<img src="//xxoo.xo/user/logout/" />

那么如果用户访问了这个页面,它在 xxoo.xo 网站就会被注销登录,无论当前站点是不是 xxoo.xo !

这样攻击是非常隐蔽的,因为如果图片加载失败了也只不过是一个小叉而已,还可以通过 CSS 隐藏起来。

上面的例子也许可以认为是无害,因为被注销登录也算不得什么大不了的事情,但如果你继续使用 GET 方式响应非修改性的请求的话,事情就没那么简单了。

记住: 所有修改性的操作都使用POST方式

[2]: XMLHttpRequest, JS 无刷新请求技术的 API 接口。

关注官方通告

有时候,漏洞并不在于我们编写的代码,而在于我们所使用的软件本身,如PHP, 或者Linux, MySQL等等。

虽然这些软件的代码审核和发布过程是非常严谨的,但也难免有漏网之鱼。一些严重的漏洞会在一天之间被传播,利用和修复。

对此我们没有什么对策,毕竟这些软件不是我们自己写的,我们能做的但只有:

  • 使用官方的稳定发行版
  • 及时关注官方通告,安装补丁

在PHP领域,有很多问题是官方在反复提醒,但仍广泛存在的,我们在此指出几点:

关闭 Register Globals

这是 php.ini 中的一个选项(register_globals), 开启后会将所有表单变量($_GET和$_POST)注册为全局变量.

看下面的例子:

if(isAuth())
    $authorized = true;
if($authorized)
    include("page.php");

这段代码在通过验证时,将 $authorized 设置为 true. 然后根据 $authorized 的值来决定是否显示页面.

但由于并没有事先把 $authorized 初始化为 false, 当 register_globals 打开时,可能访问 /auth.php?authorized=1 来定义该变量值,绕过身份验证。

该特征属于历史遗留问题,在 PHP4.2 中被默认关闭,在 PHP5.4 中被移除。

关闭 Magic Quotes

对应 php.ini 中的选项 magic_quotes_gpc, 这个特征同样属于历史遗留问题,已经在 PHP5.4 中移除。

该特征会将所有用户输入进行转义,这看上去不错,我们之前提到过要对用户输入进行转义。

但是 PHP 并不知道哪些输入会进入 SQL , 哪些输入会进入 Shell, 哪些输入会被显示为 HTML, 所以很多时候这种转义会引起混乱。

最小权限原则

让我们试想如果 PHP 出现了一个验证漏洞,导致可以任意执行Shell命令,这将会多么恐怖。事实上这种漏洞在各种服务器软件上时有发生。

虽然我们无法阅读和修改 PHP 的源代码,但我们可以通过给予 PHP 最小的权限,来减少漏洞被利用后的损失。

很多教程在遇到文件权限问题的时候,会很不负责任地让你将文件夹权限设置为 777, 即最宽松权限。

在Linux下,每个文件,每个进程都对应着一个用户,非 root 用户只能控制自己的文件,自己的进程,自己的进程创建的文件也是属于自己的。

每个文件有 9 个权限位,分别对应 自己, 同组用户, 其他用户读取, 修改, 执行 权限

基于这种机制,我们可以将服务器的不同功能划分为不同的用户,实现隔离,即使一个服务被攻击者控制,也不会影响到其他的服务。

事实上各个发行版也是这么做的,Apache2, PHP-FPM[2] 大多是以 www-data 或类似的用户运行的,即使被拿到权限,也只能控制网站的部分,无法执行需要 root 权限的系统级命令。

我们可以进一步对权限进行细分,如果应用不需要写入文件,那么给 Web 目录设置只读权限就可以了。

储存静态资源(图片, CSS, JS等)的目录,不要允许 PHP 执行。

甚至让每一个站点都以单独的用户来运行[3].

如果服务器仅提供 Web 服务,那么防火墙仅开放 80 端口。

对于数据库以及其他服务,我们也需要给予应用最小的权限,为每个应用建立单独的账户,只赋予每个应用读写自己的数据库的权限。

[3]: PHP 在 Apache2 下可能依附于 Apache2进程,在 Nginx 则大多用 PHP-FPM 方式运行。

[4]: Apache2 可使用 apache2-mpm-itk, PHP-FPM 可使用配置文件中的 user 选项。

中间人攻击

在 Web 领域,中间人攻击即服务器和用户之间,存在着一个可以双向篡改数据包的 中间人,它有可能是网络中的一个路由节点,也可能是一个钓鱼网站。

说实话这已经是网络传输层面的问题了,和 PHP 没多大关系,解决中间人攻击的唯一途径就是使用 https 协议,SSL 除了可以防御中间人攻击外,还可以让你的数据以加密的形式传输(HTTP是明文传输的), 目前网络上已经有了一些免费的证书颁发机构(CA), 如 StarSSL.

DoS 攻击

Denial of Service, 阻断服务攻击,通过大量垃圾请求迫使服务器无法工作。

如果你的网站让攻击者无处下手,那么攻击者可能会使出最野蛮的攻击方式,DoS, 比如使用脚本快速访问你的站点,让你的服务器不堪重负。

首先你应当避免你的站点出现性能瓶颈,尽量通过缓存(后文会讲解)来提供内容,减少请求处理时间。

设置 Web 服务器的最大进程数,避免进程过多导致内存不足。

还可以使用如 DDoS deflate[5] 的脚本,自动对攻击 IP 进行封禁,这对单点 DoS 非常有效。

如果攻击者不计成本地使用流量“堵”你的线路,那么你可以选择一些知名的 CDN 服务来过滤请求,隐藏服务器的真实 IP 地址。

隐藏信息

虽然隐藏信息是一种比较消极的防御手段,但也能为攻击者添加一些障碍。

值得隐藏的信息包括:Web服务器版本,脚本引擎(PHP)版本,错误信息等等。

撰写评论

如希望撰写评论,请发邮件至 jysperm@gmail.com 并注明文章标题,我会挑选对读者有价值的评论附加到文章末尾。

精子生于 1995 年,英文 ID jysperm.

订阅推送

通过 Telegram Channel 订阅我的博客日志、产品和项目的动态:

王子亭的博客 @ Telegram


通过邮件订阅订阅我的博客日志、产品和项目的动态(历史邮件):

该博客使用基于  Hexo  的  simpleblock  主题。博客内容使用  CC BY-NC-ND  授权发布。最后生成于 2023-12-20.