L2 Wireguard

Wireguard 소개

wireguard는 VPN 프로토콜 중 하나로 필수적인 기능만 구현되어있어 기존 ipsec 등의 vpn보다 훨씬 단순하고 직관적이다. wireguard protocol 메세지 유형은 다음 4가지 뿐이다. (출처: https://www.wireguard.com/papers/wireguard.pdf)


일반적인 흐름을 아주 단순화하면 Initiation -> Response -> Transport Data 다.

공격 방어가 필요한 경우 Cookie Reply 가 개입할 수 있는 구조다.

테스트 환경 구성

=== office-gw ===
Arch:    x86_64
OS:      Debian 13 (trixie)
Kernel:  6.12.107+deb13-amd64
WG tool: v1.0.20210914

=== home-gw ===
Arch:    aarch64
OS:      Debian 12 (bookworm)
Kernel:  6.12.95+deb12-arm64
WG tool: v1.0.20210914

=== raspberrypi ===
Arch:    aarch64
OS:      Raspbian 12 (bookworm)
Kernel:  6.18.33-v8+
WG tool: v1.0.20210914

=== fedora ===
Arch:    x86_64
OS:      Fedora 43 (Workstation Edition)
Kernel:  6.17.12-300.fc43.x86_64
WG tool: v1.0.20260223


L3 Wireguard 동작 확인




// office-gw
root@office-gw:/home/suunj# ping -c 3 10.1.1.1
PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data.
64 bytes from 10.1.1.1: icmp_seq=1 ttl=64 time=0.971 ms
64 bytes from 10.1.1.1: icmp_seq=2 ttl=64 time=1.18 ms
64 bytes from 10.1.1.1: icmp_seq=3 ttl=64 time=1.23 ms

--- 10.1.1.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 0.971/1.124/1.225/0.110 ms

// home-gw
root@home-gw:/home/debian# ping -c 3 10.1.1.2 
PING 10.1.1.2 (10.1.1.2) 56(84) bytes of data.
64 bytes from 10.1.1.2: icmp_seq=1 ttl=64 time=4.65 ms
64 bytes from 10.1.1.2: icmp_seq=2 ttl=64 time=1.36 ms
64 bytes from 10.1.1.2: icmp_seq=3 ttl=64 time=1.21 ms

--- 10.1.1.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 1.212/2.404/4.646/1.586 ms

// raspberry pi
root@raspberrypi:/home/jc3wrld999# ping -c 3 192.168.1.2
PING 192.168.1.2 (192.168.1.2) 56(84) bytes of data.
64 bytes from 192.168.1.2: icmp_seq=1 ttl=62 time=2.03 ms
64 bytes from 192.168.1.2: icmp_seq=2 ttl=62 time=1.78 ms
64 bytes from 192.168.1.2: icmp_seq=3 ttl=62 time=2.11 ms

--- 192.168.1.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 1.780/1.975/2.113/0.141 ms

// 노트북
root@fedora:/home/suunj# ping -c 3 192.168.0.2
PING 192.168.0.2 (192.168.0.2) 자료의 56(84) 바이트.
64 바이트 (192.168.0.2에서): icmp_seq=1 ttl=62 시간=1.84 ms
64 바이트 (192.168.0.2에서): icmp_seq=2 ttl=62 시간=1.87 ms
64 바이트 (192.168.0.2에서): icmp_seq=3 ttl=62 시간=1.73 ms

--- 192.168.0.2 ping 통계 ---
3 패킷이 전송됨, 3 수신됨, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 1.730/1.813/1.872/0.060 ms

Ethernet frame, IP Packet 비교

기존 Wireguard는 L3 네트워크 인터페이스 이므로 송신 시 기본적으로 IP Packet을 전달받는다.
따라서 코드 곳곳에서 wg0으로 들어오는 데이터는 ipv4 또는 ipv6 packet이다는 전제를 가지고 있다.
이걸 Ethernet frame 전체를 전달하는 것으로 수정하기 위해선 해당 부분을 수정할 필요가 있다.

1) net_device 수정
기존 wireguard 의 net_device는 IP packet을 처리하는 L3 인터페이스로 구성되어있다.

Ethernet frame 전체를 전달하기 위해서는 먼저 wireguard 인터페이스가 Ethernet device로 동작하도록 net_device 의 설정을 변경해야한다.

기존 wireguard는 device를 생성하면서 struct net_device *dev를 준비한 뒤 wireguard에 맞게 여러 필드를 직접 설정한다. 
ip link add dev wg0 type wireguard

