配置安全的共享web服务器(抛砖引玉)

本文所讲的共享web服务器,并非共享文件的服务器,而是多人一起使用的web服务器,各有各自的网站、管理自己的文件,互不干涉,且对系统无影响。鉴于功力较浅,只敢对较信得过的朋友开放这种账号,本文涉及的范围也有限,所以安全漏洞可能还有,请诸位切勿直接用于生产环境。

服务器环境:Ubuntu 8.10, OpenSSH_5.1p1 Debian-3ubuntu1, Apache 2.2.9, PHP 5.2.6-2ubuntu4

登录 – SFTP

传统的 FTP 肯定是不如这个安全,telnet 更不用说了。使用 SFTP 还有一个起始想法是想配置证书自动登录,后来发现 SFTP 客户端(FileZilla)没这功能,就没再作下去,命令行下 scp 的自动登录倒是 和 ssh 的一样很好配置。

网上很多文章介绍把 sftp 用户限制在 $HOME 目录下的方法,使用的是 sshd 的 ChrootGroups 选项,这个选项在我的版本里没有找到,找到另外一篇参考文章使用的是 ChrootDirectory,也很好用。

创建一个用户组,作为所有 sftp 用户的用户组:

$ sudo groupadd sftp

创建用户,设置密码,并归入 sftp 组:

$ sudo useradd -m friend
$ sudo passwd friend
$ sudo usermod -g sftp friend

为了进一步增强安全性,还可以将用户的登录 shell 设置为 /bin/false,是个好习惯,但在本例中并非必须,下面的 sshd 设置也会让用户无法登录 shell (我观察的结果)。

$ sudo usermod -s /bin/false friend

下来就要配置 sshd 了,编辑配置文件 /etc/ssh/sshd_config

# 修改下面这句
#Subsystem sftp /usr/lib/openssh/sftp-server
Subsystem sftp internal-sftp

然后在此配置文件末尾添加:

Match group sftp
    X11Forwarding no
    ChrootDirectory %h
    AllowTcpForwarding no
    ForceCommand internal-sftp

配置含义大概为:凡是 sftp 组的用户,关闭 X 转发,chroot 到 $HOME 目录,关闭 TCP 转发(无法使用隧道了?),强制使用 internal-sftp(这个不明白)。

现在,重启 ssh 服务,用户就只能通过 sftp 访问 /home/friend 下的文件了。

PS: 我发现 sshd 如果配置错误,在 restart 服务的时候会先检查,而不是直接 stop 服务然后在 start 的时候出现错误,搞得服务启不来。大概是考虑到很多人都是远程 ssh 上来进行维护,服务 down 了以后就麻烦了,很贴心的设置。

Apache & PHP

Apache 配置简单,创建 /home/friend/www 目录,约定网站文件都放在这个目录下,然后弄个 Alias 指向就可以了。

但有一个极大的安全隐患需要堵上,用户可以通过编写 PHP 程序,读取系统中任何 www-data 用户有权限访问的文件,包括系统的 shadow 文件,包括 其它用户的网站文件等等。解决这个问题,一种是开启 PHP 的 safe_mode ,安全模式下 PHP 将只能访问 owner 为自己(也就是 www-data)的文件;另外一种是使用 open_basedir,这将限制 PHP 只能打开某一目录树下的文件,并且不可能通过符号链接避开此限制。显然 safe_mode 的副作用太多,后一种方法更适合我的这种情况,配置写到 Apache 的 conf 里就行了:

<Directory /home/friend>
    php_admin_value open_basedir "/home/friend/"
</Directory>

注意open_basedir 后面的参数只代表文件路径的前缀,所以要带上末尾的斜杠,明确指出是目录。

不使用 safe_mode 的另外一个原因是在未来的 PHP6 里就要删掉它了。

缺点

最大的缺点就是 sftp 用户无法自己更改密码,除非自己写个守护程序啥的。这个程序在写的时候要非常小心,因为操作的是系统用户文件,如果遗留有安全漏洞可能会使别人获得其它用户权限。一个折中的方法是写个程序,定期更改密码并通过邮件告知用户,虽不方便但安全性要好一些。

由一个错误学到的一些php安全配置问题

错误

