ある方から質問を受けたのもあり、OWASP ZAPの基本的な使い方(手動診断編)の続編として、「環境構築からテストサイトを構築して動的スキャンでXSSなどを検出するまで」の手順を解説してみます。

OWASP ZAP初心者が基本的な動的スキャン検査を行えるようになるまでの手順、という位置づけです。

ここでは、Windows環境で、XAMPPとOWASP ZAPを一からセットアップするという形で解説します。(Win以外の環境や、XAMPP以外の環境を使いたい方は適宜ご自身の環境に合わせて読み替えてください)

■Windwows環境でXAMPPを使ったテストサイトの構築


まず注意点ですが、普通にインターネット上に公開されているサイトにOWASP ZAPの動的スキャンなどをかけたら、サイバー攻撃と見なされて最悪通報されてしまうため、OWASP ZAPで診断を行う対象は「自己の管理下にあるサイト」である必要があります。

つまり、OWASP ZAPの使い方を習得するにあたっては、ローカル環境や社内LAN上に「練習用の診断対象サイト」(いわゆるわざと脆弱性を持たせた「やられサイト」)を構築することがまず必要です。

「やられサイト」にはOWASP Mutillidae 2などがありますが、「やられサイト」のインストール・使いこなしはそれはそれでやや敷居が高い面があり、何かしらサイトを構築した経験があるのであれば、「検出したい脆弱性があるサイト」をローカル環境に自力で作ってしまうのが一番敷居が低い・手っ取り早いように思うので、ここではその手順で説明します。

1.XAMPPのインストール

XAMPPダウンロードサイトよりWindows向けXAMPPをダウンロードし、インストールします。

インストール時の手順については、特に難しい設定はないと思いますので、図などは割愛します。基本的にデフォルトの設定で「次へ」で進んでいけばインストールが完了します。

XAMPPインストール完了後、「スタート」-「XAMPP」-「XAMPP Control Panel」を選択するとXAMPPのコントロールパネルが起動します。

XAMPPのコントロールパネルで「Apache」のところにある「Start」のボタンを押下すると、Apacheが起動し、ブラウザから「http://localhost/」にアクセスすると、XAMPPのApacheが動作していることを表す「Welcome to XAMPP for Windows 5.6.28」等の表記があるページが表示されます。



もし、XAMPPのコントロールパネルで「Apache」のところにある「Start」のボタンを押下してエラーになる場合は、80ポートを別のアプリケーションが使っている可能性があります。

例えば、Skypeが起動している場合、Skypeはデフォルトでは80ポートを使うためエラーになります。この場合Skypeのオプションで80ポートではなく別のポートを使うように設定するオプションがありますので、その設定でSkypeが80以外の別のポートを使うように設定してください。
参考:Skype が占有するポート 80 を変更する方法

2.脆弱性診断用テストサイトの構築

検出したい脆弱性のある動的ページを作成します。

環境がXAMPPなので、単純なXSSのあるサイトをphpなどで作ればよいのですが、本手順では、Hack Your Design!様が公開しているXSS脆弱性のあるPHPコード簡易サンプルが1ソースでシンプルなので、こちらを利用させていただきます。
(自分自身にPOSTするフォームで、POST値がそのままページ上に表示される、というサンプルソースです)

上記サイトのサンプルコードをXAMPP内のApacheのドキュメントルート(デフォルトでは「C:\xampp\htdocs」下)に、test.phpなどの名前で保存し、ブラウザから「http://localhost/test.php」というURLにアクセスし、POSTを行い、ちゃんとサンプルサイトがPHPとして動作していれば、ZAP練習用のXSS脆弱性のあるサイトが完成します。

(この手順のままのXAMPPデフォルト環境だと、「Notice: Undefined index: xss_text in C:\xampp\htdocs\test.php on line 7」のようなメッセージが画面上に表示されるかもしれませんが、特に動作に支障はないのでそのまま放置でも良いですし、気になる場合はphp.iniなどでnoticeレベルのメッセージは表示しないように設定しましょう)

■OWASP ZAPのインストール・ブラウザのプロキシ設定


3.OWASP ZAPのインストール・設定

OWASP ZAPプロジェクトのサイトからWindows版のZAPをダウンロードし、そのままインストールを行います。(本記事執筆時の最新版は2.5.0です)

インストール完了後、OWASP ZAPを起動し、メニューの「ツール」-「オプション」-「ローカル・プロキシ」で、ZAPの待ち受けポートが「8080」になっているのを「7777」などの空いているポートに変更します。
(8080のままでも良いですが、8080ポートは他のアプリケーションとかぶりやすいので、エラーではまるのを防ぐため別ポートにしておいたほうが安全です)


4.ブラウザのプロキシ設定

ブラウザのプロキシ設定でOWASP ZAPの待ち受けポートを指定します。ここではFirefoxでの手順を説明します。

Firefoxのメニューの「ツール」-「オプション」-「詳細」-「ネットワーク」タブの「接続 インターネット接続に使用するプロキシを設定します。」の横の「接続設定...」ボタンを押下します。

出てきたウィンドウで「手動でプロキシを設定する」ラジオボタンを選択し、HTTPプロキシ欄に、ホスト「localhost」、ポート「7777」(ZAPの待ち受けポート)を指定し、「すべてのプロトコルでこのプロキシを使用する」にチェックを入れます。
また、今回のテストサイトがlocalhost上にあるため、「プロキシなしで接続」欄に、「localhost」「127.0.0.1」がある場合は削除します。



■ブラウザで対象サイトにアクセス・動的スキャン実施まで


5.ブラウザで対象サイト操作

4.の手順でプロキシ設定を終えたブラウザで、2.の手順で構築したテストサイトにアクセスを行います。

テストサイトにブラウザからアクセスした際、OWASP ZAPの履歴タブ内にテストサイトへのアクセス履歴が表示されます。(ここではブラウザの通信を、ローカルプロキシであるZAPが中継して通信内容を表示しています。ここでZAPに何も反応がなければブラウザのプロキシ設定やZAPのポート設定などのどこかが間違っています)



テストサイト上のフォームから任意の値(「aaa」など)をPOSTします。二つ目の履歴(「メソッド」が「POST」になっているもの)がZAPの履歴タブ内に記録されます。



