Chromium Снятие звукового отпечатка через AudioContext API
Заметил странность в работе 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 результаты аналогичные). Уточню - вопрос не в том как снять правильный звуковой отпечаток, а в том как исправить поведение браузера так, чтобы известные сайты могли видеть правильный одинаковый результат.