在MediaTemple主机从(dv)3.0升级到3.5之后,我遇到的第一个问题就是一个莫名奇妙的php错误:

[Sat Jul 12 04:51:27 2008] [error] [client 121.42.26.81] PHP Warning:  require_once(/var/www/vhosts/fwolf.com/include/fwolflib/func/config.php) [<a href='function.require-once'>function.require-once</a>]: failed to open stream: Operation not permitted in /var/www/vhosts/fwolf.com/httpdocs/info.php on line 4
[Sat Jul 12 04:51:27 2008] [error] [client 121.42.26.81] PHP Fatal error:  require_once() [<a href='function.require'>function.require</a>]: Failed opening required '/var/www/vhosts/fwolf.com/include/fwolflib/func/config.php' (include_path='.:/var/www/vhosts/fwolf.com/include') in /var/www/vhosts/fwolf.com/httpdocs/info.php on line 4

因为是migration过来的,所以require的这个文件肯定存在,并且apache用户也的确有权访问,那问题出在哪里呢?

如果换一种方式,require直接使用文件的全路径,错误信息就更清楚了:

<b>Warning</b>:  require_once() [<a href='function.require-once'>function.require-once</a>]: open_basedir restriction in effect. File(/var/www/vhosts/fwolf.com/include/fwolflib/func/config.php) is not within the allowed path(s): (/var/www/vhosts/fwolf.com/httpdocs:/tmp) in <b>/var/www/vhosts/fwolf.com/httpdocs/info.php</b> on line <b>4</b><br />
<br />
<b>Warning</b>:  require_once(/var/www/vhosts/fwolf.com/include/fwolflib/func/config.php) [<a href='function.require-once'>function.require-once</a>]: failed to open stream: Operation not permitted in <b>/var/www/vhosts/fwolf.com/httpdocs/info.php</b> on line <b>4</b><br />

<br />
<b>Fatal error</b>:  require_once() [<a href='function.require'>function.require</a>]: Failed opening required '/var/www/vhosts/fwolf.com/include/fwolflib/func/config.php' (include_path='.:/var/www/vhosts/fwolf.com/include') in <b>/var/www/vhosts/fwolf.com/httpdocs/info.php</b> on line <b>4</b><br />

原来是有个open_basedir限制,找了一下是在$HOME/conf/httpd.include里,这个文件是由plesk自动维护的:

<IfModule sapi_apache2.c>
    php_admin_flag engine on
    php_admin_flag safe_mode off
    php_admin_value open_basedir "/var/www/vhosts/fwolf.com/httpdocs:/tmp"
</IfModule>
<IfModule mod_php5.c>
    php_admin_flag engine on
    php_admin_flag safe_mode off
    php_admin_value open_basedir "/var/www/vhosts/fwolf.com/httpdocs:/tmp"
</IfModule>

看到没,只允许包含httpdocs下的文件。open_basedir影响的范围是fopen, require, include之类的函数,在一定程度上加强了安全防护。

问题

但open_basedir也有局限性,它不会影响那些执行系统命令的函数,比如exec, system,如果我想偷主机上另外一位同学的文件(内容),也不见得非要去用require包含过来或者种个hack过去,直接system('cat /path/to/file')不是更省事么?

system函数有时候还是能派上正当用场的,直接禁用不是什么好办法,现在流行chroot,就是用户的/就是自己的$HOME,压根儿就访问不到别人的文件,什么open_basedir, exec, dl都不用禁用,我觉得这才是安全和方便的最佳接合点。

以前用(dv)3.0的时候,手工配置使用fastcgi的php5就是这样,每个用户的cgi用自己的身份,在自己的chroot环境下运行。

不过plesk现在的版本8、将来的版本9都没有要直接使用fastcgi解析php的打算,在“更远的计划里”,才可怜兮兮的有这么一句:

Use PHP via FastCGI rather than Apache module

参见:Parallels Summit 2008 – Day 1,所以就只能自己动手了。

fastcgi

很走运,找到了一个2天前刚刚出炉的脚本:Script for using php-cgi instead of mod_php,专门针对plesk,禁用掉mod_php,然后用它来配置fcgi解析。

