Hidden in Plain Sight – Steganography

To us, a flower looks like a splash of color. To a bee, it’s a visual map. Many flowers have ultraviolet patterns invisible to humans but clear to pollinators. These markings act like landing guides, directing bees straight to nectar.

UV flowers, inse…


This content originally appeared on DEV Community and was authored by Michael Z

To us, a flower looks like a splash of color. To a bee, it’s a visual map. Many flowers have ultraviolet patterns invisible to humans but clear to pollinators. These markings act like landing guides, directing bees straight to nectar.

UV flowers, insects disguised as leaves, octopuses blending into coral... Secrets can sit in the open, plain and boring, until you know the key that reveals their hidden meaning.

Nature has been “hiding” information this way for millions of years. Humans, too, learned the same trick: the best way to conceal a message is not just behind locks or codes, but inside something that looks completely ordinary.

The Romans, for example, wrote with milk that dried invisible, leaving a letter that looked blank until heated. To most, it was an innocent sheet of parchment, but to the intended recipient, it revealed a secret.

Over the centuries, methods like this grew more sophisticated, and with the rise of the digital age, steganography found entirely new opportunities.

Today, billions of images are viewed online every day and some of them may be carrying secrets. Let’s explore the fascinating world of steganography.

Steganography comes in many forms and, similarly to encryption, relies on the sender and receiver agreeing on a method. For example, "the first letter of each word reveals the password."

The difference with cryptography is this: with encryption, you know there’s a hidden message. With steganography, you may be completely unaware that one exists.

For instance, hiding the password "SynwiL" using cryptography might produce something like 4MJJ0NwTSiJJdBp+g9Skrw== whereas steganography could encode it in a sentence like: See you next week in London.

But humans are clever, and simple text-based methods can be easily uncovered.
We need something bigger, way bigger, where tiny bits can be changed without anyone noticing. Painters sometimes hide their signature in the eyes of a portrait, perhaps we can do something similar with digital images.

Large images come with many pixels, and pixels come with many bytes.

For example, a 1920x1080 image has over 2 million pixels. A 24-bit color BMP image uses 3 bytes (red, green, blue) per pixel, totaling over 6 million bytes.

Each pixel can display 16.7 million different colors (2^(3*8)) and the human eye cannot distinguish between subtle differences. This is exactly what we can exploit: by changing the least significant bit (LSB) of a byte, we can embed a secret message in an image.

However, there are a few precautions:

  • Tools can detect statistical anomalies, so it’s wise to first encrypt the message. This ensures that even if discovered, the message remains unreadable.
  • More sophisticated algorithms exist to mitigate these risks, but we won’t cover them today.

Choosing which bits to flip is also crucial. Sequential changes are too obvious, and the receiver still needs to reconstruct the message. This rules out simple methods and true randomness, leaving us with pseudo-randomness. Using a PRNG (pseudo-random number generator) with a shared seed and tool, both sender and receiver can deterministically select the same seemingly random bytes. This is powerful: even if the seed is compromised, the algorithm to generate the numbers remain a mystery.

But enough with theory, let's see how this looks in action.

Embedding

To embed the message into a file, we first encrypt it, and then turn it into the individual bits.
We also wrap the code in a special signature so we can identify when the message is fully read when extracting:

var BMP_HEADER_BYTES = 54 // we must not change these
var START = "1::"
var END = ":>0$"

encryptedPayload := encrypt(payload)
// wrap payload in start and end tags
payloadBytes := []byte(START + encryptedPayload + END)

// turn payload into bits
var bitStringBuilder strings.Builder
bitStringBuilder.Grow(len(payloadBytes) * 8)
for _, b := range payloadBytes {
  bitStringBuilder.WriteString(fmt.Sprintf("%08b", b))
}
payloadBits := bitStringBuilder.String()

// check image is large enough
availableBits := len(file) - BMP_HEADER_BYTES
if len(payloadBits) > availableBits {
  log.Fatalf("payload is too large to embed in this image.")
}

"payloadBits" is now a string that has the entire payload encrypted and encoded in binary.

We now initialize our "random" number generator. It returns a function that returns the next position for the given seed and file. We look more in depth at this later.

getNextPosition := startRng(seed, file)

Now we loop through each bit of the payload, take the byte from the file at the determined position, and replace its final bit with our new payload bit.

for _, bit := range payloadBits {
  position := getNextPosition()
  // retrieve bits from the next position in the file
  binaryStr := byteToBinaryString(file[position])
  // replace LSB
  newBits := binaryStr[:len(binaryStr)-1] + string(bit)
  // write bits back to the file
  file[position] = binaryStringToByte(newBits)
}

