Scarlet Tactics

悪用厳禁

GlytchC2 詳細技術解析 [twitch irc]

github.com

ファイル構成と概要

GlytchC2-main/
├── attacker/twitch_chat_irc.py   ... Attacker側のコピー(victim側と完全同一)
├── victim/twitch_chat_irc.py     ... Victim側のコピー
├── attacker/example.env          ... Attacker側 Twitch認証情報テンプレート
└── victim/example.env            ... Victim側 Twitch認証情報テンプレート
ファイル 種別 行数 役割概要
twitch_chat_irc.py Python ライブラリ 217行 Twitch IRC プロトコルによるチャットメッセージ送受信の全機能を実装
example.env 設定テンプレート 5行 Twitch OAuth トークンとユーザー名のテンプレート

注: attacker/twitch_chat_irc.pyvictim/twitch_chat_irc.pydiff で確認した結果完全同一のファイルである。元は xenova/twitch-chat-irc リポジトリのサードパーティ製ライブラリであり、GlytchC2 プロジェクトに同梱されている。

解析の流れ: 本ドキュメントでは、IRC プロトコルの基礎から始め、Twitch がこれをどのように利用しているかを解説した後、TwitchChatIRC クラスの接続確立、メッセージ受信(listen)、メッセージ送信(send)の各機能をコードレベルで詳細に解析する。


1. 背景: IRC プロトコルと Twitch チャット

1.1 IRC(Internet Relay Chat)とは

IRC は 1988 年にフィンランドで開発されたテキストベースのリアルタイム通信プロトコルである。インターネット上で最も古いチャットプロトコルの一つであり、RFC 1459(1993年)で標準化された。

IRC の基本構造は以下の通りである:

┌───────────────┐        ┌────────────────┐        ┌───────────────┐
│  IRC クライアント │──TCP──→│  IRC サーバー     │←──TCP──│  IRC クライアント │
│  (ユーザーA)    │  6667   │  (irc.twitch.tv)│  6667   │  (ユーザーB)    │
└───────────────┘   番ポート └────────────────┘   番ポート └───────────────┘
                              │
                              ↓
                         チャンネル #game_channel
                         ├── ユーザーA のメッセージ
                         └── ユーザーB のメッセージ
  • サーバー: メッセージの中継を行う中央サーバー。Twitch の場合は irc.chat.twitch.tv
  • チャンネル: # で始まる名前のグループチャットルーム。Twitch では各配信者のチャットルームが 1 つの IRC チャンネルに対応する
  • クライアント: サーバーに接続してメッセージを送受信するプログラム

IRC 通信は テキストベース であり、各メッセージは \r\n(キャリッジリターン + ラインフィード)で区切られた 1 行のテキストとして送受信される。主要な IRC コマンドには以下がある:

コマンド 説明
PASS 認証パスワードの送信 PASS oauth:abc123...
NICK ニックネーム(表示名)の設定 NICK myusername
JOIN チャンネルへの参加 JOIN #channelname
PRIVMSG チャンネルへのメッセージ送信 PRIVMSG #channel :Hello!
PING 接続維持の確認要求(サーバー→クライアント) PING :tmi.twitch.tv
PONG PING への応答(クライアント→サーバー) PONG :tmi.twitch.tv
CAP REQ 拡張機能の要求 CAP REQ :twitch.tv/tags

1.2 Twitch の IRC 実装

Twitch は自社のチャットシステムの基盤として IRC プロトコルを採用しており、TMI(Twitch Messaging Interface)と呼ばれる独自拡張を加えている。主な拡張には以下がある:

  • IRCv3 タグ: @badge-info=;badges=...;display-name=User;... のような形式で、メッセージに付随するメタデータ(バッジ、表示名、タイムスタンプ等)を伝達する
  • OAuth 認証: 従来の IRC パスワード認証の代わりに、OAuth 2.0 トークンを使用する
  • 匿名接続: ユーザー名 justinfan + 数字の組み合わせで、認証なしの読み取り専用接続が可能

GlytchC2 における IRC の役割: IRC チャットは 制御チャネル(コマンドの送受信と制御シグナル "OK" / "READY" の伝達)として使用される。データ転送チャネル(コマンド出力やファイルの送信)は Twitch の映像ストリームが担当する。この 2 チャネル方式により、少量の制御情報と大量のデータを効率的に分離して伝送する。

参照

ID タイトル URL 対応箇所
1 RFC 1459 — Internet Relay Chat Protocol https://datatracker.ietf.org/doc/html/rfc1459 IRC プロトコルの基本仕様
2 Twitch IRC Guide https://dev.twitch.tv/docs/irc/guide Twitch IRC の公式ドキュメント
3 Twitch Chat & Chatbots — Getting Started https://dev.twitch.tv/docs/irc/ TMI 接続、認証、メッセージ形式

2. 認証情報の管理: example.env

# Once credentials are set up, rename this file to .env
# Go to https://twitchapps.com/tmi/ to get your oauth token
# Credit: https://github.com/xenova/twitch-chat-irc
NICK=username
PASS=oauth:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

example.env 1〜5行目、attacker/ と victim/ で同一内容)

NICK: Twitch のユーザー名。IRC プロトコルにおけるニックネームに対応する。

PASS: Twitch の OAuth トークン。oauth: プレフィックスに続いて長いランダム文字列が格納される。このトークンは Twitch のアカウント認証情報であり、ユーザーの代わりにチャットの読み書きを行う権限を付与する。

セキュリティ上の意味: OAuth トークンはパスワードと同等の秘密情報であり、漏洩した場合はそのアカウントのチャット操作権限が第三者に奪われる。.env ファイルは .gitignore に追加して VCS(バージョン管理システム)にコミットしないのが一般的な慣行であり、example.env というテンプレートファイルのみをリポジトリに含めることでトークンの漏洩を防いでいる。


3. TwitchChatIRC クラスの詳細解析

3.1 クラス定義と定数

'''
Credit: https://github.com/xenova/twitch-chat-irc
'''

import socket, re, json, argparse, emoji, csv               # ← 標準ライブラリ + サードパーティ
from decouple import config                                  # ← python-decouple: .env ファイルの読み込み

class DefaultUser(Exception):
    """Raised when you try send a message with the default user"""
    pass                                                     # ← 匿名ユーザーでのメッセージ送信試行時の例外

class CallbackFunction(Exception):
    """Raised when the callback function does not have (only) one required positional argument"""
    pass                                                     # ← コールバック関数の引数エラー時の例外

class TwitchChatIRC():
    __HOST = 'irc.chat.twitch.tv'                            # ← Twitch IRC サーバーのホスト名
    __DEFAULT_NICK = 'justinfan67420'                         # ← 匿名接続用デフォルトニックネーム
    __DEFAULT_PASS = 'SCHMOOPIIE'                             # ← 匿名接続用デフォルトパスワード
    __PORT = 6667                                            # ← IRC の標準ポート番号

    __PATTERN = re.compile(                                  # ← IRCメッセージのパース用正規表現
        r'@(.+?(?=\s+:)).*PRIVMSG[^:]*:([^\r\n]*)'
    )

    __CURRENT_CHANNEL = None                                 # ← 現在参加中のチャンネル名

twitch_chat_irc.py 1〜24行目)

インポートされるモジュール:

  • socket: TCP ソケット通信の低レベル API。IRC サーバーとの TCP 接続を直接管理する
  • re: 正規表現モジュール。IRC メッセージのパース(構文解析)に使用
  • json / csv: メッセージのファイル出力時に使用(JSON / CSV 形式)
  • emoji: Unicode 絵文字のテキスト表現変換ライブラリ。チャットメッセージ内の絵文字を demojize(テキスト表現に変換)するために使用
  • decouple (python-decouple): .env ファイルから設定値を読み込むライブラリ。config('KEY', default) で環境変数またはファイルから値を取得する

クラス変数(ダブルアンダースコアプレフィックス):

Python ではクラス変数名の先頭にダブルアンダースコア(__)を付けると「名前マングリング」(name mangling)が適用される。これは Python の擬似的なアクセス制御機構で、__HOST は内部的に _TwitchChatIRC__HOST にリネームされる。クラス外部からの直接アクセスを困難にする(ただし完全な禁止ではない)。

__HOST = 'irc.chat.twitch.tv': Twitch の IRC サーバーホスト名。全ての Twitch IRC クライアントはこのホストに接続する。

