
Фаззинг-тестирование noVNC
Введение
Современные облачные платформы и системы виртуализации повсеместно используют web-консоли для удалённого доступа к виртуальным машинам. В основе большинства таких решений лежит noVNC — JavaScript-реализация VNC-клиента, работающая прямо в браузере. Эта библиотека используется в:
- OpenStack Horizon — консоль виртуальных машин.
- Proxmox VE — web-интерфейс управления виртуализацией.
- oVirt/RHEV — корпоративные решения Red Hat.
- Kubernetes Dashboard — через VNC-proxy к подам.
- Множество коммерческих облаков — AWS WorkSpaces, Google Cloud и др.
С точки зрения безопасности, noVNC представляет особый интерес по нескольким причинам:
- Обработка недоверенных данных — клиент получает поток данных от VNC-сервера, который может быть скомпрометирован или специально модифицирован атакующим.
- Сложные бинарные протоколы — декодирование изображений (Raw, Hextile, Tight, ZRLE), сжатие zlib, криптографические операции.
- Выполнение в браузере — уязвимости могут привести к XSS, утечке данных или отказу в обслуживании.
- Обработка буфера обмена — Extended Clipboard Protocol позволяет передавать произвольные данные между клиентом и сервером.
В данной статье мы подробно рассмотрим, как провести фаззинг c обратной связью по покрытию (coverage guided) критических модулей noVNC с использованием современных инструментов, какие технические проблемы возникают при тестировании JavaScript-кода и как их преодолеть.
1. Обоснование выбора Jazzer.js
Для фаззинг-тестирования JavaScript-кода существует несколько подходов:
| Инструмент | Тип | Особенности |
|---|---|---|
| Jazzer.js | Coverage-guided | Основан на libFuzzer, инструментирует код |
| Jsfuzz | Coverage-guided | Устаревший, не поддерживается |
| AFL.js | Coverage-guided | Экспериментальный |
Мы выбрали Jazzer.js — это порт легендарного Jazzer (Google) для экосистемы JavaScript/Node.js. Ключевые преимущества:
- Coverage-guided мутации — фаззер отслеживает, какие ветви кода исполняются, и генерирует входные данные, максимизирующие покрытие.
- Интеграция с libFuzzer — используется проверенный движок мутаций.
- Поддержка корпуса — сохранение интересных тест-кейсов между запусками.
- Детекция ошибок — автоматически находит аварийные завершения, зависания, OOM.
2. Проблема инструментирования ESM модулей
Суть проблемы
noVNC написан с использованием ES Modules (ESM) — современного стандарта модульной системы JavaScript:
// core/decoders/raw.js
import { toSigned32bit } from '../util/int.js';
export default class RawDecoder { ... }
Однако Jazzer.js инструментирует код путём перехвата require() — механизма CommonJS (CJS). При попытке импортировать ESM модули инструментация просто не происходит, и фаззер работает "вслепую", без обратной связи по покрытию.
Дополнительная сложность - top-level await
Модуль core/util/browser.js в noVNC использует top-level await для определения возможностей браузера:
// core/util/browser.js
const _supportsCursorURIs = await (async function() {
// Проверка поддержки cursor URIs в браузере
})();
export { _supportsCursorURIs as supportsCursorURIs };
При попытке импортировать такой модуль в Node.js без браузерного окружения возникает ошибка. Даже если бы мы могли загрузить ESM напрямую, код упал бы на отсутствии объектов window, document, navigator.
Решение - транспиляция через Rollup
Мы применили многоступенчатый подход:
- Сборка ESM → CommonJS с помощью Rollup.
- Подмена проблемных модулей через plugin.
- Внедрение browser mock в каждый бандл.
Конфигурация Rollup (rollup.config.js):
const resolve = require('@rollup/plugin-node-resolve').default;
const commonjs = require('@rollup/plugin-commonjs');
const path = require('path');
// Plugin для замены browser.js на mock
const replaceBrowserModule = {
name: 'replace-browser-module',
resolveId(source, importer) {
if (source.endsWith('util/browser.js')) {
return path.resolve(__dirname, 'browser-mock-module.js');
}
return null;
}
};
// Browser mock, внедряемый в начало каждого бандла
const browserMockBanner = <code>
global.window = global.window || {
location: { protocol: 'http:', hostname: 'localhost' },
performance: { now: () => Date.now() },
screen: { width: 1920, height: 1080 }
};
global.document = global.document || {
createElement: () => ({
getContext: () => ({
createImageData: (w, h) => ({
data: new Uint8ClampedArray(w * h * 4), width: w, height: h
}),
putImageData: () => {}, drawImage: () => {}
})
})
};
global.navigator = { userAgent: 'Mozilla/5.0', platform: 'Linux' };
`;
module.exports = [
{
input: '../core/decoders/raw.js',
output: { file: 'bundled/raw-decoder.cjs', format: 'cjs', banner: browserMockBanner },
plugins: [resolve(), commonjs()]
},
// ... аналогично для других модулей
];
В результате получаем набор .cjs файлов, которые:
- Корректно загружаются через
require(). - Инструментируются Jazzer.js.
- Не падают из-за отсутствия браузерного окружения.
3. Выбор фаззинг-целей
3.1 Тестирование декодеров изображений
VNC передаёт изображение рабочего стола в виде прямоугольников, закодированных различными алгоритмами. Каждый алгоритм — потенциальный вектор атаки:
| Кодировка | Описание | Риски |
|---|---|---|
| Raw | Несжатые пиксели | Buffer overflow при неверных размерах |
| Hextile | Тайловое кодирование | Переполнение при рекурсивном разборе |
| Tight | zlib + JPEG | Уязвимости декомпрессии, JPEG parsing |
| ZRLE | zlib + RLE | Integer overflow в длинах |
Структура фаззинг-харнесса (fuzz-decoders.js):
const RawDecoder = require('./bundled/raw-decoder.cjs');
const HextileDecoder = require('./bundled/hextile-decoder.cjs');
const TightDecoder = require('./bundled/tight-decoder.cjs');
const ZRLEDecoder = require('./bundled/zrle-decoder.cjs');
module.exports.fuzz = function(data) {
if (data.length < 20) return; // Минимальный размер для осмысленного теста
const bytes = new Uint8Array(data);
// Выбор декодера и параметров на основе входных данных
const decoderType = bytes[0] % 4;
const width = ((bytes[3] << 8 | bytes[4]) % 800) + 1;
const height = ((bytes[5] << 8 | bytes[6]) % 600) + 1;
const depth = (bytes[11] % 3 === 0) ? 8 : 24;
// Создание mock-объектов для эмуляции среды выполнения
const mockDisplay = {
_fbWidth: 1920, _fbHeight: 1080,
blitImage: (x, y, w, h, data, offset) => { /* эмуляция отрисовки */ },
fillRect: (x, y, w, h, color) => { /* заливка цветом */ }
};
const mockSock = createMockWebSocket(bytes.slice(12));
// Выбор и запуск декодера
const decoders = [new RawDecoder(), new HextileDecoder(),
new TightDecoder(), new ZRLEDecoder()];
const decoder = decoders[decoderType];
decoder.decodeRect(x, y, width, height, mockSock, mockDisplay, depth);
};
Ключевая особенность — мы эмулируем всё окружение: WebSocket для чтения данных, Display для отрисовки. Это позволяет тестировать декодеры изолированно, без реального сетевого соединения.
3.2 Clipboard Fuzzer: Extended Clipboard Protocol
VNC Extended Clipboard — расширение протокола для обмена данными буфера обмена. Он поддерживает:
- Текст (plain text, UTF-8).
- RTF (форматированный текст).
- HTML.
- DIB (Device Independent Bitmap).
- Файлы.
Протокол использует zlib-сжатие, что создаёт дополнительную поверхность атаки:
const Inflator = require('./bundled/inflator.cjs');
const Deflator = require('./bundled/deflator.cjs');
const { encodeUTF8, decodeUTF8 } = require('./bundled/strings-util.cjs');
module.exports.fuzz = function(data) {
const bytes = new Uint8Array(data);
// Парсинг флагов протокола (4 байта)
const actions = bytes[0] << 24; // Caps, Request, Peek, Notify, Provide
const formats = bytes[3]; // Text, RTF, HTML, DIB, Files
const isProvide = !!(actions & extendedClipboardActionProvide);
if (isProvide && (formats & extendedClipboardFormatText)) {
// Распаковка zlib-сжатых данных
const streamInflator = new Inflator();
streamInflator.setInput(bytes.slice(4));
// Чтение размера и данных для каждого формата
const sizeArray = streamInflator.inflate(4);
const size = (sizeArray[0] << 24) | (sizeArray[1] << 16) |
(sizeArray[2] << 8) | sizeArray[3];
const chunk = streamInflator.inflate(Math.min(size, 1024 * 1024));
// Декодирование UTF-8 и нормализация
let textData = decodeUTF8(chunk);
textData = textData.replaceAll("\r\n", "\n");
}
};
Можно модифицировать фаззинг-цель так, чтобы заголовок файла (magic bytes, сигнатуры JPEG/ZIP) оставался константным и не подвергался мутациям. Это предотвращает генерацию невалидных файлов, отбрасываемых на первой же проверке формата.
4. Запуск фаззинг-тестирования
4.1 Сборка инструментированных модулей
cd ~/noVNC/fuzz
# Установка зависимостей
npm install
# Сборка CommonJS бандлов
npm run build
Результат — 7 файлов в каталоге bundled/:
raw-decoder.cjs,hextile-decoder.cjs,tight-decoder.cjs,zrle-decoder.cjs.inflator.cjs,deflator.cjs.strings-util.cjs.
4.2 Создание начального корпуса
Для эффективного фаззинга важно начать с качественного seed-корпуса. Необходимо собрать минимальные, но валидные примеры входных данных для каждого фаззера. Например:
- Валидный Raw-прямоугольник 16x16.
- Hextile с различными типами тайлов.
- Tight с JPEG-сжатием.
4.3 Запуск фаззеров
Decoders Fuzzer:npx jazzer fuzz-decoders-cjs.js corpus -i bundled/ \
-- -max_total_time=7200 --sync -print_final_stats=1

npx jazzer fuzz-clipboard-cjs.js corpus-clipboard -i bundled/ \
-- -max_total_time=7200 --sync -print_final_stats=1

5. Сбор и анализ покрытия кода
5.1 Методология
После завершения фаззинга корпус содержит тысячи тест-кейсов, обнаруживших новые пути выполнения. Для оценки качества тестирования необходимо измерить покрытие кода.
Мы используем c8 — инструмент coverage для Node.js, основанный на V8 coverage API:
# Прогон всех тест-кейсов из корпуса с измерением покрытия
npm run coverage:collect

Скрипт collect-coverage.js последовательно загружает каждый файл из корпуса и прогоняет через соответствующий фаззер:
function runCorpusFile(fuzzerModule, inputFile) {
const data = fs.readFileSync(inputFile);
const fuzzer = require(fuzzerModule);
fuzzer.fuzz(data);
}
// Обработка всех файлов корпуса
for (const file of corpusFiles) {
runCorpusFile('./fuzz-decoders.js', file);
}
5.2 Генерация отчётов
npm run coverage:report
6. Результаты фаззинг-тестирования
6.1 Статистика выполнения
| Фаззер | Запусков | Найдено путей | Время |
|---|---|---|---|
| Decoders | > 800 000 | 1350 | 2 часа |
| Clipboard | > 400 000 | 538 | 2 часа |
Фаззинг продолжался 2 часа для каждого фаззера, при этом количество уникальных путей еще продолжало расти. Для выполнения требований по проведению фаззинга необходимо корректировать строку запуска - увеличивать время или количество запусков, следить за открытием новых путей.
Разница в количестве путей объясняется сложностью кода:
- Декодеры содержат множество ветвлений для разных типов данных.
- Clipboard имеет среднюю сложность (сжатие + парсинг).
6.2 Покрытие кода


7. Выводы
В данной статье представлена методика проведения coverage-guided фаззинг-тестирования JavaScript-библиотек на примере noVNC. Ключевые элементы методики:
- Транспиляция ESM → CommonJS — использование Rollup для преобразования ES-модулей в формат, поддерживающий инструментирование Jazzer.js.
- Эмуляция браузерного окружения — создание mock-объектов для
window,document,navigator, позволяющих выполнять browser-only код в среде Node.js. - Изолированное тестирование модулей — построение фаззинг-целей с эмуляцией WebSocket и Display для тестирования декодеров без сетевого соединения.
- Сбор покрытия — использование c8 для измерения code coverage и оценки полноты тестирования.
Представленный подход применим к широкому классу JavaScript-библиотек, обрабатывающих недоверенные данные, и может быть адаптирован для тестирования других компонентов. Методика легко интегрируется в CI/CD pipeline для регулярного фаззинг-тестирования.
Автор:
Козачок Александр Васильевич, доктор технических наук доцент, заведующий лабораторией 12.2 ИСП РАН
Николаев Дмитрий Александрович, кандидат технических наук, научный сотрудник лаборатории 12.2 ИСП РАН