【広告ブロックDNS】特定ドメインへのTCP接続を途中で切断してAd-Shield対策

今回は広告ブロックに関する記事です。

広告ブロッカーには、HTMLのDOMを直接読み書きして広告枠を除去する従来型(ブラウザ拡張機能は基本的にこれ)とDNSリクエストをブロックして広告の取得を妨げるネットワーク型の大きく分けて2種類があります。

このうち後者はスマホのアプリ(Chrome含む)などのブラウザ拡張が使えない環境で効果を発揮しますが、機構としてはごく単純なフィルタリングしかできないため、近年は広告ブロッカー検出ツール(広告回復ツール)で検出されることが増えてきました。(もちろん従来型に対する検出ツールもあります)

このような検出ツールの代表例がAd-ShieldCHP Ads Block Detectorで、それぞれhtml-load.comやpagead2.googlesyndication.comという広告配信用ドメインを検出に使い、これらのドメインがブロックされていると警告メッセージを表示してページを閲覧できなくします。

Ad-Shieldへの対抗手段としてはtinyShieldというスクリプトを使うのが主流ですが、これもJavaScriptを使うため、従来型の広告ブロッカーが使えるような環境でないと動きません。

そこでこの記事では、これらの検出ツールに対するネットワーク型の対策として、検出用ドメインへの接続を途中で切断する手法を説明します。

参考リンク

広告ブロックに関しては、Yuki27183さんというAdGuardのフィルタなどのメンテナンスをされている日本の方が非常に詳しく(かつタイムリーに)情報発信されているのでそちらもあわせてご覧ください。

概要

こちらのリプライツリー

Yukiさんに解説していただいたのですが、Ad-ShieldやCHP Ads Block Detectorは、バージョンにより、広告配信用ドメインとの通信が通るかどうかをいくつかの異なる手法を用いてチェックします。

そこで、このチェックの時点では広告配信用ドメインと正しく通信できているように見せかけた上で、チェックが終わって広告が実際に表示されるまでのタイミングでTCP接続を切断する、というのが今回の手法になります。

この場合、広告のメタデータ(サイズなど)は正しく読み込まれるため、典型的には以下のようなブラウザエラーの表示が広告枠に表示されることになります。(このサイトは上記のYukiさんのリプライツリーで「4.」の例として教えていただいたものです)

典型的な動作例

さらに、この手法は他のブロック手法とも相互補完的に使うことができます。例えば、従来型ブロックを貫通する広告が設置されているとか、従来型だとAd-Shieldに検出されてしまいtinyShieldもまだ対応していないのでとりあえず従来型ブロックを解除するしかない、といった場合でも広告を表示させない最終防衛ライン的な使い方はできると思います。逆に、この手法だとAd-Shieldが入っていないサイトでも広告分のスペースが空いてしまうようなことがありますが、従来型ブロックを使っていればその部分を問題なく消してくれます。

ドメインごとの速度制限

一番重要なのが、ドメインごとの速度制限ってどうやるの?というところです。一般にはネットワーク型で通信の中間者として動作するとなるとデータとして使いやすいのはL4層のIPアドレス・ポートで、HTTPのhostを見ることはできますがそれに基づいて速度制限をかけるとなるとLinuxiptablesなどL4層のソフトウェア単体ではやりづらくなります。

しかし、以前の【Android】NextDNSをアプリごとに有効無効切り替え+Tailscale経由の自宅鯖を使ってControl D風のトラフィック転送 - turgenev’s blogで紹介したように、DNSでHTTP/SNIリバースプロキシのIPを応答するようにすることで、ドメインごとに通信ルートを制御できるようになります。基本的なアイデアについてはこちらの記事を適宜参照してください。

上記の記事では海外への転送などを想定していたため自宅サーバーを中継点としてルーティングする設定にしましたが、今回は単なる速度制限であって他のサーバーを経由する必要がないので、それに対応した設定を今回の記事では紹介します。

さらに、完全にネットワーク型として動作させるとなるとほぼ前回と同じになってしまうので、ブラウザでプロキシ指定する想定にします。

前回記事同様に、パフォーマンスに関してちゃんとした検証はしていません。なんなら体感ちょっと遅くなっているような気もするので、その辺もご了承のうえでお読みください。

DNSゾルバをローカルに立てる(Linuxでは不要)

この記事では一応クロスプラットフォーム対応を念頭に置いています。筆者が実際に設定したのは今のところWindowsです。

DNSを設定するにあたって、とりあえず「PCが今使っているDNSサーバーを使って名前解決をしてくれるローカルのDNSサーバー(UDPポート)」があると便利です。最近のよくあるLinuxだと、systemd-resolvedというのが動いていて、127.0.0.53:53をこの目的で使うことができます。しかしWindowsには多分そういうものはないのでとりあえずgetaddrinfo()をするだけの自明なDNSゾルバをRustで書きました→https://github.com/ge9/rust-simple-dns

これが127.0.0.1:2053とかで動作しているものとしましょう。

macOSではどうなのか知りませんが、なければこのRustを使うといいです。

「あると便利です」と書いた通り、別に必ずしもこれがなくてもいい(8.8.8.8みたいな固定のサーバーを指定してもいい)んですが、実際問題としてキャプティブポータルのあるWi-Fiに接続できなくなったりとかするので最低限フォールバックDNSとしてもこれは持っておいたほうがいい気もします。

AdGuard Homeを立てる

今回は広告ブロック検出ツールの対策をするわけなので、当然ネットワーク型の広告ブロックを導入する前提です。

今回はAdGuard Homeを使ってみました。もちろん以前の記事のNextDNSのような広告ブロック機能付きの既成のDNSを使ってもいいのですが、普段使いのノートPCで使うとなるとさすがに月30万クエリの無料枠じゃ足りないので、自前でフィルタを動かすタイプにしました。このタイプだと他にPi-Holeが有名ですがWindowsには対応していないようです。そもそもDockerを使ってインストールすることが案内されている時点でモッサリした設計なんだろうなあという気持ちになってしまいます。それ以外だとTechnitium DNSというのも結構良さそうでしたが日本語情報が少なめだったのでとりあえず順張りでAdGuard系列にしておきました。

AdGuardはWindowsをサポートしていて、しかも管理者権限がなくても(インストール不要で)動くのですが、それで適当に使ってたら

AdGuardHome Windows edge version [Unknown error: Access is denied. (0x80070005)] · Issue #7400 · AdguardTeam/AdGuardHome · GitHub

こんな感じで権限周りの不具合が出たので、AdGuardのインストールフォルダに対してAdministratorが「フル コントロールを除くすべての権限を持つように設定したところ快調に動作するようになりました。

AdGuard Homeの初歩的な設定は省略しますが、好みに応じて

  • 「アップストリームDNSサーバー」のみ
  • 「フォールバックDNSサーバー」のみ
  • 「フォールバックDNSサーバー」「ブートストラップDNSサーバ」の両方

にさっきの127.0.0.1:2053(Linuxなら127.0.0.53)を設定するような感じになると思います。また、(後述のプロキシの実装を変えない限り)ローカルのリゾルバとして1秒間に同じドメインの解決を何度もリクエストするような使い方になるので、レート制限は無効にしておいた方がいいでしょう。

AdGuard Home自体は127.0.0.1:3153で動かすことにしました。ポート番号はいずれも適当です。

SOCKS5プロキシの準備

前回と同様、SOCKS5プロキシを使ってパケットをいじって転送先を操作することにします。ただし、透過プロキシを用いた前回と違って、今回はこのプロキシを明示的なプロキシとしてブラウザで指定することになります。この場合、プロキシ側がUDPに対応している必要はない(ドメインの解決はプロキシにそのままドメイン名を送ることで行われる)ので、SOCKS5ではなくHTTPプロキシを使うという選択肢もあります。しかし前回で作業した内容をそのまま流用するほうが楽なので、前回と同じhttps://github.com/ge9/go-socks5/tree/mitm(このブランチで作業しています)を選びました。

SOCKS5側でやる内容ですが、まず以前と同様、DNS応答ではプロキシのIPとしてダミーのIP(192.0.2.0/24の範囲)を応答させ、そのIPに対するconnect要求が来たらそれを速度制限付きのHTTP/SNIプロキシに流すことにします。この部分は以前のコードがそのまま使えます。

もう一か所、変更点として、0.0.0.0へのconnect要求を即座にエラー応答するようにしてあります。AdGuard Homeを含め多くの広告ブロックDNSがブロック時に0.0.0.0アドレスで応答するので、ブラウザからプロキシに0.0.0.0へのconnect要求が来ることになりますが、Windowsは閉じたTCPポートへのconnect要求がエラーになるまでに2-3秒ほどのタイムラグがあるため、そのまま0.0.0.0:443などに接続させると動作が遅くなります。Linuxではこのラグがないので対処は必要ありません(が、しておいた方がリソースの節約にはなると思います)。

