WireGuard로 멋진 VPN 서버 구축하기 - 2

박재현

안녕하세요, 저는 인프라셀에서 데브옵스 엔지니어로 일하고 있는 박재현이라고 합니다. 이 글은 앞선 1부의 글에서 이어지는 내용입니다. 1부에서는 WireGuard에 대한 설명과, 커널 네트워킹 스택, 그리고 AWS의 네트워킹 스택에 대한 내용을 다루었습니다.

2부에서는 VPN 서버에서 사용하는 인증eBPF를 이용한 패킷 필터링, 그리고 eBPF를 이용한 유저 편의기능을 소개합니다.

공식 WireGuard 로고

남은 요구사항

앞서서 1부에서 다루었던 요구사항에 대한 해결 방법은 다음과 같았습니다.

  • 그룹 별 접근 가능한 사설망 자원 접근 제어
  • 유저마다 고유한 아이피로 사설망에 접근

그리고 이번 글에서는 다음과 같은 요구사항들을 다룹니다.

  • Keycloak SSO에 인증 연동
  • 키를 일정 시간만 사용 가능하게 하도록 하기, 활성화되지 않은 키에 대한 경고 알림 보내주기
  • 기타 보안 및 편의 기능, 관리자 UI

인증

기존에 사용하던 OpenVPN에서는 VPN 서버에 연결을 시작할 때 인증을 수행합니다. 반면, WireGuard는 기본적으로 그러한 인증 방식이 아닌 공개키 기반 인증을 사용하므로, 서버와 클라이언트 측이 서로를 인지하고 있고(즉, Peer로서 추가가 되어있고), 비밀키를 각자 잘 가지고 있다면 별다른 유저 개입 없이 바로 연결을 할 수 있습니다. 이는 편리함으로 다가올 수 있지만, 보안 허점으로 다가올 수 있습니다. 예를 들면, 키 파일이 유출되었을 때 제 3자가 별다른 인증 없이 바로 사설망에 접근할 수 있게 되고, 유출이 되었다는 사실을 곧바로 알아차리기가 힘듭니다. 그래서 저희의 구현체는 다음과 같은 흐름을 따르기로 했습니다.

  1. Keycloak SSO를 사용해서 Web UI에 로그인
  2. Web UI에서 유저의 디바이스를 "활성화"
  3. 활성화된 디바이스는 일정 시간동안 연결을 유지할 수 있음

위와 같은 흐름을 따르면, 키가 유출되었어도 Web UI에 로그인해서 활성화하지 못한다면 사설망에 접속할 수 없게 됩니다. 만약 활성화되어 있는 키라고 할지라도, 일정 시간이 지나면 만료되어 다시 활성화할 때까지 사용할 수 없기 때문에 키 유출에서부터 안전합니다. 그리고 Web UI에서 Keycloak SSO를 연동하는 것은 인증 프록시를 이용해서 매우 쉽고 안전하게 구현할 수 있습니다.

1 web ui
Web UI에서 디바이스를 활성화하고 관리하는 모습

디바이스 단위로 키를 가지도록 나누어놓았기 때문에, 유저는 각자 사용하고 싶은 기기에서 설정 파일을 발급받아서 WireGuard 어플리케이션에서 임포트하여 사용할 수 있습니다. 따라서 여러 기기에서 사용하기 위해 설정 파일을 외부 저장장치에 가지고 다니거나 네트워크로 전송할 필요가 없습니다. 만약 디바이스를 분실하거나 키 파일이 유출되었을 경우, Web UI에 로그인하여 디바이스를 삭제해주기만 하면 간단하게 키를 무효화할 수 있기 때문에, 보안 사고에도 재빠르게 대응이 가능하게 됩니다.

키 활성화 및 만료

