Le Duy Khuong

Chuỗi: ai-security-supply-chain · Phần 2

Năng suất & công cụ dev

Floating Tags & Auto-Updaters — Backdoor Thầm Lặng Trong Docker Stack

`:latest` không phải version. Nó là con trỏ thay đổi theo thời gian. Kết hợp với Watchtower chạy lúc 4 giờ sáng, bạn đã tự tạo một backdoor.

2026-03-2614 phút đọcVI

Phần 2 của 729% hoàn thành

Floating Tags & Auto-Updaters — Backdoor Thầm Lặng Trong Docker Stack

Năm 2013, Docker Hub ra mắt. Mọi image đều có tag :latest mặc định. Ý tưởng rất đơn giản: bạn chạy docker pull nginx mà không cần nhớ số phiên bản — Docker tự lấy bản mới nhất. Tiện lợi. Nhanh gọn. Ai cũng dùng.

Mười năm sau, :latest trở thành một trong những attack vector phổ biến nhất trong container ecosystem. Không phải vì Docker thiết kế sai. Mà vì cách chúng ta hiểu nó sai ngay từ đầu.

Rồi Watchtower ra đời — một container nhỏ gọn, mục tiêu duy nhất là giữ cho các container khác luôn "mới nhất". Poll registry mỗi vài giờ, thấy image mới thì pull về, restart. Vận hành tự động, không cần chạm tay. Hoàn hảo cho homelab, cho side project, cho AI server cá nhân.

Và cũng hoàn hảo cho attacker — vì nó biến mỗi floating tag thành một cánh cửa mở sẵn lúc 4 giờ sáng.


1. Vì Sao :latest Không Phải Là Một Phiên Bản

Hiểu lầm cốt lõi nằm ở đây: :latest trông giống một version, nhưng nó không phải. Nó là một mutable pointer — một con trỏ có thể thay đổi bất cứ lúc nào.

Khi bạn viết:

image: ghcr.io/berriai/litellm:main-latest

Bạn đang nói với Docker: "Lấy cho tôi bất cứ thứ gì đang được gắn tag main-latest tại thời điểm pull." Hôm nay nó trỏ tới bản v1.82.6. Ngày mai maintainer push bản mới — nó trỏ tới v1.83.0. Tuần sau ai đó compromise CI pipeline — nó trỏ tới bản chứa malware. Cùng một tag, nội dung hoàn toàn khác nhau.

Đây là điều quan trọng cần phân biệt:

  • Tag (:latest, :main, :stable, :main-latest): Mutable. Người có quyền push lên registry có thể thay đổi nó trỏ tới image nào, bất cứ lúc nào, không cần thông báo.
  • Digest (sha256:a1b2c3d4...): Immutable. Là hash SHA256 của image manifest. Không thể thay đổi mà không thay đổi nội dung.

Khi bạn dùng tag, bạn đang implicit trust chuỗi sau: registry (GitHub Container Registry, Docker Hub) → maintainer account → CI/CD pipeline của maintainer → mọi dependency trong build process. Bất kỳ mắt xích nào bị compromise, image bạn pull về có thể chứa bất cứ thứ gì.

Và đây không chỉ là lý thuyết. Quay lại bài 1 trong series — vụ LiteLLM bị compromise bắt đầu từ Trivy (security scanner), lan qua CI credentials, và kết quả là image bị nhiễm được push lên registry. Nếu bạn dùng floating tag, bạn sẽ nhận image đó mà không biết.

Một số tag trông có vẻ "an toàn" nhưng vẫn mutable:

TagCó vẻ ổn?Thực tế
:latestKhôngMutable, thay đổi liên tục
:mainKhôngMutable, cập nhật mỗi commit vào main
:main-latestKhôngMutable, tệ hơn vì tên dài gây cảm giác specific
:stableKhôngMutable, maintainer quyết định "stable" nghĩa là gì
:v1.82.6Tốt hơnThường immutable theo convention, nhưng kỹ thuật vẫn có thể bị ghi đè
@sha256:abc...An toàn nhấtImmutable, nội dung cố định

2. Mô Hình Tin Cậy Của Docker Image

Để hiểu tại sao floating tag nguy hiểm, cần biết Docker image hoạt động thế nào bên trong.

OCI Image Spec

Một Docker image theo chuẩn OCI (Open Container Initiative) gồm ba phần:

  • Manifest: File JSON mô tả image — liệt kê các layers, config, platform target. Đây là thứ mà digest hash tham chiếu tới.
  • Layers: Các tầng filesystem xếp chồng. Mỗi layer là một tarball chứa file changes. Layer cuối cùng thường chứa application code.
  • Config: Metadata — ENV, CMD, ENTRYPOINT, labels.

Khi bạn docker pull image:tag, Docker gọi tới registry API, hỏi "tag này trỏ tới manifest nào?". Registry trả về manifest digest. Docker so sánh với local — nếu khác thì pull layers mới. Tag chỉ là lookup key, không phải nội dung.

Watchtower Hoạt Động Thế Nào

Watchtower chạy theo lịch (mặc định mỗi 5 phút, nhiều người cấu hình cron schedule). Quy trình:

  1. Duyệt qua tất cả container đang chạy trên host
  2. Với mỗi container, gọi registry API để lấy manifest digest hiện tại của tag
  3. So sánh digest từ registry với digest local
  4. Nếu khác: pull image mới → stop container cũ → start container mới với image mới
  5. Nếu WATCHTOWER_CLEANUP=true: xóa image cũ (mất forensic evidence)

Bước 4 là nơi vấn đề xảy ra. Container mới khởi động với tất cả environment variables bạn đã cấu hình: API keys, database URLs, master secrets. Nếu image mới chứa malware, malware đó có quyền đọc hết.

Docker Socket — Chìa Khóa Vạn Năng

Để Watchtower làm được việc trên, nó cần mount Docker socket:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock

Docker socket là root-equivalent access trên host. Bất kỳ container nào có Docker socket đều có thể:

  • Tạo container mới với bất kỳ volume mount nào (kể cả / của host)
  • Đọc environment variables của mọi container khác
  • Stop/start/remove container
  • Pull và chạy image tùy ý

Đây là Watchtower Paradox: nó cần Docker socket để hoạt động, nhưng chính quyền đó biến nó thành crown jewel target. Nếu Watchtower bị compromise (qua image bị nhiễm của chính nó, hoặc qua container khác cùng host), attacker có toàn quyền trên host.

Chuỗi Tin Cậy Ngầm

Khi bạn kết hợp floating tag + Watchtower + Docker socket, bạn đã xây dựng chuỗi sau mà không nhận ra:

Maintainer CI/CD → Registry → Watchtower poll → Auto-pull → Auto-restart → Full env access

Mỗi mắt xích là một điểm tin cậy. Bạn tin maintainer không bị hack. Bạn tin registry không bị tamper. Bạn tin CI pipeline không bị inject. Và bạn đã tự động hóa toàn bộ — không có bước nào yêu cầu bạn review trước khi deploy.


3. Cấu Hình: Trước và Sau

Hãy nhìn hai cấu hình cụ thể.

Trước — Nguy hiểm

services:
  litellm:
    image: ghcr.io/berriai/litellm:main-latest
    environment:
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - DATABASE_URL=${DATABASE_URL}
      - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY}
 
  watchtower:
    image: containrrr/watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_SCHEDULE=0 0 4 * * *

Vấn đề:

  • litellm dùng floating tag → nội dung thay đổi không kiểm soát
  • watchtower cũng dùng floating tag (implicit :latest)
  • Docker socket mount với full write access
  • WATCHTOWER_CLEANUP=true xóa image cũ → mất bằng chứng forensic
  • Chạy lúc 4 giờ sáng → không ai giám sát

Sau — Hardened

services:
  litellm:
    image: ghcr.io/berriai/litellm:main-v1.82.6
    environment:
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - DATABASE_URL=${DATABASE_URL}
      - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY}
 
  watchtower:
    image: containrrr/watchtower:1.7.1
    environment:
      - WATCHTOWER_MONITOR_ONLY=true
      - WATCHTOWER_CLEANUP=false
      - WATCHTOWER_NOTIFICATIONS=slack
      - WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL=${SLACK_WEBHOOK}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

Thay đổi quan trọng:

  • Pin version tag: main-v1.82.6 thay vì main-latest
  • Monitor-only mode: Watchtower chỉ kiểm tra và thông báo, không tự pull
  • Cleanup off: Giữ lại image cũ để so sánh nếu cần điều tra
  • Notification: Gửi alert khi phát hiện image mới
  • Read-only socket: :ro giảm attack surface (Watchtower monitor-only không cần write)

Mức Cao Nhất: Pin Bằng SHA256 Digest

Nếu bạn cần bảo mật tối đa:

services:
  litellm:
    image: ghcr.io/berriai/litellm@sha256:9f3e8a7b2c1d5e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e0f2a4b6c8d0e2f

Digest pinning đảm bảo chính xác image nào sẽ chạy. Không ai có thể thay đổi nội dung mà không thay đổi hash. Trade-off là bạn phải cập nhật digest thủ công mỗi khi muốn upgrade.

Để lấy digest hiện tại:

# Từ container đang chạy
docker inspect --format='{{index .RepoDigests 0}}' litellm
 
# Từ registry (không cần pull)
docker manifest inspect ghcr.io/berriai/litellm:main-v1.82.6 \
  | jq -r '.digest'
 
# Hoặc khi pull
docker pull ghcr.io/berriai/litellm:main-v1.82.6 2>&1 \
  | grep "Digest:"

4. Quy Trình: Audit và Migration

Nếu bạn đang chạy Docker stack với floating tags, đây là quy trình chuyển đổi từng bước.

Bước 1: Tìm tất cả floating tags

# Tìm trong docker-compose files
grep -rn -E ':(latest|main|master|stable|dev)(\s|$|")' docker-compose*.yml
 
# Tìm image không có tag (implicit :latest)
grep -rn 'image:' docker-compose*.yml | grep -v -E ':[a-z0-9]+-v?[0-9]|@sha256:'

Bước 2: Ghi lại digest hiện tại (forensic baseline)

Trước khi thay đổi bất cứ thứ gì, ghi lại chính xác image nào đang chạy:

# Liệt kê tất cả container và digest hiện tại
docker ps --format '{{.Names}} {{.Image}}' | while read name image; do
  digest=$(docker inspect --format='{{index .RepoDigests 0}}' "$name" 2>/dev/null)
  echo "$name | $image | $digest"
done > image-baseline-$(date +%Y%m%d).txt

File này là bằng chứng. Nếu sau này phát hiện bị compromise, bạn biết chính xác image nào đang chạy tại thời điểm audit.

Bước 3: Pin từng image

Với mỗi service trong docker-compose:

  1. Tìm phiên bản hiện tại: check release page / changelog của project
  2. Thay floating tag bằng version tag cụ thể
  3. (Optional) Thay bằng digest nếu cần security cao

Bước 4: Cấu hình lại Watchtower

watchtower:
  image: containrrr/watchtower:1.7.1
  environment:
    - WATCHTOWER_MONITOR_ONLY=true
    - WATCHTOWER_CLEANUP=false
    - WATCHTOWER_NOTIFICATIONS=slack
    - WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL=${SLACK_WEBHOOK}
    - WATCHTOWER_POLL_INTERVAL=86400  # 1 lần/ngày là đủ
  volumes:
    - /var/run/docker.sock:/var/run/docker.sock:ro

Monitor-only nghĩa là Watchtower vẫn kiểm tra registry, nhưng chỉ gửi notification khi có image mới — không tự pull hay restart.

Bước 5: Thiết lập quy trình cập nhật thủ công

Khi nhận notification có image mới:

  1. Đọc changelog: Xem có gì thay đổi, có breaking changes không
  2. Kiểm tra security advisories: Project có bị report CVE nào không
  3. Test trên môi trường staging (nếu có): Pull image mới, chạy smoke test
  4. Cập nhật docker-compose: Thay version tag cũ bằng mới
  5. Pull và restart: docker compose pull && docker compose up -d
  6. Verify: Kiểm tra health check, logs, behavior

Có chậm hơn auto-update không? Có. Nhưng bạn biết chính xác cái gì đang chạy trên server của mình.

Bước 6: Tự động hóa phần an toàn

Bạn vẫn có thể tự động hóa phần notification và kiểm tra. Chỉ phần deploy cuối cùng cần human approval:

#!/bin/bash
# update-check.sh — chạy hàng ngày qua cron
 
COMPOSE_FILE="/opt/docker/docker-compose.yml"
 
# Kiểm tra image mới
updates=$(docker compose -f "$COMPOSE_FILE" pull --dry-run 2>&1 | grep "Pulling")
 
if [ -n "$updates" ]; then
  echo "Available updates:"
  echo "$updates"
  # Gửi notification — bạn quyết định có update không
fi

5. Ứng Dụng Cho AI Infrastructure

Tất cả những gì đã thảo luận ở trên áp dụng cho mọi Docker stack. Nhưng AI infrastructure có đặc thù riêng khiến vấn đề nghiêm trọng hơn.

Tốc Độ Release Nhanh Bất Thường

Các công cụ AI đang trong giai đoạn phát triển cực nhanh. LiteLLM push bản mới gần như hàng ngày. Ollama release liên tục khi có model mới. Open WebUI cập nhật UI hàng tuần. Vaultwarden, SearXNG, các MCP server — tất cả đều chạy đua tính năng.

Nhịp release nhanh tạo áp lực tâm lý: "Nếu không dùng bản mới nhất, mình sẽ bỏ lỡ model mới, thiếu tính năng quan trọng." Áp lực đó dẫn đến :latest. Và :latest dẫn đến bạn chạy code chưa review trên production mỗi sáng.

Stack Nhiều Container

Một AI server điển hình không phải 1-2 container. Nó thường có 10-20 services:

  • LLM Gateway (LiteLLM)
  • Model Runner (Ollama)
  • Web UI (Open WebUI, LibreChat)
  • Vector Database (Qdrant, ChromaDB)
  • Search (SearXNG)
  • Monitoring (Prometheus, Grafana)
  • Proxy (Traefik, Caddy)
  • Auth (Authentik, Keycloak)
  • Password Manager (Vaultwarden)
  • MCP Servers
  • Watchtower
  • ...

Mỗi service là một entry point tiềm năng. Nếu 15 service dùng floating tag, bạn có 15 cánh cửa mở. Attacker chỉ cần compromise một maintainer account, một CI pipeline, push image bị nhiễm lên registry — và Watchtower sẽ tận tình kéo về cho bạn.

API Keys Là Mục Tiêu Cao Giá

Container AI không chứa data thông thường. Chúng chứa:

  • API keys tới Claude, GPT-4, Gemini — mỗi key có thể tiêu hàng nghìn USD
  • Database credentials với dữ liệu conversation history
  • Master keys cho toàn bộ proxy layer
  • SSH keys nếu bạn mount volume từ host

Một floating tag bị poison → Watchtower pull về → malware đọc ENV → exfiltrate API keys. Toàn bộ quá trình diễn ra trong vài giây, im lặng, lúc 4 giờ sáng.

Cách Tiếp Cận Đúng

Nguyên tắc không phức tạp:

  1. Pin version cho mọi image: Không ngoại lệ, kể cả Watchtower, kể cả Traefik
  2. Subscribe release channels: Watch GitHub repo, enable release notifications
  3. Update có chủ đích: Đọc changelog → test → deploy, không phải tự động hóa mù quáng
  4. Giảm thiểu secret exposure: Dùng Docker secrets thay vì environment variables khi có thể. Với Compose, dùng file secret thay vì inline value
  5. Audit định kỳ: Mỗi tuần chạy script kiểm tra floating tags, image không rõ nguồn gốc

Nếu bạn vận hành AI stack cho team hoặc production: cân nhắc private registry mirror. Pull image từ upstream về internal registry, scan, rồi mới cho phép deploy. Thêm một bước, nhưng kiểm soát hoàn toàn nội dung.


Khi Tiện Lợi Trở Thành Lỗ Hổng

:latest được thiết kế cho tiện lợi trong development. Watchtower được thiết kế để giảm operational burden. Cả hai đều không sai trong context ban đầu. Nhưng khi bạn đặt chúng cạnh nhau trên một server chứa API keys trị giá hàng nghìn USD, bạn đã tạo ra một automated deployment pipeline — mà bạn không kiểm soát đầu vào.

Bảo mật container không phải chỉ là scan CVE. Nó bắt đầu từ câu hỏi đơn giản: "Ai quyết định code nào chạy trên server của tôi? Tôi, hay một CI pipeline của người lạ?"

Pin version. Monitor thay vì auto-update. Review trước khi deploy. Ba hành động nhỏ, nhưng là sự khác biệt giữa kiểm soát và hy vọng.


Đây là bài 2/7 trong series Bảo Mật Trong Kỷ Nguyên AI Agent.

Bài tiếp trong series: Container Hardening 101 — từ "chạy được" đến "an toàn".

LDK

Le Duy Khuong

AI Transformation & Digital Strategy. Writing about agentic systems, engineering leadership, and building in public.