Le Duy Khuong

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

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

Container Hardening 101 — Từ 'Chạy Được' Đến 'An Toàn'

Hai services trên cùng server. Một cái hardened — attacker vào được nhưng không làm được gì. Cái kia chạy root, full capabilities — attacker lấy được mọi thứ.

2026-03-2615 phút đọcVI

Phần 3 của 743% hoàn thành

Container Hardening 101 — Từ "Chạy Được" Đến "An Toàn"

Hai container chạy trên cùng một server.

Container thứ nhất là hệ thống ERP nội bộ. Filesystem read-only, tất cả Linux capabilities bị loại bỏ, process không thể tự nâng quyền. Attacker tìm được một vulnerability trong application code, exploit thành công, có shell bên trong container. Nhưng không ghi được file — filesystem bất biến. Không escalate được quyền — no-new-privileges chặn. Không thấy container khác — network bị phân tách. Không ăn hết RAM — resource limits giới hạn. Attacker vào được, nhưng không làm được gì đáng kể. Đội vận hành phát hiện qua log, patch, tiếp tục làm việc.

Container thứ hai là AI proxy service. Chạy root, full capabilities, bind 0.0.0.0 trên mọi port, không resource limits, nằm chung network với database. Cùng attacker, cùng vulnerability tương tự. Nhưng lần này: ghi malware vào filesystem, escalate lên root, đọc environment variables chứa API keys tới Claude và GPT-4, kết nối thẳng tới database, exfiltrate conversation history. Toàn bộ diễn ra trong vài phút.

Sự khác biệt không nằm ở application code. Không nằm ở firewall. Nằm ở cách container được cấu hình.

Bài trước trong series, chúng ta nói về floating tags và auto-updaters — cách attacker đưa image nhiễm vào hệ thống. Bài này nói về chuyện gì xảy ra sau khi attacker đã ở bên trong: làm sao để giới hạn thiệt hại xuống mức gần bằng không.


1. Sáu Tầng Phòng Thủ — Defense in Depth

Container hardening không phải một setting duy nhất. Nó là nhiều tầng chồng lên nhau, mỗi tầng chặn một loại hành vi khác nhau. Nếu attacker vượt qua tầng 1, tầng 2 chặn tiếp. Vượt tầng 2, tầng 3 chờ sẵn. Nguyên lý này gọi là defense in depth — phòng thủ theo chiều sâu.

Tầng 1: read_only: true — Filesystem Bất Biến

Khi bạn bật read_only: true trong docker-compose, toàn bộ filesystem của container trở thành read-only. Không process nào bên trong có thể tạo file mới, sửa file cũ, hay xóa bất cứ thứ gì.

Tại sao điều này quan trọng? Phần lớn malware sau khi exploit được vulnerability sẽ cần ghi file vào container: tải payload xuống /tmp, tạo script trong /var, cài reverse shell. read_only: true chặn toàn bộ hành vi này ngay từ gốc.

Nhưng application cũng cần ghi file — log, cache, temp data. Giải pháp là tmpfs — filesystem tạm trên RAM, chỉ mount vào những thư mục cụ thể mà application thực sự cần:

read_only: true
tmpfs:
  - /tmp:size=100M
  - /app/cache:size=200M

tmpfs tồn tại trên RAM, biến mất khi container restart, và bị giới hạn dung lượng. Malware có thể ghi vào /tmp, nhưng dung lượng chỉ 100MB, không persist qua restart, và toàn bộ filesystem còn lại vẫn bất biến.

Tầng 2: cap_drop: [ALL] — Loại Bỏ Linux Capabilities

Linux kernel không chỉ có hai mức quyền "root" và "user". Giữa hai mức đó là hệ thống capabilities — chia nhỏ quyền root thành khoảng 40 capability riêng biệt:

  • CAP_NET_ADMIN: Thay đổi cấu hình mạng, tạo interface, sửa routing table
  • CAP_SYS_ADMIN: Quyền gần như root — mount filesystem, thay đổi namespace, load kernel module
  • CAP_DAC_OVERRIDE: Bỏ qua permission check khi đọc/ghi file
  • CAP_NET_RAW: Tạo raw socket — dùng cho packet sniffing, ARP spoofing
  • CAP_SYS_PTRACE: Debug process khác — attacker dùng để đọc memory của process khác

