본문 바로가기

CS연구소👨‍💻

[네트워크] IP프로토콜 분석

들어가며

네트워크 CS에는 신기한 녀석이 살고있다. 바로 계층구조

OSI 7단계와 TCP/IP 4계층이라고도 하는 녀석들이다 얘네는 실제 프로젝트에서 경험할 일이 없는 녀석이다 보니 외워도 까먹고 외워도 까먹고 항상 그렇다. 그 멍청한 연쇄를 끊고자 실제 실습을 겸해서 네트워크 공부를 하려고한다.

개념

TCP/IP 계층과 OSI 7계층은 같은 개념이고 그중에서 ip 프로토콜은 인터넷 계층의 프로토콜의 일종이다. 그냥 추상적으로 프로토콜을 공부하기는 힘들어서 C++의 ip 프로토콜을 직접 담당하는 라이브러리를 읽으면서 분석해보자

 

ip 모듈의 분해

struct ip {
#ifdef _IP_VHL
    u_char  ip_vhl;                 /* version << 4 | header length >> 2 */
#else
#if BYTE_ORDER == LITTLE_ENDIAN
    u_int   ip_hl:4,                /* 헤더의 길이와 */
        ip_v:4;                     /* ip 헤더의 버전 */
#endif
#if BYTE_ORDER == BIG_ENDIAN
    u_int   ip_v:4,                 /* ip 헤더의 버전  */
        ip_hl:4;                    /* 헤더의 길이 */

// 리틀 엔디안, 빅엔디안을 정하는 코드        

#endif

#endif /* not _IP_VHL */
    u_char  ip_tos;                 /* type of service */
    u_short ip_len;                 /* total length */
    u_short ip_id;                  /* identification */
    u_short ip_off;                 /* fragment offset field */
#define IP_RF 0x8000                    /* reserved fragment flag */
#define IP_DF 0x4000                    /* dont fragment flag */
#define IP_MF 0x2000                    /* more fragments flag */
#define IP_OFFMASK 0x1fff               /* mask for fragmenting bits */
    u_char  ip_ttl;                 /* time to live */
    u_char  ip_p;                   /* protocol */
    u_short ip_sum;                 /* checksum */
    struct  in_addr ip_src, ip_dst;  /* source and dest address */
};

netinet 라이브러리에 있는 내용

버전과 헤더의 길이를 설정하는 1바이트 정수

ip 서비스의 종류를 1바이트 1바이트로 정리

ip 패킷의 길이를 2바이트

ip 식별 아이디를 2바이트

ip 요소들을 오프셋 하는 코드 2바이트

IP_RF~ IP_MF 프래그먼트

오프셋 마스크

ip_ttl 패킷의 유효기안 1바이트

ip_p 프로토콜 1바이트

ip_sum 체크섬 = 데이터의 무결성을 파악하기 위해 설정해놓는 2바이트 값

4바이트짜리 소스 in_addr ip_src, ip_dst 내 주소와 목적지의 주소에 대한 4바이트 정보

한마디로 ip헤더에는 총 20바이트의 정보가 들어가며,

ip 프로토콜의

버전과 길이,

서비스의 종류,

패킷의 길이,

식별아이디,

프래그먼트에 대한 3가지 설정과 시작위치를 나타내는 오프셋

유효기한,

프로토콜에 대한 정보

무결성을 체크하기위한 정보

출발지점과 도착지점의 정보 등이 포함된다.

그리고 여기서부터는 이 헤더 요소들의 개념중에서 이해안됐던 것을 적어보았다.

RF, DF, MF가 의미하는건 무엇인가?

위의 IP 헤더 패킷에 대한 코드에서

#define IP_RF 0x8000                    /* reserved fragment flag */
#define IP_DF 0x4000                    /* dont fragment flag */
#define IP_MF 0x2000                    /* more fragments flag */
#define IP_OFFMASK 0x1fff               /* mask for fragmenting bits */

이런 부분을 확인할 수 있다. 이 정의 자체는 코드에 관여하지는 않지만 <netinet.ip.h> 을 include하면 같이 값으로 딸려온다. 여기서

각각의 부분은

ip_off라고하는 2바이트 정수의 일부에 해당하는데

RF는 예약된 프레그먼트 플래그이며 나중에 확장성을 위해서 마련된 자리이며 사용되지 않고 0이다.

DF는 패킷화 되지 않은 프래그먼트를 나타내는 플래그다. MTU라고하는 프로토콜과, 네트워크상 최대 전달가능 데이터인 MTU보다 작은 데이터의 경우 프래그먼트화하지 않기 위해서 설정하고 반대로 데이터가 MTU보다 클경우 1로 설정해서 프래그먼트화 한다.

MF는 MF는 패킷 사이즈가 1 이상인 데이터를 나타내는 플래그다. 데이터 패킷을 확인하면서 이 값이 1인경우 뒤에 값이 있다는 것이고 0인경우는 데이터가 없다는 걸 의미한다.

그리고 IP_OFFMASK는 MF에 의해 분리된 데이터가 들어올때 이것이 몇번째 조각인지를 나타내는 코드다.

MTU는 무엇이고 어떻게 설정하는가?

MTU는 방금 프로토콜과, 네트워크상 최대 전달가능 데이터라고 하였는데 이 값이 어떻게 설정되는지가 궁금했다 예를들어 실제로 목적지로 가상의 데이터를 보내보면서 그 곳의 네트워크 상태를 받고 설정하는 것인지 아니면 그냥 정해져있는 값이 있는건지.

일단 이 값은 네트워크의 물리 계층에 따라서 달라지는 것으로 보인다.

이더넷 네트워크의 경우는 1500 바이트가 표준 MTU이지만 PPP네트워크의 경우엔 576 바이트가 MTU의 표준이라고한다.

또한 IPv4와 IPv6에 따라서 MTU는 다를 수 있으며, IPv6에서는 Path MTU Discovery 메커니즘을 사용하여 동적으로도 조절할 수 있다고 한다.

어쩃든 MTU를 통해서 IP프로토콜에 탑재될 수 있는 데이터의 크기는 MTU - 20바이트가 된다. 1500바이트일경우에 1480바이트가 되는 셈이다. 물론 이것도 실제로는 전송계층의 헤더까지 포함한 값이기 때문에 실제 데이터는 더 적어질 것이다.

리틀 엔디언과 빅엔디언에 따라서 ip_hl, ip_v 이 달라지는건 어째서인가?

일단 리틀엔디안과 빅엔디안은 데이터를 메모리 주소에서 어떻게 채우느냐에 따라서 차이가 존재한다.

리틀엔디안의 경우엔 낮은 주소에 낮은 값을 저장한다

0x12345678의 경우 78을 낮은 주소, 12를 높은 주소에 저장한다.

그리고 빅엔디안의 경우엔 78이 높은주소 12가 낮은 주소에 저장된다.

이 자체는 간단한 CS이지만 ip헤더는 이 엔디안에 따라서 데이터를 저장하는 방식이 달랐다.

#if BYTE_ORDER == LITTLE_ENDIAN
    unsigned int ip_hl : 4; // header length (4 bits)
    unsigned int ip_v : 4;  // version (4 bits)
#elif BYTE_ORDER == BIG_ENDIAN
    unsigned int ip_v : 4;  // version (4 bits)
    unsigned int ip_hl : 4; // header length (4 bits)
#endif

애초에 이 코드를 보면서 ip_hl, ip_v 자체가 이해가 안갔다.

지금 알고보니 : 4라는거는 비트 필드에 대한 정의이다. unsigned int 는 4바이트이지만 :4를 쓰게되면 그중에서 4비트만 할당받게 되는 것이다. 그리고 찾아본 바에 의하면 구조체에서 비트필드는 선언된 순서대로 메모리에 할당된다고한다.

그러니 저 코드는 ip 프로토콜이 그 프로토콜 규약상 항상 버전이 헤더의 길이보다 높은 주소에 위치해야한다는 것을 의미한다.

체크섬은 어떤 값을 저장하는가?

체크섬의 2바이트값은 데이터의 무결성을 확인하기 위해서 사용되는데 이 2바이트의 정보가 어떻게 패킷 전체의 무결성을 판단하는지가 궁금했다.

  1. 초기화: 체크섬 필드 (ip_sum)를 0으로 설정합니다.
  2. 데이터 분할: IP 헤더를 16비트 워드 단위로 나눕니다.
  3. 합산: 모든 16비트 워드를 더합니다. 이 때, 더한 값이 16비트를 초과하면 초과된 상위 비트를 하위 비트에 다시 더합니다 (캐리 발생 시 캐리를 더하는 방식).
  4. 1의 보수: 합산 결과의 1의 보수를 취합니다. 즉, 모든 비트를 반전시킵니다.
  5. 체크섬 필드에 할당: 계산된 체크섬 값을 ip_sum 필드에 할당합니다.

즉 헤더 20바이트 ( 옵션에 따라서 추가될 수 있음 )를 2바이트 단위로 쪼재고, 그 모든 값을 앞에서부터 순서대로 더한다.

그런데 그냥 더하는게 아니라 2바이트 이상값이 되었을 경우 이상의 값을 다시 낮은 비트에 더한다. 그리고 1의 보수로 비트 값을 뒤집은걸 체크섬으로 정한다.

그리고 데이터를 수신하는 쪽에서도 같은 작업을 반복해보면 이 데이터가 무결한지 아닌지 알 수 있다.

#include <iostream>
#include <cstdint>

uint16_t calculate_checksum(uint16_t* buffer, int size) {
    uint32_t sum = 0;

    // 모든 16비트 워드를 더합니다.
    while (size > 1) {
        sum += *buffer++;
        size -= sizeof(uint16_t);
    }

    // 만약 홀수 바이트가 남았다면, 이를 더합니다.
    if (size) {
        sum += *(uint8_t*)buffer;
    }

    // 캐리를 더합니다.
    while (sum >> 16) {
        sum = (sum & 0xFFFF) + (sum >> 16);
    }

    // 1의 보수를 취합니다.
    return ~sum;
}

int main() {
    uint16_t header[] = { 0x4500, 0x0034, 0x1c46, 0x4000, 0x4006, 0xb1e6, 0xc0a8, 0x0001, 0xc0a8, 0x00c7 };
    int header_size = sizeof(header) / sizeof(header[0]);

    uint16_t checksum = calculate_checksum(header, header_size * sizeof(uint16_t));
    std::cout << "Checksum: " << std::hex << checksum << std::endl;

    return 0;
}

사실 ip 프로토콜은 전체 네트워크 중에서 극히 일부에 불과하다. 그래도 일단 그 일부를 파악한 것에 만족하고 다른 인터넷 프로토콜이나 전송계층을 공부해봐야겠다.