PwnMachine: the self-hosted bug bounty workstation
When you do bug bounty, you need an environment: a DNS you control (for callbacks), a reverse proxy (to expose tools) and a web interface to orchestrate. All on a machine you can reinstall in ten minutes. PwnMachine is that setup, packaged in Docker Compose.
The concept
PwnMachine is a self-hosting solution based on Docker aiming to provide an easy-to-use pwning station for bug hunters. The basic install includes a web interface, a DNS server and a reverse proxy. One docker-compose up --build -d and it stands up.
The components:
- Traefik : the reverse proxy that routes to each tool and manages Let’s Encrypt certificates
- PowerDNS : the DNS server for callbacks (OOB interactions, DNS rebinding)
- The web interface : a manager orchestrating the pentest tools
- Redis : the manager’s cache
- logbeat : the log shipper
The setup
git clone https://github.com/yeswehack/pwn-machine.git
cd pwn-machine/
echo 'LETS_ENCRYPT_EMAIL="your_email@domain.com"' > .env
docker-compose up --build -d
Starting pm_powerdns-db_1 ... done
Starting pm_redis_1 ... done
Starting pm_powerdns_1 ... done
Starting pm_logbeat_1 ... done
Recreating traefik ... done
Recreating pm_manager_1 ... done
After that, http://your_ip shows the web interface. DNS points to the PowerDNS server and callbacks (Burp Collaborator, DNS rebinding, XSS callbacks) arrive on the server you control.
The three versions
The project went through three iterations:
- v1 (July 2020) : a Python client,
pm.pip install pwn-machine, thenpm setupandpm service start: it drove a remote machine created with docker-machine and deployed DNS, the reverse proxy and the webserver on it. - v2 (2021) : the pure-Docker rewrite. Nothing left to install client-side, just Docker:
docker-compose up --build -d. Thedockers/are split per tool (manager, PowerDNS, Traefik, logbeat) and a web interface drives images and containers. - the internal YesWeHack version (2022) : the production deployment I built at YesWeHack. A dedicated VM, Docker in rootless mode, a dedicated compose file (
docker-compose-h2h.yml) and Salt provisioning for the environment and log rotation.
The big gap is between v1 and v2: the Python client and docker-machine are gone, only Docker remains.
The internal production deployment
The internal version pushes the setup further than the public repository: images are built by CI and pulled from a private registry, the Docker daemon runs rootless and the whole thing is provisioned by Salt. Here is the real surface of that deployment, stripped of the internal hosts.
The code
Three tweaks to the v2 code.
The manager registered itself in Traefik at startup, creating a service and a router in the Redis provider. In production the routing is declared in the compose file, the manager no longer publishes itself:
- await traefik_redis.create_service(
- "pm-manager-service",
- "http",
- "loadBalancer",
- {
- "servers": [{"url": "http://manager:5000/"}],
- },
- )
- await traefik_redis.create_router(
- "http",
- {
- "entrypoints": ["http"],
- "name": "pm-manager-router",
- "service": "pm-manager-service@redis",
- "rule": "PathPrefix(`/`)",
- },
- )
await redis_client.set(MANAGER_ROUTER_INSTALLED_KEY, "True")
PowerDNS writes its API key into its configuration at boot, instead of freezing it in the image. It refuses to start when the variable is missing:
-echo "Starting pdns_server"
-/usr/sbin/pdns_server
+if [[ -n "$PDNS_API_KEY" ]];then
+ echo "Starting pdns_server"
+ echo "api-key=$PDNS_API_KEY" >> /etc/powerdns/pdns.conf
+ /usr/sbin/pdns_server
+else
+ echo "error :\$PDNS_API_KEY missing from environment variable"
+fi
rotatedb, a rotation script for the SQLite log database: compressed dump, purge of entries older than one day, vacuum, then removal of the backups older than thirty days:
#!/usr/bin/env bash
# DB rotation
vDataDir="/home/docker/.local/share/docker/volumes/all-logs/_data"
vBackupFilename="logs.$(date +"%Y%m%d-%H%M%S").sql.bz2"
# Get path (unused here) and docker socket from docker user's ~/.profile
source /home/docker/.profile
# Stop logbeat container
/home/docker/bin/docker-compose -f /home/docker/pwn-machine/docker-compose-h2h.yml stop logbeat
# Dump sqlite db
/usr/bin/sqlite3 -list "$vDataDir/logs.db" ".dump" | /usr/bin/bzip2 > "$vDataDir/$vBackupFilename"
/usr/bin/chmod 0600 "$vDataDir/$vBackupFilename"
/usr/bin/chown docker:docker "$vDataDir/$vBackupFilename" #If script is copied to /etc/cron.daily instead of docker user crontab.
# Remove all entries older than 1 day
/usr/bin/sqlite3 -list "$vDataDir/logs.db" "delete from docker_logs where date < strftime('%s','now','-1 days');"
/usr/bin/sqlite3 -list "$vDataDir/logs.db" "delete from powerdns_logs where date < strftime('%s','now','-1 days');"
/usr/bin/sqlite3 -list "$vDataDir/logs.db" "delete from traefik_logs where date < strftime('%s','now','-1 days');"
# Vacuum db
/usr/bin/sqlite3 -list "$vDataDir/logs.db" "vacuum;"
# Remove all backup files older than 30 days
/usr/bin/find "$vDataDir" -type f -name "*.sql.bz2" -mtime +30 -delete
# Start logbeat container
/home/docker/bin/docker-compose -f /home/docker/pwn-machine/docker-compose-h2h.yml start logbeat
The images
The Dockerfiles move to pinned bases and batch the upgrades, for more reproducible images.
The manager builds the front then the API in a single RUN and cleans the apt lists:
-#FROM node:15.10.0 as build-front
FROM node:16.14.0-bullseye-slim as build-front
WORKDIR /app
-COPY front/package*.json ./
-RUN yarn install
COPY front/ ./
-RUN yarn build
+RUN yarn install && yarn build
FROM python:3.9
WORKDIR /usr/src/app
-RUN apt update && apt install -y docker.io
-#RUN pip install --upgrade pip poetry
-RUN pip install poetry
-COPY back/pyproject.toml ./
-RUN poetry install
-COPY back/ ./
COPY --from=build-front /app/dist/spa/ static/
+COPY back/ ./
+
+RUN apt-get update && apt-get upgrade -y && apt-get -y install docker.io && \
+ pip install poetry && poetry update && poetry install && \
+ rm -rf /tmp/* /var/tmp/* /var/lib/apt/lists/*
+
EXPOSE 5000/tcp
CMD poetry run uvicorn --host 0.0.0.0 --port 5000 --log-level warning app:app
The logbeat installs sqlite without cache:
FROM node:16-alpine as common
-
WORKDIR /usr/src/app
COPY package.json yarn.lock ./
-RUN yarn install
+RUN yarn install && yarn upgrade
FROM common as builder
ADD . ./
RUN yarn build
FROM common
-RUN apk update && apk add sqlite
-COPY --from=builder /usr/src/app/dist/ ./
+COPY --from=builder /usr/src/app/dist/ ./
+RUN apk update && apk upgrade --no-cache && apk add --no-cache sqlite
CMD yarn serve
PowerDNS pins its base to ubuntu:focal and fixes its EXPOSE (53/udp, where v2 exposed 53/tcp twice); the API key leaves the image build:
-FROM ubuntu:latest
-ARG API_KEY
+FROM ubuntu:focal
# Setup powerdns repositories
-RUN apt-get update && apt-get install curl gnupg dnsutils rsyslog -y
+RUN apt-get update && apt-get upgrade -y && apt-get install -y curl gnupg dnsutils rsyslog
@@
-RUN apt-get install pdns-server pdns-backend-mysql mariadb-client -y
+RUN apt-get install -y pdns-server pdns-backend-mysql mariadb-client
@@
-RUN echo "\napi-key=$API_KEY" >> /etc/powerdns/pdns.conf
+#RUN echo "\napi-key=$API_KEY" >> /etc/powerdns/pdns.conf
COPY start.sh ./
-EXPOSE 53/tcp 53/tcp 8081/tcp
+EXPOSE 53/udp 53/tcp 8081/tcp
The dedicated Traefik image disappears: dockers/traefik/Dockerfile and traefik.toml (62 lines) are removed. TLS and the certificates leave the image and become mounted files (see the compose file below).
The pipeline
The GitLab CI builds each dockers/<tool> and pushes it to the private registry, tagged on the short SHA; two manual jobs promote latest and the version tags:
image: docker:latest
services:
- docker:dind
stages:
- build
- release
before_script:
- echo "$REGISTRY_PASS" | docker login $REGISTRY_URL -u $REGISTRY_USER --password-stdin
workflow:
rules:
- changes:
- dockers/**/*
build:
stage: build
when: always
tags:
- docker20
script:
- >
for i in $(ls dockers);do
docker build -t $REGISTRY_URL/$i:$CI_COMMIT_SHORT_SHA -f dockers/$i/Dockerfile dockers/$i
docker push $REGISTRY_URL/$i:$CI_COMMIT_SHORT_SHA
done
only:
- H2H
except:
- tags
release-tags:
stage: release
when: manual
tags:
- docker20
script:
- >
for i in $(ls dockers);do
docker pull $REGISTRY_URL/$i:latest
docker tag $REGISTRY_URL/$i:latest $REGISTRY_URL/$i:CI_COMMIT_REF_NAME
docker push $REGISTRY_URL/$i:$CI_COMMIT_REF_NAME
done
only:
- tags
except:
- branches
release-latest:
stage: release
when: manual
tags:
- docker20
script:
- >
for i in $(ls dockers);do
docker pull $REGISTRY_URL/$i:$CI_COMMIT_SHORT_SHA
docker tag $REGISTRY_URL/$i:$CI_COMMIT_SHORT_SHA $REGISTRY_URL/$i:latest
docker push $REGISTRY_URL/$i:latest
done
only:
- H2H
The Salt
Two Salt states. The first one, pwn-machine, deploys the application: it installs the dependencies, clones the repository on the H2H branch, writes the environment and the log rotation, then starts the stack:
{% set docker_user = salt["pillar.get"]("docker_rootless:user","docker") %}
git:
pkg.installed
sqlite3:
pkg.installed
pwn-machine:
git.latest:
- name: https://git.example.internal/pwn-machine.git
- target: /home/{{ docker_user }}/pwn-machine
- branch: H2H
- user: {{ docker_user }}
- require:
- pkg: git
#TODO: env1 = salt["pillar.get"]("pwn-machine:env1")
/home/{{ docker_user }}/pwn-machine/.env
file.managed:
- user: {{ docker_user }}
- group: {{ docker_user }}
- mode: '0400'
- template: jinja2
- source: salt://pwn-machine/files/env.j2
- require:
- git: pwn-machine
/etc/logrotate.d/pwn-machine:
file.managed:
- user: root
- group: root
- mode: '0640'
- template: jinja2
- source: salt://pwn-machine/files/logrotate.j2
- default:
docker_user: {{ docker_user }}
- require:
- git: pwn-machine
#TODO: file.managed template slat://pwn-machine/file/cron.j2
/etc/cron.daily/pwn-machine:
file.copy:
- source: /home/{{ docker_user }}/pwn-machine/rotatedb
- user: root
- group: root
- mode: '0750'
- require:
- git: pwn-machine
docker-compose_up:
cmd.run:
- name: cd pwn-machine; docker-compose -f docker-compose-h2h.yml up -d
- runas: {{ docker_user }}
- require:
- file: /home/{{ docker_user }}/pwn-machine/.env
The .env (mode 0400) and the logrotate configuration come from Salt fileserver templates (env.j2, logrotate.j2), not shown here.
The second one, pwnmachine, describes the VM firewall (registry allowed out, VPN access in, proxy) and the manager’s TLS certificates:
firewall_output_registry:
iptables.append:
- table: filter
- chain: OUTPUT
- jump: ACCEPT
- match: state
- connstate: NEW
- dport: 443
- proto: tcp
- sport: 10225:65535
- destination: registry.example.internal
- save: True
firewall_allow_vpn_adm_tcp:
iptables.append:
- chain: INPUT
- jump: ACCEPT
- connstate: NEW
- table: filter
- match: multiport
- dport: 53,80,443
- in-interface: {{ salt["pillar.get"]("vminfo:primary_interface") }}
- proto: tcp
- save: True
- source: {{ salt["pillar.get"]("infra:vpnfront_network") }}
firewall_allow_vpn_adm_udp:
iptables.append:
- chain: INPUT
- jump: ACCEPT
- connstate: NEW
- table: filter
- match: state
- dport: 53
- sport: 1025:65535
- in-interface: {{ salt["pillar.get"]("vminfo:primary_interface") }}
- proto: udp
- save: True
- source: {{ salt["pillar.get"]("infra:vpnfront_network") }}
firewall_allow_vpn_dev_http_s:
iptables.append:
- chain: INPUT
- jump: ACCEPT
- connstate: NEW
- table: filter
- match: multiport
- dport: 80,443
- in-interface: {{ salt["pillar.get"]("vminfo:primary_interface") }}
- proto: tcp
- save: True
- source: 172.16.0.0/12
iptables_output_proxy:
iptables.append:
- table: filter
- chain: OUTPUT
- jump: ACCEPT
- match: state
- connstate: NEW
- dport: 3128
- proto: tcp
- sport: 1025:65535
- save: True
- destination: {{ salt["pillar.get"]("infra:proxy")[0] }}
/home/docker/.local/share/ssl:
file.directory:
- user: docker
- group: docker
- makedirs: True
- recurse:
- user
- group
pwnmachine.example.internal.crt:
file.managed:
- name: /home/docker/.local/share/ssl/pwnmachine.example.internal.crt
- user: docker
- group: docker
- mode: 0640
- contents_pillar: ssl:pwnmachine.example.internal:cert:contents
- require:
- file: /home/docker/.local/share/ssl
pwnmachine.example.internal.key:
file.managed:
- name: /home/docker/.local/share/ssl/pwnmachine.example.internal.key
- user: docker
- group: docker
- mode: 0640
- contents_pillar: ssl:pwnmachine.example.internal:key:contents
- require:
- file: /home/docker/.local/share/ssl
#unattend upgrades
unattended-upgrades:
pkg.installed
/etc/apt/apt.conf.d/50unattended-upgrades:
file.managed:
- user: root
- group: root
- mode: '0644'
- replace: true
- source: salt://docker/files/50unattended-upgrades
- require:
- pkg: unattended-upgrades
unattended-upgrades-debconf:
debconf.set:
- name: unattended-upgrades
- data:
'unattended-upgrades/enable_auto_updates': {'type': 'boolean', 'value': 'true'}
- require:
- pkg: unattended-upgrades
unattended-upgrades-reconfigure:
cmd.run:
- name: dpkg-reconfigure -f noninteractive unattended-upgrades
- onchanges:
- debconf: unattended-upgrades-debconf
For the rootless daemon itself, see Docker rootless .
The compose file
The internal deployment uses docker-compose-h2h.yml: the same services as v2, but the images come from the private registry, the manager router is restricted by an IP whitelist on a dedicated domain. Traefik loads an external certificates file:
version: "3.8"
x-pm-settings:
- &DOCKER_USER_UID "${DOCKER_USER_UID?Please provide docker user UID}"
- &DOCKER_USER_HOME "${DOCKER_USER_HOME?Please provide docker user home dir}"
- &PM_DOMAIN "${PM_DOMAIN?Please provide domain name}"
- &PM_WHITELIST "${PM_WHITELIST?Please provide ip whitelist}"
- &LETS_ENCRYPT_EMAIL "${LETS_ENCRYPT_EMAIL?Please provide an email for lets encrypt}"
- &PDNS_MYSQL_ROOT_PASSWORD "${PDNS_MYSQL_ROOT_PASSWORD?Please provide a root passws for mysql database}"
- &PDNS_MYSQL_DATABASE "pdns"
- &PDNS_API_KEY "${PDNS_API_KEY?Please provide the API key}"
services:
powerdns-db:
container_name: pm_powerdns-db
image: mariadb:10.7.1
environment:
MYSQL_ROOT_PASSWORD: *PDNS_MYSQL_ROOT_PASSWORD
MYSQL_DATABASE: *PDNS_MYSQL_DATABASE
networks:
- dns-net
volumes:
- powerdns-data:/var/lib/mysql
restart: unless-stopped
powerdns:
container_name: pm_powerdns
image: registry.example.internal/pwn-machine/powerdns:latest
environment:
PDNS_API_KEY: *PDNS_API_KEY
MYSQL_HOST: powerdns-db
MYSQL_USER: "root"
MYSQL_PASSWORD: *PDNS_MYSQL_ROOT_PASSWORD
MYSQL_DATABASE: *PDNS_MYSQL_DATABASE
depends_on:
- powerdns-db
- traefik
networks:
- dns-net
volumes:
- powerdns-logs:/logs/pdns
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.tcp.routers.dns.entrypoints=dns"
- "traefik.tcp.routers.dns.rule=HostSNI(`*`)"
- "traefik.tcp.routers.dns.service=powerdns-pwn-machine@docker"
- "traefik.udp.routers.dns.entrypoints=dns-udp"
- "traefik.udp.routers.dns.service=powerdns-pwn-machine@docker"
redis:
container_name: pm_redis
image: redis:6.2.6
networks:
- redis-net
command:
- /bin/sh
- -c
- |
redis-server --appendonly yes &
sleep .5
redis-cli SET traefik 1 >/dev/null
wait
volumes:
- redis-data:/data
restart: unless-stopped
traefik:
container_name: pm_traefik
image: traefik:v2.6.1
ports:
- "53:53"
- "53:53/udp"
- "80:80"
- "443:443"
command:
- "--log.level=WARN"
- "--accesslog=true"
- "--accesslog.filepath=/logs/traefik/traefik.json"
- "--accesslog.format=json"
- "--accesslog.fields.headers.defaultmode=keep"
- "--accesslog.fields.headers.names.X-Forwaded-For=keep"
- "--api=true"
- "--api.insecure=true"
- "--api.dashboard=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.redis=true"
- "--providers.redis.endpoints=redis:6379"
- "--providers.redis.rootkey=traefik"
- "--providers.file.directory=/etc/traefik/"
- "--providers.file.watch=true"
- "--entrypoints.http.address=:80"
- "--entrypoints.https.address=:443"
- "--entrypoints.dns.address=:53"
- "--entrypoints.dns-udp.address=:53/udp"
- "--certificatesResolvers.letsencrypt-staging-http=true"
- "--certificatesResolvers.letsencrypt-staging-http.acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory"
- "--certificatesResolvers.letsencrypt-staging-http.acme.storage=/tmp/acme-staging-http.json"
- "--certificatesResolvers.letsencrypt-staging-http.acme.email=${LETS_ENCRYPT_EMAIL}"
- "--certificatesResolvers.letsencrypt-staging-http.acme.httpchallenge=true"
- "--certificatesResolvers.letsencrypt-staging-http.acme.httpchallenge.entrypoint=http"
- "--certificatesResolvers.letsencrypt-staging-dns=true"
- "--certificatesResolvers.letsencrypt-staging-dns.acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory"
- "--certificatesResolvers.letsencrypt-staging-dns.acme.storage=/tmp/acme-staging-dns.json"
- "--certificatesResolvers.letsencrypt-staging-dns.acme.email=${LETS_ENCRYPT_EMAIL}"
- "--certificatesResolvers.letsencrypt-staging-dns.acme.dnschallenge=true"
- "--certificatesResolvers.letsencrypt-staging-dns.acme.dnschallenge.provider=pdns"
- "--certificatesResolvers.letsencrypt-staging-dns.acme.dnschallenge.delayBeforeCheck=5"
- "--certificatesResolvers.letsencrypt-http=true"
- "--certificatesResolvers.letsencrypt-http.acme.caServer=https://acme-v02.api.letsencrypt.org/directory"
- "--certificatesResolvers.letsencrypt-http.acme.storage=/tmp/acme-http.json"
- "--certificatesResolvers.letsencrypt-http.acme.email=${LETS_ENCRYPT_EMAIL}"
- "--certificatesResolvers.letsencrypt-http.acme.httpchallenge=true"
- "--certificatesResolvers.letsencrypt-http.acme.httpchallenge.entrypoint=http"
- "--certificatesResolvers.letsencrypt-dns=true"
- "--certificatesResolvers.letsencrypt-dns.acme.caServer=https://acme-v02.api.letsencrypt.org/directory"
- "--certificatesResolvers.letsencrypt-dns.acme.storage=/tmp/acme-dns.json"
- "--certificatesResolvers.letsencrypt-dns.acme.email=${LETS_ENCRYPT_EMAIL}"
- "--certificatesResolvers.letsencrypt-dns.acme.dnschallenge=true"
- "--certificatesResolvers.letsencrypt-dns.acme.dnschallenge.provider=pdns"
- "--certificatesResolvers.letsencrypt-dns.acme.dnschallenge.delayBeforeCheck=5"
volumes:
- "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
- "~/.config/traefik/certs.yml:/etc/traefik/certs.yml:ro"
- "~/.local/share/ssl:/etc/traefik/ssl:ro"
- traefik-data:/data:rw
- traefik-logs:/logs/traefik
environment:
TRAEFIK_PROVIDERS_REDIS_ENDPOINTS: "redis:6379"
TRAEFIK_PROVIDERS_REDIS_ROOTKEY: "traefik"
PDNS_API_KEY: *PDNS_API_KEY
PDNS_API_URL: "http://powerdns:8081/"
depends_on:
- redis
networks:
- traefik-net
- dns-net
- redis-net
restart: unless-stopped
logbeat:
container_name: pm_logbeat
image: registry.example.internal/pwn-machine/logbeat:latest
user: root
network_mode: none
volumes:
- "~/.local/share/docker:/var/lib/docker:ro"
- "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
- powerdns-logs:/logs/pdns
- traefik-logs:/logs/traefik
- all-logs:/logs/all
restart: unless-stopped
manager:
container_name: pm_manager
image: registry.example.internal/pwn-machine/manager:latest
depends_on:
- powerdns
- redis
- traefik
volumes:
- "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
- "~/.local/share/docker/volumes:${DOCKER_USER_HOME}/.local/share/docker/volumes"
- all-logs:/logs/all
environment:
PM_REDIS_HOST: "redis://redis"
PM_TRAEFIK_HTTP_API: "http://traefik:8080/api"
PM_TRAEFIK_REDIS_ROOT: "traefik"
PM_POWERDNS_HTTP_API: "http://powerdns:8081"
PM_POWERDNS_HTTP_API_KEY: *PDNS_API_KEY
networks:
- redis-net
- dns-net
- traefik-net
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.middlewares.wl.ipwhitelist.sourcerange=${PM_WHITELIST}"
- "traefik.http.middlewares.redir.redirectscheme.scheme=https"
- "traefik.http.middlewares.redir.redirectscheme.permanent=true"
- "traefik.http.routers.redir-https.entrypoints=http"
- "traefik.http.routers.redir-https.rule=Host(`pwnmachine.${PM_DOMAIN}`) && PathPrefix(`/`)"
- "traefik.http.routers.redir-https.middlewares=redir"
- "traefik.http.routers.redir-https.service=noop@internal"
- "traefik.http.routers.redir-https.priority=2147483646"
- "traefik.http.routers.pm-manager.entrypoints=https"
- "traefik.http.routers.pm-manager.rule=Host(`pwnmachine.${PM_DOMAIN}`) && PathPrefix(`/`)"
- "traefik.http.routers.pm-manager.tls=true"
- "traefik.http.routers.pm-manager.middlewares=wl"
- "traefik.http.routers.pm-manager.service=manager-pwn-machine@docker"
- "traefik.http.routers.pm-manager.priority=2147483646"
volumes:
powerdns-logs:
name: powerdns-logs
powerdns-data:
name: powerdns-data
redis-data:
name: redis-data
traefik-data:
name: traefik-data
traefik-logs:
name: traefik-logs
all-logs:
name: all-logs
networks:
redis-net:
name: pm_redis-net
dns-net:
name: pm_dns-net
traefik-net:
name: pm_traefik-net
The rootless variant of the same file, without a registry (each image is built locally) and without a whitelist, differs only by this:
@@ -3,11 +3,9 @@ version: "3.8"
x-pm-settings:
- &DOCKER_USER_UID "${DOCKER_USER_UID?Please provide docker user UID}"
- - &DOCKER_USER_HOME "${DOCKER_USER_HOME?Please provide docker user home dir}"
- - &PM_DOMAIN "${PM_DOMAIN?Please provide domain name}"
- - &PM_WHITELIST "${PM_WHITELIST?Please provide ip whitelist}"
- - &LETS_ENCRYPT_EMAIL "${LETS_ENCRYPT_EMAIL?Please provide an email for lets encrypt}"
- - &PDNS_MYSQL_ROOT_PASSWORD "${PDNS_MYSQL_ROOT_PASSWORD?Please provide a root passws for mysql database}"
+ - &DOCKER_USER_HOME "${DOCKER_USER_HOME?Please provide docker user UID}"
+ - &LETS_ENCRYPT_EMAIL "${LETS_ENCRYPT_EMAIL?Please provide an email for let's encrypt}"
+ - &PDNS_MYSQL_ROOT_PASSWORD "pdns"
- &PDNS_MYSQL_DATABASE "pdns"
- - &PDNS_API_KEY "${PDNS_API_KEY?Please provide the API key}"
+ - &PDNS_API_KEY "pdns-secret"
services:
@@ -26,5 +24,6 @@ services:
powerdns:
container_name: pm_powerdns
- image: registry.example.internal/pwn-machine/powerdns:latest
+ build:
+ context: ./dockers/powerdns
environment:
PDNS_API_KEY: *PDNS_API_KEY
@@ -93,7 +92,4 @@ services:
- "--providers.redis.rootkey=traefik"
- - "--providers.file.directory=/etc/traefik/"
- - "--providers.file.watch=true"
-
- "--entrypoints.http.address=:80"
- "--entrypoints.https.address=:443"
@@ -132,6 +128,4 @@ services:
volumes:
- "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
- - "~/.config/traefik/certs.yml:/etc/traefik/certs.yml:ro"
- - "~/.local/share/ssl:/etc/traefik/ssl:ro"
- traefik-data:/data:rw
- traefik-logs:/logs/traefik
@@ -143,4 +137,5 @@ services:
depends_on:
- redis
+ - powerdns
networks:
- traefik-net
@@ -151,10 +146,11 @@ services:
logbeat:
container_name: pm_logbeat
- image: registry.example.internal/pwn-machine/logbeat:latest
+ build:
+ context: ./dockers/logbeat
user: root
network_mode: none
volumes:
- "~/.local/share/docker:/var/lib/docker:ro"
- - "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock:ro"
+ - "/run/user/${DOCKER_USER_UID}/docker.sock:/var/run/docker.sock"
- powerdns-logs:/logs/pdns
- traefik-logs:/logs/traefik
@@ -164,5 +160,7 @@ services:
manager:
container_name: pm_manager
- image: registry.example.internal/pwn-machine/manager:latest
+ image: "pm-manager"
+ build:
+ context: ./dockers/manager
depends_on:
- powerdns
@@ -186,17 +184,16 @@ services:
labels:
- "traefik.enable=true"
- - "traefik.http.middlewares.wl.ipwhitelist.sourcerange=${PM_WHITELIST}"
- "traefik.http.middlewares.redir.redirectscheme.scheme=https"
- "traefik.http.middlewares.redir.redirectscheme.permanent=true"
- "traefik.http.routers.redir-https.entrypoints=http"
- - "traefik.http.routers.redir-https.rule=Host(`pwnmachine.${PM_DOMAIN}`) && PathPrefix(`/`)"
+ - "traefik.http.routers.redir-https.rule=PathPrefix(`/`)"
- "traefik.http.routers.redir-https.middlewares=redir"
- "traefik.http.routers.redir-https.service=noop@internal"
- "traefik.http.routers.redir-https.priority=2147483646"
- - "traefik.http.routers.pm-manager.entrypoints=https"
- - "traefik.http.routers.pm-manager.rule=Host(`pwnmachine.${PM_DOMAIN}`) && PathPrefix(`/`)"
- - "traefik.http.routers.pm-manager.tls=true"
- - "traefik.http.routers.pm-manager.middlewares=wl"
- - "traefik.http.routers.pm-manager.service=manager-pwn-machine@docker"
+ - "traefik.http.routers.pm-manager-https.entrypoints=https"
+ - "traefik.http.routers.pm-manager-https.rule=PathPrefix(`/`)"
+ - "traefik.http.routers.pm-manager-https.tls=true"
+ - "traefik.http.routers.pm-manager-https.tls.certresolver=letsencrypt-staging-http"
+ - "traefik.http.routers.pm-manager-https.service=manager-pwn-machine@docker"
- "traefik.http.routers.pm-manager.priority=2147483646"
What it is worth
The self-hosted pentest workstation is the bug bounty equivalent of what the CI status light is for development: a setup that runs permanently and tells you what is happening. PwnMachine provides the infrastructure (DNS, reverse proxy, interface) and each pentest tool plugs into it.
PwnMachine is still open source (github.com/yeswehack/pwn-machine) and still active. The concept has not moved: a pentest workstation that deploys in one command, with DNS and TLS built in. A web interface adds tools: a Docker Hub image to pick, a container to create.