Metasploitのガイドに従って、HTTP LoginScannerを書いてみる

タイトル通り、Metasploitのサイトにある[Writing a HTTP LoginScanner](https://docs.metasploit.com/docs/development/developing-modules/guides/scanners/how-to-write-a-http-loginscanner-module.html)に従って、HTTP LoginScannerを書いてみました。
ガイドでは、Symantec Web Gatewayのトライアル版を使用することになっていますが、見つけることができませんでしたので、テスト用にログインするだけのサイトを作りました。
# サイトの準備
Ubuntuサーバーで、LAMP環境を作ります。
テスト用なので、UFW等の設定は行いません。
UbuntuサーバーのIPアドレス：192.168.56.133
#### Apacheのインストール
```
sudo apt update
sudo apt upgrade
sudo apt install apache2
```
ブラウザで、以下のURLにアクセスして、デフォルトページが表示されるか、確認します。
```
http://192.168.56.133
```
#### MySQLのインストール
```
sudo apt install mysql-server
```
セキュリティの設定をします。
```
sudo mysql_secure_installation
```
VALIDATE PASSWORD PLUGINは、Nで回答し、以降は、Yで回答します。
完了したら、MySQLコンソールにログインできるか、確認します。
```
sudo mysql
```
ログイン出来たら、以下のコマンドで、終了します。
```
exit
```
#### PHPのインストール
```
sudo apt install php libapache2-mod-php php-mysql
```
PHPの動作確認をします。
```
php -v
```
PHPとApacheの連携を確認します。
以下のファイルを作成します。
```
sudo nano /var/www/html/info.php
```
```php:info.php
<?php phpinfo(); ?>
```
ブラウザで、以下のURLにアクセスして、PHPのインフォメーションが表示されるか、確認します。
```
http://192.168.56.133/info.php
```
確認ができたら、ファイルを削除します。
```
sudo rm /var/www/html/info.php
```
#### データベースの準備
MySQLにログインします。
```
sudo mysql
```
データベース(example_database)を作成します。
```
CREATE DATABASE example_database;
```
ユーザー(example_user)を作成して、パスワード(password)を設定します。
```
CREATE USER 'example_user'@'%' IDENTIFIED WITH mysql_native_password BY 'password';
```
example_userにexample_databaseの権限を付与します。
```
GRANT ALL ON example_database.* TO 'example_user'@'%';
```
一旦、MySQLから、ログアウトします。
```
exit
```
example_userで、MySQLにログインします。
```
mysql -u example_user -p
```
example_databaseに、認証で使用するusernameとpasswordのテーブル(users)を作成します。
```
CREATE TABLE example_database.users (
user_id INT AUTO_INCREMENT,
username VARCHAR(255),
password VARCHAR(255),
PRIMARY KEY(user_id)
);
```
認証用のデータを作成します。
```
INSERT INTO example_database.users (username,password) VALUES ("leia_organa","help_me_obiwan");
```
確認します。
```
select * from example_database.users;
+---------+-------------+----------------+
| user_id | username    | password       |
+---------+-------------+----------------+
|       1 | leia_organa | help_me_obiwan |
+---------+-------------+----------------+
```
MySQLから、ログアウトします。
```
exit
```
# サイトの作成
```/var/www/html```に```testsite```を作成します。
```
sudo mkdir /var/www/html/testsite
```
以下の3つのPHPファイルを作成します。
```php:dbconfig.php
<?php
$dsn = 'mysql:host=localhost;dbname=example_database;charset=utf8';
$user = 'example_user';
$password = 'password';

try {
    $pdo = new PDO($dsn, $user, $password);
} catch (PDOException $e) {
    echo 'Connection failed: ' . $e->getMessage();
}
?>
```
```php:login.php
<?php
session_start();
require_once 'dbconfig.php';

if(isset($_POST['username']) && isset($_POST['password'])) {

	$username = $_POST['username'];
	$password = $_POST['password'];
	
	$sql = 'SELECT * FROM users WHERE username = :username';
	$stmt = $pdo->prepare($sql);
	$stmt->execute(['username' => $username]);
	$user = $stmt->fetch();
	if(isset($user['username']) && $password === $user['password']) {
		$_SESSION['username'] = $user['username'];
		session_regenerate_id();
		header('Location: ./index.php');
	} else {
		echo 'ログイン失敗';
?>
<br><a href="./login.php">login.php</a>
<?php
	}
} else {
?>
<form action="login.php" method="post">
    <label for="username">ユーザー名:</label>
    <input type="text" id="username" name="username"><br>
    <label for="password">パスワード:</label>
    <input type="password" id="password" name="password"><br>
    <input type="submit" value="ログイン">
</form>
<?php
}
?>
```
```php:index.php
<?php
session_start();

if(isset($_POST['logout'])) {
	session_destroy();
	header('Location:./login.php');
}
if(isset($_SESSION['username'])) {
	echo 'ログイン成功';
?>
<form action="index.php" method="post">
    <input type="hidden" id="logout" name="logout">
    <input type="submit" value="ログアウト">
</form>
<?php
} else {
	header('Location:./login.php');
}
?>
```
#### 動作
ブラウザで、```http://192.168.56.133/testsite/```にアクセスすると、```http://192.168.56.133/testsite/login.php```にリダイレクトします。
ユーザー名、パスワードで認証すると、```http://192.168.56.133/testsite/index.php```にリダイレクトします。
認証に失敗した時は、「ログイン失敗」とlogin.phpに戻るリンクが表示されますので、リンクで戻ります。
index.phpのログアウトボタンをクリックすると、login.phpに戻ります。
ユーザー名：leia_organa
パスワード：help_me_obiwan
# HTTP LoginScannerの作成
#### ログイン時の動作の確認
ログイン成功時のリクエストとレスポンス
ログインに成功した時は、302でレスポンスして、Locationヘッダーにindex.phpがあります。
```
POST /testsite/login.php HTTP/1.1
Host: 192.168.56.133
Content-Length: 44
Cache-Control: max-age=0
Accept-Language: en-US,en;q=0.9
Origin: http://192.168.56.133
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.56.133/testsite/login.php
Accept-Encoding: gzip, deflate, br
Cookie: PHPSESSID=k55vh6i6neboo7istaeag3krin
Connection: keep-alive

username=leia_organa&password=help_me_obiwan
```
```
HTTP/1.1 302 Found
Date: Tue, 06 May 2025 06:30:44 GMT
Server: Apache/2.4.58 (Ubuntu)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Set-Cookie: PHPSESSID=32qos1p3mfdidluaa8021d06p0; path=/
Location: ./index.php
Content-Length: 0
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=UTF-8
```
```
GET /testsite/index.php HTTP/1.1
Host: 192.168.56.133
Cache-Control: max-age=0
Accept-Language: en-US,en;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.56.133/testsite/login.php
Accept-Encoding: gzip, deflate, br
Cookie: PHPSESSID=32qos1p3mfdidluaa8021d06p0
Connection: keep-alive
```
```
HTTP/1.1 200 OK
Date: Tue, 06 May 2025 06:30:44 GMT
Server: Apache/2.4.58 (Ubuntu)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Vary: Accept-Encoding
Content-Length: 168
Keep-Alive: timeout=5, max=99
Connection: Keep-Alive
Content-Type: text/html; charset=UTF-8

ログイン成功<form action="index.php" method="post">
    <input type="hidden" id="logout" name="logout">
    <input type="submit" value="ログアウト">
</form>
```
ログイン失敗時のリクエストとレスポンス
ログインに失敗した時は、200でレスポンスします。
```
POST /testsite/login.php HTTP/1.1
Host: 192.168.56.133
Content-Length: 53
Cache-Control: max-age=0
Accept-Language: en-US,en;q=0.9
Origin: http://192.168.56.133
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://192.168.56.133/testsite/login.php
Accept-Encoding: gzip, deflate, br
Cookie: PHPSESSID=32qos1p3mfdidluaa8021d06p0
Connection: keep-alive

username=leia_organa&password=like_my_father_beforeme
```
```
HTTP/1.1 200 OK
Date: Tue, 06 May 2025 06:45:13 GMT
Server: Apache/2.4.58 (Ubuntu)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Content-Length: 58
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=UTF-8

ログイン失敗<br><a href="./login.php">login.php</a>
```
#### HTTP LoginScannerの作成
HTTP LoginScanner テンプレートは次のようになります。
空のattempt_loginメソッドしかありません。
attempt_loginが自動的に呼び出されます。
```ruby
require 'metasploit/framework/login_scanner/http'

module Metasploit
  module Framework
    module LoginScanner
      class SymantecWebGateway < HTTP


        # Attempts to login to the server.
        #
        # @param [Metasploit::Framework::Credential] credential The credential information.
        # @return [Result] A Result object indicating success or failure
        def attempt_login(credential)

        end

      end
    end
  end
end
```
クラス名を変更して、標準的なattempt_loginを記入します。
```ruby
require 'metasploit/framework/login_scanner/http'

module Metasploit
  module Framework
    module LoginScanner
      class TestSite < HTTP

        def attempt_login(credential)
          result_opts = {
            credential: credential,
            status: Metasploit::Model::Login::Status::INCORRECT,
            proof: nil,
            host: host,
            port: port,
            protocol: 'tcp'
          }
          result_opts.merge!(do_login(credential.public, credential.private))
          Result.new(result_opts)
        end

      end
    end
  end
end

```
PHPSESSIDを取得するget_session_idメソッドを追加します。
```ruby
require 'metasploit/framework/login_scanner/http'

module Metasploit
  module Framework
    module LoginScanner
      class TestSite < HTTP

        def get_session_id
          login_uri = normalize_uri("#{uri}/testsite/login.php")
          res = send_request({'uri' => login_uri})
          sid = res.get_cookies.scan(/(PHPSESSID=\w+);*/).flatten[0] || ''
          return sid
        end

        def attempt_login(credential)
          result_opts = {
            credential: credential,
            status: Metasploit::Model::Login::Status::INCORRECT,
            proof: nil,
            host: host,
            port: port,
            protocol: 'tcp'
          }
          result_opts.merge!(do_login(credential.public, credential.private))
          Result.new(result_opts)
        end

      end
    end
  end
end
```
実際にリクエストの送信とレスポンスの確認を行うdo_loginを追加します。
レスポンスの確認は、ロケーションヘッダーでtestsite/index.phpページにリダイレクトされることを利用しています。
```ruby:test_site.rb
require 'metasploit/framework/login_scanner/http'

module Metasploit
  module Framework
    module LoginScanner
      class TestSite < HTTP

        LOGIN_STATUS  = Metasploit::Model::Login::Status

        def get_session_id
          login_uri = normalize_uri("#{uri}/testsite/login.php")
          res = send_request({'uri' => login_uri})
          sid = res.get_cookies.scan(/(PHPSESSID=\w+);*/).flatten[0] || ''
          return sid
        end

        def do_login(username, password)
          protocol  = ssl ? 'https' : 'http'
          peer      = "#{host}:#{port}"
          login_uri = normalize_uri("#{uri}/testsite/login.php")

          res = send_request({
            'uri' => login_uri,
            'method' => 'POST',
            'cookie' => get_session_id,
            'headers' => {
              'Referer' => "#{protocol}://#{peer}/#{login_uri}"
            },
            'vars_post' => {
              'username' => username,
              'password' => password
            }
          })
          if res && res.headers['Location'].include?('index.php')
            return {:status => LOGIN_STATUS::SUCCESSFUL, :proof => res.to_s}
          end

          {:proof => res.to_s}
        end

        def attempt_login(credential)
          result_opts = {
            credential: credential,
            status: Metasploit::Model::Login::Status::INCORRECT,
            proof: nil,
            host: host,
            port: port,
            protocol: 'tcp'
          }
          result_opts.merge!(do_login(credential.public, credential.private))
          Result.new(result_opts)
        end

      end
    end
  end
end
```
#### 保存先
/usr/share/metasploit-framework/lib/metasploit/framework/login_scanner/
#### auxiliary moduleの作成
auxiliary moduleはユーザーインターフェースのような役割を果たします。
モジュールの動作を記述し、オプションの処理、オブジェクトの初期化、レポート作成などを行います。
auxiliary moduleのテンプレートは次のようになります。
initializeメソッドと空のrun_hostメソッドがあります。
initializeメソッドは、`show info`で表示される情報になると思います。
run_hostメソッドが、メインのメソッドになります。
```ruby
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'metasploit/framework/login_scanner/symantec_web_gateway'
require 'metasploit/framework/credential_collection'

class MetasploitModule < Msf::Auxiliary

  include Msf::Exploit::Remote::HttpClient
  include Msf::Auxiliary::AuthBrute
  include Msf::Auxiliary::Report
  include Msf::Auxiliary::Scanner

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Symantec Web Gateway Login Utility',
        'Description' => %q{
          This module will attempt to authenticate to a Symantec Web Gateway.
        },
        'Author' => [ 'sinn3r' ],
        'License' => MSF_LICENSE,
        'DefaultOptions' => {
          'RPORT' => 443,
          'SSL' => true,
          'SSLVersion' => 'TLS1'
        }
      )
    )
  end

  def run_host(ip)
  end

end
```
initializeメソッドを修正して、LoginScannerオブジェクトを初期化するscannerメソッドを追加します。
```ruby
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'metasploit/framework/login_scanner/test_site'
require 'metasploit/framework/credential_collection'

class MetasploitModule < Msf::Auxiliary

  include Msf::Exploit::Remote::HttpClient
  include Msf::Auxiliary::AuthBrute
  include Msf::Auxiliary::Report
  include Msf::Auxiliary::Scanner

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Test Site Login Utility',
        'Description' => %q{
          This module will attempt to authenticate to a Test Site.
        },
        'Author' => [ 'tsuko5963' ],
        'License' => MSF_LICENSE,
        'DefaultOptions' => {
          'RPORT' => 80,
          'SSL' => false,
        }
      )
    )
  end

  def scanner(ip)
    @scanner ||= lambda {
      cred_collection = Metasploit::Framework::CredentialCollection.new(
        password:        datastore['PASSWORD'],
        username:        datastore['USERNAME'],
      )

      return Metasploit::Framework::LoginScanner::TestSite.new(
        configure_http_login_scanner(
          host: ip,
          port: datastore['RPORT'],
          cred_details:       cred_collection,
          stop_on_success:    datastore['STOP_ON_SUCCESS'],
          bruteforce_speed:   datastore['BRUTEFORCE_SPEED'],
          connection_timeout: 5
        ))
      }.call
  end

  def run_host(ip)
  end

end
```
scannerメソッドの呼び出しを行うbruteforceメソッドと
ログイン成功時のレポートを行うreport_good_credメソッドと
ログイン失敗時のレポートを行うreport_bad_credメソッドを追加します。
```ruby:test_site_login.rb
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'metasploit/framework/login_scanner/test_site'
require 'metasploit/framework/credential_collection'

class MetasploitModule < Msf::Auxiliary

  include Msf::Exploit::Remote::HttpClient
  include Msf::Auxiliary::AuthBrute
  include Msf::Auxiliary::Report
  include Msf::Auxiliary::Scanner

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Test Site Login Utility',
        'Description' => %q{
          This module will attempt to authenticate to a Test Site.
        },
        'Author' => [ 'tsuko5963' ],
        'License' => MSF_LICENSE,
        'DefaultOptions' => {
          'RPORT' => 80,
          'SSL' => false,
        }
      )
    )
  end

  def scanner(ip)
    @scanner ||= lambda {
      cred_collection = Metasploit::Framework::CredentialCollection.new(
        password:        datastore['PASSWORD'],
        username:        datastore['USERNAME'],
      )

      return Metasploit::Framework::LoginScanner::TestSite.new(
        configure_http_login_scanner(
          host: ip,
          port: datastore['RPORT'],
          cred_details:       cred_collection,
          stop_on_success:    datastore['STOP_ON_SUCCESS'],
          bruteforce_speed:   datastore['BRUTEFORCE_SPEED'],
          connection_timeout: 5
        ))
      }.call
  end

  def report_good_cred(ip, port, result)
    service_data = {
      address: ip,
      port: port,
      service_name: 'http',
      protocol: 'tcp',
      workspace_id: myworkspace_id
    }

    credential_data = {
      module_fullname: self.fullname,
      origin_type: :service,
      private_data: result.credential.private,
      private_type: :password,
      username: result.credential.public,
    }.merge(service_data)

    login_data = {
      core: create_credential(credential_data),
      last_attempted_at: DateTime.now,
      status: result.status,
      proof: result.proof
    }.merge(service_data)

    create_credential_login(login_data)
  end

  def report_bad_cred(ip, rport, result)
    invalidate_login(
      address: ip,
      port: rport,
      protocol: 'tcp',
      public: result.credential.public,
      private: result.credential.private,
      realm_key: result.credential.realm_key,
      realm_value: result.credential.realm,
      status: result.status,
      proof: result.proof
    )
  end

  def bruteforce(ip)
    scanner(ip).scan! do |result|
      case result.status
      when Metasploit::Model::Login::Status::SUCCESSFUL
        print_brute(:level => :good, :ip => ip, :msg => "Success: '#{result.credential}'")
        report_good_cred(ip, rport, result)
      when Metasploit::Model::Login::Status::UNABLE_TO_CONNECT
        vprint_brute(:level => :verror, :ip => ip, :msg => result.proof)
        report_bad_cred(ip, rport, result)
      when Metasploit::Model::Login::Status::INCORRECT
        vprint_brute(:level => :verror, :ip => ip, :msg => "Failed: '#{result.credential}'")
        report_bad_cred(ip, rport, result)
      end
    end
  end

  def run_host(ip)
    bruteforce(ip)
  end

end
```
#### 保存先
~/.msf4/modules/auxiliary/scanner/http/
# 実行
Metasploitを起動します。
```
msfconsole
```
モジュールを読み込ませます。
```
[msf](Jobs:0 Agents:0) >> use auxiliary/scanner/http/test_site_login
```
RHOSTS,USERNAME,PASSWORDを設定します。
```
[msf](Jobs:0 Agents:0) auxiliary(scanner/http/test_site_login) >> set rhosts 192.168.56.133
rhosts => 192.168.56.133
[msf](Jobs:0 Agents:0) auxiliary(scanner/http/test_site_login) >> set username leia_organa
username => leia_organa
[msf](Jobs:0 Agents:0) auxiliary(scanner/http/test_site_login) >> set password help_me_obiwan
password => help_me_obiwan
```
実行します。
```
[msf](Jobs:0 Agents:0) auxiliary(scanner/http/test_site_login) >> run
[+] 192.168.56.133:80 - Success: 'leia_organa:help_me_obiwan'
[!] No active DB -- Credential data will not be saved!
[*] Scanned 1 of 1 hosts (100% complete)
[*] Auxiliary module execution completed
```
ログインに成功しました。
WireSharkで通信を確認すると、PHPSESSIDを取得するために、GETでリクエストして、その後、POSTでリクエストしています。
```
GET /testsite/login.php HTTP/1.1
Host: 192.168.56.133
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36


HTTP/1.1 200 OK
Date: Tue, 06 May 2025 07:50:07 GMT
Server: Apache/2.4.58 (Ubuntu)
Set-Cookie: PHPSESSID=51ms1fhvkn5s34l0hrsvsl54qu; path=/
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Vary: Accept-Encoding
Content-Length: 317
Content-Type: text/html; charset=UTF-8

<form action="login.php" method="post">
    <label for="username">...............:</label>
    <input type="text" id="username" name="username"><br>
    <label for="password">...............:</label>
    <input type="password" id="password" name="password"><br>
    <input type="submit" value="............">
</form>


POST /testsite/login.php HTTP/1.1
Host: 192.168.56.133
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
Cookie: PHPSESSID=51ms1fhvkn5s34l0hrsvsl54qu
Referer: http://192.168.56.133:80//testsite/login.php
Content-Type: application/x-www-form-urlencoded
Content-Length: 44

username=leia_organa&password=help_me_obiwan


HTTP/1.1 302 Found
Date: Tue, 06 May 2025 07:50:07 GMT
Server: Apache/2.4.58 (Ubuntu)
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
Set-Cookie: PHPSESSID=seesrcj3lrdka9vkuighdgm1gd; path=/
Location: ./index.php
Content-Length: 0
Content-Type: text/html; charset=UTF-8
```