
Doki was container malware that used a legitimate Docker API and a hidden C2 path. It is a reminder that a socket is a privilege.
If a workload can talk to the Docker daemon, it can start a sibling container. That is not a novel exploit. It is a permission you granted.
The usual mistake is mounting docker.sock into an app container because a plugin needed it, then never reading the Intezer-style notes when they name that pattern.
This page is what Doki actually did, and the runtime settings that stop the next family from using the same socket.
VirusTotal first analyzed the Doki sample on 14 January 2020. Intezer’s Watch Your Containers note, dated 28 July 2020 and credited to Michael Kajiloti and Nicole Fishbein, said none of the 60 engines on VirusTotal had flagged it in the six months since that first scan. The useful half of that sentence is not a stealth recipe. It is that a signature box stayed quiet while a public Engine port already meant host root.
That is a replay guide with a vendor bow. This page is a case study for people who run Docker on a cloud VM. Keep the Ubuntu server guide next to it for sshd, updates, and firewalls. Keep the secure coding checklist for the app. Read injection when a string inside a container reaches a shell you meant to keep as data.
What Intezer dated, and what this page will not do
Intezer placed Doki inside a longer Ngrok mining botnet that had already been hunting exposed Docker APIs for at least two years by mid-2020. Netlab and Trend Micro had written about that campaign earlier. Intezer’s new claim was a Linux backdoor that talked to operators through a domain derived from the Dogecoin chain, and that stayed unlabeled on VirusTotal from 14 January 2020 through the July writeup.
I am not publishing a create-container body, a bind path, a wallet address, or a domain-construction formula. Intezer already did that work for responders. You do not need a second copy to close a port.
MITRE ATT&CK software S0600, created 6 April 2021 and last modified 25 April 2025, summarizes the same family: Linux and containers, first observed July 2020, used with the Ngrok mining botnet, reached through an open Docker daemon port. Treat S0600 as the index card. Treat Intezer as the dated narrative. Treat this page as the defender checklist that still applies in 2026.
An open Engine port is host root
Docker’s own Protect the Docker daemon socket page still opens with the default: a non-networked unix socket. SSH and TLS are the optional remote paths. Unauthenticated TCP on 2375 is the path Doki’s operators scanned for. Anyone who can speak that API can start a container. Anyone who can start a container with a host bind can write the host. That is not a 2020-only trick. It is the daemon’s trust model.
Intezer said a newly misconfigured server was infected within hours, because the scan was continuous. That clock is the reason this page leads with the socket, not with a YARA rule. If 2375 is reachable from the internet, you are already past the sample. Close the port. Then look for guests you did not start.
A sibling failure is mounting the Engine socket file into a CI container so the job can “build images.” That guest is now the daemon. A hostile commit that talks to the socket is host root. Rootless Podman, Kaniko, or a dedicated builder VM are the replacements. Do not treat DinD on a shared runner as a small convenience.
Sixty engines missed a sample for six months
Intezer’s key finding was the quiet VirusTotal row: first seen 14 January 2020, still unlabeled by 60 engines in July. BleepingComputer’s same-week recap repeated those numbers. The lesson does not need a current score. The lesson is that a hash feed is late when the entry was a public API and a stock image.
The C2 path made static URL lists late too. Intezer described a domain generated from a public Dogecoin explorer and a DynDNS suffix, so the operator could move the name by moving coins. I am not repeating the hash steps. The defender translation is: a blocklist of last week’s domains will not save a host whose API is open, and a proxy that only denies known bad URLs will not see a name that did not exist this morning. Close the API. Watch process ancestry and unexpected outbound HTTPS from a guest that should be idle.
Hacker News story 24002836 linked the BleepingComputer recap on 30 July 2020. The quote on this page is the later socket comment, which is the control the thread never had to invent.
A clean Alpine image is not a verdict
Intezer said each throwaway guest was based on a public Alpine image that already contained curl. The image on Docker Hub was not hostile. The create options were. A scanner that only flags “malicious layers” stays quiet, because the layers are the same ones your own debug job uses.
That is the second clock. The first clock is the open API. The second is trust in a popular tag. alpine plus curl is a legitimate toolbox. It is also enough to pull a second stage once someone else can create the guest. Do not whitelist a host just because docker images shows official names. Ask who created the container, with which binds, and whether that create is in your runbook.
| What people trusted | What Doki showed | What you keep |
|---|---|---|
| VirusTotal clean | six quiet months | socket inventory, not a hash |
| Official Alpine | stock image, hostile create | who created it, which binds |
| URL blocklist | short-lived generated name | deny unexpected egress |
| Container jail | host root bind, host cron | inspect Binds and crontab |
Watch Binds, cron, and unexpected guests
Intezer described a guest whose bind pointed at the host root, then a change to the host cron so a downloaded script ran every minute. I am not writing that create. The detect is inventory you already have.
ENTRY dockerd listening on tcp/2375 no TLS, no allowlist SIGNALS guest you did not start Binds includes host / host cron grew a minute job alpine+curl doing egress FIX unix socket only, or TLS :2376 firewall deny 2375/2376 from WAN remove unknown guests, restore cron
On a machine you administer, these are read-only questions:
- Which containers are running, and which of those names are in the compose file you committed?
- What does
docker inspectprint underHostConfig.BindsandMounts? A bind of/,/etc, or/var/spool/cronfrom a guest you do not remember is the incident. - What does the host crontab show, including
/etc/cron.dand/var/spool/cron? A minute job that curls a URL is the persistence Intezer described. - Who can talk to the daemon?
sson 2375 and 2376, plus the group that owns the unix socket.
# scripts/prove_daemon.sh
# inventory only. run on the VM you administer.
set -eu
echo "== listeners =="
ss -lntp | grep -E ':2375|:2376' || echo "no 2375/2376 listeners"
echo "== docker hosts =="
docker info --format 'version={{.ServerVersion}} security={{.SecurityOptions}}'
echo "== unexpected binds and mounts =="
docker ps -aq | while read -r id; do
docker inspect -f '{{.Name}} binds={{json.HostConfig.Binds}} mounts={{json.Mounts}}' "$id"
done
echo "== host cron =="
crontab -l 2>/dev/null || true
ls -l /etc/cron.d /var/spool/cron 2>/dev/null || true
Identifiers stay prove_daemon. You are listing your own guests and your own crontab. You are not sending those lines to a third party. If a bind you do not recognize appears, stop, snapshot, and restore from the last known compose. Rotate cloud keys that a host cron could have read. A restored container list does not revoke a token that already left.
Close 2375. Prefer the local Engine socket
The Docker protect-access page is the first-party fix. Default is the Engine unix socket. Remote use should be SSH, which is docker context create with host=ssh://user@host, or TLS with --tlsverify on 2376. The same page warns that anyone who holds the client keys can issue any daemon command, which is root on the host. Guard those keys like a root password.
# primary on Ubuntu/Debian packages: systemd drop-in
# /etc/systemd/system/docker.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H unix:///var/run/docker.sock --live-restore
A hosts key in daemon.json fights the unit’s -H fd:// and dockerd refuses to start. Prefer the drop-in. Use daemon.json only when systemd does not pass -H.
# non-systemd case only
{
"hosts": ["unix:///var/run/docker.sock"],
"live-restore": true
}
If systemd already passes -H, do not fight it with a second hosts list. The named fallback when someone truly needs TCP is mutual TLS plus a firewall that allows only your admin network. The page’s example binds 0.0.0.0:2376 with --tlsverify. That is still a WAN risk if the security group admits the whole internet. Pin the source prefix. Leave 2375 unused.
# named fallback: mutual TLS on the admin network, never plain 2375
# dockerd --tlsverify --tlscacert=ca.pem --tlscert=server-cert.pem \
# --tlskey=server-key.pem -H=tcp://10.0.1.20:2376
Cloud security groups are the cheap belt. Deny inbound 2375 and 2376 from the internet on every VPC that runs Docker. Then run prove_daemon. A group that still shows 2375/tcp open is the 2020 finding, still unpaid.
User namespaces, as bogomipz noted, mitigate some socket abuse. They do not close a public API. Do not take “we enabled userns-remap” as a substitute for “2375 is closed.”
Prove the daemon is not on the internet
You are not proving a binary is kind. You are proving no process is listening on 2375, that 2376 if present requires TLS and a tight source prefix, and that every running guest is in your compose file.
# 1. no public Engine port
ss -lntp | grep -E ':2375|:2376'
# expect empty, or 2376 bound to an admin address only
# 2. compose inventory
docker ps --format '{{.Names}} {{.Image}} {{.Command}}'
# every name should appear in the compose file you committed
# 3. planted bind must be visible
# start a throwaway guest YOU own with a harmless bind, run prove_daemon,
# confirm the line appears, then remove the guest. do not bind host /.
Step 3 is a guest you created on purpose, with a bind of a temp directory you own, so you know the inspect line works. Do not bind the host root to “test Doki.” The inspect output is the proof. Delete the guest when you are done.
If ss shows 2375 today, treat the host as untrusted until you have rotated keys, reviewed cron, and rebuilt from a known image. Intezer’s “few hours” clock is why that is an incident, not a ticket for next sprint.
Questions we keep getting
Is Doki still an active campaign in 2026?
I am not claiming a 2026 sighting. MITRE still carries S0600. Exposed Docker APIs are still scanned. The control is the same whether or not this family is the one that knocks.
Does a runtime agent replace closing 2375?
No. An agent that notices unknown code is useful after a guest starts. The 2020 campaign needed the API first. Close the port. Then keep inventory. An agent is leftover detection, not the lock.
What if I must manage Docker from another host?
Use SSH via docker context, or mutual TLS with a CA you minted and a source prefix you listed. Do not enable plain TCP “just for the lab” on a public address. Labs get scanned too.



