Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일
 

(시리즈 글이 2개 있습니다.)
Linux: 124. eBPF - __sk_buff / sk_buff 구조체
; https://www.sysnet.pe.kr/2/0/14019

Linux: 130. eBPF - bpf_skb_load_bytes를 이용한 __sk_buff.data 영역의 IP/TCP 헤더 해석
; https://www.sysnet.pe.kr/2/0/14038




eBPF - bpf_skb_load_bytes를 이용한 __sk_buff.data 영역의 IP/TCP 헤더 해석

지난 글에서,

eBPF - __sk_buff / sk_buff 구조체
; https://www.sysnet.pe.kr/2/0/14019

__sk_buff 구조체에 대한 bpf_skb_load_bytes 함수의 반환값을 해석할 수 없다고 했는데요,

SEC("socket")
int socket_handler(struct __sk_buff *skb)
{
    __u16 proto;

    bpf_skb_load_bytes(skb, 12, &proto, 2);
    proto = __bpf_ntohs(proto); // TCP 소켓 예제에서 출력된 h_proto 값들
                                // 비정상적인 값 출력
                                // bpf_trace_printk: __sk_buff.h_proto == 8019
                                // bpf_trace_printk: __sk_buff.h_proto == b010
                                // bpf_trace_printk: __sk_buff.h_proto == 5014
                                // bpf_trace_printk: __sk_buff.h_proto == 5004

    return skb->len;

/* 실제 proto 값의 예:
0x0800 for IPv4
0x0806 for ARP
0x86DD for IPv6
*/
}

그래도 뭔가, 값이 고정적으로 나오는 것 같아서 한 번 더 확인해 봤습니다. 가만 보니까, bpf_skb_load_bytes 말고 또 다른 함수가 있는데요,

bpf_skb_load_bytes_relative
; https://docs.ebpf.io/linux/helper-function/bpf_skb_load_bytes_relative/

그러니까, __sk_buff.data의 BPF_HDR_START_MAC과 BPF_HDR_START_NET 헤더를 상대적으로 접근할 수 있게 해주는데, 실제로 이걸로 했더니 값이 제대로 나왔습니다.

SEC("socket")
int socket_handler(struct __sk_buff *skb) {
   __u16 proto;

   // 기존 코드를 주석 처리
   // bpf_skb_load_bytes(skb, 12, &proto, 2);

   // 새롭게 bpf_skb_load_bytes_relative를 이용해 MAC 헤더 기준으로 protocol offset 지정
   bpf_skb_load_bytes_relative(skb, 12, &proto, 2, BPF_HDR_START_MAC);
   proto = __bpf_ntohs(proto);
  
   bpf_printk("__sk_buff.h_proto == %04x, protocol == %04x\n", proto, __bpf_htons(skb->protocol));

   return skb->len;
}

/* 출력 결과:
__sk_buff.h_proto == 0800, protocol == 0800
*/




그렇다면 bpf_skb_load_bytes와 어떤 차이가 있는 걸까요? 이를 알아보기 위해 2개의 함수 모두 offset 0부터 48바이트를 읽어 출력해 비교했는데,

char buff_org[48];
bpf_skb_load_bytes(skb, 0, buff_org, 48);

char buff_rel[48];
bpf_skb_load_bytes_relative(skb, 0, buff_rel, 48, BPF_HDR_START_MAC);

다음과 같이 정리됩니다.

// buff_org
00,50,ec,04,d4,0c,15,af,15,ee,4d,ec,80,19,08,21,44,a9,00,00,01,01,08,0a,8d,82,2d,27,5c,51,00,cf,48,54,54,50,2f,31,2e,31,20,32,30,30,20,4f,4b,0d

// buff_rel
00,15,5d,28,cf,04,00,15,5d,00,05,06,08,00,45,00,03,fe,0a,24,40,00,80,06,6b,44,c0,a8,00,24,c0,a8,00,1d,00,50,ec,04,d4,0c,15,af,15,ee,4d,ec,80,19