wireguard 의 rtnl_link_ops 에는 다음과 같이 wg_set()이 setup callback으로 등록되어있다.

static struct rtnl_link_ops link_ops __read_mostly = {
    .kind      = KBUILD_MODNAME,
    .priv_size = sizeof(struct wg_device),
    .setup     = wg_setup,
    .newlink   = wg_newlink,
};

따라서 wireguard 타입의 인터페이스를 생성하면 wg_setup()을 통해 net_device의 특성이 설정된다. 

기존 wg_setup()을 보면 다음과 같이 Ethernet 장치가 아닌 L3 인터페이스에 맞는 설정을 사용하고 있다.

static void wg_setup(struct net_device *dev)
{
    ...
    dev->netdev_ops = &netdev_ops;
    dev->header_ops = &ip_tunnel_header_ops;
    dev->hard_header_len = 0;
    dev->addr_len = 0;
    ...
    dev->type = ARPHRD_NONE;
    dev->flags = IFF_POINTOPOINT | IFF_NOARP;
    ...
    wg->dev = dev;
}

Ethernet device 로 동작하도록 하기 위해선 그 dev에 ether_setup(dev);를 호출하여 Ethernet device의 기본 설정을 넣어줘야한다.
이를 위해 L2 용 setup callback 인 tapwg_setup() 을 만들어주었다.
static void tapwg_setup(struct net_device *dev)
{
    struct wg_device *wg = netdev_priv(dev);
    ...
    ether_setup(dev);
    dev->netdev_ops = &tapnetdev_ops;

    dev->needed_headroom = DATA_PACKET_HEAD_ROOM;
    dev->needed_tailroom = noise_encrypted_len(MESSAGE_PADDING_MULTIPLE);
    ...
    dev->mtu = ETH_DATA_LEN - overhead - ETH_HLEN;
    dev->max_mtu = ... - ETH_HLEN;

    eth_hw_addr_random(dev);
    ...
    wg->dev = dev;
}
ether_setup()은 net_device 에 Ethernet 장치의 기본 특성을 설정한다. 또한 Ethernet Header 가 추가되는 것을 고려하여 MTU 계산에 ETH_HLEN을 반영하고 eth_hw_addr_random() 을 이용해 인터페이스가 사용할 MAC 주소를 생성한다.

Ethernet device 는 MAC 주소를 사용하므로 L2 용 net_device_ops 에는 MAC 주소 설정 및 검증을 위한 operation 도 추가된다.
static const struct net_device_ops tapnetdev_ops = {
    .ndo_open              = wg_open,
    .ndo_stop              = wg_stop,
    .ndo_start_xmit        = wg_xmit,
    .ndo_set_mac_address   = eth_mac_addr,
    .ndo_validate_addr     = eth_validate_addr,
};
그리고 새로 만든 tapwg_setup() 을 사용하는 tapwireguard type을 등록한다.
static struct rtnl_link_ops taplink_ops __read_mostly = {
    .kind      = "tapwireguard",
    .priv_size = sizeof(struct wg_device),
    .setup     = tapwg_setup,
    .newlink   = wg_newlink,
};

tapwireguard 인터페이스를 생성한 뒤 상대방 IP로 ping을 수행해 보았다.
$ ping -c 2 10.3.0.2
PING 10.3.0.2 (10.3.0.2) 56(84) bytes of data.
From 10.3.0.1 icmp_seq=1 Destination Host Unreachable
From 10.3.0.1 icmp_seq=2 Destination Host Unreachable

--- 10.3.0.2 ping statistics ---
2 packets transmitted, 0 received, +2 errors, 100% packet loss, time 1010ms
pipe 2
통신은 실패했고 커널 로그에서는 다음과 같은 메세지가 출력되었다.
$ dmesg | tail -5
[ 8240.261685] wireguard: Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
[ 8305.474629] igc 0000:02:00.0 lan1: NIC Link is Up 1000 Mbps Full Duplex, Flow Control: RX/TX
[ 8479.290800] wireguard: tapwg0: Invalid IP packet
[ 8480.300330] wireguard: tapwg0: Invalid IP packet
[ 8481.324455] wireguard: tapwg0: Invalid IP packet
인터페이스 통계에서도 송신과정에서 오류가 발생한 것을 확인할 수 있다.
$ ip -s link show tapwg0
7: tapwg0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1406 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/ether 02:ca:fe:f0:0e:01 brd ff:ff:ff:ff:ff:ff
    RX:  bytes packets errors dropped  missed   mcast
             0       0      0       0       0       0
    TX:  bytes packets errors dropped carrier collsns
             0       0      3       0       0       0

