PwnMachine : la station de bug bounty auto-hébergée
Quand tu fais du bug bounty, tu as besoin d’un environnement: un DNS que tu contrôles (pour les callbacks), un reverse proxy (pour exposer les outils) et une interface web pour orchestrer. Le tout sur une machine que tu peux reinstaller en dix minutes. PwnMachine est ce montage, packagé en Docker Compose.
Le concept
PwnMachine est une solution d’auto-hébergement basée sur Docker, visant à fournir une station de pwnage facile à utiliser pour les chasseurs de bugs. L’installation de base inclut une interface web, un serveur DNS et un reverse proxy. Un docker-compose up --build -d et c’est debout.
Les composants:
- Traefik : le reverse proxy qui route vers chaque outil et gère les certificats Let’s Encrypt
- PowerDNS : le serveur DNS pour les callbacks (les OOB interactions, les DNS rebinding)
- L’interface web : un manager qui orchestre les outils de pentest
- Redis : le cache du manager
- logbeat : le shipper de logs
Le montage
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
Après ça, http://your_ip affiche l’interface web. Le DNS pointe vers le serveur PowerDNS et les callbacks (Burp Collaborator, DNS rebinding, XSS callbacks) arrivent sur le serveur que tu contrôles.
Les trois versions
Le projet a connu trois itérations:
- v1 (juillet 2020) : un client Python,
pm.pip install pwn-machine, puispm setupetpm service start: il pilotait une machine distante créée avec docker-machine et y déployait le DNS, le reverse proxy et le serveur web. - v2 (2021) : la réécriture en Docker pur. Plus rien à installer côté client, juste Docker:
docker-compose up --build -d. Lesdockers/sont séparés par outil (manager, PowerDNS, Traefik, logbeat) et une interface web pilote les images et les conteneurs. - la version interne YesWeHack (2022) : le déploiement en production que j’ai monté chez YesWeHack. Une VM dédiée, Docker en rootless, un fichier compose dédié (
docker-compose-h2h.yml) et un provisionnement Salt pour l’environnement et la rotation des logs.
Le grand écart est entre v1 et v2: le client Python et docker-machine ont disparu, il ne reste que Docker.
La mise en production interne
La version interne pousse le montage plus loin que le dépôt public: les images sont construites par la CI et tirées d’un registre privé, le démon Docker tourne en rootless et l’ensemble est provisionné par Salt. Voici la surface réelle de ce déploiement, expurgée des hôtes internes.
Le code
Trois retouches au code de la v2.
Le manager s’enregistrait lui-même dans Traefik au démarrage, en créant un service et un routeur dans le fournisseur Redis. En production le routage est déclaré dans le compose, le manager n’a plus à se publier:
- 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 écrit sa clé d’API dans sa configuration au démarrage, plutôt que de la figer dans l’image. Il refuse de démarrer si la variable manque:
-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, un script de rotation de la base de logs SQLite: dump compressé, purge des entrées de plus d’un jour, vacuum, puis suppression des sauvegardes de plus de trente jours:
#!/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
Les images
Les Dockerfiles passent à des bases figées et regroupent les mises à jour, pour des images plus reproductibles.
Le manager construit le front puis l’API dans un seul RUN et nettoie les listes apt:
-#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
Le logbeat installe sqlite sans 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 fige sa base sur ubuntu:focal et corrige son EXPOSE (53/udp, là où la v2 exposait 53/tcp deux fois); la clé d’API sort de la construction de l’image:
-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
L’image Traefik dédiée disparaît: dockers/traefik/Dockerfile et traefik.toml (62 lignes) sont supprimés. Le TLS et les certificats sortent de l’image et passent en fichiers montés (voir le compose plus bas).
Le pipeline
La CI GitLab construit chaque dockers/<outil> et le pousse dans le registre privé, tagué sur le SHA court; deux jobs manuels promeuvent latest et les tags de version:
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
Le Salt
Deux états Salt. Le premier, pwn-machine, déploie l’application: il installe les dépendances, clone le dépôt sur la branche H2H, écrit l’environnement et la rotation des logs, puis démarre la 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
Le .env (mode 0400) et la configuration logrotate sortent de templates du fileserver Salt (env.j2, logrotate.j2), non montrés ici.
Le second, pwnmachine, décrit le pare-feu de la VM (ouverture du registre en sortie, accès VPN en entrée, proxy) et les certificats TLS du manager:
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
Pour le démon rootless lui-même, voir Docker rootless .
Le compose
Le déploiement interne utilise docker-compose-h2h.yml: les mêmes services que la v2, mais les images viennent du registre privé, le routeur du manager est restreint par une liste blanche d’IP sur un domaine dédié. Traefik charge un fichier de certificats externe:
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
La variante rootless du même fichier, sans registre (chaque image est construite localement) et sans liste blanche, ne diffère que par ceci:
@@ -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"
Ce que ça vaut
La station de pentest auto-hébergée est le pendant du bug bounty de ce que la veilleuse CI est au développement: un setup qui tourne en permanence et qui te dit ce qui se passe. PwnMachine fournit l’infrastructure (DNS, reverse proxy, interface) et chaque outil de pentest se branche dessus.
PwnMachine est toujours open source (github.com/yeswehack/pwn-machine) et toujours actif. Le concept n’a pas bougé: une station de pentest qui se déploie en une commande, avec le DNS et le TLS intégrés. Une interface web ajoute les outils: une image Docker Hub à choisir, un conteneur à créer.