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.
Ö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.
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:
- MMS (Manufacturing Message Specification): TCP/IP üstünde, istemci-sunucu. SCADA'nın bir IED'den değer okuması, ayar yazması, rapor alması bununla olur. Yavaş ama güvenilir, onaylı iletişim. Ağır ASN.1 yüzeyi burada.
- GOOSE: bu yazının konusu. Layer 2, publish/subscribe, olay-tetikli, milisaniye ölçeğinde. Koruma ve interlock sinyalleri.
- Sampled Values (SV): yine Layer 2, ama sürekli akan ham ölçüm örnekleri (akım/gerilim dalga formu), saniyede binlerce frame. "Process bus"un asıl trafiği.
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.
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.)
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.
- stNum (state number): Bir değişim sayacı. Veri setinde bir şey değiştiğinde (bir röle açtı, bir ölçüm eşiği geçti) publisher stNum'u bir artırır ve sqNum'u sıfırlar. Abone için stNum'un artması "veri seti değişti, yeni bir olay var, değerleri yeniden oku" demektir. Dikkat: kesiciyi açtıran asıl komut stNum'un kendisi değil, veri setindeki değerdir (bir trip boolean'ı, bir röle konumu); stNum onu "yeni olay" diye işaretleyen sinyal. Yine de kritik olan şu: bir aboneyi yeni bir duruma ikna etmek, doğru veri setiyle birlikte stNum'u artırmaktan ibaret.
- sqNum (sequence number): Aynı olayın tekrarı. Hiçbir şey değişmediğinde publisher aynı mesajı azalan sıklıkla tekrar basar ve her tekrarda sqNum'u artırır. Bu, "hâlâ buradayım, durum aynı" demek ve kayıp mesaja karşı sigortadır.
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.
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:
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.
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.
Basamakları tek tek açayım:
- 1 · Ham replay. Bir GOOSE frame'i yakala,
tcpreplayile aynen geri bas. Sıfır anlayış ister. Ama en zayıfı: birebir aynı stNum+sqNum olduğu için libiec61850'nin replay sayacı bunu INVALID işaretledi (labda P2). Gürültülü ve kolay yakalanır. - 2 · Bump'lı replay. Aynı yakalanan frame, ama sqNum (ya da stNum) tek bayt artırılmış. Bu, o tek yerleşik savunmayı sıfır maliyetle deler; frame valid olur (labda P4). Hâlâ neredeyse beceri istemez.
- 3 · Sahte olay enjeksiyonu. Frame'i sıfırdan kur: doğru gocbRef/APPID ve veri
setinin şekli, artan stNum, ve yalan değerler. Abone yeni bir olay sanıp senin ölçümünü
raporlar (labda P3:
{9999,1}valid). Bu, gerçek "trip enjekte et" / "arızayı gizle" saldırısı. Şekli öğrenmek için önce dinlemek gerekir; orta seviye. - 4 · Yarış / patlama enjeksiyonu. Tek frame değil, meşru yayıncıdan daha hızlı bir akış bas. Abone en yüksek stNum'u sende gördüğü için senin durumuna kilitlenir. GOOSE'un kendi retransmit mantığını sana çalıştırır. Ağda "aynı gocbRef'ten iki yayıncı" izi bırakır.
- 5 · stNum poisoning. stNum'a
0xFFFFFFFFbas. stNum monotonluğu bekleyen gerçek bir IED, bundan sonraki tüm meşru frame'leri "eski" sayıp atar; koruma kanalı sessizce sağırlaşır. Bu bir enjeksiyon değil, bir reddetme saldırısı. Hedefin davranışını bilmeyi ister. - 6 · Hedefli sessiz sahte-durum. Profesyonel uç. Flood yok; SCL dosyasından ağı haritala, tek bir kesiciyi seç, confRev/quality/timing dahil her alanı meşru cihazın ritmine birebir uydur, ve olayı bir şebeke koşuluna zamanla (gerçek arızayı maskele ya da yanlış anda trip et). İstersen meşru yayıncıyı da sustur (port/fiziksel). İzleme için neredeyse görünmez. Bu labda gösterilmez; hedef-özel mantık + fiziksel/operasyonel erişim ister, devlet-seviyesi bir iştir.
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:
- IEC 62351-6: GOOSE'a kriptografik imza ekleyen standart. Varlık yıllardır biliniyor ama sahada neredeyse hiç açık değil (performans kaygısı, eski ekipman). Açılabildiği yerde açmak, sorunu kökten çözen tek şey.
- Ağ segmentasyonu: GOOSE'un yaşadığı Layer 2 alanını fiziksel/mantıksal olarak sıkı sınırlamak. Saldırganın oraya erişememesi, bu saldırının ön koşulunu ortadan kaldırır.
- İzleme: Aniden zıplayan bir stNum, beklenmedik bir kaynak MAC, ya da aynı gocbRef'ten iki farklı yayıncı; bunların hepsi ağ seviyesinde yakalanabilir anomalilerdir. Ama bu ancak gürültülü bir saldırganı yakalar; sınırını yazının sonundaki güncellemede dürüstçe çiziyorum.
- Uygulama katmanında stNum monotonluğu: Alıcı mantığının geri giden veya absürt sıçrayan stNum'a şüpheyle yaklaşması.
"İ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:
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.