シーメンス PROFINET DCP のファジング:Unicorn-AFL を用いたエミュレーション主導型アプローチ

シーメンス PROFINET DCP のファジング:Unicorn-AFL を用いたエミュレーション主導型アプローチ

セキュリティ研究者が分析が必要なデバイスに初めて取り組む際、最初に浮かぶ疑問の一つは、「このデバイスは外部に対してどのようなサービスを公開しているのか」ということですネットワークサービスは、デバイスにリモートからアクセスするための主要な手段であり、したがって、攻撃者がデバイスとやり取りを行うための主な侵入経路となります。

OT の世界では、デバイスは、産業環境で一般的に使用され、OT の制約を考慮して設計された幅広いプロトコルに対応しています。これらの制約には、リアルタイム動作や厳格な可用性要件などが含まれており、これらは従来のITシステムとは異なる課題をもたらします。ベンダー固有の独自プロトコル(OT で非常に一般的)に加え、Modbus、BACnet、PROFINETファミリーのプロトコルなど、広く利用されている標準規格も存在します。

このような不均一性があるため、ネットワークサービスのテストを可能な限り自動化することが有用であり、理想的には、各対象についてプロトコル固有の深い知識を必要としないことが望ましい。 本ブログ記事では、まさにこのテーマを取り上げ、unicornafl(AFL++ファザーをUnicorn CPUエミュレータで使用するためのブリッジ)を用いて、Siemens SCALANCE LPE9403産業用PC上で動作するPROFINET DCP実装において、4つのメモリ破損脆弱性を発見した経緯について解説します。 プロセスのスナップショットとエミュレーションを組み合わせることで、デバイス本体や完全なシステム環境を必要とせずに、複雑なプロトコルハンドラを効率的にファジングすることができます。具体的には、以下の内容について説明します:

  • 小さなGDBプラグインを使ってLinuxユーザースペースプロセスのスナップショットを作成する方法(プラグインのみに関心がある場合は、この記事の最後に掲載されています)
  • そのスナップショットをUnicornに読み込む方法
  • 関連するOSとのやり取りを特定し、スタブ化する方法
  • afl++用のUnicornハーネスを作成する方法

本書に記載されているすべての脆弱性は、Siemens社により、SCALANCE LPE9403デバイスの最新ファームウェアバージョンで修正されています。アドバイザリはこちらでご覧いただけます。

開示プロセスにおいてご支援を賜り、またあらゆる問題に迅速に対応してくださったシーメンス社に感謝申し上げます。

リサーチ範囲

図1 - シーメンス SCALANCE LPE9403

本研究の対象となるデバイスは、シーメンス社のSCALANCE LPE9403という産業用「ローカル・プロセッシング・エンジン」(LPE)です。これは、DebianベースのLinuxオペレーティングシステムを実行する堅牢な産業用PCであり、機械キャビネット、生産ライン、または現場の制御盤など、物理的なプロセスの近くに設置されるように設計されています。 一般的な導入環境において、LPEはエッジデバイスとして機能します。具体的には、PLCやフィールドデバイスからのデータを収集・前処理し、監視やセキュリティアプリケーションを実行するとともに、OT ネットワークと、SCADA、MES、あるいはクラウドベースのサービスといった上位システムとの間のゲートウェイとしての役割を果たします。

代表的なファクトリーオートメーションのシナリオでは、SCALANCE LPE9403 のようなデバイスが、複数のコントローラや I/O デバイスからの PROFINET トラフィックを集約し、ローカルでの分析や異常検知を行い、選別した情報を中央監視システムに転送することがあります。ネットワーク内でのその位置付けや、データおよび制御のハブとしての役割を考えると、このデバイスが公開しているサービスのセキュリティは特に重要となります。

調査の結果、当デバイスが公開しているさまざまなネットワークサービスに複数の脆弱性が確認されました。本ブログ記事では、PROFINET DCP(Discovery and basic Configuration Protocol)サービスの実装において発見された脆弱性に焦点を当てます。その他のサービスに影響を及ぼす問題については、別のブログ記事で取り上げる予定です。どうぞご期待ください!

