Files
document-haness/docs/keycloak-session-store/tech-log-studio/operations-that-report-success/setup/setup-reproduce-d3-secret-exposure.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:59:42 +09:00

35 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
id kind slug title topic topicName project status studio pinnedVersions source sourceRevision
186443e8-a32a-4a94-8609-845a4247d120 SETUP reproduce-d3-secret-exposure 카나리아 Secret 을 심고 네 경로에서 평문이 어디까지 나오는지 본다 operations-that-report-success 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 keycloak-session-store 게시 전 https://hyeonworks.com/studio/documents/186443e8-a32a-4a94-8609-845a4247d120/edit
name version
k3s 저장소 암호화 Disabled
name version
판 번호 SSOT D-3 절에 한 줄도 없다
final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3
cdac9b8178391311d8eca1ebc6cac15bb62d79af

카나리아 Secret 을 심고 네 경로에서 평문이 어디까지 나오는지 본다

지워도 되는 카나리아 Secret 하나를 심고 API·노드 디스크·파드 안·접근 제어 네 경로에서 평문이 어디까지 나오는지 재는 절차다. 파괴적인 단계가 없고, 복구는 그 Secret 을 지우는 한 줄이다.

관계

  • 볼륨 없는 영속화와 유예 없는 키 회전 이 절차가 판정하는 것은 값이 아니라 경로다. 그 값들을 어떻게 갈아 끼우는지는 그쪽이 다룬다.
  • up 지표는 살아 있지만 쓸모없는 상태를 보지 못한다 화면이 가려 준다고 감춰진 것은 아니라는 구별을, 지표 쪽에서 같은 모양으로 다룬다.
  • 스키마를 통째로 지우고 덤프 하나로 되살아나는지 본다 거기서는 덤프를 어디에 두느냐를 물었고, 여기서는 노드 디스크 하나가 모든 비밀이라는 답이 나온다.
  • 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다 비밀이 샜을 때 실제로 해야 하는 일이 삭제가 아니라 회전인 까닭을 그쪽이 다룬다.
  • cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다 같은 결론이 이 실험대의 cookie secret 에도 그대로 적용된다.

본문

읽기 전에 — 어디서 치는가

기계가 둘이다. kubectl[lab host] 에서 치고, k3s 서버의 저장 파일을 들여다보는 k3s·ls·grep·strings 는 그 파일이 있는 [kc-lab-1] 에서 친다. 게스트(kc-lab-1/kc-lab-2)의 sudo 는 무암호이고, 호스트와 다르다.

[kc-lab-1] 라벨이 붙은 블록은 게스트 셸이다. lab host 에서 ssh kc-lab-1 로 들어가서 치고, 끝나면 exit 로 나온다. ssh kc-lab-2 '…' 한 줄 형태는 lab host 에서 그대로 쳐도 되므로 [lab host] 로 두었다.

원 가이드는 둘을 한 기계로 묶어 전부 kc-lab-1 에서 치라고 적었고, 전제 한 줄은 「kubectlsudo 로 쓴다」인데 본문의 kubectl 줄에는 sudo 가 없다. 그 조합은 이 실험대에서 안 돈다 — 2026-09-17 에 양쪽에서 쳐서 확인했다(observed).

어디서 kubectl … sudo kubectl …
lab host 된다 안 된다
kc-lab-1 안 된다 된다

기반 가이드가 kubeconfig 를 lab host 의 ~/.kube/config 에만 두고 게스트의 사용자 홈에는 일부러 두지 않기 때문이다. kc-lab-1 에서 sudo 없이 치면 이렇게 끝난다.

error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied

그래서 kubectl[lab host] 에서 치고, 저장 파일을 보는 넷만 [kc-lab-1] 에서 sudo 로 친다. 굳이 kc-lab-1 한 기계에서 다 치겠다면 kubectl 에도 sudo 를 붙인다. D-1 은 같은 전제에 「kubeconfig 를 사용자 홈에 복사해 뒀다면 sudo 는 빼도 된다」를 괄호로 달아 두었는데, 그쪽은 반대 방향을 말한다.

무엇
네임스페이스 keycloak-lab
주입 수단 카나리아 Secret 하나 — d3-canary
되돌리는 수단 delete secret d3-canary 한 줄
전 구간 약 15분
파괴적인 단계 없다. 다섯 편 중 유일하다
jq 이 실험대 어디에도 없다. 이 절차도 쓰지 않는다

