タグ: トラブルシューティング

  • 自分のブログに記事が投稿できない — WAFの誤検知と、渡し方を変える解決

    自分のブログに記事が投稿できない — WAFの誤検知と、渡し方を変える解決

    自分のブログに、自分で書いた記事が投稿できなくなりました。

    原因はサーバーのWAF(Webアプリケーションファイアウォール)でした。記事の本文に含まれるコードが、攻撃と誤検知されて遮断されていたのです。回避を4種類試して全部失敗し、最後に「内容を変えずに、渡し方を変える」ことで解決しました。

    この記事で扱うこと

    WAFによる誤検知、二分探索を使った原因の特定、実体参照によるすり抜けがなぜ効かないのか、そしてWordPressのインポート機能を使った解決。技術記事を書く人ほど踏みやすい問題です。

    403だけが返ってくる

    記事をWordPressのREST APIに送ると、403が返りました。

    返ってきたもの
    status: 403
    body: (空。WordPressのエラー形式ですらない)

    ここに最初の手がかりがあります。WordPressが権限で拒否する場合、rest_cannot_create のようなコードを含むJSONを返します。中身が空の403は、WordPressに届く前に止められていることを意味します。

    直前まで同じ手順で5本の記事を投稿できていました。コードも認証も変えていません。変えたのは記事の中身だけです。

    どこが引っかかっているのかを探す

    本文は約13,000文字あります。どこが原因かを目で探すのは現実的ではありません。

    そこで二分探索を使いました。本文の前半だけを送ってみて、通れば原因は後半にあり、弾かれれば前半にある。これを繰り返して範囲を半分ずつ狭めていきます。13,000文字なら、14回ほどで場所が特定できます。

    やったこと(ブラウザのコンソールで実行)
    const ok = async (text) => {
      const res = await fetch('/wp-json/wp/v2/posts', {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
        body: JSON.stringify({ title: 'probe', content: text, status: 'draft' })
      });
      return res.status === 201;
    };
    
    let lo = 0, hi = content.length;
    while (hi - lo > 16) {
      const mid = Math.floor((lo + hi) / 2);
      if (await ok(content.slice(0, mid))) lo = mid; else hi = mid;
    }
    console.log(content.slice(lo - 50, hi + 16));   // 境界の前後を表示

    結果はこうでした。

    const HEADING_PATTERN = 正規表現でHTMLの見出しタグを拾う行

    記事の中で紹介していた、見出しを抽出する正規表現です。WAFから見ると、HTMLタグを組み立てようとしている文字列=コード注入の試みに見えます。

    検証用のデータを残さない工夫

    上の関数は、投稿に成功した場合その投稿をすぐゴミ箱に入れています。二分探索は十数回の投稿を行うので、これをやらないと「probe」という記事が大量に残ります。調査のために本番環境を汚さないのは、調べる前に決めておくべきことです。

    すり抜けようとして、4回失敗した

    原因が分かったので、表示を変えずに検知だけ避ける方法を順に試しました。

    試したこと ねらい 結果
    バックスラッシュを数値文字参照に エスケープ記号を消す 失敗
    バッククォートも数値文字参照に コマンド置換に見える形を消す 失敗
    コード部分の記号をすべて数値文字参照に 記号の並びを根本から消す 失敗
    JSONではなくフォーム形式で送る 検査対象から外れることを期待 失敗

    3番目で分かったことが重要でした。WAFは実体参照を元に戻してから判定しています。

    これは考えてみれば当然です。攻撃者も同じ手を使うからです。<script> と書けば通ってしまうWAFには、防御の意味がありません。ブラウザが解釈する前の姿ではなく、解釈した後の姿で判定するのが正しい実装です。

    つまり、表示が同じである限り、エンコードを変えても結果は変わりません。目的が「表示は同じまま通す」ことである以上、この方向に出口はないと分かりました。

    ひとつだけ、通っていた経路があった

    行き詰まったところで、それまでの作業を振り返りました。

    記事の本文をブラウザに渡すために、13,000文字のJSONファイルをWordPressのメディアにアップロードしていました。そのアップロードは、一度も失敗していません。

    中身は同じです。同じ正規表現も、同じコードも入っています。それでも通っていました。

    送り方 中身 結果
    JSONの本文として送る 同じ 403
    フォームの項目として送る 同じ 403
    ファイルとして送る 同じ 成功

    このWAFは、フォームの入力値は検査するが、ファイルの中身は検査していないという動きをしていました。

    これは手抜きではなく、設計上の判断だと思われます。アップロードされるファイルは画像や動画やアーカイブで、中身は任意のバイト列です。すべてを攻撃パターンで検査すれば、正常なファイルが大量に誤検知されます。

    正規の道具が、すでに用意されていた

    ファイルなら通る。ではファイルとして記事を投入する方法はあるか。

    あります。WordPressのインポート機能です。サイト間で記事を引っ越すための仕組みで、記事をWXR(WordPress eXtended RSS)という形式のXMLファイルとして読み込みます。

    article6.xml(抜粋)
    <item>
      <title>読む体験をつくる</title>
      <dc:creator><![CDATA[sakurai_dev]]></dc:creator>
      <content:encoded><![CDATA[ ここに記事本文のHTML ]]></content:encoded>
      <wp:post_name><![CDATA[reading-experience]]></wp:post_name>
      <wp:status><![CDATA[publish]]></wp:status>
      <wp:post_type><![CDATA[post]]></wp:post_type>
      <category domain="category" nicename="tech"><![CDATA[技術系]]></category>
      <category domain="post_tag" nicename="react"><![CDATA[React]]></category>
    </item>

    本文は CDATA という区画に入れます。この中身はXMLとして解釈されないので、HTMLタグをそのまま書けます。

    「ツール → インポート → WordPress」からこのファイルをアップロードすると、記事・カテゴリ・タグがまとめて作られます。一度で通りました。

    抜け道ではない

    この方法は、WAFをだましているわけではありません。WordPressが元から用意している正規の移行手段を、そのまま使っただけです。防御は一切弱めていません。<script>alert(1)</script> は今も遮断されたままです。

    WAFを無効にしなかった理由

    途中、WAFを一時的に無効にして試したことがあります。実際、正規表現の行は通るようになりました。しかし記事全体はまだ弾かれ、<script> も遮断されたままでした。防御が複数の層で構成されていたためです。

    ここで「全部無効にする」という選択もありました。しかし、それはやめました。

    理由は単純です。この問題は今後も繰り返し起きます。技術記事を書く以上、本文にコードが入るのは避けられません。次に何が引っかかるかは予測できません。そのたびにWAFを無効にしていたら、いずれ「戻し忘れ」が起きます。

    作業のたびに防御を外す運用は、いつか必ず外したままになります。人間の注意力に頼る仕組みは、長く続けると必ず破れます。防御を触らずに済む経路が見つかったなら、そちらを選ぶべきです。

    今回の学び

    まず、空の403は「届いていない」という意味だということ。アプリケーションが返すエラーには、たいてい理由が書いてあります。理由の無いエラーは、アプリケーションより手前で止められています。どこを調べるべきかが、これで決まりました。

    次に、手作業で探さないこと。13,000文字から原因の1行を見つけるのに、二分探索なら14回で済みます。目で追えば見落としますし、時間もかかります。範囲を半分ずつ狭める方法は、原因が1か所に絞れる問題ならほぼ常に使えます。

    そして、同じ内容でも、渡し方が変われば結果が変わるということ。今回いちばんの発見はこれでした。中身をどう変形しても通らなかったものが、経路を変えた瞬間に通りました。行き詰まったら、変える対象を「中身」から「経路」に移す。この視点は他の場面でも使えます。

    最後に、防御を弱める解決を最後の手段にすること。無効にすれば即座に解決しますが、それは問題を先送りしているだけです。しかも「一時的に」で済んだためしがありません。遠回りに見えても、防御を維持したまま通る道を探す価値はありました。