TEKNİK YAZI / Yapay Zekâ Entegrasyonları

LiteLLM 1.107.0 Saldırısı: LLM Gateway Güvenliği İçin Çıkarılacak Dersler

LiteLLM 1.107.0 saldırısı nasıl gerçekleşti, etkilendiğinizi nasıl anlarsınız? Başlangıç dosyası, hash ve bekleme süresi denemeleriyle gateway güvenliği.

LiteLLM 1.107.0 saldırısı, yaygın kullanılan bir LLM gateway’inin resmî yayın sürecinden 21 Ekim 2026’da kimlik bilgisi çalan bir sürümün çıkmasıdır. Zararlı paket PyPI’da 1 saat 44 dakika, Docker etiketlerinde 2 saat 34 dakika yayında kaldı. Paketi kuran makinede Python her başladığında zararlı kod çalıştı; uygulamanın LiteLLM’i içe aktarmasına bile gerek yoktu.

Olay yalnız LiteLLM kullananları ilgilendirmiyor. LLM gateway, bir kurumun model sağlayıcısı anahtarlarını tek bir süreçte toplar; bu yüzden saldırganlar için değerli bir hedeftir. Aşağıdaki önlemlerin çoğu kendi yazdığınız gateway için de, başka bir açık kaynak bağımlılık için de geçerlidir.

Yazı önce etkilenip etkilenmediğinizi nasıl anlayacağınızı anlatıyor. Ardından saldırının nasıl işlediğini deneyle gösteriyor ve hangi önlemin neyi durdurabileceğini tartışıyor. Olay bilgileri ağırlıklı olarak LiteLLM’in yayımladığı analizden ve bağımsız güvenlik ekiplerinin raporlarından alındı; 6 Kasım 2026 itibarıyla günceldir. Başlangıç denemesi Python 3.14.10 ile, paket araçlarıyla ilgili denemeler pip 26.3, uv 0.13.7 ve pip-audit 2.10.1 ile yapıldı. Zararlı sürüm indirilmedi; başlangıç mekanizmasını yalnızca ekrana bir satır yazan kendi deneme paketimle sınadım.

Kısaca

LiteLLM 1.107.0 saldırısında ne oldu?

Saldırı paket deposunda değil, aylar önce sızan bir anahtarla başladı. LiteLLM’in 23 Ekim’de yayımladığı analize göre zincir şöyle ilerledi:

  1. 27 Mayıs: Agent Platform yönetici anahtarını içeren bir yapılandırma dosyası herkese açık bir depoya girdi. Dosya kısa sürede silindi ama anahtar iptal edilmedi.
  2. 10 Ekim: Saldırgan bu anahtarla Agent Platform API’sini çağırdı. Yönetilen ajanların yapılandırmasını döndüren yanıt, bir organizasyon sahibinin GitHub CLI token’ını da içeriyordu.
  3. 10 Ekim, aynı gece: Bu token’la CI iş akışları çalıştırıldı, iki geliştiricinin daha token’ı alındı ve çalıştırma kayıtları silindi.
  4. 21 Ekim, 10:08: Yayın iş akışına, derlenecek deponun dışarıdan seçilmesine izin veren bir değişiklik gönderildi. Dal koruması ilk denemeyi reddetti; sahip token’ıyla koruma kapatıldı.
  5. 21 Ekim, 10:46–11:11: Yayın süreci, BerriAI/litellm yerine adı yalnızca bir tireyle ayrılan Berri-AI/litellm fork’undan derleme yaptı. Yayın onayı ikinci bir geliştiricinin çalıntı token’ıyla verildi. Onay kuralı o hafta geçici olarak gevşetilmişti.
  6. 21 Ekim, 11:39 ve 11:46: Yedi wheel PyPI’a, imajlar Docker Hub ve GHCR’a yüklendi. Paketleri resmî yayın süreci imzaladı.
  7. 21 Ekim, 12:06–14:20: Dış bir araştırmacı durumu GitHub’da duyurdu. PyPI sürümü 13:23’te karantinaya aldı, Docker etiketleri 14:20’de temizlendi.
27 Mayıs'ta sızan yönetici anahtarından 21 Ekim'deki zararlı yayına uzanan zaman çizelgesi: dal koruması 10:08'de kapatılıyor, derleme 10:46'da fork'tan başlıyor, paket 11:39'da PyPI'a, imajlar 11:46'da Docker'a çıkıyor; PyPI 1 saat 44 dakika, Docker 2 saat 34 dakika yayında kalıyor
Şekil 1. Saldırı aylar önce sızan ve iptal edilmeyen bir anahtarla başladı. Paket zincirin yalnızca son halkasıdır.

İlk haberlerde token’ların bir dağıtımın loglarından alındığı yazıyordu. LiteLLM 23 Ekim analizinde bunu düzeltti: ilk token log’dan değil, Agent Platform API yanıtından geldi. Eski haberleri okurken bu farka dikkat edin.

Bu, LiteLLM’in 2026’daki ikinci tedarik zinciri olayıydı. İki olay birbirine benzese de saldırganlar farklı noktalardan girdi:

Mart 2026Ekim 2026
Sürümler1.82.7 ve 1.82.81.107.0
Yayın yoluCI atlanarak PyPI’a doğrudan yüklemeResmî süreç, fork’tan derleme
Yayında kalmaYaklaşık 40 dakikaPyPI 1 sa 44 dk, Docker 2 sa 34 dk
Resmî Docker imajıEtkilenmediEtkilendi
Zararlı kodun yeriproxy_server.py, 1.82.8’de ayrıca .pthcook.pth, cook.py, sitecustomize.py

Mart’tan sonra LiteLLM yayın sürecini yeniledi. Ekim saldırısı ise bir CI bağımlılığını değil, bu süreci yönetme yetkilerini hedef aldı. Bu yüzden çıkarılacak ders de farklı: Yayın sürecini güvenli hâle getirmek gerekir ama yetmez; süreci kimlerin değiştirebileceğini de sınırlamak gerekir.

LiteLLM saldırısından etkilendiğinizi nasıl anlarsınız?

Ölçüt iki soruya dayanır: Sürümü sabitlemeden litellm kuran bir iş yukarıdaki saat aralığında çalıştı mı? Yukarıdaki etiketlerden biri o aralıkta çekildi mi? LiteLLM başka bir aracın bağımlılığı olarak da gelmiş olabilir, bu yüzden CI kayıtlarınıza ve kilit dosyalarınıza da bakın.

Bu kontrolü Python’u çalıştırmadan yapın. Bir sonraki bölümdeki denemede pip show komutu bile başlangıç kodunu çalıştırdı.

# Python başlatmadan dosya sisteminde ara
find / \( -name 'cook.pth' -o -name 'litellm-1.107.0.dist-info' \) -not -path '/proc/*' 2>/dev/null

# Çalışmanın ve arka plan sürecinin izleri
ls -la /tmp/.pc_once_* /tmp/remote_code_loader.lock 2>/dev/null

# Kilit dosyalarında aday ara; Pipfile.lock paket adı ve sürümü ayrı satırda tutar
grep -rlE '1\.107\.0' --null --include='*.txt' --include='*.lock' --include='*.toml' . | xargs -0 grep -l 'litellm'

# Yerel imajlarda zararlı digest'i ara; kapsayıcı başlatmaz
docker images --digests | grep 1bbcb1443bc962e4

Kilit araması aday dosyaları listeler; eşleşen dosyada 1.107.0 değerinin gerçekten litellm paketine ait olduğunu gözle doğrulayın.

Bu saat aralığının dışında da etkilenmiş olabilirsiniz. İlk durum, bu imajlardan türetilen imajlardır: FROM ...:main-stable ile o aralıkta derlenmiş bir imaj zararlı katmanı bugün de taşır ve digest’i farklıdır, bu yüzden yukarıdaki digest araması onu bulmaz. İkincisi kurum içi paket aynaları ve imaj önbellekleridir: PyPI ve Docker Hub sürümü kaldırdıktan sonra da 1.107.0’ı sunmaya devam edip etmediklerini kontrol edin. Bir imajın dosyalarını Python’u çalıştırmadan şöyle listeleyebilirsiniz:

cid=$(docker create imajiniz:etiket)
docker export "$cid" | tar -t | grep -E 'cook\.pth|litellm-1\.107\.0\.dist-info'
docker rm "$cid"

sitecustomize.py dosyasını tek başına kanıt saymayın. Örneğin Ubuntu 26.04’te /usr/lib/python3.14/sitecustomize.py meşru olarak bulunur; site-packages içinde beklenmeyen bir kopyayı arıyorsunuz.