그러니까, bpf_skb_load_bytes_relative는 MAC 헤더로부터 내용을 출력한 반면 bpf_skb_load_bytes는 그로부터 다소 떨어진 위치의 내용을 반환하고 있었던 것입니다. 그렇다면 정확히 어떤 위치인지 확인해 보기 위해 헤더에 따라 계산을 해보았습니다.

방법은, ethhdr, iphdr, tcphdr 구조체에 따라 buff_rel의 내용을 이렇게 분석해 나가면 됩니다.

struct ethhdr {
        unsigned char h_dest[6];    // 00,15,5d,28,cf,04
        unsigned char h_source[6];  // 00,15,5d,00,05,06
        __be16 h_proto;             // 08,00
};

struct iphdr {
                            // 0x45 == 0100 0101
        __u8 ihl: 4;        // == 0101 == 5 (헤더 길이, 5 * 4 = 20바이트)
        __u8 version: 4;    // == 0100 == 4 (IPv4 버전)

        __u8 tos;           // 00
        __be16 tot_len;     // 03,fe
        __be16 id;          // 0a,24
        __be16 frag_off;    // 40,00
        __u8 ttl;           // 80
        __u8 protocol;      // 06 (TCP)
        __sum16 check;      // 6b,44
        union {
                struct {
                        __be32 saddr; // c0,a8,00,24
                        __be32 daddr; // c0,a8,00,1d
                };
                struct {
                        __be32 saddr;
                        __be32 daddr;
                } addrs;
        };
};

struct tcphdr {
        __be16 source;  // 00,50
        __be16 dest;    // ec,04
        __be32 seq;     // d4,0c,15,af
        __be32 ack_seq; // 15,ee,4d,ec

                        // 0x80 == 1000 0000
        __u16 res1: 4;  // == 1000
        __u16 doff: 4;  // == 0000 (데이터 오프셋, 0 * 4 = 0바이트)
        __u16 fin: 1;
        __u16 syn: 1;
        __u16 rst: 1;
        __u16 psh: 1;
        __u16 ack: 1;
        __u16 urg: 1;
        __u16 ece: 1;
        __u16 cwr: 1;
        __be16 window;
        __sum16 check;
        __be16 urg_ptr;
};

아하~~~ bpf_skb_load_bytes가 반환한 위치는 TCP 헤더의 시작 부분이었던 것입니다. 이에 비춰 아래의 글에 나온 소스 코드를 다시 볼까요?

L7 Tracing with eBPF: HTTP and Beyond via Socket Filters and Syscall Tracepoints
; https://eunomia.dev/en/tutorials/23-http/

SEC("socket")
int socket_handler(struct __sk_buff *skb)
{
    struct so_event *e;
    __u8 verlen;
    __u16 proto;
    __u32 nhoff = ETH_HLEN;
    __u32 ip_proto = 0;
    __u32 tcp_hdr_len = 0;
    __u16 tlen;
    __u32 payload_offset = 0;
    __u32 payload_length = 0;
    __u8 hdr_len;

    bpf_skb_load_bytes(skb, 12, &proto, 2);
    proto = __bpf_ntohs(proto);
    if (proto != ETH_P_IP)
        return 0;

    // ...[생략]...

    return skb->len;
}

현재 기준으로 보면 저런 동작이 가능했던 것이 도저히 설명이 안 됩니다. 아마도 당시의 리눅스 커널은 저게 가능했었다고... 생각하고 지나가야 할 것 같습니다. (혹시 이에 관한 이력을 아시는 분은 덧글 부탁드립니다. ^^)

아무튼, 저 코드는 이제 다음과 같은 식으로 작성해야 동작합니다.

// TCP header, TCP header size, TCP checksum mechanism, TCP header structure, options, and format
// https://www.noction.com/blog/tcp-header

