WireGuard
Linux 커널 WireGuard VPN을 1차 자료 기준으로 다시 정리합니다. Noise_IKpsk2 핸드셰이크, AllowedIPs 기반 Cryptokey Routing, per-CPU 암복호화 큐와 per-peer NAPI/GRO, wg/wg-quick·fwmark·namespace 운용, MTU·NAT·로밍·제한사항·디버깅 절차까지 실제 운영 관점에서 설명합니다.
핵심 요약
- Peer — 공개키로 식별되는 원격 상대입니다.
- AllowedIPs — 송신 시 목적지 선택, 수신 시 소스 검증을 동시에 담당하는 규칙입니다.
- Endpoint — 실제 암호문 UDP 패킷을 주고받는 IP:포트입니다.
- Handshake — 세션 키를 만드는 1-RTT 교환이며, 첫 데이터 패킷이 키 확인 역할도 합니다.
- PersistentKeepalive — NAT나 상태 기반 방화벽 매핑을 유지하려고 보내는 주기적 빈 인증 패킷입니다.
단계별 이해
- 공개키와 주소 대역을 짝지어 생각하기
먼저 “이 피어는 어떤 IP 대역을 대표하는가”를 정합니다. - 터널과 경로를 분리해서 보기
wg는 장치 상태를 바꾸고,wg-quick은 주소·라우트·DNS·훅을 추가합니다. - 핸드셰이크와 데이터 경로를 구분하기
핸드셰이크 실패인지, 핸드셰이크는 됐지만 AllowedIPs/MTU가 틀린 것인지 따로 봐야 합니다. - 문제는 카운터로 확인하기
wg show의 latest handshake, endpoint, transfer가 가장 먼저 볼 지표입니다.
drivers/net/wireguard/ 경로가 유지됩니다.
같은 날짜 기준으로 WireGuard의 공식 기준 문서는 IETF 표준 RFC가 아니라 WireGuard 공식 프로토콜 문서와 기술 백서입니다.
WireGuard가 다른 VPN과 다른 점
WireGuard는 “복잡한 협상형 VPN”보다 “작은 커널 네트워크 장치”에 가깝습니다. 인터페이스 이름은 wg0, wg1처럼 보이지만 내부적으로는 공개키, AllowedIPs 프리픽스 트라이, UDP 소켓, 세션 키, 타이머, 작업 큐를 묶어 둔 L3 전용 네트워크 장치입니다. 데이터 평면은 커널 안에 있고, 제어 평면은 Generic Netlink로 설정됩니다.
공식 Quick Start 문서가 강조하듯 WireGuard는 유휴 상태에서는 최대한 조용하게 동작합니다. 평문 IP 패킷을 보낼 이유가 없으면 핸드셰이크도, keepalive도 필요할 때만 발생합니다. 이 점은 “항상 시끄러운 터널”을 기대하는 운영자에게는 낯설 수 있지만, WireGuard를 제대로 이해하려면 가장 먼저 잡아야 하는 특징입니다.
| 항목 | WireGuard | IPSec / xfrm | OpenVPN |
|---|---|---|---|
| 데이터 평면 위치 | 커널 L3 netdevice | 커널 xfrm / 정책 엔진 | 주로 유저스페이스 tun/tap |
| 핵심 키 합의 | Noise_IKpsk2 기반 1-RTT | 대개 IKEv2 | TLS 기반 |
| 암호 알고리즘 협상 | 없음, 고정 스위트 | 있음 | 있음 |
| 정책 모델 | 공개키 + AllowedIPs | SPD/SAD와 터널 정책 | 서버/클라이언트 옵션과 TLS 상태 |
| 로밍 | 인증된 패킷의 최신 소스 주소로 자동 갱신 | 구현과 확장(MOBIKE)에 따라 다름 | 재연결 또는 상위 로직 필요 |
| NAT 친화성 | 단일 UDP 플로우, Keepalive 지원 | ESP / UDP 캡슐화 조합에 따라 달라짐 | TCP/UDP 선택 가능하지만 유저스페이스 비용 존재 |
| L2 브리징 | 기본 제공 안 함, IP 전용 | 추가 오버레이 필요 | TAP 모드 가능 |
| 운영 표면 | wg, wg-quick, Netlink |
ip xfrm, IKE 데몬, PKI |
프로세스/서비스 설정 파일 |
암호 스위트와 키 재료
WireGuard는 암호 민첩성(ciphersuite agility)을 의도적으로 포기합니다. 이유는 단순합니다. 협상 단계가 있으면 구현 복잡도와 다운그레이드 면적이 같이 늘어나기 때문입니다. 그래서 WireGuard는 고정된 현대적 스위트를 사용하고, 그 위에 공개키 기반 인증과 선택적 사전공유키(PSK)를 얹습니다.
| 용도 | 알고리즘 | 운영상 의미 |
|---|---|---|
| ECDH | Curve25519 | 정적 공개키와 임시 공개키를 이용해 세션 비밀을 만듭니다. |
| 대칭 암호 + 인증 | ChaCha20-Poly1305 | 데이터 패킷을 AEAD로 보호합니다. 소프트웨어 구현 성능과 이식성이 좋습니다. |
| 해시 / KDF | BLAKE2s, HKDF-BLAKE2s | 핸드셰이크 체인 키와 세션 키를 유도합니다. |
| 해시테이블 키 | SipHash24 | 내부 해시테이블 키로 사용됩니다. 데이터 패킷 인증과는 역할이 다릅니다. |
| 선택적 추가 비밀 | PresharedKey | wg(8) 기준 선택 사항이며, 기존 공개키 교환 위에 추가 대칭 비밀을 섞어 후양자 저항성 관점의 여지를 더합니다. |
Noise 핸드셰이크와 패킷 형식
WireGuard 공식 프로토콜 문서는 Noise_IK를 기반으로 하고, 구성 이름으로는 Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s를 사용합니다. 여기서 핵심은 다음 세 가지입니다.
- 상대의 정적 공개키를 미리 알고 시작합니다.
- 핸드셰이크는 1-RTT로 끝납니다.
- 첫 번째 데이터 패킷이 키 확인(key confirmation) 역할도 겸합니다.
커널 소스의 패킷 구조를 기준으로 보면, 핸드셰이크 initiation은 148바이트, response는 92바이트, cookie reply는 64바이트입니다. 데이터 패킷은 message_data 헤더 16바이트와 AEAD 태그 16바이트 때문에 평문 대비 최소 32바이트가 늘어나고, 여기에 바깥쪽 UDP/IP 헤더까지 더하면 일반적인 MTU 계산에서 IPv4는 60바이트, IPv6는 80바이트 여유를 잡는 것이 안전합니다.
struct message_handshake_initiation {
struct message_header header;
__le32 sender_index;
u8 unencrypted_ephemeral[NOISE_PUBLIC_KEY_LEN];
u8 encrypted_static[noise_encrypted_len(NOISE_PUBLIC_KEY_LEN)];
u8 encrypted_timestamp[noise_encrypted_len(NOISE_TIMESTAMP_LEN)];
struct message_macs macs;
};
struct message_handshake_response {
struct message_header header;
__le32 sender_index;
__le32 receiver_index;
u8 unencrypted_ephemeral[NOISE_PUBLIC_KEY_LEN];
u8 encrypted_nothing[noise_encrypted_len(0)];
struct message_macs macs;
};
struct message_data {
struct message_header header;
__le32 key_idx;
__le64 counter;
u8 encrypted_data[];
};
여기서 특히 중요한 점은 데이터 패킷 헤더가 receiver_index가 아니라 key_idx를 사용한다는 점입니다. 수신자는 이 인덱스로 현재/이전 세션 키 쌍을 빠르게 찾고, 그다음 nonce 윈도우와 인증 태그를 검증합니다. 운영자가 “peer 식별자”처럼 이해하면 내부 동작을 잘못 추적하게 됩니다.
쿠키 기반 DoS 완화
WireGuard는 CPU를 많이 쓰는 DH 계산을 남발당하지 않도록 cookie 기반 DoS 완화를 둡니다. 모든 핸드셰이크 메시지는 먼저 mac1을 통과해야 하고, 서버가 과부하 상태이면 추가로 mac2까지 요구할 수 있습니다. 이때 서버는 즉시 키 교환을 진행하는 대신 cookie reply 패킷을 돌려주고, 다음 요청에서 올바른 cookie가 실린 mac2가 확인될 때만 본격적인 핸드셰이크를 처리합니다.
공식 프로토콜 설명 기준으로 cookie는 2분마다 바뀌는 서버 비밀과 송신자 IP 주소를 조합해 만듭니다. 즉, 소스 주소를 위조한 공격자는 cookie를 되돌려 받을 수 없고, 서버는 “실제로 그 IP를 가진 상대만” 다음 단계로 올릴 수 있습니다. 그래서 WireGuard는 “무조건 응답”이 아니라 “유효한 패킷에만 최소한으로 응답”하는 방향을 취합니다.
AllowedIPs: 라우팅이면서 ACL인 핵심 규칙
AllowedIPs는 WireGuard를 이해하는 데 가장 중요한 개념입니다. 이것은 단순한 “허용 목록”이 아닙니다. wg(8) 문서 그대로, 들어오는 트래픽에 대해 이 피어로부터 허용할 소스 프리픽스이면서 동시에 나가는 트래픽을 어느 피어로 보낼지 결정하는 목적지 프리픽스입니다.
커널 내부에서는 AllowedIPs를 프리픽스 트라이 형태로 유지하고, 송신 시에는 목적지 주소에 대해 최장 프리픽스 매칭(longest prefix match)을 수행합니다. 수신 시에는 복호화 후 내부 IP 헤더의 소스 주소가 실제 그 피어에 허용된 범위인지 다시 확인합니다. 즉, 라우팅 선택과 소스 검증이 한 자료구조로 연결됩니다.
| 예시 설정 | 의미 | 주의점 |
|---|---|---|
10.0.0.2/32 |
개별 호스트 하나를 해당 피어로 보냅니다. | 서버-클라이언트 1:1 매핑에서 가장 흔합니다. |
10.10.0.0/16 |
서브넷 전체를 그 피어 뒤에 있다고 가정합니다. | 사이트 간 터널에서 유용하지만 경로 충돌을 주의해야 합니다. |
0.0.0.0/0, ::/0 |
기본 경로를 터널로 보내는 전체 터널(full tunnel)입니다. | endpoint 재귀 라우팅을 피하려면 fwmark나 별도 예외 경로가 필요합니다. |
커널 내부 구조
WireGuard 장치는 일반 네트워크 장치처럼 등록되지만, Ethernet 장치가 아닙니다. 상류 커널의 wg_setup()는 장치 타입을 ARPHRD_NONE으로 두고, 플래그에 IFF_POINTOPOINT | IFF_NOARP를 사용합니다. 즉, ARP를 하는 L2 장치가 아니라 IP 패킷을 바로 받아서 암호화하는 L3 장치입니다.
또한 device.c를 보면 송신 엔트리 포인트는 .ndo_start_xmit = wg_xmit이고, 그 안에서 GSO skb는 먼저 분할한 뒤 peer별 staged queue에 넣습니다. 수신 쪽은 data 메시지를 복호화 워커가 처리하고, 검증이 끝난 평문 skb를 napi_gro_receive(&peer->napi, skb)로 네트워크 스택에 되돌립니다. 따라서 “WireGuard는 NAPI를 안 쓴다”도 틀리고, “하드웨어 NIC처럼 NAPI polling만으로 돈다”도 틀립니다. 정확히는 복호화 이후 per-peer NAPI/GRO 경로를 활용합니다.
static const struct net_device_ops netdev_ops = {
.ndo_open = wg_open,
.ndo_stop = wg_stop,
.ndo_start_xmit = wg_xmit,
.ndo_get_stats64 = wg_get_stats64,
};
static void wg_setup(struct net_device *dev)
{
dev->type = ARPHRD_NONE;
dev->flags = IFF_POINTOPOINT | IFF_NOARP;
dev->needed_headroom = DATA_PACKET_HEAD_ROOM;
}
제어 평면은 Generic Netlink로 노출됩니다. wg(8)는 private key, peer, endpoint, AllowedIPs, fwmark, persistent-keepalive 같은 순수 WireGuard 속성만 커널에 밀어 넣습니다. 반대로 wg-quick(8)은 쉘 래퍼로서 주소 할당, 라우팅 테이블, DNS, PostUp/PreDown 훅, SaveConfig까지 다룹니다. 이 둘을 헷갈리면 운영 장애 시 원인을 잘못 찾게 됩니다.
송수신 경로 상세
송신 경로 (TX)
- 로컬 스택이 목적지 IP를 보고
wg0를 선택
이 시점의 패킷은 평문 IPv4/IPv6 skb입니다. wg_xmit()가 AllowedIPs로 peer 검색
매칭 peer가 없으면 커널은 host unreachable 계열 오류를 되돌릴 수 있습니다. “조용히 터널이 먹었다”고 보면 안 됩니다.- peer의 endpoint 주소군과 현재 keypair 상태 확인
유효 endpoint가 아직 없으면 전송 자체가 불가능합니다. endpoint는 정적으로 넣거나, 이전에 인증된 수신 패킷으로 학습되어야 합니다. - GSO skb면 먼저 분할하고 peer staged queue에 적재
커널 소스는 암호화 전에skb_gso_segment()를 호출합니다. 이것이 MTU와 대용량 전송 성능을 이해하는 핵심입니다. - 세션 키가 없거나 오래됐으면 핸드셰이크 큐잉
패킷은 잠시 staged queue에 머물고, 새 세션이 준비되면 순서대로 암호화됩니다. - 암호화 워커가 UDP 소켓으로 송신
이때 바깥쪽 패킷에는fwmark가 붙을 수 있고, 실제 송신 인터페이스는 WireGuard 장치의 birth namespace 소켓이 선택합니다.
수신 경로 (RX)
- UDP 소켓이 암호문 패킷 수신
메시지 타입이 initiation, response, cookie, data 중 무엇인지 먼저 분기합니다. - 핸드셰이크 메시지는 MAC / cookie / 타임스탬프 검증
과부하 상태라면 cookie reply만 보낼 수 있습니다. - data 메시지는
key_idx로 keypair 조회 후 복호화 워커로 이동
여기서 nonce와 AEAD 태그 검증이 수행됩니다. - 리플레이 윈도우 확인
현재 커널 구현은 8192비트 카운터 비트맵에서 중복 영역을 뺀COUNTER_WINDOW_SIZE를 씁니다. 64비트 플랫폼에서는 대략 8128개 이전 nonce를 추적합니다. - 복호화된 패킷의 source AllowedIPs 재검증
이 검사를 통과해야만 “이 peer가 이 내부 주소를 주장할 자격이 있다”고 인정합니다. - per-peer NAPI/GRO로 일반 네트워크 스택 복귀
이후에는 로컬 라우팅/소켓 경로와 동일한 평문 skb처럼 처리됩니다.
타이머, 재키잉, keepalive
상류 커널의 messages.h와 timers.c 기준으로 WireGuard는 몇 가지 고정 상수를 조합해 세션을 유지합니다. 이 값들을 정확히 알아야 “왜 25초 keepalive가 필요한가”, “왜 2분마다 새 핸드셰이크가 보이는가”를 설명할 수 있습니다.
| 상수 | 값 | 의미 | 운영 해석 |
|---|---|---|---|
REKEY_TIMEOUT |
5초 | 핸드셰이크 응답이 없을 때 재전송 타이머 기준값 | 연결이 잠시 막히면 약 5초 간격으로 재시도합니다. |
KEEPALIVE_TIMEOUT |
10초 | 수신은 있었지만 송신이 없을 때 내부 keepalive를 보낼 기준 | 사용자 설정 PersistentKeepalive와 다른 내부 메커니즘입니다. |
REKEY_AFTER_TIME |
120초 | initiator가 현재 세션을 새로 고칠 시점 | 2분 이상 같은 세션 키를 계속 쓰지 않으려는 안전장치입니다. |
REJECT_AFTER_TIME |
180초 | 오래된 세션을 더 이상 받지 않을 기준 | 키 자체 제거는 이 값의 3배(540초) 타이머로 별도 수행됩니다. |
REKEY_AFTER_MESSAGES |
2^60 |
보낸 패킷 수가 너무 많아지면 새 세션 유도 | 장기 대역폭 세션에서 시간 기준 외에 메시지 수 기준도 있습니다. |
MAX_TIMER_HANDSHAKES |
18 | 재시도 상한 | 계속 실패하면 staged queue 패킷을 버리고 잔여 키를 정리합니다. |
사용자가 설정하는 PersistentKeepalive는 별개입니다. wg(8) 문서대로 1초에서 65535초 사이를 줄 수 있고, NAT나 상태 기반 방화벽 뒤에서 유휴 상태에서도 외부로부터 들어오는 패킷을 받고 싶을 때만 켭니다. 공식 Quick Start가 추천하는 범용 값은 25초입니다.
PersistentKeepalive = 25는 “모든 peer에 기본으로 넣는 값”이 아닙니다.
보통은 NAT 뒤에 있고, 한동안 아무 것도 보내지 않아도 외부에서 먼저 들어올 수 있어야 하는 쪽에만 넣습니다. 서버 측이나 항상 송신이 일어나는 peer에는 불필요한 경우가 많습니다.
wg, wg-quick, fwmark, 전체 터널
wg는 WireGuard 장치의 순수 속성만 설정합니다. 주소 부여, 라우트 추가, DNS 설정, 방화벽 훅은 하지 않습니다. 반면 wg-quick은 설정 파일의 Address, DNS, MTU, Table, PreUp, PostUp, PreDown, PostDown, SaveConfig 같은 항목을 해석해 운영 편의를 제공합니다.
기본 서버 / 클라이언트 예제
[Interface]
Address = 10.70.0.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY
[Peer]
PublicKey = CLIENT_PUBLIC_KEY
AllowedIPs = 10.70.0.2/32
# ------------------------------
[Interface]
Address = 10.70.0.2/32
PrivateKey = CLIENT_PRIVATE_KEY
DNS = 10.70.0.1
[Peer]
PublicKey = SERVER_PUBLIC_KEY
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
위 구성에서 서버는 “이 공개키의 peer가 내부 주소 10.70.0.2/32를 대표한다”고 이해합니다. 클라이언트는 기본 경로 전체를 wg0로 넣으므로, endpoint 재귀 라우팅을 피하기 위해 wg-quick이 fwmark와 정책 라우팅을 함께 사용합니다.
# 순수 wg + ip 명령만으로 최소 구성
ip link add wg0 type wireguard
ip address add 10.70.0.1/24 dev wg0
wg set wg0 private-key ./server.key listen-port 51820 \
peer CLIENT_PUBLIC_KEY allowed-ips 10.70.0.2/32
ip link set wg0 up
# 전체 터널의 핵심 정책 라우팅 아이디어
wg set wg0 fwmark 51820
ip route add default dev wg0 table 51820
ip rule add not fwmark 51820 table 51820
ip rule add table main suppress_prefixlength 0
# 무중단 재적용
wg syncconf wg0 <(wg-quick strip wg0)
Table = off를 쓰면 wg-quick이 라우트를 자동으로 추가하지 않게 할 수 있고, 그 대신 운영자가 원하는 policy routing을 직접 설계할 수 있습니다. 반대로 편의성이 중요하면 wg-quick의 자동 라우팅을 쓰되, 반드시 ip rule 결과를 같이 확인해야 합니다.
네트워크 네임스페이스와 birthplace 소켓
WireGuard 공식 namespace 문서가 가장 강하게 강조하는 특징은 birth namespace입니다. WireGuard 인터페이스는 “어디서 생성되었는지”를 기억하고, 실제 암호문 UDP 소켓은 그 생성 당시 namespace에 남습니다. 인터페이스를 다른 namespace로 이동해도 이 사실은 변하지 않습니다.
이 덕분에 “컨테이너 namespace 안에서는 평문 트래픽만 보이고, 실제 암호문 UDP는 호스트의 물리 NIC namespace에서 나가는” 구조를 만들 수 있습니다. 전체 터널 환경을 깔끔하게 분리할 때 매우 강력합니다.
# init namespace에서 생성
ip netns add container
ip link add wg0 type wireguard
# 인터페이스만 다른 namespace로 이동
ip link set wg0 netns container
ip -n container address add 10.70.0.2/32 dev wg0
ip netns exec container wg set wg0 private-key ./client.key \
peer SERVER_PUBLIC_KEY endpoint 203.0.113.10:51820 \
allowed-ips 0.0.0.0/0,::/0
ip -n container link set wg0 up
위 예제에서 wg0는 container namespace 안에 있지만, 암호문 UDP 소켓은 원래 생성된 namespace에 남아 있습니다. 전체 터널 환경을 더 극단적으로 분리하고 싶다면 공식 문서처럼 물리 NIC를 별도 namespace로 옮기고, WireGuard 인터페이스만 기본 namespace로 다시 가져오는 방식도 가능합니다.
성능과 MTU 튜닝
WireGuard 성능을 이야기할 때 가장 흔한 실수는 “몇 Gbps 나온다” 같은 절대 수치를 먼저 외우는 것입니다. 실제 성능은 CPU 세대, SIMD 구현, physical NIC 드라이버, IRQ 분배, GSO/GRO 경로, MTU, NAT 위치, 테스트 방법에 따라 크게 달라집니다. WireGuard는 구조상 유리한 편이지만, 성능 병목은 늘 주변 계층과 함께 측정해야 합니다.
| 점검 항목 | 왜 중요한가 | 실무 확인 방법 |
|---|---|---|
| MTU / PMTU | 터널 헤더와 바깥 IP/UDP 헤더가 붙어 단편화가 생길 수 있습니다. | tracepath, ping -M do, tcpdump로 확인합니다. |
| GSO / GRO 경로 | WireGuard는 송신 전 GSO 분할, 수신 후 GRO 경로 활용 여부가 중요합니다. | perf, ethtool -k, 드라이버/장치별 기능 확인이 필요합니다. |
| 멀티코어 분산 | 암복호화 워커와 physical NIC IRQ가 특정 CPU에 몰리면 성능이 꺾입니다. | top -H, perf top, IRQ affinity를 함께 봅니다. |
| UDP 버퍼 / 드롭 | 암호문 UDP가 burst로 몰릴 때 소켓 버퍼 부족이 문제일 수 있습니다. | ss -u -i, netstat -su, 시스템 UDP 통계를 봅니다. |
| 테스트 방식 | 단일 스트림과 다중 스트림은 CPU와 qdisc 사용 양상이 다릅니다. | iperf3 -P로 여러 스트림을 함께 측정합니다. |
실무에서 자주 보이는 숫자는 MTU 1420이지만, 이것은 “늘 정답”이 아니라 1500바이트 링크에서 IPv6 최악 헤더 여유까지 잡은 흔한 출발점에 가깝습니다. wg-quick은 endpoint 주소나 기본 라우트에서 자동으로 적절한 MTU를 추정하므로, 먼저 자동값을 확인하고 문제가 있을 때만 수동으로 조정하는 편이 낫습니다.
# PMTU / 단편화 점검
tracepath 10.70.0.1
ping -M do -s 1372 10.70.0.1
# 대역폭과 멀티스트림 점검
iperf3 -c 10.70.0.1 -P 4
# CPU 핫스팟 확인
perf top -g
# 바깥쪽 암호문 / 안쪽 평문 비교
tcpdump -ni eth0 udp port 51820
tcpdump -ni wg0
보안 성질과 형식 검증
- 전방 기밀성 — 임시 키를 매 핸드셰이크마다 새로 만들기 때문에 과거 세션 키 노출이 미래 세션을 자동으로 깨뜨리지는 않습니다.
- 다운그레이드 면적 축소 — 알고리즘 협상이 없으므로 협상 단계 취약점이 줄어듭니다.
- AllowedIPs 기반 소스 검증 — 복호화에 성공해도 내부 source IP가 그 peer에 허용된 대역이 아니면 폐기됩니다.
- 선택적 PSK 혼합 —
PresharedKey는 공개키 교환 위에 추가 대칭 비밀을 섞습니다. - 유휴 시 침묵 — Quick Start 문서 기준으로, 필요할 때만 패킷을 보내는 것이 기본 동작입니다.
형식 검증 쪽도 현재 문서에서 자주 과장되는 부분입니다. WireGuard 공식 formal verification 문서 기준으로, 프로토콜 자체는 Tamarin과 CryptoVerif 계열 연구에서 다루어졌고, Curve25519 구현은 HACL*와 Fiat-Crypto(Coq 기반) 계열 검증 구현을 활용합니다. 하지만 이것이 “커널 모듈 전체가 끝까지 전부 정형 증명되었다”는 뜻은 아닙니다. 정확한 표현은 프로토콜과 일부 암호 구현이 폭넓게 검증되었다입니다.
운영상 한계와 함정
| 제한 | 의미 | 대응 |
|---|---|---|
| UDP만 지원 | 공식 known limitations 문서처럼 TCP 모드는 지원하지 않습니다. | TCP-over-TCP 문제를 피하기 위한 설계입니다. 검열 회피나 난독화는 상위 계층 도구가 맡아야 합니다. |
| Roaming Mischief | 능동적 MITM은 endpoint 주소를 바꿔 끼우는 식의 장난을 칠 수 있습니다. | 평문을 읽을 수는 없지만, endpoint를 특정 IP로 고정하고 싶다면 일반 방화벽으로 소켓 대상을 제한합니다. |
| 시스템 시간 의존 | 시계가 크게 앞으로/뒤로 튀면 핸드셰이크가 깨질 수 있습니다. | NTP를 안정적으로 운영하고, hostile 환경에서 시스템 시간을 공격자가 제어하지 못하게 해야 합니다. |
| L3 전용 | 이더넷 브로드캐스트, ARP 브리지, 순수 L2 VPN 용도로는 바로 쓰기 어렵습니다. | L2가 필요하면 다른 오버레이를 함께 설계해야 합니다. |
| 터널 재귀 | 전체 터널에서 endpoint 자신도 터널로 보내면 루프가 납니다. | fwmark, policy routing, 또는 별도 namespace로 탈출 경로를 분리합니다. |
디버깅과 모니터링
WireGuard 장애는 대체로 네 종류로 압축됩니다. “핸드셰이크 자체가 안 됨”, “핸드셰이크는 되는데 데이터가 안 흐름”, “한 방향만 흐름”, “한동안 되다가 끊김”입니다. 이 네 가지를 구분하지 않으면 무의미하게 방화벽과 MTU만 반복해서 만지게 됩니다.
| 증상 | 먼저 볼 것 | 자주 나오는 원인 |
|---|---|---|
latest handshake: never |
endpoint, 포트 개방, 외부 UDP 도달성, 시스템 시간 | ListenPort 미개방, 잘못된 endpoint, NAT 밖 peer에 keepalive 필요, 시간 문제 |
| 핸드셰이크는 되지만 전송량이 0 | AllowedIPs, 라우트, source 주소, 로컬 방화벽 | peer는 맞지만 내부 프리픽스가 틀림, 경로가 다른 인터페이스로 감 |
| 송신만 늘고 수신이 없음 | NAT 매핑, reverse 경로, 상대 peer AllowedIPs | 상대가 내 source 대역을 허용하지 않음, return route 부재 |
| 대용량 전송에서만 끊김 | MTU / PMTU / GSO | 단편화, ICMP 차단, 잘못된 수동 MTU |
| 로밍 후 간헐적 불안정 | endpoint 갱신 여부, keepalive, 소켓 고정 방화벽 | 새 경로 학습이 늦거나, 방화벽이 옛 주소만 허용 |
# 장치 / peer 상태
wg show wg0
wg show wg0 latest-handshakes transfer endpoints allowed-ips
wg showconf wg0
# 링크 / 주소 / 라우팅 / 정책 라우팅
ip -d link show wg0
ip addr show dev wg0
ip route get 10.70.0.1
ip rule show
# 소켓 / UDP / 패킷
ss -ulpn
tcpdump -ni eth0 udp port 51820
tcpdump -ni wg0
# 커널 런타임 디버그
modprobe wireguard
echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control
dmesg | tail -n 100
특히 마지막 두 줄은 공식 Quick Start의 디버그 절차와 같습니다. 예전 글에서 보이는 CONFIG_WIREGUARD_DEBUG=y 같은 설명은 현재 일반 운영 문맥에서 우선순위가 낮고, 실제로는 dynamic debug가 더 직접적입니다.
Noise IK 핸드셰이크 심화
WireGuard가 채택한 Noise_IKpsk2 패턴을 좀 더 정밀하게 들여다봅니다. Noise 프레임워크의 IK 패턴은 Initiator가 Responder의 정적 공개키를 미리 알고 있다는 전제(pre-message pattern ← s)에서 출발합니다. 여기에 psk2 수정자가 붙어 핸드셰이크 두 번째 메시지 직후에 사전 공유키(PSK)를 체인 키에 혼합합니다.
핸드셰이크의 네 단계 메시지 교환을 커널 소스 수준으로 분해하면 다음과 같습니다.
- Initiator → Responder: Handshake Initiation
Initiator는 임시 키 쌍(e_i, E_i)를 생성하고,DH(e_i, S_r)로 체인 키를 진행시킵니다. 그런 다음 자신의 정적 공개키S_i를 AEAD 암호화하여encrypted_static필드에 넣고,DH(s_i, S_r)로 다시 체인 키를 갱신합니다. 마지막으로 TAI64N 타임스탬프를 암호화하여 리플레이를 방어합니다. - Responder → Initiator: Handshake Response
Responder도 임시 키 쌍(e_r, E_r)를 생성합니다.DH(e_r, E_i),DH(e_r, S_i)순으로 체인 키를 진행시키고, 이 시점에서 PSK를 혼합합니다(psk2).encrypted_nothing필드에 빈 페이로드를 AEAD 암호화하여 키 확인 토큰 역할을 합니다. - 세션 키 유도
양쪽 모두 최종 체인 키에서HKDF-BLAKE2s를 사용해 송신용과 수신용 대칭 키를 각각 유도합니다. Initiator의 송신 키가 Responder의 수신 키가 되는 대칭 구조입니다. - 첫 데이터 패킷 = 키 확인
Initiator가 보내는 첫message_data가 성공적으로 복호화되면 Responder는 세션이 올바르게 수립되었음을 확인합니다. 별도의 확인 메시지 없이 데이터 전송과 키 확인이 동시에 이루어지는 1-RTT 설계입니다.
/* noise.c — Initiator 측 핸드셰이크 생성 핵심 흐름 (단순화) */
bool wg_noise_handshake_create_initiation(
struct message_handshake_initiation *dst,
struct noise_handshake *handshake)
{
struct noise_symmetric_key key;
u8 timestamp[NOISE_TIMESTAMP_LEN];
/* 1. 임시 키 쌍 생성 */
curve25519_generate_secret(handshake->ephemeral_private);
curve25519_generate_public(
dst->unencrypted_ephemeral,
handshake->ephemeral_private);
/* 2. DH(e_i, S_r) → 체인 키 갱신 */
mix_dh(handshake, handshake->ephemeral_private,
handshake->remote_static);
/* 3. 정적 공개키 암호화 */
message_encrypt(dst->encrypted_static,
handshake->static_identity->static_public,
NOISE_PUBLIC_KEY_LEN, key);
/* 4. DH(s_i, S_r) → 체인 키 갱신 */
mix_dh(handshake,
handshake->static_identity->static_private,
handshake->remote_static);
/* 5. 타임스탬프 암호화 (리플레이 방어) */
tai64n_now(timestamp);
message_encrypt(dst->encrypted_timestamp,
timestamp, NOISE_TIMESTAMP_LEN, key);
/* 6. mac1, mac2 계산 */
message_macs(dst, handshake);
return true;
}
Noise 패턴 비교: IK vs XX vs KK
WireGuard가 IK 패턴을 선택한 이유를 이해하려면 다른 Noise 패턴과 비교해 볼 필요가 있습니다.
| 패턴 | 사전 지식 | RTT | 신원 보호 | WireGuard 선택 이유 |
|---|---|---|---|---|
IK |
Initiator가 Responder의 S를 미리 앎 | 1-RTT | Initiator의 정적 키가 암호화됨 | VPN에서 peer 공개키는 항상 미리 설정 → IK가 자연스러움 |
XX |
없음 (상호 미지) | 1.5-RTT | 양쪽 모두 보호 | 추가 RTT가 필요하고, VPN에서는 불필요한 유연성 |
KK |
양쪽 모두 상대 S를 미리 앎 | 1-RTT | 없음 (양쪽 정적 키 노출) | Initiator 신원 보호가 없어 프라이버시 불리 |
NK |
Initiator가 Responder의 S를 미리 앎 | 1-RTT | Initiator 인증 없음 | Responder가 Initiator를 식별할 수 없어 VPN 부적합 |
psk2 수정자는 Response 메시지 처리 후 사전 공유키(PSK)를 체인 키에 혼합합니다. 이 PSK는 공개키 암호를 보완하는 추가 대칭 비밀입니다. 양자 컴퓨터가 Curve25519를 깨더라도 PSK까지 알지 못하면 세션 키를 유도할 수 없으므로, 후양자(post-quantum) 저항성에 한 겹의 방어를 더합니다. 다만 PSK 자체를 안전하게 교환하고 관리하는 것은 운영자의 책임입니다.
핸드셰이크 상태 머신
커널 소스의 noise.c에서 핸드셰이크 상태는 enum noise_handshake_state로 관리됩니다. 각 상태 전환을 이해하면 "왜 핸드셰이크가 멈추는가"를 정확히 진단할 수 있습니다.
/* noise.h — 핸드셰이크 상태 열거 */
enum noise_handshake_state {
HANDSHAKE_ZEROED, /* 초기화 전 */
HANDSHAKE_CREATED_INITIATION, /* Initiation 전송 완료 */
HANDSHAKE_CONSUMED_INITIATION, /* Initiation 수신/처리 완료 */
HANDSHAKE_CREATED_RESPONSE, /* Response 전송 완료 */
HANDSHAKE_CONSUMED_RESPONSE, /* Response 수신/처리 → 키 활성화 */
};
/* Responder 측 핸드셰이크 소비 (단순화) */
struct wg_peer *wg_noise_handshake_consume_initiation(
struct message_handshake_initiation *src,
struct wg_device *wg)
{
struct noise_handshake *handshake;
u8 s[NOISE_PUBLIC_KEY_LEN];
u8 timestamp[NOISE_TIMESTAMP_LEN];
/* 1. Initiator의 임시 공개키로 DH */
mix_hash(&handshake_hash, src->unencrypted_ephemeral,
NOISE_PUBLIC_KEY_LEN);
mix_dh(&chaining_key, wg->static_identity.static_private,
src->unencrypted_ephemeral);
/* 2. Initiator의 정적 공개키 복호화 → peer 식별 */
message_decrypt(s, src->encrypted_static,
NOISE_PUBLIC_KEY_LEN + NOISE_AUTHTAG_LEN,
key, handshake_hash);
/* 3. peer 조회 (정적 공개키 기반) */
handshake = lookup_peer(wg, s);
/* 4. 타임스탬프 검증 (리플레이 방어) */
message_decrypt(timestamp, src->encrypted_timestamp,
NOISE_TIMESTAMP_LEN + NOISE_AUTHTAG_LEN,
key, handshake_hash);
if (!timestamp_is_newer(timestamp, handshake))
goto fail; /* 리플레이 거부 */
handshake->state = HANDSHAKE_CONSUMED_INITIATION;
return handshake->peer;
}
| 상태 | 전환 조건 | 실패 시 동작 |
|---|---|---|
ZEROED → CREATED_INITIATION |
Initiator가 데이터 전송 필요 | 키 생성 실패 시 로그 없이 포기 |
ZEROED → CONSUMED_INITIATION |
Responder가 유효한 Initiation 수신 | mac1 실패, 타임스탬프 리플레이, 알 수 없는 peer → 무응답 |
CONSUMED_INITIATION → CREATED_RESPONSE |
Responder가 Response 생성/전송 | DH 실패 시 핸드셰이크 중단 |
CREATED_INITIATION → CONSUMED_RESPONSE |
Initiator가 유효한 Response 수신 | AEAD 검증 실패 → 무시, REKEY_TIMEOUT 후 재시도 |
CONSUMED_RESPONSE → 세션 활성화 |
keypair 승격, 데이터 전송 시작 | 첫 데이터 패킷이 실패하면 새 핸드셰이크 시도 |
AllowedIPs 트라이 구조와 최장 접두사 매칭
AllowedIPs는 단순한 목록이 아니라 커널 내부에서 비트 단위 트라이(bitwise trie) 자료구조로 유지됩니다. 상류 커널의 allowedips.c를 보면, IPv4와 IPv6에 대해 각각 독립적인 트라이 루트를 관리하며, 각 노드는 struct allowedips_node로 구성됩니다.
이 트라이의 핵심 특성은 다음과 같습니다.
- 최장 접두사 매칭(LPM): 송신 시 목적지 IP에 대해 가장 구체적인 프리픽스와 매칭되는 peer를 반환합니다.
- 수신 소스 검증: 복호화 후 내부 패킷의 소스 IP가 해당 peer의 AllowedIPs에 속하는지 확인합니다.
- O(비트 길이) 조회: IPv4는 최대 32단계, IPv6는 최대 128단계의 트라이 탐색으로 완료됩니다.
- 라우팅 테이블과의 관계:
wg-quick은 AllowedIPs를 기반으로 시스템 라우팅 테이블에도 경로를 추가합니다. 따라서 커널 FIB와 WireGuard 내부 트라이가 동시에 사용됩니다.
/* allowedips.c — 트라이 조회 핵심 구조 */
struct allowedips_node {
struct wg_peer __rcu *peer;
struct allowedips_node __rcu *bit[2];
u8 cidr, bit_at_a, bit_at_b, bitlen;
/* 노드 메모리는 RCU로 보호 */
};
/* 송신 시 목적지 IP로 peer 조회 */
struct wg_peer *wg_allowedips_lookup_dst(
struct allowedips *table,
struct sk_buff *skb)
{
if (skb->protocol == htons(ETH_P_IP))
return lookup(table->root4,
32, &ip_hdr(skb)->daddr);
else if (skb->protocol == htons(ETH_P_IPV6))
return lookup(table->root6,
128, &ipv6_hdr(skb)->daddr);
return NULL;
}
/* 수신 시 소스 IP 검증 */
struct wg_peer *wg_allowedips_lookup_src(
struct allowedips *table,
struct sk_buff *skb)
{
if (skb->protocol == htons(ETH_P_IP))
return lookup(table->root4,
32, &ip_hdr(skb)->saddr);
else if (skb->protocol == htons(ETH_P_IPV6))
return lookup(table->root6,
128, &ipv6_hdr(skb)->saddr);
return NULL;
}
wg-quick은 AllowedIPs의 각 프리픽스에 대해 시스템 라우팅 테이블에 경로를 자동 추가합니다. 따라서 WireGuard 내부 트라이와 커널 FIB(Forwarding Information Base)에 동일한 정보가 이중으로 존재합니다. Table = auto(기본값)일 때 wg-quick은 별도 라우팅 테이블을 만들고, 0.0.0.0/0이 포함되면 fwmark 기반 정책 라우팅을 설정합니다. 이 이중 구조를 이해하지 못하면 "라우트를 삭제했는데 왜 여전히 패킷이 터널로 가는가" 같은 혼란이 생깁니다.
RCU 보호와 동시성
AllowedIPs 트라이는 RCU(Read-Copy-Update)로 보호됩니다. 이는 데이터 경로(송수신)에서 트라이 조회가 잠금 없이 수행되어야 하기 때문입니다. peer 추가/삭제 같은 변경 작업은 Netlink 경로에서 rtnl_lock 아래에서 수행되고, 읽기 측은 rcu_read_lock() 영역에서 안전하게 트라이를 탐색합니다.
/* allowedips.c — RCU 보호 트라이 조회 (단순화) */
static struct wg_peer *lookup(
struct allowedips_node __rcu *top,
u8 bits, const void *be_ip)
{
struct allowedips_node *node;
struct wg_peer *peer = NULL;
u8 cidr;
/* RCU 읽기 영역 — 잠금 없이 안전한 탐색 */
rcu_read_lock();
node = rcu_dereference(top);
while (node) {
/* 현재 노드의 CIDR 비트까지 IP가 일치하면 */
if (prefix_matches(node, be_ip, bits)) {
/* peer가 있으면 후보로 기록 (마지막 매칭이 승자) */
struct wg_peer *p =
rcu_dereference(node->peer);
if (p)
peer = p;
/* 다음 비트 방향으로 하강 */
node = rcu_dereference(
node->bit[choose_bit(be_ip, node)]);
} else {
break;
}
}
rcu_read_unlock();
return peer;
}
| 연산 | 경로 | 잠금 | 시간 복잡도 |
|---|---|---|---|
| 조회 (송신/수신) | 데이터 경로 (softirq/BH) | rcu_read_lock() (잠금 없음) |
O(비트 길이): IPv4=32, IPv6=128 |
| 삽입 | 제어 경로 (Netlink) | rtnl_lock + mutex |
O(비트 길이) |
| 삭제 (peer 제거 시) | 제어 경로 (Netlink) | rtnl_lock + RCU synchronize |
O(노드 수) + RCU grace period 대기 |
| 전체 재구성 | 제어 경로 (syncconf) | rtnl_lock |
O(총 프리픽스 수 x 비트 길이) |
암호 프리미티브 내부 구조
WireGuard가 선택한 세 가지 핵심 암호 프리미티브 — ChaCha20-Poly1305, BLAKE2s, Curve25519 — 각각의 선택 이유와 커널 내부에서의 구현 방식을 살펴봅니다.
ChaCha20-Poly1305: AEAD 암호
ChaCha20은 Daniel Bernstein이 설계한 스트림 암호이고, Poly1305는 같은 설계자의 원타임 MAC입니다. 이 조합을 AEAD(Authenticated Encryption with Associated Data)로 묶은 것이 RFC 8439의 구성이며, WireGuard는 이것을 데이터 패킷 암호화와 핸드셰이크 내부 필드 암호화에 모두 사용합니다.
| 속성 | ChaCha20-Poly1305 | AES-GCM | WireGuard 선택 이유 |
|---|---|---|---|
| 하드웨어 가속 | AES-NI 없는 CPU에서 유리 | AES-NI 있는 CPU에서 유리 | 하드웨어 의존성 없이 일관된 성능 |
| 소프트웨어 구현 | 단순한 ARX(Add-Rotate-XOR) | GF(2^128) 곱셈 필요 | 감사 용이성, 이식성 |
| 사이드 채널 | 상수 시간 구현 자연스러움 | 테이블 기반 구현 시 캐시 타이밍 위험 | 타이밍 공격 면적 축소 |
| nonce 크기 | 96비트 (12바이트) | 96비트 (12바이트) | WireGuard는 64비트 카운터 사용 |
| SIMD 최적화 | NEON/AVX2/AVX-512 구현 존재 | AES-NI + CLMUL | 커널에 zinc/lib 경로로 SIMD 구현 포함 |
BLAKE2s: 해시와 KDF
BLAKE2s는 BLAKE2 계열의 32비트 워드 변형으로, SHA-256보다 빠르면서 충돌 저항성은 동등합니다. WireGuard는 핸드셰이크 체인 키 유도에 HMAC-BLAKE2s를 사용하고, HKDF-BLAKE2s로 최종 세션 키를 추출합니다.
WireGuard에서 BLAKE2s가 수행하는 구체적 역할은 다음과 같습니다.
| 용도 | 구성 | 입력 | 출력 |
|---|---|---|---|
| MixHash | BLAKE2s(h || data) | 현재 해시 + 새 데이터 | 갱신된 핸드셰이크 해시 |
| MixKey (HKDF-Extract) | HMAC-BLAKE2s(ck, input) | 체인 키 + DH 결과 | 새 체인 키 + 임시 키 |
| HKDF-Expand | HMAC-BLAKE2s(prk, info) | 추출된 PRK + 정보 | 세션 송신/수신 키 |
| mac1 계산 | MAC(HASH(label || pk), msg) | LABEL_MAC1 + 수신자 공개키 + 메시지 | 16바이트 MAC |
| cookie 계산 | MAC(key, ip || port) | 2분 비밀 + 소스 주소 | 16바이트 쿠키 |
/* noise.c — HKDF-BLAKE2s 기반 키 유도 (단순화) */
static void kdf(u8 *first_dst, u8 *second_dst,
u8 *third_dst,
const u8 *data, size_t dlen,
const u8 chaining_key[NOISE_HASH_LEN])
{
u8 output[BLAKE2S_HASH_SIZE + 1];
u8 secret[BLAKE2S_HASH_SIZE];
/* Extract: HMAC-BLAKE2s(chaining_key, data) */
blake2s256_hmac(secret, data, chaining_key,
dlen, NOISE_HASH_LEN);
/* Expand 1: T1 = HMAC-BLAKE2s(secret, 0x01) */
output[0] = 1;
blake2s256_hmac(first_dst, output, secret,
1, BLAKE2S_HASH_SIZE);
if (!second_dst)
goto out;
/* Expand 2: T2 = HMAC-BLAKE2s(secret, T1 || 0x02) */
memcpy(output, first_dst, BLAKE2S_HASH_SIZE);
output[BLAKE2S_HASH_SIZE] = 2;
blake2s256_hmac(second_dst, output, secret,
BLAKE2S_HASH_SIZE + 1,
BLAKE2S_HASH_SIZE);
/* 필요하면 T3도 유사하게 유도 */
out:
memzero_explicit(secret, BLAKE2S_HASH_SIZE);
memzero_explicit(output, sizeof(output));
}
Curve25519: ECDH
Curve25519는 Daniel Bernstein이 설계한 타원 곡선으로, 32바이트 공개키와 32바이트 비밀키를 사용합니다. 커널 구현은 lib/crypto/curve25519.c에 위치하며, 아키텍처별로 SIMD 최적화된 구현(curve25519-x86_64.c 등)이 선택됩니다.
Curve25519의 핵심 보안 속성은 다음과 같습니다.
- 상수 시간 구현: Montgomery ladder 알고리즘으로 입력 값에 무관한 실행 시간을 보장하여 타이밍 사이드 채널을 원천 차단합니다.
- 작은 부분군 공격 내성: 비밀키 클램핑(최하위 3비트 0, 최상위 비트 설정)으로 작은 부분군 공격을 구조적으로 방지합니다.
- 키 유효성 검사 불필요: 상대방이 보낸 공개키에 대해 별도의 점 유효성 검사 없이도 안전한 DH가 가능합니다.
arch/x86/crypto/curve25519-x86_64.c가 ADX/BMI2 명령어를 활용하고, ARM64에서는 NEON 구현이 선택됩니다. cat /proc/crypto | grep curve25519로 현재 활성화된 구현을 확인할 수 있습니다.
/* 데이터 패킷 암호화 핵심 (send.c 기반, 단순화) */
static bool encrypt_packet(
struct sk_buff *skb,
struct noise_keypair *keypair)
{
struct message_data *header;
u64 nonce;
unsigned int padding_len, plaintext_len;
/* nonce = 원자적으로 증가하는 64비트 카운터 */
nonce = atomic64_inc_return(
&keypair->sending_counter) - 1;
/* REKEY_AFTER_MESSAGES 도달 시 재핸드셰이크 유도 */
if (unlikely(nonce >= REKEY_AFTER_MESSAGES))
goto rekey;
/* 헤더 세팅 */
header = (struct message_data *)skb_push(
skb, sizeof(*header));
header->header.type = cpu_to_le32(
MESSAGE_DATA);
header->key_idx = keypair->remote_index;
header->counter = cpu_to_le64(nonce);
/* ChaCha20-Poly1305 AEAD 암호화 */
chacha20poly1305_encrypt(
skb->data + sizeof(*header),
skb->data + sizeof(*header),
plaintext_len,
header, sizeof(*header), /* AAD */
nonce, keypair->sending.key);
return true;
}
커널 모듈 아키텍처 심화
WireGuard 커널 모듈의 내부 구조를 소스 파일 단위로 분해합니다. 상류 커널의 drivers/net/wireguard/ 디렉토리는 약 4,000줄의 C 코드로 구성되어 있으며, 이 작은 크기가 WireGuard의 핵심 설계 원칙인 "감사 가능한 코드 베이스"를 반영합니다.
| 소스 파일 | 역할 | 핵심 함수/구조체 |
|---|---|---|
main.c |
모듈 초기화/해제, rtnl_link_ops 등록 | wg_init(), wg_exit() |
device.c |
net_device 생성/파괴, ndo_start_xmit | wg_setup(), wg_xmit(), wg_open() |
noise.c |
Noise_IKpsk2 핸드셰이크 상태 머신 | wg_noise_handshake_create_initiation() |
peer.c |
peer 생명주기, 참조 카운팅 | struct wg_peer, wg_peer_create() |
allowedips.c |
AllowedIPs 비트 트라이 관리 | wg_allowedips_lookup_dst() |
send.c |
송신 경로, GSO 분할, 암호화 큐잉 | wg_packet_send_staged_packets() |
receive.c |
수신 경로, 복호화, NAPI/GRO 전달 | wg_packet_receive() |
socket.c |
UDP 소켓 생성/관리, birth namespace | wg_socket_init() |
netlink.c |
Generic Netlink 인터페이스 (wg 명령) | wg_set_device(), wg_get_device() |
timers.c |
재키잉, keepalive, 만료 타이머 | wg_timers_data_sent() |
cookie.c |
DoS 완화 쿠키 계산/검증 | wg_cookie_message_create() |
queueing.c |
per-CPU 병렬 암복호화 큐 | wg_packet_queue_init() |
/* device.c — WireGuard 장치의 핵심 구조체 */
struct wg_device {
struct net_device *dev;
/* 암호 식별자 */
struct noise_static_identity static_identity;
/* peer 관리 */
struct list_head peer_list;
struct allowedips peer_allowedips;
u32 num_peers;
/* UDP 소켓 (birth namespace에 고정) */
struct wg_socket __rcu *sock4, *sock6;
u16 incoming_port;
/* 암복호화 병렬 큐 */
struct wg_queue encrypt_queue, decrypt_queue;
struct workqueue_struct *handshake_send_wq;
struct workqueue_struct *handshake_receive_wq;
/* 네트워크 네임스페이스 */
struct net *creating_net; /* birth ns */
/* DoS 완화 */
struct cookie_checker cookie_checker;
/* 인덱스 해시테이블: key_idx → keypair 빠른 조회 */
struct index_hashtable index_hashtable;
};
/* peer.c — 피어별 상태 */
struct wg_peer {
struct wg_device *device;
struct noise_handshake handshake;
struct noise_keypairs keypairs;
/* endpoint 정보 (로밍 시 갱신) */
struct endpoint endpoint;
/* 송신 대기 큐 */
struct sk_buff_head staged_packet_queue;
/* per-peer NAPI (수신 GRO 경로) */
struct napi_struct napi;
/* 타이머 */
struct timer_list timer_retransmit_handshake;
struct timer_list timer_send_keepalive;
struct timer_list timer_new_handshake;
struct timer_list timer_zero_key_material;
struct timer_list timer_persistent_keepalive;
/* 통계 */
u64 last_sent_handshake;
u64 rx_bytes, tx_bytes;
};
데이터 패킷 처리 경로 심화
앞서 개요 수준에서 송수신 경로를 봤다면, 이 절에서는 커널 소스 수준에서 패킷이 실제로 거치는 함수 호출 체인과 병렬화 메커니즘을 자세히 분석합니다.
송신 경로 (TX) 상세
wg_xmit()은 .ndo_start_xmit 콜백으로, 네트워크 스택이 wg0를 선택했을 때 호출됩니다. 이 함수의 핵심 동작은 다음과 같습니다.
- 프로토콜 확인: IPv4/IPv6만 허용합니다. 다른 프로토콜은 즉시 폐기됩니다.
- AllowedIPs LPM으로 peer 결정: 목적지 IP에 대해 트라이를 조회합니다.
- GSO skb 분할:
skb_is_gso()가 참이면skb_gso_segment()로 세그먼트 단위로 쪼갭니다. 각 세그먼트가 개별적으로 AEAD 암호화되어야 하기 때문입니다. - staged queue에 적재: 분할된 skb들은
peer->staged_packet_queue에 들어갑니다. - keypair 확인 및 핸드셰이크 트리거: 유효한 세션 키가 없으면 핸드셰이크를 큐잉하고, 패킷은 staged queue에서 대기합니다.
- per-CPU 암호화 큐로 이동:
wg_packet_send_staged_packets()가 staged queue에서 패킷을 꺼내encrypt_queue의 per-CPU 슬롯에 넣습니다. - 암호화 워커 실행:
wg_packet_encrypt_worker()가 ChaCha20-Poly1305 AEAD를 적용하고, 완료된 패킷을wg_packet_send_keepalive()경로를 통해 UDP 소켓으로 전송합니다.
수신 경로 (RX) 상세
UDP 소켓의 encap_rcv 콜백인 wg_receive()에서 시작됩니다.
- 메시지 타입 분기: 헤더의 첫 4바이트로
MESSAGE_HANDSHAKE_INITIATION,MESSAGE_HANDSHAKE_RESPONSE,MESSAGE_HANDSHAKE_COOKIE,MESSAGE_DATA를 구분합니다. - 핸드셰이크 메시지: 전용 핸드셰이크 워크큐로 전달됩니다. rate limiting이 적용됩니다.
- 데이터 메시지:
key_idx로index_hashtable에서 keypair를 조회합니다. - per-CPU 복호화 큐:
decrypt_queue의 per-CPU 슬롯에 넣고 워커를 깨웁니다. - 복호화 워커:
wg_packet_decrypt_worker()가 AEAD 복호화와 nonce 윈도우 확인을 수행합니다. - AllowedIPs 소스 검증: 복호화된 평문의 소스 IP가 해당 peer의 AllowedIPs에 속하는지 확인합니다.
- per-peer NAPI/GRO: 검증 통과 시
napi_gro_receive(&peer->napi, skb)로 네트워크 스택에 주입합니다.
per-CPU 암복호화 큐 메커니즘
WireGuard의 병렬 암복호화는 struct wg_queue를 통해 관리됩니다. 이 큐는 ptr_ring 자료구조를 기반으로 하며, 각 CPU가 독립적으로 패킷을 처리합니다.
/* queueing.h — per-CPU 큐 구조 */
struct wg_queue {
struct ptr_ring ring;
struct work_struct work;
int last_cpu;
};
/* 패킷을 큐에 넣고 워커를 깨우는 핵심 로직 */
static inline int wg_queue_enqueue_per_device_and_peer(
struct wg_queue *device_queue,
struct wg_queue *peer_queue,
struct sk_buff *skb,
struct workqueue_struct *wq)
{
int cpu;
/* peer 큐에 삽입 (순서 보장용) */
ptr_ring_produce(&peer_queue->ring, skb);
/* device 큐에도 삽입 (CPU 분산용) */
cpu = wg_cpumask_next_online(
&device_queue->last_cpu);
ptr_ring_produce_bh(&device_queue->ring, skb);
/* 해당 CPU의 워커 깨우기 */
queue_work_on(cpu, wq, &device_queue->work);
return 0;
}
peer_queue의 순서 번호로 보장됩니다. 암호화가 완료된 패킷은 peer 큐에서 원래 순서대로 꺼내져 전송됩니다. 이 이중 큐 설계(device-level 분산 + peer-level 순서)가 WireGuard의 멀티코어 성능과 패킷 순서 보장을 동시에 달성하는 핵심입니다.
키 로테이션과 세션 관리
WireGuard의 키 로테이션은 운영자가 수동으로 수행하는 것이 아니라 타이머 기반으로 자동 발생합니다. 이 메커니즘의 전체 그림을 이해해야 "왜 2분마다 핸드셰이크가 보이는가", "키 자료가 언제 메모리에서 제거되는가"를 설명할 수 있습니다.
세션 생명주기
각 peer는 최대 3개의 keypair를 유지할 수 있습니다: current, previous, next. 새 핸드셰이크가 완료되면 next가 current로 승격되고, 이전 current는 previous로 내려갑니다.
| 이벤트 | 트리거 | 결과 |
|---|---|---|
| 데이터 전송 시도 | current keypair 없거나 만료 임박 | 핸드셰이크 initiation 큐잉, 패킷은 staged queue 대기 |
| REKEY_AFTER_TIME 도달 | 현재 세션이 120초 경과 | 다음 데이터 전송 시 새 핸드셰이크 시작 |
| 핸드셰이크 응답 미수신 | REKEY_TIMEOUT(5초) 경과 | Initiation 재전송, 최대 MAX_TIMER_HANDSHAKES(18)회 |
| 18회 연속 실패 | MAX_TIMER_HANDSHAKES 도달 | staged queue 패킷 폐기, 잔여 키 자료 정리 |
| REJECT_AFTER_TIME 도달 | keypair 생성 후 180초 경과 | 해당 keypair로 들어오는 패킷 거부 |
| 키 자료 제거 | REJECT_AFTER_TIME * 3 (540초) 경과 | keypair 메모리를 0으로 밀고 해제 (전방 기밀성 보강) |
이 타이머 체계의 실무적 의미를 정리하면, WireGuard는 능동적으로 데이터를 보내는 쪽(Initiator)만 새 핸드셰이크를 시작합니다. 유휴 peer는 핸드셰이크를 하지 않습니다. 이것이 "보내야 할 게 없으면 조용히 있는다"는 WireGuard의 기본 동작과 직접 연결됩니다.
REKEY_AFTER_TIME(120초)은 Initiator 측에서만 적용됩니다. Responder는 데이터를 수신만 하는 한 직접 핸드셰이크를 시작하지 않습니다. 따라서 한 방향으로만 트래픽이 흐르는 환경(예: 모니터링 데이터 단방향 전송)에서는 항상 같은 쪽이 Initiator가 됩니다. 이 비대칭 구조를 이해하지 못하면 "왜 서버 측은 절대 핸드셰이크를 시작하지 않는가"라는 의문이 생깁니다.
noise_keypair_put() → kfree_sensitive()). 공격자가 나중에 정적 비밀키를 탈취하더라도, 임시 키가 이미 소멸된 과거 세션의 데이터는 복원할 수 없습니다.
nonce 카운터와 리플레이 윈도우
데이터 패킷의 counter 필드는 64비트 단조 증가 카운터입니다. 수신 측은 이 카운터를 사용해 리플레이 공격을 탐지합니다. 커널 구현의 리플레이 윈도우 메커니즘을 살펴봅니다.
/* peerlookup.h — 리플레이 카운터 구조 */
struct noise_replay_counter {
u64 counter; /* 수신된 최대 카운터 */
unsigned long backtrack[ /* 비트맵: 윈도우 내 수신 기록 */
COUNTER_BITS_TOTAL /
BITS_PER_LONG];
spinlock_t lock;
};
/* COUNTER_WINDOW_SIZE 계산:
* COUNTER_BITS_TOTAL = 8192 (비트)
* COUNTER_REDUNDANT_BITS = sizeof(unsigned long) * 8 = 64
* COUNTER_WINDOW_SIZE = 8192 - 64 = 8128
*
* 즉, 최대 카운터 값으로부터 8128개 이전 nonce까지 추적 */
static bool counter_validate(
struct noise_replay_counter *counter,
u64 their_counter)
{
u64 index;
bool ret = false;
spin_lock_bh(&counter->lock);
/* 새 최대값이면 윈도우 확장 */
if (likely(their_counter > counter->counter)) {
index = their_counter >> ilog2(BITS_PER_LONG);
/* 비트맵에서 해당 위치 표시 */
bitmap_set(counter->backtrack, index, 1);
counter->counter = their_counter;
ret = true;
}
/* 윈도우 안에 있고 아직 안 본 nonce면 허용 */
else if (counter->counter - their_counter
< COUNTER_WINDOW_SIZE) {
index = their_counter >> ilog2(BITS_PER_LONG);
if (!test_and_set_bit(
their_counter & (BITS_PER_LONG - 1),
&counter->backtrack[index
% (COUNTER_BITS_TOTAL
/ BITS_PER_LONG)]))
ret = true; /* 새로운 nonce */
/* 이미 본 nonce면 ret = false (리플레이) */
}
/* 윈도우 밖이면 ret = false (너무 오래된 패킷) */
spin_unlock_bh(&counter->lock);
return ret;
}
keypair 관리 코드
/* noise.c — keypair 승격 로직 (단순화) */
void wg_noise_keypair_begin_session(
struct noise_keypairs *keypairs,
struct noise_keypair *new_keypair,
bool is_initiator)
{
struct noise_keypair *previous, *next, *current;
spin_lock_bh(&keypairs->keypair_update_lock);
/* next 슬롯에 새 keypair 배치 */
next = rcu_dereference_protected(
keypairs->next_keypair,
lockdep_is_held(&keypairs->keypair_update_lock));
if (is_initiator) {
/* Initiator: 즉시 current로 승격 */
current = rcu_dereference_protected(
keypairs->current_keypair, 1);
previous = rcu_dereference_protected(
keypairs->previous_keypair, 1);
/* previous → 해제 */
rcu_assign_pointer(
keypairs->previous_keypair, current);
rcu_assign_pointer(
keypairs->current_keypair, new_keypair);
rcu_assign_pointer(
keypairs->next_keypair, NULL);
if (previous)
wg_noise_keypair_put(previous, true);
} else {
/* Responder: next에 대기, 첫 데이터 수신 시 승격 */
rcu_assign_pointer(
keypairs->next_keypair, new_keypair);
}
spin_unlock_bh(&keypairs->keypair_update_lock);
}
성능 비교와 벤치마크 분석
WireGuard의 성능 우위는 "빠르다"라는 단순 표현보다 어디서, 왜 빠른가를 구조적으로 이해하는 것이 중요합니다. 핵심 요인은 세 가지입니다: 커널 데이터 평면, 컨텍스트 스위칭 없음, 고정 알고리즘의 SIMD 최적화.
| 항목 | WireGuard | IPsec (AES-GCM) | OpenVPN |
|---|---|---|---|
| 데이터 평면 | 커널 (ndo_start_xmit) | 커널 (xfrm) | 유저스페이스 (tun/tap) |
| 컨텍스트 스위칭 | 없음 | 없음 | 매 패킷마다 발생 가능 |
| 알고리즘 선택 | 고정 (ChaCha20-Poly1305) | 협상 (주로 AES-GCM) | 설정에 따라 다양 |
| GRO/GSO 활용 | per-peer NAPI/GRO | xfrm offload (NIC 지원 시) | 제한적 |
| 코드 크기 | ~4,000줄 | 수만 줄 (xfrm + IKE) | 수만 줄 |
| AES-NI 환경 처리량 | 1~3 Gbps (CPU 의존) | 2~5 Gbps (HW offload 가능) | 0.2~0.5 Gbps |
| AES-NI 없는 환경 | ChaCha20이 유리 | AES 소프트웨어 구현은 느림 | 동일하게 느림 |
| 지연 시간 | 1-RTT 핸드셰이크 | IKEv2 2-RTT | TLS 핸드셰이크 다수 RTT |
성능 튜닝 체크리스트
# 1. SIMD 최적화 확인 (x86_64)
grep -r "chacha20" /proc/crypto
grep -r "curve25519" /proc/crypto
# 2. NIC offload 상태 확인
ethtool -k eth0 | grep -E "gso|gro|tso"
# 3. IRQ 분산 확인 (멀티코어)
cat /proc/interrupts | grep eth0
cat /proc/irq/*/smp_affinity_list
# 4. CPU별 암복호화 워커 분포
perf top -g -p $(pgrep -f wireguard)
# 5. UDP 소켓 버퍼 크기 조정
sysctl net.core.rmem_max
sysctl net.core.wmem_max
sysctl -w net.core.rmem_max=26214400
sysctl -w net.core.wmem_max=26214400
# 6. 대역폭 측정 (다중 스트림)
iperf3 -c 10.70.0.1 -P 8 -t 30
# 7. 단일 스트림 지연 시간 측정
iperf3 -c 10.70.0.1 -u -b 1G -l 1400
네트워크 네임스페이스 활용 패턴
WireGuard의 birth namespace 특성을 활용한 실전 네트워크 격리 패턴을 살펴봅니다. 이 절에서는 앞서 소개한 기본 namespace 분리를 넘어, 컨테이너 VPN, 라우팅 격리, 다중 터널 환경을 다룹니다.
패턴 1: 컨테이너 전용 VPN 게이트웨이
호스트에서 생성한 WireGuard 인터페이스를 컨테이너 namespace로 이동시켜, 컨테이너 내부의 모든 트래픽이 VPN을 통해서만 나가도록 강제합니다.
# 1. 네임스페이스 생성
ip netns add vpn-container
# 2. 호스트에서 WireGuard 인터페이스 생성 (birth ns = 호스트)
ip link add wg-vpn type wireguard
wg set wg-vpn private-key /etc/wireguard/container.key \
peer SERVER_PUB_KEY endpoint vpn.example.com:51820 \
allowed-ips 0.0.0.0/0,::/0 \
persistent-keepalive 25
# 3. 인터페이스를 컨테이너 namespace로 이동
ip link set wg-vpn netns vpn-container
# 4. 컨테이너 내부에서 주소와 라우팅 설정
ip -n vpn-container addr add 10.70.0.2/32 dev wg-vpn
ip -n vpn-container link set wg-vpn up
ip -n vpn-container link set lo up
ip -n vpn-container route add default dev wg-vpn
# 5. 컨테이너 namespace에서 프로세스 실행
ip netns exec vpn-container /usr/bin/my-app
패턴 2: 물리 NIC 격리 (역방향 namespace)
WireGuard 공식 namespace 문서가 소개하는 더 극단적인 패턴입니다. 물리 NIC를 별도 namespace로 옮기고, WireGuard 인터페이스만 기본(init) namespace에 남깁니다.
# 1. 물리 NIC를 담을 namespace 생성
ip netns add physical
# 2. WireGuard를 physical namespace에서 생성 (birth ns = physical)
ip -n physical link add wg0 type wireguard
# 3. 물리 NIC를 physical namespace로 이동
ip link set eth0 netns physical
ip -n physical addr add 203.0.113.5/24 dev eth0
ip -n physical link set eth0 up
ip -n physical route add default via 203.0.113.1
# 4. physical namespace에서 WireGuard 설정
ip netns exec physical wg set wg0 \
private-key /etc/wireguard/host.key \
listen-port 51820 \
peer CLIENT_PUB_KEY allowed-ips 10.70.0.0/24
# 5. WireGuard 인터페이스를 init namespace로 이동
ip -n physical link set wg0 netns 1
# 6. init namespace에서 주소와 라우팅
ip addr add 10.70.0.1/24 dev wg0
ip link set wg0 up
# 결과: init namespace에서는 평문 트래픽만 보임
# 물리 NIC와 암호문 UDP는 physical namespace에 격리
ip link set wg0 netns <container-pid>로 이동시키는 패턴이 일반적입니다. 이때 NET_ADMIN 능력(capability)이 필요하므로, 보안 정책에 따라 init 컨테이너나 sidecar 패턴을 고려해야 합니다.
패턴 3: 다중 터널과 라우팅 격리
서로 다른 VPN 서버에 대해 별도의 WireGuard 인터페이스를 만들고, 각각을 다른 namespace에 배치하여 트래픽을 완전히 격리하는 패턴입니다.
# 터널 A: 업무용 VPN
ip netns add work-vpn
ip link add wg-work type wireguard
wg set wg-work private-key /etc/wireguard/work.key \
peer WORK_PUB_KEY endpoint work-vpn.corp.com:51820 \
allowed-ips 10.0.0.0/8
ip link set wg-work netns work-vpn
ip -n work-vpn addr add 10.70.0.2/32 dev wg-work
ip -n work-vpn link set wg-work up
ip -n work-vpn route add 10.0.0.0/8 dev wg-work
# 터널 B: 개인용 VPN
ip netns add personal-vpn
ip link add wg-personal type wireguard
wg set wg-personal private-key /etc/wireguard/personal.key \
peer PERSONAL_PUB_KEY endpoint personal.vpn.com:51820 \
allowed-ips 0.0.0.0/0,::/0
ip link set wg-personal netns personal-vpn
ip -n personal-vpn addr add 10.80.0.2/32 dev wg-personal
ip -n personal-vpn link set wg-personal up
ip -n personal-vpn route add default dev wg-personal
# 특정 앱을 특정 터널로 실행
ip netns exec work-vpn curl https://internal.corp.com
ip netns exec personal-vpn firefox
| 패턴 | birth namespace | wg 인터페이스 위치 | 사용 사례 |
|---|---|---|---|
| 기본 (namespace 없음) | init | init | 단순 VPN, 호스트 전체 보호 |
| 패턴 1: 컨테이너 VPN | init (호스트) | 컨테이너 ns | 특정 앱/컨테이너만 VPN 경유 |
| 패턴 2: 물리 NIC 격리 | physical ns | init | 호스트에서 평문만 노출, 물리 NIC 격리 |
| 패턴 3: 다중 터널 | init (호스트) | 각각 별도 ns | 앱별 다른 VPN 터널 사용 |
wg set으로 설정을 변경해야 합니다. 그러나 wg show는 인터페이스가 있는 namespace에서만 가능합니다. 또한 namespace가 삭제되면 그 안의 WireGuard 인터페이스도 함께 사라지므로, 서비스 재시작이나 namespace 재생성 시 인터페이스를 다시 만들어야 합니다.
systemd와 namespace 통합
systemd-networkd는 [NetDev] 섹션에서 WireGuard 인터페이스를 선언적으로 관리할 수 있습니다. 이 방식은 wg-quick 대비 namespace 통합과 서비스 의존성 관리가 더 체계적입니다.
# /etc/systemd/network/99-wg0.netdev
[NetDev]
Name=wg0
Kind=wireguard
Description=WireGuard VPN
[WireGuard]
PrivateKey=SERVER_PRIVATE_KEY
ListenPort=51820
[WireGuardPeer]
PublicKey=CLIENT_PUBLIC_KEY
AllowedIPs=10.70.0.2/32
PersistentKeepalive=25
# /etc/systemd/network/99-wg0.network
[Match]
Name=wg0
[Network]
Address=10.70.0.1/24
[Route]
Destination=10.70.0.0/24
Scope=link
# systemd-networkd로 WireGuard 관리
systemctl enable --now systemd-networkd
networkctl status wg0
networkctl list
# 로그 확인
journalctl -u systemd-networkd -f --grep wireguard
ftrace/bpftrace를 활용한 WireGuard 패킷 추적
WireGuard의 커널 내부 동작을 실시간으로 추적하려면 ftrace, bpftrace, perf 같은 커널 트레이싱 도구를 사용합니다. 이 절에서는 WireGuard 특유의 추적 포인트와 실전 디버깅 기법을 다룹니다.
dynamic_debug 활성화
가장 간단한 방법은 WireGuard 모듈의 dynamic debug 메시지를 켜는 것입니다. 이는 공식 Quick Start에서도 권장하는 방법입니다.
# WireGuard 모듈의 모든 dynamic debug 메시지 활성화
echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control
# 핸드셰이크 관련 메시지만 필터링
echo 'module wireguard func wg_noise_handshake* +p' \
> /sys/kernel/debug/dynamic_debug/control
# 결과 확인
dmesg -w | grep wireguard
ftrace로 함수 호출 체인 추적
# 1. WireGuard 송신 경로 함수 그래프 추적
cd /sys/kernel/debug/tracing
echo function_graph > current_tracer
echo 'wg_xmit' > set_graph_function
echo 1 > tracing_on
# 패킷 전송 후 결과 확인
cat trace | head -100
# 2. 특정 함수의 지연 시간 측정
echo 'wg_noise_handshake_create_initiation' > set_ftrace_filter
echo function > current_tracer
echo 1 > tracing_on
cat trace_pipe
# 3. 추적 중지
echo 0 > tracing_on
echo nop > current_tracer
bpftrace 원라이너
# 1. 핸드셰이크 빈도 모니터링
bpftrace -e '
kprobe:wg_noise_handshake_create_initiation {
@handshakes[comm] = count();
}
interval:s:10 { print(@handshakes); clear(@handshakes); }
'
# 2. 패킷 암호화 지연 시간 히스토그램
bpftrace -e '
kprobe:wg_packet_encrypt_worker { @start[tid] = nsecs; }
kretprobe:wg_packet_encrypt_worker /@start[tid]/ {
@encrypt_ns = hist(nsecs - @start[tid]);
delete(@start[tid]);
}
'
# 3. peer별 송수신 바이트 추적
bpftrace -e '
kprobe:wg_xmit {
@tx_bytes = sum(((struct sk_buff *)arg0)->len);
}
kprobe:wg_packet_receive {
@rx_bytes = sum(((struct sk_buff *)arg0)->len);
}
interval:s:5 { print(@tx_bytes); print(@rx_bytes); }
'
# 4. AllowedIPs 조회 실패 (peer 없음) 추적
bpftrace -e '
kretprobe:wg_allowedips_lookup_dst /retval == 0/ {
@lookup_miss = count();
printf("AllowedIPs miss at %s\n", kstack);
}
'
perf를 활용한 CPU 프로파일링
# 1. WireGuard 관련 CPU 핫스팟 확인
perf top -g --kallsyms=/proc/kallsyms -d 5
# 2. 암호화 연산의 CPU 사이클 분포
perf record -g -a -- sleep 10
perf report --symbol-filter=chacha20
# 3. 특정 WireGuard 함수의 flamegraph 생성
perf record -g -a -F 99 -- sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > wg-flame.svg
# 4. 캐시 미스 분석 (대용량 트래픽 시)
perf stat -e cache-misses,cache-references,instructions \
-a -- sleep 10
function_graph 추적은 상당한 오버헤드를 유발할 수 있습니다. 높은 처리량에서는 kprobe 기반 포인트 추적이나 perf 샘플링이 더 적합합니다. 또한 WireGuard의 암호 연산이 SIMD를 사용할 때는 kernel_fpu_begin()/kernel_fpu_end() 사이에서 추가 오버헤드가 발생할 수 있습니다.
tcpdump로 WireGuard 트래픽 분석
WireGuard 디버깅에서 tcpdump는 가장 즉각적인 도구입니다. 핵심은 바깥쪽(eth0)과 안쪽(wg0)을 동시에 캡처하여 비교하는 것입니다.
# 터미널 1: 바깥쪽 암호문 패킷 캡처
tcpdump -ni eth0 udp port 51820 -X -c 20
# 터미널 2: 안쪽 평문 패킷 캡처
tcpdump -ni wg0 -c 20
# 양쪽을 동시에 파일로 저장하여 비교
tcpdump -ni eth0 udp port 51820 -w /tmp/outer.pcap &
tcpdump -ni wg0 -w /tmp/inner.pcap &
# 패킷 캡처 후 분석
# 바깥쪽에서 패킷이 보이지만 안쪽에서 안 보이면:
# → 복호화 실패 또는 AllowedIPs 소스 검증 실패
# 안쪽에서 패킷이 보이지만 바깥쪽에서 안 보이면:
# → 암호화 전 단계 문제 (peer 없음, endpoint 미설정)
# WireGuard 패킷 타입 확인 (첫 바이트)
# 0x01 = Handshake Initiation
# 0x02 = Handshake Response
# 0x03 = Cookie Reply
# 0x04 = Transport Data
tcpdump -ni eth0 udp port 51820 -X | head -40
| 추적 도구 | 적합한 상황 | 오버헤드 | 주의사항 |
|---|---|---|---|
wg show |
기본 상태 확인 (핸드셰이크, 전송량) | 무시 가능 | Netlink 호출이므로 빈번한 폴링 주의 |
dynamic_debug |
핸드셰이크 문제 진단 | 낮음 | 프로덕션에서도 안전, dmesg 버퍼 주의 |
tcpdump |
패킷 도달/미도달 확인 | 중간 | 캡처량이 많으면 디스크/CPU 부하 |
bpftrace (kprobe) |
특정 함수 빈도/지연 측정 | 낮음~중간 | BTF 또는 커널 헤더 필요 |
ftrace (function_graph) |
함수 호출 체인 전체 추적 | 높음 | 고부하 환경에서는 부적합 |
perf |
CPU 핫스팟, 캐시 미스 분석 | 낮음 (샘플링) | 심볼 해석에 kallsyms 필요 |
eBPF를 활용한 WireGuard 관찰
eBPF/XDP는 WireGuard 패킷을 직접 처리하지는 않지만(WireGuard는 XDP 훅을 사용하지 않음), 관찰 도구로서 패킷 흐름과 성능 지표를 수집하는 데 유용합니다.
/* BPF 프로그램 예시: WireGuard 핸드셰이크 모니터링 (개념) */
SEC("kprobe/wg_noise_handshake_consume_initiation")
int trace_handshake_init(struct pt_regs *ctx)
{
struct event *e;
u64 ts = bpf_ktime_get_ns();
e = bpf_ringbuf_reserve(&events,
sizeof(*e), 0);
if (!e)
return 0;
e->type = EVENT_HANDSHAKE_INIT;
e->timestamp = ts;
e->pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
return 0;
}
SEC("kretprobe/wg_allowedips_lookup_dst")
int trace_lookup_result(struct pt_regs *ctx)
{
void *ret = (void *)PT_REGS_RC(ctx);
if (!ret) {
/* peer 못 찾음 → AllowedIPs miss 카운트 */
bpf_map_update_elem(&miss_count,
&zero, &one, BPF_ANY);
}
return 0;
}
문제 해결 심화와 디버깅 체크리스트
WireGuard 장애는 크게 네 범주로 나뉩니다. 각 범주마다 체계적인 진단 순서를 따르면 불필요한 시행착오를 크게 줄일 수 있습니다.
체계적 디버깅 순서
# === 1단계: 기본 상태 확인 ===
wg show wg0
ip -d link show wg0
ip addr show dev wg0
ip route show table all | grep wg0
ip rule show
# === 2단계: 외부 연결성 확인 ===
# endpoint에 UDP 도달 가능한가?
ss -ulpn | grep 51820
# 바깥쪽 패킷이 나가는가?
tcpdump -ni eth0 udp port 51820 -c 10
# === 3단계: 핸드셰이크 확인 ===
wg show wg0 latest-handshakes
# dynamic debug 활성화
echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control
dmesg -w | grep wireguard
# === 4단계: 데이터 경로 확인 ===
# 안쪽 평문 패킷이 보이는가?
tcpdump -ni wg0 -c 10
# AllowedIPs와 라우팅 교차 검증
wg show wg0 allowed-ips
ip route get 10.70.0.2 fibmatch
# === 5단계: MTU / 단편화 확인 ===
tracepath 10.70.0.1
ping -M do -s 1372 10.70.0.1
# === 6단계: 카운터 / 통계 확인 ===
wg show wg0 transfer
ip -s link show wg0
cat /proc/net/udp | grep $(printf '%04X' 51820)
| 증상 | 진단 명령 | 흔한 원인 | 해결책 |
|---|---|---|---|
RTNETLINK: Operation not supported |
modprobe wireguard |
커널 모듈 미로드 또는 커널 미지원 | 커널 5.6+ 확인, CONFIG_WIREGUARD=y/m 확인 |
| 핸드셰이크 후 즉시 끊김 | dmesg | grep wireguard |
시스템 시간 불일치 (TAI64N 검증 실패) | timedatectl 확인, NTP 동기화 |
| SSH는 되지만 SCP는 중간에 멈춤 | ping -M do -s 1372 |
MTU 문제, 중간 경로에서 ICMP 차단 | MTU를 1380 또는 1340으로 수동 설정 |
| 외부에서 접속 불가 (내부 → 외부는 됨) | wg show wg0 endpoints |
NAT 뒤 peer에 PersistentKeepalive 미설정 | PersistentKeepalive = 25 추가 |
wg-quick: ip rule 오류 |
ip rule show |
fwmark 충돌 또는 이전 설정 잔재 | wg-quick down 후 정리, Table = off 고려 |
| DNS 해석 실패 | resolvectl status |
wg-quick이 resolv.conf 덮어씀 |
PostUp에서 resolvectl 직접 사용 |
| 로밍 후 5~10초 불통 | wg show wg0 endpoints |
endpoint 갱신 지연 | 양쪽 중 한 쪽이 먼저 패킷을 보내야 학습 발생 |
wg show wg0의 latest handshake 타임스탬프가 최근(보통 2분 이내)이면 핸드셰이크는 정상입니다. 그 이후는 AllowedIPs, 라우팅, 방화벽, MTU 순서로 확인합니다. 이 순서를 지키지 않으면 "방화벽이 문제일 것"이라는 추측만 반복하게 됩니다.
자동화 모니터링 스크립트
프로덕션 환경에서 WireGuard 상태를 주기적으로 감시하는 스크립트 예시입니다.
#!/bin/bash
# wg-monitor.sh — WireGuard 상태 모니터링
# cron: */5 * * * * /usr/local/bin/wg-monitor.sh
INTERFACE="wg0"
ALERT_THRESHOLD=300 # 5분 이상 핸드셰이크 없으면 경고
check_peer() {
local pubkey="$1"
local last_hs
last_hs=$(wg show "$INTERFACE" latest-handshakes \
| grep "$pubkey" | awk '{print $2}')
if [ -z "$last_hs" ] || [ "$last_hs" -eq 0 ]; then
echo "CRITICAL: peer $pubkey - 핸드셰이크 기록 없음"
return 1
fi
local now
now=$(date +%s)
local age=$((now - last_hs))
if [ "$age" -gt "$ALERT_THRESHOLD" ]; then
echo "WARNING: peer $pubkey - 마지막 핸드셰이크 ${age}초 전"
return 1
fi
# 전송량 확인
local transfer
transfer=$(wg show "$INTERFACE" transfer \
| grep "$pubkey")
local rx=$(echo "$transfer" | awk '{print $2}')
local tx=$(echo "$transfer" | awk '{print $3}')
echo "OK: peer ${pubkey:0:8}... hs=${age}s rx=$rx tx=$tx"
return 0
}
# 인터페이스 존재 확인
if ! ip link show "$INTERFACE" > /dev/null 2>&1; then
echo "CRITICAL: $INTERFACE 인터페이스 없음"
exit 2
fi
# 모든 peer 확인
wg show "$INTERFACE" peers | while read -r pubkey; do
check_peer "$pubkey"
done
# 시스템 리소스 확인
echo "--- 시스템 상태 ---"
echo "UDP 드롭: $(cat /proc/net/snmp \
| grep Udp: | tail -1 | awk '{print $4}')"
echo "소프트IRQ 시간: $(cat /proc/softirqs \
| grep NET_RX)"
흔한 설정 실수 패턴
| 실수 패턴 | 증상 | 진단 방법 | 수정 |
|---|---|---|---|
| 양쪽 모두 endpoint 미설정 | 핸드셰이크가 영원히 시작되지 않음 | wg show에서 endpoint 필드 확인 |
최소 한 쪽은 endpoint 설정 필요 |
| 비밀키 대신 공개키를 PrivateKey에 넣음 | 핸드셰이크 실패, 오류 메시지 없음 | wg show에서 공개키가 예상과 다름 |
키 쌍 재생성: wg genkey | tee priv | wg pubkey |
| AllowedIPs에 자기 터널 IP 미포함 | 핸드셰이크 성공하지만 ping 실패 | wg show에서 allowed-ips 확인 |
peer의 AllowedIPs에 터널 IP 프리픽스 추가 |
| 전체 터널에서 fwmark 미설정 | 0.0.0.0/0 설정 후 네트워크 전체 불통 |
ip route get로 라우팅 루프 확인 |
wg-quick 사용 또는 수동 fwmark + ip rule 설정 |
| ListenPort 미개방 (방화벽) | 서버 측에서 핸드셰이크 수신 못함 | ss -ulpn, iptables -L INPUT |
UDP 51820 (또는 설정 포트) 개방 |
| DNS 경로 충돌 (wg-quick DNS) | VPN 연결 후 이름 해석 불가 | resolvectl status, cat /etc/resolv.conf |
PostUp에서 resolvectl dns wg0 DNS_IP 사용 |
| SaveConfig=true와 수동 변경 충돌 | wg-quick down 시 수동 변경 사항 덮어써짐 |
설정 파일과 런타임 상태 비교 | SaveConfig=false로 변경하거나 wg syncconf 사용 |
커널 로그 분석 가이드
WireGuard의 dynamic debug 메시지를 활성화한 후 dmesg에서 볼 수 있는 주요 로그 패턴과 해석 방법입니다.
# dynamic debug 활성화 후 핵심 로그 패턴
# 정상: 핸드셰이크 완료
# wireguard: wg0: Receiving handshake initiation from peer N
# wireguard: wg0: Sending handshake response to peer N
# → 양쪽이 정상적으로 Initiation/Response를 교환함
# 문제: 리플레이 타임스탬프 거부
# wireguard: wg0: Invalid timestamp (대략적 표현)
# → 시스템 시간이 틀려 TAI64N 검증 실패
# 해결: timedatectl set-ntp true
# 문제: peer를 찾을 수 없음
# wireguard: wg0: No peer found for given public key
# → Initiation에 포함된 정적 공개키가 등록된 peer와 불일치
# 해결: 양쪽 공개키 설정 교차 확인
# 문제: mac1 검증 실패
# → 패킷이 변조되었거나 잘못된 키로 계산됨
# 해결: 네트워크 경로에서 패킷 변조 요소 확인
# 문제: 과부하 모드 진입
# wireguard: wg0: Under load, rate limiting...
# → cookie 기반 DoS 완화가 활성화됨
# 해결: 정상 동작이며, 부하가 줄면 자동 해제
echo module wireguard -p > /sys/kernel/debug/dynamic_debug/control로 비활성화하고, 필요에 따라 로그를 정리하세요. 또한 wg showconf의 출력에는 비밀키가 포함되므로 화면 공유나 로그 수집 시 각별히 주의해야 합니다.
관련 문서
- IPSec & xfrm — 전통적인 커널 VPN / 정책 기반 터널링과 비교할 때 유용합니다.
- 라우팅 — AllowedIPs와 policy routing,
fwmark동작을 이해하는 데 필요합니다. - 네트워크 보안 — 방화벽, NAT, 상태 기반 필터와 함께 운용할 때 참고합니다.
- ethtool — offload, 드라이버 기능, NIC 특성 확인에 필요합니다.
- NAPI — WireGuard 수신 경로가 최종적으로 합류하는 GRO/NAPI 모델을 보완합니다.
- Linux Crypto Framework (Crypto API) — ChaCha20-Poly1305, BLAKE2s, Curve25519의 커널 암호 인프라입니다.
- 네트워크 네임스페이스 — birth namespace와 인터페이스 이동의 기반 개념입니다.
- GSO/GRO 네트워크 오프로드 — WireGuard 송수신 경로의 GSO 분할과 GRO 집계를 이해하는 데 필요합니다.
- ftrace/Tracepoints — WireGuard 커널 함수 추적과 성능 분석의 기반 도구입니다.
- BPF/eBPF/XDP — bpftrace를 활용한 WireGuard 관찰과 커스텀 모니터링에 참고합니다.
- Network Device 드라이버(net_device) — WireGuard가 등록하는 net_device와 ndo_start_xmit의 기반 구조입니다.
- 동기화 기법 — AllowedIPs 트라이의 RCU 보호와 keypair의 spinlock 사용을 이해하는 데 필요합니다.
- RCU — WireGuard 데이터 경로의 잠금 없는 읽기를 가능하게 하는 핵심 동기화 메커니즘입니다.
- NAT — WireGuard endpoint 뒤의 NAT 동작과 PersistentKeepalive의 필요성을 이해하는 데 참고합니다.
- Netlink — WireGuard 제어 평면이 사용하는 Generic Netlink 인터페이스의 기반입니다.
- Workqueue(CMWQ) — WireGuard의 핸드셰이크 워크큐와 암복호화 워커가 사용하는 커널 작업 큐 프레임워크입니다.