(前編から続く)
以降は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
[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で必要最低限の設定を加えれば動的スキャンできる、のような状態を目標にしていたのですが、今回はできませんでした)
後編へ