SSLproxyで透過プロキシを構成し、Suricataにより通信を検査・遮断する

はじめに

Suricataは無償で利用できるIDS/IPSですが、TLSなどの暗号化通信を検査できません。ペイロード部分を検査するには通信を復号する必要があります。また、通信を復号するとき、インバウンド通信とアウトバウンド通信では複雑さが異なります。インバウンド通信を検査する場合はサーバ自身が秘密鍵を持ち、復号機能を備えていることが多いため比較的容易です。一方、アウトバウンド通信を検査する場合は秘密鍵がないため手順が複雑になります。この記事ではSSLproxyというソフトウェアを用いてアウトバウンド通信の復号と検査、再暗号化を実現していきます。なお、検証目的で行ったもので実環境での運用を想定したものではありません。

以下は留意事項

・proxmoxのVM環境を用います。
・pfSenseやubuntuの初期セットアップは省略します。
・Suricataはinline modeを用います。
・Suricataのアラートの送信元・送信先IPアドレス情報がプロキシの内部IPアドレスになります。本来のIPアドレス情報を知るには復号時、通信に追加されるsslproxyヘッダの内容を確認する必要があります。

Proxmox上に展開しているpfSenseに、新たに赤色で示されるセグメントとホストを追加していきます。

セグメントを追加する

まずはプロキシなどを設置するMonitorセグメントを構成していきます。
proxmox上でブリッジを作成し、pfSense上でインターフェースとしてアサインします。

proxmoxでOVSブリッジを追加する

proxmox上でブリッジを作成します。この時OVS Bridgeを選択して下さい。Linux Bridgeは同セグメント内通信をpfSenseに転送せず、Suricataで検査できないためです。(インラインモードでの話。他のモードであればLinux Bridgeでも同セグメント内通信を監視できるようです。)OVSの通信経路の設定は後述する各ホストのセットアップが完了してから行います。
作成できない場合は、proxmoxのCLI上で以下コマンドを発行しインストールしてください。
apt update -y
apt install -y openvswitch-switch




作成したOVS BridgeをpfSenseのVMに追加していきます。


pfSenseのインターフェースにアサインする

次に、pfSenseのGUIで作成したOVS Bridgeをアサインしていきます。



アサインしたインターフェースの最低限の設定を行っていきます。手順については一般的かつ環境依存なので省略します。(IPアドレス、DHCPの有無、FWルールなど)
後ほど追加するLinuxホストのセットアップのため、インターネットに接続できるよう設定を行ってください。また、ホストのIPアドレスについては固定する前提で設定を行ってください。


ホストの追加

画像にある2つのホストsslproxyとechoを追加します。
ホストSSLproxyはその名の通り、SSLproxyをインストールするためのホストです。
ホストechoについては、SSLproxyからの通信を送り返す役割があります。後述しますがSSLproxyの仕様上、復号した通信を一度SSLproxyに戻す必要があるためです。

ホスト「sslproxy」の追加

proxmox上での追加手順やOSの初期セットアップ手順については省略します。稼働する十分なリソースを割り当てる他に、VMで作成すること、あとはネットワークに先ほど作成したOVS Bridgeを追加して下さい。今回はubuntu24.04を使用しています。
SSLproxyを導入していきます。記事作成時点での最新バージョンは0.9.7です。
https://github.com/sonertari/SSLproxy
以下は導入コマンド例です。最後のコマンドでバージョン情報が表示されれば成功しています。
sudo apt update
sudo apt install -y build-essential git libevent-dev libssl-dev libnet1-dev libpcap-dev libnetfilter-queue-dev zlib1g-dev sqlite3 libsqlite3-dev
git clone https://github.com/sonertari/SSLproxy.git
cd SSLproxy
make
sudo make install
sslproxy -V

プロキシ用の証明書と秘密鍵を発行します。以下はコマンド例です。ca.keyとca.certが生成されます。今回構成するのは透過プロキシなのでca.certを予めクライアント端末にインポートしておく必要があります。
cd /usr/local/etc/sslproxy/
sudo openssl genrsa -out ca.key 2048
sudo openssl req -new -x509 -days 36500 -key ca.key -out ca.crt -subj "/C=JP/ST=Tokyo/L=Chiyoda/O=SSLproxy/CN=SSLproxy Root CA"

SSLproxy用のユーザを追加します。
sudo useradd -r -s /bin/false _sslproxy
SSLproxの設定ファイルを作成していきます。
sudo apt install nano -y
mkdir /usr/local/etc/sslproxy

iptablesの設定を行います。
以下は例は443番宛に来た通信を8443番宛にリダイレクトする設定です。
リダイレクト先の8443番ではSSLproxyが通信を待ち受けるよう設定する予定です。
sudo iptables -t nat -A PREROUTING -i ens18 -p tcp --dport 443 -j REDIRECT --to-port 8443
上記の設定はホスト再起動後に揮発してしまうので、必要に応じて設定を永続化します。
sudo apt update
sudo apt install iptables-persistent
sudo iptables-save | sudo tee /etc/iptables/rules.v4 > /dev/null

