Powrót do strony głównej

FIFO dla jednorazowych sekretów Docker Swarm

Artykuł opisuje metodę zabezpieczenia sekretów Docker Swarm przez potoki nazwane FIFO. Sekrety przekazywane są jednorazowo do aplikacji, wykluczając dostęp przez docker exec. Implementacja oparta na dwóch kontenerach ze wspólnym tomem tmpfs.

Jednorazowe sekrety Docker Swarm: FIFO przeciwko docker exec
Advertisement 728x90

Jednorazowe sekrety w Docker Swarm poprzez kanały FIFO

Docker Swarm montuje sekrety w /run/secrets/ jako tylko do odczytu tmpfs. Każdy z dostępem do docker exec może je odczytać w dowolnym momencie. Rozwiązanie: użyć nazwanych kanałów (FIFO) do jednorazowego przekazywania sekretów przy uruchomieniu kontenera.

Problemy standardowego mechanizmu

Sekrety są przechowywane w Raft log menedżera-węzła, przekazywane przez mTLS do worker, montowane w kontenerze. Definicja w docker-compose.yml:

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

Odczytywanie w aplikacji:

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

Dostęp przez docker exec:

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

Dostęp na hoście:

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

Dlaczego standardowe metody nie działają

  • rm /truncate/chmod: read-only tmpfs
  • umount: wymaga CAP_SYS_ADMIN
  • Zmienne środowiskowe: widoczne w /proc/1/environ, dziedziczone przez procesy potomne
  • Zewnętrzne menedżery (Vault): dodatkowa infrastruktura

Zasada działania FIFO

Nazwany kanał:

Google AdInline article slot
  • Zapisywanie jest zablokowane bez czytelnika
  • Odczytywanie jest zablokowane bez pisarza
  • Dane są konsumowane przy pierwszym odczycie
  • Powtórne odczytywanie zawiesza się

Demonstracja:

$ mkfifo /tmp/my_pipe
echo "secret_value" > /tmp/my_pipe  # blokuje się
cat /tmp/my_pipe                    # secret_value, dane usunięte
cat /tmp/my_pipe                    # zawiesza się

Architektura rozwiązania

Dwa kontenery, wspólny tom tmpfs z FIFO:

  • secret-injector: tworzy FIFO, zapisuje sekrety, kończy działanie
  • app: odczytuje z FIFO jednorazowo, sekrety tylko w pamięci

Kontener app nie ma /run/secrets/.

Google AdInline article slot

Implementacja

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

Sekwencja wykonania

  • secret-injector:

- mkfifo /shared/db_password /shared/api_key

- cat /run/secrets/... > /shared/... (blokuje się)

- wait utrzymuje kontener

  • app:

- DB_PASSWORD=$(cat /shared/db_password) → odblokowuje pisarza

- Dane są skonsumowane z FIFO

  • secret-injector kończy działanie (restart_policy: none)
  • app działa z sekretami w pamięci

Rezultat dla atakującego

W app:

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

W secret-injector:

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

Hardening: deskryptory plików

Unikaj zmiennych środowiskowych. Przekazuj przez FD:

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
}

Sekrety istnieją tylko w pamięci procesu.

Co jest ważne

  • Sekrety są odczytywane jednorazowo przez FIFO, dane są konsumowane
  • Kontener secret-injector kończy działanie, docker exec jest niemożliwy
  • App-kontener nie ma /run/secrets/
  • Używaj deskryptorów plików zamiast zmiennych środowiskowych
  • Rozwiązanie działa bez zewnętrznych serwisów

— Editorial Team

Advertisement 728x90

Czytaj dalej