Skip to content

Instantly share code, notes, and snippets.

@dhondta
Last active February 11, 2024 10:36
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save dhondta/d2151c82dcd9a610a7380df1c6a0272c to your computer and use it in GitHub Desktop.
Save dhondta/d2151c82dcd9a610a7380df1c6a0272c to your computer and use it in GitHub Desktop.
Tinyscript steganography tool implementing the Least Significant Bit algorithm

StegoLSB

This Tinyscript-based tool allows to apply steganography based on LSB (Least Significant Bit) in order to retrieve hidden data from an image.

$ pip install tinyscript
$ tsm install stegolsb

This tool is especially useful in the use cases hereafter.

Extract hidden data from an image using LSB stegano

$ stegolsb -v extract test.png --column-step 2 --rows 1 --columns 128
12:34:56 [DEBUG] Image size: 225x225
12:34:56 [DEBUG] Bits [0], channels RGB, column step 2, row step 1
12:34:56 [INFO] Hidden data:
[...]

Bruteforce LSB stegano parameters to recover hidden data from an image

This will display readable strings recovered using bruteforced paramters.

$ stegolsb bruteforce test.png
12:34:56 [INFO] [...]
#!/usr/bin/python3
# -*- coding: UTF-8 -*-
from PIL import Image
from tinyscript import *
__author__ = "Alexandre D'Hondt"
__version__ = "1.2"
__copyright__ = ("A. D'Hondt", 2020)
__license__ = "gpl-3.0"
__examples__ = [
"-v extract -b 0 test.png",
"extract test.png --cols 128 --rows 1 --column-step 2",
"-w secret.txt bruteforce test.png",
]
__doc__ = """
*StegoLSB* allows to apply steganography based on LSB (Least Significant Bit) in order to retrieve hidden data from an image.
"""
BANNER_FONT = "standard"
BANNER_STYLE = {'fgcolor': "lolcat"}
HOTKEYS = {'d': lambda: show_data(), 'h': lambda: show_data(first=1), 't': lambda: show_data(last=1)}
SCRIPTNAME_FORMAT = "none"
def show_data(first=0, last=0, width=16):
global p
if not hasattr(p, "data"):
return
d = p.data
n_lines, PRINT = len(d) // width, re.sub(r"\s", "", string.printable)
for i in range(0, len(d), width):
if first > 0 and i // width >= first:
break
elif last > 0 and i // width <= n_lines - last:
continue
h = ts.str2hex(d[i:i+width])
h = " ".join(h[j:j+4] for j in range(0, len(h), 4))
b = "".join(c if c in PRINT else "." for c in d[i:i+width])
print("%0.8x: %s %s" % (i, h.ljust(39), b))
class LSB(object):
def __init__(self, image, secret=None):
self.__image = image
self.__secret = secret
self.__write = True
self.__obj = Image.open(image)
logger.debug("Image size: {}x{}".format(*self.__obj.size))
def bruteforce(self, bits=False, channels=False, nchars=16, maxstep=10):
self.__write = False
for ch in (ts.bruteforce(3, "RGB", repeat=False) if channels else ["RGB"]):
for bi in (ts.bruteforce(8, range(8), repeat=False) if bits else [(0, )]):
for y_s in range(1, maxstep + 1):
for x_s in range(1, maxstep + 1):
self.extract(bi, ch, colstep=x_s, rowstep=y_s)
for s in ts.strings(self.data, nchars):
logger.info(s)
if self.__secret:
self.write(s)
def extract(self, bits=(0, ), channels="RGB", cols=None, rows=None, coloffset=0, rowoffset=0, colstep=1, rowstep=1):
logger.debug("Bits {}, channels {}, column step {}, row step {}".format(list(bits), channels, colstep, rowstep))
i = self.__obj
cols = cols or i.size[0]
rows = rows or i.size[1]
self.data = ""
for y in range(rowoffset, rows, max(1, rowstep)):
data = ""
for x in range(coloffset, cols, max(1, colstep)):
pixel = {k: v for k, v in zip("RGB", i.getpixel((x, y)))}
for c in channels.upper():
B = ts.int2bin(pixel[c])[::-1]
for b in bits:
data += B[b]
d = ts.bin2str(data)
self.data += d
if self.__write:
self.write(d)
return self
def hide(self, data):
bin_data = ts.str2bin(data)
#TODO: implement hiding data
bin_len = ts.int2bin(len(bin_data))
return self
def write(self, content):
fn = self.__secret
if fn is None:
fn = os.path.basename(self.__image)
fn, _ = os.path.splitext(fn)
fn = "{}-secret.txt".format(fn)
with open(fn, 'ab') as f:
f.write(b(content))
return self
if __name__ == "__main__":
parser.add_argument("-w", "--write", help="write data to a file")
subparsers = parser.add_subparsers(help="commands", dest="command")
extract = subparsers.add_parser('extract', help="manually extract hidden data")
bruteforce = subparsers.add_parser('bruteforce', help="bruteforce parameters for extracting hidden data")
extract.add_argument("image", type=ts.file_exists, help="image path")
extract.add_argument("-b", "--bits", type=ts.pos_ints, default="0", help="bits to be considered, starting from LSB")
extract.add_argument("-c", "--channels", default="RGB", help="channels to be considered")
extract.add_argument("--columns", dest="cols", type=ts.pos_int, help="number of image columns to be considered")
extract.add_argument("--column-offset", dest="coloffset", type=ts.pos_int, default=0,
help="column offset for searching for data")
extract.add_argument("--column-step", dest="colstep", type=ts.pos_int, default=1,
help="step number for iterating columns")
extract.add_argument("--rows", type=ts.pos_int, help="number of image rows to be considered")
extract.add_argument("--row-offset", dest="rowoffset", type=ts.pos_int, default=0,
help="row offset for searching for data")
extract.add_argument("--row-step", dest="rowstep", type=ts.pos_int, default=1,
help="step number for iterating rows")
bruteforce.add_argument("image", type=ts.file_exists, help="image path")
bruteforce.add_argument("-b", "--bits", action="store_true", help="bruteforce the bits positions",
note="if false, only the LSB is considered")
bruteforce.add_argument("-c", "--channels", action="store_true", help="bruteforce the color channels",
note="if false, RGB are considered")
bruteforce.add_argument("-n", "--nchars", type=ts.pos_int, default=16, help="minimal length for readable strings")
bruteforce.add_argument("-s", "--max-step", type=ts.pos_int, default=10, help="maximum bit step to be considered",
note="e.g. 3 will lookup every 3 bits in the LSB-collected data")
initialize(noargs_action="demo")
p = LSB(args.image, args.write)
if args.command == "bruteforce":
p.bruteforce(args.bits, args.channels, args.nchars, args.max_step)
elif args.command == "extract":
p.extract(args.bits, args.channels, args.cols, args.rows, args.coloffset, args.rowoffset, args.colstep,
args.rowstep)
logger.info("Hidden data:\n" + p.data)
@neoxeo
Copy link

