Skip to content

Latest commit

 

History

1,600 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

pdvrdt

pdvrdt is a steganography command-line tool for concealing and extracting any file type via a PNG image.

Demo Image
Demo Image - KEY: V8zh-FPeF-C1cU-rjC1-e5nF-Lazc

Your data file is compressed with libdeflate/zlib, then encrypted with XChaCha20-Poly1305 (libsodium secretstream) under a 256-bit encryption key derived by Argon2id from a randomly generated 128-bit recovery KEY, and finally embedded in the cover image.

The KEY is displayed once, at the end of conceal, printed as 24 Base58 characters (letters and digits) in six groups of four (XXXX-XXXX-XXXX-XXXX-XXXX-XXXX) and is not saved by the program. Without it, the concealed file cannot be recovered. Its last two characters form a checksum, so most typing mistakes are reported straight away.

Images created before pdvrdt 6.0 used a numeric recovery PIN instead. This release does not read them: recover them with the legacy pdvrdt release.

Using the default conceal mode, you can conceal any file type, with the finished image capped at 2GiB. The other platform conceal modes and the compatible social media sites (listed below) have their own much smaller size limits and other requirements.

There is a Web edition of pdvrdt, which you can use immediately, as a convenient alternative to downloading and compiling the CLI source code. Web file uploads are limited to 20MiB.

An experimental Rust port pdvrdt-rs is also available for those interested in that language. It is format-compatible: either build can recover the other's images.

How pdvrdt conceals data

Unlike the common LSB (Least Significant Bit) steganography method of concealing data within the pixels of a cover image, pdvrdt mostly hides data within the chunks of a PNG image (IDAT, iCCP).

Conceal mode Storage Method Compatible Platforms
(no option) Last IDAT Chunk X-Twitter, ImgPile, PostImage,
ImgBB, Flickr
-m iCCP Profile Chunk Mastodon
(also X-Twitter, within its limits)
-r RGB pixel samples
(adaptive matrix embedding)
Reddit
(also Mastodon, within its limits)

The one platform exception to the default chunk storage method is Reddit, which has its own conceal mode. Reddit re-encodes uploaded images and discards the metadata chunks the default mode relies on, so -r is the only mode that works there, and it carries the payload in the image's pixels instead.

For the Reddit conceal mode (-r), we use the advanced steganography method content-adaptive LSB matching with Hamming-code matrix embedding, a spatial-domain syndrome coding scheme, as this is the only storage method that currently works for Reddit. The matrix-embedding half is the construction popularised by F5, moved from JPEG coefficients to image pixels.

Each 4 bits of payload are carried by 15 RGB samples drawn from a permutation keyed by the recovery KEY, using (1,15,4) Hamming-syndrome matrix embedding: the syndrome is moved to the target by a single ±1 change (LSB matching, not LSB replacement).

Which sample to change is chosen by a per-sample distortion cost derived from local image activity and channel sensitivity, so edits land in textured regions rather than flat ones. Where two changes cost less than the one required change, it takes them — measured on a 4.3-megapixel cover, 93% of groups need one change, 0.4% take two and 6% need none, about 4.25 bits per changed sample.

Carrier positions are derived from the 128-bit recovery KEY using Argon2id and ChaCha20, so testing whether an image holds a payload costs a full Argon2id evaluation per guessed KEY, and recovery reports the same error for a wrong KEY as for an ordinary PNG. Keyed positions alone do not guarantee that an image is undetectable as steganography.

In the default and -m modes, by contrast, the presence of a payload-carrying chunk is visible to anyone; what is hidden is its contents.

To maximise storage capacity for the Reddit platform, use a cover image with large dimension sizes, up to the 8192x8192 maximum. Quality of cover image is not important for this method and should be kept basic for the largest dimensions to help minimise cover image file size.

The -r mode carries far less data than the default mode, so use capsize to measure a cover image before choosing a payload (see Checking capacity).

pdvrdt_cli_reddit_capsize_jpg.mp4
pdvrdt_web_reddit_capsize_jpg.mp4
pdvrdt_cli_xtwitter.mp4

Requirements & Compilation (Linux)

Building requires CMake 3.20 or newer, flock from util-linux, and either Ninja (preferred) or Make. The compiler must be GCC 14 or newer, or Clang 18 or newer paired with a C++23 standard library that implements features such as std::format and std::print. The native libraries required are libsodium, zlib and libdeflate (1.8 or newer). LodePNG is vendored in src/lodepng, so it needs no separate package.

$ sudo apt update
$ sudo apt install g++ cmake ninja-build util-linux libsodium-dev zlib1g-dev libdeflate-dev

