新規投稿のお知らせを受信されたい方は、サブスクリプションをご登録ください:

バイトコードからバイトへ:マジックパケットの自動生成

2026-04-08

6分で読了
この投稿はEnglishおよび한국어でも表示されます。

このコンテンツは自動機械翻訳サービスによる翻訳版であり、皆さまの便宜のために提供しています。原本の英語版と異なる誤り、省略、解釈の微妙な違いが含まれる場合があります。ご不明な点がある場合は、英語版原本をご確認ください。

Linuxのマルウェアは、Serkeley Packet Filter(BPF)ソケットプログラムに潜んでいます。BPFは、Linuxカーネルに埋め込んだ実行可能なロジックで、ネットワークトラフィックの処理方法をカスタマイズすることができます。インターネット上の最も執拗な脅威の一部は、これらのフィルターを使用して、特定の「マジック」パケットを受信するまで休止状態を維持します。これらのフィルターは何百もの命令になり、複雑な論理ジャンプを含むため、手作業によるリバースエンジニアリングはセキュリティ研究者にとってボトルネックとなります。

私たちはより良い方法を見つけるために、シンボリック実行を検討しました。これは、コードを単なる命令ではなく、一連の制約として扱う方法です。Z3証明者を使うことで、悪意のあるフィルターから逆算して、トリガーに必要なパケットを自動的に生成することができます。この記事では、何時間もかかる手作業による組み立て分析をわずか数秒で完了するタスクに、これを自動化するツールをどのように作ったかを説明します。

複雑さの上限

悪意のあるフィルターの分解方法を見る前に、フィルターを実行しているエンジンを理解する必要があります。バークレーパケットフィルター(BPF)は、カーネルが一連のバイトコード指示に基づいてネットワークスタックから特定のパケットを引き出すことができる、非常に効率的な技術です。

可観測性とセキュリティに使用される強力な進化であるeBPF(拡張BPF)は、多くの現代の開発者がご存知でしょうが、この記事では「classic」BPFに焦点を当てます。もともとtcpdumpなどのツール用に設計された従来のBPFは、わずか2つのレジスタを持つシンプルな仮想マシンを使用し、ネットワークトラフィックを高速に評価します。カーネルの内部で深く実行され、ユーザー空間のツールからトラフィックを「隠す」ことができるため、巧妙なバックドアを構築しようとするマルウェア作成者にとってお気に入りのツールとなっています。

LLMを使用してBPF指示のコンテキスト表現を作成することで、アナリストの手作業によるオーバーヘッドはすでに削減されていますが、検証条件に対応するネットワークパケットを作成するには、LLMが提供するコンテキストが追加されても、依然として多くの作業が必要です。

ほとんどの場合、BPFプログラムに20個の命令しかない場合は問題ありませんが、一部のサンプルで観察されたように、BPFプログラムが100以上の命令で構成されている場合、指数関数的に複雑になり、時間がかかります。

問題を分解すると、結果に応じて実行パスを継続するか、停止して最終結果を確認するかの、バッファの読み取りと制約のチェックに確認できます。

決定論的な結果を持つこの種の問題は、与えられた制約で問題を解決する手段を持つ理論証明者であるZ3が解くことができます。

資料A:BPFDoor

BPFDoorは、高度なパッシブLinuxバックドアであり、主に中国拠点の脅威アクター(別名:Earth Bluecrow)によるサイバースパイ活動に使用されます。少なくとも2021年以来活動しているこのマルウェアは、電気通信、教育、政府部門を標的とし、侵害されたネットワークに隠れた足掛かりを維持するように設計されており、特にアジアと中東での活動に重点を置いています。

BPFDoorは、BPFを使用して、特定のネットワークポートを開くことなく、すべての着信トラフィックを監視します。

BPFDoorの指示例

Fortinetの調査で共有されたサンプル(82ed617816453eba2d755642e3efebfcbd19705ac626f6bc8ed238f4fc111bb0)に焦点を当ててみましょう。BPFの指示を分解して、注釈を追加すると、次のようになります。

(000) ldh [0xc]                   ; Read halfword at offset 12 (EtherType)
(001) jeq #0x86dd, jt 2, jf 6     ; 0x86DD (IPv6) -> ins 002 else ins 006
(002) ldb [0x14]                  ; Read byte at offset 20 (Protocol)
(003) jeq #0x11, jt 4, jf 15      ; 0x11 (UDP) -> ins 004 else DROP
(004) ldh [0x38]                  ; Read halfword at offset 56 (Dst Port)
(005) jeq #0x35, jt 14, jf 15     ; 0x35 (DNS) -> ACCEPT else DROP
(006) jeq #0x800, jt 7, jf 15     ; 0x800 (IPv4) -> ins 007 else DROP
(007) ldb [23]                    ; Read byte at offset 23 (Protocol)
(008) jeq #0x11, jt 9, jf 15      ; 0x11 (UDP) -> ins 009 else DROP
(009) ldh [20]                    ; Read halfword at offset 20 (fragment)
(010) jset #0x1fff, jt 15, jf 11  ; fragmented -> DROP else ins 011
(011) ldxb 4*([14]&0xf)           ; Load index (x) register ihl & 0xf
(012) ldh [x + 16]                ; Read halfword at offset x+16 (Dst Port)
(013) jeq #0x35, jt 14, jf 15     ; 0x35 (DNS) -> ACCEPT else DROP
(014) ret #0x40000 (ACCEPT)
(015) ret #0 (DROP)

上記の例では、ACCEPTの結果につながる2つの経路(ステップ5とステップ13)が確立されています。また、そのオフセットや値を含め、特定のバイトがチェックされていることがはっきりとわかります。

これらの検証を行い、ACCEPTパスに一致するものを追跡すれば、パケットを自動的に作成できるはずです。

最短パスを計算する

BPF指示で提示された条件を検証するパケットへの最短経路を見つけるには、望ましくない状態で終了していない経路を追跡しておく必要があります。

まずは小さなキューを作ることから始めます。このキューは、いくつかの重要なデータポイントを保持します。

  • 次の命令へのポインタ。

  • 現在実行されている命令のパス+次の命令までです。

条件をチェックしている命令に遭遇すると、ブール値を使用して結果を追跡し、これをキューに保存します。これにより、ACCEPT条件に到達する前の条件量のパスを比較し、最短のパスを計算することができます。擬似コードでは、これは次のように最適に表現できます:

paths = []
queue = dequeue([(0, [0])])

while queue:
	pc, path = queue.popleft()

	if pc >= len(instructions):
            continue

instruction = instructions[pc]
	
	if instruction.class == return_instruction:
		if instruction_constant != 0:  # accept
			paths.append(path)
		continue  # drop or accept, stop parsing this instruction

if instruction.class == jump_instruction:
	if instruction.operation == unconditional_jump:
		next_pc = pc + 1 + instruction_constant
		queue.append((next_pc, path + [next_pc]))
		continue

	# Conditional jump, explore both
	pc_true = pc + 1 + instruction.jump_true
	pc_false = pc + 1 + instruction.jump_false
	
	if instruction.jump_true <= instruction.jump_false:
		queue.append((pc_true, path + [pc_true]))
		queue.append((pc_false, path + [pc_false]))
	# else: same as above but reverse order of appending
# else: sequential instruction, append to the queue

先ほどのBPFDoorの例に対してこのロジックを実行すると、受け入れられたパケットへの最短パスが表示されます。

(000) code=0x28 jt=0 jf=0  k=0xc     ; Read halfword at offset 12 (EtherType)
(001) code=0x15 jt=0 jf=4  k=0x86dd  ; IPv6 packet
(002) code=0x30 jt=0 jf=0  k=0x14    ; Read byte at offset 20 (Protocol)
(003) code=0x15 jt=0 jf=11 k=0x11    ; Protocol number 17 (UDP)
(004) code=0x28 jt=0 jf=0  k=0x38    ; Read word at offset 56 (Destination Port)
(005) code=0x15 jt=8 jf=9  k=0x35    ; Destination port 53
(014) code=0x06 jt=0 jf=0  k=0x40000 ; Accept

これは、BPF指示を分析し、バックドアのために受け入れられたパケットがどのように見えるかを理解する際に、BPF制約を自動的に解決するのに役立つ自動化機能です。しかし、さらに一歩踏み込むことができるとしたらどうでしょう。

もし、期待されたパケットを自動化された方法で返してくれる小さなツールを作成できたらどうなるでしょうか?

Z3とScapyの採用

一連の制約が与えられた問題を解決するのに最適なツールの1つが、Z3です。Microsoftが開発したこのツールは、定説証明者と名付けられ、内部で非常に複雑な数学的操作を行う使いやすい関数を公開します。

有効なマジックパケットを作成するために使用するもう1つのツールは、対話型のパケット操作のための人気のPythonライブラリであるscapyです。

受け入れられたパケットへのパスを見つける方法がすでにあるので、この問題自体を解決し、この解決策をネットワークパケットのそれぞれのオフセットのバイトに変換するだけです。

シンボリック実行

あるプログラムで実行されるパスを調べるのは、シンボリック実行と呼ばれるのが一般的な手法です。このテクニックでは、制約を含め変数として使用できる入力を与えています。成功したパスの結果を知ることで、ツールを調整してこれらの成功したパスをすべて見つけ、最終結果をコンテキストに基づいた形式で表示することができます。

これを機能させるためには、定数、レジスタ、さまざまなブール演算子などの状態を、チェックされている条件の結果として追跡できる小さなマシンを実装する必要があります。

class BPFPacketCrafter:
    MIN_PKT_SIZE = 64           # Minimum packet size (Ethernet + IP + UDP headers)
    LINK_ETHERNET = "ethernet"  # DLT_EN10MB - starts with Ethernet header
    LINK_RAW = "raw"            # DLT_RAW - starts with IP header directly
    MEM_SLOTS = 16              # Number of scratch memory slots (M[0] to M[15])

    def __init__(self, ins: list[BPFInsn], pkt_size: int = 128, ltype: str = "ethernet"):
        self.instructions = ins
        self.pkt_size = max(self.MIN_PKT_SIZE, pkt_size)
        self.ltype = ltype

        # Symbolic packet bytes
        self.packet = [BitVec(f"pkt_{i}", 8) for i in range(self.pkt_size)]

        # Symbolic registers (32-bit)
        self.A = BitVecVal(0, 32)  # Accumulator
        self.X = BitVecVal(0, 32)  # Index register

        # Scratch memory M[0-15] (32-bit words)
        self.M = [BitVecVal(0, 32) for _ in range(self.MEM_SLOTS)]

上記のコードで、シンボリックな実行中に状態を維持するためのマシンの大部分をカバーすることができました。もちろん、記録しておく必要のある条件はもっとありますが、これらは解決の過程で処理されます。ADD命令を処理するために、マシンはBPF操作をZ3追加にマッピングします。

def _execute_ins(self, insn: BPFInsn):
    cls = insn.cls
    if cls == BPFClass.ALU:
        op = insn.op
        src_val = BitVecVal(insn.k, 32) if insn.src == BPFSrc.K else self.X
        if op == BPFOp.ADD:
            self.A = self.A + src_val

幸いなことに、BPF命令セットは実装が比較的簡単な小さな命令セットしかなく、2つのレジスタだけを追跡するのは、間違いなく歓迎すべき制約です!

このシンボリックな実行の全体的な仕組みは、以下のように抽象化された概要で説明することができます:

  • 「x」(インデックス)と「a」(累積コンピュータ)レジスタをベースの状態に初期化します。

  • 成功パスとして識別されたパスからの指示をループさせます。

    • ジャンプ以外の命令をそのまま実行し、登録の状態を追跡します。

    • ジャンプ命令があるかどうかを判断し、分岐を実行する必要があるかどうかを確認します。

  • Z3check()関数を使用して、与えられた制約(ACCEPT)で当社の条件が満たされているかどうかをチェックします。

  • Z3ビットベクトル配列をバイトに変換します。

  • scapyを使用して、変換されたバイトのパケットを構築します。

Z3リゾルバーによって構築される制約を見ると、Z3がパケットバイトを構築するために取る実行ステップを追跡することができます。

[If(Concat(pkt_12, pkt_13) == 0x800,
    pkt_14 & 0xF0 == 0x40,
    True),
 If(Concat(pkt_12, pkt_13) == 0x800, pkt_14 & 0x0F >= 5, True),
 If(Concat(pkt_12, pkt_13) == 0x800, pkt_14 & 0x0F == 5, True),
 If(Concat(pkt_12, pkt_13) == 0x86DD,
    pkt_14 & 0xF0 == 0x60,
    True),
 0x86DD == ZeroExt(16, Concat(pkt_12, pkt_13)),
 0x11 == ZeroExt(24, pkt_20),
 0x35 == ZeroExt(16, Concat(pkt_56, pkt_57))]

Z3表示の制約の最初の部分は、リンク層のBPF指示を処理する際に、有効なイーサネットIPを構築するために追加された制約です。「If」文は、検出されるプロトコルに基づいて特定の制約を適用します。

  • IPv4 Logic (0x0800):

    • pkt_14 & 240 == 64: Byte 14はIPヘッダーの始まりです。0xF0マスクは、ハイニブル(Versionフィールド)を分離して、バージョンが4(0x40)かどうかを確認します。

    • pkt_14 & 15 == 5: 15 (0x0F)、ローニブル(IHL - インターネットヘッダー長)を分離これは、ヘッダーの長さ5(20バイト)を必須とします。これは、オプションのない標準サイズです。

  • IPv6 Logic (0x86dd):

    • pkt_14 & 240 == 0x60: バージョンフィールドがバージョン6(0x60)かどうかを確認する

異なる値がチェックされている2番目の部分を見ると、ネットワークパケット値が確認できます。

  • 0x86DD: IPv6ヘッダーのパケット条件。

  • 0x11: UDPプロトコル番号。

  • 0x35: 宛先ポート(53)。

予想される値の横に、指定されたパケット(例:pkt_12, pkt_13)。

パケットの作成

これで、どのバイトが特定のオフセットに存在すべきかが確立されたので、それをscapyを使って実際のネットワークパケットに変換することができます。以前のZ3制約のバイトから新しいパケットを生成すると、パケットがどのようなものになるかを明確に確認でき、これをさらなる処理のために保存することができます。

###[ Ethernet ]###
  dst       = 00:00:00:00:00:00
  src       = 00:00:00:00:00:00
  type      = IPv6                 <-- IPv6 Packet
###[ IPv6 ]###
     version   = 6
     tc        = 0
     fl        = 0
     plen      = 0
     nh        = UDP               <-- UDP Protocol
     hlim      = 0
     src       = ::
     dst       = ::
###[ UDP ]###
        sport     = 0
        dport     = domain         <-- Port 53
        len       = 0
        chksum    = 0x0

これらの新しく作成されたパケットは、ネットワーク上でこれらをスキャンすることで、これらのインプラントの存在を特定するために、さらなる調査や特定に使用することができます。

ご自身で試してみてください

特定のBPF指示が何をしているのかを理解するのは、面倒で時間のかかる作業です。この例は、合計16の指示に過ぎませんが、私たちは、理解するのに少なくとも1日はかかる200以上の命令を検出したことがあります。Z3リゾルバーを使って、この時間をわずか数秒に短縮し、受け入れられたパケットへのパスだけでなく、そのためのパケットスクレイピングも表示できるようになりました。

当社は、コミュニティがBPFベースのインプラントの分解を自動化できるように、filterforgeツールをオープンソース化しました。ソースコードと使用例は、弊社のGitHubリポジトリでご覧いただけます。

この調査結果を公開し、アナリストがBPFの指示を理解するのに費やす時間を削減するためのツールを共有することで、他の人がこの形態の自動化を拡大するためにさらに研究を進めることを願っています。

Cloudflareは企業ネットワーク全体を保護し、お客様がインターネット規模のアプリケーションを効率的に構築し、あらゆるWebサイトやインターネットアプリケーションを高速化し、DDoS攻撃を退けハッカーの侵入を防ぎゼロトラスト導入を推進できるようお手伝いしています。

ご使用のデバイスから1.1.1.1 にアクセスし、インターネットを高速化し安全性を高めるCloudflareの無料アプリをご利用ください。

より良いインターネットの構築支援という当社の使命について、詳しくはこちらをご覧ください。新たなキャリアの方向性を模索中の方は、当社の求人情報をご覧ください。
MalwareNetworkZ3BPFリバースエンジニアリング

Xでフォロー

Cloudflare|@cloudflare

関連ブログ投稿

2025年11月05日

Workers VPCサービスが世界中どこからでもお客様のリージョンプライベートネットワークに接続する仕組み

Workers VPCサービスは、本日よりオープンベータとなります。Workers VPCがCloudflareのグローバルネットワークを使用して、グローバルにデプロイされたWorkersをどのように地域のプライベートネットワークに接続しているのか、その内容をご紹介します。また、クロスクラウドネットワーキングの複雑さを抽象化しています。...

2025年10月31日

BGPゾンビと過剰なパスハンティング

BGP「ゾンビ」は、本質的にはインターネットのデフォルトフリーゾーン(DFZ)で行き詰まったルートであり、潜在的にはプレフィックスの取り消し、または紛失が原因です。BGPゾンビがゾンビから立ち上がり、大混乱を引き起こす可能性が高いいくつかの状況を紹介します。 ...