이건 비밀을 화면에 띄우는 실험이다. 몇 개의 명령은 비밀번호를 터미널에 그대로 찍는다. 그게 결론이라 피할 수 없지만, 그 값은 스크롤백·화면 공유·터미널 로그에 남는다. 가이드는 그래서 셋을 정해 두고 시작한다.

  • 남의 진짜 비밀은 길이(wc -c)와 키 이름까지만 본다.
  • 값을 찍어 봐야 하는 곳은 이 실험용으로 직접 만든 카나리아 Secret 을 쓴다. 지워도 되는 값이므로 찍어도 된다.
  • 실측으로 실린 값들은 이 저장소의 매니페스트와 문서에 이미 적혀 있는 실험대 전용 값이고, 그래서 값 이름에 change-me 가 들어 있다.

이 절차는 그 셋을 한 겹 더 지킨다. 아래에서 API 로 뽑힌 네 값과 파드 안 환경변수 두 값은 키 이름과 길이까지만 적는다. 그리고 가이드가 grep 인자에 클라이언트 비밀 평문을 적어 둔 두 줄은 카나리아 문자열로 바꿔 실었다 — 명령의 모양은 같고 옮기면 안 되는 값만 빠졌다.

이 실험이 가르는 것

「비밀번호를 Secret 으로 옮겼습니다」는 리뷰에서 통과 도장을 받는 문장이라고 가이드는 적는다. 이 절차는 그 문장이 실제로 무엇을 막아 주는지를 네 경로로 나눠 판정한다. 가이드는 예측 칸을 넷 다 물음표로 비워 두고 시작한다.

# 경로 누가 쓰나
쿠버네티스 API (get secret) 클러스터에 접근하는 사람
노드 디스크의 저장 파일 디스크·백업·스냅샷을 얻은 사람
파드 안의 프로세스 exec 권한이 있는 사람, 크래시 덤프
RBAC 권한이 없는 주체

판정에 앞서 개념 둘을 가른다.

목적 되돌리기
인코딩 (base64) 바이너리를 텍스트로 안전하게 옮기기 키 없이 누구나
암호화 키 없이는 못 읽게 하기 키가 있어야

Secret 이 base64 를 쓰는 까닭은 감추려는 것이 아니라 YAML 에 임의 바이트를 담기 위해서다. 그런데 kubectl describe 가 값을 가려서 보여 주므로 「가려져 있구나」라는 인상이 남는다. 이 절차는 그 인상과 사실 사이의 거리를 잰다.

가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. describe14 bytes 만 보여 주는 것, 같은 값이 한 줄로 평문이 되는 것, 저장소 암호화가 꺼져 있는 것, 노드 디스크의 저장 파일 안에 평문이 있는 것, 그 grep0 을 돌려주는데도 안전하지 않은 것, 파드 안에서는 그냥 환경변수인 것, 접근 제어는 실제로 막는 것.

다섯째가 이 편의 요점이다. 같은 파일에 같은 명령을 걸었는데 키에 따라 20 이 나왔고, 0 을 「없다」로 읽으면 틀린다는 것을 가이드가 따로 한 절로 적는다.

전제와 되돌리기

  • 명령은 kc-lab-1 에서 친다. k3s 서버의 저장 파일도 이 노드에 있고, 그래서 ②를 여기서 칠 수 있다.
  • 게스트의 sudo 는 무암호다. 호스트와 다르다.
  • 네임스페이스는 keycloak-lab 이다.
  • B-6(key 회전)와 B-7(쿠키 비밀 회전)을 이미 했다면 이 실험의 결론이 그 key 들에도 그대로 적용된다는 것을 알고 있을 것이라고 가이드는 적는다.

파괴적인 단계가 없는 편이다. 만드는 것은 카나리아 Secret 하나뿐이고 복구 절에서 지운다. 전 구간 약 15분. 되돌리기는 한 줄이다.

kubectl -n keycloak-lab delete secret d3-canary

주입 전에 같은 명령으로 먼저 본다

무엇이 있는지부터 본다. 카나리아를 심기 전에 목록과 describe 화면을 봐 둬야, 심은 뒤의 describe 가 같은 화면이라는 것이 보인다.

Secret 목록 → describe 가 감추는 화면 → 키 이름만 → 길이만

1. Secret 이 몇 개 있는가

무엇을 보는가 — 이름과 키 개수.

kubectl -n keycloak-lab get secret

어디를 보나 — 실측은 이렇다(observed, 01-base64-not-encryption.txt).

  bff-secrets                Opaque       keys=1
  keycloak-lab-secrets       Opaque       keys=2
  oauth2-proxy-secrets       Opaque       keys=3