使用环境:Plesk 8.X on Centos 5.X,依赖:

  • 禁用mod_php,开启mod_fcgid
  • python-curl, PyXML
  • php开启–enable-fastcgi, –enable-force-cgi-redirect

文件需要解压到/root/bin/下,自己一个子目录,幸好我也是用这个bin目录的。

然后在Server -> Control Panel -> Event Manager里添加自定义事件,在增加、修改、删除domain的时候,自动调用这个脚本。(subdomain的删除没有包含,手工删除文件就可以了)设置好大概就是这个样子:

using php-cgi instead of mod_php, plesk event manager

还要把/etc/httpd/conf.d/php.conf删得只剩一行:

LoadModule php5_module modules/libphp5.so

并且在/etc/httpd/conf.d/fcgid.conf里加一句:

PHP_Fix_Pathinfo_Enable 1

不过,这种方法作到一半我就没有继续了,因为我想起来前几天一位朋友和我提到过的suPHP。

suPHP

个人感觉suPHP是最“正统”的解决方案,它是以文件属主用户的身份来运行,正好使用各个用户的权限实现访问限制。

没找到centos的mod_suphp包,只好下载suphp 0.6.3源码自己编译,不过之前要先修改src/apache2/mod_suphp.c,在324行替换掉两行内容:

//AP_INIT_ITERATE("suPHP_AddHandler", suphp_handle_cmd_add_handler, NULL, ACCESS_CONF, "Tells mod_suphp to handle these MIME-types"),
AP_INIT_ITERATE("suPHP_AddHandler", suphp_handle_cmd_add_handler, NULL, RSRC_CONF | ACCESS_CONF, "Tells mod_suphp to handle these MIME-types"),
//AP_INIT_ITERATE("suPHP_RemoveHandler", suphp_handle_cmd_remove_handler, NULL, ACCESS_CONF, "Tells mod_suphp not to handle these MIME-types"),
AP_INIT_ITERATE("suPHP_RemoveHandler", suphp_handle_cmd_remove_handler, NULL, RSRC_CONF | ACCESS_CONF, "Tells mod_suphp not to handle these MIME-types"),

然后就是编译安装那三板斧:

# ./configure\
--with-apxs=/usr/sbin/apxs\
--with-php=/usr/bin/php-cgi\
--with-logfile=/var/log/suphp.log\
--with-min-uid=10000\
--with-min-gid=10000\
--with-apache-user=apache\
--with-apr=/usr/bin/apr-1-config\
--with-setid-mode=owner\
--prefix=/usr\
--sysconfdir=/etc
# make
# make install

/etc/httpd/conf/httpd.conf中加入一句(这一句也可以放到后面的suphp.conf中):

LoadModule suphp_module modules/mod_suphp.so

关闭safe_mode,并且注释掉下面两句:

safe_mode = Off
#AddType application/x-httpd-php .php
#AddType application/x-httpd-php-source .phps

创建suphp的conf文件,使用源码中的conf文件模板:

# cp doc/suphp.conf-example /etc/httpd/conf.d/suphp.conf

修改之:

<Directory /var/www/vhosts>
    RemoveHandler x-httpd-php
#   php_admin_value engine off
    AddHandler x-httpd-php .php .php3 .php4 .php5
    suPHP_AddHandler x-httpd-php
    suPHP_Engine On
    suPHP_ConfigPath /etc/php.ini
</Directory>

禁用mod_php,把php.conf文件换一个扩展名就行了:

# cd /etc/httpd/conf.d
# mv php.conf php.conf.bak

创建suPHP的配置文件/etc/suphp.conf,这个文件和用于apache配置的conf是不一样的,其内容如下,可根据具体环境设定参数:

[global]
;Path to logfile
logfile=/var/log/suphp.log

;Loglevel
;loglevel=info
;info, warn, error
loglevel=warn

;User Apache is running as
;webserver_user=wwwrun
webserver_user=apache

;Path all scripts have to be in
docroot=/var/www/vhosts/

;Path to chroot() to before executing script
;chroot=/mychroot

; Security options
;allow_file_group_writeable=false
allow_file_group_writeable=true
;allow_file_others_writeable=false
allow_file_others_writeable=true
;allow_directory_group_writeable=false
allow_directory_group_writeable=true
;allow_directory_others_writeable=false
allow_directory_others_writeable=true

;Check wheter script is within DOCUMENT_ROOT
check_vhost_docroot=true

;Send minor error messages to browser
;errors_to_browser=false
errors_to_browser=true

;PATH environment variable
env_path=/bin:/usr/bin

;Umask to set, specify in octal notation
;umask=0077
umask=0022

; Minimum UID
;min_uid=100
min_uid=10000

; Minimum GID
;min_gid=100
; Consider of psacln, psaserv
min_gid=200

; Use correct permissions for mod_userdir sites
;handle_userdir=true


[handlers]
;Handler for php-scripts
;x-httpd-php=php:/usr/bin/php
x-httpd-php=php:/usr/bin/php-cgi

;Handler for CGI-scripts
x-suphp-cgi=execute:!self

现在,重启apache,就可以啦!如果发现返回空页面,并且错误log中有如下内容:

Premature end of script headers:

那有可能是因为你把cli模式的php可执行文件拿过来当cgi模式的用了,注意他们的区别:

# php -v
PHP 5.2.6 (cli) (built: May  2 2008 16:06:40) 

# php-cgi -v
PHP 5.2.6 (cgi-fcgi) (built: May  2 2008 16:01:17)

把正确的cgi模式php执行文件设定到/etc/suphp.conf中即可。

chroot的疑惑

由于以前为了安全,ssh权限都是限定在chroot环境下,这样用户无法访问自己$HOME之外的内容。使用了suPHP之后,虽然php文件是以用户身份运行的,但却不是chroot的环境。也就是说,“理论上”在php文件执行的时候,可以访问其他用户的文件,这不也是个安全隐患么?

为了这个问题,我翻阅了好多资料,却发现很少人提起这个东西,suPHP安装不复杂,介绍的也不少,就是没有和chroot搭配的,倒是有人提出和fastcgi搭配使用。后来和michael沟通后才突然醒悟,suPHP的伪装身份和chroot是两种机制,之间没有什么联系,所以也就不存在什么配套使用的问题。至于不想让用户访问别人的文件,完全可以通过设定文件权限来实现嘛,不过还是要在安全方面比以前更加留心:

  • $HOME下系统自动创建的目录,一般属主都是user:psaserv或者root:root,有些对所有人都有rx权限(755),有些则是750权限,私密文件不要往755权限的目录下放。这些目录一般不宜改为750权限,因为有些文件是其他系统服务需要读取的。
  • $HOME下httpdocs, private等目录默认就是禁止所有人访问的,保持这样不要更改,并且httpdocs下的文件你就是搞成777权限,别人也访问不到。
  • 用户自建的文件、目录一般为user:psacln权限,主机上所有host用户所属组都是psacln,所以如果不想让别人访问,又没有上级目录的权限限制的话,一定调整为700权限。
  • 为了使用更方便,可以把$HOME目录的属主设为用户本身,比如chown fwolf:psaserv /var/www/vhosts/fwolf.com,不过这就需要一个个的单独开通了。
  • 如果发现其他系统文件中有泄密的,或者其他用户没有设置好权限,存在安全隐患,请及时告诉我或者相应用户,这样我们才是和谐的一家人嘛 🙂

取消chroot,还有一个好处就是用户几乎能够使用主机上的所有命令了,不像以前那样用哪个就需要把哪个设置到chroot的jail中,方便多了。

chroot的取消不是自动的,我已经给所有用户加上了可指定/bin/bash作为登录shell的权限,用户在plesk的站点设置中,把ssh用户的登录更换为/bin/bash即可,当然如果对安全没有信心,觉得chroot也够用的用户可以保留。

其他

suphp比suexec(就是原来dv3.0升php5的方法)要快一点;比suphp更快的还有suphp_mod_php;再快一些的是mpm-peruser,不过安装配置的麻烦程度也随之递增。

相比而言,suPHP速度还算可以接受(对于负载不是很大的站),配置方便,不用修改每个virtualhost的参数(就是$HOME/conf/vhost.conf),直接改apache的总conf就ok了,当然也比上面fastcgi方式下用event触发脚本来实现更加简洁。

参考

GoDaddy的帐号自动锁定