neoxeo commented Jul 23, 2022

Hi,

It's me again :-)

I have tried to use StegoLsb to find "file" hidden into image but without success

I use two tools to hide file :

I don't understand why stegolsb doesn't find something.

Here are 2 samples of images contains an other image created with 2 tools.

Stego-LSB Sample :
Stego-lsb_Sample

ImageContainer Sample :
ImageContainer_Sample

If you find time to have a look, it will be fantastic.

Thank you again for this excellent tool.

@kevinmillegmailcom
Copy link

from markdown.util import etree is deprecated.

cf. https://python-markdown.github.io/change_log/release-3.2/#markdownutiletree-deprecated

Previously, Python-Markdown was using either the xml.etree.cElementTree module or the xml.etree.ElementTree module, based on their availability. In modern Python versions, the former is a deprecated alias for the latter. Thus, the compatibility layer is deprecated and extensions are advised to use xml.etree.ElementTree directly. Importing markdown.util.etree will raise a DeprecationWarning beginning in version 3.2 and may be removed in a future release.
Therefore, extension developers are encouraged to replace from markdown.util import etree with import xml.etree.ElementTree as etree in their code.

@kevinmillegmailcom
Copy link

in mdv/markdownviewer.py, line 157

@dhondta
Copy link
Author

dhondta commented Jul 28, 2022