設定ファイルを作成します。
設定ファイルの中身については、以下URLのUTMFWというプロジェクトにあるSSLproxyののコンフィグファイルを流用しました。
変更した項目のみ以下に記載します。今回はhttpsのみ復号の検査の対象としています。以下の例では、ポート番号8443番で通信を待ち受け、通信を復号した後は192.168.100.3のポート10080番に通信を転送する設定です。
sudo nano /usr/local/etc/sslproxy/sslproxy.conf
# One line proxy specifications
# type listenaddr+port up:utmport [ua:utmaddr ra:returnaddr]
#ProxySpec https 127.0.0.1 8443 up:8080 [ua:127.0.0.1 ra:127.0.0.1]
ProxySpec https 0.0.0.0 8443 up:10080 ua:192.168.100.3 ra:192.168.100.2
※192.168.100.2はSSLproxy 192.168.100.3はechoのIPアドレスです。
これでSSLporxyの設定は完了です。

ホスト「echo」の追加

次にホストechoを構成していきます。基本構成はホストsslproxyと同じです。
先述した通りこのホストの目的はSSLproxyから転送されてきた通信をSSLproxyに返送することです。ホストechoは以下通信の流れのうち(3)に位置しています。(4)のSSLproxyでの再暗号化に移るため、通信を返送する必要があります。
(1)HTTPS通信発生 > (2)SSLproxyで復号 > (3)セキュリティ装置で検査 > (4)SLLproxyで再暗号化 > (5)本来の宛先に転送
返送先のポート番号は、(2)の時点で挿入されたsslproxyヘッダを読み取る必要があるため、専用のプログラムを組む必要があります。
詳細な仕様については以下URLを確認してください。

プログラムについてchatGPTに相談したところ、以下pythonスクリプトを提案してくれたので記載しておきます。適宜利用ください。
sudo apt update -y
sudo apt install -y python3 python3-pip python3-scapy
sudo nano /opt/sslproxy_handler.py

---------------------------------------------------------------------------------
# sslproxy_handler.py
import socket
import threading
import re

# SSLproxyのリスニングポート
LISTEN_PORTS = {
    "SSLPROXY_HTTP": 10080,
    "SSLPROXY_HTTPS": 10443,
}

# SSLproxyヘッダーの解析
HEADER_PATTERN = re.compile(rb"SSLproxy: \[([0-9.]+)\]:(\d+),\[([0-9.]+)\]:(\d+),\[([0-9.]+)\]:(\d+),[sp]")

def parse_sslproxy_header(data):
    match = HEADER_PATTERN.search(data)
    if match:
        dest_ip = match.group(1).decode()
        dest_port = int(match.group(2))
        return dest_ip, dest_port
    return None, None

def handle_client(client_socket, protocol_name):
    try:
        data = client_socket.recv(4096)
        if not data:
            client_socket.close()
            return

        print(f"[{protocol_name}] Received data: {data}")

        # SSLproxyのヘッダーを解析し、返送先のアドレスを取得
        dest_ip, dest_port = parse_sslproxy_header(data)
        if dest_ip and dest_port:
            print(f"[{protocol_name}] Forwarding traffic to {dest_ip}:{dest_port}")

            # SSLproxyにデータを送り返す
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
                server_socket.connect((dest_ip, dest_port))
                server_socket.sendall(data)

                while True:
                    response = server_socket.recv(4096)
                    if not response:
                        break
                    client_socket.sendall(response)

        else:
            print(f"[{protocol_name}] No valid SSLproxy header found. Dropping packet.")

    except Exception as e:
        print(f"[{protocol_name}] Error: {e}")
    finally:
        client_socket.close()

def start_server(protocol_name, listen_port):
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(("0.0.0.0", listen_port))
    server.listen(5)
    print(f"[{protocol_name}] Listening on port {listen_port}...")

    while True:
        client_socket, addr = server.accept()
        print(f"[{protocol_name}] Connection received from {addr}")
        client_handler = threading.Thread(target=handle_client, args=(client_socket, protocol_name))
        client_handler.start()

def main():
    threads = []
    for protocol, port in LISTEN_PORTS.items():
        thread = threading.Thread(target=start_server, args=(protocol, port))
        thread.start()
        threads.append(thread)

    for thread in threads:
        thread.join()

if __name__ == "__main__":
    main()
---------------------------------------------------------------------------------
これでホストechoの導入は完了です。

その他の通信経路の設定とか

クラアントから発生するHTTPS通信をSSLproxyに向ける設定

クライアントのHTTPS通信がSSLproxyに転送されるように設定していきます。
※以下画像でいうところのセグメント「VM_LAN」にクライアント端末がいると想定します。
色々方法はあるかと思いますが、以下は一例です。
pfSenseのWebUIの上のメニューから System > Routing > Gateways と辿り、以下画像例のようにゲートウェイを登録します。
次に、クライアント端末が発生させるHTTPS通信がSSLproxyに転送されるようにFWルールを追加します。(以下画像は例)
以上です。

