PHP-CGI远程代码执⾏漏洞分析与防范
CVE-2012-1823出来时据说是“PHP远程代码执⾏漏洞”,曾经也“轰动⼀时”,当时的我只是刚踏⼊安全门的⼀个⼩菜,直到前段时间tomato师傅让我看⼀个案例,我才想起来这个漏洞。通过在中对这个漏洞环境的搭建与漏洞原理的分析,我觉得还挺有意思的,故写出⼀篇⽂章来,和⼤家分享。
⾸先,介绍⼀下PHP的运⾏模式。
下载PHP源码,可以看到其中有个⽬录叫sapi。sapi在PHP中的作⽤,类似于⼀个消息的“传递者”,⽐如我在《》⼀⽂中介绍的fpm,他的作⽤就是接受Web容器通过fastcgi协议封装好的数据,并交给PHP解释器执⾏。
除了fpm,最常见的sapi应该是⽤于Apache的mod_php,这个sapi⽤于php和apache之间的数据交换。
php-cgi也是⼀个sapi。在远古的时候,web应⽤的运⾏⽅式很简单,web容器接收到http数据包后,拿到⽤户请求的⽂件(cgi 脚本),并fork出⼀个⼦进程(解释器)去执⾏这个⽂件,然后拿到执⾏结果,直接返回给⽤户,同时这个解释器⼦进程也就结束了。基于bash、perl等语⾔的web应⽤多半都是以这种⽅式来执⾏,这种执⾏⽅式⼀般就被称为cgi,在安装Apache的时候默认有⼀个cgi-bin⽬录,最早就是放置这些cgi脚本⽤的。
但cgi模式有个致命的缺点,众所周知,进程的创建和调度都是有⼀定消耗的,⽽且进程的数量也不是⽆限的。所以,基于cgi 模式运⾏的⽹站通常不能同时接受⼤量请求,否则每个请求⽣成⼀个⼦进程,就有可能把服务器挤爆。于是后来就有了fastcgi,fastcgi进程可以将⾃⼰⼀直运⾏在后台,并通过fastcgi协议接受数据包,执⾏后返回结果,但⾃⾝并不退出。
php有⼀个叫php-cgi的sapi,php-cgi有两个功能,⼀是提供cgi⽅式的交互,⼆是提供fastcgi⽅式的交互。也就说,我们可以像perl⼀样,让web容器直接fork⼀个php-cgi进程执⾏某脚本;也可以在后台运⾏php-cgi -b 127.0.0.1:9000(php-cgi作为fastcgi 的管理器),并让web容器⽤fastcgi协议和9000交互。
那我之前说的fpm⼜是什么呢?为什么php有两个fastcgi管理器?php确实有两个fastcgi管理器,php-cgi可以以fastcgi模式运⾏,fpm也是以fastcgi模式运⾏。但fpm是php在5.3版本以后引⼊的,是⼀个更⾼效的fastcgi管理器,其诸多优点我就不多说了,可以⾃⼰去翻翻源码。因为fpm优点更多,所以现在越来越多的web应⽤使⽤php-fpm去运⾏php。
回到本漏洞。CVE-2012-1823就是php-cgi这个sapi出现的漏洞,我上⾯介绍了php-cgi提供的两种运⾏⽅式:cgi和fastcgi,本漏洞只出现在以cgi模式运⾏的php中。
这个漏洞简单来说,就是⽤户请求的querystring被作为了php-cgi的参数,最终导致了⼀系列结果。
探究⼀下原理,中规定,当querystring中不包含没有解码的=号的情况下,要将querystring作为cgi的参数传⼊。所
以,Apache服务器按要求实现了这个功能。
但PHP并没有注意到RFC的这⼀个规则,也许是曾经注意并处理了,处理⽅法就是web上下⽂中不允许传⼊参数。但在2004年的时候某个开发者发表过这么⼀段⾔论:
From: Rasmus Lerdorf <rasmus <at> lerdorf>
Subject: [PHP-DEV] php-cgi command line switch memory check
Newsgroups: gmanep.php.devel
Date: 2004-02-04 23:26:41 GMT (7 years, 49 weeks, 3 days, 20 hours and 39 minutes ago)
In our SAPI cgi we have a check along these lines:
if (getenv("SERVER_SOFTWARE")
|| getenv("SERVER_NAME")
|| getenv("GATEWAY_INTERFACE")
|| getenv("REQUEST_METHOD")) {
cgi = 1;
}
if(!cgi) getopt(...)
As in, we do not parse command line args for the cgi binary if we are
running in a web context. At the same time our regression testing system
tries to use the cgi binary and it sets these variables in order to
properly test GET/POST requests. From the regression testing system we
use -d extensively to override ini settings to make sure our test
environment is sane. Of course these two ideas conflict, so currently our
regression testing is somewhat broken. We haven't noticed because we
don't have many tests that have GET/POST data and we rarely build the cgi
binary.
The point of the question here is if anybody remembers why we decided not
to parse command line args for the cgi version? I could easily see it
being useful to be able to write a cgi script like:
#!/usr/local/bin/php-cgi -d include_path=/path
<?php
...
>
and have it work both from the command line and from a web context.
As far as I can tell this wouldn't conflict with anything, but somebody at
some point must have had a reason for disallowing this.
-Rasmus
显然,这位开发者是为了⽅便使⽤类似#!/usr/local/bin/php-cgi -d include_path=/path的写法来进⾏测试,认为不应该限制php-cgi接受命令⾏参数,⽽且这个功能不和其他代码有任何冲突。
于是,if(!cgi) getopt(...)被删掉了。
但显然,根据RFC中对于command line的说明,命令⾏参数不光可以通过#!/usr/local/bin/php-cgi -d include_path=/path的⽅式传⼊php-cgi,更可以通过querystring的⽅式传⼊。
这就是本漏洞的历史成因。
那么,可控命令⾏参数,能做些什么事。
通过阅读源码,我发现cgi模式下有如下⼀些参数可⽤:
-c指定php.ini⽂件的位置
-n不要加载php.ini⽂件
-d指定配置项
-b启动fastcgi进程
-s显⽰⽂件源码
-T执⾏指定次该⽂件
-h和-?显⽰帮助
最简单的利⽤⽅式,当然就是-s,可以直接显⽰源码:
但阅读过我写的fastcgi那篇⽂章的同学应该很快就想到了⼀个更好的利⽤⽅法:通过使⽤-d指定auto_prepend_file来制造任意⽂件读取漏洞,执⾏任意代码:
注意,空格⽤+或%20代替,=⽤url编码代替。
这个漏洞被爆出来以后,PHP官⽅对其进⾏了修补,发布了新版本5.4.2及5.3.12,但这个修复是不完全的,可以被绕过,进⽽衍⽣出CVE-2012-2311漏洞。
PHP的修复⽅法是对-进⾏了检查:
if(query_string = getenv("QUERY_STRING")) {
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
if(*decoded_query_string == '-' && strchr(decoded_query_string, '=') == NULL) {
skip_getopt = 1;
}
free(decoded_query_string);
}
可见,获取querystring后进⾏解码,如果第⼀个字符是-则设置skip_getopt,也就是不要获取命令⾏参数。
这个修复⽅法不安全的地⽅在于,如果运维对php-cgi进⾏了⼀层封装的情况下:
#!/bin/sh
exec /usr/local/bin/php-cgi $*
通过使⽤空⽩符加-的⽅式,也能传⼊参数。这时候querystring的第⼀个字符就是空⽩符⽽不是-了,绕过了上述检查。
于是,php5.4.3和php5.3.13中继续进⾏修改:
if((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
/* we've got query string that has no = - apache CGI will pass it to command line */
unsigned char *p;
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
for (p = decoded_query_string; *p && *p <= ' '; p++) {
/* skip all leading spaces */
}
if(*p == '-') {
skip_getopt = 1;
}
free(decoded_query_string);php文件下载源码
}
先跳过所有空⽩符(⼩于等于空格的所有字符),再判断第⼀个字符是否是-。
这个漏洞在当年的影响应该说中等。因为PHP-CGI这个SAPI在漏洞出现的时间点,因为其性能等问题,
已经在慢慢退出历史舞台了。但考虑到PHP这个在Web领域举⾜轻重的语⾔,跨越多年,⽤量巨⼤,很多⽼的设备、服务器仍在运⾏有漏洞的版本和PHP-CGI,所以影响也不能低估。
不过,在2017年的今天,我分析这个漏洞当然已经不能谈影响了,只是其思路确实⽐较有意思,⼜让我领会了⼀次阅读RFC 的重要性。