WHITEPLUS TechBlog

株式会社ホワイトプラスのエンジニアによる開発ブログです。

libpcapでVPNのアクセスを可視化する

WHITEPLUSで基盤回りの担当をしているakaimoです。
最近はGKEやIstioなんかを触っています。


Istioでマイクロサービスを構築していると、設定ミスなどで意図した通りに通信ができないことがあります。Istioを隅々まで把握している人ならば、設定ファイルを見ただけで原因が分かるかもしれませんが、触り始めたばかりの人はそうもいきません。

そうなると1つずつデバッグをしていくわけですが、これもまた慣れていないと難しいです。そこで今回はネットワークをデバッグするときの武器の1つであるtcpdumpに慣れるため、パケットキャプチャをして遊んでみたいと思います。

と言っても、ただキャプチャして中身をみるだけなら他に素晴らしい本や記事があるので、今回はキャプチャする部分から自分でソースコードを書いて解析し、有向グラフにして可視化するところまでやってみます。

通常のパケットだと面白さに欠けるので、VPNに接続した通信の流れを可視化してみたいと思います。

なお、今回は簡略化のため、IPv4のみを対象にしたいと思います。

可視化したいパケットをキャプチャする

まず、tcpdumpやWiresharkなんかで適当にキャプチャしてpcapファイルを用意します。

今回はMacでL2TP/IPsecのVPN通信を可視化するので、デバイスに en0ppp を指定してキャプチャしています。
ここは環境やプロトコルなどによって変わってくると思います。

うっかりWiresharkでpcapngで保存してしまった場合はpcapに変換します。(デフォだとpcapngが選択されていて、やらかしてしまうんですよね...)

pcapng.com

このpcapファイルを可視化していきます。

キャプチャしたパケットを表示する

tcpdumpやWiresharkで使われているpcapというライブラリがあります。
そのpcapをgoでラップした、gopacketを使ってパケットの中を見ていきます。

func main() {
    handle, err := pcap.OpenOffline("en0.pcap")
    if err != nil {
        log.Fatal(err)
    }
    defer handle.Close()

    packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
    for {
        packet, err := packetSource.NextPacket()
        if err == io.EOF {
            break
        } else if err != nil {
            log.Println("Error:", err)
            continue
        }
        fmt.Println(packet)
    }
}

たったこれだけのコードで内容を出力できます。
これがen0の出力結果の一部です。

PACKET: 381 bytes, wire length 381 cap length 381 @ 2018-11-26 17:13:55.088535 +0900 JST
- Layer 1 (14 bytes) = Ethernet {Contents=[..14..] Payload=[..367..] SrcMAC=f4:0f:24:1e:36:29 DstMAC=01:00:5e:00:00:fb EthernetType=IPv4 Length=0}
- Layer 2 (20 bytes) = IPv4 {Contents=[..20..] Payload=[..347..] Version=4 IHL=5 TOS=0 Length=367 Id=38803 Flags= FragOffset=0 TTL=255 Protocol=UDP Checksum=7452 SrcIP=192.168.100.42 DstIP=224.0.0.251 Options=[] Padding=[]}
- Layer 3 (08 bytes) = UDP  {Contents=[..8..] Payload=[..339..] SrcPort=5353(mdns) DstPort=5353(mdns) Length=347 Checksum=45194}
- Layer 4 (339 bytes) = Payload 339 byte(s)

この内容では大ざっぱすぎて扱いに困りますね。
詳細は後で見るとして、pppも出力してみます。

PACKET: 44 bytes, wire length 44 cap length 44 @ 2018-11-26 17:13:54.250617 +0900 JST
- Layer 1 (01 bytes) = PPP  {Contents=[255] Payload=[..43..] PPPType=UnknownPPPType}
- Layer 2 (43 bytes) = DecodeFailure    Packet decoding error: Unable to decode PPPType 255

おっと、デコードに失敗してますね。
ここで原因の追求をしてもよかったのですが、せっかくですのでpcap直接扱えるCで書いていこうと思います。

libpcapでパケットを表示する

Cでパケットを扱う上で大切なことは、バイトを意識することです。

gopacketを使うとライブラリがパケットをGoの構造体に変換した状態で受け取れますが、pcapを直接使うと自分でパケットのバイト列を構造体に変換する必要があります。そのため、プロトコルごとのパケットのフレーム構造を知っている必要があります。

逆に、適切なプロトコルの構造体にマッピングさえできれば、好きなように加工・表示することができます。

Etherヘッダ

まずはen0から見ていきます。

Ethernetのフレーム構造はこことかに書いてあります。

Ethernet frame - Wikipedia

Macの場合は/usr/include/net/ethernet.hに適切なバイト数が指定された構造体が定義済みなので、この構造体にマッピングしていきます。

int main(int argc, char *argv[]) {
    char *pcap_file;
    char error_buffer[PCAP_ERRBUF_SIZE];

    if ((pcap_file = argv[1]) == NULL) {
        usage(argv[0]);
    };

    pcap_t *handle = pcap_open_offline(pcap_file, error_buffer);
    if (handle == NULL) {
        printf("error: open pcap file");
        return 1;
    }

    if (pcap_loop(handle, 0, ethernetPacketHandler, NULL) < 0) {
        exit(EXIT_FAILURE);
    }

    pcap_close(handle);
    return 0;
}