ここではDCPに焦点を当てているため、それが何であるかを簡単に振り返っておきましょう。PROFINET DCPは、PROFINETネットワーク上のデバイスの初期検出および基本設定に使用されるレイヤ2サービスです。これにより、IOコントローラやエンジニアリングツールがIOデバイスを検索・識別し、デバイス(ステーション)名やIP設定などの基本パラメータを読み取ったり割り当てたりすることが可能になります。 DCPはローカルサブネット上で直接動作し、DHCPなどの上位レベルのサービスに依存しないため、試運転やメンテナンス作業において広く利用されています。代表的な操作としては、デバイスの位置特定、デバイス名やIPアドレスの割り当て・変更、識別機能の起動(物理的な位置を確認するためのLED信号など)、およびデバイスの工場出荷時設定への復元などが挙げられます。

DCP デーモンの脆弱性一覧および影響を受けるバージョン

以下の表は、Nozomi Networks Labs が今回の脆弱性調査において、Siemens SCALANCE LPE9403(バージョン V4.0 HF0 まで)の DCP デーモンで発見したすべての脆弱性をまとめたものです:

CVE弱点CVSSスコアCVSSベクトル
40575初期化されていない値の使用 (CWE-457)5.3CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
40576NULL Pointer Dereference (CWE-476)5.3CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
40577境界外読み取り (CWE-125)5.3CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
40578境界外読み取り (CWE-125)5.3CVSS:4.0/AV:A/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N

DCP デーモンの脆弱性が及ぼす影響

DCP サービスにおいて当社が特定した 4 つのメモリ破損の脆弱性により、隣接するネットワーク(同じレイヤー 2 セグメント)上の認証されていないリモート攻撃者が、特別に細工されたイーサネットパケットを送信することで、デーモンをクラッシュさせることが可能となります。

DCPサービスはデバイスの検出やデバイス情報の取得に使用されるため、一部の導入シナリオでは、攻撃者がデバイスを到達不能に見せかけることで、SCADAシステムを事実上機能不能に陥らせることが可能となる。

さらに、ネットワークの初期導入時にDCPが使用されている場合、攻撃者がサービスをダウンさせ、デバイスがIPアドレスを取得できないようにする可能性があります。

シーメンスのDCP実装に対するファジング

Siemens SCALANCE LPE9403 では、DCP を使用することで、DHCP サーバーを必要とせずに、デバイスのネットワーク設定(IP アドレスなど)を構成することができます。

このデバイスでは、DCPサーバーは初期設定時のみ書き込みモードで動作可能ですが、管理者パスワードが変更されると、書き込みアクセスは無効になります。ただし、サーバーは引き続き稼働しており、デバイスの検出やネットワーク設定の読み取りには引き続き使用できます。

実装レベルでは、SCALANCE LPE は AArch64 Linux を採用しており、DCP サービスは、デバイスの 3 つのインターフェース(P1、P2、および P3C)すべてにバインドされたネイティブのユーザー空間デーモン(dcpd.bin)であり、レイヤ 2 の DCP トラフィックをリッスンします:

admin@lpe9403-VPN3185789:~$ ps aux | grep dcpd
root 1927746 0.0 67.6 2762980 2633512 ? Ssl 9月19日 57:40 /sbin/dcpd.bin
admin 3368387 0.0 0.0 6404 1936 pts/0 S+ 13:24 0:00 grep dcpd
admin@lpe9403-VPN3185789:~$ sudo ss -lpnwA packet | grep dcpd
p_raw UNCONN 0 0 *:P1 * users:(("dcpd.bin",pid=1927746,fd=3))
p_raw UNCONN 214528 0 *:P1 * users:(("dcpd.bin",pid=1927746,fd=4))
p_raw UNCONN 0 0 *:P2 * users:(("dcpd.bin",pid=1927746,fd=5))
p_raw UNCONN 0 0 *:P2 * users:(("dcpd.bin",pid=1927746,fd=6))
p_raw UNCONN 0 0 *:P3C * users:(("dcpd.bin",pid=1927746,fd=7))
p_raw UNCONN 0 0 *:P3C * users:(("dcpd.bin",pid=1927746,fd=8))

