GitHub - marcsnid/steganeur: Hide secret messages inside LLM-generated text. Neural linguistic steganography in Rust with multiple methods.

GitHub

14 min read Original article ↗

Hide secret messages inside natural-looking text generated by a language model. A recipient recovers the message using only the cover text and the same model.

"Whereas traditional cryptography encrypts a secret message into an unintelligible form, steganography conceals that communication is taking place by encoding a secret message into a cover signal." Ziegler, Deng, Rush (2019)

Steganeur is a Rust implementation of neural linguistic steganography. A secret message is encoded into the token choices of a language model, producing cover text that reads like normal prose. A third party who sees the text on a forum, in an email, or on a blog cannot tell it carries a hidden message. A recipient who knows the model and the community's default settings feeds the text back through steganeur and recovers the message.

What it does

You give steganeur a secret message and a context prompt. It produces cover text that continues the prompt naturally, with the message encoded into the token choices. The recipient gives the cover text (and only the cover text) back to steganeur with the same model settings, and gets the message back.

Messages can be text or arbitrary bytes, including ciphertext. For binary input via stdin, use --raw; for binary output, decode writes raw bytes by default (use --text for text output with a trailing newline). See Binary messages and framing below for details.

Encode:

echo "Meet me at noon" \
  | steganeur encode \
    --method rejection --rejection-bits 2 --seed 42 \
    --temperature 1.0 --top-k 300 \
    --context "The old lighthouse stood at the edge of the cliff, its beam sweeping across the dark waters each night." \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

Output (the cover text):

The old lighthouse stood at the edge of the cliff, its beam sweeping across the
dark waters each night. But something changed -- no warning lights blinked on,
and no keeper kept the logbook open. The beam was blind, not from storm, but
because the system that fed it was failing silently, like a machine running on
fang fuel instead. The fog felt thick. Nearshore waters glided under the sky,
untouched.

Decode (from the cover text alone, same model settings):

cat cover.txt \
  | steganeur decode \
    --method rejection --rejection-bits 2 \
    --temperature 1.0 --top-k 300 \
    --context "The old lighthouse stood at the edge of the cliff, its beam sweeping across the dark waters each night." \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

# Output: Meet me at noon

No tokens file, no side channel. The cover text is the channel.

Quick start

1. Build

2. Start your LLM server

For the full-quality methods (arithmetic, rejection, huffman), run on CPU so the logprobs are deterministic (see "Challenges" below for why):

llama-server -m model.gguf --host 0.0.0.0 --port 11434 \
  --n-gpu-layers 0 \
  --cache-type-k f16 --cache-type-v f16 \
  --flash-attn off

For block (which tolerates GPU non-determinism), any server config works.

3. Encode and decode

Use the examples in "What it does" above. See "Methods" below for which method to pick and the flags each one requires.

Methods

Steganeur supports four encoding methods. Each has different trade-offs in cover quality, bit density, and server requirements.

Rejection (Cachin, 2004) -- recommended

The model's distribution is partitioned into 2^b equal-probability-mass bins. The message bits select a target bin. The encoder draws a random sample from the distribution (locally, with a seeded RNG, no extra model calls per rejection) and emits the token only if it falls in the target bin, redrawing otherwise.

echo "Meet me at noon" \
  | steganeur encode --method rejection --rejection-bits 2 --seed 42 \
    --temperature 1.0 --top-k 300 \
    --context "The old lighthouse stood at the edge of the cliff, its beam sweeping across the dark waters each night." \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

Cover text (real example):

The old lighthouse stood at the edge of the cliff, its beam sweeping across the
dark waters each night. But something changed -- no warning lights blinked on,
and no keeper kept the logbook open. The beam was blind, not from storm, but
because the system that fed it was failing silently, like a machine running on
fang fuel instead. The fog felt thick. Nearshore waters glided under the sky,
untouched.

Pros: exactly zero KL divergence from the model's distribution. The output is statistically identical to normal generation, which is the strongest undetectability claim possible. Cover quality is the best of the four methods.

