小規模ハードニング競技環境を構築してみた

はじめに

NTTビジネスソリューションズの衣川です。

この記事は、私が自作した小規模なハードニング競技環境の構成と設計思想を解説する記事です。

ハードニング競技とは、サーバの脆弱性を減らし、模擬的なサイバー攻撃からサーバを守る競技です。

作成したハードニング競技の概要

本記事で紹介するハードニング競技の概要は以下になります。

項目 内容
守る対象 WordPress(バージョン4.7.0)がインストールされたWebサーバ1つ
演習時間 30分/回
攻撃の種類 全6つ
攻撃方法 攻撃シナリオに沿って作成されたスクリプトを使い自動で実施
評価方法 ハッキングされていないWebページ(WordPressの固定ページと投稿)のアクセス数の累積

今回自作したハードニング競技環境では基本的なセキュリティ対策が施されていない基本的な脆弱性を扱っており、短時間でサイバー攻撃を体験できる構成にしました。

対象読者

本記事が想定する対象読者は以下を想定しています。

  • ハードニング競技に興味のある方
  • ハードニング競技環境を作成するにあたり、参考情報を探している方

本記事では詳細な構築手順や攻撃スクリプトの解説は扱っていませんので、ご了承ください。

背景

取り組みのきっかけは、Hardening Project主催のイベントで実施されたMicro Hardeningに参加したことです。 参加者としてハードニング競技をしつつも、裏側の仕組みに興味がわいてきて自分でも約30分で終わるようなハードニング競技環境を作成したいと思い、取り組み始めました。

注意事項

ここに記載されている情報は教育目的および検証を目的としたものです。 実運用環境や第三者のシステムに対して無断で使用することは、法律に違反する可能性があります。

  • 本記事の内容を使用する際は、必ず自分が管理・所有する環境でのみ実行してください。
  • 脆弱性のある環境を構築するため、グローバルIPアドレスを付与したサーバで構築しないでください。
  • 本記事の内容の使用によって生じたいかなる損害・不利益・法的責任についても、執筆者は一切の責任を負いません。
  • 本記事の内容の悪用を固く禁じます。
  • この注意書きをもって、執筆者は十分な注意喚起を行ったことを示します。

目次

1. ハードニング競技環境の全体構成

ハードニング競技で利用するサーバはすべてAWSの東京リージョンに構築しており、主なサーバは以下です。

サーバ種別 役割・説明
Webサーバ 競技参加者が保守するサーバで、WordPressが稼働している
評価サーバ Webサーバにスクレイピングして正常稼働しているか確認する。また、稼働状況を点数にして表示する
攻撃サーバ Webサーバに攻撃をするサーバ
踏み台サーバ 上記3つのサーバにアクセスする時に経由するサーバ

サーバのOSはすべてUbuntu Server 24.04 LTSです。

ハードニング競技の作成に直接関わらない以下の情報は付録にまとめています。

  • ネットワーク
  • サーバ 
    • インスタンスの設定値
    • 新規作成したユーザー
    • 各サーバの共通設定

2. Webサーバの詳細設定

攻撃シナリオで悪用する設定のみを抜粋して記載します。

2-1. ユーザーの権限追加

以下を実行して、www-data ALL=(ALL) NOPASSWD: ALLを追記します。

$ sudo nano /etc/sudoers

後述する攻撃でHTTPリクエストでサーバの設定変更をするために、www-dataはパスワード無しでsudoを実行できるようにしています。

2-2. /var/www/htmlの権限変更

/var/www/htmlの権限を777に設定し、誰でも読み込み・書き込み・実行 ができるようにします。

インターネットに公開されているディレクトリに書き込みができてしまうことは大変危険で、攻撃シナリオでもOSコマンドインジェクションを実行するスクリプトを/var/www/htmlに配置します。

2-3. ソフトウェアの設定変更

主に攻撃で利用する以下3つのソフトウェアをインストールしました。

  • WordPress(バージョン4.7.0) 
  • vsftpd
  • zip

このうち、WordPressとvsftpdに追加した設定について解説します。

2-3-1. WordPressの設定値

項目 設定値
ドメイン名 wphardening1.com
接続ポート 8081
REST API 有効化済み