ここで安全のための手順として、ZAP左上の「モード」を「標準モード」から「プロテクトモード」に変更します。
プロテクトモードにすると、「コンテキスト」に入れたサイト以外に対しては「攻撃」メニューが実行できなくなり、ZAPからの攻撃対象でなくなります。サイトを「コンテキスト」に入れる方法は次で解説します。



サイトツリーの「http://localhost」を右クリックし「Include in Context」-「規定コンテキスト」を選択し、「セッション・プロパティ」ウィンドウが出るのでそのままOKを押します。





すると、サイトツリーの「http://localhost」に赤い二重丸がつきます。この二重丸が付いているサイトにしかZAPは動的スキャンなどの処理を行わないため、想定外のサイトを攻撃してしまう事故が起こらなくなります。



ZAPの履歴タブの二つ目の履歴(「メソッド」が「POST」になっているもの)をZAP上で右クリックし、コンテキストメニューから「攻撃」-「動的スキャン...」を選択します。



「動的スキャン」ウィンドウの「スコープ」タブで「Starting point」が目的のサイトか念のため確認し、「Show advanced options」チェックボックスをオンにします。すると、「スコープ」以外のタブが表示されます。



「入力ベクトル」タブで「POST Data」にチェックを入れます。



「ポリシー」タブの「インジェクション」で「クロスサイトスクリプティング(反射型)」がThreshold、Strengthともに「規定」になっていることを確認し「スキャンを開始」ボタンを押下します。



スキャンが完了すると、XSSが検出されます。



上記の手順は、テストサイトのPOSTにターゲットを絞ったものでしたが、サイトツリーの「http://localhost」を右クリックして動的スキャンを行うと、ZAPに記録されたlocalhost下の履歴全体に対する動的スキャンが行われ、同様にXSSが検出されます。

(厳密にはサイト全体への動的スキャンは「スコープ」タブで「再帰的」のチェックが入っている必要がありますが、サイトツリーで一番上のノードを選択して「動的スキャン」を選択した場合はデフォルトでチェックが入っています)

ここまでの手順が成功すれば、診断用のテストサイトを構築して、OWASP ZAPの動的スキャンでXSSが検出できたことになります。
SQLインジェクションなど、他の脆弱性を検出したい場合は、その脆弱性のあるテストサイトを作成し、同様の手順で動的スキャンを実施してみてください。


※OWASP ZAPの「クイックスタート」でのスキャンについて

上記手順はブラウザのプロキシを設定するなど、やや手間のかかる手順でした。

OWASP ZAPを起動すると、起動して表示される最初の画面に「クイックスタート」というタブが表示されていて、そこにお手軽に一発でサイトをスキャンできそうな入力欄があります。



なぜこれを使わないのか、と疑問に感じる方もおられるかもしれません。

手順3までの構築が終わり、XAMPP上にあるテストサイトで手動ではXSSが発動できるのに、テストサイトのURLをOWASP ZAPの「クイックスタート」のところにある「攻撃対象URL」欄に入力して「攻撃」を行っても、XSSが検出されません。

OWASP ZAP「クイックスタート」による検査は、対象として入力したURLを起点に「スパイダー」(クローリング)を実施し、その後で「動的スキャン」を実施する機能で、今回のテストサイトのXSSは検出されていいはずなのですが、何でこれが検出されないんだろうと思って調べたところ、「スパイダー」の記録ではちゃんとテストサイトへのPOST(XSS発火点)までクローリングしているのに、なぜか、「動的スキャン」のほうではGETしか見ておらず、検出されないという挙動であることが分かりました。

ZAPの「ツール」-「オプション」-「動的スキャンの入力ベクトル」設定ではちゃんとPOSTまで見る設定になっているのに、GETしか見てないのはおかしいので、またバグなのでは……? という感じなのですが、ちょっと「クイックスタート」でのスキャンは、私自身が今まであまり利用したことがないので、設定が足りていない等あるのかもしれません。(「クイックスタート」でのスキャンはZAPの標準モードでないと実施できないので、誤爆事故が起こりうる設定での全自動検査というのが危険と感じるためあまり利用していません)

ただ、とりあえずインストール直後のZAPのデフォルト設定で気軽に「クイックスタート」でスキャンしてみたところ、検出されるべき単純なXSSが検出できなかったという事象が今回発生したので、サイトへの動的スキャンをかけたい場合には、「クイックスタート」ではなく、本稿で説明したような「ブラウザにプロキシ設定を行って、そのブラウザでサイトを見て回り、ZAPに記録された履歴に対しての動的スキャン」という手順のほうが確実とは言えそうです。
(解説に含めませんでしたが、ブラウザでサイトを見て回った後、ZAPの「スパイダー」機能を使ってクローリングを補強しても良いと思います)

「クイックスタート」での不検出問題については、後日時間があったら調べてバグだったらまたGithubに報告を挙げておきます。


※本稿のOWASP ZAPでの動的スキャンの手順は、「脆弱性診断ええんやで」講師松本さんから教えてもらった内容をベースにしています。
https://security-testing.doorkeeper.jp/

中編からの続きです)

・実験3:ZAP HTTP SenderスクリプトにFiddlerスクリプトの小技を移植する

実験2の調査結果により、ZAPのHTTP Senderスクリプトは、ZAPが送受信するすべてのリクエストに干渉することができるようなので、これを使えば、ZAP+Fiddlerで行っていたような細かい挙動の変更をZAP単体でできるようになるかもしれません。

少し実験して確かめてみます。以下、「前編」のZAP+Fiddlerの小技集と対応しています。

小技1':リクエストに対し一定のウェイト(ディレイ)をかける

HTTP SenderスクリプトをECMAScriptで作成し、sendingRequest関数内に下記のコードを書けば、ZAPが発行するリクエストに対してウェイト(ディレイ)が掛けられます。

function sendingRequest(msg, initiator, helper) {
    java.lang.Thread.sleep(1000);
}

ZAPの動的スキャンのスレッド数を1にし、上記コードと組み合わせることで動的スキャンの各リクエストに対し任意の頻度になるようウェイトをかけることが可能になります。

小技1-1':動的スキャン以外のリクエストに対してはウェイトをかけない

プロキシでウェイトを掛けるように設定していると、手動で診断対象サイトにアクセスした際に、大量の画像や静的コンテンツが読み込まれ、そのリクエストごとに所定のウェイトがかかることでページ遷移がとても重たくなる現象があります。

ZAP+Fiddlerの場合、静的コンテンツなどのリクエストの場合はウェイトをかけないコードにすることでその問題を回避しましたが、ZAPのHTTP Senderスクリプトでは別の解決方法があります。

HTTP Senderスクリプト下にデフォルトのテンプレートでECMAScriptを作成すると、コメント文中に色々と書いてある中に下記のリストが登場します。
// 'initiator' is the component the initiated the request:
//   1 PROXY_INITIATOR
//   2 ACTIVE_SCANNER_INITIATOR
//   3 SPIDER_INITIATOR
//   4 FUZZER_INITIATOR
//   5 AUTHENTICATION_INITIATOR
//   6 MANUAL_REQUEST_INITIATOR
//   7 CHECK_FOR_UPDATES_INITIATOR
//   8 BEAN_SHELL_INITIATOR
//   9 ACCESS_CONTROL_SCANNER_INITIATOR
これは、sendingRequest関数の引数initiatorに、ZAPのリクエストの種別が渡って来るということなので、この値で分岐するIF文を書けば、「普段の手動ブラウジングの時にはウェイトがかからないが、動的スキャンの場合のみリクエストに指定のウェイトがかかる」というコードにできます。

動的スキャン時のみウェイトがかかるサンプルコード:
function sendingRequest(msg, initiator, helper) {
    print('initiator:'+initiator +' / sendingRequest called for url=' + msg.getRequestHeader().getURI().toString());
    if(initiator==2){
        java.lang.Thread.sleep(5000);
    }
}
sendingRequest関数に上記のように書くと、

・手動でのリクエスト時(initiatorに1が渡って来る):
initiator:1 / sendingRequest called for url=http://localhost/zaptest/test.php?aaa=ccc
→ウェイトが全くかからない

・動的スキャン時(initiatorに2が渡って来る):
initiator:2 / sendingRequest called for url=http://localhost/zaptest/test.php?aaa=0W45pz4p
→ウェイトが1リクエストごとに5秒かかる(サンプル通りThread.sleep(5000)を指定していた場合)

小技2':基本認証を通す

アクセス先ホストがlocalhostの場合のみ、Authorizationヘッダを付けるサンプルコード:
function sendingRequest(msg, initiator, helper) {
    host = msg.getRequestHeader().getURI().getHost();
    print('host:'+host);
    if(host=='localhost'){
        msg.getRequestHeader().setHeader("Authorization", "Basic XXXXXX");
    }
}

小技3':特定のホストに対しては特定のプロキシを通す

ZAPのスクリプトから外部プロキシ設定を変更する手段が見つからなかったので、これを実現するコードは作成できませんでした。

小技4':ZAPが投げる危険な文字列をZAPのHTTPSenderでDROPする(サンプルコード)

Fiddlerスクリプトでは偽のレスポンスを返すという手段で防止していましたが、ZAPのHTTPSenderではリクエストを差し止めて投げないという方法が見つからなかったので、127.0.0.1にリクエストを向けるというやや力技で解決しました。
(たぶんリクエストを差し止めて投げない方法はちゃんと存在すると思うので、見つけられたら修正します)
function sendingRequest(msg, initiator, helper) {

    var reqhead = msg.getRequestHeader().toString();
    print('reqhead:'+reqhead+"\n\n");
    var reqbody = msg.getRequestBody().toString();
    print('reqbody:'+reqbody);

    if (reqhead.indexOf(' OR 1=1 --') > -1 || reqhead.indexOf('%20OR%201=1%20--') > -1
        ||reqbody.indexOf(' OR 1=1 --') > -1 || reqbody.indexOf('%20OR%201=1%20--') > -1) {

        msg.getRequestHeader().setURI(new org.apache.commons.httpclient.URI("http://127.0.0.1", false));
        msg.getRequestBody().setBody(""); 
    }
}

・実験5:ZAP+ZAP 多段プロキシ構成を試してみる

OWASP ZAPは起動時に-dirオプションを付けて起動すると、起動ディレクトリを指定して起動することができます。

設定等は起動ディレクトリに保存されるので、同じバイナリを使って設定の違う二つのZAPを多重起動して動作させることが可能です。

前編の冒頭で解説したように、ZAPはログ機能が微妙なので、「ZAP+何か」の形で多段プロキシにしてログを別途記録したほうが良いのですが、FiddlerはWindows環境以外ではα版もしくはβ版であるため、Windows環境以外だとやや導入に不安を感じます。

では、ZAP+Burpだとどうかというと、Burp SuiteがFree版の場合、ログは記録できるものの、そのログをBurpSuiteで改めて読み込むことができないため、何かを調べたいときにGUI上ではなく、ログが全て記録されたテキストファイル内を検索する必要があります。(Burp Suite Professional版であればProjectという形でログの保存・復元が可能なようです)

Burp Suite Professionalが買えず、Win環境以外の場合、三大プロキシの二つがログを記録する用途に適さないので、じゃあZAPの先に別のZAPをもう一つ繋げて、ZAPのログをZAPで記録するというのはどうか? ということで実験してみます。

[ZAP多重起動のための手順(Windows環境)]
1. ZAPの起動ディレクトリを定めます

ここでは仮に
・C:\zaptest\dirtest1
・C:\zaptest\dirtest2
とします。

2. ZAPを起動させるバッチファイルを作成
[zap1.bat]
java -jar zap-2.5.0.jar -dir "C:\zaptest\dirtest1"
pause
[zap2.bat]
java -jar zap-2.5.0.jar -dir "C:\zaptest\dirtest2"
pause

3.ZAPを起動し、それぞれの設定を行います。

※WindowsDefenderが入っている場合、インストールするアドオンの一部がバックドアとして除外対象判定になることがあるので、ZAPの起動ディレクトリをスキャン対象から除外しておく必要があります。

[zap1.batで起動したZAP]
・ヘルプ - アップデートのチェック - マーケットプレイスで「Release」レベルのアドオンを全てインストール(外部プロキシサーバ設定前に実行)
・ツール - オプション - ローカルプロキシの値がlocalhost:8080での待ち受けになっているので、本例では localhost:7771 に変更
・ツール - オプション - ネットワーク - 外部プロキシサーバ利用をオンにし、外部プロキシサーバとして本例では localhost:7772 を設定


