RDP セッションにはクライアントアドレスが必要ですが、明白に見える情報源は一致しません。CLIENTNAME が返すのはコンピューター名であり、レジストリには別のセッションの値が入っており、ソケットの相手側はクライアントではなくゲートウェイや NAT のアドレスである場合があります。Windows はセッションごとに報告されたクライアントアドレスを公開しますが、正しいセッションを問い合わせ、応答を正しくデコードする必要があります。

検索を一つのセッションに固定する

人物ではなくセッションから始めます。Windows の RDP ホストは一つのアカウントで複数のセッションを持つことがあり、再接続前に見たアドレスは現在の接続と一致しない可能性があります。セッション ID、ユーザー、観測時刻をまとめて記録します。セッション ID は API が期待するキーであり、二つのセッションを取り違えないための値です。

ホストでは、インストール済みのセッションツールが存在するものを一覧表示し、管理用の表示は、それを見る権利がある場合にのみ使用してください。使用する ID は、ID が再利用されると別のセッションのデータが返るため、問い合わせの直前に確認してください。

qwinsta

その ID に対して WTSQuerySessionInformationW を呼び出す

ターミナルサービスの API は情報クラス 14、WTSClientAddress でこの問いに答えます。Windows では小さな ctypes ラッパーで十分であり、別のユーザーのハイブを読まずに済みます。以下のコードは明示的なセッション ID を受け取り、ローカルホストに問い合わせます。

import ctypes
import ipaddress
import socket
from ctypes import wintypes

class WTS_CLIENT_ADDRESS(ctypes.Structure):
    _fields_ = [("AddressFamily", wintypes.DWORD),
                ("Address", ctypes.c_ubyte * 20)]

wts = ctypes.WinDLL("Wtsapi32", use_last_error=True)
query = wts.WTSQuerySessionInformationW
query.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.c_int,
                  ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(wintypes.DWORD)]
query.restype = wintypes.BOOL
free = wts.WTSFreeMemory
free.argtypes = [ctypes.c_void_p]

def client_address(session_id):
    buf = ctypes.c_void_p()
    size = wintypes.DWORD()
    if not query(None, session_id, 14, ctypes.byref(buf), ctypes.byref(size)):
        raise ctypes.WinError(ctypes.get_last_error())
    try:
        if size.value < ctypes.sizeof(WTS_CLIENT_ADDRESS):
            raise RuntimeError("short WTS buffer")
        value = ctypes.cast(buf, ctypes.POINTER(WTS_CLIENT_ADDRESS)).contents
        raw = bytes(value.Address)
        if value.AddressFamily == socket.AF_INET:
            addr = ipaddress.ip_address(raw[2:6])
        elif value.AddressFamily == socket.AF_INET6:
            addr = ipaddress.ip_address(raw[:16])
        else:
            return None
        return None if addr.is_unspecified else str(addr)
    finally:
        free(buf)

直前で確認した整数のセッション ID を渡し、ローカルマシン用に null のサーバーハンドルを保ちます。失敗した呼び出しは、両者が異なる結論に至るため、アドレスが存在しない場合ではなくエラーとして扱います。他のユーザーのセッションを問い合わせるには適切な Query Information 権限が必要なので、その場で昇格するのではなく、権限が付与されたアカウントでツールを実行します。返されたバッファーは API に属し、デコードが失敗した場合でも解放する必要があります。

ファミリをデコードしてからバッファーを解放する

この構造体は IP 文字列ではありません。ファミリフィールドが二十バイトのアドレスの読み方を決め、IPv4 はオフセット二から、IPv6 はオフセット零から始まります。未指定アドレスはクライアントが報告しなかったことを意味し、空文字列とは異なります。Remote Desktop Gateway 経由の接続はアドレスが取得できないケースとして文書化されているため、ゲートウェイを介したセッションはクライアントの実際のアドレスについて有用な情報を報告しません。その場合は相関をゲートウェイ自身の記録に移す必要があります。

デバッグ中は、両者の不一致がもっともらしいが誤った値の通常の原因になるため、アドレスと並べてファミリを出力してください。バッファーは finally ブロックで WTSFreeMemory を使って解放し、例外によるリークを防ぎます。

報告されたアドレスと証拠を分ける

報告されたクライアントアドレスはクライアントが提供する申告であり、信頼できる観測ではありません。TCP の相手側でも、VPN ゲートウェイのリース表でも、クライアントの公開アドレスでもありません。本当の問いが「どの VPN リースが誰のものか」である場合は、同時に VPN サーバーの認証済みセッション記録を相関させます。問いが「どの送信元がリスナーに到達したか」である場合は、接続テーブルまたは許可されたサーバー側ログを読みます。

どのサポートツールでも、これらの値を別々にラベル付けして保持します。欠落した WTS の結果は、別のセッションの任意のレジストリ値にフォールバックするのではなく、欠落したままにすべきです。同じユーザーが再接続して新しい値を得る可能性があるため、アドレスの隣にセッション ID を記録します。クライアント名も報告されたアドレスも、アクセスを許可する十分な証拠ではありません。

関連記事