セキュリティの観点から見ると、このサービスは以下の理由から魅力的な標的となっています:

  • あらゆるデバイスのインターフェースからアクセス可能で、
  • root権限で実行し、かつ
  • 認証されていない。

したがって、これはファジング攻撃を行うには格好の標的である。

このサービスに対してファジングを行う方法はいくつかあります。例えば、パーシングルーチンを切り出して、LD_PRELOADを使用してテストしたり、QEMUを使ってインストルメンテーションを行ったりすることができます。

このサービスはかなり小規模であるため、スナップショット・ファジングを採用することにしました。大まかに言えば、その考え方は以下の通りです:

  • 実行ファイルをリバースエンジニアリングして、ネットワークからの入力を解析する機能など、主要な機能を特定する
  • プロセスが入力を受け取る直前の、(ユーザースペースの)メモリとレジスタのスナップショットを撮影する
  • Unicornエンジン(QEMUをベースとしたCPUエミュレータ)にメモリを読み込む
  • 以下の機能を持つハーネスを作成してください:
  • ユーザー入力をメモリ内の適切な位置に書き込みます
  • コードを適切な方法で実行する
  • unicornaflを使用してプログラムに計測機能を組み込む
  • AFLplusplusを使ってファズ処理を行う

シンプルさを保つため、他の選択肢(優れたQilingフレームワークなど)ではなく、標準のUnicornを採用しました。実行ファイルのサイズが比較的小さく、セットアップに余計な複雑さや煩わしさを持ち込みたくなかったからです。

スナップショット・ファジング

図2 - スナップショット・ファジング

まず、スナップショット・ファジングの概要、その主な利点、そしてそれに伴う課題について簡単に紹介しましょう。

スナップショット・ファジングの核心となる考え方は、プログラム、あるいはOS全体を興味深い実行ポイントまで実行させ、その時点でのCPU/レジスタ/メモリの状態のスナップショットを取得し、その後、メモリ上で直接、あるいはエミュレートされたI/Oを通じて入力を変異させながら、そのスナップショットを繰り返し復元することです。

この手法は、ファジングを行いたいプロセスの部分に到達するのに時間がかかる場合や、非決定論的な場合に特に有用です。適切なタイミングでスナップショットを取得することで、その状態に至るまでに必要なすべての手順を省略できます。その時点以降、特定の時点におけるターゲットの決定論的で再現可能な状態に基づいてファジングを行うことになります。

もちろん、スナップショット・ファジングにはいくつかの課題も伴います。

まず、スナップショットは実行中の特定の時点において取得されるため、その時点より前に発生した事象には一切影響を与えることができません。したがって、適切なスナップショット時点を選択することは極めて重要ですが、多くの場合、それは決して簡単なことではありません。

第二に、スナップショットではシステムの状態を静的な視点でしか捉えることができません。プロセスやOSが外部からの入力を必要とした場合はどうなるのでしょうか?今回のケースでは、ユーザー空間のみをスナップショット対象としたいと考えていました。つまり、カーネルの状態はキャプチャしません。もしプロセスがシステムコールを実行した場合はどうなるのでしょうか?そうした瞬間をインターセプト(「スタブ」)し、その挙動を意味のある形でモデル化する必要があります。

たとえば、プロセスがシステムコールを通じてファイルを開こうとした場合、実行の一貫性を保つために、スタブは現実的な応答やデータを提供しなければならない。

また、カーネルのスナップショットも取得したとしても、プロセスがスナップショット外の要素に依存している場合は、同様の問題が発生してしまうという点も強調しておく価値があります。例えば、キーボード入力を待機したり、ハードウェアセンサーの状態を確認したり、ネットワークインターフェースからデータを読み込んだりする場合、どうすればよいのでしょうか? スナップショットには含めることのできない外部とのやり取りは常に存在するため、ファジングの効果を維持するためには、それらを何らかの形でモデル化する必要があります。

課題の一つは、適切な場所を見つけ、そこでスナップショットを撮ることです。理想としては、外部とのやり取りを最小限に抑え、メモリ内での操作のみを行うコードをファジング対象とすることが望ましいです。

GDB を使用したスナップショットの生成

それでは、DCPプロセスに戻り、スナップショットをどのように生成したかについて説明しましょう。

まず、スナップショットを撮影するのに最適なタイミングを見極める必要がありました。そのためには、実行ファイルのリバースエンジニアリングが必要でした。DCPサービスはネットワークベースであるため、プログラムがネットワークからデータを読み込み、その解析を開始する直前にスナップショットを撮影したいと考えました。

このサービスの動作は以下の通りです。まず、/etc/ディレクトリ内の各種ファイルを読み込んで設定をロードし、いくつかの設定手順を実行した後、各ネットワークインターフェース上で生ソケットを設定します。

データを受信するために、PACKET_RX_RINGソケットオプションを使用して、メモリバッファをローソケットの受信リングにバインドします。

図3 - DCPソケットに対するsetsockopt

次に、インターフェースごとに1つのスレッドを生成します。各スレッドは、自身のソケット記述子に対して`poll` を呼び出し、データが到着しているかどうかを確認します。ネットワークデータが到着すると、スレッドはリクエストを処理し、パケットを解析するハンドラ関数を呼び出します。その関数は以下の通りです:

図4 - ネットワーク処理ルーチン

画像に示されているように、この関数は最初の入力としてrx_ringバッファを受け取ります。このバッファには(ハイライトされているように)、ネットワークから受信した生データ(data_buffer)が含まれています。

この関数は、実行開始直後からパケットの解析と必要なロジックの実行を開始します。そのため、この関数はスナップショットを作成するのに最適なタイミングとなります。

そのために、GDBと、読み取り可能なユーザースペースのメモリ領域すべてを、すべてのCPUレジスタの値とともにユーザー指定のフォルダに保存する小さなプラグイン(付録に含まれています)を併用しました:

(gdb) b *
(gdb) run
...
(gdb) gdbdumper /tmp/out
[+] aaaaaaaa0000 のダンプ中 [ サイズ 4096 ]
...
admin@lpe9403-VPN3185789:~$ ls /tmp/out
aaaaaaaa0000.r-x.4096.bin
...
mem_info.json
regs.json

Unicornの状態を初期化する

この時点で、GDBスナップショットを通じて保存されたデータを使用して、Unicornの状態を初期化しました。後でスナップショットに計測コードを挿入し、afl-fuzzで利用できるようにするため、unicornaflを使用しました。

また、process_rx関数が期待するメモリ領域に入力データを挿入できる関数も必要でした。

以下は、この問題を解決するPythonのスニペットです(簡潔にするため、一部の関数やクラスは省略しています):

from Unicorn.arm64_const import *
from Unicornafl import *

def inject_input(uc: Uc, data: bytes):
rx_ring = uc.reg_read(UC_ARM64_REG_X0)
off1 = int.from_bytes(uc.mem_read(rx_ring + 0x10, 4), 'little')
off2 = int.from_bytes(uc.mem_read(rx_ring + off1 + 0x18, 2), 'little')
data_ptr = rx_ring + off1 + off2
data_size_ptr = rx_ring + off1 + 0xc

uc.mem_write(data_ptr, data)
uc.mem_write(data_size_ptr, int.to_bytes(len(data), 4, 'little'))

uc = Uc(UC_ARCH_ARM64, 0)
for f in iterate_files("/path/to/dump"):
if not f.endswith(".bin"): continue
addr, perm, _, _ = os.path.basename(f).split(".")
if perm == "---": continue

section = MemorySection(addr, perm, f)
uc.mem_map(section.addr, section.size, section.unicorn_perm())
uc.mem_write(section.addr, section.get_content())

init_regs(uc, "/path/to/dump/regs.json")

