😂

お前のパケットはもう死んでいる。TCPに死亡フラグを実装してみた

2023/06/07に公開

はじめに

プロトコルの仕様などIETFが発行しているRFCにはジョークRFCというものが存在しています。
伝書鳩でIP通信するとか、コーヒーポットを制御するなどが有名です。

鳥類キャリアによるIP
Hyper Text Coffee Pot Control Protocol (HTCPCP/1.0) 日本語訳

今年そんなジョークRFCに、TCPに死亡フラグを実装するというRFC9401が追加されました。

The Addition of the Death (DTH) Flag to TCP 日本語訳

この記事ではこのTCPに死亡フラグを実装するというRFC9401を真面目に実装してみることにします。

実装したコードは以下にあります。
https://github.com/sat0ken/go-rfc9401

こういう場合の死亡フラグもあるのでは?など実装漏れがありましたらご連絡ください。

RFC9401

RFC9401の内容を確認します。

  1. Introductionでは提案の意義と、死亡フラグについて説明されています。

塹壕の兵士がこの戦いが終わったら故郷に帰り結婚について話すと死亡フラグが立つなど具体的な例が示されています。
参考引用では以下の記事も示されています。

https://www.cbr.com/anime-death-hints-signs

  1. Specificationの3.1. TCP Packet Formatで仕様が例示されています。
    TCPヘッダーのコントロールビットフィールドの4番目のビットを使用すると書かれています。

死を意味する4番目のビットが立っていたら、死亡フラグが立っていることを意味するパケットになります。

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Source Port          |       Destination Port        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Sequence Number                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Acknowledgment Number                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Data |D|     |C|E|U|A|P|R|S|F|                               |
| Offset|T| Rsr |W|C|R|C|S|S|Y|I|            Window             |
|       |H| vd  |R|E|G|K|H|T|N|N|                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           Checksum            |         Urgent Pointer        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           [Options]                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               :
:                             Data                              :
:                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

以下はRFC9293にある通常のTCPヘッダです。RFC9401のPacket Formatと見比べてみましょう。
通常DataOffsetの後ろ4bitはRsrvdとして確保されています。このRsrvdの先頭bit(死を意味する4bit目)を死亡フラグに使うというのが今回の提案になります。

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Source Port          |       Destination Port        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Sequence Number                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Acknowledgment Number                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Data |       |C|E|U|A|P|R|S|F|                               |
| Offset| Rsrvd |W|C|R|C|S|S|Y|I|            Window             |
|       |       |R|E|G|K|H|T|N|N|                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           Checksum            |         Urgent Pointer        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           [Options]                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               :
:                             Data                              :
:                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

RFC9401の続きです。
死亡フラグを受信してもすぐにソケットを閉じず、RSTかFINを待てと書かれているので、実装で考慮する必要があります。

ただし、情報をアプリケーションレイヤーに伝えることをお勧めします。そうすれば、エンドユーザーにインシデントを通知できます。DTHセグメントの受信者は、受信後すぐにソケットを閉じないでください。RSTまたはFINセグメントを待つ必要があります。

3.2. When to Sendではいつ送るのか説明されています。
突然悔い改めたときや裏切りの初期の兆候などとのことです。いつ送るかは置いといて、とりあえず死亡フラグを出すことを考えます笑笑

3.3. When Not to Sendでは送らないときについて説明されています。
死亡フラグはFINとは異なると書かれています。
北斗神拳の使い手である場合は例外として、送信者はすでに死んでいますが、数秒間活動し続けていると書かれています。
お前はもう死んでいるって有名なセリフですね。

TCPセッションの初期状態でのフラグの使用は推奨されません。 とのことなので、死亡フラグが立ったハンドシェイクはしないほうがいいでしょう。
あくまでTCP接続後に起きるということで実装で考慮します。

実装に関係しそうな点はざっとこんなところでしょうか。

RFC9401の実装

実装はGoで行います。

Goで通信プログラムを実装するときは、標準のnetパッケージを利用します。
以下例のように、net.Dialの引数で指定したnetwork層で通信ができます。

conn, err := net.Dial("tcp", "golang.org:80")
if err != nil {
	// handle error
}
fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n")
status, err := bufio.NewReader(conn).ReadString('\n')

ただ今回は死亡フラグをセットするために、TCPヘッダをプログラム側でいじる必要があります。
net.Dialtcpをセットした場合はTCP接続がカーネル側で行われ、作成されたソケットのディスクリプタが返ってくるので、
プログラム側からTCPヘッダにアクセスすることはできません。
実際にnetパッケージのソースを見てみましょう。

net.Dialからソースを辿っていくとTCP接続の場合はdial.godialSingleからdialTCP関数が呼ばれます。