위 인증 단락에서 디바이스를 "활성화" 한다는 표현을 사용했었는데요, 사실은 WireGuard에서는 따로 키를 "활성화"하는 개념이 존재하지 않습니다. Peer로서 추가가 되어있고 공개키 기반으로 인증만 성공한다면 무조건 통신을 할 수 있습니다. 하지만 키 유출 등에 대비하기 위해서 유저가 직접 통신을 시작하고 싶다는 의사를 밝힌 후에 연결을 할 수 있게 하고, 일정 시간이 지나면 더 이상 연결이 불가능하도록 만들면 이상적일 것입니다.

이 기능 구현의 핵심은 "어떤 방식으로 활성화되지 않은 디바이스의 패킷을 차단할 것인가" 입니다. 이 목적을 달성할 방법은 여러 가지가 있습니다. 리눅스 라우팅 테이블에서 유저의 고유 아이피만 골라서 drop할 수도 있고, 서버 측 WireGuard Peer 리스트에서 대상 Peer을 제외해도 됩니다. 두 가지 방법 모두 효율적이게 패킷을 차단할 수 있지만, VPN 서버를 직접 구현하는 입장에서는 더 구현하기 편하고 효율적이며 확장성 있는 방법을 사용하고 싶었습니다.

eBPF

그래서 eBPF라는 기술을 사용하기로 결정했습니다. eBPF라는 단어를 처음 들어보시는 분들이 많으실 것입니다. eBPF를 한 줄로 설명하기는 어렵지만, 간단하게 설명하자면, 리눅스 커널의 여러 부분에서 실행할 수 있는 샌드박스된 바이트코드입니다. 원래라면 커널 공간에서 유저의 코드를 실행하는 것은 보안 상으로 매우 위험할 수 있는 데다가, 잘못하면 시스템이 크래시될 수 있기 때문에 금기시되는 행동 중 하나입니다. 하지만 eBPF의 바이트코드 인스트럭션은 샌드박스되어 안전한 실행을 보장하고, 바이트코드가 로드 되기 전에 eBPF Verifier라는 커널의 로직이 코드가 실행되어도 안전한지(out-of-bound read, infinite loop 등)를 검증하기 때문에 유저의 커스텀한 로직을 커널 공간에서 실행하기에 매우 적합한 수단입니다. 또한 eBPF 바이트코드는 간단한 인스트럭션 집합으로 구성되기 때문에, 커널에서 JIT을 통해 코드 실행을 가속화해주기도 합니다.

2 ebpf
eBPF의 로고

이러한 eBPF의 특성 때문에 여러 커널 구성요소가 유저의 eBPF 코드를 내부적으로 실행할 수 있도록 하는 기능을 내장하고 있습니다. 그 중 대표적으로 네트워크 스택의 많은 부분에서 eBPF 코드를 사용할 수 있도록 해주는데, 대표적인 것으로 XDPtc-bpf가 있습니다. XDP는 커널에서 네트워크 버퍼를 할당하기 이전에 패킷에 대한 제어를 할 수 있도록 해줍니다. 반면 tc네트워크 인터페이스의 Egress나 Ingress에서 패킷에 대한 제어를 할 수 있게 도와줍니다. 이러한 특성 때문에 XDPtc에 비해서 매우 빠르게 패킷을 eBPF 코드를 통해서 필터링하거나 조작할 수 있게 해줍니다.

하지만 우리는 유저별로 패킷을 드랍하거나 허용해주고 싶습니다. 우리가 유저를 이전에 고유한 IP로 통신을 할 수 있도록 해놓았으니 Source IP로 유저를 구분할 수 있을 것입니다. 하지만 이 정보는 외부에서 바로 들어온 패킷이 아닌 WireGuard에서 암호화된 패킷을 복호화한 뒤에 알 수 있는 특성이므로, 아쉽게도 XDP 단에서는 구분이 힘들 것입니다. 따라서 이전에 우리가 만들어둔 WireGuard 네트워크 인터페이스에서 tc의 eBPF 기능을 사용해서 구분을 해야할 것으로 보입니다.

여기서 tc에 대한 설명을 간단하게 하자면, tciproute2에 포함된 요소 중 하나로, 네트워크 인터페이스 단에서 트래픽에 대한 여러 가지 제어를 해줄 수 있도록 도와줍니다. 예를 들면, 네트워크 인터페이스로 들어오고 나가는 트래픽의 대역폭을 조절하거나, 임의로 레이턴시를 조절하거나, 특정 조건에 맞는 패킷에 대해서 drop, mirror 등의 액션을 취하거나 하는 여러 역할을 수행할 수 있게 해줍니다. tc의 구성 요소는 크게 세 가지로 나눌 수 있습니다.

  • qdisc

    • 네트워크 인터페이스에 붙은 패킷 큐(Queue). 다양한 종류의 큐를 정의할 수 있습니다.
    • 상태가 없는 classless qdisc와 상태를 가지고 하위 class를 가질 수 있는 classful qdisc로 분류됩니다.
  • class

    • 트래픽 분류 기준을 만들어주는 요소.
    • classful qdisc에 붙거나 class 아래에 붙을 수 있습니다.
  • filter

    • classful qdisc에서 패킷이 어떤 class 쪽으로 인입될지 결정하게 해주는 요소.
    • 간단하게 패킷 경로 분기점이라고 생각하면 됩니다.

이러한 구성요소를 특성에 맞게 조합해서 원하는 효과를 내도록 만들 수 있습니다. 그 중 우리가 사용할 기능으로는, tc에 eBPF 코드를 붙여서, eBPF가 패킷의 운명을 결정할 수 있도록 해주는 기능입니다. 따라서 filter의 한 종류인 tc-bpf를 사용합니다.

원래 tc의 eBPF 코드는 패킷을 "분류"해주는 역할이 주된 업무였습니다. 예를 들면, 특정 목적지로 가는 패킷은 대역폭이 작은 class로, 나머지는 대역폭이 큰 class로 보내는 등의 기능 정도를 수행할 수 있었습니다. 그래서 특정 패킷을 drop하거나 리다이렉션 해주는 등의 "액션"을 취하는 역할은 원래라면 지원이 되지 않았습니다. 하지만 리눅스 커널 버전 4.1 버전부터 direct-action 이라는 플래그를 사용하여 eBPF 프로그램이 tc에서 액션을 취할 수 있게 되도록 패치되었고, 4.5 버전부터는 clsact 라는 qdisc를 통해서 더 쉽게 direct-action eBPF 프로그램을 붙일 수 있게 되었습니다. 따라서 저희의 VPN에서 사용할 eBPF 프로그램 또한 WireGuard 인터페이스에 clsact qdisc를 추가한 뒤, eBPF 프로그램을 로드시킬 것입니다.

eBPF 프로그램은 바이트코드이기 때문에, eBPF 타겟으로 컴파일해주는 도구가 필요합니다. 여러 가지 방법이 있지만, 그 중 가장 쉬운 방법은 LLVM 컴파일러의 BPF 백엔드를 사용하는 것입니다. 이 기능은 LLVM 3.7 릴리즈부터 추가되었습니다. 즉, 프로그램을 LLVM으로 컴파일 할 수 있는 도구가 있다면, LLVM 컴파일러가 eBPF 바이트코드를 생성해줄 수 있다는 의미입니다. 따라서 다음과 같은 간단한 tc eBPF 프로그램으로 실험을 해볼 수 있습니다.

#include <linux/ip.h>
#include <linux/if_ether.h>
#include <linux/pkt_cls.h>
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

SEC("classifier_dropper")
int dropper(struct __sk_buff *skb) {
    void *data_end = (void *)(unsigned long long)skb->data_end;
    void *data = (void *)(unsigned long long)skb->data;
    struct ethhdr *eth = data;

    int ipsize = sizeof(*eth);
    ipsize += sizeof(struct iphdr);

    // drop malformed ip packet
    if (data + ipsize > data_end) {
        return TC_ACT_SHOT;
    }

    return TC_ACT_OK;
}

