当社のAIエンジンNeutronが、UCバークレーのCyberGymベンチマークで96.75%のスコアを記録しました。 詳細を見る

エンジニアリング

エンジニアリング

スキャンのライフサイクル:OXOのオープンソース脆弱性スキャナーの仕組み

本記事では、OXOの内部の仕組みについて解説します。

OXOがオープンソース化されてしばらく経ちました。本記事では、その内部の仕組みを詳しく見ていきます。スキャンのライフサイクルに加え、エージェントとは何か、そしてスキャンの実行においてどのような役割を果たすのかを取り上げます。

スキャン作成の画像
スキャンの作成

スキャンの作成

本記事では、Nmap、Tsunami、OpenVas、Nucleiの各エージェントを使ってIP 8.8.8.8に対するスキャンを実行し、完了するまでのライフサイクルを見ていきます。初めてスキャンを作成する場合は、こちらの記事https://oxo.ostorlab.co/tutorialsを参照してください。このスキャンを実行するには、次のコマンドを使用します。

oxo scan run --install --agent agent/ostorlab/nmap --agent agent/ostorlab/tsunami --agent agent/ostorlab/nuclei --agent agent/ostorlab/openvas ip 8.8.8.8

上記のスキャンコマンドの詳しい解説については、こちらのチュートリアルをご覧ください。

まずは、スキャンが最初から最後まで実際にどのように実行されるかに入る前に、いくつかの重要な概念を見ておきましょう。

エージェントとエージェントグループ

OXOは実際のスキャンをエージェントに任せており、これらのエージェントがOXOの検出能力の中核を担っています。エージェントは、エージェント定義のYAMLファイルを持ちます。このファイルは、リポジトリ内の任意の場所に置けるDockerファイルを指しています。このファイルでは、Dockerファイルへのパス、受け付ける引数、入力セレクターと出力セレクターなど、エージェント固有の一連の属性を定義します。セレクターは、エージェントが期待する、あるいは関心を持つメッセージの種類を表します。エージェントは入力セレクターを通じて届くメッセージを受信し、出力セレクターを通じてメッセージを送り返すことができます。

エージェントは、セキュリティスキャン、ファイル解析、サブドメインの列挙など、さまざまなタスクを実行でき、ほかのエージェントと通信することもできます。

エージェント定義ファイルのスキーマは、agent_schema.jsonファイルで規定されており、定義ファイルの完全な説明が含まれています。すべての定義ファイルで必須となる属性は、name、kind(AgentまたはAgentGroup)、in_selectors、out_selectorsです。

Dockerを使ってアプリケーションをパッケージ化する際の課題の一つは、生成されるイメージのサイズです。以前は、開発用と本番用の2つのDockerファイルが必要でした。この問題に対処するため、OXOはエージェントのイメージを最適化するビルダーパターンを提案しています。これにより、生成されるイメージが小さくなり、2つのDockerファイルを保守する必要もなくなります。

エージェントのメインファイルは、この例ではnmap_agent.pyです。これがエントリポイントになります。

Nmap AgentのDockerfileの例

FROM python:3.8-alpine as base
FROM base as builder
RUN apk add build-base
RUN mkdir /install
WORKDIR /install
COPY requirement.txt /requirement.txt
RUN pip install --prefix=/install -r /requirement.txt
FROM base
RUN apk update && apk add nmap && apk add nmap-scripts
COPY --from=builder /install /usr/local
RUN mkdir -p /app/agent
ENV PYTHONPATH=/app
COPY agent /app/agent
COPY ostorlab.yaml /app/agent/ostorlab.yaml
WORKDIR /app
CMD ["python3", "/app/agent/nmap_agent.py"]

エージェントの構造

すべてのエージェントは、メッセージの自動シリアライズ、メッセージの送受信、セレクターの登録、エージェントのヘルスチェックなど、さまざまな機能を利用するためにAgentクラスを継承する必要があります。なお、エージェントはメッセージプロセッサーにもスタンドアロンにもなり得ます。スタンドアロンのエージェントは、長時間実行型と1回実行型のいずれかです。

メッセージを処理するエージェントは、processメソッドを実装し、受信するセレクターの一覧を宣言する必要があります。セレクターはエージェント定義のYAMLファイルで定義します。

スタンドアロンのエージェントには受信するセレクターがなく、ロジックをstartメソッドに実装する必要があります。ユースケースとしては、アセットインジェクターや設定インジェクターといった入力インジェクターがあります。プロキシのような長時間実行されるプロセスも含まれます。

エージェントは、オプションでカスタムのヘルスチェックメソッドis_healthyを定義できます。これにより、エージェントが稼働可能であることをランタイムに伝え、エージェントのセットアップが完了する前にスキャン対象のアセットが投入されないようにします。

データを永続化したいエージェントは、データを永続化するロジックを実装したAgentPersistMixinを継承する必要があります。このミックスインにより、エージェントのレプリカのグループや、信頼性の高いストレージを必要とする単一のエージェントのための分散ストレージが利用可能になります。

エージェントは、オプションでAgentReportVulnMixinを継承することもできます。これは、ナレッジベースからエントリを取得し、脆弱性メッセージを発行するロジックを実装しています。

エージェントの実装方法の詳しい説明については、こちらのチュートリアルを参照してください。

Nmap Agentのエージェント定義ファイルの例です。このファイルで定義される属性は、エージェントごとに固有のものです。

kind: Agent
name: nmap
version: 0.4.0
image: images/logo.png
description: |
Agent responsible for network discovery and security auditing using Nmap.
license: Apache-2.0
source: https://github.com/Ostorlab/agent_nmap
in_selectors:
 - v3.asset.ip.v4
 - v3.asset.ip.v6
 - v3.asset.domain_name
 - v3.asset.link
out_selectors:
 - v3.asset.ip.v4.port.service
 - v3.asset.ip.v6.port.service
 - v3.asset.domain_name.service-
 - v3.report.vulnerability
docker_file_path : Dockerfile
docker_build_root : .
args:
 - name: "ports"
   type: "string"
   description: "List of ports to scan."
   value: "0-65535"
 - name: "timing_template"
   type: "string"
   description: "Template of timing settings (T0, T1, ... T5)."
   value: "T4"
  1. kind:単一のエージェントの場合はAgent、複数のエージェントをサポートしたい場合はAgentGroupを指定できます。
  2. name:エージェントの名前です。小文字で、英数字のみで構成する必要があります。
  3. version:セマンティックバージョニングの規則に従う必要があります。
  4. image:エージェントの画像またはロゴへのパスです。
  5. description:エージェントの説明です。
  6. source:エージェントのソースリポジトリです。
  7. in_selectors:エージェントが受信メッセージを待ち受けるセレクターです。
  8. out_selectors:エージェントがメッセージを送り返すセレクターです。
  9. args:エージェントに渡したい引数です。この例ではportsで、スキャンするポートの一覧です。
  10. docker_file_path:エージェントのDockerイメージを組み立てるDockerfileへのパスです。
  11. docker_build_root:自動リリースビルド用のDockerビルドディレクトリです。

サポートされているエージェントのミックスイン

  1. AgentHealthcheckMixin:エージェントのヘルスチェック用ミックスインは、エージェントが稼働可能であることを保証します。200ステータスとレスポンスとしてOKを返す必要があるWebサービス(デフォルトは0.0.0.0:5000)を実行します。このミックスインでは、すべてがTrueを返す必要がある複数のヘルスチェック用コールバックを追加できます。ステータスはエンドポイントhttp://0.0.0.0:5000/statusで確認され、すべて正常であればOKを、いずれかのチェックが失敗した場合はNOKを返します。

    チェックは別の形で失敗することもあります。たとえば、チェック用コールバックが例外をスローした場合、検出とデバッグを容易にするため、Webサービスはあえてその例外をキャッチしません。ヘルスチェックでは、NOKがないことではなく、OKがあることを確認する必要があります。

典型的な使い方:

status_agent = agent_healthcheck_mixin.AgentHealthcheckMixin()
status_agent.add_healthcheck(self._is_healthy)
status_agent.start()
  1. AgentReportVulnMixin:このミックスインは、ナレッジベースからエントリを取得し、脆弱性メッセージを発行するロジックを実装しています。

  2. AgentPersistMixin:このミックスインは、データを永続化するロジックを実装しています。エージェントのレプリカのグループや、信頼性の高いストレージを必要とする単一のエージェントのための分散ストレージを利用可能にします。典型的なユースケースは、ほかの複数のエージェントが重複または類似するメッセージを生成している場合でも、あるメッセージの値が一度だけ処理されるようにすることです。

典型的な使い方:

status_agent = agent_persist_mixin.AgentPersistMixin(agent_settings)
status_agent.set_add('crawler_agent_asset_dna', dna)
is_new =  not status_agent.set_is_member()

メッセージ

すべてのエージェントはメッセージを介して通信します。メッセージには、IPアドレス、ファイルのBLOB、サブドメインの一覧といった情報を含めることができます。メッセージはセレクターに基づいて自動的にシリアライズされます。これにより、メッセージの送信が容易になります。フォーマットは次のルールに従います。

  • メッセージはprotobuf形式でシリアライズされます。Protobufは、構造化データをシリアライズするために使われるデータ交換フォーマットです。Protobufが使われているのは、中間的な(コストのかかる)エンコードを必要とせずにバイト型をサポートする、コンパクトなバイナリのシリアライゼーション形式だからです。

  • メッセージは階層構造に従い、子セレクターのフィールドは親メッセージのすべてのフィールドを含む必要があります。これは受信メッセージと送信メッセージの両方に当てはまります。たとえば、セレクター/foo/borのメッセージが次の定義を持つとします。

{
   color: str
   size: int
}

この場合、セレクター/foo/bar/bazのメッセージは、たとえば次のようにcolorとsizeのフィールドを持つ必要があります。

{
   color: str
   size: int
   weight: int
}

特定のメッセージで期待されるフィールド、その型、そしてそれらのフィールドが必須かどうかは、protobufを使って指定します。プロトコルバッファの特定の用途に対応するスキーマは、データ型をフィールド名に関連付け、各フィールドを識別するために整数を使用します。

Nmapの出力セレクターv3.asset.ip.v4.portのprotobufファイルの例

syntax = "proto2";

package v3.asset.ip.v4.port;

message Message {
 optional string host = 1;
 optional string mask = 2;
 optional int32 version = 3 [default = 4];
 optional uint32 port = 5;
 optional string protocol = 6;
 optional string state = 7;
}

すべてのprotoはostorlab > agent > message > proto > v3で定義されています。v3内の各フォルダーは、対応するセレクターにちなんで名付けられています。たとえば、assetフォルダーには、v3.asset*で始まるセレクターのprotoがすべて含まれています。

Protoの画像
Proto

Messageデータクラスの構造

メッセージデータクラスは、送信元または送信先のセレクターに基づいて、データのシリアライズとデシリアライズを担当します。このデータクラスには、from_dataとfrom_rawの2つのメソッドがあります。これらのメソッドを使うと、protobufメッセージや、あまり使いやすいとは言えないprotobufのAPIを直接扱わずに済みます。

  • from_data:構造化データと送信先セレクター(メッセージのフォーマットを定義するために使われるターゲットセレクター)からメッセージを生成し、生データとデータの両方の定義を持つメッセージを返します。このメソッドに渡すセレクターでは、使用するバージョンを指定する必要があります。

  • from_raw:生データ(Pythonのデータ構造にデシリアライズされる生データ)と送信元セレクターからメッセージを生成し、生データとデータの両方の定義を持つメッセージを返します。セレクターでは、使用するバージョンを指定する必要があります。

スケーラビリティ

エージェントは、複数のレプリカを動的に実行することをサポートしています。エージェントのSDKには、分散ロック、分散ストレージ、メッセージのロードバランシングなど、複数インスタンスの機能を実装するためのツールが備わっています。

エージェントは、Dockerのサービス名を調べて次のコマンドを実行することで、動的にスケールできます。 docker service scale <service_name>=<replicas_count>

また、スキャンの実行時に、エージェントグループ定義のYAMLファイルでレプリカの初期数を直接設定することもできます。

ローカルランタイム

OXOは複数のランタイムをサポートしており、ローカルランタイムが同梱されています。ローカルランタイムは、使いやすく軽量であることから、Docker Swarm上でローカルに動作します。複数のランタイムをサポートしているため、Kubernetes上でスキャンエージェントを実行するといった、ほかのランタイムを追加することも可能です。ランタイムは、素のRabbitMQサービスと素のRedisサービスを起動し、AgentRunDefinitionに列挙されたすべてのエージェントを起動して、それらが正常であることを確認したうえで、ターゲットのアセットを投入します。

1. データベースの作成

Docker Swarmサービスを起動する前に、ランタイムはDockerがインストールされていること、Dockerが動作していること、そしてユーザーにDockerを実行する権限があることを確認するためのチェックを行います。

すべてのチェックに合格すると、ランタイムはSQLiteデータベースをインスタンス化し、必要なテーブルを作成したうえで、データベース内にスキャンのインスタンスを作成します。データベースは、スキャン結果をローカルに保存するために使われます。データベースは.ostorlabプライベートディレクトリにあり、db.sqliteファイルとして保存されます。

2. サービスの作成

ランタイムは、すべてのサービスとエージェントが通信できるDocker Swarmネットワークを作成し、メッセージバスとして機能するローカルのRabbitMQサービスと、スキャンの一時データの保存と分散ロックのためのローカルのRedisサービスを起動します。先に進む前に、ランタイムはRabbitMQとRedisのサービスが実行中で正常であるかを確認します。いずれかのサービスが正常でない場合は、UnhealthyService例外が発生します。

3. プリエージェントの起動

プリエージェントとは、ほかのエージェントより先に存在している必要があるエージェントです。これは、エージェントの起動時からデータの送信を開始できる、すべての永続化エージェントに当てはまります。