ご覧のとおり、このスニペットはGDBを使用して生成された.binファイルとregs.jsonファイルを読み込み、エミュレートされたマシンの状態を表すUnicornのUcオブジェクトに書き込みます。また、指定された入力データを用いてUnicornの状態を変更するinject_input関数も提供しています。

TLS を初期化する

この手法を用いてユーザー空間のLinuxプログラムをファジングする際、典型的な落とし穴となるのが、スレッドローカルストレージ(TLS)の処理です。

libcの機能の多くはTLSに依存しています(たとえば、スタックカナリアなど)。TLSを正しくモデル化していない場合、スナップショットはlibc関数を呼び出した途端に正常に実行できなくなる可能性があります。

TLSはアーキテクチャに依存するため、その処理方法はスナップショットが実行されるプラットフォームに大きく左右されます。

幸いなことに、Linux AArch64 では、TLS ポインタはレジスタtpidr_el0 に格納されており、これはユーザー空間から容易にアクセス可能で、当社の GDB プラグインによって取得することができます。この場合、TLS の設定は、tpidr_el0 をGDB で確認された値に復元するだけで済みます。

とはいえ、他のアーキテクチャではTLSの設定にさらに手間がかかる場合があります。例えば、x64ではTLSポインタがfs:0に保持されるため、スナップショットが期待どおりに動作するようにするには、UnicornでFSセグメントレジスタを正しく設定する必要があります。

関連するAPIのスタブ化

この時点で、GDBで停止した箇所から実行を再開できるよう、Unicornの状態を設定しました。

まだ不足しているのは、環境のエミュレーションです。前述したように、スナップショットコードがシステムコールを実行した場合はどうなるのでしょうか?それらをスタブ化する必要があります。

Linuxカーネル全体をスタブ化するのは避けたかったため、必要な部分のみを実装しました。私たちのアプローチは反復的なものでした:

  • 呼び出し(例:bx addr)や戻り(例:pop lr)を追跡するために、すべての命令に対してUnicornフックを登録しました。これにより、シャドウコールスタックを構築することができます。
  • システムコールが発生するたびに例外をスローする、もう1つのUnicornフックを登録しました。
  • テスト用入力に対してスナップショットを実行しました。例外が発生した際、何が起きているのか把握するためにシャドウコールスタックを調査し、その後、適切なスタブを実装しました。

コードは次のようになっています:

...

def hook_instruction(uc: Uc, addr, size, user_data):
... populate shadow_stack

def syscall_hook(uc: Uc, *args):
syscall_n = uc.reg_read(UC_ARM64_REG_X8)
if syscall_n not in implemented_hooks:
print(f"Unimplemented Syscall {syscall_n}")
shadow_stack.print()
raise ValueError("Unimplemented Syscall")

...

uc.hook_add(UC_HOOK_CODE, hook_instruction)
uc.hook_add(UC_HOOK_INTR, syscall_hook)
inject_input(uc, b"... our test input ...")
uc.emu_start(uc.reg_read(UC_ARM64_REG_PC), 0)

このアプローチは、ファザーが起動した後も継続されます。ファザーが、これまで実装されていなかったシステムコールをトリガーするパスを発見した場合、システムはクラッシュします。このクラッシュを分析することで、欠落しているシステムコールを特定し、そのスタブを実装することができます。

この作業は手間がかかる場合がありますが、スナップショットを適切に選択すれば、発生するシステムコールの数は許容範囲内に収まるはずです。残念ながら、今回のケースでは、実行ファイルがネットワークパケットの処理中に設定ファイルを開いて解析していたため、かなりの数のスタブを実装せざるを得ませんでした。

この反復プロセスの終了時点で、以下のシステムコールのスタブが完成していました:

  • ソケット
  • mkdirat
  • 書く
  • sendto

また、以下の libc 関数についてもスタブを作成しました(多くの場合、システムコールよりも高レベルの関数のスタブを作成するほうが簡単です):

  • fopen
  • fclose
  • getline
  • calloc
  • 無料

最後に、内部関数もいくつかスタブ化しました。例えば、そのうちの1つはデバイスのLEDを制御するものでした。

私たちのコードにおけるスタブは、次のようなものです(この場合はcallocのスタブです):

def calloc_hook(uc: Uc, *args):
n = uc.reg_read(UC_ARM64_REG_X0) * uc.reg_read(UC_ARM64_REG_X1)
uc.reg_write(UC_ARM64_REG_X0, allocate(uc, n)) # 簡潔にするためallocateは省略
uc.reg_write(UC_ARM64_REG_PC, uc.reg_read(UC_ARM64_REG_LR))

calloc_addr = 0xaaaadd361ca0
uc.hook_add(UC_HOOK_CODE, calloc_hook, begin=calloc_addr, end=calloc_addr+4)

このコード断片からわかるように、uc.hook_add というUnicorn API を使用して、メモリ上のcallocが位置するアドレスにフックを追加しています。このフックは、X0 および X1 レジスタからパラメータを取得し、結果を X0 に書き込み、PC を(LR レジスタに格納されている)戻りアドレスに設定します。

BPFフィルター

スナップショットからUnicornの状態を初期化し、その状態にネットワーク入力を注入する関数を追加し、すべてがスムーズに動作するように関連するスタブを実装したところで、これで完了でしょうか?

残念ながら、まだです。このデバイスは、ソケットにBPFフィルタを登録し、カーネルレベルで不正な形式のパケットを早期に破棄するようにしています。

BPFは、ユーザープログラムがパケットやその他のカーネルイベントに対して独自のフィルタリングや処理ロジックを適用できるようにする、軽量なカーネル内仮想マシンです。フィルタとは、BPF仮想マシンが理解できる言語で記述された小さなプログラムであり、パケットがユーザー空間に渡される直前にカーネル内で実行されます。

デバイスのシェルでssツールを使用して、フィルタを取得しました:

admin@lpe9403-VPN3185789:~$ sudo ss -lpnwbA パケット
* ユーザー:(("dcpd.bin",pid=1064,fd=7))
bpf フィルタ (24): 0x30 0 0 12, 0x15 0 2 136 …
...

実行ファイルは、ここでこれを登録します:

図5 - BPFフィルタ

このフィルタを考慮に入れなければ、ファザーは実際の運用環境では無視されてしまうような入力を生成してしまう可能性があります。つまり、デバイス上では到達不可能な状態を調査することに時間を浪費してしまう恐れがあります。そこで、このフィルタについてさらに詳しく分析してみましょう。

これが分解したBPFフィルターです(今回はCapstoneエンジンを使用しました):

0000: ldb [12]
0001: jeq 0x88, 2, 4
0002: ldb [13]
0003: jeq 0x92, 8, 4
0004: ldb [16]
0005: jeq 0x88, 6, 22
0006: ldb [17]
0007: jeq 0x92, 8, 22
0008: ldb [18]
0009: jeq 0xfe, 10, 15
0010: ldb [19]
0011: jeq 0xfe, 23, 12
0012: jeq 0xfd, 23, 13
0013: jeq 0xff, 23, 14
0014: jeq 0xfc, 23, 15
0015: ldb [14]
0016: jeq 0xfe, 17, 22
0017: ldb [15]
0018: jeq 0xfe, 22, 19
0019: jeq 0xfd, 22, 20
0020: jeq 0xff, 22, 21
0021: jeq 0xfc, 22, 23
0022: ret 0xffff
0023: ret 0x0

このフィルタは、2つのグループに分かれたチェックを実行します:

  • 標準のイーサネットの場合(オフセット 12)または VLAN タグ付きの場合(オフセット 16)のいずれにおいても、イーサネットタイプが 0x8892(PROFINET)であることを確認します。
  • 次に、バイト18、19、あるいはバイト14、15のいずれかが、0xFEFE、0xFEFD、0xFEFF、または0xFEFCのいずれかの値と一致するかどうかを確認します。