REST APIを有効にしたのは、バージョン4.7.0のWordPressのREST APIの脆弱性をついてWebページの改ざんを実施するためです。

また、念のため /var/www/htmlの所有者がwww-dataになっていることを確認します。

2-3-2. vsftpdの設定値

vsftpdをインストールした後に、以下3つの設定を追加します

設定項目 設定値 説明
listen YES FTPアクセスを受け入れる(IPv4)
write_enable YES 書き込み(ファイルアップロードなど)を許可
allow_writeable_chroot NO chroot を強制しない設定にし、ホームディレクトリ外へのアクセスを許可

特に最後のホームディレクトリ外へのアクセスを許可を悪用して、攻撃を仕掛けます。

3. 攻撃シナリオと対応する設定値

攻撃サーバからWebサーバに実施する攻撃を全部で6つ用意しました。

指定の時間になれば、以下の表の上から順番に攻撃を仕掛けます。

No 攻撃手法 内容
3-1 不正なファイルの配置 OSコマンドインジェクションを行うスクリプトをWebサーバにアップロード
3-2 不正ユーザーの作成 バックドアとして利用するユーザーを作成
3-3 Webページの削除 WordPressの投稿を削除
3-4 Webサイトの機能停止 コマンドインジェクションまたは不正ユーザー経由で、サイトをパスワード付きzipにし閲覧不可に
3-5 窃取した情報をWebサイトに投稿 FTPで窃取したファイルの内容をWebページで公開
3-6 WordPressの脆弱性をついた改ざん WordPressのREST APIの脆弱性を悪用してWebページを改ざん

以降で、各攻撃について起点となった脆弱性と攻撃手法、そして対処方法を説明します。

3-1. 不正なファイルの配置

攻撃の起点となった脆弱性、攻撃手法、そして対処方法の概要を以下に記載します。

項目 概要
脆弱性 脆弱なパスワードと過剰な書き込み権限
攻撃手法 SSHブルートフォース→スクリプト配置
対処方法 ユーザーロック、パスワード強化、権限制限

以降で、詳細について解説します。

3-1-1. 脆弱性と攻撃手法

まずSSH接続にブルートフォース攻撃します。 成功すれば、そのアカウントを利用して、SCPでOSコマンドインジェクションを実行するためのスクリプトをWebサーバの/var/www/htmlに配置します。

以下の事前に作成済みのユーザーでブルートフォース攻撃が成功してしまいます。

ユーザー名 パスワード
test test

このユーザー名と脆弱なパスワードでは、SSH接続をブルートフォース攻撃で突破されてしまいます。

加えて、/var/www/htmlにどのユーザーでも書き込みができてしまうため、スクリプトの配置も成功します。

参考までに、スクリプトの内容は以下の通りで、クエリパラメータをそのままexec()で実行するものになっています。

<?php
$command= $_GET['cmd'];
exec($command);
?>

3-1-2. 対処方法

考えられる対処を3つ挙げます。

  • 不要なユーザーはロックする
  • ユーザーのパスワードを複雑にする
  • 権限を必要最小限にする

他にもパスワード認証でSSH接続できないようにすることも考えられますが、競技参加者はパスワード認証でのみWebサーバにアクセスできることにしているのでここでは検討しません。

3-1-2-1. 不要なユーザーはロックする

今回はWebサーバにアクセスするために別のユーザーが用意されていました。 そのため、このアカウントは不要でユーザーをロックしてしまえば、この攻撃は成功しません。

以下はユーザーロックのコマンド例です。

$ sudo passwd -l [ユーザー名]

3-1-2-2. ユーザーのパスワードを複雑にする

testユーザーのパスワードがtestで、脆弱なことを知っていれば、パスワードは長く複雑なものにするべきです。

以下はコマンド例です

$ sudo passwd [ユーザー名]

ただ、現実では勝手に変更してしまうことはリスクがあるので、利用者がいないことを確認してからになるかと思います。

3-1-2-3. 権限を必要最小限にする

/var/www/htmlの書き込み権限が緩く、どのユーザーでも書き込みができてしまったため、ファイルの配置が成功していました。 逆にディレクトリの書き込み権限を制限することで、この攻撃は防げます。

