puck tools — test your egress policy against real C2 traffic →

A tool for every job

Instead of installing tools and all of their dependencies on your host OS Docker lets you keep your host machine clean. Containers let you easily run tools with different versions and different userlands.

The version problem, gone

An exploit needs Python 3.11. Another needs 3.12. Your distro ships 3.13 and your system tooling depends on it. On the host, that is pyenv, a virtualenv, and an afternoon.

$ docker run --rm -it python:3.11 python -V
Python 3.11.16

$ docker run --rm -it python:3.12 python -V
Python 3.12.14

Both, right now, without touching your host Python. Drop the python -V and you get a shell in that image instead:

$ docker run --rm -it python:3.11 bash
root@7709f12587bf:/# python
Python 3.11.16 (main, Aug 13 2026, 19:40:55) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

The same trick covers every runtime you have ever had a version fight with: node:20, openjdk:11, golang:1.22, ruby:3.2, php:7.4 when you need to reproduce something ancient.

Tags matter, latest lies

An image reference is repository:tag. The tag is a label someone moved to a build, not a version guarantee, and latest is just the tag applied when you do not ask for one. It is not “the newest release”, it is whatever was pushed there last, which may be a nightly, or two years stale.

Be specific when you care:

$ docker run --rm ubuntu:22.04 cat /etc/os-release | head -2
PRETTY_NAME="Ubuntu 22.04.5 LTS"
NAME="Ubuntu"

When you really care, pin the digest. That is content addressed and cannot move under you:

$ docker run --rm ubuntu@sha256:941f1899488c45cd4657f7872ddec86b47907e3e2165b944a1219c80b63c283d uname -m
x86_64

Where images come from

That ubuntu:22.04 reference is missing a piece. The full form is registry/repository:tag, and when you leave the registry off, Docker fills in Docker Hub. These two are the same pull:

$ docker pull ubuntu:22.04
$ docker pull docker.io/library/ubuntu:22.04

A registry is just a server that stores images. docker pull fetches from one, docker run pulls first if the image is not already local, and docker push sends one back. You saw the first pull happen in lesson 1 when hello-world came down from Docker Hub.

Hub is the default, it isn’t the only one. The address in front of the repository names the server, so an image can come from anywhere:

$ docker pull ghcr.io/astral-sh/uv:latest
latest: Pulling from astral-sh/uv
2efb1cc6cef2: Pull complete
549437617bd5: Pull complete
Digest: sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1
Status: Downloaded newer image for ghcr.io/astral-sh/uv:latest

The registries you will meet most:

  • docker.io, Docker Hub, the default. library/ is the namespace for official images, which is why ubuntu expands to docker.io/library/ubuntu.
  • ghcr.io, GitHub Container Registry. A lot of security tooling ships here now, tied to the project’s GitHub repo.
  • quay.io, Red Hat’s registry.
  • gcr.io and its regional variants, plus AWS ECR and Azure ACR, which is where an organisation’s own images tend to live.

Because the name carries the whole address, running someone’s tool is a copy and paste. That cuts both ways. docker run will fetch and execute code from a server named in a string, so read the string. docker.io/library/nginx is the official image, docker.io/evilcorp/nginx is a stranger’s, and typosquatted repository names are a real supply-chain trick. The image runs as root by default, so treat an unknown image the way you would treat curl | sh.

Once an image is local it stays cached, and a pull is a one-time cost. That is why the tools in this course still work on a machine with no internet, as long as you pulled them first:

$ docker run --rm --network none alpine:3.20 echo "ran from local cache with no network"
ran from local cache with no network

Public images pull with no account. Private ones need docker login first, and without it the daemon refuses:

$ docker pull ghcr.io/offensivecontext/private-tool:latest
Error response from daemon: error from registry: denied

docker login ghcr.io with a token fixes that. Pushing your own image to a registry, and the reasons to keep it private, is lesson 9.

If you would rather browse than guess, docker search queries Hub from the command line:

$ docker search --limit 5 nmap
NAME                  DESCRIPTION                                     STARS     OFFICIAL
securecodebox/nmap    A Docker image containing the NMAP security …   31
demisto/nmap                                                          0
parrotsec/nmap        Official Parrot container for nmap              1
instrumentisto/nmap   Nmap ("Network Mapper") Docker Image            31
frapsoft/nmap         nmap on Alpine Linux (6 MB)                     3

STARS and OFFICIAL are the only trust signal it gives you, and neither is much of one. Prefer an image whose source Dockerfile you can read, which for ghcr.io images is usually one click away in the same GitHub repo.

Tools, not just runtimes

Most tools publish images, security tools included which means you can easily run one without installing it or its dependency tree:

$ docker run --rm instrumentisto/nmap -V
Nmap version 7.98 ( https://nmap.org )
Platform: x86_64-unknown-linux-gnu
Compiled with: liblua-5.4.8 openssl-3.5.5 libssh2-1.11.1 libz-1.3.1 nmap-libpcre2-10.45 libpcap-1.10.5 nmap-libdnet-1.18.0 ipv6

Notice we just used -V, not nmap -V. That image sets nmap as its entrypoint, so anything you type is passed to it as arguments. Images built from a base OS usually do not, which is why the Ubuntu examples all end in an explicit bash.

There is another catch worth knowing, the scan runs from the container’s network, not your host’s. Lesson 6 covers this, for now know --network host is the flag that makes this alias behave more like a localls installed nmap.

A container isolated

The other half of the value is containment. When you have to run something you do not trust, an install script, a sample, a random ELF from a machine you are looking at, do not run it as you.

$ docker run --rm -it --network none -v "$PWD/sample:/s:ro" ubuntu:22.04 bash

Three things happened there. --network none gave it a network stack with nothing but loopback, so it cannot phone home. :ro on the mount made your copy of the sample read only. --rm means whatever it did to its filesystem evaporates on exit.

This a lightweight sandbox. It is the same kernel, lesson 8 is a tour of some of the ways out. Against malware that is actively looking for a way onto your host, use a VM. Against a build script you would rather not have writing to ~/.ssh, this is exactly right and takes one second to set up.

Next: Cross compiling and getting files in and the results back out.