Разговоры в небе. Как самолеты передают данные по ADS-B
Современные самолеты непрерывно передают друг другу телеметрию по протоколу ADS-B. В этой статье мы с тобой сделаем собственный воображаемый самолет, чтобы посмотреть, как работает передача данных. Конечно, будем при этом соблюдать все законы и правила безопасности.
ADS-B — это часть большого протокола Mode S, который активно используется для идентификации и отслеживания воздушных судов. ADS-B позволяет отслеживать летательные аппараты, на которых установлен специальный транспондер. Он регулярно передает данные о самолете: высоту, координаты, скорость и многое другое. Принять их может любой желающий, так как никакой защиты или шифрования в протоколе не предусмотрено.
На этот раз мы полностью сосредоточимся на передаче своего сигнала ADS-B. Чтобы никому не помешать, принимать мы его тоже будем на свой приемник. Учти: это вовсе не пособие по оснащению самолетов транспондерами. Скорее мы будем использовать этот любопытный пример для демонстрации того, как кто‑то может разобрать и подделать любой незащищенный сигнал.
ТЕСТОВЫЙ ПОЛИГОН
Тестовое окружение необходимо, чтобы видеть, что мы делаем, но при этом не влиять на оборудование аэропортов. Вряд ли они оценят наши эксперименты! Зато ущерб вполне могут оценить, а сумму выставить к оплате. Чтобы этого избежать, я взял два SDR и использовал на минимальной мощности в экранирующем помещении. К тому же сигналы на частотах около 1 ГГц, которые я использовал, практически не распространяются за горизонт.
В моем распоряжении оказались два неплохих и довольно популярных SDR: HackRF и BladeRF. Первый я буду использовать как передатчик, а второй — как приемник. Программную часть сделаем с помощью программы, которая умеет парсить сигналы Mode S (частью которого и является ADS-B) в реальном времени и выводить их в читаемом формате.
Схема установки будет выглядеть примерно так.
Для стабильной работы на оба SDR нужно накрутить любую штыревую антенну, так как без нее ничего работать не будет или будет, но очень‑очень плохо.
Настройка BladeRF
С заводской прошивкой BladeRF не умеет работать с dump1090 — придется их подружить вручную. Первым шагом будет скачивание прошивки, совместимой с ADS-B, с официального сайта разработчика:
wget https://www.nuand.com/fpga/adsbxA4.rbf
После этого сразу же загружаем скачанный файл в BladeRF:
Дальше нам надо установить еще две программы: bladeRF-adsb и саму dump1090. Первая нужна просто для того, чтобы транслировать данные с SDR напрямую в dump1090.
Скачиваем и собираем dump1090:
git clone https://github.com/mutability/dump1090.git cd dump1090 make ./dump1090 --net-only --raw --interactive
После запуска dump1090 переходим к мосту:
git clone https://github.com/Nuand/bladeRF-adsb cd bladeRF-adsb/bladeRF_adsb wget http://nuand.com/fpga/adsbx40.rbf wget http://nuand.com/fpga/adsbx115.rbf make ./bladeRF_adsb
Теперь, когда все готово для приема и декодирования сигналов ADS-B, уже можно принять какие‑то сигналы от самолетов (да, даже в комнате), особенно если самолет близко или если у тебя хорошая антенна.
ViralAir
Чтобы генерировать свои сообщения ADS-B, я создал утилиту ViralAir и выложил ее на GitHub. Чтобы повторять за мной, скачай и скомпилируй ее:
git clone https://github.com/st3rv04ka/ViralAir cd ViralAir go build cmd/viralair/main.go
После сборки появится файл main
, который и будет генерировать готовый для передачи файл. Формат этого файла полностью подходит для HackRF, но может быть совместим и с другими SDR (я не проверял). Чтобы проверить, что все хорошо, можно создать тестовый самолет с позывным 0xDEADBE
на высоте 9999 футов следующей командой:
./main -altitude 9999.0 --icao 0xDEADBE
В текущей директории должен появиться файл Samples.iq8s
. Если он действительно есть, значит, все работает и можно продолжать.
СТРУКТУРА ADS-B
Подготовка
Давай немного освежим знания про структуру сообщения ADS-B из прошлой статьи. Сообщение может быть разной длины — 56 либо 112 бит. От длины зависит тип передаваемой информации, а сам тип записывается в поле TC (Type Code) сообщения. Обычное 112-битное сообщение выглядит следующим образом.
Как ты мог заметить, никакого поля TC тут нет — и все верно, оно спрятано внутри поля ME. Дело в том, что ADS-B — это всего лишь часть большого протокола Mode S, и выше представлена структура именно сообщения Mode S, в то время как все специфичные для ADS-B поля находятся внутри поля данных (ME) Mode S.
Давай освежим в памяти назначение остальных полей пакета:
- DF (downlink format) отвечает за тип сигнала в Mode S. Для ADS-B всегда равен 17;
- CA (transponder capability) отвечает за тип транспондера, который мы теоретически можем поставить какой угодно, но на практике будем использовать 6 (что означает транспондер уровня 2+);
- после CA идет ICAO — уникальный номер самолета. В эти 24 бита мы можем запихнуть что угодно, а в рамках этой статьи будем запускать самолет
0xDEADBE
; - ME — само сообщение ABS-B. Это может быть высота, скорость или координаты. Мы будем передавать только координаты (TC равен 11), без скорости и высоты. Скорость и высота ничем не хуже, просто мне хочется разобрать подробнее кодирование CPR.
CPR
Возможно, ты читал прошлую статью и еще помнишь, как я мучился с CPR и упрощенными картами для передачи в маленьких сообщениях. Из‑за этих карт в ADS-B есть два вида сообщений с координатами: четное и нечетное, и, только имея оба сообщения, можно определить точную локацию самолета. Создавать такие пакеты еще сложнее, чем принимать.
Давай пробежимся по моему коду на Go, который переводит координаты с CPR:
func nl(declatIn float64) float64 {
// Близко к полюсам секторов мало, возвращаем всего один
if math.Abs(declatIn) >= 87.0 { return 1.0 }
return math.Floor(
(2.0 * math.Pi) * math.Pow(
math.Acos(1.0 - (1.0-math.Cos(math.Pi/(2.0*latz)))
/ math.Pow(math.Cos((math.Pi/180.0) * math.Abs(declatIn)), 2)), -1))}func dlon(declatIn float64, ctype int, surface int) float64 {
var tmp float64
if surface == 1 { tmp = 90.0 }
else { tmp = 360.0 }
nlcalc := math.Max(nl(declatIn)-float64(ctype), 1)
return tmp / nlcalc}
// Кодируем в CPRfunc
CprEncode(lat float64, lon float64, ctype int, surface int) (int, int) {
var scalar float64
if surface == 1 { scalar = math.Pow(2, 19) }
else { scalar = math.Pow(2, 17) }
dlati := dlat(ctype, surface) yz := math.Floor(scalar*((math.Mod(lat, dlati))/dlati) + 0.5)
dloni := dlon(lat, ctype, surface) xz := math.Floor(scalar*((math.Mod(lon, dloni))/dloni) + 0.5)
return int(yz) & ((1 << 17) - 1), int(xz) & ((1 << 17) - 1)}
Да, в этом коде черт ногу сломит, но на самом деле за сложной математикой стоят довольно простые алгоритмы.
Начнем по порядку, с функции nl()
. Она рассчитывает количество зон на заданной нами широте. Логика простая: чем ближе к полюсам, тем меньше зон заданного размера поместится на одной широте. Вот так выглядят реальные зоны в CPR.
Чтобы не морочить себе голову сложной математикой, при широте больше 87 градусов будем возвращать количество делений равное единице. Формула для расчета взята из официальной документации к протоколу, и в самом документе она выглядит довольно страшно.
После nl()
идет функция dlon()
. Количество делений на широте у нас уже есть, а теперь нужно рассчитать интервал, чтобы равномерно распределить эти деления по кругу и чтобы все они были одинаковыми. При этом важно учитывать, где находится самолет, — на земле или в воздухе. Если самолет в воздухе, мы делим 360 на количество интервалов минус тип сообщения (even или odd). Полученный результат возвращается в конце функции. Функция dlat()
делает плюс‑минус то же самое для долготы, так что не будем останавливаться на ней отдельно.
Теперь перейдем к основной функции расчета CPR. После получения количества зон можно рассчитать непосредственно координаты самолета. Для этого учтем множитель, который можно найти в документации протокола (он скрыт глубоко в формулах). Для воздуха он будет равен 217, а для земли — 219.
Полученное после кодирования число нужно сжать до 17 бит, что и делается при возврате результатов из функции.
PI
Если ты выдохнул с мыслью, что все сложное уже позади, вдыхай обратно! Вторая сложность при передаче своих сигналов — это расчет контрольной суммы (CRC), или parity bits. Нужно посчитать и добавить в конец сообщения 24 бита, которые используются для проверки целостности сообщения. Именно на них ориентируется, например, dump1090. Загвоздка в том, что формула для расчета выглядит так.
В псевдокоде эта функция выглядит примерно так:
generator = 1111111111111010000001001
# 11 + 3 нулевых байта data_hex = 8D406B902015A678D4D220[000000]
# 88 бит data = 1000110101000000011010 1110010000001000000001 0101101001100111100011 0101001101001000100000 [000000000000000000000000]
# 24 бита FOR i FROM 0 TO (112-24):
IF data[i] IS 1:
data[i:i+24] = data[i:i+24]
XOR generator remainder = data[-24:]
# Результат: 101010100100101111011010, или AA4BDA в HEX
Генератор — это константа, которую нашли специально для этого алгоритма и которая наиболее эффективна. В цикле мы просто проходим по всем битам от 0 до 88 (112 минус 24, потому что последние 24 бита мы сейчас и заполняем) и применяем исключающее ИЛИ с генератором. Полученные 24 бита нужно добавить к нашему сообщению, чтобы у нас был законченный пакет данных. Вот как я это сделал в ViralAir:
const ( GENERATOR = "1111111111111010000001001" )
func Crc(msg string, encode bool) string {
msgbin := []rune(misc.Hex2bin(msg))
generator := []int{} for _, char := range GENERATOR {
generator = append(generator, int(char-'0')) }
if encode {
for i := len(msgbin) - 24;
i < len(msgbin); i++ { msgbin[i] = '0' } }
for i := 0; i < len(msgbin)-24; i++ {
if msgbin[i] == '1' { for j := range generator {
msgbin[i+j] = rune('0' + (int(msgbin[i+j]-'0') ^ generator[j])) } } } reminder := string(msgbin[len(msgbin)-24:])
return reminder}
Реализация на Go похожа на наш псевдокод, так что разбирать подробно ее не буду.
Собираем всё вместе
Теперь, когда у нас есть все служебные функции, можно создавать пакет! Начинается всё с кодирования высоты, за это отвечает функция encodeAltModes()
.
// Encode altitude
func encodeAltModes(alt float64, surface int) int {
mbit := 0 qbit := 1 encalt := int((int(alt) + 1000) / 25)
var tmp1, tmp2 int
if surface == 1 { tmp1 = (encalt & 0xfe0) << 2
tmp2 = (encalt & 0x010) << 1 }
else { tmp1 = (encalt & 0xff8) << 1
tmp2 = 0 }
return (encalt & 0x0F) | tmp1 | tmp2 | (mbit << 6) | (qbit << 4)}
В зависимости от высоты применяются разные делители. Для обычных самолетов — 25, но есть и другие, например 100. Они нужны для самолетов, которые летают выше, чем обычные. За выбор делителя отвечает параметр qbit
. Смысл делителя — указать, какого размера взят интервал для высоты.
Поскольку указывать высоту в нормальных величинах, а не в футах — это слишком просто, авторы протокола придумали делить высоту на интервалы по N футов и указывать номер такого интервала, а N — это и есть наш делитель (сколько футов в одном интервале). Делитель в 25 футов для нашего самолета дает точность около 7,6 метра.
По поводу самого qbit
документация протокола говорит следующее:
This field will contain barometric altitude encoded in 25 or 100-foot increments (as indicated by the Q Bit). All zeroes in this field will indicate that there is no altitude data.
То есть если qbit
установить в 0, то делитель станет равным 100, что бывает полезно, если высота слишком большая и ее сложно уместить в сообщение. В ViralAir предусмотрен только делитель 25, хотя добавить 100 можно довольно легко — попробуй реализовать это сам на досуге!
Результат работы функции для высоты в 9999 футов ты можешь увидеть ниже:
2024/01/15 03:40:37 [+] Encode altitude [9999.000000] with the surface flag [0]
2024/01/15 03:40:37 [+] Encoded altitude [0x377]
Следующая задача — создать два сообщения (even
и odd
), и начнем мы с even
. Первая часть любого сообщения ADS-B — это его тип. DF (тип сообщения Mode S) для ADS-B всегда равен 17, так что это константа в коде, а после него идут CA (уровень транспондера) и ICAO (номер самолета). CA и ICAO можно задать свои как аргументы при запуске ViralAir.
// Format + CA + ICAO
dataEven = append(dataEven, byte((format<<3)|ca))
dataEven = append(dataEven, byte((icao>>16)&0xff))
dataEven = append(dataEven, byte((icao>>8)&0xff))
dataEven = append(dataEven, byte((icao)&0xff))
Теперь добавим к фрейму долготу, широту и высоту:
// Even
log.Printf("[+] Encode even frame with lat [%f] and lon [%f]", lat, lon)
evenLat, evenLon := cpr.CprEncode(lat, lon, 0, surface)
log.Printf("[+] Encoded even frame lat [0x%02x] and lon [0x%02x]", evenLat, evenLon)
// Odd
log.Printf("[+] Encode odd frame with lat [%f] and lon [%f]", lat, lon)
oddLat, oddLon := cpr.CprEncode(lat, lon, 1, surface)
log.Printf("[+] Encoded odd frame lat [0x%02x] and lon [0x%02x]", oddLat, oddLon)
При запуске программы закодированные значения выводятся в консоль:
./main -altitude 9999.0 -icao 0xDEADBE -latitude 11.33 -longitude 11.22
<...>
2024/01/15 03:40:37 [+] Encode even frame with lat [11.330000] and lon [11.220000]
2024/01/15 03:40:37 [+] Encoded even frame lat [0x1c6d4] and lon [0x19d86]
2024/01/15 03:40:37 [+] Encode odd frame with lat [11.330000] and lon [11.220000]
2024/01/15 03:40:37 [+] Encoded odd frame lat [0x1b6b6] and lon [0x18d91]
<...>
Байты для обоих пакетов готовы, добавляем их к нашему фрейму:
// Lat + Lot + Alt (even)
dataEven = append(dataEven, byte((tc<<3)|(ss<<1)|nicsb))
dataEven = append(dataEven, byte((encAlt>>4)&0xff))
dataEven = append(dataEven, byte((encAlt&0xf)<<4|(time<<3)|(ff<<2)|(evenLat>>15)))
dataEven = append(dataEven, byte((evenLat>>7)&0xff))
dataEven = append(dataEven, byte(((evenLat&0x7f)<<1)|(evenLon>>16)))
dataEven = append(dataEven, byte((evenLon>>8)&0xff))
dataEven = append(dataEven, byte((evenLon)&0xff))
Данные фрейма готовы, осталось дописать к ним контрольную сумму.
// Convert to hex
var sbEven strings.Builderfor _, b := range dataEven[:11] { sbEven.WriteString(fmt.Sprintf("%02x", b))}
dataEvenString := sbEven.String()log.Printf("[+] Even frame without CRC [%s]", dataEvenString)
// Calculate CRC
dataEvenCRC := misc.Bin2int(crc.Crc(dataEvenString+"000000", true))
log.Printf("[+] Even data CRC [%02x]", dataEvenCRC)
// Append CRC
dataEven = append(dataEven, byte((dataEvenCRC>>16)&0xff))
dataEven = append(dataEven, byte((dataEvenCRC>>8)&0xff))
dataEven = append(dataEven, byte((dataEvenCRC)&0xff))log.Printf("[+] Even data [%02x]", dataEven)
После этого в терминал вывалится полный фрейм Mode S, который можно использовать как угодно: передать, декодировать онлайн или даже распечатать и повесить на стенку.
2024/01/15 03:40:37 [+] Even data [8ddeadbe5837738da99d861b04b3]
2024/01/15 03:40:37 [+] Odd data [8ddeadbe5837776d6d8d9121b103]
От отправки сигнала нас отделает всего одна мелочь: нужно смодулировать наш сигнал.
ПЕРЕДАЧА
Модуляция
Чтобы передать наши байты в эфир, нужно преобразовать их в формат, который поймет HackRF. Формат очень простой: нужно закодировать все байты в комплексные числа и сохранить их в файл.
Для ADS-B применяется манчестерское кодирование, то есть единица кодируется как 01
, а ноль как 10
. Чтобы лучше это понять, посмотри на картинку ниже.
То есть нужно двоичное представление каждого байта закодировать по описанной выше схеме — и дело в шляпе. Все это базовые операции с числами, которые можно легко сделать на любом языке. Ниже — моя реализация на Go.
func Frame1090esPpmModulate(even, odd []byte) []byte {
var ppm []byte for i := 0; i < 48; i++ {
ppm = append(ppm, 0) }
ppm = append(ppm, 0xA1, 0x40) for _, byteVal := range even {
word16 := misc.Packbits(manchesterEncode(^byteVal))
ppm = append(ppm, word16[0])
ppm = append(ppm, word16[1]) }
for i := 0; i < 100; i++ {
ppm = append(ppm, 0) }
ppm = append(ppm, 0xA1, 0x40)
for _, byteVal := range odd {
word16 := misc.Packbits(manchesterEncode(^byteVal))
ppm = append(ppm, word16[0])
ppm = append(ppm, word16[1]) }
for i := 0; i < 48; i++ {
ppm = append(ppm, 0) }
return ppm}
Нужно только не забыть о наличии преамбулы — специальной последовательности битов в начале фрейма, по которой приемник определяет начало сообщения. В нашем случае это 0xA1 0х40
, что соответствует преамбуле Mode S.
Полученные биты нужно модулировать в PPM. Как работает PPM и чем отличается от других способов модуляции, наглядно видно на картинке ниже.
Только вот кодировать мы будем не аналоговый сигнал, а уже готовый цифровой, то есть представлять наш сигнал в виде комплексных чисел. Углубляться во все их тонкости мы сейчас не станем, поскольку для наших задач нужно только два таких числа, с которыми мы даже не будем производить никаких расчетов. Комплексное число состоит из двух компонент (I и Q), которые для бита с высоким уровнем мы установим в максимальное значение, а для низкого — в минимальное:
func GenerateSDROutput(ppm []byte) []byte {
bits := misc.Unpackbits(ppm)
var signal []byte for _, bit := range bits {
var I, Q byte if bit == 1 { I = byte(127) Q = byte(127) }
else { I = 0 Q = 0 } signal = append(signal, I, Q) }
return signal}
Теперь записываем результат в файл и получаем наш заветный Samples.iq8s
.
В эфир
Ну вот, теперь можешь выдыхать с облегчением. После сборки тестового стенда, генерации файла с сигналом и настройки всего оборудования мы наконец‑то можем передать данные о нашем самолете самим себе!
Включаем прием, как описано в начале, и передаем сигнал через HackRF:
~/P/ViralAir (main)> hackrf_transfer -t Samples.iq8s -f 1090000000 -s 2000000 -x 47 -R
call hackrf_set_sample_rate(2000000 Hz/2.000 MHz)
call hackrf_set_hw_sync_mode(0)
call hackrf_set_freq(1090000000 Hz/1090.000 MHz)
Stop with Ctrl-C
3.9 MiB / 1.000 sec = 3.9 MiB/second, average power -6.5 dBfs
3.9 MiB / 1.000 sec = 3.9 MiB/second, average power -6.5 dBfs
3.9 MiB / 1.000 sec = 3.9 MiB/second, average power -6.5 dBfs
4.2 MiB / 1.000 sec = 4.2 MiB/second, average power -6.5 dBfs
Аргумент -t
— это файл с сигналом, который мы сгенерировали; -f
задает частоту передачи (для ADS-B это 1090 МГц, или 1 090 000 000 Гц), а -s
— частоту дискретизации. Последние два аргумента отвечают за мощность передачи и ее зацикливание. Зачем зацикливать? Ведь у нас всего два сообщения, и сразу после окончания передачи они пропадут из окна dump1090
.
Давай теперь взглянем на приемник!
В таблице виден наш самолет DEADBE
! Он летит на высоте 9975 футов (сказывается неточность кодирования высоты), и его даже можно найти на карте.