В ядре Windows есть модуль, отвечающий за поддержку группировки файловых операций в некоторую сущность, называемую транзакцией. Действия над этой сущностью изолированы и атомарны: её можно применить, сделав перманентной, или откатить. Очень удобно при установке программ, согласитесь? Мы всегда переходим от одного согласованного состояния к другому, и если что-то идёт не так, все изменения откатываются.
С тех пор, как я узнал о поддержке такой функциональности, я всегда хотел посмотреть на мир изнутри этих транзакций. И знаете что: я нашёл простой и поистине замечательный метод заставить любой процесс работать внутри файловой транзакции, но поля книги слишком узки для него. В большинстве случаев, для этого не требуются даже административные привилегии.
Давайте разберёмся, как же это работает, поэкспериментируем с моей программой, и поймём, при чём тут вообще песочницы.
Репозиторий
Для тех, кому не терпится попробовать: TransactionMaster на GitHub.
Теория
Поддержка транзакционной NTFS, или TxF, появилась в Windows Vista, и позволила существенно упростить код, отвечающий за восстановление при ошибках в процессе обновления ПО и самой ОС. Фактически, задачу по восстановлению перенесли на ядро операционной системы, которое стало применять полноценную ACID-семантику к файловым операциям — только попроси.
Для поддержки этой технологии, были добавлены новые API функции, которые дублировали уже имеющуюся функциональность, добавляя один новый параметр — транзакцию. Сама транзакция стала одним из многих объектов ядра ОС, наряду с файлами, процессами и объектами синхронизации. В простейшем случае, последовательность действий при работе с транзакциями заключается в создании объекта транзакции вызовом CreateTransaction
, работе с файлами (с использованием таких функций как CreateFileTransacted
, MoveFileTransacted
, DeleteFileTransacted
и им подобных), и применению/откату транзакции с помощью CommitTransaction
/RollbackTransaction
.
Теперь давайте взглянем на архитектуру этих функций. Мы знаем, что документированный слой API, из таких библиотек как kernel32.dll
, не передаёт управление в ядро ОС напрямую, а обращается к нижележащему слою абстракции в пользовательском режиме — ntdll.dll
, который уже и производит системный вызов. И вот тут нас ожидает сюрприз: никакого дублирования функций для работы с файлами в контексте транзакций в ntdll, как и в ядре, просто нет.
И тем не менее, прототипы этих фукнций из 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.
Эта же статья на английском.