위 코드가 처음엔 난해해보일 수 있지만, 수행하는 기능은 매우 간단합니다.

  1. 함수 dropper은 네트워크 버퍼인 skb 를 인자로 받습니다.
  2. skb->data 에서부터 이더넷 헤더와 IP 헤더만큼의 사이즈만큼 양의 방향으로 이동한 포인터 값을 계산합니다.
  3. 만약 2의 값이 데이터의 끝부분을 가리키는 skb->data_end 보다 크다면, 처리할 수 없는 이상한 모양의 패킷이므로 TC_ACT_SHOT 이라는 값을 리턴하여 이 패킷은 드랍해야 함을 알려줍니다.
  4. 만약 그렇지 않다면 TC_ACT_OK 라는 값을 리턴하여 이 패킷은 허용함을 알려줍니다.

그리고 위 코드를 다음 명령어로 컴파일해봅니다.

clang -O2 -g -Wall -Werror -target bpf -c example.c -o example.o

그러면 example.o 라는 오브젝트 파일이 결과물로 생성되는데, 이제 이 오브젝트 파일을 eBPF 프로그램으로서 tc 에서 로드하여 쓰도록 할 수 있습니다.

tc qdisc add dev eth0 clsact
tc filter add dev eth0 ingress bpf direct-action obj example.o sec classifier_dropper

위 명령어를 수행했다면 eth0 의 qdisc에서 들어오는 패킷 하나하나를 우리의 eBPF 프로그램에 통과시켜줄 것입니다.

위 방식대로 eBPF 코드를 tc에서 사용할 수 있는 것을 검증했으니, 이제 다시 우리의 요구사항으로 돌아와봅시다. 우리가 원하는 것은 조건에 따라서 유저의 패킷을 드랍하거나 허용해주는 것입니다. 하지만 위 예제 코드는 네트워크 버퍼의 모양만 보고 패킷의 운명을 결정해야 하기에 유저 공간 어플리케이션인 우리의 VPN 서버의 의사가 개입될 여지가 없습니다. 이런 요구사항을 위해서 eBPF에는 특별한 자료구조가 하나 있는데, 그것이 바로 eBPF 맵(Map)이라는 자료구조입니다.

3 ebpf map
eBPF Map

eBPF 맵은 유저 공간 어플리케이션과 커널 공간에서 실행되는 eBPF 프로그램이 서로 정보를 공유할 수 있도록 해주는 특별한 커널 자료구조로, 유저 공간 어플리케이션이 맵을 할당한 뒤 eBPF 프로그램을 로드할 때 맵의 정보를 넘겨주어서 서로 같은 맵에서 통신할 수 있도록 해줍니다. 맵을 참조하기 위해서는 file descriptor을 사용하게 되고, 같은 file descriptor를 공유하면 같은 맵을 가리킬 수 있습니다. 이름에서 알 수 있듯이, eBPF 맵은 Key-Value로 자료를 저장하는 자료구조입니다. 맵의 종류는 매우 다양하지만 이 글에서는 설명하지 않고, 가장 기본적인 형태의 해시맵인 BPF_MAP_TYPE_HASH 를 사용할 것입니다.

eBPF 맵을 만들 때에는 보통 유저 공간에서 bpf syscall을 이용해서 만들게 됩니다. 하지만 직접 이렇게 system call을 호출해서 만들기보다는 보통 보조 도구들을 이용하게 됩니다. 다음과 같이 프로그램 코드에 우선 맵을 정의해줍니다.

struct bpf_map_def SEC("maps") allow_map = {
    .type = BPF_MAP_TYPE_HASH,
    .key_size = sizeof(__u32),
    .value_size = sizeof(__u32),
    .max_entries = 65536,
};

