docs: record proxy-bypass closure with before and after evidence

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-03 16:17:24 +09:00
co-authored by Claude Opus 5
parent 3af52bb66a
commit e1ba9c5626
3 changed files with 151 additions and 12 deletions
@@ -0,0 +1,48 @@
수집 시각: 2026-09-03 16:16:48 KST
주제: 프록시 우회 경로 차단 (NetworkPolicy)
=== [1] 차단 전 — 클러스터 안에서 앱에 직접 요청 ===
명령: kubectl run ... -- curl http://echo:8081/api/echo \
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
scheme https
secure True
serverName evil.example.com ← 위조 성공
remoteAddr 1.2.3.4 ← 위조 성공
requestUrl https://evil.example.com/api/echo
★ Traefik 을 거치지 않으면 헤더 위조가 그대로 통한다.
trustedIPs 와 internalProxies 가 둘 다 '대역'을 믿기 때문.
=== [2] 적용한 것 ===
deploy/lab/k8s/traefik-forwarded-headers.yaml — 192.168.122.0/24 제거
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16"
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16"
deploy/lab/k8s/echo-network-policy.yaml — Traefik 파드에서만 8081 허용
[{"from":[{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"kube-system"}},"podSelector":{"matchLabels":{"app.kubernetes.io/name":"traefik"}}}],"ports":[{"port":8081,"protocol":"TCP"}]},{"from":[{"ipBlock":{"cidr":"10.42.0.1/32"}},{"ipBlock":{"cidr":"10.42.1.1/32"}}],"ports":[{"port":8081,"protocol":"TCP"}]}]
=== [3] 차단 후 — 정상 경로 (계속 동작해야 함) ===
x-forwarded-proto https
x-forwarded-port 443
x-forwarded-host app1.hyeonworks.com
x-real-ip 100.123.124.30
x-forwarded-server traefik-5d6fcf895-wpfhr
--- 앱이 해석한 값
scheme https
secure True
serverName app1.hyeonworks.com
serverPort 443
remoteAddr 100.123.124.30
localAddr 10.42.0.14
requestUrl https://app1.hyeonworks.com/api/echo
=== [4] 차단 후 — 우회 시도 ===
HTTP 000 / curl exit 7
HTTP 000 / curl exit 7
★ curl exit 7 = Failed to connect. 연결 자체가 성립하지 않는다.
=== [5] 파드 건강 상태 (probe 가 차단되지 않았는지) ===
echo-54dbd94986-8jmdb 1/1 Running restarts=0
echo-54dbd94986-lfltk 1/1 Running restarts=0
@@ -164,3 +164,36 @@ curl -s http://192.168.122.11/api/echo \
업그레이드하는 동안 구 Traefik 파드가 함께 살아 있다.** 이 시점에 측정하면
옛 파드가 응답해 "고쳤는데 안 바뀌었다"고 오해하게 된다. `x-forwarded-server`
값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다.
---
## 프록시 우회 차단 (2026-09-03 16:16 KST)
`05-networkpolicy.txt`
헤더 신뢰를 켠 뒤 남아 있던 구멍을 실증하고 막았다.
**차단 전** — 클러스터 안에서 Traefik을 우회해 앱에 직접 요청하면
`serverName: evil.example.com`, `remoteAddr: 1.2.3.4`**위조가 성립했다.**
**적용한 것**
| 파일 | 변경 |
|---|---|
| `traefik-forwarded-headers.yaml` | `192.168.122.0/24` 제거 (SNAT 때문에 도달 불가한 대역) |
| `echo-network-policy.yaml` | Traefik 파드에서만 8081 허용 (라벨 기준) |
**차단 후**
```
정상 경로 scheme=https, remoteAddr=100.123.124.30 동작
우회 시도 HTTP 000 / curl exit 7 연결 거부
파드 상태 1/1 Running, restarts=0 probe 정상
```
`exit 7`은 curl의 "Failed to connect"다. HTTP 403이 아니라
**TCP 연결 자체가 성립하지 않았다**는 뜻이다.
`restarts=0`이 중요하다. NetworkPolicy에서 kubelet probe 경로를 빠뜨리면
probe가 실패해 파드가 재시작 루프에 빠진다. 노드의 cni0 주소
(`10.42.0.1`, `10.42.1.1`)를 `/32`로 허용해 이를 피했다.
+70 -12
View File
@@ -714,24 +714,82 @@ curl -s https://app1.hyeonworks.com/api/echo \
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 클라이언트가 넣은 값이
사슬 앞부분에 남아 신뢰 경계가 무너진다.
### 잔여 위험 — 프록시 우회 경로
### 프록시 우회 경로 차단
앱이 헤더를 신뢰하게 됐으므로, **Traefik을 거치지 않고 파드에 직접 도달할 수
으면 위조가 가능하다.** 클러스터 안에서는 Service ClusterIP로 접근할
다.
앱이 헤더를 신뢰하게 되면 **Traefik을 거치지 않고 파드에 직접 도달할 수
는 경로가 곧 구멍**이 된다. 클러스터 안에서는 Service ClusterIP로 접근할
수 있으므로 실제로 위조가 성립했다.
```bash
# 클러스터 내부에서 (위조 가능함을 확인)
kubectl -n header-lab run t --rm -it --image=curlimages/curl --restart=Never -- \
curl -s http://echo:8081/api/echo -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-For: 1.2.3.4'
kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
curl -s http://echo:8081/api/echo \
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
```
**NetworkPolicy로 Traefik에서 오는 트래픽만 허용하는 것이 정석이다.**
이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다.
아직 적용하지 않았으며, `feature/keycloak-header-spoofing-defense`에서
다룰 항목이다.
```
serverName evil.example.com ← 위조 성공
remoteAddr 1.2.3.4 ← 위조 성공
requestUrl https://evil.example.com/api/echo
```
---
**두 신뢰 설정이 모두 "대역"을 믿기 때문**이다.
| 계층 | 신뢰 범위 | 지정 방식 |
|---|---|---|
| Traefik `trustedIPs` | 파드 대역 전체 | IP 대역 |
| 앱 Tomcat `internalProxies` | 사설 대역 전체 (기본 정규식) | IP 정규식 |
IP로는 Traefik을 특정할 수 없다. **파드 IP가 재시작마다 바뀌기 때문**이다
(측정 중 실제로 `10.42.0.8``10.42.1.12`로, 노드까지 옮겨갔다).
**해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.**
| | |
|---|---|
| 저장소 파일 | `deploy/lab/k8s/echo-network-policy.yaml` |
```yaml
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik # ← IP 가 아니라 라벨
ports:
- protocol: TCP
port: 8081
```
`namespaceSelector``podSelector`를 **같은 리스트 항목**에 두면 AND로
결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로
주의한다.
**kubelet probe를 위한 규칙이 별도로 필요하다.** readiness/liveness는 파드가
아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고
**파드가 재시작 루프에 빠진다.**
```yaml
- from:
- ipBlock: { cidr: 10.42.0.1/32 } # kc-lab-1 의 cni0
- ipBlock: { cidr: 10.42.1.1/32 } # kc-lab-2 의 cni0
```
probe의 출발지는 **노드의 flannel 브리지(cni0)** 이고, 각 노드 `/24`의 첫
주소다. `/32`로 정확히 지정해야 한다 — `10.42.0.0/16`으로 넓히면 임의의
파드가 다시 들어와 정책이 무의미해진다.
**적용 후 확인**
```
정상 경로 scheme=https, remoteAddr=100.123.124.30 계속 동작
우회 시도 HTTP 000 / curl exit 7 연결 자체가 거부됨
파드 상태 1/1 Running, restarts=0 probe 정상
```
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
앞의 것만 하면 이 구멍이 남는다.
## 12. 증거