The audit found ~80 placeholders, and the damaging ones were where the measuring apparatus itself was prose rather than a command: a6 "( curl ... ) & 를 20개 띄우고 wait" — the 22.2s headline came from this a3 "<로그인 반복, sid 를 /tmp/sids 에>" — the whole RPO measurement a3 "<sid 목록>" — the control it is compared against a5 "<수신 파드IP>" — the injection a8 writes /tmp/tok, reads /tmp/rt — self-inconsistent, sent an empty token b3 $KC / $RT / $NEW never assigned c2 bare kcadm.sh with no kubectl exec a1 conntrack tuples written by hand, though the direction flips per restart Each is now a shell-expandable form: pod IPs from jsonpath, the admin password from the secret, ids from kcadm --format csv, conntrack tuples derived from "conntrack -L" with awk rather than transcribed. Then the rewritten commands were executed against the live cluster, and one of them failed — the 20-way load generator, written as "kubectl run --rm -i", lost its output stream twice in a row. That is a trap this series already hit once, and the rewrite reintroduced it. A-6 now uses a resident probe pod that collects into a file and is cat-ed once; verified 20/20 lines. Evidence: docs/evidence/followup/05-command-reproducibility.txt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.6 KiB
D-3 — Secret 은 정말 감춰지는가
브랜치 feature/keycloak-d3-secret-management ·
증거 docs/evidence/d3-secret-management/ ·
2026-09-04 17:15–17:25 KST
구조
다이어그램 규약은
diagrams/_style.md. 실험대 전체 구조는diagrams/lab-topology.svg.
0. 결론부터
| 경로 | 감춰지는가 |
|---|---|
kubectl get secret -o jsonpath | base64 -d |
★ 한 줄로 읽힌다 |
kubectl describe secret |
값을 숨긴다 — 그래서 안전하다고 착각한다 |
| 저장소(at rest) | ★ 암호화 꺼져 있음. 저장 파일에 평문이 있다 |
| 파드 안 | ★ 평범한 환경변수다 |
| RBAC 기본값 | 막는다 — default 서비스계정은 못 읽는다 |
"Secret 이니까 안전하다" 는 네 가지 중 하나(RBAC)만 맞다.
1. 한 줄로 읽힌다
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
keycloak-lab-secrets/POSTGRES_PASSWORD = lab-postgres-change-me
keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD = lab-admin-change-me
bff-secrets/KEYCLOAK_CLIENT_SECRET = bff-lab-secret
oauth2-proxy-secrets/COOKIE_SECRET_A = lab-cookie-secret-aaaaaaaaaaaaaa
실험대의 모든 비밀이 명령 네 줄로 나온다.
describe 는 감춘다 — 그것이 함정이다
Type: Opaque
Data
====
KEYCLOAK_CLIENT_SECRET: 14 bytes
바이트 수만 보여준다. 이것만 보면 "가려져 있구나" 싶다.
get -o jsonpath 한 번이면 값이 나온다.
개념 — base64 는 인코딩이지 암호화가 아니다
| 목적 | 되돌리기 | |
|---|---|---|
| 인코딩 (base64) | 바이너리를 텍스트로 안전하게 옮기기 | 키 없이 누구나 |
| 암호화 | 키 없이는 못 읽게 하기 | 키가 있어야 |
Secret 이 base64 를 쓰는 이유는 감추려는 것이 아니라 YAML 에 임의 바이트를 담기 위해서다.
2. 저장소에는 평문으로 있다
ssh kc-lab-1 'sudo k3s secrets-encrypt status'
Encryption Status: Disabled, no configuration file found
k3s 의 저장소 암호화가 꺼져 있다. 기본값이다.
/var/lib/rancher/k3s/server/db/state.db 13MB
/var/lib/rancher/k3s/server/db/state.db-wal 10MB
ssh kc-lab-1 'sudo grep -c "lab-postgres-change-me" /var/lib/rancher/k3s/server/db/state.db'
state.db 안의 평문 일치: 2
저장 파일 안에 비밀번호가 그대로 있다.
| 그래서 무엇이 위험한가 | |
|---|---|
| 노드 디스크를 얻으면 | 전 클러스터의 비밀 |
| 노드 백업/스냅샷 | 같은 것을 복사한다 |
A-4 에서 본 local-path PVC |
같은 디스크에 있다 |
D-1 에서 "덤프를 같은 장애 도메인에 두면 백업이 아니다" 라고 썼는데, 여기서는 "노드 디스크 하나가 모든 비밀" 이다. 백업을 잘 챙겨도 그 백업 안에 비밀이 평문으로 들어간다.
k3s 는 --secrets-encryption 플래그로 켤 수 있다. 지금은 안 켜져 있다.
3. 파드 안에서는 환경변수다
kubectl -n keycloak-lab exec $(kubectl -n keycloak-lab get pod -l app=bff --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}') -- sh -c 'env | grep -iE "secret|password"'
KEYCLOAK_CLIENT_SECRET=bff-lab-secret
BFF_DB_PASSWORD=lab-postgres-change-me
env 한 번이면 나온다.
| 새는 경로 | |
|---|---|
kubectl exec 권한이 있는 사람 |
바로 본다 |
| 같은 파드의 다른 프로세스 | /proc/<pid>/environ |
| 크래시 덤프 · 오류 리포트 | 환경변수를 함께 담는 도구가 많다 |
| 자식 프로세스 | 상속된다 |
볼륨으로 마운트하면 이 중 몇 가지가 줄어든다 — 파일 권한으로 제한할 수 있고 환경변수 덤프에 안 들어간다.
volumeMounts:
- name: secrets
mountPath: /etc/secrets
readOnly: true
4. RBAC 은 실제로 막는다
kubectl auth can-i get secrets -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
default SA: no
기본 서비스계정은 Secret 을 못 읽는다. 쿠버네티스의 기본값이 제한적이다.
네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다. 그러므로 "누가
get secrets를 할 수 있는가" 가 실질적인 방어선이며, 관리자 권한을 가진 사람에게는 아무 방어가 없다.
A-0 의 관측 스택에서 nodes/proxy 서브리소스를 따로 줘야 했던 것처럼,
Secret 접근도 리소스 단위로 나눌 수 있다.
5. 그래서 무엇을 해야 하는가
지금: 매니페스트에 stringData 평문 → git 에 커밋되면 끝
k3s 저장소 암호화 꺼짐
파드 환경변수
| 단계 | 얻는 것 |
|---|---|
① 매니페스트에서 값을 빼고 .example 만 커밋 |
git 유출을 막는다 |
② k3s --secrets-encryption 활성화 |
노드 디스크 유출을 막는다 |
| ③ 환경변수 대신 볼륨 마운트 | 프로세스·덤프 유출을 줄인다 |
| ④ SealedSecret / 외부 KMS | 매니페스트에 암호문만 남는다 |
| ⑤ RBAC 최소화 | 유일하게 이미 동작하는 방어선을 좁힌다 |
이 실험대는 ①~④ 중 아무것도 안 하고 있다. 실험 목적으로는 의도적이지만, 그 사실을 기록해두지 않으면 그대로 운영에 옮겨간다.
이 실험대의 비밀들은 이미 문서에 있다
lab-postgres-change-me, bff-lab-secret 같은 값이 이 저장소의 매니페스트와
문서에 그대로 적혀 있다. 실험대 전용이며 외부에서 접근할 수 없는 값이지만,
"실험대니까 괜찮다" 가 습관이 되면 위험하다. 이름에 change-me 를 넣은 것이
그 최소한의 표시다.
증거 파일
증거 수집 시각: 2026-09-04 15:05 – 15:06 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
| 파일 | 종류 |
|---|---|
01-base64-not-encryption.txt |
터미널 원문 |
02-at-rest.txt |
터미널 원문 |
파일별 상세는 evidence/d3-secret-management/README.md.
6. 재현 절차 (명령어)
# 1. 한 줄로 읽힌다
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
# 2. describe 는 감춘다 — 대조
kubectl -n keycloak-lab describe secret bff-secrets
# 3. 저장소 암호화 여부
ssh kc-lab-1 'sudo k3s secrets-encrypt status'
# 4. 저장 파일에 평문이 있는가
ssh kc-lab-1 'sudo grep -c "lab-postgres-change-me" /var/lib/rancher/k3s/server/db/state.db'
# 5. 파드 안에서는 환경변수
kubectl -n keycloak-lab exec keycloak-0 -- sh -c 'env | grep -i secret'
# 6. 누가 읽을 수 있는가
kubectl auth can-i get secrets -n keycloak-lab \
--as=system:serviceaccount:keycloak-lab:default
7. 다음에 남기는 것
| D-4 인증서 갱신 | 인증서 개인키도 같은 문제다 |
| B-6 암호화 key | key 를 Secret 에 두면 이 실험의 결론이 그대로 적용된다 |
| 운영 | RBAC 이 유일하게 동작하는 방어선이다 |