--- id: 186443e8-a32a-4a94-8609-845a4247d120 kind: SETUP slug: reproduce-d3-secret-exposure title: 카나리아 Secret 을 심고 네 경로에서 평문이 어디까지 나오는지 본다 topic: operations-that-report-success topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/186443e8-a32a-4a94-8609-845a4247d120/edit" pinnedVersions: - name: k3s 저장소 암호화 version: Disabled - name: 판 번호 version: SSOT D-3 절에 한 줄도 없다 source: - final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-3 sourceRevision: 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` 에서 치라고 적었고, 전제 한 줄은 「`kubectl` 은 `sudo` 로 쓴다」인데 본문의 `kubectl` 줄에는 `sudo` 가 없다. 그 조합은 이 실험대에서 안 돈다 — 2026-09-17 에 양쪽에서 쳐서 확인했다(observed). | 어디서 | `kubectl …` | `sudo kubectl …` | |---|---|---| | lab host | 된다 | 안 된다 | | `kc-lab-1` | 안 된다 | 된다 | 기반 가이드가 kubeconfig 를 lab host 의 `~/.kube/config` 에만 두고 게스트의 사용자 홈에는 일부러 두지 않기 때문이다. `kc-lab-1` 에서 `sudo` 없이 치면 이렇게 끝난다. ```text 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` 가 값을 가려서 보여 주므로 「가려져 있구나」라는 인상이 남는다. 이 절차는 그 인상과 사실 사이의 거리를 잰다. 가이드는 끝났을 때 확인되는 것을 일곱으로 적는다. `describe` 가 `14 bytes` 만 보여 주는 것, 같은 값이 한 줄로 평문이 되는 것, 저장소 암호화가 꺼져 있는 것, 노드 디스크의 저장 파일 안에 평문이 있는 것, 그 `grep` 이 `0` 을 돌려주는데도 안전하지 않은 것, 파드 안에서는 그냥 환경변수인 것, 접근 제어는 실제로 막는 것. **다섯째가 이 편의 요점이다.** 같은 파일에 같은 명령을 걸었는데 키에 따라 `2` 와 `0` 이 나왔고, `0` 을 「없다」로 읽으면 틀린다는 것을 가이드가 따로 한 절로 적는다. ## 전제와 되돌리기 - 명령은 `kc-lab-1` 에서 친다. k3s 서버의 저장 파일도 이 노드에 있고, 그래서 ②를 여기서 칠 수 있다. - 게스트의 `sudo` 는 무암호다. 호스트와 다르다. - 네임스페이스는 `keycloak-lab` 이다. - B-6(key 회전)와 B-7(쿠키 비밀 회전)을 이미 했다면 이 실험의 결론이 그 key 들에도 그대로 적용된다는 것을 알고 있을 것이라고 가이드는 적는다. **파괴적인 단계가 없는 편이다.** 만드는 것은 카나리아 Secret 하나뿐이고 복구 절에서 지운다. 전 구간 약 15분. 되돌리기는 한 줄이다. ```bash label="[lab host] 되돌리기 한 줄" kubectl -n keycloak-lab delete secret d3-canary ``` ## 주입 전에 같은 명령으로 먼저 본다 **무엇이 있는지부터 본다.** 카나리아를 심기 전에 목록과 `describe` 화면을 봐 둬야, 심은 뒤의 `describe` 가 같은 화면이라는 것이 보인다. ```text Secret 목록 → describe 가 감추는 화면 → 키 이름만 → 길이만 ``` ### 1. Secret 이 몇 개 있는가 **무엇을 보는가** — 이름과 키 개수. ```bash label="[lab host] Secret 목록을 본다" kubectl -n keycloak-lab get secret ``` **어디를 보나** — 실측은 이렇다(observed, `01-base64-not-encryption.txt`). ```text bff-secrets Opaque keys=1 keycloak-lab-secrets Opaque keys=2 oauth2-proxy-secrets Opaque keys=3 ``` **이 값이 뜻하는 것** — 이 실험대는 스크립트로 정리해 찍었다(observed). 위 명령을 그대로 치면 `NAME` · `TYPE` · `DATA` · `AGE` 네 칸이 나오고, `DATA` 열이 실측 줄의 `keys=` 에 해당한다. `TYPE` 이 `Opaque` 인 것도 본다 — 「불투명」이라는 이름이지만 그건 쿠버네티스가 내용 구조를 모른다는 뜻이지 감춘다는 뜻이 아니다. ### 2. describe 가 무엇을 감추는가 **무엇을 보는가** — 키 이름과 바이트 수. ```bash label="[lab host] describe 화면을 본다" kubectl -n keycloak-lab describe secret bff-secrets ``` **어디를 보나** — 실측은 이렇다(observed, `01-base64-not-encryption.txt`). ```text Type: Opaque Data ==== KEYCLOAK_CLIENT_SECRET: 14 bytes ``` **이 값이 뜻하는 것** — 키 이름과 바이트 수만 나오고 값이 없다. 이 화면이 「Secret 은 감춰진다」는 인상의 출처다. `describe` 는 일부러 값을 안 찍는데, 그건 `describe` 라는 명령의 동작이지 저장이나 전송의 성질이 아니다. 이 구별이 이 편 전체의 축이다. ### 3. 남의 비밀은 키 이름과 길이까지만 본다 **무엇을 보는가** — 어떤 키가 들어 있는가. 값은 보지 않는다. ```bash label="[lab host] ① 키 이름만 뽑는다" kubectl -n keycloak-lab get secret keycloak-lab-secrets -o jsonpath='{.data}' \ | tr ',' '\n' | grep -o '"[A-Z_]*"' ``` **어디를 보나** — 모양은 이렇다(observed). ```text "KC_BOOTSTRAP_ADMIN_PASSWORD" "POSTGRES_PASSWORD" ``` `jq` 가 없어서 `tr` 과 `grep` 으로 자른다. D-2 가 레지스트리 태그 목록을 자를 때 쓴 것과 같은 수법이고, `jq` 가 없다는 전제가 여기서도 형태를 정한다. ```bash label="[lab host] ② 값 대신 길이를 잰다" kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c ``` **어디를 보나** — 모양은 이렇다(observed). ```text 22 ``` **이 값이 뜻하는 것** — 숫자 하나가 나오고 값은 화면에 없다. 「Secret 이 제대로 들어갔는가」를 확인하는 데는 길이면 충분한 경우가 대부분이다. 배포가 안 될 때 진짜로 궁금한 것은 대개 「비었는가 아닌가」이지 값 자체가 아니다. **문제가 생기면** — `wc -c` 는 개행까지 세므로 `base64 -d` 결과에 개행이 없으면 실제 길이와 같다. 값이 비었으면 `0` 이 나오고, `0` 은 「Secret 은 있는데 그 키가 비었다」는 뜻이며 배포 실패의 흔한 원인이라고 가이드는 적는다. ## 주입 여기부터 상태가 바뀐다. 바뀌는 것은 Secret 하나다. ### 1. 카나리아 Secret 을 심는다 **목적** — 저장 파일 안을 `grep` 할 때 쓸, 찾아도 아무 피해가 없는 문자열을 하나 만든다. **왜 카나리아를 쓰는지가 먼저다.** 관찰 절에서 저장 파일 안을 `grep` 해야 하는데, 그러려면 찾을 문자열을 알고 있어야 한다. 진짜 비밀번호를 `grep` 인자로 쓰면 그 값이 셸 히스토리와 프로세스 목록(`ps` 로 다른 사용자에게도 보인다)에 남는다. 실험 대상이 값 자체가 아니라 경로이므로 이렇게 해도 결론은 같다. ① 카나리아를 만든다. ```bash label="[lab host] ① 카나리아 Secret 을 만든다" kubectl -n keycloak-lab create secret generic d3-canary \ --from-literal=CANARY=d3-canary-zq7v-do-not-use ``` **예상 결과** — 모양은 이렇다(observed). ```text secret/d3-canary created ``` **왜 필요한가** — 이 값은 아무 데도 쓰이지 않는다. 어떤 파드도 참조하지 않으므로 지워도 아무것도 안 깨진다. 값에 `do-not-use` 를 넣어 둔 까닭은 나중에 저장 파일 어딘가에서 이 문자열을 다시 만났을 때 무엇인지 알아보기 위해서다. **문제가 생기면** — 이미 있으면 `AlreadyExists` 가 나온다. 지우고 다시 만든다. ## 주입 검증 ```bash label="[lab host] 카나리아가 심겼는지 본다" kubectl -n keycloak-lab get secret d3-canary kubectl -n keycloak-lab describe secret d3-canary ``` 모양은 이렇다(observed). ```text Data ==== CANARY: 25 bytes ``` **이 화면은 이 실험대가 본 적이 없다**(unknown) — 카나리아를 심지 않고 실제 값으로 쟀기 때문이다. `--from-literal` 이 개행을 붙이지 않으므로 `25` 가 `d3-canary-zq7v-do-not-use` 의 글자 수와 그대로 맞는다. 가이드에 `26` 으로 적혀 있던 것을 고쳤다. 가이드 자신이 적어 둔 `--from-literal` 값의 글자 수와도 맞지 않는 수였다. **여기서도 `describe` 는 바이트 수만 준다. 주입 전에 본 화면과 같다.** 값을 아는 것은 당신뿐이고, 그래서 다음 절의 비교가 성립한다 — 저장 파일에서 이 문자열을 찾았을 때 그것이 무엇인지 아는 사람이 당신 하나이기 때문이다. ## 관찰 네 경로를 하나씩 연다. ①은 API, ②는 노드 디스크, ③은 파드 안, ④는 RBAC(Role-Based Access Control, 역할 기반 접근 제어)다. ### 1. ① API — 한 줄로 읽힌다 값을 아는 카나리아로 먼저 해 본다. ```bash label="[lab host] ① 카나리아를 API 로 뽑아 본다" kubectl -n keycloak-lab get secret d3-canary \ -o jsonpath='{.data.CANARY}' | base64 -d; echo ``` 모양은 이렇다(observed). ```text d3-canary-zq7v-do-not-use ``` 주입 절에서 심은 값이 그대로 나온다. 같은 명령이 실제 비밀에도 그대로 듣고, 원래 실행이 네 개를 뽑은 결과가 증거 파일에 있다(observed, `01-base64-not-encryption.txt`). **값은 옮기지 않는다** — 네 줄 전부 `/<키> = <평문>` 꼴로 나왔고, 값 자리에 있던 것은 이름에 `change-me` 가 들어간 실험대 전용 문자열이다. 원문은 증거 파일에 둔다. ```text 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 = <평문> ``` 실험대의 모든 비밀이 명령 네 줄로 나온다. `describe` 가 `14 bytes` 라고 했던 그 키의 값이 정확히 14자다 — 같은 값을 명령 둘이 다르게 보여 주고 있었고, 감춘 쪽은 `describe` 뿐이다. **①은 막지 않는다.** base64 는 인코딩이고 `base64 -d` 는 누구나 칠 수 있다. 여기서 실질적인 방어선은 누가 이 명령을 칠 수 있는가이며, 그건 ④로 넘어가는 물음이다. 이 네 줄을 당신 환경에서 그대로 재현할 필요는 없다고 가이드는 적는다. 카나리아로 한 번 확인했으면 기제는 같고, 진짜 비밀은 앞에서 한 길이 확인으로 충분하다. ### 2. ② 저장소 — 노드 디스크에 평문이 있다 암호화 설정부터 본다. ```bash label="[kc-lab-1] ① 저장소 암호화 상태를 본다" sudo k3s secrets-encrypt status ``` 실측은 이렇다(observed, `02-at-rest.txt`). ```text Encryption Status: Disabled, no configuration file found ``` `Disabled`, 그리고 `no configuration file found` 를 본다. 설정 파일이 아예 없다 — 껐다기보다 켠 적이 없다는 뜻이고, 이게 기본값이다. ```bash label="[kc-lab-1] ② 저장 파일 셋을 본다" sudo ls -l /var/lib/rancher/k3s/server/db/ ``` 실측은 이렇다(observed, `02-at-rest.txt`). ```text 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 나 되는 것을 봐 둔다 — 방금 만든 카나리아는 아직 본체에 없을 가능성이 높고, 그게 바로 아래에서 함정이 된다. **파일 안을 찾아본다.** 카나리아부터다. ```bash label="[kc-lab-1] ③ 저장 파일에서 카나리아를 찾는다" 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) — 그때까지 가이드가 미검증으로 표시해 둔 줄이다. ```text state.db 0 state.db-wal 1 ``` **그 `0` 과 `1` 이 가이드가 예측만 하고 못 가른 것을 가른다.** 방금 만든 값은 아직 본체로 안 내려가고 `-wal` 에만 있다. 그러니 본체에서 `0` 이 나왔다고 「평문이 없다」가 아니라, **`-wal` 까지 봐야 답이 나온다.** 실제로 나온 결과가 이것이다(observed, `02-at-rest.txt`). ```text === ★ 저장 파일에서 비밀번호가 그대로 보이는가 === state.db 안의 평문 일치: 2 === 평문이 저장 파일에 있다는 것을 눈으로 === client secret 평문 등장 횟수: 0 ``` **두 줄의 값이 다르다. `2` 와 `0` 이다.** 같은 파일, 같은 명령, 다른 키인데 하나는 두 번 나오고 하나는 안 나온다. 가이드가 이 절에서 제일 중요하다고 적은 문장이 그다음에 온다. > `grep` 이 `0` 을 돌려준 것은 「평문이 없다」가 아니라 「이 파일의 이 시점에 이 형태로는 못 찾았다」이다. `2` 가 나온 순간 ②의 답은 이미 정해졌다 — 저장 파일에 평문이 있다. `0` 이 나온 키를 두고 「그건 안전한가 보다」라고 읽으면, 같은 파일에 평문이 들어 있는 것을 이미 본 뒤에 그러는 셈이다. `0` 이 나왔을 때 다음에 볼 곳을 가이드가 적어 두긴 했는데, 이 실험은 원인을 가리지 않았다(unknown). 아래 두 줄과 표가 전부 미검증이다. **카나리아로 쳐서 두 줄 다 `0` 이 나와도 여기서 멈추지 않는다.** 방금 만든 값이라 아직 `-wal` 에만 있거나 둘 다에 안 내려갔을 수 있고, 어느 쪽인지 가르는 절차를 이 실험이 밟지 않았다(unknown). ②의 판정은 당신 화면의 숫자가 아니라 위 실측의 `2` 가 이미 냈다. 아래 표로 한 번 더 보고, 숫자가 무엇이든 ③으로 넘어간다. **가이드는 이 두 줄의 인자에 클라이언트 비밀 평문을 적어 두었다. 값은 옮기지 않는다** — 그리고 가이드 자신이 주입 절에서 「진짜 비밀번호를 `grep` 인자로 쓰면 셸 히스토리와 `ps` 에 남는다」고 적었으므로, 아래에는 카나리아 문자열을 넣었다. ```bash label="[kc-lab-1] ④ -wal 과 strings 로 한 번 더 본다" 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` 이 직접 읽는 아래 형태에서는 `sudo` 가 `grep` 에 붙어야 한다. | 왜 안 나올 수 있나 | 확인 | |---|---| | 아직 `-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. ③ 파드 안 — 평범한 환경변수다 어느 파드를 볼지 먼저 정한다. ```bash label="[lab host] ① BFF 파드를 본다" kubectl -n keycloak-lab get pods -l app=bff ``` 이 실험대는 파드 이름을 직접 지정했다(observed). 아래 형태는 가이드가 미검증으로 표시한 줄이다(unknown). **치기 전에 — 이 줄은 값을 화면에 찍는다.** 나오는 것은 카나리아가 아니라 `KEYCLOAK_CLIENT_SECRET` 과 `BFF_DB_PASSWORD` 의 평문이다. 바로 아래 실측을 `<평문 14자>` 로 가린 것은 이 문서이지 당신의 터미널이 아니다. 찍힌 값은 스크롤백과 셸 히스토리에 남고, 화면을 공유 중이면 보는 사람 모두에게 간다. 이 편이 「읽기 전에」에서 세운 「남의 진짜 비밀은 길이와 키 이름까지만 본다」를 이 줄 하나가 벗어나는데, 같은 결론을 값 없이 내는 명령은 가이드에 없다(unknown). 운영 클러스터에서는 치지 않는다. 실험대에서 쳤으면 복구 절의 `history` 확인까지 마치고, 운영 값을 찍었으면 회전(B-6·B-7)으로 이어 간다. ```bash label="[lab host] ② 파드 안의 환경변수를 본다 (미검증 · 평문이 화면에 찍힌다)" kubectl -n keycloak-lab exec deploy/bff -- sh -c 'env | grep -iE "secret|password"' ``` 실측은 이렇다(observed, `02-at-rest.txt`). **값은 옮기지 않는다** — 두 줄 다 `<환경변수>=<평문>` 꼴이고, 오른쪽에 있던 것이 ①에서 API 로 뽑은 바로 그 값이다. ```text KEYCLOAK_CLIENT_SECRET=<평문 14자> BFF_DB_PASSWORD=<평문 22자> ``` `env` 한 번이면 나온다. 그리고 클라이언트 비밀은 ①에서 API 로 뽑은 값과 같다 — 두 경로가 같은 평문에 닿는다. `exec` 이 `deploy/bff` 로 안 되면(파드가 종료 중이거나 여럿이면) 이름을 골라 친다. ```bash label="[lab host] ③ Running 인 파드 이름을 고른다" 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). **이 줄도 값을 화면에 찍는다.** ②에서 본 것과 같은 평문이 같은 자국을 남긴다. 여기서 확인하려는 것은 「같은 값이 또 나오는가」뿐이므로, 화면을 공유 중이거나 운영 클러스터에 붙어 있으면 치지 않고 ②의 결과로 판정한다. ```bash label="[lab host] ④ 다른 프로세스의 환경변수를 읽는다 (미검증 · 평문이 화면에 찍힌다)" kubectl -n keycloak-lab exec deploy/bff -- \ sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i secret' ``` 같은 값이 나오는가를 본다. `/proc//environ` 은 그 프로세스의 환경변수를 그대로 담고 있고, 같은 사용자 id 로 도는 아무 프로세스나 읽는다. | 새는 경로 | | |---|---| | `kubectl exec` 권한이 있는 사람 | 바로 본다 | | 같은 파드의 다른 프로세스 | `/proc//environ` | | 크래시 덤프 · 오류 리포트 | 환경변수를 함께 담는 도구가 많다 | | 자식 프로세스 | 상속된다 | 볼륨으로 마운트하면 이 중 몇 가지가 줄어든다 — 파일 권한으로 제한할 수 있고, 환경변수 덤프에 안 들어간다. ```yaml volumeMounts: - name: secrets mountPath: /etc/secrets readOnly: true ``` 줄어드는 것이지 없어지지는 않는다. `exec` 권한이 있으면 파일도 읽는다. ### 4. ④ RBAC — 유일하게 막는다 ```bash label="[lab host] ① 기본 서비스계정이 Secret 을 읽을 수 있는지 묻는다" kubectl auth can-i get secrets -n keycloak-lab \ --as=system:serviceaccount:keycloak-lab:default ``` 실측은 이렇다(observed, `02-at-rest.txt`). ```text default SA: no ``` `no` 한 단어다. 기본 서비스계정은 Secret 을 못 읽는데, 명시적으로 거부해서가 아니라 아무 권한도 주지 않았기 때문이다. RBAC 은 기본이 거부이고 Role 을 붙여야 할 수 있게 된다. 어떤 권한이 있는지 통째로 보는 형태도 가이드에 있고, 미검증이다(unknown). ```bash label="[lab host] ② 권한 목록과 Role 을 본다 (미검증)" 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. 카나리아를 지운다 **목적** — 주입 전에 본 목록으로 되돌린다. ① 지우고 목록을 다시 본다. ```bash label="[lab host] ① 카나리아를 지우고 목록을 본다" kubectl -n keycloak-lab delete secret d3-canary kubectl -n keycloak-lab get secret ``` **예상 결과** — 모양은 이렇다(observed). ```text secret "d3-canary" deleted ``` 목록이 세 개로 돌아왔는가를 본다. **왜 필요한가** — 어떤 파드도 이 Secret 을 참조하지 않으므로 지워도 아무것도 안 깨진다. **문제가 생기면** — `NotFound` 가 나오면 이미 지워졌다. ### 2. 지웠다고 파일에서 없어지지는 않는다 **2026-09-17 에 삭제 후를 쟀다**(observed). 그때까지 이 실험이 안 밟고 넘어간 단계다. ```bash label="[kc-lab-1] 삭제 뒤에 저장 파일을 다시 본다" 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 ``` **지운 뒤에 오히려 늘었다.** ```text 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` | 비밀번호가 찍힌 줄이 어디까지 남았는지 본다 | **이 실험의 진짜 뒷정리는 스크롤백이다.** ①을 실제 비밀로 쳤다면 그 값이 터미널 버퍼와 셸 히스토리에 있다. 실험대 값이라 지금은 상관없지만, 같은 절차를 운영에서 하면 그게 유출 경로가 된다. ## 막히면 | 증상 | 원인 | 확인 | |---|---|---| | `grep` 이 `0` 인데 안전하다고 읽힌다 | `0` 은 「이 파일의 이 시점에 이 형태로는 못 찾았다」 | `-wal` 과 `strings` 로 한 번 더 | | `grep` 이 `Binary file matches` 만 찍는다 | 바이너리 파일이다 | `-c` 를 쓴다(개수만). 값을 안 띄우는 이점도 있다 | | `k3s secrets-encrypt` 가 없다 | 서버 노드가 아니다 | `kc-lab-1`(control-plane)에서 친다 | | `state.db` 가 `Permission denied` | root 전용 디렉터리 | 게스트 sudo 는 무암호다. `sudo` 를 붙인다 | | `exec deploy/bff` 가 실패한다 | 파드가 종료 중이거나 여럿이다 | `--field-selector=status.phase=Running` 으로 이름을 고른다 | | `auth can-i` 가 `yes` 라고 한다 | 그 서비스계정에 Role 이 붙어 있다 | `get rolebinding -o wide` 로 누가 줬는지 본다 | | 값이 `0 bytes` 로 나온다 | Secret 은 있는데 키가 비었다 | `describe` 의 바이트 수를 본다 — 배포 실패의 흔한 원인 | | 비밀번호를 화면에 찍어 버렸다 | ①을 실제 값으로 쳤다 | 스크롤백·히스토리를 지우고, 운영이면 회전한다 | ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 15:05–15: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:05–15:06` 이 D-2 의 첫 실행(`15:00–15: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 도 전부 안 했다.