Год назад наша CTF-команда на крупном международном соревновании RuCTF в Екатеринбурге в качестве одного из призов получила радиоуправляемый танк.
Зачем команде хакеров игрушечный радиоуправляемый танк? Чтобы его реверсить, конечно.
В статье я расскажу, как при помощи GNU Radio и HackRF One можно c нуля разобраться в беспроводном протоколе управления танком, как декодировать его пакеты и генерировать их программно, чтобы управлять танком с компьютера.
Осмотр подопытного
Дано:
- Радиоуправляемый танк
- Пульт управления
- Компьютер с установленным пакетом GNU Radio
- HackRF One
Посмотрим сперва на сам пульт.
Правый джойстик пульта отвечает за движение танка: вперед, назад, поворот на месте. У джойстика нет промежуточных положений, то есть ехать медленно не получится. Можно только ехать или не ехать.
Левый джойстик отвечает за поворот башни и стрельбу. "Лево"-"право" поворачивает саму башню в соответствующем направлении. "Вниз" позволяет целиться по вертикали: пока джойстик находится в этом положении, ствол циклически двигается по вертикали вверх-вниз. А чтобы выстрелить, нужно задержать джойстик в положении "вверх" на несколько секунд.
Внизу пульта есть переключатель канала с тремя позициями ("A", "B" и "C"), на днище танка есть такой же.
На пульте есть и несколько других кнопок. На кнопки OK и 123456 танк никак не реагирует. Нажатие на кнопку (/) переводит пульт в какой-то странный режим, в котором танк перестаёт на него реагировать. Повторное нажатие возвращает всё, как было. Скорее всего, этот пульт может использоваться для других кроме танка игрушек, и там эти кнопки уже как-то осмысленно задействованы.
Ну а сзади пульта есть очень полезная для нас наклейака "27.145 MHz".
SDR
Вначале посмотрим на радиоэфир при помощи программы gqrx, которая показывает его в виде красивого "водопада", а также позволяет послушать эфир.
Сразу после включения пульт немного "щелкает", а потом просто оставляет заметную тонкую линию постоянного сигнала. При нажатии кнопок и отклонении джойстиков пульт тоже "щелкает". Ну что ж, пульт мы нашли. Но для декодирования этого, конечно, мало. Движемся дальше в GNU Radio Companion, где будем собирать различные схемы для декодирования сигнала.
Соберем нехитрую схему в GNU Radio, которая позволяет настроиться на частоту и визуализировать сигнал.
Попробую, будучи самим не экспертом в SDR, и действовавшему в основном по наитию, объяснить, что происходит.
Во-первых, в качестве источника мы будем использовать элемент RTL-SDR Source, который работает как с совсем дешевыми RTL-SDR, так и с более продвинутыми устройствами типа HackRF One.
Важно то, что настраиваться нужно не ровно на требуемую частоту, а немного в сторону. Это связано с тем, что большинство SDR по чисто аппаратным причинам обладают так называемым DC bias. После настройки на определенную частоту ровно "посередине", на нулевой частоте, будет присутствовать постоянная составляющая, которая выглядит как достаточно мощный постоянный сигнал. Чтобы обойти эту особеннось, достаточно настраиваться немного вбок, а затем, если оно требуется, сдвигать сигнал уже программно. Тогда пик DC bias и исследуемый сигнал будут достаточно разнесены, чтобы не влиять друг на друга.
На скриншоте видно, что в качестве альтернативного источника я использовал файл. Действительно, зачем каждый раз тянуться за пультом, если можно один раз записать и потом просто воспроизводить?
Следующий элемент, Frequency Xlating FIR Filter, является комбинированным блоком для переноса сигнала по частоте, фильтрации и децимации. После переноса интересующий нас сигнал оказывается в нулевой частоте, фильтрация отбрасывает неинтересные нам частоты, где находятся DC bias и прочие шумы, а децимация понижает частоту дискретизации. С сигналом низкой частоты дискретизации проще и эффективнее работать (попросту требуется меньше ресурсов CPU). Сейчас я, к сожалению, не могу вспомнить, из каких рандомных блогов и каких соображений я подобрал такие значения для фильтра low_pass, но они работают достаточно хорошо: firdes.low_pass(1.0, samp_rate, samp_rate / decimation * 0.4, 2e3)
.
Хинт: в GNU Radio можно использовать в качестве переменных-параметров блоков виджеты типа QT GUI Range (просто указывая их ID вместо константы), и тогда эти параметры можно будет регулировать интерактивным виджетом прямо во время работы схемы.
Ну и в конце схемы стоит универсальный QT GUI Sink для визуализации сигнала разными способами.
После запуска схемы мы увидим такую картину на вкладке Waterfall Display:
Поднастроим freq_offset
так, чтобы сигнал был как можно ближе к нулю. Сигнал всё равно будет немного плавать по частоте, и от этого, видимо, никуда не деться. Но это не помешает нам в дальнейшем.
И теперь откроем вкладку Time Domain Display. Поигравшись немного с FFT Size внизу, можно получить в итоге такую картину:
Опа! Да это похоже на биты!
Итак, всё, что мы сделали — это настроились на частоту. То есть перед нами самая обычная амплитудная модуляция.
Комплексная составляющая сбивает с толку, и по графику интуитивно видно, что она здесь не нужна. Нам нужен модуль числа. Выделим его при помощи блока Complex to Mag и посмотрим график ещё раз:
Уже гораздо лучше. Тут сразу видно два логических уровня — "0" на отметке около 0.4, и "1" на отметке 1.3. Ну и всё это разбавлено небольшим шумом, конечно. Хочу обратить внимание, что этот "0" не "абсолютный", а тоже передаётся пультом. Если пульт выключить вообще, сигнал просядет с 0.4 до 0.
Давайте разбираться с этим "фреймом". Сравнительно длинный "1" и следующий за ним "0" — видимо, специальные стартовые биты для синхронизации.
Значение бита кодируется длиной "0" от одной "1" до следующей: короткий "0" — логический ноль, длинный "0" — логическая единица. В одном фрейме, как видно, 16 бит.
Декодирование команд
Теперь можно написать специальный блок для GNU Radio на Python, который будет декодировать фремы и писать их в консоль. Исходники блоков типа Python Block можно редактировать, не выходя из GNU Radio Companion! Очень удобно.
Не буду заострять внимание на коде, желающие могут посмотреть его под спойлером. А итоговая схема декодирования сигнала получилась такая:
import os
import sys
import numpy as np
from gnuradio import gr
class blk(gr.sync_block):
def __init__(self, samp_rate=0.0):
"""arguments to this function show up as parameters in GRC"""
gr.sync_block.__init__(
self,
name='Shitty Tank Decoder', # will show up in GRC
in_sig=[np.int8],
out_sig=[]
)
self._samp_rate = samp_rate
self._sync_threshold = samp_rate / 1000
# for tracking state across buffers
self._last_idx = 0
self._last_level = 0
# for state machine
self._state_machine = None
self._last_event = None
self._last_cmd = None
def start(self):
self._log = sys.stderr
return True
def _on_edge(self, ts, is_raising):
if self._state_machine is None:
self._state_machine = self._state_machine_gen()
self._state_machine.send(None)
elif ts - self._last_event > self._sync_threshold * 10:
if not is_raising:
# stuck on high level? weird
return
# reset state machine
self._state_machine = self._state_machine_gen()
self._state_machine.send(None)
self._state_machine.send(ts)
self._last_event = ts
def _state_machine_gen(self):
while True:
raising = yield
falling = yield
sync_length = falling - raising
if sync_length < self._sync_threshold:
continue
#print >>sys.stderr, "Sync length", sync_length, "samples"
if self._last_cmd is not None:
pass
#print >>sys.stderr, "Intercommand delay", raising - self._last_cmd
res = []
raising = yield
sync_length_low = raising - falling
#print >>sys.stderr, "Sync low length", sync_length_low, "samples"
while len(res) < 16:
falling = yield
#print >>sys.stderr, "peak length", falling - raising
raising = yield
if raising - falling < sync_length_low // 6:
continue
#print >>sys.stderr, "low length", raising - falling
res.append([0, 1][int(raising - falling > sync_length_low // 3)])
falling = yield
cmd = "".join(str(x) for x in res)
print >>self._log, cmd
self._last_cmd = falling
def work(self, input_items, output_items):
data = input_items[0]
if self._last_level is not None:
data = np.insert(data, 0, self._last_level)
else:
self._last_idx = 0
edges = np.diff(data)
edge_indices = np.where(edges != 0)[0]
for i in edge_indices:
self._on_edge(self._last_idx + i, edges[i] > 0)
self._last_idx += len(data)
self._last_level = data[-1]
return len(input_items[0])
Вообще схема получилась весьма неидеальная. Разделение "0" и "1" по константному порогу 0.5 приводит к тому, что схема вообще не работает, когда пульт находится слишком далеко или слишком близко. Дальнейшим экспериментам это не помешало, и вообще я эту особенность заметил только спустя полгода, когда стал писать эта статью. Но я буду признателен, если кто-то подскажет, как это делается правильно.
Разберёмся же, что значат биты в этом протоколе. Будем считать, что данные передаются в порядке MSB, то есть от старших бит к младшим (это лишь вопрос соглашения, не более того).
Во первых, три младших бита отвечают за канал. 000 — А, 010 — B, 100 — C. Это было несложно проверить экспериментально.
Однократное отклонение левого джойстика влево генерирует такую последовательность команд (здесь и далее канал будет A):
0000010000000000
0000010000000000
0000000011110000 # <- повторяется порядка 20 раз
Отклонив и задержав джойстик, мы получаем такое:
0000010000000000
0000010000000000
0001010000000000 # <- повторяется пока мы держим джойстик
0000000011110000 # <- повторяется порядка 20 раз
Для всех других направлений паттерн получается похожий: старшие три бита остаются неизменными и нулевыми, четвертый бит работает как этакий "флаг повтора", последующие четыре бита отвечают за направление (право, лево, вверх, вниз соответственно). И в самом конце повторяется довольно странно выглядящая команда, по семантике, видимо, означающая "стоп". Эту же команду "стоп" пульт несколько раз транслирует сразу после включения.
С правым джойстиком, отвечающим за перемещение танка, всё несколько интереснее. Напомню, что "вверх"-"вниз" отвечает за движение танка вперед и назад, а "влево"-"вправо" — за поворот на месте. Эти биты идут сразу после предущих, как раз в той позиции, где мы видели 1111 при останове. Изменяются они довольно странным образом. Сможете догадаться, почему именно так?
- 0101 — вперед
- 1010 — назад
- 0110 — влево
- 1001 — вправо
Как и в случае левого джойстика, при повторе выставляется тот же самый четвертый старший бит, и после отпускания идет пакет с четырьмя единицами.
Кнопка ОК посылает команду с зажженым первым старшим битом (то есть 1000000000000000), длительное нажатие порождает такие же команды с флагом повтора. Танк команду игнорирует.
Кнопка (/) переводит пульт в странный режим, где ко всем командам джойстиков (кроме "стоп") добавляются старшие биты 2 и 3. Танк на такие команды, как было сказано в начале, не реагирует. Повторное нажатие на кнопку переводит пульт обратно в исходный режим.
Кнопка 123456 посылает команду "стоп", (которая с 1111 в позиции джойстика движения). Если удерживать кнопку нажатой, выставляется флаг повтора. Зачем она нужна, тоже непонятно.
Назначение четвертого младшего бита выяснить не удалось, он всегда равен нулю.
Два джойстика можно отклонять одновременно, при этом получаются пакеты c ненулевыми битами в обоих полях. С кнопкой ОК это не сочетается, она имеет приоритет над джойстиками.
Резюмируя, общий формат пакетов получается таков:
K##RTTTTMMMMxCCC
R - повтор
T - башня (turret)
M - движение (movement)
C - канал (channel)
K - кнопка OK
# - странные биты, на которые влияет кнопка (/)
x - неизвестно
Управление танком с компьютера
HackRF One умеет не только принимать сигнал, но и передавать его. Так давайте же попробуем поуправлять танком с компьютера!
Мы увидели, что модуляция сигнала там очень простая. Сгенерировать такой сигнал с помощью GNU Radio будет несложно. Для этого достаточно генерировать последовательность "0" и "1" с нужными задержками и отправлять их в osmocom Sink, который отправляет их прямиком в эфир.
Приведённый ниже под спойлером блок умеет передавать только команды движения, но его несложно расширить для поддержки всего остального.
from __future__ import print_function
import sys
import numpy as np
from gnuradio import gr
LOW_AMPLITUDE = 0.5
HIGH_AMPLITUDE = 1.0
HIGH_PULSE_LENGTH = 1014e-6
LOW_PULSE_LENGTH = 600e-6
PEAK_LENGTH = 140e-6
LOW_LENGTH_ZERO = 150e-6
LOW_LENGTH_ONE = 270e-6
INTERPACKET_PAUSE = 52000e-6
REPEAT_BIT = 0b0001000000000000
CHANNEL_BITS = {
"A": 0b000,
"B": 0b010,
"C": 0b100,
}
# WTF: 0b0010000001010000
def xround(val):
return int(val + 0.5)
def encode_action(channel, forward, backward, left=False, right=False):
value = 0
value |= CHANNEL_BITS[channel]
print(forward, backward, left, right, file=sys.stderr)
if 0:
value |= 0b0000000011110000
elif forward:
value |= 0b1010000001010000
elif backward:
value |= 0b1010000010100000
elif right:
value |= 0b0000000010010000
elif left:
value |= 0b0000000001100000
else:
value |= 0b0000000011110000
return value
def encode_samples(value, sample_rate):
for _ in xrange(xround(HIGH_PULSE_LENGTH * sample_rate)):
yield 1
for _ in xrange(xround(LOW_PULSE_LENGTH * sample_rate)):
yield 0
for i in range(16):
for _ in xrange(xround(PEAK_LENGTH * sample_rate)):
yield 1
bit = (1<<15) & (value << i)
if bit:
for _ in xrange(xround(LOW_LENGTH_ONE * sample_rate)):
yield 0
else:
for _ in xrange(xround(LOW_LENGTH_ZERO * sample_rate)):
yield 0
for _ in xrange(xround(PEAK_LENGTH * sample_rate)):
yield 1
class blk(gr.sync_block):
def __init__(self, sample_rate=1.0, forward=False, backward=False, left=False, right=False, channel="A"):
gr.sync_block.__init__(
self,
name='Tank Control', # will show up in GRC
in_sig=[],
out_sig=[np.float32]
)
if not channel in ("A", "B", "C"):
raise ValueError(channel)
self.sample_rate = sample_rate
self.forward = forward
self.backward = backward
self.left = left
self.right = right
self.channel = channel
def start(self):
self._generator = self._generate_samples()
return True
def _should_tx(self):
return self.forward or self.backward or self.left or self.right
def _generate_samples(self):
while True:
if self._should_tx():
value = encode_action(self.channel, self.forward, self.backward, self.left, self.right)
# output twice without repeat bit
# weird, but that's what remote does
for _ in xrange(2):
for bit in encode_samples(value, self.sample_rate):
yield bit
for _ in xrange(xround(self.sample_rate * INTERPACKET_PAUSE)):
yield 0
value |= REPEAT_BIT
while self._should_tx():
for bit in encode_samples(value, self.sample_rate):
yield bit
for _ in xrange(xround(self.sample_rate * INTERPACKET_PAUSE)):
yield 0
# stop thing
value = encode_action(self.channel, False, False)
for _ in xrange(2):
for bit in encode_samples(value, self.sample_rate):
yield bit
for _ in xrange(xround(self.sample_rate * INTERPACKET_PAUSE)):
yield 0
yield 0
def work(self, input_items, output_items):
output_items[0].fill(LOW_AMPLITUDE)
output_bits = min(len(output_items[0]), int(self.sample_rate / 100))
for i in xrange(output_bits):
output_items[0][i] = HIGH_AMPLITUDE if next(self._generator) else LOW_AMPLITUDE
return output_bits
И эта схема действительно успешно управляет танком!
Единственная проблема, с которой я столкнулся и не смог до конца победить — очень значительный лаг. Я смог уменьшить эту проблему путем уменьшения размера буферов (hackrf,buffers=2
в Device Arguments у osmocom Sink), а также использованием большой итоговой частоты дискретизации (sample rate). Но неприятный ощутимый лаг, не наблюдающийся при управлении со штатного пульта, всё ещё остался.
Но тем не менее, "proof of concept" был успешно продемонстрирован.
Заключение
Это радиоуправляемый танк работает по очень простому протоколу, который легко реверсится при помощи GNU Radio.
В протоколе используется амплитудная манипуляция с достаточно простым физическим кодированием, где в пакете есть выраженная метка начала, а биты кодируются длиной "0" (низкого уровня).
В каждом пакете есть 16 бит информации, и назначение почти всех этих 16 бит несложно понять просто экспериментируя с пультом.
Собрать схему в GNU Radio Companion, которая бы отправляла команды танку, также оказалось очень несложно. Единственная проблема, которую не удалось побороть до конца — это лаг.
Приложение
- Схема GNU Radio Companion — декодер
- Схема GNU Radio Companion — передатчик
- Запись сигнала с работающего пульта
Автор: WGH