以下は/var/www/htmlの権限を755に設定し、所有者以外書き込めないようにするコマンド例です

$ sudo chmod 755 /var/www/html

3-2. 不正ユーザーの作成

攻撃の起点となった脆弱性、攻撃手法、そして対処方法の概要を以下に記載します。

項目 概要
脆弱性 www-dataに過剰なsudo権限
攻撃手法 スクリプト利用で不正ユーザー追加
対処方法 権限最小化、不審ユーザー確認

以降で、詳細について解説します。

3-2-1. 脆弱性と攻撃

No.1 でアップロードしたスクリプトを利用して不正なユーザーを作成します。

www-dataの権限が非常に強いことも攻撃の起点になっており、以下を実行しwww-data ALL=(ALL) NOPASSWD: ALLを追記することでパスワード無しでsudoを実行できるようにしていました。

$ sudo nano /etc/sudoers

すべてのコマンドを、パスワード無しで実行できるユーザーを現実には設定しないとは思いますが、必要最小限の権限が守れていないことで成功してしまう攻撃です。

3-2-2. 対処方法

以下2つが考えられます。

  • ユーザー権限を必要最小限にする
  • 不審なユーザーの有無を確認する

3-2-2-1. ユーザー権限を必要最小限にする

www-dataの権限が強すぎたことで攻撃が成功してしまいます。

以下で/etc/sudoersを編集し、www-dataの権限を変更することで、sudo権限で実行できるコマンドを絞ることや、パスワードを求めるように設定変更すれば、攻撃を防ぐことができます。

$ sudo nano /etc/sudoers

3-2-2-2. 不審なユーザーの有無を確認する

定期的にユーザー一覧を確認して、不審なユーザーが増えていないか確認することが重要です。

現実世界でも数か月に1回以上はアカウント・ユーザーの棚卸しすることで、攻撃に気が付けるかもしれません。

3-3. Webページの削除

攻撃の起点となった脆弱性、攻撃手法、そして対処方法の概要を以下に記載します。

項目 内容
脆弱性 REST APIが有効で不要機能露出
攻撃手法 REST APIから投稿ID取得→Webページ削除
対処方法 REST API無効化、バックアップ取得

以降で、詳細について解説します。

3-3-1. 脆弱性と攻撃

以下のREST APIから投稿IDを取得し、OSコマンドインジェクション用スクリプトを経由して削除コマンドを実行します。

http://wphardening1.com:8081/wp-json/wp/v2/posts

3-3-2. 対処方法

スクリプトが実行されてしまうことへの対処はNo.2でお伝えしたので、それ以外で以下2つ挙げます。

  • 不要な機能を無効にする
  • バックアップを取得する

3-3-2-1. 不要な機能を無効にする

可能な限り不要な機能(今回はREST API)は無効にするべきです。

しかし、30分の間でREST APIが有効になっていることに気が付くことは難しいため、次の対処の方が実施しやすいと思います。

3-3-2-2. バックアップを取得する

競技の開始タイミングで、/var/www/htmlのバックアップを取得しておくことで、削除された後でもバックアップから復元すれば元に戻せます。

基本的なことですが、バックアップの取得と復元は大切なことです。

以下は/var/www/html/tmp/にコピーするコマンド例です

$ sudo cp -a /var/www/html /tmp/

今回の競技環境にはなかったですが、バックアップ用サーバがあれば、そちらに格納した方が安全です。

3-4. Webサイトの機能停止

攻撃の起点となった脆弱性、攻撃手法、そして対処方法の概要を以下に記載します。

項目 概要
脆弱性 OSコマンドインジェクションや不正ユーザー接続
攻撃手法 /var/www/htmlをzip化しサイト停止
対処方法 No.1〜3の攻撃を防ぐ対処

以降で、詳細について解説します。

3-4-1. 脆弱性と攻撃

OSコマンドインジェクションを行うスクリプト、または不正ユーザーのSSH接続のどちらかを起点として、/var/www/htmlのディレクトリをパスワード付きzipにし、Webサイト全体が機能しないようにします。

(No.5, 6の攻撃も続くため、一定時間経過後に自動で復旧させます)

3-4-2. 対処方法

考えられる対処は以下3つですが、すべて前述のためこちらでの解説は省略します。

  • 不正なファイルを配置させない(No.1)
  • 不正なユーザーを作成させない(No.2)
  • バックアップを取得しておく(No.3)

3-4-3. 補足

これまでと同じ脆弱性を利用していますが、Webサイトを機能停止させることで、サイバー攻撃を受けた衝撃を競技参加者に体験してもらいます。

これまでに競技を体験した方は、ここで一番驚いていました。

3-5. 窃取した情報をWebサイトに投稿

攻撃の起点となった脆弱性、攻撃手法、そして対処方法の概要を以下に記載します。

項目 概要
脆弱性 FTP設定の不備でホーム外にアクセス可能
攻撃手法 /etc/passwd窃取→Webページで公開
対処方法 FTPポートの接続制限、FTPの設定変更、プロセス監視

以降で、詳細について解説します。

3-5-1. 脆弱性と攻撃

以下のFTPの設定を攻撃の起点とし、/etc/passwdを窃取します。

設定項目 設定値 説明
allow_writeable_chroot NO chroot を強制しない設定にし、ホームディレクトリ外へのアクセスを許可

そして、No.2で作成した不正なユーザーでSSH接続しWebページにファイルの中身を書き込んで公開します。

3-5-2. 対処方法

FTPに関する対処として以下3つを挙げます。

  • FTPポートの接続制限する
  • FTPの設定を変更する
  • プロセスを確認する

3-5-2-1. FTPポートの接続制限する

ファイアウォール機能で20番・21番ポートの接続を制限することで攻撃を防ぎます。

以下はufwを利用したコマンド例です。

$ sudo ufw deny 20
$ sudo ufw deny 21

ufwが有効になっていなければ、以下コマンドで有効にします。

$ sudo ufw enable

3-5-2-2. FTPの設定を変更する

FTPが使われている可能性がある場合はFTPの脆弱な設定を変更することでも対応できます。

しかし、FTPの設定値に詳しくなければ難しいと思われます。 (生成AIに聞くのも一案です)

3-5-2-3. プロセスを確認する

プロセスを確認して、FTPを使っていないのにプロセスが起動していれば、悪用を疑えます。

攻撃を防ぐことはできませんが、攻撃検知を早め迅速な対処につながるかもしれません。

以下はプロセスの中からftpを含む行を抽出するコマンド例です。

$ ps aux | grep ftp

3-6. WordPressの脆弱性をついた改ざん

攻撃の起点となった脆弱性、攻撃手法、そして対処方法の概要を以下に記載します。

項目 概要
脆弱性 WordPress 4.7.0 REST API脆弱性(CVE-2017-1001000)
攻撃手法 REST API悪用で認証なし改ざん
対処方法 WPアップグレード、REST API無効化、バックアップ取得

以降で、詳細について解説します。

3-6-1. 脆弱性と攻撃

Webサーバには、REST APIの脆弱性があるバージョン4.7.0のWordPressをインストールしています。

悪用する脆弱性はCVE-2017-1001000で、REST APIの機能が有効になっていれば、認証なしでコンテンツを改ざんできてしまう脆弱性です。 これを悪用し、Webページを改ざんします。

参考:CVE-2017-1001000

3-6-2. 対処方法

攻撃を防ぐには、以下3つが挙げられます。

  • WordPressのバージョンをアップグレードする
  • 不要な機能(REST API)を無効にする
  • バックアップを取得しておく

しかし、1つ目のアップグレードは今回の環境ではWebサーバからインターネットに通信することは想定していないため難しく、3つ目のバックアップ取得は前述で記載済みなので、詳細は割愛します。

30分間ではREST APIが有効になっていることに気が付きにくいですが、以下サイトでは無効化する手順も記載されていましたので、参考にしてみてください。

参考:WordPress4.7.0以降で「REST API」を無効にする方法が変わっていたので試しました

3-7. 攻撃シナリオのまとめ

今回用意した6つの攻撃で、起点になったことをまとめると以下3つになります。

  • 脆弱なパスワードのユーザー
  • 必要以上の権限付与(ディレクトリ、ユーザー)
  • 不要だが稼働しているサービス・機能の放置

30分の中で6つの攻撃が実施されると、準備をしていない方はほとんどの攻撃を受けてしまいます。

