// CLASSIFIED // OFFENSIVE OPS // YETKİLİ PERSONEL DIŞINA ÇIKMAZ //
SYS ONLINE
SES --------
UPLINK SECURE
COORD --.----°N ---.----°W
----.--.--
--:--:-- UTC
MUCAHIC
// Offensive Operations Division, EST. 2026
// OT PROTOKOLLERİ // BÖLÜM #1 // IEC 61850 GOOSE
ENDÜSTRİYEL / OT BLOG · LAB IEC 61850 GOOSE #1

OT Protokolleri #1: GOOSE'u Sökmek

Trafo merkezlerini, kesicileri uzaktan nasıl kontrol ediyorlar, güvenliğini nasıl sağlıyorlar? Merak ettim ve kendime küçük bir lab kurdum. Gördüğüm şu: kesiciyi açtıran o kritik emir, ağda uçuşan sıradan bir Ethernet frame'inden ibaret. Üstelik o frame'in hiçbir yerinde, onu kimin gönderdiğini söyleyen tek bir alan yok. Protokolün adı IEC 61850 GOOSE.

Operatör
OP-0042 / MUCAHIC
Yayın
2026.09.12
Okuma
~ 25 DK
Dosya ID
MU-016-GOOSE

Trafo Merkezinde Bir Emir

Bir trafo merkezinde bir hatta arıza çıkınca, o hattı besleyen kesicinin milisaniyeler içinde açması gerekir; yoksa ekipman yanar, daha kötüsü biri zarar görür. Bu "aç" emrini bir koruma rölesi verir ve komşu cihazlara haber uçurur: "ben açıyorum, sen kilitle." Eskiden bu işi bir bakır tel yapardı: iki cihaz arasına çekilmiş fiziksel bir sinyal hattı. IEC 61850 o teli ortadan kaldırdı: emir artık diğer bütün trafikle aynı Ethernet ağında uçuşan sıradan bir frame. Adı GOOSE: Generic Object Oriented Substation Event. Beni asıl merak ettiren şuydu: bu kadar kritik bir sinyal sıradan bir ağ mesajına dönüştüyse, onu taklit etmekten kimi alıkoyuyor? Kısa cevap, bu yazının konusu: kimse.

GOOSE'un tek işi hız ve güvenilirlik: kritik bir olayda hedef, bir "trip"in yaklaşık üç milisaniye içinde ilgili tüm cihazlara ulaşması. Bunu başarmak için TCP/IP'yi tamamen atlıyor, doğrudan Layer 2'de (Ethernet frame'i olarak) yayınlanıyor. Kimse "aldım" demiyor; gönderen aynı mesajı azalan sıklıkta tekrar tekrar basıyor ki kaybolmasın. Zarif bir tasarım. Ama bu yazının konusu şu: o "kesiciyi aç" emri imzasız bir Ethernet frame'inin içinde taşınıyor, ve o frame'de kimden geldiğini kanıtlayan hiçbir şey yok. İmza yok, mesaj doğrulama kodu (MAC) yok, anahtar yok.

Kritik nokta: GOOSE'un zayıflığı bir kod hatası değil, bir tasarım kararı. Frame kimden geldiğini kanıtlayamıyor, ve bunu hiçbir yama düzeltmez. Aşağısı, o tek boşluğun neye mal olduğunu byte seviyesinde ve gerçek bir labda gösteriyor.

Önce Resmin Tamamı: Bu Cihazlar Ne İş Yapıyor

Bu alanı hiç bilmiyorsan, birkaç kelimeyi baştan oturtalım; gerisi çorap söküğü gibi gelir. Bir trafo merkezi, elektriğin şehre/fabrikaya dağıtıldığı yerdir. Oradaki en kritik parça kesici (breaker): elektrik hattını fiziksel olarak açıp kapatan dev bir şalter. Normalde kapalıdır, akım geçer. Hatta bir arıza olursa (kısa devre gibi), o kesicinin milisaniyeler içinde açması gerekir, yoksa kablolar erir, trafo patlar, biri yaralanır. Peki kesici arızayı nereden bilecek? Bilmez. Onun yerine bir koruma rölesi (bir tür akıllı bilgisayar, cihaz adıyla IED) akımı sürekli izler; anormalliği görünce kesiciye "AÇ" der. İşte o "AÇ" komutu, aşağıdaki sağ panelde kırmızı okla gösterdiğim mesaj, ağ üstünden gidiyorsa adı GOOSE.

NORMAL DURUM ARIZA ANI Şebeke gelen hat Kesici KAPALI Şehir aydınlık Koruma rölesi akımı izler, sorun yok Kesici kapalı, akım geçiyor. Her şey normal. Şebeke gelen hat ARIZA Kesici AÇIK Şehir elektrik yok Koruma rölesi arızayı gördü! Röle "AÇ" dedi, kesici açıldı. Hat kesildi, ekipman korundu. bu "AÇ" mesajı = GOOSE Röleden çıkan bu "AÇ" mesajı GOOSE: kesiciyi çoğu zaman doğrudan değil, onu süren IED'i tetikler, ve bus'taki tek bir cihaza değil ilgili tüm abonelere aynı anda gider (multicast).
// TEMEL SENARYO · KESİCİ, RÖLE VE ARADAKİ "AÇ" MESAJI (GOOSE)
Kesici breaker
Elektrik hattını fiziksel açıp kapatan dev şalter. Açarsa o hattın elektriği kesilir.
Koruma rölesi IED
Akımı izleyen akıllı cihaz. Arıza görünce kesiciye "aç" komutunu gönderen beyin.
Trip
Kesiciye giden "aç" komutu; sonucunda o hat enerjisiz kalır.
GOOSE
O komutu ve olay bildirimlerini cihazdan cihaza taşıyan ağ mesajı. Yazının baş aktörü.
Bus
Tüm cihazların bağlı olduğu ortak ağ kablosu. GOOSE burada herkese aynı anda yayılır.
Publish / Subscribe
Bir cihaz yayınlar (publish), ilgilenenler dinler (subscribe). Tıpkı bir radyo kanalı gibi.