__DEFAULT_NICK = 'justinfan67420': Twitch の匿名接続用ニックネーム。Twitch IRC では、justinfan に続く任意の数字をニックネームに使用することで、認証なしの読み取り専用接続が可能になる。匿名接続ではチャットの閲覧は可能だが、メッセージの送信はできない。67420 は任意の数値であり、このライブラリの作者が選んだものである。

__DEFAULT_PASS = 'SCHMOOPIIE': 匿名接続用のダミーパスワード。匿名接続ではパスワードは検証されないため、任意の文字列で問題ない。

__PORT = 6667: IRC プロトコルの標準ポート番号。IRC の暗号化版(IRC over TLS)はポート 6697 を使用するが、このライブラリは 平文 IRC(ポート 6667)を使用する。つまり、通信内容(コマンド、制御シグナル、OAuth トークンを含む)は暗号化されずにネットワーク上を流れる。

__PATTERN(正規表現): IRC メッセージをパースするためのコンパイル済み正規表現。この正規表現の構造を分解すると:

@(.+?(?=\s+:)).*PRIVMSG[^:]*:([^\r\n]*)

@                  → IRCv3 タグの開始を示す @ 記号
(.+?(?=\s+:))     → グループ1: タグ部分(非貪欲マッチ、空白+コロンの手前まで)
.*                 → 任意の文字列(ホスト情報等をスキップ)
PRIVMSG            → PRIVMSG コマンド(チャットメッセージ)のリテラルマッチ
[^:]*              → コロン以外の文字列(チャンネル名等)
:                  → メッセージ本文の区切りコロン
([^\r\n]*)         → グループ2: メッセージ本文(改行文字以外の全文字)

例えば、以下の IRC メッセージに対して:

@badge-info=;badges=;display-name=User123;tmi-sent-ts=1234567890 :user123!user123@user123.tmi.twitch.tv PRIVMSG #channel :Hello World
  • グループ 1: badge-info=;badges=;display-name=User123;tmi-sent-ts=1234567890
  • グループ 2: Hello World

3.2 コンストラクタ — 接続の確立

    def __init__(self, username = None, password = None):
        # try get from environment variables (.env)
        self.__NICK = config('NICK', self.__DEFAULT_NICK)      # ← .env から NICK を読み込み(なければデフォルト)
        self.__PASS = config('PASS', self.__DEFAULT_PASS)      # ← .env から PASS を読み込み(なければデフォルト)

        # overwrite if specified
        if(username is not None):
            self.__NICK = username                             # ← 引数で上書き
        if(password is not None):
            self.__PASS = 'oauth:'+str(password).lstrip('oauth:')  # ← oauth: プレフィックスを正規化

        # create new socket
        self.__SOCKET = socket.socket()                        # ← TCPソケットを作成

        # start connection
        self.__SOCKET.connect((self.__HOST, self.__PORT))      # ← Twitch IRCサーバーに接続
        print('Connected to',self.__HOST,'on port',self.__PORT)

        # log in
        self.__send_raw('CAP REQ :twitch.tv/tags')             # ← IRCv3タグ機能を要求
        self.__send_raw('PASS ' + self.__PASS)                 # ← パスワード(OAuthトークン)を送信
        self.__send_raw('NICK ' + self.__NICK)                 # ← ニックネームを送信

twitch_chat_irc.py 26〜47行目)

コンストラクタの処理フロー:

Step 1: 認証情報の取得(28〜35行目)

