自分のブログに記事が投稿できない — 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か所に絞れる問題ならほぼ常に使えます。

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

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