ファジングを現実的なものにするためには、入力を生成する際にこのフィルタを考慮に入れる必要があります。私たちの解決策は単純でした。Pythonで同じロジックを再実装し、生成されたすべての入力をそれに照らして検証したのです。もし入力がBPFフィルタによって拒否された場合は、その入力を破棄しました。

AFL++ を実行する

この時点で、ファザーを起動してクラッシュを検出するために必要なものはほぼすべて揃っていました。まだ不足していたのは以下の点でした:

  • 初期コーパス
  • afl++で必要となる一部のグルーコード

コーパスについては、デバイスとの正当なDCP通信を含むいくつかのpcapファイルを収集し、そこからイーサネットのペイロードを抽出しました。

一方、グルーコードは次のようなものです:

PC_START = 0x0000aaaadd367960
MAX_ETH_SIZE = 1518

def fuzzer_main(input_file):
# スナップショットを読み込み、フックを登録する
uc = prepare_emulator()

def place_input_callback(uc: Uc, inp, persistent_round, data):
if len(inp) > MAX_ETH_SIZE:
return False
if bpf_filter(inp) != 0:
return False
inject_input(uc, inp)
return True

def harness(uc: Uc, *args):
uc.emu_start(PC_START, 0)
return 0

uc_afl_fuzz_custom(
uc=uc,
input_file=input_file,
place_input_callback=place_input_callback,
fuzzing_callback=harness)

if __name__ == "__main__":
fuzzer_main(sys.argv[1])

これでようやくすべての準備が整い、次のようにファザーを実行できるようになりました:

$ afl-fuzz -U -i corpus -o queue -- python3 harness.py @@
図6 - AFL++ ダッシュボード

パフォーマンスはそれほど良くありませんが、クラッシュの原因を突き止め始めるには十分なレベルです!

おまけのヒント:派手にしよう!

ファジングの利点は、初期のテストコーパスでは実行されなかった新しい実行パスを自動的に発見できる点にある。

新しいパスが、これまで確認されていなかったシステムコールを引き起こすタイミングを容易に検出できるため、ファザーを用いてクラッシュを検出するだけでなく、プロトコルを完全にリバースエンジニアリングすることなく、デバイスにおける新たな挙動を明らかにする入力を生成することも可能です。

簡単な例として、ファザーによって検出された、デバイスのLEDを点滅させる入力があります。

この特定のDCPパケットは、サーバールーム内でデバイスを容易に見つけられるようにするためのものですが、当初のコーパスには含まれていませんでした。

この事実を突き止めることができたのは、そのサービスが、LEDを点灯させるために使用された入力を受け取った後、シェルスクリプトを実行したためです。

以下は、ファザーによって生成されたパケットと、それによるデバイスの動作です:

$ xxd flash.bin
00000000: d4f5 27a5 6602 000c 2947 7830 8892 fefd ..'.f...)Gx0....
00000010: 0400 8892 fefd 0300 0002 0503 0202 0202 ................
00000020: 0202 0202 ....
$ python3 send_l2.py flash.bin
図7 - 点滅するLED

結果と学び

このブログ記事では、aflunicorn を用いたスナップショット・ファジングにより、Linux 実行ファイルをファジングテストする方法を紹介しました。スナップショットの生成や、関連する OS とのやり取りをスタブ化するプロセスについて解説しました。スナップショットのセットアップは難しそうに思えるかもしれませんが、それがもたらす詳細な内部構造の可視化は、ファジングだけでなくリバースエンジニアリングにおいても非常に有用です。 今回のファジング調査では、4つの異なるメモリ破損の脆弱性を発見しました。これらは、シーメンス社がSCALANCE LPEファームウェアの最新リリースで修正済みです。クローズドソースのバイナリや組み込みファームウェアを扱っている方は、ぜひスナップショットファジングを試してみて、ご自身のターゲットからどのような問題を発見できるか確認してみてください!

修復

シーメンスは、SCALANCE LPE9403のファームウェアに対するセキュリティパッチを通じてこれらの脆弱性に対処し、セキュリティレポートを公開しました。資産所有者および運用者には、以下の措置を講じるよう強く推奨します:

  • 影響を受けるSiemens SCALANCE LPE9403デバイスを、新しいバージョンのファームウェアに更新してください。
  • システムの露出を制限するために、ネットワークのセグメンテーションを実施する。
  • ネットワークトラフィックを監視し、脆弱性のある資産が存在しないかを確認する。