config('NICK', self.__DEFAULT_NICK)python-decouple ライブラリの関数で、以下の優先順位で値を検索する: 1. 環境変数 NICK 2. .env ファイル内の NICK=... 行 3. デフォルト値(self.__DEFAULT_NICK = 'justinfan67420'

GlytchC2 の Victim 側では .env ファイルに実際の Twitch 認証情報を設定する必要がある(メッセージ送信が必要なため)。Attacker 側もコマンド送信のために認証情報が必要である。

'oauth:'+str(password).lstrip('oauth:')(35行目): パスワードに oauth: プレフィックスを付加する。lstrip('oauth:') はパスワードが既に oauth: で始まっている場合に重複を防ぐために使用されている。ただし、lstrip はプレフィックスの除去ではなく文字セットの除去であるため、'oauth:' の各文字(o, a, u, t, h, :)がパスワードの先頭から除去される。例えば、パスワードが oauth:abc の場合、lstrip('oauth:')bc を返す(a も除去される)。これは潜在的なバグだが、通常 OAuth トークンはランダムな文字列であるため実害はまれである。

Step 2: TCP ソケットの作成と接続(37〜40行目)

socket.socket() はデフォルトで TCP ソケット(AF_INET, SOCK_STREAM)を作成する。TCP(Transmission Control Protocol)は信頼性のあるストリーム型通信を提供するプロトコルで、データの到達順序と完全性が保証される。

self.__SOCKET.connect((self.__HOST, self.__PORT)) は DNS 解決(ホスト名 → IP アドレスの変換)を行い、irc.chat.twitch.tv のポート 6667 に TCP 接続を確立する。この呼び出しは TCP の 3 ウェイハンドシェイク(SYN → SYN-ACK → ACK)が完了するまでブロックされる。

Step 3: IRC ログインシーケンス(43〜47行目)

IRC サーバーへの接続が確立した後、3 つのコマンドを送信してログインする:

  1. CAP REQ :twitch.tv/tags: Twitch 独自の拡張機能「タグ」を要求する。タグは IRC メッセージに付随するメタデータ(表示名、バッジ、送信タイムスタンプ等)を提供する。この要求を行わないと、メッセージにタグが付加されず、メッセージのパースが不完全になる。

  2. PASS oauth:...: OAuth トークンを送信して認証する。Twitch IRC では、従来のパスワード認証の代わりに OAuth 2.0 トークンが使用される。

  3. NICK username: ニックネーム(Twitch ユーザー名)を設定する。サーバーはこのニックネームと OAuth トークンの組み合わせで認証を検証する。

IRC ログインシーケンス:

クライアント                              サーバー
   │                                        │
   │  TCP SYN ──────────────────────────→   │
   │  ←─────────────────────── TCP SYN-ACK  │
   │  TCP ACK ──────────────────────────→   │  ← TCP接続確立
   │                                        │
   │  CAP REQ :twitch.tv/tags ─────────→   │  ← タグ機能要求
   │  PASS oauth:abc123... ────────────→   │  ← 認証トークン送信
   │  NICK username ───────────────────→   │  ← ニックネーム設定
   │                                        │
   │  ←───── :tmi.twitch.tv 001 ... ────   │  ← ログイン成功応答
   │  ←───── :tmi.twitch.tv 376 ... ────   │  ← MOTD終了(ログイン完了)
   │                                        │

3.3 内部ヘルパーメソッド

    def __send_raw(self, string):
        self.__SOCKET.send(                                    # ← ソケット経由でデータ送信
            (string+'\r\n').encode('utf-8')                    # ← 文字列に改行を付加してUTF-8バイト列に変換
        )

twitch_chat_irc.py 49〜50行目)

IRC プロトコルでは各メッセージは \r\n(CRLF: Carriage Return + Line Feed)で終端する。__send_raw は文字列に \r\n を付加し、UTF-8 エンコードしてソケットに送信する。

    def __print_message(self, message):
        print(                                                 # ← メッセージを整形して表示
            '['+message['tmi-sent-ts']+']',                    # ← Twitchサーバーのタイムスタンプ
            message['display-name']+':',                       # ← 送信者の表示名
            emoji.demojize(message['message'])                 # ← 絵文字をテキスト表現に変換
                .encode('utf-8').decode('utf-8','ignore')      # ← UTF-8 エンコード/デコード(不正文字除去)
        )

twitch_chat_irc.py 52〜53行目)

tmi-sent-ts は Twitch が付与するサーバー側タイムスタンプ(Unix エポックからのミリ秒)。emoji.demojize() は Unicode 絵文字を :smile: のようなテキスト表現に変換する。

    def __recvall(self, buffer_size):
        data = b''                                             # ← 受信データを蓄積するバッファ
        while True:
            part = self.__SOCKET.recv(buffer_size)             # ← 最大buffer_sizeバイトを受信
            data += part                                       # ← バッファに追加
            if len(part) < buffer_size:                        # ← 受信量がバッファサイズ未満なら終了
                break
        return data.decode('utf-8')                            # ← UTF-8文字列として返す

twitch_chat_irc.py 55〜62行目)

__recvall の受信ロジック: TCP はストリーム型プロトコルであるため、recv() が返すデータ量は可変である。__recvallrecv()buffer_size 未満のデータを返すまで(=ソケットバッファにこれ以上データがないと推定されるまで)繰り返し受信する。

ただし、この実装には潜在的な問題がある。recv()buffer_size ちょうどのデータを返した場合でも、それが最後のデータである可能性がある。この場合、次の recv() 呼び出しでブロック(データが来るまで待機)してしまう。実際には、ソケットにタイムアウトが設定されている(listen メソッド内で settimeout が呼ばれる)ため、タイムアウト例外で抜ける。

    def __join_channel(self, channel_name):
        channel_lower = channel_name.lower()                   # ← チャンネル名を小文字に正規化

        if(self.__CURRENT_CHANNEL != channel_lower):           # ← 同じチャンネルへの重複JOINを防止
            self.__send_raw('JOIN #{}'.format(channel_lower))   # ← IRC JOINコマンド送信
            self.__CURRENT_CHANNEL = channel_lower              # ← 現在のチャンネルを記録

twitch_chat_irc.py 64〜69行目)

JOIN #channelname: IRC のチャンネル参加コマンド。Twitch IRC では、配信者のユーザー名がチャンネル名になる(例: JOIN #bilocan1337)。__CURRENT_CHANNEL で現在のチャンネルを追跡し、同じチャンネルへの重複 JOIN を防止する。ただし、このライブラリは 1 チャンネルのみ の同時接続をサポートする(新しいチャンネルに JOIN しても、前のチャンネルからの PART は送信されない)。

3.4 公開メソッド: is_default_user / close_connection

    def is_default_user(self):
        return self.__NICK == self.__DEFAULT_NICK              # ← 匿名ユーザーかどうかを判定

    def close_connection(self):
        self.__SOCKET.close()                                  # ← TCPソケットを閉じる
        print('Connection closed')

twitch_chat_irc.py 71〜76行目)

is_default_user(): 現在のニックネームが匿名デフォルト(justinfan67420)であるかを判定する。匿名ユーザーはメッセージの送信ができないため、send() メソッド内でこの判定を使用してエラーを防止する。

close_connection(): TCP ソケットを閉じて接続を切断する。socket.close() は TCP の正常切断シーケンス(FIN → FIN-ACK)を開始する。GlytchC2 の victim.pyattacker.pyfinally 節から呼び出され、プログラム終了時の確実なリソース解放を保証する。

3.5 listen() — メッセージ受信の中核メソッド

listen() は GlytchC2 の通信において最も重要なメソッドであり、指定チャンネルの IRC メッセージを監視・収集する。Victim はこのメソッドでコマンドを受信し、Attacker は制御シグナル("OK", "READY")を受信する。

    def listen(self, channel_name, messages = [], timeout=None,
               message_timeout=1.0, on_message = None,
               buffer_size = 4096, message_limit = None, output=None):
        self.__join_channel(channel_name)                      # ← チャンネルに参加(未参加の場合)
        self.__SOCKET.settimeout(message_timeout)              # ← ソケットタイムアウトを設定

        if(on_message is None):
            on_message = self.__print_message                  # ← デフォルトのメッセージハンドラ

twitch_chat_irc.py 78〜83行目)

引数の解説:

  • channel_name: 監視する Twitch チャンネル名(例: "bilocan1337"
  • messages = []: 収集したメッセージを格納するリスト。注意: ミュータブルデフォルト引数のバグ。Python ではリストやディクショナリをデフォルト引数に使用すると、関数定義時に一度だけ生成されたオブジェクトが全ての呼び出しで共有される。つまり、前回の listen() 呼び出しで蓄積されたメッセージが次回の呼び出しに引き継がれる可能性がある。GlytchC2 ではこの引数を明示的に渡さずデフォルト値を使用しているため、セッション間でメッセージリストが蓄積されるバグが潜在する。
  • timeout: 最後のメッセージ受信からの無メッセージタイムアウト(秒)。None の場合はタイムアウトなし(永久待機)
  • message_timeout=1.0: 個々の recv() 呼び出しのソケットタイムアウト(秒)。1 秒ごとにソケットを確認する
  • on_message: メッセージ受信時のコールバック関数。None の場合はデフォルトの表示関数
  • buffer_size=4096: ソケット受信バッファサイズ(4KB)
  • message_limit: 収集するメッセージの上限数。GlytchC2 では message_limit=1 で使用し、1 件受信で即リターンする

settimeout(message_timeout)(80行目): ソケットのタイムアウトを設定する。これにより recv() は最大 message_timeout 秒間ブロックした後、socket.timeout 例外を発生させる。GlytchC2 ではデフォルトの 1.0 秒が使用され、1 秒ごとにタイムアウトチェックが行われる。

        print('Begin retrieving messages:')

        time_since_last_message = 0                            # ← 最後のメッセージ受信からの経過時間
        readbuffer = ''                                        # ← 受信データの蓄積バッファ
        try:
            while True:                                        # ← メインの受信ループ
                try:
                    new_info = self.__recvall(buffer_size)      # ← データ受信
                    readbuffer += new_info                      # ← バッファに追加

                    if('PING :tmi.twitch.tv' in readbuffer):   # ← PING チェック
                        self.__send_raw('PONG :tmi.twitch.tv') # ← PONG 応答(接続維持)

                    matches = list(self.__PATTERN.finditer(readbuffer))  # ← 正規表現でメッセージを抽出

twitch_chat_irc.py 86〜98行目)