OVS Bridgeの通信経路設定

最初のほうに作成したOVS Bridgeの通信経路の設定を行います。
現状はSSLproxyとechoが通信するとき、同じセグメント内に存在するため、以下図の赤色のフローを辿ります。この場合、復号した後にFWのインターフェース(Suricata)を経由しないため、せっかく復号したにも関わらずペイロードの検査ができません。青色の通信経路になるようにOVS Bridgeを設定します。

proxmoxのCLIにログインし、以下コマンドを発行します。
ovs-vsctl show
すると、以下のような結果が返ってくるので、pfSenseのインターフェース名を控えておきます。以下例ですと、proxmox上のVMマシンID 105 番の 3 番目のインターフェースがpfSenseのインターフェースなので、tap105i3を控えておきます。
Bridge vmbr_Mon
        Port vmbr_Mon
                Interface vmbr_Mon
                        type: internal
        Port tap108i0
                Interface tap108i0
        Port tap105i3
                Interface tap105i3
        Port tap107i0
                Interface tap107i0

proxmoxのCLIで、OVSの設定を行っていきます。以下の内容の設定を行います。
・tap105i3(pfSense)から出てくる通信は通常通り転送する
ovs-ofctl add-flow vmbr_Mon "priority=300,in_port=tap105i3,actions=NORMAL"
・同セグメント内通信はtap105i3(pfSense)を経由するよう強制する
ovs-ofctl add-flow vmbr_Mon "priority=200,ip,nw_src=192.168.100.0/24,nw_dst=192.168.100.0/24,actions=output:tap105i3"
・該当ルールがなかったときの保険用のデフォルトルール。
ovs-ofctl add-flow vmbr_Mon "priority=0,actions=NORMAL"

設定を間違ったり挙動がおかしくなった時は以下コマンドを適宜使用します。
# 既存のフローを確認
ovs-ofctl dump-flows vmbr_Mon
# 既存のフローを削除
ovs-ofctl del-flows vmbr_Mon
# 最低限必要な転送ルール
ovs-ofctl add-flow vmbr_Mon "priority=0,actions=NORMAL"

これでOVS Bridgeの設定は完了です。

クラアント端末に証明書を導入

ここぐらいのタイミングでSSLproxyを構成するときに作成した ca.crt をクライアント端末にインポートしておきます。手順については解説がたくさんあるので省略します。インポートしなくてもブラウザの警告を無視すればいいだけかもしれませんが、上手く行かないときの切り分けが簡単になるので入れておいたほうがいいかも。
Firefoxとchrome系は参照している証明書の場所が別でインポートの仕方が違うようなので注意。

Suricataの設定

Suricataの初期セットアップについてはpfSenseのPackage Managerから簡単に導入できるため省略します。
設定内容についても細かいところは省略しますが、監視対象インターフェースは先程作成したMonitorとし、インラインモードで動作させることを想定しています。また、SuricataのアラートのIPアドレス情報がホストsslproxyとechoになってしまう性質上、通信内にあるsslproxyヘッダの情報を確認する必要があるので、eve-jsonでペイロードなどの記録を有効にしたほうがよいと思います。

検知テスト用カスタムルールの作成

検知・ブロックできるかどうか確認するための検知テスト用のカスタムルールを作成していきます。以下画像例の通り、カスタムルールの編集画面に移ります。

カスタムルールを記述して、SAVEを押下します。

以下に検知テスト用のカスタムルールの例を記載します。
例えば、クライアントのブラウザでhttps://example.com/alert.testにアクセスすると、一番上のカスタムシグネチャがトリガーされます。drop.testやreject.testは正常に動作すれば通信がブロックされます。
alert http any any -> any any (msg:"alert http test"; content:"/alert.test"; sid:1000000; rev:1;)
drop http any any -> any any (msg:"drop http test"; content:"/drop.test"; sid:1000002; rev:1;)
reject http any any -> any any (msg:"reject http test"; content:"/reject.test"; sid:1000003; rev:1;)

ようやく

これで全ての準備ができたと思うので、各ホストを動かして、最後に通信を検知できるか確認します。

sslproxyを起動します。
コマンドは以下です。(デバッグモードで起動しています)
sudo sslproxy -D -f /usr/local/etc/sslproxy/sslproxy.conf
echoでスクリプトを起動します。
sudo python3 /opt/sslproxy_handler.py
クライアント端末でカスタムルールを検知するような通信を発生させます。
curl -k https://example.com/reject.test
アラートが出ているか確認します。
pfSense > Services > Suricata > Alerts
赤色で記載されているアラートは実際に通信を遮断できています。
以下のeve.jsonログを見れば本来の送信元と送信先IPアドレスを確認できます。(他に方法はあるかもしれません)
pfSense備え付けのアラート画面から確認するのは辛いので、別途SIEMなどを用意してそちらにログ転送したほうが良いかと思われます。

おわり











コメント