7

Apache mod_proxy SSRF(CVE-2021-40438)的一点分析和延伸

 2 years ago
source link: https://www.leavesongs.com/PENETRATION/apache-mod-proxy-ssrf-cve-2021-40438.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Apache mod_proxy SSRF(CVE-2021-40438)的一点分析和延伸

phithon

2021 十月 18 10:05
阅读:431

周五晚上开始学习Apache HTTP Server(后文简称为Apache)mod_proxy的SSRF漏洞(CVE-2021-40438),并在星球简单介绍了一下编译、调试Apache服务器的方法,今天继续深入分析一下这个漏洞的成因,以及一些延伸的问题研究。

我很久不发漏洞分析了,因为不是自己挖的漏洞,我自己的思考其实是不够的,再加上很多出漏洞的软件我都没用过,也更谈不上深入的理解,很容易落俗套变成流水账;另外这些年网友们也很积极,不缺我的一篇漏洞分析文章,所以我就写的少了。不过最近Apache HTTP Server出的几个漏洞,还是值得分析一下。

另外,阅读本文前,建议按照我在这两篇帖子里发的方式https://t.zsxq.com/RvfmEu3https://t.zsxq.com/7qNfeie下载好apr、apr-util、apache的源码并编译,便于后续调试以理解文章。

0x01 Apache Module综述

如果我们要部署一个PHP运行环境,且将Apache作为Web应用服务器,那么常用的有三种方法:

  1. Apache以CGI的形式运行PHP脚本
  2. PHP以mod_php的方式作为Apache的一个模块运行
  3. PHP以FPM的方式运行为独立服务,Apache使用mod_proxy_fcgi模块作为反代服务器将请求代理给PHP-FPM

第一种方式比较古老,性能较差,基本已经淘汰;第二种方式在Apache环境下使用较广,配置最为简单;第三种方法也有较大用户体量,不过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_fcgi用于反代PHP,我们在使用Node.js、Python等脚本语言编写的应用也常常会使用mod_proxy_http作为一层反代服务器,这样中间层可以做ACL、静态文件服务等。

这次的SSRF漏洞是出在mod_proxy这个模块中的,我们就来从代码的层面分析一下它的原理是什么,究竟影响有多大。

0x02 漏洞原理分析

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函数:

/*
 * In the case of the reverse proxy, we need to see if we
 * were passed a UDS url (eg: from mod_proxy) and adjust uds_path
 * as required.  
 */
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, '|'))) {
        apr_uri_t urisock;
        apr_status_t rv;
        *ptr = '\0';
        rv = apr_uri_parse(r->pool, ptr2, &urisock);
        if (rv == APR_SUCCESS) {
            char *rurl = ptr+1;
            char *sockpath = ap_runtime_dir_relative(r->pool, urisock.path);
            apr_table_setn(r->notes, "uds_path", sockpath);
            *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);
            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自创的写法来配置后端地址。那么这时候就会涉及到parse的过程,需要将这种自创的语法转换成能兼容正常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.sockrurl的值等于http://localhost:8080/

看到这里其实都没有什么问题,那么我们肯定会思考,r->filename是从哪来的,用户可控吗,为什么?

这时就要说到另一个函数,proxy_hook_canon_handler,这个函数用于注册canon handler,比如:

可以看到,每一个mod_proxy_xxx都会注册一个自己的canon handler,canon handler会在反代的时候被调用,用于告诉Apache主程序它应该把这个请求交给哪个处理方法来处理。

比如,我们看到mod_proxy_httpproxy_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: /* 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 */
        host = apr_pstrcat(r->pool, "[", host, "]", NULL);
    }

    // fourth 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、sport来自于配置文件中配置的ProxyPass,而path、search来自于用户发送的数据包。也就是说,r->filename中的后半部分是用户可控的。

那我们回看前面的fix_uds_filename函数,它在r->filename中查找关键字unix:,并将这个关键字后面直到|的部分作为unix套接字地址,而将|后面的部分作为反代的后端地址。