Hi @neoxeo !
Thanks for the heads up.
I updated the Gist with a new feature from Tinyscript ; some hotkeys for printing hexdumps of data while computing. With "h" and "t", you can respectively check the heading (i.e. to see if you get a file signature like "\x89PNG") and trailing lines of data.
Can you provide the command lines and/or options you used for both of your examples ?

@CriimBow
Copy link

In the usage example and in your README example, it is written that the option to specify the columns is --cols, when in fact it is --columns.

@dhondta
Copy link
Author

dhondta commented Mar 26, 2023

@CriimBow Thank you for mentioning this ! 👍

@BREBION-Mathis
Copy link

BREBION-Mathis commented Sep 1, 2023

Hi, i have this error with this command:

python stego_lsb.py extract ch9.png --column-step 2 --rows 1 --columns 128

Error:

Traceback (most recent call last):
  File "/home/kali/.local/lib/python3.11/site-packages/tinyscript/helpers/data/utils.py", line 35, in __init_bitstring
    patchy.replace(bs.Bits._getlength, OLD_CODE % " -> int", NEW_CODE % " -> int")
  File "/home/kali/.local/lib/python3.11/site-packages/patchy/api.py", line 56, in replace
    _assert_ast_equal(current_source, expected_source, func.__name__)
  File "/home/kali/.local/lib/python3.11/site-packages/patchy/api.py", line 350, in _assert_ast_equal
    raise ValueError(msg)
ValueError: The code of '_getlength' has changed from expected.
The current code is:
def _getlength(self) -> int:
    """Return the length of the bitstring in bits."""
    return len(self._bitstore)

The expected code is:

def _getlength(self) -> int:
    """Return the length of the bitstring in bits."""
    return self._datastore.bitlength


