理解しておくと良い前提知識
- カーネル空間/ユーザー空間の違い
- ネットワークの基礎
- ソケットプログラミングの基礎
環境
$ go version
go version go1.13.5 linux/amd64
パケットフィルタの仕組み
特定のプロトコルパケットをフィルタする前にパケットフィルタの仕組みについて記す。
※cBPFとeBPFの違い、現在のBPFの機能がパケットフィルタだけではないとか、BPFの歴史/変遷などについてはこの記事では触れていませんのでご了承ください
例としてtcpdumpでのパケットフィルタを用いる。
tcpdumpなどのパケットキャプチャツールはソフトウェアとしてユーザ空間で動作します。
また、tcpdumpでキャプチャされているデータはカーネル空間からコピーされたデータとなっており、直接アプリケーションなどがやり取りするパケットを覗いているわけではありません。(そんなことしてたらオーバーヘッドがやばい)
ユーザランドでフィルタをしていてはカーネル空間から実際にフィルタしたいプロトコルのパケット以外のパケットも全てコピーしてしまう。
これを回避するためにカーネルの**BPF(Berkeley Packet Filter)**と呼ばれるパケットフィルタリング機能を使用している。
カーネル空間で仮想マシン(仮想的なレジスタマシン)を実行しフィルタをカーネル空間で実現している。
Goを用いてネットワークに流れるパケットを取得する
比較を行うために以下のコードを用いてフィルタを行わずパケットを取得します。
イーサネットフレームのタイプを用いて出力を制御。
package main
import (
"encoding/binary"
"flag"
"fmt"
"net"
"os"
"syscall"
)
// 他にも上位のプロトコルタイプは存在するがわかりやすくするため3種類のみ使用する
const (
EthTypeArp uint16 = 0x0806
EthTypeIpv4 uint16 = 0x0800
EthTypeIpv6 uint16 = 0x86dd
)
// MACヘッダ構造体
type EtherHeader struct {
DstMacAddr net.HardwareAddr
SrcMacAddr net.HardwareAddr
ProtoType uint16
}
// お約束のビッグエンディアン変換
func htons(host uint16) uint16 {
return (host&0xff)<<8 | (host >> 8)
}
// イーサネットヘッダのタイプを見て出力の内容を制御
func analyzePacket(data []byte) {
// パース
dstMacAddr := data[:6]
srcMacAddr := data[6:12]
protoType := binary.BigEndian.Uint16(data[12:14])
eh := &EtherHeader{
DstMacAddr: dstMacAddr,
SrcMacAddr: srcMacAddr,
ProtoType: protoType,
}
// ここでイーサネットヘッダーのタイプを判別
switch eh.ProtoType {
case EthTypeArp:
fmt.Println("ARP Packet: src->", eh.SrcMacAddr, "dst->", eh.DstMacAddr)
case EthTypeIpv4:
fmt.Println("IPv4 Packet: src->", eh.SrcMacAddr, "dst->", eh.DstMacAddr)
case EthTypeIpv6:
fmt.Println("IPv6 Packet: src->", eh.SrcMacAddr, "dst->", eh.DstMacAddr)
default:
fmt.Println("Another Type Packet")
}
}
func main() {
// 引数のパース
flag.Parse()
// リンクレイヤ(L2)のヘッダを含むすべてのプロトコルパケットの生データを取得する
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_ALL)))
if err != nil {
fmt.Println(err.Error())
}
// 終了したときにファイルディスクリプタを閉じる
defer syscall.Close(fd)
// インターフェースのインデックスを取得する
ifIndex, err := net.InterfaceByName(flag.Arg(0))
if err != nil {
fmt.Println(err.Error())
}
// インターフェースにソケットをバインドする
addr := syscall.SockaddrLinklayer{Protocol: htons(syscall.ETH_P_ALL), Ifindex: ifIndex.Index}
if err := syscall.Bind(fd, &addr); err != nil {
fmt.Println(err.Error())
}
// プロミスキャス・モードで受信
if err := syscall.SetLsfPromisc(flag.Arg(0), true); err != nil {
fmt.Println(err.Error())
}
// ファイルの作成
file := os.NewFile(uintptr(fd), "")
// パケットをforループで読み続け出力
for {
buf := make([]byte, 2048)
num, err := file.Read(buf)
if err != nil {
fmt.Println(err.Error())
break
} else {
analyzePacket(buf[:num])
}
}
}
出力が制御できることを確認する。
$ sudo ./packet-analyzer eth1 # ネットワークインターフェースの名前
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> a0:56:f3:cc:89:31 dst-> 01:00:5e:00:00:fb
IPv6 Packet: src-> a0:56:f3:cc:89:31 dst-> 33:33:00:00:00:fb
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> d4:f5:47:26:8d:7f
IPv4 Packet: src-> d4:f5:47:26:8d:7f dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> d4:f5:47:26:8d:7f
IPv4 Packet: src-> a0:56:f3:cc:89:31 dst-> 01:00:5e:00:00:fb
IPv6 Packet: src-> a0:56:f3:cc:89:31 dst-> 33:33:00:00:00:fb
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
IPv4 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv6 Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
IPv6 Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
特定のプロトコルをフィルタしてみる
先程記述したプログラムからARPパケットのみを出力するようBPF(LSF)を用いてフィルタしてみる。
プログラムに少し追記します。
package main
import (
"encoding/binary"
"flag"
"fmt"
"net"
"os"
"syscall"
)
const (
EthTypeArp uint16 = 0x0806
EthTypeIpv4 uint16 = 0x0800
EthTypeIpv6 uint16 = 0x86dd
)
type EtherHeader struct {
DstMacAddr net.HardwareAddr
SrcMacAddr net.HardwareAddr
ProtoType uint16
}
func htons(host uint16) uint16 {
return (host&0xff)<<8 | (host >> 8)
}
func analyzePacket(data []byte) {
dstMacAddr := data[:6]
srcMacAddr := data[6:12]
protoType := binary.BigEndian.Uint16(data[12:14])
eh := &EtherHeader{
DstMacAddr: dstMacAddr,
SrcMacAddr: srcMacAddr,
ProtoType: protoType,
}
switch eh.ProtoType {
case EthTypeArp:
fmt.Println("ARP Packet: src->", eh.SrcMacAddr, "dst->", eh.DstMacAddr)
case EthTypeIpv4:
fmt.Println("IPv4 Packet: src->", eh.SrcMacAddr, "dst->", eh.DstMacAddr)
case EthTypeIpv6:
fmt.Println("IPv6 Packet: src->", eh.SrcMacAddr, "dst->", eh.DstMacAddr)
default:
fmt.Println("Another Type Packet")
}
}
func main() {
flag.Parse()
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, int(htons(syscall.ETH_P_ALL)))
if err != nil {
fmt.Println(err.Error())
}
defer syscall.Close(fd)
ifIndex, err := net.InterfaceByName(flag.Arg(0))
if err != nil {
fmt.Println(err.Error())
}
addr := syscall.SockaddrLinklayer{Protocol: htons(syscall.ETH_P_ALL), Ifindex: ifIndex.Index}
if err := syscall.Bind(fd, &addr); err != nil {
fmt.Println(err.Error())
}
if err := syscall.SetLsfPromisc(flag.Arg(0), true); err != nil {
fmt.Println(err.Error())
}
// ここでカーネルにフィルタを適用する
// ARPパケットのみを扱う際の命令を定義し、適用する(ARP以外のパケットはアプリケーションで受信しない)
rawInstructions := []syscall.SockFilter{
{0x28, 0, 0, 0x0000000c},
{0x15, 0, 1, 0x00000806},
{0x6, 0, 0, 0x00040000},
{0x6, 0, 0, 0x00000000},
}
if err := syscall.AttachLsf(fd, rawInstructions); err != nil {
fmt.Println(err.Error())
}
file := os.NewFile(uintptr(fd), "")
for {
buf := make([]byte, 2048)
num, err := file.Read(buf)
if err != nil {
fmt.Println(err.Error())
break
} else {
analyzePacket(buf[:num])
}
}
}
出力がARPパケットのみになっていることを確認する。
$ sudo ./packet-analyzer eth1
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
ARP Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
ARP Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> d4:f5:47:26:8d:7f dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
ARP Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
ARP Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
ARP Packet: src-> 3c:28:6d:d0:6f:e2 dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 6c:62:6d:e9:b4:9a dst-> ff:ff:ff:ff:ff:ff
ARP Packet: src-> 48:ad:08:d4:5e:27 dst-> 68:ec:c5:70:cd:80
ARP Packet: src-> 68:ec:c5:70:cd:80 dst-> 48:ad:08:d4:5e:27
追記したコードについての説明です。
下記でBPFの命令セットを定義します。
この命令セット(packet-matching code)はtcpdump -dd arp
で得ることができます。
rawInstructions := []syscall.SockFilter{
{0x28, 0, 0, 0x0000000c},
{0x15, 0, 1, 0x00000806},
{0x6, 0, 0, 0x00040000},
{0x6, 0, 0, 0x00000000},
}
その後、syscall.AttachLsf
を用いて命令をソケットにアタッチすることでフィルタすることができます。
if err := syscall.AttachLsf(fd, rawInstructions); err != nil {
fmt.Println(err.Error())
}
google/gopacketでの実装
今回はsyscall
を直接叩きましたがgoogle/gopacketではCGoを用いて実装されているようです!
libpcap
のPCAP_API
を呼び出して使用しています。
最後に
理解を深めるために実装を読んだり、BPFの歴史について調べたり、なかなか面白みがありました!
間違っている所などもあるかもしれませんが、その時はご教授いただけると幸いです。