CVE-2021-40438 Apache mod_proxy SSRF
0x01 漏洞概述
2021年9月16日,Apache官方发布了Apache httpd mod_proxy SSRF漏洞CVE-2021-40438,影响v2.4.48及以下版本。该版本中mod_proxy模块存在一处逻辑错误,导致攻击者可以控制反向代理服务器的地址,进而导致SSRF漏洞。该漏洞影响范围较广,危害较大,利用简单,目前已在vulhub上有靶场,在审计代码并分析原理后我会直接用docker创建一个环境。
0x02 漏洞背景
Ⅰ.正向代理与反向代理
正向代理 是客户端与正向代理服务器在同一局域网,客户端发出请求,正向代理服务器替代客户端向服务器发出请求。服务器不知道谁是真正的客户端,即向服务器隐藏了真实的请求客户端,如的校园网、科学VPN等
反向代理 是服务器与反向代理服务器在同一局域网,客户端发出请求,反向代理接收请求 ,反向代理服务器会把我们的请求分转发到真实提供服务的各台服务器,即向客户端隐藏了真实的服务器,如CDN技术等
Vulhub可以为我们自动配置好基于Tomcat的反向代理服务器,只需知道原理即可。
Ⅱ.关于mod_proxy
如果我们要部署一个PHP运行环境,且将Apache作为Web应用服务器,那么常用的有三种方法:
- Apache以CGI的形式运行PHP脚本
- PHP以mod_php的方式作为Apache的一个模块运行
- PHP以FPM的方式运行为独立服务,Apache使用mod_proxy_fcgi模块作为反代服务器将请求代理给PHP-FPM
[CGI是公共网关接口,描述的是服务器和请求处理程序之间传输数据的一种标准,它会根据客户端输入(环境变量,命令行,标准输入)作出响应,把响应结果传送给 Web 服务器]
第一种方式比较古老,性能较差,支持的交互很有限,有点像10多年前的盗版小说网站,基本已经淘汰;
第二种方式不存在外部的PHP进程,而是由mod_php模块进程解释执行PHP脚本,意味着PHP与Apache通信更方便快捷,在Apache环境下使用较广,配置最为简单,也是目前最常用的一种;
[FPM是FastCGI进程管理器,相当于CGI的升级版,传统的CGI进程是用完即销,每次都需要解析配置,初始化环境和分析请求等,而FastCGI会用一个持久的main进程负责以上,同时每次会fork出子进程用以针对处理用户实际的请求]
第三种方法也有较大用户体量,不过Apache仅作为一个中间的反代服务器,更多新的用户会选择使用性能更好的Nginx替代。
这其中,第三种方法使用的mod_proxy_fcgi就是mod_proxy模块的一个子模块。mod_proxy是Apache服务器中用于反代后端服务的一个模块,而它拥有数个不同功能的子模块,分别用于支持不同通信协议的后端,比如常见的有:
- mod_proxy_fcgi 用于反代后端是fastcgi协议的服务,比如php-fpm
- mod_proxy_http 用于反代后端是http、https协议的服务
- mod_proxy_uwsgi 用于反代后端是uwsgi协议的服务,主要针对uWSGI
- mod_proxy_ajp 用于反代后端是ajp协议的服务,主要针对Tomcat
- mod_proxy_ftp 用于反代后端是ftp协议的服务
进行复现的时候会对物理机服务器上的一个页面伪造访问请求,故以分析mod_proxy_http为例
Ⅲ.Apache hook机制
Apache对HTTP的请求可以分为连接、处理和断开连接三个阶段;从小的方面而言,每个阶段又可以分为更多的子阶段。比如对HTTP的请求,我们可以进一步划分为客户身份验证、客户权限认证、请求校验等阶段,每一个阶段调用相应的函数进行处理。在Apache中,这些子阶段可以用术语hook来描述。因此你只需要创建一个hook,挂于请求处理程序上:“告诉服务器它要么服务用户发起的请求,要么只是瞥一眼该请求。”
Apache所有的模块(包括mod_rewrite, mod_authn_*, mod_proxy等)均是将钩子挂于请求程序的各个部分来实现。这样一来,Apache服务器本身无需知道每个模块具体负责处理哪个部分以及处理什么,它只需要在客户端请求达到的时候询问下哪个模块对这个请求『感兴趣』即可,而每个模块只需选择要还是不要,如果要,那就按照hook定义的内容处理然后返回接口。 通过Hook机制,PHP模块可以在Apache请求处理流程中负责处理那些关于php脚本的请求(即负责解释、执行php脚本)。
0x03 漏洞原理分析
《Building a POC for CVE-2021-40438》这篇文章中提到了这个漏洞的复现方法:当目标环境使用了mod_proxy做反向代理,比如ProxyPass / "http://localhost:8000/"
,此时通过请求http://target/?unix:{'A'*5000}|http://example.com/
即可向http://example.com
发送请求,造成一个SSRF攻击。
这里面,Apache源码中出现逻辑错误是在modules/proxy/proxy_util.c的fix_uds_filename函数:
static void fix_uds_filename(request_rec *r, char **url)
{
char *ptr, *ptr2;
if (!r || !r->filename) return;
if (!strncmp(r->filename, "proxy:", 6) &&
(ptr2 = ap_strcasestr(r->filename, "unix:")) &&
(ptr = ap_strchr(ptr2, '|'))) {//判断r->filename中是否包含proxy:、unix:以及|
apr_uri_t urisock;
apr_status_t rv;
*ptr = '';//用来分割unix domain socket 和 http协议
rv = apr_uri_parse(r->pool, ptr2, &urisock);
if (rv == APR_SUCCESS) {
char *rurl = ptr+1;//获取代理的http路径
char *sockpath = ap_runtime_dir_relative(r->pool, urisock.path);
apr_table_setn(r->notes, "uds_path", sockpath);//设置表中键的函数,apr_table是Apache中提供数据结构的模块,负责提供表和数组
*url = apr_pstrdup(r->pool, rurl); /* so we get the scheme for the uds */
/* r->filename starts w/ "proxy:", so add after that */
memmove(r->filename+6, rurl, strlen(rurl)+1);//覆盖原有代理路径为新的http路径
ap_log_rerror(APLOG_MARK, APLOG_TRACE2, 0, r,
"*: rewrite of url due to UDS(%s): %s (%s)",
sockpath, *url, r->filename);
}
else {
*ptr = '|';
}
}
}
Apache在配置反代的后端服务器时,有两种情况:
- 直接使用某个协议反代到某个IP和端口,比如
ProxyPass / "http://localhost:8080"
- 使用某个协议反代到unix套接字,比如
ProxyPass / "unix:/var/run/www.sock|http://localhost:8080/"
第一种情况比较好理解,第二种情况相当于让用户可以使用一个Apache自创的写法来配置后端地址。那么这时候就会涉及到分析语句的过程,需要将这种自创的语法转换成能兼容正常socket连接的结构,而fix_uds_filename函数就是做这个事情的。
使用字符串文法来表示多种含义的方式通常暗藏一些漏洞,比如这里,进入这个if语句需要满足三个条件:
r->filename
的前6个字符等于proxy:
r->filename
的字符串中含有关键字unix:
unix:
关键字后的部分含有字符|
当满足这三个条件后,将unix:
后面的内容进行解析,设置成uds_path
的值;将字符|
后面的内容,设置成rurl
的值。
举个例子,前面介绍中的ProxyPass / "unix:/var/run/www.sock|http://localhost:8080/"
,在解析完成后,uds_path
的值等于/var/run/www.sock
,rurl
的值等于http://localhost:8080/
。
看到这里其实都没有什么问题,那么我们肯定会思考,r->filename
是从哪来的,用户可控吗,为什么?
这时就要说到另一个函数,proxy_hook_canon_handler
,这个函数用于注册canon handler,比如:
可以看到,每一个mod_proxy_xxx
都会注册一个自己的canon handler,canon handler会在反代的时候被调用,用于告诉Apache主程序它应该把这个请求交给哪个处理方法来处理。
比如,我们看到mod_proxy_http
的proxy_http_canon
函数:
static int proxy_http_canon(request_rec *r, char *url)
{
// ...
// first part
if (strncasecmp(url, "http:", 5) == 0) {
url += 5;
scheme = "http";
}
else if (strncasecmp(url, "https:", 6) == 0) {
url += 6;
scheme = "https";
}
else {
return DECLINED;
}
port = def_port = ap_proxy_port_of_scheme(scheme);
// second part
ap_proxy_canon_netloc(r->pool, &url, NULL, NULL, &host, &port);//根据配置文件来设置
switch (r->proxyreq) {
default:
case PROXYREQ_REVERSE://反向代理配置
if (apr_table_get(r->notes, "proxy-nocanon")) {
path = url; /* this is the raw path */
}
else {
path = ap_proxy_canonenc(r->pool, url, strlen(url),
enc_path, 0, r->proxyreq);
search = r->args;
}
break;
case PROXYREQ_PROXY:
path = url;
break;
}
if (path == NULL)
return HTTP_BAD_REQUEST;
if (port != def_port)
apr_snprintf(sport, sizeof(sport), ":%d", port);
else
sport[0] = '';
if (ap_strchr_c(host, ':')) { /* if literal IPv6 address */
host = apr_pstrcat(r->pool, "[", host, "]", NULL);
}
// third part
r->filename = apr_pstrcat(r->pool, "proxy:", scheme, "://", host, sport,
"/", path, (search) ? "?" : "", (search) ? search : "", NULL);
return OK;
}
这个函数中有三个主要的部分,第一部分检查了配置中的url的开头是不是http:
或https:
,如果不是,说明这个请求不该由mod_proxy_http
模块处理,后续的过程跳过;第二部分,用各种方式获取到scheme、host、port、path、search等几个URL的组成变量;第三部分,拼接proxy:
、scheme、://
、host、sport、/
、path、search,成为一个字符串,赋值给r->filename
。
这里面,scheme、host、port来自于配置文件中配置的ProxyPass,而path、search来自于用户发送的数据包。也就是说,r->filename
中的后半部分是用户可控的。
那我们回看前面的fix_uds_filename
函数,它在r->filename
中查找关键字unix:
,并将这个关键字后面直到|
的部分作为unix套接字地址,而将|
后面的部分作为反代的后端地址。
比如发送如下数据包
GET /proxy/xxxx?unix:///tmp/xxxx|http://127.0.0.1:8888/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: curl/7.64.1
Accept: */*
在后端查看log日志显示内容如下,attempt to connect to Unix domain socket /tmp/xxxx,这说明已经把之前配置文件里代理到http://127.0.0.1:8888 链接的配置修改为了访问Unix domain socket套接字。
我们可以通过请求的path或者search来控制这两个部分,控制了反代的后端地址,这也就是为什么这里会出现SSRF的原因。
我们在构造请求包的时候有一个问题,那就是Apache在正常情况下,因为识别到了unix套接字,所以会把用户请求发送给这个本地文件套接字,而不是后端URL
可以来做个测试,我们向物理机上搭建的一个php页面发送这一个请求:
GET /?unix:/var/run/test.sock|http://192.168.0.104:8080/ HTTP/1.1
Host: localhost:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Length: 4
此时会得到一个503错误:
我们根本就没有创建unix套接字/var/run/test.sock
,当然是访问不了的。
然而我们不能让他把请求发送到unix套接字上,而是发送给我们需要的|
后面的地址,这样才能构成SSRF攻击
国外那位作者给出了一个非常巧妙的方法,在fix_uds_filename
函数中,unix套接字的地址来自于下面这两行代码:
char *sockpath = ap_runtime_dir_relative(r->pool, urisock.path);
apr_table_setn(r->notes, "uds_path", sockpath);
如果这里ap_runtime_dir_relative
函数返回值是null,则后面获取uds_path
时将不会使用unix套接字地址,而变成普通的TCP连接:
uds_path = (*worker->s->uds_path ? worker->s->uds_path : apr_table_get(r->notes, "uds_path"));
if (uds_path) {
if (conn->uds_path == NULL) {
/* use (*conn)->pool instead of worker->cp->pool to match lifetime */
conn->uds_path = apr_pstrdup(conn->pool, uds_path);
}
// ...
conn->hostname = "httpd-UDS";
conn->port = 0;
}
else {
// TCP
conn->hostname = apr_pstrdup(conn->pool, uri->hostname);
conn->port = uri->port;
// ...
}
那么如何让ap_runtime_dir_relative
的返回值是null呢?
AP_DECLARE(char *) ap_runtime_dir_relative(apr_pool_t *p, const char *file)
{
char *newpath = NULL;
apr_status_t rv;
const char *runtime_dir = ap_runtime_dir ? ap_runtime_dir : ap_server_root_relative(p, DEFAULT_REL_RUNTIMEDIR);
rv = apr_filepath_merge(&newpath, runtime_dir, file,
APR_FILEPATH_TRUENAME, p);
if (newpath && (rv == APR_SUCCESS || APR_STATUS_IS_EPATHWILD(rv)
|| APR_STATUS_IS_ENOENT(rv)
|| APR_STATUS_IS_ENOTDIR(rv))) {
return newpath;
}
else {
return NULL;
}
}
可以看到,ap_runtime_dir_relative
函数最后引用了apr库中的apr_filepath_merge
函数,它的主要作用就是路径的join,用于处理相对路径、绝对路径、../
连接。
APR_DECLARE(apr_status_t) apr_filepath_merge(char **newpath,
const char *rootpath,
const char *addpath,
apr_int32_t flags,
apr_pool_t *p)
{
...
rootlen = strlen(rootpath);
maxlen = rootlen + strlen(addpath) + 4; /* 4 for slashes at start, after
* root, and at end, plus trailing
* null */
if (maxlen > APR_PATH_MAX) {
return APR_ENAMETOOLONG;
}
...
}
这个函数中,当待join的两段路径长度+4大于APR_PATH_MAX
,也就是4096的时候,则函数会返回一个路径过长的状态码,导致最后unix套接字的值是null
也就是说,我们只需要在unix:
与|
之间传入内容长度大概超过4092的字符串,就能构造出uds_path
为null的结果,让Apache不再发送请求给unix套接字。
0x04 漏洞复现
最后,这样构造出的请求成功触发SSRF漏洞:
Apache官方对这个漏洞的修复也比较简单,因为用户只能控制r->filename
的后半部分,而前半部分proxy:{scheme}://{host}{port}/
来自于配置文件,所以最新版改成检查其开头是不是proxy:unix:
这一用户无法控制的部分。
不再发送请求给unix套接字。
0x04 漏洞复现
最后,这样构造出的请求成功触发SSRF漏洞:
Apache官方对这个漏洞的修复也比较简单,因为用户只能控制r->filename
的后半部分,而前半部分proxy:{scheme}://{host}{port}/
来自于配置文件,所以最新版改成检查其开头是不是proxy:unix:
这一用户无法控制的部分。