Как спрятать любые данные в PNG
Настало время открыть Америку!
Меня действительно удивило предельно малое кол-во информации на данную тему. Будем исправлять.
И так, сразу к делу! Что нам нужно знать, чтобы спрятать что-то внутри PNG картинки?
Нам нужно знать, что PNG внутри себя хранит информацию о каждом пикселе. В каждом пикселе в свою очередь 3 канала (R, G, B), которые описывают цвет и один альфа-канал, который описывает прозрачность.
LSB (Least Significant Bit) — младшие биты, которые мы можем использовать для своих темных делишек. Их изменение повлечет незначительное изменение цвета, которое человеческий глаз не способен распознать.
Нам лишь нужно привести «секретную информацию» к побитовому виду и пройтись по каждому каналу каждого пикселя, меняя LSB на нужный нам.
Каждый пиксель может вмещать 3 бита информации. А значит, классическое «Hello world» на UTF-8 потребует 30 пикселей (изображение 6x6). Текст из 100 тыс слов поместится на 1000х1000. Хотим больше? Потенциальные 5мб спонтанных данных разместятся на 5000х5000.
Теория понятна (надеюсь). Время практических примеров.
Кодируем наше сообщение внутри PNG:
import { PNG } from 'pngjs'; import fs from 'node:fs'; function writeData(imageBinary, dataBinary) { for (let i = 0, dataBitIndex = 0; i < imageBinary.length; i += 4) { for (let j = 0; j < 3; j++, dataBitIndex++) { if (dataBitIndex >= dataBinary.length * 8) { return imageBinary; } /** * Получаем текущий бит данных **/ let bit = (dataBinary[Math.floor(dataBitIndex / 8)] >> (7 - (dataBitIndex % 8))) & 1; /** * Смещаем цвет **/ imageBinary[i + j] = (imageBinary[i + j] & 0xFE) | bit; } } return imageBinary; } function async encode(inputPath, outputPath, message) { let binaryMessage = Buffer.from(message, 'utf-8'); return new Promise(resolve => { /** * Открываем изображение и получаем его пиксели **/ fs.createReadStream(inputPath) .pipe(new PNG()) .on('parsed', function() { //this - Объект PNG //this.data - Объект Buffer, по сути [R, G, B, A, R, G, B, A...] /** * Запишем длинну сообщения в первые 4 байта **/ let length = Buffer.alloc(4); length.writeUInt32BE(binaryMessage.length, 0); let binaryTotalData = Buffer.concat([ length, binaryTotalData ]); /** * Заменяем пиксели **/ writeData(this.data, binaryTotalData); /** * Сохраняем в файл **/ let stream = fs.createWriteStream(outputPath); stream.on('finish', resolve); this.png.pack().pipe(stream); }); }); }
function readMessage(dataBinary) { let bytes: number[] = []; for (let i = 0, dataBitIndex = 0, currentByte = 0; i < pixels.length; i += 4) { for (let j = 0; j < 3; j++) { let bit = pixels[i + j] & 1; currentByte = (currentByte << 1) | bit; dataBitIndex++; if (dataBitIndex % 8 === 0) { bytes.push(currentByte); currentByte = 0; } } } return Buffer.from(bytes); } function async decode(targetPath) { return new Promise(resolve => { /** * Открываем изображение и получаем его пиксели **/ fs.createReadStream(targetPath) .pipe(new PNG()) .on('parsed', function() { //this - Объект PNG //this.data - Объект Buffer, по сути [R, G, B, A, R, G, B, A...] /** * Читаем данные **/ let binaryTotalData = = readData(this.data); /** * Узнаем длинну исходного сообщения и обрезаем **/ let length = binaryTotalData.readUInt32BE(); let binaryMessage = binaryTotalData.slice(4, 4 + length); resolve(binaryMessage); }); }); }
Самое интересное, что после всех манипуляций у картинок даже отличие в весе будет минимальным.
Дальше все зависит от вашей фантазии. Можно записать внутрь PNG другой файл, можно шифровать данные через AES, можно запрятать все свои пароли в фотографию с любимым вождем котом.
Можно выбирать пиксели не в произвольном порядке (использовать для этого эллиптические кривые?), можно добавить произвольный шум чтобы сложнее было обнаружить факт сокрытия данных.
Код более развернутого решения можно найти на GitHub (использование AES, сокрытие файлов в картинке).