eBPF SSL uprobe로 컨테이너 안 OpenSSL 후킹하기 — TLS 평문 가시성
암호화된 동서(East-West) 트래픽은 어떤 모니터링으로도 페이로드를 못 본다. 답은 암호화 직전의 평문 단계에서 잡는 것.
목차
클라우드 환경에서 VM/컨테이너끼리 주고받는 동서(East-West) 트래픽은 대부분 TLS다. AWS VPC Flow Logs, Azure NSG 로그 — 죄다 5-tuple만 잡고 페이로드는 못 본다. VPC Traffic Mirroring으로 패킷 통째로 떠도 암호화돼 있어 무용지물.
그런데 암호화되기 직전, 또는 복호화 직후의 평문 단계에 후크를 걸면? 데이터가 그대로 보인다.
eBPF SSL uprobe가 그 도구다.
컨셉
Application code
│
│ SSL_write(buf, len) ← 평문이 여기 있음
▼
OpenSSL libssl.so
│
│ AES_encrypt() ...
▼
Kernel TCP → 네트워크
SSL_write가 호출되는 순간, 첫 번째 인자 buf가 평문 그대로다. 이걸 eBPF userspace probe(uprobe)로 잡으면 끝.
컨테이너에서 라이브러리 찾기
호스트의 OpenSSL은 컨테이너 안 OpenSSL과 다른 인스턴스다 (각자 자기 파일시스템). 컨테이너 내부 라이브러리 경로:
# 컨테이너 PID 확인
docker inspect --format '{{.State.Pid}}' <컨테이너명>
# → 12345
# 컨테이너 내부 라이브러리 경로 (호스트에서 접근)
ls /proc/12345/root/usr/lib/x86_64-linux-gnu/libssl.so.3
/proc/<pid>/root 트릭으로 호스트에서 컨테이너 파일시스템 접근. 컨테이너의 OpenSSL 3.1.7을 그대로 가리킬 수 있다.
심볼 오프셋 확인
uprobe는 함수 이름이 아니라 바이너리 내 오프셋으로 거는 게 안전. 동적 라이브러리는 ASLR이라 절대 주소가 매번 바뀌지만 오프셋은 동일.
objdump -t /proc/12345/root/usr/lib/x86_64-linux-gnu/libssl.so.3 | grep ' SSL_write\b'
# 0000000000045620 g DF .text 0000000000000234 SSL_write
오프셋 0x45620. 이걸 eBPF 프로그램에 넘긴다.
eBPF 프로그램 (libbpf + Clang)
// ssl_uprobe.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
struct event {
u32 pid;
u32 len;
char data[256];
};
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(int));
__uint(value_size, sizeof(int));
} events SEC(".maps");
// SSL_write(SSL *ssl, const void *buf, int num)
SEC("uprobe/SSL_write")
int BPF_KPROBE(ssl_write, void *ssl, const void *buf, int num) {
struct event e = {};
e.pid = bpf_get_current_pid_tgid() >> 32;
e.len = num;
bpf_probe_read_user(e.data, sizeof(e.data), buf);
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &e, sizeof(e));
return 0;
}
char LICENSE[] SEC("license") = "GPL";
userspace 로더에서 attach:
struct ssl_uprobe_bpf *skel = ssl_uprobe_bpf__open_and_load();
bpf_program__attach_uprobe_opts(
skel->progs.ssl_write,
pid, // 컨테이너 PID
"/proc/12345/root/usr/lib/x86_64-linux-gnu/libssl.so.3",
0x45620, // 오프셋
NULL
);
이제 컨테이너 안에서 발생한 SSL_write 호출의 평문이 perf event로 들어온다.
verifier 친화 설계
eBPF는 커널 안에서 돌므로 검증기(verifier)가 매우 까다롭다. 흔한 함정:
1) 스택 크기 제한 (512 byte)
이벤트 구조체를 함수 스택에 잡으면 256바이트 데이터 + 메타가 거의 한계. BPF_PERCPU_ARRAY 스크래치 맵을 따로 두고 거기에 쓰는 게 안전.
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, struct event);
} scratch SEC(".maps");
SEC("uprobe/SSL_write")
int BPF_KPROBE(ssl_write, ...) {
u32 z = 0;
struct event *e = bpf_map_lookup_elem(&scratch, &z);
if (!e) return 0;
// e에 쓰기 (스택 안 씀)
...
}
2) 무한 루프 금지
verifier는 모든 루프가 종료됨을 증명할 수 있어야 통과. bpf_loop() helper 또는 컴파일 타임 상수 카운트 루프.
3) 포인터 안전성
사용자 공간 버퍼는 직접 dereference 못 함. 항상 bpf_probe_read_user() 통해야 함.
실측 — 성능 영향
OpenEMR 7.0.2 컨테이너에서 N=100 트랜잭션 측정:
| 메트릭 | uprobe 없음 | uprobe 있음 | p-value |
|---|---|---|---|
| 처리량 (req/s) | 245.3 | 243.1 | 0.60 |
| p95 지연 (ms) | 18.4 | 18.7 | 0.64 |
Welch’s t-test 결과 통계적 유의차 없음. 함수 호출 한 번에 ~300 ns 추가되는 정도라, 일반적 워크로드에선 측정 노이즈 안에 묻힌다.
응용 — 인커널 PII 마스킹
평문이 보이면 그 자리에서 마스킹도 가능하다 (사용자 공간 갈 필요 없이). eBPF 프로그램 안에서:
// 주민번호 패턴 [0-9]{6}-[0-9]{7}
// 비번 = 패턴 password=
// 전화번호 01X-XXXX-XXXX
if (matches_rrn(e->data, e->len)) {
mask_rrn(e->data);
}
if (matches_password(e->data, e->len)) {
mask_password(e->data);
}
마스킹된 데이터를 로깅 → 로그에는 평문 PII가 안 남음. 컴플라이언스 친화적.
한계와 주의
- 보안 적법성: 컨테이너 내부 평문을 호스트에서 보는 건 명백히 침해 행위. 자기 인프라/연구 환경에서만.
- OpenSSL 정적 링크: 일부 Go 바이너리는 OpenSSL을 정적 링크 → libssl.so 후크 안 통함. Go-specific 후크 필요.
- TLS 1.3 vs 1.2: 둘 다 SSL_write/SSL_read 인터페이스는 동일.
- 버전 의존: OpenSSL 3.x로 가면서 일부 함수 시그니처 변경. 버전마다 검증 필요.
만들면서 알게 된 것들
커널과 사용자 공간의 경계는 의외로 투명하다. uprobe는 사용자 공간 함수 호출도 커널 후크로 잡을 수 있게 해준다. eBPF의 가장 강력한 기능 중 하나.
verifier가 까다로워 보이지만 보호 장치다. verifier 통과 못 하는 코드 = 커널을 패닉시킬 수 있는 코드. verifier가 거부하는 이유 메시지를 잘 읽으면 어디가 위험한지 학습됨.
/proc/<pid>/root 트릭이 컨테이너 디버깅에 강력하다. docker exec 안 들어가도 호스트에서 컨테이너 파일시스템 접근. eBPF 외에도 디버깅 전반에 유용.
클라우드 가시성의 본질은 “어디에서 보는가”다. 네트워크 플로우 로그에선 안 보이는 게 함수 호출 후크로는 보인다. 추상화 사다리에서 한 칸 위에서 잡으면 다 보임.
eBPF는 “커널 안에 작은 인터프리터”라는 발상이 만든 도구다. 이 한 발상 덕분에 가시성, 관측, 보안 전반의 게임 규칙이 바뀌었다.