이 값이 뜻하는 것 — 이 실험대는 스크립트로 정리해 찍었다(observed). 위 명령을 그대로 치면 NAME · TYPE · DATA · AGE 네 칸이 나오고, DATA 열이 실측 줄의 keys= 에 해당한다. TYPEOpaque 인 것도 본다 — 「불투명」이라는 이름이지만 그건 쿠버네티스가 내용 구조를 모른다는 뜻이지 감춘다는 뜻이 아니다.

2. describe 가 무엇을 감추는가

무엇을 보는가 — 키 이름과 바이트 수.

kubectl -n keycloak-lab describe secret bff-secrets

어디를 보나 — 실측은 이렇다(observed, 01-base64-not-encryption.txt).

  Type:  Opaque

  Data
  ====
  KEYCLOAK_CLIENT_SECRET:  14 bytes

이 값이 뜻하는 것 — 키 이름과 바이트 수만 나오고 값이 없다. 이 화면이 「Secret 은 감춰진다」는 인상의 출처다. describe 는 일부러 값을 안 찍는데, 그건 describe 라는 명령의 동작이지 저장이나 전송의 성질이 아니다. 이 구별이 이 편 전체의 축이다.

3. 남의 비밀은 키 이름과 길이까지만 본다

무엇을 보는가 — 어떤 키가 들어 있는가. 값은 보지 않는다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \
  | tr ',' '\n' | grep -o '"[A-Z_]*"'

어디를 보나 — 모양은 이렇다(observed).

"KC_BOOTSTRAP_ADMIN_PASSWORD"
"POSTGRES_PASSWORD"

jq 가 없어서 trgrep 으로 자른다. D-2 가 레지스트리 태그 목록을 자를 때 쓴 것과 같은 수법이고, jq 가 없다는 전제가 여기서도 형태를 정한다.

kubectl -n keycloak-lab get secret keycloak-lab-secrets \
  -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c

어디를 보나 — 모양은 이렇다(observed).

22

이 값이 뜻하는 것 — 숫자 하나가 나오고 값은 화면에 없다. 「Secret 이 제대로 들어갔는가」를 확인하는 데는 길이면 충분한 경우가 대부분이다. 배포가 안 될 때 진짜로 궁금한 것은 대개 「비었는가 아닌가」이지 값 자체가 아니다.

문제가 생기면wc -c 는 개행까지 세므로 base64 -d 결과에 개행이 없으면 실제 길이와 같다. 값이 비었으면 0 이 나오고, 0 은 「Secret 은 있는데 그 키가 비었다」는 뜻이며 배포 실패의 흔한 원인이라고 가이드는 적는다.

주입

여기부터 상태가 바뀐다. 바뀌는 것은 Secret 하나다.

1. 카나리아 Secret 을 심는다

목적 — 저장 파일 안을 grep 할 때 쓸, 찾아도 아무 피해가 없는 문자열을 하나 만든다.

왜 카나리아를 쓰는지가 먼저다. 관찰 절에서 저장 파일 안을 grep 해야 하는데, 그러려면 찾을 문자열을 알고 있어야 한다. 진짜 비밀번호를 grep 인자로 쓰면 그 값이 셸 히스토리와 프로세스 목록(ps 로 다른 사용자에게도 보인다)에 남는다. 실험 대상이 값 자체가 아니라 경로이므로 이렇게 해도 결론은 같다.

① 카나리아를 만든다.

kubectl -n keycloak-lab create secret generic d3-canary \
  --from-literal=CANARY=d3-canary-zq7v-do-not-use

예상 결과 — 모양은 이렇다(observed).

secret/d3-canary created

왜 필요한가 — 이 값은 아무 데도 쓰이지 않는다. 어떤 파드도 참조하지 않으므로 지워도 아무것도 안 깨진다. 값에 do-not-use 를 넣어 둔 까닭은 나중에 저장 파일 어딘가에서 이 문자열을 다시 만났을 때 무엇인지 알아보기 위해서다.

문제가 생기면 — 이미 있으면 AlreadyExists 가 나온다. 지우고 다시 만든다.

주입 검증

kubectl -n keycloak-lab get secret d3-canary
kubectl -n keycloak-lab describe secret d3-canary

모양은 이렇다(observed).

Data
====
CANARY:  25 bytes

이 화면은 이 실험대가 본 적이 없다(unknown) — 카나리아를 심지 않고 실제 값으로 쟀기 때문이다. --from-literal 이 개행을 붙이지 않으므로 25d3-canary-zq7v-do-not-use 의 글자 수와 그대로 맞는다. 가이드에 26 으로 적혀 있던 것을 고쳤다. 가이드 자신이 적어 둔 --from-literal 값의 글자 수와도 맞지 않는 수였다.