기존 wg_xmit 경로에서는 전달받은 skb가 정상적인 IP packet인지 검사한다.
$ grep -r "Invalid IP packet" .
grep: ./wireguard.ko: binary file matches
grep: ./device.o: binary file matches
./device.c:		net_dbg_ratelimited("%s: Invalid IP packet\n", dev->name);
grep: ./wireguard.o: binary file matches
static netdev_tx_t wg_xmit(struct sk_buff *skb, struct net_device *dev)
{
	...
	if (unlikely(!wg_check_packet_protocol(skb))) {
		ret = -EPROTONOSUPPORT;
		net_dbg_ratelimited("%s: Invalid IP packet\n", dev->name);
		goto err;
	}
}
ip_tunnel_parse_protocol 에서 ipv4 또는 ipv6 packet인지 확인한다.
static inline bool wg_check_packet_protocol(struct sk_buff *skb)
{
    __be16 real_protocol = ip_tunnel_parse_protocol(skb);

    return real_protocol && skb->protocol == real_protocol;
}
이는 기존 wireguard에서는 문제가 되지 않는다.
하지만 L2 wireguard 에서는 IPv4나 IPv6 뿐 아니라 다양한 프로토콜이 들어갈 수 있다.
따라서 Ethernet frame 자체를 터널링하려면 기존의 검사 부분을 적용하지 않도록 수정한다.

어차피 수신쪽에서 최소한 Ethernet Frame으로 성립할 수 있는 길이는 따로 처리하고 있다.
static void wg_packet_consume_data_done(struct wg_peer *peer,
                                        struct sk_buff *skb,
                                        struct endpoint *endpoint)
{
    ...
    wg_timers_data_received(peer);

    if (unlikely(!pskb_may_pull(skb, ETH_HLEN)))
        	goto dishonest_packet_size;

다만 이 검사는 wg_check_packet_protocol() 을 대체하는 검사는 아니다.
송신 측의 wg_check_packet_protocol() 은 기존 IP 전용 가정을 제거하기 위해 우회한 것이고, 수신 측의 pskb_may_pull() 은 Ethernet Header 에 접근 가능한지를 확인하기 위한 별도의 검증이다.

2) l2 peer 선택 경로 추가

다시 ping 을 보내면 에러는 발생하지 않지만 여전히 ping이 가지 않는다.

bpftrace 를 돌려보면 wg_allowedips_lookup_dst 에서 -126(ENOKEY) 에러가 발생함을 알 수 있다.
#include <linux/skbuff.h>

kprobe:wg_xmit {
    $skb = (struct sk_buff *)arg0;
    printf("\n[xmit] proto=0x%04x len=%d comm=%s\n",
           bswap($skb->protocol), $skb->len, comm);
}

kretprobe:wg_xmit {
    printf("[xmit] ret=%d\n", (int64)retval);
}

kprobe:wg_* /probe != "kprobe:wg_xmit"/ {
    printf("  %s\n", probe);
}
#include <linux/skbuff.h>

kprobe:wg_allowedips_lookup_dst {
    @in = 1;
    $skb = (struct sk_buff *)arg1;
    printf("[lookup_dst] enter protocol=0x%04x\n", bswap($skb->protocol));
}
kretprobe:wg_allowedips_lookup_dst {
    @in = 0;
    printf("[lookup_dst] exit ret=%p\n", retval);
}

kprobe:wg_*, kprobe:ip_*, kprobe:ipv6_*, kprobe:skb_*, kprobe:pskb_* /@in == 1/ {
    printf("    %s\n", probe);
}

[lookup_dst] enter protocol=0x0806
[lookup_dst] exit ret=(nil)
[lookup_dst] enter protocol=0x0806
[lookup_dst] exit ret=(nil)

