FreeBSD 14 pf ステートフル・パケットフィルタと synproxy state による
L4 DDoS・VPN境界防御の設計と運用
監修:加藤 明生(株式会社オープンナレッジ 創業者・代表取締役CTO / ネットワークスペシャリスト / 登録情報処理安全確保支援士(第007617号))
この記事の要点と4つの章のご案内
| 章構成 | 主なテーマ | 要点と確認ポイント |
|---|---|---|
| 1. 基本と全体像 | 身近なたとえで見る仕組み:「誰でも内線につながる受付」と「身元確認を終えるまで扉を開けないホテルのコンシェルジュ」 | 国内の病院や工場で多発するランサムウェア被害の約8割は、インターネットに公開された「VPN機器やリモート接続機器の隙間」から侵入されています。 |
| 2. 現場での進め方と対策 | 現場で陥りやすい落とし穴と回避策:境界ファイアウォール運用の3大アンチパターン | FreeBSD 14の「pf(パケットフィルタ)」と「synproxy state」は、身元が確認できるまで奥のサーバーへ通信を一切通さず、玄関先で不正アクセスや大量通信攻撃(DDoS)を無効化します。 |
| 3. 関連する制度とルール | 一般的な市販のUTM(統合脅威管理ボックス)やVPN機器と、FreeBSD 14 pfによる境界防御は何が違うのですか? | さらにはみ出した断片通信を整える「scrub(パケット正規化)」と、秒単位で攻撃元IPを自動隔離するテーブル機構により、後ろにあるWebやデータベースを着実に守り抜きます。 |
| 4. 詳しい技術仕様としくみ | 「synproxy state(シン・プロキシ)」とは、具体的に言うとどのような仕組みですか? | 市販の専用ボックス型機器の多くは、内部で複雑な管理用Web画面(PHPや古いスクリプト等で構築された管理画面)をインターネット側に公開しており、そこに見つかった未知の弱点(ゼロデイ脆弱性)を突かれて侵入される事案が多発しています。一方、当社のFreeBSD 14 pf基盤はインターネット側に管理用Web画面を一切持たず、OSの心臓部(カーネル)で直接パケットを検査・代理応答するため、攻撃者が侵入する足がかりそのものが存在しません。 |
- 01 国内の病院や工場で多発するランサムウェア被害の約8割は、インターネットに公開された「VPN機器やリモート接続機器の隙間」から侵入されています。
- 02 FreeBSD 14の「pf(パケットフィルタ)」と「synproxy state」は、身元が確認できるまで奥のサーバーへ通信を一切通さず、玄関先で不正アクセスや大量通信攻撃(DDoS)を無効化します。
- 03 さらにはみ出した断片通信を整える「scrub(パケット正規化)」と、秒単位で攻撃元IPを自動隔離するテーブル機構により、後ろにあるWebやデータベースを着実に守り抜きます。
なぜ高価なセキュリティ機器を入れても被害に遭う会社があるのか、そして「最初から外部に弱点をさらさない入口設計」がどれほど維持費とリスクを下げるのかが3分でわかります。
VPN機器のポート開放リスク、管理画面の非公開化、レート制限によるブルートフォース(総当たり攻撃)の自動遮断など、現場で今日から点検すべき3つの急所を確認できます。
記事後半の「第3章 Monograph」にて、赤黒木状態テーブル、SipHash-2-4によるSYN Cookie生成数理、TCPシーケンス番号変換、本番用pf.confとRust製解析器を詳解しています。
身近なたとえで見る仕組み:「誰でも内線につながる受付」と「身元確認を終えるまで扉を開けないホテルのコンシェルジュ」
会社のサーバーや社内ネットワークを、ひとつの「ホテル」だと想像してみてください。インターネットからやってくる通信パケットは、玄関を訪れる「訪問者」です。
一般的なファイアウォール設定(ポート開放)では、訪問者が玄関で「予約したいのですが(TCP SYNパケット)」と声をかけた瞬間、受付係は何も確認せずに奥の事務室にいるスタッフ(WebサーバーやVPN装置)へ直接内線をつないでしまいます。もし悪意のある集団が、返事をするつもりのない「イタズラ電話」を1秒間に何十万回もかけ続けたらどうなるでしょうか?奥のスタッフは「もしもし、お名前は?」と聞き返す待ち時間(半開接続:SYN_RCVD)だけで手一杯になり、本当に予約したいお客様の電話が一切つながらなくなってしまいます。これが「SYNフラッド攻撃(DDoS攻撃)」の正体です。
これに対して、私たちが採用しているFreeBSD 14 の pf(パケットフィルタ)と synproxy state は、玄関に立つ「熟練のコンシェルジュ」として働きます。訪問者が声をかけてきても、奥のスタッフにはまだ一切知らせません。まずコンシェルジュ自身が訪問者と挨拶を交わし、「偽装された住所ではないか」「本当に会話が成立する相手か」を100%確認し終えてから、初めて奥のスタッフへ内線をつなぎます。そのため、イタズラ電話が何百万件来ても奥のサーバーは1ミリも疲れず、いつも通り着実に仕事を続けられるのです。
国内のランサムウェア被害において、侵入経路の約8割は「VPN機器」や「リモートデスクトップ」などインターネットに面した境界機器の弱点から発生しています。
FreeBSD 14 pf は最初の1パケット目で通信の合否を判定し、2パケット目以降はルール表を読み直さず高速な状態表(State Table)から即座に通過させます。
synproxy state により、3ウェイ・ハンドシェイクが完了しない不正なSYNパケットは1件たりとも背後のアプリケーションやDBへ到達しません。
pf(scrub正規化 + synproxy代理応答 + 動的ブラックリスト)の3段検問ゲート
| 比較項目 | 一般的なポート開放 + 従来型VPN/UTM機器 | 当社標準:FreeBSD 14 pf + synproxy state 境界設計 |
|---|---|---|
| 外部公開される管理画面 | Web管理画面やSSL-VPN画面が外から見え、脆弱性攻撃の標的になる | 外部公開Web管理画面は一切なし(攻撃対象領域を根本から排除) |
| 大量SYNフラッド攻撃への耐性 | 背後のWebサーバーの接続待ち枠(Backlog)が埋まりサイトが停止 | カーネルpfが代理でハンドシェイクを検証し、背後サーバーの負荷はゼロ |
| 細工された断片化パケット | 検査をすり抜けて内部のアプリケーションまで届く恐れがある | scrub 機構がメモリ上で再構築・正規化し、異常パケットを即座に破棄 |
| 総当たり攻撃(ブルートフォース) | ログを見てから人間が手動でIPアドレスを登録することが多い | 5秒間に15回以上の接続試行を検知した瞬間にカーネル表で自動遮断 |
現場で陥りやすい落とし穴と回避策:境界ファイアウォール運用の3大アンチパターン
回避策:「デフォルト全拒否(block drop all)」を土台にし、管理通信は閉域網と公開鍵認証に限定する
「外出先から設定変更ができるように」と、ルーターやUTMの設定画面をインターネット全体に公開してしまうケースが後を絶ちません。攻撃者の自動巡回プログラム(スキャナ)は、新しく公開された管理画面を数分以内に見つけ出します。FreeBSD 14 pf では、1行目に「すべての通信を音もなく破棄する(block drop all)」を宣言し、本当に必要な公開Web通信(80/443番)だけを厳密な状態管理つきで開ける設計を順守します。
synproxy state を有効にした際、TCPウィンドウ・スケーリング(WSCALE)が失われ通信速度が落ちる
回避策:scrub 正規化とTCPオプション中継の整合性を検証し、広帯域通信と安全性を両立させる
インフラ実務者がよく直面する技術的な落とし穴として、不用意に synproxy state だけを設定すると、クライアントと背後サーバーの間で交換されるべきTCP拡張オプション(Window Scaling: RFC 7323 や SACK: RFC 2018)が初期化され、大容量ファイルの転送速度が低下する現象があります。当社では scrub (reassemble tcp) によるシーケンス番号正規化と、エッジのリバースプロキシ(ap01)終端設計を組み合わせることで、DDoS耐性と高速なスループットを両立させています。
回避策:set optimization aggressive と接続上限・自動期限切れ( overload <bruteforce> flush global)を組み合わせる
ファイアウォールが通信の状態(State)を記憶する方式では、大量の攻撃を受けると記憶枠(デフォルト1万件など)が満杯になり、新しいお客様が入れなくなることがあります。これを防ぐため、当社の本番環境では状態テーブル上限を200万件以上に拡張した上で、異常な頻度で接続してくるIPアドレスを検知した瞬間に「そのIPが作った過去の状態をすべて即座に強制切断(flush global)」する自動防衛ルールを稼働させています。
今日から確認できる!ネットワークの玄関(境界防御)安心度 3項目チェック
達成度: 0 / 3クリックしてチェックを入れると、現在の防衛レベルに応じたアドバイスが表示されます(入力内容はブラウザ内のみで処理されます)。
FreeBSD 14 pfctl 状態テーブル・SYNプロキシ動作の一次検証手順
境界ゲートウェイ(ap01.ok.st)において、synproxy state が未完了ハンドシェイクを代理処理し、かつレート超過IPを <bruteforce> テーブルへ自動隔離していることは、以下のカーネル統計カウンタから決定論的に検証できます。
| VERIFICATION COMMAND | KERNEL COUNTER / STATE | 正常稼働時の不変条件(Expected Operational Invariant) |
|---|---|---|
| pfctl -si | grep -E "synproxy|state" | synproxy / current entries | SYNフラッド印加時にもバックエンドの netstat -Lan 半開キュー長が 0 を維持すること |
| pfctl -t bruteforce -T show -v | V_pf_keyhash / Radix Table | 閾値(45接続/5秒)を超過した送信元IPが即時登録され、既存ステートが flush global されること |
| tcpdump -n -e -ttt -i pflog0 | DLT_PFLOG (100-Byte Header) | 破棄パケットのルール番号・理由コード(match / bad-offset / fragment)がゼロコピー記録されること |
なぜ「音もなく捨てる(block drop)」設定が、攻撃者の偵察を無力化するのか?
ファイアウォールが通信を拒否するときには、大きく分けて2つの断り方があります。ひとつは「この番号は現在使われておりません(TCP RSTやICMP Unreachable)」と丁寧に返事をする「return(返答拒否)」、もうひとつは返事を一切せずにパケットをブラックホールへ吸い込む「drop(無言破棄)」です。もし丁寧に「使われていません」と即答してしまうと、攻撃者は「少なくともそこにサーバーが生きていること」を0.01秒で知り、次のポートを次々と試すことができます。反対に block drop all で無言破棄すると、攻撃者のプログラムは「返事が来るまで数秒間じっと待ち続ける(タイムアウト待ち)」ことになり、偵察のスピードを数百分の1以下に遅らせることができるのです。
FreeBSD 14 カーネル pf 状態テーブル内部構造・SipHash-2-4 SYNプロキシ順序番号変換数理と Rust 2024 pflog0 テレメトリ解析器
FreeBSD 14 の sys/netpfil/pf/pf.c におけるステートフル検査エンジンは、全受信フレームを pfil(9) フックで捕捉し、最初に pf_normalize_ip() および pf_normalize_tcp()(scrub 処理)を実行します。ここで IP フラグメントの再構築、不正な TCP フラグ組み合わせ(TH_SYN | TH_FIN や TH_SYN | TH_RST)の即時破棄が行われます。続いて pf_find_state() が 6タプル鍵 k = (af, proto, src_addr, dst_addr, src_port, dst_port) を用いてカーネル状態ハッシュ表(V_pf_keyhash、バケットごとに細粒度行ロック PF_HASHROW_LOCK を保持)および送信元追跡用の赤黒木(struct pf_src_node)を検索します。既存セッションに一致したパケットはルールリスト線形探索 O(R) を構造的にバイパスし、定数時間 O(1) で通過します。
新規 TCP SYN パケットが synproxy state 適用ルールに一致した場合、状態機械は PF_TCPS_PROXY_SRC へ遷移します。この段階ではバックエンド(オリジンサーバー)へ一切のパケットを送出せず、pf 自身がクライアントの初期シーケンス番号 Sc に対して暗号学的擬似乱数関数 SipHash-2-4 を用いたプロキシ初期シーケンス番号 Sp を生成し、SYN+ACK を直接返信します:
クライアントから正当な ACK(ack = (Sp + 1) mod 232)が到着して初めて、状態は PF_TCPS_PROXY_DST へ進み、pf がバックエンドへ代理 SYN を送出します。バックエンドが返した初期シーケンス番号 Sb とプロキシが返した Sp は異なるため、struct pf_state_peer 内の seqdiff フィールドに差分 Δseq = (Sp − Sb) mod 232 を保持し、以降すべての双方向パケットの th_seq / th_ack と 16ビット補数和チェックサム(RFC 1624 増分再計算)をワイヤスピードで書き換えます。
| Layer / Offset | Kernel Structure / Parameter | Size / Value | Defensive / Mathematical Role |
|---|---|---|---|
| pf_state_key | af, proto, addr[2], port[2] | 36 Bytes (IPv6 ready) | 6-tuple hash lookup key in V_pf_keyhash |
| pf_state_peer.seqdiff | u_int32_t seqdiff (S_p - S_b) | 4 Bytes (Modulo 2^32) | Translates sequence/ack numbers after synproxy completion |
| TCP Window Check | pf_tcp_track_full() SEQ/ACK Window | 66,000 Bytes + wscale | Rejects blind in-window RST / data injection (RFC 5961) |
| pfloghdr +0x00 | length (1B), af (1B), action (1B), reason (1B) | 4 Bytes Header Prefix | BPF DLT_PFLOG (117) frame metadata for IDS telemetry |
| pfloghdr +0x04..0x3C | ifname[16], ruleset[16], rulenr, subrulenr, dir | 56 Bytes (Total 100B aligned) | Identifies exact matching rule and interface in O(1) |
| src_node Tracking | max-src-conn-rate 15/5 overload | RB-Tree Entry / IP | Atomic insertion into <bruteforce> + flush global states |
# ============================================================================
# 1. FreeBSD 14 Production /etc/pf.conf (ap01 Edge Boundary Defense)
# ============================================================================
# ext_if = "vtnet0"
# int_vlan = "vtnet1" # Private VLAN to db01.ok.st (10.0.x.x/24)
# set limit { states 2000000, frags 65536, src-nodes 500000, table-entries 1000000 }
# set optimization aggressive
# set block-policy drop
# set skip on lo0
#
# table <bruteforce> persist counters
# table <bogons_rfc6890> persist file "/etc/pf.bogons.conf"
#
# scrub in on $ext_if all fragment reassemble min-ttl 15 max-mss 1460 random-id
#
# block drop log all
# block drop in quick on $ext_if from <bruteforce>
# block drop in quick on $ext_if from <bogons_rfc6890>
# antispoof quick for { $ext_if, $int_vlan }
#
# # L4 DDoS Protection via synproxy state + Automatic Rate-Based Quarantine
# pass in on $ext_if proto tcp from any to ($ext_if) port { 80, 443 } \
# flags S/SA synproxy state \
# (max-src-conn 80, max-src-conn-rate 45/5, overload <bruteforce> flush global)
// ============================================================================
// 2. Rust Edition 2024: Zero-Copy FreeBSD DLT_PFLOG Parser & RFC 1624 Seq Translater
// ============================================================================
#![no_std]
#![forbid(unsafe_code)]
/// Binary representation of FreeBSD 14 `pfloghdr` (sys/net/if_pflog.h)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PflogRecord {
pub hdr_len: u8,
pub address_family: u8,
pub action: PfAction,
pub reason: u8,
pub rule_number: u32,
pub direction: PfDirection,
pub src_ipv4: [u8; 4],
pub dst_ipv4: [u8; 4],
pub src_port: u16,
pub dst_port: u16,
pub tcp_flags: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PfAction {
Pass = 0,
Drop = 1,
Scrub = 2,
SynProxyDrop = 9,
Unknown = 255,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PfDirection {
In = 1,
Out = 2,
Unknown = 0,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParseError {
FrameTooShort,
UnsupportedAddressFamily(u8),
NotTcpProtocol(u8),
}
/// Parses a raw BPF `DLT_PFLOG` frame (minimum 64-byte pflog header + 20-byte IPv4 + 20-byte TCP)
/// in O(1) time without heap allocation or unsafe pointer casts.
pub const fn parse_pflog_ipv4_tcp(frame: &[u8]) -> Result<PflogRecord, ParseError> {
if frame.len() < 104 {
return Err(ParseError::FrameTooShort);
}
let hdr_len = frame[0];
// Word-aligned length in BPF: roundup(hdr_len, 4)
let ip_offset = ((hdr_len as usize) + 3) & !3usize;
if ip_offset < 60 || frame.len() < ip_offset + 40 {
return Err(ParseError::FrameTooShort);
}
let af = frame[1];
if af != 2 {
// AF_INET on FreeBSD is 2
return Err(ParseError::UnsupportedAddressFamily(af));
}
let action = match frame[2] {
0 => PfAction::Pass,
1 => PfAction::Drop,
2 => PfAction::Scrub,
9 => PfAction::SynProxyDrop,
_ => PfAction::Unknown,
};
let reason = frame[3];
let rule_number = u32::from_be_bytes([frame[36], frame[37], frame[38], frame[39]]);
let direction = match frame[60] {
1 => PfDirection::In,
2 => PfDirection::Out,
_ => PfDirection::Unknown,
};
let ip_hdr = &frame[ip_offset..];
let ihl = ((ip_hdr[0] & 0x0F) as usize) * 4;
if ihl < 20 || frame.len() < ip_offset + ihl + 14 {
return Err(ParseError::FrameTooShort);
}
let proto = ip_hdr[9];
if proto != 6 {
// IPPROTO_TCP == 6
return Err(ParseError::NotTcpProtocol(proto));
}
let src_ipv4 = [ip_hdr[12], ip_hdr[13], ip_hdr[14], ip_hdr[15]];
let dst_ipv4 = [ip_hdr[16], ip_hdr[17], ip_hdr[18], ip_hdr[19]];
let tcp_hdr = &frame[ip_offset + ihl..];
let src_port = u16::from_be_bytes([tcp_hdr[0], tcp_hdr[1]]);
let dst_port = u16::from_be_bytes([tcp_hdr[2], tcp_hdr[3]]);
let tcp_flags = tcp_hdr[13];
Ok(PflogRecord {
hdr_len,
address_family: af,
action,
reason,
rule_number,
direction,
src_ipv4,
dst_ipv4,
src_port,
dst_port,
tcp_flags,
})
}
/// Performs RFC 1624 incremental 16-bit one's complement TCP checksum update
/// when `synproxy state` translates a 32-bit sequence or acknowledgment number by `seqdiff`.
#[inline]
pub const fn translate_synproxy_seq_and_cksum(
old_seq: u32,
seqdiff: u32,
old_check: u16,
) -> (u32, u16) {
let new_seq = old_seq.wrapping_add(seqdiff);
let old_hi = (old_seq >> 16) as u16;
let old_lo = (old_seq & 0xFFFF) as u16;
let new_hi = (new_seq >> 16) as u16;
let new_lo = (new_seq & 0xFFFF) as u16;
// HC' = ~(~HC + ~m_hi + ~m_lo + m'_hi + m'_lo) in 1's complement arithmetic
let mut sum: u32 = (!old_check as u32)
+ (!old_hi as u32)
+ (!old_lo as u32)
+ (new_hi as u32)
+ (new_lo as u32);
while (sum >> 16) != 0 {
sum = (sum & 0xFFFF) + (sum >> 16);
}
(new_seq, !(sum as u16))
}