During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/kali/Documents/Tools/Steganography/LSB-Steganography/stego_lsb.py", line 133, in <module>
    p.extract(args.bits, args.channels, args.cols, args.rows, args.coloffset, args.rowoffset, args.colstep,
  File "/home/kali/Documents/Tools/Steganography/LSB-Steganography/stego_lsb.py", line 76, in extract
    B = ts.int2bin(pixel[c])[::-1]
        ^^^^^^^^^^^^^^^^^^^^
  File "/home/kali/.local/lib/python3.11/site-packages/tinyscript/helpers/data/transform/common.py", line 143, in int2bin
    bs = Bits()
         ^^^^^^
  File "/home/kali/.local/lib/python3.11/site-packages/tinyscript/helpers/data/utils.py", line 60, in __init_bitarray
    class BitArray(bitstring.BitArray):
                   ^^^^^^^^^^^^^^^^^^
  File "/home/kali/.local/lib/python3.11/site-packages/tinyscript/helpers/common.py", line 53, in _load
    postload(m)
  File "/home/kali/.local/lib/python3.11/site-packages/tinyscript/helpers/data/utils.py", line 37, in __init_bitstring
    patchy.replace(bs.Bits._getlength, OLD_CODE % "", NEW_CODE % "")
  File "/home/kali/.local/lib/python3.11/site-packages/patchy/api.py", line 56, in replace
    _assert_ast_equal(current_source, expected_source, func.__name__)
  File "/home/kali/.local/lib/python3.11/site-packages/patchy/api.py", line 350, in _assert_ast_equal
    raise ValueError(msg)
ValueError: The code of '_getlength' has changed from expected.
The current code is:
def _getlength(self) -> int:
    """Return the length of the bitstring in bits."""
    return len(self._bitstore)

The expected code is:

def _getlength(self):
    """Return the length of the bitstring in bits."""
    return self._datastore.bitlength

Python version:

Python 3.11.4

@kevinmillegmailcom
Copy link

@BREBION-Mathis
Try
python3 stegolsb.py -v extract ch9.png --column-step 2 --rows 1 --columns 128
or
python3 stegolsb.py bruteforce ch9.png

@BREBION-Mathis
Copy link

@kevinmillegmailcom

Tks for you'r post but i have the same error...

@MonkeyCst
Copy link

Hello !
Déjà merci pour ton tool ! Lorsque je l'utilise sur une image png j'ai une énorme chaine de caractère illisible en sortie apres [Hidden data], ca me semble etre du binaire je pense :

python stegolsb.py extract hidden.png
_ _ _
| | ___ __ _ ___ | || |_
/ | / _ / ` |/ _ | / | ' \
_
\ || __/ (
| | () | _ \ |) |
|
/___
|_, |___/||/./
|___/

09:04:10 [DEBUG] Image size: 225x225
09:04:10 [DEBUG] Bits [0], channels RGB, column step 1, row step 1
09:04:17 [INFO] Hidden data:
ó®ûãþº÷;çÏ;ï¯>÷+§ÜÕÆùqø·Þkã¯Z· aòßÏ¿öÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿï¿ÛÞnG}@»FÏY;V¬wçÿEÿÿöÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿïÿÛÿãÛß3Ô2I$ñ¿ûÿöÛÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿýÿßÿü[k$
U*Ih▒ëÿ_׿·Ûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿý¶ßüÚß_à]Ñë(§¹¶öÛ·Ûÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ETC.....

Une idée de ce que ca représente exactement ?

@BREBION-Mathis
Copy link

@MonkeyCst
Salut à toi !
Juste une question sur quel système et quelle version de python utilises-tu ce tool ?

@dhondta
Copy link
Author

dhondta commented Oct 19, 2023

@BREBION-Mathis
Bonjour !
Mes excuses pour le temps que je mets à répondre (emploi du temps très chargé).

L'erreur vient d'un changement de code de la librairie bitstring ;

Je vais regarder à ceci dès que possible et mettre à jour Tinyscript.

@dhondta
Copy link
Author

dhondta commented Oct 19, 2023

@BREBION-Mathis
J'ai rencontré récemment un problème avec une version au-delà de 4.0.2 de bitstring. Je n'ai actuellement pas de fix, j'ai donc piné la version dans ma dernière release (1.28.6) de Tinyscript.
Si tu fais pip install --upgrade tinyscript (il faut peut-être ajouter d'autres options avec Python3.11 et une version récente de Pip) pour mettre à jour vers Tinyscript 1.28.6, l'outil devrait fonctionner.

@dhondta
Copy link
Author

dhondta commented Oct 19, 2023

@MonkeyCst
C'est certainement du bullshit. Ce sont certainement tes paramètres qui ne sont pas encore les bons.
Il faut jouer avec les options --column-step, --rows et --columns.

@dhondta
Copy link
Author

dhondta commented Oct 19, 2023

@BREBION-Mathis @MonkeyCst
J'en profite pour faire ma pub' ; si vous aimez, n'oubliez pas de starrer, y compris Tinyscript ;-)

@BREBION-Mathis
Copy link

@dhondta

Merci beaucoup de ta réponse et pas de soucis je comprend, on a tous nos propres préoccupations.
Je vais donc suivre tes conseils et aussi essayer de regarder de mon côté et changer de version tinyscript. :)

@CoSmOo1
Copy link

CoSmOo1 commented Feb 10, 2024

Hello, I have the next problem with your tool @dhondta :
Command: tsm install stegolsb
Result:

 [WARNING] No cache available ; please use 'tinyscript update' to get script links from sources
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "C:\Users\**********\AppData\Local\Programs\Python\Python312\Scripts\tsm.exe\__main__.py", line 7, in <module>
  File "C:\Users\**********\AppData\Local\Programs\Python\Python312\Lib\site-packages\tinyscript\__main__.py", line 192, in main
    _update_cache(cache)
                  ^^^^^
UnboundLocalError: cannot access local variable 'cache' where it is not associated with a value

How can i fix it?

@dhondta
Copy link
Author

dhondta commented Feb 11, 2024

@CoSmOo1 Thank you for mentioning.
I pointed out this issue when solving another one of these last days on this similar Gist.

With this latest modification, you should now get the warning with "tsm update" instead of "tinyscript update" and no exception related to the cache.

Please do the following and retest :

$ pip install --upgrade tinyscript
$ tsm update
$ tsm install --force stegolsb
$ stegolsb --help

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment