SSL Inspection
VPP 기반 SSL/TLS Inspection(TLS 가시성) 아키텍처와 실전 구현 가이드입니다. 이중 TLS 세션 관리, 동적 leaf 인증서 발급, CA 인프라 구성, VCL/VLS/LD_PRELOAD/memif 선택, SNI 바이패스, TLS 1.3/ECH 고려사항, 운영 트러블슈팅 런북, 거버넌스 통합을 다룹니다.
SSL Inspection (TLS 가시성 확보)
SSL Inspection(TLS Inspection)은 암호화된 트래픽의 내용을 중간에서 복호화하여 보안 검사(IDS/IPS, DLP, 악성코드 탐지)를 수행한 후 재암호화하여 전달하는 기술입니다. 엔터프라이즈 방화벽, NGFW, 보안 웹 게이트웨이(SWG)의 핵심 기능이며, VPP의 유저스페이스 TLS 스택은 이 기능을 고성능으로 구현하기에 최적의 플랫폼입니다.
SSL Inspection 아키텍처 개요
"두 개의 TLS 세션으로 나누어 중간에서 평문을 본다"는 개념 자체는 운영편 0.1절에서 이미 다루었습니다. 이 소절은 그 이중 세션 모델을 VPP의 내부 구성요소로 옮겨 놓았을 때 어떤 모듈이 어떤 역할을 맡는지를 한 장의 그림으로 정리합니다. 핵심은 "TLS 종단 ① / 평문 검사 영역 / TLS 개시 ② / 동적 인증서 엔진 / 바이패스 정책 엔진" 다섯 블록이 한 프로세스(VPP) 안에서 세션 계층을 통해 맞물린다는 점입니다.
SSL Inspection의 두 가지 배포 모드가 있습니다:
| 모드 | 동작 방식 | 클라이언트 설정 | 적용 시나리오 |
|---|---|---|---|
| 투명 프록시(Transparent) | 네트워크 경로 상에서 패킷을 가로채어 검사합니다. 클라이언트는 프록시 존재를 인식하지 못합니다 | 프록시 CA 인증서만 설치 | 엔터프라이즈 방화벽, ISP 보안 장비 |
| 명시적 프록시(Explicit) | 클라이언트가 HTTP CONNECT 메서드로 프록시에 터널(Tunnel) 요청을 보냅니다 | 프록시 주소 + CA 인증서 설정 | SWG(Secure Web Gateway), 클라우드 프록시 |
이중 TLS 세션 구조
SSL Inspection의 핵심은 이중 TLS 세션(Dual TLS Session) 관리입니다. VPP의 TLS 추상 계층이 이미 이중 세션 모델(TCP 세션 + App 세션)을 사용하므로, SSL Inspection은 이를 확장하여 4개의 세션을 동시에 관리합니다.
/* SSL Inspection 프록시의 핵심 데이터 구조 (개념적 설계) */
typedef struct ssl_inspect_session_ {
/** 클라이언트 측 TLS 세션 (프록시가 서버 역할) */
u32 client_tls_session_handle;
tls_ctx_t *client_tls_ctx; /* 동적 생성 인증서 사용 */
/** 서버 측 TLS 세션 (프록시가 클라이언트 역할) */
u32 server_tls_session_handle;
tls_ctx_t *server_tls_ctx; /* 원본 서버 인증서 검증 */
/** 검사 정책 결과 */
ssl_inspect_action_t action; /* ALLOW / BLOCK / LOG / BYPASS */
/** 원본 서버 정보 (SNI에서 추출) */
u8 *server_name; /* ClientHello SNI 값 */
u8 *original_cert_subject; /* 원본 인증서 CN/SAN */
/** 동적 인증서 캐시 인덱스 */
u32 cert_cache_index;
/** 검사 통계 */
u64 bytes_inspected;
u64 threats_detected;
} ssl_inspect_session_t;
코드 설명
-
3~5행
client_tls_ctx는 프록시가 서버 역할을 수행하는 TLS 컨텍스트입니다. 클라이언트가 요청한 SNI 도메인에 맞는 동적 인증서(프록시 CA로 서명)를 사용합니다. -
7~9행
server_tls_ctx는 프록시가 클라이언트 역할을 수행하는 TLS 컨텍스트입니다. 원본 서버의 실제 인증서를 CA 체인으로 검증하여 중간자 공격(Man-in-the-Middle)이 아닌 정당한 서버임을 확인합니다. -
12행
action필드는 보안 검사 엔진의 판정 결과를 저장합니다. ALLOW(통과), BLOCK(차단), LOG(기록 후 통과), BYPASS(검사 생략) 중 하나입니다. -
18행
동적 인증서 생성은 비용이 높으므로
cert_cache_index로 동일 도메인의 인증서를 캐싱합니다. 캐시 히트 시 RSA/ECDSA 서명 연산을 생략할 수 있습니다.
핸드셰이크 MSC — 시간축으로 본 이중 세션
이중 세션 구조는 "어떤 컴포넌트가 있는가"까지는 블록도로 표현할 수 있지만, 언제 무엇을 기다려야 하는가는 시간축 다이어그램(Message Sequence Chart, MSC)으로만 드러납니다. 이 소절은 TLS 1.3 한 세션이 SSL Inspection을 거칠 때 클라이언트·VPP 중간자·원서버 사이에 오가는 메시지를 시간 순으로 정렬합니다. 이어지는 동적 인증서 엔진·세션 FIFO 설명은 이 MSC 위의 한 점(SignLeaf 호출 직후)에서 벌어지는 일을 확대해 보는 관점입니다.
이 MSC에서 짚어야 할 다섯 가지 포인트는 다음과 같습니다. 각 포인트는 뒤이어 나오는 소절들이 "왜 그런 구조로 구현되는가"에 대한 답을 제공합니다.
- ② SNI 파싱 이전에는 업스트림을 열 수 없다 — ①의 ClientHello 안의
server_name확장을 파싱해야만 ③의 upstream ClientHello에 목적지가 정해집니다. VPP에서는tls-async-handshake계열 플러그인이 ClientHello를 먼저 본 뒤 accept 콜백을 미룬다는 점이 여기서 기인합니다. 자세한 배선은 운영편 11번 소절에서 다룹니다. - ⑤ SignLeaf은 ④ 직후에만 수행된다 — 원서버 leaf의 Subject/SAN을 복사해야 하므로, ⑤의 서명은 반드시 ④ 이후에 일어납니다. 이 시점이 TLS 1.3 핸드셰이크에서 가장 지연에 민감한 구간이며, cert cache 히트 여부에 따라 평균 지연이 크게 갈립니다(다음 소절).
- ⑦과 ⑧의 순서는 서로 독립 — Client Finished(⑦)와 upstream Finished(⑧)는 상호 의존이 없어서 병렬로 처리됩니다. VPP에서는 이 병렬성을 살리기 위해 양 방향 FIFO 쌍을 따로 두며, 둘이 모두 끝나야만 application_data 구간이 열립니다.
- Phase 2의 early_data는 기본 차단 — TLS 1.3의 0-RTT는 클라이언트가 ServerHello를 받기 전에 데이터를 흘려 보내는 구조이지만, SSL Inspection은 아직 TLS-B가 완성되기 전이라 이 데이터를 검사할 수 없습니다. 대부분의 구현은 early_data를 거부하거나 버퍼링하는 쪽을 기본값으로 둡니다. 이것이 "0-RTT 미지원"이라는 운영 고지의 실제 이유입니다.
- Phase 3의 HRR은 두 핸드셰이크에서 따로 발생할 수 있다 — TLS-B 쪽에서 key_share group이 맞지 않아 HelloRetryRequest를 받으면, VPP는 업스트림 CH를 재전송해야 합니다. TLS-A 쪽은 별개로 자체 HRR을 낼 수도 있어서, 네 번의 ClientHello가 한 세션 안에서 오갈 수 있습니다. 세션 상태 머신이 "CH 카운터"를 분리해서 들고 있어야 하는 이유가 이것입니다.
event-logger와 TLS 플러그인 내부 트레이스에 거의 그대로 나타납니다. show event-logger로 ①~⑧이 순서대로 찍히는지, 특히 ⑤ signleaf와 ④ upstream_finished 사이의 지연(ms 단위)이 장시간 튀지 않는지를 보는 것이, HSM/공개키 가속기의 포화 여부를 빨리 잡아내는 가장 싼 방법입니다. 상세는 SSL Inspection 모니터링 및 디버깅 소절에서 다룹니다.
동적 인증서 생성 엔진
SSL Inspection의 핵심 컴포넌트는 동적 인증서 생성 엔진입니다. 클라이언트가 ClientHello에 포함한 SNI(Server Name Indication) 값을 기반으로, 프록시 CA 개인 키로 해당 도메인의 인증서를 실시간(Real-time)으로 서명합니다.
/* 동적 인증서 생성 구현 (OpenSSL 기반, 간략화) */
static X509 *
ssl_inspect_generate_cert (ssl_inspect_ctx_t *ctx,
const char *server_name,
X509 *original_cert)
{
X509 *cert = X509_new ();
EVP_PKEY *pkey;
X509_NAME *subj;
/* 1. 키 쌍 생성 또는 캐시에서 재사용 */
pkey = ssl_inspect_get_or_create_key (server_name);
/* 2. 원본 인증서의 Subject/SAN 복사 */
subj = X509_get_subject_name (original_cert);
X509_set_subject_name (cert, subj);
/* 3. Issuer를 프록시 CA로 설정 */
X509_set_issuer_name (cert,
X509_get_subject_name (ctx->proxy_ca_cert));
/* 4. SAN(Subject Alternative Name) 복사 */
ssl_inspect_copy_san (cert, original_cert);
/* 5. 유효기간 설정 (원본과 동일 또는 단축) */
X509_set_notBefore (cert, X509_get_notBefore (original_cert));
X509_set_notAfter (cert, X509_get_notAfter (original_cert));
/* 6. 시리얼 번호 (고유) */
ASN1_INTEGER_set (X509_get_serialNumber (cert),
ssl_inspect_next_serial ());
/* 7. 공개 키 설정 */
X509_set_pubkey (cert, pkey);
/* 8. 프록시 CA 개인 키로 서명 (비용이 큰 연산) */
X509_sign (cert, ctx->proxy_ca_key, EVP_sha256 ());
/* 9. 캐시에 저장 (TTL: 인증서 유효기간 또는 설정값) */
ssl_inspect_cache_cert (server_name, cert, pkey);
return cert;
}
/* 인증서 캐시 구조 */
typedef struct {
u8 *domain; /* 해시 키: 서버 도메인 */
X509 *cert; /* 동적 생성된 인증서 */
EVP_PKEY *pkey; /* 대응하는 개인 키 */
f64 created_at; /* 생성 시각 (VPP 시간) */
f64 ttl; /* Time-to-Live (초) */
u32 hit_count; /* 캐시 히트 횟수 */
} ssl_inspect_cert_cache_entry_t;
코드 설명
- 11행 도메인별 키 쌍을 캐싱하여 재사용합니다. 매 연결마다 RSA 키를 생성하면 ~2ms가 추가되므로, 키 재사용은 성능에 핵심적입니다.
- 14~16행 원본 인증서의 Subject(CN)를 그대로 복사합니다. 클라이언트의 인증서 검증 로직이 CN을 확인하므로, 원본과 동일해야 브라우저 경고가 발생하지 않습니다(프록시 CA를 신뢰하는 경우).
-
22행
SAN(Subject Alternative Name) 확장을 원본에서 복사합니다. 현대 브라우저는 CN보다 SAN을 우선하므로, SAN 누락 시
ERR_CERT_COMMON_NAME_INVALID오류가 발생합니다. -
34행
X509_sign()이 전체 흐름에서 가장 비용이 큰 연산입니다. RSA-2048 서명은 CPU에서 ~0.5ms, QAT 오프로드 시 ~0.02ms가 소요됩니다. 이 연산을 줄이기 위해 인증서 캐싱이 필수적입니다.
LRU 캐시 오버플로와 지연 시간 스파이크(Latency Spike)
동적 인증서 캐시는 해시맵 + LRU 연결 리스트(Linked List) 조합으로 구현하는 것이 표준입니다. 그런데 운영 환경에서 가장 흔히 관찰되는 장애는 "평상시 1ms 미만이던 SSL_accept 지연이 특정 시간대에 10~50ms로 급격히 튀는" 현상입니다. 근본 원인은 세 가지로 요약됩니다. ① 캐시 용량 초과에 따른 대규모 eviction, ② TTL 동시 만료(Expiry Thundering Herd), ③ 프록시 CA 개인 키 경합(RSA 서명 직렬화(Serialization))입니다.
첫 번째 원인은 크롤러 트래픽이나 봇 공격이 수천 개의 1회성 도메인을 SNI로 보낼 때 발생합니다. 캐시 상한(예: 2048개)을 초과하면 LRU 정책이 가장 오래된 엔트리를 대량으로 퇴출시키는데, 이 작업은 연결 리스트 조작·X509/EVP_PKEY 객체 free·해시맵 rehash를 수반해 단일 세션 처리 경로를 수 ms 동안 블록합니다. 두 번째 원인은 TTL을 동일 값(예: 3600초)으로 설정했을 때 발생합니다. 한 시점에 대량 생성된 인증서가 정확히 1시간 뒤 동시에 만료되면, 다음 ClientHello 배치가 전부 캐시 미스로 떨어져 RSA 서명 폭주가 일어납니다.
/* LRU 캐시 안정화 — jittered TTL + 연성 제한(soft limit) */
static void
cert_cache_insert (cert_cache_t *c, const char *sni,
X509 *cert, EVP_PKEY *k)
{
/* 1. TTL에 ±15% 지터(jitter) 추가 — thundering herd 방지 */
f64 base_ttl = c->default_ttl;
f64 jitter = ((f64) (random_u32 () % 30) - 15.0) / 100.0;
f64 ttl = base_ttl * (1.0 + jitter);
/* 2. 연성 제한 도달 시 백그라운드 evict 큐에 예약 (인라인 free 금지) */
if (hash_elts (c->map) > c->soft_limit) {
evict_queue_push (&c->evict_q, lru_tail (c));
/* 본 스레드는 즉시 insert 진행 — 정리는 백그라운드 워커가 수행 */
}
/* 3. 경성 제한(hard limit) 도달 시에만 동기적 eviction */
if (hash_elts (c->map) > c->hard_limit) {
cert_cache_entry_t *victim = lru_pop_tail (c);
hash_unset (c->map, victim->sni);
X509_free (victim->cert);
EVP_PKEY_free (victim->pkey);
clib_mem_free (victim);
}
/* 4. 새 엔트리 삽입 + LRU head에 연결 */
cert_cache_entry_t *e = clib_mem_alloc (sizeof (*e));
e->sni = format (0, "%s", sni);
e->cert = cert;
e->pkey = k;
e->created_at = vlib_time_now (vm);
e->ttl = ttl;
hash_set_mem (c->map, e->sni, e);
lru_push_head (c, e);
}
/* 5. 부정 캐시(Negative cache) — 1회성 SNI 공격 완화 */
static int
cert_cache_lookup_with_neg (cert_cache_t *c, const char *sni)
{
if (bloom_filter_test (&c->singleton_bloom, sni))
return CERT_CACHE_BYPASS; /* 이전에 1회만 본 SNI — 서명 생략, 원본 통과 */
... 정상 조회 ... */
}
| 시나리오 | 캐시 상태 | SSL_accept p50 | p99 | 완화책 |
|---|---|---|---|---|
| 정상 브라우징 | 히트율 96% | 0.3 ms | 1.2 ms | 해당 없음 |
| 크롤러 폭주 | 히트율 12%, LRU 빈번 | 1.8 ms | 47 ms | 부정 캐시 + Bloom |
| 동시 TTL 만료 | 히트율 3% (배치 만료) | 2.4 ms | 68 ms | ±15% TTL 지터 |
| CA 키 경합 | QAT 미사용 | 4.1 ms | 120 ms | per-worker CA + QAT |
| 모든 완화책 적용 | 히트율 88% | 0.5 ms | 3.8 ms | — |
운영 권고: 프록시 CA 개인 키는 워커별로 사본을 두고, QAT(또는 AES-NI 이상의 비대칭 가속기)를 장착한 장비에서는 ENGINE_set_default_RSA로 서명을 오프로드하세요. CA 키의 RSA-2048 서명은 x86 단일 코어에서 ~500μs이지만 QAT 오프로드 시 ~20μs로 떨어져 p99 개선 효과가 극적입니다. 단, QAT 큐 오버플로 시 폴백 경로가 CPU이므로 queue depth 모니터링이 필수입니다.
TLS 1.3 0-RTT 재사용과 Replay 위험
TLS 1.3은 핸드셰이크 RTT를 줄이기 위해 0-RTT 조기 데이터(Early Data)와 PSK(Pre-Shared Key) 기반 재개를 도입했습니다. SSL Inspection 프록시가 PSK/session ticket을 캐싱하면 클라이언트는 동일 프록시로 재연결 시 1-RTT 혹은 0-RTT로 빠르게 복귀할 수 있습니다. 그러나 0-RTT 데이터는 forward secrecy가 약하고 재생(replay) 공격이 가능하다는 RFC 8446 §2.3/§8의 근본적 제약이 있어, 프록시가 이를 중계할 때는 멱등(idempotent) 요청(HTTP GET 등)만 허용해야 합니다.
- 위험 1 — Ticket Reuse Attack: 공격자가 합법 클라이언트의 0-RTT 플라이트를 캡처해 프록시에 재전송하면, 프록시는 유효한 PSK binder를 확인하고 그대로 백엔드에 전달합니다. 백엔드가 POST/결제 같은 비멱등 요청을 처리하면 중복 부과·이중 주문이 발생합니다. VPP-TLS는
early_data_allowed플래그로 제어하며, SSL Inspection 시에는 명시적 화이트리스트(Method: GET, HEAD, OPTIONS)로만 전달해야 합니다. - 위험 2 — Cross-Origin Ticket Binding 우회: 프록시가 ticket을 SNI와 무관하게 관리하면 동일 ticket이 다른 도메인 세션 재개에 재사용될 수 있습니다. 반드시
(SNI, client IP network, cipher, ALPN)4-튜플로 ticket 색인을 구성해야 합니다. - 위험 3 — Anti-Replay 단일 윈도우 병목: RFC 권고 anti-replay는 서버측에 시간 기반 윈도우(예: 10초)와 단일 사용 마커(single-use marker)를 요구합니다. 대규모 프록시에서는 워커별 블룸 필터 + 주기적 동기화로 구현하고, 거짓 양성 시 full handshake로 자동 폴백해야 합니다.
ECH(Encrypted ClientHello)와 "우회 불가" 한계
TLS 1.3의 후속 확장인 ECH(Encrypted ClientHello, draft-ietf-tls-esni)는 클라이언트가 실제 SNI를 ECH 공개 키로 암호화하여 외부 SNI("public name")만 노출시킵니다. ECH를 지원하는 클라이언트(Chrome 117+, Firefox 118+)와 HTTPS 레코드가 DNS에 게시된 서버 사이 통신은 다음과 같은 이유로 기존 SSL Inspection 아키텍처로 복호화가 구조적으로 불가능합니다.
- ECH 공개 키 미보유: 프록시가 ECH를 풀려면 해당 서비스의 ECH 개인 키가 필요하지만, 이는 오직 진짜 서버만 보유합니다. 중간자가 임의로 "가짜 ECH 공개 키"를 DNS에 주입해도 DNSSEC/DoH를 사용하는 클라이언트는 이를 거부합니다.
- GREASE ECH 차단의 역효과: 일부 기업은 ECH ClientHello를 아예 drop해 "ECH 미지원으로 다운그레이드"를 유도했지만, ECH draft-15 이상은 이 다운그레이드를 탐지해 클라이언트가 연결을 중단(fatal alert)합니다.
- Outer SNI 기반 정책의 과매칭: 외부 SNI가
cloudflare-ech.com처럼 공용 앵커 도메인이면 "바이패스 or 차단" 둘 중 하나만 선택할 수 있어, 기존의 도메인 단위 정책이 전부 무력화됩니다.
현실적 대응: ECH를 사용하는 서비스에 대해 SSL Inspection을 강제하려면 (a) 조직 장비에 ECH 클라이언트 비활성화 정책(Chrome: EncryptedClientHelloEnabled=false) 배포, (b) 엔드포인트 에이전트에서 복호화 후 재발사, (c) 해당 도메인 자체 차단 중 하나를 택해야 합니다. "투명 프록시만으로 ECH 트래픽을 해독할 수 있다"고 약속하는 벤더 주장은 항상 검증이 필요합니다.
IDS 연동 memif 큐 크기 계산식
VPP가 복호화한 평문을 Suricata/Snort memif 인터페이스로 전달할 때, 큐가 너무 작으면 IDS가 잠시 뒤처질 때 VPP가 drop을 발생시키고, 너무 크면 메모리 낭비와 지연 증가를 초래합니다. 적정 큐 크기는 IDS 처리 지터(jitter)와 VPP 순간 입력 폭주를 모두 수용하도록 계산합니다.
기본 공식은 다음과 같습니다:
queue_depth ≥ (peak_pps × max_ids_stall_ms / 1000) × safety_factor
예: peak_pps = 200,000 (200K pps 복호화 평문)
max_ids_stall_ms = 8 (Suricata GC/rule reload 최악 지연)
safety_factor = 2 (워커 수 증가·순간 폭주 대비)
→ queue_depth ≥ 200000 × 0.008 × 2 = 3200 descriptor
→ 2의 거듭제곱으로 올림: ring_size = 4096
# VPP 측 memif 생성 — 계산된 ring_size 적용
vpp# create memif socket id 1 filename /run/vpp/memif-suricata.sock
vpp# create interface memif id 0 master ring-size 4096 buffer-size 2048
vpp# set interface state memif0/0 up
vpp# set interface l2 xconnect TenGigabitEthernet0/0/0 memif0/0
# Suricata 측 — 동일 ring-size로 slave 생성
# suricata.yaml
af-packet:
- interface: memif0/0
memif:
role: slave
ring-size: 4096
buffer-size: 2048
zero-copy: true
| 트래픽 프로파일 | peak_pps | stall 가정 | 최소 queue | 권장 ring_size |
|---|---|---|---|---|
| 사내 웹 트래픽 | 50 kpps | 4 ms | 400 | 512 |
| 데이터센터 북-남 | 200 kpps | 8 ms | 3,200 | 4,096 |
| CDN 엣지 | 800 kpps | 10 ms | 16,000 | 16,384 |
| 백본 미러링 | 2.5 Mpps | 15 ms | 75,000 | 131,072 |
측정 기반 튜닝: show memif 출력의 rx/tx ring full 카운터가 지속적으로 증가하면 queue가 부족한 것입니다. 반대로 avg ring occupancy가 평균 10% 미만이면 과대 설정입니다. Suricata의 suricata.log에서 kernel drops 증가도 동시 모니터링해, 두 쪽 카운터가 함께 0에 가까운 크기가 최적입니다.
VCL + VPP-TLS 기반 SSL Inspection 구현
앞 절까지는 VPP 내부(그래프 노드, svm_fifo, tls_ctx_t) 관점에서 SSL Inspection을 설명했습니다. 실제로는 VPP 본체를 수정하지 않고도 VCL 유저스페이스 애플리케이션 + VPP-TLS 엔진 + VPP CLI로 등록한 cert-key pair만으로 MITM(Man-in-the-Middle) 복호화 프록시를 구현할 수 있습니다. 이 방식은 프로덕션에서 가장 많이 쓰이는 형태이며, 동적 인증서 생성기·정책 엔진·IDS 연동을 모두 평범한 리눅스 프로세스에서 다룰 수 있다는 장점이 있습니다.
전체 아키텍처
구현은 다섯 개의 협력 단계로 나뉩니다. 각 단계가 어떤 VCL API 혹은 VPP CLI 명령과 1:1로 대응되는지를 먼저 잡아두면 코드 흐름을 이해하기가 훨씬 쉬워집니다.
| 단계 | 역할 | 사용 API / 명령 |
|---|---|---|
| ① 프록시 CA 준비 | 루트 CA 생성, 클라이언트 신뢰 저장소에 설치 | openssl req/x509, OS별 CA 등록 |
| ② VCL 리스너 바인드 | 443에서 raw TCP로 들어오는 ClientHello를 먼저 받음 | vppcom_session_create(VPPCOM_PROTO_TCP) |
| ③ SNI 피킹 | 핸드셰이크 전에 ClientHello를 엿보아 바이패스 판정과 동적 인증서 발급 대상 결정 | vppcom_session_read + MSG_PEEK 파싱 |
| ④ 동적 ckpair 등록 | SNI용 leaf 인증서를 생성해 VPP에 cert-key pair로 등록 | VPP binary API app_tls_cert_key_pair_add_del 또는 CLI |
| ⑤ 이중 TLS 터널링 | 클라이언트 쪽 TLS 세션을 업그레이드하고, 업스트림으로 별도 TLS 연결 수립, 평문 릴레이 | VPPCOM_ATTR_SET_CKPAIR, VPPCOM_PROTO_TLS, vppcom_epoll_wait |
① 프록시 CA와 ckpair 사전 등록
VPP-TLS는 애플리케이션이 직접 PEM을 들고 있지 않고, cert-key pair index를 사용합니다. 프록시 CA 자체는 리프 인증서 서명용이므로 VPP에 넣을 필요가 없고, 부팅 시점에 "빈 템플릿"이나 기본 leaf를 한 번 등록해 두면 이후 동적으로 교체할 수 있습니다.
# 1. 프록시 CA 생성 (오프라인, 한 번만)
$ openssl genrsa -out proxy-ca.key 4096
$ openssl req -x509 -new -nodes -key proxy-ca.key -sha256 -days 3650 \
-subj "/CN=VPP SSL Inspection CA" \
-out proxy-ca.crt
# 2. 클라이언트 장비의 신뢰 저장소에 proxy-ca.crt 설치 (OS/브라우저별 방식)
# — 이 단계 없이는 브라우저가 즉시 경고합니다.
# 3. VPP에 "기본" cert-key pair 한 개를 미리 등록해 인덱스 확보
$ vppctl tls cert-key-pair add \
cert /etc/vpp/certs/default-leaf.crt \
key /etc/vpp/certs/default-leaf.key
cert-key pair added with index 0
# 4. VCL 프로세스용 vcl.conf
$ cat /etc/vpp/vcl-inspect.conf
vcl {
rx-fifo-size 4000000
tx-fifo-size 4000000
app-scope-local
app-scope-global
api-socket-name /run/vpp/api.sock
use-mq-eventfd
event-queue-size 512
}
② ClientHello에서 SNI 피킹하기
VCL은 MSG_PEEK을 직접 지원하지 않지만, vppcom_session_read_segments()로 RX FIFO 안을 소모 없이 들여다볼 수 있습니다. TLS 레코드 헤더(5바이트)와 ClientHello 고정 구조를 파싱하여 SNI를 꺼냅니다. 이 작업은 아직 PROTO_TCP 상태의 세션에서 수행한다는 점이 중요합니다.
/* VCL 세션에서 ClientHello를 소모 없이 들여다보고 SNI를 추출합니다.
PROTO_TCP로 먼저 accept한 세션에만 의미가 있습니다. */
static int
peek_sni (int sh, char *out, size_t out_sz)
{
vppcom_data_segment_t segs[2];
int n = vppcom_session_read_segments (sh, segs, 2, 0);
if (n <= 0) return -1;
/* 여러 세그먼트를 하나의 선형 버퍼로 합칩니다 */
uint8_t buf[2048];
uint32_t total = 0;
for (int i = 0; i < n && total < sizeof (buf); i++) {
uint32_t take = segs[i].len;
if (total + take > sizeof (buf)) take = sizeof(buf) - total;
memcpy (buf + total, segs[i].data, take);
total += take;
}
/* 중요: 아직 데이터를 소모하지 않았다고 VPP에 알립니다 */
vppcom_session_free_segments (sh, 0);
/* TLS Record: 0x16(Handshake) + version(2) + length(2) */
if (total < 43 || buf[0] != 0x16) return -1;
uint8_t *p = buf + 5;
uint32_t left = total - 5;
/* Handshake: HandshakeType(1)=0x01 ClientHello + length(3) */
if (left < 4 || p[0] != 0x01) return -1;
p += 4; left -= 4;
/* version(2) + random(32) */
if (left < 34) return -1;
p += 34; left -= 34;
/* session_id */
if (left < 1) return -1;
uint32_t sid = p[0]; p += 1 + sid; left -= 1 + sid;
/* cipher_suites */
if (left < 2) return -1;
uint32_t cs = (p[0] << 8) | p[1];
p += 2 + cs; left -= 2 + cs;
/* compression_methods */
if (left < 1) return -1;
uint32_t cm = p[0]; p += 1 + cm; left -= 1 + cm;
/* extensions 총 길이 */
if (left < 2) return -1;
uint32_t ext_total = (p[0] << 8) | p[1];
p += 2; left -= 2;
if (ext_total > left) return -1;
/* extensions 순회하면서 server_name(0x0000) 찾기 */
while (ext_total >= 4) {
uint16_t etype = (p[0] << 8) | p[1];
uint16_t elen = (p[2] << 8) | p[3];
p += 4; ext_total -= 4;
if (elen > ext_total) return -1;
if (etype == 0x0000 && elen >= 5) {
/* server_name_list: list_len(2) + type(1)=0 + name_len(2) + name */
uint16_t name_len = (p[3] << 8) | p[4];
if (name_len + 5u > elen || name_len + 1u > out_sz)
return -1;
memcpy (out, p + 5, name_len);
out[name_len] = 0;
return 0;
}
p += elen; ext_total -= elen;
}
return -1;
}
free_segments(sh, 0)인가: 두 번째 인자는 "소비된 바이트 수"입니다. 0을 주면 데이터는 FIFO에 그대로 남아 있어서, 곧이어 세션을 TLS로 승격할 때 VPP-TLS 엔진이 같은 ClientHello를 다시 읽어 핸드셰이크를 정상적으로 수행할 수 있습니다. 이 트릭이 없으면 승격 후 TLS 엔진은 ClientHello를 못 받고 무한 대기하게 됩니다.
③ 동적 leaf 인증서 → VPP cert-key pair 등록
리프 인증서는 유저스페이스에서 OpenSSL로 서명하고, 결과 PEM을 VPP binary API로 넘겨 새 ckpair index를 받습니다. 받은 index를 SNI→index 해시에 캐싱하면 동일 도메인 재접속 시 RSA 서명을 생략할 수 있습니다.
/* 도메인별 ckpair index 캐시 */
typedef struct {
char sni[256];
uint32_t ckpair_index; /* vppcom_add_cert_key_pair 반환값 */
time_t expires_at;
} ckpair_entry_t;
static ckpair_entry_t ckpair_cache[4096];
static uint32_t
get_or_mint_ckpair (const char *sni)
{
ckpair_entry_t *e = ckpair_lookup (sni);
if (e && e->expires_at > time (NULL))
return e->ckpair_index; /* 캐시 히트 — 서명 생략 */
/* 1) 프록시 CA로 SNI용 leaf 서명 (OpenSSL) */
uint8_t *cert_pem, *key_pem;
uint32_t cert_len, key_len;
mint_leaf_cert (sni, &cert_pem, &cert_len, &key_pem, &key_len);
/* 2) VPP에 등록 — 새 cert-key pair index 획득
내부적으로 binary API app_tls_cert_key_pair_add_del을 호출합니다. */
vppcom_cert_key_pair_t ck = {
.cert = cert_pem,
.cert_len = cert_len,
.key = key_pem,
.key_len = key_len,
};
uint32_t idx = vppcom_add_cert_key_pair (&ck);
free (cert_pem); free (key_pem);
/* 3) 캐시에 기록 */
ckpair_store (sni, idx, time (NULL) + 3600);
return idx;
}
vppcom_add_cert_key_pair로 등록한 인덱스는 해제하지 않으면 VPP 메모리에 누적됩니다. 캐시 엔트리가 퇴거(evict)될 때 반드시 vppcom_del_cert_key_pair(idx)를 호출해 해제하고, 프록시 프로세스 종료 시에도 모든 인덱스를 회수해야 합니다. 장기 운용 시 show app의 ckpair 개수를 모니터링하십시오.
④ 메인 루프: accept → SNI peek → TLS 승격 → 업스트림 연결 → 평문 릴레이
아래 예제는 단일 스레드 이벤트 루프(Event Loop) 구조입니다. 핵심은 같은 원격 피어에 대해 두 개의 VCL 세션을 쓴다는 점입니다. 첫 번째는 PROTO_TCP로 SNI를 엿보기 위한 세션, 두 번째는 SNI와 ckpair를 확정한 뒤 새로 생성하는 PROTO_TLS 세션입니다. PROTO_TCP 세션은 SNI를 얻는 즉시 닫지 않고 그대로 TLS 핸드셰이크에 사용할 수 없기 때문에, 실전에서는 accept 자체를 PROTO_TLS 리스너에서 받고, SNI는 VPP의 VPPCOM_ATTR_GET_SERVER_NAME(TLS 엔진이 핸드셰이크 시작 단계에 제공)을 통해 가져오는 방식과 병행합니다. 여기서는 교육 목적으로 가장 평범한 흐름을 보여줍니다.
/* vcl-ssl-inspect.c — VCL + VPP-TLS SSL Inspection 메인 루프 (핵심 부분) */
#include <vcl/vppcom.h>
#include <sys/epoll.h>
typedef struct {
int c_sh; /* 클라이언트 TLS 세션 (프록시=서버) */
int u_sh; /* 업스트림 TLS 세션 (프록시=클라이언트) */
char sni[256];
int blocked; /* 정책 판정 결과 */
} insp_pair_t;
static int epfd;
static int
connect_upstream_tls (const char *sni, uint8_t ip[4])
{
int u = vppcom_session_create (VPPCOM_PROTO_TLS, 1);
uint32_t slen = strlen (sni);
vppcom_session_attr (u, VPPCOM_ATTR_SET_SNI_HOSTNAME,
(void *) sni, &slen);
vppcom_endpt_t ep = { 0 };
ep.is_ip4 = 1; ep.ip = ip; ep.port = htons (443);
int rv = vppcom_session_connect (u, &ep);
if (rv < 0 && rv != VPPCOM_EINPROGRESS) {
vppcom_session_close (u);
return -1;
}
return u;
}
static void
on_client_accept (int c_sh, const char *sni_from_handshake,
uint8_t real_ip[4])
{
/* 1) 바이패스 정책 */
if (policy_should_bypass (sni_from_handshake)) {
/* 바이패스: TLS 세션을 닫고 raw TCP로 스플라이스하거나,
애초에 PROTO_TCP 리스너에서 처리합니다. */
splice_tcp_passthrough (c_sh, real_ip);
return;
}
/* 2) 업스트림 TLS 연결 개시 (CONNECTED 이벤트는 epoll로 받음) */
int u_sh = connect_upstream_tls (sni_from_handshake, real_ip);
if (u_sh < 0) {
send_block_page (c_sh, "upstream unreachable");
vppcom_session_close (c_sh);
return;
}
/* 3) 상태 구조 할당 + epoll 등록 */
insp_pair_t *pp = calloc (1, sizeof (*pp));
pp->c_sh = c_sh; pp->u_sh = u_sh;
strncpy (pp->sni, sni_from_handshake, sizeof (pp->sni) - 1);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLRDHUP;
ev.data.ptr = pp; /* 양쪽 모두 같은 pp를 가리킴 */
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, c_sh, &ev);
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, u_sh, &ev);
}
static void
relay_one_direction (insp_pair_t *pp, int from, int to)
{
uint8_t buf[32768];
int n = vppcom_session_read (from, buf, sizeof (buf));
if (n <= 0) {
if (n == VPPCOM_EAGAIN) return;
teardown_pair (pp);
return;
}
/* 정책 엔진: 평문 검사 후 허용/차단 */
if (inspect_plaintext (pp, buf, n) == INSPECT_BLOCK) {
send_block_page (pp->c_sh, "blocked by policy");
teardown_pair (pp);
return;
}
int off = 0;
while (off < n) {
int w = vppcom_session_write (to, buf + off, n - off);
if (w == VPPCOM_EAGAIN) { /* TX FIFO full — EPOLLOUT 등록 권장 */ continue; }
if (w < 0) { teardown_pair (pp); return; }
off += w;
}
}
int
main (void)
{
vppcom_app_create ("vcl-ssl-inspect");
epfd = vppcom_epoll_create ();
/* 기본 ckpair(index 0)로 시작하는 TLS 리스너 —
SNI callback 안에서 ckpair를 새 인덱스로 교체합니다. */
int lsh = vppcom_session_create (VPPCOM_PROTO_TLS, 1);
uint32_t idx = 0;
uint32_t ilen = sizeof (idx);
vppcom_session_attr (lsh, VPPCOM_ATTR_SET_CKPAIR, &idx, &ilen);
vppcom_endpt_t ep = { 0 };
uint8_t any[4] = { 0, 0, 0, 0 };
ep.is_ip4 = 1; ep.ip = any; ep.port = htons (443);
vppcom_session_bind (lsh, &ep);
vppcom_session_listen (lsh, 2048);
struct epoll_event ev = { .events = EPOLLIN, .data.u32 = (uint32_t) lsh };
vppcom_epoll_ctl (epfd, EPOLL_CTL_ADD, lsh, &ev);
struct epoll_event events[256];
for (;;) {
int rv = vppcom_epoll_wait (epfd, events, 256, 1.0);
for (int i = 0; i < rv; i++) {
if (events[i].data.u32 == (uint32_t) lsh) {
vppcom_endpt_t peer = { 0 };
uint8_t pip[16]; peer.ip = pip;
int c_sh = vppcom_session_accept (lsh, &peer, 0);
/* accept는 TLS 핸드셰이크 완료된 세션을 반환 — SNI는 속성으로 질의 */
char sni[256] = { 0 };
uint32_t slen = sizeof (sni);
vppcom_session_attr (c_sh, VPPCOM_ATTR_GET_SERVER_NAME, sni, &slen);
/* 원래 목적지 IP는 TPROXY 또는 명시적 매핑 테이블에서 조회 */
uint8_t real_ip[4];
resolve_original_dst (&peer, sni, real_ip);
on_client_accept (c_sh, sni, real_ip);
continue;
}
insp_pair_t *pp = events[i].data.ptr;
int sh = event_to_sh (pp, &events[i]);
if (events[i].events & (EPOLLHUP | EPOLLRDHUP | EPOLLERR)) {
teardown_pair (pp);
continue;
}
if (events[i].events & EPOLLIN) {
if (sh == pp->c_sh)
relay_one_direction (pp, pp->c_sh, pp->u_sh);
else
relay_one_direction (pp, pp->u_sh, pp->c_sh);
}
}
}
}
코드 설명
-
connect_upstream_tls()
업스트림 방향은 프록시가 클라이언트 역할이므로
VPPCOM_ATTR_SET_SNI_HOSTNAME으로 원본 SNI를 넣어야 합니다. 이 값이 없거나 프록시의 외부 IP로 대체되면 상대 서버가 잘못된 인증서를 내려보내거나 연결을 거절합니다. 논블로킹이므로VPPCOM_EINPROGRESS는 정상입니다. -
on_client_accept()의 ckpair 동적 교체
리스너는 "기본" ckpair index 0으로 만들어 두지만, 실제 리프 인증서는 SNI를 본 뒤
get_or_mint_ckpair(sni)로 새 인덱스를 만들어VPPCOM_ATTR_SET_CKPAIR로 세션에 다시 꽂아줍니다. 리스너 자체의 ckpair를 바꾸지 않는 이유는 서로 다른 SNI를 동시에 받기 때문입니다. -
양방향 epoll data.ptr
c_sh와u_sh를 같은insp_pair_t*에 묶어 등록하면, 어느 방향에서 이벤트가 와도 한 번의 포인터 역참조(Dereference)로 상대 세션을 찾을 수 있습니다.event_to_sh()는 events 구조체(Struct)에서 어느 핸들인지 복원하는 헬퍼입니다 — 실전 구현은data.u64에 포인터와 방향 비트를 같이 담는 패턴을 씁니다. -
inspect_plaintext()
이 시점의
buf는 이미 TLS 엔진이 복호화한 평문입니다. URL 필터, Suricata 매칭, DLP 정규식, 로깅을 여기서 수행합니다. 차단 판정이 나오면 클라이언트에만 블록 페이지를 보내고 업스트림 세션은 조용히 닫습니다. - resolve_original_dst() 투명 모드에서 원본 목적지 IP는 커널 TPROXY나 VPP 쪽의 session 네임스페이스(Namespace) 매핑(Mapping)에서 가져와야 합니다. 명시적 프록시(HTTP CONNECT)를 쓰는 경우에는 CONNECT 헤더에서 호스트를 파싱해 DNS로 해석합니다.
⑤ 실행 및 점검
# 빌드
$ gcc -O2 -o vcl-ssl-inspect vcl-ssl-inspect.c sni_peek.c cert_mint.c \
-I/usr/include/vpp -lvppcom -lvlibmemoryclient -lsvm \
-lssl -lcrypto -lpthread
# 실행 (VPP가 이미 동작 중이어야 합니다)
$ VCL_CONFIG=/etc/vpp/vcl-inspect.conf ./vcl-ssl-inspect &
# 클라이언트 트래픽을 443/tcp 리스너로 유도 — TPROXY 규칙 예
$ iptables -t mangle -A PREROUTING -p tcp --dport 443 \
-j TPROXY --tproxy-mark 0x1/0x1 --on-port 443
# 동작 확인: 프록시 CA를 신뢰시킨 클라이언트에서
$ curl -v https://api.example.com/
# → 인증서 Issuer가 "VPP SSL Inspection CA"로 보이면 성공
# VPP 측 상태 점검
$ vppctl show app
$ vppctl show session verbose
$ vppctl show tls # 등록된 cert-key pair 수
$ vppctl show errors | grep tls
send_block_page는 반드시 close 전에 write를 완료해야 하므로, TX FIFO가 비워질 때까지 EPOLLOUT을 기다리고 닫아야 합니다. ③ 정책 엔진은 호출당 지연이 누적되므로 호출 횟수를 제한하거나 별도 스레드·ring buffer로 옮기는 편이 안정적입니다. ④ QUIC(HTTP/3) 트래픽은 같은 구조로 복호화할 수 없으므로, UDP 443을 방화벽에서 차단해 TCP 443으로 폴백(Fallback)을 강제하는 정책이 실무에서 자주 병행됩니다.
투명 SSL Inspection 구현
투명(Transparent) 모드의 SSL Inspection은 네트워크 경로 상에서 패킷을 가로채는 방식입니다. VPP의 그래프 노드 아키텍처를 활용하여, ip4-input 이후 특정 조건(목적지 포트 443)의 트래픽을 검사 노드로 리다이렉트합니다.
/* 투명 SSL Inspection 그래프 노드 흐름 */
/*
* 일반 트래픽:
* dpdk-input → ip4-input → ip4-lookup → ip4-rewrite → dpdk-output
*
* SSL Inspection 대상:
* dpdk-input → ip4-input → ip4-lookup
* → ssl-inspect-classify (dst port 443 검출)
* → tcp-input (TCP 연결 수립)
* → tls-input (TLS 세션 ① 핸드셰이크)
* → ssl-inspect-engine (평문 검사)
* → tls-output (TLS 세션 ② 재암호화)
* → tcp-output → ip4-rewrite → dpdk-output
*/
/* ssl-inspect-classify 노드: 검사 대상 분류 */
static uword
ssl_inspect_classify_fn (vlib_main_t *vm,
vlib_node_runtime_t *node,
vlib_frame_t *frame)
{
u32 *from = vlib_frame_vector_args (frame);
u32 n_left = frame->n_vectors;
u32 next_index;
while (n_left > 0) {
vlib_buffer_t *b = vlib_get_buffer (vm, from[0]);
ip4_header_t *ip = vlib_buffer_get_current (b);
tcp_header_t *tcp;
if (ip->protocol == IP_PROTOCOL_TCP) {
tcp = (tcp_header_t *) ip4_next_header (ip);
if (clib_net_to_host_u16 (tcp->dst_port) == 443) {
/* 바이패스 정책 확인 */
if (ssl_inspect_should_bypass (b, ip, tcp))
next_index = SSL_INSPECT_NEXT_BYPASS;
else
next_index = SSL_INSPECT_NEXT_INTERCEPT;
} else {
next_index = SSL_INSPECT_NEXT_PASSTHROUGH;
}
} else {
next_index = SSL_INSPECT_NEXT_PASSTHROUGH;
}
vlib_validate_buffer_enqueue_x1 (vm, node, next_index,
to_next, n_left_to_next,
from[0], next_index);
from += 1;
n_left -= 1;
}
return frame->n_vectors;
}
코드 설명
-
1~14행
VPP 그래프 노드 체인에서 SSL Inspection을 투명하게 삽입하는 구조를 보여줍니다.
ssl-inspect-classify노드가 ip4-lookup 이후에 위치하여, 목적지 포트 443인 패킷만 검사 경로로 분기합니다. -
31~38행
목적지 포트 443(HTTPS) 트래픽을 감지하면, 먼저
ssl_inspect_should_bypass()로 바이패스 정책을 확인합니다. 금융, 의료, 인증서 피닝 도메인 등은 검사를 건너뛰어야 합니다. -
35~38행
바이패스가 아닌 경우
SSL_INSPECT_NEXT_INTERCEPT로 분기하여 TLS 종단 → 검사 → 재암호화 경로로 진입합니다. 바이패스 트래픽은 원래 경로(ip4-rewrite)로 직접 전달됩니다.
/* ssl-inspect-engine 노드: 평문 검사 핵심 로직 */
static uword
ssl_inspect_engine_fn (vlib_main_t *vm,
vlib_node_runtime_t *node,
vlib_frame_t *frame)
{
ssl_inspect_main_t *sim = &ssl_inspect_main;
u32 *from = vlib_frame_vector_args (frame);
u32 n_left = frame->n_vectors;
while (n_left > 0) {
ssl_inspect_session_t *sis;
session_t *app_session;
u8 *plaintext;
u32 plaintext_len;
ssl_inspect_action_t action;
/* 클라이언트 측 TLS에서 복호화된 평문 읽기 */
app_session = session_get (sis->client_tls_session_handle);
plaintext_len = svm_fifo_max_dequeue (app_session->rx_fifo);
if (plaintext_len == 0)
goto next;
plaintext = svm_fifo_peek (app_session->rx_fifo,
plaintext_len);
/* ─── 보안 검사 파이프라인 ─── */
/* 1단계: URL/도메인 필터링 (HTTP Host 헤더 검사) */
action = ssl_inspect_url_filter (sim, plaintext,
plaintext_len);
if (action == SSL_INSPECT_BLOCK)
goto block;
/* 2단계: IDS/IPS 패턴 매칭 (Suricata 룰셋) */
action = ssl_inspect_ids_check (sim, plaintext,
plaintext_len);
if (action == SSL_INSPECT_BLOCK)
goto block;
/* 3단계: DLP (데이터 유출 방지) 검사 */
action = ssl_inspect_dlp_check (sim, plaintext,
plaintext_len);
if (action == SSL_INSPECT_BLOCK)
goto block;
/* 4단계: 악성코드 시그니처 스캔 */
action = ssl_inspect_malware_scan (sim, plaintext,
plaintext_len);
if (action == SSL_INSPECT_BLOCK)
goto block;
/* 모든 검사 통과 → 서버 측 TLS로 포워딩 */
ssl_inspect_forward_to_server (sis, plaintext,
plaintext_len);
sis->bytes_inspected += plaintext_len;
goto next;
block:
/* 차단: 클라이언트에 에러 응답 + 연결 종료 */
ssl_inspect_send_block_page (sis);
ssl_inspect_close_session (sis);
sis->threats_detected++;
next:
from += 1;
n_left -= 1;
}
return frame->n_vectors;
}
코드 설명
- 19~21행 클라이언트 측 TLS 세션(프록시가 서버 역할)의 App RX FIFO에서 복호화된 평문을 읽습니다. 이 평문은 클라이언트가 보낸 원본 HTTP 요청(또는 다른 프로토콜 데이터)입니다.
- 30~53행 4단계 보안 검사 파이프라인을 순차적으로 적용합니다. URL 필터링 → IDS/IPS → DLP → 악성코드 순서로 진행하며, 어느 단계에서든 차단 판정이 나오면 즉시 중단합니다. 이 순서는 비용이 낮은 검사(URL 필터)를 먼저 수행하여 불필요한 비용을 줄이는 최적화입니다.
- 56~57행 모든 검사를 통과하면 평문을 서버 측 TLS 세션(프록시가 클라이언트 역할)의 App TX FIFO에 기록합니다. TLS 엔진이 이를 재암호화하여 원본 서버로 전송합니다.
- 62행 차단 시 클라이언트에 커스텀 차단 페이지(Block Page)를 전송합니다. 일반적으로 HTTP 403 응답과 차단 사유를 포함한 HTML 페이지를 반환합니다.
SNI 추출과 사전 판정
SSL Inspection에서 가장 먼저 수행하는 작업은 ClientHello 메시지에서 SNI(Server Name Indication)를 추출하는 것입니다. SNI는 TLS 핸드셰이크의 첫 메시지에 평문으로 포함되어 있으므로, TLS 복호화 없이도 접근할 수 있습니다. 이를 통해 바이패스 정책을 TLS 세션 수립 전에 판정할 수 있습니다.
/* ClientHello에서 SNI 추출 (TLS 레코드 파싱) */
static int
ssl_inspect_extract_sni (u8 *data, u32 len, u8 **sni_out)
{
u8 *p = data;
u16 tls_version, handshake_len, extensions_len;
/* TLS Record Layer: ContentType(1) + Version(2) + Length(2) */
if (len < 5 || p[0] != 0x16) /* 0x16 = Handshake */
return -1;
tls_version = (p[1] << 8) | p[2];
u16 record_len = (p[3] << 8) | p[4];
p += 5;
/* Handshake: MsgType(1) + Length(3) */
if (p[0] != 0x01) /* 0x01 = ClientHello */
return -1;
u32 hs_len = (p[1] << 16) | (p[2] << 8) | p[3];
p += 4;
/* ClientHello: Version(2) + Random(32) + SessionID(var)
* + CipherSuites(var) + Compression(var)
* + Extensions(var) */
p += 2 + 32; /* Version + Random */
p += 1 + p[0]; /* SessionID (길이 + 데이터) */
p += 2 + ((p[0] << 8) | p[1]); /* CipherSuites */
p += 1 + p[0]; /* Compression */
/* Extensions 순회하여 SNI(type=0x0000) 찾기 */
extensions_len = (p[0] << 8) | p[1];
p += 2;
u8 *ext_end = p + extensions_len;
while (p + 4 <= ext_end) {
u16 ext_type = (p[0] << 8) | p[1];
u16 ext_len = (p[2] << 8) | p[3];
p += 4;
if (ext_type == 0x0000) { /* server_name 확장 */
/* ServerNameList: Length(2) + Type(1) + NameLength(2) + Name */
u16 sni_list_len = (p[0] << 8) | p[1];
u8 name_type = p[2];
u16 name_len = (p[3] << 8) | p[4];
if (name_type == 0x00) { /* host_name */
*sni_out = vec_new (u8, name_len + 1);
clib_memcpy (*sni_out, p + 5, name_len);
(*sni_out)[name_len] = 0;
return 0;
}
}
p += ext_len;
}
return -1; /* SNI 없음 (IP 직접 연결 등) */
}
코드 설명
- 8~10행 TLS Record Layer의 ContentType을 확인합니다. 0x16은 Handshake 메시지를 의미하며, 첫 번째 패킷이 ClientHello임을 확인합니다.
- 30~31행 ClientHello의 고정 필드(Version, Random)와 가변 필드(SessionID, CipherSuites, Compression)를 건너뛰어 Extensions 영역에 도달합니다.
- 40행 Extension type 0x0000은 SNI(server_name) 확장입니다. TLS 1.3에서도 이 확장은 동일한 형식으로 유지됩니다.
- 55행 SNI가 없는 경우는 IP 주소로 직접 연결하거나, ESNI(Encrypted SNI)/ECH(Encrypted Client Hello)를 사용하는 경우입니다. 이 경우 도메인 기반 정책 판정이 불가능합니다.
바이패스 정책 엔진(Policy Engine)
모든 TLS 트래픽을 검사하는 것은 법적·기술적으로 적절하지 않습니다. 바이패스 정책 엔진은 특정 조건에 해당하는 트래픽을 검사 없이 통과시킵니다.
| 바이패스 조건 | 판정 시점 | 이유 |
|---|---|---|
| 금융 기관 도메인 | SNI 확인 후 | 온라인 뱅킹 인증 정보 보호, 규제 준수 (PCI-DSS) |
| 의료 기관 도메인 | SNI 확인 후 | HIPAA 개인 건강 정보(PHI) 보호 의무 |
| 인증서 피닝(Certificate Pinning) | SNI 확인 후 | 앱이 특정 인증서/공개 키를 하드코딩하여 MITM 인증서 거부 |
| mTLS (상호 인증) | ClientHello 분석 후 | 클라이언트 인증서가 필요한 연결은 프록시가 대행 불가 |
| 내부 서버 통신 | IP 기반 | 내부 서비스 간 통신은 검사 불필요 |
| VPN/터널 트래픽 | 포트/프로토콜 기반 | 이미 터널로 보호된 트래픽의 이중 검사 방지 |
| QUIC/HTTP3 | UDP 443 포트 | QUIC은 핸드셰이크 자체가 암호화되어 투명 MITM 불가 |
/* 바이패스 정책 판정 */
static int
ssl_inspect_should_bypass (ssl_inspect_main_t *sim,
const u8 *sni,
ip4_address_t *dst_ip)
{
/* 1. 도메인 화이트리스트 확인 (해시 테이블 O(1) 조회) */
if (hash_get (sim->bypass_domain_hash, sni))
return 1;
/* 2. 와일드카드 매칭 (*.bank.com) */
if (ssl_inspect_wildcard_match (sim->bypass_wildcards, sni))
return 1;
/* 3. IP 대역 확인 (내부 서버) */
if (ip4_prefix_match (dst_ip, &sim->internal_prefix,
sim->internal_prefix_len))
return 1;
/* 4. 카테고리 기반 (금융, 의료 — 외부 URL DB 연동) */
url_category_t cat = url_db_lookup (sni);
if (cat == URL_CAT_FINANCIAL || cat == URL_CAT_HEALTHCARE)
return 1;
return 0; /* 바이패스 조건 불일치 → 검사 진행 */
}
/* CLI: 바이패스 도메인 추가 */
/* vpp# ssl-inspect bypass add domain banking.example.com */
/* vpp# ssl-inspect bypass add domain *.medical-provider.org */
/* vpp# ssl-inspect bypass add prefix 10.0.0.0/8 */
/* vpp# ssl-inspect bypass add category financial */
/* vpp# show ssl-inspect bypass */
IDS/IPS 엔진 통합
SSL Inspection으로 확보한 평문 트래픽에 대해 침입 탐지/방지 시스템(IDS/IPS)을 적용할 수 있습니다. VPP는 두 가지 통합 방식을 지원합니다.
인라인 IDS 엔진은 Intel Hyperscan 라이브러리를 활용합니다. Hyperscan은 SIMD 명령어로 수천 개의 정규표현식 패턴을 동시에 매칭할 수 있으며, VPP의 벡터 처리 모델과 잘 어울립니다.
/* Hyperscan 기반 인라인 IDS 패턴 매칭 */
typedef struct {
hs_database_t *db; /* 컴파일된 패턴 데이터베이스 */
hs_scratch_t **scratch; /* 워커별 스크래치 공간 */
u32 n_patterns; /* 로드된 시그니처 수 */
} ssl_inspect_ids_t;
/* 패턴 매칭 콜백 */
static int
ids_match_handler (unsigned int id,
unsigned long long from,
unsigned long long to,
unsigned int flags,
void *ctx)
{
ssl_inspect_match_ctx_t *mc = ctx;
mc->matched_rule_id = id;
mc->action = SSL_INSPECT_BLOCK;
/* 첫 번째 매칭에서 중단 (IPS 모드) */
return 1;
}
/* VPP 노드 내에서 Hyperscan 호출 */
ssl_inspect_action_t
ssl_inspect_ids_check (ssl_inspect_main_t *sim,
u8 *data, u32 len)
{
ssl_inspect_match_ctx_t match = { .action = SSL_INSPECT_ALLOW };
u32 thread_index = vlib_get_thread_index ();
/* Hyperscan 스트리밍 스캔: 워커별 scratch 사용 */
hs_scan (sim->ids.db, (const char *) data, len,
0, sim->ids.scratch[thread_index],
ids_match_handler, &match);
return match.action;
}
QUIC/ECH 환경에서의 SSL Inspection 과제
QUIC과 ECH(Encrypted Client Hello)의 등장은 전통적인 SSL Inspection에 근본적인 도전을 제기합니다.
tlsopenssl·tlspicotls 엔진은 아직 ECH(draft-ietf-tls-esni) 네이티브 처리를 제공하지 않습니다. 즉 VPP 자체가 ECH를 복호화하거나 내부 SNI를 들여다보는 경로는 없으며, 본 절의 대응 전략은 모두 VPP 외부(DNS 정책·UDP 차단·TCP 폴백 유도·엔터프라이즈 MDM)에서 수행해야 합니다. 마찬가지로 quic 플러그인(quicly 기반)은 experimental 단계이며 프로덕션 SSL Inspection 파이프라인에서는 여전히 TCP+TLS 1.3 경로로 강제 폴백시키는 것이 현실적입니다.
| 기술 | SSL Inspection에 미치는 영향 | 대응 전략 |
|---|---|---|
| QUIC (UDP 443) | 핸드셰이크 자체가 암호화되어 투명 MITM이 불가능합니다. TCP처럼 SYN을 가로채는 방식을 사용할 수 없습니다 | UDP 443 차단 → TCP 폴백 유도, 또는 QUIC 인지 명시적 프록시 구현 |
| ECH (Encrypted Client Hello) | SNI가 암호화되어 도메인 기반 바이패스 정책 판정이 불가능합니다 | DNS-over-HTTPS 프록시에서 ECHConfig를 제거하여 ECH 비활성화 유도 |
| Certificate Transparency | 동적 생성 인증서가 CT 로그에 없으므로 브라우저가 경고할 수 있습니다 | 프록시 CA를 엔터프라이즈 정책으로 CT 예외 등록 |
| TLS 1.3 0-RTT | Early Data가 핸드셰이크 완료 전에 전송되어 검사 시점 문제가 발생합니다 | Early Data 차단 후 1-RTT로 다운그레이드 |
| 인증서 피닝 앱 | 모바일 앱이 특정 인증서를 하드코딩하여 프록시 CA를 거부합니다 | 앱별 바이패스 정책 또는 MDM으로 피닝 해제 |
# VPP에서 QUIC 차단 → TCP 폴백 유도 설정
# 1. ACL로 UDP 443 차단
vpp# create access-list ip permit+reflect src 0.0.0.0/0 \
dst 0.0.0.0/0 proto udp dport 443 action deny
# 2. 인터페이스에 ACL 적용
vpp# set acl-plugin acl 0 interface host-eth0 input
# 3. 확인: 브라우저가 QUIC 시도 실패 후 TCP 443으로 폴백
# Chrome: chrome://flags/#enable-quic → Disabled로 확인 가능
# 브라우저 DevTools → Network → Protocol 컬럼에서 h2(TCP) 확인
SSL Inspection 성능 최적화
SSL Inspection은 이중 TLS 처리로 인해 단순 TLS 종단 대비 2배 이상의 암호화 연산 부하가 발생합니다. VPP 환경에서의 최적화 전략:
| 최적화 항목 | 구현 방법 | 성능 향상 |
|---|---|---|
| 인증서 캐싱 | 도메인별 동적 인증서 + 키 쌍 캐시, LRU 교체 정책 | 캐시 히트 95%+ 시 CPS 3x 향상 |
| ECDSA 전환 | 프록시 CA와 동적 인증서를 RSA → ECDSA P-256으로 전환 | 서명 속도 5x 향상 (0.5ms → 0.1ms) |
| QAT 이중 오프로드 | 양쪽 TLS 세션의 벌크 암호화 + 핸드셰이크를 QAT에 오프로드 | CPU 사용률 70% 감소 |
| 비동기 인증서 생성 | 인증서 서명을 비동기 큐에 넣고 핸드셰이크를 파이프라이닝 | 핸드셰이크 병목 해소 |
| 선택적 검사(Selective Inspection) | SNI 기반 카테고리 분류 → 위험 카테고리만 심층 검사 | 검사 대상 트래픽 50~70% 감소 |
| TLS 1.3 전용 | TLS 1.2 이하 비활성화 → 1-RTT 핸드셰이크, 간소화된 cipher | 핸드셰이크 지연 30% 감소 |
| 벡터 배치 검사 | Hyperscan의 hs_scan_stream()으로 여러 세션 평문을 벡터 처리 | IDS 처리량 2x 향상 |
# startup.conf — SSL Inspection 고성능 설정 예시
session {
evt_qs_memfd_seg
event-queue-length 200000
preallocated-sessions 256000 # 이중 세션이므로 2배 할당
rx-fifo-size 32K
tx-fifo-size 32K
}
tls {
default-crypto-engine openssl
async # 비동기 암호화 필수
}
tlsopenssl {
engine cryptodev # QAT 오프로드
async
max-async-frames 512
}
cpu {
main-core 0
corelist-workers 1-15 # SSL Inspection은 CPU 집약적
}
# SSL Inspection 전용 설정
ssl-inspect {
proxy-ca-cert /etc/vpp/ssl-inspect/proxy-ca.pem
proxy-ca-key /etc/vpp/ssl-inspect/proxy-ca.key
cert-cache-size 100000 # 도메인 인증서 캐시 (10만 개)
cert-cache-ttl 86400 # 캐시 TTL: 24시간
cert-key-type ecdsa-p256 # 동적 인증서 키 유형
bypass-list /etc/vpp/ssl-inspect/bypass-domains.txt
ids-engine hyperscan # 인라인 IDS 엔진
ids-rules /etc/vpp/ssl-inspect/suricata.rules
}
SSL Inspection 환경의 대표적 성능 수치(Xeon Platinum 8380, QAT C62x, 16 워커):
| 항목 | 단순 TLS 종단 | SSL Inspection (SW) | SSL Inspection (QAT) |
|---|---|---|---|
| 새 연결 (CPS) | ~150,000 | ~40,000 | ~100,000 |
| 처리량 (Gbps) | ~80 | ~20 | ~50 |
| p99 지연 (ms) | 0.2 | 0.8 | 0.4 |
| 동시 연결 | 500K | 250K | 250K |
| CPU 사용률 | 25% | 90% | 35% |
| 메모리 (연결당) | ~50KB | ~120KB | ~120KB |
명시적 HTTP CONNECT 프록시 구현
명시적(Explicit) 프록시 모드에서는 클라이언트가 HTTP CONNECT 메서드로 프록시에 터널을 요청합니다. 프록시는 이 요청을 가로채어 SSL Inspection을 수행합니다.
/* HTTP CONNECT 프록시 핸들러 (개념적 구현) */
static int
ssl_inspect_handle_connect (session_t *client_session,
u8 *request, u32 len)
{
u8 *host;
u16 port;
ssl_inspect_session_t *sis;
/* "CONNECT api.example.com:443 HTTP/1.1" 파싱 */
if (parse_connect_request (request, len, &host, &port))
return -1;
/* 바이패스 정책 확인 */
if (ssl_inspect_should_bypass_domain (host)) {
/* 바이패스: 원본 서버에 직접 TCP 터널 */
send_connect_response (client_session,
"200 Connection Established");
setup_tcp_tunnel (client_session, host, port);
return 0;
}
/* SSL Inspection 세션 초기화 */
sis = ssl_inspect_session_alloc ();
sis->server_name = vec_dup (host);
/* 1. 원본 서버에 TLS 연결 (프록시가 클라이언트 역할) */
sis->server_tls_session_handle =
ssl_inspect_connect_to_server (host, port);
/* 2. 서버 인증서 수신 대기 → 동적 인증서 생성 */
/* → 비동기 콜백에서 처리 (server_connected_cb) */
return 0;
}
/* 서버 연결 완료 콜백 */
static void
ssl_inspect_server_connected_cb (ssl_inspect_session_t *sis,
session_t *server_session)
{
X509 *server_cert, *proxy_cert;
SSL *server_ssl;
/* 서버 인증서 추출 */
server_ssl = tls_ctx_get_ssl (sis->server_tls_ctx);
server_cert = SSL_get_peer_certificate (server_ssl);
/* 서버 인증서 검증 (CA 체인, CRL/OCSP) */
if (ssl_inspect_verify_server_cert (server_cert) != 0) {
/* 서버 인증서 검증 실패 → 클라이언트에 경고 */
ssl_inspect_send_cert_error (sis);
return;
}
/* 동적 인증서 생성 (캐시 확인 포함) */
proxy_cert = ssl_inspect_get_or_generate_cert (
sis, sis->server_name, server_cert);
/* 클라이언트에 "200 Connection Established" 응답 */
send_connect_response (sis->client_session,
"200 Connection Established");
/* 클라이언트 측 TLS 세션 시작 (동적 인증서 사용) */
sis->client_tls_ctx = ssl_inspect_start_client_tls (
sis, proxy_cert);
/* 이후 양쪽 TLS 세션이 모두 ESTABLISHED되면
* ssl_inspect_engine 노드에서 평문 검사 시작 */
}
코드 설명
-
10~11행
클라이언트가 보낸
CONNECT api.example.com:443 HTTP/1.1요청에서 대상 호스트와 포트를 추출합니다. 이 시점에서는 아직 TLS 핸드셰이크가 시작되지 않았으므로 평문입니다. - 14~20행 바이패스 대상 도메인인 경우, SSL Inspection 없이 단순 TCP 터널을 설정합니다. 클라이언트와 서버가 직접 TLS 핸드셰이크를 수행하며 프록시는 바이트를 투명하게 중계합니다.
- 27~28행 먼저 원본 서버에 TLS 연결을 수립합니다. 서버의 실제 인증서를 받아야 동적 인증서를 생성할 수 있기 때문입니다. 이 연결은 비동기로 진행됩니다.
- 47~50행 서버 인증서의 유효성을 검증합니다. 프록시가 만료되거나 위조된 서버 인증서를 그대로 전달하면 안 되므로, CA 체인 검증 + CRL/OCSP 확인을 수행합니다. 검증 실패 시 클라이언트에 적절한 에러를 전달합니다.
-
59~60행
200 Connection Established응답을 보낸 후에 클라이언트 측 TLS 핸드셰이크를 시작합니다. 이 순서가 중요합니다. 클라이언트는 200 응답을 받은 후에 TLS ClientHello를 전송하기 때문입니다.
SSL Inspection 모니터링 및 디버깅
# SSL Inspection 상태 모니터링
vpp# show ssl-inspect summary
Active inspection sessions: 12,345
Bypassed sessions: 8,901
Certificate cache entries: 45,678
Certificate cache hit rate: 97.3%
Threats blocked: 42
# 인증서 캐시 상태
vpp# show ssl-inspect cert-cache [verbose]
[0] *.google.com hits: 15234 age: 3600s
[1] api.github.com hits: 2341 age: 1800s
[2] cdn.example.com hits: 892 age: 7200s
# 바이패스 통계
vpp# show ssl-inspect bypass stats
Domain whitelist hits: 5,432
Category bypass (financial): 2,100
Category bypass (healthcare): 890
Certificate pinning detected: 479
Internal IP bypass: 1,000
# IDS 매칭 통계
vpp# show ssl-inspect ids stats
Patterns loaded: 12,345
Scans performed: 1,234,567
Matches found: 42
Average scan time: 0.02ms
# 개별 세션 상세
vpp# show ssl-inspect session verbose index 42
Client: 192.168.1.100:52341
Server: 93.184.216.34:443 (example.com)
Client TLS: TLSv1.3 TLS_AES_256_GCM_SHA384
Server TLS: TLSv1.3 TLS_AES_256_GCM_SHA384
Proxy cert: CN=example.com (ECDSA P-256)
Bytes inspected: 1,048,576
IDS matches: 0
Action: ALLOW
# 실시간 검사 트레이싱
vpp# trace add ssl-inspect-classify 10
vpp# trace add ssl-inspect-engine 10
vpp# show trace
| 문제 | 진단 명령 | 해결책 |
|---|---|---|
| 인증서 오류 (브라우저 경고) | show ssl-inspect cert-cache verbose | 프록시 CA가 클라이언트에 설치되었는지 확인, SAN 복사 로직 검증 |
| 핸드셰이크 지연 | show ssl-inspect summary 캐시 히트율 확인 | 캐시 크기 증가, ECDSA 전환, QAT 오프로드 활성화 |
| 특정 앱 연결 실패 | show ssl-inspect bypass stats에서 피닝 감지 확인 | 해당 앱 도메인을 바이패스 목록에 추가 |
| IDS 오탐(False Positive) | show ssl-inspect ids stats + 매칭 룰 ID 확인 | 해당 룰을 suppress 또는 threshold 조정 |
| 처리량 부족 | show runtime에서 노드별 사이클 확인 | 선택적 검사로 대상 트래픽 축소, 워커 수 증가 |
| 메모리 부족 | show memory verbose에서 세션 풀 확인 | preallocated-sessions을 이중 세션 고려하여 2배로 설정 |
SSL Inspection 실전 구현 가이드
현대 네트워크 트래픽의 90% 이상이 TLS로 암호화되어 있습니다. 방화벽이나 IDS/IPS가 암호화된 페이로드(Payload)를 검사하려면 SSL/TLS Inspection(SSL 가시성 확보)이 필수입니다. VPP는 유저스페이스에서 TLS 종단(Termination)과 재암호화(Re-encryption)를 수행할 수 있어, 커널 기반 프록시 대비 훨씬 높은 처리량으로 SSL Inspection을 구현할 수 있습니다.
이 절은 두 부분으로 나뉩니다. Part A: 기술 구현(원리·아키텍처·데이터 릴레이·성능)은 코드와 CLI 중심의 엔지니어링 참고 자료입니다. Part B: 운영과 거버넌스(배치 토폴로지·법적 컴플라이언스·감사 로그·트러블슈팅)는 보안 팀·운영 팀이 먼저 읽어야 할 정책과 런북입니다. 구현만 가지고 배포하면 법적·조직적 리스크가 커지므로, 두 파트를 모두 통과한 뒤에 실제 환경에 올리시기 바랍니다.
Part A — 기술 구현
SSL Inspection의 원리
SSL Inspection이 중간자 구조로서 "무엇을 하고 왜 필요한가"는 VPP on DPU 심화 문서 0번 소절에서 개념·Python 최소 구현·원리적 한계까지 이미 정리했고, 본 페이지의 SSL Inspection 아키텍처 개요에서도 VPP 관점에서 한 번 다루었습니다. 이 A 소절부터는 그 개념을 전제로, 실전 배포에서 실제로 부딪히는 구현 선택—VCL/VLS/LD_PRELOAD/memif 중 무엇을 고를지, CA 인프라를 어떻게 구성할지, 인라인/SFC/TLS 1.3 각 경로를 어떻게 분기할지—에 집중해서 설명합니다. 아래 아키텍처 그림은 그 선택들이 어디에 위치하는지를 한 장에 요약한 것입니다.
- VPP on DPU 11번 소절 — 세션 계층 배선:
src/vnet/session의 app listener/segment manager 위에 SSL Inspection 애플리케이션을 얹는 연결점,tls_ctx_t->sni를 채우는 순서, accept 콜백을 지연시키는 이유. - VPP on DPU 12번 소절 — 그래프 노드 워크스루: 복호화된 바이트가 실제로 거치는 노드 시퀀스와 벡터 단위 검사 훅의 부착 지점.
- VPP on DPU 6.5번 소절 — DPU↔Host 왕복 배치의 VCL+OpenSSL 구현: 아래 "직접 VCL" 방식을 DPU 맥락에서 확장한 완전 구현 골격(BIO 래퍼, SNI 콜백 내부의 업스트림 핸드셰이크, memif 기반 평문 릴레이).
VPP SSL Inspection 아키텍처
VPP에서 SSL Inspection을 구현하는 방식은 크게 두 가지입니다. 각각의 장단점을 이해해야 환경에 맞는 설계를 선택할 수 있습니다.
| 구현 방식 | 구조 | 장점 | 단점 | 적합 환경 |
|---|---|---|---|---|
| 인라인 프록시 | VPP 내 VCL + TLS 플러그인 | 단일 프로세스, 낮은 지연 | VPP 플러그인 개발 필요 | 고성능 NGFW |
| SFC 분리형 | VPP L4 분류 → memif → 외부 프록시 | 기존 프록시 재활용(Recycling), 유연 | memif 오버헤드(Overhead), 복잡도 | 기존 인프라 연동 |
VCL, VLS, LD_PRELOAD, memif 중 무엇을 선택할 것인가
SSL Inspection을 설계할 때 가장 먼저 정해야 하는 것은 "TLS를 어디서 종단할 것인가"가 아니라 "애플리케이션을 어떤 방식으로 VPP 호스트 스택에 붙일 것인가"입니다. 같은 TLS Inspection이라도 진입 방식에 따라 개발 난이도, 장애 격리(Isolation), 워커 배치, 멀티스레드 잠금(Lock) 경합(Contention) 양상이 크게 달라집니다.
| 방식 | 장점 | 위험 | 적합한 팀/환경 |
|---|---|---|---|
| 직접 VCL | 지연이 가장 낮고 TLS 세션·FIFO·이벤트 큐를 세밀하게 제어할 수 있습니다 | 코드 수정량이 많고, 자체 운영 런북을 새로 만들어야 합니다 | 전용 보안 어플라이언스, 자체 프록시 개발팀 |
| VLS 래핑 | 기존 멀티스레드 TCP 서버 구조를 상당 부분 유지할 수 있습니다 | 세션 공유가 많으면 락과 워커 간 clone/share RPC 비용이 커집니다 | 사내 프록시, 자체 C/C++ 서버를 단계적으로 이전하는 팀 |
| LD_PRELOAD | 가장 빠르게 파일럿을 만들 수 있습니다 | sendmsg(), fd 전달, 복잡한 소켓 옵션, 일부 라이브러리 훅 충돌이 문제될 수 있습니다 | nginx, curl, 간단한 TLS 리버스 프록시의 사전 검증 |
| memif + 외부 프록시 | 기존 Envoy, HAProxy, Squid를 재활용하기 쉽고 장애 격리가 좋습니다 | memif 왕복과 운영 복잡도가 추가됩니다 | 이미 프록시 인프라가 있고 빠르게 붙여야 하는 조직 |
# 기존 HAProxy/Envoy를 VPP 호스트 스택 위에서 빠르게 검증하는 파일럿 예시
$ export VCL_CONFIG=/etc/vpp/vcl.conf
$ export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libvcl_ldpreload.so
# 기존 설정 파일은 유지하고, 먼저 기능 호환성을 확인합니다
$ haproxy -f /etc/haproxy/haproxy.cfg
# 관찰 포인트
vpp# show session verbose
vpp# show runtime
vpp# show errors | grep -iE 'tls|session'
인라인 SSL Inspection 구현
VPP의 VCL(VPP Communication Library)과 TLS 플러그인을 활용하여 단일 VPP 프로세스 내에서 SSL Inspection을 구현하는 방법입니다. 핵심은 두 개의 독립적인 TLS 컨텍스트를 관리하는 것입니다.
# /etc/vpp/startup.conf — SSL Inspection용 구성
unix {
cli-listen /run/vpp/cli.sock
log /var/log/vpp/vpp.log
full-coredump
gid vpp
}
dpdk {
dev 0000:00:08.0 { name GigabitEthernet0/8/0 } /* LAN */
dev 0000:00:09.0 { name GigabitEthernet0/9/0 } /* WAN */
no-multi-seg
}
cpu {
main-core 0
corelist-workers 1-7 /* TLS 핸드셰이크가 CPU 집약적 */
}
buffers {
buffers-per-numa 65536 /* TLS 레코드 버퍼링 위해 증가 */
default data-size 2048
}
session {
evt_qs_memfd_seg /* 세션 이벤트 큐 공유 메모리 */
event-queue-length 100000 /* 동시 세션 수에 비례 */
}
plugins {
plugin default { disable }
plugin dpdk_plugin.so { enable }
plugin tlsopenssl_plugin.so { enable }
plugin http_plugin.so { enable }
plugin nat_plugin.so { enable }
plugin acl_plugin.so { enable }
plugin ping_plugin.so { enable }
}
CA 인증서 인프라 구성
SSL Inspection의 기반은 내부 CA(Certificate Authority)입니다. 검사기가 원본 서버의 인증서 정보를 복사하여 이 CA로 동적 서명한 인증서를 클라이언트에 제시합니다.
# 1. 내부 Root CA 키 쌍 생성
$ openssl ecparam -genkey -name prime256v1 -out inspection-ca.key
$ openssl req -new -x509 -key inspection-ca.key -sha256 -days 3650 \
-out inspection-ca.crt \
-subj "/CN=Internal SSL Inspection CA/O=MyOrg/C=KR"
# 2. VPP TLS에서 사용할 서버 키 (동적 인증서 서명용)
$ openssl ecparam -genkey -name prime256v1 -out inspection-server.key
# 3. 클라이언트(브라우저)에 CA 인증서 배포
# Linux: /usr/local/share/ca-certificates/ 에 복사 후 update-ca-certificates
# Windows: GPO로 Trusted Root CA에 배포
# macOS: Keychain Access에서 "항상 신뢰"로 추가
# 4. CKPAIR 등록은 CLI가 아니라 바이너리 API 경로입니다.
# 제어 평면(GoVPP/PAPI/자체 C 앱)에서 다음과 같이 주입합니다.
# vl_api_app_add_cert_key_pair_t { certkey = cert||key }
# -> 반환 index를 SNI별 라우팅 테이블에 저장
0600으로 제한하세요. CA 인증서의 유효기간도 운영 정책에 맞게 설정해야 합니다.
동적 인증서 생성 구현
SSL Inspection의 핵심 기술은 원본 서버 인증서의 Subject/SAN을 복제하여 내부 CA로 즉시 서명하는 것입니다. 이 과정이 TLS 핸드셰이크 지연의 가장 큰 비중을 차지합니다.
/* SSL Inspection 플러그인: 동적 인증서 생성 핵심 로직 */
/* OpenSSL API를 사용한 구현 예시 */
static X509 *
generate_inspection_cert (X509 *orig_cert,
EVP_PKEY *ca_key,
X509 *ca_cert)
{
X509 *cert = X509_new ();
X509_NAME *subj = X509_get_subject_name (orig_cert);
/* 원본 인증서에서 Subject 복사 */
X509_set_subject_name (cert, subj);
/* Issuer를 내부 CA로 설정 */
X509_set_issuer_name (cert, X509_get_subject_name (ca_cert));
/* 원본의 SAN(Subject Alternative Name) 복사 — 필수! */
int san_idx = X509_get_ext_by_NID (orig_cert, NID_subject_alt_name, -1);
if (san_idx >= 0)
{
X509_EXTENSION *san_ext = X509_get_ext (orig_cert, san_idx);
X509_add_ext (cert, san_ext, -1);
}
/* 일련번호, 유효기간 설정 */
ASN1_INTEGER_set (X509_get_serialNumber (cert),
generate_unique_serial ());
X509_gmtime_adj (X509_get_notBefore (cert), 0);
X509_gmtime_adj (X509_get_notAfter (cert), 86400 * 365);
/* 검사기 서버의 공개키 설정 */
X509_set_pubkey (cert, inspection_server_key);
/* 내부 CA 개인키로 서명 */
X509_sign (cert, ca_key, EVP_sha256 ());
return cert;
}
/* 캐시: 동일 서버에 대한 반복 생성 방지 */
typedef struct {
u8 *server_name; /* SNI 또는 Subject CN */
X509 *cached_cert; /* 생성된 위조 인증서 */
f64 created_at; /* 캐시 생성 시각 */
f64 ttl; /* 캐시 유효 시간 */
} ssl_cert_cache_entry_t;
SNI 기반 바이패스와 정책 분류
모든 TLS 트래픽을 복호화하는 것은 성능 낭비이자 법적 리스크입니다. SNI(Server Name Indication)를 기반으로 검사 대상과 바이패스 대상을 분류해야 합니다. SNI는 TLS ClientHello의 확장 필드에 평문으로 포함되므로, TLS 세션을 맺기 전에 읽을 수 있습니다.
/* SNI 파싱: ClientHello에서 서버 이름 추출 */
static int
parse_sni_from_client_hello (u8 *data, u32 len,
u8 **sni_out, u16 *sni_len_out)
{
/* TLS Record: ContentType(1) + Version(2) + Length(2) */
if (len < 5 || data[0] != 0x16) /* 0x16 = Handshake */
return -1;
/* Handshake: HandshakeType(1) + Length(3) */
u8 *hs = data + 5;
if (hs[0] != 0x01) /* 0x01 = ClientHello */
return -1;
/* ClientHello: Version(2) + Random(32) + SessionID(var)
* + CipherSuites(var) + Compression(var) + Extensions(var) */
u32 offset = 5 + 4; /* record header + handshake header */
offset += 2 + 32; /* version + random */
offset += 1 + data[offset]; /* session ID length + data */
u16 cs_len = (data[offset] << 8) | data[offset + 1];
offset += 2 + cs_len; /* cipher suites */
offset += 1 + data[offset]; /* compression */
/* Extensions 순회 → SNI(type=0x0000) 찾기 */
u16 ext_total = (data[offset] << 8) | data[offset + 1];
offset += 2;
u32 ext_end = offset + ext_total;
while (offset + 4 <= ext_end)
{
u16 ext_type = (data[offset] << 8) | data[offset + 1];
u16 ext_len = (data[offset + 2] << 8) | data[offset + 3];
offset += 4;
if (ext_type == 0x0000) /* SNI extension */
{
/* SNI list: ListLen(2) + Type(1) + NameLen(2) + Name */
u16 name_len = (data[offset + 3] << 8) | data[offset + 4];
*sni_out = data + offset + 5;
*sni_len_out = name_len;
return 0;
}
offset += ext_len;
}
return -1; /* SNI 없음 (IP 직접 연결 등) */
}
SFC 분리형: VPP + 외부 SSL 프록시
기존 프록시(Squid SSL Bump, mitmproxy 등)를 재활용하면서 VPP의 고성능 패킷 분류만 활용하는 구조입니다. VPP가 TLS 트래픽을 식별하여 memif로 외부 프록시에 전달하고, 검사 완료 후 다시 VPP로 복귀시킵니다.
# VPP: TLS 트래픽 분류 및 memif 전달 구성
# 1. SSL 프록시용 memif 생성
vpp# create memif socket id 10 filename /run/vpp/memif-ssl-proxy.sock
vpp# create interface memif id 0 socket-id 10 master ring-size 2048
vpp# set interface state memif10/0 up
vpp# set interface ip address memif10/0 10.255.0.1/30
# 2. 프록시로부터 복귀용 memif
vpp# create memif socket id 11 filename /run/vpp/memif-ssl-return.sock
vpp# create interface memif id 0 socket-id 11 master ring-size 2048
vpp# set interface state memif11/0 up
vpp# set interface ip address memif11/0 10.255.1.1/30
# 3. ACL로 TCP 443 트래픽 분류 → 프록시로 라우팅
vpp# set acl-plugin acl permit+reflect proto 6 dport 443
vpp# set acl-plugin interface GigabitEthernet0/8/0 input acl 0
# 4. PBR(Policy-Based Routing): TCP 443 매칭 시 프록시로 전달
vpp# ip table add 100
vpp# ip route add 0.0.0.0/0 table 100 via 10.255.0.2 memif10/0
# classify 테이블로 TLS 트래픽을 FIB 100으로 전환
vpp# classify table mask l3 ip4 proto l4 dst_port
vpp# classify session table-index 0 match l3 ip4 proto 6 l4 dst_port 443 \
action set-ip4-fib-id 100
# 5. 프록시에서 복귀한 트래픽은 기본 FIB로 WAN 전달
vpp# ip route add 0.0.0.0/0 via 203.0.113.254 GigabitEthernet0/9/0
# 외부 프록시 측: libmemif를 사용한 Squid SSL Bump 연동
# 또는 mitmproxy 기반 간이 구성
# mitmproxy 실행 (memif 대신 TAP으로 연결하는 간이 예시)
$ sudo ip link set vpp-proxy up
$ sudo ip addr add 10.255.0.2/30 dev vpp-proxy
$ sudo ip route add default via 10.255.0.1 dev vpp-proxy
# mitmproxy를 투명 프록시 모드로 실행
$ mitmproxy --mode transparent --listen-host 10.255.0.2 --listen-port 8443 \
--set ssl_insecure=true \
--set confdir=/etc/mitmproxy \
-s inspection_policy.py
# iptables로 투명 리다이렉트 (프록시 호스트에서)
$ sudo iptables -t nat -A PREROUTING -i vpp-proxy -p tcp --dport 443 \
-j REDIRECT --to-port 8443
TLS 1.3 Inspection 고려사항
TLS 1.3은 핸드셰이크 구조가 크게 바뀌어 SSL Inspection 구현에 영향을 줍니다.
| 변경점 | TLS 1.2 | TLS 1.3 | Inspection 영향 |
|---|---|---|---|
| 핸드셰이크 왕복 | 2-RTT | 1-RTT (0-RTT 가능) | 인증서 위조 시간 압박 증가 |
| 서버 인증서 위치 | 평문 전송 | 암호화 후 전송 | 검사기가 먼저 서버와 핸드셰이크 완료해야 원본 인증서 확인 가능 |
| 세션 재개 | Session ID/Ticket | PSK (0-RTT) | 0-RTT 데이터는 재생 공격 위험 → 검사기에서 0-RTT 차단 또는 제한 필요 |
| 암호 스위트 | 다양 (RC4, CBC 등) | AEAD만 (GCM, ChaCha20) | 오래된 암호 스위트 다운그레이드 불가 → 검사기도 AEAD 지원 필수 |
| SNI 암호화 (ECH) | 없음 | 선택적 (초안) | ECH 활성 시 SNI 파싱 불가 → DNS 기반 정책으로 전환 필요 |
/* TLS 1.3 핸드셰이크 시 Inspection 순서 */
/* 1. ClientHello 수신 → SNI 추출 + 바이패스 판단 */
/* 2. 검사 대상이면: 검사기가 원본 서버에 먼저 TLS 1.3 연결 */
/* 3. 서버의 EncryptedExtensions + Certificate 수신 (복호화 후) */
/* 4. 원본 인증서 정보로 위조 인증서 생성 */
/* 5. 클라이언트에게 위조 인증서로 ServerHello 응답 */
static int
tls13_inspection_handshake (ssl_inspect_ctx_t *ctx)
{
/* 서버측 TLS 1.3 핸드셰이크 먼저 완료 */
int rv = SSL_connect (ctx->server_ssl);
if (rv != 1)
return handle_ssl_error (ctx, rv);
/* 서버 인증서 체인 가져오기 */
X509 *server_cert = SSL_get_peer_certificate (ctx->server_ssl);
if (!server_cert)
return -1;
/* 원본 인증서 검증 (OCSP, CRL 확인) */
long verify_result = SSL_get_verify_result (ctx->server_ssl);
if (verify_result != X509_V_OK)
{
/* 원본 서버 인증서 검증 실패 → 정책에 따라 차단 또는 경고 */
log_cert_error (ctx, verify_result);
if (ctx->policy & SSL_INSPECT_BLOCK_INVALID_CERT)
return -1;
}
/* 동적 인증서 생성 (캐시 우선 확인) */
X509 *forged = cert_cache_lookup (ctx->sni);
if (!forged)
{
forged = generate_inspection_cert (server_cert,
ctx->ca_key, ctx->ca_cert);
cert_cache_insert (ctx->sni, forged, CERT_CACHE_TTL);
}
/* 클라이언트측 TLS 컨텍스트에 위조 인증서 설정 */
SSL_use_certificate (ctx->client_ssl, forged);
SSL_use_PrivateKey (ctx->client_ssl, ctx->server_key);
/* 클라이언트 핸드셰이크 완료 */
return SSL_accept (ctx->client_ssl);
}
SSL_CTX_set_max_early_data(ctx, 0)으로 0-RTT를 비활성화하는 것이 안전합니다.
양방향 데이터 릴레이 구현
핸드셰이크가 완료되면 검사기는 양쪽 TLS 세션의 평문 데이터를 릴레이하면서, 중간에 DPI 엔진을 통과시킵니다. VPP의 세션 레이어를 활용하면 이 릴레이를 이벤트 기반으로 효율적으로 구현할 수 있습니다.
/* 양방향 릴레이 핵심 로직 — VCL 세션 기반 */
static void
ssl_inspect_relay_data (ssl_inspect_ctx_t *ctx)
{
u8 buf[16384]; /* TLS 레코드 최대 크기 */
int n;
/* 클라이언트 → 서버 방향 */
while ((n = SSL_read (ctx->client_ssl, buf, sizeof (buf))) > 0)
{
/* DPI 엔진에 평문 전달 */
dpi_verdict_t verdict = dpi_inspect (ctx->dpi_ctx,
buf, n,
DPI_DIR_CLIENT_TO_SERVER);
if (verdict == DPI_BLOCK)
{
/* 차단: 클라이언트에 블록 페이지 전송 후 세션 종료 */
send_block_page (ctx->client_ssl, ctx->block_reason);
ssl_inspect_close (ctx);
return;
}
/* 허용: 원본 서버로 재암호화하여 전달 */
int written = SSL_write (ctx->server_ssl, buf, n);
if (written <= 0)
{
handle_ssl_error (ctx, written);
return;
}
}
/* 서버 → 클라이언트 방향 (대칭) */
while ((n = SSL_read (ctx->server_ssl, buf, sizeof (buf))) > 0)
{
dpi_verdict_t verdict = dpi_inspect (ctx->dpi_ctx,
buf, n,
DPI_DIR_SERVER_TO_CLIENT);
if (verdict == DPI_BLOCK)
{
ssl_inspect_close (ctx);
return;
}
SSL_write (ctx->client_ssl, buf, n);
}
}
SSL Inspection 성능 고려사항
SSL Inspection은 TLS 핸드셰이크(특히 RSA/ECDSA 연산)와 대칭 암호화/복호화를 이중으로 수행하므로, 일반 패킷 포워딩 대비 CPU 부담이 매우 큽니다.
| 구성 요소 | CPU 비용 (상대) | 최적화 방법 |
|---|---|---|
| RSA-2048 서명 (인증서 생성) | 매우 높음 | ECDSA P-256 전환 (10배 빠름), 인증서 캐싱 |
| TLS 핸드셰이크 | 높음 | 세션 재개(PSK), QAT 비동기 오프로드 |
| AES-GCM 대칭 암복호화 | 중간 | AES-NI 활용 (기본 활성), QAT |
| DPI 시그니처 매칭 | 가변 (룰 수 의존) | Hyperscan/Vectorscan 가속, 룰 최적화 |
| 인증서 검증 (OCSP/CRL) | 네트워크 의존 | OCSP Stapling, 로컬 CRL 캐싱 |
# SSL Inspection 성능 모니터링
# TLS 핸드셰이크 횟수 및 소요 시간 확인
vpp# show tls
# 출력 예시:
# OpenSSL engine:
# Ctx allocated: 45231
# Handshakes: 12847 (active: 23)
# Rx bytes: 4.2 GB
# Tx bytes: 1.8 GB
# 세션 레이어 상태 (동시 세션 수)
vpp# show session verbose
# Transport Active Listening
# tcp 2341 4
# tls 1892 2
# 워커별 부하 분포 확인
vpp# show runtime
# session-queue 노드의 Vectors/Call이 워커 간 균등해야 합니다.
# 편향이 심하면 TCP 해시 분산(RSS/Flow Director) 조정 필요
# 인증서 캐시 적중률 (커스텀 플러그인 시)
# 적중률 90% 이상이면 양호, 50% 이하면 캐시 크기 확대 필요
| 구성 | 동시 세션 | 신규 핸드셰이크/초 | 처리량 (Gbps) | CPU 코어 |
|---|---|---|---|---|
| VPP + OpenSSL (SW) | 50K | ~2,000 CPS | ~5 | 8 |
| VPP + OpenSSL + QAT | 100K | ~8,000 CPS | ~12 | 4 |
| 커널 Squid SSL Bump | 10K | ~500 CPS | ~1 | 8 |
TCP 병목과 TLS 병목을 분리하는 운영 런북
SSL Inspection은 TCP와 TLS가 한꺼번에 보이기 때문에 문제를 잘못 분류하기 쉽습니다. 예를 들어 "HTTPS가 느리다"는 증상 하나만 보면 네트워크 경로, 핸드셰이크 CPU, 인증서 캐시 미스, 워커 편향, FIFO 고갈이 모두 같은 문제처럼 보입니다. 운영에서는 반드시 TCP 계층과 TLS 계층의 병목을 분리해야 합니다.
| 증상 | 우선 의심할 계층 | 확인 항목 | 조치 |
|---|---|---|---|
| 새 연결 수만 급감합니다 | TLS 핸드셰이크 | show tls active handshake 수, 인증서 캐시 히트율 | ECDSA CA, 세션 재개, 인증서 캐시 확대, QAT 비동기 오프로드를 적용합니다 |
| 연결은 많지만 대역폭(Bandwidth)이 안 나옵니다 | TCP 전송 | 재전송(Retransmission), RTT 상승, FIFO 사용률, 워커 편향 | RSS 재분배, rx/tx-fifo-size 조정, NIC 큐 수와 워커 핀닝을 재점검합니다 |
| 한쪽 방향만 유독 느립니다 | TCP 또는 재암호화 측 TLS | 클라이언트 측/서버 측 TLS 컨텍스트 처리 시간, ACK 편향 | 양방향 워커 분산과 backend 방향 인증서 검증 지연을 따로 봅니다 |
| CPU는 높지만 Gbps는 낮습니다 | TLS 제어 경로 | RSA/ECDSA 서명 비중, SNI 정책 엔진(Policy Engine), 동적 인증서 생성 | RSA에서 ECDSA 전환, 동적 인증서 캐시, 정책 매칭 단순화가 먼저입니다 |
| 메모리만 빠르게 줄어듭니다 | 세션/FIFO/TLS 컨텍스트 | 동시 세션 수, FIFO 크기, 인증서 캐시, 이중 TLS 세션 개수 | 세션 산정식을 다시 계산하고, inspection 워크로드만 별도 프로세스로 분리합니다 |
운영 지표도 계층별로 나눠 보는 것이 좋습니다. TCP 관점에서는 RTT, 재전송, FIFO 포화, 워커별 session-queue 부하를 봐야 하고, TLS 관점에서는 핸드셰이크 CPS, 세션 재개율, 인증서 생성 지연, ALPN 선택, mTLS 실패 건수를 봐야 합니다. 둘을 한 그래프에 섞으면 병목 원인이 흐려집니다.
Part B — 운영과 거버넌스
아래부터는 기술 구현을 어떤 토폴로지·법적 근거·감사 체계 위에 올려 놓을지 다루는 절입니다. 배치 형태(배치 토폴로지), SNI/ALPN 바이패스 정책, 법적 근거 체크리스트, 트러블슈팅 런북이 여기 모여 있습니다. 기술 구현 파트(Part A)에서 결정한 구조가 여기의 운영 제약과 충돌하면 구현을 다시 조정해야 합니다.
SSL Inspection 트러블슈팅
| 증상 | 진단 | 원인 | 해결 |
|---|---|---|---|
| 브라우저 인증서 경고 | 브라우저에서 인증서 체인 확인 | 내부 CA가 신뢰 저장소에 미설치 | GPO/MDM으로 CA 인증서 배포 |
| 특정 앱 연결 실패 | 앱의 인증서 요구사항 확인 | Certificate Pinning | 해당 도메인을 바이패스 목록에 추가 |
| 핸드셰이크 타임아웃 | show tls active 핸드셰이크 수 | 서버 연결 지연 또는 인증서 생성 병목 | ECDSA CA 전환, QAT 추가, 타임아웃 조정 |
| CPS 저하 | show runtime session-queue 클럭 | RSA 서명 CPU 부하 | 인증서 캐시 확대, CA 키를 ECDSA로 변경 |
| QUIC/HTTP3 통과 안됨 | UDP 443 트래픽 확인 | QUIC은 UDP 기반 → TCP 분류기 미매칭 | UDP 443도 분류 대상에 추가, 또는 QUIC 차단 후 TCP 폴백 유도 |
| mTLS 서비스 장애 | 서버가 클라이언트 인증서 요구 | 검사기에 원본 클라이언트 인증서 없음 | mTLS 대상을 바이패스, 또는 클라이언트 인증서 릴레이 구현 |
SSL Inspection 배치 토폴로지 — 투명·명시·게이트웨이·DLP 연동
SSL Inspection은 어디에 놓이느냐에 따라 완전히 다른 설계가 됩니다. 네트워크 구조, 조직 보안 정책, 인증서 배포 가능 범위에 따라 아래 네 가지 토폴로지 중 하나 이상을 혼합 운영합니다. 각 패턴의 VPP 설정 포인트와 걸림돌을 함께 정리했습니다.
| 토폴로지 | 위치 | 인증서 배포 요건 | 장점 | 단점 |
|---|---|---|---|---|
| 투명(Transparent) | 인라인, 클라이언트 기본 게이트웨이 | MDM으로 전 기기 루트 CA 배포 | 클라이언트 프록시 설정 불필요, 강제 적용 가능 | ECH·인증서 피닝 앱에서 실패, QUIC 우회 위험 |
| 명시적(Explicit HTTP CONNECT) | 클라이언트가 프록시 주소 지정 | 루트 CA 배포 + PAC/WPAD | 트래픽 분류 명확, 실패 시 명시적 오류 | 사용자가 프록시를 우회할 수 있음 |
| 게이트웨이(Enterprise SWG) | 데이터센터 egress | 관리 장비에만 CA 신뢰 | 관리 트래픽만 선별 검사, 법적 리스크 낮음 | 원격 근무자에 적용 어려움 |
| DLP 사이드카 | VPP가 평문만 memif로 전달 | 전통적 CA + DLP 벤더 통합 | DLP/IDS 기존 투자 재사용 | 추가 홉, 평문이 사이드카까지 흐름 |
투명 토폴로지 VPP 설정
클라이언트 HTTPS를 강제로 가로채려면 투명 L2 브리지(Bridge) 또는 L3 라우팅으로 VPP가 해당 트래픽을 받게 한 뒤, 사용자 공간(User Space) Inspect 애플리케이션이 443번을 listen하면서 목적지 재작성(destination NAT 또는 세션 레이어 레벨 투명 리스너)으로 연결을 가로챕니다. 공식 VPP에 tcp-intercept add나 tls ca-signer add, tls dynamic-cert-cache size 같은 전용 CLI는 존재하지 않습니다 — 아래는 역할 수준의 개요이며, 실제 구현은 커스텀 플러그인/앱이 담당합니다.
# 기본 네트워크 구성 — LAN 인터페이스 준비
vpp# set interface l3 GigabitEthernet0/8/0
vpp# set interface state GigabitEthernet0/8/0 up
vpp# set interface ip address GigabitEthernet0/8/0 192.168.10.1/24
# 443 트래픽을 Inspect 애플리케이션으로 보내는 경로는 두 가지입니다:
# (A) VPP NAT 또는 policy route로 목적지를 로컬 세션으로 재작성
# (B) Inspect 앱이 transparent listener로 직접 수신 (세션 레이어 설정)
# 어느 쪽이든 'tcp-intercept add' 같은 일체형 CLI는 없습니다.
/* 동적 인증서 발급에 쓰이는 issuer CA는 Inspect 애플리케이션 코드가
OpenSSL로 직접 로드하고, 즉석에서 leaf 인증서를 서명해 BAPI로
app_add_cert_key_pair에 주입합니다. 캐시 크기·TTL은 애플리케이션 내부
파라미터이며, VPP CLI로 노출되지 않습니다. */
EVP_PKEY *issuer_key = load_pem_privkey ("/etc/vpp/issuer.key");
X509 *issuer_crt = load_pem_cert ("/etc/vpp/issuer.pem");
u32 ckpair_for_sni (const char *sni) {
u32 *cached = lru_lookup (&cache, sni);
if (cached) return *cached;
X509 *leaf = sign_leaf_for (issuer_crt, issuer_key, sni);
u32 idx = bapi_add_cert_key_pair (x509_to_pem (leaf), privkey_to_pem (leaf));
lru_insert (&cache, sni, idx);
return idx;
}
동적 인증서 캐시는 TTL을 짧게 잡지 마시기 바랍니다. 실사용 환경에서 하루 수천 도메인이 등장하므로 캐시 히트율이 50 % 이하면 발급 비용이 폭발합니다. 기본 1시간 TTL + 8K 엔트리로 시작해 히트율을 보면서 조정하시기 바랍니다. 이 파라미터는 애플리케이션 코드 안에서 결정되는 값입니다.
명시적 CONNECT 구성
HTTP CONNECT host:443으로 요청한 뒤 VPP가 터널을 수립하고 그 터널 안에서 TLS 핸드셰이크를 가로챕니다. 이미 본 문서의 명시적 HTTP CONNECT 프록시 구현 절에 기본 코드가 있으므로, 여기서는 운영 관점의 팁만 보충합니다.
- PAC 파일에 우회 목록을 직접 기술해 금융·의료·인증서 피닝 앱을 proxy bypass로 분리합니다. 서버에서 걸러내는 것보다 훨씬 가볍습니다.
- 기업망에서는 NTLM/Kerberos 인증과 결합해 사용자별 감사로그를 남깁니다. VPP 자체는 인증 프로토콜을 지원하지 않으므로, 전방에 Squid/ICAP 스터브를 두거나 VPP 앞단에 별도 인증 프록시를 두시기 바랍니다.
- 프록시 URL은 HTTPS(
https://proxy.corp:3128)로 제공하는 것이 좋습니다. 평문 HTTP 프록시는 중간자에 취약합니다.
인증서 피닝·공공 CT·ECH 우회 전략
모던 앱은 다양한 방식으로 MITM에 저항합니다. 모두가 완벽히 막히지는 않지만, 어느 정도 작동하는지 알고 있어야 설계가 가능합니다.
| 저항 기법 | 동작 | 탐지 방법 | 대응 |
|---|---|---|---|
| Public Key Pinning (HPKP, 모바일 앱) | 미리 알고 있는 공개 키 해시와 비교 | TLS 핸드셰이크는 성공하나 앱 내부에서 연결 거부 | 해당 앱은 반드시 바이패스 |
| Certificate Transparency (CT) 검증 | SCT가 없는 인증서 거부 | 브라우저 콘솔에 NET::ERR_CERTIFICATE_TRANSPARENCY_REQUIRED | 내부 CA에 대해서는 CT 예외 정책 배포 |
| Encrypted ClientHello (ECH) | SNI를 암호화해 라우팅 불가 | ClientHello의 encrypted_client_hello 확장 | ECHConfig 배포해 선제적 복호, 불가 시 바이패스 |
| DoH/DoT | DNS 자체가 암호화되어 IP만 보임 | 포트 853/443 outbound | 내부 DoH 리졸버 강제, 외부 직접 쿼리 차단 |
| QUIC/HTTP3 | TCP 기반 가로채기 불가 | UDP 443 트래픽 급증 | UDP 443 차단 → HTTP/2 폴백 유도 |
DLP/ICAP/ClamAV 연동 — 복호화 후 파이프라인
평문을 얻은 뒤에는 이를 검사 엔진에 보내야 합니다. VPP에서 가장 흔한 3가지 연동 방식은 다음과 같습니다.
- ICAP over memif: VPP가
memif로 평문 스트림을 밖으로 빼고, ICAP 서버(예: c-icap + ClamAV)가 REQMOD/RESPMOD를 처리한 뒤 다시 VPP로 되돌립니다. 지연이 가장 낮지만 ICAP 구현 품질에 좌우됩니다. - 소켓 미러링: 플러그인이 복호화된 바이트를 별도 VLS 세션으로 복제해 검사 엔진으로 보냅니다. 검사 엔진은 통과·차단만 결정하고 반환 데이터를 수정하지 못합니다. DLP에 적합합니다.
- 커널 사이드카: 평문을 AF_UNIX 또는 abstract namespace 소켓으로 Linux 프로세스에 전달. 전통적인 Suricata·Zeek 등을 재사용할 때 편합니다.
/* 미러링 샘플 — 수신 시 검사 엔진에도 동시에 write */
static void
forward_with_mirror (session_t *client, session_t *upstream, session_t *ids, u8 *buf, u32 n)
{
(void) session_tx (upstream, buf, n); /* 실서비스 */
(void) session_tx (ids, buf, n); /* IDS 사이드카 — best effort */
}
미러 경로는 best effort로 취급하시기 바랍니다. IDS 사이드카가 느리다고 실서비스를 막으면 사용자 체감 품질이 떨어집니다. 실질 차단이 필요한 시나리오(DLP 차단)에서는 미러가 아니라 정방향 릴레이 구조로 바꿔, IDS 응답을 기다린 뒤 업스트림으로 흘려보내야 합니다.
감사 로그·법적 컴플라이언스
SSL Inspection은 기술 문제 이전에 거버넌스 문제입니다. 운영팀은 다음 로그 항목을 기본으로 남겨야 합니다.
- 클라이언트 IP, 사용자 식별자(있을 경우), 타임스탬프
- 목적지 SNI, IP, ALPN, 바이패스 결정 여부와 사유
- 인증서 체인 해시(피닝 앱 판별용)
- 검사 결과 — 통과/차단/경고, 연동 엔진 식별자
# VPP elog + syslog 연동 예 — 한 줄로 감사 로그 생성
vpp# set logging class tls level info
vpp# set logging class session level info
vpp# set logging syslog-sender 10.0.0.5
# 운영 로그 조회
vpp# show log | grep -i inspection
set logging syslog-sender·set logging unix-sink 등으로 달라져 왔습니다. 현재 사용 중인 VPP 버전에서 ? logging으로 사용 가능한 하위 명령을 먼저 확인하시기 바랍니다. 이 문서의 표기는 대표적인 형태이며 그대로 복사해 쓰실 수 있는 명령어가 아닙니다.
SSL Inspection 기능 검증 체크리스트
| 테스트 | 명령 / 도구 | 기대 결과 |
|---|---|---|
| 루트 CA 신뢰 확인 | openssl s_client -connect example.com:443 -servername example.com | 체인 최상위가 내부 CA, 오류 없음 |
| 바이패스 대상 확인 | 뱅킹 앱 / 금융 사이트 실행 | 앱 정상 동작, 로그에 bypass 표시 |
| QUIC 폴백 강제 | curl --http3 https://www.example.com | 타임아웃 후 h2로 재접속 |
| ECH 대응 | ECH 활성 브라우저로 접근 | 폴백 또는 명시적 바이패스 |
| 성능 부하 | wrk -c 1000 -d 60s https://test.local/ | CPU·핸드셰이크/s·SSL fifo 카운터 안정 |
| 감사 로그 적재 | 로그 수집 시스템에서 조회 | 사용자 식별·SNI·결정 모두 기록 |
| 인증서 피닝 앱 | 대상 앱 테스트 | 자동으로 bypass 또는 명시적 차단 알림 |