🍻

Golangで行うポートスキャナ自作ではじめるペネトレーションテスト

2023/11/03に公開
3

はじめに

オライリーでポートスキャナ自作ではじめるペネトレーションテストという本が発売されました。
2章ではScapyを利用して実際にパケットを作成して、nmapのようなポートスキャナ自作します。
パケットのカプセル化などNWの仕組みから丁寧に解説されていてとても良書だと思います。

ただ筆者はPythonよりGolang派なので2章のプログラムをGolangに書き換えてみました。

https://github.com/sat0ken/go-port-scanner

※オリジナルはこちら

https://github.com/oreilly-japan/pentest-starting-with-port-scanner/

gopacket入門

gopacketはGolangでパケットを読み込んだり作ったりするためのライブラリです。

https://github.com/google/gopacket

プログラムを作る前に必要なパッケージをインストールしておきます。
ubuntu 22.04で動作確認をしています。

$ sudo apt install -y gcc libpcap0.8 libpcap-dev

※Dockerimageを用意しているのでDocker環境があれば以下で動作できます。
imageをpullしたらレポジトリをcloneします。cloneしたフォルダをmountして起動してください。

$ docker pull sat0ken/go-port-scanner:latest
$ git clone https://github.com/sat0ken/go-port-scanner
$ cd go-port-scanner
$ docker run --privileged -it -v $(pwd):/work sat0ken/go-port-scanner bash

gopacketをインストールするとgopacket以下のサブパッケージに様々な関数やタイプが定義されています。

・layers: You'll probably use this every time. This contains of the logic built into gopacket for decoding packet protocols. Note that all 
・example code below assumes that you have imported both gopacket and gopacket/layers.
・pcap: C bindings to use libpcap to read packets off the wire.
・pfring: C bindings to use PF_RING to read packets off the wire.
・afpacket: C bindings for Linux's AF_PACKET to read packets off the wire.
・tcpassembly: TCP stream reassembly

https://pkg.go.dev/github.com/google/gopacket

パケットを読み込んだり、キャプチャするためにはgopacket/pcapを利用します。

https://pkg.go.dev/github.com/google/gopacket@v1.1.19/pcap

tcpdumpやWiresharkで保存したファイルからパケットを読み込むときは、pcap.OpenOfflineを利用します。

if handle, err := pcap.OpenOffline("/path/to/my/file"); err != nil {
  panic(err)
} else {
  packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
  for packet := range packetSource.Packets() {
    handlePacket(packet)  // Do something with a packet here.
  }
}

https://pkg.go.dev/github.com/google/gopacket@v1.1.19/pcap#hdr-Reading_PCAP_Files

tcpdumpやWiresharkのようにリアルタイムでパケットを受信する場合は、pcap.OpenLiveを利用します。
OpenLive()の引数にeth0などNWインターフェイスを指定すると、そのNWインターフェイスでパケットを受信します。
SetBPFFilter()を利用すると、合わせてフィルタも行えます。

if handle, err := pcap.OpenLive("eth0", 1600, true, pcap.BlockForever); err != nil {
  panic(err)
} else if err := handle.SetBPFFilter("tcp and port 80"); err != nil {  // optional
  panic(err)
} else {
  packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
  for packet := range packetSource.Packets() {
    handlePacket(packet)  // Do something with a packet here.
  }
}

https://pkg.go.dev/github.com/google/gopacket@v1.1.19/pcap#hdr-Reading_Live_Packets

OpenLive()を呼んで取得したHandleに対して、WritePacketData()を呼ぶとパケットを送信することができます。

func (p *Handle) WritePacketData(data []byte) (err error)

https://pkg.go.dev/github.com/google/gopacket@v1.1.19/pcap#Handle.WritePacketData

パケットの受信や送信はこれでできるようになるので、次はパケットの作成です。

gopacketによるパケットの作成や解析

gopacketではgopacket/layersに様々なパケットがタイプとして定義されているので、これを利用してパケットの作成や受信したパケットの解析が可能です。

https://pkg.go.dev/github.com/google/gopacket@v1.1.19/layers

ポートスキャナ自作本でも説明されているOSI参照モデルとパケットのカプセル化は皆さんもご存知でしょう。
L7→L4→L3→L2とパケットを作成して送信すると、受信側ではL2→L3→L4→L7と作るときの逆の順番で取り出されていきます。

gopacketでもこの仕組みは同じです。
例えばgopacketでL2、Ethernetのパケットを作るためには、layers.Ethernetを利用します。

type Ethernet struct {
	BaseLayer
	SrcMAC, DstMAC net.HardwareAddr
	EthernetType   EthernetType
	// Length is only set if a length field exists within this header.  Ethernet
	// headers follow two different standards, one that uses an EthernetType, the
	// other which defines a length the follows with a LLC header (802.3).  If the
	// former is the case, we set EthernetType and Length stays 0.  In the latter
	// case, we set Length and EthernetType = EthernetTypeLLC.
	Length uint16
}

https://pkg.go.dev/github.com/google/gopacket@v1.1.19/layers#Ethernet

layers.Ethernetが持つフィールドのSrcMacは送信元、自分のNWインターフェイスのMACアドレスです。
DstMacは宛先IPアドレスのMACアドレスです。IPv4ではARPで取得しますね。
EthernetTypeは次のL3レイヤのパケットタイプを指定します。
次のパケットがIPv4ならlayers.EthernetTypeIPv4をセットしますし、IPv6ならlayers.EthernetTypeIPv6をセットします。

EthernetTypeはIEEEで以下のように定義されています。

https://www.iana.org/assignments/ieee-802-numbers/ieee-802-numbers.xhtml

IPv4は0800なので、gopacketでもconstで定義されています。

const (
	EthernetTypeIPv4                        EthernetType = 0x0800
)

https://pkg.go.dev/github.com/google/gopacket@v1.1.19/layers#EthernetType

このように各レイヤごとにパケットデータを作成していきます。
パケットを作成したら送信する前に各レイヤごとのパケットデータをカプセル化して1つにする必要があります。

以下のようにgopacket.NewSerializeBuffer()で作成したバッファに、gopacket.SerializeLayers()で各レイヤのパケットを1つにまとめ、
buf.Bytes()でbyteのパケットデータが作成されます。

buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{}
gopacket.SerializeLayers(buf, opts,
  &layers.Ethernet{},
  &layers.IPv4{},
  &layers.TCP{},
  gopacket.Payload([]byte{1, 2, 3, 4}))
packetData := buf.Bytes()

https://pkg.go.dev/github.com/google/gopacket@v1.1.19#hdr-Creating_Packet_Data

パケットを読み込むときは読み込ませたいレイヤのタイプを指定すると、byteのパケットデータを各レイヤごとに読み込んでくれて
いい感じに構造体にセットしてくれます。

// Decode an ethernet packet
ethP := gopacket.NewPacket(p1, layers.LayerTypeEthernet, gopacket.Default)
// Decode an IPv6 header and everything it contains
ipP := gopacket.NewPacket(p2, layers.LayerTypeIPv6, gopacket.Default)
// Decode a TCP header and its payload
tcpP := gopacket.NewPacket(p3, layers.LayerTypeTCP, gopacket.Default)

https://pkg.go.dev/github.com/google/gopacket@v1.1.19#hdr-Basic_Usage

PINGの作成

パケットの作成・送信・受信などgopacketの使い方を一通り説明したので実際にコードを書いて動かしてみましょう。
以下はpingコマンドと同じようにICMPのEcho Requestを送信するコードです。

パケットを作成する部分に着目しましょう。
Etherent, IPv4, ICMPとパケットを作成します。EthernetとIPv4の送信元のMACアドレスとIPアドレスは取得したNWインターフェイスの情報からセットします。
宛先のMACアドレスとIPアドレスはプログラムのコマンドライン引数からセットします。

send-icmp.go
package main

import (
	"flag"
	"fmt"
	"github.com/google/gopacket"
	"github.com/google/gopacket/layers"
	"github.com/google/gopacket/pcap"
	"log"
	"net"
	"strings"
)

type nwDevice struct {
	macAddr  net.HardwareAddr
	ipv4Addr net.IP
}

func getInterface(ifname string) nwDevice {
	netifs, _ := net.Interfaces()
	for _, netif := range netifs {
		if netif.Name == ifname {
			addrs, _ := netif.Addrs()
			for _, addr := range addrs {
				if !strings.Contains(addr.String(), ":") && strings.Contains(addr.String(), ".") {
					ip, _, _ := net.ParseCIDR(addr.String())
					return nwDevice{
						macAddr:  netif.HardwareAddr,
						ipv4Addr: ip,
					}
				}
			}

		}
	}
	return nwDevice{}
}

func parseMac(macaddr string) net.HardwareAddr {
	parsedMac, _ := net.ParseMAC(macaddr)
	return parsedMac
}

func main() {
	// コマンド引数を受け取る
	var iface = flag.String("i", "eth0", "Interface to read packets from")
	var dstIp = flag.String("dst", "192.168.1.3", "dest ip addr")
	var dstMac = flag.String("dstmac", "", "dest ip addr")
	flag.Parse()

	// 送信元のMACアドレスとIPアドレスを取得する
	netif := getInterface(*iface)

	// Ethernetのパケットを作成する
	ethernet := &layers.Ethernet{
		SrcMAC:       netif.macAddr,
		DstMAC:       parseMac(*dstMac),
		EthernetType: layers.EthernetTypeIPv4,
	}
	// IPヘッダを作成する
	ip := &layers.IPv4{
		Version:  4,
		Flags:    layers.IPv4DontFragment,
		TTL:      64,
		Protocol: layers.IPProtocolICMPv4,
		SrcIP:    netif.ipv4Addr,
		DstIP:    net.ParseIP(*dstIp),
	}
	// ICMP Echo Requestのパケットを作成する
	icmp := &layers.ICMPv4{
		TypeCode: layers.CreateICMPv4TypeCode(layers.ICMPv4TypeEchoRequest, 0),
		Id:       0,
		Seq:      0,
	}
	packetbuf := gopacket.NewSerializeBuffer()
	// パケットを作成する
	err := gopacket.SerializeLayers(
		packetbuf,
		gopacket.SerializeOptions{
			FixLengths:       true,
			ComputeChecksums: true,
		},
		ethernet,
		ip,
		icmp)
	if err != nil {
		log.Fatalf("create packet err : %v", err)
	}

	handle, err := pcap.OpenLive(*iface, 1600, true, pcap.BlockForever)
	if err != nil {
		log.Fatal(err)
	}

	// pingを送信
	handle.WritePacketData(packetbuf.Bytes())
	// pingを受信
	packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
	for packet := range packetSource.Packets() {
		icmpLayer := packet.Layer(layers.LayerTypeICMPv4)
		reply := icmpLayer.(*layers.ICMPv4)
		if reply.TypeCode == layers.ICMPv4TypeEchoReply {
			fmt.Printf("recieve echo reply %+v\n", reply)
			break
		}
	}
}

スクリプトでNWを作成して動作確認しましょう。
ポートスキャナ自作本ではdockerを利用していますが、今回はNetwork Namespaceを利用してNetworkを作成します。

$ sudo ./tools/netns1.sh

スクリプトを実行すると以下図のようなNWが作成されるので、確認してみましょう。

$ sudo ip netns exec host0 ip addr show host0-host1
42: host0-host1@if41: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 8a:1c:60:3f:22:78 brd ff:ff:ff:ff:ff:ff link-netns host1
    inet 192.168.1.2/24 scope global host0-host1
       valid_lft forever preferred_lft forever
    inet6 fe80::881c:60ff:fe3f:2278/64 scope link 
       valid_lft forever preferred_lft forever
$ sudo ip netns exec host1 ip addr show host1-host0
41: host1-host0@if42: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 22:14:dd:c8:61:41 brd ff:ff:ff:ff:ff:ff link-netns host0
    inet 192.168.1.3/24 scope global host1-host0
       valid_lft forever preferred_lft forever
    inet6 fe80::2014:ddff:fec8:6141/64 scope link 
       valid_lft forever preferred_lft forever

host0からhost1にpingを実行してみると応答があります。

$ sudo ip netns exec host0 ping 192.168.1.3
PING 192.168.1.3 (192.168.1.3) 56(84) bytes of data.
64 bytes from 192.168.1.3: icmp_seq=1 ttl=64 time=0.083 ms
64 bytes from 192.168.1.3: icmp_seq=2 ttl=64 time=0.060 ms
^C
--- 192.168.1.3 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1024ms
rtt min/avg/max/mdev = 0.060/0.071/0.083/0.011 ms

プログラムをビルドしてpingを実行してみます。

$ ./tools/build.sh send-icmp.go

ARPを実装していないので、送信先のhost1のMACアドレスを変数にセットします。

$ export macaddr=$(sudo ip netns exec host0 ./tools/getmac.sh 192.168.1.3)

send-icmpを実行すると、応答が返ってきます。

$ sudo ip netns exec host0 ./send-icmp -i host0-host1 -dst 192.168.1.3 -dstmac $macaddr
recieve echo reply &{BaseLayer:{Contents:[0 0 0 0 0 0 0 0] Payload:[]} TypeCode:EchoReply Checksum:0 Id:0 Seq:0}

ポートスキャナの作成

tcp-syn-scan.goはポートスキャナ自作本のtcp-syn-scan.pyをGoで書いたファイルです。

ListenしているポートにTCPのSYNパケットを送信するとSYNACKのパケットが返ってくるので、その結果によりポートが開いているかを確認することになります。
TCPの3ハンドシェイクをせずSYNACKの応答があるかないかでスキャンを終了するので、TCPSYNスキャンとなります。

tcp-syn-scan.go
package main

import (
	"flag"
	"fmt"
	"github.com/google/gopacket"
	"github.com/google/gopacket/layers"
	"github.com/google/gopacket/pcap"
	"log"
	"net"
	"strconv"
	"strings"
)

type nwDevice struct {
	macAddr  net.HardwareAddr
	ipv4Addr net.IP
}

func parseMac(macaddr string) net.HardwareAddr {
	parsedMac, _ := net.ParseMAC(macaddr)
	return parsedMac
}

func getInterface(ifname string) nwDevice {
	netifs, _ := net.Interfaces()
	for _, netif := range netifs {
		if netif.Name == ifname {
			addrs, _ := netif.Addrs()
			for _, addr := range addrs {
				if !strings.Contains(addr.String(), ":") && strings.Contains(addr.String(), ".") {
					ip, _, _ := net.ParseCIDR(addr.String())
					return nwDevice{
						macAddr:  netif.HardwareAddr,
						ipv4Addr: ip,
					}
				}
			}

		}
	}
	return nwDevice{}
}

func main() {
	var iface = flag.String("i", "eth0", "Interface to read packets from")
	var dstIp = flag.String("dst", "127.0.0.1", "dest ip addr")
	var dstMac = flag.String("dstmac", "00:00:00:00:00:00", "dest macc addr")
	var dstPortStr = flag.String("p", "22", "dest port")
	flag.Parse()

	var srcPort = 20
	dstPort, _ := strconv.Atoi(*dstPortStr)
	netif := getInterface(*iface)

	ethernet := &layers.Ethernet{
		BaseLayer:    layers.BaseLayer{},
		SrcMAC:       netif.macAddr,
		DstMAC:       parseMac(*dstMac),
		EthernetType: layers.EthernetTypeIPv4,
	}
	ip := &layers.IPv4{
		Version:  4,
		Flags:    layers.IPv4DontFragment,
		TTL:      64,
		Protocol: layers.IPProtocolTCP,
		SrcIP:    netif.ipv4Addr,
		DstIP:    net.ParseIP(*dstIp),
	}
	tcp := &layers.TCP{
		SrcPort: layers.TCPPort(srcPort),
		DstPort: layers.TCPPort(dstPort),
		SYN:     true,
	}
	packetbuf := gopacket.NewSerializeBuffer()
	tcp.SetNetworkLayerForChecksum(ip)
	err := gopacket.SerializeLayers(
		packetbuf,
		gopacket.SerializeOptions{
			FixLengths:       true,
			ComputeChecksums: true,
		},
		ethernet,
		ip,
		tcp)
	if err != nil {
		log.Fatalf("create packet err : %v", err)
	}

	handle, err := pcap.OpenLive(*iface, 1600, true, pcap.BlockForever)
	if err != nil {
		log.Fatal(err)
	}
	defer handle.Close()
	// SYNパケットを送信
	handle.WritePacketData(packetbuf.Bytes())
	packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
	// SYNパケットを受信
	for packet := range packetSource.Packets() {
		ipLayer := packet.Layer(layers.LayerTypeIPv4)
		tcpLayer := packet.Layer(layers.LayerTypeTCP)
		synackip := ipLayer.(*layers.IPv4)
		synack := tcpLayer.(*layers.TCP)
		if synackip.SrcIP.Equal(ip.DstIP) && synack.ACK {
			fmt.Printf("TCP %d is open\n", dstPort)
			break
		} else {
			fmt.Printf("TCP %d is close\n", dstPort)
			break
		}
	}
}

次にポートスキャナ自作本ではTCP connectスキャンを自作しています。TCP connectスキャンは3ハンドシェイクの成立をもってポートが開いているかを
確認します。TCP SYNスキャンより時間がかかるので低速になります。

GoでTCPハンドシェイクをする場合、socketを利用することもできますが、netパッケージを利用するのがラクです。
net.Dial()を呼ぶとTCP接続が実行されるので、その結果を持ってポートが開いているかを確認します。

tcp-connect-scan-by-net.go
package main

import (
	"fmt"
	"log"
	"net"
	"os"
)

func main() {
	targetIP := os.Args[1]
	targetPort := os.Args[2]

	conn, err := net.Dial("tcp", fmt.Sprintf("%s:%s", targetIP, targetPort))
	if err != nil {
		log.Fatalf("TCP %s is close", targetPort)
	}
	defer conn.Close()

	fmt.Printf("TCP %s is open\n", targetPort)
}

gopacketを利用して、SYNACKに対してACKパケットを送り返してTCPハンドシェイクを成立させることも可能です。
pythonのコードと見比べながら、tcp-syn-scan.goにコードを追加してみましょう。

コードができたら動作確認をしてみましょう。
NWは先程作成したのと同じ構成を使います。

$ sudo ./tools/netns1.sh
$ sudo ip netns ls
host1 (id: 1)
host0 (id: 0)

プログラムをビルドします。

 $ ./tools/build.sh tcp-syn-scan.go

ARPを実装していないので、送信先のhost1のMACアドレスを変数にセットします。

$ export macaddr=$(sudo ip netns exec host0 ./tools/getmac.sh 192.168.1.3)
$ echo $macaddr
22:14:DD:C8:61:41

host1でncコマンドでTCPリッスンをしてポートを開きます。
host1のNetwork Namespaceでは8080ポートが開いています。

$ sudo ip netns exec host1 nc -kl 8080 &
$ sudo ip netns exec host1 ss -antu
Netid    State      Recv-Q     Send-Q         Local Address:Port         Peer Address:Port    Process    
tcp      LISTEN     0          1                    0.0.0.0:8080              0.0.0.0:*

ビルドしたtcp-syn-scanを実行すると、SYNパケットに対してSYNACKパケットが返ってくるのでポートが開いていると確認できます。

$ sudo ip netns exec host0 ./tcp-syn-scan -i host0-host1 -dst 192.168.1.3 -dstmac $macaddr -p 8080
TCP 8080 is open

ARPスプーフィング

ポートスキャナ自作本ではAPRスプーフィングを紹介していますので、Goでもやってみます。
Ethernetからパケットを送信するときには、宛先IPアドレスのMACアドレスが必要です。

宛先IPアドレスの取得はIPv4であれば、ARPリクエストをブロードキャストで送信します。

  1. 「このIPアドレスを持っている人がいたらMACアドレス教えてください」(ARPリクエスト)
  2. 「そのIPアドレスは私です。私のMACアドレスはxx:xx:xx:xx:xxです」(ARPリプライ)

↑のようにARPリクエストがLAN内にブロードキャストされて、該当のIPアドレスを持つホストがARPリプライを返します。
こうして宛先IPアドレスのMACアドレスが取得されます。

この仕組みを応用して、自分のMACアドレスを他のIPアドレスに置き換えたARPリプライを送信することでなりすましが可能です。
これがARPスプーフィングになります。

ARPスプーフィングのプログラムを作成する前に、gopacketでARPリクエストを送るプログラムを作成してみます。

send-arp.go
package main

import (
	"flag"
	"fmt"
	"github.com/google/gopacket"
	"github.com/google/gopacket/layers"
	"github.com/google/gopacket/pcap"
	"log"
	"net"
	"strings"
)

type nwDevice struct {
	macAddr  net.HardwareAddr
	ipv4Addr net.IP
}

func getInterface(ifname string) nwDevice {
	netifs, _ := net.Interfaces()
	for _, netif := range netifs {
		if netif.Name == ifname {
			addrs, _ := netif.Addrs()
			for _, addr := range addrs {
				if !strings.Contains(addr.String(), ":") && strings.Contains(addr.String(), ".") {
					ip, _, _ := net.ParseCIDR(addr.String())
					return nwDevice{
						macAddr:  netif.HardwareAddr,
						ipv4Addr: ip,
					}
				}
			}

		}
	}
	return nwDevice{}
}

func parseMac(macaddr string) net.HardwareAddr {
	parsedMac, _ := net.ParseMAC(macaddr)
	return parsedMac
}

func main() {
	var iface = flag.String("i", "eth0", "Interface to read packets from")
	var dstIp = flag.String("dst", "127.0.0.1", "dest ip addr")
	flag.Parse()

	netif := getInterface(*iface)

	ethernet := &layers.Ethernet{
		SrcMAC:       netif.macAddr,
		DstMAC:       parseMac("FF:FF:FF:FF:FF:FF"),
		EthernetType: layers.EthernetTypeARP,
	}
	arpreq := &layers.ARP{
		AddrType:          layers.LinkTypeEthernet,
		Protocol:          layers.EthernetTypeIPv4,
		HwAddressSize:     6,
		ProtAddressSize:   4,
		Operation:         layers.ARPRequest,
		SourceHwAddress:   netif.macAddr,
		SourceProtAddress: netif.ipv4Addr.To4(),
		DstHwAddress:      []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
		DstProtAddress:    net.ParseIP(*dstIp).To4(),
	}
	packetbuf := gopacket.NewSerializeBuffer()
	err := gopacket.SerializeLayers(
		packetbuf,
		gopacket.SerializeOptions{
			FixLengths: true,
		},
		ethernet,
		arpreq)
	if err != nil {
		log.Fatalf("create packet err : %v", err)
	}
	handle, err := pcap.OpenLive(*iface, 1600, true, pcap.BlockForever)
	if err != nil {
		log.Fatal(err)
	}

	// ARPリクエストを送信
	handle.WritePacketData(packetbuf.Bytes())
	// ARPリプライを受信
	packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
	for packet := range packetSource.Packets() {
		ethernetLayer := packet.Layer(layers.LayerTypeEthernet)
		ethernetPacket := ethernetLayer.(*layers.Ethernet)
		if ethernetPacket.EthernetType.LayerType() == layers.LayerTypeARP {
			fmt.Printf("packet is %+v\n", packet)
			break
		}
	}
}

これをビルドして実行すると、ARPリプライの応答があります。

$ sudo ip netns exec host0 ./send-arp -i host0-host1 -dst 192.168.1.3
packet is PACKET: 42 bytes, wire length 42 cap length 42 @ 2023-11-03 11:39:43.447143 +0900 JST
- Layer 1 (14 bytes) = Ethernet	{Contents=[..14..] Payload=[..28..] SrcMAC=22:14:dd:c8:61:41 DstMAC=ce:8c:ad:1d:46:a6 EthernetType=ARP Length=0}
- Layer 2 (28 bytes) = ARP	{Contents=[..28..] Payload=[] AddrType=Ethernet Protocol=IPv4 HwAddressSize=6 ProtAddressSize=4 Operation=2 SourceHwAddress=[..6..] SourceProtAddress=[192, 168, 1, 3] DstHwAddress=[..6..] DstProtAddress=[192, 168, 1, 2]}

send-arp.goを参考に、ARPリプライを送りつけるプログラムを作成するとARPスプーフィングが可能になります。
今回は以下のようなNWを作成します。

host0からhost1に通信をする場合、ARPリクエスト要求が送られます。
この時attackerは偽のAPRリプライを送信することで、host0からhost1への通信を自分にも送ることができます。

実際に試してみましょう。
まずNWを作成します。

$ sudo ./tools/netns2.sh
$ sudo ip netns ls
attacker (id: 4)
host1 (id: 3)
host0 (id: 2)

プログラムが作成できたらビルドして実行します。

$ ./tools/build.sh arp-spoof.go
$ sudo ip netns exec attacker ./arp-spoof -i attacker-br0

host0からhost1へpingを送ると、Redirect Hostというメッセージが見えます。
これはARPスプーフィングによる偽のARPリプライにより、attackerにもpingが送信されてしまっているからです。

$ sudo ip netns exec host0 ping 192.168.1.3
PING 192.168.1.3 (192.168.1.3) 56(84) bytes of data.
64 bytes from 192.168.1.3: icmp_seq=1 ttl=64 time=0.111 ms
64 bytes from 192.168.1.3: icmp_seq=2 ttl=64 time=0.083 ms
From 192.168.1.4: icmp_seq=3 Redirect Host(New nexthop: 192.168.1.3)
64 bytes from 192.168.1.3: icmp_seq=3 ttl=64 time=0.223 ms
From 192.168.1.4: icmp_seq=4 Redirect Host(New nexthop: 192.168.1.3)
64 bytes from 192.168.1.3: icmp_seq=4 ttl=64 time=0.087 ms
From 192.168.1.4: icmp_seq=5 Redirect Host(New nexthop: 192.168.1.3)
64 bytes from 192.168.1.3: icmp_seq=5 ttl=64 time=0.156 ms
64 bytes from 192.168.1.3: icmp_seq=6 ttl=64 time=0.112 ms
From 192.168.1.4: icmp_seq=7 Redirect Host(New nexthop: 192.168.1.3)
64 bytes from 192.168.1.3: icmp_seq=7 ttl=64 time=0.122 ms
From 192.168.1.4: icmp_seq=8 Redirect Host(New nexthop: 192.168.1.3)
64 bytes from 192.168.1.3: icmp_seq=8 ttl=64 time=0.094 ms
^C
--- 192.168.1.3 ping statistics ---
8 packets transmitted, 8 received, 0% packet loss, time 7154ms
rtt min/avg/max/mdev = 0.083/0.123/0.223/0.043 ms

curlコマンドでどうなるか試してみましょう。
host1でhttpサーバのプログラムを起動します。

$ go build http-server.go
$ sudo ip netns exec host1 ./http-server

host0でcurlコマンドをhost1に実行すると、正常にHTTPレスポンスが返ってきます。

$ sudo ip netns exec host0 curl http://192.168.1.3:8080
hello

しかしこのHTTPリクエストはARPスプーフィングによりattackerにも送信されているので、キャプチャされてしまいます。

$ sudo ip netns exec attacker ./arp-spoof -i attacker-br0
start ARP spoofing...
ICMP is &{BaseLayer:{Contents:[5 1 188 215 192 168 1 3] Payload:[69 0 0 60 122 24 64 0 63 6 62 78 192 168 1 2 192 168 1 3 213 30 31 144 127 75 36 64 0 0 0 0 160 2 250 240 241 8 0 0 2 4 5 180 4 2 8 10 4 144 59 230 0 0 0 0 1 3 3 7]} TypeCode:Redirect(Host) Checksum:48343 Id:49320 Seq:259}
HTTP Request is GET / HTTP/1.1
Host: 192.168.1.3:8080
User-Agent: curl/7.81.0
Accept: */*


arp-spoof.goは穴埋めになっています。
どのようなメッセージを作成すれば、上記の結果のように騙すことができるでしょうか試してみてください。

おわりに

gopacketよりScapyの方が簡単にパケットを作れますが、面倒な分パケットの構造などよりgopacketの方が勉強になるかなと思います。
いろいろな言語で実装してみるのも楽しみの1つですよね。

ポートスキャナ自作本もぜひ購入してみてください。

Discussion

uzullauzulla
macaddr=$(sudo ip netns exec host0 ./tools/getmac.sh 192.168.1.3)

とありますが、順番に実行するなら、以下のようにexportが必要かもしれません。

export macaddr=$(sudo ip netns exec host0 ./tools/getmac.sh 192.168.1.3)
kn1515kn1515
docker run --privileged -it -v $(pwd):/work -w /work sat0ken/go-port-scanner bash

細かいのですが、-wオプションをつけて docker run すると、cdせずにcloneしたディレクトリにいる状態でコンテナに入るので、混乱せずに作業できる気がしました。
※wオプションをつけない状態だと、 /go から始まるので、buildするときなどに若干混乱しました。