PING/PONG メカニズム(95〜96行目): IRC サーバーは定期的に PING :tmi.twitch.tv を送信してクライアントの接続状態を確認する。クライアントが一定時間内に PONG :tmi.twitch.tv を返さない場合、サーバーは接続を切断する。この PING/PONG はキープアライブ(接続維持)メカニズムであり、IRC プロトコルの標準的な仕組みである。

GlytchC2 が長時間にわたってコマンドの送受信を待つ場合(例: Victim 側の READY_TIMEOUT = 600 秒)、この PING/PONG 応答が正しく処理されないと接続が切断される可能性がある。listen() メソッド内では正しく処理されているが、listen()message_limit=1 で即座にリターンした後の待機期間中は listen() が呼び出されていないため、PING に応答できない時間帯が生じうる。

__PATTERN.finditer(readbuffer)(98行目): コンパイル済み正規表現をバッファ全体に適用し、全てのマッチを取得する。finditer はイテレータを返すため、list() で全マッチをリストに変換している。

                    if(matches):
                        time_since_last_message = 0            # ← メッセージ受信でタイマーリセット

                        if(len(matches) > 1):
                            matches = matches[:-1]             # ← 最後のマッチを除外(不完全な可能性)

                        last_index = matches[-1].span()[1]     # ← 最後の完全マッチの終了位置
                        readbuffer = readbuffer[last_index:]    # ← 処理済み部分をバッファから除去

                        for match in matches:
                            data = {}                          # ← メッセージのメタデータ辞書
                            for item in match.group(1).split(';'):  # ← タグをセミコロンで分割
                                keys = item.split('=',1)       # ← キー=値 で分割
                                data[keys[0]]=keys[1]          # ← 辞書に格納
                            data['message'] = match.group(2)   # ← メッセージ本文を格納

                            messages.append(data)              # ← メッセージリストに追加

                            if(callable(on_message)):
                                try:
                                    on_message(data)           # ← コールバック関数を呼び出し
                                except TypeError:
                                    raise Exception('Incorrect number of parameters ...')

                            if(message_limit is not None and len(messages) >= message_limit):
                                return messages                # ← メッセージ上限に達したらリターン

twitch_chat_irc.py 100〜125行目)

不完全メッセージの処理(103〜104行目): if(len(matches) > 1): matches = matches[:-1] は、最後のマッチが不完全な IRC メッセージである可能性を考慮した処理である。TCP はストリーム型プロトコルであるため、1 回の recv() で受信したデータがメッセージの途中で切れている可能性がある。最後のマッチを除外することで、不完全なメッセージの処理を避けている。除外されたデータは readbuffer に残り、次の recv() で続きのデータが受信された後に再度パースされる。

IRCv3 タグのパース(109〜113行目): 正規表現のグループ 1 で取得したタグ文字列を ;(セミコロン)で分割し、各タグをさらに = で キーと値に分割して辞書に格納する。例:

入力: "badge-info=;badges=broadcaster/1;display-name=User123;tmi-sent-ts=1234567890"

分割結果:
  data['badge-info']    = ''
  data['badges']        = 'broadcaster/1'
  data['display-name']  = 'User123'
  data['tmi-sent-ts']   = '1234567890'
  data['message']       = 'Hello World'    ← グループ2から追加

この data 辞書が GlytchC2 で msg.get("message", "") として参照される。

message_limit によるリターン(124〜125行目): GlytchC2 は message_limit=1 でこのメソッドを呼び出すため、最初の PRIVMSG を受信した時点で即座にメッセージリストを返す。これにより、GlytchC2 のメインループは 1 コマンドずつ処理できる。

                except socket.timeout:                         # ← recv のタイムアウト
                    if(timeout != None):
                        time_since_last_message += message_timeout  # ← 経過時間を加算

                        if(time_since_last_message >= timeout): # ← 全体タイムアウト判定
                            print('No data received in',timeout,'seconds. Timing out.')
                            break                              # ← ループを抜ける

        except KeyboardInterrupt:
            print('Interrupted by user.')

        except Exception as e:
            print('Unknown Error:',e)
            raise e

        return messages

twitch_chat_irc.py 127〜142行目)

タイムアウト管理の仕組み:

timeout パラメータは「最後のメッセージ受信から何秒間メッセージがなければタイムアウトとするか」を制御する。message_timeout(デフォルト 1.0 秒)ごとにソケットの recv() がタイムアウトし、socket.timeout 例外が発生する。この例外を time_since_last_message の加算に利用し、累積経過時間が timeout に達したらループを抜ける。メッセージを受信した場合は time_since_last_message = 0 にリセットされる。

GlytchC2 での使用例: chat.listen(channel, timeout=60, message_limit=1) は「60 秒間メッセージがなければタイムアウト、またはメッセージを 1 件受信したらリターン」の動作となる。

3.6 send() — メッセージ送信メソッド

    def send(self, channel_name, message):
        self.__join_channel(channel_name)                      # ← チャンネルに参加(未参加の場合)

        # check that is using custom login, not default
        if(self.is_default_user()):
            raise DefaultUser                                  # ← 匿名ユーザーでは送信不可
        else:
            self.__send_raw(                                   # ← IRC PRIVMSG コマンドを送信
                'PRIVMSG #{} :{}'.format(
                    channel_name.lower(), message               # ← チャンネル名を小文字化
                )
            )
            print('Sent "{}" to {}'.format(message,channel_name))

twitch_chat_irc.py 144〜152行目)

PRIVMSG #channel :message: IRC のメッセージ送信コマンド。#channel は送信先チャンネル名、: 以降がメッセージ本文である。

匿名ユーザーチェック(148〜149行目): 匿名接続(justinfan67420)ではメッセージの送信が Twitch サーバーに拒否される。送信前にこのチェックを行い、匿名ユーザーの場合は DefaultUser 例外を発生させて早期にエラーを通知する。

GlytchC2 では、Victim 側は "OK" / "READY" の制御シグナルを送信し、Attacker 側は Base64 エンコードされたコマンドと "OK" シグナルを送信する。いずれも .env ファイルに実際の認証情報を設定する必要がある。

3.7 スタンドアロン実行(__main__ ブロック)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description='Send and receive Twitch chat messages over IRC ...'
    )

    parser.add_argument('channel_name', help='Twitch channel name')
    parser.add_argument('-timeout','-t', default=None, type=float)
    parser.add_argument('-message_timeout','-mt', default=1.0, type=float)
    parser.add_argument('-buffer_size','-b', default=4096, type=int)
    parser.add_argument('-message_limit','-l', default=None, type=int)
    parser.add_argument('-username','-u', default=None)
    parser.add_argument('-oauth', '-password','-p', default=None)
    parser.add_argument('--send', action='store_true')
    parser.add_argument('-output','-o', default=None)

    args = parser.parse_args()

    twitch_chat_irc = TwitchChatIRC(username=args.username, password=args.oauth)

    if(args.send):
        if(twitch_chat_irc.is_default_user()):
            print('Unable to send messages with default user. ...')
        else:
            try:
                while True:
                    message = input('>>> Enter message (blank to exit): \n')
                    if(not message):
                        break
                    twitch_chat_irc.send(args.channel_name, message)
            except KeyboardInterrupt:
                print('\nInterrupted by user.')

    else:
        messages = twitch_chat_irc.listen(
            args.channel_name,
            timeout=args.timeout,
            message_timeout=args.message_timeout,
            buffer_size=args.buffer_size,
            message_limit=args.message_limit)

        if(args.output != None):
            if(args.output.endswith('.json')):
                with open(args.output, 'w') as fp:
                    json.dump(messages, fp)                     # ← JSON形式で保存
            elif(args.output.endswith('.csv')):
                with open(args.output, 'w', newline='',encoding='utf-8') as fp:
                    fieldnames = []
                    for message in messages:
                        fieldnames+=message.keys()
                    if(len(messages)>0):
                        fc = csv.DictWriter(fp,fieldnames=list(set(fieldnames)))
                        fc.writeheader()
                        fc.writerows(messages)                  # ← CSV形式で保存
            else:
                f = open(args.output,'w', encoding='utf-8')
                for message in messages:
                    print('['+message['tmi-sent-ts']+']',
                          message['display-name']+':',
                          message['message'],file=f)            # ← テキスト形式で保存
                f.close()

            print('Finished writing',len(messages),'messages to',args.output)

    twitch_chat_irc.close_connection()