enter protocol=0x806->exit ret=(nil) 사이에 아무 함수도 불리지 않았다.
즉 lookup_dst 가 내부 lookup() 조차 안 부르고 바로 NULL을 리턴했음을 알 수 있다.
/* Returns a strong reference to a 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 기반 peer 선택 또한 IPv4, IPv6만 지원하는 것을 확인할 수 있다.
ARP, PPPoE 같은 L2 Frame 에는 이 방식으로 사용할 IPv4/IPv6 목적지 주소가 없다.




이제 기존 L3 기반 패킷 프레임을 분석해보면서 코드상에 어느 부분을 수정해야할지 확인해보자.
raspberry pi에서 다음과 같이 office-gw쪽으로 ping을 날리고 office-gw에서 tcpdump로 패킷을 확인하면
ping -c1 -s 1 192.168.1.1    # IP 20 + ICMP 9 = 29
ping -c1 -s 4 192.168.1.1    # 32 → 패딩 0
ping -c1 -s 5 192.168.1.1    # 33 → 48로 패딩
ping -c1 -s 20 192.168.1.1   # 48
ping -c1 -s 21 192.168.1.1   # 49 → 64

  • UDP len 0x48=72 (8+64)
    • UDP Header len : 8 byte
    • Wireguard Data len : 64 byte
  • WG type 0x04
  • reveiver index 0xf325ec1c
/* receive.c */
PACKET_CB(skb)->keypair = (struct noise_keypair *)wg_index_hashtable_lookup(
    wg->index_hashtable, INDEX_HASHTABLE_KEYPAIR,
    ((struct message_data *)skb->data)->key_idx,    /* ← f325ec1c */
    &peer);
  • counter 0x01
  • encrypted data 32 byte (ip 29 byte + padding 3 byte)
  • poly1305 tag 16 byte
Encrypted Data 에는 페이로드가 암호화되어있고 남은 부분은 패딩으로 처리되어있다. 
  • IPv4 (0100) / Header len = 5 (0101) = 20 byte
  • TOS (0x00) / tot_len (0x1d = 29)
  • TTL 63 (0x3f) / ICMP (0x01)
  • src ip (c0a8 0002) / dst ip (c0a8 0101)
  • ICMP 9 byte (0800 fe45 f9b8 0001 00)
다른 터미널에서 icmp 패킷을 캡쳐해보면 ip header를 통해 페이로드 길이는 29 byte 라는 걸 알 수 있다.
하지만 Ethernet header엔 길이 필드가 없다. 그래서 페이로드 길이를 알려면 ethertype(0800)을 보고 그 뒤 IP 헤더의 001d를 읽어서 14 (dst MAC 6 byte + src MAC 6 byte + ethertype 2 byte) + 29 = 43을 계산해야한다. 

	if (peer->device->is_tap) {
		if (unlikely(!pskb_may_pull(skb, ETH_HLEN)))
			goto dishonest_packet_size;
		skb->dev = dev;
		skb->ip_summed = CHECKSUM_UNNECESSARY;
		skb->csum_level = ~0;
		skb->protocol = eth_type_trans(skb, dev);
		len = skb->len;                 /* ← 여기가 패딩 포함 그대로 */
		len_before_trim = skb->len;
		...

			skb->protocol = eth_type_trans(skb, dev);
		skb_reset_network_header(skb);          /* ip_hdr()가 맞게 잡히도록 */
		len_before_trim = skb->len;             /* 패딩 포함, 통계용 */

		len = tapwg_payload_len(skb);           /* 이더넷 헤더 뒤 실제 길이 */
		if (unlikely(len > skb->len))
			goto dishonest_packet_size;
		if (unlikely(pskb_trim(skb, len)))
			goto packet_processed;

static unsigned int tapwg_payload_len(struct sk_buff *skb)
{
	switch (skb->protocol) {
	case htons(ETH_P_IP):
		if (!pskb_may_pull(skb, sizeof(struct iphdr)))
			return skb->len;
		return ntohs(ip_hdr(skb)->tot_len);
	case htons(ETH_P_IPV6):
		if (!pskb_may_pull(skb, sizeof(struct ipv6hdr)))
			return skb->len;
		return sizeof(struct ipv6hdr) + ntohs(ipv6_hdr(skb)->payload_len);
	case htons(ETH_P_ARP):
		return sizeof(struct arphdr) + 20;   /* Ethernet/IPv4 ARP = 28 */
	default:
		return skb->len;                      /* 모르는 건 패딩 못 뗌 */
	}
}


VPN L2 확장


1) net_device 를 Ethernet 방식으로 변경

2) Ethernet frame 통신 확인
실제 Ethernet frame이 wg_xmit()으로 들어오는 것을 확인해보자.
ARP 의 경우 Invalid IP Packet 에러 발생

문제되는 부분은 기존 wireguard 는 ip 전용이기 때문에 protocol check에서 ipv4/ipv6가 아닌 프로토콜은 드롭시킴

3) protocol 처리 수정 (L2 protocol 까지 고려하여 xmit 경로 코드 수정)

4) peer 선택 처리 부분 수정

5) packet length 부분 수정

L2 Wireguard 통신 확인













댓글

이 블로그의 인기 게시물

ELF 구조부터 로딩까지

VM escape - QEMU의 메모리를 읽는 두 가지 방법