Steganography is the art and science of hiding information within other seemingly innocent data. Let’s build one in Rust from scratch
Steganography is the art and science of hiding information within other seemingly innocent data or media, such as images, audio files, or text. It operates under the principle that the hidden message goes unnoticed by anyone except the intended recipient. Recently, I have been working in the world of cybersecurity, and I found the LSB steganography to be a fun exercise to practice my Rust.
What is LSB?
LSB steganography, also known as Least Significant Bit steganography, stores information in the last bit of the binary number. Let´s see an example:
An image is just an assortment of pixels organized as a matrix. We can see each of the values of the pixels as a byte, thus allowing us to use the LSB to store our information and change the value of the information in the image slightly so it’s not notable.
We can use LSB with other techniques, such as encryption, to obfuscate our message even more. For this article, we will encode our message in plain text.
Implementing LSB
We can split the implementation into two parts encoding and decoding. Encoding is the part where the message is inserted into a particular image. Decoding is where we retrieve the data stored inside an image.
Encoding
To start, we will need a function that has the following parameters: an image path that we will use to hide the information, a file path to the information to be hidden, and lastly, a destination to save the image with our hidden message. In Rust, this would look like this:
fn encode(src: &str, msg_src: &str, dest: &str) {
// Load file content as bits
// Get message size (stored as a 4 byte value)
// Add size to the message
// Open the image
// Check capacity of the image
// Encode data using LSB
// Save the image
}
As you can see, I also show the steps to be followed in the encoding algorithm. Let’s do the first:
fn encode(src: &str, msg_src: &str, dest: &str) {
// Load file content as bits
let message_bytes = read(msg_src).unwrap();
let message_bits = BitUtils::make_bits(message_bytes);
// Get message size (stored as a 4 byte value)
// Add size to the message
// Open the image
// Check capacity of the image
// Encode data using LSB
// Save the image
}
That was easy, but as you can see, I’ve used a function contained in BitUtils called make_bits. This function takes a byte represented in decimal base and transforms it to its binary representation. Meaning, for example, that the byte 4 would be [0,0,0,0,0,1,0,0] . The implementation is as follows:
// Takes a bytes vector and transforms them into a bit
// For example a vector like [4, 8] would be [0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0]
pub fn make_bits(bytes: Vec<u8>) -> Vec<u8>
bytes
.iter() // Iterates over every byte
.flat_map(|byte| Self::byte_to_bit(*byte)) // Transforms the current byte into a bit
.collect() // Collects the results
}
// Transforms a decimal represented byte into its bit representation
// For example 4 would be [0,0,0,0,0,1,0,0]
pub fn byte_to_bit(byte: u8) -> Vec<u8> {
(0..8)
.rev() // Itererates from 7 to 0
.map(|i| (byte >> i) & 1) // Gets the bit in the current position
.collect() // Collects the results
}
We can now keep going with our encoding function by getting the number of bits that are part of our message and appending it to the start of our message to allow for a quicker recovery later down the line.
fn encode(src: &str, msg_src: &str, dest: &str) {
// Load file content as bits
let message_bytes = read(msg_src).unwrap();
let message_bits = BitUtils::make_bits(message_bytes);
// Get message size (stored as a 4 byte value)
let message_size = BitUtils::byte_u32_to_bit(message_bits.len() as u32);
// Add size to the message
let mut complete_message = Vec::new();
complete_message.extend_from_slice(&message_size); // adds the message size
complete_message.extend_from_slice(&message_bits); // adds the message
// Open the image
// Check capacity of the image
// Encode data using LSB
// Save the image
}
We need to open the image where we will perform our LSB steganography. For this, I will use only .png images by using the png crate.
fn encode(src: &str, msg_src: &str, dest: &str) {
// Load file content as bits
let message_bytes = read(msg_src).unwrap();
let message_bits = BitUtils::make_bits(message_bytes);
// Get message size (stored as a 4 byte value)
let message_size = BitUtils::byte_u32_to_bit(message_bits.len() as u32);
// Add size to the message
let mut complete_message = Vec::new();
complete_message.extend_from_slice(&message_size); // adds the message size
complete_message.extend_from_slice(&message_bits); // adds the message
// Open the image
// Decodes the image where we are going to hide our data
let decoder = Decoder::new(File::open(src).unwrap());
// Get the reader as mutable to perform operations
let mut binding = decoder.read_info(); // This is needed because of borrow
let reader = binding.as_mut().unwrap();
// Check capacity of the image
// Encode data using LSB
// Save the image
}
Now that we have the image decoded, we can check the data and the metadata, like, for example, the capacity of the image. We’ll use this to check for enough room for our data in the image.
fn encode(src: &str, msg_src: &str, dest: &str) {
// Load file content as bits
let message_bytes = read(msg_src).unwrap();
let message_bits = BitUtils::make_bits(message_bytes);
// Get message size (stored as a 4 byte value)
let message_size = BitUtils::byte_u32_to_bit(message_bits.len() as u32);
// Add size to the message
let mut complete_message = Vec::new();
complete_message.extend_from_slice(&message_size); // adds the message size
complete_message.extend_from_slice(&message_bits); // adds the message
// Open the image
// Decodes the image where we are going to hide our data
let decoder = Decoder::new(File::open(src).unwrap());
// Get the reader as mutable to perform operations
let mut binding = decoder.read_info(); // This is needed because of borrow
let reader = binding.as_mut().unwrap();
// Check capacity of the image
if complete_message.len() > reader.output_buffer_size() {
error!("Image is too small: message size is {} and image allows for {}",
complete_message.len(),
reader.output_buffer_size());
return;
}
// Encode data using LSB
// Save the image
}
Now comes the time we have been waiting for the actual implementation of the LSB.
fn encode(src: &str, msg_src: &str, dest: &str) {
// Load file content as bits
let message_bytes = read(msg_src).unwrap();
let message_bits = BitUtils::make_bits(message_bytes);
// Get message size (stored as a 4 byte value)
let message_size = BitUtils::byte_u32_to_bit(message_bits.len() as u32);
// Add size to the message
let mut complete_message = Vec::new();
complete_message.extend_from_slice(&message_size); // adds the message size
complete_message.extend_from_slice(&message_bits); // adds the message
// Open the image
// Decodes the image where we are going to hide our data
let decoder = Decoder::new(File::open(src).unwrap());
// Get the reader as mutable to perform operations
let mut binding = decoder.read_info(); // This is needed because of borrow
let reader = binding.as_mut().unwrap();
// Check capacity of the image
if complete_message.len() > reader.output_buffer_size() {
error!("Image is too small: message size is {} and image allows for {}",
complete_message.len(),
reader.output_buffer_size());
return;
}
// Encode data using LSB
let mut data = vec![0; reader.output_buffer_size()]; // Create a data vector
reader.next_frame(&mut data).unwrap(); // Gets image data
let info = reader.info(); // Gets metadata of the image
let mut i = 0; // Tracks current bit
// Iterate over bits of the message
for bit in complete_message.iter() {
if *bit == 1 && data[i] % 2 == 0 {
// Check if the current bit of the message is equal
// to 1 and check if the LSB of the image data is 0
// If both are true, then we need to flip the data to 1 (+1)
data[i] += 1;
} else if *bit == 0 && data[i] % 2 != 0 {
// Check if the current bit of the message is equal
// to 0and check if the LSB of the image data is 1
// If both are true, then we need to flip the data to 0(-1)
data[i] -= 1;
}
i += 1;
}
// Save the image
// Creates the destination file
let encoded_img = File::create(dest).unwrap();
// Creates a png encoder, with the same dimension than the original image
let mut image_encoder = Encoder::new(BufWriter::new(encoded_img), info.width, info.height);
// Set the same properties than the original img
image_encoder.set_color(info.color_type);
image_encoder.set_depth(info.bit_depth);
// Writes the image
image_encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
The encode function encodes data using LSB steganography. It loads the content of a message file, converts it to bits, determines the message size, and combines it with the message bits. It then opens an image file, reads its data, and checks if the image has enough capacity to accommodate the complete message. If so, it modifies the image data by flipping specific bits based on the message bits using LSB manipulation.
Finally, it creates a destination file, sets the properties of the encoded image, and writes the modified image data to the file. The resulting image file contains the hidden message using LSB steganography.
We now have a fully working encoding function, but how will we know it’s working if we cannot get the message back to test it? That’s were the decode function comes in.
Decoding
The decode function is way easier than the encode one. This one reads the image, reads the first 32 bits to check the length of the message, then reads the number of bits signaled by the length and saves the data recovered into a file.
fn decode(src: &str, dest: &str) {
// Decodes the data from the image with the message
let decoder = Decoder::new(File::open(src).unwrap());
let mut binding = decoder.read_info();
// Gets the reader
let reader = binding.as_mut().unwrap();
// Reads the data in the reader to the data vector (we will recive bytes)
let mut data = vec![0; reader.output_buffer_size()];
reader.next_frame(&mut data).unwrap();
// Splits the first 32 bits to check the message length,
// then the rest of the data
let (message_len, image_data) = data.split_at(32);
// Transforms the message length bytes to it's decimal value,
// signifing the number of bits
let message_len = BitUtils::byte_u32_to_decimal(BitUtils::read_lsb(message_len.to_vec()));
// Gets the bytes that have LSB that
// are part of the message, discards the rest
let (bytes_message, _): (&[u8], &[u8]) = image_data.split_at(message_len as usize);
// Gets the bit of the message, by reading the LSB of ever byte
let message_bits = BitUtils::read_lsb(bytes_message.to_vec());
// Gets the bytes of the message again
let message_retrived = BitUtils::bits_to_bytes(message_bits);
// Creates and saves the file with the message
let mut output_file = File::create(Path::new(dest)).unwrap();
output_file.write_all(&message_retrived).unwrap();
}
As with the decoder, we use the BitUtils functionality we made. The code is shown below:
// Transforms 4 bytes in its bit form into its decimal representation
pub fn byte_u32_to_decimal(byte: Vec<u8>) -> u32 {
byte.iter() // Iterate over each byte
.enumerate() // Enumerate the bits of each byte, pairing each bit with its index
.filter(|(_, &bit)| bit == 1) // Filter the enumerated bits, keeping only those where the value is equal to 1
.fold(0u32, |acc, (i, _)| acc + 2u32.pow(31 - i as u32))
// Perform a folding operation, accumulating the values obtained from the filtered bits
// - The accumulator `acc` is initialized with 0u32
// - For each filtered bit, raise 2 to the power of (31 - i) and add it to the accumulator
// The result is the accumulated value, representing the decimal interpretation of the filtered bits
}
// Reads the least significant bit (LSB) from a byte array
pub fn read_lsb(bytes: Vec<u8>) -> Vec<u8> {
bytes.iter() // Iterate over each byte in the `bytes` vector
.map(|byte| byte % 2) // Apply the modulo operation (`byte % 2`) to each byte, resulting in the LSB
.collect(); // Collect the mapped values into a new `Vec<u8>`
}
// Takes bits and transforms them into bytes
pub fn bits_to_bytes(bits: Vec<u8>) -> Vec<u8> {
let mut output: Vec<u8> = Vec::new();
// Iterate over chunks of 8 bits in the `bits` vector
for byte in bits.chunks(8) {
// Check if the chunk has exactly 8 bits
if byte.len() == 8 {
// Convert the chunk of 8 bits to a decimal value and push it to the `output` vector
output.push(Self::byte_to_decimal(byte.to_vec()));
}
}
output
}
Now that we have an encoder and a decoder, we can perfectly use the program to make LSB steganography, but changing the sources for the files would be uncomfortable. As such, we can create a simple CLI using clap.
CLI
We will make a CLI that will be called rust-steganography –option write|read –file path/to/file.txt –image path/to/image.png –output path/to/file-or-image. With clap, this can be done with a struct representing our command. Here’s what the code looks like:
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// If is to read orwrite
#[arg(short, long)]
option: String,
/// The path to the file to read the message from
#[arg(short, long)]
file: Option<String>,
/// The path to the image to use to hide the message using LSB
#[arg(short, long)]
image: String,
/// The path to output the file
#[arg(long)]
output: String,
}
fn main() {
// Read arguments
let args = Cli::parse();
// Match the value of the `option` field in the `args` struct
match args.option.as_str() {
"read" => {
info!("Starting to read file {}", &args.image);
decode(&args.image, &args.output) // Call `decode` function with `image` and `output` fields as arguments
},
"write" => {
// Match the value of the `file` field in the `args` struct
match args.file {
Some(file) => {
info!("Starting to write file {}", &args.output);
encode(&args.image, &file, &args.output) // Call `encode` function with `image`, `file`, and `output` fields as arguments
},
None => eprintln!("ERROR: File not passed!") // Print error message if `file` field is `None`
}
},
_ => panic!("No valid option given: please try to use --help to see the valid options"), // Panic with an error message for an invalid option
}
}
As such, we now have the following application:
We can see that our program saved the data into the image out.png , because the size of the file is bigger.
Then by using or decode function, we can retrieve the data saved in out.png as shown below:
Conclusion
We made a simple plain text LSB steganography CLI using Rust that allows us to encode a message (shown as text, but it works in images and other files) into a .png image for recovery later. Though it works, it could be improved by the following:
- Using encryption to secure the information even if the steganography is compromised.
- Using other algorithms, this varies in type and can be seen here.
- Removing Rust code smells.
- Anything else your heart desires
You can find this code in this GitHub repository.
Let’s Build a Steganography CLI From Scratch was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.