GOOSE Nerede Oturur

Bir şeyi taklit etmeden önce ağda nerede durduğunu bilmen gerekir; o yüzden ilk baktığım şey GOOSE'un ağdaki yeri oldu. Çoğu protokol katman katman yükselir: Ethernet, IP, TCP, uygulama. GOOSE bunların üstüne çıkmaz. IP yoktur, TCP yoktur. Frame doğrudan Ethernet'in üstüne binen bir "EtherType" ile işaretlenir: 0x88B8. Bu bir tasarım tercihi, çünkü her katman gecikme demek; koruma sinyali gecikmeyi kaldıramaz.

Sonuç şu: GOOSE yönlendirilmez. IP olmadığı için bir router onu bir ağdan diğerine geçiremez. Bir Layer 2 multicast olarak yalnızca aynı Ethernet segmentindeki cihazlara ulaşır. Hedef MAC adresi de standart bir multicast bloğundan gelir: 01:0c:cd:01:xx:xx. Çalışma mantığı bir radyo yayını gibi: bir röle konuşur (publisher), dinlemek isteyen cihazlar onu dinler (subscriber). Peki bir cihaz, ağdaki onca yayının içinden hangisini dinleyeceğini nereden biliyor? Üç etikete bakarak: paketin gittiği MAC adresi, bir uygulama numarası (APPID) ve yayının adı (gocbRef). Üçü de tutuyorsa "bu benim yayınım" deyip içeriğini işler. Aklında tut, çünkü saldırıda bu üç etiketi de biz taklit edeceğiz.

Bunun güvenlik açısından iki yüzü var. İyi haber: GOOSE internetten erişilemez, tehdit "içeriden" olmak zorunda. Kötü haber: içeride olduğunda, aradaki hiçbir şey seni durdurmaz.

GOOSE, 61850 Ailesinin Neresinde

GOOSE tek başına bir standart değil, IEC 61850 ailesinin bir parçası. Aynı ailede üç ana haberleşme servisi var ve karıştırılmamaları önemli:

Fiziksel olarak bir modern trafo merkezinde iki ağ katmanı bulunur: sahadaki Merging Unit'lerin ürettiği ölçüm örneklerini (SV) ve koruma IED'lerinin trip GOOSE'larını taşıyan process bus, ve IED'lerle SCADA/HMI arasındaki MMS ve istasyon-seviyesi GOOSE'u taşıyan station bus. Her IED içinde işlevler "mantıksal düğüm" (logical node) olarak modellenir: bir mesafe koruma fonksiyonu PDIS, bir kesici XCBR, ölçüm MMXU gibi. Tüm bu yapı, mühendisin yazdığı bir XML dosyasında (SCL/SCD) tanımlanır; hangi IED'nin hangi GOOSE'u yayınladığı, hangi aboneye gittiği hep orada sabittir. Saldırgan açısından bu iyi haber: hedef ağın haritası çoğu zaman bir yapılandırma dosyasında hazır durur.

Bir Trip Nasıl Yayılır

Somutlaştıralım. Trafo merkezinde cihazlar (IED'ler) ortak bir Ethernet ağına, "istasyon bus"a bağlıdır. Bir hatta arıza olduğunda, o hattı koruyan röle bunu milisaniyeler içinde algılar, veri setini günceller (yani bir olay olduğunu işaretler) ve tek bir GOOSE frame'ini bus'a multicast eder. Adres tek bir cihaza değil, o frame'le ilgilenen herkese gider: komşu röle kilitlenir, kesici kontrol IED'i kesiciyi açar, HMI olayı gösterir. Kimse "aldım" demez; zaten cevap beklenmez, çünkü kritik bir trip mesajının teslimi yaklaşık üç milisaniyelik bir bütçeyle sınırlıdır (bu tek mesajın transfer süresi, tüm koruma zincirinin değil). Aşağıda bu dağılımı canlandırdım: akan çizgiler tek bir frame'in bus üzerinden aynı anda herkese ulaşmasını temsil ediyor.

Röle A arıza gördü stNum++ · "AÇ" İSTASYON BUS · tek frame, herkese 1 Röle B ✓ etiket tuttu → kilitle 2 Kesici IED ✓ etiket tuttu → kesiciyi AÇ 3 Başka röle ✗ etiket tutmadı → yok say 4 Router IP katmanı geçmez · IP yok Paket bus'ta ilerler; her durakta etiket kontrol edilir. Tutan işler, tutmayan yok sayar. Router IP olmadığı için geçirmez.
// BİR TRIP ADIM ADIM · PAKET DURAK DURAK NASIL İŞLENİR

Bir Frame'i Byte Byte

Konuşmak yeter, bir frame'e bakalım. Aşağıdaki, kendi labımda yakaladığım bir GOOSE frame'i: libiec61850'nin örnek publisher'ının yaydığı, üç değerlik bir analog veri seti taşıyan 198 baytlık bir mesaj. Ethernet başlığından sonra 8 baytlık bir GOOSE başlığı (APPID, uzunluk, iki rezerve alan), ardından da asıl içerik: ASN.1/BER ile kodlanmış bir APDU. BER, "her alan bir etiket, bir uzunluk, bir değer" (TLV) mantığıyla ilerler. Söküyoruz. (Uyarı: burası yazının en dibi, ham byte'lara ve ASN.1'e iniyoruz. Byte matematiği ilgini çekmiyorsa bu bölümü hızlı geçip stNum/sqNum'u anlattığım bir sonraki bölüme atlayabilirsin, kaçırdığın kritik bir şey olmaz.)

GERÇEK FRAME · 198 BAYT (soldan sağa telde bu sırayla) Ethernet II 14 bayt GOOSE başlığı 8 bayt goosePdu APDU (BER/ASN.1) 176 bayt · frame'in asıl gövdesi, aşağıda açtım ↓ goosePdu APDU'nun İÇİNDEKİ alanlar (her biri: etiket · uzunluk · değer) gocbRef yayın adı TTL 500 ms datSet veri seti t zaman stNum 1 sqNum 0 confRev 1 allData 1234·5678 ↑ değişim sayacı · "yeni olay, datayı oku" (asıl komut allData'da) Dikkat: bu alanların hiçbiri "gönderen kim" demiyor. İmza yok, kimlik yok, anahtar yok. Doğru yayın adını (gocbRef) ve artan bir stNum'u yazan herkes, o cihaz gibi konuşur.
// GOOSE FRAME'İN İÇİ · APDU AÇILIMI

Baştaki üç alan frame'i tanımlar: gocbRef (kontrol bloğunun adı), timeAllowedToLive (abone bir sonraki mesajı ne kadar beklesin, milisaniye), ve datSet (hangi veri seti). Sonra bir zaman damgası (t), sonra da bu yazının kalbi olan iki sayaç: stNum ve sqNum. En sonda allData, yani gerçek değerler. Bu örnekte üç değer var: iki tamsayı (1234 ve 5678) ve bir zaman değeri. Röle dünyasında bunlar birer akım/gerilim ölçümü ya da bir röle konumu (açık/kapalı) olurdu.

BER: Her Şey Bir Etiket, Bir Uzunluk, Bir Değer

GOOSE'un içi ASN.1'in BER kurallarıyla kodlanır ve bu kural aslında tek bir fikirden ibarettir: TLV, yani "Tag-Length-Value". Her alan bir etiket baytıyla başlar (bu alanın ne olduğu), ardından bir uzunluk gelir (değerin kaç bayt olduğu), sonra da değerin kendisi. Uzunluk 127 veya daha küçükse (0x7F dahil) tek bayttır; daha büyükse "uzun form" devreye girer: ilk baytın en anlamlı biti 1'e çekilir ve kalan 7 bit "kaç bayt uzunluk geliyor"u söyler. Yakaladığım frame'de APDU'nun başındaki 61 81 ad tam olarak budur: 0x61 "goosePdu" etiketi, 0x81 "bir baytlık uzunluk geliyor", 0xad = 173 bayt.

GOOSE'a özgü ikinci incelik IMPLICIT context etiketleme. Standart tip baytları (INTEGER, OCTET STRING gibi) yerine her alan sırasına göre bir "context" etiketi taşır: 0x80 birinci alan, 0x81 ikinci, ve böyle gider. 0xAB ise (yani 0xA0 | 0x0B, "constructed" biti set, context etiketi 11) içinde başka TLV'ler barındıran allData. Bütün bir frame'i, hiçbir kütüphane olmadan, sadece bu iki kuralla açabilirsin. İşte yakaladığım frame'in APDU'su, TLV TLV:

# captured.bin — goosePdu APDU
61 81 ad                          goosePdu · uzun form len = 0xAD (173 bayt)
 80 29 73696d..416c756573       [0] gocbRef (41 b): "simpleIOGenericIO/LLN0$GO$gcbAnalogValues"
 81 02 01 f4                   [1] timeAllowedToLive = 0x01F4 = 500 ms
 82 23 73696d..756573          [2] datSet (35 b): "simpleIOGenericIO/LLN0$AnalogValues"
 83 29 73696d..416c756573       [3] goID (41 b): varsayilan olarak gocbRef
 84 08 6a a4 67 5a 7e 35 3f 0a  [4] t (UtcTime): sec=0x6AA4675A + fraction(3B) + quality(1B)
 85 01 01                      [5] stNum = 1   # olay sayaci
 86 01 00                      [6] sqNum = 0
 87 01 00                      [7] test = false   # Ed2'de "simulation" (Ed1'de "test") alani; libiec61850 bunu boyle cozer
 88 01 01                      [8] confRev = 1
 89 01 00                      [9] ndsCom = false
 8a 01 03                      [10] numDatSetEntries = 3
 ab 10                            [11] allData (16 bayt):
    85 02 04 d2                    integer = 0x04D2 = 1234
    8c 06 00 00 00 00 00 00           binary-time (6 bayt)
    85 02 16 2e                    integer = 0x162E = 5678

Bu döküm frame'in tamamı; 173 baytın her biri yerli yerinde. Dikkat et, allData'nın içinde de aynı TLV mantığı özyineli işliyor; bir 0xA1 (array) ya da 0xA2 (structure) görürsen, değeri yine bir TLV dizisidir ve çözücü kendini tekrar çağırır. Bu özyinelemeli yapı, çözücünün en dikkat isteyen yeri.

Küçük ama dürüst bir not: bu dökümde Ethernet başlığı 14 bayt görünüyor, ama işin aslı biraz farklı. Kullandığım örnek publisher frame'i aslında bir 802.1Q öncelik etiketiyle (priority 4) basıyor. Etiketi ham yakalamada görmememin sebebi "sahada var, labda yok" değil: Linux'un AF_PACKET yakalama yolu 802.1Q etiketini frame'in içinden çıkarıp ayrı bir meta alanına (PACKET_AUXDATA) taşıyor, o yüzden sıradan bir recv() bize etiketsiz 14 baytlık başlık veriyor. Auxdata'ya bakınca etiket orada duruyordu: tpid 0x8100, priority 4. Gerçek bir teldeyse o +4 bayt fiziksel olarak başta durur (18 baytlık başlık) ve GOOSE genelde kendi VLAN'ında, yüksek öncelikle taşınır. Mantık aynı, sadece başında 4 bayt daha olur.

İsimleri Okumak: gocbRef ve UtcTime

Bu iki alan biraz daha açıklamayı hak ediyor, çünkü IEC 61850'nin isimlendirme mantığını taşıyorlar. gocbRef değeri olan simpleIOGenericIO/LLN0$GO$gcbAnalogValues dört parçadan oluşur: simpleIOGenericIO mantıksal cihaz (Logical Device), LLN0 o cihazın "sıfırıncı" mantıksal düğümü (her IED'de bulunan yönetim düğümü), GO fonksiyonel kısıt (Functional Constraint) yani "bu bir GOOSE kontrol bloğu", ve gcbAnalogValues kontrol bloğunun adı. Yani bu string, koca bir trafo merkezinde tek bir yayın akışının küresel adresi gibidir. Saldırgan için anlamı: aboneyi hangi yayına bağlı olduğunu bu isimden okur ve birebir taklit eder.

t alanı ise 8 baytlık bir UtcTime: ilk 4 bayt 1970'ten beri geçen saniye (0x6AA4675A, çözünce yakaladığım güne düşüyor), sonraki 3 bayt saniyenin kesri, son 1 bayt ise "zaman kalitesi" (saatin senkron olup olmadığını söyleyen bir bayrak). Bu da spoof edilebilir; abone "olay ne zaman oldu"yu tamamen gönderene güvenerek okur.

Geriye kalan üç bayrağı da kısaca geçeyim, çünkü diyagramda görünüyorlar: confRev veri setinin/konfigürasyonun sürüm numarası; mühendis dataset'i değiştirince artar ve abone kendi beklediğiyle eşleşmezse frame'i reddedebilir, yani bir tür sürüm kilidi (gerçekçi bir spoof'ta bunu da doğru vermen gerekir; gerçi her stack bunu geçerlilik için dayatmaz, örneğin lab'ın stok abonesi confRev'i sadece saklıyordu, reddetmiyordu). ndsCom "commissioning gerekiyor" işareti, cihaz henüz tam ayarlanmadığında set edilir. test ise mesajın gerçek mi yoksa test amaçlı mı olduğunu söyler. Üçü de düz alan, hiçbiri kimlik doğrulamıyor.

Kalp: stNum ve sqNum

GOOSE'un tüm semantiği bu iki sayaçta saklı, o yüzden ağır çekim gidelim.

Bu ritmi görmek en iyisi. Aşağıdaki zaman çizelgesinde solda sükunet var: hiçbir şey değişmiyor, aynı frame geniş aralıklarla tekrarlanıyor, sadece sqNum tikliyor. Ortada bir olay oluyor (arıza), ve her şey değişiyor: stNum bir artıyor, sqNum sıfırlanıyor, ve publisher aynı yeni durumu önce çok hızlı (milisaniyeler), sonra aralığı büyüterek tekrar tekrar basıyor. İşte GOOSE'un cevapsız güvenilirliği burada: kaybolan tek bir frame kritik değil, çünkü hemen ardından bir sürüsü geliyor.

zaman → SÜKUNET · stNum = 1 yavaş tekrar, sadece sqNum tikler OLAY ANI · stNum = 2 hızlı patlama, sonra yavaşlar sq0 sq1 sq2 sq3 stNum 1→2 · sqNum sıfır sq0 1 2 3 4 5 Güvenilirlik ACK'ten değil tekrardan gelir: değişince önce çok sık (≈4 ms), sonra aralık büyür.
// stNum / sqNum ZAMANLAMASI — SÜKUNET vs OLAY ANI (ANİMASYONLU)

Yakaladığım üç ardışık frame'de bunu doğrudan gördüm: stNum sabit 1 kaldı, sqNum 0, 1, 2 diye ilerledi. Yani "durum değişmedi, sadece tekrar ediyorum". Şimdi kritik soru: abone, gelen bir frame'in gerçekten meşru bir cihazdan mı yoksa benden mi geldiğini nasıl anlıyor? Cevap: anlamıyor. Elindeki tek ipucu bu iki sayaç, ve ikisini de ben yazabiliyorum.

Lab: Trafiği Akıtmak

Teori yeter, gerçek trafik lazım. Referans implementasyon libiec61850 (mz-automation, endüstride yaygın, açık kaynak); güncel sürümüyle derlenen örnek publisher ve subscriber elimde. GOOSE Layer 2 olduğu için ortamı izole tuttum: bir Linux veth çifti, yani sanal bir Ethernet kablosunun iki ucu. Bir uçta publisher yayınlıyor, diğerinde abone dinliyor, host'un gerçek ağına hiç dokunmadan.

Kurulumu bilerek herkesin tekrarlayabileceği kadar basit tuttum: bir Linux kutusu, libiec61850'yi kaynağından derlemek (birkaç dakika), ve iki komut. Önce sanal kabloyu kuruyorsun, ip link add veth-pub type veth peer name veth-sub, iki ucu da up. Sonra bir uçta kütüphanenin örnek goose_publisher'ını, diğer uçta trafiği yakalayan küçük bir ham-soket dinleyiciyi çalıştırıyorsun. Hepsi bir avuç script. Bu yazıdaki decoder, forger, saldırı ve monitör dahil lab'ın tamamını tek bir depoda paylaşıyorum (github.com/Mucahic/iec61850-goose-lab), ki istersen kendi masanda, gerçek bir trafo merkezine dokunmadan bire bir kurup koşturasın. Repoda ayrıca tarayıcıda çalışan bir frame decoder (hex yapıştır, byte byte açılsın) ve bir saldırı simülatörü de var; komut satırına inmek istemezsen oradan da bakabilirsin.

Publisher çalışınca abonenin çıktısı şöyle oldu; her gelen olayda stNum/sqNum, geçerlilik ve değerleri basıyor:

# gercek libiec61850 abonesi, veth uzerinde
GOOSE event:
  stNum: 1 sqNum: 0
  timeToLive: 500
  message is valid
  allData: {1234, ..., 5678}

İşte akan trafik. Şimdi bu trafiği anladığımı kanıtlamam lazım; onun için kendi çözücümü yazıyorum.

Kendi Decoder'ımı Yazmak

Bir protokolü gerçekten anladığının kanıtı, onu ikinci el bir kütüphaneye sormadan kendin çözebilmendir. Yakaladığım ham baytları, sıfırdan yazdığım küçük bir BER çözücüye verdim. Ethernet başlığını, GOOSE başlığını ve APDU'daki her TLV alanını hiçbir kütüphane kullanmadan açtı:

$ ./goose_decode.py captured.bin
=== goosePdu APDU (tag 0x61, len=173) ===
  [0x80] gocbRef = simpleIOGenericIO/LLN0$GO$gcbAnalogValues
  [0x81] timeAllowedToLive = 500
  [0x82] datSet = simpleIOGenericIO/LLN0$AnalogValues
  [0x83] goID = simpleIOGenericIO/LLN0$GO$gcbAnalogValues
  [0x84] t (UtcTime) = 6aa4675a7e353f0a
  [0x85] stNum = 1
  [0x86] sqNum = 0
  [0x87] test = 0
  [0x88] confRev = 1
  [0x89] ndsCom = 0
  [0x8a] numDatSetEntries = 3
  [0xab] allData:
      [0] integer = 1234
      [1] binary-time
      [2] integer = 5678

Değerler libiec61850'nin yaydığıyla bire bir tuttu. (Dürüst not: GOOSE'u çözmek yeni bir şey değil; Wireshark'ın yerleşik bir GOOSE dissector'ı, libiec61850'nin de kendi çözücüsü zaten var. Amacım bir boşluğu doldurmak değil, protokolü hiçbir kütüphaneye sormadan sıfırdan açabildiğimi göstermek. Zaten çıktımın doğruluğunu bu hazır araçlara referans vererek kopya çektim, bire bir tuttu.) Artık frame'i hem okuyabiliyorum hem de, bir sonraki adımda, sıfırdan yazabiliyorum. Ve yazabildiğim an, kimlik doğrulaması olmayan bir protokolde her şey bitiyor.

Kırılma Noktası: Kimlik Yok

Decode'un tersi encode. Aynı BER mantığıyla çalışan küçük bir "forger", abonenin filtresine uyan alanları (gocbRef, datSet, APPID) birebir kopyalıyor; stNum, sqNum ve değerler ise tamamen benim. Kaynak MAC rastgele, hiçbir gerçek cihaza ait değil. Bu frame, veth'in publisher ucundan gerçek libiec61850 abonesine gidiyor.

Aboneyi kırma mantığını doğru kurabilmek için önce kaynak kodunu okudum. Frame gocbRef/APPID/MAC filtresine uyunca goose_receiver.c içinde şu kritik blok çalışıyor (sadeleştirilmiş):

/* goose_receiver.c — eslesen abone bulundu, listener cagrilmadan hemen once */
if (matchingSubscriber->stNum == stNum) {
    if (matchingSubscriber->sqNum >= sqNum)
        isValid = false;          // ayni stNum + artmayan sqNum = replay
}
matchingSubscriber->stNum = stNum;   // stNum KOSULSUZ uzerine yazilir
matchingSubscriber->sqNum = sqNum;   // (daha dusuk stNum bile reddedilmez)
matchingSubscriber->listener(...);   // HER eslesen cerceve icin cagrilir

Bütün "güvenlik" bu. Kütüphanenin geçerlilik kararında iki yerleşik mekanizma var: (a) BER çözümü bozuksa ya da aynı stNum içinde gelen sqNum bir öncekinden büyük değilse mesaj "geçersiz" sayılır (kaba bir sıra/replay kontrolü), ve (b) timeAllowedToLive süresi dolduğunda mesaj "bayat" sayılır (timeliness). Ama ikisi de kimlik değil: kaynak/imza doğrulaması yok, stNum'un monotonluğu dayatılmıyor (daha düşük bir stNum bile sessizce kabul edilip üzerine yazılıyor), ve "bu gerçek bir olay mı" kararı tamamen üstteki uygulamaya bırakılıyor. Üstelik saldırgan taze, sqNum'u artan frame'ler bastığı sürece iki kontrolü de kendiliğinden geçer. Yani doğru gocbRef ve artan bir stNum'la konuştuğum sürece, abone beni bir cihaz sanır.

Forger tarafında iş basit: her alanı elle TLV olarak kodlayan küçük bir BER yazıcı (etiket + uzunluk + değer), abonenin beklediği gocbRef/datSet/APPID'yi birebir kopyalıyor, stNum/sqNum/değerleri ise parametre. Tek gerçek kısıt, veri setinin şeklini (kaç eleman, hangi tipler) korumak; şekli bozarsan abone parse-error yolundan gidip eski değerleri tutuyor (bunu aşağıda dead-end olarak da yaşadım). Dört fazlık bir senaryo kurdum, dördü de yukarıdaki kabul mantığıyla bire bir tuttu:

SAHTE FRAME → GERÇEK ABONE P1 · uydurma frame (stNum=1) VALID P2 · birebir replay INVALID P3 · yalan ölçüm (stNum=2) VALID · {9999,1} P4 · sqNum bump VALID kimlik yok → kabul tek yerleşik kontrol saldırgan ölçümü dikte etti replay kontrolü delindi
// GOOSE SEMANTİK SALDIRI — 4 FAZ

P1'de rastgele MAC'ten uydurduğum frame doğrudan valid kabul edildi. Abone benim frame'imi gerçek cihazdan ayıramadı; kimlik doğrulaması yok, o kadar. P2'de aynı frame'i birebir tekrarladım ve INVALID aldım; tek yerleşik savunma olan replay sayacı devreye girdi. Kulağa savunma gibi geliyor, ama P3 ve P4 onu yıkıyor: P4'te sadece sqNum'u 0'dan 5'e çıkardım, kontrol düştü. P3 ise asıl vuruş; veri setinin şeklini bozmadan değerleri yalanladım (1234 yerine 9999), stNum'u artırdım, ve abone yalan ölçümü valid yeni bir olay olarak raporladı:

GOOSE event:
  stNum: 2 sqNum: 0
  message is valid
  allData: {9999, ..., 1}   # benim yalan olcumum, mesru kabul edildi

Gerçek bir kurulumda P3'ün karşılığı şudur: GOOSE'un asıl taşıdığı şey çoğu zaman ikili durum sinyalleridir, bir "trip" bayrağı, bir kesici konumu (XCBR açık/kapalı), bir interlock/bloklama biti. Saldırının en temiz hâli sahte bir "trip" ya da sahte bir "kesici açık" durumu basmak, ya da tam tersi, gerçek bir arıza varken "durum normal / bloklama aktif" diye sahte bir sinyal basıp korumayı köreltmek. Lab'daki örnek publisher analog değerler yaydığı için ben integer'ları (9999/1) yalanladım; GOOSE ölçüm ve scheme değerleri de taşıyabilir, ama kanonik hedef ikili trip/konum sinyalidir. Hepsi sadece doğru gocbRef'i ve artan bir stNum'u bilmeyi gerektiriyor, ki ikisi de telde açıkça duruyor.

Bir sınırı net koyayım: bu dört fazın sonucu libiec61850'nin abone davranışı, evrensel bir "GOOSE röleleri hep böyle yapar" kanıtı değil. P2'nin reddedilmesi (replay kontrolü) bir standart zorunluluğu değil, bu kütüphaneye özgü bir implementasyon tercihi; başka bir stack ham replay'i kabul de edebilir, farklı davranabilir. Değişmeyen tek şey protokolün kendisi: çerçevede kimlik yok. Kabul edip etmeme mantığı stack'ten stack'e değişir, ama "kimin gönderdiğini kanıtlama" hiçbirinde yok.

Dürüst bir çıkmaz: ilk denememde sahte frame'e fazladan bir alan koyup veri setinin şeklini bozmuştum; abone bunu uyumsuz sayıp eski değerlerini korudu, INVALID işaretledi. Ders şu: körlemesine veri basmak işe yaramıyor, gerçek veri setinin şeklini birebir taklit edip yalnızca değerleri yalanlaman gerekiyor. Zaten saldırıyı gerçekçi kılan da bu, sahadaki saldırgan önce dinler, şekli öğrenir, sonra ona göre üretir. (Küçük bir lab notu: referans alıcı açılışta ilk frame'i iki kez işliyordu; telde tek kopya olduğunu doğruladım, güvenlik etkisi yok, sadece bir başlatma ayrıntısı.) Çıkmazları da yazıyorum, çünkü labın dürüstlüğü kanıtın bir parçası.

stNum Poisoning: Röleyi Sağırlaştırmak

Sahte olay enjekte etmenin bir kardeşi var: poisoning. Küçük bir ayrım işi netleştirir: az önceki enjeksiyon (P1-P4) monotonluğu dayatmayan bir alıcıda işliyordu; poisoning ise tam tersine monotonluğu dayatan bir alıcıyı hedefler. İkisi aynı kimlik-boşluğunun zıt uçları. Monotonluk dayatan bir alıcı, gelen stNum'un bir öncekinden büyük olmasını bekler (mantıklı, çünkü olaylar geri gitmez). Bir saldırgan stNum'a mümkün olan en büyük değeri (0xFFFFFFFF) basarsa, o abone artık bundan sonraki tüm meşru frame'leri "eski" sayıp yok sayar. Yani gerçek röle bağırmaya devam eder ama abone sağırlaşmıştır; kritik anda gelen gerçek "aç" emrini duymaz.

Bir incelik var: stock libiec61850 abonesi stNum monotonluğunu kendisi dayatmaz (o kararı uygulamaya bırakır), yani poisoning'in etkisi bu davranışı uygulayan gerçek IED mantığında görünür. O yüzden bunu havada bırakmamak için, gerçek röle mantığını taklit eden küçük bir abone yazdım: kural "bir gocbRef için yalnızca stNum önceki en yüksekten büyükse yeni geçerli olay say, değilse eski/geri sayıp düşür." Sonra meşru röleyi (artan stNum) çalıştırıp araya poison'u bastım. Sonuç:

$ ./goose_guard_sub.py veth-sub   # stNum-monotonluk dayatan abone
[GUARD] stNum=1          KABUL (yeni olay, işle)
[GUARD] stNum=2          KABUL
[GUARD] stNum=3          KABUL
[GUARD] stNum=4294967295 KABUL   # saldırgan: 0xFFFFFFFF, abone buna kilitlendi
[GUARD] stNum=4          DÜŞ  (ESKİ sayıldı, YOK SAYILDI)  # meşru röle
[GUARD] stNum=5          DÜŞ  (ESKİ sayıldı, YOK SAYILDI)  # meşru röle

İşte poisoning bu: saldırgan tek bir 0xFFFFFFFF frame'iyle aboneyi tavana kilitledi; ondan sonra gerçek röle bağırmaya devam etti (stNum 4, 5) ama abone hepsini "eski" sayıp yok saydı. Yani kritik anda gelen gerçek "aç" emrini artık duymaz. Bu, bir enjeksiyon değil bir reddetme saldırısı: koruma kanalını sessizce sağır ediyor. (Not: bu abone benim yazdığım, monotonluğu dayatan gerçekçi bir model; gerçek bir vendor IED'sinde etki tam olarak stack'e bağlıdır. Durumu naif tutan, yani TTL/timeAllowedToLive dolduğunda sıfırlamayan ve stNum sarmasını (0xFFFFFFFF sonrası 1'e wrap) düzgün ele almayan bir alıcı bu kilide açıktır; TTL zaman aşımıyla durumu tazeleyen bir alıcıda kalıcı etki için saldırganın poison'u sürekli tazelemesi gerekebilir.)

Saldırı Vektörleri: Basitten Profesyonele

Şimdiye kadar tek bir saldırıyı ayrıntılı gösterdim. Ama GOOSE'un kimlik-doğrulamasızlığı tek bir teknik değil, bir yelpaze açıyor: en tembel script-kiddie hamlesinden, bir şebeke koşuluna zamanlanmış cerrahi bir sabotaja kadar. Aynı boşluğu farklı ustalık seviyeleriyle sömüren bu basamakları, en aşağıdan (basit, gürültülü, kolay yakalanır) en yukarıya (ince, sinsi, neredeyse görünmez) doğru dizdim. Her basamağın yanında onu labda gösterip göstermediğimi işaretledim.

profesyonel · sinsi basit · gürültülü 6 · Hedefli sessiz sahte-durum cerrahi trip / arızayı maskele, izlemeye görünmez TEORİK 5 · stNum poisoning meşru publisher'ı sağırlaştır (sessiz koruma DoS) MEKANİZMA 4 · Yarış / patlama aboneyi kendi sahte durumuma kilitle MEKANİZMA 3 · Sahte olay (spoof) yalan trip/ölçüm valid kabul edilir GÖSTERİLDİ 2 · Bump'lı replay tek bayt değiştir, replay kontrolü delinir GÖSTERİLDİ 1 · Ham replay frame'i olduğu gibi geri bas — çoğu zaman yakalanır GÖST. (RED)
// GOOSE SALDIRI VEKTÖRÜ MERDİVENİ — BASİTTEN PROFESYONELE

Basamakları tek tek açayım:

Dikkat edilecek nokta: yukarı çıktıkça teknik zorluk değil, asıl artan şey keşif ve zamanlama. Aşağıdaki basamaklar "bus'a eriş, bir şeyler at" seviyesindeyken, yukarıdakiler "hedefi tanı, doğru anı bekle, iz bırakma" seviyesi. İkisini de mümkün kılan aynı tek boşluk: frame'de kimlik yok.

Gerçekte Kim Sömürür

Şimdi abartıyı kesme zamanı. GOOSE routing edilmez; internetten kimse bir trafo merkezine GOOSE basamaz. Bu saldırının ön koşulu, saldırganın istasyon LAN'ına veya şalt sahası switch'ine Layer 2 erişimi olması. Bu ciddi bir eşik. Ama imkânsız değil: yanlış yapılandırılmış bir mühendislik VLAN'ı, fiziksel olarak erişilebilir bir switch portu, uzaktan ele geçirilmiş bir HMI ya da mühendislik istasyonu, kötü ayrılmış bir kurumsal/OT ağı. Yani bu "uzaktan tek tıkla" değil, "içeri bir kere girildiğinde aradaki her şeyin çöktüğü" bir zafiyet. Bir OT ağında derinlemesine savunmanın neden bu kadar önemli olduğunun tam da sebebi bu.

Bunun teorik olmadığını gösteren en somut örnek Industroyer / CRASHOVERRIDE (2016, Ukrayna): ICS protokollerine özel yazılmış, bir şebekede kesici açtıran malware. IEC 60870-5-101 ve -104'ün yanında bir IEC 61850 modülü de taşıyordu; ağdaki cihazları keşfedip anahtarları işletebiliyordu. Kritik nokta şu: o saldırıda da (ve 2015'teki BlackEnergy olayında da, 2022'deki Industroyer2'de de) kesiciler açıldı, ama saldırganlar bunu çoğunlukla meşru arayüzleri ve MMS/104 üzerinden yaptı, ham GOOSE spoof'uyla değil. Yani GOOSE'un imzasızlığı henüz manşet olmuş bir saha vektörü değil; ama o ağa girildiğinde elde hazır duran, daha sinsi bir alternatif. Bu yazıdaki lab tam da o "içeri girildikten sonra" anını modelliyor.

