Your own attack image
Every build in the last lesson started with apt-get update && apt-get install.
That is thirty seconds of downloading the same packages, every single run, and
it needs the internet at exactly the moment you might not have it.
A Dockerfile is the fix. It is a recipe for an image, run once, reused forever.
The smallest useful one
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y build-essential file iproute2 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /w
CMD ["bash"]
Lets look at what the Dockerfile does:
FROMpicks the base image. Everything you do is a layer on top of it.ENV DEBIAN_FRONTEND=noninteractivestopsaptprompting for a timezone and hanging.RUNexecutes a command at build time and commits the result as a layer.WORKDIRsets the default directory, so-w /wbecomes the default rather than something you retype.CMDis the default command when someone runs the image with no arguments.
Build it, tagging it with a name you will actually type:
$ docker build -t builder ./builder
#1 [internal] load build definition from Dockerfile
#4 [1/3] FROM docker.io/library/ubuntu:22.04
#5 [2/3] RUN apt-get update && apt-get install -y build-essential file iproute2 && rm -rf /var/lib/apt/lists/*
#5 DONE 29.0s
#6 [3/3] WORKDIR /w
#6 DONE 0.1s
#7 exporting to image
#7 naming to docker.io/library/builder:latest done
That is the last time you pay those 29 seconds. The build from lesson 4 is now:
$ docker run --rm -v "$PWD:/w" builder gcc -static -o hello main.c
No apt, no network, instant.
Layers and the cache
Each instruction produces a layer, and docker history shows you what each one
cost:
$ docker history builder --format '{{.CreatedBy}}\t{{.Size}}'
CMD ["bash"] 0B
WORKDIR /w 8.19kB
RUN /bin/sh -c apt-get update && apt-get… 301MB
ENV DEBIAN_FRONTEND=noninteractive 0B
/bin/sh -c #(nop) CMD ["/bin/bash"] 0B
/bin/sh -c #(nop) ADD file:4501ff54ffd37d854… 87.7MB
The bottom two lines came from ubuntu:22.04 itself. Our RUN added 301MB of
compiler.
Docker caches each layer and reuses it while the instruction and everything above it are unchanged. Change line four and lines one to three are still cache hits. Change line one and everything rebuilds.
It is a good idea to put the things that rarely change at the top and the things you edit regularly at the bottom.
Your package list goes near the top. COPY of your own scripts goes near the
bottom, otherwise editing one line of your own code throws away the
whole toolchain layer and re-downloads it.
It is good practice to have RUN as one long chained command rather than
multiple RUN commands. Calling the apt-get update alone would get cached
and leave stale package index. Chain the update with the install so they live
or die together, and rm -rf /var/lib/apt/lists/* in the same layer resulting
in a much smaller image.
A Kali image that starts instantly
Kali publishes a base image. It is bare, roughly 189MB, with essentially none of the tools you associate with Kali, because the full metapackage is enormous. That is the right default: you add what you need.
FROM kalilinux/kali-rolling:latest
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y nmap ncat curl dnsutils iproute2 iputils-ping && \
rm -rf /var/lib/apt/lists/*
CMD ["tail", "-f", "/dev/null"]
$ docker build -t attackbox ./kali
#4 [1/2] FROM docker.io/kalilinux/kali-rolling:latest@sha256:1a2eb0501a2dbe8aa8f513b0791699dbd9b539696768f40b2cbe1dead2fb0d67
#5 [2/2] RUN apt-get update && apt-get install -y nmap ncat curl dnsutils iproute2 iputils-ping && rm -rf /var/lib/apt/lists/*
#5 DONE 12.0s
#6 naming to docker.io/library/attackbox:latest done
$ docker run --rm -it attackbox bash
┌──(root㉿fbb97a3a3388)-[/]
└─#
Kali metapackages give you bigger presets when you want them: kali-linux-core
is small, kali-tools-top10 is the greatest hits, and kali-linux-headless is
most of the distro and will take a while. Install what the job needs, not the
catalogue.
That CMD ["tail", "-f", "/dev/null"] is a deliberate choice. A container
lives as long as its main process, and we want this container to sit there
waiting for us to docker exec into it, which is exactly how the lab in lesson
7 uses it.
CMD versus ENTRYPOINT
We touched on this earlier in a previous lesson. There are two ways a container can be built to run commands. They differ in what happens when you pass arguments
- With
CMD ["bash"],docker run builder ls -lareplaces the whole thing and runsls -la. - With
ENTRYPOINT ["nmap"],docker run scanner -sV targetappends, and the container runsnmap -sV target.
ENTRYPOINT is how the nmap image in lesson 3 turned into a drop in replacement
for the nmap binary. Use it when the image is one tool. Use CMD when the
image is one you want a shell on.
Copying your own kit in
COPY moves files from the build context into the image, which is how your
scripts and static binaries become part of the image:
FROM kalilinux/kali-rolling:latest
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y nmap ncat curl python3 && \
rm -rf /var/lib/apt/lists/*
COPY tools/ /opt/tools/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
The build context is that last argument to docker build, the directory Docker
tars up and hands to the daemon. COPY cannot reach outside it, which is why
COPY ../secrets . fails and why pointing a build at your home directory
uploads your home directory. Add a .dockerignore for anything large or
private that lives next to your Dockerfile.
Repeatable by design
The Dockerfile is the whole point. It is the source of truth for what is in the
image, so anyone who runs docker build on it ends up with the same tools at
the same versions. No “works on my machine”, no hand-configured box you cannot
reproduce after a laptop dies. Rebuild it, and a change to one line rebuilds only
the layers from that line down, because everything above stays a cache hit.
Two flags for when the cache is the problem rather than the point:
--no-cache rebuilds everything from scratch, and --pull re-fetches the base
image so kali-rolling:latest means the latest rather than the copy you pulled
in March.
Tag your versions
docker build -t attackbox builds attackbox:latest, and latest is a moving
label. The next build reassigns it, so “the attackbox I used last month” is
gone. On an engagement that matters: you want the image you run to be the image
you tested, not whatever the tag points at today.
Give a build a real version. You can set more than one tag at once, so keep a
latest for convenience and pin a dated or commit-stamped one you can go back
to:
$ docker build -t attackbox:latest -t attackbox:2026-08 ./kali
#5 [2/2] RUN apt-get update && apt-get install -y nmap ncat curl dnsutils… CACHED
#6 naming to docker.io/library/attackbox:latest done
#6 naming to docker.io/library/attackbox:2026-08 done
A tag is just a label pointing at an image, not a copy, so several tags can name
the same build. docker tag attackbox:latest attackbox:2026-08 adds one to an
image you already built, and the ID gives it away:
$ docker images attackbox
REPOSITORY TAG IMAGE ID SIZE
attackbox 2026-08 acb93ad6d041 282MB
attackbox latest acb93ad6d041 282MB
Same IMAGE ID, two names. Run attackbox:2026-08 on the engagement and you
know exactly what is in it, because you can rebuild that exact image from the
Dockerfile at the commit you tagged it from. Pushing those tags to a registry so
a teammate pulls the identical image is lesson 9.
Next: We explore networking and the fun we can have there.