void ethernetPacketHandler(u_char *userData, const struct pcap_pkthdr *pkthdr, const u_char *packet) {
    struct ether_header *eth = (struct ether_header *) packet;
}

少し解説します。

pcapファイル名を引数として受け取ってそのファイルを読み込み、パケットの数だけpcap_loopが実行されethernetPacketHandlerが呼ばれています。
ethernetPacketHandlerの1行目でパケットの先頭からether_headerという構造体にマッピングしています。

IPヘッダ

Etherヘッダの次はIPヘッダです。
IPヘッダの場合も同じように定義済みの構造体があるので、それにマッピングしていきます。

void ethernetPacketHandler(u_char *userData, const struct pcap_pkthdr *pkthdr, const u_char *packet) {
    struct ether_header *eth = (struct ether_header *) packet;

    // IPv4でない場合は無視
    if (ntohs(eth->ether_type) != ETHERTYPE_IP) {
        return;
    }
    struct ip *ip = (struct ip *) (packet + sizeof(struct ether_header));

    printf("ip_src = %s\n", inet_ntoa(ip->ip_src));
    printf("ip_dst = %s\n", inet_ntoa(ip->ip_dst));
    printf("\n");
}

これで送信元のIPと送信先のIPが取得できました。

まだまだこの先にたくさんのレイヤがありますが、知りたい情報は取得できたので今回はここで終わりにします。この先のレイヤの情報が必要な場合も、いままでやったように次のレイヤのプロトコルを調べ、構造体を定義してマッピングしていくだけです。

PPPヘッダ

次にgopacketで表示できなかったpppのパケットを解析していきます。

軽く検索したところ、pppヘッダは定義されていなかったので自分で定義します。
フレーム構造を調べたところ4バイト分がPPPヘッダだったので、4バイトの構造体を作ります。

今回はPPPヘッダの後ろ2バイトが必要なのでこのような構造体を定義しました。

struct ppp_header {
    u_char address;
    u_char control;
    u_short protocol;
};

この構造体をベースに目当てのIPヘッダを取得します。

void pppPacketHandler(u_char *userData, const struct pcap_pkthdr *pkthdr, const u_char *packet) {
    struct ppp_header *ppp = (struct ppp_header *) packet;
    // IPv4でない場合は無視
    if (ntohs(ppp->protocol) != 0x0021) {
        return;
    }

    struct ip *ip = (struct ip *) (packet + sizeof(struct ppp_header));
    printf("ip_src = %s\n", inet_ntoa(ip->ip_src));
    printf("ip_dst = %s\n", inet_ntoa(ip->ip_dst));
    printf("\n");
}

無事にpppからもIPを取得することができました。
Cを書いた甲斐がありますね。

ip_src = 35.187.196.5
ip_dst = 10.10.1.3

通信の流れを可視化する

可視化には有向グラフを出力する鉄板ツールgraphvizを使って行きます。

MacでCから利用するにはbrewでインストールするだけです。

$ brew install graphviz

さて、graphvizで使いやすいようにパケットの中身を加工していくことになりますが、ちょっとした問題があります。

Cには便利なデータ構造が存在しないため、すでに表示済みのIPかどうかの判断などを自分たちのコードで行う必要があります。
さすがにそれは面倒なので、ここからはC++で書いていこうと思います。
Better Cと言う方が適切かもしれません。

C++の機能が使えればmapやsetを使って、送信元と送信先の組をユニークに作成すれば完成です。

std::map<std::string, std::set<std::string> > flows;

void pppPacketHandler(u_char *userData, const struct pcap_pkthdr *pkthdr, const u_char *packet) {
    struct ppp_header *ppp = (struct ppp_header *) packet;
    if (0x0021 != ntohs(ppp->protocol)) {
        // IPv4パケットでない場合は無視
        return;
    }

    struct ip *ip = (struct ip *) (packet + sizeof(struct ppp_header));
    std::string src_ip = inet_ntoa(ip->ip_src);
    std::string dst_ip = inet_ntoa(ip->ip_dst);

    flows[src_ip].insert(dst_ip);
}

void generateGraph() {
    GVC_t *gvc = gvContext();
    Agraph_t *g = agopen("sample", Agdirected, 0);

    for (std::pair<std::string, std::set<std::string> > flow:flows) {
        Agnode_t *src = ::agnode(g, (char *)flow.first.c_str(), 1);
        for(auto d : flow.second) {
            Agnode_t *dst = ::agnode(g, (char *)d.c_str(), 1);
            ::agedge(g, src, dst, 0, 1);
        }
    }

    gvLayout(gvc, g, "dot");
    gvRenderFilename(gvc, g, "png", "sample.png");
    gvRender(gvc, g, "dot", stdout);
}

出来上がった画像がこちらになります。
VPNサーバーのIPなど、一部加工してあります。
モザイクがかかっている部分がVPNサーバーで、黄色いアンダーラインがMacのIP、青のアンダーラインがVPNサーバーのプライベートIPです。

f:id:akaimo3:20181206125801p:plain f:id:akaimo3:20181206125725p:plain

このグラフを見る限りだと、外部への通信は全てVPNの通信になっていそうですね。

終わりに

こんな面倒なことをしなくても、送信元と送信先の組が見たいだけならWiresharkでConversationsを表示すれば見ることができます。

f:id:akaimo3:20181206125833p:plain

Wireshark最高ですね。