Savunma ve Sonuç

Sonuçta mesele tek bir varsayıma iniyor: GOOSE'da güven, "bu frame doğru MAC'ten ve doğru gocbRef'le geldi" demekten ibaret, ve bu varsayımın altında hiçbir kriptografik temel yok. Parser'ı ne kadar sertleştirirsen sertleştir, boşluk kodda değil, protokolün kendisinde.

Peki savunma? Sırasıyla:

"İzleme"yi lafta bırakmayayım. Gürültülü bir saldırgan, meşru yayıncıyla aynı gocbRef'i paylaştığı sürece ağda iki gözle görülür iz bırakır: (1) aynı gocbRef'in iki farklı kaynak MAC'ten gelmesi, (2) stNum'un geri gitmesi ya da absürt sıçraması. Bu iki kuralı basan küçük, pasif bir monitör yazıp yukarıdaki saldırının üstüne saldım. Kod yaklaşık 30 satır ve mantığı bir Zeek/Suricata betiğine birebir taşınabilir. Meşru röle (bir MAC, stNum 1..3) yayınlarken saldırgan farklı bir MAC'ten stNum=5000 basınca:

$ ./goose_monitor.py veth-sub   # pasif mavi-takım monitörü
[MONITOR] taban: gcbAnalogValues <- 00:11:22:33:44:55 (stNum=1)
[ALARM] AYNI gocbRef İKİ KAYNAKTAN: gcbAnalogValues  beklenen=00:11:22:33:44:55  gelen=de:ad:be:ef:00:01  (SPOOF / çift-publisher)
[ALARM] stNum absürt SIÇRADI: 3 -> 5000  (poison / enjeksiyon şüphesi)

Yani GOOSE'u imzalayamıyor olsan bile, gürültülü bir spoof'u ve poison'u ağ seviyesinde yakalamak mümkün. Ama bu iki kural, ritme uyup sessiz kalan usta saldırganı yakalamaz; o sınırı ve izin asıl nerede durduğunu yazının sonundaki güncellemeye ekledim.

Neden 62351-6 Hâlâ Açık Değil

"İmza varsa niye kullanmıyorlar" sorusu haklı, ve cevabı GOOSE'un kendi doğasında. Koruma sinyali yaklaşık üç milisaniye bütçesiyle çalışıyor demiştik; üstelik bir olay olduğunda tek frame değil, arka arkaya bir patlama gönderiliyor. Bu, her frame'in ayrı ayrı ve o dar zaman bütçesi içinde imzalanması (ve alıcıda doğrulanması) demek. 62351-6'nın ilk sürümleri bunun için asimetrik imza öneriyordu; sahadaki gömülü rölelerin işlemcisinde bu, zaman bütçesini tek başına yiyip bitirebiliyor. Bir de anahtar dağıtımı sorunu var: onlarca IED'ye kimin hangi anahtarla güveneceğini yönetmek (GDOI/anahtar sunucuları) başlı başına bir altyapı. Sonuç: standart yıllardır kâğıt üstünde var ama saha kurulumlarının ezici çoğunluğunda kapalı. Bu yüzden bu yazıdaki zayıflık "teorik" değil, bugünkü gerçek trafo merkezlerinin varsayılan durumu.

