Прочитал я как то раз статью о том, как спрятать фото в другом фото, вот ее перевод. Статья довольно короткая и задумка описанная в ней никакой новизны не несет. И не своей простотой привлекла меня описанная идея, а довольно широким кругом возможных расширений.
Коротко излагаю суть идеи: в одно фото (PNG) можно встроить другое фото или совсем не фото, а чего сами хотите. Реализация проста: каждый младший бит в RGB матрице несет полезную нагрузку, собрав их вместе, вы получите массив байтов, который хотели спрятать, а изменение в исходном изображении не ощутимо человеческим глазом. Кому интересно, ознакомьтесь с исходной статьей, ну а в этой статье мы попробуем рассмотреть возможные юзкейсы и улучшения.
Реализация выполнена на языке GO и доступна на моем гитхабе. Там же найдете руководство по эксплуатации с примерами запуска с разными ключами. А в папке demo рабочая демка (правда приложение все равно придется сначала скомпилировать). Но, если компилировать лень, то не беда, для ленивых я опубликовал приложение, как веб-сервис. На этой страничке вы можете попробовать спрятать шифрованное послание в своем PNG и расшифровать его.
Итак, теперь об улучшениях исходной идеи. Для того, чтобы было интереснее читать, я буду приводить примеры использования этого приложения двумя секретными агентами, находящимися в разных странах. Они пытаются передать друг-другу защищенные сообщения по открытым каналам связи. А мы им в этом поможем. Ну а тем, кто нетерпелив, я сразу скажу, что плюшки вот такие:
- AES шифрование;
- XOR ключом длиной равной длине сообщения;
- Цифровая подпись.
Нибблы
Но сначала давайте разберемся, как отцепить от исходного массива байтов по одному биту и спрятать в каждом байте каждого RGB вектора в изображении?
Сама концепция проста и понятна: берем первый бит, прячем в R первого вектора RGB, берем второй бит, прячем в G первого вектора RGB и т.д. На рисунке мы видим — в верхней части идет массив данных, который мы хотим спрятать, а в нижней — части RGB изображения. Каждый младший бит мы заменяем на бит данных и не паримся — изменения настолько незначительны, что глазом не отличить. Альфа-канал я не нарисовал умышленно — в нем мы ничего не прячем, потому что палево =).
Для реализации задумки мы “пилим” исходные данные на нибблы по три бита в каждом. Каждый ниббл будет целиком ложится на RGB вектор. Таким образом, в R мы заменим младший бит на nibble & 1, в G заменим младший бит на nibble & 2, а в B заменим младший бит на nibble & 4. Альфа-канал оставляем без изменений.
Под спойлером код, который пилит данные на нибблы.
package nibbles
type nibble struct {
mask int16
size int
current int
data []byte
}
const (
MaxNibbleSize = 6
MinNibbleSize = 1
DefaultNibbleSize = 4
bitsInByte = 8
)
func New(size int, data []byte) *nibble {
var mask int16
if size < MinNibbleSize || size > MaxNibbleSize {
size = DefaultNibbleSize
}
for i := 0; i < size; i++ {
mask |= 1 << i
}
return &nibble{
mask: mask,
size: size,
data: data,
}
}
func (n *nibble) Next() (byte, bool) {
byteIndex := (n.current * n.size) / bitsInByte
if byteIndex >= len(n.data) {
return 0, false
}
bitIndex := (n.current * n.size) % bitsInByte
n.current++
word := int16(n.data[byteIndex])
if len(n.data) > byteIndex+1 && bitIndex > bitsInByte-n.size {
word |= int16(n.data[byteIndex+1]) << bitsInByte
}
result := (word >> bitIndex) & n.mask
return byte(result), true
}
func Convert(data []byte, size int) (result []byte) {
var (
filledBits int
bitBuffer int16
)
for _, b := range data {
bitBuffer |= int16(b) << filledBits
filledBits += size
if filledBits >= bitsInByte {
result = append(result, byte(bitBuffer&0xff))
bitBuffer = bitBuffer >> bitsInByte
filledBits -= bitsInByte
}
}
if filledBits >= size {
result = append(result, byte(bitBuffer&0xff))
}
return
}
Итоговый файл сохраняем, как изображение PNG. И теперь, когда все готово для реализации основной идеи, давайте приступим к улучшениям и будем отталкиваться от возникающих в процессе эксплуатации потребностей.
AES шифрование
Агент Маша хочет передать агенту Вите сообщение. Она договаривается со своим другом (который живет в другой стране) о том, что в определенный день и определенный час выложит в сети фотографию внутри которой скрыто послание. Но есть проблема: агенты, ее прослушивающие, узнают об этом ходе и получают файл, анализируют его и восстанавливают исходное сообщение. Почему бы ей не зашифровать сообщение?
Давайте поможем им и добавим немного симметричного шифрования AES. В GO шифрование этим алгоритмом реализуется пакетом crypto/aes. Достаточно просто создать шифрующий блок, вызвав функцию aes.NewCipher(key). И теперь мы можем нарезать данные блоками и применить к каждому из них метод Encrypt.
Как видите, полезная нагрузка шифруется поблочно и, если мы попытаемся зашифровать фотографию, то может выйти так, что очертания на шифрованной картинке все-таки останутся, хоть и потеряются цвета. Так что для увеличения криптостойкости мы применим режим распространяющегося сцепления блоков — это когда первый блок шифруется нашим ключом, а в каждый последующий подмешивается шифротекст, полученный на предыдущем шаге. О синхронных шифрах AES можно почитать тут.
О ключах Маша и Витя должны договориться заранее: при личной встрече им необходимо обменяться несколькими ключами, один основной и несколько резервных на случай, если основной будет скомпрометирован. Очень важно, чтобы эти ключи не просачивались в публичную сеть!
Под спойлером шифрующая функция.
func EncryptDataAES(data []byte, key []byte) ([]byte, error) {
aesEncoder, err := newAES(key)
if err != nil {
return nil, err
}
chainSize := aesEncoder.blockSize()
// первым блоком будет блок информации о размере исходного сообщения
// т.к. мы собираемся выровнять его по chainSize
infoBlock := newSizeInfoChunk(len(data), chainSize)
data = alignDataBy(data, chainSize)
encrypted := make([]byte, len(infoBlock)+len(data))
// шифруем блок с информацией
if err = aesEncoder.encode(encrypted[0:len(infoBlock)], infoBlock); err != nil {
return nil, err
}
// шифруем все сообщение
for n := 0; n < len(data)/chainSize; n++ {
var dst, src = encrypted[(n+1)*chainSize : (n+2)*chainSize], data[n*chainSize : (n+1)*chainSize]
if err = aesEncoder.encode(dst, src); err != nil {
return nil, err
}
}
return encrypted, nil
}
type encoder struct {
cipher cipher.Block
initVc []byte
}
func newAES(key []byte) (*encoder, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
enc := encoder{
cipher: block,
initVc: make([]byte, block.BlockSize()),
}
return &enc, nil
}
func (e *encoder) blockSize() int {
. . .
func (e *encoder) encode(dst, src []byte) (err error) {
. . .
func (e *encoder) decode(dst, src []byte) (err error) {
. . .
Длина ключа равна длине сообщения
Шифр AES — дело хорошее, но говорят, что самый криптостойкий ключ — это ключ равный по длине исходному сообщению. Прослушивающие агенты могут проанализировать достаточно сообщений, чтобы по первому блоку (в который не подмешивается шифротекст) получить ключ.
Маша и Витя не дураки, они используют двойное шифрование: сразу после того, как сообщение зашифровано алгоритмом AES, они применяют простой XOR с ключом равным по длине исходному сообщению. Мы добавляем эту возможность в наше приложение: ключом будет какая-нибудь другая фотография (или любой файл), который тоже можно передать по публичным каналам. Дата и время следующей передачи такого ключа Маша прикрепляет к каждому сообщению. Очень важно для каждого следующего сообщения применять новый ключ. Если прослушивающие агенты не смогли расшифровать сообщение — каждый следующий раз ключ будет меняться, что затрудняет криптоанализ.
Теперь немного об энтропии. Ежу понятно, что в качестве ключа необходимо использовать “случайные данные”, а в нашей логике описано использование изображения, которое может содержать невысокую энтропию. Ничего страшного, мы добавим в алгоритм нашей программы функцию moreStrongKey(key []byte) []byte которая “замесит” биты в файле так, что они станут похожи на случайные. Функция скалярная и при выполнении с одним и тем же файлом дает один и тот же массив перемешанных данных.
Под спойлером функция шифровки/расшифровки.
func EncryptDecryptData(data []byte, key []byte) error {
key = moreStrongKey(key)
if len(key) < len(data) {
return ErrKeyShortedThanData
}
for i, d := range data {
data[i] = d ^ key[i]
}
return nil
}
func moreStrongKey(key []byte) []byte {
const (
salt = 170
bufLen = 16
)
var (
buf [bufLen * 2]byte
unf int
out []byte
)
flush := func() {
unf = 0
h := md5.Sum(buf[:])
out = append(out, h[:]...)
}
for i, b := range key {
r := key[len(key)-i-1]
p := i % bufLen
buf[p*2] = b
buf[p*2+1] = b ^ r ^ salt
unf++
if (i+1)%bufLen == 0 {
flush()
}
}
if unf > 0 {
flush()
}
return out
}
Маша и Витя шифруют свои сообщения двумя каскадами меняя XOR-ключ с каждым новым сообщением. И мы почти уверены, что никто их не подслушивает. Но есть еще один случай, когда всех примененных хитростей будет недостаточно.
Цифровая подпись
Теперь о плохом: Машу накрыли. AES ключи оказались в руках злоумышленников и с помощью них удалось расшифровать какие-то сообщения! Но в последний момент ей удалось сбежать и теперь она должна сообщить Вите, что это провал.
“Не доверяй никому” пишет она в последнем сообщении и выкладывает его в условленное время. Но вот незадача. Теперь злоумышленники, воспользовавшись ключами, могут выложить свое сообщение и полностью захватить их канал связи. Как ей доказать, что ее сообщение истинное?
Давайте добавим в наш код возможность ставить цифровую подпись на сообщение с помощью асинхронных ключей? Вот тут можно немного узнать о цифровых подписях. Мы используем ключи RSA и научим наше приложение генерировать такие ключи, хотя подойдут и свои.
Очень хорошо, что Маша держит ключи шифрования отдельно от ключей для подписи в своем секретном месте. В асинхронных ключах есть одно очень положительное свойство: публичный ключ, с помощью которого цифровая подпись проверяется, можно передавать по открытым каналам связи, а сама подпись выполняется с помощью приватного ключа, который невозможно вычислить (за разумный срок), имея на руках публичный ключ. Несколько сообщений назад Маша передала Вите новый публичный ключ для проверки сообщений и теперь, даже если это сообщение расшифруют, все, что можно будет сделать с этим ключом — это проверить достоверность сообщения.
Маша подписывает новое сообщение в котором говорит о провале и подписывает его приватным ключом, теперь она уверена, что ее сообщению Витя будет доверять и злоумышленникам никак не удастся скомпрометировать их канал связи.
Под спойлером функции цифровой подписи и ее проверки.
func SignData(data []byte, privateKey string) ([]byte, error) {
private, err := getPrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("cannot parse private key: %w", err)
}
sign, err := rsa.SignPSS(rand.Reader, private, signHashFn, hashData(data), nil)
if err != nil {
return nil, fmt.Errorf("error while signing: %w", err)
}
return sign, nil
}
func SignVerify(data, sign []byte, publicKey string) error {
public, err := getPublicKey(publicKey)
if err != nil {
return fmt.Errorf("cannot parse public key `%s`: %w", publicKey, err)
}
err = rsa.VerifyPSS(public, signHashFn, hashData(data), sign, nil)
if err != nil {
return fmt.Errorf("error while sign checking: %w", err)
}
return nil
}
Заключение
В заключении хочу поблагодарить читателя за то, что он помог Маше и Вите установить секретный канал связи в публичных сетях. Но как вы понимаете, это просто маленькая игра. В действительности все гораздо сложнее и я тут много о чем умолчал. Например, если Маша прячет секретные данные в картинке PNG, то это палево. Ну согласитесь, если вы выкладываете фотографии в сети, то это наверняка JPEG?
Однако такого приложения явно хватит, чтобы поиграть со своим другом (или подругой) в секретных агентов и просто ощутить, как можно защищать каналы связи в публичных сетях.
Как я уже сказал выше, код можете почитать на моем гитхабе. В каталоге crypt найдете все три описанных алгоритма и еще две хеш-функции — одна для усиления XOR-ключа, а другая для формирования отпечатка для цифровой подписи.
В папке demo найдете мою PNG фотографию с зашифрованным внутри посланием, необходимые для расшифровки ключи прошиты в decode.sh файле, который позволит получить расшифрованное послание и проверить его цифровую подпись.
В папке carrier лежит код, который позволяет разбить сообщение на биты и встроить их в PNG картинку. А разбивать данные на маленькие кусочки битов, которые легко встраиваются в RGB вектор, нам позволяет код, который лежит в папке nibbles. Так что тут все очень интересно.
А те, у кого нет компилятора GO или кому лень, можете попробовать мой онлайн сервис стеганографии, о нем я тоже уже сказал в первой части этой статьи. Ну, а мы с Машей и Витей прощаемся с вами, надеюсь, не надолго.
Комментарии (3)
titbit
21.06.2022 16:53+1В стеганографии самое главное это качественно спрятать. Криптография и подписи это тоже понятно и обязательно, но по этой части информации намного больше. А вот сама стеганография в статьях обычно ограничивается играми с младшими битами в картинках и звуке. А хотелось бы услышать и про другие методы сокрытия данных, но почему-то их почти не описывают.
alexs963
Давайте еще rarjpeg вспомним.
devalio Автор
но ведь это совсем другая история
rarjpeg просто приклеивал архив после завершающего маркера JPEG файла. Даже если вы зашифруете сообщение, определить, что к файлу "пришиты" какие-то данные легко. вот тут (https://habr.com/ru/company/infowatch/blog/337084/) есть статья в которой описываются алгоритмы обнаружения таких данных
в своей статье я хотел показать, что "спрятать" данные - это только половина дела. необходимо позаботиться об их защите как минимум с помощью шифра или цифровой подписи. и показал юзкейсы