Docker mặc định cấp cho container một danh sách dài capabilities. Hầu hết application không cần bất kỳ capability nào trong đó. Nhưng nếu bạn không chủ động loại bỏ, chúng vẫn có sẵn — và attacker biết cách khai thác.

cap_drop: [ALL] loại bỏ tất cả capabilities. Sau đó bạn chỉ thêm lại những gì thực sự cần:

cap_drop:
  - ALL
cap_add:
  - NET_BIND_SERVICE  # Cho phép bind port dưới 1024

Nguyên tắc: bắt đầu từ zero, thêm từng cái một. Nếu service chạy bình thường với cap_drop: [ALL] mà không thêm gì, hoàn hảo — để nguyên. Nếu lỗi, đọc error log để biết cần thêm capability nào. Không bao giờ thêm CAP_SYS_ADMIN — nếu application yêu cầu capability đó, cần xem xét lại kiến trúc.

Tầng 3: no-new-privileges: true — Chặn Privilege Escalation

Một kỹ thuật phổ biến của attacker sau khi vào được container là tìm cách nâng quyền. Ví dụ: tìm binary có setuid bit, exploit kernel vulnerability để chuyển từ user thường lên root bên trong container.

no-new-privileges ngăn chặn điều này ở cấp kernel. Khi flag này bật, không process nào trong container có thể nhận thêm privilege mới — kể cả thông qua setuid, setgid, hay bất kỳ cơ chế nào khác.

security_opt:
  - no-new-privileges:true

Một dòng cấu hình, nhưng đóng lại toàn bộ lớp attack surface liên quan đến privilege escalation. Hầu như không có application nào cần tắt flag này.

Tầng 4: 127.0.0.1 Port Binding — Chỉ Localhost

Mặc định, khi bạn viết ports: ["8080:8080"] trong docker-compose, Docker bind port đó lên 0.0.0.0 — nghĩa là mọi network interface. Server có IP public? Port đó mở ra internet. Server trên VPN? Port đó accessible từ toàn bộ VPN.

Điều này đặc biệt nguy hiểm vì Docker tự thêm rule vào iptables, bypass UFW và firewall rules mà bạn đã cấu hình cẩn thận. Nhiều người setup UFW chặn mọi port, nhưng Docker vẫn expose container port ra ngoài.

Cách khắc phục đơn giản:

ports:
  - "127.0.0.1:8080:8080"

Bây giờ port chỉ accessible từ localhost. Muốn expose ra ngoài? Dùng reverse proxy (Nginx, Traefik, Caddy) — reverse proxy kiểm soát TLS, authentication, rate limiting, và quan trọng nhất: nằm trong tầm kiểm soát của bạn.

Tầng 5: Network Segmentation — Phân Tách Mạng

Docker network mặc định là bridge network — tất cả container trên cùng network có thể liên lạc với nhau tự do. Nếu attacker compromise một container, họ có thể scan và tấn công mọi container cùng network.

Network segmentation chia container thành các nhóm cô lập:

networks:
  app-core:
    name: app-core
  app-data:
    name: app-data
    internal: true
  app-monitor:
    name: app-monitor
    internal: true

Keyword quan trọng ở đây là internal: true. Network nội bộ không thể kết nối ra internet. Database nằm trên app-data (internal) — kể cả attacker chiếm được database container, họ cũng không thể exfiltrate data ra ngoài vì network đó không có đường ra internet.

Mỗi service chỉ join vào network mà nó thực sự cần:

  • API service: join app-core (nhận request) + app-data (truy cập database)
  • Database: chỉ join app-data (internal, không ra internet)
  • Monitoring: chỉ join app-monitor (internal, thu thập metrics)

Attacker compromise API service? Thấy database trên app-data, nhưng không thấy monitoring trên app-monitor. Compromise database? Không ra được internet. Mỗi container chỉ nhìn thấy đúng phần mà nó cần.

Tầng 6: Resource Limits — Giới Hạn Tài Nguyên

Không có resource limits, một container bị compromise có thể:

  • Ăn hết RAM của host → crash tất cả container khác (DoS)
  • Chiếm 100% CPU → server không phản hồi
  • Ghi log liên tục → đầy disk → host mất khả năng hoạt động
deploy:
  resources:
    limits:
      memory: 2G
      cpus: "2.0"
logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"

Resource limits không chỉ ngăn abuse — chúng cũng giúp bạn phát hiện bất thường. Container bình thường dùng 500MB RAM, đột nhiên chạm 2GB limit? Có gì đó sai. Alert, kiểm tra, phản ứng.

Logging limits cũng quan trọng không kém. Không giới hạn, Docker log có thể phình tới hàng chục GB. max-size: "10m" + max-file: "5" = tối đa 50MB log per container, xoay vòng tự động.


2. Khái Niệm Nền Tảng

Linux Capabilities — Hệ Thống Phân Quyền Kernel

Trước Linux 2.2, chỉ có hai trạng thái: privileged (root, UID 0) và unprivileged (mọi user khác). Root có toàn quyền, user thường không có gì. Điều này vi phạm nguyên tắc least privilege — nhiều chương trình chỉ cần một mảnh nhỏ quyền root nhưng phải chạy với toàn bộ.

Capabilities chia nhỏ quyền root thành các đơn vị độc lập. Một web server cần bind port 80? Chỉ cần CAP_NET_BIND_SERVICE, không cần toàn bộ root. Một monitoring tool cần đọc network statistics? Chỉ cần CAP_NET_ADMIN.

Docker cấp cho container một subset capabilities mặc định — đủ để hầu hết application chạy, nhưng cũng đủ để attacker khai thác. Danh sách mặc định bao gồm CAP_NET_RAW (cho phép tạo raw socket — công cụ yêu thích cho network sniffing), CAP_CHOWN, CAP_SETUID, CAP_SETGID (cho phép thay đổi ownership và user context).

Seccomp Profiles — Lọc System Calls

Nếu capabilities kiểm soát quyền thì seccomp kiểm soát hành vi. Seccomp (Secure Computing Mode) giới hạn system calls mà process có thể thực hiện. Docker có seccomp profile mặc định chặn khoảng 44 syscalls nguy hiểm — nhưng bạn có thể tạo profile chặt hơn tùy theo application.

Một AI inference service thông thường chỉ cần: đọc file model, mở network socket, allocate memory. Nó không cần mount, reboot, kexec_load, hay ptrace. Seccomp profile chặt giúp chặn những hành vi bất thường ngay ở cấp kernel.

Docker Network — Không Chỉ Là Connectivity

Docker hỗ trợ ba loại network chính cho single-host:

  • Bridge (mặc định): Container giao tiếp qua virtual bridge. Có thể truy cập internet.
  • Host: Container dùng trực tiếp network stack của host. Nhanh hơn nhưng không cô lập.
  • None: Không có network — container hoàn toàn cô lập.

Và trong bridge network, flag internal: true tạo ra network không có route ra internet. Container trên internal network vẫn giao tiếp được với nhau, nhưng không gửi được packet ra ngoài. Đây là phòng tuyến cuối cho data exfiltration — kể cả khi attacker có shell, có root, có mọi thứ bên trong container, dữ liệu vẫn không ra được ngoài.

Resource Limits — Deploy vs Runtime

Docker Compose có hai cách đặt resource limits:

# Deploy limits (Docker Swarm / Compose v3)
deploy:
  resources:
    limits:
      memory: 2G
    reservations:
      memory: 512M
 
# Runtime limits (Compose v2 legacy)
mem_limit: 2G

Trong Compose hiện đại, dùng deploy.resources. limits là giới hạn tối đa — container vượt quá sẽ bị OOM killed. reservations là mức tối thiểu được đảm bảo — Docker scheduler sẽ cố gắng giữ ít nhất từng đó cho container.


3. Cấu Hình Hoàn Chỉnh

Template Cơ Bản — Áp Dụng Toàn Bộ 6 Tầng

services:
  my-service:
    image: my-image:1.2.3  # Pin version, không dùng :latest
    read_only: true
    tmpfs:
      - /tmp:size=100M
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE   # Chỉ thêm capability thực sự cần
    ports:
      - "127.0.0.1:8080:8080"  # Chỉ localhost
    networks:
      - app-core
    deploy:
      resources:
        limits:
          memory: 2G
          cpus: "2.0"
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

Mỗi dòng trong template này tương ứng với một tầng phòng thủ. Không có dòng nào là "nice to have" — mỗi dòng chặn một loại attack cụ thể.

YAML Anchors — DRY Cho Security Config

Khi bạn có 5-10 services, copy-paste security config cho mỗi service là con đường dẫn đến sai sót. Quên một service là mở một lỗ hổng. YAML anchors giải quyết vấn đề này:

x-security: &security
  read_only: true
  security_opt:
    - no-new-privileges:true
  cap_drop:
    - ALL
 
x-logging: &logging
  logging:
    driver: json-file
    options:
      max-size: "10m"
      max-file: "5"
 
services:
  api-gateway:
    image: api-gateway:2.1.0
    <<: *security
    <<: *logging
    cap_add:
      - NET_BIND_SERVICE
    ports:
      - "127.0.0.1:443:443"
    networks:
      - app-core
    deploy:
      resources:
        limits:
          memory: 1G
 
  worker:
    image: worker:1.5.0
    <<: *security
    <<: *logging
    tmpfs:
      - /tmp:size=50M
    networks:
      - app-core
      - app-data
    deploy:
      resources:
        limits:
          memory: 4G
 
  database:
    image: postgres:16.2-alpine
    <<: *security
    <<: *logging
    cap_add:
      - CHOWN
      - SETUID
      - SETGID
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - app-data
    deploy:
      resources:
        limits:
          memory: 2G

Anchor &security định nghĩa một lần, dùng *security cho mọi service. Thêm capability? Override cap_add cho từng service cụ thể. Cần writable directory? Thêm tmpfs hoặc named volume. Security baseline nhất quán, customization theo nhu cầu.

Network Segmentation Hoàn Chỉnh

networks:
  app-core:
    name: app-core
    # Không có 'internal' → có thể kết nối internet
    # Dành cho services cần nhận request từ bên ngoài
 
  app-data:
    name: app-data
    internal: true
    # Không kết nối internet → data không ra ngoài được
    # Database, cache nằm ở đây
 
  app-monitor:
    name: app-monitor
    internal: true
    # Monitoring stack cô lập
    # Prometheus, Grafana, alerting

Quy tắc phân bổ:

  • Service nhận request từ client → app-core
  • Service truy cập data store → app-data
  • Service cần cả hai → join cả app-core lẫn app-data
  • Monitoring → app-monitor, tách biệt hoàn toàn

4. Quy Trình Hardening — 12 Bước Tuần Tự

Đừng áp dụng tất cả cùng lúc. Mỗi bước thay đổi một thứ, test, confirm service vẫn hoạt động, rồi mới tiếp.

  1. Pin tất cả image tags — Thay :latest bằng version cụ thể. Chạy docker compose pull để verify image tồn tại
  2. Thêm read_only: true — Test từng service. Nếu lỗi "read-only file system", thêm tmpfs cho thư mục cần ghi
  3. Thêm cap_drop: [ALL] — Service nào cần capability cụ thể, thêm cap_add riêng. Postgres cần CHOWN, SETUID, SETGID. Web server cần NET_BIND_SERVICE nếu bind port dưới 1024
  4. Thêm no-new-privileges: true — Hiếm khi gây lỗi. Nếu lỗi, kiểm tra xem application có dùng setuid binary không
  5. Bind port về 127.0.0.1 — Kiểm tra mọi ports entry. Cấu hình reverse proxy cho traffic từ bên ngoài
  6. Tạo network segmentation — Định nghĩa networks theo chức năng: core, data, monitor
  7. Đánh dấu data network là internal: true — Database, cache, message queue không cần internet
  8. Thêm resource limits — Bắt đầu từ mức rộng rãi (2x mức sử dụng bình thường), tinh chỉnh sau
  9. Thêm logging limitsmax-size: "10m", max-file: "5" cho mọi service
  10. Docker socket: mount read-only — Nếu bắt buộc phải mount (/var/run/docker.sock), thêm :ro. Nếu có thể tránh, đừng mount
  11. Test từng service — Chạy full workflow: healthcheck, request/response, edge cases. Không chỉ kiểm tra "container start được"
  12. Monitor 24 giờ — Theo dõi log, resource usage, network traffic. Nếu không có bất thường sau 24h, hardening thành công

Bước 11 là quan trọng nhất và hay bị bỏ qua. read_only: true có thể làm service lỗi âm thầm — application ghi cache file thất bại nhưng không crash, chỉ chậm dần. Test kỹ, kiểm tra log cẩn thận.


5. Container AI — Nhu Cầu Đặc Biệt, Nguyên Tắc Không Đổi

Container chạy AI workload có profile khác biệt so với web service thông thường. GPU passthrough, model cache hàng chục GB, memory usage cao. Nhưng nguyên tắc hardening vẫn hoàn toàn áp dụng được — chỉ cần điều chỉnh thông số.

GPU Passthrough

NVIDIA GPU passthrough trong Docker Compose dùng deploy.resources.reservations.devices:

services:
  inference:
    image: ollama/ollama:0.5.4
    <<: *security
    deploy:
      resources:
        limits:
          memory: 16G
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    tmpfs:
      - /tmp:size=500M
    networks:
      - app-core

GPU config nằm trong deploy.resources — hoàn toàn tương thích với read_only, cap_drop, no-new-privileges. Không có lý do gì để nới lỏng security vì GPU.

Model Cache — Named Volumes, Không Phải Bind Mounts

AI service cần cache model files — từ 4GB cho model 7B đến 40GB+ cho model 70B. Hai cách tiếp cận:

# Không tốt — bind mount, attacker có thể ghi file lên host
volumes:
  - ./models:/root/.ollama
 
# Tốt — named volume, Docker quản lý, cô lập khỏi host filesystem
volumes:
  - ollama-models:/root/.ollama
 
volumes:
  ollama-models:
    name: ollama-models

Named volumes kết hợp với read_only: true tạo ra pattern mạnh: filesystem container bất biến, nhưng model cache vẫn persistent qua restart. Attacker không ghi được file ở đâu ngoài volume mount point — và volume đó chỉ chứa model files, không chứa application code hay config.

Memory — Set Limit, Luôn Luôn

AI inference cần nhiều RAM — model 7B cần 4-8GB, model 13B cần 8-16GB, model 70B cần 40-80GB. Nhưng "cần nhiều RAM" không có nghĩa "không giới hạn RAM".

deploy:
  resources:
    limits:
      memory: 16G     # Giới hạn cứng
    reservations:
      memory: 8G      # Đảm bảo tối thiểu

Reservation đảm bảo container luôn có đủ RAM cho inference bình thường. Limit ngăn memory leak hoặc attacker cố tình ăn hết RAM host. Khoảng cách giữa reservation và limit cho phép burst khi cần mà không gây rủi ro cho toàn bộ server.

Multi-Model Stack

Stack AI điển hình có nhiều service: LLM inference (Ollama), API proxy (LiteLLM), embedding service, vector database. Mỗi service là một attack surface. Nhưng pattern hardening không thay đổi:

services:
  llm:
    image: ollama/ollama:0.5.4
    <<: *security
    networks:
      - app-core
    # ... GPU config, model volume
 
  proxy:
    image: ghcr.io/berriai/litellm:main-v1.82.6
    <<: *security
    ports:
      - "127.0.0.1:4000:4000"
    networks:
      - app-core
      - app-data
 
  embedding:
    image: ghcr.io/huggingface/text-embeddings-inference:1.6
    <<: *security
    networks:
      - app-core
 
  vectordb:
    image: qdrant/qdrant:v1.13.2
    <<: *security
    volumes:
      - qdrant-data:/qdrant/storage
    networks:
      - app-data  # internal: true → không ra internet

Mỗi service pin version, read_only, cap_drop: ALL, no-new-privileges. Database trên internal network. Proxy là service duy nhất expose port — và chỉ expose ra localhost. Stack có 4 services nhưng attacker chỉ thấy một cổng vào — và cổng đó đã được reverse proxy bảo vệ.


Hardening Là Quá Trình, Không Phải Sự Kiện

Không ai hardened hết mọi thứ trong một buổi chiều. Bắt đầu từ bước 1 — pin image tag. Tuần sau thêm read_only. Tuần sau nữa thêm cap_drop. Mỗi tuần container của bạn an toàn hơn một chút. Sau 12 bước, attacker vào được container nhưng thấy: filesystem không ghi được, quyền không nâng được, network không thoát được, resource bị giới hạn. Blast radius thu gọn từ "mất toàn bộ server" xuống "mất một container rỗng".

Security không bao giờ tuyệt đối. Nhưng khoảng cách giữa container chạy root không giới hạn và container hardened đúng cách — khoảng cách đó đủ lớn để attacker chọn mục tiêu khác.


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

Bài tiếp trong series: Dependency Poisoning — Khi pip install Trở Thành Vũ Khí.

LDK

Le Duy Khuong

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