しかし、攻撃を受けることを経験するという観点では、準備と対応の時間が十分ではない方がよいと考えて、あえて短時間に6つの攻撃を盛り込むようにしました。

4. 評価サーバの作成

詳細は最後の付録に記載していますが、

トップページにアクセスし、同じドメインのURLがあれば、すべてに遷移していきスクレイピングし、正常なWebページ数をカウントしていきます。

そして、カウントを累積した値が最終的な得点となります。

5. おわりに

今回の記事では、私が作成した小規模なハードニング競技環境の概要を説明しました。

脆弱性のあるサーバを構築し、その攻撃方法も検討することは以下の2点で勉強になりました。

  • 攻撃を受けてしまった時の影響を具体的に体験できる
  • 脆弱な設定値を検討することで、設定値の意味を理解できる

脆弱性の再現と攻撃の自動化も難しく、いかに不自然でない範囲で攻撃を自動化できるような脆弱な設定を組み込むのか、という思考も必要でした。

また、攻撃スクリプトをテストしている時に、構築している環境が何度か破損してしまい、本当の意味でバックアップの重要性を体験できました。 ハードニング環境の構築を検討されている方は定期的にバックアップ(AWSならAMIなど)を取得することをおすすめいたします。

今後、今回扱わなかった代表的な脆弱性をもりこんだハードニング競技環境も機会があれば作成・執筆したいと考えています。

執筆者

衣川 琢磨(NTTビジネスソリューションズ株式会社 バリューデザイン部)

セキュリティ分野の中でも特にサイバーハイジーンの実現にむけて支援するマネージドサービスの開発と運用に携わっています。

免責事項

  • 本記事の内容は執筆時点(2025年11月)の情報に基づいており、サービスの仕様変更等により内容が変更される可能性があります。
  • 本記事で紹介する設定手順や使用方法は、特定の環境での動作確認に基づくものであり、すべての環境での動作を保証するものではありません。

商標

  • WordPress は WordPress Foundation の登録商標です。
  • AWS および Amazon Web Services は Amazon.com, Inc. の商標です。

付録

ハードニング環境を構築するにあたり必要ですが、ハードニング競技そのものにはあまり関わらない情報を記載します。

インフラの詳細設定

ネットワーク

種類 Name プライベートIPアドレス帯 設定・備考
VPC my-hardening-project 192.168.0.0/16
プライベートサブネット my-hardening-private-subnet 192.168.1.0/24 0.0.0.0あての通信がNATゲートウェイにいくルートテーブルを作成
パブリックサブネット my-hardening-public-subnet 192.168.2.0/24 0.0.0.0あての通信がインターネットゲートウェイにいくルートテーブルを作成
NATゲートウェイ my-hardening-nat-tmp 構築時に各サーバがインターネット接続できるように設置。構築後に削除

サーバ

Webサーバ、攻撃サーバ、評価サーバはすべて プライベートサブネット内に構築し、直接インターネットからアクセスできないようにします。これらのサーバへ接続する際は、必ず 踏み台サーバを経由させることで、脆弱な設定を持つWebサーバがインターネットに公開されることを防ぎます。

サーバ構築時には、私が所有する Windows 11のPC(以下「接続元PC」)から、パブリックサブネット上に配置した踏み台サーバへSSH接続しました。その後、SSHのポートフォワーディング機能を利用して、プライベートサブネット内の各サーバに接続しました。

インスタンスの設定値

種類 Name プライベートIPアドレス 通信の制限
踏み台サーバ my-hardening-jmp 192.168.2.1 SSHのインバウンド通信のみを許可
Webサーバ my-hardening-web1 192.168.1.101 VPC内からのSSH, FTP, HTTP接続と、8081ポートのアクセスを許可
攻撃サーバ my-hardening-atk1 192.168.1.11 プライベートサブネットからの全通信と、VPC内からのSSH接続を許可
評価サーバ my-hardening-evl 192.168.1.201 プライベートサブネットからの全通信と、VPC内からのSSH接続を許可

新規作成したユーザー

種類 ユーザー名 パスワード
踏み台サーバ jumper jmphardening
Webサーバ web-viewer webhardening
Webサーバ test test
攻撃サーバ
評価サーバ viewer viewerhardening

