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

Ảnh chụp đoạn mã nền tối secret trong tiến trình ba đường vào chín chỗ rò với Go 1.23 container sec-lab golang 1.23 root cap mặc định không SYS_PTRACE tmpfs run secrets mode 0700, ba đường đưa secret vào mỗi đường một chuỗi đánh dấu env docker run -e ENV_SECRET ENVpw os.Getenv giữ trong struct Config DBPass string, file work tls08 secret.txt 0400 root os.ReadFile giữ dạng byte slice trong atomic Pointer, mount tmpfs run secrets mode 0700 file db_pw 0400 trong RAM không chạm đĩa, arg password ARGpw để thấy nó lộ ở đâu không ai nên làm vậy, chín chỗ đi tìm cùng một binary chế độ bad rồi good 1 log bad log.Printf %+v cfg good kiểu Redacted có String GoString MarshalJSON, 2 cmdline proc pid cmdline và docker top từ host, 3 environ proc pid environ good os.Unsetenv ngay sau khi đọc có xoá được không, 4 con exec sh -c env tiến trình con thừa kế gì, 5 RAM sống python đọc proc pid maps cộng proc pid mem đếm từng chuỗi root cùng container không yama, 6 quyền file 0400 root đọc bằng setpriv uid 65534 và bằng root, 7 reload ghi file mới watcher mtime 500 ms SIGHUP đo ms tới khi phiên bản mới được dùng, 8 bản cũ sau xoay bản cũ còn trong RAM bad giữ tham chiếu good zero byte slice đo trước sau GC, 9 core dump GOTRACEBACK crash cộng SIGABRT core trong cwd strings core grep từng chuỗi rồi ulimit -c 0, vì sao đếm bản sao trong RAM chứ không chỉ hỏi có lộ không secret kiểu string trong Go không xoá được bất biến GC quyết định khi nào vùng nhớ được dùng lại byte slice zero được nhưng mọi bản sao trung gian ReadFile TrimSpace fmt bufio thì không đếm mới thấy số bản sao bằng số chỗ một core dump một proc pid mem một swap-out sẽ mang đi

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

Ảnh chụp output thật nền tối Unsetenv không xoá environ core 118 MB giữ hết với sec-lab cùng binary sec chạy mode bad rồi mode good root trong container 4 chuỗi đánh dấu, chín chỗ rò bad so với good 1 log %+v bad DBPass ENVpw-9f3a7c2e-ROT good DBPass REDACTED, 2 proc pid cmdline bad password ARGpw-deadbeef-ROT good không truyền qua argv, 3 proc pid environ bad ENV_SECRET ENVpw good ENV_SECRET ENVpw vẫn còn, 4 con sh -c env bad con thấy ENV_SECRET 1 good con thấy ENV_SECRET 0 Unsetenv ăn, 5 proc pid mem bad ENV 5 FILE 2 MOUNT 2 ARG 1 110 MB good ENV 2 FILE 2 MOUNT 2, 6 file 0400 root uid 65534 Permission denied cả secret.txt lẫn run secrets db_pw root đọc được, 7 reload 0 restart watcher mtime 500 ms v2 sau 467 ms bad 400 ms good SIGHUP v3 tức thì, 8 bản cũ sau xoay bad trước GC 2 sau GC 1 còn tham chiếu good trước GC 1 sau GC 0 zero cộng GC, 9 core dump 118 MB bad ENV 2 FILE cũ 1 FILE mới 2 MOUNT 1 ARG 2 good ENV 2 FILE cũ 0 FILE mới 2 MOUNT 1 ulimit -c 0 crash lại không có file core, phía host không cần vào container docker inspect sec-lab Config.Env ENV_SECRET ENVpw-9f3a7c2e-ROT DB_HOST pg-lab docker top sec-lab sec mode good file work tls08 secret.txt addr 9541 bad kèm password ai gọi được Docker API là đọc được mọi -e và mọi argv của mọi container không cần shell, đọc số cho đúng ENV 5 bad environ gốc cộng cfg.DBPass cộng bản trong log buffer cộng bản fmt tạm ENV 2 good chỉ còn environ gốc cộng cfg.DBPass environ gốc là vùng kernel sao chép lúc exec Go Unsetenv chỉ sửa bảng của runtime kernel không biết mount tmpfs không chạm đĩa nhưng đọc xong vẫn là string trong RAM tmpfs bảo vệ đĩa không bảo vệ tiến trình, nói thẳng 0400 và tmpfs chặn người dùng khác không chặn root và không chặn chính tiến trình rò ra log core con rẻ nhất và hiệu quả nhất đo được ở đây kiểu Redacted cho log không truyền secret qua argv ulimit -c 0

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ề

  1. Unsetenv chặn con, không chặn /proc/pid/environ và 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ọi Unsetenv vẫn nên, nhưng vì lý do tiến trình con.
  2. Core dump giữ mọi secret tiến trình từng cầm, 118 MB và strings là đủ. ulimit -c 0 cho dịch vụ cầm khoá riêng; nếu cần core để debug, zero []byte khi xoay và chấp nhận rằng string không xoá được.
  3. Kiểu Redacted và 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

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.