여기서도 describe 는 바이트 수만 준다. 주입 전에 본 화면과 같다. 값을 아는 것은 당신뿐이고, 그래서 다음 절의 비교가 성립한다 — 저장 파일에서 이 문자열을 찾았을 때 그것이 무엇인지 아는 사람이 당신 하나이기 때문이다.

관찰

네 경로를 하나씩 연다. ①은 API, ②는 노드 디스크, ③은 파드 안, ④는 RBAC(Role-Based Access Control, 역할 기반 접근 제어)다.

1. ① API — 한 줄로 읽힌다

값을 아는 카나리아로 먼저 해 본다.

kubectl -n keycloak-lab get secret d3-canary \
  -o jsonpath='{.data.CANARY}' | base64 -d; echo

모양은 이렇다(observed).

d3-canary-zq7v-do-not-use

주입 절에서 심은 값이 그대로 나온다. 같은 명령이 실제 비밀에도 그대로 듣고, 원래 실행이 네 개를 뽑은 결과가 증거 파일에 있다(observed, 01-base64-not-encryption.txt). 값은 옮기지 않는다 — 네 줄 전부 <Secret>/<키> = <평문> 꼴로 나왔고, 값 자리에 있던 것은 이름에 change-me 가 들어간 실험대 전용 문자열이다. 원문은 증거 파일에 둔다.

  keycloak-lab-secrets/POSTGRES_PASSWORD            = <평문 22자>
  keycloak-lab-secrets/KC_BOOTSTRAP_ADMIN_PASSWORD  = <평문>
  bff-secrets/KEYCLOAK_CLIENT_SECRET                = <평문 14자>
  oauth2-proxy-secrets/COOKIE_SECRET_A              = <평문>

실험대의 모든 비밀이 명령 네 줄로 나온다. describe14 bytes 라고 했던 그 키의 값이 정확히 14자다 — 같은 값을 명령 둘이 다르게 보여 주고 있었고, 감춘 쪽은 describe 뿐이다.

①은 막지 않는다. base64 는 인코딩이고 base64 -d 는 누구나 칠 수 있다. 여기서 실질적인 방어선은 누가 이 명령을 칠 수 있는가이며, 그건 ④로 넘어가는 물음이다.

이 네 줄을 당신 환경에서 그대로 재현할 필요는 없다고 가이드는 적는다. 카나리아로 한 번 확인했으면 기제는 같고, 진짜 비밀은 앞에서 한 길이 확인으로 충분하다.

2. ② 저장소 — 노드 디스크에 평문이 있다

암호화 설정부터 본다.

sudo k3s secrets-encrypt status

실측은 이렇다(observed, 02-at-rest.txt).

  Encryption Status: Disabled, no configuration file found

Disabled, 그리고 no configuration file found 를 본다. 설정 파일이 아예 없다 — 껐다기보다 켠 적이 없다는 뜻이고, 이게 기본값이다.

sudo ls -l /var/lib/rancher/k3s/server/db/

실측은 이렇다(observed, 02-at-rest.txt).

  total 23336
  drwx------ 2 root root     4096 Sep  2 09:12 .
  drwx------ 8 root root     4096 Sep  4 03:23 ..
  -rw-r--r-- 1 root root 13078528 Sep  4 06:05 state.db
  -rw-r--r-- 1 root root    32768 Sep  4 06:06 state.db-shm
  -rw-r--r-- 1 root root 10769712 Sep  4 06:06 state.db-wal

파일이 셋이다.

파일 무엇인가
state.db 본체
state.db-wal 아직 본체에 합쳐지지 않은 최근 쓰기
state.db-shm 공유 메모리 인덱스

k3s 는 etcd 대신 SQLite 를 쓴다. 「저장소(at rest)」가 놓이는 곳은 같다 — etcd 를 쓰는 클러스터라면 여기가 etcd 의 데이터 디렉터리다. -wal 이 10MB 나 되는 것을 봐 둔다 — 방금 만든 카나리아는 아직 본체에 없을 가능성이 높고, 그게 바로 아래에서 함정이 된다.

파일 안을 찾아본다. 카나리아부터다.

sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal

이 실험대는 카나리아 대신 실제 값으로 쟀다(observed). 위의 카나리아 형태는 2026-09-17 에 쳐서 확인했다(observed) — 그때까지 가이드가 미검증으로 표시해 둔 줄이다.

state.db      0
state.db-wal  1

01 이 가이드가 예측만 하고 못 가른 것을 가른다. 방금 만든 값은 아직 본체로 안 내려가고 -wal 에만 있다. 그러니 본체에서 0 이 나왔다고 「평문이 없다」가 아니라, -wal 까지 봐야 답이 나온다. 실제로 나온 결과가 이것이다(observed, 02-at-rest.txt).