我们可以通过请求的path或者search来控制这两个部分,控制了反代的后端地址,这也就是为什么这里会出现SSRF的原因。

0x03 限制绕过

当然,这里面有一个问题,那就是Apache在正常情况下,因为识别到了unix套接字,所以会把用户请求发送给这个本地文件套接字,而不是后端URL。

可以来做个测试,我们发送这样一个请求:

GET /?unix:/var/run/test.sock|http://example.com/ HTTP/1.1
...

此时会得到一个503错误,错误日志会反馈这样的结果:

[Mon Oct 18 00:14:38.634795 2021] [proxy:error] [pid 782180:tid 140737306797824] (2)No such file or directory: AH02454: HTTP: attempt to connect to Unix domain socket /var/run/test.sock (192.168.1.1) failed
[Mon Oct 18 00:14:38.634875 2021] [proxy_http:error] [pid 782180:tid 140737306797824] [client 192.168.1.142:59696] AH01114: HTTP: failed to make connection to backend: httpd-UDS

找不到unix套接字/var/run/test.sock,这是当然。

我们不能让他把请求发送到unix套接字上,而是发送给我们需要的|后面的地址。

国外那位作者给出了一个非常巧妙的方法,在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 {
    // ...
    conn->hostname = apr_pstrdup(conn->pool, uri->hostname);
    conn->port = uri->port;
    // ...
}

那么如何让ap_runtime_dir_relative的返回值是null?ap_runtime_dir_relative函数最后引用了apr库中的apr_filepath_merge函数,它的主要作用就是路径的join,用于处理相对路径、绝对路径、../连接。

这个函数中,当待join的两段路径长度+4大于APR_PATH_MAX,也就是4096的时候,则函数会返回一个路径过长的状态码,导致最后unix套接字的值是null:

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;
}

也就是说,我们只需要在unix:|之间传入内容长度大概超过4092的字符串,就能构造出uds_path为null的结果,让Apache不再发送请求给unix套接字。

最后,这样构造出的请求成功触发SSRF漏洞:

Apache官方对这个漏洞的修复也比较简单,因为用户只能控制r->filename的后半部分,而前半部分proxy:{scheme}://{host}{sport}/来自于配置文件,所以最新版改成检查其开头是不是proxy:unix:,这一部分用户无法控制。

0x04 mod_proxy_fcgi是否存在漏洞?

我们前文都以mod_proxy_http作为例子来研究,而在Apache+PHP环境下,mod_proxy_fcgi的使用频率更高,那么它是否也会被SSRF漏洞影响呢?

这个漏洞出现在modules/proxy/proxy_util.c的fix_uds_filename函数,理论上是mod_proxy的漏洞,那么它的子模块应该都会被影响,但这个漏洞中有一个很关键的变量是r->filename,他是否可控决定了后面的利用是否可以成功。

我们看一下mod_proxy_fcgi的canon函数:

static int proxy_fcgi_canon(request_rec *r, char *url)
{
    char *host, sport[7];
    const char *err;
    char *path;
    apr_port_t port, def_port;
    fcgi_req_config_t *rconf = NULL;
    const char *pathinfo_type = NULL;

    if (ap_cstr_casecmpn(url, "fcgi:", 5) == 0) {
        url += 5;
    }
    else {
        return DECLINED;
    }

    // ...

    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);
    }
    if (path == NULL)
        return HTTP_BAD_REQUEST;

    r->filename = apr_pstrcat(r->pool, "proxy:fcgi://", host, sport, "/",
                              path, NULL);
    // ...
}

可见,这里的r->filename等于proxy:fcgi://{host}{sport}/{path},相比于mod_proxy_http少了search。不过,path仍然是用户可以控制的,我们可以尝试发送这样的数据包:

GET /unix:testtest|http://example.com/1.php HTTP/1.1
...

经过调试可见,path中的|ap_proxy_canonenc函数编码成了%7C:

没有|,后面也就无法完成SSRF利用了。

0x05 哪些模块受到影响

那么,我们其实可以认为,如果r->filename有部分可控,且可控的部分没有被编码(不是path),这个模块就会受到SSRF漏洞的影响。

对这个结论我没有逐一测试考证,我仅挑选另一个较为常用的模块mod_proxy_ajp来复现漏洞。

mod_proxy_ajp是用于反代Tomcat的一个Apache模块,Tomcat在8.5.51版本以前默认会开启两个端口8080和8009,分别对应HTTP协议和AJP协议。HTTP协议好理解,AJP协议是一个二进制协议,通信协议相比起来效率更高。所以以前很多运维人员会将Tomcat假设在Apache之后,然后二者之间使用AJP协议通信。

Tomcat 8.5.51之后的版本受到Ghostcat漏洞影响不再默认开放8009端口。

Apache下有两个模块能实现AJP的反代通信:

  • mod_proxy_ajp 这就是mod_proxy的一个子模块,由Apache HTTPd官方维护
  • mod_jk 这是Tomcat官方维护的一个Apache模块,更加出名用户也更多

由于mod_jk不是用mod_proxy的代码,所以不受到影响,我们今天仅测试mod_proxy_ajp。

简单部署一个开放8009端口的Tomcat服务器,并配置好mod_proxy_ajp进行调试,可见其proxy_ajp_canon函数r->filename中是包含search的:

static int proxy_ajp_canon(request_rec *r, char *url)
{
    char *host, *path, sport[7];
    char *search = NULL;
    const char *err;
    apr_port_t port, def_port;

    /* ap_port_of_scheme() */
    if (strncasecmp(url, "ajp:", 4) == 0) {
        url += 4;
    }
    else {
        return DECLINED;
    }

    // ...
    r->filename = apr_pstrcat(r->pool, "proxy:ajp://", host, sport,
                              "/", path, (search) ? "?" : "",
                              (search) ? search : "", NULL);
    return OK;
}

那么按照我们的预测,这里也会存在SSRF漏洞。果然测试成功:

那么,mod_proxy_http2、mod_proxy_balancer、mod_proxy_wstunnel等这些模块也会受到影响,而mod_proxy_uwsgi、mod_proxy_scgi等模块不受影响。我没有严格验证,有兴趣的同学可以自己下去调试一下,也许还能找到绕过方法。

0x06 几个常见问题和总结

一个大家问的比较多的问题:这个SSRF漏洞是否能够POST?答案是肯定的,理解了原理的同学肯定能明白,我们实际上是控制了反向代理的目标服务器地址。既然是反向代理,那么实际上用户请求的大部分原始数据都会被直接转发给后端,所以,我们只需要发送POST请求,即可让执行POST的SSRF,比如:

另一个,这个SSRF漏洞是否可以打本地的unix socket?答案是肯定的。原本这个漏洞的第一请求目标就是本地的unix套接字,我们使用4092个超长search绕过了这个限制让他可以打任意远程地址,只要让它回归原本的方法就可以打本地的unix套接字了:

打本地unix套接字的好处是可以攻击类似于Docker、Supervisor这样的本地服务。

最后一个问题,这个SSRF漏洞是否可以攻击一些非HTTP协议的服务?答案也是肯定的。TCP是一个数据流,即使我们打出的数据包前面有HTTP头,这并不影响后续正常的满足二进制协议的数据流的发送与接收。不过有一个例外情况,如果目标服务有一些特殊的操作,类似于高版本redis读取到一些特殊的HTTP数据段就断开TCP连接这样的操作,那么可能需要进行一些额外绕过了。

总结一下,这个SSRF漏洞的本质是Apache在解析反代服务URL的时候,由于对unix:位置要求不严格,导致用户的输入可以控制反代的逻辑,最终导致反代URL被控制,造成SSRF漏洞。

参考链接:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK