OpenAICloudflareApr 8, 2026, 1:00 PM

From bytecode to bytes: automated magic packet generation

A condensed section focused on the key takeaways first.

Original Post

Quick Digest

Summary

A condensed section focused on the key takeaways first.

openaienmodel: gpt-5-mini-2025-08-07

From bytecode to bytes: automated magic packet generation

Key Points

  • Automate magic-packet generation from malicious BPF via symbolic execution
  • Use Z3 to solve BPF constraints and scapy to materialize packets
  • Shortest-accepting-path search reduces manual analysis from hours to seconds

Summary

This post describes an automated approach to generate "magic" network packets that trigger malicious classic BPF filters (used by backdoors like BPFDoor). The authors combine a shortest-accepting-path search over BPF bytecode with symbolic execution (Z3) to produce concrete packet bytes, then materialize packets with scapy. The technique reduces manual reverse-engineering of long BPF programs from hours to seconds.

Key Points

  • Problem: Classic BPF programs can be hundreds of instructions and hide backdoors that only activate on a specific packet; manual analysis is slow and error-prone.
  • Path discovery: Use a BFS-style queue to enumerate instruction paths and select the shortest path that ends in an ACCEPT (non-zero return) from the BPF VM.
  • Symbolic execution: Model packet bytes as Z3 BitVecs and maintain symbolic A/X registers and M[0..15] memory; map BPF ALU/jump ops to Z3 constraints.
  • Practical checks: Add link-layer-aware constraints (ethernet vs raw) such as Ethernet EtherType checks and IPv4/IPv6 version/IHL masks to constrain pkt offsets (e.g., pkt_12/pkt_13 for EtherType, pkt_14 & 0xF0 == 0x40 for IPv4).
  • Solving and crafting: Use Z3.check() and extract concrete byte values from BitVecs, then construct packets with scapy (respecting minimum packet size, offsets, and endianness).
  • Implementation tips:
    • Represent each packet byte as an 8-bit BitVec (pkt_0..pkt_N).
    • Initialize A/X as 32-bit BitVecs and map k/X sources for ALU ops (e.g., A = A + src).
    • Keep the queue entries with (pc, path, constraints) and prune on drop paths; prefer exploring lower-jump branches first to get shortest path.
    • Ensure a MIN_PKT_SIZE (e.g., 64) and account for header offsets when applying constraints.

Outcome for engineers

  • You can automate generation of trigger packets for classic BPF-based backdoors by combining a shortest-path search over BPF bytecode with Z3-based symbolic execution and scapy-based packet assembly. This approach scales to long BPF programs and captures protocol/offset constraints necessary for valid packets.

Key references

  • Tools/tech: Z3 (theorem prover), scapy (packet crafting)
  • Target: classic BPF bytecode (two-register VM), example BPFDoor sample analysis

Full Translation

Translations

A translation section that keeps the flow of the original article.

openaijamodel: gpt-5-mini-2025-08-07

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

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

LinuxマルウェアはしばしばBerkeley Packet Filter (BPF) のソケットプログラムに潜伏します。これらはカーネル内に埋め込まれる小さな実行可能ロジックで、ネットワークトラフィックの処理方法をカスタマイズできます。インターネット上の最も持続的な脅威のいくつかは、これらのフィルタを使って特定の“マジック”パケットを受信するまで休眠し、検出を回避します。これらのフィルタは数百命令に及び、複雑な論理ジャンプを含むことがあるため、手作業でリバースエンジニアリングするのは非常に時間がかかり、セキュリティ研究者のボトルネックになります。

より良い方法を模索するために、我々はシンボリック実行を検討しました。これはコードを単なる命令列としてではなく、制約の集合として扱う手法です。Z3 theorem prover を用いることで、マルウェアなフィルタから逆算し、それをトリガーするパケットを自動生成できます。本記事では、手作業でのアセンブリ解析に数時間を要していた作業を数秒に短縮するツールをどのように構築したかを説明します。

複雑性の上限

マルチプルな悪意あるフィルタを分解する前に、その実行エンジンを理解する必要があります。Berkeley Packet Filter (BPF) は、バイトコード命令の集合に基づいてカーネルがネットワークスタックから特定のパケットを高速に取り出すことを可能にする高効率技術です。多くの開発者が慣れ親しんでいるeBPF (Extended BPF) の強力な進化版とは別に、本稿では「クラシック」BPFに焦点を当てます。元々はtcpdumpのようなツール用に設計されたクラシックBPFは、わずか2つのレジスタしか持たないシンプルな仮想マシンで、ネットワークトラフィックを高速に評価します。カーネル深部で動作し、ユーザースペースツールからトラフィックを“隠す”ことができるため、ステルスなバックドアを構築するマルウェア作者に好まれています。

LLMを使ってBPF命令の文脈的表現を作ることは、アナリストの手作業の負担を既に軽減していますが、検証条件に対応するネットワークパケットを作成する作業は、LLMからの追加コンテキストがあっても依然として手間がかかります。BPFプログラムが約20命令程度であれば問題になることは少ないですが、一部サンプルで観察されたように100命令を超えると指数的に複雑化し、時間がかかります。

問題を分解すると、バッファを読み取り制約をチェックし、その結果に応じて実行パスを続行するか停止して結果を判定する、という構造に還元できます。このような決定性のある問題は、与えられた制約集合を解ける Z3 によって解決可能です。

Exhibit A: BPFDoor

BPFDoor は高度な受動型 Linux バックドアで、Red Menshen(別名 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 命令が示す条件を満たすパケットへの最短パスを見つけるには、不利な条件で終了しないパスを追跡する必要があります。小さなキューを作成して開始します。このキューは次の重要なデータポイントを保持します:

  • 次に実行する命令へのポインタ(program counter)。
  • 実行済みの命令のパス(現在のパス)+次に実行される命令。

条件をチェックする命令に遭遇するたびに、その結果をブール値で保持してキューに格納します。これにより、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 例に対して実行すると、ACCEPT されるパケットへの最短パスが次のように示されます:

(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 命令を解析してバックドアに対する受け入れパケットがどのように見えるべきかを自動的に解く上で既に有用な自動化です。しかし、ここからさらに一歩進めて、期待されるパケットを自動で返してくれる小さなツールを作れたらどうでしょうか。

Z3 と scapy の利用

与えられた制約集合を解くのに適しているツールの一つが Z3 です。Microsoft が開発したこのツールは theorem prover と呼ばれ、内部で非常に複雑な数学的演算を行う簡便な関数群を公開しています。もう一つのツールは scapy で、これは対話的なパケット操作に人気のある Python ライブラリです。

既に ACCEPT されるパケットへのパスを求める方法があるので、残る課題はそのパスで必要とされる制約を解き、得られた解をネットワークパケットの各オフセットのバイトに変換することです。

シンボリック実行

あるプログラムで取られるパスを探索する一般的な手法にシンボリック実行があります。入力を変数(および制約)として与え、成功するパスの結果を知ることでツールにこれらの成功パスを見つけさせ、コンテキスト化した結果を出力させます。

これを実現するために、定数、レジスタ、条件判定の結果となるブール演算子などの状態を追跡できる小さなマシンを実装する必要があります。例として以下のようなクラスを示します。

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"(index)と "a"(accumulator)を初期状態に設定する。
  • 成功パスとして特定された命令列に沿ってループする。
  • 非ジャンプ命令をそのまま実行し、レジスタ状態を追跡する。
  • ジャンプ命令が出現したら、どちらの分岐が取られるかを判定する。
  • Z3 の check() を使用して、与えた制約で条件(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))]

最初の部分は、リンク層 BPF 命令を扱うときに有効な Ethernet/IP を構築するために追加された制約です。If 文は検出されたプロトコルに基づいて特定の制約を適用します:

  • IPv4 ロジック (0x0800):
    • pkt_14 & 240 == 64: バイト14はIPヘッダの開始。0xF0 マスクで上位ニブル(Version)を分離し、Versionが4 (0x40) かをチェック。
    • pkt_14 & 15 == 5: 0x0F で下位ニブル(IHL)を分離し、IHL が 5(20バイト)であることを要求。オプションなしの標準的なサイズ。
  • IPv6 ロジック (0x86dd):
    • pkt_14 & 240 == 0x60: Versionフィールドが 6 (0x60) であることをチェック。

最後の部分では、異なる値がネットワークパケットのどのバイトに対応しているかを観察できますが、ここで原文は途中で切れており詳細が欠けています。


(注: この記事は原文の 2026-04-08 公開版に基づいて翻訳しています。原文の末尾が途中で切れている箇所があります。)