=== ★ 저장 파일에서 비밀번호가 그대로 보이는가 ===
  state.db 안의 평문 일치: 2
=== 평문이 저장 파일에 있다는 것을 눈으로 ===
  client secret 평문 등장 횟수: 0

두 줄의 값이 다르다. 20 이다. 같은 파일, 같은 명령, 다른 키인데 하나는 두 번 나오고 하나는 안 나온다. 가이드가 이 절에서 제일 중요하다고 적은 문장이 그다음에 온다.

grep0 을 돌려준 것은 「평문이 없다」가 아니라 「이 파일의 이 시점에 이 형태로는 못 찾았다」이다.

2 가 나온 순간 ②의 답은 이미 정해졌다 — 저장 파일에 평문이 있다. 0 이 나온 키를 두고 「그건 안전한가 보다」라고 읽으면, 같은 파일에 평문이 들어 있는 것을 이미 본 뒤에 그러는 셈이다.

0 이 나왔을 때 다음에 볼 곳을 가이드가 적어 두긴 했는데, 이 실험은 원인을 가리지 않았다(unknown). 아래 두 줄과 표가 전부 미검증이다.

카나리아로 쳐서 두 줄 다 0 이 나와도 여기서 멈추지 않는다. 방금 만든 값이라 아직 -wal 에만 있거나 둘 다에 안 내려갔을 수 있고, 어느 쪽인지 가르는 절차를 이 실험이 밟지 않았다(unknown). ②의 판정은 당신 화면의 숫자가 아니라 위 실측의 2 가 이미 냈다. 아래 표로 한 번 더 보고, 숫자가 무엇이든 ③으로 넘어간다.

가이드는 이 두 줄의 인자에 클라이언트 비밀 평문을 적어 두었다. 값은 옮기지 않는다 — 그리고 가이드 자신이 주입 절에서 「진짜 비밀번호를 grep 인자로 쓰면 셸 히스토리와 ps 에 남는다」고 적었으므로, 아래에는 카나리아 문자열을 넣었다.

sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal
sudo sh -c 'strings /var/lib/rancher/k3s/server/db/state.db | grep -c d3-canary-zq7v-do-not-use'

:::warning

게스트에 strings 가 없다. binutils 가 안 깔려 있어 그대로 치면 sh: 1: strings: not found 로 끝난다(2026-09-17, observed). sudo apt install -y binutils 로 깔거나, strings 없이 grep -c 만으로도 같은 답이 나온다 — 둘 다 쳐서 같은 수를 받았다(observed).

:::

sudo strings … | grep 이 아니라 sudo sh -c '…' 인 까닭은 파이프가 sudo 밖에서 이어지기 때문이다. 앞의 형태로 치면 strings 만 root 로 돌고 grep 은 일반 사용자로 도는데, 여기서는 읽는 쪽이 strings 라 결과는 같다. 다만 파일을 grep 이 직접 읽는 아래 형태에서는 sudogrep 에 붙어야 한다.

왜 안 나올 수 있나 확인
아직 -wal 에만 있다 -wal 을 같이 grep
값이 페이지 경계를 넘어 잘렸다 strings 로 한 번 더
그 키가 그 시점에 없었다 get secret 으로 존재 확인

grep -c 는 바이너리 파일에도 듣는다. 평소의 grep 은 바이너리를 만나면 Binary file ... matches 한 줄만 찍고 내용을 안 보여 주는데, -c 는 개수만 세므로 그대로 숫자가 나온다. 값 자체를 화면에 안 띄운다는 점에서도 이 형태가 맞다 — 여기서 궁금한 것은 「있는가」이지 「무엇인가」가 아니다.

그래서 무엇이 위험한지를 가이드가 넷으로 적는다.

노드 디스크를 얻으면 전 클러스터의 비밀
노드 백업/스냅샷 같은 것을 복사한다
A-4 에서 본 local-path PVC 같은 디스크에 있다
D-1 의 덤프 같은 기계에 뒀다면 거기도 같이

D-1 에서 「덤프를 같은 장애 도메인에 두면 백업이 아니다」라고 했는데, 여기서는 노드 디스크 하나가 모든 비밀이다. 백업을 잘 챙길수록 비밀도 잘 복사된다.

k3s 는 --secrets-encryption 플래그로 켤 수 있다. 지금은 안 켜져 있고 이 절차는 켜지 않는다 — 켜는 것은 서버 재시작과 기존 Secret 재암호화를 수반하고, 이 실험대에서 시험하지 않았다(unknown).

3. ③ 파드 안 — 평범한 환경변수다

