[創意料理] 反爬蟲二部曲 - 如何防治中級網頁爬蟲?

反爬蟲首部曲 - 如何防治初級網頁爬蟲?的文章當中,已經跟各位朋友大致上介紹過,這次防治爬蟲案例中的情境及所使用到的工具,防治初級爬蟲算是容易的,接下來我們要來防治中級爬蟲,難度上會稍微高一點,而且一樣要撰寫一些程式碼。

何謂中級爬蟲?

中級爬蟲在我自己的定義上,一樣是會使用 HTTP 客戶端來爬資料,只是這類的爬蟲對於 HTTP Request/Response 的互動過程有一定程度的了解,他們可以向 Web Api 發送合法的 Request,造成後端在爬蟲的判定上有一定的困難度,無法分辨 Request 是真實使用者發送過來的?還是爬蟲發送過來的?而我們遇到的大多數爬蟲都是屬於中級爬蟲,因此在防治上我們必須多下點工夫,防治手段我分成三個步驟來進行。

第一步驟:驗

第一個步驟「」,就是驗證,在這系列的文章中所描述的情境,Web Api 皆是提供給執行在瀏覽器的前端程式來呼叫的,因此「瀏覽器」就成了我們可以用來防治爬蟲的工具,我們可以在網頁載入的一開始預埋一段演算法,這段演算法產生一個「簽章(Signature)」,在呼叫 Web Api 時夾帶在 Request Headers 裡面,後端程式執行相同的演算法進行簽章的驗證,驗證通過才算是合法的呼叫。

產生簽章的演算法非常的彈性,我們可以視需求去挑選我們要的資訊來做,我這邊展示幾個我用到的資訊:

  1. Fingerprint:透過 FingerprintJS 這個套件取得的一個客戶端的指紋資訊,它使用一些機制盡可能地為每個客戶端產生唯一的識別碼,相關原理請參考它的官網,這邊不多做說明。
  2. UUID:就是一個 UUID,原來是追蹤瀏覽軌跡用的,這是瀏覽網頁必定會產生的資料。
  3. User-Agent:透過 window.navigator.userAgent 取得
  4. 參考網頁路徑(Referer):透過 window.location 取得
  5. 密鑰:一串自定義的密碼

關於產生的方式,我這邊是把這幾個資訊串起來,透過 SHA384 做雜湊,然後把演算法與 AJAX 的設定結合起來,在發送 Request 之前執行一次產生簽章的演算法,將它加進 Request Headers 裡面,其中 Fingerprint 及 UUID 會被帶進 Cookie ,大致上就像下面的程式碼:

signature.js

var http = (function () {
    var _defaultSettings;
    return {
        get defaultSettings() {
            return typeof _defaultSettings === "function" ? _defaultSettings() : _defaultSettings;
        },
        set defaultSettings(val) {
            _defaultSettings = val;
        }
    };
})();

(function () {
    // 產生 UUID
    const u = Cookies.get("UUID") || (function () {
        let d = Date.now();
        if (typeof performance !== "undefined" && typeof performance.now === "function") {
            d += performance.now();
        }
        return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
            let r = (d + Math.random() * 16) % 16 | 0;
            d = Math.floor(d / 16);
            return (c === "x" ? r : (r & 0x3 | 0x8)).toString(16);
        });
    })().toUpperCase();
    Cookies.remove("UUID");
    Cookies.set("UUID", u, { expires: 365 });

    // 簽章演算法與 AJAX 設定結合
    http.defaultSettings = (function () {
        const dfd = $.Deferred();
        // 產生客戶端指紋
        FingerprintJS.load()
            .then(fp => fp.get())
            .then(r => {
                Cookies.set("fingerprint", r.visitorId, { expires: 365 });
                dfd.resolve();
            });
        return function () {
            return dfd.promise().then(() => {
                // 產生簽章
                const f = Cookies.get("fingerprint");
                const s = f + u + window.navigator.userAgent + window.location.toString() + "kTwpZe9DS7NnARXn";
                const h = CryptoJS.SHA384(s).toString();

                return { headers: { "X-Signature": h } }
            });
        };
    })();
})();

Index.cshtml

@{
    ViewData["Title"] = "Home Page";
}

<div class="text-center">
    <!-- ... -->
    <div id="my-api"></div>
</div>

@section Scripts
{
    <script src="https://cdnjs.cloudflare.com/ajax/libs/js-cookie/3.0.1/js.cookie.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/core.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/x64-core.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/sha512.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/sha384.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@@fingerprintjs/fingerprintjs@3.3.3/dist/fp.min.js"></script>
    <script src="/js/signature.js" asp-append-version="true"></script>
    <script>
        $(function () {
            http.defaultSettings.then(function (defaultSettings) {
                var settings = $.extend(true, { method: "GET" }, defaultSettings);

                $.ajax("/Home/MyApi", settings)
                    .then(function (result) {
                        $("#my-api").text(result.value);
                    });
            });
        });
    </script>
}

前端搞定之後我們再來處理後端,我直接建一個 Action Filter 給需要防治爬蟲的 Web Api 使用,在 Action Filter 裡面我們除了進行簽章的檢查之外,在這之前還可以先檢查組成簽章的元素,發送來的 Request 如果沒有這些組成簽章的元素,就可以先擋掉了。

public class AntiCrawlerAttribute : Attribute, IAsyncActionFilter, IOrderedFilter
{
    public int Order { get; set; } = -99;

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        if (!HasUUID(out var uuid))
        {
            context.Result = BadRequestContent();
        }
        else if (!HasFingerprint(out var fingerprint))
        {
            context.Result = BadRequestContent();
        }
        else if (!HasSignature(out var clientSignature))
        {
            context.Result = BadRequestContent();
        }
        else if (!HasReferer(out var referer))
        {
            context.Result = BadRequestContent();
        }
        else if (!HasUserAgent(out var userAgent))
        {
            context.Result = BadRequestContent();
        }
        else if (ValidSignature(clientSignature, userAgent, fingerprint, uuid, referer))
        {
            await next();
        }
        else
        {
            context.Result = BadRequestContent();
        }

        ContentResult BadRequestContent()
        {
            return new ContentResult { Content = $"400 - {(HttpStatusCode)400}", StatusCode = 400 };
        }

        bool HasSignature(out StringValues signature)
        {
            if (!context.HttpContext.Request.Headers.TryGetValue("X-Signature", out signature)) return false;
            if (string.IsNullOrEmpty(signature)) return false;

            return true;
        }

        bool HasFingerprint(out string fingerprint)
        {
            if (!context.HttpContext.Request.Cookies.TryGetValue("fingerprint", out fingerprint)) return false;
            if (string.IsNullOrEmpty(fingerprint)) return false;

            return true;
        }

        bool HasUUID(out string uuid)
        {
            if (!context.HttpContext.Request.Cookies.TryGetValue("UUID", out uuid)) return false;
            if (string.IsNullOrEmpty(uuid)) return false;

            return true;
        }

        bool HasUserAgent(out StringValues userAgent)
        {
            if (!context.HttpContext.Request.Headers.TryGetValue(HeaderNames.UserAgent, out userAgent)) return false;
            if (string.IsNullOrEmpty(userAgent)) return false;

            return true;
        }

        bool HasReferer(out StringValues referer)
        {
            if (!context.HttpContext.Request.Headers.TryGetValue(HeaderNames.Referer, out referer)) return false;
            if (string.IsNullOrEmpty(referer)) return false;

            return true;
        }

        bool ValidSignature(string clientSignature, string userAgent, string fingerprint, string uuid, string referer)
        {
            var signature = $"{fingerprint}{uuid}{userAgent}{referer}kTwpZe9DS7NnARXn";

            var signatureHash = BitConverter.ToString(SHA384.Create().ComputeHash(Encoding.UTF8.GetBytes(signature)))
                .Replace("-", string.Empty)
                .ToLower();

            return clientSignature == signatureHash;
        }
    }
}

第一步驟完成之後,正常會在畫面中看到 Hello World 的字樣。

如果我們直接去打 /Home/MyApi,應該會看到 400 - BadRequest 的訊息。

第二步驟:變

第二個步驟「」,意思是簽章要時刻在改變,我們加入時間元素,讓簽章具有時效性,我們可以這樣做,將 UNIX Timestamp 除以一個秒數取商數,當成組成簽章的第 6 個資訊,我這邊是除以 60 秒。

後端的部分,由於前後端可能有時間差,所以後端允許 ±1 的緩衝。

這樣一來,簽章的值就不固定了,會隨著時間而改變,即使 Request 被整個捕捉下來,簽章也很快就失效了。

第三步驟:藏

第三個步驟是「」,即隱藏,我們的演算法在前端是用 JavaScript 寫的,形同裸奔,在這邊我們要使用著名的程式碼混餚工具 - JavaScript Obfuscator Tool,將我們的簽章演算法原始碼進行高度混餚,使用的方法很簡單,複製貼上之後,Options Preset 選項選擇 High,接著將 Disable Console OutputSelf DefendingDebug Protection 取消勾選,最後點擊 Obfuscate 就行了。

兵不厭詐,把混餚過後的程式碼貼到 js 檔之中,這個 js 檔另外命名為其他知名套件的名稱,形成資訊上的落差,我們發佈出去的網頁就改引用這個偽裝的 js 檔,甚至把混餚過後的程式碼,附加在知名套件的程式碼檔案的後面也可以,儘量藏在不容易被知道的地方。

經過了三個步驟:驗、變、藏,基本上不清楚規則的爬蟲,就比較難從我們的 Web Api 持續地抓到資料,其實有爬蟲來抓我們的資料,代表我們的資料有那麼一點價值,可以思考是不是有收費的可能,以上是中級爬蟲的防治方法,提供給大家參考,希望對大家有一點點幫助。

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學