static void print_sk_buff(char* title, struct __sk_buff *skb) {
    struct iphdr iph;
    long result = bpf_skb_load_bytes_relative(skb, 0, &iph, sizeof(struct iphdr), BPF_HDR_START_NET);
    if (result != 0) {
        bpf_printk("[%s]: unexpected-packet = %d", title, result);
        return;
    }

    if (iph.protocol != IPPROTO_TCP) {
        bpf_printk("[%s]: !tcp_packet(protocol = %d)", title, iph.protocol);
        return;
    }

    __u8 ip_header_length = iph.ihl * 4; // IP Header 크기

    struct tcphdr tcph;
    result = bpf_skb_load_bytes_relative(skb, ip_header_length, &tcph, sizeof(struct tcphdr), BPF_HDR_START_NET);
    __u8 tcp_header_length = tcph.doff * 4; // TCP Header 크기

    __u32 ip_tcp_header_legnth = ip_header_length + tcp_header_length; // (IP Header + TCP Header) 크기

    __u32 total_packet_length = __bpf_ntohs(iph.tot_len); // 패킷의 전체 크기
    __u32 tcp_payload_length = total_packet_length - ip_tcp_header_legnth; // TCP Payload 크기

    bpf_printk("[%s]: len(IPHeader) = %d, len(TCPHeader) = %d, len(TCPPayload) = %d", title, ip_header_length, tcp_header_length, tcp_payload_length);
}

SEC("socket")
int socket_handler(struct __sk_buff *skb) {
    print_sk_buff("socket", skb);
    return skb->len;
}

정리해 보면, (과거에는 어땠는지 모르겠지만) 현재 __sk_buff.data 필드에 대한 데이터는 다음과 같은 기준으로 가져올 수 있습니다.

// BPF_PROG_TYPE_SOCKET_FILTER 유형

bpf_skb_load_bytes_relative(BPF_HDR_START_MAC) - MAC 헤더를 시작점으로 offset 지정
bpf_skb_load_bytes_relative(BPF_HDR_START_NET) - IP 헤더를 시작점으로 offset 지정
bpf_skb_load_bytes - TCP 헤더를 시작점으로 offset 지정




문제는, bpf_skb_load_bytes가 언제나 TCP 헤더를 시작점으로 잡지는 않는다는 것입니다. 테스트해 보면, 저렇게 나오는 경우는 BPF_PROG_TYPE_SOCKET_FILTER 유형의 SEC("socket") 프로그램에서 그런 것이고, 다른 유형, 예를 들어 BPF_PROG_TYPE_CGROUP_SKB 프로그램에서는 전혀 다른 위치가 나옵니다.

예를 들어 아래의 코드를 테스트하면,

SEC("cgroup_skb/egress")
int cgroup_egress_packets(struct __sk_buff *skb) {
    __u8 buff_rel[48];
    bpf_skb_load_bytes_relative(skb, 0, buff_rel, 48, BPF_HDR_START_MAC); // EFAULT 14 Bad address

    return 1;
}

bpf_skb_load_bytes_relative 함수가 -14를 반환했습니다. 반면 BPF_HDR_START_MAC이 아닌 BPF_HDR_START_NET을 옵션으로 줬더니 성공했습니다. 다시 말해, bpf_skb_load_bytes_relative 함수라고 해도 어떤 eBPF 프로그램 유형에서 불리느냐에 따라 성공/실패로 나뉠 수 있습니다.

또한, bpf_skb_load_bytes 함수가 반환한 데이터와 비교하면,

int cgroup_egress_packets(struct __sk_buff *skb) {
    __u8 buff_org[48];
    bpf_skb_load_bytes(skb, 0, buff_org, 48);

    __u8 buff_rel[48];
    bpf_skb_load_bytes_relative(skb, 0, buff_rel, 48, BPF_HDR_START_NET);

    return 1;
}

buff_org와 buff_rel 데이터가 완전히 동일합니다. 그러니까, BPF_PROG_TYPE_CGROUP_SKB 프로그램에서의 호출 결과는 다음과 같이 정리가 됩니다.

// BPF_PROG_TYPE_CGROUP_SKB 유형

bpf_skb_load_bytes_relative(BPF_HDR_START_MAC) - 오류 발생 (EFAULT 14 Bad address)
bpf_skb_load_bytes_relative(BPF_HDR_START_NET) - IP 헤더를 시작점으로 offset 지정
bpf_skb_load_bytes - IP 헤더를 시작점으로 offset 지정

이러한 차이점에 대해 Google AI 검색에서는 다음과 같은 설명을 하고 있습니다.

cgroup_skb programs: Attached to a cgroup, these programs operate higher in the stack, where packets have already been associated with a socket and the L2 header has often been stripped. The cgroup context is associated with a specific process or set of processes, and the BPF program inspects traffic from the perspective of that application, not the raw network interface.


기왕에 예제를 작성했으니 앞서 작성한 print_sk_buff 함수를 BPF_PROG_TYPE_CGROUP_SKB 프로그램에서도 사용해 볼까요?

SEC("cgroup_skb/ingress")
int test_ingress_packets(struct __sk_buff *skb) {
    print_sk_buff("ingress", skb);
    return 1;
}

SEC("cgroup_skb/egress")
int test_egress_packets(struct __sk_buff *skb) {
    print_sk_buff("egress", skb);
    return 1;
}

SEC("cgroup/connect4")
int socket_connect4(struct bpf_sock_addr *ctx)
{
    struct bpf_sock* bpf_sk = ctx->sk;
    bpf_printk("[socket_connect4] bpf_sk == %p\n", bpf_sk);

    return SYS_PROCEED;
}

동작을 테스트하기 위해 HTTP 요청을 직접 Socket을 사용해 다음과 같이 다루는 경우,

var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
int readCount = 0;

socket.Connect("www.sysnet.pe.kr", 80);

string request = "GET / HTTP/1.1\r\nHost: sysnet.pe.kr\r\nConnection: close\r\n\r\n";
byte[] requestBytes = Encoding.ASCII.GetBytes(request);
Console.WriteLine($"Socket payload size: {requestBytes.Length} bytes");

socket.Send(requestBytes);

var buffer = new byte[4096];
int bytesReceived;
var response = new StringBuilder();

do
{
    readCount++;
    bytesReceived = socket.Receive(buffer);
    response.Append(Encoding.ASCII.GetString(buffer, 0, bytesReceived));
                    
} while (bytesReceived > 0);

Console.WriteLine($"{socket.LocalEndPoint} <==> {socket.RemoteEndPoint}, # of reads: {readCount}");
socket.Close();

실행해 보면 이런 식의 로그를 얻게 됩니다.

bpf_trace_printk: [socket_connect4] bpf_sk == 00000000883b5933
bpf_trace_printk: [egress]: !tcp_packet(protocol = 17)
bpf_trace_printk: [ingress]: !tcp_packet(protocol = 17)
bpf_trace_printk: [egress]: !tcp_packet(protocol = 17)
bpf_trace_printk: [ingress]: !tcp_packet(protocol = 17)

bpf_trace_printk: [socket_connect4] bpf_sk == 000000001740e3bf
bpf_trace_printk: [egress]: len(IPHeader) = 20, len(TCPHeader) = 40, len(TCPPayload) = 0
bpf_trace_printk: [egress]: len(IPHeader) = 20, len(TCPHeader) = 32, len(TCPPayload) = 57
bpf_trace_printk: [egress]: len(IPHeader) = 20, len(TCPHeader) = 32, len(TCPPayload) = 0

첫 번째 socket_connect4의 경우 이후 protocol == 17(IPPROTO_UDP)인 걸로 봐서 아마도 DNS 조회인 듯하고, 두 번째 socket_connect4 이후부터 HTTP 요청을 위한 TCP 통신의 결과로 나온 것 같습니다. 그런데, 이상하게도 HTTP 응답을 위한 ingress 로그가 없습니다. 왜일까요? ^^; (혹시 이유를 아시는 분은 덧글 부탁드립니다.)

이번엔 대충 여기까지... 살펴본 것으로 만족해야겠습니다. ^^




