如何用 WebGL / shader 來偵測 CDP / 指紋模糊

如何用 WebGL / shader 來偵測 CDP / 指紋模糊 的原理、常見實作檢查點、範例流程(概念性 shader 與讀取像素流程)、以及合法測試時的緩解建議都說清楚。

重點放在「偵測方法與理論」,不會給出能直接用來逃避防護的詳盡步驟

概觀

反爬/反機器人系統會利用 WebGL 的渲染差異來判斷執行環境是否為真實使用者的瀏覽器。原因在於:WebGL shader 的行為依賴於 GPU、驅動、GL implementation(例如 ANGLE)、字型、字元渲染、浮點精度、抗鋸齒等,這些在 headless/CDP、自動化或虛擬化環境中常常出現可被量化的偏差。透過執行精心設計的 fragment shader 並讀出像素(readPixels),可以建立一個「渲染指紋」或做一致性檢查,若與真實瀏覽器分布不符,則可判為自動化/模糊環境。

原理要點(為何可行)

  1. 浮點與精度差異:不同 GPU 與 driver 在 shader 中的浮點運算會造成微小數值差異,累積下來會顯示在像素值上。
  2. 實作差異(ANGLE / GLES / Desktop GL):Chrome 在 Windows 上用 ANGLE 把 GL 呼叫轉成 DirectX;在 headless 或某些 container 中,實作可能落回軟體 rasterizer(如 SwiftShader),輸出特徵明顯不同。
  3. 擴充與功能支援:某些 extension(例如 EXT_shader_texture_lodOES_texture_float)在 headless/簡化環境可能不可用。
  4. 抗鋸齒 / 分段化差異:anti-aliasing 與浮點貼圖處理,在不同構建環境會有可測差異。
  5. 字型與 subpixel 差異:如果 shader 涉及文本或特定字形渲染,系統缺字型或不同 font rasterizer 會造成不同像素結果。
  6. 時間 / 性能模式:在 DevTools 附著或模擬模式下,shader 編譯時間與渲染 timing 可能異常(可做 timing-based 偵測)。
  7. 精心設計的 shader 可放大差異:例如遞迴累積、對細微四捨五入敏感的數學運算等,都會把硬體差異放大成可量化的像素差。

常見的檢查項目(實務上會做的)

  • 渲染 hash:執行 shader、gl.readPixels,取得 RGBA bytes 並用 hash(SHA1/MD5)得到渲染指紋,與資料庫常見真實瀏覽器值比對。
  • 浮點貼圖支援檢測:嘗試建立 OES_texture_floatEXT_color_buffer_float 並檢查成功率與渲染結果。
  • 精度一致性檢測:讓 shader 做大量迭代累加,檢查最終像素是否落在某範圍(微小偏移會被識別)。
  • extension list 差異:列出 WebGL extensions,與常見真機差比對。
  • 編譯/連結錯誤與警告行為:某些 headless stack 對 shader 的容錯不同,會有可檢測訊息。
  • 分辨率 / DPR / AA 行為:在不同 devicePixelRatio 與抗鋸齒開關下的行為差異。
  • timing 分佈:著色器編譯與渲染所需時間(performance.now())在 headless/virtual environment 常有可辨識模式。

概念性範例流程(偵測端 — 抽象示意)

下列為「概念性」示意,省略低階細節與環境逃避步驟。

  1. 建立一個 canvas、取得 webgl context(可能用 webglwebgl2)。
  2. 編譯一個 fragment shader,內容做大量浮點運算、三角函數、雜湊式混合等,設計能放大微小浮點誤差。
  3. 在 shader 中輸出到畫面(每個 pixel 的值依賴複雜計算),呼叫 gl.readPixels 取得結果陣列。
  4. 從結果陣列計算統計量或 hash(例如:平均值、標準差、SHA256)。
  5. 與真實瀏覽器資料庫或預期分佈比較,若差異超過閾值則標註為「可疑」。