Bir de şu var: GOOSE aslında yönlendirilemez dedik, ama R-GOOSE (IEC 61850-90-5) onu UDP/IP'ye sararak geniş alan üzerinden, hatta merkezler arası taşınabilir hâle getiriyor (senkrofazör/WAMS senaryoları). Bu, "sadece L2, sadece içeriden" konforunu genişletir: routing'e açılan her yerde tehdit yüzeyi de büyür. R-GOOSE tasarımı imzayı daha ciddiye alıyor, ama açık olup olmaması yine kuruluma bağlı.

Serinin ilk durağı buydu. Bir sonraki protokolde yine aynı hattan geçeceğim: en baştan sök, labda kur, kır, ve dürüstçe ne bulduğunu, daha önemlisi ne bulmadığını söyle. GOOSE bize güzel bir ders verdi: bazen en tehlikeli zafiyet, kodda bir hata değil, herkesin normal saydığı bir tasarım kararıdır.

Kullandığım her şey (yakalanan frame, sıfırdan decoder, forger) kendi labımda, izole bir ortamda üretildi. Zaman ayırıp okuduğun için teşekkürler; bir hatamı görürsen yaz, seve seve ders çıkarırım.

// RAPOR SONU, MUCAHIC / OP-0042 / 2026.09.12

