Заставляем любой процесс работать с транзакционной NTFS: мой первый шаг к созданию песочницы для Windows

в 10:03, , рубрики: acid, Delphi, native api, ntdll, ntfs, sandboxing, transactions, TxF, windows, Программирование, разработка под windows, системное программирование

TransactionMaster В ядре Windows есть модуль, отвечающий за поддержку группировки файловых операций в некоторую сущность, называемую транзакцией. Действия над этой сущностью изолированы и атомарны: её можно применить, сделав перманентной, или откатить. Очень удобно при установке программ, согласитесь? Мы всегда переходим от одного согласованного состояния к другому, и если что-то идёт не так, все изменения откатываются.

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

Давайте разберёмся, как же это работает, поэкспериментируем с моей программой, и поймём, при чём тут вообще песочницы.

Репозиторий

Для тех, кому не терпится попробовать: TransactionMaster на GitHub.

Теория

Поддержка транзакционной NTFS, или TxF, появилась в Windows Vista, и позволила существенно упростить код, отвечающий за восстановление при ошибках в процессе обновления ПО и самой ОС. Фактически, задачу по восстановлению перенесли на ядро операционной системы, которое стало применять полноценную ACID-семантику к файловым операциям — только попроси.

Для поддержки этой технологии, были добавлены новые API функции, которые дублировали уже имеющуюся функциональность, добавляя один новый параметр — транзакцию. Сама транзакция стала одним из многих объектов ядра ОС, наряду с файлами, процессами и объектами синхронизации. В простейшем случае, последовательность действий при работе с транзакциями заключается в создании объекта транзакции вызовом CreateTransaction, работе с файлами (с использованием таких функций как CreateFileTransacted, MoveFileTransacted, DeleteFileTransacted и им подобных), и применению/откату транзакции с помощью CommitTransaction/RollbackTransaction.

Теперь давайте взглянем на архитектуру этих функций. Мы знаем, что документированный слой API, из таких библиотек как kernel32.dll, не передаёт управление в ядро ОС напрямую, а обращается к нижележащему слою абстракции в пользовательском режиме — ntdll.dll, который уже и производит системный вызов. И вот тут нас ожидает сюрприз: никакого дублирования функций для работы с файлами в контексте транзакций в ntdll, как и в ядре, просто нет.

Слои API

И тем не менее, прототипы этих фукнций из Native API не менялись с незапамятных времён, а значит о том, в контексте какой транзакции выполнять операцию они узнают откуда-то ещё. Но откуда? Ответ заключается в том, что у каждого потока есть специальное поле, в котором хранится дескриптор текущей транзакции. Область памяти, где оно находится, называется TEB — блоком окружения потока. Из известных вещей, там также хранятся код последней ошибки и идентификатор потока.

Таким образом, функции с суффиксом *Transacted устанавливают поле текущей транзакции, вызывают аналогичную функцию без суффикса, а затем восстанавливают предыдущее значение. Делают они это, используя пару функций RtlGetCurrentTransaction/RtlSetCurrentTransaction из ntdll. Код самих функций весьма прямолинеен, за исключением случая с WoW64, о чём будет ниже.

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

Подводные камни

Cамые первые эксперименты показали, что идея работоспособна: Far Manager, которым я пользуюсь вместо проводника Windows, прекрасно переживает подмену транзакций на лету, и позволяет смотреть на мир в их контексте. Но также обнаружились и программы, которые постоянно создают новые потоки для файловых операций. И в первоначальном сценарии это прореха, поскольку отслеживать создание потоков в другом процессе не слишком-то удобно (не говоря уже о том, что "опоздания" здесь критичны). Примером приложения из второго класса является недавно портированный WinFile.

Отслеживающая DLL

К счастью, синхронное отслеживание создания потоков с последующей настройкой для них транзакций совершенно элементарно изнутри целевого процесса. Достаточно внедрить в него DLL, и загрузчик модулей будет вызывать её точку входа с параметром DLL_THREAD_ATTACH каждый* раз при создании нового потока. Реализовав эту функциональность я починил совместимость ещё с доброй дюжиной программ.

* Технически, вызов срабатывает не всегда, и это поведение иногда можно пронаблюдать в интерфейсе моей программы. По большей части, исключениями являются потоки из рабочего пула самого загрузчика модулей. Всё дело в том, что оповещение DLL-библиотек происходит под блокировкой загрузчика, и это значит: загружать новые модули в этот момент нельзя. А потоки загрузчика, как вы понимаете, именно этим и занимаются, распараллеливая доступ к файловой системе. Для подобных случаев предусмотрено исключение: если указать THREAD_CREATE_FLAGS_SKIP_THREAD_ATTACH в качестве флага при вызове NtCreateThreadEx, можно избежать присоединения нового потока к существующим DLL, и, соответственно, взаимных блокировок. Примерно это здесь и происходит.

Запускаем проводник

Осталась третья, последняя категория программ, которые до сих пор падают при попытке заставить их работать внутри транзакции. Одна из этих программ — проводник Windows. Я не могу точно диагностировать проблему, но приложение это сложное, и горячее переключение внутрь транзакции сказывается на нём не очень. Возможно, причина в том, что оно имеет много открытых файловых дескрипторов, часть из которых перестаёт быть действительными в новом контексте. А может это что-то ещё. В подобных ситуациях помогает перезапуск процесса, да так, чтобы он с самого начала работал в транзакции. Тогда никаких несогласованностей возникнуть не должно.

А потому, я добавил в программу возможность запуска новых процессов, для которых транзакция и слежение за новыми потоками настраивается ещё до достижения точки входа, пока процесс приостановлен. И знаете что, оно заработало! Правда, поскольку проводник активно использует объекты COM вне процесса, предпросмотр ломается при перемещении файлов. Но в остальном — всё стабильно.

Что там с WoW64?

Эта подсистема для запуска 32-битных программ на 64-битных системах является крайне удобным инструментом, но необходимость учёта её особенностей часто осложняет системное программирование. Выше я упоминал, что поведение Rtl[Get/Set]CurrentTransaction заметно отличается в случае подобных процессов. Причина этому кроется в том, что потоки в WoW64-процессах имеют целых два блока окружения. Они имеют разные размеры указателя, и их желательно поддерживать в согласованном состоянии, хотя, в случае транзакций, 64-битный TEB имеет приоритет. Когда мы устанавливаем транзакции удалённо, мы должны воспроизвести поведение этих функций. Это не сложно, но забывать об этом не стоит, а подробности можно посмотреть здесь. И последнее, для WoW64 процессов нужна дополнительная 32-битная копия нашей отслеживающей DLL.

Нерешённые проблемы

Вынужден огорчить — самый первый сценарий, что приходит на ум, а именно запуск установщиков программ в этом режиме — пока что не работоспособен. Во первых, не настроен захват дочерних процессов в ту же транзакцию. Здесь есть несколько путей решения, я работаю над этим. Но если приложение создаёт несколько процессов — пользоваться им в сочетании с транзакциями ещё рановато.

Во вторых, особого внимания заслуживает случай с исполняемыми файлами, которых не существует снаружи транзакции. Помнится, был какой-то вирус, который обманывал наивные антивирусы подобным образом: распаковывался внутрь транзакции, запускал себя, а затем откатывал транзакцию. Процесс есть, а исполняемого файла нет. Антивирус мог решить, что сканировать нечего, и проигнорировать угрозу. Здесь тоже нужно поработать над креативными решениями, поскольку, по некоторой причине, NtCreateUserProcess (и, соответственно, CreateProcess) игнорирует текущую транзакцию. Конечно, всегда остаётся NtCreateProcessEx, но с ним ожидается много возни для устранения проблем с совместимостью. Ничего, что-нибудь придумаю.

Причём тут песочницы?

Взгляните на картинку. Здесь три разных программы показывают содержимое одной и той же папки из трёх разных транзакций. Классно, правда?

Взгляд изнутри транзакций

И всё же, моя программа — ни в коем случае не песочница, ей не хватает одной важной детали — границы безопасности. Конечно, это не мешает некоторым компаниям продавать сходные поделки под видом полноценных песочниц, позор им, что я могу сказать. И, несмотря на то, что это кажется совершенно невозможным, — как вообще мы можем запретить программе изменить переменную в своей же памяти, будь мы даже отладчиком? — у меня припасён один восхитительный трюк, который позволит завершить начатое и создать первую известную мне песочницу, которая не будет требовать драйвера, но будет виртуализировать файловую систему. А до тех пор — ждите обновлений, используйте Sandboxie и экспериментируйте с технологией AppContainer. Спасибо за внимание.

Репозиторий проекта на GitHub: TransactionMaster.
Эта же статья на английском.

Автор: diversenok

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js