← ~/notes · 3 min read

eBPF SSL uprobe로 컨테이너 안 OpenSSL 후킹하기 — TLS 평문 가시성

암호화된 동서(East-West) 트래픽은 어떤 모니터링으로도 페이로드를 못 본다. 답은 암호화 직전의 평문 단계에서 잡는 것.

목차
  1. 컨셉
  2. 컨테이너에서 라이브러리 찾기
  3. 심볼 오프셋 확인
  4. eBPF 프로그램 (libbpf + Clang)
  5. verifier 친화 설계
  6. 1) 스택 크기 제한 (512 byte)
  7. 2) 무한 루프 금지
  8. 3) 포인터 안전성
  9. 실측 — 성능 영향
  10. 응용 — 인커널 PII 마스킹
  11. 한계와 주의
  12. 만들면서 알게 된 것들

클라우드 환경에서 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.3243.10.60
p95 지연 (ms)18.418.70.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는 “커널 안에 작은 인터프리터”라는 발상이 만든 도구다. 이 한 발상 덕분에 가시성, 관측, 보안 전반의 게임 규칙이 바뀌었다.