Container forensics and incident response

Contain, capture and diff a suspect container before you destroy it.

Advanced14 min · lesson 20 of 24

The reflex that ends an outage, kill it and let it come back clean, destroys the evidence of a compromise. A container keeps that evidence in places the quick fixes erase in seconds. Live process memory and open sockets are gone when the process dies. The container's log and configuration are gone on docker rm with the default json-file driver. So is the writable layer stacked on the read-only image. Your job in the first few minutes is preservation, in one fixed order: contain, capture, investigate, remediate. You'll run that order on a benign suspect on the main lab VM (secopslog-docker), in ~/lab/forensics. The suspect plants its own fake evidence and exploits nothing.

The order that keeps the evidence readable
1Contain
pause, record PID, config, logs and sockets, then cut the network
2Capture
host /proc, docker cp, diff, export, commit, save, hashes
3Investigate
line up the artifacts against the host timeline
4Remediate
neutralise restart policy, rm, rebuild from a clean image
Jumping to remediate redeploys the same vulnerable image an hour later, having learned nothing about the way in.

The suspect logs one line, drops a copy of a binary and adds a crontab line. It opens a TCP connection to lab-c2, a stand-in for an attacker's server on the same network. Then it sleeps, with the fake command-and-control address in its environment:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ docker pull -q alpine:3.24 docker network create lab-evnet >/dev/null docker run -d --name lab-c2 --network lab-evnet alpine:3.24 sh -c "sleep 3600 | nc -l -p 4444 >/dev/null" >/dev/null sleep 1 docker run -d --name lab-suspect --network lab-evnet --restart unless-stopped \ -e C2_HOST=lab-c2:4444 alpine:3.24 sh -c ' echo "worker started" cp /bin/busybox /tmp/.miner printf "* * * * * root /tmp/.miner\n" >> /etc/crontab (sleep 3600 | nc lab-c2 4444 >/dev/null &) exec sleep 3600' >/dev/null sleep 2; docker ps --filter name=lab- --format "{{.Names}} {{.Status}}"
docker.io/library/alpine:3.24 lab-suspect Up 2 seconds lab-c2 Up 3 seconds

Contain: freeze, record, then cut the network

Freeze first. docker pause stops every thread at once through the cgroup freezer. It acts underneath the process, so there's no signal for an attacker to trap, and nothing gets a chance to run a clean-up. A paused container also refuses docker exec. That's why every capture step runs from the host:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ docker pause lab-suspect docker inspect -f "{{.State.Status}}" lab-suspect docker exec lab-suspect true 2>&1; echo "exec while paused: exit $?"
lab-suspect paused Error response from daemon: Container lab-suspect is paused, unpause the container before exec exec while paused: exit 1

Then write down what the daemon knows before anything changes it. The host-side PID is the number the kernel gives the container's main process (not the 1 it sees inside). The next steps read the kernel's view of exactly that process. docker inspect is the full record of how the container was started: image digest, mounts, environment, restart policy, labels and network settings. docker logs is everything the process wrote to stdout and stderr. Both disappear with docker rm, so they go into the evidence directory now. The directory is mode 700. On a real host, write to a root-only location on separate storage, and copy it off the host as soon as you can:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ install -d -m 700 ir-42 PID=$(docker inspect -f "{{.State.Pid}}" lab-suspect); echo "host PID $PID" | tee ir-42/pid.txt docker inspect lab-suspect > ir-42/inspect.json docker logs --timestamps lab-suspect > ir-42/logs.txt 2>&1 cat ir-42/logs.txt ls -l ir-42
host PID 165278 2026-10-26T07:34:41.653813658Z worker started total 16 -rw-r--r-- 1 ubuntu ubuntu 7892 Oct 26 13:04 inspect.json -rw-r--r-- 1 ubuntu ubuntu 46 Oct 26 13:04 logs.txt -rw-r--r-- 1 ubuntu ubuntu 16 Oct 26 13:04 pid.txt

Record the connections next, from the host. nsenter joins only the container's network namespace ("Namespaces from first principles" introduced the method). So the ss binary you run is the host's, and the attacker can't have replaced it:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ PID=$(cut -d" " -f3 ir-42/pid.txt) sudo nsenter -t "$PID" -n ss -tanp | tee ir-42/sockets.txt
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 0 4096 127.0.0.11:38109 0.0.0.0:* users:(("dockerd",pid=27856,fd=52)) ESTAB 0 0 172.18.0.3:38549 172.18.0.2:4444 users:(("nc",pid=165313,fd=3))

The ESTAB line is the open channel: the suspect's nc connected to 172.18.0.2:4444, the address of lab-c2. On a real incident that peer address is often the most valuable volatile fact you'll collect. (The LISTEN line on 127.0.0.11 is Docker's embedded DNS resolver.) Now cut the network:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ docker network disconnect lab-evnet lab-suspect docker inspect -f "attached networks: {{len .NetworkSettings.Networks}}" lab-suspect
attached networks: 0

Disconnecting removes the container's interface, so the connection can no longer carry traffic. Anything sent on it fails, the socket times out, and the address that tied it to the network is gone. That's why you saved the socket table first. If the container is actively sending data out, cut first and accept the loss. Or drop its traffic with a host firewall rule on its address, which stops the flow and leaves the socket state readable.

Capture from the host, not from inside

A compromised container controls its own userspace, so its ps, ls and cat can lie, and its own files can be edited. Read the kernel's view from the host instead, and know which parts of it you can trust. Kernel-maintained entries such as /proc/<pid>/exe, fd, maps and status can't be forged from inside. exe is the running binary, and it still resolves even if the attacker unlinked the file (it then reads (deleted)). /proc/<pid>/cmdline and environ are different. The kernel reads them out of the process's own memory, so a process can overwrite them (argument rewriting is routine malware behaviour). And environ shows the environment block the process started with, not variables it set later. Treat those two as claims to corroborate. Pull the dropped artifact with docker cp, which reads the filesystem through the daemon and works on a paused container:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ PID=$(cut -d" " -f3 ir-42/pid.txt) sudo ls -l /proc/$PID/exe
lrwxrwxrwx 1 root root 0 Oct 26 13:04 /proc/165278/exe -> /bin/busybox
$ docker cp lab-suspect:/tmp/.miner ir-42/miner.bin file ir-42/miner.bin | cut -d, -f1
Successfully copied 919kB (transferred 921kB) to /home/ubuntu/lab/forensics/ir-42/miner.bin ir-42/miner.bin: ELF 64-bit LSB pie executable
$ PID=$(cut -d" " -f3 ir-42/pid.txt) sudo cat /proc/$PID/environ | tr "\0" "\n" > ir-42/environ.txt grep C2 ir-42/environ.txt
C2_HOST=lab-c2:4444

The live binary resolves, the dropped file comes out as an ELF executable, and the fake C2 address is read from the process's environment block.

Diff, export, commit, hash

The image ships read-only and never changes, so everything the container wrote sits in the thin writable layer on top. docker diff reads that layer directly. A well-behaved app writes almost nothing, so a dropped binary and a new crontab stand out:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ docker diff lab-suspect | tee ir-42/diff.txt
C /tmp A /tmp/.miner C /etc A /etc/crontab

A is added, C is changed. Take the whole filesystem as a tarball for offline analysis, and commit a runnable snapshot you can detonate later in an isolated lab. docker save writes that snapshot to a file. The evidence directory holds it too, so it doesn't depend on the image staying on this host:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ docker export lab-suspect -o ir-42/rootfs.tar tar tf ir-42/rootfs.tar | grep -E "tmp/\.miner|etc/crontab"
etc/crontab etc/crontabs/ etc/crontabs/root tmp/.miner
$ docker commit lab-suspect lab-evidence:incident-42 docker save lab-evidence:incident-42 -o ir-42/image.tar ls ir-42
sha256:ef2c20961c6206434df2c7b50f0405436a0e01b36b82d51ea752704e79a6bdd0 diff.txt image.tar logs.txt pid.txt sockets.txt environ.txt inspect.json miner.bin rootfs.tar

The export holds the dropped binary and the new crontab. Both the export and the saved image are for a lab, never for re-running attacker code on a production host. Last, hash everything you took. A copied file is just a copy. A copied file with a checksum recorded at collection time is something an auditor will still trust months later:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ (cd ir-42 && sha256sum * > SHA256SUMS) awk '{print substr($1,1,16), $2}' ir-42/SHA256SUMS
0e238af531c3bdfe diff.txt 90ba87633240e9a1 environ.txt 11fd6a8b30b9b09d image.tar 679f4578936d6432 inspect.json 76043e74de61ddd9 logs.txt 97d52efa149563c8 miner.bin da7baf156e461b2d pid.txt 78de1f51c44f6181 rootfs.tar a9425cb3f059c6e2 sockets.txt

The subshell keeps your shell in ~/lab/forensics. The hashes vary from run to run (timestamps, PIDs and addresses differ). What matters is that SHA256SUMS is written now and travels with the files.

Memory needs CRIU, and the lab does not install it

Disk shows what was written down. A payload that lives only in RAM leaves nothing there. To capture it, you checkpoint the running process tree with CRIU, which docker checkpoint drives:

docker checkpoint (reference)
# experimental daemon + CRIU installed on the host
$ docker checkpoint create --leave-running suspicious cp-incident-42
$ docker checkpoint ls suspicious

docker checkpoint is experimental on Docker 29. The daemon must run in experimental mode, and CRIU must be installed on the host. CRIU supports x86_64, arm64 and other architectures. The lab VM simply doesn't install it, so this course doesn't run it. --checkpoint-dir chooses where the images land. Without it they go under the container's directory in the daemon's data root, which docker rm deletes, so set it to your evidence storage. Without CRIU you stop at the filesystem, configuration, log and socket evidence above. That's enough to answer most of the case.

Investigate, then remediate

Lay the pieces side by side. docker diff and the recovered binary show what ran and what it dropped. The socket table and the environment show who it called. The logs and the inspect record show how it was started and what it printed. Your host auditd or Falco trail shows when it arrived. Only then remediate, and remediate never means restart. Before you remove anything, neutralise the restart policy so the daemon can't bring a copy back over your evidence:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ docker inspect -f "{{.HostConfig.RestartPolicy.Name}}" lab-suspect docker update --restart=no lab-suspect >/dev/null docker inspect -f "{{.HostConfig.RestartPolicy.Name}}" lab-suspect
unless-stopped no
Watch out
A restart policy, or an orchestrator, exists to replace a failed container so desired state returns. That's the one thing you can't allow mid-incident. If the suspect process dies by itself, or someone kills its host PID, a restart=always or unless-stopped policy re-runs the entrypoint. Live memory is gone, and the attacker's persistence may fire on the way up. docker stop and docker kill count as a manual stop and don't trigger the policy. Under Swarm or Kubernetes the control plane replaces the task and garbage-collects the old one, writable layer and all, while you're still capturing. Take the workload out of reconciliation without deleting it. On Kubernetes, relabel the pod so its ReplicaSet no longer selects it. The controller starts a replacement and leaves the suspect pod running, and its Service stops sending it traffic. Then isolate it with a NetworkPolicy and cordon the node. Never scale the controller to zero, which deletes the pod. For a plain container, docker update --restart=no, as above.

Reconstruct the timeline from the daemon's own event stream, then remove the container. The evidence directory is what survives:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ docker events --since 10m --until 0s --filter container=lab-suspect \ --format "{{.Time}} {{.Type}} {{.Action}}"
1793000081 container create 1793000081 container start 1793000083 container pause
$ docker unpause lab-suspect >/dev/null docker rm -f lab-suspect docker inspect lab-suspect 2>&1 | tail -1 ls ir-42 | wc -l
lab-suspect error: no such object: lab-suspect 10

docker events with --since and --until gives each state change with a Unix timestamp. In this run it listed only the create, the start and the pause. The docker cp, the export, the update and the commit are missing, though an earlier run did show archive-path and export. So your own case notes with times are part of the record. Stream events to your log pipeline in production, because the daemon keeps only a short history and loses it on restart. After docker rm the container, its log file and its writable layer are gone. The ten files in ir-42 are what's left. With the evidence off the box, patch the image or close the way in. Rotate every credential the container could reach, and treat them as burned. Redeploy from a freshly built clean image. Then turn the drop you found (a new binary under /tmp writing to /etc/crontab) into a Falco rule, so the next attempt trips an alarm. Clean up:

ubuntu@secopslog-docker:~/lab/forensics · Docker 29.9.2
$ docker rm -f lab-c2 >/dev/null docker rmi lab-evidence:incident-42 >/dev/null docker network rm lab-evnet rm -rf ir-42
lab-evnet

Try this

On the main lab VM, stop before the clean-up step deletes ir-42. Run sha256sum -c SHA256SUMS inside ir-42 and predict the result: one OK line for each file in the hash listing above. Then append a line to ir-42/logs.txt and run the check again. Predict which file fails before you read the output, then clean up as before.

Takeaway

Freeze and record before you touch the network or the restart policy. docker inspect, docker logs and the writable layer that held /tmp/.miner all vanished with docker rm, and only the ten files in ir-42 survived. Cases usually go wrong in two places: an unless-stopped policy like the suspect's re-runs the entrypoint, or a responder trusts cmdline and environ, which the process can rewrite.

Quick check
01You have frozen a compromised container. Why read its binary and open sockets from the host (/proc/<pid>/exe, nsenter -n ss) rather than running ps, ss and ls inside it?
Correct — Whoever owns the container owns its userspace. The host's tools reading kernel-maintained entries give you facts the attacker cannot edit; cmdline and environ are the exception, since they come from process memory.
Incorrect — The issue is trust, not speed; fast answers from a compromised toolset are still wrong.
Incorrect — Reading another process's /proc entries takes host root, and exec can run as any user.
Incorrect — Pause does block exec, but that is a side effect; even on a running container you would not trust its own binaries.
02In what order should the first containment steps run, given a suspect container with one established outbound connection?
Incorrect — A paused container refuses exec, the in-container ss is untrusted, and cutting first destroys the connection the evidence is about.
Incorrect — docker rm deletes the writable layer, the json-file log and the inspect record; there is nothing left to compare.
Incorrect — Stopping kills the process, so memory and the socket table are gone before you have recorded them.
Correct — Freezing stops the process without killing it, the records disappear with docker rm, and the socket table must be saved before the interface goes.
03A compromised container runs with --restart always. Someone runs kill -9 on its host PID on reflex. What happens to the investigation?
Incorrect — A restart runs the entrypoint again in a new process; the old memory and sockets are gone.
Correct — A process that dies on its own (or is killed from the host) triggers the policy; only the writable layer survives until docker rm.
Incorrect — Restart policies restore the desired state; they never preserve a failed process.
Incorrect — It is the reverse: memory dies with the process, while the writable layer stays until the container is removed.

Related