// dialSingle attempts to establish and returns a single connection to
// the destination address.
func (sd *sysDialer) dialSingle(ctx context.Context, ra Addr) (c Conn, err error) {
	trace, _ := ctx.Value(nettrace.TraceKey{}).(*nettrace.Trace)
	if trace != nil {
		raStr := ra.String()
		if trace.ConnectStart != nil {
			trace.ConnectStart(sd.network, raStr)
		}
		if trace.ConnectDone != nil {
			defer func() { trace.ConnectDone(sd.network, raStr, err) }()
		}
	}
	la := sd.LocalAddr
	switch ra := ra.(type) {
	case *TCPAddr:
		la, _ := la.(*TCPAddr)
		c, err = sd.dialTCP(ctx, la, ra)

tcpsock_posix.godialTCPからdoDialTCPinternetSocket関数が呼ばれます。
ソケットを作成するinternetSocket関数の引数にはsyscall.SOCK_STREAMが指定されています。

func (sd *sysDialer) dialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
	if h := sd.testHookDialTCP; h != nil {
		return h(ctx, sd.network, laddr, raddr)
	}
	if h := testHookDialTCP; h != nil {
		return h(ctx, sd.network, laddr, raddr)
	}
	return sd.doDialTCP(ctx, laddr, raddr)
}

func (sd *sysDialer) doDialTCP(ctx context.Context, laddr, raddr *TCPAddr) (*TCPConn, error) {
	fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_STREAM, 0, "dial", sd.Dialer.Control)

socketのmanページに書かれているようにSOCK_STREAMは、
順序性と信頼性があり、双方向の、接続された バイトストリーム (byte stream) を提供します。

SOCK_STREAMでソケットを作成すると、例えるとクライアントとサーバ間で糸電話が接続されたような状態になります。紙コップに声を出せば、相手に声が届きます。
internetSocketで作成されたソケットのディスクリプタにデータを書き込めば、相手に届きます。相手はディスクリプタから読み込んでデータを受信します。

TCP接続をするときのnetパッケージのソースを追ってみました。
ソケットの作成でSOCK_STREAMが指定されてしまうと、ユーザプログラムからTCPヘッダを処理することができないので死亡フラグの実装に困ります。
これを解消するためには、net.Dialの引数にはip:tcpをセットします。
これはIPレイヤからTCPパケットを送信するという意味になります。実際にnetパッケージのソースを見てみます。

IP接続の場合はdial.godialSingleから、TCPの場合はdialTCPが呼ばれていましたが、dialIP関数が呼ばれます。

	case *IPAddr:
		la, _ := la.(*IPAddr)
		c, err = sd.dialIP(ctx, la, ra)

iprawsock_posix.godialIPからソケットを作成するinternetSocketを呼んでいますが、引数にSOCK_RAWがセットされています。
SOCK_RAW生のネットワークプロトコルへのアクセスを提供するソケットです。

func (sd *sysDialer) dialIP(ctx context.Context, laddr, raddr *IPAddr) (*IPConn, error) {
	network, proto, err := parseNetwork(ctx, sd.network, true)
	if err != nil {
		return nil, err
	}
	switch network {
	case "ip", "ip4", "ip6":
	default:
		return nil, UnknownNetworkError(sd.network)
	}
	fd, err := internetSocket(ctx, network, laddr, raddr, syscall.SOCK_RAW, proto, "dial", sd.Dialer.Control)
	if err != nil {
		return nil, err
	}
	return newIPConn(fd), nil
}

こうしてソケットを作成するとTCPをカーネルに処理させず、ユーザプログラム側でパケットを送受信することができます。
ip:tcpをセットするとカーネルに処理してもらうのはIPレイヤまでになるからです。

これを応用すると例えばip:icmpと書けば、自分でICMPパケットを作ってpingコマンドを実装したりもできます。
IPレイヤまではカーネル側にお願いして、それより上のTCPはユーザプログラム側で実装することでTCPヘッダに死亡フラグを立てることが可能になります。

さてこれが何を意味するかというと、TCPスタックを自分で実装する必要があるということになります。
TCPヘッダに死亡フラグが立っているか、いないかを確認するためだけに、ジョークRFCを実装するためだけにです...(渇いた笑い)

TCPスタックの実装

TCPの実装をしたファイルとしてレポジトリ内に3つのファイルがあります。

tcp_conn.go     // パケットの送信受信処理をする
tcp_header.go   // TCPヘッダの構造体や定数を定義
tcp_option.go   // TCPオプションを定義

tcp_header.goファイルを上から説明していきます。
まず、TCPヘッダを構造体で定義しています。DTHが死亡フラグのフィールドです。

type TCPHeader struct {
	TCPDummyHeader tcpDummyHeader
	SourcePort     []byte
	DestPort       []byte
	SeqNumber      []byte
	AckNumber      []byte
	DataOffset     uint8
	DTH            uint8        // 死亡フラグ
	Reserved       uint8
	TCPCtrlFlags   tcpCtrlFlags
	WindowSize     []byte
	Checksum       []byte
	UrgentPointer  []byte
	Options        tcpOptions
	Data           []byte
}

tcpDummyHeaderはチェックサムの計算に使う疑似ヘッダです。
送信元、送信先のIPアドレスとプロトコル番号、パケット長を持ちます。

type tcpDummyHeader struct {
	SourceIP []byte
	DestIP   []byte
	Protocol []byte
	Length   []byte
}

tcpCtrlFlagsは8bitでTCPの状態を表すフィールドです。
SYNだったら10で0x02になりますし、PSHACKだったら11000で0x18になります。

各フラグに値をセットする関数ではbit演算をしてbitシフトします。

type tcpCtrlFlags struct {
	CWR uint8
	ECR uint8
	URG uint8
	ACK uint8
	PSH uint8
	RST uint8
	SYN uint8
	FIN uint8
}

func (ctrlFlags *tcpCtrlFlags) parseTCPCtrlFlags(packet uint8) {
	ctrlFlags.CWR = packet & 0x80 >> 7
	ctrlFlags.ECR = packet & 0x40 >> 6
	ctrlFlags.URG = packet & 0x20 >> 5
	ctrlFlags.ACK = packet & 0x10 >> 4
	ctrlFlags.PSH = packet & 0x08 >> 3
	ctrlFlags.RST = packet & 0x04 >> 2
	ctrlFlags.SYN = packet & 0x02 >> 1
	ctrlFlags.FIN = packet & 0x01
}

TCPのフラグ状態を読み取る関数とフラグをセットする関数です。
フラグをセットするときは、bitが立っているフラグを定数で定義しておいて足し算をして返します。

SYNだったら10なので0x02を足しますし、SYNACKなら10010なのでSYN=0x02+ACK=0x10を返します。

func (ctrlFlags *tcpCtrlFlags) getState() int {
	if ctrlFlags.SYN == 1 && ctrlFlags.ACK == 0 {
		return SYN
	} else if ctrlFlags.SYN == 1 && ctrlFlags.ACK == 1 {
		return SYN + ACK
	} else if ctrlFlags.PSH == 1 && ctrlFlags.ACK == 1 {
		return PSH + ACK
	} else if ctrlFlags.FIN == 1 && ctrlFlags.ACK == 1 {
		return FIN + ACK
	} else if ctrlFlags.ACK == 1 && ctrlFlags.SYN == 0 || ctrlFlags.FIN == 0 || ctrlFlags.PSH == 0 {
		return ACK
	}
	return 0
}

func (ctrlFlags *tcpCtrlFlags) toPacket() (flags uint8) {

	if ctrlFlags.CWR == 1 {
		flags += CWR
	}
	if ctrlFlags.ECR == 1 {
		flags += ECR
	}
	if ctrlFlags.URG == 1 {
		flags += URG
	}
	if ctrlFlags.ACK == 1 {
		flags += ACK
	}
	if ctrlFlags.PSH == 1 {
		flags += PSH
	}
	if ctrlFlags.RST == 1 {
		flags += RST
	}
	if ctrlFlags.SYN == 1 {
		flags += SYN
	}
	if ctrlFlags.FIN == 1 {
		flags += FIN
	}

	return flags
}

TCPヘッダのパケットをパースして構造体にセットして返す関数です。
先頭からサイズ分読み取って構造体のフィールドにセットしていきます。

死亡フラグは4bit目が立っているかなので、1000=0x08とANDを取ってセットします。

func parseTCPHeader(packet []byte, clientAddr string, serverAddr string) (tcpHeader TCPHeader) {
	// SourceのIPアドレスとDestinationのIPアドレスをダミーヘッダにセット
	tcpHeader.TCPDummyHeader.SourceIP = ipv4ToByte(serverAddr)
	tcpHeader.TCPDummyHeader.DestIP = ipv4ToByte(clientAddr)

	// TCPヘッダをセット
	tcpHeader.SourcePort = packet[0:2]
	tcpHeader.DestPort = packet[2:4]
	tcpHeader.SeqNumber = packet[4:8]
	tcpHeader.AckNumber = packet[8:12]
	tcpHeader.DataOffset = packet[12] >> 2
	tcpHeader.DTH = packet[12] & 0x08
	tcpHeader.Reserved = packet[12] & 0x07
	tcpHeader.TCPCtrlFlags.parseTCPCtrlFlags(packet[13])
	tcpHeader.WindowSize = packet[14:16]
	tcpHeader.Checksum = packet[16:18]
	tcpHeader.UrgentPointer = packet[18:20]
	tcpHeader.Options = parseTCPOptions(packet[20:tcpHeader.DataOffset])
	// TCPデータがあればセット
	if int(tcpHeader.DataOffset) < len(packet) {
		tcpHeader.Data = packet[tcpHeader.DataOffset:]
	}

	return tcpHeader
}

toPacket関数で構造体から送信するパケットデータを作成します。
構造体にセットされている値を順番にbytes.BufferWriteしていきます。

チェックサムを計算する必要があるので、いったんTCPパケットを生成します。
生成したパケットでチェックサムを計算したら、計算結果のチェックサムをセットしてTCPのパケットが完成です。

func (tcpheader *TCPHeader) toPacket() (packet []byte) {
	var b bytes.Buffer

	b.Write(tcpheader.SourcePort)
	b.Write(tcpheader.DestPort)
	b.Write(tcpheader.SeqNumber)
	b.Write(tcpheader.AckNumber)

	offset := tcpheader.DataOffset << 2
	// 死亡フラグが立ってたら4bit目を立てるので8(=1000)を足す
	if tcpheader.DTH == 1 && tcpheader.Reserved == 0 {
		offset += 0x08
	}

	b.Write([]byte{offset})
	b.Write([]byte{tcpheader.TCPCtrlFlags.toPacket()})
	b.Write(tcpheader.WindowSize)
	// checksumを計算するために0にする
	tcpheader.Checksum = []byte{0x00, 0x00}
	b.Write(tcpheader.Checksum)
	b.Write(tcpheader.UrgentPointer)

	packet = b.Bytes()
	// チェックサムを計算する用の変数
	var calc []byte
	// TCPダミーヘッダをセット
	if tcpheader.Data == nil {
		calc = tcpheader.TCPDummyHeader.toPacket(len(packet))
	} else {
		calc = tcpheader.TCPDummyHeader.toPacket(len(tcpheader.Data) + len(packet))
	}
	// TCPヘッダを追加
	calc = append(calc, packet...)
	// https://atmarkit.itmedia.co.jp/ait/articles/0401/29/news080_2.html
	// TCPのチェックサムを計算する場合は、先頭にこの擬似的なヘッダが存在するものとして、TCPヘッダ、TCPペイロードとともに計算する。
	// IPアドレスの情報はIPヘッダ中から抜き出してくる。
	// ペイロード長が奇数の場合は、最後に1byteの「0」を補って計算する(この追加する1byteのデータは、パケット長には含めない)。
	if tcpheader.Data != nil {
		if len(tcpheader.Data)%2 != 0 {
			calc = append(calc, tcpheader.Data...)
			calc = append(calc, 0x00)
		} else {
			calc = append(calc, tcpheader.Data...)
		}
	}
	// チェックサムを計算
	checksum := calcChecksum(calc)

	// 計算したchecksumをセット
	packet[16] = checksum[0]
	packet[17] = checksum[1]
	// TCPデータをセット
	if tcpheader.Data != nil {
		packet = append(packet, tcpheader.Data...)
	}

	return packet
}

func (dummyHeader *tcpDummyHeader) toPacket(length int) []byte {
	var b bytes.Buffer
	b.Write(dummyHeader.SourceIP)
	b.Write(dummyHeader.DestIP)
	// 0x06 = TCP
	b.Write([]byte{0x00, 0x06})
	b.Write(uint16ToByte(uint16(length)))
	return b.Bytes()
}

TCPヘッダ構造体に紐付いている関数がまだありますが、tcp_conn.goの説明といっしょに
説明したほうがいいのでtcp_conn.goの説明に移ります。

net.Dialと同じようにクライアントから呼ぶときのrfc9401.Dial関数を用意しました。

Dial関数ではgoroutineでパケットを受信するListen関数を呼んでおきます。
TCPパケットを送信するだけではTCPハンドシェイクが成立しないので、送信したTCPパケットに対する返信を受信して処理する必要があります。
そのため、Listen関数でパケットを受信する必要があります。

net.DialでTCPパケットを送るためのソケットを作成したら、SYNパケットを作成してサーバに送信します。
SYNパケットを送ったら、サーバからSYNACKパケットが返ってくるはずなので、goroutineで呼んだListen関数内で処理をします。

func Dial(clientAddr string, serverAddr string, serverpPort int) (TCPHeader, error) {
    // エフェメラルポートをランダムで取得
	clientPort := getRandomClientPort()

	chHeader := make(chan TcpState)
	go func() {
		Listen(serverAddr, clientPort, chHeader)
	}()

	conn, err := net.Dial(NETWORK_STR, serverAddr)
	if err != nil {
		return TCPHeader{}, err
	}
	defer conn.Close()

	// SYNパケットを作る
	syn := TCPHeader{
		TCPDummyHeader: tcpDummyHeader{
			SourceIP: ipv4ToByte(clientAddr),
			DestIP:   ipv4ToByte(serverAddr),
		},
		SourcePort:    uint16ToByte(uint16(clientPort)),
		DestPort:      uint16ToByte(uint16(serverpPort)),
		SeqNumber:     uint32ToByte(209828892),
		AckNumber:     uint32ToByte(0),
		DataOffset:    20,
		DTH:           0,
		Reserved:      0,
		TCPCtrlFlags:  tcpCtrlFlags{SYN: 1},
		WindowSize:    uint16ToByte(65495),
		Checksum:      uint16ToByte(0),
		UrgentPointer: uint16ToByte(0),
	}

	// SYNパケットを送る
	_, err = conn.Write(syn.toPacket())
	if err != nil {
		return TCPHeader{}, fmt.Errorf("SYN Packet Send error : %s", err)
	}

	fmt.Println("Send SYN Packet")
	// SYNACKに対して受信したACKを受ける
	result := <-chHeader
	if result.err != nil {
		return TCPHeader{}, result.err
	}

	return result.tcpHeader, nil
}

Dialからgoroutineで呼んでいたListen関数です。
net.ListenPacketでTCPパケットを受信して、parseTCPHeaderでパケットをパースします。

宛先ポートがDial関数で取得したエフェメラルポートであれば自分宛てのパケットとしてhandleTCPConnection関数を呼び
受信したパケットを処理をします。

func Listen(listenAddr string, port int, chHeader chan TcpState) error {
	conn, err := net.ListenPacket(NETWORK_STR, listenAddr)
	if err != nil {
		log.Fatalf("Listen is err : %v", err)
	}
	defer conn.Close()

	for {
		buf := make([]byte, 1500)
		n, clientAddr, err := conn.ReadFrom(buf)
		if err != nil {
			return err
		}
		tcp := parseTCPHeader(buf[:n], clientAddr.String(), listenAddr)
		// 宛先ポートがクライアントのソースポートであれば
		if byteToUint16(tcp.DestPort) == uint16(port) {
			// fmt.Printf("tcp header is %+v\n", tcp)
			handleTCPConnection(conn, tcp, conn.LocalAddr(), port, chHeader)
		}
	}
}

受信したTCPパケットを処理するhandleTCPConnection関数です。
TCPのフローに従ってパケットを処理します。

https://tex2e.github.io/rfc-translater/html/rfc9293.html#3-3-2--State-Machine-Overview

siwtch文で以下のパケットを受信したケースに対する処理を実装しています。

  • SYN = SYNACKを返す
  • SYNACK = ACKを返す
  • PSHACK = ACKを返す
  • FINACK = ACKを返す
  • ACK = 状態を保持しておく

TCPではパケットを正常に受信したことを相手に伝えるためにACKパケットを送る必要があります。
そのため、SYN, SYNACK, PSHACK, FINACKパケットを受信しときはACKを返します。

TCPはSequence番号とAcknowledge番号によって信頼性を担保するため、プログラムでもそのように処理をする必要があります。
受信したパケットのSequence番号をAcknowledge番号に、Acknowledge番号にはSequence番号+受信したデータサイズを
セットすることで正常にパケットを受信したことを相手に伝達します。

ACKを受信したときは、以後の通信の続きで使用するため、値を保持しておきます。

受信しパケットに対して返信したら、channelで返します。

func handleTCPConnection(pconn net.PacketConn, tcpHeader TCPHeader, client net.Addr, port int, ch chan TcpState) {

	switch tcpHeader.TCPCtrlFlags.getState() {
	case SYN:
		fmt.Println("receive SYN packet")
		tcpHeader.DestPort = tcpHeader.SourcePort
		tcpHeader.SourcePort = uint16ToByte(uint16(port))
		tcpHeader.AckNumber = addAckNumber(tcpHeader.SeqNumber, 1)
		tcpHeader.SeqNumber = uint32ToByte(2142718385)
		tcpHeader.DataOffset = 20
		tcpHeader.TCPCtrlFlags.ACK = 1
		// SYNACKパケットを送信
		_, err := pconn.WriteTo(tcpHeader.toPacket(), client)
		if err != nil {
			log.Fatal(" Write SYNACK is err : %v\n", err)
		}
		fmt.Println("Send SYNACK packet, wait ACK Packet...")
	case SYN + ACK:
		fmt.Println("Recv SYNACK packet")
		tcpHeader.DestPort = tcpHeader.SourcePort
		tcpHeader.SourcePort = uint16ToByte(uint16(port))
		// ACKをいったん保存
		tmpack := tcpHeader.AckNumber
		// ACKに
		tcpHeader.AckNumber = addAckNumber(tcpHeader.SeqNumber, 1)
		tcpHeader.SeqNumber = tmpack
		// Option+12byte
		tcpHeader.DataOffset = 20
		tcpHeader.TCPCtrlFlags.SYN = 0
		// ACKパケットを送信
		_, err := pconn.WriteTo(tcpHeader.toPacket(), client)
		if err != nil {
			ch <- TcpState{tcpHeader: TCPHeader{}, err: fmt.Errorf("Send SYNACK err: %v", err)}
		}
		fmt.Println("Send ACK Packet")
		// ACKまできたのでchannelで一度返す
		ch <- TcpState{tcpHeader: tcpHeader, err: nil}
	case PSH + ACK:
		fmt.Println("Recv PSHACK packet")
		var tcpdata []byte
		var isexistTCPData bool
		if tcpHeader.Data != nil {
			isexistTCPData = true
			tcpdata = tcpHeader.Data
		}
		tcpHeader.DestPort = tcpHeader.SourcePort
		tcpHeader.SourcePort = uint16ToByte(uint16(port))
		// ACKをいったん保存
		tmpack := tcpHeader.AckNumber
		// ACKに
		tcpHeader.AckNumber = addAckNumber(tcpHeader.SeqNumber, uint32(len(tcpHeader.Data)))
		tcpHeader.SeqNumber = tmpack
		tcpHeader.DataOffset = 20
		tcpHeader.TCPCtrlFlags.PSH = 0
		tcpHeader.Data = nil
		// ACKパケットを送信
		_, err := pconn.WriteTo(tcpHeader.toPacket(), client)
		if err != nil {
			ch <- TcpState{tcpHeader: TCPHeader{}, err: fmt.Errorf("Send SYNACK err: %v", err)}
		}
		fmt.Println("Send ACK to PSHACK Packet")
		// TCPのデータがなければACKを送ったのでchannelで返す
		if !isexistTCPData {
			ch <- TcpState{tcpHeader: tcpHeader, err: nil}
		} else {
			if port == int(byteToUint16(tcpHeader.SourcePort)) && port == 18000 {
				fmt.Println("Send PSHACK Packet From server")
				// サーバならHTTPレスポンスを返す
				tcpHeader.DTH = 1
				resultHeader, _, err := tcpHeader.Write(CreateHttpResp("hello\n"))
				if err != nil {
					ch <- TcpState{tcpHeader: TCPHeader{}, err: err}
				}
				ch <- TcpState{tcpHeader: resultHeader, err: nil}
			} else {
				ch <- TcpState{tcpHeader: tcpHeader, tcpData: tcpdata, err: nil}
			}
		}
	case FIN + ACK:
		fmt.Println("Recv FINACK packet")
		tcpHeader.DestPort = tcpHeader.SourcePort
		tcpHeader.SourcePort = uint16ToByte(uint16(port))
		// ACKをいったん保存
		tmpack := tcpHeader.AckNumber
		// ACKにSEQを
		tcpHeader.AckNumber = addAckNumber(tcpHeader.SeqNumber, 1)
		// SEQにACKを
		tcpHeader.SeqNumber = tmpack
		tcpHeader.DataOffset = 20
		tcpHeader.TCPCtrlFlags.FIN = 0
		// ACKパケットを送信
		_, err := pconn.WriteTo(tcpHeader.toPacket(), client)
		if err != nil {
			ch <- TcpState{tcpHeader: TCPHeader{}, err: fmt.Errorf("Send SYNACK err: %v", err)}
		}
		fmt.Println("Send ACK to FINACK Packet")
		ch <- TcpState{tcpHeader: tcpHeader, err: nil}
	case ACK:
		fmt.Println("Recv ACK packet")
		tcpHeader.DestPort = tcpHeader.SourcePort
		tcpHeader.SourcePort = uint16ToByte(uint16(port))
		// ACKをいったん保存
		tmpack := tcpHeader.AckNumber
		// ACKに
		tcpHeader.AckNumber = addAckNumber(tcpHeader.SeqNumber, 1)
		tcpHeader.SeqNumber = tmpack
		ch <- TcpState{tcpHeader: tcpHeader, err: nil}
	}
}

HTTP1.1の実装

TCPハンドシェイクが成立したら、TCPのデータにHTTPをセットして送ることができます。
これまで作成したTCPスタック上に簡単なHTTP1.1を作成します。http1.1.goファイルの実装を説明します。

HTTPヘッダを表した構造体とHTTPヘッダとBodyを格納する構造体を定義します。

type HttpHeader struct {
	Key   string
	Value string
}

type HttpHeaderBody struct {
	Status  int
	Headers []HttpHeader
	Body    string
}

HTTPのリクエスト、レスポンスデータを作成する関数です。
HTTP1.1はテキストベースのプロトコルなので、curlコマンドのヘッダを真似して文字列を生成すればよいです。

func CreateHttpGet(server string, port int) []byte {

	reqstr := "GET / HTTP/1.1\n"
	reqstr += fmt.Sprintf("Host: %s:%d\n", server, port)
	reqstr += "User-Agent: curl/7.81.0\n"
	reqstr += "Accept: */*\n\n"

	return []byte(reqstr)
}

func CreateHttpPost(server string, port int, data string) []byte {

	reqstr := "POST / HTTP/1.1\n"
	reqstr += fmt.Sprintf("Host: %s:%d\n", server, port)
	reqstr += "User-Agent: curl/7.81.0\n"
	reqstr += "Accept: */*\n"
	reqstr += fmt.Sprintf("Content-Length: %d\n", len(data))
	reqstr += "Content-Type: application/x-www-form-urlencoded\n\n"
	reqstr += data

	return []byte(reqstr)
}

func CreateHttpResp(data string) []byte {

	respbody := "HTTP/1.1 200 OK\r\n"
	respbody += "Date: Sun, 04 Jun 2023 10:15:28 GMT\r\n"
	respbody += fmt.Sprintf("Content-Length: %d\r\n", len(data))
	respbody += "Content-Type: text/plain; charset=utf-8\r\n\r\n"
	respbody += data

	return []byte(respbody)
}

受信したHTTPデータをパースする処理です。ヘッダを解釈してContent-Lengthの長さからBodyをセットします。

func ParseHTTP(httpbyte string) (http HttpHeaderBody) {

	spiltstr := strings.Split(httpbyte, "\r\n")
	for _, v := range spiltstr {
		// HTTP1.1のみ対応
		if strings.HasPrefix(v, "HTTP/1.1") {
			tmp := strings.Split(v, " ")
			status, _ := strconv.Atoi(tmp[1])
			http.Status = status
		}
		if strings.Contains(v, ": ") {
			tmp := strings.Split(v, ": ")
			http.Headers = append(http.Headers, HttpHeader{
				Key:   tmp[0],
				Value: tmp[1],
			})
			if tmp[0] == "Content-Length" {
				bodylen, _ := strconv.Atoi(tmp[1])
				http.Body = httpbyte[len(httpbyte)-bodylen:]
			}
		}

	}

	return http
}

クライアントがGetとPostするときの関数です。
まずDial関数を呼んで、TCPハンドシェイクを成立させます。

Dialが返すconnはTCPヘッダの構造体です。TCP接続をすればソケットのディスクリプタが返ってきますが、TCPのレイヤをプログラム側で
処理しているのでディスクリプタではなく、TCPヘッダが返ってきます。

TCPは状態を持つステートフルなプロトコルで、重要なのはSequence番号とAcknowledge番号です。
Sequence番号とAcknowledge番号さえ正しく保持していれば、その続きとしてパケットデータをやり取りできます。

なのでこの実装ではパケットを投げるその都度ソケットを作成して書き込みをしています。
Write関数がTCP接続成立後に、サーバにパケットを送信する関数です。

func HttpGet(client, server string, port int) (string, error) {

	conn, err := Dial(client, server, port)
	if err != nil {
		return "", err
	}

	conn, data, err := conn.Write(CreateHttpGet(server, port), true)
	if err != nil {
		return "", err
	}
	defer conn.Close()

	return string(data), err
}

func HttpPost(client, server string, port int, postdata string) (string, error) {

	conn, err := Dial(client, server, port)
	if err != nil {
		return "", err
	}

	conn, data, err := conn.Write(CreateHttpPost(server, port, postdata), true)
	if err != nil {
		return "", err
	}
	defer conn.Close()

	return string(data), err
}

tcp_header.goにあるWrite関数です。
TCPハンドシェイク後に、PSHACKでTCPデータを送る用の関数です。引数で死亡フラグが立っていたら、フラグをセットします。

パケットを作成して送信したら、goroutineで受信したACKパケットとチャネルで受信してreturnします。

// PSHACKでデータを送る
func (tcpheader *TCPHeader) Write(data []byte, DTH bool) (header TCPHeader, respdata []byte, err error) {
	chHeader := make(chan TcpState)
	go func() {
		Listen(ipv4ByteToString(tcpheader.TCPDummyHeader.SourceIP),
			int(byteToUint16(tcpheader.SourcePort)), chHeader)
	}()

	tcpheader.TCPCtrlFlags.PSH = 1

	// 死亡フラグが立っていたら1をセット
	if DTH {
		tcpheader.DTH = 1
	}
	// TCPデータをセット
	tcpheader.Data = data

	conn, err := net.Dial(NETWORK_STR, ipv4ByteToString(tcpheader.TCPDummyHeader.DestIP))
	if err != nil {
		return TCPHeader{}, nil, fmt.Errorf("connection err : %v", err)
	}
	defer conn.Close()

	// PSHACKパケットを送信
	_, err = conn.Write(tcpheader.toPacket())
	if err != nil {
		return TCPHeader{}, nil, fmt.Errorf("send data err : %v\n", err)
	}

	fmt.Println("Send PSHACK packet")

	// PSHACKを受けて送ったACKを受ける
	result := <-chHeader
	if result.err != nil {
		return TCPHeader{}, nil, err
	}

	fmt.Printf("ACK State header is %+v\n", result.tcpHeader)

	return result.tcpHeader, result.tcpData, nil
}

Close関数です。FINパケットを生成して送ります。
相手からもFINACKを受信したら、TCP接続は正常に終了したので、処理を終了します。

// FINでTCP接続を終了する
func (tcpheader *TCPHeader) Close() error {
	chHeader := make(chan TcpState)
	go func() {
		Listen(ipv4ByteToString(tcpheader.TCPDummyHeader.SourceIP),
			int(byteToUint16(tcpheader.SourcePort)), chHeader)
	}()

	tcpheader.TCPCtrlFlags.FIN = 1
	conn, err := net.Dial(NETWORK_STR, ipv4ByteToString(tcpheader.TCPDummyHeader.DestIP))
	if err != nil {
		return fmt.Errorf("connection err : %v", err)
	}
	defer conn.Close()

	// FINACKパケットを送信
	_, err = conn.Write(tcpheader.toPacket())
	if err != nil {
		return fmt.Errorf("send fin err : %v\n", err)
	}
	fmt.Println("Send FINACK packet")
	// channelで結果を受ける
	result := <-chHeader
	if result.err != nil {
		return result.err
	}
	close(chHeader)

	return nil
}

動作チェック ~死亡フラグが立ったTCPパケットとは~

さてここまで一通り処理を実装したので、動作チェックをしてみましょう。
まずクライアントで動かしてみます。Goで簡単なHTTPサーバを用意します。

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
)

func hello(w http.ResponseWriter, req *http.Request) {
	reqb, err := io.ReadAll(req.Body)
	if err != nil {
		log.Println(err)
	}
	defer req.Body.Close()
	
	fmt.Println(string(reqb))
	fmt.Fprintf(w, "hello\n")
}

func main() {

	http.HandleFunc("/", hello)
	http.ListenAndServe("127.0.0.1:18000", nil)
}

プログラムを実行する前に、iptableでRSTパケットをDROPするようにしておきます。
Dial関数でSYNパケットを送信しても、ユーザプログラムで送信したパケットをカーネルは検知していないので、
カーネルは「何だこのパケット?」となりRSTパケットが送られてしまいます。
カーネルにRSTパケットを出されてしまうと、ユーザプログラム側でTCPハンドシェイクが成立しないので、RSTパケットを半ば無理やりですがDROPします。

$ sudo iptables -A OUTPUT -s 127.0.0.1 -d 127.0.0.1 -p tcp --tcp-flags RST RST -j DROP

HTTPデータを送るクライアントプログラムを実行します。
明らかに死亡フラグが立ったセリフを送ります。

package main

import (
	"fmt"
	"rfc9401"
)

func main() {
	localhost := "127.0.0.1"
	postdata := "この戦争が終わったらこいつと結婚するんだ"

	resp, err := rfc9401.HttpPost(localhost, localhost, 18000, postdata)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Println(resp)
}

サーバを動かして、クライアントを実行します。

$ sudo ./httppost
Send SYN Packet
Recv SYNACK packet
Send ACK Packet
Send PSHACK packet
Recv PSHACK packet
Send ACK to PSHACK Packet
ACK State header is {TCPDummyHeader:{SourceIP:[127 0 0 1] DestIP:[127 0 0 1] Protocol:[] Length:[]} SourcePort:[167 17] DestPort:[70 80] SeqNumber:[12 129 188 231] AckNumber:[142 58 122 46] DataOffset:20 DTH:0 Reserved:0 TCPCtrlFlags:{CWR:0 ECR:0 URG:0 ACK:1 PSH:0 RST:0 SYN:0 FIN:0} WindowSize:[255 13] Checksum:[0 0] UrgentPointer:[0 0] Options:{nop:{kind:0} mss:{kind:0 length:0 value:0} windowscale:{kind:0 length:0 shiftcount:0} sackpermitted:{kind:0 length:0} timestamp:{kind:0 length:0 value:0 replay:0}} Data:[]}
Send FINACK packet
Recv FINACK packet
Send ACK to FINACK Packet
Recv FINACK packet
Send ACK to FINACK Packet
HTTP/1.1 200 OK
Date: Tue, 06 Jun 2023 22:57:50 GMT
Content-Length: 6
Content-Type: text/plain; charset=utf-8

hello

サーバでもクライアントからデータを受信しました。

$ go run debug/http_server.go
この戦争が終わったらこいつと結婚するんだ

wiresharkでパケットを見てみます。
TCPハンドシェイクが成立後、クライアントからHTTPデータが送信され、サーバから200を受信しています。
200を受信したらFINACKを送り接続を終了しています。

ここでクライアントから送ったHTTPデータのTCPヘッダに注目します。
クライアントは明らかに死亡フラグの立ったセリフを送っていたので、Reservedのフラグが1になっています。死亡フラグを意味する4bit目が立っているからです。

GoのHTTPサーバからのTCPヘッダと見比べてみましょう。
TCPヘッダがオプションなしで20byteの場合は、0x50が入ります。
通常のTCPヘッダは先頭の4bitがDataOffset=TCPヘッダの長さ、残りの4bitが予約で通常使われていません。

0x50を2進数にすると01010000になります。0101は10進数で5です、4bitシフトしているので5x4=20でTCPヘッダが20byteと計算されます。

20byteのTCPヘッダに死亡フラグが立った場合は、01011000で0x58になります。
クライアントからのパケットは死亡フラグが立っていたので、正しく0x58になっていました。

ただ、クライアントから死亡フラグが立ったTCPヘッダを送っても、GoのHTTPサーバは何も反応してくれません。
これは悲しいですね。

今度はサーバを実行してみます。

$ sudo ./server

curlでリクエストを送ります。
サーバから明らかに死亡フラグが立ったデータが返ってきているので、パケットを見てみます。

$ curl 127.0.0.1:18000
もう何も怖くない 

一見すると何もない正常なTCPとHTTPのやり取りに見えます。

しかし、サーバからのHTTPレスポンスのTCPヘッダに注目すると、4bit目のTCPの死亡フラグが立っているので、0x58になっています。

ただ、サーバから死亡フラグが立ったTCPヘッダを送っても、curlは何も反応せずそのまま処理します。(当たり前の模様)

おわりに

というわけでジョークRFC9401を実装するために、ほぼTCPスタックを実装するような記事になりましたがいかがでしたでしょうか。
ジョークRFCも真面目に実装するといい勉強になりますね。

皆さんもぜひRFCを読んでみて何か実装するのはいかがでしょうか。それではまた。

Discussion