Создание прокси-dll для проверок эксплуатации dll hijack

    Когда я исследую безопасность ПО, то одним из пунктов проверки является работа с динамическими библиотеками. Атаки типа DLL hijack («подмена dll» или «перехват dll») встречаются очень редко. Скорее всего, это связано с тем, что и разработчики Windows добавляют механизмы безопасности для предотвращения атак, и разработчики софта аккуратнее относятся к безопасности. Но тем интереснее ситуации, когда целевое ПО уязвимо.

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

    Чтобы загрузка библиотеки прошла корректно, необходимо выполнить ряд условий: битность исполняемого файла и библиотеки должна совпадать и, если библиотека загружается при старте приложения, то dll должна экспортировать все те функции, которые это приложение ожидает импортировать. Часто одного импорта мало — очень желательно, чтобы приложение продолжило свою работу после загрузки dll. Для этого необходимо, чтобы у подготовленной библиотеки функции работали так же, как и у оригинальной. Реализовать это проще всего, просто передавая вызовы функций из одной библиотеки в другую. Вот именно такие dll называют прокси-dll.



    Под катом будет несколько вариантов создания таких библиотек — как в виде кода, так и утилитами.

    Небольшой теоретический обзор


    Загрузка библиотек чаще происходит с помощью функции LoadLibrary, в которую передается имя библиотеки. Если вместо имени передать полный путь, то приложение попытается загрузить именно указанную библиотеку. Например, вызов LoadLibrary(“C:\Windows\system32\version.dll”) приведет к загрузке именно указанной dll. Или, если библиотека не будет существовать, то не будет загружена.

    Немного занудства
    Если в приложение уже загружена некоторая dll, то повторно она не будет загружаться. Учитывая, что именно version.dll загружается при старте почти любого exe-файла, то на самом деле вызов выше реально ничего не загрузит. Но мы все же рассматриваем общий случай, рассматривайте пример как вызов некоторой абстрактной библиотеки.

    Совсем другое дело, если написать LoadLibrary(“version.dll”). В обычной ситуации результат будет ровно такой же, как в предыдущем случае — загрузится C:\Windows\system32\version.dll, но не все так просто.

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

    1. Папка с исполняемым файлом
    2. Папка C:\Windows\System32
    3. Папка C:\Windows\System
    4. Папка C:\Windows
    5. Папка, установленная как текущая для приложения
    6. Папки из переменной окружения PATH

    Еще немного занудства
    При запуске 32-битных приложений в 64-битной системе все обращения C:\Windows\system32 будут пробрасываться к C:\Windows\SysWOW64. Это просто для точности описания, с точки зрения атакующего разница не особо важна.

    При запуске exe-файла ОС загружает все библиотеки из секции импорта файла. В общем смысле можно считать, что ОС принуждает файл к вызову LoadLibrary, передавая все те имена библиотек, которые написаны в секции импорта. Поскольку в 99,9% случаев там именно имена, а не пути, то при старте приложения все загружаемые библиотеки будут искаться в системе.

    Из списка мест поиска dll реально нам важны два пункта — 1 и 6. Если мы подложим version.dll в ту же папку, откуда запускается файл, то вместо системного будет загружен именно подложенный. Такая ситуация практически не встречается, поскольку, если есть возможность подложить библиотеку, то, скорее всего, есть возможность и заменить сам исполняемый файл. Но все же такие ситуации возможны. Например, если исполняемый файл находится в доступной для записи папке и является сервисом с автостартом, то его нельзя изменить пока сам сервис работает. Или запускаемый файл перед стартом проверяется извне по контрольной сумме, то заменять файл все равно не вариант. А вот положить библиотеку рядом — будет вполне реально.

    Возможно, нельзя создавать файлы рядом с исполняемы файлом, но можно создавать папки. В такой ситуации может сработать механизм WinSxS redirect (aka “DotLocal”).

    Кратко о DotLocal
    В манифесте файла может быть прописана зависимость от библиотеки конкретной версии. В таком случае при старте исполняемого файла (например, пусть это будет application.exe) ОС проверит существование папки с именем application.exe.local в той же папке, что и сам файл. В этой папке должна быть вложенная папка со сложным именем типа amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.9600.19291_none_6248a9f3ecb5e89b внутри которой уже библиотека comctl32.dll. Имя библиотеки и информация для имени папки должна быть указана в манифесте, здесь же просто пример из первого попавшегося процесса. Если папок или файла не будет, то библиотека будет взята из C:\Windows\WinSxS. В примере — C:\Windows\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.9600.19291_none_6248a9f3ecb5e89b\comctl32.dll.

    Но и это скорее исключение, нежели правило. А вот ситуации, когда поиск dll доходит до 6 номера в списке — вполне реальны. Если приложение попытается загрузить dll, которой нет в системе или рядом с файлом, то все поиски будут доходить до 6 пункта, в котором, потенциально, могут оказаться доступные для записи папки.

    Например, типовая установка Python чаще всего происходит в папку C:\Python (или близкую). Сам установщик питона предлагает добавить свои папки в системную переменную PATH. В итоге имеем хороший плацдарм для начала атаки — папка доступна для записи всем пользователям и любая попытка загрузить несуществующую библиотеку дойдет до поиска в путях из PATH.

    Теперь, когда теория пройдена, рассмотрим создание полезной нагрузки — самих прокси-библиотек.

    Первый вариант. Честная прокси-библиотека


    Начнем с относительно простого — сделаем честную прокси-библиотеку. Честность в данном случае подразумевает, что все функции в dll будут прописаны явно, и для каждой функции будет написан вызов функции с тем же именем из оригинальной библиотеки. Работа с такой библиотекой будет полностью прозрачна для вызываемого кода: если тот вызывает некоторую функцию, то он получит корректный ответ, результат и все, что там побочно должно произойти.

    Вот ссылка на готовый пример (github) библиотеки version.dll.

    Основные моменты кода:

    • Все прототипы функций из таблицы экспорта оригинальной библиотеки честно описаны.
    • Загружается оригинальная библиотека и все вызовы наших функций пробрасываются в нее.

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

    Второй вариант. Упрощаем написание кода


    Когда имеешь дело с библиотекой типа version.dll, где таблица импорта небольшая, всего 17 функций, и прототипы простые, то честная прокси-библиотека — хороший выбор.



    А вот если прокси для библиотеки, например, bcrypt, то все сложнее. Вот ее таблица импорта:



    57 функций! Причем вот пара примеров прототипов:




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

    Упростить код можно, если немного схитрить с функциями. Объявим все функции в библиотеке как __declspec(naked), а в теле — код на ассемблере, который просто сделает jmp на функцию из оригинальной библиотеки. Это позволит нам не использовать длинные прототипы, а поставить везде простые объявления без параметров вида:

    void foo()

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

    Пример (github) библиотеки version.dll с таким подходом.

    Основные моменты:

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

    Удобно и корректной работой приложения и тем, что даже большое количество функций легко описывается, благодаря макросам. Неудобно тем, что довольно неожиданные грабли в x64. Visual Studio (где-то, начиная с 2012, если я правильно помню) запрещает в 64-битном коде использовать naked и asm-вставки. При написании прокси «с нуля», необходимо для каждой функции проконтролировать, что она описана в def-файле, что загружается оригинал и описано тело функции.

    Третий вариант. Выкидываем тело вообще


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

    void nop() {}

    Такая библиотека будет загружена приложением, но не будет работать. При вызове любой из функций будет, скорее всего, порван стек или случится еще какая-то гадость. Но это не всегда и плохо — если, например, цель dll-инъекции просто запустить код с нужными правами, то достаточно исполнить полезную нагрузку из DllMain прокси-библиотеки и тут же тихо завершить работу приложения. В таком случае до реального вызова функций дело не дойдет и ошибок-падений не появится.

    Пример на гитхабе, опять для version.dll.

    Основные моменты кода:

    • Все функции из def-файла ссылаются на одну nop-функцию.

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

    Четвертый вариант. Возьмем готовые утилиты


    Писать dll это хорошо, но не всегда удобно и не очень быстро, поэтому стоит рассмотреть автоматизированные варианты.

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

    Для эксплуатации dll hijack мы добавим еще один dll hijack.



    Сделать это относительно просто. Скопируем библиотеку, прокси которой хотим сделать, и добавим в таблицу импорта этой копии какую-то dll с произвольной функцией. Теперь загрузка пойдет по цепочке — при старте исполняемого файла будет загружена прокси-dll, которая сама загрузит указанную библиотеку.

    «Хей, ты же заменил загрузку одной библиотеки другой. В чем смысл? Все равно надо будет кодить dll!». Все правильно, но смысл все же есть. Теперь к библиотеке с полезной нагрузкой будет меньше требований. Имя можно задать любое, главное экспортировать всего одну функцию, у которой может быть любой прототип. Главное имя библиотеки и функции вписать в таблицу импорта.

    А библиотека с полезной нагрузкой может быть одна на все случаи жизни.

    Модифицировать таблицу импорта можно многими редакторами PE, например CFF explorer или pe-bear. Для себя я написал небольшую утилиту на C#, которая правит таблицу без лишних телодвижений. Исходники на гитхабе, бинарь в разделе Release.

    Заключение


    В статье я постарался раскрыть основные способы создания прокси-dll, которыми пользовался сам. Осталось только рассказать, как защищаться.

    Универсальных рекомендаций не так много:

    • Не храните исполняемые файлы, особенно запускаемые с высокими правами, в папках доступных для записи пользователям.
    • Лучше сначала найти и проверить существование библиотеки, прежде чем делать LoadLibrary.
    • Посмотрите на существующие способы защиты, доступные в ОС. Например, в Windows 10 можно задать флаг PreferSystem32 чтобы поиск dll начинался не с папки с исполняемым файлом, а с system32.

    Спасибо за внимание, буду рад услышать вопросы, пожелания, предложения и комментарии.

    UPD: По советам комментаторов напоминаю о том, что выбирать библиотеку нужно аккуратно и внимательно. Если бибилиотека входит в список KnownDlls или имя похоже на MinWin (ApiSetSchema, api-ms-win-core-console-l1-1-0.dll — вот это вот все), то скорее всего перехватить ее не удастся из-за особенносей обработки таких dll в ОС.
    Перспективный мониторинг
    Компания

    Похожие публикации

    Комментарии 21

      +2
      Знание порядка загрузки помогло мне однажды, когда надо было подсунуть к SFTP плагину для Total Commander правильную версию libssh библиотеки.
        +2
        Собственно, это и есть правильное применение технологии — подложить конкретную библиотеку для конкретного приложения.
        +2
        Есть еще один довольно действенный вариант защиты:
        Если разработчик загружает свою dll (о которой все известно и которая может меняться только этим разработчиком), то можно считать md5 этой dll'ки и сравнивать с заранее известным значением. Таким образом, если попытаться подменить dll с помощью dll-прокси, то программа это заметит
          +2
          С одной стороны метод хороший, но довольно сложно его реализовать правильно. Дело в том, что если загрузка идет через секцию импорта, то проверять и считать что-либо уже поздно — библиотека уже загружена и выгружать ее бесполезно — код уже выполнен. Если загрузка происходит ручками, то у нас появлются варианты: можно намеренно испортить dll и тем самым сломать логику приложения, но что хуже, потенциально можно поставить OpLock на файл и подменить его после доступа на чтение. Получится, что сначала у файла посчитают контрольную сумму и она будет корректной, а загрузит приложение уже подмененный файл.
            +2
            А не поделитесь идеями, как правильно реализовать данный метод защиты?
            Интересует не намеренное ломание логики приложения, т.к. если есть возможность подменить DLL, то просто сломать можно простым ее переименованием. Интересует защита именно проверкой загружаемой библиотеки.
            К слову у DLL прокси, после реализации всех экспортных методов, с помощью мусорного кода, можно и MD5 нужный подобрать. Тут лучше 2 хеша разных сразу проверить.
              +1
              Я бы решал эту задачу используя механизмы безопасности Windows — загрузка библиотек только из папки на которой явно прописаны допустимые права и вместо проверки хэшей, сразу бы сделал проверку подписи PE-файла, благо к этому довольно внятное АПИ, хоть и не без подводных камней. Если запускать сервис, то я вижу лучшим решением скопировать его в C:\Windows\system32 — стандартные библиотеки защищены от переписывания механизмами защиты ОС и если там можно будет подменять библиотеки, то у нас проблемы посерьезнее, чем внедрении dll в сервис.
          +3

          Вы между тем забыли упомянуть о KnownDLLs в который внесены основные системные dll-ки. Так что трюк с version.dll не пройдёт. А ещё есть CWDIllegalInDllSearch который тоже меняет порядок поиска dll-ок… Это правда редко кем-либо настраивается, скорее в корпоративных окружениях.

            +1
            Да, спасибо, что упомянули список KnownDLLs и ключ CWDIllegalInDllSearch. Они слишком редко встречаются в боевых условиях, поэтому не стал заострять на них внимание.
            Но все же, version.dll не входит в список KnownDLLs (по крайней мере на ближайших доступных мне машинах). И инъекция отлично работает (на гитхабе выложены билды dll, опыт ставится легко).



              +2
              Странно, инжектился в один процесс, как раз хотел через version.dll, но что-то пошло не так, и не получилось, и почему-то отложилось, что он по умолчанию в KnownDLLs… Странно. Ну да ладно. Проверил, вроде действительно нету.
            0
            del
              0
              Жесть. Process Monitor справляется с задачей гораздо проще и быстрее.
                +1
                Process Monitor только найдет возможность загрузки, а dll он за нас не сделает.
                +2

                Как уже написали выше, про KnownDLLs хорошо бы указать в самой статье, а это важное место с точки зрения защиты.


                WinSxS redirect (aka “DotLocal”).

                Это не "aka", а лишь один из способов. Не освещен другой механизм: ApiSetSchema — набор маршрутов загрузки dll по спец именам (типа api-ms-win-core-errorhandling-l1-1-0.dll), присутствующий в Win7 и выше. Тут хайджекинг не сработает.

                  +1
                  Добавил небольшой апдейт в статью.
                  +2

                  До сих пор встречаю инсталляторы, которые распаковываются в Temp и запускаются оттуда.


                  Запомните: корень папки Temp — это помойка, в ней может лежать что угодно, в том числе и любые dll, которые запущенная оттуда программа радостно подхватит. Если вам нужно распаковаться во временное хранилище и запустить оттуда код — создавайте хотя-бы подпапку со случайным именем.


                  P.S.

                  И то, даже в этом случае будет состояние гонки, так что по-хорошему на эту папку нужно ещё и правильный дескриптор безопасности навесить. Естественно, это относится к ситуации, когда пользователь работает со включённым UAC в Admin Approval Mode, ведь в этом случае папка Temp является общей для программ, работающих с разными уровнями привилегий.

                    +1
                    Да, вы правы. Из недавнего — такие уязвимости были у NVIDIA и, вроде, у какого-то продукта Cisco.
                    А про состояние гонки — TOCTOU часто можно выиграть используя OpLock.
                    +2
                    Ещё немного занудства:
                    BOOL WINAPI SetDllDirectory(LPCTSTR lpPathName);

                    Добавляет произвольный путь к местам поиска DLL и меняет порядок (после папки с приложением поиск будет в папке, указанной в lpPathName); это влияет на все последующие вызовы LoadLibrary[Ex].
                      +1
                      Опять-таки, на импортируемые библиотеки это не повлияет.
                        +1
                        Как же не повлияет, если библиотека (или не она, а подставная) загрузится с другого места?

                        upd: Когда-то я так делал для BDE (IDAPI32.dll), теперь — для sqlite. В моих заголовочниках импорт статический, переписывать на динамический лень. Потому я просто заменил импорт на отложенный импорт, чтоб виндовый загрузчик не ругался, а при старте приложения читаю путь к DLL из реестра и передаю в SetDllDirectory. Можно, конечно, положить DLL в папку к каждому приложению (и не забывать их обновлять) или же нагадить в System32, но мне так больше нравится.
                          +1
                          Библиотеки из таблицы импорта грузятся до того, как будет выполнен любой код программы, в том числе до того, как произойдёт вызов указанной выше функции.
                            +1
                            Если это не отложенный импорт, тогда библиотека загрузится при первом обращении к любой из её функций. Механизмы импорта разные бывают, помимо стандартного есть ещё delayed (о котором я писал) и bound (при котором адреса в таблице импорта уже вычислены на этапе компиляции, применяется, когда вы поставляете весь набор приложений и библиотек одной кучей (операционка, например)).

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое