Как стать автором
Обновить

Исполняемые PNG: запускаем изображения как программы

Время на прочтение 6 мин
Количество просмотров 9.2K
Автор оригинала: Daniel James Harper

Это изображение и одновременно программа

Несколько недель назад я читал о PICO-8, выдуманной игровой консоли, обладающей большими ограничениями. Особо мой интерес привлёк новаторский способ распространения её игр — кодирование их изображение PNG. В него включается всё — код игры, ресурсы, вообще всё. Изображение может быть любым: скриншоты из игры, крутой арт или просто текст. Чтобы загрузить игру, нужно передать изображение на вход программы PICO-8, и можно начинать играть.

Это заставило меня задуматься: наверно, будет круто, если получится сделать то же самое с программами в Linux? Нет! Я понимаю, вы скажете, что это тупая идея, но я всё равно ею занялся, и ниже представлено описание одного из тупейших проектов, над которыми я работал в этом году.

Кодирование


Я не вполне понимаю, что именно делает PICO-8, но предположу, что она, вероятно, применяет техники стеганографии, скрывающие данные в «сырых» байтах изображения. В Интернете есть много ресурсов с объяснением принципов работы стеганографии, но самая её суть довольно проста: изображение, в котором вы хотите скрыть данные, состоит из байтов и из пикселей. Пиксели состоят из трёх значений красного, зелёного и синего (RGB), представленных в виде трёх байтов. Чтобы скрыть данные («полезную нагрузку»), мы, по сути, «смешиваем» байты полезной нагрузки с байтами изображения.

Если просто заменить байты изображения байтами полезной нагрузки, то в картинке появятся области с искажёнными цветами, потому что они не будут совпадать с цветами исходного изображения. Хитрость заключается в том, чтобы быть как можно незаметнее, скрыть информацию на ровном месте. Это можно сделать, распределив байты полезной нагрузки по байтам изображения-прикрытия, пряча их в младших битах. Другими словами, вносить небольшие изменения в значения байтов, чтобы изменения цветов были недостаточно сильными для восприятия человеческим глазом.

Допустим, наша полезная нагрузка — это буква H, представляемая в двоичном коде как 01001000 (72), а изображение содержит набор чёрных пикселей.


Биты вводимых байтов распределены по 8 выходным байтам благодаря сокрытию их в младшем бите

На выходе мы получим несколько пикселей, которые будут чуть менее чёрными, чем ранее, но можно ли заметить разницу?


Цвета пикселей немного подкорректированы.

Возможно, чрезвычайно опытный знаток цветов сможет увидеть разницу, но в реальной жизни такие крошечные сдвиги заметны только машине. Чтобы получить нашу сверхсекретную букву H, достаточно просто считать 8 байт получившегося изображения и собрать их снова в 1 байт. Очевидно, что сокрытие единственной буквы — дурацкая затея, но масштаб передачи можно свободно увеличивать. Допустим передать сверхсектерное предложение, копию «Войны и мира», ссылку на Soundcloud, компилятор Go — единственным ограничением будет количество доступных в изображении байтов, потому что их должно быть как минимум в 8 раз больше, чем во вводимой информации.

Сокрытие программ


Итак, вернёмся к нашей идее исполняемых файлов Linux в изображении. Если рассматривать исполняемые файлы просто как байты, то понятно, что их можно скрыть в изображениях, прямо как это делает PICO-8.

Прежде чем реализовывать это, я решил написать собственную библиотеку стеганографии и инструмент, поддерживающий кодирование и декодирование данных в PNG. Разумеется, существует множество уже готовых стеганографических библиотек и инструментов, но я лучше всего учусь, когда делаю что-то своё.

$ stegtool encode \
--cover-image htop-logo.png \
--input-data /usr/bin/htop \
--output-image htop.png
$
$ echo "Super secret hidden message" | stegtool encode \
--cover-image image.png \
--output-image image-with-hidden-message.png
$ stegtool decode --image image-with-hidden-message.png
Super secret hidden message

Поскольку всё написано на Rust, было совсем не сложно скомпилировать это на WASM, поэтому можете поэкспериментировать самостоятельно.

Итак, теперь мы можем встраивать данные, добавляя исполняемые файлы в изображения. Но как нам их запускать?

Запускаем изображение


Проще всего было бы всего лишь запустить указанный выше инструмент, выполнить decode данных в новый файл, изменить права с помощью chmod +x, а затем запустить его. Это сработает, но будет слишком скучно. Я хотел сделать нечто в стиле PICO-8 — мы передаём некой сущности изображение PNG, и она делает всё остальное.

Однако, как оказалось, нельзя просто загрузить в память произвольный набор байтов и приказать Linux перейти к нему… по крайней мере, напрямую. Однако можно воспользоваться некоторыми простыми трюками, чтобы провернуть это.

memfd_create


После прочтения этого поста стало очевидно, что можно создать файл в памяти и пометить его как исполняемый.

Разве не здорово было бы просто взять блок памяти, записать туда двоичные данные и запустить их без патчинга ядра, перезаписи execve(2) в userland или загрузки библиотеки в другой процесс?

В этом способе используется системный вызов memfd_create(2) для создания файла в пространстве имён /proc/self/fd вашего процесса и загрузки нужных вам данных в него при помощи write. Я потратил довольно много времени, разбираясь в привязках libc с Rust, чтобы всё это заработало, и мне сложно было понять передаваемые типы данных, документация по этим привязкам Rust не особо помогла.

Однако мне удалось получить нечто работающее.

unsafe {
    let write_mode = 119; // w
    // create executable in-memory file
    let fd = syscall(libc::SYS_memfd_create, &write_mode, 1);
    if fd == -1 {
        return Err(String::from("memfd_create failed"));
    }

    let file = libc::fdopen(fd, &write_mode); 

    // write contents of our binary
    libc::fwrite(
        data.as_ptr() as *mut libc::c_void, 
        8 as usize,
        data.len() as usize,
        file,
    );
}

Вызова /proc/self/fd/<fd> как дочернего процесса от создавшего его родителя достаточно для запуска вашего двоичного файла.

let output = Command::new(format!("/proc/self/fd/{}", fd))
    .args(args)
    .stdin(std::process::Stdio::inherit())
    .stdout(std::process::Stdio::inherit())
    .stderr(std::process::Stdio::inherit())
    .spawn();

Имея на руках эти строительные блоки, я написал программу pngrun для запуска изображений. По сути, она делает следующее:

  1. Принимает от стеганографического инструмента изображение, в которое встроен наш двоичный файл, и аргументы
  2. Декодирует его (т.е. извлекает и собирает заново байты)
  3. Создаёт файл в памяти при помощи memfd_create
  4. Помещает байты двоичного файла в файл в памяти
  5. Вызывает файл /proc/self/fd/<fd> как дочерний процесс, передавая все аргументы из родительского.

То есть можно запустить это следующим образом:

$ pngrun htop.png
<htop output>
$ pngrun go.png run main.go
Hello world!

После завершения pngrun файл в памяти уничтожается.

binfmt_misc


Однако каждый раз вводить pngrun надоедает, поэтому последним простым трюком в этом бессмысленном проекте стало использование binfmt_misc — системы, позволяющей «исполнять» файлы на основании их типа файла. Думаю, в первую очередь эта функция разрабатывалась для интерпретаторов/виртуальных машин наподобие Java. Вместо ввода java -jar my-jar.jar достаточно ввести ./my-jar.jar и при этом будет вызван процесс java для запуска JAR. Однако при этом файл my-jar.jar сначала должен быть помечен как исполняемый.

То есть добавить в binfmt_misc запись для pngrun, чтобы получить возможность запускать любые png с установленным флагом x, можно так:

$ cat /etc/binfmt.d/pngrun.conf
:ExecutablePNG:E::png::/home/me/bin/pngrun:
$ sudo systemctl restart binfmt.d
$ chmod +x htop.png
$ ./htop.png
<output>

В чём смысл проекта


Ну, на самом деле особого смысла нет. Меня соблазнила идея о создании изображений PNG, которые могли бы запускать программы, и я её немного развил, однако проект всё равно был интересным. Есть что-то восхитительное в возможности распространения программ в виде изображений — вспомните забавные картонные коробки с программным обеспечением для PC с графическим оформлением на передней части. Почему бы не вернуть их обратно? (Хотя на самом деле не стоит.)

Проект очень туп и имеет множество изъянов, из-за которых становится совершенно бессмысленным и непрактичным. Самый главный изъян — для его работы на машине должна быть дурацкая программа pngrun. Однако я заметил некоторые странности в программах наподобие clang. Я закодировал её в этот забавный логотип LLVM, и хотя она работает нормально, при попытке компилирования происходит сбой.


$ ./clang.png --version
clang version 11.0.0 (Fedora 11.0.0-2.fc33)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /proc/self/fd
$ ./clang.png main.c
error: unable to execute command: Executable "" doesn't exist!

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

Почему ещё этот проект туп


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

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

Вывод


Вероятно, это самый тупой проект среди тех, над которыми я работал этот год, но он определённо был забавным, я узнал о стеганографии, memfd_create, binfmt_misc и ещё немного поигрался с Rust.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+16
Комментарии 8
Комментарии Комментарии 8

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн