バイトコードからバイトへ: 自動化されたマジックパケット生成
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:
paths.append(path)
continue
if instruction.class == jump_instruction:
if instruction.operation == unconditional_jump:
next_pc = pc + 1 + instruction_constant
queue.append((next_pc, path + [next_pc]))
continue
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]))
このロジックを先の BPFDoor 例に対して実行すると、ACCEPT されるパケットへの最短パスが次のように示されます:
(000) code=0x28 jt=0 jf=0 k=0xc
(001) code=0x15 jt=0 jf=4 k=0x86dd
(002) code=0x30 jt=0 jf=0 k=0x14
(003) code=0x15 jt=0 jf=11 k=0x11
(004) code=0x28 jt=0 jf=0 k=0x38
(005) code=0x15 jt=8 jf=9 k=0x35
(014) code=0x06 jt=0 jf=0 k=0x40000
これは、BPF 命令を解析してバックドアに対する受け入れパケットがどのように見えるべきかを自動的に解く上で既に有用な自動化です。しかし、ここからさらに一歩進めて、期待されるパケットを自動で返してくれる小さなツールを作れたらどうでしょうか。
Z3 と scapy の利用
与えられた制約集合を解くのに適しているツールの一つが Z3 です。Microsoft が開発したこのツールは theorem prover と呼ばれ、内部で非常に複雑な数学的演算を行う簡便な関数群を公開しています。もう一つのツールは scapy で、これは対話的なパケット操作に人気のある Python ライブラリです。
既に ACCEPT されるパケットへのパスを求める方法があるので、残る課題はそのパスで必要とされる制約を解き、得られた解をネットワークパケットの各オフセットのバイトに変換することです。
シンボリック実行
あるプログラムで取られるパスを探索する一般的な手法にシンボリック実行があります。入力を変数(および制約)として与え、成功するパスの結果を知ることでツールにこれらの成功パスを見つけさせ、コンテキスト化した結果を出力させます。
これを実現するために、定数、レジスタ、条件判定の結果となるブール演算子などの状態を追跡できる小さなマシンを実装する必要があります。例として以下のようなクラスを示します。
class BPFPacketCrafter:
MIN_PKT_SIZE = 64
LINK_ETHERNET = "ethernet"
LINK_RAW = "raw"
MEM_SLOTS = 16
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
self.packet = [BitVec(f"pkt_{i}", 8) for i in range(self.pkt_size)]
self.A = BitVecVal(0, 32)
self.X = BitVecVal(0, 32)
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 公開版に基づいて翻訳しています。原文の末尾が途中で切れている箇所があります。)