We write the file again and that's all there is for for embedding.

Extracting

To extract the message again, we do the same process in reverse.

In an infinite loop, we go through each position using the same rng function and collect the bits. Each 8 bits, we can save one byte. Once we reach the END marker, we break the loop.

getNextPosition := startRng(seed, file)

byteBuffer := ""
var payloadBytes []byte
extractedBits := 0
maxBitsToExtract := len(file) * 8 // Absolute theoretical maximum

for {
  // Prevent infinite loop if END marker is not found
  if extractedBits > maxBitsToExtract {
    log.Fatal("Extraction limit reached without finding END marker. Seed likely incorrect.")
  }

  position := getNextPosition()
  // extract LSB from position
  binaryStr := byteToBinaryString(file[position])
  byteBuffer += binaryStr[len(binaryStr)-1:]
  extractedBits++

  // once we have one full byte...
  if len(byteBuffer) == 8 {
    // ... append byte to our final payload
    charByte := binaryStringToByte(byteBuffer)
    payloadBytes = append(payloadBytes, charByte)
    byteBuffer = ""

    payload := string(payloadBytes)
    // another safety check
    if len(payload) == len(START) && payload != START {
      log.Fatal("Start of the message does not match. Seed/Salt likely incorrect.")
    }
    // break if end marker was detected
    if strings.HasSuffix(payload, END) {
      break
    }
  }
}
payload := string(payloadBytes)

// trim start and end markers and decrypt the message

PRNG

"startRng" returns "getNextPosition". This is so it can keep track of specific variables like the counter and previous position used. "getNextPosition" creates a unique seed using the previous position and counter to avoid repeating the same positions. It also uses the image's meta data to make the positions unique for each image.

func startRng(seed string, file []byte) func() uint64 {
    seen := make(map[uint64]bool)
    counter := 0
    var previousPos uint64

    return func() uint64 {
        for {
            imageSize := len(file)
      // create a unique seed
            uniqueSeed := seed + strconv.FormatUint(previousPos, 10) + "$#" + string(file[:BMP_HEADER_SIZE]) + string(imageSize) + ":" + strconv.Itoa(counter)
            pos := prng(uniqueSeed, imageSize)
            previousPos = pos
            counter++
      // safety check to avoid reusing positions
            if !seen[pos] {
                seen[pos] = true
                return pos
            }
        }
    }
}

// based on the unique seed, get the position within the available image size
func prng(seed string, imageSize int) uint64 {
    modifyableImageSize := imageSize - BMP_HEADER_SIZE
    hasher := sha256.New()
    hasher.Write([]byte(seed))
    seedHashStr := hex.EncodeToString(hasher.Sum(nil))[0:16]
    seedHash, err := strconv.ParseUint(seedHashStr, 16, 64)

    if err != nil {
        log.Fatal(err)
    }

    return seedHash%uint64(modifyableImageSize) + uint64(BMP_HEADER_SIZE)
}

It's surprisingly simple! For the full code, check out https://github.com/MZanggl/stego-go/tree/main.


This content originally appeared on DEV Community and was authored by Michael Z


Print Share Comment Cite Upload Translate Updates
APA

Michael Z | Sciencx (2025-08-25T20:07:41+00:00) Hidden in Plain Sight – Steganography. Retrieved from https://www.scien.cx/2025/08/25/hidden-in-plain-sight-steganography/

MLA
" » Hidden in Plain Sight – Steganography." Michael Z | Sciencx - Monday August 25, 2025, https://www.scien.cx/2025/08/25/hidden-in-plain-sight-steganography/
HARVARD
Michael Z | Sciencx Monday August 25, 2025 » Hidden in Plain Sight – Steganography., viewed ,<https://www.scien.cx/2025/08/25/hidden-in-plain-sight-steganography/>
VANCOUVER
Michael Z | Sciencx - » Hidden in Plain Sight – Steganography. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/08/25/hidden-in-plain-sight-steganography/
CHICAGO
" » Hidden in Plain Sight – Steganography." Michael Z | Sciencx - Accessed . https://www.scien.cx/2025/08/25/hidden-in-plain-sight-steganography/
IEEE
" » Hidden in Plain Sight – Steganography." Michael Z | Sciencx [Online]. Available: https://www.scien.cx/2025/08/25/hidden-in-plain-sight-steganography/. [Accessed: ]
rf:citation
» Hidden in Plain Sight – Steganography | Michael Z | Sciencx | https://www.scien.cx/2025/08/25/hidden-in-plain-sight-steganography/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.