NullReceiver: C2 Address Read from a Zero-Value Ethereum Transfer
WindowsmacOSLinux
TTP Hunt medium Contagious InterviewSupply Chain
A node process calling public Ethereum JSON-RPC endpoints to read an attacker wallet's latest transfer, whose recipient address encodes the next command server, then connecting to it: the NullReceiver technique OpenSourceMalware and Sonatype document.
What this hunt looks for
In August 2026 OpenSourceMalware described NullReceiver in two npm packages, bianira-ui@1.27.0 and fluid-type-ui@2.0.8, clones of Tailwind CSS plugins it attributes to Contagious Interview. The loader asks a public Ethereum RPC endpoint for one attacker wallet's latest outbound transaction, decodes an IP address from the recipient address and connects to it; in the sample, 166.88.134.62.
Sonatype reported six more packages on 10 August 2026 using the same wallet: three hijacked legitimate packages and three published with the loader present. Its fuller sample races several RPC providers, falls back to the Blockscout API and fetches two further stages from /0x/cls and /0x/ls, reading X-Payload-B64 if a plain GET fails.
Nothing on the chain gives a defender a fixed landmark until the wallet is known. The host is where it shows: a node build step or dev server making JSON-RPC calls to a public Ethereum endpoint and then connecting to a bare address. The wallet is the one durable value, behind all eight packages.
- Technique
- Compromise of software dependencies (MITRE ATT&CK T1195.001) carrying a JavaScript loader (T1059.007) that resolves its command server through a dead drop on a public blockchain (T1102.001) and retrieves further stages over HTTP (T1071.001, T1105).
- Data sources
- EDR network telemetry, EDR process telemetry, Proxy logs, Package inventory, File scanning
- Last updated
- 2026-10-04
Indicators
The atomic values this hunt matches on, listed separately from the queries so they can be lifted into tooling that speaks none of the query languages below. Every value here is reported by one of the sources cited at the foot of this page.
| Type | Value | Note |
|---|---|---|
| npm package | bianira-ui@1.27.0, fluid-type-ui@2.0.8 |
The two packages in which OpenSourceMalware identified the technique. |
| npm package | @kolbo/mcp@1.57.1, agentgui@1.0.1127, godot-kit@1.0.1786316795 |
Legitimate packages Sonatype assesses were hijacked to publish these versions (sonatype-2026-005899). The package names identify victims; only the versions are indicators. |
| npm package | envpack-conf@1.0.1, postcss-initial-provider@3.0.4, tailwindcss-motion-advanced@1.0.1 |
Packages Sonatype reports were published with the loader present (sonatype-2026-005901); in the last, the loader sits in utils.min.js. |
| Wallet address | 0xa322e5f3d311d3080e6f0121063e9adc2490ef1a |
Attacker Ethereum wallet hard-coded in the loader (OpenSourceMalware); Sonatype confirmed the same wallet in all six of its packages. |
| IP | 166.88.134.62:443, 166.88.134.62:80 |
Command server decoded from the recipient address (OpenSourceMalware). |
| URL | https://1rpc.io/eth, https://eth.drpc.org |
Public Ethereum RPC endpoints hard-coded in the loader (OpenSourceMalware). Both are legitimate services. |
| URI path | /0x/cls, /0x/ls |
Endpoints for the two further stages (Sonatype). |
| HTTP header | X-Payload-B64 |
Response header carrying the payload when a plain GET fails (Sonatype). |
| String | A10-npm3! |
Value the loader assigns to global.i, which OpenSourceMalware names as its detection signature. |
Logic
Every query below implements these steps, so the same hunt returns the same result in each product.
- Collect: node network connections on Windows, macOS and Linux; npm packages via osquery; package files via YARA
- Match: node calls a public Ethereum RPC or Blockscout host (1rpc.io, drpc.org, blockscout)
- Match: and, within 2 minutes on the same device, node connects to a public address on port 80 or 443 that has no host name
- Match: or a connection to the published command server 166.88.134.62, or a request for /0x/cls or /0x/ls (specific to these samples)
- Match: or an installed npm package is one of the eight published versions (osquery)
- Report: devices with the signals seen, first and last time, newest first
Queries
Defender / Sentinel KQL
Written for this site, from indicators reported by OpenSourceMalware, SonatypeReports devices where node called an Ethereum RPC host and then reached a bare public address within 2 minutes, a pairing worth a look because EDR sometimes leaves the host name empty for named hosts too.
// NullReceiver: node reads an on-chain address through a public RPC, then connects to a bare public address within 2 minutes
let Lookups = DeviceNetworkEvents
| where Timestamp > ago(30d)
| where InitiatingProcessFileName in~ ("node.exe", "node")
| where RemoteUrl has_any ("1rpc.io", "drpc.org", "blockscout")
| project DeviceName, LookupTime = Timestamp, RpcHost = RemoteUrl;
union
(DeviceNetworkEvents
| where Timestamp > ago(30d)
| where InitiatingProcessFileName in~ ("node.exe", "node")
| where isempty(RemoteUrl) and not(ipv4_is_private(RemoteIP)) and RemotePort in (80, 443)
| join kind=inner Lookups on DeviceName
| where Timestamp between (LookupTime .. (LookupTime + 2m))
| project Timestamp, DeviceName, Signal = "rpc-then-bare-address", Detail = strcat(RpcHost, " then ", RemoteIP, ":", tostring(RemotePort)))
// optional: published command server and second-stage paths, specific to these samples
, (DeviceNetworkEvents
| where Timestamp > ago(90d)
| where RemoteIP == "166.88.134.62" or (InitiatingProcessFileName in~ ("node.exe", "node") and RemoteUrl has_any ("/0x/cls", "/0x/ls"))
| project Timestamp, DeviceName, Signal = "published-c2", Detail = strcat(RemoteUrl, " ", RemoteIP, ":", tostring(RemotePort)))
| summarize Signals = make_set(Signal), Events = count(), Examples = make_set(Detail, 5), FirstSeen = min(Timestamp), LastSeen = max(Timestamp) by DeviceName
| sort by LastSeen desc
Lower-fidelity parts (1)
- the published command server 166.88.134.62 and the /0x/cls and /0x/ls paths — specific to these samples, so it stops matching once the server or wallet changes. Remove: delete the `// optional: published command server` line and the `, (DeviceNetworkEvents ...` branch that follows it, up to the line before `| summarize`.
Splunk SPL
Written for this site, from indicators reported by OpenSourceMalware, SonatypeKeeps hostless node connections only where an RPC lookup by node on the same host came in the previous 2 minutes.
```index=endpoint is a placeholder for EDR network events carrying the Endpoint data model fields```
index=endpoint earliest=-30d
| eval proc=lower(process_name), web=lower(coalesce(url, query))
| eval is_node=if(match(proc, "^node(\\.exe)?$"), 1, 0)
| eval signal=case(
is_node=1 AND match(web, "1rpc\\.io|drpc\\.org|blockscout"), "lookup",
is_node=1 AND isnull(web) AND isnotnull(dest_ip) AND in(tostring(dest_port), "80", "443") AND NOT (cidrmatch("10.0.0.0/8", dest_ip) OR cidrmatch("172.16.0.0/12", dest_ip) OR cidrmatch("192.168.0.0/16", dest_ip) OR cidrmatch("127.0.0.0/8", dest_ip)), "bare",
```optional: published command server and second-stage paths, specific to these samples```
dest_ip="166.88.134.62" OR (is_node=1 AND match(web, "/0x/cls|/0x/ls")), "published-c2",
true(), null())
| where isnotnull(signal)
| eval is_lookup=if(signal="lookup", 1, 0)
| sort 0 host _time
| streamstats time_window=2m current=f sum(is_lookup) AS lookups_2m BY host
| where (signal="bare" AND lookups_2m > 0) OR signal="published-c2"
| stats count AS events values(signal) AS signals values(dest_ip) AS addresses values(web) AS urls min(_time) AS first_seen max(_time) AS last_seen BY host
| convert ctime(first_seen) ctime(last_seen)
| sort - last_seen
Lower-fidelity parts (1)
- the published command server 166.88.134.62 and the /0x/cls and /0x/ls paths — specific to these samples, so it stops matching once the server or wallet changes. Remove: delete the ```optional: published command server``` comment and the `dest_ip="166.88.134.62" OR ...` line below it.
Sigma
Written for this site, from indicators reported by OpenSourceMalware, SonatypeThe single-event legs only, because a Sigma correlation cannot be converted for Kusto, so the 2-minute pairing with a hostless connection is applied in the KQL, SPL and ES|QL queries.
title: Node.js Calls a Public Ethereum RPC Endpoint or the NullReceiver Server
id: 7514b8d1-0fb5-4763-aea4-55a47e342557
status: experimental
description: >
The single-event legs of NullReceiver: node connecting to the public Ethereum
RPC endpoints or Blockscout API the loader uses to read its attacker wallet's
latest transfer, and any connection to the command server OpenSourceMalware
decoded from that transfer. The RPC call alone is common in web3 work, so the
two-minute pairing with a bare-address connection is applied in the KQL, SPL
and ES|QL queries.
references:
- https://opensourcemalware.com/blog/nullreceiver-dprk-c2-technique
- https://www.sonatype.com/blog/six-npm-packages-use-ethereum-transactions-to-retrieve-malicious-payloads
logsource:
category: network_connection
detection:
selection_rpc:
Image|re: '(?i)[\\/]node(\.exe)?$'
DestinationHostname|contains:
- '1rpc.io'
- 'drpc.org'
- 'blockscout'
# optional: published command server, specific to these samples
selection_c2:
DestinationIp: '166.88.134.62'
condition: 1 of selection_*
falsepositives:
- Web3 development scripts that use these public RPC providers
level: medium
Lower-fidelity parts (1)
- the published command server 166.88.134.62 and the /0x/cls and /0x/ls paths — specific to these samples, so it stops matching once the server or wallet changes. Remove: delete the `# optional: published command server` comment and the `selection_c2` block; `1 of selection_*` needs no change.
CrowdStrike LogScale
Written for this site, from indicators reported by OpenSourceMalware, SonatypeFalcon network events name no process in this field set, so only the published command server is expressible and the pairing is left to the other languages.
// optional: this one dated address is the whole query, because Falcon network events name no process in this field set and the RPC-then-bare-address pairing is left to the other languages
#event_simpleName=NetworkConnectIP4 RemoteAddressIP4="166.88.134.62"
| groupBy([ComputerName, aid], function=[count(as=events), collect([RemoteAddressIP4, RemotePort]), min(@timestamp, as=first_seen), max(@timestamp, as=last_seen)])
| sort(last_seen, order=desc)
Lower-fidelity parts (1)
- the whole query, one dated address — it is the only match Falcon can express here and it retires with the server. Remove: delete the query once the address is retired, or add the address to a block list instead.
Elastic
Written for this site, from indicators reported by OpenSourceMalware, SonatypePairs a lookup and a hostless connection inside one fixed 2-minute bucket per host.
FROM logs-*
| WHERE @timestamp > NOW() - 30 days
| EVAL proc = TO_LOWER(process.name), web = TO_LOWER(COALESCE(url.original, destination.domain, dns.question.name))
| EVAL is_node = proc IN ("node", "node.exe")
| EVAL is_lookup = is_node AND (web LIKE "*1rpc.io*" OR web LIKE "*drpc.org*" OR web LIKE "*blockscout*")
| EVAL is_bare = is_node AND web IS NULL AND destination.port IN (80, 443) AND NOT CIDR_MATCH(destination.ip, "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8")
| EVAL is_c2 = TO_STRING(destination.ip) == "166.88.134.62" OR (is_node AND (web LIKE "*/0x/cls*" OR web LIKE "*/0x/ls*"))
| EVAL window = DATE_TRUNC(2 minutes, @timestamp)
| STATS lookups = SUM(CASE(is_lookup, 1, 0)), bares = SUM(CASE(is_bare, 1, 0)), c2 = SUM(CASE(is_c2, 1, 0)), first_seen = MIN(@timestamp), last_seen = MAX(@timestamp) BY host.name, window
| WHERE (lookups > 0 AND bares > 0) OR c2 > 0
| SORT last_seen DESC
| LIMIT 100
Lower-fidelity parts (1)
- the published command server 166.88.134.62 and the /0x/cls and /0x/ls paths — specific to these samples, so it stops matching once the server or wallet changes. Remove: delete the `| EVAL is_c2` line, `c2 = SUM(CASE(is_c2, 1, 0)),` from the `| STATS` line and ` OR c2 > 0` from the `| WHERE` line.
Cortex XQL
Written for this site, from indicators reported by OpenSourceMalware, SonatypePairs a lookup and a hostless connection inside one 2-minute bin per host, and has no URL field for the second-stage paths.
// Fixed 2-minute bins stand in for the sliding window of the KQL and SPL queries
config case_sensitive = false
| dataset = xdr_data
| filter event_type = ENUM.NETWORK
| alter f_lookup = if(actor_process_image_name in ("node.exe", "node") and (action_external_hostname ~= "1rpc[.]io|drpc[.]org|blockscout" or dns_query_name ~= "1rpc[.]io|drpc[.]org|blockscout"), 1, 0)
| alter f_bare = if(actor_process_image_name in ("node.exe", "node") and action_external_hostname = null and action_remote_port in (80, 443) and not (action_remote_ip ~= "^(10[.]|192[.]168[.]|172[.](1[6-9]|2[0-9]|3[01])[.]|127[.])"), 1, 0)
// optional: published command server, specific to these samples
| alter f_c2 = if(action_remote_ip = "166.88.134.62", 1, 0)
| bin _time span = 2m
| comp sum(f_lookup) as lookups, sum(f_bare) as bares, sum(f_c2) as c2s by agent_hostname, _time
| filter (lookups > 0 and bares > 0) or c2s > 0
| sort desc _time
Lower-fidelity parts (1)
- the published command server 166.88.134.62 and the /0x/cls and /0x/ls paths — specific to these samples, so it stops matching once the server or wallet changes. Remove: delete the `// optional: published command server` line and the `alter f_c2` line below it, then `, sum(f_c2) as c2s` from `comp` and ` or c2s > 0` from the `filter`.
osquery
Written for this site, from indicators reported by OpenSourceMalware, SonatypeLists installed copies of the eight published package versions, which matter by version because three are hijacked legitimate packages; add AND directory = '<project path>' on osquery versions that require it.
SELECT name, version, path, directory
FROM npm_packages
WHERE (name = 'bianira-ui' AND version = '1.27.0')
OR (name = 'fluid-type-ui' AND version = '2.0.8')
OR (name = '@kolbo/mcp' AND version = '1.57.1')
OR (name = 'agentgui' AND version = '1.0.1127')
OR (name = 'godot-kit' AND version = '1.0.1786316795')
OR (name = 'envpack-conf' AND version = '1.0.1')
OR (name = 'postcss-initial-provider' AND version = '3.0.4')
OR (name = 'tailwindcss-motion-advanced' AND version = '1.0.1')
YARA
Written for this site, from indicators reported by OpenSourceMalware, SonatypeFor package tarballs or a node_modules tree: the wallet or signature alone matches, otherwise both RPC endpoints plus one of Sonatype's retrieval details.
rule NullReceiver_Loader
{
meta:
description = "npm loader resolving its C2 from the recipient of a zero-value Ethereum transfer (NullReceiver)"
reference = "https://opensourcemalware.com/blog/nullreceiver-dprk-c2-technique"
strings:
$wallet = "0xa322e5f3d311d3080e6f0121063e9adc2490ef1a" nocase
$signature = "A10-npm3!"
$rpc1 = "1rpc.io/eth"
$rpc2 = "eth.drpc.org"
$stage1 = "/0x/cls"
$stage2 = "/0x/ls"
$header = "X-Payload-B64" nocase
condition:
filesize < 5MB and
($wallet or $signature or (all of ($rpc*) and any of ($stage*, $header)))
}
False positives and tuning
1rpc.io and drpc.org are ordinary RPC providers that Ethereum teams call from node constantly; there, rely on the paired match, since a script reading a transfer rarely then opens a raw connection to the decoded address.
The three hijacked package names are legitimate projects and must be matched by version, never by name. The wallet, server and packages are specific to these samples; the behavioural pairing will outlast them.
Sources
- NullReceiver's Blank Crypto Transfers Solves the Challenges of EtherHiding — OpenSourceMalware, 2026-08-02
- Six npm Packages Use Ethereum Transactions to Retrieve Malicious Payloads — Sonatype, 2026-08-10