Model Theft and Extraction

Description

A fine-tuned model is attackable in two forms. The first is the artifact: safetensors shards, GGUF files, LoRA adapters and checkpoints in a registry, an artifact bucket, or an on-host cache on the serving node. The second is the behaviour, reachable through the completion API and harvestable into an input-output corpus that trains a student model. LLM06:2026 names the second directly, as model extraction and distillation theft: the attacker consumes inference capacity you pay for in order to reproduce the asset you paid to train. The artifact read path is tested here because it reaches the same asset for less effort.

A stolen checkpoint hands over the fine-tuning corpus indirectly, removes guardrails applied at the serving layer, and enables offline white-box attack development. A distilled clone is cheaper and far harder to prove, so attribution controls matter as much as prevention. Testing misses both because registries and artifact buckets are treated as internal infrastructure, and because extraction traffic is authenticated and individually unremarkable. Deserialization and write-path provenance belong to the Supply Chain pages, and side-channel recovery of weights or architecture through timing or shared infrastructure is routed to LLM02.

Examples

Sweep registries, buckets and host caches for readable weights

Enumerate the tracking server and its artifact store, then look for weight extensions rather than assuming the bucket is private.

curl -s "http://mlflow.internal.example:5000/api/2.0/mlflow/registered-models/search"
curl -s "http://mlflow.internal.example:5000/api/2.0/mlflow/model-versions/get-download-uri?name=support-router&version=7"

aws s3 ls --no-sign-request s3://ml-artifacts-example/ --recursive \
  | grep -Ei '\.(safetensors|bin|gguf|pt|ckpt|npz)$'

Prove readability with a range request rather than pulling gigabytes; a safetensors file opens with a little-endian u64 header length followed by a JSON header.

curl -s -r 0-511 "https://<presigned-url>/model-00001-of-00004.safetensors" | xxd | head -4

A 206 with a parsable header, or a get-download-uri that resolves without credentials, is the finding. Record the presigned URL lifetime. On the serving host the same weights sit unpacked under ~/.cache/huggingface/hub/models—org—name/snapshots and ~/.ollama/models/blobs/sha256-*, readable by the service user or any container mounting them.

Query the serving admin surface

Serving processes expose model-management routes on the inference listener. Triton’s repository index is a POST.

curl -s http://inference.internal.example:8000/v1/models
curl -s -X POST http://triton.internal.example:8000/v2/repository/index -d '{}'
curl -s http://ollama.internal.example:11434/api/tags
curl -s http://ollama.internal.example:11434/api/show -d '{"model":"internal-support-7b"}'

Any route answering unauthenticated is the finding: a reachable Triton repository index, vLLM started without —api-key, runtime LoRA loading enabled via VLLM_ALLOW_RUNTIME_LORA_UPDATING, or an Ollama daemon on 0.0.0.0 that also accepts /api/push.

Distil behaviour through the completion API

Drive a seeded prompt bank at volume with one test credential and record whether anything stops you.

import requests

API = "https://api.example.com/v1/chat/completions"
KEY = "<TEST_KEY>"
sent = 0
for i in range(5000):
    r = requests.post(API, headers={"Authorization": f"Bearer {KEY}"},
        json={"model": "support-router",
              "messages": [{"role": "user", "content": f"CANARY-1234 task {i}: rewrite the sentence below."}],
              "temperature": 0, "logprobs": True, "top_logprobs": 5})
    sent += 1
    if r.status_code != 200:
        print(sent, r.status_code, r.headers.get("retry-after"))
        break
print("completions collected:", sent)

If all 5000 land with no 429 and no token-budget rejection, and top_logprobs is populated, you hold a labelled corpus plus soft targets. Report throughput and total billed tokens: that is the cost the operator absorbed to be cloned.

Check whether the watermark or fingerprint survives

Fine-tune a small student on the harvested pairs in a lab, then run the same detector over teacher and student output.

from transformers import SynthIDTextWatermarkingConfig

SECRET_KEYS = [...]  # the operator's private key list, one integer per depth
watermarking_config = SynthIDTextWatermarkingConfig(keys=SECRET_KEYS, ngram_len=5)
# teacher: model.generate(**tokenized, watermarking_config=watermarking_config, do_sample=True)
# then score teacher and student samples with SynthIDTextWatermarkDetector and compare

Also send the secret trigger phrase used as a behavioural fingerprint. If the teacher scores watermarked and the student does not, or the trigger fires on the teacher only, the scheme did not survive distillation and cannot support a takedown claim. Repeat after quantization, which often strips the same signal.

Remediation

  1. Close the artifact read path
    • Deny anonymous and public-ACL access to weight buckets at account level; keep training and serving hosts in egress-restricted subnets.
    • Issue download URLs with minute-scale lifetimes bound to an authenticated principal and log object reads with requester identity.
  2. Treat the inference admin plane as privileged
    • Bind vLLM, Triton, TGI and Ollama to loopback or an mTLS mesh, run vLLM with —api-key, and keep repository, runtime-LoRA and push routes off user-facing listeners.
  3. Make extraction expensive in tokens, not requests
    • Enforce per-credential daily token and completion ceilings alongside rate limits, and withhold logprobs from untrusted tiers.
  4. Watermark and fingerprint for attribution
    • Watermark at generation time and hold a secret trigger-response fingerprint out of every published eval set.
    • Verify both survive quantization and distillation before relying on them.
  5. Monitor for the copy
    • Diff registry and bucket read volumes against training schedules, and probe public model hubs with your fingerprint trigger.