昨晚发现godaddy突然无法登录了,我确定输入的用户名和密码都是正确的,可系统就是提示:

There is no account with that information. Please try again.

试了好多次都不行,也不是网络的问题,甚至我还通过重设密码功能修改了密码,仍然是登录不进去,只好给godaddy发邮件咨询,3个半小时后收到回信(部分内容):

 Thank you for contacting Online Support. Your account has been locked as a
 security measure due to multiple failed login attempts*. To have your
 account unlocked please respond to this email with the last 6 digits of the
 credit card used on your account for verification, or the shopper PIN for
 your account. Once we receive this information we will then submit a request
 to have your account unlocked.

 *If you have the "Card on File" feature enabled, your account will become
 locked after a single failed login. When using this feature you must either
 make sure that you are careful to enter the correct password every time you
 log into your account, or you will need to disable the "Card on File"
 feature.

大体内容是说,由于有失败的登录,所以帐号被暂时锁定了,需要写信要求他们解除锁定,并附上付款的信用卡号部分号码或者shopper PIN验证身份。并且如果开通了Card on File功能的话,只要有一次失败的登录,帐号就会被锁定,所以输入密码的时候一定要十分小心。

我回信提供了相关信息,大约2小时后就收到回信,帐号已经恢复正常,可以登录了。响应得还是比较及时的。

个人以为这是一种比较负责任的态度,godaddy帐号里面最值钱的就是域名,而如果被别人冒用帐号,把域名转移出去,大概就没希望要回来了,对上了规模的网站来说损失难以估量。所以如果有人“试”密码的话自动锁定帐号是对客户的负责任,要知道域名不像主机天天都会去看看是否运转正常,不作什么设置修改的话几个月甚至半年有的人也不会去登录godaddy(主机一旦出问题,网站立马挂掉,想不知道都难,域名被盗,别人可以暂时还保留原来的dns记录,外人感觉不到)。如果帐号被锁定后有个邮件通知那就更好了,可客服说没这服务。

如果害怕帐号时常被锁定的话,可以取消Card on File功能,因为开着这个一旦别人冒用你的帐号,偷走的就不仅仅是域名了,甚至信用卡里的票票都有危险。但我觉得为了方便还是开着吧,正常有一次错误登录就锁定,安全措施还显得更“灵敏”一些呢。

另外客服说的shopper PIN或者Support Pin,以及网站上显示的Call-in Pin其实都是一个东西,用于服务的那个4位数字码,别被不同的叫法弄糊涂了。

针对$_SERVER[‘PHP_SELF’]的跨站脚本攻击(XSS)

现在的web服务器和开发工具虽然不会再出现像asp的%81那样明显的漏洞了,但是由于开发人员的疏忽和各种语言特性组合造成的一些奇异的漏洞仍然会存在。今天偶然读到的XSS Woes,就详细讲述了和$_SERVER[‘PHP_SELF’]相关的一个危险漏洞。

$_SERVER[‘PHP_SELF’]在开发的时候常会用到,一般用来引用当前网页地址,并且它是系统自动生成的全局变量,也会有什么问题么?让我们先看看下面的代码吧:

	<form action="<?php echo $_SERVER['PHP_SELF']; ?>">
	<input type="submit" name="submit" value="submit" />
	</form>

这段代码非常简单,我们想用$_SERVER['PHP_SELF']来让网页提交时提交到它自己,假设代码文件名为test.php,在执行的时候就一定会得到我们期望的地址么?首先试试地址http://.../test.php,结果当然是没有问题的啦,别着急,你再访问一下http://.../test.php/a=1,将会得到如下客户端代码:

	<form action="/fwolf/temp/test.php/a=1">
	<input type="submit" name="submit" value="submit" />
	</form>

显然,这已经超出了我们的期望,web服务器居然没有产生诸如404之类的错误,页面正常执行了,并且在生成的html代码中居然有用户可以输入的部分,恐怖的地方就在这里。别小看那个“a=1”,如果把它换成一段js代码,就显得更危险了,比如这么调用:

http://.../test.php/%22%3E%3Cscript%3Ealert('xss')%3C/script%3E%3Cfoo

是不是看到了js的alert函数执行的效果?检查一下生成的html源代码找找原因吧。