SEC("maps") 부분을 눈여겨보시길 바랍니다. 위처럼 맵 정의를 하고 컴파일을 하고 나면, ELF 섹션의 maps 에 맵 정의를 포함시키게 됩니다. 그러면 나중에 유저 공간에서 이 eBPF 프로그램을 로딩할 때, 이 섹션의 맵 정의들을 모두 읽고 bpf syscall을 통해 맵을 생성해서 맵의 file descriptor을 따로 저장하여 나중에 맵을 사용할 때 참조할 수 있게 됩니다. eBPF를 사용하기 쉽게 해 주는 보조 라이브러리들은 여러 가지가 있지만, 결론은 매우 쉽게 맵을 생성하고 사용할 수 있다는 의미입니다.

eBPF로 키 만료 구현하기

그래서 우리의 eBPF 프로그램에서는 맵을 이렇게 사용하기로 합니다.

  1. allow_map 맵의 Key는 유저의 고유 IP를 사용하고, Value는 1 or 0 를 가집니다.
  2. eBPF 프로그램에서는 패킷의 Source IP를 보고 맵을 참조합니다.

    1. 만약 항목이 존재하지 않는다면, 패킷을 드랍합니다.
    2. 만약 Value가 1 이라면, 패킷을 허용합니다.
    3. 만약 0이거나 다른 값이라면(invalid state), 패킷을 드랍합니다.

그리고 우리의 VPN 서버에서는 다음과 같이 구현합니다.

  1. 유저가 키를 활성화하면, allow_map의 항목을 1로 바꿔줍니다.
  2. 주기적으로 유저의 키가 활성화된 이후에 얼마나 시간이 흘렀는지 체크합니다.
  3. 만약 일정 시간이 지났다면, allow_map 의 항목을 0 으로 바꿔줍니다.

위 과정을 따르면 아주 쉽게 조건부로 패킷을 허용하거나 드랍할 수 있습니다. 다음 코드는 위 조건을 적용한 eBPF 프로그램 코드의 예시입니다.

#include <linux/ip.h>
#include <linux/if_ether.h>
#include <linux/filter.h>
#include <linux/pkt_cls.h>
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

struct bpf_map_def SEC("maps") allow_map = {
    .type = BPF_MAP_TYPE_HASH,
    .key_size = sizeof(__u32), // ipv4
    .value_size = sizeof(__u32),
    .max_entries = 65536,
};

unsigned long long load_word(void *skb, unsigned long long off)
    asm("llvm.bpf.load.word");

SEC("classifier_dropper")
int dropper(struct __sk_buff *skb) {
    void *data_end = (void *)(unsigned long long)skb->data_end;
    void *data = (void *)(unsigned long long)skb->data;
    struct ethhdr *eth = data;

    int ipsize = sizeof(*eth);
    ipsize += sizeof(struct iphdr);

    // drop malformed ip packet
    if (data + ipsize > data_end) {
        return TC_ACT_SHOT;
    }

    __u32 saddr = load_word(skb, BPF_NET_OFF + offsetof(struct iphdr, saddr));
    __u32 *allow_val;
    // read `allow_map` to figure out if source address is allowed
    allow_val = bpf_map_lookup_elem(&allow_map, &saddr);
    if (!allow_val) {
        // key not found, so drop packet
        return TC_ACT_SHOT;
    }
    if (*allow_val == 1) {
        // value is 1, so allow packet
        return TC_ACT_OK;
    } else {
        // otherwise, drop packet
        return TC_ACT_SHOT;
    }
}

그리고 VPN 서버 측에서 맵을 적절히 잘 업데이트해주는 로직을 넣어주면, 키 활성화 / 만료 로직을 쉽게 구현할 수 있습니다. 이렇게 하여 유연하면서도 고성능의 패킷 필터링 로직을 쉽게 VPN 서버에 통합시킬 수 있었습니다.

활성화되지 않은 연결 경고

OpenVPN은 연결을 활성화할 때마다 아이디와 패스워드를 입력해서 인증을 합니다. 따라서 유저는 인증을 했다면 당연히 연결이 활성화되었을 것이라고 생각합니다. 이는 OpenVPN을 사용할 때에는 맞지만, 우리가 만든 WireGuard VPN 솔루션에서는 적용되지 않습니다. 왜냐하면 우리의 접근 방식은 다음과 같기 때문입니다.

  1. WireGuard Peer 인증에 필요한 키는 유저 개개인이 가지고 있는다.
  2. 연결은 되지만 "키 만료" 상태라면 패킷은 드랍한다.
  3. 키를 활성화하기 위해서는 Web UI에서 Keycloak SSO로 인증을 하는 과정을 거쳐야한다.

따라서 WireGuard 앱에서 연결을 활성화하고 연결이 바로 되는 모습을 보고 사설망에 접근할 수 있다고 생각할 수 있지만, Web UI에서 키를 활성화한 상태가 아니라면 패킷은 조용히 드랍되고, 유저는 통신이 되지 않는다는 사실에 충격과 혼란에 빠지게 될 것입니다. 따라서 키가 활성화된 상태가 아닌데 연결을 해서 패킷이 드랍되고 있다는 사실을 유저에게 알려주어야 유저가 불편함을 느끼지 않을 것입니다.

척 보기에는 꽤 힘든 작업이라고 생각되지만, 위에 만들어둔 eBPF 프로그램을 이용하면 단숨에 해결할 수 있습니다. 왜냐하면 패킷을 드랍하는 주체는 tc에 붙은 eBPF 프로그램이기 때문에, 패킷을 드랍한 순간 유저 공간에서 돌고 있는 우리의 VPN 서버에게 알려주면 됩니다. eBPF가 서버에게 알려주는 수단으로는 역시 eBPF 맵을 사용합니다.

자세한 구현 방식은 여러 가지가 있겠지만, 다음과 같은 방식을 사용하기로 결정합니다.

  1. eBPF 프로그램에서 "키 만료"에 의해서 패킷이 드랍되면, dropped_map 맵에 드랍된 패킷 개수를 기록합니다.
  2. 유저 공간 VPN 서버에서는 dropped_map 맵의 패킷 개수를 지켜보다가, 드랍된 패킷이 감지되면 그 유저에게 슬랙 알림을 보내줍니다.
  3. 만약 유저가 그에 반응해 키를 활성화해서 eBPF 프로그램에서 패킷을 허용하게 되면, dropped_map 의 엔트리를 초기화해줍니다.

위와 같이 구현을 해서 테스트를 하였고, 키가 만료된 상태에서 연결을 한 경우에 슬랙 알림을 잘 받아볼 수 있었습니다.

4 slack notification
키가 만료된 상태에서 VPN 서버에 패킷을 보내면 오는 슬랙 알림

결론

OpenVPN을 사용하며 느낀 불편한 점들을 개선하면서 원래 사용하던 목적에도 잘 맞는 VPN 솔루션을 만들 수 있었습니다.

  • WireGuard를 사용하여 안전하고 빠른 연결을 가능하게 함
  • KeyCloak SSO와 연동하여 관리 코스트를 줄임
  • 커널 네트워킹 스택을 활용하여 그룹 별 접근 제어를 구현함
  • AWS 네트워크 스택을 잘 이용해서 유저 별 고유 IP로 액세스 로그가 남을 수 있도록 함
  • eBPF를 사용하여

    • 키 활성화 / 만료 로직을 구현함
    • 활성화되지 않은 연결에 대해 슬랙 알림을 유저에게 보내줌
    • 패킷 처리에 대해 고성능성과 확장성까지 갖추게 됨

많은 기술들을 동원한 재미있는 경험이었다고 생각합니다. 긴 글 읽어주셔서 감사합니다.

데브시스터즈는 최고의 인재를 찾고 있습니다.

데브시스터즈에서는 능력있는 DevOps Engineer (경력)DevOps Engineer (신입)를 찾고 있습니다.
자세한 내용은 채용 사이트를 확인해주세요!
DevOpsinfra채용

© 2024 Devsisters Corp. All Rights Reserved.