[zap2.batで起動したZAP]
・ヘルプ - アップデートのチェック - マーケットプレイスで「Release」レベルのアドオンを全てインストール(外部プロキシサーバ設定前に実行)
・ツール - オプション - ローカルプロキシの値がlocalhost:8080での待ち受けになっているので、本例では localhost:7772 に変更


[ブラウザ]
プロキシサーバとして localhost:7771 を設定


これで、ブラウザでサイトを見るとZAP1、ZAP2それぞれに履歴が記録されます。

[loggerとしてのZAPのテスト]
・ZAP1で(診断して良い)特定サイトに対し動的スキャンを実行し、セッションデータを保存・再読み込みを行ってみると、前編冒頭で書いたように、動的スキャンの結果は残りますが動的スキャンのログが消えてしまいます。
しかし、ZAP2の方ではZAP1の動的スキャンのログが全て「履歴」タブのところに記録されているためログを保存・再読み込みしても記録が消えません。

・また、前編冒頭で書いたリダイレクトの再送信でログが記録されない問題も、ZAP1では記録されませんが、ZAP2ではきちんとindex.php - index2.php - index3.php という遷移が履歴に残るので、ログとして正確な記録がZAP2のほうには残ります。

・逆に、ZAP1の動的スキャンの診断結果(XSSが出た等)は、ZAP2のほうには表示されずログに記録もされないので、動的スキャンの結果に関してはZAP1のほうを確認する必要があります。


このように、ZAP+ZAPで、片方のZAPでログを取るという構成は機能的には問題なく成立しそうな感じでした。

ただ、複数のZAPが立ち上がっていると、どっちがどっちだか分からなくなり、混乱の結果ログ用のZAPで動的スキャンをしてしまったり、見るほうを間違えたり、という事故が起こりそうなので、ログ用ZAPを別PCで立てるなど、何か混乱を防ぐような工夫が必要と思われます。

以上、OWASP ZAPの多段プロキシ構成に関するテクニック紹介、および実験の記録でした。

前編から続く)

以降はZAP+Fiddlerの多段プロキシを利用してどこまでできるか、という実験の記録です。

・実験1:ZAPの動的スキャンを無理やり複数画面遷移に対応させる(ZAP+Fiddler編)

入力-確認-完了という遷移があり、この完了の画面に対して一回ごとに必ず入力-確認という遷移を辿る必要がある場合、この完了画面に対してZAPの標準機能では動的スキャンをかけることができません。

当ブログで少し前に書いていた「OWASP ZAPのスクリプトを作ってみる」シリーズで、入力-確認-完了の遷移が毎回必要になるページへの動的スキャンというのを最終的にやりたかったのですが、できないで終わりました。

OWASP ZAPのスクリプトを作ってみる part9の末尾で

残件として、ZAPのスクリプトで複数画面遷移+動的スキャンができなかったのが心残りですが、とあるアドオンでできる可能性があるという情報を入手したので、今後時間ができたらそれについて調べて、できるようなら続きを書きます。


こう書いたので、今回はこれについての調査結果報告でもあります。

ZAPのマーケットプレイスで配布しているアルファ版の「sequence」というアドオンを導入すれば、複数画面遷移しつつ動的スキャンを実行、というのができそうという情報があってしばらく調べていたのですが、アルファ版なせいなのか、少なくとも私の環境ではどうもうまくそのようには動作しませんでした。

(プラグインを操作し画面遷移A-B-Cを登録して、動的スキャンを実施すると、1診断ごとにA-B-Cと遷移するようなログが表示されたが、実際にはA-B-の遷移はZAP内のキャッシュを読んでるだけで実際にリクエストしに行かないという挙動だったので使えなかった)

このプラグインの検証をしているうちに、ZAPで毎回複数画面遷移しつつ動的スキャンを実行というのを一度実現してみたい、という気持ちになってきたので、じゃあ試しにFiddlerスクリプトでゴリゴリ遷移を書いて実現したらどれくらい手間なのか、というのをやってみました。

結果が以下のスクリプトになります。

まず、複数画面を遷移するようなサンプルサイトが必要になるので、そのサンプルサイトを用意しました。
サンプルサイトのPHPソースは以下です。

https://sites.google.com/site/secmemofiles1452/cabinet/csrftest_forseq.zip

これをXAMPPなどPHPが実行できる環境に乗せれば複数画面遷移が必要なサンプルサイトになります。

サンプルサイトのページ構成は index.php(入力) - confirm.php(確認) - complete.php(完了)で、それぞれの遷移でそのページで発行されるCSRF防止用トークンが必要になります。

complete.php(完了)画面には、POST値を表示する箇所にXSS脆弱性が仕込んでありますが、これを検出するためには、CSRFチェックエラーにならずindex.php(入力) - confirm.php(確認) - complete.php(完了)と正しい遷移を行う必要があります。

このページ遷移に対応したFiddlerScriptを書いてみました。

「complete.php(完了)」へのアクセス発生時に、FiddlerScript側でindex.php(入力) - confirm.php(確認)へのアクセスを発行し、必要な値を取得して「complete.php(完了)」へのリクエストの値を上書きする、という感じです。
(セッションIDは付け替わるのでオリジナルのリクエストとは別ものになります)

以下がそのコードです。
// 複数画面遷移(マルチステップ)用関数
static function customMultiStep(url){

    // アクセスしようとしているURLが、複数画面遷移が必要なページであれば
    var sTargetUrl = 'localhost/csrftest_forseq/complete.php';
    if(url!==sTargetUrl){
        return false;
    } else {
        /* ターゲットのページにアクセスする前にpage1にアクセスし
           必要情報(PHPSESSID, anticsrftoken)を取得 */
        var oPage1ReqHeader: HTTPRequestHeaders = new HTTPRequestHeaders(
            /* Page1URL */
            "http://localhost/csrftest_forseq/",
            /* RequestHeader */
            [
            'Host: localhost',
            'User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:49.0) Gecko/20100101 Firefox/49.0',
            'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language: ja,en-US;q=0.7,en;q=0.3',
            'Connection: keep-alive',
            'Upgrade-Insecure-Requests: 1']
            );

        /* HTTPMethod */
        oPage1ReqHeader.HTTPMethod = "GET";

        var sPhpSes = '';
        var sPage1AntiCsrfTkn = '';
                
        var arResp = sendReqestForMulti(oPage1ReqHeader,[]);
        if(arResp!=false){

            // PHPSESSIDを取得
            var arPhpSes 
                = arResp[0]["Set-Cookie"].match(/PHPSESSID=(.*); path=/i);
            if(arPhpSes.length > 1){
                sPhpSes = arPhpSes[1];
            }

            // anticsrftoken(CSRF防止用トークン)取得
            var sPage1ResBody = arResp[1];
            var sPage1AntiCsrfTkn_RegEx =/input type=\"hidden\" name=\"anticsrftoken\" value=\"(.*)\"/i;
            var arPage1AntiCsrfTkn = sPage1ResBody.match(sPage1AntiCsrfTkn_RegEx);    
            if(arPage1AntiCsrfTkn.length > 1){
                sPage1AntiCsrfTkn = arPage1AntiCsrfTkn[1];
            }
        } else {
            return false;
        }
                
        /* ターゲットのページにアクセスする前にpage1の情報を使って
            page2にアクセスし、必要情報(anticsrftoken)を取得 */
        var sPage2Param = 'anticsrftoken='+ sPage1AntiCsrfTkn +'&name=Test+Taro&address=Test+Street+12345';
        var bPostParam: byte[] = System.Text.Encoding.UTF8.GetBytes(sPage2Param);
            
        var oPage2ReqHeader: HTTPRequestHeaders = new HTTPRequestHeaders(
            /* Page2URL */
            "http://localhost/csrftest_forseq/confirm.php",
            /* RequestHeader */
            [
            'Host: localhost',
            'User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:49.0) Gecko/20100101 Firefox/49.0',
            'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language: ja,en-US;q=0.7,en;q=0.3',
            'Referer: http://localhost/csrftest_forseq/',
            'Cookie: PHPSESSID='+sPhpSes,
            'Connection: keep-alive',
            'Upgrade-Insecure-Requests: 1',
            'Content-Type: application/x-www-form-urlencoded',
            'Content-Length: '+bPostParam.length.ToString()]
            );

        /* HTTPMethod */
        oPage2ReqHeader.HTTPMethod = "POST";
               
        var sPage2AntiCsrfTkn = '';
            
        //Send Request
        var arResp = sendReqestForMulti(oPage2ReqHeader,bPostParam);
        if(arResp!=false){
            var sPage2ResBody = arResp[1];
            var sPage2AntiCsrfTkn_RegEx =/input type=\"hidden\" name=\"anticsrftoken\" value=\"(.*)\"/i;
            var arPage2AntiCsrfTkn = sPage2ResBody.match(sPage2AntiCsrfTkn_RegEx);    
            if(arPage2AntiCsrfTkn.length > 1){
                sPage2AntiCsrfTkn = arPage2AntiCsrfTkn[1];
            }
        } else {
            return false;
        }
        return [sPhpSes,sPage2AntiCsrfTkn];
    }
}

// マルチステップ用リクエスト送信関数
static function sendReqestForMulti(oRQH, bPostParam){
    var oSD = new System.Collections.Specialized.StringDictionary();
    var newSession =
FiddlerApplication.oProxy.SendRequestAndWait(oRQH, bPostParam, oSD,
null);
    if(200 == newSession.responseCode){
        var arResHeaders = newSession.oResponse.headers;
        var sResBody = newSession.GetResponseBodyAsString();
        return [arResHeaders,sResBody];
    } else {
        return false;
    }
}

static function OnBeforeRequest(oSession: Session) {

    /* Multi Step */
    var arMultiFuncRet = customMultiStep(oSession.url);
    if(arMultiFuncRet !== false){
        var sBody=oSession.GetRequestBodyAsString();                                                    
        sBody=sBody.replace(/anticsrftoken=[^&]*&/i,"anticsrftoken="+arMultiFuncRet[1]+"&");
        oSession.utilSetRequestBody(sBody);

        var sCookie = oSession.oRequest["Cookie"]; 
        sCookie = sCookie.Replace("PHPSESSID=", "ignorePHPSESSID="); 
        sCookie = sCookie + ";PHPSESSID="+arMultiFuncRet[0];
        oSession.oRequest["Cookie"] = sCookie; 
    }

    ・
    ・
    ・
   (以後、OnBeforeRequestのもとの内容)

コードの解読は難しくないと思うので、コードの詳細な解説は割愛します。
* 記事公開時sendReqestForMulti関数が欠けていました。すみません。

このコードをFiddlerScriptに追加し、localhost/csrftest_forseq/complete.php にアクセスすると、Fiddler上で

・complete.php へのリクエストが表示されるが、それが待ちになっている間に index.php, confirm.php へのアクセスが発生し、それからcomplete.php へのリクエストが完了する。
・complete.php へのリクエストを見ると、オリジナルのPHPSESSIDがignorePHPSESSIDにリネームされ、FiddlerScript内でアクセスされたindex.php, confirm.phpの遷移時に発行されたPHPSESSIDがcomplete.php へのリクエストヘッダに追加されている

というようなログが発生します。

(ログの出方がcomplete.php - index.php - confirm.php という順で前後して出ます。あと何故かFiddlerScriptからアクセスしたURLが http:// からのフルパスで出力されます)


この状態で、complete.phpに対するPOSTをターゲットに、ZAPから動的スキャンを実行すると、1リクエストのたびにindex.php, confirm.php へのアクセスを行い、正しい遷移を行いながらcomplete.phpのPOST値を診断するという処理が実現できます。

Fiddler:

ZAP(XSS検出):


この複数画面遷移用のFiddlerScriptは、完全にサンプルサイト用に特化したコードですが、他の画面遷移に応用するときに書き換える箇所は、リクエストヘッダ(正規アクセス時のを配列にコピペ)と、レスポンスに含まれるどの値を取得するかの正規表現ぐらいなので、他サイトへの転用はそこまでは難しくないのではと思います。(とはいえ手間ですが)


・実験2:ZAPの動的スキャンを無理やり複数画面遷移に対応させる(ZAP HTTP Sender編)※失敗

実験1により、FiddlerScriptを使ってゴリゴリ書けば、一応複数画面遷移が必要なページへの動的スキャン実施ができることが分かりました。
ただ、いちいち対象のページに合わせて正規表現やらリクエストヘッダやらを書くのは非効率的なので、もうちょっと効率的な方法が欲しいところです。

ZAPのZestスクリプトであれば、遷移に必要な値を取得しつつ複数画面遷移を行うという処理が簡単に書けるので(このあたりの記事参照)、それを動的スキャン時に適用できれば良いはず、と思って、調べてみたところ、ZAPスクリプトの「HTTP Sender」の処理が使えそうなことが分かりました。

HTTP Senderスクリプトのサンプルを動かす

ZAPを起動し、サイトツリーの上部にある「サイト」タブの隣の「スクリプト」タブを開き(※)、「HTTP Sender」カテゴリを選択します。


※インストール直後のデフォルトのZAPでは「スクリプト」の機能が組み込まれていないので、「スクリプト」タブが表示されていない場合は、ZAPの[ヘルプ]-[アップデートのチェック]-[マーケットプレイス]で「Script Console」というアドオンおよび「Zest - Graphical Security Scripting Language」アドオンを導入してください。

ZAPの画面右下の「インフォメーション」の領域に下記のような英文が書いてあります。

HTTP Sender scripts run against every request/response sent/received by ZAP.

This includes the proxied messages, messages sent during active scanner, fuzzer, ...


You must enable them before they will be used.


HTTP SenderスクリプトはZAPが送受信するリクエスト/レスポンスに対し動作するということで、動的スキャンに対してスクリプトの処理を実行させたい場合は、HTTP Senderスクリプトを書くのが良いことになります。

本当に動的スキャンでZAPが発行するリクエストにこのスクリプトが干渉できるかどうか、組み込まれているサンプルで確かめてみます。

ツリーのHTTP Senderカテゴリを右クリック-新規スクリプトで ScriptEngine「ECMAScript」、テンプレート「HTTPSender_Default_template.js」で任意の名前でサンプルスクリプトを作成し、そのスクリプトを有効にしてからスキャンしてよい対象サイトに対し動的スキャンを実行してみると、サンプルスクリプトに含まれる下記2ファンクションが動作することが確認できます(※)。
「sendingRequest」がリクエスト送信時、「responseReceived」がレスポンス受信時に動作します。

※注意「<eval>:2 ReferenceError: "println" is not defined:29 ReferenceError: "println" is not defined」のようなエラーが出る場合は、コード中の「println」を「print」に書き換えてスクリプトを保存し、スクリプトを再有効化してください。

[HTTP Sender スクリプト](テンプレートから新規作成した状態の初期コード)
function sendingRequest(msg, initiator, helper) {
 // Debugging can be done using println like this
 println('sendingRequest called for url=' + msg.getRequestHeader().getURI().toString())
}

function responseReceived(msg, initiator, helper) {
 // Debugging can be done using println like this
 println('responseReceived called for url=' + msg.getRequestHeader().getURI().toString())
}
正常にスクリプトが動作している場合、ZAP右上のワークスペース下段に
sendingRequest called for url=http://localhost/zaptest0W45pz4p/csrftest_forseq/index.php
responseReceived called for url=http://localhost/zaptest0W45pz4p/csrftest_forseq/index.php
sendingRequest called for url=http://localhost/'%22%3Cscript%3Ealert(1);%3C/script%3E/csrftest_forseq/index.php
responseReceived called for url=http://localhost/'%22%3Cscript%3Ealert(1);%3C/script%3E/csrftest_forseq/index.php
sendingRequest called for url=http://localhost/zaptest/0W45pz4p/index.php
responseReceived called for url=http://localhost/zaptest/0W45pz4p/index.php
例えばこのような感じで、動的スキャン中でのリクエストURL、レスポンスURLがデバッグ的に出力されます。

複数画面遷移+動的スキャンをHTTPSender、Zestスクリプトで実現してみる ※失敗


このように動的スキャン中にスクリプトが動作するのであれば、うまく処理を書けば複数画面遷移+動的スキャンが実現できそうです。

・・・と思って少しやってみたのですが、うまく動作させることができませんでした。

Zestスクリプトで、上記のFiddlerScriptと同じロジックを組もうとして、complete.phpにアクセスがあったら、index.phpとconfirm.phpにアクセスして必要な値を変数に格納、というところまでは実現できましたが、そこから先がうまく実現できず、問題を解決する手段も調べが付かなかったので、今回は諦めることにしました。
(もしかしたら私の知らない情報が隠れていてうまく解決する方法はあるのかもしれませんが、手掛かりが見つけられませんでした)

出た問題:

・complete.phpにアクセスがあったら、index.phpとconfirm.phpにアクセスするというロジックを書いたら、何故かZAPにもFiddlerにも履歴が残らないリクエストが発生。Fiddlerをシステムプロキシとして動作させるモードにしたらその通信を捕捉できた。おそらくZest内部からの通信がZAPに設定されているプロキシ設定を使わないで直で繋ぎに行ったせいと思われる。(バグ?→再現させてバグ報告しようとしたが単にZestから外部URLに接続しに行くだけでは再現されず。complete.phpへのリクエストだけはFiddlerに捕捉されていたのでプロキシ設定ミスでもないし…再現条件不明な現象のためいったん削除します)
・complete.phpへのリクエスト時に、Zestスクリプト側でindex.php, confirm.phpへのリクエストを行うと、その後の処理で、complete.phpを指すポインタが失われてしまう(オリジナルのリクエストの値を改変して処理をZAPに戻したいが、オリジナルのリクエストの取得・改変などの方法が不明)


(Zestではなく、ECMAScriptのほうでゴリゴリ書けばたぶん実現できるのではないかと思いますが、それだと実験1 ZAP+Fiddlerと手間が変わらないのでメリットがあまりありません。複数画面遷移の時にZestのGUIで必要最低限の設定を加えれば動的スキャンできる、のような状態を目標にしていたのですが、今回はできませんでした)

後編へ

OWASP ZAPは、単体で使うよりもFiddlerやBurp Suiteなどの他のローカルプロキシと組み合わせて多段プロキシの形にして利用するほうが便利です。

■なぜZAP+他プロキシの形にするか


OWASP ZAPはいろいろ便利な機能がある反面、ログ機能に関していまいちな挙動をいくつか持っています。

OWASP ZAPのログ機能のいまいちさの例(ZAP 2.5.0での現象):

・「動的スキャン」のログと「履歴」のログが別で、診断対象サイトにアクセスしたり動的スキャンした後に、いったんセッションファイルを保存してZAPを再起動してセッションファイルを開くと、「履歴」のログのみが残っていて「動的スキャン」のログは復活しない(実施した動的スキャンのログはZAPを落とすまでは見れるが、保存されない)。

・リダイレクトが発生するページにアクセスした際、ZAPの通常の履歴にはリダイレクトの各ページの遷移が出てくるが、そのページに対して動的スキャンを実施した場合、動的スキャン側のログではリダイレクトの履歴が省略される場合がある(あたかもそのページからレスポンスが来たかのようなログになっているが、実際はリダイレクト先からのレスポンスである、というケースがある)。

・同様に、リダイレクトが発生するページに対して履歴右クリック-「再送信」を行うと、履歴ウィンドウに途中のリダイレクトが省略されて1回のリクエスト→レスポンスであるかのようなログが記録される。


ZAP「再送信」の時の例
Locationヘッダでindex.php - index2.php - index3.php とリダイレクトするサンプルページを作成し、
・ブラウザでindex.phpへアクセス
・ZAPの履歴ウィンドウでindex.phpを右クリック-再送信
した時のスクリーンショット:
[ZAP]

[Fiddler]


再送信の時にリダイレクトが記録されないのは単にZAPのバグなのかもしれないのですが(後で時間できた時にまたissue上げておきます→上げました)、上で挙げたようにZAPのログ機能にはいまいちな仕様が含まれているため、それに備えて、OWASP ZAPは単体で使うよりも、多段プロキシの形にしてZAPの挙動を別のプロキシで記録しながら使うほうが安全です。

(実施した診断が原因かもしれない問題が発生した場合、診断時に何を投げたのかのログが完璧に残っていないと詳細な原因調査ができなくなり、推測で原因を特定しなければならなくなりますし、診断が原因でなかった場合でも潔白が証明できなくなります。診断時のログは全て記録しておいた方が良いです)

また、Fiddlerを単にLoggerとして使うだけでもメリットはあるのですが、Fiddler Scriptを利用して多段プロキシならではの合わせ技のようなこともできるので、今回はその合わせ技をいくつか紹介します。

■ZAP+Fiddler 多段プロキシの設定方法


設定の概略としては

【ブラウザ】
・ブラウザのプロキシ設定としてlocalhostの7777番ポートを設定(ブラウザからの通信がlocalhost:7777へ送信される)

【OWASP ZAP】
・localhostの7777番ポートで待ち受ける設定を行う
・ZAP自身のプロキシ設定としてlocalhostの8888番ポートを設定(ZAPからの通信がlocalhost:8888へ送信される)

【Fiddler】
・localhostの8888番ポートで待ち受ける設定を行う


このように設定することで

【ブラウザ】-7777番ポート-【OWASP ZAP】-8888番ポート-【Fiddler】-【診断対象サイト】


というプロキシツールを二つ経由して対象サイトへアクセスする状態が作れます。

構成図


・ブラウザの設定
ウェブブラウザには通常プロキシサーバーを設定する設定項目があります。

例えばFirefoxであれば、[ツール]-[オプション]-[詳細]-[ネットワークタブ]-[接続設定] の画面を開くとプロキシの設定画面が出てきます。

HTTPプロキシとSSLプロキシの欄に、ZAPを動作させるサーバ(通常ローカルPCでブラウザと一緒に起動すると思うのでlocalhostか127.0.0.1)と、ZAPが待ち受けているポート(本設定例では7777)を設定します。


・ZAPの設定
設定1:
ZAPのメニューの[ツール]-[オプション]-[ローカルプロキシ]の設定画面でZAPがどのポートで待ち受けるかの設定を行います。
本設定例ではAddtess:localhost ポート:7777に設定してください。

設定2:
ZAPのメニューの[ツール]-[オプション]-[ネットワーク]の設定画面で[プロキシ・チェイン利用]の[外部プロキシサーバ利用]のチェックボックスをオンにすると、ZAPからの通信を中継するプロキシサーバを指定することができます。ここにFiddlerの待ち受けポートを設定します。
本設定例では「アドレス/ドメイン名」をlocalhost、ポートを8888に設定します。


・Fiddlerの設定

Fiddlerのメニューの[Telerik Fiddler Options]-[Connections]タブで「Fiddler listens on port」を8888(本設定例での値)に設定します。

あと、同じタブにある[Act as system proxy on startup]チェックボックスはオフにしたほうが良いと思います。これをオンにしているとFiddlerがシステムのプロキシとして働く設定で起動するので、ZAPから来た以外の全ての通信をキャプチャしてしまい、別ブラウザで開いたサイトの通信などもFiddlerのログに記録されてしまうからです。(これをオフにしておくと8888ポートに来た通信のみキャプチャします)


この設定を行った状態で、ブラウザからhttp通信のサイトを見ると、ZAPとFiddlerにそれぞれログが記録されることが確認できると思います。

※この設定で通信を問題なく中継できるのはhttpのサイトだけで、SSLを利用したhttpsプロトコルのサイトだとうまく中継できないと思います。httpsのサイトにアクセスできるようにするためには、この設定に加えて、別途、FiddlerやZAPの証明書を証明書ストアに登録する必要があります。

その手順について簡潔に解説して下さっているブログ記事がありましたので、参考資料としてリンクを張っておきます。

Fiddler、ZAPでhttps通信で証明書エラーを出さなくする(ブラウザルート証明書の登録) (linux-555様)
http://ameblo.jp/soft3133/entry-11774215969.html

■ZAP+Fiddler 小技集


・小技1:リクエストに対し一定のウェイト(ディレイ)をかける

FiddlerではFiddlerScriptをカスタマイズすることでFiddlerを通るリクエストやレスポンスに一定のウェイト(ディレイ)をかけることが可能です。
そのカスタマイズ機能を利用し、ZAPの動的スキャンのアクセスの速度をFiddler側で制御して、動的スキャン対象サーバーにかける負荷を緩めることが可能です。

Fiddlerの[Rules]-[Customize Rules...] を選択すると、FiddlerScript と呼ばれるFiddlerカスタマイズ用スクリプトが表示されます。

FiddlerScript のOnBeforeRequestという関数内部に(どこでもかまいませんが、冒頭あたりに)
System.Threading.Thread.Sleep(1000);
このコードを書いておくと、ZAPの動的スキャンなどの際に、1リクエストごとに所定のウェイト(上記であれば1000ミリ秒)が入ることになるため、動的スキャン対象サーバへ負荷をかけすぎずに診断を行うことができます。

またFiddlerのカラムにリクエスト時間を表示しておいたほうが何かと便利なので、stackoverflowの下記スレッド
How to display the request sent time and the response received time in Fiddler?

> For Fiddler 4.6.2, Right Click on any of the Column Headers on the Sessions pane.
> Customize Columns > Collection > SessionTimers > ClientBeginRequest and ClientDoneRequest

このカスタマイズを入れておくと良いと思います。

OWASP ZAPにも動的スキャン時にリクエストを遅延させる設定項目はあるのですが、

・ZAPの動的スキャンでは最大1秒しかウェイトを設定できない
→ Fiddler側で調整する形だと何秒でも設定できる。
・ZAPのウェイト指定だと動的スキャン中にウェイトの設定を柔軟に変えることができない
→ Fiddlerでウェイトを調整するとどのタイミングでも柔軟に調節できる

という理由によりOWASP ZAP単体のウェイト指定よりもFiddler側でウェイトをかける方式のほうが使い勝手が良いです。

また、多段プロキシを用いて診断の負荷を調節するような方法は汎用的なテクニックなので、ZAP以外にも外部プロキシが設定できるツール全般に使えます。例えばniktoなどにも外部プロキシの設定オプションがあるので、多段プロキシの形にすることでniktoのリクエスト間隔を調整することができます。


・小技1-1:静的なコンテンツへのリクエストに対してはウェイトをかけない

上に書いたコードの条件追加版です。単に
System.Threading.Thread.Sleep(1000);
だけを書くと、Fiddlerを通るすべてのリクエストに対しウェイト1秒が発生しますが、それだと、画像などの静的なコンテンツを大量に読み込むページなど、リクエスト数がやたら多いページにアクセスする場合、パフォーマンスの劣化が著しいということになります。

そのために、診断対象でない静的コンテンツやHTTPSの場合のCONNECTメソッドなどを除外する条件を追加したものが下記になります。
if (!oSession.HTTPMethodIs("CONNECT")
     && !oSession.oRequest.headers.ExistsAndContains("Accept", "image/") 
     && !oSession.uriContains(".js")){
    System.Threading.Thread.Sleep(1000);
}

・小技2:基本認証を通す

基本認証が設定されているページの診断の場合、ZAPの設定で基本認証を通すことも可能ですが、FiddlerScriptでリクエストヘッダにAuthorizationヘッダを追加するという方法があり、こちらほうが手軽です。(追加箇所はOnBeforeRequest関数内)
oSession.oRequest["Authorization"] = "Basic dGVzdDp0ZXN0";

しかし、上記のコードだと全リクエストヘッダに基本認証のID:パスワードのBase64値を付けて送信してしまうので、対象のサイト以外にアクセスした際にも、設定した基本認証のIDパスワードを送り付けてしまうことになるため、以下のように、「このホストだったら」というのを付けたほうが良いと思われます。
(ホスト単位以上にもっと細かい条件分けが必要な場合はFiddler公式ドキュメントのこのあたりを参照ください)
if(oSession.host === "example.com"){
    oSession.oRequest["Authorization"] = "Basic dGVzdDp0ZXN0";
}

・小技3:特定のホストに対しては特定のプロキシを通す

(追加箇所はOnBeforeRequest関数内)
if(oSession.host === "example.com" ){
    oSession["x-overrideGateway"] = "127.0.0.1:1234"; 
}

・小技4:ZAPが投げる危険な文字列をFiddlerでDROPする(サンプルコード)

ZAPが投げる診断文字列に、サーバ側に送信したくない特定の文字列が含まれている場合にFiddlerでDROPして偽のレスポンスを返すサンプルコードです。

このコードはもともとZAPのSQLインジェクション検査で投げられる「 OR 1=1 -- 」などの危険な文字列をDROPするために書いてみたのですが、ZAPのSQLインジェクション診断用文字列は危険なものが多く、診断文字列を部分的にDROPするよりThresholdをlowにして危険な文字列を投げない最低限の検査を行い、残りは手動で検査したほうがよさそうという結論になったため、このコードは現在は使っていません。

ただ、特定の文字列がリクエストヘッダもしくはボディに含まれていた場合にサーバに投げないでDROPするという処理はどこかで使う機会がありそうなので、自分用メモも兼ねてコードを公開しておきます。
(追加箇所はOnBeforeRequest関数内)
var bBlockOr = false;

// リクエストヘッダに危険なSQLiパターンがあれば
var ReqHeaders = oSession.oRequest.headers.ToString();
if(ReqHeaders.indexOf("+OR+1%3D1+--+") > -1 
 || ReqHeaders.indexOf(" OR 1=1 --") > -1 ){
    bBlockOr = true;
}

// リクエストボディに危険なSQLiパターンがあれば
var ReqBody = System.Text.Encoding.UTF8.GetString(oSession.requestBodyBytes);
if(ReqBody.indexOf("+OR+1%3D1+--+") > -1 
 || ReqBody.indexOf(" OR 1=1 --") > -1 ){
    bBlockOr = true;
}

// リクエストのブロック処理(偽のレスポンスを返す)
if(bBlockOr == true){ 
    oSession.utilCreateResponseAndBypassServer();
    oSession.oResponse.headers.SetStatus(503,"filtered (SQLi test blocked)");
} 


長くなったので記事を分けて前・中・後編とします。中編に続きます。

※記事中で紹介したサンプルコードはなるべく間違いのないように心がけておりますが、無保証です。利用する場合は自己責任にて改めて検証の上ご利用ください。
(何かミスを見つけたらお知らせください)

中編へ

Powered by Blogger.
© WEB系情報セキュリティ学習メモ Suffusion theme by Sayontan Sinha. Converted by tmwwtw for LiteThemes.com.