Представьте, что вы хотите отправить секретное сообщение другу, но канал, который вы хотите использовать, взломан и контролируется. Вы могли бы использовать какое-нибудь шифрование, но это вызовет подозрения у людей, отслеживающих ваши разговоры, поэтому вам придется использовать что-то другое.
В настоящее время стеганография — это метод сокрытия секретных сообщений в другом, несекретном файле (например, изображении кошки), чтобы, если вы отправите этот файл, он не был бы обнаружен. Стеганография не ограничивается сокрытием текста в изображениях и обычно означает «сокрытие секретной информации в другом несекретном сообщении или физическом объекте»: вы можете скрыть некоторые сообщения в аудио, видео или других текстах, используя, например, столбчатое транспонирование.
Стеганография также может быть чрезвычайно полезна во многих других случаях, например, она может стать хорошей альтернативой водяным знакам в конфиденциальных документах для защиты их от утечки.
Существует множество способов скрыть информацию на изображениях: от простого добавления текста в конец файла до его сокрытия в метаданных. В этой статье я хочу осветить более продвинутый метод стеганографии, спускающийся на двоичный уровень и скрывающий сообщения внутри границ самого изображения.
Для моего примера стеганографии я решил использовать JavaScript, поскольку это мощный язык программирования, который можно выполнять в браузере.
Я придумал простой интерфейс, который позволяет пользователям загружать изображение, чтобы прочитать скрытое в нем сообщение или самостоятельно закодировать сообщение в изображении.
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Steganography</title> <style> body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; } textarea { width: 300px; height: 100px; } button { margin: 10px; } #outputImage { margin-top: 20px; max-width: 100%; } </style> </head> <body> <h1>Steganography Example</h1> <input type="file" id="upload" accept="image/*"><br> <canvas id="canvas" style="display:none;"></canvas><br> <textarea id="text" placeholder="Enter text to encode"></textarea><br> <button id="encode">Encode Text</button> <button id="decode">Decode Text</button> <p id="decodedText"></p> <img id="outputImage" alt="Output Image"> <script src="./script.js"></script> </body> </html>
Чтобы использовать его, пользователи могут просто выбрать изображение, которым они хотят манипулировать, и либо попытаться декодировать из него некоторый текст, либо закодировать его и загрузить изображение позже.
Для работы с изображениями в JavaScript мы можем использовать Canvas API . Он предоставляет множество различных функций для управления и рисования изображений, анимации или даже игровой графики и видео.
Canvas API в основном используется для 2D-графики. Если вы хотите работать с 3D-графикой с аппаратным ускорением, вы можете использовать API WebGL (который, кстати, также использует элемент <canvas>).
const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const image = new Image();
Чтобы прочитать файл изображения из файловой системы и добавить его в контекст холста, мы можем использовать FileReader API . Это позволяет нам легко читать содержимое любого файла, хранящегося на компьютере пользователя, без необходимости использования специальной библиотеки.
function handleFileUpload(event) { const reader = new FileReader(); reader.onload = function (e) { image.src = e.target.result; image.onload = function () { canvas.width = image.width; canvas.height = image.height; ctx.drawImage(image, 0, 0); }; }; reader.readAsDataURL(event.target.files[0]); }
Он считывает файл и рисует изображение из этого файла в нашем ранее определенном контексте 2D-холста, после чего мы можем либо закодировать некоторый текст в это изображение, либо попытаться прочитать текст из изображения.
Изображения состоят из пикселей, и каждый пиксель содержит информацию о своих цветах. Например, если изображение закодировано с использованием модели RGBA, каждый пиксель будет содержать 4 байта информации о том, сколько красного, зеленого, синего и альфа (непрозрачности) он представляет.
Чтобы закодировать некоторый текст в изображении, мы могли бы использовать один из этих каналов (например, альфа-канал). Поскольку эта информация представлена в двоичной системе (например, 01001100), мы можем переключить последний бит на то, что нам нужно. Он называется наименее значащим битом (LSB), и его изменение приводит к минимальным изменениям в самом изображении, что делает его неотличимым от человеческого.
Теперь представьте, что у нас есть текст типа «Привет», и мы хотим закодировать его в изображение. Алгоритм для этого будет
Преобразуйте текст «Привет» в двоичный.
Переберите байты данных изображения и замените младший бит этих байтов битом двоичного текста (каждый пиксель содержит 4 байта данных для каждого цвета, в моем примере я хочу изменить канал непрозрачности изображения). , поэтому я бы повторял каждый 4-й байт).
Добавьте нулевой байт в конце сообщения, чтобы при декодировании мы знали, когда остановиться.
Примените измененные байты изображения к самому изображению.
Во-первых, нам нужно взять у пользователя текст, который мы хотим закодировать, и выполнить с ним некоторые базовые проверки.
const text = document.getElementById("text").value; if (!text) { alert("Please enter some text to encode."); return; }
Затем нам нужно преобразовать текст в двоичный формат и создать холст изображения, в которое мы собираемся закодировать этот текст.
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imgData.data; let binaryText = ""; for (let i = 0; i < text.length; i++) { let binaryChar = text.charCodeAt(i).toString(2).padStart(8, "0"); binaryText += binaryChar; }
Для этого мы можем просто перебрать каждый из символов и получить индекс Unicode, используя функцию charCodeAt
. Затем этот Юникод преобразуется в двоичный и дополняется так, чтобы его длина была такой же, как у любого другого символа.
Например, буква «H» представлена в Юникоде как 72; затем мы преобразуем это число в двоичный формат (1001000) и добавляем 0 в начале (01001000), чтобы убедиться, что все буквы будут одинаковой длины (8 бит).
Затем нам нужно добавить нулевой байт в конце сообщения, чтобы быть уверенными, что при его расшифровке мы сможем отличить реальный текст от случайных пиксельных данных изображения.
binaryText += "00000000";
Затем нам нужно выполнить базовую проверку, чтобы убедиться, что изображение имеет достаточно пикселей для кодирования нашего сообщения и не допускает его переполнения.
if (binaryText.length > data.length / 4) { alert("Text is too long to encode in this image."); return; }
А затем наступает самое интересное — кодирование сообщения. Массив данных, который мы определили ранее, содержит информацию о пикселях в виде значений RGBA для каждого пикселя изображения. Итак, если изображение закодировано RGBA, каждый пиксель в нем будет представлен четырьмя значениями массива данных; каждое значение показывает, сколько красного, зеленого и синего имеет этот пиксель.
for (let i = 0; i < binaryText.length; i++) { data[i * 4] = (data[i * 4] & 0b11111110) | parseInt(binaryText[i]); } ctx.putImageData(imgData, 0, 0); const outputImage = document.getElementById("outputImage"); outputImage.src = canvas.toDataURL();
В приведенном выше коде мы перебираем текст в двоичном коде. data[i * 4]
находит байт, который нам нужно изменить, и поскольку мы хотим изменить только байты определенного канала, мы умножаем переменную i
на 4
чтобы получить к ней доступ.
Операция data[i * 4] & 0b11111110
устанавливает младший бит в 0
. Например, если data[i * 4]
равно 10101101
в двоичном формате, то результатом операции 10101101 & 11111110
будет 10101100
. Это гарантирует, что младший бит будет установлен в 0
, прежде чем мы приступим к дальнейшим манипуляциям с ним.
parseInt(binaryText[i])
— это текущий бит двоичной строки; это либо 1
, либо 0
. Затем мы можем установить этот бит в младший разряд, используя побитовую операцию ИЛИ ( |
). Например, если левая часть побитового ИЛИ равна 10101100
, а binaryText[i]
равен 1
, то 10101100 | 00000001
приведет к 10101101
. Если бы текущий бит был равен 0
, результатом OR было бы 10101100
. Вот почему нам пришлось в первую очередь удалить LSB.
Как только сообщение закодировано, мы можем поместить его на текущий холст и отобразить в HTML с помощью метода canvas.toDataURL
.
Процесс декодирования изображения на самом деле намного проще, чем кодирование. Поскольку мы уже знаем, что закодировали только альфа-канал, мы можем просто перебрать каждый 4-й байт, прочитать последний бит, объединить его в окончательную строку и преобразовать эти данные из двоичного кода в строку Unicode.
Сначала нам нужно инициализировать переменные. Поскольку imgData
уже заполнена информацией об изображении (мы вызываем ctx.drawImage
каждый раз, когда читаем файл из файловой системы), мы можем просто извлечь ее в переменную данных.
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imgData.data; let binaryText = ""; let decodedText = "";
Затем нам нужно перебрать каждый 4-й байт изображения, прочитать последний бит и объединить его с переменной binaryText
.
for (let i = 0; i < data.length; i += 4) { binaryText += (data[i] & 1).toString(); }
data[i]
— это закодированный байт, и для извлечения младшего разряда мы можем использовать побитовый оператор AND ( &
). Он принимает два значения и выполняет операцию И над каждой парой соответствующих битов. Сравнивая data[i]
с 1
, мы по сути изолируем младший бит из информации о пикселе, и если младший бит равен 1
, то результатом такой операции будет 1
. Если младший бит равен 0
, результат также будет 0
.
После того, как мы прочитали все младшие разряды и сохранили их в binaryText
, нам нужно преобразовать их из двоичного формата в обычный текст. Поскольку мы знаем, что каждый символ состоит из 8 бит (помните, как мы использовали padStart(8, "0")
чтобы сделать каждый символ одинаковой длины?), мы можем выполнять итерацию для каждого 8-го символа binaryText
.
Затем мы можем использовать операцию .slice()
для извлечения текущего байта из binaryText
на основе нашей итерации. Двоичную строку можно преобразовать в число с помощью функции parseInt(byte, 2)
. Затем мы можем проверить, равен ли результат 0
(нулевой байт) — мы останавливаем преобразование и запрашиваем результат. В противном случае мы можем найти, какой символ соответствует номеру Юникода, и добавить его в нашу строку результата.
for (let i = 0; i < binaryText.length; i += 8) { let byte = binaryText.slice(i, i + 8); if (byte.length < 8) break; // Stop if the byte is incomplete let charCode = parseInt(byte, 2); if (charCode === 0) break; // Stop if we hit a null character decodedText += String.fromCharCode(charCode); }
Декодированный текст затем можно безопасно отобразить пользователю:
document.getElementById("decodedText").textContent = decodedText;
Я оставил полный код, используемый в этой статье, в своем репозитории GitHub ; не стесняйтесь играть с этим. Есть много вещей, которые можно улучшить :)
Стеганография — очень мощный метод, и ее можно применять во множестве различных случаев использования, начиная от проверки документов, предотвращения утечек, проверки изображений AI, управления DRM музыкальных файлов и многих других. Эту технику можно применять даже к видео, играм и даже необработанному тексту, поэтому я думаю, что у нее огромный потенциал.
В эпоху NFT и блокчейнов еще интереснее посмотреть, как они найдут свои варианты использования и как будет развиваться эта технология.