サービスが正常であれば、ランタイムは3つのフェーズでエージェントの実行を開始します。これは、スキャンの進捗を追跡するトラッカーのように、すべてのエージェントが起動する前に実行されなければならないエージェントがあるためです。また、アセット投入エージェントのように、すべてのエージェントが起動して動作し始めた後に実行されなければならないエージェントもあります。次のステップでは、プリエージェントが正常であることを確認し、正常でなければAgentNotHealthy例外を発生させます。

4. エージェントの起動

プリエージェントが正常であることを確認したら、エージェントグループ定義に列挙されたエージェントを起動します。エージェントグループ定義とは、エージェントとその設定の集合です。エージェントグループ定義内の各エージェントに対して、一連のステップが実行されます。まず、オプションで追加のDocker設定とマウントを渡したうえで、エージェントがインストールされているか(イメージがDockerコンテナ内に存在するか)を確認します。次に、エージェントの設定とエージェントのデフォルト定義の統合を担うランタイムエージェントをインスタンス化し、エージェントサービスを作成します。スキャンの作成時に--followフラグが渡されていた場合、ランタイムはエージェントサービスのログのストリーミングを開始します。いずれかのエージェントが正常でない場合は、例外を発生させます。

5. アセットの投入

アセットを投入するには、まずagent/ostorlab/inject_assetエージェントのエージェント設定を作成し、それをDockerクライアント、MQサービス、RedisサービスとともにAgentRuntimeクラスに渡します。AgentRuntimeクラスは、エージェントの設定とエージェントのデフォルト設定の統合を担います。このクラスはランタイムエージェントを返し、それに対してcreate_agent_serviceメソッドを呼び出すと、適切な設定とポリシーを持つDockerエージェントサービスが作成されます。その後、スキャンの進捗状況がIN_PROGRESSに設定されます。

6. ポストエージェントの起動

さて、スキャンが正常に作成されたと確信できるまで、あと一歩です。ポストエージェントを起動する必要があります。ポストエージェントとは、ほかのエージェントの後に存在している必要があるエージェントです。これは、ほかのエージェントを監視し、スキャンのライフサイクルを管理する必要があるトラッカーエージェントに当てはまります。続いてポストエージェントが正常であることを確認し、正常であれば、スキャンは正常に作成されたことになります。

上記のステップの途中でスローされた例外はいずれも、スキャンを停止させます。

7. メッセージの処理

スキャンの実行を開始するためにrunメソッドが呼び出されると、ヘルスチェックが開始され、新しいメッセージの待ち受けが始まります。各エージェントはキューへのパブリッシュとキューからのコンシュームを行い、そのエージェントが指定したセレクターのメッセージのみを処理します。同様に、エージェントがメッセージの処理を終えると、指定されたセレクターで待ち受けているすべてのエージェントにメッセージを送信します。エージェントがどのようにキューへパブリッシュし、キューからコンシュームするのかを見てみましょう。

  • まだ作成していない場合は、次のコマンドでスキャンを作成してください。
oxo scan run --install --agent agent/ostorlab/nmap --agent agent/ostorlab/tsunami --agent agent/ostorlab/nuclei --agent agent/ostorlab/openvas ip 8.8.8.8
  • 次のコマンドで、稼働中のすべてのコンテナを一覧表示します。
docker ps
  • 次のコマンドで、RabbitMQコンテナに接続します。
docker exec -it <container_id> bash

container_idは、RabbitMQコンテナのIDです。

  • キューを一覧表示するには、次のコマンドを使います。
rabbitmqadmin list queues name node messages messages_ready messages_unacknowledged message_stats.publish_details.rate message_stats.deliver_get_details.rate message_stats.publish

上記のコマンドの出力例

RabbitMQのキューの画像
RabbitMQのキュー

脆弱性の報告

エージェントが脆弱性を報告したい場合は、OstorlabのKnowledge Baseのエントリをreport_vulnerabilityメソッドに渡すだけで簡単に報告できます。このメソッドはナレッジベースからエントリの詳細を取得し、脆弱性メッセージを発行します。

組み込みのナレッジベースの使用は必須ではなく、APIは任意の脆弱性エントリの永続化をサポートしています。

Nmapが脆弱性を報告する例:

scan_result_technical_detail = process_scans.get_technical_details(scan_results)
       if normal_results is not None:
           technical_detail = f'{scan_result_technical_detail}\n```xml\n{normal_results}\n```'
           self.report_vulnerability(entry=kb.KB.NETWORK_PORT_SCAN,
                                     technical_detail=technical_detail,
                                   risk_rating=vuln_mixin.RiskRating.INFO)

報告された脆弱性には、次のコマンドでアクセスできます。

oxo vulnz describe  -s [scan-id]

本記事では、OXOが舞台裏でどのように動作しているかを深く掘り下げました。プラットフォームは継続的に進化しているため、ここで説明した概念も、より多くのユースケースや将来のニーズに対応するために変わっていく可能性があります。