Cons: requires a deterministic server (CPU). The bin boundaries depend on the cumulative probability distribution, so logprob drift shifts them. Errors do not cascade (each token's bits are independent), but the bits themselves can flip under drift.

Arithmetic (Ziegler, Deng, Rush, 2019)

The message is interpreted as a binary fraction. At each step, the model's distribution partitions [0, 1). The token whose bin contains the message fraction is emitted, and the interval narrows. This gives variable bit density (1 to 8 bits per token).

echo "Meet me at noon" \
  | steganeur encode --method arithmetic \
    --temperature 2.0 --top-k 300 \
    --context "She opened the old letter carefully, the paper yellowed with age." \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

Cover text (real example):

She opened the old letter carefully, the paper yellowed with age. "To Jennifer
of Carpathia," it read on the upper-right corner. A name so distinct and like
from pure antiquum.
She read the letter,

Pros: best cover quality alongside rejection. Variable bit density means high capacity per token for flat distributions.

Cons: requires a deterministic server (CPU). A single bin flip from logprob drift cascades through the interval state and corrupts all subsequent bits, so arithmetic is the most fragile method under non-determinism. Use the --arith-block-size flag to reset the interval periodically, which limits cascade damage to a single block (pair with --ecc to recover).

Huffman (Yang et al., 2019)

A length-limited Huffman tree (built with the package-merge algorithm) is constructed from the model's distribution at each step. Message bits traverse the tree to select tokens. Higher-probability tokens consume fewer bits.

echo "Meet me at noon" \
  | steganeur encode --method huffman \
    --temperature 2.0 --top-k 300 \
    --context "The autumn leaves drifted down" \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

Cover text (real example):

The autumn leaves drifted down in crisp glory upon a
bright Indian September. It was clear you could smell Thanksgiving drifting forward
in the

Pros: good cover quality. Variable bit density adapts to the distribution shape.

Cons: requires a deterministic server (CPU). The tree structure can drift after a mis-assigned token, similar to arithmetic.

Block (Fang et al., 2017)

The vocabulary is split into 2^b bins via a fixed hash. The message is split into b-bit chunks. For each chunk, the highest-probability token in the corresponding bin is emitted. Each token encodes exactly b bits.

echo "Meet me at noon" \
  | steganeur encode --method block --block-bits 2 \
    --temperature 2.0 --top-k 300 \
    --context "She walked through the forest" \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

Cover text (real example):

She walked through the forest with no one in mind. A simple walk, with simple
goals and simple pleasures: the wind through leaves, sunlight breaking in the
gaps in branches, moss covering tree roots like velvet pillows and flowers of
all shapes adoring every path and clearing in the area around this forest's
many trails.
Her pace remained casual for

Pros: works on any server, including non-deterministic GPU servers (the hash bins ignore logprob drift). Each token carries a fixed number of bits, so decode is simple and robust.

Cons: cover quality is the lowest of the four methods. To hit a target bin, the encoder may be forced to emit an unusual or low-probability token, which can make the prose look slightly off.

Method comparison

Method Bits per token Cover quality Deterministic server (CPU) Non-deterministic server (GPU) Security claim
Rejection Fixed (e.g. 2) Best Reliable Fragile (bin shift) Exactly zero KL
Arithmetic Variable (1-8) Best Reliable Fragile (cascade)
Huffman Variable Good Reliable Fragile (tree drift)
Block Fixed (e.g. 2) Moderate Reliable Reliable (hash bins ignore drift)

Recommendation:

  • On a deterministic (CPU) server: use rejection for the best cover quality and zero-KL undetectability, or arithmetic for maximum bit density.
  • On a non-deterministic (GPU) server: use block with --ecc. Its hash bins ignore logprob drift.
  • For production use across unknown servers, document the community default as a deterministic (CPU) server configuration.

Error correction (ECC)

A Reed-Solomon layer over GF(2^8) is available to tolerate bit errors from logprob drift or other noise. It wraps the framed payload (length prefix + message + end-of-stream marker) before encoding and unwraps after decoding. Protecting the length prefix is important: a drifted length byte would corrupt the entire message.

echo "Meet me at noon" \
  | steganeur encode --ecc --ecc-parity 10 --method block --block-bits 2 \
    --temperature 2.0 --top-k 300 \
    --context "She walked through the forest" \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

cat cover.txt \
  | steganeur decode --ecc --ecc-parity 10 --method block --block-bits 2 \
    --temperature 2.0 --top-k 300 \
    --context "She walked through the forest" \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

--ecc-parity N adds N parity bytes to the payload. The decoder can correct up to floor(N/2) byte errors. ECC is most useful on methods where errors are localized (block, rejection) and less effective on arithmetic, where a single bin flip cascades into an unbounded burst.

Binary messages and framing

Steganeur handles arbitrary bytes, not just text. This matters for the real use case: encrypted messages (ciphertext), which are uniformly distributed bytes that frequently contain 0x00.

The payload uses chunked varint framing:

[varint N][N message bytes][0x00]

The varint length prefix tells the decoder exactly how many bytes to read, and the trailing 0x00 (varint 0) marks end of stream. The decoder reads by count, not by scanning for a sentinel, so 0x00 bytes inside the message are just data. Overhead is 2 bytes flat for any message under 128 bytes, scaling logarithmically for larger ones. There is no cap on message size.

Encode (binary via stdin):

# Pipe ciphertext directly. --raw prevents stripping a trailing newline.
cat ciphertext.bin \
  | steganeur encode --raw --method block --block-bits 2 \
    --temperature 2.0 --top-k 300 \
    --context "She walked through the forest" \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

--message-file always reads raw bytes (no --raw needed):

steganeur encode --message-file ciphertext.bin --method block --block-bits 2 \
  --temperature 2.0 --top-k 300 \
  --context "She walked through the forest" \
  --llama-url "http://127.0.0.1:11434" \
  --model "Qwen3.6-27B-GGUF" \
  --vocab-size 152064 --eos-token 151643

Decode (binary output):

By default, decode writes raw message bytes to stdout with no trailing newline (binary-safe). Use --text to validate UTF-8 and add a trailing newline for shell pipelines:

# Binary output (for ciphertext or raw bytes):
cat cover.txt \
  | steganeur decode --method block --block-bits 2 \
    --temperature 2.0 --top-k 300 \
    --context "She walked through the forest" \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643 \
  > decrypted.bin

# Text output (for human-readable messages):
cat cover.txt \
  | steganeur decode --text --method block --block-bits 2 \
    --temperature 2.0 --top-k 300 \
    --context "She walked through the forest" \
    --llama-url "http://127.0.0.1:11434" \
    --model "Qwen3.6-27B-GGUF" \
    --vocab-size 152064 --eos-token 151643

Temperature and bit rate

Temperature controls how many bits each token can carry. Higher temperature spreads the probability mass, giving more room for message bits.

Temperature Distribution shape Bits per token Best message length
1.0 Peaked (natural) 0.5 to 2 Very short (1-5 bytes)
2.0 Spread out 2 to 6 Short to medium (5-50 bytes)
3.0+ Near-uniform 6 to 8 Up to about 200 bytes

For the arithmetic method, use 2.0 or higher. The block method is less sensitive to temperature.

Testing without a server

echo "Hello" | steganeur encode --context "Some context" --dummy

The dummy LM has no token strings, so text-based decode will not work, but it is useful for testing the encode path in isolation.

Challenges

This section covers the design constraints and the one real-world challenge that affects method choice.

Charter

Steganeur exists for one purpose: letting two people exchange messages through text that looks like ordinary writing, where neither side needs anything but the text and the same language model to communicate.

The requirements, in priority order:

  1. Natural cover text. The encoded output must read like normal prose. A third party who sees the text should not be able to tell that it carries a hidden message. If the text looks generated, repetitive, or structurally odd, the steganography has already failed.

  2. Reliable decode from text alone. The recipient has only the cover text and the same model (same weights, same temperature, same settings). There is no shared side file, no tokens file, no out-of-band channel. The cover text is the channel.

  3. Cross-server operation. The sender encodes on one server; the recipient decodes on a different server (their own, or a shared public one). The recipient cannot rely on server-side state. cache_prompt is permanently set to false in the client, with a comment that it must never change. Every call recomputes the forward pass from scratch, because that is the honest condition a recipient on a different server will experience.

  4. Community defaults. A community of users agrees on a model and default settings (temperature, method, bit rate). Any member can then encode a message into text and publish it; any other member, seeing the text and knowing only the community defaults, can attempt to decode it. There is no per-message coordination.

  5. Simplicity. One command to encode, one to decode, a small set of options.

Server determinism

Steganeur reads the model's top_logprobs directly, not the sampled output. This means sampling parameters (temperature, seed, top-k) do not affect what steganeur sees. What matters is whether the logprob values themselves are reproducible across calls.

On CPU (--n-gpu-layers 0), the forward pass is deterministic: identical prompts produce bit-identical logprobs. All four methods work reliably.

On GPU (--n-gpu-layers > 0), cuBLAS matmul reductions are non-deterministic: logprobs drift by 0.05 to 0.30 per call. This breaks arithmetic, rejection, and huffman, whose bin or code assignments depend on the cumulative probability distribution. Block's bins are hashes of token IDs, which are magnitude-independent, so block works on GPU too.

This is why the method comparison table above has separate columns for CPU and GPU. There is a steganeur method for almost any server: block on GPU, any method on CPU.

Things that do not help on GPU:

  • temperature=0.0 or seed=N: these are sampling parameters. They do not affect the logprob values steganeur reads.
  • cache_prompt=true: permanently disabled. It only works when the recipient shares the sender's warm server cache, which is useless for cross-server communication.
  • Disabling --cont-batching or --spec-type: helps somewhat, but GPU matmul non-determinism persists.

Running a deterministic server

For the full-quality methods (arithmetic, rejection, huffman), run on CPU:

llama-server -m model.gguf --host 0.0.0.0 --port 11434 \
  --n-gpu-layers 0 \
  --cache-type-k f16 --cache-type-v f16 \
  --flash-attn off

CPU inference is slower than GPU, but for the message sizes steganeur handles (short text, tens of bytes), a 27B model on CPU is practical. Encode and decode each take seconds, not minutes. Smaller models (7B to 13B) are faster still and fully capable of natural prose.

Requirements

  • Rust
  • An LLM server with an OpenAI-compatible /v1/completions endpoint (llama.cpp, Ollama, vLLM, TGI). The server must return top_logprobs with token id fields (llama.cpp does; some pure-OpenAI servers do not).
  • Choose your method based on the server. Block works on any server; arithmetic, rejection, and huffman require a deterministic (CPU) server.

References

  • Ziegler, Z. M., Deng, Y., & Rush, A. M. (2019). Neural Linguistic Steganography. Proceedings of the 2019 Conference on Empirical Methods in Natural Language Processing (EMNLP). arXiv:1909.01496.
  • Fang, T., Jaggi, M., & Argyraki, K. (2017). Generating steganographic text with LSTMs. arXiv:1705.10742.
  • Yang, Z.-L., et al. (2019). RNN-Stega: Linguistic steganography based on recurrent neural networks. IEEE Transactions on Information Forensics and Security, 14(5):1280-1295.
  • Cachin, C. (2004). An information-theoretic model for steganography. Information and Computation, 192(1):41-56.
  • Sallee, P. (2004). Model-based steganography. International Workshop on Digital Watermarking (IWDW 2003), Lecture Notes in Computer Science, pp. 154-167.
  • Rissanen, J. & Langdon, G. G. (1979). Arithmetic coding. IBM Journal of Research and Development, 23(2):149-162.
  • Rubin, F. (1979). Arithmetic stream coding using fixed precision registers. IEEE Transactions on Information Theory, 25(6):672-675.

License

Dual-licensed under the MIT License or the Apache License, Version 2.0, at your option (MIT OR Apache-2.0). The Apache-2.0 option grants an explicit patent license from contributors; the MIT option is provided for maximum compatibility. See LICENSE for the full text of both.