twitch_chat_irc.py 155〜217行目)

このブロックは twitch_chat_irc.py をスタンドアロン(直接実行)で使用する場合のインターフェースである。GlytchC2 ではこのスクリプトをモジュールとして import するため、__main__ ブロックは実行されない。

このスタンドアロンモードは、チャットの監視(受信モード)と対話的なメッセージ送信(--send モード)の 2 つの動作モードを提供する。出力は JSON、CSV、テキストの 3 形式をサポートする。

参照

ID タイトル URL 対応箇所
1 socket — Low-level networking — Python docs https://docs.python.org/3/library/socket.html TCP ソケット通信
2 python-decouple — PyPI https://pypi.org/project/python-decouple/ .env ファイルからの設定読み込み
3 emoji — PyPI https://pypi.org/project/emoji/ 絵文字のテキスト表現変換
4 xenova/twitch-chat-irc — GitHub https://github.com/xenova/twitch-chat-irc 本ライブラリの原作リポジトリ

4. 通信基盤の弱点・検知ポイント

4.1 検知ポイント

検知ポイント 検知手法 説明
平文 IRC 接続 ネットワーク DPI ポート 6667 への TCP 接続は IRC として検知可能。通信内容(OAuth トークン含む)が平文で流れる
irc.chat.twitch.tv への接続 DNS 監視 / ネットワークフロー このドメインへの接続は Twitch IRC の使用を示す
PING/PONG パターン ネットワークフロー解析 定期的な PING/PONG 交換は IRC 接続の特徴的なパターン
OAuth トークンの平文送信 パケットキャプチャ PASS oauth:... が暗号化されずに送信される(ポート 6667 は TLS なし)

4.2 設計上の弱点

  1. 平文通信(最重大): ポート 6667 の IRC 接続は暗号化されていない。ネットワーク経路上の傍受者(ISP、企業プロキシ、中間者攻撃者)が全通信内容を閲覧でき、OAuth トークン、コマンド(Base64 は容易にデコード可能)、制御シグナルが露出する。TLS 対応のポート 6697 を使用するか、WSS(WebSocket over TLS)を使用すべきである。

  2. ミュータブルデフォルト引数: listen() メソッドの messages = [] はミュータブルデフォルト引数であり、Python の既知の落とし穴である。GlytchC2 が同一の TwitchChatIRC インスタンスで listen() を繰り返し呼び出す際に、前回のメッセージが蓄積される可能性がある。

  3. シングルチャンネル制約: __CURRENT_CHANNEL による 1 チャンネル制約は、GlytchC2 の使用パターン(Victim と Attacker が同一チャンネルを使用)では問題ないが、前のチャンネルからの PART コマンドが送信されないため、複数チャンネルの使用時にメッセージの混在が発生しうる。

  4. lstrip によるパスワード破損リスク: 'oauth:'+str(password).lstrip('oauth:')lstrip の文字セット除去の挙動により、パスワードの先頭が o, a, u, t, h, : のいずれかで始まる場合に意図しない文字が除去される。removeprefix('oauth:') (Python 3.9+) を使用すべきである。

  5. 不完全メッセージの処理: matches = matches[:-1](最後のマッチを除外)は、バッファに 1 つしかマッチがない場合に空リストになり、有効なメッセージが失われる可能性がある。