Продолжаю серию. В этот раз — про
SharedArrayBuffer и историю с отключением во всех браузерах.
SharedArrayBuffer — почти то же самое, что
ArrayBuffer, но с одним принципиальным отличием: его можно передать в несколько Web Workers одновременно, и все они будут работать с одной и той же памятью.
const sab = new SharedArrayBuffer(256);
worker.postMessage(sab); // worker получает доступ к той же памяти
С обычным
ArrayBuffer при передаче через
postMessage происходит transfer — оригинал становится недоступным, а получатель получает владение. Это сделано намеренно: два потока не должны одновременно писать в одну память без синхронизации.
SharedArrayBuffer снимает это ограничение — но тогда синхронизацию нужно делать самостоятельно. Для этого существует объект
Atomics. Он предоставляет атомарные операции — гарантированно неделимые чтение-запись:
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
// В воркере 1:
Atomics.add(view, 0, 1); // атомарный инкремент
// В воркере 2:
Atomics.load(view, 0); // атомарное чтение
Без
Atomics параллельная запись в
SharedArrayBuffer приводит к data race — результат непредсказуем.
Atomics.add,
Atomics.load и другие методы гарантируют, что операция выполнится целиком, без прерывания другим потоком. Есть и механизм ожидания:
Atomics.wait(view, index, expectedValue) блокирует поток, пока значение по индексу равно ожидаемому.
Atomics.notify(view, index) будит ждущие потоки.
У
SharedArrayBuffer непростая история. В начале 2018 года его отключили во всех браузерах — Chrome, Firefox, Safari, Edge — одновременно. Причина:
атака Spectre. Чтобы понять, при чём тут
SharedArrayBuffer, нужно разобрать саму атаку — она на самом деле довольно изящная.
Современные процессоры не ждут, пока проверится условие
if — они предсказывают результат и выполняют код «наперёд». Если предсказание верное — результат уже готов. Если нет — процессор откатывает результат. Но есть нюанс: данные, которые попали в кеш процессора во время спекуляции, остаются там даже после отката.
Допустим, в памяти процесса (рядом с нашим JS-кодом) лежит секретный байт — например, кусок данных от другой вкладки. Напрямую прочитать его нельзя, есть проверка границ. Но атакующий может обойти её через спекуляцию.
Шаг 1: атакующий много раз вызывает код с валидным индексом. Предсказатель ветвлений запоминает: «условие всегда true».
Шаг 2: атакующий подаёт индекс, который выходит за границу массива и указывает на секретный байт. Процессор по привычке предсказывает «true» и спекулятивно читает секрет — допустим, значение
42.
Шаг 3: спекулятивный код использует прочитанное значение как индекс в другом массиве —
probeArray[42 * 256]. Эта ячейка попадает в кеш. Потом процессор понимает, что условие было false, и откатывает всё. Но кеш не откатывается.
Шаг 4: атакующий перебирает все 256 возможных значений, обращаясь к
probeArray[0 * 256],
probeArray[1 * 256], …,
probeArray[255 * 256], и замеряет время каждого обращения. Одна ячейка отвечает за ~3 наносекунды (из кеша), все остальные — за ~100 наносекунд (из оперативной памяти). Быстрая ячейка выдаёт значение секрета:
42.
Секрет утёк не напрямую, а через побочный канал: значение было использован как индекс в массиве и он оставил физический след в кеше.
Вся атака стоит на способности различить «3 наносекунды» и «100 наносекунд». Для этого нужен очень точный таймер.
performance.now() давал точность ~5 микросекунд — это всё ещё достаточно грубо. А
SharedArrayBuffer позволяет собрать самодельный наносекундный таймер буквально в пять строк:
// Воркер-таймер:
const counter = new Uint32Array(sharedBuffer);
while (true) {
counter[0]++;
}
// Основной поток:
const start = counter[0];
// ... замеряем что-то ...
const elapsed = counter[0] - start;
Один воркер крутит счётчик в бесконечном цикле, другой поток читает его до и после интересующей операции. Разница — время выполнения с точностью вплоть до наносекунд. Этого достаточно, чтобы отличить попадание в кеш от промаха, а значит — достаточно для Spectre.