できる!TCP/IPスタック改造!
TCP/IPスタックを改造!?
サービスの通信トラブルを解析する場合、弊社では通信パケットを取得して原因を探ることがあります。そういうことをやっていると、そのうちTCP/IPのやりとりについて途中で自由にパケットを弄れたらいいのに…という欲望がふつふつと湧いてくるときが来ます。
それじゃぁとLinuxのネットワークスタックのソースコードに挑むと、これがささっと手軽に改造してなにかできるような作りではありません。さあ、困りました。改造への思いをぶつける方法を探る必要があります。というわけで、
- ユーザランドで簡易のTCP/IPドライバが実装されており、
- 構造もわかりやすくて、簡単に改造できて、ささっと実験できそうなもの
を探したところ、PyTCP(https://github.com/ccie18643/PyTCP)がありましたので、紹介します。
いきなり余談:scapyはどうなの?
パケットを直接いじったものを送信するということであれば、CTFなどでおなじみのscapy(https://scapy.net/)というPython製のネットワーク用ツール(ライブラリ?)があるじゃない!という方もいらっしゃると思います。もちろん、思いのままのパケットを発生して挙動を伺うという用途であればscapyは非常に良いツールです。しかしながら、socketをlistenした状態で、TCP通信途中のパケットを誰にも(OSにも!)邪魔されずに弄って実験したい…となると、TCP/IPドライバそのものの挙動を変えたい衝動に駆られることでしょう!ここではそういう向きの人向けの話をします。
実際の例
こちらに実際に起きた通信不具合のパケットのやり取りをwiresharkで図示したものを載せます。
TCPのやり取りをご存知の方はSYNのあとにはSYN-ACKが返却されてきて…と思われる方もいらっしゃると思います。しかし、現実にはSYNのあとに只のACKが返却されてきちゃうという現象が起きてます。見ての通り、SYNの再送のたびに只のACKが返ってくるという挙動です。現実はいつも小説より奇なりです!当方ACKもらったらRSTをちゃんと送ってるのに!
これが発生すると、とあるサービスが刺さってしまい、お客様にエラーが見えてしまうという問題が発生してしまうので、アプリケーション側でなんとかしたいというのが今回の相談です。再現率100%で発生であれば、アプリケーション側の挙動解析も容易なのですが、発生条件もよくわからず、ときおり発生するので、調査がままならないという状況です。
図のやりとりを100%いつでも発生できれば、アプリケーション側での対策を好きに調査と試行錯誤ができるのにぃ~。
やってみた
この謎のパケットのやりとりを100%再現するプログラム(tcpchallenge_ack_only.py)を掲載します。プログラムの説明は後で掲載しますので、ここでは早速Debian sidで動かしてみます。
PyTCPを使うには、Linuxのセットアップ済のtapデバイスと、bridgeデバイスが必要ですので、まずはそこからセットアップします。
$ lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 12 (bookworm)
Release: 12
Codename: bookworm
# tap デバイス(tap7)とブリッジ(br3)の準備
$ sudo apt install bridge-utils
$ sudo ip tuntap add name tap7 mode tap
$ sudo ip link set dev tap7 up
$ sudo brctl addbr br3
$ sudo brctl addif br3 tap7
$ sudo ip address add 192.168.0.101/24 broadcast 192.168.0.255 dev br3
$ sudo ip route add 192.168.0.0/24 via 192.168.0.101 dev br3
$ sudo ip link set dev br3 up
# すべてちゃんとネットワークができていることを確認
$ ip address show tap7
6: tap7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel master br3 st
ate UP group default qlen 1000
link/ether 2e:96:f6:3d:62:84 brd ff:ff:ff:ff:ff:ff
inet6 fe80::2c96:f6ff:fe3d:6284/64 scope link
valid_lft forever preferred_lft forever
$ ip address show br3
7: br3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group
default qlen 1000
link/ether 46:59:6a:94:e1:65 brd ff:ff:ff:ff:ff:ff
inet 192.168.0.101/24 brd 192.168.0.255 scope global br3
valid_lft forever preferred_lft forever
inet6 fe80::4459:6aff:fe94:e165/64 scope link
valid_lft forever preferred_lft forever
$ brctl show br3
bridge name bridge id STP enabled interfaces
br3 8000.46596a94e165 no tap7
$ ip route show 192.168.0.0/24
192.168.0.0/24 dev br3 proto kernel scope link src 192.168.0.101
早速tap7とbr3をセットアップできました。
早速、tcpchallenge_ack_only.py
をvenv環境で起動します。venv環境作っているのは、俺流!PEP668とうまくやっていく方法で紹介した理由のためとなります。
# Pythonの環境準備
$ python3 -m venv --system-site-packages --symlinks --clear --prompt 'pytcp' --upgrade-deps .venv
$ source .venv/bin/activate
(pytcp) $ pip3 install PyTCP #PyTCPを導入
(pytcp) $ ls
tcpchallenge_ack_only.py
(pytcp) $ ./tcpchallenge_ack_only.py
0006.52 | SOCKET | TcpChallengeAckSocket.__init__ | [AF_INET4/SOCK_STREAM/0.0.
0.0/0/0.0.0.0/0] - Create socket
0006.52 | SOCKET | TcpChallengeAckSocket.bind | [AF_INET4/SOCK_STREAM/0.0.0.0/
8000/0.0.0.0/0] - Bound socket
0006.52 | SOCKET-CH | TcpChallengeAckSocket.listner_to_send_challenge_ack | [AF
_INET4/SOCK_STREAM/0.0.0.0/8000/0.0.0.0/0] - Socket starting to listen for inbou
nd to send challenge ack.
tcpchallenge_ack_only.py
が起動しました。このプログラムは192.168.0.101をtap7デバイス上に割り当てて8000/tcpでlistenしています。wireshark でtap7を観測しつつ、curlでアクセスしてみます。
$ sudo wireshark &
$ curl https://192.168.0.101:8000/
無事、観測したものと同じパケットのやりとりが記録されました。あとはcurlにデバッガ仕掛けるなり、いろんなオプションをつけて試してみるなりすると、問題の通信が発生したときにアプリケーション側でどうするか?を心ゆくまで試せるようになりました!
余談ですが、本プログラムを使っていろいろ試行錯誤したところ、アプリケーションプログラム側でconnect system callをのみをタイムアウトさせれば、問題を緩和できることがわかって、めでたしめでたしとなりそう…といったところです。
PyTCPとは
今回のプログラムはPyTCPを改造して利用しています。
PyTCP(https://github.com/ccie18643/PyTCP)
PyTCPは、PythonにTCP/IPスタックをとても分かりやすく実装してみたという、大変ありがたいライブラリになります。基本的な動作はLinux上のtapデバイス経由で生のパケットをそのままライブラリで受け取り、PyTCPに実装されたTCP/IPのプロトコルエンジンで処理して返すというものです。PyTCPが実装しているTCP/IPの範囲は基本的な動きのTCP/IPプロトコルなので、昨今のTCPに施された様々な拡張は実装されていませんが、ちょっとした実験にはこれで十分なことも多いです。また、ありがたいことに基本的なBerkeley Socket APIと同じようなI/FをPyTCPは持つので、従来のTCP/IPのプログラムのやり方で利用すると、あら不思議、PyTCPで処理された通信をあたかも普通のTCP/IPと同じ手続きで利用することができます。
PyTCPは100% pure Pythonで作られているので、改造目的のクラス継承あるいはMonkey Patchで、最小限の改造で挙動を変更することができます。つまり、Pythonが書ければ、TCP/IPの通信について、やりたい放題できます。
早速PyTCP付属のサンプルプログラムを動かしてみます。venv環境とbr3、tap7は前章で実施したものをそのまま利用します。また、サンプルプログラムはtap7がDHCPサーバにリーチしてIPアドレスを入手することが前提で作られているので、IPアドレスを直接振ってしまうように改造して動かします。
(pytcp) $ git clone https://github.com/ccie18643/PyTCP
(pytcp) $ cd PyTCP
(pytcp) $ git checkout -b your-spike
(pytcp) $ vi examples/tcp_echo_service.py
stack = TcpIpStack(interface=interface)
↓ こう変更する。tap7デバイスに192.168.0.100を直接割り当てるの意味
stack = TcpIpStack(interface=interface,ip4_address="192.168.0.100/24")
(pytcp) $ env PYTHONPATH=(pwd) python3 examples/tcp_echo_service.py
これで、PyTCP製echoサーバが起動したので、別のターミナルを開いて早速アクセスしてみましょう。
$ sudo apt install netcat-traditional
$ hash -r
$ nc 192.168.0.100 7
***CLIENT OPEN / SERVICE OPEN*** ←早速example/tcp_echo_service.pyから応答。
hello ← 打ち込む
hello ← echoサーバから応答
malpka ← malpka/malpa/malpiのいずれかのコマンドを打つと、猿の絵がでてくる。
.="=.
_/.-.-.\_ _
( ( o o ) ) ))
|/ " \| //
\'---'/ //
/`---`\ ((
/ /_,_\ \ \\
\_\_'__/ \ ))
/` /`~\ |//
/ / \ /
,--`,--'\/\ /
'-- "--' '--'
quit ← quitと打つと終了する。
***CLIENT OPEN, SERVICE CLOSING***
このとおり、PyTCPに実装されたTCP/IPドライバでPyTCP付属のTCP echoサーバの動作が行われました。
図にexamples/tcp_echo_service.py
との通信の様子を図示します。
PyTCPの構造
本記事はTCP/IPスタックを弄ろう!というお話なので、PyTCPの構造を掲載します。
まずは簡単なclass Timerから。こちらはstackというモジュールの中に用意されています。約1msecの分解能で処理をスケジュールできます。時間が経つと何かしなければならない処理はこちらのTimerにどんどん登録していきます。
ARP/Neighbour Discovery等の基本的なサービスもstackモジュールが担い、こちらによりIPv4/IPv6アドレスをPyTCPに付与したような動きをさせることができます。
パケットはstackモジュールのpacket_handler
インスタンスにより行われ、実際のパケットの送信・受信はRxRing/TxRing経由でひたすらLinuxのtapデバイスを駆動して実施します。packet_handler
はソースpytcp/protocols以下にある各プロトコルの送信モジュール(phtx.py)で生成されたパケットに応じてethernetフレームを生成して送信します。一方でRxRingで受け取ったパケットの種別をみてソースpytcp/protols以下にある各プロトコルの受信モジュール(phrx.py)に遷移して受信します。なおTCPのようにもう少し複雑なプロトコルは、さらにclass TcpSocketやclass TcpSessionで処理します。
TCP通信の場合は、IPv4/IPv6の分類が行われ、TCPパケットであると識別したら、TcpSocket経由でTcpSessionのtcp_fsmメソッドにTcpMetaDataに分解されたパケットが引き渡されます。あとはTcpSession内部でFSMがくるくる回り、TCPプロトコルで通信ができるようになる…という構造です。また、送信は直接stack.packet_handler.send_tcp_packet()
を呼び出したり、送信キューに積んで送信しています。
また、TCP一本あたり、TcpSocketとTcpSessionの両オブジェクトが生成され、個別のTCPの通信はこの2つのオブジェクトが連携して対応します。
これでPyTCPの構造が一度わかってしまえば、venv環境ということもあり、
- PyTCPをgit cloneしたソースを直接改造したものをvenv環境にインストールしてしまうとか、
- TcpSessionの代わりにtcp_fsmというメソッドを持つ、別のclassを用意してTcpSocket内部のtcp_sessionに設定してしまうとか
すれば、PyTCP内部の便利な手続きをちょいちょい呼び出すだけで、簡単に好きなパケットを送受信することができるようになります。とっても便利ですね!
tcpchallenge_ack_only.pyの説明
実際の改造の例として今回のプログラム(tcpchallenge_ack_only.py)の説明をします。
まず、パケットを受け取ったら問答無用で微妙にwindowサイズ内だけどフレームを送った覚えのないフレームに対するAckパケットを生成して発射するだけの振る舞いをPyTCPにさせます。
こちらを実現するために、パケットが来たら即微妙なAck番号を持つパケットを打ち返すだけの動作を実装した、ほぼほぼ中身空っぽのclass TcpSendChallengeAckSession
を定義します。さらにこれをclass TcpSocket
から継承したclass TcpChallengeAckSocket
に拡張メソッドlistner_to_send_challenge_ack()
を実装して、class TcpSession
の代わりにclass TcpSendChallengeAckSession
を登録してしまいます。こうすることで、class TcpSocket
クラスの仕組みを全部再利用できて、かつ、TCPのパケットがやってきたら、細工されたAck番号を持つパケットを即打ち返すという仕組みの出来上がりです。
また、PacketHandler._phrx_tcp
は、管理外のRSTパケットを受信すると_phrx_tcp
内で勝手にAckを返してしまうというロジックが含まれており、これが今回の用途では邪魔になるので、そこを削除しただけの_phrx_tcp
そのままである、_phrx_custom_tcp
をPacketHandler._phrx_tcp
にMonkey Patchして入れ替えています。
PacketHandler._phrx_tcp=_phrx_custom_tcp # monkey patching to PacketHandler
の部分ですね。
さらに、パケットが処理される様の実行トレースが見たいので、pytcp.lib.logger.log()
のチャネル指定にtcp-ch/tcp-ch-ss/socket-ch
を各々指定し、config.LOG_DEBUG = True
、config.LOG_CHANEL
に観測したいチャネルを限定して指定しています。
おわりに
TCP/IP通信そのものをちょいちょい弄って改造する件について、PyTCPを語ってみました。これで現実世界で変な通信に遭遇しても、ささっと再現テスト環境を作っていろいろ実験できて、デバッグもはかどる!と思ってます。
付録
tcpchallenge_ack_only.py
#!/usr/bin/env python3
import sys
from pytcp import config,TcpIpStack
from pytcp.lib import socket,stack
from pytcp.protocols.tcp.socket import TcpSocket
from pytcp.lib.logger import log
from pytcp.subsystems.packet_handler import PacketHandler
from pytcp.protocols.tcp.fpp import TcpParser
from pytcp.protocols.tcp.metadata import TcpMetadata
from threading import RLock
def _phrx_custom_tcp(self, packet_rx):
"""
Custom Handler inbound TCP packets.
This codes are borrowed from _phrx_tcp()
"""
self.packet_stats_rx.tcp__pre_parse += 1
TcpParser(packet_rx)
if packet_rx.parse_failed:
self.packet_stats_rx.tcp__failed_parse__drop += 1
__debug__ and log(
"tcp-ch",
f"{packet_rx.tracker} - <CRIT>{packet_rx.parse_failed}</>",
)
return
__debug__ and log("tcp-ch", f"{packet_rx.tracker} - {packet_rx.tcp}")
assert isinstance(
packet_rx.tcp.data, memoryview
) # memoryview: data type check point
# Create TcpMetadata object for further processing by TCP FSM
packet_rx_md = TcpMetadata(
local_ip_address=packet_rx.ip.dst,
local_port=packet_rx.tcp.dport,
remote_ip_address=packet_rx.ip.src,
remote_port=packet_rx.tcp.sport,
flag_syn=packet_rx.tcp.flag_syn,
flag_ack=packet_rx.tcp.flag_ack,
flag_fin=packet_rx.tcp.flag_fin,
flag_rst=packet_rx.tcp.flag_rst,
seq=packet_rx.tcp.seq,
ack=packet_rx.tcp.ack,
win=packet_rx.tcp.win,
wscale=packet_rx.tcp.wscale,
mss=packet_rx.tcp.mss,
data=packet_rx.tcp.data, # memoryview: passing as memoryview for tcp
# session to consume, no need to convert to
# bytes here
tracker=packet_rx.tracker,
)
# Check if incoming packet matches active TCP socket.
if tcp_socket := stack.sockets.get(str(packet_rx_md), None):
self.packet_stats_rx.tcp__socket_match_active__forward_to_socket += 1
__debug__ and log(
"tcp-ch",
f"{packet_rx_md.tracker} - <INFO>TCP packet is part of active "
f"socket [{tcp_socket}]</>",
)
tcp_socket.process_tcp_packet(packet_rx_md)
return
# Check if incoming packet is an initial SYN packet and if it matches any
# listening TCP socket.
if all({packet_rx_md.flag_syn}) and not any(
{packet_rx_md.flag_ack, packet_rx_md.flag_fin, packet_rx_md.flag_rst}
):
for (
tcp_listening_socket_pattern
) in packet_rx_md.tcp_listening_socket_patterns:
if tcp_socket := stack.sockets.get(
tcp_listening_socket_pattern, None
):
self.packet_stats_rx.tcp__socket_match_listening__forward_to_socket += (
1
)
__debug__ and log(
"tcp-ch",
f"{packet_rx_md.tracker} - <INFO>TCP packet matches "
f"listening socket [{tcp_socket}]</>",
)
tcp_socket.process_tcp_packet(packet_rx_md)
return
# In case packet doesn't match any session send RST packet, Ignore it.
self.packet_stats_rx.tcp__no_socket_match__respond_rst += 1
__debug__ and log(
"tcp-ch",
f"{packet_rx.tracker} - TCP packet from {packet_rx.ip.src} to "
f"closed port {packet_rx.tcp.dport},Simply Ignored"
)
return
class TcpSendChallengeAckSession:
def _send_tcp_challenge_ack_to_remote(self,packet_rx_md):
winsz = 224
stack.packet_handler.send_tcp_packet(
local_ip_address=packet_rx_md.local_ip_address,
remote_ip_address=packet_rx_md.remote_ip_address,
local_port=packet_rx_md.local_port,
remote_port=packet_rx_md.remote_port,
flag_syn=False,
flag_ack=True,
flag_fin=False,
flag_rst=False,
seq=packet_rx_md.seq+1,
ack=packet_rx_md.seq+packet_rx_md.win-1,
win=winsz,
mss=None,
wscale=None,
data=None,
)
def tcp_fsm(self,packet_rx_md):
if packet_rx_md is None:
__debug__ and \
log("tcp-ch-ss",f"uh? packt_rx_md is None,but invoke tcp_fsm")
return
# always send tcp challenge ack to remote
with RLock():
self._send_tcp_challenge_ack_to_remote(packet_rx_md)
class TcpChallengeAckSocket(TcpSocket):
def listner_to_send_challenge_ack(self):
self._tcp_session = TcpSendChallengeAckSession()
__debug__ and log(
"socket-ch",
f"<g>[{self}]</> - Socket starting to listen for "
"inbound to send challenge ack."
)
stack.sockets[str(self)] = self
if __name__ == "__main__":
config.LOG_DEBUG = True
config.LOG_CHANEL = {
"tcp",
"tcp-ch",
"socket",
"tcp-ch-ss",
"socket-ch",
}
tcpip_stack = TcpIpStack(interface='tap7',ip4_address="192.168.0.100/24")
PacketHandler._phrx_tcp=_phrx_custom_tcp # monkey patching to PacketHandler
tcpip_stack.start()
listening_socket = TcpChallengeAckSocket(family=socket.AF_INET4)
listening_socket.bind(("0.0.0.0", 8000))
listening_socket.listner_to_send_challenge_ack()
sys.exit(0)