GNU RadioおよびHackRFを使用したラジコンタンクのリバースエンジニアリング

1年前、エカテリンブルクで開催された主要な国際大会RuCTFのCTFチームは、賞品の1つとしてラジコンタンクを受け取りました。







ハッカーのチームがおもちゃのラジコンタンクを持っているのはなぜですか? もちろん、それを逆にします。







この記事では、GNU RadioとHackRF Oneを使用してワイヤレスタンク制御プロトコルをゼロから理解する方法、パケットをデコードしてプログラムで生成し、コンピューターからタンクを制御する方法を説明します。







画像







被験者試験



与えられた:









最初にリモコンを見てみましょう。







画像







リモコンの右のジョイスティックは、タンクの動きを担当します。前方、後方、所定の位置に曲がります。 ジョイスティックには中間位置がありません。つまり、ゆっくり運転しても機能しません。 あなたは行くこともしないこともできます。







左スティックは、砲塔の回転と射撃を担当します。 「左」-「右」は、タワー自体を対応する方向に回転させます。 「下」では、ジョイスティックがこの位置にある間、バレルが周期的に上下に周期的に移動します。 そして、撮影するには、ジョイスティックを数秒間上に保持する必要があります。







コンソールの下部には、3つの位置(「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バイアスを持っているという事実によるものです。 ゼロ周波数で正確に「中間」にある特定の周波数にチューニングした後、かなり強力な一定の信号のように見える一定の成分があります。 この機能を回避するには、少し横に調整し、必要に応じて既にプログラムで信号をシフトします。 次に、DCバイアスピークと調査対象の信号は、互いに影響を与えないように十分な間隔を空けます。







スクリーンショットは、このファイルを代替ソースとして使用したことを示しています。 確かに、一度リモートコントロールに到達し、一度記録してから再生することができるのであれば、どうしてでしょうか。







次の要素、 Frequency Xlating FIR Filterは、周波数信号の転送、フィルタリング、およびデシメーションのための結合ユニットです。 転送後、関心のある信号はゼロ周波数で表示され、フィルタリングは、DCバイアスやその他のノイズが存在する関心のない周波数を破棄し、デシメーションはサンプリング周波数を下げます。 低サンプリングレートの信号を使用する方が簡単で効率的です(必要なCPUリソースが少なくなります)。 残念ながら、どのランダムブログからどのような理由でlow_passフィルターにこのような値を選択したか思い出せませんが、 firdes.low_pass(1.0, samp_rate, samp_rate / decimation * 0.4, 2e3)



ます。







ヒント:GNU Radioでは、 QT GUI Rangeタイプのウィジェットを変数ブロックパラメーターとして使用でき(定数の代わりにIDを指定するだけで)、これらのパラメーターは、回路の操作中にインタラクティブウィジェットによって調整できます。







さて、回路の最後には、信号をさまざまな方法で視覚化するためのユニバーサルQT GUIシンクがあります。







サーキットを開始すると、 Waterfall Displayタブにそのような画像が表示されます。







画像







freq_offset



を調整して、信号ができるだけゼロに近づくようにします。 信号の周波数はまだ少し変動しますが、これは避けられないようです。 しかし、これは将来私たちを止めることはありません。







そして、 時間領域表示タブを開きます。 以下のFFTサイズで少し遊んだ後、最終的に次の図を取得できます。







画像







おっと! はい、それはビットのように見えます!







したがって、私たちがしたことは、周波数に合わせることだけでした。 つまり、最も一般的な振幅変調があります。







複雑なコンポーネントは紛らわしく、スケジュールはここでは不要であることを直感的に示しています。 数のモジュールが必要です。 Complex to Magブロックを使用して選択し、グラフを再度表示します。







画像







すでにはるかに優れています。 ここには2つの論理レベルがすぐに表示されます-0.4付近の「0」と1.3付近の「1」。 もちろん、これはすべて少しのノイズで希釈されています。 この「0」は「絶対」ではなく、リモートコントロールによって送信されることに注意してください。 リモートコントロールを完全にオフにすると、信号は0.4から0に低下します。







この「フレーム」に対処しましょう。 比較的長い「1」と次の「0」は、明らかに同期のための特別な開始ビットです。







ビット値は、「1」から次の「0」までの長さでエンコードされます。短い「0」は論理ゼロ、長い「0」は論理ユニットです。 ご覧のとおり、1つのフレームで16ビットです。







画像







コマンドのデコード



これで、PythonでGNU Radio用の特別なブロックを作成できます。これにより、フレームがデコードされ、コンソールに書き込まれます。 Pythonブロックのソースコードは、GNU Radio Companionを離れることなく編集できます! とても快適です。







私はコードに焦点を合わせません。誰でもスポイラーの下でそれを見ることができます。 最終的な信号デコード方式は次のとおりです。







画像







GNU Radio Packet Decoder
 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.5の一定のしきい値による「0」と「1」の分離は、リモートが遠すぎたり近すぎたりすると、回路がまったく機能しないという事実につながります。 これはさらなる実験を止めませんでしたが、一般的に私はこの機能に気づきましたが、この記事を書き始めたのは6か月後のことです。 しかし、誰かが私に正しいやり方を教えてくれたら感謝します。







このプロトコルのビットの意味を理解しましょう。 データはMSBの順序で、つまり最上位ビットから下位ビットに送信されると仮定します(これは単なる合意の問題であり、それ以上ではありません)。







まず、3つの最下位ビットがチャネルを担当します。 000-A、010-B、100-C。これは実験的に確認することは難しくありませんでした。







左のジョイスティックを左に1回動かすと、次の一連のコマンドが生成されます(以降、チャネルはAになります)。







 0000010000000000 0000010000000000 0000000011110000 # <-   20 
      
      





ジョイスティックを拒否して保持すると、次のようになります。







 0000010000000000 0000010000000000 0001010000000000 # <-      0000000011110000 # <-   20 
      
      





他のすべての方向については、パターンは似ています。上位3ビットは変化せずゼロのままで、4番目のビットは一種の「繰り返しフラグ」として機能し、次の4ビットが方向(それぞれ、右、左、上、下)を担当します。 そして最後に、意味的には「奇妙に見える」という意味の、奇妙に見えるチームが繰り返されます。 リモートコントロールは、スイッチをオンにした直後に同じ「停止」コマンドを数回ブロードキャストします。







戦車の移動を担当する適切なジョイスティックを使用すると、すべてが多少面白くなります。 「上」-「下」はタンクを前後に動かす役割を果たし、「左」-「右」は所定の位置に曲がることを思い出させてください。 これらのビットは、前のビットの直後に、ちょうど1111が停止していた位置に移動します。 それらはかなり奇妙な方法で変化します。 なぜそうなのか推測できますか?









左スティックの場合と同様に、同じ4番目の上位ビットが繰り返し中に設定され、リリース後に4つのユニットのパケットがあります。







[OK]ボタンは、最初の上位ビットが点灯したコマンド(つまり、1000000000000000)を送信します。長押しすると、繰り返しフラグ付きの同じコマンドが生成されます。 タンクはコマンドを無視します。







ボタン(/)は、リモートコントロールを奇妙なモードにします。このモードでは、上位ビット2と3がすべてのジョイスティックコマンド(「停止」を除く)に追加されます。 もう一度ボタンを押すと、リモコンは元のモードに戻ります。







ボタン123456は、停止コマンド(1111からモーションジョイスティックの位置まで)を送信します。 ボタンを押し続けると、繰り返しフラグが設定されます。 なぜそれが必要なのかも不明です。







4番目の最下位ビットの割り当てを判別できませんでした;常にゼロです。







2つのジョイスティックを同時に拒否でき、両方のフィールドに非ゼロビットのパケットが取得されます。 [OK]ボタンと組み合わされず、ジョイスティックよりも優先されます。







要約すると、パッケージの一般的な形式は次のとおりです。







 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
      
      





そして、この回路は本当にタンクをうまく制御しています!







私が遭遇し、完全に打ち負かすことができなかった唯一の問題は、非常に重大な遅れでした。 バッファーのサイズを小さくすることで( osmocom Sinkの Device Argumentshackrf hackrf,buffers=2



)、結果のサンプルレートを大きくすることで、この問題を軽減できました。 しかし、標準のリモートコントロールから操作する場合には観察されない不快な具体的な遅延が依然として残っていました。







それにもかかわらず、「概念実証」は実証されています。







おわりに



この無線制御タンクは、GNU Radioを使用して簡単に元に戻すことができる非常に単純なプロトコルで動作します。







このプロトコルは、パケットに明確な開始マークがあり、ビットが「0」(低レベル)の長さでエンコードされている、かなり単純な物理エンコーディングで振幅操作を使用します。







各パケットには16ビットの情報があり、これらの16ビットのほとんどすべての目的は、リモートコントロールを試すだけで簡単に理解できます。







GNU Radio Companionで戦車にコマンドを送信する回路を組み立てることも非常に簡単でした。 最後まで克服できなかった唯一の問題は遅れです。







アプリ






All Articles