├── py.typed ├── tests ├── __init__.py ├── test_bit_manipulation.py ├── test_lsbsteg.py └── test_wavsteg.py ├── stego_lsb ├── __init__.py ├── StegDetect.py ├── WavSteg.py ├── cli.py ├── bit_manipulation.py └── LSBSteg.py ├── mypy.ini ├── readme_illustration.png ├── AUTHORS.md ├── LICENSE.md ├── pyproject.toml ├── .github └── workflows │ └── python-package.yml ├── .gitignore └── README.md /py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stego_lsb/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | files = *.py, */*.py -------------------------------------------------------------------------------- /readme_illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ragibson/Steganography/HEAD/readme_illustration.png -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | * [Ryan Gibson](https://github.com/ragibson) - Original Author 4 | * [Peter Justin](https://github.com/sh4nks) - Contributor -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Ryan Gibson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 75.3.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "stego-lsb" 7 | version = "1.7.1" 8 | description = """Least Significant Bit Steganography for bitmap images (.bmp and .png), WAV sound files, and byte 9 | sequences. Simple LSB Steganalysis (LSB extraction) for bitmap images.""" 10 | readme = "README.md" 11 | requires-python = ">=3.8" 12 | license = { text = "MIT" } 13 | authors = [ 14 | { name = "Ryan Gibson", email = "ryan.alex.gibson@gmail.com" }, 15 | ] 16 | keywords = ["steganography", "steganalysis"] 17 | 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Intended Audience :: Developers", 21 | "Natural Language :: English", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Programming Language :: Python :: 3.13", 29 | "Programming Language :: Python :: 3.14", 30 | "Programming Language :: Python :: 3 :: Only", 31 | ] 32 | 33 | dependencies = [ 34 | "Click", 35 | "Pillow", 36 | "numpy; python_version >= '3.10'", 37 | "numpy < 2.1.0; python_version >= '3.9' and python_version < '3.10'", 38 | "numpy < 1.25.0; python_version >= '3.8' and python_version < '3.9'", 39 | ] 40 | 41 | [project.urls] 42 | Homepage = "https://github.com/ragibson/Steganography" 43 | 44 | [project.scripts] 45 | stegolsb = "stego_lsb.cli:main" 46 | 47 | [tool.setuptools.packages.find] 48 | include = ["stego_lsb"] 49 | 50 | [tool.setuptools.package-data] 51 | stego_lsb = ["py.typed"] 52 | -------------------------------------------------------------------------------- /stego_lsb/StegDetect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | stego_lsb.StegDetect 4 | ~~~~~~~~~~~~~~~~~~~~ 5 | 6 | This module contains functions for detecting images 7 | which have been modified using the functions from 8 | the module :mod:`stego_lsb.LSBSteg`. 9 | 10 | :copyright: (c) 2015 by Ryan Gibson, see AUTHORS.md for more details. 11 | :license: MIT License, see LICENSE.md for more details. 12 | """ 13 | import logging 14 | import os 15 | from time import time 16 | from typing import cast, Tuple, Iterable 17 | 18 | from PIL import Image 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | 23 | def show_lsb(image_path: str, n: int) -> None: 24 | """Shows the n least significant bits of image""" 25 | if image_path is None: 26 | raise ValueError("StegDetect requires an input image file path") 27 | 28 | start = time() 29 | with Image.open(image_path) as image: 30 | # Used to set everything but the least significant n bits to 0 when 31 | # using bitwise AND on an integer 32 | mask = (1 << n) - 1 33 | 34 | image_data = cast(Iterable[Tuple[int, int, int]], image.getdata()) 35 | color_data = [ 36 | (255 * ((rgb[0] & mask) + (rgb[1] & mask) + (rgb[2] & mask)) // (3 * mask),) * 3 37 | for rgb in image_data 38 | ] 39 | 40 | # Image.putdata expects Sequence[Sequence[int]] but mypy thinks it's Sequence[int] 41 | # this isn't consistent in all Python versions, so we actually need to ignore the unused-ignore as well 42 | image.putdata(color_data) # type: ignore[arg-type, unused-ignore] 43 | log.debug(f"Runtime: {time() - start:.2f}s") 44 | file_name, file_extension = os.path.splitext(image_path) 45 | image.save(f"{file_name}_{n}LSBs{file_extension}") 46 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m venv env 29 | source env/bin/activate 30 | python -m pip install --upgrade pip 31 | pip install setuptools 32 | pip install cython flake8 pytest 33 | pip install . 34 | - name: Lint with flake8 35 | run: | 36 | source env/bin/activate 37 | # stop the build if there are Python syntax errors or undefined names 38 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude env/ 39 | # exit-zero treats all errors as warnings 40 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics --exclude env/ 41 | - name: Type checking with mypy 42 | run: | 43 | source env/bin/activate 44 | python -m pip install mypy 45 | python -m mypy --install-types --non-interactive stego_lsb 46 | - name: Test with pytest 47 | run: | 48 | source env/bin/activate 49 | pytest 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Potential PyCharm IDE files 2 | .idea/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # celery beat schedule file 87 | celerybeat-schedule 88 | 89 | # SageMath parsed files 90 | *.sage.py 91 | 92 | # Environments 93 | .env 94 | .venv 95 | env/ 96 | venv/ 97 | ENV/ 98 | env.bak/ 99 | venv.bak/ 100 | 101 | # Spyder project settings 102 | .spyderproject 103 | .spyproject 104 | 105 | # Rope project settings 106 | .ropeproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ 118 | -------------------------------------------------------------------------------- /tests/test_bit_manipulation.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from stego_lsb.bit_manipulation import lsb_interleave_bytes, lsb_deinterleave_bytes 6 | 7 | 8 | class TestBitManipulation(unittest.TestCase): 9 | def assertConsistentInterleaving(self, carrier: bytes, payload: bytes, num_lsb: int, byte_depth: int = 1) -> None: 10 | num_payload_bits = 8 * len(payload) 11 | 12 | encoded = lsb_interleave_bytes(carrier, payload, num_lsb, byte_depth=byte_depth) 13 | decoded = lsb_deinterleave_bytes(encoded, num_payload_bits, num_lsb, byte_depth=byte_depth) 14 | self.assertEqual(decoded, payload) # payload correctly decoded 15 | self.assertEqual(len(encoded), len(carrier)) # message length is unchanged after interleaving 16 | 17 | truncated_encode = lsb_interleave_bytes(carrier, payload, num_lsb, byte_depth=byte_depth, truncate=True) 18 | truncated_decode = lsb_deinterleave_bytes(truncated_encode, num_payload_bits, num_lsb, byte_depth=byte_depth) 19 | self.assertEqual(truncated_decode, payload) 20 | 21 | def check_random_interleaving(self, byte_depth: int = 1, num_trials: int = 1024) -> None: 22 | np.random.seed(0) 23 | for _ in range(num_trials): 24 | carrier_len = np.random.randint(1, 16384) 25 | 26 | # round up carrier length to next multiple of byte depth 27 | carrier_len += (byte_depth - carrier_len) % byte_depth 28 | assert carrier_len % byte_depth == 0 29 | 30 | num_lsb = np.random.randint(1, 8 * byte_depth + 1) 31 | payload_len = carrier_len * num_lsb // (8 * byte_depth) 32 | carrier = np.random.randint(0, 256, size=carrier_len, dtype=np.uint8).tobytes() 33 | payload = np.random.randint(0, 256, size=payload_len, dtype=np.uint8).tobytes() 34 | self.assertConsistentInterleaving(carrier, payload, num_lsb, byte_depth=byte_depth) 35 | 36 | def test_interleaving_consistency_8bit(self) -> None: 37 | self.check_random_interleaving(byte_depth=1) 38 | 39 | def test_interleaving_consistency_16bit(self) -> None: 40 | self.check_random_interleaving(byte_depth=2) 41 | 42 | def test_interleaving_consistency_24bit(self) -> None: 43 | self.check_random_interleaving(byte_depth=3) 44 | 45 | def test_interleaving_consistency_32bit(self) -> None: 46 | self.check_random_interleaving(byte_depth=4) 47 | 48 | def test_interleaving_consistency_40bit(self) -> None: 49 | self.check_random_interleaving(byte_depth=5) 50 | 51 | def test_interleaving_consistency_48bit(self) -> None: 52 | self.check_random_interleaving(byte_depth=6) 53 | 54 | def test_interleaving_consistency_56bit(self) -> None: 55 | self.check_random_interleaving(byte_depth=7) 56 | 57 | def test_interleaving_consistency_64bit(self) -> None: 58 | self.check_random_interleaving(byte_depth=8) 59 | 60 | 61 | if __name__ == "__main__": 62 | unittest.main() 63 | -------------------------------------------------------------------------------- /tests/test_lsbsteg.py: -------------------------------------------------------------------------------- 1 | import os 2 | import string 3 | import unittest 4 | from random import choice 5 | 6 | import numpy as np 7 | import pytest 8 | from PIL import Image 9 | 10 | from stego_lsb.LSBSteg import hide_data, recover_data 11 | from stego_lsb.bit_manipulation import roundup 12 | 13 | 14 | class TestLSBSteg(unittest.TestCase): 15 | def write_random_image(self, filename: str, width: int, height: int, num_channels: int) -> None: 16 | image_data = np.random.randint(0, 256, size=(height, width, num_channels), dtype=np.uint8) 17 | with Image.fromarray(image_data) as image: 18 | image.save(filename) 19 | 20 | def write_random_file(self, filename: str, num_bytes: int) -> None: 21 | with open(filename, "wb") as file: 22 | file.write(os.urandom(num_bytes)) 23 | 24 | def check_random_interleaving(self, num_trials: int = 256, filename_length: int = 5, num_channels: int = 3, 25 | skip_storage_check: bool = False, payload_size_shift: int = 0) -> None: 26 | filename = "".join(choice(string.ascii_lowercase) for _ in range(filename_length)) 27 | png_input_filename = f"{filename}.png" 28 | payload_filename = f"{filename}.txt" 29 | png_output_filename = f"{filename}_steg.png" 30 | recovered_data_filename = f"{filename}_recovered.txt" 31 | 32 | np.random.seed(0) 33 | for _ in range(num_trials): 34 | width = np.random.randint(1, 256) 35 | height = np.random.randint(1, 256) 36 | num_lsb = np.random.randint(1, 9) 37 | 38 | file_size_tag_length = roundup(int(num_channels * width * height * num_lsb).bit_length() / 8) 39 | payload_len = (num_channels * width * height * num_lsb - 8 * file_size_tag_length) // 8 40 | 41 | if payload_len < 0: 42 | continue 43 | 44 | self.write_random_image(png_input_filename, width=width, height=height, num_channels=num_channels) 45 | self.write_random_file(payload_filename, num_bytes=payload_len + payload_size_shift) 46 | 47 | try: 48 | hide_data(png_input_filename, payload_filename, png_output_filename, num_lsb, compression_level=1, 49 | skip_storage_check=skip_storage_check) 50 | recover_data(png_output_filename, recovered_data_filename, num_lsb) 51 | 52 | with open(payload_filename, "rb") as input_file, open(recovered_data_filename, "rb") as output_file: 53 | input_payload_data = input_file.read() 54 | output_payload_data = output_file.read() 55 | except ValueError as e: 56 | raise e 57 | finally: 58 | for fn in [png_input_filename, payload_filename, png_output_filename, recovered_data_filename]: 59 | if os.path.exists(fn): 60 | os.remove(fn) 61 | 62 | self.assertEqual(input_payload_data, output_payload_data) 63 | 64 | def test_rgb_steganography_consistency(self) -> None: 65 | self.check_random_interleaving(num_channels=3) 66 | 67 | def test_rgba_steganography_consistency(self) -> None: 68 | self.check_random_interleaving(num_channels=4) 69 | 70 | def test_la_steganography_consistency(self) -> None: 71 | self.check_random_interleaving(num_channels=2) 72 | 73 | def check_maximum_storage(self, num_channels: int = 3) -> None: 74 | with pytest.raises(ValueError): 75 | # add an extra byte onto the payload and expect failure 76 | self.check_random_interleaving(num_channels=num_channels, skip_storage_check=True, payload_size_shift=1) 77 | 78 | def test_rgb_maximum_storage(self) -> None: 79 | self.check_maximum_storage(num_channels=3) 80 | 81 | def test_rgba_maximum_storage(self) -> None: 82 | self.check_maximum_storage(num_channels=4) 83 | 84 | def test_la_maximum_storage(self) -> None: 85 | self.check_maximum_storage(num_channels=2) 86 | 87 | 88 | if __name__ == "__main__": 89 | unittest.main() 90 | -------------------------------------------------------------------------------- /tests/test_wavsteg.py: -------------------------------------------------------------------------------- 1 | import os 2 | import string 3 | import unittest 4 | import wave 5 | from random import choice 6 | from typing import Any, Type 7 | 8 | import numpy as np 9 | 10 | from stego_lsb.WavSteg import hide_data, recover_data 11 | 12 | 13 | class TestWavSteg(unittest.TestCase): 14 | def write_random_wav(self, filename: str, num_channels: int, sample_width: int, framerate: int, 15 | num_frames: int) -> None: 16 | if sample_width < 1 or sample_width > 4: 17 | # WavSteg doesn't support higher sample widths, see setsampwidth() in cpython/Libwave.py 18 | raise ValueError("File has an unsupported bit-depth") 19 | 20 | with wave.open(filename, "w") as file: 21 | file.setnchannels(num_channels) 22 | file.setsampwidth(sample_width) 23 | file.setframerate(framerate) 24 | 25 | dtype: Type[np.unsignedinteger[Any]] 26 | if sample_width == 1: 27 | dtype = np.uint8 28 | elif sample_width == 2: 29 | dtype = np.uint16 30 | else: 31 | dtype = np.uint32 32 | 33 | data = np.random.randint(0, 2 ** (8 * sample_width), dtype=dtype, size=num_frames * num_channels) 34 | # note: typing does not recognize that "writeframes() accepts any bytes-like object" (see documentation) 35 | file.writeframes(data) # type: ignore[arg-type,unused-ignore] 36 | 37 | def write_random_file(self, filename: str, num_bytes: int) -> None: 38 | with open(filename, "wb") as file: 39 | file.write(os.urandom(num_bytes)) 40 | 41 | def check_random_interleaving(self, byte_depth: int = 1, num_trials: int = 256, filename_length: int = 5) -> None: 42 | filename = "".join(choice(string.ascii_lowercase) for _ in range(filename_length)) 43 | wav_input_filename = f"{filename}.wav" 44 | payload_input_filename = f"{filename}.txt" 45 | wav_output_filename = f"{filename}_steg.wav" 46 | payload_output_filename = f"{filename}_recovered.txt" 47 | 48 | np.random.seed(0) 49 | for _ in range(num_trials): 50 | num_channels = np.random.randint(1, 64) 51 | num_frames = np.random.randint(1, 16384) 52 | num_lsb = np.random.randint(1, 8 * byte_depth + 1) 53 | payload_len = (num_frames * num_lsb * num_channels) // 8 54 | 55 | self.write_random_wav(wav_input_filename, num_channels=num_channels, sample_width=byte_depth, 56 | framerate=44100, num_frames=num_frames) 57 | self.write_random_file(payload_input_filename, num_bytes=payload_len) 58 | 59 | try: 60 | hide_data(wav_input_filename, payload_input_filename, wav_output_filename, num_lsb) 61 | recover_data(wav_output_filename, payload_output_filename, num_lsb, payload_len) 62 | 63 | with open(payload_input_filename, "rb") as input_file, open(payload_output_filename, 64 | "rb") as output_file: 65 | input_payload_data = input_file.read() 66 | output_payload_data = output_file.read() 67 | except ValueError as e: 68 | raise e 69 | finally: 70 | for fn in [wav_input_filename, payload_input_filename, wav_output_filename, payload_output_filename]: 71 | if os.path.exists(fn): 72 | os.remove(fn) 73 | 74 | self.assertEqual(input_payload_data, output_payload_data) 75 | 76 | def test_consistency_8bit(self) -> None: 77 | self.check_random_interleaving(byte_depth=1) 78 | 79 | def test_consistency_16bit(self) -> None: 80 | self.check_random_interleaving(byte_depth=2) 81 | 82 | def test_consistency_24bit(self) -> None: 83 | self.check_random_interleaving(byte_depth=3) 84 | 85 | def test_consistency_32bit(self) -> None: 86 | self.check_random_interleaving(byte_depth=4) 87 | 88 | 89 | if __name__ == "__main__": 90 | unittest.main() 91 | -------------------------------------------------------------------------------- /stego_lsb/WavSteg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | stego_lsb.WavSteg 4 | ~~~~~~~~~~~~~~~~~ 5 | 6 | This module contains functions for hiding and retrieving 7 | data from .wav files. 8 | 9 | :copyright: (c) 2015 by Ryan Gibson, see AUTHORS.md for more details. 10 | :license: MIT License, see LICENSE.md for more details. 11 | """ 12 | import logging 13 | import math 14 | import os 15 | import wave 16 | from time import time 17 | 18 | from stego_lsb.bit_manipulation import lsb_deinterleave_bytes, lsb_interleave_bytes 19 | 20 | log = logging.getLogger(__name__) 21 | 22 | 23 | def hide_data(sound_path: str, file_path: str, output_path: str, num_lsb: int) -> None: 24 | """Hide data from the file at file_path in the sound file at sound_path""" 25 | if sound_path is None: 26 | raise ValueError("WavSteg hiding requires an input sound file path") 27 | if file_path is None: 28 | raise ValueError("WavSteg hiding requires a secret file path") 29 | if output_path is None: 30 | raise ValueError("WavSteg hiding requires an output sound file path") 31 | 32 | with wave.open(sound_path, "r") as sound: 33 | params = sound.getparams() 34 | num_channels = sound.getnchannels() 35 | sample_width = sound.getsampwidth() 36 | num_frames = sound.getnframes() 37 | num_samples = num_frames * num_channels 38 | 39 | # We can hide up to num_lsb bits in each sample of the sound file 40 | max_bytes_to_hide = (num_samples * num_lsb) // 8 41 | file_size = os.stat(file_path).st_size 42 | 43 | log.debug(f"Using {num_lsb} LSBs, we can hide {max_bytes_to_hide} bytes") 44 | 45 | start = time() 46 | sound_frames = sound.readframes(num_frames) 47 | with open(file_path, "rb") as file: 48 | data = file.read() 49 | log.debug(f"{'Files read':<30} in {time() - start:.2f}s") 50 | 51 | if file_size > max_bytes_to_hide: 52 | required_lsb = math.ceil(file_size * 8 / num_samples) 53 | raise ValueError(f"Input file too large to hide, requires {required_lsb} LSBs, using {num_lsb}") 54 | 55 | if sample_width < 1 or sample_width > 4: 56 | # WavSteg doesn't support higher sample widths, see setsampwidth() in cpython/Libwave.py 57 | raise ValueError("File has an unsupported bit-depth") 58 | 59 | start = time() 60 | sound_frames = lsb_interleave_bytes(sound_frames, data, num_lsb, byte_depth=sample_width) 61 | log.debug(f"{f'{file_size} bytes hidden':<30} in {time() - start:.2f}s") 62 | 63 | start = time() 64 | with wave.open(output_path, "w") as sound_steg: 65 | sound_steg.setparams(params) 66 | sound_steg.writeframes(sound_frames) 67 | log.debug(f"{'Output wav written':<30} in {time() - start:.2f}s") 68 | 69 | 70 | def recover_data(sound_path: str, output_path: str, num_lsb: int, bytes_to_recover: int) -> None: 71 | """Recover data from the file at sound_path to the file at output_path""" 72 | if sound_path is None: 73 | raise ValueError("WavSteg recovery requires an input sound file path") 74 | if output_path is None: 75 | raise ValueError("WavSteg recovery requires an output file path") 76 | if bytes_to_recover is None: 77 | raise ValueError("WavSteg recovery requires the number of bytes to recover") 78 | 79 | start = time() 80 | with wave.open(sound_path, "r") as sound: 81 | # num_channels = sound.getnchannels() 82 | sample_width = sound.getsampwidth() 83 | num_frames = sound.getnframes() 84 | sound_frames = sound.readframes(num_frames) 85 | log.debug(f"{'Files read':<30} in {time() - start:.2f}s") 86 | 87 | if sample_width < 1 or sample_width > 4: 88 | # WavSteg doesn't support higher sample widths, see setsampwidth() in cpython/Libwave.py 89 | raise ValueError("File has an unsupported bit-depth") 90 | 91 | start = time() 92 | data = lsb_deinterleave_bytes(sound_frames, 8 * bytes_to_recover, num_lsb, byte_depth=sample_width) 93 | log.debug(f"{f'Recovered {bytes_to_recover} bytes':<30} in {time() - start:.2f}s") 94 | 95 | start = time() 96 | with open(output_path, "wb+") as output_file: 97 | output_file.write(bytes(data)) 98 | log.debug(f"{'Written output file':<30} in {time() - start:.2f}s") 99 | -------------------------------------------------------------------------------- /stego_lsb/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | stego_lsb.cli 4 | ~~~~~~~~~~~~~ 5 | 6 | This module provides the command line interface for: 7 | - hiding and recovering data in .wav files 8 | - hiding and recovering data in bitmap (.bmp and .png) 9 | files 10 | - detecting images which have modified using the 11 | LSB methods. 12 | 13 | TODO: should this be refactored more? I am trusting that @sh4nks implemented this well. 14 | 15 | :copyright: (c) 2015 by Ryan Gibson, see AUTHORS.md for more details. 16 | :license: MIT License, see LICENSE.md for more details. 17 | """ 18 | import logging 19 | 20 | import click 21 | 22 | from stego_lsb import LSBSteg, StegDetect, WavSteg, bit_manipulation 23 | 24 | # enable logging output 25 | logging.basicConfig(format="%(message)s", level=logging.INFO) 26 | log = logging.getLogger("stego_lsb") 27 | log.setLevel(logging.DEBUG) 28 | 29 | 30 | @click.group() 31 | @click.version_option() 32 | def main() -> None: 33 | """Console script for stegolsb.""" 34 | 35 | 36 | @main.command(context_settings=dict(max_content_width=120)) 37 | @click.option("--hide", "-h", is_flag=True, help="To hide data in an image file") 38 | @click.option("--recover", "-r", is_flag=True, help="To recover data from an image file") 39 | @click.option("--analyze", "-a", is_flag=True, default=False, show_default=True, 40 | help="Print how much data can be hidden within an image") 41 | @click.option("--input", "-i", "input_fp", help="Path to an bitmap (.bmp or .png) image") 42 | @click.option("--secret", "-s", "secret_fp", help="Path to a file to hide in the image") 43 | @click.option("--output", "-o", "output_fp", help="Path to an output file") 44 | @click.option("--lsb-count", "-n", default=2, show_default=True, help="How many LSBs to use", type=int) 45 | @click.option("--compression", "-c", help="1 (best speed) to 9 (smallest file size)", default=1, show_default=True, 46 | type=click.IntRange(1, 9)) 47 | @click.pass_context 48 | def steglsb(ctx: click.Context, hide: bool, recover: bool, analyze: bool, input_fp: str, secret_fp: str, output_fp: str, 49 | lsb_count: int, compression: int) -> None: 50 | """Hides or recovers data in and from an image""" 51 | try: 52 | if analyze: 53 | LSBSteg.analysis(input_fp, secret_fp, lsb_count) 54 | 55 | if hide: 56 | LSBSteg.hide_data(input_fp, secret_fp, output_fp, lsb_count, compression) 57 | elif recover: 58 | LSBSteg.recover_data(input_fp, output_fp, lsb_count) 59 | 60 | if not hide and not recover and not analyze: 61 | click.echo(ctx.get_help()) 62 | except ValueError as e: 63 | log.debug(e) 64 | click.echo(ctx.get_help()) 65 | 66 | 67 | @main.command() 68 | @click.option("--input", "-i", "image_path", help="Path to an image") 69 | @click.option("--lsb-count", "-n", default=2, show_default=True, type=int, help="How many LSBs to display") 70 | @click.pass_context 71 | def stegdetect(ctx: click.Context, image_path: str, lsb_count: int) -> None: 72 | """Shows the n least significant bits of image""" 73 | if image_path: 74 | StegDetect.show_lsb(image_path, lsb_count) 75 | else: 76 | click.echo(ctx.get_help()) 77 | 78 | 79 | @main.command() 80 | @click.option("--hide", "-h", is_flag=True, help="To hide data in a sound file") 81 | @click.option("--recover", "-r", is_flag=True, help="To recover data from a sound file") 82 | @click.option("--input", "-i", "input_fp", help="Path to a .wav file") 83 | @click.option("--secret", "-s", "secret_fp", help="Path to a file to hide in the sound file") 84 | @click.option("--output", "-o", "output_fp", help="Path to an output file") 85 | @click.option("--lsb-count", "-n", default=2, show_default=True, help="How many LSBs to use", type=int) 86 | @click.option("--bytes", "-b", "num_bytes", help="How many bytes to recover from the sound file", type=int) 87 | @click.pass_context 88 | def wavsteg(ctx: click.Context, hide: bool, recover: bool, input_fp: str, secret_fp: str, output_fp: str, 89 | lsb_count: int, num_bytes: int) -> None: 90 | """Hides or recovers data in and from a sound file""" 91 | try: 92 | if hide: 93 | WavSteg.hide_data(input_fp, secret_fp, output_fp, lsb_count) 94 | elif recover: 95 | WavSteg.recover_data(input_fp, output_fp, lsb_count, num_bytes) 96 | else: 97 | click.echo(ctx.get_help()) 98 | except ValueError as e: 99 | log.debug(e) 100 | click.echo(ctx.get_help()) 101 | 102 | 103 | @main.command() 104 | def test() -> None: 105 | """Runs a performance test and verifies decoding consistency""" 106 | bit_manipulation.test() 107 | -------------------------------------------------------------------------------- /stego_lsb/bit_manipulation.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2018 Ryan Gibson 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | import os 24 | from math import ceil 25 | from time import time 26 | from typing import List 27 | 28 | import numpy as np 29 | 30 | 31 | def roundup(x: float, base: int = 1) -> int: 32 | return int(ceil(x / base)) * base 33 | 34 | 35 | def lsb_interleave_bytes(carrier: bytes, payload: bytes, num_lsb: int, truncate: bool = False, 36 | byte_depth: int = 1) -> bytes: 37 | """ 38 | Interleave the bytes of payload into the num_lsb LSBs of carrier. 39 | 40 | :param carrier: carrier bytes 41 | :param payload: payload bytes 42 | :param num_lsb: number of least significant bits to use 43 | :param truncate: if True, will only return the interleaved part 44 | :param byte_depth: byte depth of carrier values 45 | :return: The interleaved bytes 46 | """ 47 | 48 | plen = len(payload) 49 | payload_bits = np.zeros(shape=(plen, 8), dtype=np.uint8) 50 | payload_bits[:plen, :] = np.unpackbits(np.frombuffer(payload, dtype=np.uint8, count=plen)).reshape(plen, 8) 51 | 52 | bit_height = roundup(plen * 8 / num_lsb) 53 | payload_bits.resize(bit_height * num_lsb) 54 | 55 | carrier_bits = np.unpackbits(np.frombuffer(carrier, dtype=np.uint8, count=byte_depth * bit_height) 56 | ).reshape(bit_height, 8 * byte_depth) 57 | carrier_bits[:, 8 * byte_depth - num_lsb: 8 * byte_depth] = payload_bits.reshape(bit_height, num_lsb) 58 | 59 | ret = np.packbits(carrier_bits).tobytes() 60 | return ret if truncate else ret + carrier[byte_depth * bit_height:] 61 | 62 | 63 | def lsb_deinterleave_bytes(carrier: bytes, num_bits: int, num_lsb: int, byte_depth: int = 1) -> bytes: 64 | """ 65 | Deinterleave num_bits bits from the num_lsb LSBs of carrier. 66 | 67 | :param carrier: carrier bytes 68 | :param num_bits: number of num_bits to retrieve 69 | :param num_lsb: number of least significant bits to use 70 | :param byte_depth: byte depth of carrier values 71 | :return: The deinterleaved bytes 72 | """ 73 | 74 | plen = roundup(num_bits / num_lsb) 75 | payload_bits = np.unpackbits(np.frombuffer(carrier, dtype=np.uint8, count=byte_depth * plen) 76 | ).reshape(plen, 8 * byte_depth)[:, 8 * byte_depth - num_lsb: 8 * byte_depth] 77 | return np.packbits(payload_bits).tobytes()[: num_bits // 8] 78 | 79 | 80 | def lsb_interleave_list(carrier: List[np.uint8], payload: bytes, num_lsb: int) -> List[np.uint8]: 81 | """Runs lsb_interleave_bytes with a List[uint8] carrier. 82 | 83 | This is slower than working with bytes directly, but is often 84 | unavoidable if working with libraries that require using lists.""" 85 | bit_height = roundup(8 * len(payload) / num_lsb) 86 | carrier_bytes = np.array(carrier[:bit_height], dtype=np.uint8).tobytes() 87 | interleaved = lsb_interleave_bytes(carrier_bytes, payload, num_lsb, truncate=True) 88 | carrier[:bit_height] = np.frombuffer(interleaved, dtype=np.uint8).tolist() 89 | return carrier 90 | 91 | 92 | def lsb_deinterleave_list(carrier: List[np.uint8], num_bits: int, num_lsb: int) -> bytes: 93 | """Runs lsb_deinterleave_bytes with a List[uint8] carrier. 94 | 95 | This is slower than working with bytes directly, but is often 96 | unavoidable if working with libraries that require using lists.""" 97 | plen = roundup(num_bits / num_lsb) 98 | carrier_bytes = np.array(carrier[:plen], dtype=np.uint8).tobytes() 99 | deinterleaved = lsb_deinterleave_bytes(carrier_bytes, num_bits, num_lsb) 100 | return deinterleaved 101 | 102 | 103 | def test(carrier_len: int = 10 ** 7, payload_len: int = 10 ** 6) -> bool: 104 | """Runs consistency tests with a random carrier and payload of byte 105 | lengths carrier_len and payload_len, respectively.""" 106 | 107 | def print_results(e_rates: List[str], d_rates: List[str]) -> None: 108 | print("\n" + "-" * 40) 109 | print(f"| {'# LSBs':<7}| {'Encode Rate':<13}| {'Decode rate':<13}|") 110 | for n, e, d in zip(range(1, 9), e_rates[1:], d_rates[1:]): 111 | print(f"| {n:<7}| {e:<13}| {d:<13}|") 112 | print("-" * 40) 113 | 114 | current_progress = 0 115 | 116 | def progress() -> None: 117 | nonlocal current_progress 118 | print(f"\rProgress: [{'#' * current_progress}{'-' * (32 - current_progress)}]", end="", flush=True) 119 | current_progress += 1 120 | 121 | print(f"Testing {payload_len / 1e6:.1f} MB payload -> {carrier_len / 1e6:.1f} MB carrier...") 122 | progress() 123 | 124 | carrier = os.urandom(carrier_len) 125 | payload = os.urandom(payload_len) 126 | encode_rates = [""] * 9 127 | decode_rates = [""] * 9 128 | 129 | for num_lsb in range(1, 9): 130 | # LSB interleavings that match carrier length 131 | encoded = lsb_interleave_bytes(carrier, payload, num_lsb) 132 | progress() 133 | decoded = lsb_deinterleave_bytes(encoded, 8 * payload_len, num_lsb) 134 | progress() 135 | 136 | # truncated LSB interleavings 137 | encode_time = time() 138 | truncated_encode = lsb_interleave_bytes(carrier, payload, num_lsb, truncate=True) 139 | encode_time = time() - encode_time 140 | progress() 141 | 142 | decode_time = time() 143 | truncated_decode = lsb_deinterleave_bytes(truncated_encode, 8 * payload_len, num_lsb) 144 | decode_time = time() - decode_time 145 | progress() 146 | 147 | encode_rates[num_lsb] = f"{(payload_len / 1e6) / encode_time:<6.1f} MB/s" 148 | decode_rates[num_lsb] = f"{(payload_len / 1e6) / decode_time:<6.1f} MB/s" 149 | 150 | if decoded != payload or truncated_decode != payload: 151 | print(f"\nTest failed at {num_lsb} LSBs!") 152 | return False 153 | 154 | print_results(encode_rates, decode_rates) 155 | return True 156 | 157 | 158 | if __name__ == "__main__": 159 | test() 160 | -------------------------------------------------------------------------------- /stego_lsb/LSBSteg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | stego_lsb.LSBSteg 4 | ~~~~~~~~~~~~~~~~~ 5 | 6 | This module contains functions for hiding and recovering 7 | data from bitmap (.bmp and .png) files. 8 | 9 | :copyright: (c) 2015 by Ryan Gibson, see AUTHORS.md for more details. 10 | :license: MIT License, see LICENSE.md for more details. 11 | """ 12 | import logging 13 | import os 14 | import sys 15 | from time import time 16 | from typing import Tuple, IO, Union, List, cast 17 | 18 | from PIL import Image 19 | 20 | from stego_lsb.bit_manipulation import ( 21 | lsb_deinterleave_list, 22 | lsb_interleave_list, 23 | roundup, 24 | ) 25 | 26 | log = logging.getLogger(__name__) 27 | 28 | 29 | def _str_to_bytes(x: Union[bytes, str], charset: str = sys.getdefaultencoding(), errors: str = "strict") -> bytes: 30 | if x is None: 31 | return None 32 | if isinstance(x, (bytes, bytearray, memoryview)): # noqa 33 | return bytes(x) 34 | if isinstance(x, str): 35 | return x.encode(charset, errors) 36 | if isinstance(x, int): 37 | return str(x).encode(charset, errors) 38 | raise TypeError("Expected bytes") 39 | 40 | 41 | def prepare_hide(input_image_path: str, input_file_path: str) -> Tuple[Image.Image, IO[bytes]]: 42 | """Prepare files for reading and writing for hiding data.""" 43 | # note that these should be closed! consider using context managers instead 44 | image = Image.open(input_image_path) 45 | input_file = open(input_file_path, "rb") 46 | return image, input_file # these should be closed after use! Consider using a context manager 47 | 48 | 49 | def prepare_recover(steg_image_path: str, output_file_path: str) -> Tuple[Image.Image, IO[bytes]]: 50 | """Prepare files for reading and writing for recovering data.""" 51 | # note that these should be closed! consider using context managers instead 52 | steg_image = Image.open(steg_image_path) 53 | output_file = open(output_file_path, "wb+") 54 | return steg_image, output_file # these should be closed after use! Consider using a context manager 55 | 56 | 57 | def get_filesize(path: str) -> int: 58 | """Returns the file size in bytes of the file at path""" 59 | return os.stat(path).st_size 60 | 61 | 62 | def max_bits_to_hide(image: Image.Image, num_lsb: int, num_channels: int) -> int: 63 | """Returns the number of bits we're able to hide in the image using num_lsb least significant bits.""" 64 | # num_channels color channels per pixel, num_lsb bits per color channel. 65 | return int(num_channels * image.size[0] * image.size[1] * num_lsb) 66 | 67 | 68 | def bytes_in_max_file_size(image: Image.Image, num_lsb: int, num_channels: int) -> int: 69 | """Returns the number of bits needed to store the size of the file.""" 70 | return roundup(max_bits_to_hide(image, num_lsb, num_channels).bit_length() / 8) 71 | 72 | 73 | def hide_message_in_image(input_image: Image.Image, message: Union[str, bytes], num_lsb: int, 74 | skip_storage_check: bool = False) -> Image.Image: 75 | """Hides the message in the input image and returns the modified image object.""" 76 | start = time() 77 | num_channels = len(input_image.getbands()) 78 | flattened_color_data = [v for t in list(input_image.getdata()) for v in t] 79 | 80 | # We add the size of the input file to the beginning of the payload. 81 | message_size = len(message) 82 | file_size_tag = message_size.to_bytes(bytes_in_max_file_size(input_image, num_lsb, num_channels), 83 | byteorder=sys.byteorder) 84 | data = file_size_tag + _str_to_bytes(message) 85 | log.debug(f"{'Files read':<30} in {time() - start:.2f}s") 86 | 87 | if 8 * len(data) > max_bits_to_hide(input_image, num_lsb, num_channels) and not skip_storage_check: 88 | raise ValueError(f"Only able to hide {max_bits_to_hide(input_image, num_lsb, num_channels) // 8} bytes in " 89 | f"this image with {num_lsb} LSBs, but {len(data)} bytes were requested") 90 | 91 | start = time() 92 | flattened_color_data = lsb_interleave_list(flattened_color_data, data, num_lsb) 93 | log.debug(f"{f'{message_size} bytes hidden':<30} in {time() - start:.2f}s") 94 | 95 | start = time() 96 | # PIL expects a sequence of tuples, one per pixel 97 | input_image.putdata(cast(List[int], list(zip(*[iter(flattened_color_data)] * num_channels)))) 98 | log.debug(f"{'Image overwritten':<30} in {time() - start:.2f}s") 99 | return input_image 100 | 101 | 102 | def hide_data(input_image_path: str, input_file_path: str, steg_image_path: str, num_lsb: int, 103 | compression_level: int, skip_storage_check: bool = False) -> None: 104 | """Hides the data from the input file in the input image.""" 105 | if input_image_path is None: 106 | raise ValueError("LSBSteg hiding requires an input image file path") 107 | if input_file_path is None: 108 | raise ValueError("LSBSteg hiding requires a secret file path") 109 | if steg_image_path is None: 110 | raise ValueError("LSBSteg hiding requires an output image file path") 111 | 112 | image, input_file = prepare_hide(input_image_path, input_file_path) 113 | with image as image, input_file as input_file: 114 | image = hide_message_in_image(image, input_file.read(), num_lsb, skip_storage_check=skip_storage_check) 115 | 116 | # just in case is_animated is not defined, as suggested by the Pillow documentation 117 | is_animated = getattr(image, "is_animated", False) 118 | image.save(steg_image_path, compress_level=compression_level, save_all=is_animated) 119 | 120 | 121 | def recover_message_from_image(input_image: Image.Image, num_lsb: int) -> bytes: 122 | """Returns the message from the steganographed image""" 123 | start = time() 124 | num_channels = len(input_image.getbands()) 125 | color_data = [v for t in list(input_image.getdata()) for v in t] 126 | 127 | file_size_tag_size = bytes_in_max_file_size(input_image, num_lsb, num_channels) 128 | tag_bit_height = roundup(8 * file_size_tag_size / num_lsb) 129 | 130 | bytes_to_recover = int.from_bytes(lsb_deinterleave_list(color_data[:tag_bit_height], 8 * file_size_tag_size, 131 | num_lsb), byteorder=sys.byteorder) 132 | 133 | maximum_bytes_in_image = (max_bits_to_hide(input_image, num_lsb, num_channels) // 8 - file_size_tag_size) 134 | if bytes_to_recover > maximum_bytes_in_image: 135 | raise ValueError(f"This image appears to be corrupted.\nIt claims to hold {bytes_to_recover} B, " 136 | f"but can only hold {maximum_bytes_in_image} B with {num_lsb} LSBs") 137 | 138 | log.debug(f"{'Files read':<30} in {time() - start:.2f}s") 139 | 140 | start = time() 141 | data = lsb_deinterleave_list(color_data, 8 * (bytes_to_recover + file_size_tag_size), num_lsb)[ 142 | file_size_tag_size:] 143 | log.debug(f"{f'{bytes_to_recover} bytes recovered':<30} in {time() - start:.2f}s") 144 | return data 145 | 146 | 147 | def recover_data(steg_image_path: str, output_file_path: str, num_lsb: int) -> None: 148 | """Writes the data from the steganographed image to the output file""" 149 | if steg_image_path is None: 150 | raise ValueError("LSBSteg recovery requires an input image file path") 151 | if output_file_path is None: 152 | raise ValueError("LSBSteg recovery requires an output file path") 153 | 154 | steg_image, output_file = prepare_recover(steg_image_path, output_file_path) 155 | with steg_image as steg_image, output_file as output_file: 156 | data = recover_message_from_image(steg_image, num_lsb) 157 | start = time() 158 | output_file.write(data) 159 | log.debug(f"{'Output file written':<30} in {time() - start:.2f}s") 160 | 161 | 162 | def analysis(image_file_path: str, input_file_path: str, num_lsb: int) -> None: 163 | """Print how much data we can hide and the size of the data to be hidden""" 164 | if image_file_path is None: 165 | raise ValueError("LSBSteg analysis requires an input image file path") 166 | 167 | with Image.open(image_file_path) as image: 168 | num_channels = len(image.getbands()) 169 | print(f"Image resolution: ({image.size[0]}, {image.size[1]}, {len(image.getbands())})\n" 170 | f"{f'Using {num_lsb} LSBs, we can hide:':<30} {max_bits_to_hide(image, num_lsb, num_channels) // 8} B") 171 | 172 | if input_file_path is not None: 173 | print(f"{'Size of input file:':<30} {get_filesize(input_file_path)} B") 174 | 175 | print(f"{'File size tag:':<30} {bytes_in_max_file_size(image, num_lsb, num_channels)} B") 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Steganography 2 | 3 | ![Steganography illustration](readme_illustration.png) 4 | 5 | # Table of Contents 6 | 7 | * [Installation](#installation) 8 | * [Byte Sequence Manipulation](#byte-sequence-manipulation) 9 | * [WavSteg](#wavsteg) 10 | * [LSBSteg](#lsbsteg) 11 | * [StegDetect](#stegdetect) 12 | 13 | If you are unfamiliar with steganography techniques, I have also written a 14 | basic overview of the field in 15 | [Steganography: Hiding Data Inside Data](https://ryanagibson.com/posts/steganography-intro/). 16 | 17 | ## Installation 18 | 19 | This project is on [PyPI](https://pypi.org/project/stego-lsb/) and can be 20 | installed with 21 | 22 | pip install stego-lsb 23 | 24 | Alternatively, you can install it from this repository directly: 25 | 26 | git clone https://github.com/ragibson/Steganography 27 | cd Steganography 28 | python3 setup.py install 29 | 30 | After installation, use the `stegolsb` command in the terminal or import 31 | functions from `stego_lsb` in your code. 32 | 33 | ## Byte Sequence Manipulation 34 | 35 | bit_manipulation provides the ability to (quickly) interleave the bytes of a 36 | payload directly in the least significant bits of a carrier byte sequence. 37 | 38 | Specifically, it contains four primary functions: 39 | 40 | # Interleave the bytes of payload into the num_lsb LSBs of carrier. 41 | lsb_interleave_bytes(carrier, payload, num_lsb, truncate=False) 42 | 43 | # Deinterleave num_bits bits from the num_lsb LSBs of carrier. 44 | lsb_deinterleave_bytes(carrier, num_bits, num_lsb) 45 | 46 | # Runs lsb_interleave_bytes with a List[uint8] carrier. 47 | lsb_interleave_list(carrier, payload, num_lsb) 48 | 49 | # Runs lsb_deinterleave_bytes with a List[uint8] carrier. 50 | lsb_deinterleave_list(carrier, num_bits, num_lsb) 51 | 52 | Running `bit_manipulation.py`, calling its `test()` function directly, or 53 | running `stegolsb test` should produce output similar to 54 | 55 | Testing 1.0 MB payload -> 10.0 MB carrier... 56 | Progress: [################################] 57 | ---------------------------------------- 58 | | # LSBs | Encode Rate | Decode rate | 59 | | 1 | 60.6 MB/s | 95.9 MB/s | 60 | | 2 | 56.6 MB/s | 52.7 MB/s | 61 | | 3 | 82.5 MB/s | 77.4 MB/s | 62 | | 4 | 112.4 MB/s | 105.9 MB/s | 63 | | 5 | 135.9 MB/s | 129.8 MB/s | 64 | | 6 | 159.9 MB/s | 152.4 MB/s | 65 | | 7 | 181.7 MB/s | 174.6 MB/s | 66 | | 8 | 372.8 MB/s | 1121.8 MB/s | 67 | ---------------------------------------- 68 | 69 | ## WavSteg 70 | 71 | WavSteg uses least significant bit steganography to hide a file in the samples 72 | of a .wav file. 73 | 74 | For each sample in the audio file, we overwrite the least significant bits with 75 | the data from our file. 76 | 77 | ### How to use 78 | 79 | WavSteg requires Python 3 80 | 81 | Run WavSteg with the following command line arguments: 82 | 83 | Command Line Arguments: 84 | -h, --hide To hide data in a sound file 85 | -r, --recover To recover data from a sound file 86 | -i, --input TEXT Path to a .wav file 87 | -s, --secret TEXT Path to a file to hide in the sound file 88 | -o, --output TEXT Path to an output file 89 | -n, --lsb-count INTEGER How many LSBs to use [default: 2] 90 | -b, --bytes INTEGER How many bytes to recover from the sound file 91 | --help Show this message and exit. 92 | 93 | Example: 94 | 95 | $ stegolsb wavsteg -h -i sound.wav -s file.txt -o sound_steg.wav -n 1 96 | # OR 97 | $ stegolsb wavsteg -r -i sound_steg.wav -o output.txt -n 1 -b 1000 98 | 99 | ### Hiding Data 100 | 101 | Hiding data uses the arguments -h, -i, -s, -o, and -n. 102 | 103 | The following command would hide the contents of file.txt into sound.wav and 104 | save the result as sound_steg.wav. The command also outputs how many bytes have 105 | been used out of a theoretical maximum. 106 | 107 | Example: 108 | 109 | $ stegolsb wavsteg -h -i sound.wav -s file.txt -o sound_steg.wav -n 2 110 | Using 2 LSBs, we can hide 6551441 bytes 111 | Files read in 0.01s 112 | 5589889 bytes hidden in 0.24s 113 | Output wav written in 0.03s 114 | 115 | If you attempt to hide too much data, WavSteg will print the minimum number of 116 | LSBs required to hide your data. 117 | 118 | ### Recovering Data 119 | 120 | Recovering data uses the arguments -r, -i, -o, -n, and -b 121 | 122 | The following command would recover the hidden data from sound_steg.wav and 123 | save it as output.txt. This requires the size in bytes of the hidden data to 124 | be accurate or the result may be too short or contain extraneous data. 125 | 126 | Example: 127 | 128 | $ stegolsb wavsteg -r -i sound_steg.wav -o output.txt -n 2 -b 5589889 129 | Files read in 0.02s 130 | Recovered 5589889 bytes in 0.18s 131 | Written output file in 0.00s 132 | 133 | ## LSBSteg 134 | 135 | LSBSteg uses least significant bit steganography to hide a file in the color 136 | information of an RGB image (.bmp or .png). 137 | 138 | For each color channel (e.g., R, G, and B) in each pixel of the image, we 139 | overwrite the least significant bits of the color value with the data from our 140 | file. In order to make recovering this data easier, we also hide the file size 141 | of our input file in the first few color channels of the image. 142 | 143 | ### How to use 144 | 145 | You need Python 3 and Pillow, a fork of the Python Imaging Library (PIL). 146 | 147 | Run LSBSteg with the following command line arguments: 148 | 149 | Command Line Arguments: 150 | -h, --hide To hide data in an image file 151 | -r, --recover To recover data from an image file 152 | -a, --analyze Print how much data can be hidden within an image [default: False] 153 | -i, --input TEXT Path to an bitmap (.bmp or .png) image 154 | -s, --secret TEXT Path to a file to hide in the image 155 | -o, --output TEXT Path to an output file 156 | -n, --lsb-count INTEGER How many LSBs to use [default: 2] 157 | -c, --compression INTEGER RANGE 158 | 1 (best speed) to 9 (smallest file size) [default: 1] 159 | --help Show this message and exit. 160 | 161 | Example: 162 | 163 | $ stegolsb steglsb -a -i input_image.png -s input_file.zip -n 2 164 | # OR 165 | $ stegolsb steglsb -h -i input_image.png -s input_file.zip -o steg.png -n 2 -c 1 166 | # OR 167 | $ stegolsb steglsb -r -i steg.png -o output_file.zip -n 2 168 | 169 | ### Analyzing 170 | 171 | Before hiding data in an image, it can be useful to see how much data can be 172 | hidden. The following command will achieve this, producing output similar to 173 | 174 | $ stegolsb steglsb -a -i input_image.png -s input_file.zip -n 2 175 | Image resolution: (2000, 1100, 3) 176 | Using 2 LSBs, we can hide: 1650000 B 177 | Size of input file: 1566763 B 178 | File size tag: 3 B 179 | 180 | ### Hiding Data 181 | 182 | The following command will hide data in the input image and write the result to 183 | the steganographed image, producing output similar to 184 | 185 | $ stegolsb steglsb -h -i input_image.png -s input_file.zip -o steg.png -n 2 -c 1 186 | Files read in 0.26s 187 | 1566763 bytes hidden in 0.31s 188 | Image overwritten in 0.27s 189 | 190 | ### Recovering Data 191 | 192 | The following command will recover data from the steganographed image and write 193 | the result to the output file, producing output similar to 194 | 195 | $ stegolsb steglsb -r -i steg.png -o output_file.zip -n 2 196 | Files read in 0.30s 197 | 1566763 bytes recovered in 0.28s 198 | Output file written in 0.00s 199 | 200 | ## StegDetect 201 | 202 | StegDetect provides one method for detecting simple steganography in images. 203 | 204 | ### How to Use 205 | 206 | You need Python 3 and Pillow, a fork of the Python Imaging Library (PIL). 207 | 208 | Run StegDetect with the following command line arguments: 209 | 210 | Command Line Arguments: 211 | -i, --input TEXT Path to an image 212 | -n, --lsb-count INTEGER How many LSBs to display [default: 2] 213 | --help Show this message and exit. 214 | 215 | ### Showing the Least Significant Bits of an Image 216 | 217 | We sum the least significant n bits of the RGB color channels for each pixel 218 | and normalize the result to the range 0-255. This value is then applied to each 219 | color channel for the pixel. Where n is the number of least significant bits to 220 | show, the following command will save the resulting image, appending "_nLSBs" 221 | to the file name, and will produce output similar to the following: 222 | 223 | $ stegolsb stegdetect -i input_image.png -n 2 224 | Runtime: 0.63s 225 | --------------------------------------------------------------------------------