$ g++ --version   # confirm the reported version is 14 or newer

$ chmod +x compile_pdvrdt.sh
$ ./compile_pdvrdt.sh

$ sudo cp pdvrdt /usr/bin

If your distribution ships GCC 14 as a versioned package, install g++-14 and build with CXX=g++-14 ./compile_pdvrdt.sh.

The wrapper keeps dependency-tracked object files under src/build/, so later invocations rebuild only what changed, and it replaces the published pdvrdt binary only after a complete, successful build. Set PDVRDT_JOBS=<count> to change the parallelism limit (default: CPU count, capped at 8), PDVRDT_BUILD_DIR=<path> to use a separate build cache, or PDVRDT_BUILD_MODE=sanitize for an ASan/UBSan build.

Usage

$ pdvrdt

Usage: pdvrdt conceal [-m|-r] <cover_image> <secret_file>
       pdvrdt recover <cover_image>
       pdvrdt capsize <cover_image>
       pdvrdt --info

Run pdvrdt --info for the full built-in guide to modes, platform options and size limits.

$ pdvrdt conceal your_cover_image.png your_secret_file.doc

Cover image: your_cover_image.png (1.1MiB).
Data file:   your_secret_file.doc (18KiB).

Preparing cover image...
 ✓ 900x600, 8-bit RGB, color metadata kept, other metadata removed.
Compressing data file...
 ✓ 18KiB -> 2KiB (87% smaller).
Encrypting...
 ✓ XChaCha20-Poly1305, 256-bit key derived by Argon2id from the 128-bit recovery KEY.
Embedding...
 ✓ Final IDAT chunk (2KiB).
Writing output image...
 ✓ 1.1MiB.

Platform compatibility for output image:-

 ✓ Flickr
 ✓ ImgBB
 ✓ PostImage
 ✓ ImgPile
 ✓ X-Twitter

Saved "file-embedded" PNG image: prdt_221863.png (1183991 bytes).

Recovery KEY: [***Ad1u-LDhm-KKmU-opwq-FkZp-FCzc***]

Important: Keep your KEY safe, so that you can extract the hidden file.

Complete!

$ pdvrdt recover prdt_221863.png

Image file: prdt_221863.png (1.1MiB).

Locating hidden data...
 ✓ Default mode, IDAT chunk (2KiB).

KEY: ****-****-****-****-****-****

Deriving key...
 ✓ Argon2id from the 128-bit recovery KEY.
Decrypting...
 ✓ XChaCha20-Poly1305, authenticated.
Decompressing...
 ✓ 2.3KiB -> 18.5KiB.
Writing output file...
 ✓ your_secret_file.doc (18.5KiB).

Complete! Please check your file.

pdvrdt mode arguments:

conceal - Compresses, encrypts and embeds your secret data file within a PNG cover image.
recover - Decrypts, uncompresses and extracts the concealed data file from a PNG cover image (recovery KEY required).
capsize - Reports the adaptive carrier capacity of a cover image for -r mode. No image is saved.

Requirements for the cover image:

● PNG only. The cover must be 4MiB or smaller in the default and -m modes; -r instead allows a cover up to 16MiB.

● Neither side may exceed 4096 pixels in the default and -m modes. -r allows up to 8192x8192, because its capacity comes from the pixels themselves rather than from a chunk appended to the image.

● Animated PNG (APNG) covers are rejected. Static PNG colour metadata is preserved.

● -m (Mastodon) mode accepts covers with iCCP or sRGB declarations, but because its payload occupies the iCCP chunk, those declarations are replaced in the output while compatible colour metadata is retained. Its payload travels in an RGB ICC profile, which PNG does not allow on a greyscale image, so a greyscale cover is saved as RGB (or RGBA, if it has an alpha channel) with the same pixels.

● -r (Reddit) mode emits a non-interlaced, metadata-free, 8-bit RGB PNG, and rejects covers containing transparency.

Requirements for the secret data file:

● The embedded filename must be no longer than 20 bytes, must not begin with . or - or end with a space or ., and must not contain control characters. The name is stored in the image and restored on recover.

● Your data file is compressed before encryption. A file whose header shows an already-compressed format (ZIP, 7-Zip, RAR, gzip, MP4, MKV, MP3, JPG, PNG, etc) is stored in a level-0 zlib stream instead. The file's contents decide, not its extension, so a renamed archive is still recognised. For anything else destined for a small platform limit, consider compressing it yourself first (zip, rar, 7z, etc.) so that you know its exact stored size.

Compatible Platforms

Posting size limit measured by the combined size of the cover image + compressed data file:

● Flickr (200MB), ImgBB (32MB), PostImage (32MB), ImgPile (8MB), X-Twitter (5MB + *dimension limits, see below).

The source file may be larger than a platform limit: pdvrdt applies the limit to the resulting compressed/encrypted representation, so a highly compressible input remains eligible when the final PNG fits.

X-Twitter image dimension size limits:

● PNG-32/24 (Truecolor) 68x68 Min. — 900x900 Max.

● PNG-8 (Indexed-color) 68x68 Min. — 4096x4096 Max.

Other: platforms with their own conceal mode:

● Mastodon (-m option). The finished "file-embedded" PNG must not exceed 16MiB, so the cover image (itself capped at 4MiB and 4096x4096) and the compressed data file share one budget. Because the -m payload lives in a small chunk rather than the pixels, these images also remain postable on X-Twitter when they fit its own limits (10KiB iCCP chunk and dimensions requirements).

● Reddit (-r option). The cover image must be no larger than 16MiB and 8192x8192 pixels; the data file and the finished image must each stay within Reddit's 20MiB upload ceiling. The actual carrier capacity of the cover is much smaller than any of those numbers and depends on its dimension sizes. Use pdvrdt capsize to measure it. The finished image is also compatible with Mastodon when it is no larger than 16MiB and no more than 8.3 megapixels (3840x2160); the conceal output says which platforms apply.

For platforms such as X-Twitter & ImgPile, which have smaller data size limits, you may want to focus on data that compresses well, such as text files, etc.

ImgPile: sign in to an account before sharing, otherwise the embedded data will not be preserved.

Checking capacity with capsize

capsize prepares the cover image exactly as conceal -r would, then reports how much encrypted payload it can carry. Nothing is written to disk. The figures apply only to the Reddit carrier; the default and -m modes are limited by output size, not by pixel capacity.

$ pdvrdt capsize basic_img_large_dims.png

Cover image: basic_img_large_dims.png (5.3MiB).
Capacity check for conceal -r (Reddit) mode only.

Preparing cover image...
 ✓ 1600x1600, 8-bit RGB, metadata removed.
Measuring carrier capacity...
 ✓ 255980 bytes (250KiB), adaptive (1,15,4) matrix embedding.
Calculating payload limits...
 ✓ 255762 bytes (249.8KiB) conservative maximum, 254738 bytes (248.8KiB) recommended, for a 20-byte filename.

The figure reported is the total encrypted envelope capacity, not a raw secret-file limit: for a payload contained in one encryption frame, the filename, encryption and recovery metadata consume 199 to 218 bytes, and larger payloads add framing overhead. Don't aim at the theoretical limit — where capacity allows, keep the stored payload at least 1KiB below the conservative maximum. The size check performed by conceal is the authoritative one.

Conceal mode platform options

To create compatible "file-embedded" PNG images for posting on the Mastodon platform, you must use the -m option with conceal mode.

$ pdvrdt conceal -m my_image.png hidden.doc

To create compatible "data-concealed" PNG images for posting on the Reddit platform, you must use the -r option with conceal mode.

$ pdvrdt conceal -r my_image.png hidden.doc

These images are compatible for posting on Reddit, and also on Mastodon when the finished image is no larger than 16MiB and no more than 8.3 megapixels (3840x2160). Your embedded data file will be lost if posted on a different platform.

When saving/downloading an image from Reddit or X-Twitter make sure to click on the image within the post to fully expand it before saving.

Recovery KEY format

This section is for anyone writing a compatible implementation. Everything below is fixed by pdvrdt 6.0.