Güncelleme · Bir Okuyucu Düzeltmesi

// 2026.09.19 · SAVUNMA BÖLÜMÜ DÜZELTMESİ

İlk hâlinde savunma bölümünde "saldırgan sessiz kalamaz, çünkü meşru yayıncıyla yarışmak zorundadır" demiştim. @kujutabooo bunun fazla genel bir iddia olduğunu keskin bir gözlemle gösterdi, ve haklı.

Yazdığım iki monitör kuralı (farklı kaynak MAC + absürt stNum sıçraması) yalnızca naif saldırganı yakalar. Oysa 9. bölümde, saldırı merdiveninin 6. basamağında kendi tarif ettiğim profesyonel saldırgan tam da bu iki kuralın kör noktasında: meşru MAC'i kopyalar (MAC yazılabilir bir alan, yazının kendi tezi), stNum'u ritme uyup yalnızca bir artırır (sqNum sıfır, normal bir olay gibi görünür), hatta meşru yayıncıyı susturarak yarışı bütünüyle ortadan kaldırır. Yani cümlenin çekirdeği (yarışan, gürültülü saldırgan iz bırakır) doğru, ama onu "her saldırgan" diye genellemem hataydı; üstelik kendi 6. basamağımla çelişiyordu.

Asıl iz, spooflanabilir MAC seviyesinde değil, giderek derinleşen bir merdivende duruyor:

1 · Switch ingress portu. gocbRef'i kaynak MAC'e değil, yayıncının bağlı olduğu fiziksel switch portuna bağla; aynı gocbRef iki porttan geliyorsa, saldırgan MAC'i kusursuz taklit etse bile yakalanır.

2 · (stNum, sqNum) tekilliği. Meşru yayıncı hâlâ hayattaysa, aynı (stNum, sqNum) çifti telde iki kez ya da çelişkili içerikle görünür.

3 · Semantik korelasyon. Saldırgan yayıncıyı sustursa bile, rölenin kendi olay kaydı (SOE) abonenin işlediği olayla çelişir: röle "trip yok" derken abone açmışsa oradan bellidir.

4 · Fiziksel gerçek. Saldırgan doğrudan yayıncı IED'nin üstündeyse (Industroyer deseni) port da SOE de yanıltılabilir; geriye tek ground-truth fiziksel süreç kalır: kesici cidden açıldı mı, analog telemetri ne diyor.

"Sessiz kalınamaz" tezi aslında bu merdivenin tepesinde, fiziksel gerçekte doğru; ama benim yazdığım ağ katmanında değil. İz yukarı çıktıkça saldırganın onu silmesi zorlaşır. Bu düzeltme için @kujutabooo'ya teşekkürler.