通过这种嵌入js代码的方式,攻击者能夠获得512~4k的代码空间,甚至还可以连接外部网站的js代码或者通过image调用来伪装js代码的方式,那样js代码的长度就不受限制了,然后通过js,他们可以轻松的获取用户的cookie,或者更改当前页面的任何内容,比如更改表单提交的目的地,更改显示的内容(比如给一个<a>链接地址增加一个onclick=…的属性,这样用户点击的时候就会执行攻击者指定的代码,甚至连接到并非此链接地址本身的网站),甚至作出一个ajax效果来也不一定,总之,不要忽视js的威力。

那么,再来看看这个漏洞产生的原理,首先test.php/....这种调用是web服务器允许的,很多cms系统,比如我以前用过的plog,好像也是采用这种方式,在服务器不支持rewrite的情况下实现诸如http://…/index.php/archive/999这样的固定网址的(我以前还以为是对404错误页下的手),所以带“/”的地址无法从web服务器上禁止。然后再看看php中对$_SERVER[‘PHP_SELF’]的识别,他就是一个包含当前网址值的全局变量,天知道用户会输入什么样的网站,在上面的例子中是恶意的,可是在wikipedia这样的网站上,却又是可以正常使用这种方式的地址的。所以,最终的结论要落在开发人员身上了,没有很好的处理与用户交互的数据。

从安全角度来讲,在开发应用尤其是web应用的时候,所有用户提交的数据都是不安全的,这是基本原则,所以我们才不厌其烦的又是客户端验证又是服务端验证。从上面说的这个安全漏洞来讲,不安全的内容中又要增加“网址”一条了。要解决$_SERVER[‘PHP_SELF’]的安全隐患,主要有以下2种方式:

1、htmlentities 用htmlentities($_SERVER[‘PHP_SELF’])来替代简单的$_SERVER[‘PHP_SELF’],这样即使网址中包含恶意代码,也会被“转换”为用于显示的html代码,而不是被直接嵌入html代码中执行,简单一点说,就是“<”会变成“&lt;”,变成无害的了。

2、REQUEST_URI 用$_SERVER[‘REQUEST_URI’]来替代$_SERVER[‘PHP_SELF’],在phpinfo()中可以看到这两个变量的区别:

_SERVER["REQUEST_URI"] /fwolf/temp/test.php/%22%3E%3Cscript%3Ealert('xss')%3C/script%3E%3Cfoo
_SERVER["PHP_SELF"] /fwolf/temp/test.php/"&gt;&lt;script&gt;alert('xss')&lt;/script&gt;&lt;foo

$_SERVER[‘REQUEST_URI’]会原封不动的反映网址本身,网址中如果有%3C,那么你得到的也将会是%3C,而$_SERVER[‘PHP_SELF’]会对网址进行一次urldecode操作,网址中的%3C将会变成字符“<”,所以就产生了漏洞。需要注意的是,在很多情况下,浏览器会对用户输入要提交给web服务器的内容进行encode,然后服务器端程序会自动进行decode,得到相应的原指,在我们进行post或者get操作的时候都是这样。

另外还有两点需要指出,第一是<form action="">这种写法虽然没有直接用到$_SERVER[‘PHP_SELF’],但实际效果却是一样的,只是发生的时间错后到了用户提交之后的下一个页面,所以,form的action还是不要留空的好。第二点,除了PHP_SELF之外,其他的$_SERVER变量也许也会有类似的漏洞,比如SCRIPT_URI, SCRIPT_URL, QUERY_STRING, PATH_INFO, PATH_TRANSLATED等等,在使用他们之前一定要先作htmlentities之类的转换。

最后,提供一个地址,里面有很多XSS的例子,可以作为反面教材或者测试工具: XSS (Cross Site Scripting) Cheat Sheet

Update @ 2007-07-31

SCRIPT_URI在cgi方式下或者在某些虚拟主机上无法使用:

Notice: Undefined index: SCRIPT_URI in ......

所以就只能用REQUEST_URI了:

((isset($_SERVER["HTTPS"]) && 'on' == $_SERVER["HTTPS"]) ? 'https://' : 'http://') . $_SERVER["HTTP_HOST"] . $_SERVER['REQUEST_URI'];