Chromium Снятие звукового отпечатка через AudioContext API

Рейтинг: 0Ответов: 1Опубликовано: 08.02.2023

Заметил странность в работе Chromium при снятии звуковых отпечатков системы через AudioContext API. Конкретно речь идёт о гибридном методе OscillatorNode/DynamicsCompressor.

Результат снятия отпечатков через Google Chrome 110.0.5481.78 (privacycheck.sec.lrz.de): введите сюда описание изображения

Результат снятия через Chromium 109.0.5412.0 (сборка для разработчиков) на том же компьютере: введите сюда описание изображения

Вторая попытка: введите сюда описание изображения

Можно заметить, что значения поля Hybrid of OscillatorNode/DynamicsCompressor Hash отличаются на всех скриншотах. Прочие сайты, предоставляющие возможность посмотреть звуковой отпечаток дают аналогичные результаты. То есть отпечаток гибридным методом OscillatorNode/DynamicsCompressor в Chromium каждый раз разный.

Я решил посмотреть как работает код, выполняющий эту работу:

setTimeout(function() {
  run_pxi_fp();
}, 0);
setTimeout(function() {
  run_nt_vc_fp();
}, 1000);
setTimeout(function() {
  run_cc_fp();
}, 2000);
setTimeout(function() {
  run_hybrid_fp();
  $("#startAudioFingerprint").html("Click here to rerun the Test");
}, 3000);

