• 15 апреля стартует «Курс «SQL-injection Master» ©» от команды The Codeby

    За 3 месяца вы пройдете путь от начальных навыков работы с SQL-запросами к базам данных до продвинутых техник. Научитесь находить уязвимости связанные с базами данных, и внедрять произвольный SQL-код в уязвимые приложения.

    На последнюю неделю приходится экзамен, где нужно будет показать свои навыки, взломав ряд уязвимых учебных сайтов, и добыть флаги. Успешно сдавшие экзамен получат сертификат.

    Запись на курс до 25 апреля. Получить промодоступ ...

Статья Скрываем текст в изображении тремя алгоритмами с помощью Python

Еще пару-тройку лет назад стеганография вызвала довольно большой интерес у интернет-аудитории. И инструкций о том, как скрыть сообщения и даже файлы в картинке, а то и в аудиозаписи, было довольно много. Но, постепенно интерес поубавился. И, хотя это все еще довольно популярная тема, но, она была вытеснена другими новостями и событиями. Я же решил сделать для себя небольшой «комбайн», который скрывает простой текст в изображении с помощь Python. И то, что у меня получилось, будет описано ниже.

000.gif

Для начала я определил, что это должно быть приложение с пользовательским интерфейсом, а значит, его нужно на чем-то делать. Либо это будет Tkinter, либо PyQt. Выбор пал на последний, так как Qt Designer очень похож на визуальный редактор, который использовался в Delphi. Да и в целом легче накидать элементов на форму, а потом уже описывать логику их работы. Ну, или так для меня. Суть не в том.

Теперь нужно было определиться с алгоритмами. В приложении будут использованы алгоритмы скрытия информации в JPG изображении в Exif Headers, то есть в заголовках, алгоритм LSB, то есть скрытие информации в наименее значимом бите, а также алгоритм дозаписи текста в конец JPG-файла.


Что потребуется?

Потребуется на этот раз довольно много. Установить библиотеку PyQT5, а также PyQT5 Designer. Пишем команду в терминале:

pip install PyQt5Designer PyQt5

Для вывода всплывающих сообщений в трей установить библиотеку plyer. Хоть она изначально предназначена не для этого, но ее модуль notification с этим прекрасно справляется. Почему она, а не win10toast? Я пробовал использовать эту библиотеку. И если с консольными приложениями она работает на ура, то в PyQt почему-то не очень. Программа, после вывода сообщения, просто закрывается. И в чем тут дело я, пока не разобрался. Установка также в терминале:

pip install plyer

Далее нужно установить стеганографическую библиотеку stegano, из нее мы будем использовать модуль exifHeader для скрытия текста в заголовках.

pip install stegano

Также нужно установить библиотеку cryptography. Из нее будет использоваться модуль Fernet для генерации ключа, с помощью которого будет шифроваться и дешифроваться сообщение, скрываемое в картинке с помощью алгоритма LSB.

pip install cryptography

Ну и так как мы будем работать с алгоритмом LSB, значит необходимо установить Pillow, для работы с изображениями.

pip install Pillow

Так как я не нашел более-менее вменяемой библиотеки работающей с кириллицей на PyPI, а самому реализовать данный алгоритм пока не позволяет отсутствие необходимых знаний и навыков (как говориться – учите матчасть), но сделать программу очень уж хотелось, я покопавшись в интернетах нашел статью, в которой пользователь Хабр под ником Securityhigh описывает создание модуля с помощью Python, а также выкладывает его на GitHub. Правда статья от 2020 года и автор обещал дорабатывать данную библиотеку, но, похоже, этому что-то помешало. Так как особо ничего с того времени не изменилось. Тем не менее, алгоритм работает, и автор разрешает воспользоваться его кодом – почему бы и нет. Потому я скачал проект и взял из него stegopy.py, который подключил к основному модулю. Правда пришлось внести небольшие правки, в основном касающиеся вывода данных и обработки ошибок. Так как при попытке чтения изображения без зашифрованного сообщения скрипт выпадал в осадок, равно как и при использовании неверного ключа.

Ну вот, вроде бы ничего не забыл. Теперь все это надо импортировать. Правда, будем мы это делать поэтапно. В данном случае у нас три питоновских файла + файл описания интерфейса PyQt. Для начала я опишу сам интерфейс и те библиотеки, которые в нем подключаются. Ну, а все остальное будет описано ниже. Итак, для начала у нас есть файл main.py, в который импортируется следующее:

Python:
import os.path
from os import system
from platform import system as psystem
from sys import argv, exit

from cryptography.fernet import Fernet
from PyQt5.QtWidgets import QFileDialog
from plyer import notification

import hex_stg_func
from stegopy import encrypt, decrypt
from steg_tool import *

Я набросал в дизайнере небольшую форму.

screenshot1.png


Здесь есть несколько выпадающих списков, в первом можно выбрать алгоритм, с помощью которого будет скрываться текст, во втором действие, то есть запись или чтение. Ну и еще один бокс, уже для параметра balance, который используется при работе алгоритма LSB. Как пишет автор скрипта, данный параметр отвечает за количество младших битов, которые будут задействованы в стеганографии. Тут используется интервал от 1 до 4. Таким образом, получается, что чем больше баланс, тем меньше пикселей будет задействовано в алгоритме и тем заметнее будут изменения в цветовых каналах. А значит, чем меньше баланс, тем меньше заметность человеческому глазу. Впрочем, данный алгоритм использует синий канал. А оттенки синего наименее заметны для глаза человека.

Также я добавил вывод текста в QTextEdit, правда, без возможности редактирования, но с возможностью сохранения. И выбор ключа, который становиться активным только при чтении текста с изображения скрытого по алгоритму LSB.

Почему не LSB из stegano? Я сначала пробовал использовать данный модуль. Но, я, также как и многие до меня, столкнулся с проблемой обработки кириллицы. То есть, записываться то она записывается, а вот после считывания выводятся крякозябры. В теории так быть не должно. Но, тем не менее, происходит.

Описывать нажатия на кнопочки, думаю, не имеет большого смысла. Одни становятся активными, другие не активными, для того, чтобы избежать обработки лишних ошибок при нажатии на них. Ну и разнообразные проверки данных в полях, а также в полученных от функций из другого модуля данных.

Python:
# pip install pyqt5-tools
# pip install PyQt5
# pip install PyQt5Designer
# pip install stegano
# pip install plyer
# pip install cryptography
# pip install Pillow
# pyuic5 steg_tool.ui -o steg_tool.py

import os.path
from os import system
from platform import system as psystem
from sys import argv, exit

from cryptography.fernet import Fernet
from PyQt5.QtWidgets import QFileDialog
from plyer import notification

import hex_stg_func
from stegopy import encrypt, decrypt
from steg_tool import *


class MyWin(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        QtWidgets.QWidget.__init__(self, parent)
        self.ui = Ui_MainWindow()
        self.ui.setupUi(self)

        self.ui.imgOpenBtn.clicked.connect(self.img_open)
        self.ui.takeBtn.clicked.connect(self.get_job)
        self.ui.savehsText.clicked.connect(self.save_text)
        self.ui.keyBtn.clicked.connect(self.key_path)
        self.ui.hexexCombo.currentTextChanged.connect(self.hexex_change)
        self.ui.wreadCmb.currentTextChanged.connect(self.hexex_change)

    def hexex_change(self):
        if self.ui.hexexCombo.currentText() == "LSB" and self.ui.wreadCmb.currentText() == "Write":
            self.ui.balanceBox.setEnabled(True)
            self.ui.keyBtn.setEnabled(False)
        elif self.ui.hexexCombo.currentText() == "LSB" and self.ui.wreadCmb.currentText() == "Read":
            self.ui.balanceBox.setEnabled(False)
            self.ui.keyBtn.setEnabled(True)
        elif self.ui.hexexCombo.currentText() == "HEX" or self.ui.hexexCombo.currentText() == "ExifHeaders":
            self.ui.keyBtn.setEnabled(False)

    def key_path(self):
        self.ui.pathKey.setText('')
        if psystem() == "Linux":
            file = QtWidgets.QFileDialog.getOpenFileName(self, "Выбор ключа .dat", None,
                                                         "Key file (*.dat)")[0]
        elif psystem() == "Windows":
            file = QtWidgets.QFileDialog.getOpenFileName(self, "Выбор ключа .dat", None,
                                                         "Key file (*.dat)")[0].replace('/', '\\')
        if file == '':
            return

        self.ui.pathKey.setText(file)

    def img_open(self):
        self.ui.savehsText.setEnabled(False)
        self.ui.txtSteg.setText('')
        self.ui.pathImg.setText('')
        if self.ui.hexexCombo.currentText() == "HEX" or self.ui.hexexCombo.currentText() == "ExifHeaders":
            extension = '*.jpg'
        else:
            extension = '*.png *.jpg'

        if psystem() == "Linux":
            file = QtWidgets.QFileDialog.getOpenFileName(self, "Выбор файла", None,
                                                         f"Image file ({extension})")[0]
        elif psystem() == "Windows":
            file = QtWidgets.QFileDialog.getOpenFileName(self, "Выбор файла", None,
                                                         f"Image file ({extension})")[0].replace('/', '\\')
        if file == '':
            return

        if self.ui.wreadCmb.currentText() == "Write":
            self.ui.txtSteg.setEnabled(True)
        self.ui.pathImg.setText(file)
        self.ui.takeBtn.setEnabled(True)

    def get_job(self):
        if self.ui.hexexCombo.currentText() == "HEX":
            self.ui.keyBtn.setEnabled(False)
            if self.ui.wreadCmb.currentText() == "Write":
                if self.ui.txtSteg.toPlainText():
                    hex_stg_func.write_text_ph(self.ui.pathImg.text(), self.ui.txtSteg.toPlainText())
                    self.ui.pathImg.setText('')
                    self.ui.txtSteg.setText('')
                    self.ui.txtSteg.setEnabled(False)
                    self.ui.takeBtn.setEnabled(False)
            elif self.ui.wreadCmb.currentText() == "Read":
                msg = hex_stg_func.read_text_ph(self.ui.pathImg.text())
                if msg == 'В картинке нет текста!':
                    if psystem() == "Windows":
                        notification.notify(message=msg)
                    elif psystem() == "Linux":
                        command = f'''notify-send {msg}"'''
                        system(command)
                    self.ui.txtSteg.setText('')
                    self.ui.takeBtn.setEnabled(False)
                else:
                    self.ui.txtSteg.setText(msg)
                    self.ui.pathImg.setText('')
                    self.ui.takeBtn.setEnabled(False)
                    self.ui.savehsText.setEnabled(True)

        if self.ui.hexexCombo.currentText() == "ExifHeaders":
            self.ui.keyBtn.setEnabled(False)
            if self.ui.wreadCmb.currentText() == "Write":
                if self.ui.txtSteg.toPlainText():
                    path_image_text = os.path.join(os.getcwd(),
                                                   f'{os.path.split(self.ui.pathImg.text())[1].split(".")[0]}_exif.jpg')
                    hex_stg_func.write_text_exif(self.ui.pathImg.text(), path_image_text, self.ui.txtSteg.toPlainText())
                    self.ui.pathImg.setText('')
                    self.ui.txtSteg.setText('')
                    self.ui.txtSteg.setEnabled(False)
                    self.ui.takeBtn.setEnabled(False)
            elif self.ui.wreadCmb.currentText() == "Read":
                msg = hex_stg_func.read_text_exif(self.ui.pathImg.text())
                if msg == 'В картинке нет текста!':
                    if psystem() == "Windows":
                        notification.notify(message=msg)
                    elif psystem() == "Linux":
                        command = f'''notify-send {msg}"'''
                        system(command)
                    self.ui.txtSteg.setText('')
                    self.ui.takeBtn.setEnabled(False)
                else:
                    self.ui.txtSteg.setText(msg)
                    self.ui.pathImg.setText('')
                    self.ui.takeBtn.setEnabled(False)
                    self.ui.savehsText.setEnabled(True)

        if self.ui.hexexCombo.currentText() == "LSB" and self.ui.wreadCmb.currentText() == "Write":
            if self.ui.txtSteg.toPlainText():
                encrypt(self.ui.pathImg.text(), self.ui.txtSteg.toPlainText(), Fernet.generate_key().decode(),
                        int(self.ui.balanceBox.currentText()))
                self.ui.balanceBox.setEnabled(False)
                self.ui.pathImg.setText('')
                self.ui.takeBtn.setEnabled(False)
                self.ui.txtSteg.setText('')
                self.ui.txtSteg.setEnabled(False)
        elif self.ui.hexexCombo.currentText() == "LSB" and self.ui.wreadCmb.currentText() == "Read":
            if self.ui.pathKey.text():
                with open(self.ui.pathKey.text(), 'r') as k:
                    key = k.read()
                dec = decrypt(self.ui.pathImg.text(), key)
                if dec is None:
                    if psystem() == "Windows":
                        notification.notify(message="В файле нет текста или неверный ключ!")
                    elif psystem() == "Linux":
                        command = f'''notify-send "В файле нет текста или неверный ключ!"'''
                        system(command)
                    self.ui.savehsText.setEnabled(False)
                    self.ui.takeBtn.setEnabled(False)
                else:
                    self.ui.txtSteg.setText(dec)
                    self.ui.pathImg.setText('')
                    self.ui.takeBtn.setEnabled(False)
                    self.ui.pathKey.setText('')
                    self.ui.savehsText.setEnabled(True)

    def save_text(self):
        ok = QFileDialog.getSaveFileName(self, "Сохранить файл", ".", "(*.txt)")
        path_s = ok[0].replace("/", "\\") + ok[1]
        if path_s:
            with open(path_s, 'w', encoding='utf-8') as text:
                text.write(self.ui.txtSteg.toPlainText())
            if psystem() == "Windows":
                notification.notify(message='Текст сохранен!')
            elif psystem() == "Linux":
                command = f'''notify-send "Текст сохранен!"'''
                system(command)
            self.ui.txtSteg.setText('')
            self.ui.savehsText.setEnabled(False)


if __name__ == '__main__':
    app = QtWidgets.QApplication(argv)
    myapp = MyWin()
    myapp.show()
    exit(app.exec_())

Сам модуль, а также UI для дизайнера я прикреплю к статье. Да простят меня пользователи Codeby, но вот аккаунта на GitHub я пока не завел. Хотя понимаю порой, что надо бы.



Функции обработки текста


Запись в конец JPG-файла


Дальше идет модуль с функциями, которые и занимаются обработкой и скрытием текста. Его можно рассмотреть немного подробнее. Называется он у меня hex_stg_func.py. Блок импорта в данном модуле выглядит так:

Python:
import os.path
from os import system
from platform import system as psystem
from shutil import copy2
from zlib import error

from plyer import notification
from stegano import exifHeader

Первая функция в этом модуле, это функция для дозаписи текста в файл JPG в конец файла. Назвал я ее write_text_ph(path_image, text_write). На вход она получает путь к картинке, а также текст, который нужно записать. Затем из полученного пути получаем название файла, формируем новый путь, куда надо будет скопировать изображение, копируем его и открываем в бинарном режиме для дозаписи, записываем текст и выводим сообщение пользователю о том, что запись завершена.

Python:
def write_text_ph(path_image, text_write):
    image_name = f'{os.path.split(path_image)[1].split(".")[0]}_hex.jpg'
    path_image_new = f'{os.path.join(os.getcwd(), image_name)}'

    copy2(path_image, path_image_new)

    with open(path_image_new, 'ab') as ph:
        ph.write(text_write.encode())

    if psystem() == "Windows":
        notification.notify(message='HEX текст записан!')
    elif psystem() == "Linux":
        command = f'''notify-send "HEX текст записан!"'''
        system(command)

Здесь информация дописывается в конец файла.

Далее идет функция чтения записанной информации. Причем, думаю, что если записать текст другой программой, его также можно будет считать. Не экспериментировал в этом направлении, но думаю, что попробую, если программу найду. Назвал я ее read_text_ph(path_image). Тут тоже ничего особо сложного. На вход она получает путь к изображению для чтения. Затем открывает в бинарном режиме для чтения. Считываются данные в переменную ph_content. Затем устанавливаем смещение, в бинарном файле, которое равно «FFD9». Эти байты означают конец JPG файла и присутствуют почти в любом из изображений JPG. И смещаемся на указанную позицию, плюс два байта. Ведь нам не надо считывать те байты, которые мы искали. После чего информация по указанному смещению считывается, декодируется и передается для вывода на экран. А если в картинке нет текста, то возвращаем текст о том, что текста нет.

Python:
def read_text_ph(path_image):
    with open(path_image, 'rb') as ph:
        ph_content = ph.read()
        offset = ph_content.index(bytes.fromhex('FFD9'))
        ph.seek(offset + 2)
        text = ph.read().decode()
        if text == "":
            return 'В картинке нет текста!'
        else:
            return text


Запись в exif-заголовки

Теперь нужно добавить функцию для записи информации в exif-заголовки. Назову ее write_text_exif(path_image, path_image_text, text_write). Здесь уже потребуется на вход больше данных. А именно: путь к картинке оригиналу; полный путь с названием, куда нужно скопировать картинку; текст для записи. Используем функцию exifHeader и ее метод hide, которые и выполняют всю основную работу. А дальше выводится сообщение о выполнении действия.

Python:
def write_text_exif(path_image, path_image_text, text_write):
    exifHeader.hide(path_image, path_image_text, text_write)
    if psystem() == "Windows":
        notification.notify(message='Exif текст записан!')
    elif psystem() == "Linux":
        command = f'''notify-send "Exif текст записан!"'''
        system(command)

Затем чтение из заголовков. Я сделал функцию read_text_exif(path_image), на входе, которая получает путь к изображению, считывает данные с помощью метода reveal, декодирует его и возвращает для вывода на экран.

Python:
def read_text_exif(path_image):
    try:
        result = exifHeader.reveal(path_image).decode()
        return result
    except error:
        return 'В картинке нет текста!'

На самом деле, если пользоваться данной функцией, нелишним было бы добавить сюда еще и шифрование. Так как спрятанный таким образом текст довольно просто обнаружить в свойствах изображения. Вот, для примера, изображение с записанным текстом.

screenshot2.png

Как видите, все на виду. И если понимать, что тут может содержаться информация, а не просто крякозябры, то можно ее оттуда даже выдернуть и декодировать.


Скрытие изображения с помощью алгоритма LSB

А далее, я использовал модуль пользователя Хабр Securityhigh. Думаю, что описывать алгоритм не имеет смысла, так как он уже описан на Хабре вот в этой статье. Нужно только обозначить, где я его вызываю в коде, и какие данные в него передаю. Ну и те небольшие изменения, которые я в него внес. Но, они на самом деле небольшие.

Функция кодирования текста вызывается при нажатии на кнопку «Get job»:

Python:
encrypt(self.ui.pathImg.text(), self.ui.txtSteg.toPlainText(), Fernet.generate_key().decode(),
                        int(self.ui.balanceBox.currentText()))

Сюда передается путь к изображению, текст который нужно закодировать, сгенерированный с помощью Fernet ключ, а также значение баланса.

Соответственно, декодирование вызывается похожим образом, в том же нажатии кнопки:

Python:
with open(self.ui.pathKey.text(), 'r') as k:
    key = k.read()
dec = decrypt(self.ui.pathImg.text(), key)

Здесь уже передается меньше данных. Нужно передать путь к картинке со скрытым текстом и ключ для дешифровки, который считывается из файла. В оригинале путь к ключу не указывается. Он должен лежать в папке с программой. Я же сделал кнопку выбора файла ключа. Так что, он может лежать в любом месте. Главное, чтобы этот ключ был правильный.

А теперь про изменения. В функцию encrypt добавил определение пути к картинке:

Python:
path_new = os.path.join(os.getcwd(), f'{os.path.split(path_to_image)[1].split(".")[0]}_lsb.png')

То есть, с каким названием ее сохранять. Чтобы было не жестко зашитое в коде, а то название, что и у оригинала с небольшим суффиксом. Ну и убрал вывод сообщение в терминал, а добавил всплывающие сообщения:

Python:
    if psystem() == "Windows":
        notification.notify(message='LSB текст записан!\nКлюч сохранен в файле: "key.dat"\nНе потеряйте!')
    elif psystem() == "Linux":
        command = f'''notify-send "LSB текст записан!\nКлюч сохранен в файле: "key.dat"\nНе потеряйте!"'''
        os.system(command)

Дальше, в функции decrypt убрал сохранение считанного текста в файл, а добавил его возврат в основной модуль для вывода на экран. Так же добавил обработку ошибки типов:

Python:
    try:
        des_decrypt(''.join(outed), end_key)
    except TypeError:
        return "В файле нет текста или неверный ключ!"
    return des_decrypt(''.join(outed), end_key)

В данном модуле есть функция des_decrypt(text, key). В нее добавил обработку ошибки связанной с неверным ключом:

Python:
    try:
        cipher = Fernet(key.encode())
        result = cipher.decrypt(text.encode())
    except InvalidToken:
        return

Python:
import os.path
from os import system
from platform import system as psystem
from shutil import copy2
from zlib import error

from plyer import notification
from stegano import exifHeader


def write_text_ph(path_image, text_write):
    image_name = f'{os.path.split(path_image)[1].split(".")[0]}_hex.jpg'
    path_image_new = f'{os.path.join(os.getcwd(), image_name)}'

    copy2(path_image, path_image_new)

    with open(path_image_new, 'ab') as ph:
        ph.write(text_write.encode())

    if psystem() == "Windows":
        notification.notify(message='HEX текст записан!')
    elif psystem() == "Linux":
        command = f'''notify-send "HEX текст записан!"'''
        system(command)


def read_text_ph(path_image):
    with open(path_image, 'rb') as ph:
        ph_content = ph.read()
        offset = ph_content.index(bytes.fromhex('FFD9'))
        ph.seek(offset + 2)
        text = ph.read().decode()
        if text == "":
            return 'В картинке нет текста!'
        else:
            return text


def write_text_exif(path_image, path_image_text, text_write):
    exifHeader.hide(path_image, path_image_text, text_write)
    if psystem() == "Windows":
        notification.notify(message='Exif текст записан!')
    elif psystem() == "Linux":
        command = f'''notify-send "Exif текст записан!"'''
        system(command)


def read_text_exif(path_image):
    try:
        result = exifHeader.reveal(path_image).decode()
        return result
    except error:
        return 'В картинке нет текста!'

А на этом, пожалуй, все. Надо добавить, что данный код будет работать как на Windows, так и на Linux. Единственная заморочка, у меня, почему-то, возникла с путями к изображениям, которые QtWidgets.QFileDialog.getOpenFileName возвращает в линуксовом формате с прямым слешем. Потому, я и добавил модуль определения версии ОС, для того, чтобы менять слеши в Windows на обратные. Буду рад, если кто-нибудь подскажет почему так происходит и способ решения данного недоразумения )) Ну и всплывающие сообщения выводятся также по-разному.

Видео работы программы (получилось чуток кривовато, но работу показывает):


Спасибо за внимание! Надеюсь, что данная информация будет кому-нибудь полезна.
 

Вложения

  • stegankav1.zip
    8,1 КБ · Просмотры: 142
  • Нравится
Реакции: semen_rod
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!