RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

Pythonによるバイナリプロトコルの実装 〜STUNにパターンマッチを添えて〜

こんにちは。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 Response
    • 00 0c: 長さは12 (Big Endian)
    • 21 12 a4 42: Magic Cookie
    • 00 01 ... 11: Transaction ID
  • アトリビュート部
    • 00 20: XOR-MAPPED-ADDRESS
    • 00 08: 長さは8
    • 00 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を見通しよく記述できました。積極的に使っていきたい機能ですね。