簡化的 fragment shader 構成(概念):

// 概念性片段 shader(不是完整可執行程式)
precision highp float;
uniform vec2 u_res;
void main() {
  vec2 uv = gl_FragCoord.xy / u_res;
  // 做多次迭代,放大精度差異
  float v = 0.0;
  for (int i = 0; i < 200; ++i) {
    v += sin(uv.x * float(i) * 12.9898) * 43758.5453;
    v = fract(v);
  }
  gl_FragColor = vec4(fract(v), fract(v*1.3), fract(v*1.7), 1.0);
}

呼叫 gl.readPixels 後把 bytes 轉成 hash,得到渲染指紋。

進一步的統計/判斷方法

  • K–S 檢定 / 距離度量:對連續像素值分佈做統計檢測,判斷與真實分布是否不同。
  • 多場景測試:不同 shader、不同分辨率、不同 uniform(字體、AA on/off),收集多個 hash 做 ensemble 判斷,提高準確性。
  • 惡意偵測回合(challenge-response):反爬系統會多次循環檢測並根據「是否能穩定複製預期 hash」來加權。

限制與誤判風險

  • 驅動/硬體差異本身會造成 false positive:不同使用者的真實硬體差異也會導致渲染指紋不同,必須用群體分布做判斷。
  • 虛擬機 / 雲端真實 GPU:若在 VM 裝有真實 GPU,也可能通過檢測。
  • 系統升級或 driver 更新會改變分佈:需定期更新指紋數據庫。
  • 讀取像素需權限與 CORS 注意:跨域或某些瀏覽器策略可能限制 readPixels 在某些情況下的可用性。

合法測試與緩解建議(給做 QA / 測試的人)

如果你的目的是合法地讓自動化測試結果更接近真實瀏覽器,建議採取下列方法,而不是嘗試直接繞過檢測:

  1. 使用真實或完整的瀏覽器二進位(non-headless):避免使用舊式 headless 與軟體 rasterizer。
  2. 啟用硬體加速 / 使用實體 GPU 或完整 GPU passthrough:比軟體 rasterizer 更接近真實硬體行為。
  3. 安裝常見字型與系統元件:避免字型差異造成的渲染偏差。
  4. 使用受信任的商用工具/服務(Multilogin、Kameleo 等)在合規範圍內:這些工具專門為合法測試/多帳號管理設計。
  5. 在測試中接受小幅度的渲染差異:建立自己的容錯閾值,用多場景測試驗證穩定性。
  6. 盡量以行為(模擬人類互動)而非僅指紋偽裝為主:滑鼠移動、鍵入節奏與互動邏輯對真實度影響最大。
  7. 與對方溝通/取得測試授權:若要做負載或擴大測試,事先取得網站方同意或提供 test endpoints。

小結(關鍵提醒)

  • WebGL shader 檢測基於硬體、驅動與 GL 實作的微小差異,能放大 headless/CDP 與真實瀏覽器的差異並生成可比的指紋。
  • 這些檢測技術對反爬系統來說非常有效,但也可能造成誤判;因此需要用統計方法與多重指標綜合判定。
  • 如果你的目標是合法的 QA / 自動化測試,應採用「使測試環境接近真實使用情境」的策略(真實瀏覽器、硬體、字型、自然互動),而不是專注於繞過檢測算法。

提供一個用於測試/研究的可重複實驗設計(如何在受控環境下收集 WebGL 指紋分佈並做比較)

下面給你一份完整且可重複的實驗設計(繁體中文),目的是在受控環境下收集 WebGL 渲染指紋(render fingerprint) 的分佈並做比較。設計涵蓋:實驗目的、變項、收集程式碼(可直接注入瀏覽器或在自動化測試中執行)、資料格式、實驗流程、統計分析方法、變異來源控制、樣本量建議與倫理注意事項。你可直接把下面的程式碼注入頁面或放進自動化框架執行收集資料。

一、實驗目標

  1. 在多個受控環境(真實桌機、VM、headless、雲端 VM)中收集 WebGL render 指紋(shader output hash + metadata)。
  2. 比較不同環境間的像素輸出分佈與 timing 差異,評估能否把環境分類(例如:真機 vs headless vs VM-swiftshader)。
  3. 提供可重複的資料格式與分析流程(包含統計檢定與 clustering)。

二、實驗變項(獨立與依變)

  • 獨立變項(環境):
    • 真實桌機(具硬體 GPU)
    • VM with GPU passthrough(如果可用)
    • Headless Chrome + SwiftShader(軟體 rasterizer)
    • Headful Chrome but DevTools/CDP 附著
    • 不同 GPU driver 版本 / 不同字型集合(可視需要)
  • 控制變項:
    • Canvas 大小、devicePixelRatio、AA on/off、瀏覽器版本、shader 程式、sampling 次數
  • 依變項(將被收集/比較):
    • shader output bytes(raw RGBA bytes)
    • hash(SHA-256)或多個 hash(不同 shader)
    • 編譯時長、render 時長(ms)
    • WebGL extensions 列表
    • gl.getParameter 的若干值(VENDOR, RENDERER, VERSION, MAX_TEXTURE_SIZE, MAX_VERTEX_UNIFORM_VECTORS…)
    • navigator 相關屬性(userAgent、hardwareConcurrency、platform、language、userAgentData if available)
    • 隨機 seed 與 run id

三、實驗硬體/軟體環境建議

