GA4 に出る不審な海外アクセスの調査手順|アクセスログで分散ボットを特定する

  • URLをコピーしました!
目次

はじめに

GA4(Google アナリティクス 4)のレポートを確認していると、Urumqi(ウルムチ)など、心当たりのない海外の都市からのアクセスが継続的に計上されていることがあります。リアルタイムレポートにも表示されるため実際の訪問者のように見えますが、ページの内容や滞在時間を考えると不自然であり、レポートの数値を歪める要因になります。

本記事では、筆者のブログで実際に発生した Urumqi からの不審なアクセスを題材に、サーバーのアクセスログを使って正体を特定するまでの調査手順と、.htaccess によるブロック設定をまとめます。

この記事でわかること
  • GA4 に不審な海外アクセスが計上される仕組み
  • アクセスログ(生ログ)を grep で調査し、ボットを特定する手順
  • IP アドレスと User-Agent を切り替える分散型クローラーの見抜き方
  • .htaccessRequire not ip による IP レンジ単位のブロック設定

結論を先に述べると、今回のアクセスの正体は、ページ本体とアセット(CSS・JS・画像)の取得でそれぞれ別の IP レンジと User-Agent を使い分ける、分散型のクローラーでした。JavaScript を実行するため GA4 には一般ユーザーとして計上されますが、アクセスログを時系列で突き合わせることで挙動の特徴を特定でき、.htaccess の IP レンジ遮断で対処できます。

GA4 に現れた Urumqi からの不審なアクセス

事象の概要と最初に確認したこと

発端は、GA4 のリアルタイムレポートでした。日本語の技術記事しか公開していないにもかかわらず、Urumqi からのアクティブユーザーが繰り返し表示され、特定の記事ページやトップページを閲覧している状態が続いていました。

単発であれば偶発的な訪問の可能性もありますが、今回は以下の点が不自然でした。

  • 数時間おきに、ほぼ同じパターンで出現する
  • 閲覧ページに一貫性がなく、記事・カテゴリーページ・日付アーカイブを機械的に巡回しているように見える
  • 参照元が特定できない

この時点で考えられる原因は、大きく次の 2 つに分けられます。

実際にサーバーへアクセスしているボット

ヘッドレスブラウザ等で JavaScript を実行しながら巡回するクローラー。サーバーのアクセスログに痕跡が残る。

計測 ID を悪用したスパム

サイトの HTML から測定 ID(G- から始まる ID)を抜き取り、サーバーを経由せず Google に直接計測データを送り込む手口。アクセスログには痕跡が残らない。

どちらであるかによって対処方法がまったく異なるため、まずサーバーのアクセスログと GA4 の記録を突き合わせて、切り分けを行う必要があります。この切り分けの手順が、本記事の中心テーマです。

GA4 に計上される条件(gtag.js の実行)

切り分けの前提として、GA4 にアクセスが計上される条件を整理します。GA4 の計測は、ページに埋め込まれた Google タグ(gtag.js)という JavaScript ライブラリがブラウザ上で実行され、Google のサーバーへ計測データを送信することで成立します。

参考: gtag.js(Google アナリティクス ヘルプ)
“The Google tag uses the gtag.js JavaScript library to send data to Google Analytics.”
(Google タグは gtag.js JavaScript ライブラリを使用して、Google アナリティクスにデータを送信します)
https://support.google.com/analytics/answer/10220869

ここから、調査に使える重要な性質が 2 つ導けます。

  • HTML を取得しただけのクローラーは GA4 に計上されない。検索エンジンのクローラーや curl などによる単純な取得は、JavaScript を実行しないため計測データが送信されません。
  • GA4 に計上されている以上、「JavaScript を実行する何か」が存在する。それがサーバーにアクセスした上で実行しているのか、サーバーを経由せず計測データだけを送り込んでいるのかが、切り分けのポイントになります。

つまり、GA4 に記録された時間帯のアクセスログに「ブラウザとしての一連の挙動(HTML・CSS・JS・画像の取得)」が残っていれば前者、何も残っていなければ後者の可能性が高い、という判定ができます。次のセクションでは、この判定を行うための具体的な grep 手順を解説します。

アクセスログで正体を調査する手順

このセクションでは、GA4 に記録された不審なアクセスの正体を、サーバーのアクセスログから特定する手順を解説します。調査の流れは「該当ページへのアクセスを抽出する → HTML 本体とアセットを切り分ける → JavaScript 実行の有無を判定する」の 3 段階です。使用するコマンドは lessgrep のみで、レンタルサーバーの SSH 環境でそのまま実行できます。

前提として、アクセスログの各行には「接続元 IP・日時・リクエスト・ステータスコード・リファラ・User-Agent」が記録されています。この形式は Apache の Combined Log Format と呼ばれる標準的なものです。

参考: Log Files(Apache HTTP Server 公式ドキュメント)
“exactly the same as the Common Log Format, with the addition of two more fields”
(Common Log Format とまったく同じで、さらに 2 つのフィールドが追加されたもの)
https://httpd.apache.org/docs/2.4/logs.html

追加された 2 つのフィールドがリファラと User-Agent であり、今回の調査ではこの 2 つが重要な手がかりになります。

該当ページへのアクセスを grep で抽出する

まず、GA4 で不審なアクセスが記録されていたページ(今回は /ransomware-protection/)に関するログを抽出します。

less access_log | grep "ransomware-protection"

レンタルサーバーの環境によっては、grep access_log のようにファイルを直接指定する形式が動作しない場合があります。その場合は上記のように less からパイプで渡す形式が確実です。

このコマンドで、該当ページの HTML 本体・CSS・JavaScript・画像など、ページに関係するすべてのリクエストが時系列で表示されます。ただし、このままではアセット(CSS や画像)のリクエストが大量に混ざるため、次の手順で切り分けます。

HTML 本体とアセットのリクエストを切り分ける

「誰がページを開いたのか」を知りたい場合、見るべきは HTML 本体への GET リクエストです。grep -v でアセットのパス(wp-contentwp-includes)を除外すると、HTML 本体のリクエストだけを抽出できます。

less access_log | grep "ransomware-protection/ HTTP" | grep -v "wp-content\|wp-includes"

筆者の環境で実行した結果の抜粋が以下です。(IP アドレスの第 4 オクテットはマスクしています)

111.225.214.xxx - - [06/Jul/2026:18:54:47 +0900] "GET /ransomware-protection/ HTTP/2.0" 200 33284 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) ... Chrome/48.0.2564.116 Safari/537.36"

ここで注目すべきポイントは 2 つあります。

  • User-Agent の Chrome バージョンが極端に古い。Chrome/48 は約 10 年前のバージョンであり、自動更新が標準の現在の実ユーザー環境ではまず出現しません。UA 文字列を固定値でハードコードした自動化ツールの痕跡と考えられます。
  • リファラが「-」(なし)。検索エンジンやリンク経由の訪問であればリファラが付くのが自然です。

さらに時系列を追うと、この直後にアセットのリクエストが別の IP アドレスから発生していました。

116.179.33.xxx - - [06/Jul/2026:18:54:58 +0900] "GET /wp-content/uploads/2026/06/ransomware-protection-01.jpg HTTP/1.1" 200 118238 "https://mytech-blog.com/ransomware-protection/" "Mozilla/5.0 ... Chrome/99.0.4844.51 Safari/537.36"

HTML 本体は 111.225.214.xxx(Chrome/48)、画像は 116.179.33.xxx(Chrome/99)と、1 回のページ閲覧であるにもかかわらず、接続元 IP と User-Agent が途中で切り替わっています。通常のブラウザ閲覧ではあり得ない挙動であり、これが分散型クローラーを見抜く決定的な手がかりになります(詳細は次のセクションで解説します)。

PV カウンターの発火有無で「ブラウザ実行」を判定する

前セクションで整理したとおり、GA4 に計上されるのは JavaScript が実行された場合のみです。したがって「このアクセスは JavaScript を実行しているのか」を判定できれば、GA4 の記録との対応付けができます。

WordPress のテーマやプラグインの多くは、JavaScript 経由で PV カウント用のリクエストを送信します。筆者の環境では、ページ閲覧時に POST /wp-json/wp/v2/swell-ct-pv というリクエストが JavaScript から発行されるため、これを「JavaScript が実行された証拠」として利用できます。使用しているテーマによってエンドポイントは異なりますが、同様の PV カウント用リクエストがあれば判定に使えます。