Определение функций:

 // Performs fingerprint as found in https://client.a.pxi.pub/PXmssU3ZQ0/main.min.js
  var pxi_output;
  var pxi_full_buffer;

  function run_pxi_fp() {
    try {
      if (context = new(window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 44100, 44100), !context) {
        set_result("no_fp", "pxi_result");
        pxi_output = 0;
      }

      // Create oscillator
      pxi_oscillator = context.createOscillator();
      pxi_oscillator.type = "triangle";
      pxi_oscillator.frequency.value = 1e4;

      // Create and configure compressor
      pxi_compressor = context.createDynamicsCompressor();
      pxi_compressor.threshold && (pxi_compressor.threshold.value = -50);
      pxi_compressor.knee && (pxi_compressor.knee.value = 40);
      pxi_compressor.ratio && (pxi_compressor.ratio.value = 12);
      pxi_compressor.reduction && (pxi_compressor.reduction.value = -20);
      pxi_compressor.attack && (pxi_compressor.attack.value = 0);
      pxi_compressor.release && (pxi_compressor.release.value = .25);

      // Connect nodes
      pxi_oscillator.connect(pxi_compressor);
      pxi_compressor.connect(context.destination);

      // Start audio processing
      pxi_oscillator.start(0);
      context.startRendering();
      context.oncomplete = function(evnt) {
        pxi_output = 0;
        var sha1 = CryptoJS.algo.SHA1.create();
        for (var i = 0; i < evnt.renderedBuffer.length; i++) {
          sha1.update(evnt.renderedBuffer.getChannelData(0)[i].toString());
        }
        hash = sha1.finalize();
        pxi_full_buffer_hash = hash.toString(CryptoJS.enc.Hex);
        pxi_full_buffer_hash = forge_sha256(pxi_full_buffer_hash).slice(0, 32);
        set_result(pxi_full_buffer_hash, "pxi_full_buffer_result");
        document.getElementById('ribbonDynamicsCompressorHash').innerHTML = pxi_full_buffer_hash;
        for (var i = 4500; 5e3 > i; i++) {
          pxi_output += Math.abs(evnt.renderedBuffer.getChannelData(0)[i]);
        }
        set_result(pxi_output.toString(), "pxi_result");
        document.getElementById('ribbonDynamicsCompressor').innerHTML = pxi_output.toString();
        pxi_compressor.disconnect();
      }
    } catch (u) {
      pxi_output = 0;
      set_result("no_fp", "pxi_result");
    }
  }
  // End PXI fingerprint

  // Performs fingerprint as found in some versions of http://metrics.nt.vc/metrics.js
  function a(a, b, c) {
    for (var d in b) "dopplerFactor" === d || "speedOfSound" === d || "currentTime" ===
      d || "number" !== typeof b[d] && "string" !== typeof b[d] || (a[(c ? c : "") + d] = b[d]);
    return a
  }

  var nt_vc_output;

  function run_nt_vc_fp() {
    try {
      var nt_vc_context = window.AudioContext || window.webkitAudioContext;
      if ("function" !== typeof nt_vc_context) nt_vc_output = "Not available";
      else {
        var f = new nt_vc_context,
          d = f.createAnalyser();
        nt_vc_output = a({}, f, "ac-");
        nt_vc_output = a(nt_vc_output, f.destination, "ac-");
        nt_vc_output = a(nt_vc_output, f.listener, "ac-");
        nt_vc_output = a(nt_vc_output, d, "an-");
        nt_vc_output = window.JSON.stringify(nt_vc_output, undefined, 2);
      }
    } catch (g) {
      nt_vc_output = 0
    }
    document.getElementById('ribbonAudioContextProps').innerHTML = forge_sha256(nt_vc_output).slice(0, 32);
    set_result(nt_vc_output, 'nt_vc_result')
  }

  // Performs fingerprint as found in https://www.cdn-net.com/cc.js
  var cc_output = [];

  function run_cc_fp() {
    var audioCtx = new(window.AudioContext || window.webkitAudioContext),
      oscillator = audioCtx.createOscillator(),
      analyser = audioCtx.createAnalyser(),
      gain = audioCtx.createGain(),
      scriptProcessor = audioCtx.createScriptProcessor(4096, 1, 1);


    gain.gain.value = 0; // Disable volume
    oscillator.type = "triangle"; // Set oscillator to output triangle wave
    oscillator.connect(analyser); // Connect oscillator output to analyser input
    analyser.connect(scriptProcessor); // Connect analyser output to scriptProcessor input
    scriptProcessor.connect(gain); // Connect scriptProcessor output to gain input
    gain.connect(audioCtx.destination); // Connect gain output to audiocontext destination

    scriptProcessor.onaudioprocess = function(bins) {
      bins = new Float32Array(analyser.frequencyBinCount);
      analyser.getFloatFrequencyData(bins);
      for (var i = 0; i < bins.length; i = i + 1) {
        cc_output.push(bins[i]);
      }
      analyser.disconnect();
      scriptProcessor.disconnect();
      gain.disconnect();
      var cc_result_hash = forge_sha256(cc_output.toString().slice(0, 42)).slice(0, 32); // hash of unsliced output shield different results
      document.getElementById('onHash').innerHTML = cc_result_hash;
      document.getElementById('ribbonOscillatorNodeHash').innerHTML = cc_result_hash;
      set_result(cc_output.slice(0, 42), 'cc_result');
      draw_fp(bins);
    };

    oscillator.start(0);
  }

  // Performs a hybrid of cc/pxi methods found above
  var hybrid_output = [];

  function run_hybrid_fp() {
    var audioCtx = new(window.AudioContext || window.webkitAudioContext),
      oscillator = audioCtx.createOscillator(),
      analyser = audioCtx.createAnalyser(),
      gain = audioCtx.createGain(),
      scriptProcessor = audioCtx.createScriptProcessor(4096, 1, 1);

    // Create and configure compressor
    compressor = audioCtx.createDynamicsCompressor();
    compressor.threshold && (compressor.threshold.value = -50);
    compressor.knee && (compressor.knee.value = 40);
    compressor.ratio && (compressor.ratio.value = 12);
    compressor.reduction && (compressor.reduction.value = -20);
    compressor.attack && (compressor.attack.value = 0);
    compressor.release && (compressor.release.value = .25);

    gain.gain.value = 0; // Disable volume
    oscillator.type = "triangle"; // Set oscillator to output triangle wave
    oscillator.connect(compressor); // Connect oscillator output to dynamic compressor
    compressor.connect(analyser); // Connect compressor to analyser
    analyser.connect(scriptProcessor); // Connect analyser output to scriptProcessor input
    scriptProcessor.connect(gain); // Connect scriptProcessor output to gain input
    gain.connect(audioCtx.destination); // Connect gain output to audiocontext destination

    scriptProcessor.onaudioprocess = function(bins) {
      bins = new Float32Array(analyser.frequencyBinCount);
      analyser.getFloatFrequencyData(bins);
      for (var i = 0; i < bins.length; i = i + 1) {
        hybrid_output.push(bins[i]);
      }
      analyser.disconnect();
      scriptProcessor.disconnect();
      gain.disconnect();
      hybrid_output_hash = forge_sha256(hybrid_output.toString().slice(0, 42)).slice(0, 32); // hash of unsliced output shield different results

      set_result(hybrid_output.slice(0, 42), 'hybrid_result');
      document.getElementById('ondcHash').innerHTML = hybrid_output_hash;
      document.getElementById('ribbonHybridHash').innerHTML = hybrid_output_hash;
      var combinedHash = forge_sha256($("#ribbonAudioContextProps").html() + $("#ribbonDynamicsCompressor").html() + $("#ribbonDynamicsCompressorHash").html() + $("#ribbonOscillatorNodeHash").html() + $("#ribbonDynamicsCompressorHash").html() +
        hybrid_output_hash).slice(0, 32);
      $("#ribbonCombinedHash").html(combinedHash);
    };

    oscillator.start(0);
  }