위의 프로그램을 테스트하다가 겪은 오류인데요, 만약 다음과 같이 코드를 만들면,

void print_sk_buff(char* title, struct __sk_buff *skb) {
    // ...[생략]...
}

SEC("cgroup_skb/ingress")
int test_ingress_packets(struct __sk_buff *skb) {
    print_sk_buff("ingress", skb);
    return 1;
}

SEC("cgroup_skb/egress")
int test_egress_packets(struct __sk_buff *skb) {
    print_sk_buff("egress", skb);
    return 1;
}

eBPF 로딩 시 이런 오류가 발생합니다.

load program: invalid argument: Caller passes invalid args into func#1 ('print_sk_buff') (8 line(s) omitted)

저 오류를 피하려면 print_sk_buff 함수를 명시적으로 static으로 지정해야 하는데요,

static void print_sk_buff(char* title, struct __sk_buff *skb) {
    // ...[생략]...
}

공식 문서에 보면,

eBPF Docs - Functions
; https://docs.ebpf.io/linux/concepts/functions/

딱히 static 함수여야 한다는 제약은 없습니다. 또한, 관련해서 리눅스 소스 코드도 찾아보면,

linux/kernel/bpf/verifier.c
; https://github.com/torvalds/linux/blob/master/kernel/bpf/verifier.c#L10636

// ...[생략]...
err = btf_check_subprog_call(env, subprog, caller->regs);
if (err == -EFAULT)
    return err;
if (subprog_is_global(env, subprog)) {
    const char *sub_name = subprog_name(env, subprog);

    // ...[생략]...

    if (err) {
        verbose(env, "Caller passes invalid args into func#%d ('%s')\n",
            subprog, sub_name);
        return err;
    }

    // ...[생략]...
    return 0;
}

static 예약어가 없다면 해당 C 함수는 global로 취급하는데요, 그런 상황에서 btf_check_subprog_call 함수가 오류 코드를 반환했다는 의미가 됩니다. 그렇다면, 반대로 static 함수인 경우라면 btf_check_subprog_call 함수에서 오류를 반환해도 상관없다는 것인데... 좀 이해가 안 되는 코드입니다. ^^;




마지막으로, 위의 BPF_PROG_TYPE_CGROUP_SKB 예제에서도 지난 글에서와 동일한 문제가 발생했는데요, 예를 들어, 다음의 코드는,

SEC("cgroup_skb/egress")
int cgroup_egress_packets(struct __sk_buff *skb) {
    struct bpf_sock *read_bpf_sock = (struct bpf_sock*)BPF_CORE_READ(skb, sk); // NULL 반환
    // 또는 이렇게 호출해도,
    // struct bpf_sock *read_bpf_sock = NULL;
    // int err = bpf_core_read(&read_bpf_sock, sizeof(void *), &skb->sk); // NULL 반환
}

반환값이 NULL이 나오고, 오히려 직접 접근하는 경우에는,

struct bpf_sock *direct_bpf_sock = skb->sk; // 포인터 주소 반환

정상적으로 포인터 값이 나옵니다. 도대체 왜 ^^; 저런 차이가 있는 걸까요? 후자의 경우처럼 직접 접근하는 코드가 동작한다고 해도, 때로는 BPF_CORE_READ를 사용해야만 하는 경우도 있기 때문에 마냥 무시할 수만은 없는 문제입니다.

암튼... 근래에 리눅스 환경을 다루면서 (결국엔 이유가 있겠지만) 수박 겉 핡기 식의 제 지식만으로는 당최 이해가 안 되는 상황들을 겪게 됩니다. ^^;




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 11/6/2025]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




... 136  [137]  138  139  140  141  142  143  144  145  146  147  148  149  150  ...
NoWriterDateCnt.TitleFile(s)
1746정성태9/18/201430500.NET Framework: 461. .NET EXE 파일을 닷넷 프레임워크 버전에 상관없이 실행할 수 있을까요? - 두 번째 이야기 [6]파일 다운로드1
1745정성태9/17/201428949개발 환경 구성: 237. 리눅스 Integration Services 버전 업그레이드 하는 방법 [1]
1744정성태9/17/201437569.NET Framework: 460. GetTickCount / GetTickCount64와 0x7FFE0000 주솟값 [4]파일 다운로드1
1743정성태9/16/201427156오류 유형: 238. 설치 오류 - Failed to get size of pseudo bundle
1742정성태8/27/201433399개발 환경 구성: 236. Hyper-V에 설치한 리눅스 VM의 VHD 크기 늘리는 방법 [2]
1741정성태8/26/201426884.NET Framework: 459. GetModuleHandleEx로 알아보는 .NET 메서드의 DLL 모듈 관계파일 다운로드1
1740정성태8/25/201438515.NET Framework: 458. 닷넷 GC가 순환 참조를 해제할 수 있을까요? [2]파일 다운로드1
1739정성태8/24/201432666.NET Framework: 457. 교착상태(Dead-lock) 해결 방법 - Lock Leveling [2]파일 다운로드1
1738정성태8/23/201428111.NET Framework: 456. C# - CAS를 이용한 Lock 래퍼 클래스파일 다운로드1
1737정성태8/20/201424614VS.NET IDE: 93. Visual Studio 2013 동기화 문제
1736정성태8/19/201431451VC++: 79. [부연] CAS Lock 알고리즘은 과연 빠른가? [2]파일 다운로드1
1735정성태8/19/201424155.NET Framework: 455. 닷넷 사용자 정의 예외 클래스의 최소 구현 코드 - 두 번째 이야기
1734정성태8/13/201426365오류 유형: 237. Windows Media Player cannot access the file. The file might be in use, you might not have access to the computer where the file is stored, or your proxy settings might not be correct.
1733정성태8/13/201432705.NET Framework: 454. EmptyWorkingSet Win32 API를 사용하는 C# 예제파일 다운로드1
1732정성태8/13/201441394Windows: 99. INetCache 폴더가 다르게 보이는 이유
1731정성태8/11/201432712개발 환경 구성: 235. 점(.)으로 시작하는 파일명을 탐색기에서 만드는 방법
1730정성태8/11/201428365개발 환경 구성: 234. Royal TS의 터미널(Terminal) 연결에서 한글이 깨지는 현상 해결 방법
1729정성태8/11/201423801오류 유형: 236. SqlConnection - The requested Performance Counter is not a custom counter, it has to be initialized as ReadOnly.
1728정성태8/8/201437019.NET Framework: 453. C# - 오피스 파워포인트(Powerpoint) 파일을 WinForm에서 보는 방법파일 다운로드1
1727정성태8/6/201427369오류 유형: 235. SignalR 오류 메시지 - Counter 'Messages Bus Messages Published Total' does not exist in the specified Category. [2]
1726정성태8/6/201425743오류 유형: 234. IIS Express에서 COM+ 사용 시 SecurityException - "Requested registry access is not allowed" 발생
1725정성태8/6/201427839오류 유형: 233. Visual Studio 2013 Update3 적용 후 Microsoft.VisualStudio.Web.PageInspector.Runtime 모듈에 대한 FileNotFoundException 예외 발생
1724정성태8/5/201431985.NET Framework: 452. .NET System.Threading.Thread 개체에서 Native Thread Id를 구하는 방법 - 두 번째 이야기 [1]파일 다운로드1
1723정성태7/29/201464861개발 환경 구성: 233. DirectX 9 예제 프로젝트 빌드하는 방법 [3]파일 다운로드1
1722정성태7/25/201427335오류 유형: 232. IIS 500 Internal Server Error - NTFS 암호화된 폴더에 웹 애플리케이션이 위치한 경우
1721정성태7/24/201431240.NET Framework: 451. 함수형 프로그래밍 개념 - 리스트 해석(List Comprehension)과 순수 함수 [2]
... 136  [137]  138  139  140  141  142  143  144  145  146  147  148  149  150  ...