Zurück zur Startseite

FIFO für einmalige Docker Swarm-Geheimnisse

Der Artikel beschreibt eine Methode zum Schutz von Docker Swarm-Geheimnissen über benannte FIFO-Kanäle. Geheimnisse werden einmal an die Anwendung übertragen, wodurch der Zugriff über docker exec ausgeschlossen wird. Implementierung basierend auf zwei Containern mit einem gemeinsamen tmpfs-Volume.

Einmalige Docker Swarm-Geheimnisse: FIFO gegen docker exec
Advertisement 728x90

Einmalige Geheimnisse in Docker Swarm mit FIFO-Pipes

Docker Swarm mountet Geheimnisse unter /run/secrets/ als schreibgeschützte tmpfs-Volumes. Jeder mit docker exec-Zugriff kann sie jederzeit lesen. Die Lösung: Named Pipes (FIFOs) für einmalige Geheimnisübergabe beim Container-Start.

Probleme des Standardansatzes

Geheimnisse werden im Raft-Log auf Manager-Nodes gespeichert, via mTLS an Worker gesendet und in Container gemountet. So wird es in docker-compose.yml definiert:

version: "3.8"
services:
  app:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
secrets:
  db_password:
    external: true
  api_key:
    external: true

Lesen in der App:

Google AdInline article slot
with open("/run/secrets/db_password") as f:
    db_password = f.read().strip()

Zugriff via docker exec:

$ docker exec -it <container_id> cat /run/secrets/db_password
SuperSecretPassword123

Zugriff auf dem Host:

sudo cat /var/lib/docker/containers/<container_id>/mounts/secrets/ioz4pc6q8fjsfhem4pwsr35u6
SuperSecretPassword123

Warum Standard-Workarounds scheitern

  • rm /truncate/chmod: schreibgeschütztes tmpfs
  • umount: erfordert CAP_SYS_ADMIN
  • Umgebungsvariablen: sichtbar in /proc/1/environ, vererbt an Child-Prozesse
  • Externe Manager (Vault): zusätzliche Infrastruktur

Funktionsweise von FIFO-Pipes

Named Pipes haben diese Eigenschaften:

Google AdInline article slot
  • Schreibvorgänge blockieren ohne Leser
  • Lesevorgänge blockieren ohne Schreiber
  • Daten werden beim ersten Lesen verbraucht
  • Folgende Lesevorgänge hängen

Demo:

$ mkfifo /tmp/my_pipe
echo "secret_value" > /tmp/my_pipe  # blockiert
cat /tmp/my_pipe                    # secret_value, Daten weg
cat /tmp/my_pipe                    # hängt

Lösungsarchitektur

Zwei Container teilen ein tmpfs-Volume mit FIFOs:

  • secret-injector: erstellt FIFOs, schreibt Geheimnisse, beendet sich
  • app: liest einmal aus FIFOs, hält Geheimnisse nur im Speicher

Der App-Container hat kein /run/secrets/.

Google AdInline article slot

Implementierung

docker-compose.yml

version: "3.8"
services:
  secret-injector:
    image: alpine:3.19
    secrets:
      - db_password
      - api_key
    volumes:
      - secrets_pipe:/shared
    entrypoint: ["sh", "-c", "
      mkfifo /shared/db_password /shared/api_key &&
      cat /run/secrets/db_password > /shared/db_password &
      cat /run/secrets/api_key > /shared/api_key &
      wait
    "]
    deploy:
      restart_policy:
        condition: none
      resources:
        limits:
          memory: 16M
  app:
    image: myapp:latest
    volumes:
      - secrets_pipe:/shared:ro
    entrypoint: ["sh", "-c", "
      DB_PASSWORD=$(cat /shared/db_password) &&
      API_KEY=$(cat /shared/api_key) &&
      exec myapp
    "]
    depends_on:
      - secret-injector
volumes:
  secrets_pipe:
    driver: local
    driver_opts:
      type: tmpfs
      device: tmpfs
      o: size=1m,noexec,nosuid
secrets:
  db_password:
    external: true
  api_key:
    external: true

Ablaufsequenz

  • secret-injector:

- mkfifo /shared/db_password /shared/api_key

- cat /run/secrets/... > /shared/... (blockiert)

- wait hält Container am Leben

  • app:

- DB_PASSWORD=$(cat /shared/db_password) → entblockt Schreiber

- Daten aus FIFO verbraucht

  • secret-injector beendet sich (restart_policy: none)
  • app läuft mit Geheimnissen im Speicher

Was ein Angreifer sieht

In app:

$ docker exec -it <app_container> sh
$ ls /run/secrets/
ls: /run/secrets/: No such file or directory
$ cat /shared/db_password  # hängt
^C

In secret-injector:

$ docker exec -it <injector_container> sh
Error: container is not running

Absicherung: Dateideskriptoren nutzen

Umgebungsvariablen überspringen. Über Dateideskriptoren (FDs) übergeben:

Entrypoint:

#!/bin/sh
exec 3< /shared/db_password
exec 4< /shared/api_key
exec myapp --db-password-fd=3 --api-key-fd=4

Python:

import os

def read_secret_from_fd(fd_num: int) -> str:
    with os.fdopen(fd_num, 'r') as f:
        return f.read().strip()

db_password = read_secret_from_fd(3)
api_key = read_secret_from_fd(4)

Go:

package main

import (
	"fmt"
	"io"
	"os"
)

func readSecretFromFD(fd int) (string, error) {
	f := os.NewFile(uintptr(fd), fmt.Sprintf("fd-%d", fd))
	if f == nil {
		return "", fmt.Errorf("invalid file descriptor: %d", fd)
	}
	defer f.Close()
	data, err := io.ReadAll(f)
	if err != nil {
		return "", err
	}
	return string(data), nil
}

Geheimnisse leben nur im Prozessspeicher.

Wichtige Erkenntnisse

  • Geheimnisse einmal via FIFO gelesen, Daten verbraucht
  • secret-injector beendet sich, kein docker exec möglich
  • App hat kein /run/secrets/
  • Dateideskriptoren statt Umgebungsvariablen bevorzugen
  • Keine externen Dienste nötig

— Editorial Team

Advertisement 728x90

Weiterlesen