各サーバの共通設定

パスワードでSSH接続できるようにし、以下コマンドで/etc/hosts192.168.1.101 wphardening1.comを追記し、WebサーバのIPアドレスとドメインを対応づけ、各Webサーバのページにアクセスする際に名前解決できるようにします。

$ sudo nano /etc/hosts

接続元PCの設定

Windows 11の接続元PCで、管理者権限で起動させたメモ帳から、C:\Windows\System32\drivers\etc\hostsを開き、以下を追記します。

127.0.0.1 wphardening1.com

接続元PCでSSHポートフォワードを設定すると、127.0.0.1の特定ポートがプライベートサブネット内のWebサーバへ転送され、ポートフォワード経由でプライベートサーバのWebページが表示されます。

利用したWordPressのプラグイン

脆弱性の悪用のためではなく、利便性向上のためプラグインは以下2つを導入しておきます。

  • WP Sitemap Page(サイトマップ作成用)
  • Migration, Backup, Staging – WPvivid Backup & Migration(バックアップ取得用)

参考:WP Sitemap Page – WordPress プラグイン | WordPress.org 日本語

参考:Migration, Backup, Staging – WPvivid Backup & Migration – WordPress プラグイン | WordPress.org 日本語

スクレイピングを実施するスクリプト

スクレイピングの大まかな処理の流れは以下です。 1. 開始時間まで待機 1. スクレイピングした結果から、遷移可能なURLを抽出 1. 遷移可能なURLがなくなるまで2を実施 1. 最初のWebページから2を実施する

以下がスクレイピングを実行するコードです。 複数台のWebサーバを同時にスクレイピングするならば、サーバの台数だけ複製し、以下3つの項目はWebサーバの情報に合わせます。

  • start_url:スクレイピングを開始するトップページのURL
  • allowed_domains:評価対象で、スクレイピングをするドメイン名
  • output_file:スクレイピング結果を格納するファイル名
import subprocess
from html.parser import HTMLParser
from urllib.parse import urljoin, urlparse
import datetime
import time
import os

import config


start_url = "http://wphardening1.com:8081"
list_team = config.team_name_list

output_file = f"titles{list_team[0]}.csv"


# スクレイピングデータを格納するディレクトリ
folder = "/home/ubuntu/results_curl"

file_path = os.path.join(folder, output_file)


# 過去のスクレイピング結果があれば削除
if os.path.isfile(file_path):
    try:
        os.remove(file_path)
    except FileNotFoundError:
        print("ファイルが見つかりませんでした")
    except PermissionError:
        print("削除権限がありません")

else:
    print(f"{file_path} は存在しません")


# 外部サイトを得点に入れないようにドメインを限定
allowed_domains = ["wphardening1.com:8081"]


start_time = config.start_time
end_time = config.end_time

visited = set()
url_list = [start_url]



# スクレイピングしてパースするClass
class SimpleHTMLParser(HTMLParser):
    def __init__(self, base_url):
        super().__init__()
        self.in_title = False
        self.title = ""
        self.links = []
        self.base_url = base_url

    def handle_starttag(self, tag, attrs):
        if tag.lower() == "title":
            self.in_title = True
        if tag.lower() == "a":
            for name, value in attrs:
                if name == "href":
                    absolute = urljoin(self.base_url, value)
                    if urlparse(absolute).netloc in allowed_domains:
                        self.links.append(absolute)

    def handle_endtag(self, tag):
        if tag.lower() == "title":
            self.in_title = False

    def handle_data(self, data):
        if self.in_title:
            self.title += data.strip()

def fetch_html(url):
    try:
        res = subprocess.run(
            ["curl", "-sL", url],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            timeout=10
        )
        return res.stdout.decode("utf-8", errors="ignore")
    except Exception as e:
        print(f"[!] curl failed: {e}")
        return ""

print("start time",start_time)
print("end time", end_time)
while datetime.datetime.now() < end_time:
   
    # 開始時間までは待機
    if (datetime.datetime.now() < start_time):
        print("wait")

        time.sleep(30)
        continue

    # 遷移可能なURLがなくなればトップページから再スタート
    if not url_list:
        time.sleep(40)

        url_list.append(start_url)
        visited.clear()
        print("not url_list \n restart with ",url_list)

        continue


    # 遷移した先のWebページに記載されているURLをたどってスクレイピングをしていく
    time.sleep(1)
    target_url = url_list.pop(0)
    if target_url in visited:
        continue
    visited.add(target_url)

    html = fetch_html(target_url)
    if not html:
        continue

    parser = SimpleHTMLParser(base_url=target_url)

    try:
        parser.feed(html)
    except Exception as e:
        print(f"[Warning] HTML parse error: {e}")
        time.sleep(60)


    if not(target_url==start_url):
        with open(file_path, "a", encoding="utf-8") as f:
            f.write(f"{parser.title or 'No Title or hacked'},{time.time()},{list_team[0]}\n")

    for link in parser.links:
        if link not in visited and link not in url_list:
            url_list.append(link)

スクレイピング結果の可視化

スクレイピングで得られたデータをStreamlitで可視化します。

Streamlitはpipでインストールするのですが、Ubuntu Server 24.04 LTSではpipが使えないので、仮想環境の中でインストールします。

$ python3 -m venv venv-streamlit
$ source venv-streamlit/bin/activate
$ pip install streamlit

大まかな処理の流れは以下です。

  1. 全チームのスクレイピング結果をDataFrameに格納
  2. タイトルにhackedと無いWebページ数を集計
  3. 時間推移を折れ線グラフで、最新の数値をテーブルで表示
import numpy as np
import pandas as pd
import time
import streamlit as st
import altair as alt

import config
import datetime

import os


# スクレイピング結果を格納するディレクトリ
folder = "/home/ubuntu/results_curl"

st.title("獲得点数")

# プレースホルダー作成
placeholder = st.empty()

# Hardening競技の開始と終了の時間
start_time = config.start_time
end_time = config.end_time


while(1):

    # Hardeningが始まった直後はスクレイピング結果がまだない可能性があるためフラグで判断
    exist_data_flag = False
    df = pd.DataFrame()

    # 参加チームの分だけデータをDataFrameに取り込む
    for team_name in config.team_name_list:


        # データの再読み込み(ファイルが更新される前提)
        result_scrapy_file = os.path.join(folder, f"titles{team_name}.csv")

        if os.path.exists(result_scrapy_file):

            exist_data_flag = True

            tmp_df = pd.read_csv(result_scrapy_file, names=["title", "time", "name"])

            # Wordpressページのタイトルに"hacked"とあれば加点しない
            tmp_df["valid_title"] = tmp_df["title"].apply(lambda x: 0 if "hacked" in x else 1).astype(int)

            # UNIX TIMEを日時に変換
            tmp_df["time"] = tmp_df["time"].apply(lambda ts: datetime.datetime.fromtimestamp(ts))

        else:
            tmp_df = pd.DataFrame(columns=["title", "time", "name", "valid_title"])

        df = pd.concat([df, tmp_df])


    # まだスクレイピング結果がなければ以降の処理を実行しない
    if not(exist_data_flag):
        continue

    # "hacked"とないタイトルのページに訪れた回数の累積和(=点数)を計算
    df["cumsum"] = df.groupby("name")["valid_title"].cumsum()


    # 各 name ごとに最新行を取得して、時刻を上書きして複製
    # データ改ざんでページに到達できなくてもグラフ描画を実行するため
    latest_df = (
        df.sort_values("time", ascending=False)
          .groupby("name", as_index=False)
          .head(1)
          .copy()
    )


    # Altairで折れ線グラフを作成
    chart_data = df[["time", "name", "cumsum"]]

    chart = alt.Chart(chart_data).mark_line().encode(
        x=alt.X("time:T", scale=alt.Scale(domain=[start_time, end_time])),
        y=alt.X("cumsum:Q", scale=alt.Scale(domain=[0, 1000])),
        color="name:N",
        tooltip=["name", "time", "cumsum"]
    ).properties(width=1600, height=900)

    placeholder.altair_chart(chart, use_container_width=True)


    # 表形式でも最新の点数を表示
    st.table(latest_df[[ "name", "cumsum"]].reset_index(drop=True))

    # 1分待機
    time.sleep(60)

© NTT WEST, Inc.