脆弱なファームウェアを搭載したデバイスが自社環境に存在するかどうかを組織が迅速に特定できるよう、資産所有者はNozomi Networks IoT の高度な機能を活用できます。本プラットフォームはネットワークトラフィックとホスト活動に対する深い可視性を提供し、OT 全体にわたる効果的な脆弱性および脅威の検知を実現します。

図8 - 脆弱性が確認されたSiemens SCALANCE LPE9403デバイスの検出

この積極的な監視により、セキュリティチームは脆弱性や攻撃に迅速かつ効果的に対応でき、重要ネットワークを標的とした攻撃の影響を最小限に抑えます。Nozomi Networks IoT 詳細と実際の動作を確認するには、今すぐデモをご請求ください

重要なインフラを保護し、業務の完全性を維持するためには、迅速な対応が不可欠である。

付録

GDB プラグイン

プロセスのユーザー空間のメモリとレジスタのスナップショットを取得するための、小型のGDBプラグイン。

これを使用するには、~/.gdbinitファイルにsource /path/to/plugin.py を追加するだけです。そうすれば、GDB 内でgdbdumperコマンドを使用できるようになります。

import zlib
import json
import gdb
import os
import re

class GDBDumper(gdb.Command):
"""
gdbdumper - 実行ファイルのすべてのマッピングをダンプする GDB コマンド

使用方法:
gdbdumper [--compress]
"""

def __init__(self):
gdb.write("[+] loaded gdbdumper\n")
super(GDBDumper, self).__init__("gdbdumper", gdb.COMMAND_USER)

def invoke(self, arg, from_tty):
args = gdb.string_to_argv(arg)

if not args or args[0] == "help":
self._show_help()
return
d = args[0]
if not os.path.isdir(d):
gdb.write(f"'{d}' is not a directory\n")
return

compress = False
if len(args) > 1 and args[1] == "--compress":
compress = True

regions = self._get_mem_regions()
for r in regions:
if r["perm"] == "---": continue
try:
data = gdb.selected_inferior().read_memory(r["start"], r["size"])
except:
gdb.write(f'[!] unable to dump memory @ 0x{r["start"]:x}\n')
continue

if compress:
data = zlib.compress(data)
gdb.write(f'[+] dumping {r["start"]:x} [ size {r["size"]}')
if compress:
gdb.write(f', compressed {len(data)} ]\n')
else:
gdb.write(f' ]\n')

out = f'{d}/{r["start"]:x}.{r["perm"]}.{r["size"]}.bin'
if compress:
out += "zlib"
with open(out, "wb") as fout:
fout.write(data)

regs = self._get_registers()
with open(f'{d}/regs.json', "w") as fout:
json.dump(regs, fout, indent=4)
with open(f'{d}/mem_info.json', "w") as fout:
json.dump(regions, fout, indent=4)

def _show_help(self):
gdb.write(self.__doc__ + "\n")

def _get_mem_regions(self):
output = gdb.execute("info proc mappings", to_string=True)
mappings = []
regex = r"\s*0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+([rxw\-]+).\s+(.*)"
for line in output.splitlines()[4:]: # Skip the first 4 lines (header)
match = re.match(regex, line)
if match:
start, end, size, offset, perm, objfile = match.groups()
mappings.append({
"start": int(start, 16),
"end": int(end, 16),
"size": int(size, 16),
"offset": int(offset, 16),
"perm": perm,
"objfile": objfile,
})
return mappings

def _get_registers(self):
regs = dict()
regex = r"(\S+)\s+(0x[0-9a-f]+)\s+.*"
output = gdb.execute("info registers", to_string=True)
for line in output.splitlines():
match = re.match(regex, line)
if match:
name, value = match.groups()
regs[name] = int(value, 16)
return regs

GDBDumper()