Однако если попробовать убрать например вызов run_pxi_fp:

setTimeout(function() {
  run_nt_vc_fp();
}, 0);
setTimeout(function() {
  run_cc_fp();
}, 1000);
setTimeout(function() {
  run_hybrid_fp();
  $("#startAudioFingerprint").html("Click here to rerun the Test");
}, 2000);

Результат становится правильный:

введите сюда описание изображения

Кроме того, неверные результаты получаются только в таком порядке следования вызовов: 1. run_pxi_fp, 2. run_cc_fp, 3. run_hybrid_fp (run_nt_vc_fp влияния не оказывает).

Даже если попробовать сделать так, результат так же будет верным:

setTimeout(function () {
    run_pxi_fp();
    setTimeout(function () {
        run_cc_fp();
        setTimeout(function () {
            run_hybrid_fp();
        }, 1000);
    }, 1000);
}, 0);

При запуске браузера никаких аргументов не передаю. Мне важно разобраться с этим вопросом и попытаться исправить поведение Chromium поскольку стоит задача подмены звуковых отпечатков браузера таким образом, чтобы они оставались всегда постоянными. У меня возникает проблема именно с гибридным методом OscillatorNode/DynamicsCompressor в Chromium.


Для запуска и управления браузером использую PuppeteerSharp, однако все представленные тесты проводил запуская браузер в ручную (при управлении через DevTools Protocol результаты аналогичные). Уточню - вопрос не в том как снять правильный звуковой отпечаток, а в том как исправить поведение браузера так, чтобы известные сайты могли видеть правильный одинаковый результат.

Ответы

▲ 1

При работе с setTimeout() следуем помнить, что этот метод не гарантирует запуска callback ровно через переданный параметр таймаута. Он помещает ваш callback в очередь выполнения через указанное время. Если сейчас выполняется какая-то задача, то ваш callback будет ждать, пока задача завершится. Поэтому мы не можем полагаться на то, что передав 3000 мс, мы не вызовем callback через большее время.

Думаю, только этого достаточно, чтобы любые слепки, замеры и бенчмарки, основанные на setTimeout() считать недостоверными.

Но есть выход. Интерфейс performance. Вы можете замерять реально время, которое прошло с момента инициализации таймаута и использовать это значение как коэффициент для нормализации данных перед расчётами.

Ссылки: