During a short review of the Jenkins source code, we found a vulnerability that can be used to bypass the mutual authentication when using the JNLP3 remoting protocol. In particular, this allows anyone to impersonate a client and thereby gain access to the information and functionality that should only be available to that client.
During a short review of the Jenkins source code, we found a vulnerability that can be used to bypass the mutual authentication when using the JNLP3 remoting protocol. In particular, this allows anyone to impersonate a client and thereby gain access to the information and functionality that should only be available to that client.
Jenkins supports 4 different versions of the remoting protocol. 1 and 2 are unencrypted, 3 uses a custom handshake protocol and 4 is secured using TLS. The vulnerability exists only in version 3.
1, 2 and 3 are deprecated and warnings are shown when they are enabled. However, these warnings and the documentation only mention stability impact, no security impact, such as a lack of authentication.
As described in the documentation in the code, the JNLP3 handshake works as follows:
src/main/java/org/jenkinsci/remoting/engine/JnlpProtocol3Handler.java#L80-L110
Client Master
handshake ciphers = createFrom(agent name, agent secret)
| |
| initiate(agent name, encrypt(challenge), encrypt(cookie)) |
| -------------------------------------------------------------->>> |
| |
| encrypt(hash(challenge)) |
| <<<-------------------------------------------------------------- |
| |
| GREETING_SUCCESS |
| -------------------------------------------------------------->>> |
| |
| encrypt(challenge) |
| <<<-------------------------------------------------------------- |
| |
| encrypt(hash(challenge)) |
| -------------------------------------------------------------->>> |
| |
| GREETING_SUCCESS |
| <<<-------------------------------------------------------------- |
| |
| encrypt(cookie) |
| <<<-------------------------------------------------------------- |
| |
| encrypt(AES key) + encrypt(IvSpec) |
| -------------------------------------------------------------->>> |
| |
channel ciphers = createFrom(AES key, IvSpec)
channel = channelBuilder.createWith(channel ciphers)
The encrypt
function in this diagram uses keys that are derived from the
client name and client secret. The exact procedure createFrom
is not important
for this issue, just that the keys only depend on the client name and secret and
are therefore constant for all connections between that client and the master:
src/main/java/org/jenkinsci/remoting/engine/HandshakeCiphers.java#L108-L123
The encryption algorithm used is AES/CTR/PKCS5Padding
:
src/main/java/org/jenkinsci/remoting/engine/HandshakeCiphers.java#L135
As is commonly known, CTR mode must never be reused with the same keys and counter (IV): the encrypted value is generated by bytewise XORing a keystream with the plaintext data. When two different messages are encrypted using the same key and counter, the XOR of the two ciphertexts gives the XOR of the plaintexts as the keystream is canceled out. If one plaintext is known, this makes it possible to determine the keystream and the data in the second plaintext.
Each call to encrypt
in the diagram above restarts the cipher, therefore, even
when performing the handshake just once the keystream is reused multiple times.
Knowing the first ~2080 bytes of the AES-CTR keystream is enough to impersonate a client: the client needs to be able decrypt the server’s challenge, which is around 2080 bytes. All other packets are smaller than that.
There are a number of ways to trick the server into encrypting a known plaintext, which allows an attacker to recover a part of the keystream, which can then be used to decrypt other packets. We describe a relatively efficient approach below, but many different (possibly more efficient) approaches are likely to exist.
The client can send an initiate packet with the challenge as an empty string. This means that the response from the server will always be the encryption of the SHA-256 hash of the empty string. This allows the attacker to decrypt the initial bytes of the keystream.
Then, the attacker can obtain the rest of the keystream byte by byte in the following way: The attacker encrypts a message that is exactly as long as the keystream the attacker currently knows and appends one extra byte. The server will respond with one of 256 possible hashes, depending on how the extra byte was decrypted by the server. The attacker can decrypt the hash (because a large enough prefix is already known from the previous step) and determine which byte the server had used, which can be XORed with the ciphertext byte to obtain the next keystream byte.
There is one complication to this approach: in many places in the handshake binary data is for some unknown reason interpreted as ISO-8859-1 and converted to UTF8 or vice versa. This means that when the decrypted challenge ends in a character that is a partial UTF-8 multibyte sequence, the character is ignored. In that case, it is not possible to determine which character the server had decrypted. By trying at most 3 different bytes, it is possible to find one that is valid.
We have developed a proof-of-concept of this attack. Using this, we were able to retrieve enough bytes of the keystream to pass authentication with about 3000 connections to Jenkins, which took around 5 minutes against a local server. As mentioned, it is likely that this can be reduced even further.
It is also possible to perform a similar attack to impersonate a master against a client if the connection can be intercepted and the client automatically reconnects. We did not spend time performing this.
It is not possible to prevent this attack in a way that is backwards compatible with existing JNLP3 clients and masters. Therefore, we recommend removing support for JNLP3 completely. Arguably, JNLP1 and JNLP2 protocols are safer to use as those can only be taken over if a connection is intercepted. A safer encrypted alternative already exists (JNLP4), so investing time in fixing this protocol would not be needed.
We reported the issue to the Jenkins team, who coincidentally were already considering removing support for the version 1, 2 and 3 remoting protocols as they are deprecated and were known to have stability impact. These protocols have now been removed in Jenkins 2.219. In version 2.204.2 of the LTS releases of Jenkins, this protocol can still be enabled by setting a configuration flag, but this is strongly discouraged.
Users using an older version of Jenkins can mitigate this issue by not enabling version 3 of the remoting protocol.
2019-12-06 Issue reported to Jenkins as SECURITY-1682.
2019-12-06 Issue acknowledged by the Jenkins team.
2020-01-16 Fix prepared.
2020-01-29 Advisory published by Jenkins
2020-01-30 This advisory published by Computest.