お前のパケットはもう死んでいる。TCPに死亡フラグを実装してみた
はじめに
プロトコルの仕様など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を真面目に実装してみることにします。
実装したコードは以下にあります。
こういう場合の死亡フラグもあるのでは?など実装漏れがありましたらご連絡ください。
RFC9401
RFC9401の内容を確認します。
- Introductionでは提案の意義と、死亡フラグについて説明されています。
塹壕の兵士がこの戦いが終わったら故郷に帰り結婚について話すと死亡フラグが立つなど具体的な例が示されています。
参考引用では以下の記事も示されています。
- 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.Dial
でtcp
をセットした場合はTCP接続がカーネル側で行われ、作成されたソケットのディスクリプタが返ってくるので、
プログラム側からTCPヘッダにアクセスすることはできません。
実際にnet
パッケージのソースを見てみましょう。
net.Dial
からソースを辿っていくとTCP接続の場合はdial.go
のdialSingle
から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.go
のdialTCP
からdoDialTCP
→internetSocket
関数が呼ばれます。
ソケットを作成する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.go
のdialSingle
から、TCPの場合はdialTCP
が呼ばれていましたが、dialIP
関数が呼ばれます。
case *IPAddr:
la, _ := la.(*IPAddr)
c, err = sd.dialIP(ctx, la, ra)
iprawsock_posix.go
のdialIP
からソケットを作成する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.Buffer
にWrite
していきます。
チェックサムを計算する必要があるので、いったん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のフローに従ってパケットを処理します。
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