Bir şey bulmak etkilendiğinizi gösterir. Bulamamak ise temiz olduğunuzu kanıtlamaz: CI makineleri ve geçici kapsayıcılar çoktan yok olmuş olabilir. Etkilendiyseniz şu sırayı izleyin:

  1. Python ve pip çalıştırmayın. Her başlatma zararlı kodu yeniden tetikler.
  2. Ortamı sıfırdan kurun. İmajı ve sanal ortamı yeniden oluşturmak, temizlemekten daha güvenlidir. Temizlemek zorundaysanız site-packages içinden cook.pth, cook.py, sitecustomize.py ve bunların __pycache__ kopyalarını elle silin, /tmp/remote_code_loader.lock dosyasını tutan arka plan sürecini durdurun.
  3. Temiz bir makineden sırları değiştirin. LiteLLM özellikle instance ya da task IAM rolünü, proxy veritabanındaki bütün sağlayıcı anahtarlarını, master ve sanal anahtarları, DATABASE_URL parolasını ve süreçlerin ortam değişkenlerindeki sırları sayıyor. Zararlı kodun topladıkları arasında SSH ve GPG anahtarları, Git ve bulut kimlikleri, tarayıcı oturumları ve yapay zekâ aracı token’ları da var.
  4. Kayıtları inceleyin. CloudTrail’de kurulum zamanı civarındaki GetCallerIdentity çağrılarına ve rol kimliklerinin altyapınız dışından kullanımına bakın.
  5. Sürümü bilinçli seçin. LiteLLM olay gününde 1.106.2 sürümünü öneriyordu. Bugün için güncel bildirimi kontrol edin ve seçtiğiniz sürümü sabitleyin.

Sızıntıdan sonra izlenecek genel sıra frontend API anahtarı yazısındakiyle aynıdır: önce iptal, sonra temizlik.

Python paketi içe aktarılmadan nasıl kod çalıştırır?

Olayın en şaşırtıcı kısmı budur. Uygulama import litellm yazmasa da kod çalıştı. Bunu mümkün kılan, Python’un başlangıç sırasıdır:

  1. Python başlarken site modülünü yükler.
  2. site, site-packages içindeki .pth dosyalarını okur. Bu dosyalar normalde içe aktarma yolu eklemek içindir.
  3. Bir .pth satırı import ile başlıyorsa Python o satırı doğrudan çalıştırır.
  4. Ardından varsa sitecustomize modülünü içe aktarır.

SafeDep’in analizine göre zararlı wheel üç dosya bırakıyordu: 12 baytlık cook.pth, kimlik bilgilerini çalan 22 KB’lık cook.py ve arka kapı yükleyen sitecustomize.py. cook.pth dosyasının içeriği tek satır: import cook. Bu satırı kendim yazıp SHA-256 aldığımda SafeDep’in yayımladığı 7f191632… değerinin aynısı çıktı.

Deney: hangi komut başlangıç kodunu çalıştırır?

Aynı düzeni zararsız bir pakette kurdum. Deneme paketi, demo_startup.pth ile yalnızca stderr’e bir satır yazan demo_startup.py dosyasını bırakıyor. Paketi kurup her komutta bu satırın görünüp görünmediğine baktım:

KomutBaşlangıç kodu çalıştı mı?
python -c passEvet
python -E -c pass, -s, -I, -PEvet
python -S -c passHayır
pip --version, pip list, pip showEvet
pip uninstall -y demo-gatewayEvet, dosyaları silmeden önce
Paket dizini silindikten sonra python -c passEvet
Aynı makinede başka bir sanal ortamHayır

Çalışan süreçte demo_gateway modülü yüklü değildi, demo_startup yüklüydü. sitecustomize.py ile aynı deney -c pass ve -I altında çalıştı, -S altında çalışmadı. Python 3.10.16 ile yaptığım ilk deneme de ortak komutlarda aynı sonucu verdi.

python app.py, python -I, pip show ve pip uninstall komutlarının hepsi site modülünden geçiyor; site modülü site-packages içindeki cook.pth, cook.py ve sitecustomize.py dosyalarını çalıştırıyor, uygulama kodu ancak sonra başlıyor. Yalnız python -S bu adımı atlıyor
Şekil 2. Zararlı kod uygulamadan önce çalışır. İçe aktarılması gerekmez, paketin kurulu olması yeter.

Bu tablodan üç pratik sonuç çıkıyor:

Python bu açığı kapatıyor, ama yavaş ilerliyor. PEP 829 .pth içindeki import satırlarını Python 3.15 ile kullanımdan kaldırıyor. Satırlar 3.17’ye kadar çalışmaya devam ediyor, 3.18’den itibaren yok sayılacak. sitecustomize.py bu değişikliğin kapsamında değil ve Ekim saldırısı iki yolu da kullandı.

Kurulumdan sonra başlangıç dosyalarını denetlemek

Paket kurulduktan sonra, ortamdaki Python ilk kez başlamadan önce bu dosyalara bakabilirsiniz. Meşru örnekler de var: denemede setuptools paketinin distutils-precedence.pth dosyası ve coverage paketinin a1_coverage.pth dosyası import satırı taşıyordu. Bu yüzden kara liste değil, hash’li izin listesi gerekir.

İlk denememde denetimi Python ile yazıp python -S ile çalıştırdım ve yanıldım. Python 3.10’da sanal ortam içinde -S kullanılınca site.getsitepackages() sanal ortamı değil temel kurulumu gösterdi ve zararlı dosya görünmedi. Python belgesine göre sys.prefix değeri ancak 3.14’ten itibaren -S seçeneğinden etkilenmiyor. Bu nedenle aşağıdaki betik Python’u hiç çalıştırmıyor, denetlenecek dizini parametre olarak alıyor:

#!/usr/bin/env bash
# Python başlatmadan, başlangıçta kod çalıştırabilen dosyaları izin listesiyle karşılaştırır.
# Kullanım: python-baslangic-denetimi.sh <site-packages> <izin-listesi>
set -euo pipefail

site_dir="$1"
allow_list="$2"
unknown=0

for file in "$site_dir"/*.pth "$site_dir"/sitecustomize* "$site_dir"/usercustomize*; do
  [ -e "$file" ] || continue
  case "$file" in
    *.pth) grep -qE '^import[[:space:]]' "$file" || continue ;;
  esac
  name=$(basename "$file")
  if [ -f "$file" ]; then
    hash=$(sha256sum "$file" | cut -d' ' -f1)
  else
    hash="dizin"
  fi
  if grep -qxF "$hash  $name" "$allow_list"; then
    echo "izinli      $name"
  else
    echo "BILINMEYEN  $name  $hash"
    unknown=1
  fi
done

exit "$unknown"

İzin listesi, gözden geçirilmiş temiz bir kurulumdan bir kez üretilip depoya eklenir. Bağımlılık güncellenince hash değişir ve betik durur; bu bir hata değil, değişikliği gözden geçirmeniz gerektiğinin işaretidir.

(cd .venv/lib/python3.12/site-packages && sha256sum distutils-precedence.pth) > startup-allowlist.txt
bash python-baslangic-denetimi.sh .venv/lib/python3.12/site-packages startup-allowlist.txt

Betiği sekiz senaryoda sınadım. Temiz ortam çıkış kodu 0 verdi. import cook satırlı cook.pth, yeni sitecustomize.py, kaynaksız sitecustomize.pyc, sitecustomize/ dizini ve izin listesindeki dosyanın değişmiş hâli çıkış kodu 1 verdi. Boş izin listesi meşru dosyaları da bilinmeyen saydı. Yalnız yol içeren .pth dosyası ise doğru biçimde görmezden gelindi.

Betiğin sınırı nettir: yalnız başlangıçta çalışan dosyalara bakar. Mart’taki 1.82.7 gibi zararlı kodu sıradan bir modülün içine koyan sürümü görmez.

Sürüm numarasına değil içeriğe güvenmek: hash ve digest

Kurulumun neye dayandığını aynı deneme paketiyle üç biçimde sınadım:

GereksinimKayıttaki durumSonuç
demo-gateway>=1.0Yeni 1.1.0 ele geçirilmiş1.1.0 kuruldu, kod çalıştı
demo-gateway==1.0.0Aynı sürümün içeriği değişmişKuruldu, kod çalıştı
--require-hashesAynı sürümün içeriği değişmişpip çıkış kodu 1, hiçbir şey kurulmadı

İlk satır Ekim saldırısının ta kendisidir. LiteLLM’in etkilenme tanımı sürümü sabitlememiş kurulumları kapsıyor; ==1.106.2 yazan biri yeni sürümü almadı.

İkinci satır PyPI için gerçekçi değil, çünkü PyPI aynı dosya adının yeniden yüklenmesine izin vermiyor. Aynı risk Docker etiketlerinde vardır. Ekim’de latest ve main-stable etiketleri önce zararlı imaja yöneldi, sonra temiz imajlara çevrildi ya da kaldırıldı. Etiket değişebilir bir işaretçidir, digest ise içeriğin kendisine bağlıdır.

Python tarafında kilit dosyası şöyle üretilir. requirements.in yalnız doğrudan bağımlılıkları taşır:

uv pip compile --generate-hashes --exclude-newer '7 days' requirements.in -o requirements.txt
pip install --require-hashes --only-binary :all: -r requirements.txt

litellm>=1.100 tek satırından bu komut 3 Kasım’da 58 paket ve 1.901 hash üretti. --only-binary :all: kaynak dağıtımların kurulum sırasında kod çalıştırmasını da engeller.

İmajın digest değerini Docker çalıştırmadan imaj deposundan (registry) alabilirsiniz:

TOKEN=$(curl -s 'https://ghcr.io/token?scope=repository:berriai/litellm:pull' | jq -r .token)
curl -sI -H "Authorization: Bearer $TOKEN" \
  -H 'Accept: application/vnd.oci.image.index.v1+json' \
  https://ghcr.io/v2/berriai/litellm/manifests/v1.106.2 | grep -i docker-content-digest

Ardından dağıtımda etiket yerine bu değeri kullanın:

services:
  gateway:
    image: ghcr.io/berriai/litellm@sha256:<digest>

Hash’li kilidin bir sınırı var. Kilidi saldırı sürerken yeniden üretirseniz zararlı sürümün hash’i de kilide girer. Kilit yalnız daha önce gözden geçirdiğiniz içeriği korur; yeni sürümün güvenli olduğunu söylemez. Bu boşluğu bekleme süresi kapatır.

Yeni sürümü hemen almamak: bekleme süresi

Zararlı sürüm PyPI’da iki saatten kısa kaldı. Dış bir araştırmacı yarım saat içinde durumu duyurmuştu. Bağımlılık çözücüsüne “son yedi günde yayımlanan sürümleri hesaba katma” demek, böyle kısa bir saldırı süresini tamamen dışarıda bırakır.

# pip: yalnız yedi günden eski yüklemeleri değerlendir
pip install --uploaded-prior-to P7D -r requirements.in

# uv: aynı sınır, göreli süre ya da sabit tarih alır
uv pip compile --exclude-newer '7 days' requirements.in -o requirements.txt

litellm>=1.100 için pip bu sınırla 3 Kasım’da 1.106.4 sürümünü seçti. Sınırı saldırıdan yedi gün önceye, 14 Ekim 11:39 UTC’ye çektiğimde 1.105.1, saldırıdan hemen önceye çektiğimde 1.106.2 seçti. Aynı ayarı bağımlılık güncelleme aracınızda da açın:

version: 2
updates:
  - package-ecosystem: "pip"
    directory: "/"
    schedule:
      interval: "daily"
    cooldown:
      default-days: 7

Dependabot belgesine göre cooldown yalnız sürüm güncellemelerine uygulanır, güvenlik güncellemelerine uygulanmaz; docker ekosisteminde de default-days desteklenir. Renovate’te karşılığı minimumReleaseAge seçeneğidir. uv ise süreyi paketin yüklenme zamanından hesaplar; bu yüzden paket deposunun bu bilgiyi vermesi gerekir. PyPI veriyor, kurum içi aynanızı ayrıca kontrol edin.

Bekleme süresi ve kilit, güvenlik yamalarını da geciktirir. 21 Ekim öncesine kilitlediğim 58 paketlik dosyada pip-audit, 3 Kasım’da filelock için 12 bilinen güvenlik kaydı buldu. Bu yüzden kilitle birlikte düzenli güvenlik taraması çalıştırın ve kritik yamalar için bir istisna yolu tanımlayın. uv’de exclude-newer-package tek bir paketi bu kuraldan ayırır.

pip-audit önleyici değil, geriye dönük bir araçtır. Denemede litellm==1.107.0 için PYSEC-2026-2 kaydıyla çıkış kodu 1 verdi. Ancak ilgili OSV kaydı son olarak 29 Ekim’de güncellenmiş; saldırı sürerken sizi durdurması beklenemez. Yararı, sonradan bakıp geçmişte neyi kurduğunuzu görmektir.

İmzayı doğrulamak neden yetmedi?

Yazılım tedarik zinciri güvenliğinde ilk akla gelen öneri “imzayı doğrula” olur. Bu olayda yetmemesinin nedeni, imzanın başka bir soruyu yanıtlamasıdır.

İmza “bu paketi hangi kimlik üretti?” sorusunu yanıtlar. Kaynak kanıtı (provenance) ise paketin hangi depodan ve hangi iş akışından çıktığını kaydeder. Mart saldırısında paketler CI atlanarak doğrudan yüklenmişti. Yayıncı kaynak kanıtı üretiyor ve siz de bunu zorunlu tutuyorsanız bu fark görünür.

Ekim’de durum farklıydı. Paketleri LiteLLM’in kendi yayın süreci ürettiği için imzalayan kimlik doğruydu; yanlış olan, sürecin derlediği kaynak koddu. Datadog’a göre paketler, fork’u kaynak olarak doğru kaydeden ve görünüşte meşru bir kaynak kanıtıyla yayımlandı. Yani kanıt yalan söylemedi, ama yalnız imzanın geçerliliğine bakan biri bu ayrıntıyı görmezdi. Kayıtlı kaynağı beklenen depoyla tam eşleştirmek gerekir. Rapor bu kanıtın hangi pakete ve hangi biçime ait olduğunu ayrıntılı olarak vermiyor. LiteLLM’e göre fork’taki commit GitHub’da BerriAI/litellm adresi altında da görünüyordu, bu yüzden commit bağlantısına bakmak da yetmez.

İmza anahtarının kendisi de değişti. LiteLLM, saldırganın topladığı token’larla eski cosign anahtarının özel yarısının da açığa çıktığını düşünüyor ve 23 Ekim’de anahtarı yeniledi. Yeni anahtarla imzalanan ilk sürümler 24 Ekim’de çıkan v1.103.8, v1.104.6, v1.105.4 ve v1.106.3; sonraki bütün sürümler de bu anahtarı kullanıyor. İmajı cosign ile doğruluyorsanız elinizdeki cosign.pub dosyasının SHA-256 parmak izini olay sayfasındaki değerle karşılaştırın. Anahtar sızmış olabileceği için eski anahtarla atılmış bir imza tek başına güven kanıtı sayılmamalı.

Kaynak depo kontrolünü pypi-attestations ile denedim:

pypi-attestations verify pypi --repository https://github.com/pypa/sampleproject \
  pypi:sampleproject-4.0.0.tar.gz
# OK: sampleproject-4.0.0.tar.gz

pypi-attestations verify pypi --repository https://github.com/py-pa/sampleproject \
  pypi:sampleproject-4.0.0.tar.gz
# Verification failed: provenance was signed by repository "pypa/sampleproject", expected "py-pa/sampleproject"

Kontrol benzer depo adını yakalıyor. Ama elde doğrulanacak bir kanıt olması gerekir: litellm için 1.82.6, 1.105.7, 1.106.2 ve 1.106.6 sürümlerinde denediğim wheel dosyalarında PyPI “No provenance available” yanıtı verdi. Bu yüzden bu yolun Ekim’de PyPI kurulumunu durduracağını söyleyemem. Kullandığınız projenin hangi paketler için hangi kanıtı yayımladığını önceden kontrol edin.

Ekim saldırısını daha doğrudan yakalayan kontrol, yayımlanan paketi etiketlenmiş kaynak kodla karşılaştırmaktır. SafeDep ve Aikido’ya göre derleme yapılandırmasına data = "cook" satırı eklenmişti ve cook/ dizini GitHub’daki kaynakta yoktu. LiteLLM de GitHub dışında çalışan, yayımlanan paket ve imajları etiket içeriğiyle karşılaştırıp fark görünce sürümü geri çeken bir doğrulayıcıyı 31 Aralık’a kadar kuracağını açıkladı. İç paket aynası işleten kurumlar aynı karşılaştırmayı kendi tarafında yapabilir.

Kendi paketinizi ya da imajınızı yayımlıyorsanız Datadog’un önerisi açık: Yayın iş akışının yalnızca onaylı depolardan derleme yapmasını sağlayın ve geliştiricilerin CLI token’larını yüksek değerli sır olarak koruyun. Ekim’de saldırganın ilk işi, iş akışındaki bu depo kısıtını kaldırmak oldu.

Kontrol matrisi: kaynak kodu incelemek iki saldırıda da durdurmaz; sürüm sabitleme, hash'li kilit ve yedi gün bekleme ikisini de durdurur; digest Ekim'de gerekir; yalnız imzayı doğrulamak Mart'ta koşullu, Ekim'de yetersiz; kaynak depoyu doğrulamak ikisinde de yayıncının kanıt üretmesine bağlı; başlangıç dosyası denetimi Ekim'i, Mart'ta kısmen yakalar; paketi etiketle karşılaştırmak ikisini de yayından sonra gösterir. pip-audit sonradan yakalar, çıkış izin listesi ve kendi ağına bağlı kimlik hasarı sınırlar
Şekil 3. Mart ve Ekim saldırılarına göre kontroller. Değerlendirme, olay raporları ve bu yazıdaki deneylere dayanan bir çıkarımdır.

Gateway’de hasarı nasıl sınırlarsınız?

Hiçbir önlem kusursuz değildir. Zararlı kod gateway ile aynı kullanıcıyla çalışıyorsa gateway’in okuduğu her şeyi okur. Veritabanındaki anahtarlar şifreli olsa bile gateway onları çözebildiği için zararlı kod da aynı yolu kullanabilir. SafeDep’e göre zararlı kod LiteLLM’in kimlik bilgisi tablolarını, diğer süreçlerin ortam değişkenlerini ve bulut meta veri servisini hedefliyordu.

Solda gateway üzerindeki sağlayıcı anahtarları, master ve sanal anahtarlar, DATABASE_URL, bulut rolü ve ortam değişkenleri ile geliştirici ve CI üzerindeki SSH, Git, tarayıcı oturumu, yapay zekâ aracı token'ları ve CI sırları zararlı koda gidiyor. Sağda çıkış izin listesi model sağlayıcısına geçişe izin veriyor, listede olmayan www.litellm.dev adresini durduruyor
Şekil 4. Kurulumu engelleyemediğinizde amaç, zararlı kodun erişebildiği ve dışarı çıkarabildiği bilgiyi azaltmaktır.

Bu durumda amaç, çalınan bilginin değerini düşürmek ve dışarı çıkış yolunu daraltmaktır:

ÖnlemNe yapar?Ekim’deki karşılığı
Çıkış trafiği izin listesiGateway yalnızca model sağlayıcılarına ve veritabanına bağlanabilirVeri www.litellm[.]dev adresine gidiyordu. Alan adı meşru göründüğü için engelleme listesi işe yaramazdı
Kendi ağına bağlı bulut kimliğiStatik anahtar yerine görev rolü kullanılır, rol kimliği yalnız kendi ağından kabul edilirÇalınan AWS rol kimlikleri zaten geçiciydi ve yaklaşık 4,5 saat sonra denendi. Kısa ömürlü olmak tek başına yetmez; dışarıdan kullanımı ağ koşulu engeller
Meta veri servisini kapatmakHttpTokens=required ve hop limit 1 ile köprü ağındaki kapsayıcı IMDS yanıtı alamazZararlı kod 169.254.169.254 adresinden kimlik çekiyordu
Ortam ve kiracı başına anahtarHer ortamın kendi sağlayıcı anahtarı ve harcama tavanı olurTek bir anahtar sızınca bütün canlı ortam etkilenmez
Geliştirici makinesini ayırmakGateway geliştirme sırasında da kapsayıcı içinde çalışırZararlı kod SSH anahtarlarını, tarayıcı oturumlarını ve yapay zekâ aracı token’larını topluyordu
Yönetim arayüzünü kapatmakYönetim API’si kimlik doğrulayan bir proxy arkasında dururZincir, yönetici anahtarının dışarıdan kullanılabilmesiyle başladı

AWS’de EC2 rol kimlikleri için aws:Ec2InstanceSourceVpc koşulu bu işe yarar: çağrı, kimliğin teslim edildiği VPC’den gelmiyorsa reddedilir. Örnek politikalar IAM belgesinde; koşulun karşılaştırdığı aws:SourceVpc değeri yalnızca istek bir VPC uç noktasından geçtiğinde oluşur.

Hop limit ayarında AWS’nin notunu unutmayın: Kapsayıcıdaki iş IMDS’e gerçekten ihtiyaç duyuyorsa hop limit 1 çağrının yanıtsız kalmasına neden olabilir. Gateway kendi görev rolünü kullanıyorsa bu istenen davranıştır.

Yönetim API’si yanıtında sır döndürmemek

Zincirin dönüm noktası tek bir GET isteğiydi: yönetilen ajanları listeleyen uç nokta, yapılandırmayla birlikte bir GitHub token’ı da döndürdü. Kendi gateway’inizde yanıtı nesnenin tamamını kopyalayarak değil, yalnızca izin verilen alanlardan oluşturun:

// admin-public.mjs: yanıta yalnız açıkça izin verilen alanlar girer
const PUBLIC_AGENT_FIELDS = ["id", "name", "model", "status"];

export function toPublicAgent(agent) {
  return Object.fromEntries(
    PUBLIC_AGENT_FIELDS.filter(field => field in agent).map(field => [
      field,
      agent[field],
    ])
  );
}

Bu kuralı bir kanarya testiyle koruyun. Test ortamının yapılandırmasına rastgele bir değer koyun ve hiçbir yanıtta görünmediğini doğrulayın:

// admin-public.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { toPublicAgent } from "./admin-public.mjs";

test("ajan listesi yanıtı yapılandırılmış sırları taşımaz", () => {
  const canary = `kanarya-${randomUUID()}`;
  const agent = {
    id: "agent-1",
    name: "pr-acici",
    model: "ornek-model",
    status: "active",
    config: { githubToken: canary, env: { PROVIDER_KEY: canary } },
  };

  const body = JSON.stringify({ agents: [agent].map(toPublicAgent) });

  assert.equal(body.includes(canary), false);
});

toPublicAgent yerine nesneyi olduğu gibi döndürdüğümde test başarısız oldu, izin listesiyle geçti. Aynı testi gerçek uç noktalara karşı da çalıştırın: her yönetim GET isteğinin gövdesinde kanaryayı arayın.

Bir not daha: 27 Mayıs’taki dosya kısa sürede silinmişti ama anahtar geçerli kaldı. Dosyayı silmek sızıntıyı kapatmaz, anahtarı iptal etmek kapatır. LiteLLM de artık sızan anahtar için, iptal doğrulanana kadar kapanmayan otomatik bir kayıt açacağını duyurdu.

Çalınan anahtarı nasıl fark edersiniz?

LLMjacking, çalınan kimlikle başkasının hesabından model çağırmaktır. Fatura kurbana, kullanım saldırgana kalır. Help Net Security’nin aktardığına göre Wiz, sızıntıdan yaklaşık dört buçuk saat sonra saldırganın CI ve Kubernetes düğümlerinden alınan AWS kimliklerini doğruladığını ve Amazon Bedrock modellerine erişimi yokladığını gördü.

Bu yüzden ilk alarm kuralları fatura değil, keşif hareketleri üzerine kurulmalıdır:

Sağlayıcı panelindeki harcama tavanı ve alarm, en basit son savunma hattıdır. Tavan saldırıyı durdurmaz ama faturanın büyüklüğünü sınırlar.

Kontrolleri CI’da birleştirmek

Aşağıdaki iş akışı bağımlılık değişikliklerinde ve her gün çalışır. Sıra önemlidir: paketler hash ile kurulur, .venv içindeki Python çalıştırılmadan önce başlangıç dosyaları denetlenir, güvenlik taraması ayrı bir araç ortamında yapılır. requirements-audit.txt yalnız pip-audit aracını hash’li olarak sabitler.

name: gateway-dependencies

on:
  pull_request:
    paths:
      - requirements.txt
      - startup-allowlist.txt
  schedule:
    - cron: "0 6 * * *"

permissions:
  contents: read

jobs:
  verify:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
        with:
          python-version: "3.12"
      - name: Kilit dosyasıyla kur
        run: |
          python -m venv .venv
          .venv/bin/pip install --require-hashes --only-binary :all: -r requirements.txt
      - name: Başlangıç dosyalarını denetle
        run: bash scripts/python-baslangic-denetimi.sh .venv/lib/python3.12/site-packages startup-allowlist.txt
      - name: Bilinen kayıtları tara
        run: |
          python -m venv .audit
          .audit/bin/pip install --require-hashes -r requirements-audit.txt
          .audit/bin/pip-audit --disable-pip --require-hashes --vulnerability-service osv -r requirements.txt

Bu iş akışının canlı ortam anahtarlarına erişimi olmamalıdır. Kurulumu yapan makine, başlangıç dosyası denetimi bitene kadar güvenilmez sayılmalı; ona sır vermeyin ve kalıcı önbellek paylaşmayın. Docker imajı üretiyorsanız aynı betiği pip install satırının hemen ardından, /usr/local/lib/python3.12/site-packages yoluyla çalıştırın.

Canlıya almadan önce kontrol listesi

Sonuç

LiteLLM 1.107.0 saldırısı, kaynak kodu temiz, imzası geçerli ve resmî kanaldan gelen bir paketin de zararlı olabileceğini gösterdi. Tek bir önlem bu zinciri kırmaya yetmiyor. Kurulumu içeriğe bağlayan kilit ve digest, yeni sürümü hemen almamayı sağlayan bekleme süresi ve başlangıç dosyası denetimi saldırıyı önleyen ilk katmanı oluşturuyor. Kendi ağına bağlı kimlik, çıkış izin listesi ve erken alarm ise bu katman aşıldığında zararı sınırlıyor.

LiteLLM’in olay sonrası yayımladığı ayrıntılı analiz bu yazının da temelini oluşturuyor. Aynı riski taşıyan her gateway ekibi için asıl soru şudur: Bizim sistemimizde bu zincirin ilk halkası, iptal edilmemiş bir anahtar, bugün nerede duruyor?

Birincil kaynaklar