つぎにエントリポイントとなるファイルを作ります。_exampleにmain.goというファイルがあるので、_testというフォルダ(.gitignoreされています)を作成してそこにコピーします。go-socks5ではWithResolverというオプションでSOCKS5による名前解決に使用する関数を指定できるので、ここでAdGuard Homeを使います。こんな感じに変えます。

package main
import (
    "context"
    "log"
    "net"
    "os"
    "time"
    "github.com/things-go/go-socks5"
)

// DNSResolver uses the system DNS to resolve host names
type MyDNSResolver struct {
    resolver net.Resolver
}

// Resolve implement interface NameResolver
func (d MyDNSResolver) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) {
    addr, err := d.resolver.LookupHost(context.Background(), name)
    if err != nil {
        return ctx, nil, err
    }
    return ctx, net.ParseIP(addr[0]), err
}

func main() {
    println("start")
    myRes := net.Resolver{
        PreferGo: true,
        Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
            d := net.Dialer{
                Timeout: time.Millisecond * time.Duration(10000),
            }
            return d.DialContext(ctx, network, "127.0.0.1:3153")
        },
    }
    mm := &MyDNSResolver{
        resolver: myRes,
    }
    server := socks5.NewServer(
        socks5.WithLogger(socks5.NewLogger(log.New(os.Stdout, "socks5: ", log.LstdFlags))),
        socks5.WithResolver(mm),
  )
    if err := server.ListenAndServe("tcp", "localhost:3127"); err != nil {
        panic(err)
    }
}

あとはgo buildして実行すれば、localhost:3127でプロキシが動きます。これをブラウザで指定すれば、AdGuard Homeが適用された状態でインターネットを閲覧できます。

ブラウザのプロキシに関して注意点ですが、FoxyProxyやProxy SwitchyOmega(ZeroOmega)のようなChromeFirefoxの通常のプロキシ拡張機能は、タブごとではなくドメインごとにプロキシを適用するので、このlocalhost:3127はデフォルトのプロキシ(除外サイトはあってもよい)として使わないとあまり意味がありません。一応Disable/enable the proxy on a tab by tab basis in Firefox - Super Userで紹介されているcontainer-proxyというのはタブごとの適用に近いことができるようですが、試していません。

HTTP/SNIプロキシによる速度制限・時間制限

ここまではまだ速度制限・時間制限をかけていません。

前述の通り、速度制限をかけたいドメインにはダミーのIPで応答します。さらに、ダミーのIPを何種類か作っておいて、それぞれに別の速度制限をかけるようにします。例えばhtml-load.comには192.0.2.111、pagead2.googlesyndication.comには192.0.2.112で応答すると、(HTTPSの場合)これらのリクエストは(go-socks5によって)それぞれ127.0.0.1:19211と127.0.0.1:19212に送信されます。HTTP/SNIプロキシ側で19211番には8kB/s、19212番には64kB/sの制限をかけておくなどすれば、各ドメインに別々の通信速度が適用されるというわけです。

さらに、これらのポートに関して、5秒以上経ったら接続を切断するようにします。これにより通信が強制的に遮断されます。

ちなみに、時間・速度については関知せず単純に通信量だけでブロック(40kB通信したらブロックなど)することも考えられますが、それだと即座にブロックされてしまうせいかAd-Shieldに検出されてしまいます。

過去記事ではHTTP/SNIプロキシにNginxを使いましたが、ちょうどNginxには速度制限・時間制限の機能があるのでそれを使うことにします。Linux(nftables)であればポートごとに速度制限をかける方法もありますが、それだと複数の接続にまとめて制限をかけることになり、Ad-Shieldを使用しているサイトに複数同時にアクセスしたときだけ検出されるみたいな変な動作になりかねないので、多分Nginx側でやったほうが精度が出ると思います。

nginxのconfは以下のような感じです。ここでも先ほどの127.0.0.1:2053を使っています(AdGuard Homeを指定するのはループしてしまうので当然ダメです)。Cygwinのnginxで動作確認しています。

  • 訂正(重要): proxy_timeoutなどに関しては実際には接続を強制的に遮断するような動作ではないようで、ここで説明した通りには動きません。自分で試した限り(AndroidChromeと、Linux上のnginx)では、結果的に速度制限だけでも正しくブロックできたような(広告なしにページ閲覧が可能な)動作になっていたために、nginx側の実際の動作をきちんと確認していませんでした。大変申し訳ありません。
