5 phút đọc

Container chạy quyền root là cho hacker thêm một bậc thang

Dockerfile AI viết thường để app chạy bằng root, không giới hạn RAM, CPU, và có khi còn mount cả docker.sock. Sáu dòng cấu hình trong Dockerfile và docker-compose giúp một lỗ hổng trong app không biến thành mất cả máy chủ.

Xem nhanh trong 1 phút

Gõ docker exec <container> id vào container đang chạy app của bạn. Nếu kết quả là uid=0(root), app đang chạy bằng root. Đây là mặc định của Docker: Dockerfile không có dòng USER thì mọi thứ trong container chạy bằng root, và phần lớn Dockerfile do AI viết không có dòng đó.

Nhiều người nghĩ “root trong container thì có sao, nó bị nhốt mà”. Container là lớp cô lập, không phải máy ảo. Root trong container mặc định cũng là UID 0 trên máy chủ, chỉ bị giới hạn bởi các lớp kernel bao quanh. Khi một trong các lớp đó có lỗi thì root trong container là người đứng gần cửa nhất.

Thùng container trong suốt phát sáng, bên trong dựng một chiếc thang viền đỏ chạm tới nắp, linh vật robot đứng cạnh nhấc thang ra và khóa nắp thùng lại

Chuyện đã xảy ra với runc

Đầu năm 2024, runc, thành phần chạy container bên dưới Docker và containerd, có lỗ hổng CVE-2024-21626 (CVSS 8,6). Một file descriptor nội bộ bị rò, cho phép tiến trình trong container chạm tới hệ thống file của máy chủ, ghi đè file chạy của máy chủ, tức là thoát khỏi container. Bản 1.0.0-rc93 tới 1.1.11 bị ảnh hưởng, bản vá là 1.1.12.

Chạy bằng user thường không vá được lỗi kiểu này, nhưng làm hậu quả nhỏ đi nhiều: tiến trình thoát ra mang UID của một user không có quyền gì trên máy chủ thay vì UID 0. Và quan trọng hơn, đa số sự cố không bắt đầu từ lỗi kernel mà từ lỗi trong chính app: một gói npm độc, một lỗi upload file, một route chạy lệnh shell. Lúc đó, mọi giới hạn bạn đặt cho container quyết định hacker làm được gì tiếp.

Sáu dòng nên có

1. Chạy bằng user thường

Image Node chính thức đã có sẵn user node, chỉ cần dùng:

FROM node:22-slim
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]

Image Python hay image khác không có sẵn thì tạo:

FROM python:3.12-slim
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --chown=app:app . .
RUN pip install --no-cache-dir -r requirements.txt
USER app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

User thường không mở được cổng dưới 1024, nên cho app nghe ở 3000 hay 8000 rồi map cổng bằng ports.

2. Bỏ hết capability, chỉ thêm cái cần

Root trong container vẫn giữ một bộ quyền kernel (capability) mặc định. Phần lớn app web không cần cái nào. OWASP khuyên bỏ hết rồi thêm lại đúng cái cần.

3. Chặn leo quyền

no-new-privileges ngăn tiến trình lấy thêm quyền qua file setuid kiểu sudo hay su, kể cả khi trong image có sẵn.

4. Hệ thống file chỉ đọc

Hacker vào được app thường việc đầu tiên là tải thêm công cụ về, sửa file của app. read_only: true chặn việc đó. Chỗ nào app cần ghi (thư mục tạm, cache) thì cấp riêng bằng tmpfs hoặc volume.

5. Giới hạn RAM, CPU, số tiến trình

Không giới hạn thì một container bị chiếm để đào coin, hay đơn giản là một vòng lặp lỗi, ăn hết máy chủ và kéo sập mọi container khác. pids_limit chặn kiểu tấn công đẻ tiến trình vô hạn.

6. Không mount docker.sock

Dòng - /var/run/docker.sock:/var/run/docker.sock xuất hiện trong nhiều file compose mẫu cho các công cụ quản lý, dashboard, reverse proxy tự động. Ai điều khiển được socket này là điều khiển được Docker trên máy chủ, tức là chạy được container mới với quyền root của máy chủ. OWASP ghi rõ mount kiểu :ro cũng không làm API của Docker thành chỉ đọc.

Gộp lại trong docker-compose

services:
  app:
    build: .
    user: "node"                 # hoặc "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    mem_limit: 512m
    cpus: "1.0"
    pids_limit: 200
    ports:
      - "127.0.0.1:3000:3000"    # chỉ mở cho reverse proxy trên cùng máy
    restart: on-failure:5
    environment:
      - NODE_ENV=production

Vài ghi chú:

  • ports có 127.0.0.1: phía trước để cổng chỉ nghe trên máy chủ. Viết "3000:3000" là mở ra internet, và Docker tự thêm luật tường lửa đi vòng qua ufw.
  • Database (Postgres, Redis) cũng làm tương tự, và thường không cần ports gì cả nếu chỉ app trong cùng network compose gọi tới.
  • Next.js chạy next start cần ghi vào .next/cache. Nếu bật read_only, thêm tmpfs cho thư mục đó hoặc dùng bản build output: "standalone".
  • Bật read_only và cap_drop rồi app lỗi thì xem log, thêm đúng thứ nó cần, đừng gỡ cả dòng.

Kiểm tra app của bạn trong vài phút

  1. Xem user đang chạy:

    docker ps --format '{{.Names}}' | xargs -I{} sh -c 'echo "{}: $(docker exec {} id -u 2>/dev/null)"'

    Số 0 là root.

  2. Xem cấu hình bảo mật của một container:

    docker inspect <ten-container> --format 'User={{.Config.User}} ReadOnly={{.HostConfig.ReadonlyRootfs}} CapDrop={{.HostConfig.CapDrop}} SecOpt={{.HostConfig.SecurityOpt}} Mem={{.HostConfig.Memory}} Pids={{.HostConfig.PidsLimit}}'

    Mem=0 nghĩa là không giới hạn RAM.

  3. Tìm docker.sock trong mọi file compose: grep -rn "docker.sock" .

  4. Xem các cổng đang mở ra ngoài: docker ps --format '{{.Names}} {{.Ports}}'. Dòng nào có 0.0.0.0: mà không phải cổng 80, 443 của reverse proxy thì xem lại.

  5. Kiểm phiên bản runc: runc --version hoặc docker info | grep -i runc. Thấp hơn 1.1.12 là phải cập nhật Docker.

Khi nhờ AI viết Dockerfile, thêm thẳng vào yêu cầu: “chạy bằng user không phải root, kèm docker-compose có read_only, cap_drop ALL, no-new-privileges, giới hạn RAM và CPU”.


Container trên VPS của bạn đang chạy bằng user nào? Vào cộng đồng Vibe Code & Bảo Mật chia sẻ file compose của bạn (nhớ xóa key trước) để mọi người cùng góp ý nhé.

Nguồn

Chia sẻ lên Facebook

Bình luận

Đang tải bình luận…