less access_log | grep "swell-ct-pv" | grep '"https://mytech-blog.com/ransomware-protection/"'

実行結果の抜粋が以下です。

116.179.33.xxx - - [06/Jul/2026:18:55:04 +0900] "POST /wp-json/wp/v2/swell-ct-pv HTTP/1.1" 403 199 "https://mytech-blog.com/ransomware-protection/" "Mozilla/5.0 ... Chrome/99.0.4844.51 Safari/537.36"

HTML 取得(18:54:47)→ 画像取得(18:54:58)→ PV カウンター POST(18:55:04)と、一連のブラウザ挙動が時系列で確認できました。このクローラーは JavaScript を実行しており、だからこそ GA4 に一般ユーザーとして計上されていた、という因果関係がここで確定します。

なお、この POST がステータスコード 403 で拒否されている点にも意味があります。REST API 側の検証によりカウント自体は弾かれていますが、GA4 の計測(gtag.js)は Google のサーバーへ直接送信されるため、この 403 とは無関係に計上されます。「WordPress 側の PV カウントには乗らないのに GA4 には出る」という食い違いも、この仕組みで説明できます。

分散クローラーの特徴と見抜き方

前セクションの調査で確認できた挙動を整理すると、今回のクローラーには 3 つの明確な特徴がありました。ここでは、それぞれの特徴と、実ユーザー・正規 bot との判別ポイントをまとめます。

ページ本体とアセットで IP レンジが分かれる挙動

最大の特徴は、1 回のページ閲覧の中で、リクエストの種類ごとに接続元 IP レンジが切り替わることです。今回の観測では、次のような役割分担が確認できました。

リクエストの種類接続元 IP レンジUser-Agent
HTML 本体の GET111.225.214.0/24、119.249.100.0/24Chrome/48
CSS・JS・画像の GET116.179.33.0/24Chrome/99
PV カウンターの POST116.179.33.0/24Chrome/99

時系列で見ると「HTML 取得(レンジ A)→ 十数秒後にアセット取得(レンジ B)→ PV カウンター POST(レンジ B)」という一連の流れが、数時間おきに別のページで繰り返されていました。通常のブラウザ閲覧では、1 セッション内のリクエストはすべて同一の IP から発生するため、この挙動はリクエスト単位で出口を切り替えるプロキシ網を経由した自動アクセスと判断できます。

この構造には、調査上の落とし穴があります。アセット取得側のレンジだけを grep で追跡しても、HTML 本体のリクエストが一切見つからず、「ページを見た形跡がないのに画像だけ取得されている」という不可解な結果になります。リファラに残されたページ URL を起点に、「そのページの HTML 本体を同時刻帯に取得した別の IP」を探すことで、初めて全体像がつながります。

古い Chrome バージョンの UA という指紋

2 つ目の特徴は、User-Agent の Chrome バージョンです。観測されたのは Chrome/48.0.2564 と Chrome/99.0.4844 で、いずれも数年以上前にリリースされたバージョンでした。Chrome は自動更新が標準であるため、実ユーザーの環境でこれほど古いバージョンが継続的に出現することは考えにくく、UA 文字列を固定値で埋め込んだ自動化ツールの指紋と判断できます。

しかも、この 2 つのバージョンは前述の役割分担(HTML 本体係が Chrome/48、アセット係が Chrome/99)と綺麗に対応しており、IP レンジが変わっても UA は固定のままでした。IP は無数に切り替えられても UA まで毎回変える実装にはなっていないケースが多く、分散型クローラーの追跡では UA が IP よりも安定した識別子になることがあります。

実ユーザー・正規 bot との判別ポイント

不審なアクセスを疑う際は、誤って実ユーザーや正規のクローラーを遮断しないことが重要です。今回の調査で整理できた判別ポイントは以下のとおりです。

実ユーザー

単一の IP から、HTML → CSS/JS → 画像 → PV カウンターまでを数秒以内に一括取得する。リファラに検索エンジンやサイト内ページが残ることが多い。UA は最新に近いバージョン。

正規 bot(Googlebot、bingbot 等)

UA に bot 名と確認用 URL を明示している。JavaScript の実行有無は bot により異なるが、身元を隠さないため UA だけで判別できる。

今回のような偽装クローラー

