羅針盤 技術航海日誌

株式会社羅針盤の技術ブログです

Goでスクレイピングするときのアレコレ (vs WAF)


この記事は羅針盤 アドベントカレンダー 2024の4日目の記事です。
qiita.com

3日目の記事は Browserstackを使ってみた(その1) - 羅針盤 技術航海日誌 でした。


注意書き

この記事はスクレイピングをする際の「接続」に焦点を置いています。
具体的なスクレイピング処理のテクニックについては記載していないので悪しからず。
あと悪用しないでください。

背景

こんばんは、羅針盤の森川です。
人間、一度はスクレイピングをしたくなるときがあります。
「このページを毎朝コピペしていて辛いんですけど、なんとか自動でできませんか? ほら、DX」
みたいな感じで雑にお願いされることが多いと思います。

そしてエンジニアとして働いて3年くらいすると「あー、そんくらい余裕っすね。5時間ください」みたいな感じで安請け合いしがちです。
これが地獄の始まりだとは知らず...

JSが動いてからじゃないと情報が取れない → 「Headlessブラウザの巻・sleep微調整の章」みたいな無間地獄もありますが、今回はこいつではなくてWAF対策になります。

注: 悪用することはおやめください。大事なことなのであとでもう一度いいます。

GoでHTTPリクエスト

Level1. 普通のリクエスト

まずは普通にリクエストをしてみます

package main

import (
    "fmt"
    "io"
    "net/http"
)

func main() {
    resp, err := http.Get("https://example.com")
    if err != nil {
        fmt.Println("http.Get Error:", err)
        return
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Println("io.ReadAll Error:", err)
        return
    }

    fmt.Println(string(body))
}

普通のWebサイトであればこのままでレスポンスが取得できますね。

Level2. ヘッダー設定済みのリクエスト

このままではユーザーエージェントからしてGo言語を使っていることがバレバレです。
バレてないと思っているのは自分だけです。 なんとかバレないようにブラウザっぽさを模倣しましょう。

そのためにヘッダー情報を設定していきます。 まずはアレコレ調べるより、自分が使っているブラウザでの情報を調べてみます。

世の中には素晴らしいAPIがありますね。 Kennethさんに多謝。

ブラウザ(Chrome)でアクセスすると以下のようなレスポンスになりました。

{
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", 
    "Accept-Encoding": "gzip, deflate, br, zstd", 
    "Accept-Language": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7", 
    "Host": "httpbin.org", 
    "Priority": "u=0, i", 
    "Sec-Ch-Ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"", 
    "Sec-Ch-Ua-Mobile": "?0", 
    "Sec-Ch-Ua-Platform": "\"macOS\"", 
    "Sec-Fetch-Dest": "document", 
    "Sec-Fetch-Mode": "navigate", 
    "Sec-Fetch-Site": "none", 
    "Sec-Fetch-User": "?1", 
    "Upgrade-Insecure-Requests": "1", 
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", 
    "X-Amzn-Trace-Id": "Root=1-674ed769-169060a056dc5118713e2027"
  }
}

一方、Goで http.Get をそのまま使うと以下のようになります。

{
  "headers": {
    "Accept-Encoding": "gzip",
    "Host": "httpbin.org",
    "User-Agent": "Go-http-client/2.0",
    "X-Amzn-Trace-Id": "Root=1-674ed866-5c39da24480a879253d9c0ba"
  }
}

全然違いますね。これらの情報を足してみます。

