Перевод статьи Feel like a secret agent: Hidden messages in images with steganography от Pascal Thormeier.
Джеймс Бонд, Итан Хант, Наполеон Соло – секретные агенты, работающие под прикрытием и отсылающие тайные сообщения своему боссу и другим агентам. Давайте честно: они крутые. В фильмах и книгах, по крайней мере. У них есть классные приборы, они ловят злодеев, ходят в модные клубы в шикарной одежде. Ну и в конце концов, они спасают мир. Когда я был ребёнком, мечтал о том, чтобы стать секретным агентом.
В этой статье я расскажу вам о технике, которую вполне могли бы использовать секретные агенты для сокрытия одних изображений внутри других – о стеганографии.
Но сперва: что же такое стеганография?
Стеганография могла бы быть изобретением инженера Q из Ми-6 в фильмах о Джеймсе Бонде, но на самом деле она гораздо старше. Люди ещё в древности умели прятать изображения хитрыми способами.
Согласно Википедии, в 440 г. до н.э. древнегреческий писатель Геродот однажды сбрил волосы на голове одного из своих самых верных слуг, чтобы написать на его голове послание, а когда волосы снова выросли, отправил слугу к адресату.
Но брить мы сегодня никого не будем. Вместо этого мы спрячем одну картинку в другой.
Чтобы сделать это, нам нужно будет отбросить все незначительные части цвета в одной картинке и заменить их значимыми частями цвета другой.
Чего-чего? Значимыми и незначимыми?
Чтобы понять это, нам нужно знать, как устроены цвета, например, в формате PNG. Веб-разработчики, скорее всего, знакомы с шестнадцатеричным представлением цветов, например: #f60053
или #16ee8a.
Цвет в таком виде состоит из 4 отдельных частей:
#
— префикс- две 16-ричных цифры отвечают за красный цвет
- две — за зелёный
- еще две — за синий.
Так как значения для каждого цвета могут варьироваться в диапазоне от 00
до FF
, в десятичной системе счисления диапазон будет от 0
до 255
, в двоичной – от 00000000
до 11111111
.
В двоичных числах, как и в десятичных, чем левее цифра, тем больше её значение. Следовательно, чем левее цифра, тем больше «значимость» бита.
Например, 11111111
– это почти в два раза больше, чем 01111111.
С другой стороны, 11111110
чуть-чуть меньше 11111111
. Человеческий глаз, скорее всего, не заметит разницу между #FFFFFF
и #FEFEFE,
а вот между #FFFFFF
и #7F7F7F
заметит.
Прячем изображение на JavaScript
Спрячем вот такую стоковую картинку:
В этом изображении кота:
Для нашей задачи я напишу небольшой скрипт на Node. Очевидно, что ему на входе нужны три аргумента:
- путь к основному изображению
- путь к секретному изображению
- путь к результату
Сперва напишем вот такой код:
const args = process.argv.slice(2) const mainImagePath = args[0] const hiddenImagePath = args[1] const targetImagePath = args[2] // Использование: // node hide-image.js ./cat.png ./hidden.png ./target.png
Отлично. Теперь установим image-size , чтобы узнать размер основного изображения и canvas для node, чтобы работать с изображениями и генерировать новое.
Сперва найдём размерность главного изображения и секретного, и создадим для каждого свой холст (canvas). Также я создам холст для изображения с результатом:
const imageSize = require('image-size') const { createCanvas, loadImage } = require('canvas') const args = process.argv.slice(2) const mainImagePath = args[0] const hiddenImagePath = args[1] const targetImagePath = args[2] const sizeMain = imageSize(mainImagePath) const sizeHidden = imageSize(hiddenImagePath) const canvasMain = createCanvas(sizeMain.width, sizeMain.height) const canvasHidden = createCanvas(sizeHidden.width, sizeHidden.height) const canvasTarget = createCanvas(sizeMain.width, sizeMain.height) const contextMain = canvasMain.getContext('2d') const contextHidden = canvasHidden.getContext('2d') const contextTarget = canvasTarget.getContext('2d')
Затем мне потребуется загрузить оба изображения в соответствующие холсты. Так как эти методы возвращают промисы, я помещу остаток кода в функцию, вызываемую асинхронно, которая допускает использование async/await:
;(async () => { const mainImage = await loadImage(mainImagePath) contextMain.drawImage(mainImage, 0, 0, sizeMain.width, sizeMain.height) const hiddenImage = await loadImage(hiddenImagePath) contextHidden.drawImage(hiddenImage, 0, 0, sizeHidden.width, sizeHidden.height) })()
Затем я итерируюсь по каждому пикселю в изображениях и получу значения их цветов:
for (let x = 0; x < sizeHidden.width; x++) { for (let y = 0; y < sizeHidden.height; y++) { const colorMain = Array.from(contextMain.getImageData(x, y, 1, 1).data) const colorHidden = Array.from(contextHidden.getImageData(x, y, 1, 1).data) } }
С этими значениями я теперь смогу посчитать «смешанный цвет» каждого пикселя в результирующем изображении.
Вычисление нового цвета
Прежде я уже говорил о значимых битах. Чтобы на самом деле рассчитать цвет, проиллюстрирую это немного подробнее.
Скажем, мне нужно объединить красные составляющие цветов A и B. Вот так выглядят их биты (в 8-битном представлении):
A7 A6 A5 A4 A3 A2 A1 A0 (color A) B7 B6 B5 B4 B3 B2 B1 B0 (color B)
Чтобы спрятать цвет B в цвете A, я заменю первые (справа) 3 бита А последними (слева) битами B. Результирующая последовательность битов выглядит так:
A7 A6 A5 A4 A3 B7 B6 B5
Это значит, что я потеряю какую-то часть информации от обоих цветов, но их объединение не будет сильно отличаться от исходного цвета B.
Наш код будет выглядеть следующим образом:
const combineColors = (a, b) => { const aBinary = a.toString(2).padStart(8, '0') const bBinary = b.toString(2).padStart(8, '0') return parseInt('' + aBinary[0] + aBinary[1] + aBinary[2] + aBinary[3] + aBinary[4] + bBinary[0] + bBinary[1] + bBinary[2], 2) }
Теперь я могу использовать эту функцию в цикле по пикселям:
const colorMain = Array.from(contextMain.getImageData(x, y, 1, 1).data) const colorHidden = Array.from(contextHidden.getImageData(x, y, 1, 1).data) const combinedColor = [ combineColors(colorMain[0], colorHidden[0]), combineColors(colorMain[1], colorHidden[1]), combineColors(colorMain[2], colorHidden[2]), ] contextTarget.fillStyle = `rgb(${combinedColor[0]}, ${combinedColor[1]}, ${combinedColor[2]})` contextTarget.fillRect(x, y, 1, 1)
Почти готово, теперь остаётся лишь сохранить изображение-результат:
const buffer = canvasTarget.toBuffer('image/png') fs.writeFileSync(targetImagePath, buffer)
Вот и оно:
В зависимости от настроек дисплея, вы, вероятно, увидите очертания спрятанного изображения в верхней половине картинки. Поэтому обычно используется основное изображение, которое лучше закрывает собой потайное.
Но как восстановить спрятанное изображение?
Чтобы извлечь скрытую картинку, всё, что вам нужно – это прочитать последние три бита каждого пикселя и снова сделать их наиболее значимыми:
const extractColor = c => { const cBinary = c.toString(2).padStart(8, '0') return parseInt('' + cBinary[5] + cBinary[6] + cBinary[7] + '00000', 2) }
Если применить это к каждому пикселю, мы вновь получим оригинальное фото (с небольшими артефактами):
Теперь вы можете почувствовать себя секретным агентом, пряча потайные изображения и посылая их своим друзьям!
[customscript]techrocks_custom_after_post_html[/customscript]
[customscript]techrocks_custom_script[/customscript]