guest@6admin.io:~$

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, puis pm setup et pm 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. Les dockers/ 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.