APACHE_CVE-2021-40438-SSRF漏洞分析复现
v2.4.48及以下版本
该版本中mod_proxy模块存在一处逻辑错误,导致攻击者可以控制反向代理服务器的地址,进而导致SSRF漏洞。该漏洞影响范围较广,危害较大,利用简单,目前已在vulhub上有靶场,在审计代码并分析原理后我会直接用docker创建一个环境。
1.使用了mod_proxy,Apache源码中出现逻辑错误是在modules/proxy/proxy_util.c的fix_uds_filename函数
2.使ap_runtime_dir_relative
函数返回值是null
3.需要在unix:
与|
之间传入内容长度大概超过4092的字符串,就能构造出uds_path
为null的结果,让Apache不再发送请求给unix套接字。
4.满足以上条件,使访问后|
面的http链接造成SSRF
正向代理与反向代理
正向代理:隐藏了客户端,代理服务器替代客户端发出请求。客户端与正向代理服务器处于同一局域网。
反向代理:反向代理服务器接受客户端请求后,会把请求分转发到真实提供服务的各台服务器。隐藏了真实服务器,如CDN。
关于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脚本)。
漏洞原理分析
这里面,Apache源码中出现逻辑错误是在modules/proxy/proxy_util.c的fix_uds_filename函数:
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)
{
const char *base_url = url;
char *host, *path, sport[7];
char *search = NULL;
const char *err;
const char *scheme;
apr_port_t port, def_port;
int is_ssl = 0;
scheme = get_url_scheme((const char **)&url, &is_ssl);
if (!scheme) {
return DECLINED;
}
port = def_port = (is_ssl) ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r,
"HTTP: canonicalising URL %s", base_url);
/* do syntatic check.
* We break the URL into host, port, path, search
*/
err = ap_proxy_canon_netloc(r->pool, &url, NULL, NULL, &host, &port);
if (err) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01083)
"error parsing URL %s: %s", base_url, err);
return HTTP_BAD_REQUEST;
}
/*
* now parse path/search args, according to rfc1738:
* process the path.
*
* In a reverse proxy, our URL has been processed, so canonicalise
* unless proxy-nocanon is set to say it's raw
* In a forward proxy, we have and MUST NOT MANGLE the original.
*/
switch (r->proxyreq) {
default: /* wtf are we doing here? */
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] = '\0';
if (ap_strchr_c(host, ':')) { /* if literal IPv6 address */