어느 파드를 볼지 먼저 정한다.

kubectl -n keycloak-lab get pods -l app=bff

이 실험대는 파드 이름을 직접 지정했다(observed). 아래 형태는 가이드가 미검증으로 표시한 줄이다(unknown).

치기 전에 — 이 줄은 값을 화면에 찍는다. 나오는 것은 카나리아가 아니라 KEYCLOAK_CLIENT_SECRETBFF_DB_PASSWORD 의 평문이다. 바로 아래 실측을 <평문 14자> 로 가린 것은 이 문서이지 당신의 터미널이 아니다. 찍힌 값은 스크롤백과 셸 히스토리에 남고, 화면을 공유 중이면 보는 사람 모두에게 간다. 이 편이 「읽기 전에」에서 세운 「남의 진짜 비밀은 길이와 키 이름까지만 본다」를 이 줄 하나가 벗어나는데, 같은 결론을 값 없이 내는 명령은 가이드에 없다(unknown). 운영 클러스터에서는 치지 않는다. 실험대에서 쳤으면 복구 절의 history 확인까지 마치고, 운영 값을 찍었으면 회전(B-6·B-7)으로 이어 간다.

kubectl -n keycloak-lab exec deploy/bff -- sh -c 'env | grep -iE "secret|password"'

실측은 이렇다(observed, 02-at-rest.txt). 값은 옮기지 않는다 — 두 줄 다 <환경변수>=<평문> 꼴이고, 오른쪽에 있던 것이 ①에서 API 로 뽑은 바로 그 값이다.

  KEYCLOAK_CLIENT_SECRET=<평문 14자>
  BFF_DB_PASSWORD=<평문 22자>

env 한 번이면 나온다. 그리고 클라이언트 비밀은 ①에서 API 로 뽑은 값과 같다 — 두 경로가 같은 평문에 닿는다.

execdeploy/bff 로 안 되면(파드가 종료 중이거나 여럿이면) 이름을 골라 친다.

kubectl -n keycloak-lab get pod -l app=bff \
  --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'; echo

이 줄은 이름을 찍기만 한다. 화면에 나온 bff-... 를 ②의 deploy/bff 자리에 그대로 넣어 다시 친다 — exec 다음의 대상만 바뀌고 -- sh -c '...' 부터는 같다. 가이드는 그 이름을 받아 치는 줄까지는 적지 않았다.

같은 파드 안의 다른 프로세스도 본다. 이게 환경변수의 진짜 성질이다. 아래도 가이드가 미검증으로 표시한 형태다(unknown).

이 줄도 값을 화면에 찍는다. ②에서 본 것과 같은 평문이 같은 자국을 남긴다. 여기서 확인하려는 것은 「같은 값이 또 나오는가」뿐이므로, 화면을 공유 중이거나 운영 클러스터에 붙어 있으면 치지 않고 ②의 결과로 판정한다.

kubectl -n keycloak-lab exec deploy/bff -- \
  sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i secret'

같은 값이 나오는가를 본다. /proc/<pid>/environ 은 그 프로세스의 환경변수를 그대로 담고 있고, 같은 사용자 id 로 도는 아무 프로세스나 읽는다.

새는 경로
kubectl exec 권한이 있는 사람 바로 본다
같은 파드의 다른 프로세스 /proc/<pid>/environ
크래시 덤프 · 오류 리포트 환경변수를 함께 담는 도구가 많다
자식 프로세스 상속된다

볼륨으로 마운트하면 이 중 몇 가지가 줄어든다 — 파일 권한으로 제한할 수 있고, 환경변수 덤프에 안 들어간다.

volumeMounts:
  - name: secrets
    mountPath: /etc/secrets
    readOnly: true

줄어드는 것이지 없어지지는 않는다. exec 권한이 있으면 파일도 읽는다.

4. ④ RBAC — 유일하게 막는다

kubectl auth can-i get secrets -n keycloak-lab \
  --as=system:serviceaccount:keycloak-lab:default

실측은 이렇다(observed, 02-at-rest.txt).

  default SA: no

no 한 단어다. 기본 서비스계정은 Secret 을 못 읽는데, 명시적으로 거부해서가 아니라 아무 권한도 주지 않았기 때문이다. RBAC 은 기본이 거부이고 Role 을 붙여야 할 수 있게 된다.

어떤 권한이 있는지 통째로 보는 형태도 가이드에 있고, 미검증이다(unknown).

kubectl auth can-i --list -n keycloak-lab \
  --as=system:serviceaccount:keycloak-lab:default
kubectl -n keycloak-lab get role,rolebinding

네 가지 중 유일하게 제 역할을 하는 것이 RBAC 다. 그러므로 실질적인 방어선은 「누가 get secrets 를 할 수 있는가」이며, 관리자 권한을 가진 사람에게는 아무 방어가 없다. A-0 의 관측 스택에서 nodes/proxy 서브리소스를 따로 줘야 했던 것처럼 Secret 접근도 리소스 단위로 나눌 수 있다고 가이드는 덧붙인다.

5. 네 경로를 한 표로 모은다

# 경로 감춰지는가 무엇이 뚫나
get -o jsonpath | base64 -d 아니다 클러스터 접근 권한
describe secret 값을 숨긴다 그래서 안전하다고 착각한다
저장 파일(state.db) 아니다. 암호화 꺼짐 노드 디스크·백업·스냅샷
파드 안 아니다. 평범한 환경변수 exec · /proc · 크래시 덤프
RBAC 막는다 관리자 권한

「Secret 이니까 안전하다」는 네 가지 중 하나만 맞다. 그리고 ②·③ 은 쿠버네티스 API 를 한 번도 거치지 않고 평문에 닿는다.

무엇을 해야 하는가를 가이드가 다섯 단계로 적고, 이 실험대는 그중 아무것도 하고 있지 않다고 같은 표에 적는다.

단계 얻는 것 이 실험대
① 매니페스트에서 값을 빼고 .example 만 커밋 git 유출을 막는다 안 함
② k3s --secrets-encryption 활성화 노드 디스크 유출을 막는다 안 함 (unknown)
③ 환경변수 대신 볼륨 마운트 프로세스·덤프 유출을 줄인다 안 함
④ SealedSecret / 외부 KMS 매니페스트에 암호문만 남는다 안 함
⑤ RBAC 최소화 유일하게 이미 동작하는 방어선을 좁힌다 기본값 그대로

실험 목적으로는 의도적이지만 그 사실을 기록해 두지 않으면 그대로 운영에 옮겨간다고 가이드는 적는다. 값 이름에 change-me 를 넣어 둔 것이 그 최소한의 표시다.

복구와 원상복구 확인표

1. 카나리아를 지운다

목적 — 주입 전에 본 목록으로 되돌린다.

① 지우고 목록을 다시 본다.

kubectl -n keycloak-lab delete secret d3-canary
kubectl -n keycloak-lab get secret

예상 결과 — 모양은 이렇다(observed).

secret "d3-canary" deleted

목록이 세 개로 돌아왔는가를 본다.

왜 필요한가 — 어떤 파드도 이 Secret 을 참조하지 않으므로 지워도 아무것도 안 깨진다.

문제가 생기면NotFound 가 나오면 이미 지워졌다.

2. 지웠다고 파일에서 없어지지는 않는다

2026-09-17 에 삭제 후를 쟀다(observed). 그때까지 이 실험이 안 밟고 넘어간 단계다.

sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db
sudo grep -c 'd3-canary-zq7v-do-not-use' /var/lib/rancher/k3s/server/db/state.db-wal

지운 뒤에 오히려 늘었다.

             state.db   state.db-wal
삭제 전         0            1
삭제 뒤         0            7

kubectl delete secret 은 API 에서 그 객체를 없앨 뿐이고, 그 삭제 자체가 같은 값을 담은 레코드를 -wal 에 더 쓴다. 그래서 「Secret 을 지웠다」와 「그 값이 디스크에서 사라졌다」는 다른 사건인 정도가 아니라, 지우는 동작이 흔적을 늘린다.

0 이 나오면 「이 시점에 이 형태로는 안 보인다」이고, 0 이 아니면 지운 Secret 의 평문이 아직 파일에 있는 것이다. 어느 쪽이든 관찰 절의 결론은 안 바뀐다 — 판정은 이미 2 에서 났다.

데이터베이스 파일은 지운 행의 공간을 즉시 0으로 덮어쓰지 않는다. 「Secret 을 지웠다」와 「그 값이 디스크에서 사라졌다」는 다른 사건이고, 비밀이 유출됐을 때 실제로 해야 하는 일이 삭제가 아니라 회전(rotation)인 까닭이 여기 있다 — B-6·B-7 의 주제다.

3. 네 항목을 대조한다

항목 명령 돌아왔을 때
카나리아 kubectl -n keycloak-lab get secret d3-canary NotFound
Secret 목록 kubectl -n keycloak-lab get secret 카나리아를 뺀 나머지. 2026-09-17 의 이 실험대에는 keycloak-lab-secrets 하나뿐이었다(observed)
파드 kubectl -n keycloak-lab get pods 전부 Running (아무것도 안 건드렸다)
터미널 history | tail -40 비밀번호가 찍힌 줄이 어디까지 남았는지 본다

이 실험의 진짜 뒷정리는 스크롤백이다. ①을 실제 비밀로 쳤다면 그 값이 터미널 버퍼와 셸 히스토리에 있다. 실험대 값이라 지금은 상관없지만, 같은 절차를 운영에서 하면 그게 유출 경로가 된다.

막히면

증상 원인 확인
grep0 인데 안전하다고 읽힌다 0 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」 -walstrings 로 한 번 더
grepBinary file matches 만 찍는다 바이너리 파일이다 -c 를 쓴다(개수만). 값을 안 띄우는 이점도 있다
k3s secrets-encrypt 가 없다 서버 노드가 아니다 kc-lab-1(control-plane)에서 친다
state.dbPermission denied root 전용 디렉터리 게스트 sudo 는 무암호다. sudo 를 붙인다
exec deploy/bff 가 실패한다 파드가 종료 중이거나 여럿이다 --field-selector=status.phase=Running 으로 이름을 고른다
auth can-iyes 라고 한다 그 서비스계정에 Role 이 붙어 있다 get rolebinding -o wide 로 누가 줬는지 본다
값이 0 bytes 로 나온다 Secret 은 있는데 키가 비었다 describe 의 바이트 수를 본다 — 배포 실패의 흔한 원인
비밀번호를 화면에 찍어 버렸다 ①을 실제 값으로 쳤다 스크롤백·히스토리를 지우고, 운영이면 회전한다

무엇이 관측이고 무엇이 아닌가

이 절차의 숫자는 2026-09-04 15:0515:06 KST 에 돈 한 번의 실행에서 나왔다(observed).

  • (observed) Secret 세 개의 목록과 keys=1 · keys=2 · keys=3, KEYCLOAK_CLIENT_SECRET: 14 bytes, 카나리아의 CANARY: 25 bytes, Encryption Status: Disabled, no configuration file found, db/ 의 파일 셋과 크기(13078528 · 32768 · 10769712), 저장 파일에서 평문 일치 2 와 같은 명령의 다른 키 0, 파드 안 env 두 줄, default SA: no.
  • 판 번호가 이 편에 한 줄도 없다. SSOT D층 머리말에는 B층 같은 버전 표가 없고, D-3 을 친 시각 15:0515:06 이 D-2 의 첫 실행(15:0015:10, 역방향 26.0 으로 keycloak-1 이 CrashLoop)과 겹쳐 그때 어느 판이 돌고 있었는지가 정해지지 않는다. 그래서 다른 편의 값을 끌어오지 않았다. 고정한 버전에 적은 것은 이 절차가 성립한 구성인 「저장소 암호화 꺼짐」 하나다.
  • 비밀은 길이·존재·키 이름만 적는다 — API 로 뽑힌 네 값과 파드 안 환경변수 두 값은 평문이 그대로 찍힌 줄이라 이 절차로 옮기지 않았다. 키 이름과 14 bytes · 22 라는 길이, 그리고 값 이름에 change-me 가 들어 있다는 모양까지가 옮긴 전부다. 원문은 증거 파일에 그대로 있다.
  • 카나리아 값은 그대로 적었다d3-canary-zq7v-do-not-use 는 이 실험이 grep 인자로 쓰려고 직접 만든 문자열이고 복구 절에서 지운다. 값을 알아야 명령이 성립하므로 명령과 함께 남겼다. 찾아도 아무 피해가 없다는 것이 이 값의 목적이다. 그리고 가이드가 0 이 나온 키를 다시 찾을 때 쓴 두 줄에는 클라이언트 비밀 평문이 인자로 적혀 있었는데, 그 인자를 카나리아 문자열로 바꿔 적었다 — 명령의 모양은 같고 옮기면 안 되는 값만 빠졌다.
  • (unknown) 카나리아를 저장 파일에서 찾는 두 줄, 0 이 나왔을 때 -wal·strings 로 다시 보는 두 줄, exec deploy/bff 형태, /proc/1/environ 을 읽는 줄, auth can-i --list, 삭제 뒤에 다시 grep 하는 두 줄. 가이드가 전부 미검증으로 표시했다. 이 실험대는 카나리아 대신 실제 값으로 쟀고, 삭제 후는 재지 않았다.
  • 이 실험이 확인하지 않은 것 — k3s --secrets-encryption 을 켠 뒤의 상태. 켜는 것은 서버 재시작과 기존 Secret 재암호화를 수반하고, 여기서는 시험하지 않았다. 0 이 나온 키의 원인도 가리지 않았다. 볼륨 마운트·SealedSecret·외부 KMS 도 전부 안 했다.