Adventures in Stegoland

03 April 2024
Claudio Contin


When writing malicious payloads for red teaming, it is common to hide the shellcode, rather than including it in plan within the payload itself. Commonly used techniques to hide it are:

  • Download it from a remote web server, SMB share, FTP server, etc.
  • Include it within the payload itself in an encoded or encrypted way, and decrypt it at runtime
Recently, commercial Command & Control (C2) frameworks started implementing loaders that use image steganography (stego) to hide the shellcode within the image itself (embed bytes within the image).
The below Steganography description is extracted from Wikipedia:
Steganography (/ˌstɛɡəˈnɒɡrəfi/ ⓘ STEG-ə-NOG-rə-fee) is the practice of representing information within another message or physical object, in such a manner that the presence of the information is not evident to human inspection. In computing/electronic contexts, a computer file, message, image, or video is concealed within another file, message, image, or video. The word steganography comes from Greek steganographia, which combines the words steganós (στεγανός), meaning "covered or concealed", and -graphia (γραφή) meaning "writing"
Using images, different techniques can be used for steganography, such as Least Significant Bit, Masking, Filtering, etc.
I was interested in testing this technique against some top tier EDRs to determine how stealthy and effective this technique is.
For my tests I decided to proceed with the Least Significant Bit technique using PNG images.

Least Significant Bit (LSB)

In images, a pixel is defined by the Red, Green and Blue (RGB) channels. Each channel, for each pixel, is represented by 1 byte (8 bits).
When simply changing the last bit of each channel, the resulting colour remains identical to the human eye.
Many tools exist that use the LSB technique, such as The tool guide explains how bytes/bits can be encoded within the RGB channels:
If we want to encode a single letter "a", the decimal value for this letter according to the table is 97, in binary: 01100001.
To store 3 bits per pixel, only changing the last bit, we'll need 3 pixels to encode the 'a' in our image.
The first pixel will store 0 1 1, on the lasts bits of each RGB channel, the second will store 0 0 0, and the third will store 0 1 (the blue channel will not be needed for this one).
Joining the last bits of these pixels we'll have: 01100001, converting this binary to decimal will result in 97, which is "a" according to the ASCII table.

Given the requirements, it is clear that the original image needs to be big enough to store the actual payload, at least 8 times bigger than the payload. If the image is too small, not enough pixels are present to store the whole shellcode.


