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

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/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.