It's time to discover America!

I was really surprised by the extremely small amount of information on this topic. Let's fix that.


So, let's get straight to the point! What do we need to know to hide something inside a PNG image?

We need to know that a PNG stores information about each pixel. Each pixel, in turn, has 3 channels (R, G, B) that describe the color and one alpha channel that describes transparency.

LSB (Least Significant Bit) — the least significant bits, which we can use for our dark deeds. Changing them will cause a slight color change that the human eye cannot detect.

We just need to convert the 'secret information' into a bitwise format and go through each channel of each pixel, changing the LSB to the one we need.

Each pixel can hold 3 bits of information. This means that the classic "Hello world" in UTF-8 will require 30 pixels (a 6x6 image). A text of 100,000 words will fit into a 1000x1000 image. Want more? A potential 5MB of arbitrary data can be placed in a 5000x5000 image.


The theory is clear (I hope). Time for some practical examples.

Encoding our message inside a 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);

         });

   });
}

Getting the message from the PNG:

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);

         });

   });


}

The most interesting part is that after all these manipulations, the difference in file size between the images will be minimal.

From here, it all depends on your imagination. You can write another file inside the PNG, you can encrypt the data using AES, you can hide all your passwords in a photo of your favorite leader cat.

You can select pixels in a non-random order (using elliptic curves for this?), you can add random noise to make it harder to detect the fact that data is hidden.

The code for a more detailed solution can be found on GitHub (using AES, hiding files in an image).

Thanks everyone. Enjoy!