steg is a command-line steganography tool written in Go. It hides an arbitrary file inside a PNG, BMP, or TIFF image by modifying the least-significant bits of selected color channels in a pseudorandom pixel sequence. The number of bits per channel (1–8) and the number of channels (R / R+G / R+G+B) are configurable, trading capacity for visual detectability. The hidden data is encrypted and authenticated, so the carrier image looks near-identical to the original while the payload is unreadable and tamper-evident without the correct password.
- Features
- Installation
- Usage
- Capacity
- Performance
- Security design
- On-image layout
- Architecture
- Development
- Known limitations
- Roadmap
- Steganalysis
- Authenticated encryption — AES-128-CTR encryption with HMAC-SHA256; a wrong password returns an error, never garbled data.
- Strong key derivation — Argon2id (time=2, mem=64 MiB, threads=4) derives independent encryption and MAC keys plus the cipher nonce from the password and per-image random salt in a single call.
- Random salt per encode —
crypto/randgenerates a fresh 16-byte salt on every encode, stored in plaintext in the image header; Argon2id derives all crypto keys and the cipher nonce from the password and this salt, so each encode produces a unique keystream even with the same password and carrier. - Password-keyed pixel traversal — pixels are visited in a Fisher-Yates-shuffled order derived from the password; an observer without the password cannot locate which pixels carry data.
- Configurable capacity vs. detectability —
--bits-per-channel(1–8 LSBs per channel) and--channels(1=R, 2=R+G, 3=R+G+B) let you trade off payload capacity against visual impact. At 1 bit/channel no pixel changes by more than ±1. capacitycommand — prints a table of usable byte capacity for every (channels × bits-per-channel) combination for a given image.test-visualcommand — generates carrier images filled to capacity at every encoding intensity for side-by-side visual comparison.detectcommand — runs chi-square and RS steganalysis on any image and reports a per-channel verdict (CLEAN/SUSPICIOUS/LIKELY_STEGO).- Multiple image formats — PNG, BMP, and TIFF are supported as both input and output.
- Parallel mode — a worker-pool implementation (
-P) scales encode/decode across all available CPUs, giving up to ~2.5× speedup on large images. - Interoperable modes — images encoded with the sequential path can be decoded with the parallel path and vice versa.
Download the latest binary for your platform from the Releases page. Releases are tagged v<YYYYMMDD>-<commit> and published automatically on every push to master.
| Platform | File |
|---|---|
| Linux amd64 | steg-linux-amd64 |
| Linux arm64 | steg-linux-arm64 |
| macOS amd64 | steg-darwin-amd64 |
| macOS arm64 (Apple Silicon) | steg-darwin-arm64 |
| Windows amd64 | steg-windows-amd64.exe |
After downloading, make the binary executable (Linux/macOS):
chmod +x steg-linux-amd64
sudo mv steg-linux-amd64 /usr/local/bin/stegRequires Go 1.24+.
go install github.com/pableeee/steg/cmd/steg@latestOr clone and build manually:
git clone https://github.com/pableeee/steg.git
cd steg
make build # produces cmd/steg/stegHide a file inside a carrier image:
steg encode -i carrier.png -f secret.txt -o output.png -p "my passphrase"| Flag | Short | Default | Description |
|---|---|---|---|
--input_image |
-i |
— | Carrier image (PNG, BMP, or TIFF) |
--input_file |
-f |
— | File to hide |
--output_image |
-o |
— | Output image containing the hidden data |
--password |
-p |
— | Passphrase (required) |
--bits-per-channel |
-b |
1 |
Number of LSBs to use per color channel (1–8) |
--channels |
-c |
3 |
Color channels to use: 1=R, 2=R+G, 3=R+G+B |
--parallel |
-P |
off | Use parallel worker pool (faster on large images) |
Recover a hidden file from a carrier image:
steg decode -i output.png -o recovered.txt -p "my passphrase"| Flag | Short | Default | Description |
|---|---|---|---|
--input_image |
-i |
— | Image containing the hidden data |
--output_file |
-o |
— | Path for the recovered file |
--password |
-p |
— | Passphrase (required) |
--bits-per-channel |
-b |
1 |
Must match the value used during encode |
--channels |
-c |
3 |
Must match the value used during encode |
--parallel |
-P |
off | Use parallel worker pool (faster on large images) |
Show usable byte capacity for every (channels × bits-per-channel) combination:
steg capacity -i carrier.pngcarrier.png — 1920 × 1080 px
1 bits/ch 2 bits/ch 4 bits/ch 8 bits/ch
1 channel (R) 253.11 KB 506.23 KB 1.01 MB 2.01 MB
2 channels (R+G) 506.23 KB 1.01 MB 2.01 MB 4.01 MB
3 channels (R+G+B) 759.34 KB 1.49 MB 3.02 MB 6.03 MB
Overhead: 56 B (16 plaintext-salt + 4 container-length + 4 real-length + 32 HMAC).
Generate carrier images filled to capacity at every intensity for side-by-side comparison:
steg test-visual -i carrier.png -o ./visual/ -p "mypass"Writes up to 12 PNGs (visual_ch{1-3}_b{1,2,4,8}.png) into the output directory.
Run steganalysis on an image to check for LSB steganography:
steg detect -i image.png| Flag | Short | Description |
|---|---|---|
--input_image |
-i |
Image to analyse (PNG, BMP, TIFF) |
Example output:
Chi-square analysis (high p-value = suspicious):
R: χ²=127.17 p=0.4790 [SUSPICIOUS]
G: χ²=1583.51 p=0.0000 [CLEAN]
B: χ²=2274.04 p=0.0000 [CLEAN]
RS analysis (positive asymmetry = suspicious):
R: Rm=0.4992 Sm=0.5008 R-m=0.5440 S-m=0.4560 asymmetry=-0.0448 [CLEAN]
G: Rm=0.5206 Sm=0.4794 R-m=0.5223 S-m=0.4777 asymmetry=-0.0017 [CLEAN]
B: Rm=0.5191 Sm=0.4809 R-m=0.5246 S-m=0.4754 asymmetry=-0.0055 [CLEAN]
Verdict: SUSPICIOUS
# Hide a document with default settings (3 channels, 1 bit/channel)
steg encode -i photo.png -f report.pdf -o photo_steg.png -p "hunter2"
# Recover it
steg decode -i photo_steg.png -o report_recovered.pdf -p "hunter2"
# Use 2 channels and 2 bits/channel for more capacity
steg encode -c 2 -b 2 -i photo.png -f archive.tar.gz -o out.png -p "hunter2"
steg decode -c 2 -b 2 -i out.png -o archive.tar.gz -p "hunter2"
# Use parallel mode for large images
steg encode -P -i 4k_photo.png -f big_archive.tar.gz -o out.png -p "hunter2"
# Encode into a BMP carrier; decode back
steg encode -i photo.bmp -f secret.txt -o out.bmp -p "hunter2"
steg decode -i out.bmp -o recovered.txt -p "hunter2"
# Check capacity before encoding
steg capacity -i photo.png
# Analyse an image for signs of LSB steganography
steg detect -i photo.png
steg detect -i suspected_steg.pngThe usable payload capacity depends on the image dimensions and the chosen --channels / --bits-per-channel settings:
max_payload = max(0, floor( width × height × channels × bitsPerChannel / 8 ) − 40) bytes
The 56-byte overhead covers: 16-byte plaintext salt + 4-byte encrypted container length + 4-byte encrypted real-length prefix + 32-byte encrypted HMAC tag.
Default settings (3 channels, 1 bit/channel):
| Image size | Pixels | Max payload |
|---|---|---|
| 100 × 100 | 10,000 | ~3.6 KB |
| 500 × 500 | 250,000 | ~91.5 KB |
| 1920 × 1080 (FHD) | 2,073,600 | ~759 KB |
| 3840 × 2160 (4K) | 8,294,400 | ~2.97 MB |
Use steg capacity -i <image> to print a full table for all (channels × bits-per-channel) combinations at once. If the payload exceeds the image capacity, encode returns an error.
Benchmarks run on an AMD Ryzen 9 9950X3D (32 logical cores, Go 1.24, GOMAXPROCS=32). Key-derivation time (Argon2id, ~10 ms) dominates at small sizes.
| Image | Payload | Encode seq | Encode par | Decode seq | Decode par |
|---|---|---|---|---|---|
| 100 × 100 | 1 KB | 10.1 ms | 10.2 ms | 11.6 ms | 10.2 ms |
| 500 × 500 | 50 KB | 18.7 ms | 13.5 ms | 15.8 ms | 12.8 ms |
| 2000 × 2000 | 500 KB | 213 ms | 126 ms | 173 ms | 118 ms |
| 3840 × 2160 (4K) | 2 MB | 833 ms | 327 ms | 759 ms | 306 ms |
Run the benchmarks yourself:
go test ./steg/ -bench=BenchmarkEncodeBySize -benchtime=3s -benchmem
go test ./steg/ -bench=BenchmarkDecodeBySize -benchtime=3s -benchmem| Component | Algorithm | Notes |
|---|---|---|
| Pixel-traversal seed | SHA-256(password), first 8 bytes | Not a crypto secret — determines which pixels carry data |
| Per-image salt | crypto/rand (16 bytes) |
Stored in plaintext at image bits 0–127; unique per encode |
| Key derivation | Argon2id | time=2, mem=64 MiB, threads=4; keyed with password + randomSalt |
| Encryption key | KDF output bytes 0–15 | 16-byte AES-128 key |
| MAC key | KDF output bytes 16–47 | 32-byte HMAC-SHA256 key |
| Payload nonce | KDF output bytes 48–51 | 4-byte AES-CTR nonce; unique per encode via random salt |
| Stream cipher | AES-128-CTR | Custom bit-addressable CTR; seekable keystream |
| Authentication | HMAC-SHA256 | Keyed with independent MAC key; constant-time comparison |
- Confidentiality — AES-128-CTR with a strong KDF-derived key. An attacker without the password sees only pseudorandom bits across a pseudorandomly-ordered set of pixels.
- Integrity / authentication — HMAC-SHA256 over the plaintext payload, encrypted alongside it. A wrong password or any bit-flip in the encrypted region produces a MAC failure; no plaintext is returned.
- Resistance to brute force — Argon2id with 64 MiB memory requirement makes offline dictionary attacks expensive, even on GPU hardware.
- Keystream uniqueness — A fresh
crypto/rand16-byte salt is generated on every encode and stored in plaintext in the image header. Argon2id derives the cipher nonce from the password and this salt, so each encode produces a unique payload keystream even when the same password and carrier are reused. - Pixel deniability — Without the password, an attacker cannot determine which pixels carry data (the traversal order is derived from SHA-256 of the password).
- An attacker who can compare the carrier and the steg image pixel-by-pixel will observe that the LSBs of R, G, and B channels differ from a typical natural image distribution (statistical steganalysis).
Bits are stored in the shuffled pixel sequence, green channel before blue within each pixel:
Bit offset Size Cipher Field
────────────────────────────────────────────────────────────────────────────────
0 128 bits none (plaintext) Per-encode random salt
128 32 bits AES-128-CTR (payload) Container length (uint32, LE)
160 32 bits AES-128-CTR (payload) Real payload length (uint32, LE)
192 N×8 bits AES-128-CTR (payload) Real payload bytes
192 + N×8 P×8 bits AES-128-CTR (payload) Random padding (fills to capacity)
192 + (N+P)×8 256 bits AES-128-CTR (payload) HMAC-SHA256 tag
A single AES-128-CTR payload cipher (AES-CTR(encKey, payloadNonce)) encrypts everything from bit 128 onward. The 16-byte randomSalt at bits 0–127 is stored in plaintext — an attacker who sees it still cannot derive any keys without the password. Argon2id takes the password and randomSalt and produces all key material (encKey, macKey, payloadNonce) in a single call, so no bootstrap cipher is needed. Every encode writes the full image capacity, so the LSB distribution is uniformly disturbed regardless of payload size.
┌─────────────────────────────────────────────────┐
│ cmd/steg (Cobra CLI, PNG I/O) │
└──────────────────┬──────────────────────────────┘
│ draw.Image + password
┌──────────────────▼──────────────────────────────┐
│ steg.Encode / Decode / EncodeParallel / │
│ DecodeParallel (orchestration, deriveKeys) │
└──────┬────────────────────────────┬─────────────┘
│ seed │ encKey, macKey
┌──────▼────────────┐ ┌───────────▼─────────────┐
│ RNGCursor │ │ cipher.NewCipher │
│ pixel traversal │ │ AES-128-CTR, seekable │
│ pixel cache │ │ bit/byte-level XOR │
└──────┬────────────┘ └───────────┬─────────────┘
│ Cursor │ StreamCipherBlock
┌──────▼────────────────────────────▼─────────────┐
│ CipherMiddleware (encrypt/decrypt per byte) │
└──────┬──────────────────────────────────────────┘
│ Cursor (ReadByte / WriteByte)
┌──────▼──────────────────────────────────────────┐
│ CursorAdapter (Cursor → io.ReadWriteSeeker) │
└──────┬──────────────────────────────────────────┘
│ io.ReadWriteSeeker + hash.Hash
┌──────▼──────────────────────────────────────────┐
│ container.WritePayload / ReadPayload │
│ [4B length][payload][32B HMAC tag] │
└─────────────────────────────────────────────────┘
| Package | Responsibility |
|---|---|
cmd/steg |
Cobra CLI; PNG/BMP/TIFF file I/O; encode, decode, capacity, test-visual, and detect subcommands |
steg |
Encode/decode orchestration; Argon2id key derivation; parallel worker pool |
steg/container |
Payload framing (length prefix + HMAC tag); constant-time tag verification |
cursors |
RNGCursor (Fisher-Yates pixel traversal, write-back pixel cache), CursorAdapter (byte↔bit bridge), CipherMiddleware (transparent encrypt/decrypt) |
cipher |
AES-128 CTR stream cipher; bit- and byte-addressable keystream; seekable |
steg/analysis |
Chi-square and RS steganalysis detectors; Analyze() returns a combined verdict |
mocks |
Auto-generated gomock mocks for Cursor and StreamCipherBlock interfaces |
testutil |
MemReadWriteSeeker in-memory helper for tests |
- Go 1.24+
make
# Build the CLI binary
make build
# Install the binary to $GOPATH/bin (or $GOBIN)
make install
# Run all tests
make test
# Run tests with the race detector
go test -race ./steg/
# Run a single test
go test ./steg/ -run TestEncodeRoundTrip
# Regenerate mocks (after editing cipher/cipher.go or cursors/cursor.go interfaces)
make mocks
# Run benchmarks
go test ./steg/ -bench=BenchmarkEncodeBySize -benchtime=3s -benchmem
go test ./steg/ -bench=BenchmarkDecodeBySize -benchtime=3s -benchmem.
├── cipher/ # AES-128-CTR stream cipher
├── cmd/steg/ # CLI entry point (Cobra)
├── cursors/ # RNGCursor, CursorAdapter, CipherMiddleware
├── docs/ # Technical spec, ADRs, release notes
├── mocks/ # Auto-generated gomock mocks
├── steg/ # Encode/decode orchestration, container framing
│ ├── analysis/ # Chi-square and RS steganalysis detectors
│ └── container/
└── testutil/ # Shared test helpers
Every push to master triggers a GitHub Actions workflow that:
- Runs
go test ./... - Cross-compiles binaries for Linux, macOS, and Windows (amd64 + arm64)
- Publishes a GitHub Release tagged
v<YYYYMMDD>-<short-sha>with all binaries attached
| Issue | Severity | Notes |
|---|---|---|
| MAC-then-Encrypt ordering | Low | HMAC is computed over plaintext before encryption. Unconventional (Encrypt-then-MAC is preferred), but not exploitable in this threat model since the tag is inside the encrypted channel. |
| No streaming decode | Medium | ReadPayload allocates the full payload in memory before returning. Very large payloads may cause high memory usage. |
| Lossy formats unsupported | High | JPEG and other lossy formats destroy LSB data. Only lossless formats (PNG, BMP, TIFF) are supported. |
| Statistical steganalysis | Medium | Modifying the LSBs of color channels across a pseudorandom pixel set produces a detectable statistical signature. The built-in detect command uses chi-square and RS analysis to surface this. Chi-square reliably detects full-fill encoding; RS analysis effectiveness varies with the carrier image's natural LSB distribution. Higher bits-per-channel settings make signatures more pronounced. |
The detect command runs two complementary statistical tests against the image's LSB distribution.
Compares the histogram of pixel value pairs (2k, 2k+1) per channel. In a natural image these pairs are unequal; LSB embedding equalises them. A high p-value (> 0.05) for a channel is flagged as suspicious.
Performance: correctly identifies which channels were written to. On a real photo encoded at full capacity with R+G+B, all three channels are flagged; untouched channels remain at p ≈ 0.0000.
Measures local pixel smoothness using regular (R) and singular (S) group fractions under a positive and a negative flipping mask. LSB embedding biases Rm above Rnm; a positive asymmetry (Rm − Rnm > 0.01) is flagged as suspicious.
Performance: reliable on natural photographs where the clean baseline asymmetry is near zero. On images whose natural LSB distribution is already skewed (e.g. heavily processed or synthetic images), the clean asymmetry may be deeply negative and encoding moves it toward zero rather than above the threshold — in which case chi-square remains the primary signal.
| Suspicious count | Verdict |
|---|---|
| 0 | CLEAN |
| 1 – (n−1) | SUSPICIOUS |
| all n | LIKELY_STEGO |
n = number of test×channel combinations (6 for a 3-channel image).
- Lossless WebP support — extend format support beyond PNG, BMP, and TIFF.
- Streaming decode — avoid loading the full payload into memory for large files.
- 16-bit image depth — exploit the extra bits-per-channel available in 16-bit PNG/TIFF carriers.