UA はブラウザを装うが、バージョンが極端に古い。IP がリクエスト単位で分かれる。リファラなしで記事・カテゴリー・日付アーカイブを機械的に巡回する。

なお、正規 bot の UA は偽装される場合もあるため、確実に判定したい場合は逆引き DNS による検証が推奨されますが、今回のように「UA のバージョンが極端に古い」「IP が途中で切り替わる」という時点で、正規 bot でも実ユーザーでもないことは十分に判断できます。

.htaccess でのブロック設定

正体が「サーバーに実際にアクセスしている分散クローラー」と特定できたため、サーバー側でのブロックが有効です。ここでは .htaccess を使った IP レンジ単位の遮断設定を解説します。

RequireAll と Require not ip による IP レンジ遮断

Apache 2.4 系では、アクセス制御に Require ディレクティブを使用します。「全体は許可しつつ、特定の IP レンジだけを拒否する」場合は、<RequireAll> ブロックの中に Require all grantedRequire not ip を組み合わせます。

参考: Access Control(Apache HTTP Server 公式ドキュメント)
“You can insert not to negate a particular requirement.”
(not を挿入することで、特定の要件を否定できます)
https://httpd.apache.org/docs/2.4/howto/access.html

今回のクローラーで確認した 3 レンジをブロックする設定例が以下です。.htaccess の WordPress ルールより前(ファイル冒頭付近)に記載します。

# ---- 分散クローラーのブロック設定 ----
<RequireAll>
    # 基本はすべて許可
    Require all granted
    # HTML 本体取得に使用されていたレンジ
    Require not ip 111.225.214.0/24
    Require not ip 119.249.100.0/24
    # アセット・PV カウンター取得に使用されていたレンジ
    Require not ip 116.179.0.0/16
</RequireAll>

ポイントは 3 つあります。

  • Require not ip は CIDR 表記でレンジ指定が可能です。個別 IP を列挙するのではなく、観測された IP が属するサブネット単位(/24 など)で指定することで、同一レンジ内の別 IP からの再訪をまとめて遮断できます。
  • レンジの広さは慎重に決めることを推奨します。今回、アセット係のレンジは複数の /24 にまたがって観測されたため /16 で指定していますが、HTML 本体係は観測された /24 に限定しています。広いレンジ(/16 など)は同一 ISP の一般ユーザーを巻き込むリスクがあるため、ログで実際に観測された範囲を基準に判断します。
  • なお、古い解説記事で見かける Order / Allow / Deny ディレクティブは Apache 公式で非推奨とされており、将来のバージョンで廃止予定です。新規に設定する場合は Require 系での記述を推奨します。

IP レンジ単位の遮断で追いきれない場合や、多層防御の観点からは、WAF の併用も選択肢になります。詳細は関連記事『WAF とは|仕組みと種類、選定のポイント』も参照してください。

設定時の注意点(WordPress ルールを消さない・バックアップ)

.htaccess の編集は、記述を誤るとサイト全体がエラーになる影響の大きい作業です。今回の作業で実際に確認できた注意点を 2 つ挙げます。

STEP
編集前に必ずバックアップを取得する

SSH 接続できる環境であれば、編集前に 1 コマンドで退避できます。

cp .htaccess .htaccess.bak_$(date +%Y%m%d)

万一表示が崩れた場合も、cp .htaccess.bak_20260706 .htaccess のように書き戻すだけで復旧できます。

STEP
WordPress の基本ルール(# BEGIN WordPress 〜 # END WordPress)を残す

WordPress の .htaccess には、以下のようなブロックが自動生成されています。

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress

このブロックは、パーマリンク(/example-post/ のような URL)へのリクエストを index.php に振り分ける、WordPress の動作に不可欠な設定です。ブロック設定を追記する際に誤ってこの部分まで削除・変更すると、トップページ以外のすべてのページが 404 エラーになります。筆者も編集時にこのブロックを誤って削除してしまい、自身のアクセスまで 404 になる事態を経験しました。ブロック設定の追記は WordPress ルールの外側(前方)に行い、# BEGIN WordPress# END WordPress の範囲には手を加えないことを推奨します。

ブロック後の効果確認

.htaccess の設定を反映したら、実際に遮断できているかを確認します。確認はサーバー側(アクセスログ)と GA4 側の両面で行います。

アクセスログで 403 への変化を確認する

ブロック対象のレンジからのアクセスが、ステータスコード 403(Forbidden)に変わっていれば設定は有効です。設定反映後、時間をおいてから以下のコマンドで確認します。

less access_log | grep "111.225.214\|119.249.100\|116.179" | tail -20

各行のステータスコード(リクエスト文字列の直後の 3 桁)に注目してください。設定前は 200 だった HTML 本体やアセットの取得が、設定後はすべて 403 になっていれば、クローラーはページの内容を取得できていません。HTML が取得できなければ gtag.js も実行されないため、GA4 への新規の計上もここで止まります

なお、ブロック後もアクセスの試行自体はログに記録され続けます。これは異常ではなく、クローラー側が遮断に気づかず巡回を続けているだけです。試行が続くこと自体の負荷はごく小さいため、403 が返っていれば対処としては完了と判断できます。

あわせて、誤って正規のアクセスを巻き込んでいないかの確認も推奨します。ブロックした CIDR レンジに対して 403 以外のアクセス(自分自身、Googlebot 等)が含まれていないか、設定後数日はログを観察すると安心です。

GA4 側のレポートフィルタ運用

サーバー側で遮断しても、遮断前に計上されたデータは GA4 に残り続けます。過去分を含めてレポートから不審なトラフィックを除外したい場合は、GA4 側での対応が必要です。

ここで注意すべき点として、GA4 の「データフィルタ」は今回のケースには使えません。データフィルタで除外できるのは次の 2 種類に限られるためです。

参考: Data filters(Google アナリティクス ヘルプ)
“Internal traffic: Filter out users with an IP address or range of IP addresses.”
(内部トラフィック: 特定の IP アドレスまたは IP アドレス範囲のユーザーを除外します)
https://support.google.com/analytics/answer/13296761

  • デベロッパートラフィック: デバッグモードを使用する開発者のアクティビティを除外する
  • 内部トラフィック: 事前に定義した IP アドレス(自社・自分の環境)からのトラフィックを除外する

つまり、データフィルタは「自分側の既知の IP」を除外する仕組みであり、外部のボットを市区町村名や任意の条件で除外することはできません。今回のようなケースでは、レポート閲覧時にフィルタをかける「レポートフィルタ」または探索レポートのセグメントで対応します。

具体的には、探索レポートで次のいずれかのフィルタを作成し、保存して常用します。

  • 市区町村(City)が Urumqi に一致(一致条件を「完全一致」に指定)→ 除外
  • または、対象期間のレポート全体に「市区町村 ≠ Urumqi」の条件を適用

レポートフィルタはデータ自体を削除しないため、後から「除外していた期間にどれだけボットが来ていたか」を検証することも可能です。データフィルタと異なり不可逆な操作ではない点も、運用上の利点といえます。

まとめ

GA4 に計上される不審な海外アクセスは、GA4 の画面だけを眺めていても正体はわかりません。サーバーのアクセスログという一次情報に立ち返り、HTML 本体・アセット・JavaScript 実行の痕跡を時系列で突き合わせることで、今回は IP と User-Agent を切り替える分散型クローラーであると特定できました。特定さえできれば、対処は .htaccess の標準的な記述で完結します。

  • GA4 に計上されるのは gtag.js が実行されたアクセスのみ
  • 調査はアクセスログの grep で完結し、特別なツールは不要
  • HTML 本体とアセットで IP が切り替わる挙動は分散クローラーの典型
  • 極端に古い Chrome バージョンの UA は自動化ツールの指紋になる
  • ブロックは RequireAll と Require not ip の組み合わせで実現できる
  • .htaccess の編集前はバックアップと WordPress ルールの保全が重要
  • 過去分のデータは GA4 のレポートフィルタで除外する運用が現実的

以上、最後までお読みいただきありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

関西を拠点に活動する、現役インフラエンジニア。経験20年超。

大手通信キャリアにて、中〜大規模インフラ(ネットワーク・サーバ・クラウド・セキュリティ)の設計・構築およびプロジェクトマネジメントに従事。現場で直面した技術課題への対処や、最新の脆弱性情報への実務対応を、一次情報として発信しています。

保有資格
CCIE Lifetime Emeritus(取得から20年以上)/ VCAP-DCA / Azure Solutions Architect Expert

▶ 運営者プロフィール(詳細)

目次