Bảy phần trước xoay quanh chứng chỉ, nhưng chứng chỉ chỉ là nửa công khai của một cặp khoá. Nửa kia, khoá riêng, cùng với mật khẩu CSDL, token API, khoá ký JWT, là những thứ tiến trình phải cầm trong tay suốt thời gian chạy, và câu hỏi không phải "có mã hoá không" mà là "nó nằm ở đâu, ai đọc được, và còn nằm đó bao lâu". Bài này viết một tiến trình Go nhận secret qua ba đường thông dụng, biến môi trường, file trên đĩa, và tmpfs mount kiểu Docker secrets, cộng một đường cố tình sai là tham số dòng lệnh, rồi đi tìm từng secret ở chín chỗ nó có thể lộ, từ log cho tới core dump và bộ nhớ sống. Cùng một binary chạy hai chế độ, xấu và tốt, để từng biện pháp được đo riêng thay vì tin theo danh sách khuyến nghị.
Ba đường vào và chín chỗ đi tìm

Hình 1: Bốn đường đưa secret vào tiến trình, chín chỗ đi tìm, và lý do phải đếm số bản sao trong RAM.
Container sec-lab chạy golang:1.23, user root, capability mặc định của Docker, không có SYS_PTRACE, không có Yama. Secret vào theo bốn đường, mỗi đường một chuỗi đánh dấu riêng để grep phân biệt được nguồn: biến môi trường ENV_SECRET qua docker run -e; file secret.txt quyền 0400 của root, đọc bằng os.ReadFile và giữ dạng []byte sau một atomic.Pointer; file db_pw trên tmpfs --tmpfs /run/secrets mode 0700, đúng cách Docker và Kubernetes mount secrets vào RAM; và tham số -password, đường mà không ai nên dùng nhưng vẫn gặp trong script.
Chín chỗ đi tìm. Log: chế độ xấu log.Printf("%+v", cfg) in cả struct, chế độ tốt dùng kiểu Redacted cài String(), GoString(), MarshalJSON(). Cmdline: /proc/pid/cmdline trong container và docker top từ host. Environ: /proc/pid/environ, với chế độ tốt gọi os.Unsetenv ngay sau khi đọc. Tiến trình con: handler /child chạy sh -c env và đếm. RAM sống: một script Python đọc /proc/pid/maps rồi /proc/pid/mem và đếm số lần mỗi chuỗi xuất hiện trên toàn bộ vùng nhớ đọc được. Quyền file: setpriv xuống uid 65534 rồi cat, và cat bằng root. Reload: ghi secret mới vào file, watcher kiểm mtime mỗi 500 ms, đo mili giây tới khi /secret báo phiên bản mới; và SIGHUP. Bản cũ: sau khi xoay, chuỗi cũ còn trong RAM không, trước và sau runtime.GC(); chế độ xấu giữ tham chiếu tới bản cũ như một cache, chế độ tốt ghi đè từng byte bằng 0. Core dump: GOTRACEBACK=crash cộng SIGABRT sinh file core trong thư mục làm việc, strings core | grep từng chuỗi; rồi ulimit -c 0 và crash lại.
Đo thật: Unsetenv nửa vời, core giữ hết, 0400 không cản root

Hình 2: Chín chỗ rò ở hai chế độ, hai lệnh Docker từ host lộ secret không cần vào container, và cách đọc các con số đếm bản sao.
| chỗ rò | chế độ xấu | chế độ tốt |
|---|---|---|
| log cấu hình | DBPass:ENVpw-… nguyên văn |
DBPass:[REDACTED] |
/proc/pid/cmdline, docker top |
-password ARGpw-… |
không truyền qua argv |
/proc/pid/environ sau Unsetenv |
còn | vẫn còn |
tiến trình con thấy ENV_SECRET |
1 | 0 |
| bản sao trong RAM sống, 110 MB | ENV 5, FILE 2, MOUNT 2, ARG 1 | ENV 2, FILE 2, MOUNT 2 |
| file 0400, đọc bằng uid 65534 / root | Permission denied / đọc được | như nhau |
| reload không restart | 467 ms qua mtime | 400 ms; SIGHUP tức thì |
| bản cũ sau xoay, sau GC | 1 còn lại | 0 |
| core dump 118 MB | ENV 2, FILE cũ 1, MOUNT 1, ARG 2 | ENV 2, FILE cũ 0, MOUNT 1 |
ulimit -c 0 rồi crash |
— | không có file core |
Log %+v và tham số dòng lệnh là hai chỗ lộ rẻ nhất, và cũng dễ chặn nhất. Chế độ xấu in cấu hình: {DBHost:pg-lab:5432 DBUser:blog DBPass:ENVpw-9f3a7c2e-ROT} ngay dòng đầu; dòng đó sẽ đi vào Docker json log, vào hệ thống gom log, và sống ở đó hàng tháng. Chế độ tốt gói mật khẩu vào kiểu Redacted có String(), GoString() và MarshalJSON(): fmt với mọi động từ, %v, %+v, %#v, và encoding/json đều in [REDACTED], không ai phải nhớ che ở từng chỗ gọi. Tham số -password thì lộ ở /proc/pid/cmdline cho mọi user trong container và ở docker top trên host; không có cách che, chỉ có cách không truyền.
os.Unsetenv chặn được tiến trình con nhưng không xoá được /proc/pid/environ. Đây là kết quả đáng nhớ nhất của bài. Chế độ tốt gọi os.Unsetenv("ENV_SECRET") ngay sau khi đọc; handler /child chạy sh -c env và không thấy biến nữa, 0 so với 1, nên mọi tiến trình con, kể cả curl hay shell mà ứng dụng gọi ra để làm việc vặt, không thừa kế secret. Nhưng tr '\0' '\n' < /proc/pid/environ vẫn in ENV_SECRET=ENVpw-9f3a7c2e-ROT nguyên vẹn. Lý do: /proc/pid/environ đọc vùng bộ nhớ mà kernel sao chép lên stack lúc execve, còn Unsetenv của Go chỉ sửa bảng môi trường trong runtime, không viết đè lên vùng gốc đó. Một tiến trình khác cùng uid đọc được nó, và docker inspect đọc được nó từ ngoài, bất kể ứng dụng làm gì sau khi khởi động. Secret qua biến môi trường vì thế phải được coi là lộ cho bất kỳ ai có quyền với container hoặc với Docker API, không phải "lộ cho tới khi ta Unsetenv".
Quét bộ nhớ sống đếm được từng bản sao, và chúng nhiều hơn một. Với root cùng container, không cần SYS_PTRACE vì cùng uid và không có Yama, script Python đọc 110 MB vùng nhớ của tiến trình và đếm: chế độ xấu có 5 bản của secret env, 2 của file, 2 của mount, 1 của argv; chế độ tốt còn 2 bản env, 2 file, 2 mount. Hai bản env tối thiểu là vùng environ gốc và chuỗi cfg.DBPass; ba bản thừa ở chế độ xấu là rác của fmt và bộ đệm log khi in struct. Secret kiểu string trong Go không xoá được vì string bất biến, và GC quyết định khi nào vùng nhớ được dùng lại; chỉ []byte mới ghi đè được, mà ngay cả vậy, các bản trung gian của ReadFile, TrimSpace, bufio vẫn nằm đâu đó cho tới GC. Tmpfs mount, thứ hay được coi là "an toàn hơn env", sau khi đọc cũng chỉ là 2 bản string trong RAM như hai đường kia: tmpfs bảo vệ đĩa khỏi ghi secret xuống, không bảo vệ tiến trình.
Core dump 118 MB mang theo mọi thứ, trừ bản []byte đã zero. GOTRACEBACK=crash cộng SIGABRT sinh file core 118 MB trong thư mục làm việc; strings core | grep thấy secret env 2 lần, mount 1 lần, argv 2 lần, secret file mới 2 lần ở cả hai chế độ. Khác biệt duy nhất là secret file cũ, đã bị thay sau một lần xoay: chế độ xấu còn 1 bản vì giữ tham chiếu, chế độ tốt 0 bản vì đã ghi đè bằng 0 rồi GC dọn bản trung gian. Hệ quả: nếu tiến trình được phép sinh core, mọi secret nó từng cầm đều nằm trong một file thường có quyền đọc rộng, được gửi đi để phân tích lỗi, và lưu lâu. ulimit -c 0 rồi crash lại: không có file nào. Với dịch vụ cầm khoá riêng TLS hay khoá ký token, tắt core dump, hay ít nhất đặt core_pattern về một chỗ có kiểm soát, là biện pháp một dòng với hiệu quả toàn phần.
0400 và tmpfs 0700 chặn người dùng khác, không chặn root, và không chặn chính tiến trình. setpriv xuống uid 65534 rồi cat: Permission denied cho cả secret.txt lẫn /run/secrets/db_pw; root đọc cả hai không cần gì. Trong container mặc định chạy root, quyền file vì thế gần như không có giá trị cho tới khi ứng dụng chạy bằng user riêng; và kể cả khi đó, mọi đường rò ở trên, log, core, con, /proc/pid/mem của chính uid ấy, đều mở từ bên trong tiến trình, nơi quyền file không có tiếng nói.
Tải lại secret không restart mất dưới nửa giây, và xoay xong vẫn phải dọn bản cũ. Watcher kiểm mtime mỗi 500 ms thấy file mới sau 467 ms ở chế độ xấu và 400 ms ở chế độ tốt, trung bình là nửa chu kỳ; SIGHUP nạp lại tức thì và là cách đáng tin hơn khi hệ quản lý secret có hook. /secret báo version=2 với fingerprint mới ngay sau đó, không kết nối nào bị ngắt. Nhưng xoay chỉ có ý nghĩa nếu bản cũ thật sự biến mất: chế độ xấu giữ nó trong một slice "cache" và sau GC vẫn còn 1 bản trong RAM và 1 trong core; chế độ tốt ghi đè 0 lên []byte cũ khi hoán đổi, sau GC còn 0. Một secret đã xoay mà vẫn nằm trong RAM là một secret chưa xoay đối với ai đọc được RAM.
Đánh đổi cần cân nhắc
Chọn đường vào theo ai cần bị chặn. Biến môi trường tiện nhất và lộ nhiều nhất: docker inspect, /proc/pid/environ, con cháu. File hay mount 0400 dưới user riêng chặn được user khác và không hiện trong metadata của Docker; nhưng cả ba đều kết thúc bằng một string trong RAM của tiến trình, và từ đó các biện pháp bên trong tiến trình mới quyết định: kiểu Redacted, không %+v struct cấu hình, không truyền argv, ulimit -c 0, và []byte zero khi xoay nếu RAM là mối lo.
Tmpfs và Docker secrets là cải thiện thật, nhưng về đĩa chứ không về tiến trình. Chúng ngăn secret bị ghi vào image layer, vào snapshot, vào backup; chúng không ngăn gì ở chín chỗ bài này đo. Đừng để tên gọi "secrets" tạo cảm giác đã xong.
Phạm vi đo. Container chạy root với capability mặc định; với user thường và ptrace_scope=1, việc đọc /proc/pid/mem của tiến trình khác bị chặn, nhưng tiến trình tự đọc mình, core dump và log thì không. Không đo swap: máy không bật swap, còn trên máy có swap thì mọi bản trong RAM đều có thể xuống đĩa trừ khi mlock. Không đo secret manager với lease TTL thật; mô hình reload bằng file cộng SIGHUP là điểm nối mà Vault Agent hay Kubernetes projected volume cắm vào.
Ba ý mang về
Unsetenvchặn con, không chặn/proc/pid/environvàdocker inspect. Secret qua env lộ cho mọi người có quyền với container từ lúc exec tới lúc chết; gọiUnsetenvvẫn nên, nhưng vì lý do tiến trình con.- Core dump giữ mọi secret tiến trình từng cầm, 118 MB và
stringslà đủ.ulimit -c 0cho dịch vụ cầm khoá riêng; nếu cần core để debug, zero[]bytekhi xoay và chấp nhận rằng string không xoá được. - Kiểu
Redactedvà không truyền argv là hai biện pháp một dòng với hiệu quả toàn phần. Quyền file 0400 và tmpfs chỉ chặn user khác; trong container chạy root chúng gần như không có tác dụng, và không bao giờ chặn được chính tiến trình rò ra log.
Nguồn
- Linux
proc(5)—/proc/pid/environ,/proc/pid/cmdline,/proc/pid/mem,/proc/pid/maps: https://man7.org/linux/man-pages/man5/proc.5.html - Linux
core(5)—core_pattern,RLIMIT_CORE: https://man7.org/linux/man-pages/man5/core.5.html - Go
runtime—GOTRACEBACK=crash: https://pkg.go.dev/runtime#hdr-Environment_Variables - Go
fmt— giao diệnStringer,GoStringervà cách%+v,%#vdùng chúng: https://pkg.go.dev/fmt#hdr-Printing - Go
os.Unsetenvvà mã nguồnsyscall.Unsetenv(chỉ sửa bảng của runtime): https://pkg.go.dev/os#Unsetenv - Docker — Manage sensitive data with Docker secrets, mount tmpfs tại
/run/secrets: https://docs.docker.com/engine/swarm/secrets/ - Kubernetes — Secrets, cảnh báo về biến môi trường và projected volume: https://kubernetes.io/docs/concepts/configuration/secret/
- Linux
ptrace(2)— Ptrace access mode checking và Yamaptrace_scope: https://man7.org/linux/man-pages/man2/ptrace.2.html
Phần sau chuyển từ secret của hệ thống sang secret của người dùng: mật khẩu. Đo bcrypt, scrypt và Argon2id trên cùng một máy, mili giây và bộ nhớ theo từng tham số, số lần thử mỗi giây kẻ tấn công đạt được với chính CPU đó, rồi chọn tham số cho ngưỡng 250 ms và nâng cost cho người dùng cũ ngay lúc họ đăng nhập.