load_module load_module /usr/lib/nginx/modules/ngx_stream_module.so;
events {
    worker_connections 1024;
}
pid /tmp/nginx-sni.pid;
http {
    map $server_port $limit_rate {
      19110 1k;
      19111 8k;
      19112 64k;
        default "";
  }
 
    server {
      listen 19110-19112;
        location / {
          resolver 127.0.0.1:2053;
            set $target http://$host;
            proxy_pass $target;
            proxy_set_header Host $host;
        }
        set $limit_rate $limit_rate;
      client_body_timeout 5s;
      client_header_timeout 5s;
      keepalive_timeout 5s;
      send_timeout 5s;
    }
}
 
stream {
    map $server_port $proxy_download_rate {
      19210 1k;
      19211 8k;
      19212 64k;
        default "";
  }
 
    server {
      listen 19210-19212;
      resolver 127.0.0.1:2053;
        proxy_pass $ssl_preread_server_name:443;
        ssl_preread on;
        proxy_download_rate $proxy_download_rate;
      proxy_timeout 5s;
    }
}

ただし、Nginxの時間超過による切断もTCP RSTで行われるのでWindowsだとやはり2-3秒の遅延が発生します。これに対応するため、一定秒数で接続を強制的にFINで切断するTCPプロキシhttps://github.com/ge9-2/tcp-limit-proxyを作成したので、これを使うこともできます。設定方法としては、上記のconfからタイムアウト関連の設定を削除し、ポートをそれぞれ19210→18210などと全て変更し、tcpproxy 127.0.0.1:18210 -b 127.0.0.1 -l 19210 -t 5000のようにしてtcpproxyを立てる感じです。

あとはAdGuard Homeの設定で各ドメインの書き換え(&許可)を指定するだけで、設定が完成します。

ドメインの発見、微調整

広告ブロッカーの検出に使われるhtml-load.comやpagead2.googlesyndication.comなどのいわゆる「罠ドメイン」をどう見分けるかということについて、原理的にはDNS側のログやブラウザの開発者ツールなどを見て個別に判断するしかないのですが、現実的にはhttps://github.com/AdguardTeam/AdguardFilters/blob/master/BaseFilter/sections/allowlist_stealth.txtあたりにだいたい載っているようです。

また、転送速度の設定が低すぎると検出されてしまう場合もあります。自分が見ているサイトに応じて動作を確認しつつ速度を調整しましょう。

サイトごとに書き換え先を変えたい

上記の話題に関連して、「example.comを見ているときはhtml-load.comを64kB/s、それ以外の場合は8kB/sに制限したい」といった需要が出てくることが考えられます。しかしネットワーク型ブロックではブラウザのタブの情報が取れないので基本的にこれはかなり難しくなります。一応、ドメイン↔IPの対応関係を保持しておいて逆引きするとかexample.comも別のドメインにリダイレクトしてSNIプロキシするとかでexample.comへの接続を検出することは可能なので、その直後のhtml-loadへのアクセスだけ64kB/sにする、とかはできると思いますが、その読み込みの間に他サイトを見たりすることを考えるとあまり綺麗な動作は期待できません。

将来的な動作可能性

広告ブロックとなるとやはり気になるのは、これは今後も対策されず動作するのか、結局いたちごっこになるのではないか、という部分です。

対策されるとするといくつかの可能性があります。(割と怪しい知識で書いているので間違っているかもしれません)

まず、検出ツールが通信速度に関してさらに厳しいチェックをするという可能性があります。一般にチェックを厳しくすればするほど正規ユーザーを誤ブロックしてしまう可能性も高まるとは思いますが、例えば広告の読み込みが完了するかどうかを確実に検知する方法が存在した場合、この記事の内容は動作しなくなると思います。ただ、そのような手段はtinyShieldなどの現在主流のAd-Shield対策に対しては効果がないと思うので、この記事の手法が広く普及しない限りは大丈夫な気もします。

さらに、DNSSECやEncrypted SNIの普及によってDNSの書き換えやSNIプロキシがやりづらくなるということも一応考えられます。とはいえ基本的にはこれらはユーザーが自主的にセキュリティを高めたいときにやるもので、DNSサーバーとして何を選ぶかに選択権を持たない個々のアプリがそう簡単にどうにかできる部分ではありません。もちろん、例えばCertificate Pinningをした自社のサーバーに対して広告配信用ドメインのIPを問い合わせるようなことをされたらどうしようもないですが、それはもはや自社サーバーから配信される広告(TwitterYouTubeなどが代表例で、DNSブロックが使えない)と同じようなものなのでまあ諦めが付くかもしれません。