func main() {
    client := &http.Client{}
    req, err := http.NewRequest("GET", "https://httpbin.org/headers", nil)
    if err != nil {
        fmt.Println("Error creating request:", err)
        return
    }

    // macOSのChromeっぽいヘッダーを設定
    req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
    req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
    req.Header.Set("Accept-Language", "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7")
    // req.Header.Set("Host", "httpbin.org")
    req.Header.Set("Priority", "u=0, i")
    req.Header.Set("Sec-Ch-Ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"")
    req.Header.Set("Sec-Ch-Ua-Mobile", "?0")
    req.Header.Set("Sec-Ch-Ua-Platform", "\"macOS\"")
    req.Header.Set("Sec-Fetch-Dest", "document")
    req.Header.Set("Sec-Fetch-Mode", "navigate")
    req.Header.Set("Sec-Fetch-Site", "none")
    req.Header.Set("Sec-Fetch-User", "?1")
    req.Header.Set("Upgrade-Insecure-Requests", "1")
    req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
    // req.Header.Set("X-Amzn-Trace-Id", "Root=1-674ed769-169060a056dc5118713e2027")

    resp, err := client.Do(req)
    if err != nil {
        fmt.Println("client.Do Error:", err)
        return
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Println("io.ReadAll Error:", err)
        return
    }

    fmt.Println(string(body))
}

結果はこうなります。

{
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
    "Accept-Encoding": "gzip, deflate, br, zstd",
    "Accept-Language": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
    "Host": "httpbin.org",
    "Priority": "u=0, i",
    "Sec-Ch-Ua": "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
    "Sec-Ch-Ua-Mobile": "?0",
    "Sec-Ch-Ua-Platform": "\"macOS\"",
    "Sec-Fetch-Dest": "document",
    "Sec-Fetch-Mode": "navigate",
    "Sec-Fetch-Site": "none",
    "Sec-Fetch-User": "?1",
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
    "X-Amzn-Trace-Id": "Root=1-674edab8-586804e92ea2521f4bb355bf"
  }
}

はい、完璧ですね。 これでもうあなたも私も一人前のブラウザです。ブラウザとしての一生を全うしていきたい。

(エセ)ブラウザくんの初めてのリクエスト

このように調子に乗ってエンジニア独特の全能感に酔いしれながら鼻高々に実行します。
自分は天才なんじゃないかと勘違いしていると、おもむろにWAFにブロックされます。追い打ちのようにCAPTCHAが出たりします。もう泣きたい。
「5時間っていったけど20分で終わっちゃうもんね」とか思ってた過去の自分を殴りたい。

何が駄目なんだと、私の何が問題なのかと、給料を上げてくれませんかと。 そうして切実にウェルテルよろしく悩んでいると、先達の同志が悩みと解決策を提示してくれています。

stackoverflow.com

これをこのまま使ってみたけどなんか微妙に動作が怪しい。
よろしい、またブラウザとしての私を見せつけてやろう。

ブラウザのSSL/TSL設定確認

素晴らしいAPIその2を使わせていただきます。Darkishさんありがとう。

https://www.howsmyssl.com/a/check

ブラウザでアクセスすると、、、

{
  "given_cipher_suites": [
    "TLS_GREASE_IS_THE_WORD_2A",
    "TLS_AES_128_GCM_SHA256",
    "TLS_AES_256_GCM_SHA384",
    "TLS_CHACHA20_POLY1305_SHA256",
    "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
    "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
    "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
    "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
    "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
    "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
    "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
    "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
    "TLS_RSA_WITH_AES_128_GCM_SHA256",
    "TLS_RSA_WITH_AES_256_GCM_SHA384",
    "TLS_RSA_WITH_AES_128_CBC_SHA",
    "TLS_RSA_WITH_AES_256_CBC_SHA"
  ],
  "ephemeral_keys_supported": true,
  "session_ticket_supported": true,
  "tls_compression_supported": false,
  "unknown_cipher_suite_supported": false,
  "beast_vuln": false,
  "able_to_detect_n_minus_one_splitting": false,
  "insecure_cipher_suites": {

  },
  "tls_version": "TLS 1.3",
  "rating": "Probably Okay"
}

いい感じに表示されています。
比較対象のGoのhttp.ClientのデフォルトのCipher Suitesを確認してみると

{
    "given_cipher_suites": [
        "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
        "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
        "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
        "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
        "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
        "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
        "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
        "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
        "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
        "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
        "TLS_AES_128_GCM_SHA256",
        "TLS_AES_256_GCM_SHA384",
        "TLS_CHACHA20_POLY1305_SHA256"
    ],
    "ephemeral_keys_supported": true,
    "session_ticket_supported": false,
    "tls_compression_supported": false,
    "unknown_cipher_suite_supported": false,
    "beast_vuln": false,
    "able_to_detect_n_minus_one_splitting": false,
    "insecure_cipher_suites": {},
    "tls_version": "TLS 1.3",
    "rating": "Probably Okay"
}

結構違いました。

Level3. TSL Cipher Suites設定済みのリクエスト

先程の情報を元にCipher Suitesを設定したGoのコードを作ってみます。

