Реверс-инжиниринг радиоуправляемого танка с помощью GNU Radio и HackRF

в 23:00, , рубрики: gnu radio, SDR, реверс-инжиниринг

Год назад наша CTF-команда на крупном международном соревновании RuCTF в Екатеринбурге в качестве одного из призов получила радиоуправляемый танк.

Зачем команде хакеров игрушечный радиоуправляемый танк? Чтобы его реверсить, конечно.

В статье я расскажу, как при помощи GNU Radio и HackRF One можно c нуля разобраться в беспроводном протоколе управления танком, как декодировать его пакеты и генерировать их программно, чтобы управлять танком с компьютера.

image

Осмотр подопытного

Дано:

  • Радиоуправляемый танк
  • Пульт управления
  • Компьютер с установленным пакетом GNU Radio
  • HackRF One

Посмотрим сперва на сам пульт.

image

Правый джойстик пульта отвечает за движение танка: вперед, назад, поворот на месте. У джойстика нет промежуточных положений, то есть ехать медленно не получится. Можно только ехать или не ехать.

Левый джойстик отвечает за поворот башни и стрельбу. "Лево"-"право" поворачивает саму башню в соответствующем направлении. "Вниз" позволяет целиться по вертикали: пока джойстик находится в этом положении, ствол циклически двигается по вертикали вверх-вниз. А чтобы выстрелить, нужно задержать джойстик в положении "вверх" на несколько секунд.

Внизу пульта есть переключатель канала с тремя позициями ("A", "B" и "C"), на днище танка есть такой же.

На пульте есть и несколько других кнопок. На кнопки OK и 123456 танк никак не реагирует. Нажатие на кнопку (/) переводит пульт в какой-то странный режим, в котором танк перестаёт на него реагировать. Повторное нажатие возвращает всё, как было. Скорее всего, этот пульт может использоваться для других кроме танка игрушек, и там эти кнопки уже как-то осмысленно задействованы.

Ну а сзади пульта есть очень полезная для нас наклейака "27.145 MHz".

SDR

Вначале посмотрим на радиоэфир при помощи программы gqrx, которая показывает его в виде красивого "водопада", а также позволяет послушать эфир.

image

Сразу после включения пульт немного "щелкает", а потом просто оставляет заметную тонкую линию постоянного сигнала. При нажатии кнопок и отклонении джойстиков пульт тоже "щелкает". Ну что ж, пульт мы нашли. Но для декодирования этого, конечно, мало. Движемся дальше в GNU Radio Companion, где будем собирать различные схемы для декодирования сигнала.

Соберем нехитрую схему в GNU Radio, которая позволяет настроиться на частоту и визуализировать сигнал.

image

Попробую, будучи самим не экспертом в 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:

image

Поднастроим freq_offset так, чтобы сигнал был как можно ближе к нулю. Сигнал всё равно будет немного плавать по частоте, и от этого, видимо, никуда не деться. Но это не помешает нам в дальнейшем.

И теперь откроем вкладку Time Domain Display. Поигравшись немного с FFT Size внизу, можно получить в итоге такую картину:

image

Опа! Да это похоже на биты!

Итак, всё, что мы сделали — это настроились на частоту. То есть перед нами самая обычная амплитудная модуляция.

Комплексная составляющая сбивает с толку, и по графику интуитивно видно, что она здесь не нужна. Нам нужен модуль числа. Выделим его при помощи блока Complex to Mag и посмотрим график ещё раз:

image

Уже гораздо лучше. Тут сразу видно два логических уровня — "0" на отметке около 0.4, и "1" на отметке 1.3. Ну и всё это разбавлено небольшим шумом, конечно. Хочу обратить внимание, что этот "0" не "абсолютный", а тоже передаётся пультом. Если пульт выключить вообще, сигнал просядет с 0.4 до 0.

Давайте разбираться с этим "фреймом". Сравнительно длинный "1" и следующий за ним "0" — видимо, специальные стартовые биты для синхронизации.

Значение бита кодируется длиной "0" от одной "1" до следующей: короткий "0" — логический ноль, длинный "0" — логическая единица. В одном фрейме, как видно, 16 бит.

image

Декодирование команд

Теперь можно написать специальный блок для GNU Radio на Python, который будет декодировать фремы и писать их в консоль. Исходники блоков типа Python Block можно редактировать, не выходя из GNU Radio Companion! Очень удобно.

Не буду заострять внимание на коде, желающие могут посмотреть его под спойлером. А итоговая схема декодирования сигнала получилась такая:

image

Блок GNU Radio для декодирования пакетов

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, который отправляет их прямиком в эфир.

image

Приведённый ниже под спойлером блок умеет передавать только команды движения, но его несложно расширить для поддержки всего остального.

Блок для кодирования команд

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, которая бы отправляла команды танку, также оказалось очень несложно. Единственная проблема, которую не удалось побороть до конца — это лаг.

Приложение

Автор: WGH

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js