こんにちは。PBXチームの山崎です。 振り返ると前回のブログからちょうど1年経ってしまいました。来年はブログのアウトプットも増やしていきたいですね。
さて早速ですが、今回のブログの概要です。
- 死活監視の一環で、STUNというバイナリベースのプロトコルのクライアントを実装してみた
- Python3.10で入ったパターンマッチングがバイナリプロトコルの解析に便利だった
前半でSTUNを軽く触って動作を確認し、後半でPythonを使って実装してみます。
目次
STUNについて
STUNは主に以下の特徴を持つプロトコルです。
- WebRTCでよく使われる、NAT越しに通信するためのプロトコル(の一部)
- 相手から見た自分のグローバルアドレスなどを知ることができる
- RFC 8489
- バイナリベース
STUN のパケット構造
プロトコルの理解には、実際にリクエスト・レスポンスを観察してみると捗ります。 実際にパケットを送信するために、必要な情報を集めていきましょう。
RFC 8489の "2. Overview of Operation” を読むと、まずはクライアントからBinding Requestを送りたまえ、と書かれています。
Binding Requestとは何ぞ?と読み進めると、5章にその構造が定義されています。
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 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |0 0| STUN Message Type | Message Length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Magic Cookie | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | | | Transaction ID (96 bits) | | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Figure 2: Format of STUN Message Header 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 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Type | Length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Value (variable) .... +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Figure 4: Format of STUN Attributes
20バイト(固定長)のヘッダの後ろに、0個以上のアトリビュートが続く構成です。
ヘッダの各フィールドの定義を以下に抜粋します
- STUN Message Type: Binding Requestは 0x0001、Responseは 0x0101
- Message Length: ヘッダを除いた STUNメッセージの長さ
- Magic Cookie:
0x2112_A442
(固定値) - Transaction ID: 12bytes の乱数
そしてアトリビュートはタイプに長さと(タイプごとに定義される)データが続く、よくある構成ですね。
アトリビュートタイプが取りうる値は、18.3. STUN Attributes Registry に定義されています。 今回使う値を以下に抜粋します。
0x0020: XOR-MAPPED-ADDRESS
やってみよう
なんとなく構造がわかったので、試しにリクエストを送ってみましょう。GoogleがSTUNサーバーを公開してくれているので、ありがたく利用させていただきます。
# リクエストデータは先頭から... # 00 01: Binding Request # 00 00: Length # 21 12 a4 42: Magic Cookie # 00 01 ... 11: Transaction ID (乱数作るの面倒なので適当に) # RFCにはリクエストにSOFTWARE Attributeを含めたまえ (SHOULD) とあるけど、面倒なので省略 bash$ printf '\x00\x01\x00\x00\x21\x12\xa4\x42\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x10\x11' | \ socat - UDP:stun.l.google.com:19302 | hexdump -C 00000000 01 01 00 0c 21 12 a4 42 00 01 02 03 04 05 06 07 |....!..B........| 00000010 08 09 10 11 00 20 00 08 00 01 99 a3 17 ba 65 4b |..... ........eK| 00000020
手抜きをしてSOFTWARE Attributeを省略しましたが、ちゃんとレスポンスを返してくれました。読んでみましょう。先頭から...
- ヘッダ部
01 01
: Binding Response00 0c
: 長さは12 (Big Endian)21 12 a4 42
: Magic Cookie00 01 ... 11
: Transaction ID
- アトリビュート部
00 20
: XOR-MAPPED-ADDRESS00 08
: 長さは800 01 99 a3 17 ba 65 4b
: アトリビュートの中身
XOR-MAPPED-ADDRESS なるものとして 00 01 99 a3 17 ba 65 4b
というデータが取得できました。
そろそろゴールが見えてきそうですね。心躍らせながらXOR-MAPPED-ADDRESSの仕様を確認して読み解いてみましょう。
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 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |0 0 0 0 0 0 0 0| Family | X-Port | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | X-Address (Variable) +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ Figure 6: Format of XOR-MAPPED-ADDRE SS Attribute
今回は99 a3
が(サーバから見た)ポート番号、その後ろ17 ba 65 4b
が(サーバから見た)IP アドレスになります。
どちらもMagic CookieとXORをとった値とあるので戻してみましょう。XORはもう一回かけると元の値に戻りますね。
bash$ echo $((0x17 ^ 0x21)).$(( 0xba ^ 0x12 )).$(( 0x65 ^ 0xa4)).$(( 0x4b ^ 0x42 )) 54.168.193.9
私のIPアドレスは54.168.193.9
のようです。
答え合わせをしてみましょう。
bash$ curl httpbin.org/ip # 自分の IP アドレスを返してくれるWebAPI { "origin": "54.168.193.9" }
正解でした!
Pythonのパターンマッチングについて
ここからはもう一つの主題である、パターンマッチングについてみていきます。
PythonのパターンマッチングはPEP 622で定義され、Python3.10で実装されました。
例をPEP 622から転載します。if elseよりもすっきりと表現できていますね。
match response.status: case 200: do_something(response.data) # OK case 301 | 302: retry(response.location) # Redirect case 401: retry(auth=get_credentials()) # Login first case 426: sleep(DELAY) # Server is swamped, try after a bit retry() case _: raise RequestError("we couldn't get the data")
バイナリデータに対するパターンマッチング
Pythonでバイナリデータを扱う場合はbytes型がよく登場します。 ところが、パターンマッチングではbytes型を扱うことができません。
PEP 622から引用:
To match a sequence pattern the subject must be an instance of collections.abc.Sequence, and it cannot be any kind of string (str, bytes, bytearray). It cannot be an iterator.
collections.abc.Sequenceであれとのことなので、組み込み関数のmemoryview()
を使います。
memoryview()
を使うと、コピーせずにシーケンスとして扱うことができます。
例として、「先頭2bytesがメッセージタイプ、その後ろ2bytesが長さ、その後ろにボディ」というデータを考えます。 これは以下のようにパースできます。
msg = b'\x01\x02' + b'\x00\x02' + b'\x02\x03' match memoryview(msg): # "_" で読み飛ばすことができる # *data のように書くと、残り全てを受け入れる case [0x01, 0x01, _, _, *data]: print(f"type=Hello data={data}") # if を続けてバリデーションを書くこともできる # 2*len みたいに、長さを指定することはできない case [0x01, 0x02, len0, len1, *data] if len(data) == (len0 << 8) + len1: print(f"type=NewTransaction data={data}") case _: print("invalid message") # => type=NewTransaction data=[2, 3]
サンプルデータ (msg) の先頭が0x0102
なので、2つ目のcaseにマッチしています。
これをif文で書いたものと比較してみます。match文ではデータ構造が表現されていて、見通しがいいですね。
# 2つ目の case です if msg[:2] == b'\x01\x02' and len(msg[4:]) == (msg[2] << 8) + msg[3] data = msg[4:] print(f"type=NewTransaction data={data}")
PythonでSTUNやってみる
これで必要なパーツが揃いました。組み上げていきましょう。
まず、リクエストを送信してレスポンスを受け取る部分です。
def stun_binding_request_udp(sock: socket.socket, hostname: str, port: int) \ -> tuple[bytes, bytes]: message_type = b'\x00\x01' length = b'\x00\x00' magic = bytes(MAGIC_COOKIE) transaction_id = RAND_bytes(12) req = message_type + length + magic + transaction_id sock.sendto(req, (hostname, port)) res = sock.recv(2048) return res, transaction_id
実際に作る際はTransport classみたいなのを作ってレイヤを分けるとか色々考えると思いますが、今回はサンプルなのでベタっと書いていきます。
でもってこのレスポンスの解析をパターンマッチングを使って書いてみます
def stun_parse_response(message: bytes, transaction_id: list[int]) -> None: header = message[:20] attr_data = message[20:] # ヘッダの情報を元に、レスポンスが壊れていないかチェック match memoryview(header): case [0x01, 0x01, length0, length1, 0x21, 0x12, 0xA4, 0x42, *_tid] \ if _tid == transaction_id and \ len(attr_data) == (length0 << 8) + length1: logger.debug("valid stun response") case _: logger.warning(f"invalid response: {message}") return # XOR-MAPPED-ADDRESS Attribute を抽出 # TODO: ~~面倒~~サンプルなので先頭に XOR-MAPPED-ADDRESS があると仮定 match memoryview(attr_data): case [0x00, 0x20, length0, length1, *value]: length = (length0 << 8) + length1 body = value[0:length] logger.info(f"type: XOR-MAPPED-ADDRESS") _stun_parse_xor_mapped_address(value) case [type0, type1, *_]: logger.warning( f"unknown attribute: type=0x{type0:02X}{type1:02X}")
そして最後に _stun_parse_xor_mapped_address() を実装したら完成です
def _stun_parse_xor_mapped_address(attribute: list[int]) -> tuple[bytes, int]: match attribute: case [0x00, 0x01, xport0, xport1, *xaddress] if len(xaddress) == 4: xport = (xport0 << 8) + xport1 port = xport ^ int.from_bytes( MAGIC_COOKIE[:2], byteorder="big", signed=False ) address: str = ".".join( [str(x ^ y) for x, y in zip(xaddress, MAGIC_COOKIE)] ) logger.info(f"global address: {address}:{port}") case _: logger.warning(f"unknown data: {attribute}")
足りない部分を補って動かしてみましょう。
import socket from logging import getLogger, StreamHandler, INFO, Formatter from ssl import RAND_bytes logger = getLogger(__name__) handler = StreamHandler() formatter = Formatter("%(filename)s:%(lineno)s - %(levelname)s - %(message)s") logger.setLevel(INFO) handler.setFormatter(formatter) logger.addHandler(handler) MAGIC_COOKIE = (0x21, 0x12, 0xA4, 0x42) # snip. def main(): stun_server = "stun.l.google.com" stun_port = 19302 af = socket.AF_INET with socket.socket(af, socket.SOCK_DGRAM) as sock: sock.settimeout(5) response, transaction_id = stun_binding_request_udp( sock, stun_server, stun_port ) logger.info(f"local address: {sock.getsockname()}") stun_parse_response(response, list(transaction_id)) main()
bash$ python3.10 ~/stun-check0.py stun-check0.py:79 - INFO - local address: ('0.0.0.0', 53603) stun-check0.py:47 - INFO - type: XOR-MAPPED-ADDRESS stun-check0.py:26 - INFO - global address: 54.168.193.9:53603
よさそうですね。
まとめ
実際にプロトコルを実装することで、普段漫然と使っていたSTUNの理解が深まりました。
また、パターンマッチングを使うことで、データの構造を表現し、if elseを見通しよく記述できました。積極的に使っていきたい機能ですね。