func main() {
    transport := &http.Transport{
        TLSClientConfig: &tls.Config{
            CipherSuites: []uint16{
                tls.TLS_AES_128_GCM_SHA256,
                tls.TLS_AES_256_GCM_SHA384,
                tls.TLS_CHACHA20_POLY1305_SHA256,
                tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
                tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
                // ...
            },
        },
    }
    client := &http.Client{
        Transport: transport,
    }
    req, err := http.NewRequest("GET", "https://www.howsmyssl.com/a/check", nil)
    if err != nil {
        fmt.Println("http.NewRequest Error:", err)
        return
    }
// ...

これでテストしてみるとそれっぽい結果になりましたが、順番や指定したCipher Suitesだけになっていなかったりして完全に指定できてるのかできてないのかどっちなんだい? ちょっと怪しい感じがします。 よく分からないのでとりあえず癒やしの檜山さんを貼っておきます。

www.youtube.com

また、ここで GREASE というものの存在に初めて気付きます。

jovi0608.hatenablog.com

うーむ、なるほど。勉強になります。

crypto/tls パッケージには GREASE は無さそうだったので「おいおい、まじかよ...」と思いましたが GitHubでみてみると、0x0A0A とかを設定すれば良さそうです

ところがどっこい、そうは問屋がおろしません。 変な値を入れても無視されます。 さらにTLS1.3の部分のCipher Suitesを自由に変更できませんし順番も変えれなさそうです。

github.com

このままではブラウザにはなれません。困った。 うーん、ブラウザが駄目ならcurlにしてみるか。

curlではなぜかリクエストがブロックされないので、ヘッダーはブラウザっぽくしてCipher Suitesをcurl準拠にしてみます。

# 顧客が必要なものはこれ
$ curl -v https://www.howsmyssl.com/a/check
// 省略


# 一応確認したい
$ curl --version
// ... LibreSSL/3.3.6 ...

# アレ...?
$ openssl version
OpenSSL 3.4.0 22 Oct 2024 (Library: OpenSSL 3.4.0 22 Oct 2024)

# 理解。
$ which openssl
/opt/homebrew/bin/openssl

# 合ってた
$ /usr/bin/openssl version
LibreSSL 3.3.6

# 一応確認(OpenSSLとの違いを楽しむ)
$ /usr/bin/openssl ciphers -v
// いっぱいでてくる

最初の https://www.howsmyssl.com/a/check で確認したCipher Suitesの内、 crypto/tls にありそうだったのがこいつらです。

       TLSClientConfig: &tls.Config{
            CipherSuites: []uint16{
                tls.TLS_CHACHA20_POLY1305_SHA256,
                tls.TLS_AES_256_GCM_SHA384,
                tls.TLS_AES_128_GCM_SHA256,
                tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
                tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
                tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
                tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
                tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
                tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
                tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
                tls.TLS_RSA_WITH_AES_256_CBC_SHA,
                tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
                tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
                tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,
                tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,
                tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
                tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
                tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
                tls.TLS_RSA_WITH_AES_128_CBC_SHA256,
                tls.TLS_RSA_WITH_AES_128_CBC_SHA,
                tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA,
                tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA,
                tls.TLS_RSA_WITH_RC4_128_SHA,
                tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,
                tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
            },
        },

こいつを元にリクエストをぶっ放したら、一応成功していました。 何もない日々に感謝。

Level4. JA3 フィンガープリント対策

なんかしばらくするとエラーになってきました。 TLS暗号スイートの迂回設定を調べていたときに少し気になっていたのでJA3設定をどうにかしないといけない気がしてきました。

www.fastly.com

うーん、勉強になりますね。でももう勉強したくない。
この頃にはもう朝日が昇りそうで達観してきており、先達にすがることだけを考えています。
そんな私に朗報! CycleTLS先生を使えば今までやってきたことは全て無駄で、簡単にリクエストが送れるようになっちゃうよ!

github.com

もうこれだけです。

package main

import (
    "fmt"

    "github.com/Danny-Dasilva/CycleTLS/cycletls"
)

func main() {
    opt := cycletls.Options{
        Headers: map[string]string{
            "Accept":                    "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
            "Accept-Encoding":           "gzip, deflate, br, zstd",
            "Accept-Language":           "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
            "Priority":                  "u=0, i",
            "Sec-Ch-Ua":                 "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"",
            "Sec-Ch-Ua-Mobile":          "?0",
            "Sec-Ch-Ua-Platform":        "\"macOS\"",
            "Sec-Fetch-Dest":            "document",
            "Sec-Fetch-Mode":            "navigate",
            "Sec-Fetch-Site":            "none",
            "Sec-Fetch-User":            "?1",
            "Upgrade-Insecure-Requests": "1",
        },
    }
    client := cycletls.Init()
    resp, err := client.Do("https://www.howsmyssl.com/a/check", opt, "GET")
    if err != nil {
        fmt.Println("client.Do Error:", err)
        return
    }
    fmt.Println(resp.Body)
}

すると...

{
    "given_cipher_suites": [
        "TLS_GREASE_IS_THE_WORD_DA",
        "TLS_AES_128_GCM_SHA256",
        "TLS_AES_256_GCM_SHA384",
        "TLS_CHACHA20_POLY1305_SHA256",
        "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
        "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
        "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
        "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
        "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
        "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
        "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
        "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
        "TLS_RSA_WITH_AES_128_GCM_SHA256",
        "TLS_RSA_WITH_AES_256_GCM_SHA384",
        "TLS_RSA_WITH_AES_128_CBC_SHA",
        "TLS_RSA_WITH_AES_256_CBC_SHA"
    ],
    "ephemeral_keys_supported": true,
    "session_ticket_supported": true,
    "tls_compression_supported": false,
    "unknown_cipher_suite_supported": false,
    "beast_vuln": false,
    "able_to_detect_n_minus_one_splitting": false,
    "insecure_cipher_suites": {},
    "tls_version": "TLS 1.2",
    "rating": "Probably Okay"
}

はい、勝ちました。 今までの私の時間(睡眠と肌荒れ)を返してください。

Level5. Residential Proxy

最高のコードを書き終えて、意気揚々と本番リリースします。ホスティング先はもちろんサーバーレスです。 このままAWS LambdaやCloud Functionで実行してみると... なぜかエラーが出ます。

「あれ、おかしいな」「何か間違えてたかな...」とログを確認してみると403エラー。 はい、詰みました。 そうだよねえ、hostingなIPはブロックするよネエ.... 自分でもそうしてたもんナア....

そんなときは串を刺すしか無い。もう寝たい。寝かせて欲しいし何なら寝かしつけて欲しい。
そう思ってリアルなプロキシサービスを探します。

いくつか候補があるのですができれば無料で試したい。

「無料で試用できます!」ってなサービスもいくつかあって使ってみたら実際には一部のサービスだけでResidential Proxyはほぼ対象外でした。 BrightDataはしっかりしていて、課金の前に本人確認が必要でした。

本人確認している間に他を使ってみて、Smartproxyで普通に動いたのでそのまま使ってみています。 どの会社も全体的に課金体系が転送量課金が多く、ちょっと嫌な感じなんですがスモールに試すなら割とおすすめです。

さて、最強の武器(Proxy IPアドレス)を手に入れたので装備してみましょう。

// e.g.) http://user:pass@example.com:15000
proxyURL := fmt.Sprintf("http://%s:%s@%s:%s", "username", "password", "hostname", "port")
u, err := url.Parse(proxyURL)
if err != nil {
    return err
}

// 通常のhttp.Client向け
transport := &http.Transport{
    Proxy: http.ProxyURL(u),
}
transport.TLSClientConfig.InsecureSkipVerify = true

// CycleTLS向け
opt := cycletls.Options{
    Proxy: proxyURL, // 直接 'http://...' を指定してOK
}

これで昼まで眠れるようになりました。 日々に感謝。 Yay.

注: 悪用することはおやめください。

LevelX. 悪用対策(成敗)

こうやって悪用する人たちがいますよね。
Q. どうすればいいのでしょうか。
A. Spur使いましょう

spur.us

怪しいIPのリストを持っていたら↓で好きなサービス使ってチェックしてみましょう。

github.com

以前のおすすめはダントツSpur(精度)で次点がBigDataCloud(コスパ最強)でした。 今は分からないのでこっそり教えて下さい。

Spurは間違えて$1000の年間課金してしまって、経費で落とせず死ぬかと思いましたがきちんと返金してくれました。 良い会社です。感謝。