Doing a quick online search, I could not identify any existing proof of concept of loaders that use steganography as a way of hiding the shellcode.
Several open source projects instead exist to perform stego operations. I decided to use as a starting point, as it is well implemented, it supports several features and it can be used for Windows, Linux and MacOS. The tool is written in C++.
The actual code I used is a fork of the project (, which splits some of the encryption and image encoding/decoding functionality into a separate library. The tool functionality is untouched.
For this post I only focused on the Windows platform, but the same concepts could be applied for other operating system.
For compilation and development I used Visual Studio with the Desktop development with C++ features. To start, I ensured that the tool worked without any modifications. I cloned the repository and use the the Visual Studio Developer Command Prompt and executed:

cd steganography


The command above generates the Visual Studio project files. To open the project in Visual Studio, simply click the steganography.sln file. The main executable is part of the steganography project (main.cpp), whereas the zlib and stb are the dependencies.
The stb is the library copied from the and allows for handling images, such as image loading/decoding from file/memory. The following image types are supported by the project: JPG, PNG, TGA, BMP, PSD, GIF, HDR, PIC. Specifically, the stb_image and stb_image_write components are used.
The zlib project I believe is based on the repository, and it is used for CRC calculation.
In Visual Studio, compile the solution.


To test that everything works as expected, test the tool with a simple shellcode, such as the plain msfvenom calculator binary shellcode.
Download a PNG image that you will use to embed the shellcode. In this example I used a PNG of 1MB in size, and 1200x1200 in actual pixel size. Open Command Prompt, navigate to the Release folder of the project, and execute the below to encode the shellcode into the image. When prompted with a password, insert any character you wish to use for it:

steganography.exe encode -i \image.png -e \calc.bin -e \output.png
The -i argument points to the original image, the -e is the shellcode file and the -o is the output image filename that will have the shellcode encoded in it.


The result will be a new image, bigger in size compared to the original one.


When comparing the two images, there will be no noticeable visual differences.


To test that you can retrieve the embedded bytes, use the tool with the decode option, supplying the same password that was used during the encoding process. The result should be the original shellcode encoded in the previous step.

steganography.exe decode -i \output.png -o \hopeitiscalc.bin



Core functionality

The tool core functionality resides in the image.cpp file. The file defines functions to load and save images from and to disk, and to encode and decode bytes using the stego LSB technique. This code interfaces with the stb library for handling PNG image related operations, such as loading the image from disk, and saving it upon encoding the data in it. The default encoding level used by the tool is Low, which we leave as it is. The header definition of the image is show below.


The encoding level is not documented in the project README, but based on the source, we can assume that the default Low operates on the last bit of the channels, which is what we are after. The Med operates on the last 2 bits, whereas the High operates on the last 4 bits.


The main.cpp of the steganography parses the arguments, and perform the encode/decode operations, as well as encrypting the data embedded, using a password key derivation algorithm to calculate the encryption key. Inspecting the file encode function implementation, we can better understand how the data is stored within the image: int encode(Image &image, const std::array &password, const std::string &input, const std::string &output, Image::EncodingLevel level). The logic:

  • Checks that the original image file exists and loads it
  • Checks that the image is big enough for embedding the provided data (shellcode in this case)
  • Calculates a random offset where the data will be stored in the image
  • Calculates the CRC of the data
  • Creates an Header structure (Struct) (defined in the main.cpp itself) to store the signature, the EncodingLevel used, the offset calculated in the prior step, the size of the data to encode and the CRC value
  • Generates a random salt and IV (initialisation vector) used for encryption
  • Derives the key based on the password provided, the salt and the IV
  • Encrypts the Header structure (Struct) using the AES (Advanced Encryption Standard) symmetric algorithm and the key derived from the password
  • Encrypts the data (shellcode in this case) using AES and the same key derived from the password
  • Writes the encrypted header and data in the image, and saves it on disk
The implementation that embeds the header and data in the encode function is displayed below:

image.encode(salt, 16, level);
image.encode(iv, 16, level, Image::encoded_size(16, Image::EncodingLevel::Low));
image.encode(encrypted_header.get(), sizeof(Header), level, Image::encoded_size(32, Image::EncodingLevel::Low));
image.encode(encrypted_data.get(), padded_size, level, offset);
Based on this implementation, we can determine that at the beginning of the image, the program firstly encodes the Salt, then the IV, and finally the encrypted header. Then, the actual encrypted data is encoded at the random offset calculated in the previous steps and stored within the header structure itself. The below shows what the data looks like within the modified image:
| Salt | IV | Header | .... | Data | .... |

The decode function loads the image with the embedded data from disk, extracts the Salt and IV from the image, calculates the encryption key based on the Salt, IV and provided password, decrypts the Header which includes the size and offset of the actual data, extracts the data, decrypts it, checks that the CRC value and signature match with the ones stored in the Header, and stores the original data on disk.

Loader idea

The tool does an amazing job at encrypting and storing the data and metadata encrypted within the image. Modifying the tool as it to build a loader around the full feature set should not be a difficult task, but I wanted to simply the logic, removing the encryption parts, the CRC calculation, etc. Removing these features would also help with removing some dependencies and reducing the compiled program size.
The goal was to simply have a program that:

  • Downloads an image from a web server
  • Extracts the shellcode embedded in the image
  • Executes the shellcode
For the tests, I decided not to implement any EDR evasion techniques, such as using indirect syscalls for NT API calls, perform userland ETW patching, encrypting the shellcode in the image, etc., as the main goal was to determine how effective this technique is by itself.


Image encoder

First, I modified the program that encodes the data in the image, to remove the encryption logic and the CRC calculation. The code still uses the Header structure to store the size and the offset of the data. The structure could be simplified to only include these two values, but given the relatively small size of the structure, I decided to just leave it as it is.
With the changes applied, the Header and shellcode are encoded in the image using the pattern shown below:
| Header | ... | Data | ... |
For testing that the data was correctly embedded in the image, I also tweaked the decode function to match the new logic: decode the Header, find the offset and size of the shellcode, decode the shellcode and save it to disk. With the encoding logic ready, I then moved to the loader implementation.


Since I wanted to keep the logic as simple as possible, I managed to completely remove the zlib library, all the code related to encryption, CRC, randomisation, command line argument parsing and the stb_image_write that is needed only if needing to save the image on disk. The partial content of the new CMakeList.txt of the project is:

  stb STATIC
  steganography_lib STATIC
The image.cpp also needed to be modified, to remove all the code related to encoding and saving of the image, and to add a new function that would allow to load a PNG image from memory, rather than from disk. The new image.hpp is shown below.

#pragma once
#include <windows.h>
#include <vector>
#include <cstdint>
#include <string>
#include <memory>
class Image{
  enum class EncodingLevel {
    Low = 0,
    Med = 1,
    High = 2,
  bool load_from_memory(std::vector<BYTE> imageData);
  std::unique_ptr<std::uint8_t[]> decode(std::size_t size, EncodingLevel level, std::size_t offset = 0);
  unsigned int w() const { return width; }
  unsigned int h() const { return height; }
  std::unique_ptr<std::uint8_t[]> image;
  unsigned int width, height;
The decode function remains untouched.
The load_from_memory takes an std::vector parameter (the download function I implemented returns a std::vector). The function uses an existing stb function (stbi_load_from_memory) that allows to load a PNG in memory. The function implementation is shown below.

bool Image::load_from_memory(std::vector<BYTE> imageData) {
  int x, y, n = 4;
  auto* buffer = stbi_load_from_memory(, imageData.size(), &x, &y, &n, n);
  if (!buffer)
    return false;
  image = std::make_unique<std::uint8_t[]>(x * y * 4);
  std::copy_n(buffer, x * y * 4, image.get());
  width = x;
  height = y;
  return true;
The stb_image.c has been modified with the following content:

#define STBI_NO_HDR
#include "stb_image.h"
Defining these MACROS allows to compile the stb library only with the support of PNG image type related functions. This helped reducing the size of the library by more than 100KB.
The rest of the changes were made of course to the main.cpp file. The remaining of the logic changes are related to the download of the image from a web server, the load of the PNG image in memory based on the data retrieved from the download (Image::load_from_memory covered above), the decode of the Header structure from retrieved by decoding the beginning of the image (first 64 bytes), and the final decoding of the actual shellcode, prior its execution. I will not cover the implementation of those, as they should be self-explanatory.


At Tier Zero Security we tested the resulting executable against Microsoft Defender, Microsoft Defender for Endpoint and another top tier EDR. For the tests, we generated a default Havoc C2 raw shellcode using the Havoc client and embedded it in a PNG using the modified steganography project, as described earlier.
The program was not detected as malicious or suspicious, and its execution was not stopped, resulting in a Havoc C2 beacon. We also tried to compile and execute the program as a DLL, and the program executed successfully without detection. Below is the execution of the executable on a host running Microsoft Defender for Endpoint with default settings.


Proof of concept

The video below shows the loader stego DLL delivered via phishing that persists and executes on every user login. The same was attempted against another top tier EDR, with the same result. The payload was not blocked and no alerts were generated.


The findings suggest that leveraging steganography to embed malicious shellcode within images offers an effective means to evade detection by AVs and EDRs.

With many web applications facilitating image upload/download, concealing malicious payloads within images becomes a straightforward task.
Such techniques could present a significant challenge to the blue team's detection capabilities if they remain unaware of this potential threat.




Claudio Claudio Contin - Principal Consultant


Get in touch