自律型ペネトレーションテストと有向攻撃経路グラフ(DAG)探索エンジンのRust 2024実装
著者:加藤 明生(株式会社オープンナレッジ 代表取締役CTO)
この記事の要点と4つの章のご案内
| 章構成 | 主なテーマ | 要点と確認ポイント |
|---|---|---|
| 1. 基本と全体像 | 要旨と脅威モデル:なぜ点単位の脆弱性スキャンは「多段連鎖型エクスプロイト」を見落とすのか | 従来のスキャナは個々の設定不備を独立した点(Point)として列挙するため、単体では低リスク(CVSS 3.1〜4.3)と判定される複数の事象が連鎖して特権昇格や内部網侵入に至る経路(Chained Exploit Path)を見落とします。当エンジンは資産・認証状態・ネットワーク到達性を状態遷移グラフとして構築し、対数変換した遷移確率の最短経路探索(Dijkstra / Yenのk最短路アルゴリズム)によって、実際に悪用可能な攻撃連鎖をO(E + V log V)で特定します。 |
| 2. 現場での進め方と対策 | 有向攻撃経路グラフ G = (V, E, P, C) の数理定式化と最短経路探索 | はい。当エンジンは非破壊状態遷移検証(Non-Destructive State Transition Verification)プロトコルを採用しています。バッファオーバーフローやデータ書き換えを伴う破壊的ペイロードは一切送信せず、DNSレコード解決、TLS 1.3ハンドシェイク証明書属性(SAN / OCSP)、HTTP OPTIONS/HEADメソッドおよび境界トークン応答の決定論的差分のみをレート制限下で観測します。 |
| 3. 関連する制度とルール | 非破壊アクティブ・プロービング仕様と RFC 9116 境界ガバナンス | はい。探索された攻撃経路グラフの頂点・辺集合、観測タイムスタンプ、および非破壊プローブの入出力ダイジェストを正規化JSONとして直列化し、SHA-256マークル木ルートに対してEd25519(RFC 8032)電子署名を付与した監査証明書を出力します。第三者監査機関が公開鍵のみで改ざん有無を即時検証可能です。 |
| 4. 詳しい技術仕様としくみ | 有向攻撃経路グラフ探索器・非破壊遷移検証・Ed25519 監査証明器の Rust 2024 実装 | 著者:加藤 明生(株式会社オープンナレッジ 代表取締役CTO) |
1. 要旨と脅威モデル:なぜ点単位の脆弱性スキャンは「多段連鎖型エクスプロイト」を見落とすのか
現代のエンタープライズおよびサプライチェーン侵害において、単一のCVSS 9.8脆弱性(未認証リモートコード実行等)のみを突いて境界から深部データベースまで一足飛びに到達する事案はむしろ少数派です。実際の攻撃者(APTおよび初期アクセスブローカー)は、個別の脆弱性スキャナでは「情報提供(Informational)」または「低リスク(Low: CVSS 3.1〜4.3)」として棄却される複数の軽微な設定不備を有向経路(Directed Path)として連鎖(Chaining)させます。
典型的な3ホップ侵害連鎖を考えてみましょう。第1ホップとして、過去に解約された外部SaaS向けの DNS CNAME レコードが残存(Dangling CNAME)しており、かつ公開 TLS 証明書の Subject Alternative Name(SAN: RFC 5280 Section 4.2.1.6)から内部ステージング用ホスト名が列挙可能であるとします。第2ホップとして、そのサブドメインを第三者が取得することで同一親ドメインスコープの Domain=.example.jp 属性を持つセッションCookieまたはOAuthリダイレクトトークンを傍受します。第3ホップとして、取得したトークンを用いて内部Webhook受信エンドポイントへ署名なしイベントを注入し、バックエンドの非同期ジョブキュー経由で特権昇格(Privilege Escalation)を達成します。
従来の四半期ごとの手動ペネトレーションテストや単純なシグネチャ型スキャナは、特定時点(Point-in-Time)における独立事象の列挙にとどまるため、DNSゾーン変更、証明書更新、コンテナ再デプロイによって日々変化する「状態遷移の組み合わせ爆発」に追随できません。本稿では、攻撃対象領域(Attack Surface)を重み付き有向グラフとして形式化し、非破壊的なアクティブ・プローブによって到達可能性を実測した上で、最も成立確率の高い攻撃経路を多項式時間で抽出する Rust Edition 2024 エンジンの設計と実装を詳解します。
| EVALUATION AXIS | 従来型シグネチャ診断 / 四半期手動ペネトレーションテスト | 自律型有向攻撃経路グラフ(DAG)探索エンジン(Rust 2024) |
|---|---|---|
| Mathematical Model | 独立事象の線形リスト(頂点間の状態遷移・前提条件を無視) | 有向状態遷移グラフ G = (V, E, P, C) 上の最短対数尤度経路探索 |
| Chained Exploit Detection | DNS不備 × TLS SAN露出 × 認可バイパスの多段連鎖を検出不能 | 事前条件(Precondition)と事後獲得権限(Postcondition)の単一化で完全捕捉 |
| Risk Prioritization | 静的な CVSS Base Score のみ(悪用実態と乖離した大量の誤検知) | CVSS v4.0 (FIRST) × EPSS 30日悪用確率 × 到達減衰のベイズ統合評価 |
| Production Safety | ファジングや破壊的ペイロード送信により本番DB汚染・プロセス異常終了の懸念 | RFC 9116 準拠・非破壊状態観測(メタデータ・応答ヘッダ・タイミング検証)のみ |
| Audit Evidence | 手動作成のPDF報告書(発行時点で設定が陳腐化、改ざん検証不能) | 60秒トリアージ + SHA-256 マークル木 + Ed25519 電子署名付き機械可読証跡 |
2. 有向攻撃経路グラフ G = (V, E, P, C) の数理定式化と最短経路探索
組織の外部公開境界から内部重要資産(データベース、鍵管理保管庫、管理コンソール)に至るセキュリティ状態を、4つ組の有向状態遷移グラフ G = (V, E, P, C) として定義します。ここで V はセキュリティ状態または資産特権ノードの有限集合、E ⊆ V × V は攻撃プリミティブ(状態遷移辺)の集合、P : E → (0, 1] は各攻撃ステップの成功確率写像、C : E → ℝ≥0 は攻撃者が要する計算・時間コスト写像を表します。
未認証の外部攻撃者の初期状態ノードを v0 ∈ Vsurface、保護対象の最重要資産(Crown Jewel:例えばプライベートvLAN内の db01.ok.st におけるテナント元帳)を vtarget ∈ Vcrown とします。v0 から vtarget へ至る単純有向経路 π = (e1, e2, …, em) において、各遷移ステップの条件付き成功確率 p(ek) ∈ (0, 1] がマルコフ連鎖として結合するとき、経路 π 全体の侵害成立確率 P(π) は各辺の確率の総乗で与えられます。
ここで 0 < p(ek) ≤ 1 より、負の自然対数変換 w(ek) = −ln(p(ek)) は常に非負(w(ek) ≥ 0)となります。したがって、「最も侵害成立確率の高い攻撃経路 π* を求める最大化問題」は、非負重み付き有向グラフにおける「単一始点最短経路問題(Single-Source Shortest Path Problem)」と数学的に同型になります。これにより、二分ヒープ(Binary Heap)を用いた Dijkstra アルゴリズムによって O((|E| + |V|) log |V|) の計算量で最適解 π* が求まり、さらに Yen のアルゴリズムを適用することで上位 k 本の危険経路(k-Shortest Exploit Paths)を O(k · |V| · (|E| + |V| log |V|)) で網羅的に列挙できます。サイクル(閉路)を含む状態グラフにおいても、負閉路が存在しないため Dijkstra 法の正当性が無条件に保証されます。
CVSS v4.0 と EPSS(Exploit Prediction Scoring System)のベイズ統合
各辺 ek の遷移確率 p(ek) を静的な主観値ではなく客観的指標から導出するため、FIRST が策定した CVSS v4.0(Base + Threat + Environmental メトリクス)の悪用容易性部分スコア Ecvss4(ek) ∈ [0, 10] と、今後30日以内に実環境で悪用される確率を機械学習で推定する EPSS(Exploit Prediction Scoring System)スコア Sepss(ek) ∈ (0, 1)、および当社の非破壊プローブが実測した境界露出係数 αprobe(ek) ∈ {0.05, 0.25, 0.60, 1.00} を統合します。
ここで δmitigation(ek) ∈ [0, 0.99] は FreeBSD 14 pf synproxy state や PostgreSQL 19 FORCE ROW LEVEL SECURITY 等の補償統制(Compensating Controls)による減衰率です。上位 k 本の互いに素な攻撃経路群 {π1, …, πk} に対する最終的な資産侵害リスク Risktotal(Vcrown) を算出し、どの1辺 ecut ∈ E を遮断(パッチ適用またはファイアウォール遮断)すれば全体の到達確率を最小化できるか(Minimum Cut / Maximum Risk Reduction)を即座に決定します。
3. 非破壊アクティブ・プロービング仕様と RFC 9116 境界ガバナンス
自律型ペネトレーションテストを実稼働システムに対して安全に継続実行するための最重要要件は、「遷移辺 ek ∈ E の存在証明(Reachability Proof)を、対象システムの状態変更(State Mutation)ゼロで完遂すること」にあります。当社のプローブエンジンは以下の4原則をコンパイル時およびランタイムの型制約として強制します。
べき等・副作用ゼロの観測プリミティブ限定
HTTP層では RFC 9110 Section 9.2.1 で定義される Safe Methods(GET, HEAD, OPTIONS)および事前承認された検証用カスタムヘッダのみを送出します。SQLインジェクションやOSコマンドインジェクションの検証においても、DROP や SLEEP(10) を送るのではなく、構文パーサの正規化差分と型エラー応答コードの決定論的遷移のみから脆弱性の有無を判定します。
トークンバケット制御とRTT適応型スロットリング
ターゲットオリジンごとに厳密なトークンバケット(標準上限 r = 5.0 req/sec, バースト上限 b = 8 tokens)を維持します。さらに、対象サーバーのベースライン往復遅延(RTTbase)を常時監視し、直近移動平均 RTTema が 2.0 × RTTbase を超過した瞬間、プローブ送出レートを自動的にゼロへ減衰させて業務トラフィックを保護します。
事前認可スコープ検証と RFC 9116 ポリシー準拠
スキャン開始前に必ず対象ドメインの DNS TXT 認可トークン(_ok-pentest-verify.example.jp)および /.well-known/security.txt(RFC 9116)の PGP 署名・スコープ宣言を検証します。ホワイトリスト外の外部IPやサードパーティCDN管理領域へのパケット送出はソケット層で遮断されます。
機密応答データの即時ハッシュ化・非保持設計
万一、対象エンドポイントの設定不備により個人情報や内部認証情報を含むレスポンスボディが返却された場合でも、エンジンは生データをディスクへ一切書き込まず、メモリ上で正規表現マスクと SHA-256 ダイジェスト計算のみを行って即座にゼロクリア(Zeroize)します。
| PROBE VECTOR ID | MITRE ATT&CK / CWE | 非破壊観測テレメトリ(State Observation Input) | 獲得されるグラフ遷移辺 e = (u, v) |
|---|---|---|---|
| PRB-DNS-01 | T1584.001 / CWE-350 | CNAME先権威DNSの NXDOMAIN 応答とクラウドプロバイダ未登録状態の照合 |
Subdomain Takeover による同一親ドメイン Cookie 注入権限 |
| PRB-TLS-02 | T1596.003 / CWE-295 | TLS 1.3 ServerHello / Certificate 内の SAN 拡張と内部プライベートIP逆引きの交差検証 | 未公開管理ホスト名・ステージングAPIエンドポイントの発見 |
| PRB-CORS-03 | T1189 / CWE-942 | OPTIONS プリフライトに対する Access-Control-Allow-Origin 反射と Allow-Credentials: true の共起 |
任意の外部オリジンからの認証付き API 応答読み取り遷移 |
| PRB-AUTH-04 | T1550.001 / CWE-347 | JWS/JWT ヘッダ alg フィールド境界検査と OAuth redirect_uri パス正規化差分の非破壊比較 |
匿名状態 v0 からテナント一般権限 vuser への昇格遷移 |
| PRB-IDOR-05 | T1078 / CWE-639 | テスト用2テナント間のオブジェクトUUID参照に対する HTTP 200 vs 403/404 応答エントロピー比較 | テナント境界越境(Cross-Tenant Isolation Breach)の成立証明 |
HKDF鍵スケジュールとDNSSEC/TLSAピンニングによる通信路の暗号学的検証仕様。
コンパイル時の型状態機械によってメモリ破壊と注入脆弱性を構造的に排除する数理。
RFC 6962 マークル監査木を用いたサプライチェーン適合証明の実装解説。
有向攻撃経路グラフ探索器・非破壊遷移検証・Ed25519 監査証明器の Rust 2024 実装
#![forbid(unsafe_code)]
use std::cmp::Ordering;
use std::collections::{BinaryHeap, BTreeMap, BTreeSet};
/// Unique identifier for a security state or privilege node in V.
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NodeId(pub u32);
/// Atomic security capability flag bitmask (Preconditions & Postconditions).
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CapabilityMask(pub u64);
impl CapabilityMask {
pub const NONE: Self = Self(0);
pub const PUBLIC_NET: Self = Self(1 << 0);
pub const SUBDOMAIN_COOKIE_SCOPE: Self = Self(1 << 1);
pub const INTERNAL_HOSTNAME_KNOWN: Self = Self(1 << 2);
pub const WEBHOOK_TOKEN_FORGED: Self = Self(1 << 3);
pub const TENANT_ADMIN_EXEC: Self = Self(1 << 4);
pub const CROWN_JEWEL_DB_READ: Self = Self(1 << 5);
#[inline]
pub const fn contains(self, required: Self) -> bool {
(self.0 & required.0) == required.0
}
#[inline]
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
/// Non-destructive active probe observation confidence factor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProbeObservation {
/// Vulnerability actively confirmed via safe idempotent state reflection (1.00)
ConfirmedReachable,
/// Configuration indicator observed, partial compensating control present (0.60)
PartialExposure,
/// Theoretical surface only; unconfirmed by active non-destructive probe (0.25)
TheoreticalOnly,
/// Blocked at kernel perimeter (e.g., FreeBSD 14 pf synproxy / FORCE RLS) (0.05)
MitigatedAtBoundary,
}
impl ProbeObservation {
#[inline]
pub const fn alpha(self) -> f64 {
match self {
Self::ConfirmedReachable => 1.00,
Self::PartialExposure => 0.60,
Self::TheoreticalOnly => 0.25,
Self::MitigatedAtBoundary => 0.05,
}
}
}
/// CVSS v4.0 + EPSS Bayesian risk input for an attack transition edge e = (u, v).
#[derive(Clone, Debug)]
pub struct ExploitEdge {
pub edge_id: &'static str,
pub cwe_id: u32,
pub mitre_technique: &'static str,
pub from: NodeId,
pub to: NodeId,
pub precondition: CapabilityMask,
pub postcondition: CapabilityMask,
/// CVSS v4.0 Exploitability sub-score in [0.0, 10.0]
pub cvss_v4_exploitability: f64,
/// FIRST EPSS 30-day exploitation probability in (0.0, 1.0)
pub epss_probability: f64,
/// Compensating control attenuation factor delta in [0.0, 0.99]
pub mitigation_delta: f64,
/// Normalized attacker computational/time cost C(e) >= 0.0
pub attacker_cost: f64,
pub observation: ProbeObservation,
}
impl ExploitEdge {
/// Computes Bayesian transition probability p(e | Probe_obs) in [1e-6, 0.9999].
#[inline]
pub fn transition_probability(&self) -> f64 {
let cvss_norm = (self.cvss_v4_exploitability / 10.0).clamp(0.0, 1.0);
let epss_norm = self.epss_probability.clamp(1e-6, 0.9999);
let prior = (0.45 * cvss_norm) + (0.55 * epss_norm);
let delta = self.mitigation_delta.clamp(0.0, 0.99);
let posterior = prior * self.observation.alpha() * (1.0 - delta);
posterior.clamp(1e-6, 0.9999)
}
/// Non-negative edge weight w(e) = -ln(p(e)) + lambda * C(e) >= 0.
#[inline]
pub fn log_weight(&self, cost_lambda: f64) -> f64 {
let p = self.transition_probability();
(-p.ln()) + (cost_lambda.max(0.0) * self.attacker_cost.max(0.0))
}
}
#[derive(Clone, Debug)]
struct SearchState {
neg_log_cost: f64,
current_node: NodeId,
capabilities: CapabilityMask,
path_edges: Vec<&'static str>,
}
impl PartialEq for SearchState {
fn eq(&self, other: &Self) -> bool {
self.neg_log_cost.to_bits() == other.neg_log_cost.to_bits()
&& self.current_node == other.current_node
}
}
impl Eq for SearchState {}
impl PartialOrd for SearchState {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SearchState {
fn cmp(&self, other: &Self) -> Ordering {
// Min-heap ordering on non-negative log weight
other
.neg_log_cost
.partial_cmp(&self.neg_log_cost)
.unwrap_or(Ordering::Equal)
}
}
/// Result of maximum-likelihood exploit path resolution.
#[derive(Clone, Debug)]
pub struct CriticalExploitPath {
pub ordered_edge_ids: Vec<&'static str>,
pub total_log_weight: f64,
pub cumulative_probability: f64,
pub acquired_capabilities: CapabilityMask,
pub recommended_cut_edge: &'static str,
}
pub struct AttackSurfaceGraph {
adjacency: BTreeMap<NodeId, Vec<ExploitEdge>>,
cost_lambda: f64,
}
impl AttackSurfaceGraph {
pub fn new(cost_lambda: f64) -> Self {
Self {
adjacency: BTreeMap::new(),
cost_lambda,
}
}
pub fn add_edge(&mut self, edge: ExploitEdge) {
self.adjacency.entry(edge.from).or_default().push(edge);
}
/// Solves for the highest-probability multi-hop exploit path from `source` to `target`
/// while respecting capability preconditions and avoiding cycles.
pub fn find_critical_path(
&self,
source: NodeId,
target: NodeId,
initial_capabilities: CapabilityMask,
excluded_edges: &BTreeSet<&'static str>,
) -> Option<CriticalExploitPath> {
let mut dist: BTreeMap<NodeId, f64> = BTreeMap::new();
let mut heap = BinaryHeap::new();
dist.insert(source, 0.0);
heap.push(SearchState {
neg_log_cost: 0.0,
current_node: source,
capabilities: initial_capabilities,
path_edges: Vec::new(),
});
while let Some(state) = heap.pop() {
if state.current_node == target {
let cumulative_probability = (-state.neg_log_cost).exp().clamp(0.0, 1.0);
let recommended_cut_edge = state.path_edges.first().copied().unwrap_or("NONE");
return Some(CriticalExploitPath {
ordered_edge_ids: state.path_edges,
total_log_weight: state.neg_log_cost,
cumulative_probability,
acquired_capabilities: state.capabilities,
recommended_cut_edge,
});
}
if let Some(&best) = dist.get(&state.current_node) {
if state.neg_log_cost > best + 1e-12 {
continue;
}
}
if let Some(edges) = self.adjacency.get(&state.current_node) {
for edge in edges {
if excluded_edges.contains(edge.edge_id) {
continue;
}
if !state.capabilities.contains(edge.precondition) {
continue;
}
let next_cost = state.neg_log_cost + edge.log_weight(self.cost_lambda);
let is_better = match dist.get(&edge.to) {
Some(&prev) => next_cost < prev - 1e-12,
None => true,
};
if is_better {
dist.insert(edge.to, next_cost);
let mut next_path = state.path_edges.clone();
next_path.push(edge.edge_id);
heap.push(SearchState {
neg_log_cost: next_cost,
current_node: edge.to,
capabilities: state.capabilities.union(edge.postcondition),
path_edges: next_path,
});
}
}
}
}
None
}
}