每个专业的 PHP 开发者都知道用户上传的文件都是极其危险的。不论是后端和前端的黑客都可以利用它们搞事情。
大约在一个月前,我在 reddit 上看了一篇 PHP 上传漏洞检测,因此, 我决定写一篇文章。用户 darpernter 问了一个棘手的问题:
尽管我将其重命名为 ‘helloworld.txt’,攻击者是否仍然能够运行他的 php 脚本?
置顶的答复是:
如果文件后缀修改为 .txt , 那么它不会被当做 php 文件执行,这样你安心了吧, 不过再三确保不是 .php.txt 的后缀上传。
不好意思, 问题的正确答案并非如此 . 虽然上面的答复并非全部错误,但显然不全面。让人惊讶的是,大多数的答案都非常相似。
我想解释清楚这个问题。所以我要讨论的东西变得有点大,我决定让它变得更大。
问题
人们允许用户上传文件,但是担心用户上传的文件在服务器上被执行。
从 php 文件如何被执行开始看。假设一个有 php 环境的服务器,那么它通常有两种方法在外部执行 php 文件。一是直接用 URL 请求文件,像 http://example.com/somefile.php。第二种是 php 现在常用的,将所有请求转发到 index.php,并在这个文件中以某种方式引入其他文件。所以,从 php 文件中运行代码有两种方式:执行文件或用 include/include_once/require/require_once 的方法引入其他需要运行的文件。
其实还有第三种方法:eval() 函数。它能将传入的字符串当做 php 代码执行。这个函数在大多数 CMS 系统中被用来执行存储在数据库里的代码。eval() 函数非常危险,但如果你用了它,通常就意味着你确认自己在做危险的操作,并确认你已经没有其他选择。实际上,eval() 有它的用途,并且在某些情况下非常有用。但如果你是新手的话,我不推荐你使用它。请看 这篇在 OWASP 的文章。我在上面写了很多。
所以,有两种方法执行文件里的代码:直接执行或者在被执行的文件中引入它。那么如何避免这种事情发生呢?
解决方法?
我们怎样才能知道一个文件包含 php 代码呢?看拓展名,如果以 .php 结尾的,像 somefile.php 我们就认为它里面有 php 代码。
如果在网站根目录下有一个 somefile.php 文件,那么在浏览器访问 http://example.com/somefile.php,这个文件就会被执行并且输出内容到浏览器上。
但是如果我重命名这个文件会怎样?如果我把它重命名为 somefile.txt 或者是 somefile.jpg 呢?我会得到什么?我会得到它的内容。它不会被执行。它会从硬盘(或者缓存)直接被发送过来。
在这点上 reddit 社区上的答案是对的。重命名能防止一个文件被非预期的执行,那么为什么我认为这种解决方法是错的呢?
我相信你注意到我在“解决方法”后面加的问号。这个问号是有意义的。现在大多数网站的 URL 上几乎看不到单独的 php 文件。并且就算有,也是人为故意伪造的,因为 URL 上需要有 .php 来实现对老版本 URL 的向后兼容。
现在绝大部分 php 代码是在运行中被引入的,因为所有请求都被发送到了网站根目录的 index.php。这个文件会根据特定的规则引入其他 php 文件。这种规则可能(或者在将来会)被恶意使用。如果你应用的规则允许引入用户的文件,那么应用会容易遭到攻击,你应该立即采取措施防止用户的文件被执行。
如何防止引入用户上传的文件?
重命名文件名可以吗?— 不,办不到!
PHP 解析器不关心文件的后缀名。事实上,所有程序都不关心。双击文件,文件会被对应的程序打开。文件后缀名只是帮助操作系统识别用什么程序打开文件。只要程序有读取文件的能力,程序就可以打开任何文件。有时程序拒绝打开和操作文件。但那并不是因为后缀名,是文件内容所致。
服务器通常被设置成执行 .php 文件并将执行结果回复输出。如果你请求图片 .jpg — 将从磁盘上原样的返回。如果你要求服务器以某种方式运行一张 jpeg 图片,会发生?服务器会执行还是不呢?
图片来源: Echo / Cultura / Getty Images
程序不关心文件名。甚至不关心文件是否有名字,也不关心它究竟是不是文件。
从文件执行 PHP 代码需要什么?
有至少两个情况可以让 PHP 执行代码:
代码介于 <?php 和 ?> 标记之间
代码介于 <?= 和 ?> 标记之间
即使文件中填充了一些奇怪的二进制数据或一些奇怪的保护名称,该标记中的代码仍然会被执行。
这里有一个图片给您:
该图片没有问题
它现在很纯净。但是您可能知道 JPEG 格式允许在文件中添加一些注释。比如,拍摄照片的相机型号或坐标地址。如果我们试图在里面放一些 PHP 代码并尝试 include 或 require 呢?让我们来看看吧!
问题! 1
下载这个图片到你的硬盘上。或者你自己去弄一张 JPEG 图片也行。你随便用什么格式的文件都无所谓。我建议用一个 JPEG 文件来演示,主要是因为它是一张图片且易于在其中进行文本编辑。我用的是一个 Windows 的笔记本,目前我手头上没有 Apple 或 Linux(或其他 UNIX 系的系统)的笔记本。所以一会我会发一个这个 OS 下的屏幕快照。但是我确信你肯定也能做这个事。
用以下这段 PHP 代码建个文件:
<h1>Problem?</h1>
<img src=”troll-face.jpg”>
<?php
include “./troll-face.jpg”;
保存一个图片命名为 troll-face.jpg
把图片和 php 脚本文件都放在同一个文件夹下
打开浏览器请求这个 php 文件
如果你把你的 php 文件命名为 index.php,然后把它放在文件根目录或者放在你网站目录下的任何一个文件目录中。
如果你准确完成了上述步骤,你就可以看到这个画面:
到此这都没毛病。没 PHP 代码展示,也没有 PHP 代码被执行。
现在,我们来添加一个问题:
打开文件属性对话框或运行一些允许编辑 EXIF 信息的应用程序
切换到 Details 选项卡或以其他方式编辑该信息
向下滚动到 camera 参数
将下面代码复制到“camera maker”字段后面:
<?php echo “<h2>Yep, a problem!</h2>”; phpinfo(); ?>
刷新页面!
很明显出现了一点问题!
您在页面上看到了该图片。相同的图片还存在页面的 PHP 代码中。图片的代码也被执行了。
我们该怎么做?!!1
长话短说: 如果我们不在程序种引入这些不安全的文件,文件中的脚本就不会执行。
仔细看下面的例子。
最终答案?
如果有人在某处看到我错了 – 请纠正我,这是一个严重的问题。
PHP 是一种脚本语言。您总是需要引用一些动态组合路径的文件。因此,为了保护服务器,您必须检查路径并防止混淆您的站点文件和用户上传或创建的文件。如果用户的文件与应用程序文件分开,则可以在使用上传或创建文件之前检查文件的路径。如果它位于您的应用程序脚本允许的文件夹中 – 那么它可以使用 include_once 或 require 或 require_once 引入这个文件。如果不是 – 那么就不引入它。
如何进行检查?这很简单。你只需要将 $folder (文件) 路径与一个允许程序引入文件 ($file) 的路径文件夹进行比较。
// 不好的例子,不要用!
if (substr($file, 0, strlen($folder)) === $folder) {
include $file;
}
如果 $folder 的存放路径是 /path/to/folder 而且 $file 的存放路径是 /path/to/folder/and/file , 然后我们在代码中使用 substr() 函数把他们的路径都变成字负串进行判断,如果文件位于不同的文件夹中 — 这个字符串将不相等。反之则反。
上面的代码有两个重要的问题。如果 file 路径是 /path/to/folderABC/and/file,很明显,该文件也不在允许引入的文件夹中。通过向两个路径添加斜杠可以防止这种情况。我们在这里向文件路径添加斜杠并不重要,因为我们只需要比较两个字符串。
举个例子: 如果 folder 路径是 /path/to/folder 并且 file 路径是 /path/to/folder/and/file,那么从 file 提取和 folder 具有相同数量的字符,那么 $ folder 将是 /path/to/folder。
再比如 folder 路径是 /path/to/folder 并且 file 路径是 /path/to/folderABC/and/file, 那么从 file 中提取 folder 具有相同数量的字符,和 $folder 一样,并且将再次成为 /path/to/folder,这种都是错误的,这不是我们期望的结果。
因此,在 /path/to/folder/ 添加斜杠后,与 /path/to/folder/and/file 的提取部分 /path/to/folder/ 相同就是安全的。
如果将 /path/to/folder/ 与 /path/to/folderABC/and/file 的提取部分 / path/to/folderA,很明显二个字符串不一样。
这就是我们期望得到的。但还有另一个问题。这并不明显。我敢肯定,如果我问你,你看到这里有一个灾难性的漏洞 – 你不会猜到它在哪里。你也许已经在经验中使用过这个东西,甚至可能就在今天。现在,您将看到漏洞是如何隐晦和显而易见。往下看。
/../
假想一个很常见的场景。
有这么一个网站。用户可以上传文件到该站点。所有的文件都位于一个特定的目录下。有一个包含用户文件的脚本。脚本自上而下进行查找是否包含用户的输入(直接或间接)路径 — 那这个脚本可以通过如下方式进行路径伪造:
/path/to/folder/../../../../../../../another/path/from/root/
举例。用户发起请求,你的脚本中包含了一个基于类似如下用户输入路径的文件:
include $folder . “/” . $_GET[‘some’]; // or $_POST, or whatever
你麻烦大了。有天用户发送一个 ../../../../../../etc/.passwd 这种或其他请求,你就哭吧。
再不然。假如有人让你的脚本加载一个他想要的文件,你就废了。它不一定就只是出现在用户文件中。它可能是你的 CMS 或你自己文件的一些插件(别相信任何人),甚至是应用程序逻辑中的错误等。
或者
用户可能会上传一个名为 file.php 的文件,你会把它和其他的用户文件一样放在一个特定的文件夹里面:
move_uploaded_file($filename, $folder . ‘/’ . $filename);
用户的文件就存放在那里,你必须常常检查从来没有包含该文件夹中的文件,目前来看,所有的东西都挺正常的。通常,用户发给你的文件不会包含斜杠或者其他特殊字符,因为这是被系统文件系统禁止的。之所以这样,是因为通常情况下浏览器发给你的文件是在真实文件系统中创建的,同时它的名字是一些真实存在的文件的名字。
但是 http 请求允许用户发送任何字符。所以如果某人伪造请求创建名为 ../../../../../../var/www/yoursite.com/index.php 的文件 — 这行代码会覆盖你的 index.php 文件,如果 index.php 处于在上述路径的话。
所有的初学者都希望通过过滤「..」或者斜杠来解决这个问题,但是这种做法是错误的,由于你在安全方面还缺乏经验。同时你必须(是的,必须)明白一个简单的事情:你永远无法在安全和密码学方面的获得足够的知识。这句话的意思是,如果你懂得了「两个点和斜杠」的漏洞,但这不代表你知道所有其他的缺陷、攻击和其他特殊字符,你也不知道在文件写入文件系统或数据库时可能发生的代码转换。
解决方案和答案
为了解决这个问题,PHP 中内置了一些特殊函数方法,只是为了在这种情况下使用。
basename()
第一个解决方案 — basename() 它从路径结束时提取路径的一部分,直到它遇到第一个斜杠,但忽略字符串末尾的斜杠,参见示例。无论如何,你会收到一个安全的文件名。如果你觉得安全 – 那么是的这很安全。如果它被不法上传利用 – 你可以使用它来校验文件名是否安全。
realpath()
另一个解决方案 — realpath() 它将上传文件路径转换规范化的绝对路径名,从根开始,并且根本不包含任何不安全因素。它甚至会将符号链接转换为此符号链接指向的路径。
因此,您可以使用这两个函数来检查上传文件的路径。要检查这个文件路径到底是否真正属于此文件夹路径。
我的代码
我编写了一个函数来提供如上的检查。我并不是专家,所以风险请自行承担。代码如下。
<?php
/**
* Example for the article at medium.com
* Created by Igor Data.
* User: igordata
* Date: 2017-01-23
* @link https://medium.com/@igordata/php-running-jpg-as-php-or-how-to-prevent-execution-of-user-uploaded-files-6ff021897389 Read the article
*/
/**
* 检查某个路径是否在指定文件夹内。若为真,返回此路径,否则返回 false。
* @param String $path 被检查的路径
* @param String $folder 文件夹的路径,$path 必须在此文件夹内
* @return bool|string 失败返回 false,成功返回 $path
*
*/
function checkPathIsInFolder($path, $folder) {
if ($path === ” OR $path === null OR $path === false OR $folder === ” OR $folder === null OR $folder === false) {
/* 不能使用 empty() 因为有可能像 “0” 这样的字符串也是有效的路径 */
return false;
}
$folderRealpath = realpath($folder);
$pathRealpath = realpath($path);
if ($pathRealpath === false OR $folderRealpath === false) {
// Some of paths is empty
return false;
}
$folderRealpath = rtrim($folderRealpath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$pathRealpath = rtrim($pathRealpath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strlen($pathRealpath) < strlen($folderRealpath)) {
// 文件路径比文件夹路径短,那么这个文件不可能在此文件夹内。
return false;
}
if (substr($pathRealpath, 0, strlen($folderRealpath)) !== $folderRealpath) {
// 文件夹的路径不等于它必须位于的文件夹的路径。
return false;
}
// OK
return $path;
}
结语。
必须过滤用户输入,文件名也属于用户输入,所以一定要检查文件名。记得使用 basename()。
必须检查你想存放用户文件的路径,永远不要将这个路径和应用目录混合在一起。文件路径必须由某个文件夹的字符串路径,以及 basename($filename) 组成。文件被写入之前,一定要检查最终组成的文件路径。
在你引用某个文件前,必须检查路径,并且是严格检查。
记得使用一些特殊的函数,因为你可能并不了解某些弱点或漏洞。
并且,很明显,这与文件后缀或 mime-type 无关。JPEG 允许字符串存在于文件内,所以一张合法的 JPEG 图片能够同时包含合法的 PHP 脚本。
不要信任用户。不要信任浏览器。构建似乎所有人都在提交病毒的后端。
当然,也不必害怕,这其实比看起来的简单。只要记住“不要信任用户”以及“有功能解决此问题”便可。
转自 PHP / Laravel 开发者社区 https://laravel-china.org/top…