The KEY is 16 bytes from a cryptographic random number generator (never all zero). It is written as:

  1. Body — the 16 bytes read as one 128-bit big-endian integer, written in Base58 as exactly 22 digits, most significant first, padded on the left with 1 (the zero digit). Alphabet (Bitcoin's): 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz.

  2. Check — two more characters:

    digest = BLAKE2b-256(ASCII("pdvrdt key check v1") || key_bytes)   (19-byte label, no NUL; unkeyed)
    value  = big_endian_u32(digest[0..4]) mod 3364
    check  = alphabet[value / 58] || alphabet[value mod 58]
    
  3. Display — the 24 characters in six groups of four, joined with -.

When a KEY is entered, spaces, tabs and - are ignored and letters are case-sensitive. The numeric PINs of images made before pdvrdt 6.0 are not accepted.

Key bytes (hex) KEY
00000000000000000000000000000001 1111-1111-1111-1111-1111-12BZ
000000000000000000000000000001bb 1111-1111-1111-1111-1111-8e11 (check value 0)
00000000000000000000000000003b26 1111-1111-1111-1111-1115-W5zz (check value 3363)
000102030405060708090a0b0c0d0e0f 112d-rXXU-ifSr-RnXL-GbXg-8Eyf
80000000000000000000000000000000 GokL-Usho-3eiV-vNYN-d1wg-fywf
ffffffffffffffffffffffffffffffff YcVf-xkQb-6JRz-qk5k-F2tN-Lvdj

Encryption key — Argon2id (libsodium crypto_pwhash, ALG_ARGON2ID13, opslimit 2, memlimit 64 MiB) with the 16 raw key bytes as the password and the random salt stored in the image (metadata version KDF3). Images made before pdvrdt 6.0 (KDF2, a numeric PIN) are reported as unsupported.

Reddit carrier key (-r, carrier version 4) — D = Argon2id(password = the 16 key bytes, salt = ASCII pdvrdtcarrier-v4, opslimit 2, memlimit 64 MiB, 32-byte output); the carrier key is BLAKE2b-256 of ASCII pdvrdt carrier v4 keyed with D. For key bytes 000102…0f it is 6eba642bddf8a83f77945ef0d588a818a6810090beb97729990957f6f97e91c8. That 256-bit key drives the ChaCha20 sample permutation and whitening (see reddit_steg.cpp); carrier versions 1–3, keyed by a numeric PIN before pdvrdt 6.0, are not read. This hides where the data is. It does not stop statistical steganalysis from detecting that an image carries hidden data.

Security limitations

pdvrdt encrypts and authenticates your data, but some exposure is outside what the program can control:

● Deleted output data is not wiped. The output file (the new image when concealing, the recovered file when recovering) is written without a visible name and only given one once it is complete, so an error or interruption never leaves a partial file under the final name. Releasing a file frees its disk blocks without overwriting them, though, so data from an abandoned run can stay on the disk until that space is reused. On SSDs, journalling or copy-on-write filesystems, or with snapshots, overwriting would not reliably erase it either. Use full-disk encryption if this matters to you.

● Memory. Keys and plaintext buffers are wiped after use, and pdvrdt tries to turn off core dumps for itself and block other processes running as your user from reading its memory (PR_SET_DUMPABLE 0 and RLIMIT_CORE 0, for that run only). This is best effort: if the system refuses either setting (a seccomp filter in a sandbox or container, for example), pdvrdt prints a warning and carries on without it. It does not lock its memory, so data in use can still be written to swap: use encrypted swap (or none) to cover that. A root user can read any process's memory.

● Interrupted runs on some filesystems. Where the filesystem lacks O_TMPFILE, the output is first written to a hidden .pdvrdt_tmp_NNNNNN file (mode 0600) in the current directory, then renamed into place. Errors remove it, but if pdvrdt is killed, crashes, loses power or is stopped with Ctrl+C (other than during KEY entry) while writing, the hidden file can be left behind holding some or all of the image or the recovered data, so delete it.

Tests

The test scripts in src/tests each take the binary to exercise:

$ bash tests/run_golden_tests.sh --bin ./pdvrdt              # recover pre-built images with known KEYs; refuse pre-6.0 images
$ bash tests/run_roundtrip_tests.sh --bin ./pdvrdt           # fresh conceal/recover round-trips
$ bash tests/run_safety_regression_tests.sh --bin ./pdvrdt   # negative-path and boundary checks
$ bash tests/run_image_regression_tests.sh --bin ./pdvrdt    # cover-image handling and PNG output
$ python3 tests/run_terminal_regression_tests.py --bin ./pdvrdt  # terminal / KEY-entry behaviour
$ python3 tests/run_recovery_path_tests.py --bin ./pdvrdt        # output-file publishing and recovery paths
$ python3 tests/run_process_hardening_tests.py --bin ./pdvrdt    # core dumps off, memory unreadable, escaped program name

These three build their own test binaries, so they take no --bin:

$ bash tests/run_key_codec_tests.sh          # recovery KEY format: display, parsing, check characters
$ bash tests/run_carrier_kdf_tests.sh        # carrier key derivation, permutation, header checks
$ bash tests/run_png_decode_limit_tests.sh   # PNG decode bounds and zlib adapter limits

Third-Party Libraries

This project makes use of the following third-party libraries:

  • LodePNG by Lode Vandevenne
    • License: zlib/libpng (see LICENSE file)
    • Copyright (c) 2005-2024 Lode Vandevenne
  • libsodium for cryptographic functions.
  • zlib: General-purpose compression library
    • License: zlib/libpng license (see LICENSE file)
    • Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler
  • libdeflate: Optimized DEFLATE/zlib compression library
    • License: MIT (see the upstream COPYING file)