為了能比較差異,至少準備以下環境:

  • A: 真實桌面(Windows 或 Linux, 實體 GPU)
  • B: VM(無 GPU passthrough,使用虛擬 GPU/軟體渲染)
  • C: Headless Chrome(with SwiftShader,--headless=new
  • D: Headful Chrome + DevTools 附著(如有)
  • 可選:雲端 GPU VM(GCE/AWS/…)與不同 driver 版本
    紀錄每個環境的 Chrome 版本、OS、GPU 型號、driver 版本、字型檔列表。

四、收集程式(前端 JavaScript,可注入頁面)

下面是一段可直接放在頁面或用 add_script_to_evaluate_on_new_document 注入的 JS(會做多次 render、讀回 pixels、計算 SHA-256, 並把結果以 POST 上傳到你指定的收集 endpoint;也可改為 console.log 存檔)。

(async function collectWebGLFingerprint(opts) {
  opts = opts || {};
  const canvasW = opts.width || 512;
  const canvasH = opts.height || 512;
  const runs = opts.runs || 3; // 每個 shader 做幾次 sampling
  const endpoint = opts.endpoint || null; // 若要上傳到 server,給 URL

  function hex(buffer) {
    const bytes = new Uint8Array(buffer);
    return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join('');
  }

  async function sha256ArrayBuffer(buf) {
    const digest = await crypto.subtle.digest('SHA-256', buf);
    return hex(digest);
  }

  function createGL() {
    const canvas = document.createElement('canvas');
    canvas.width = canvasW;
    canvas.height = canvasH;
    // 嘗試 webgl2, fallback webgl
    const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true }) ||
               canvas.getContext('webgl', { preserveDrawingBuffer: true });
    return { gl, canvas };
  }

  function compileShader(gl, type, source) {
    const s = gl.createShader(type);
    gl.shaderSource(s, source);
    gl.compileShader(s);
    const ok = gl.getShaderParameter(s, gl.COMPILE_STATUS);
    const log = gl.getShaderInfoLog(s);
    if (!ok) throw new Error("Shader compile failed: " + log);
    return s;
  }

  function createProgramFromSources(gl, vsSrc, fsSrc) {
    const vs = compileShader(gl, gl.VERTEX_SHADER, vsSrc);
    const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSrc);
    const p = gl.createProgram();
    gl.attachShader(p, vs);
    gl.attachShader(p, fs);
    gl.linkProgram(p);
    const ok = gl.getProgramParameter(p, gl.LINK_STATUS);
    if (!ok) {
      throw new Error("Program link failed: " + gl.getProgramInfoLog(p));
    }
    return p;
  }

  // 簡單 vertex shader(覆蓋整個 quad)
  const vsSrc = `#version 300 es
  in vec4 position;
  void main() {
    gl_Position = position;
  }`;

  // 範例 fragment shader - 故意做大量迭代以放大浮點差異
  const fsSrc = `#version 300 es
  precision highp float;
  out vec4 outColor;
  uniform vec2 u_res;
  float randf(float x) {
    return fract(sin(x) * 43758.5453123);
  }
  void main() {
    vec2 uv = gl_FragCoord.xy / u_res;
    float v = 0.0;
    for (int i = 1; i <= 300; ++i) {
      v += sin(uv.x * float(i) * 12.9898 + uv.y * float(i) * 78.233) * 43758.5453;
      v = fract(v);
    }
    outColor = vec4(fract(v), fract(v * 1.37), fract(v * 2.13), 1.0);
  }`;

  // Fallbacks for webgl1: convert shader sources if needed
  function runOnce() {
    const { gl, canvas } = createGL();
    if (!gl) throw new Error("No WebGL context available");
    const isWebGL2 = (gl instanceof WebGL2RenderingContext);

    // Create program
    let program;
    try {
      if (isWebGL2) {
        program = createProgramFromSources(gl, vsSrc, fsSrc);
      } else {
        // convert to webgl1 compatible
        const vs1 = `
        attribute vec4 position;
        void main() { gl_Position = position; }`;
        const fs1 = `
        precision highp float;
        uniform vec2 u_res;
        void main() {
          vec2 uv = gl_FragCoord.xy / u_res;
          float v = 0.0;
          for (int i = 1; i <= 200; ++i) {
            v += sin(uv.x * float(i) * 12.9898 + uv.y * float(i) * 78.233) * 43758.5453;
            v = fract(v);
          }
          gl_FragColor = vec4(fract(v), fract(v*1.37), fract(v*2.13), 1.0);
        }`;
        program = createProgramFromSources(gl, vs1, fs1);
      }
    } catch (e) {
      return { error: "shader_error:" + (e && e.message) };
    }

    // setup quad
    const posLoc = isWebGL2 ? gl.getAttribLocation(program, 'position') : gl.getAttribLocation(program, 'position');
    const posBuf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
      -1,-1,  1,-1,  -1,1,
      -1,1,  1,-1,   1,1
    ]), gl.STATIC_DRAW);

    gl.viewport(0,0,canvasW,canvasH);
    gl.useProgram(program);
    // set uniforms
    const uRes = gl.getUniformLocation(program, 'u_res');
    if (uRes) gl.uniform2f(uRes, canvasW, canvasH);

    gl.enableVertexAttribArray(posLoc);
    gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);

    // draw
    const t0 = performance.now();
    gl.drawArrays(gl.TRIANGLES, 0, 6);
    gl.finish();
    const t1 = performance.now();

    // read pixels
    const pixels = new Uint8Array(canvasW * canvasH * 4);
    gl.readPixels(0, 0, canvasW, canvasH, gl.RGBA, gl.UNSIGNED_BYTE, pixels);

    // collect extensions + params
    const exts = gl.getSupportedExtensions() || [];
    let vendor = null, renderer = null;
    try {
      const dbg = gl.getExtension('WEBGL_debug_renderer_info');
      if (dbg) {
        vendor = gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL);
        renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL);
      }
    } catch(e){}
    const params = {
      MAX_TEXTURE_SIZE: gl.getParameter(gl.MAX_TEXTURE_SIZE),
      MAX_VIEWPORT_DIMS: gl.getParameter(gl.MAX_VIEWPORT_DIMS),
      MAX_VERTEX_UNIFORM_VECTORS: gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS)
    };

    return {
      pixels,
      time_ms: (t1 - t0),
      extensions: exts,
      vendor,
      renderer,
      params,
      isWebGL2
    };
  }

  // run multiple times, hash each pixel buffer
  const results = [];
  for (let r=0; r<runs; ++r) {
    try {
      const out = runOnce();
      if (out.error) {
        results.push({ run: r, error: out.error });
        continue;
      }
      const hash = await sha256ArrayBuffer(out.pixels.buffer);
      results.push({
        run: r,
        hash,
        time_ms: out.time_ms,
        vendor: out.vendor,
        renderer: out.renderer,
        extensions: out.extensions,
        params: out.params,
        isWebGL2: out.isWebGL2
      });
      // small delay between runs
      await new Promise(res => setTimeout(res, 100));
    } catch (e) {
      results.push({ run: r, error: String(e) });
    }
  }

  // metadata
  const metadata = {
    ua: navigator.userAgent,
    userAgentData: navigator.userAgentData || null,
    hwConcurrency: navigator.hardwareConcurrency || null,
    platform: navigator.platform || null,
    languages: navigator.languages || null,
    dpr: window.devicePixelRatio || 1,
    canvas_w: canvasW,
    canvas_h: canvasH,
    timestamp: new Date().toISOString()
  };

  const payload = { results, metadata };

  if (endpoint) {
    try {
      await fetch(endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload)
      });
    } catch (e) {
      console.warn('upload failed', e);
      console.log(JSON.stringify(payload));
    }
  } else {
    console.log('webgl-fingerprint', payload);
  }

  return payload;
})(window.__fingerprintOpts__);

使用方法

  • 直接在欲測試的瀏覽器環境(各實驗環境)執行上面程式(或注入)。
  • 設定 window.__fingerprintOpts__ = { endpoint: "https://yourserver/collect", runs: 5 },或不給 endpoint,會 console.log 結果,由自動化腳本抓取。
  • 建議每台環境至少做 20 個 session(不同時間、不同重啟)每個 session 取得 3~5 次 run。

五、資料格式(建議 JSON / CSV schema)

將每次 session 的 payload 存成一條 JSON line (.ndjson):

{
  "session_id": "envA-2025-11-24-01",
  "env_label": "real-desktop",
  "metadata": { ... },
  "results": [
    {
      "run": 0,
      "hash": "abcdef1234...",
      "time_ms": 12.34,
      "vendor": "...",
      "renderer": "...",
      "extensions": ["OES_texture_float", ...],
      "params": { "MAX_TEXTURE_SIZE": 16384, ... }
    },
    ...
  ]
}

或轉成 CSV(每行一個 run)方便分析:

  • session_id, env_label, run, hash, time_ms, vendor, renderer, extensions(comma-joined), params_json, ua, hwConcurrency, dpr, timestamp

六、實驗流程(步驟)

  1. 設置收集 endpoint 或準備自動化腳本抓 console.log
  2. 在每個環境 (A/B/C/D) 設定好 Chrome 與系統紀錄(版本、driver、字型清單)。
  3. 在每個環境中執行 N 次 session(N 建議 ≥ 20);每 session 做 R 次 run(R 建議 3~5)。
  4. 紀錄每次 run 的 hash、render time、extensions、parameters、UA、DPR 等。
  5. 把所有資料集中到一個資料庫或 CSV/JSON 檔。
  6. 用下述統計方法進行分析。

七、統計分析方法(可重複)

7.1 前處理

  • 把 hash 作為分類特徵(若多個 shader,會有多個 hash column)。
  • 轉換 extensions 列表為 one-hot。
  • 若 raw pixels 可用,計算統計量(每 channel 的平均、標準差、pixel histogram)。

7.2 可視化

  • 熵圖 / heatmap:不同環境的 hash frequency。
  • PCA / t-SNE:用 numeric features(time_ms, params, extensions one-hot)降維並可視化群集。
  • boxplot:render time 比較。

7.3 分佈檢定(是否不同)

  • Kolmogorov–Smirnov (KS) test:比較兩組 numeric distributions(如 time_ms)。
  • Chi-squared test:比較 categorical variables(extensions presence)。
  • Permutation test:對於小樣本比較 robust。

7.4 分類 / clustering(評估可分辨度)

  • 用簡單模型(Random Forest / Logistic Regression / SVM)去分類 env_label,用 cross-validation 評估 accuracy。
  • 聚類(K-means / DBSCAN)看是否能自然分出群集。
  • ROC / AUC:如果把問題變成「是否為 headless」,可評估偵測器效能。

7.5 判斷閾值

  • 若單一 hash 在某環境的出現頻率 > 95% 且其他環境 ≪ 5%,則視為高可信度指標。
  • 綜合多重 hash/feature 時用 ensemble 決策。

八、樣本量建議

  • 每個環境至少 20 sessions × 每 session 3 runs = 60 runs。
  • 若要做 machine learning 分類,至少 200~500 runs/環境 更好(視變異程度與特徵維度)。
  • 進行統計檢定(KS test 等)最少 30 個觀察值可較穩定。

九、如何控制變異(提高可比性)

  • 每次測試前重啟瀏覽器(減少累積 state/driver cache)。
  • 固定 canvas 大小與 DPR(或記錄它,分層分析)。
  • 固定瀏覽器版本或在分析時分層(stratify)處理。
  • 安排測試在不同時間(早晚)以測量時間帶來的變動。
  • 收集硬體/driver 信息做為協變量(covariates)。

十、潛在困難與誤判風險

  • 真實用戶間本身也有大量差異(不同 GPU、driver、作業系統);因此不應用單一測試斷定「為 bot」。
  • cloud GPU / VM 若使用真實 GPU 可能會產生與實體機非常接近的指紋,導致 false negative。
  • driver 更新會改變渲染分佈;需定期更新 baseline。

十一、分析範例(Python, 使用 pandas & sklearn)

下面為一個簡短的分析腳本範例(讀 CSV、做簡單分類),你可以直接執行在本地 Python 環境。

# analysis_example.py
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score
from sklearn.preprocessing import OneHotEncoder
from sklearn.compose import ColumnTransformer

df = pd.read_csv('webgl_runs.csv')  # 假設每行一個 run,包含 env_label, hash, time_ms, extensions, vendor, renderer, ...
# simple features: time_ms, vendor, renderer, extensions -> one-hot
df['ext_key'] = df['extensions'].fillna('').apply(lambda s: s.replace(' ', ''))
cat_cols = ['vendor','renderer','ext_key']
num_cols = ['time_ms']

# One-hot ext_key by splitting comma
df['ext_key'] = df['ext_key'].astype(str)

ct = ColumnTransformer([
    ('ohe', OneHotEncoder(handle_unknown='ignore'), cat_cols)
], remainder='passthrough')

X = ct.fit_transform(df[cat_cols + num_cols])
y = df['env_label']

clf = RandomForestClassifier(n_estimators=100, random_state=0)
scores = cross_val_score(clf, X, y, cv=5, scoring='accuracy')
print("CV accuracy:", scores.mean(), scores)

十二、倫理與合規提醒

  • 僅在你擁有權限或為測試目的操作的環境與目標上收集指紋。
  • 不要在沒有對方授權的情況下對第三方網站進行大量指紋採集或壓力測試。
  • 若收集了可識別個人資訊(PII),請依照地區法律(GDPR、CCPA、個資法)保護資料與刪除策略。

十三、可擴展的後續方案(選項)

  • 建立指紋資料庫, 每種環境定期收集並更新 baseline。
  • 使用多 shader sets(不同數學特性)提高辨識率。
  • 結合行為指標(滑鼠、鍵盤)與 WebGL 指紋進行 ensemble 偵測。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *