🔖

Cloudflare Gateway TLS Inspection での Certificate pinning 検知とバイパス

に公開

はじめに

Cloudflare Gateway TLS Inspection 利用時に、Certificate pinning で通信できなくなったアプリケーションを検知・バイパスする方法を考えます。

まず、Certificate pinning は複数ある Gateway TLS Inspection の制限事項の一つに挙げられています(TLS decryption をサポートしない)。

覗き見から自分を守ろうとする Certificate pinning のアプリケーションを Gateway 経由で利用するには、定義済みの Do not inspect アプリケーション信頼できるデバイスを TLS Inspectin からバイパス(Do Not Inspect = L4 転送)する方法があります。

ここではそれらでカバーしきれない Certificate pinning を検知・バイパスする方法を試してみます。

実装の流れ

基本の流れは下記になります。

当該宛先 IP への通信が再度流れると、バイパスされるようになります。
また、その後のオプションとしては下記があります。

これにより、IP アドレスによらず、ホスト名(SNI)ベースでバイパスすることができます。

CLIENT_TLS_ERROR となるセッションの例

実際に CLIENT_TLS_ERROR となった TLS セッションの一例を見てみましょう。

このケースだとクライアントが TCP FIN で TLS ハンドシェイクを切っています。

この宛先に対し TLS Inspection をバイパスすることでクライアントが切断しなくなりました。

実装:基本(手動)

1. Network Session Logs の保管

  • Network Session Logs を R2 などに Logpush



    ConnectionCloseReason OriginIP OriginPort は含むようにします。

ConnectionCloseReason の一覧
CLIENT_CLOSED
CLIENT_IDLE_TIMEOUT
CLIENT_TLS_ERROR
CLIENT_ERROR
ORIGIN_CLOSED
ORIGIN_TLS_ERROR
ORIGIN_ERROR
ORIGIN_UNREACHABLE
ORIGIN_UNROUTABLE
PROXY_CONN_REFUSED
UNKNOWN
MISMATCHED_IP_VERSIONS
TOO_MANY_ACTIVE_SESSIONS_FOR_ACCOUNT
TOO_MANY_ACTIVE_SESSIONS_FOR_USER
TOO_MANY_NEW_SESSIONS_FOR_ACCOUNT
TOO_MANY_NEW_SESSIONS_FOR_USER.

2. Network Session Logs をダウンロード、Gateway List(IP)を作成

  • Network Session Logs を Rclone などでダウンロード
  • ConnectionCloseReasonCLIENT_TLS_ERROROriginIP を抽出
  • sort uniq などし、一行目に value を入れて、Gateway の List(IP)
リスト作成
例(macOS zsh)
# R2 パスの指定
LOGPATH=r2:logs/network_session/20250814
# ログをローカルにコピー
rm *.gz
rclone copy $LOGPATH .
# IP アドレス抽出
for f in *.gz; do gunzip -c "$f" |jq -r '. | select((.ConnectionCloseReason | contains("CLIENT_TLS_ERROR")) and .OriginPort == 443) | .OriginIP'; done | sort -u > ip.csv
# Gateway List 向けにヘッダー追加
awk 'BEGIN{print "value"} NF > 0' ip.csv > tmp && mv tmp ip.csv
# 確認
head -3 ip.csv

value
13.213.172.168
13.217.8.197

3. Gateway List(IP)をアップロード

  • 作成された List をダッシュボードあるいは API を使ってアップロード

4. HTTP Policy に適用

  • List を HTTP PolicyDestination IPDo Not Inspect に適用
  • 他の Do Not Inspect ポリシーがある場合にそれらより優先適用したいので、それらより上(先頭行など)に追加
  • ポリシー名は 6 で使うのでメモ


5. バイパスされ始める

Policy の Status を有効にすると、過去にクライアントが TLS セッションを途中で切断した宛先 IP について、その通信をパイパスするようになります。

注意点

Do Not Inspect は他の Action よりも優先適用されます
そのため、既に Inspect(Allow)中のアプリケーションが影響を受けることが考えられます。
たとえば、テナントコントロール。
Google Workspace のテナントコントロール

google.com に送られるすべてのトラフィックにヘッダーを追加

とあります。
もし、何らかの要因で google.com の IP に対して CLIENT_TLS_ERROR が発生した場合、今回の作業でその IP が Do Not Inspect に登録されることで、テナントコントロールが効かなくなる可能性が出てきます。

そのような場合は Inspect/Bypass どちらを優先するか検討し、Inspect を優先する場合は、その IP や SNI(Hostname)を Do Not Inspect から除外する処理を追加する必要があります。

必要に応じ、下記を実行します。

6. Gateway HTTP Log をダウンロード、Gateway List(DOMAIN)を作成

  • 4 にヒットした通信のログから SNI(DOMAIN)を抽出
リスト作成
例(macOS zsh)
# R2 パスの指定
LOGPATH=r2:logs/gateway_http/20250814
# ログをローカルにコピー
rm *.gz
rclone copy $LOGPATH .
# 4 の IP アドレスでバイパスしている HTTP ポリシー名
POLICY=tls-error
# HTTPHost 抽出
for f in *.gz; do gunzip -c "$f" | jq -r --arg POLICY "$POLICY" '.|select(.Action=="bypass" and .PolicyName==$POLICY)|.HTTPHost'; done |sort -u > host.csv
# Gateway List 向けにヘッダー追加
awk 'BEGIN{print "value"} NF > 0' host.csv > tmp && mv tmp host.csv
# 確認
head -3 host.csv

value
a4k-fe.amazon.com
accounts.google.com

7. Gateway List(DOMAIN) をアップロード

8. HTTP Policy に適用

  • 4 のポリシーに追加

List 作成サンプル

Gateway List(IP と DOMAIN)作成のサンプルスクリプト
#!/usr/bin/env bash
# ----------------------------------------------------------
# Usage: ./extract_logs.sh [DATE]
#        ./extract_logs.sh --help
# ----------------------------------------------------------

set -euo pipefail

show_help() {
  cat <<EOF
Usage: $0 [DATE]

Options:
  DATE       Target date in YYYYMMDD (UTC). Default: today UTC
  --help     Show this help message

Description:
  - Extracts CLIENT_TLS_ERROR IPs from network_session logs
    and saves to ip-YYYYMMDD.csv
  - Extracts Action=="bypass" HTTPHosts from gateway_http logs
    and saves to host-YYYYMMDD.csv
  - Existing *.log.gz files in the current directory will prompt
    for deletion before proceeding.
EOF
}

# ==== 引数処理 ====
if [[ "${1:-}" == "--help" ]]; then
  show_help
  exit 0
fi

DATE="${1:-$(date -u +%Y%m%d)}"
echo "📅 Target date (UTC): $DATE"

# ==== 共通設定 ====
shopt -s nullglob
check_existing_logs() {
  local files=(*.log.gz)
  if [ ${#files[@]} -gt 0 ]; then
    echo "⚠️  Found existing .log.gz files:"
#    printf '  %s\n' "${files[@]}"
    read -r -p "Delete them before proceeding? [y/N] " ans
    case "$ans" in
      [Yy]*) rm -f -- *.log.gz ;;
      *) echo "❌ Aborted."; exit 1 ;;
    esac
  fi
}

fetch_logs() {
  local path="$1"
  echo "📥 Fetching logs from $path ..."
  rclone -q copy "$path" .
}

# ==== 1. IP抽出 ====
check_existing_logs
LOGPATH_NET="r2:logs/network_session/$DATE"
fetch_logs "$LOGPATH_NET"

OUTFILE_IP="ip-$DATE.csv"
echo "🔎 Extracting IPs (CLIENT_TLS_ERROR port 443) ..."
for f in *.log.gz; do
  gunzip -c "$f" |
    jq -r '. | select((.ConnectionCloseReason | contains("CLIENT_TLS_ERROR")) and .OriginPort == 443) | .OriginIP'
done | sort -u | awk 'BEGIN{print "value"} NF>0' > "$OUTFILE_IP"
echo "✅ IP CSV: $OUTFILE_IP"
head -3 "$OUTFILE_IP"

# ==== 2. HTTPHost抽出 ====
check_existing_logs
LOGPATH_HTTP="r2:logs/gateway_http/$DATE"
fetch_logs "$LOGPATH_HTTP"

POLICY="tls-error"
OUTFILE_HOST="host-$DATE.csv"
#echo "🔎 Extracting HTTPHost (Action==\"bypass\") ..."
#for f in *.log.gz; do
#  gunzip -c "$f" |
#    jq \
#      '. | select(.Action=="bypass") | .HTTPHost'
#done | sort -u | awk 'BEGIN{print "value"} NF>0' > "$OUTFILE_HOST"
echo "🔎 Extracting HTTPHost (Action==\"bypass\" and .PolicyName==$POLICY) ..."
for f in *.log.gz; do
  gunzip -c "$f" |
    jq -r --arg POLICY "$POLICY" \
      '. | select(.Action=="bypass" and .PolicyName==$POLICY) | .HTTPHost'
done | sort -u | awk 'BEGIN{print "value"} NF>0' > "$OUTFILE_HOST"
echo "✅ Host CSV: $OUTFILE_HOST"
head -3 "$OUTFILE_HOST"

./ip-host.sh 20250818

📅 Target date (UTC): 20250818
⚠️  Found existing .log.gz files:
Delete them before proceeding? [y/N] y
📥 Fetching logs from r2:logs/network_session/20250818 ...
🔎 Extracting IPs (CLIENT_TLS_ERROR port 443) ...
✅ IP CSV: ip-20250818.csv
value
142.250.200.194
16.182.38.140
⚠️  Found existing .log.gz files:
Delete them before proceeding? [y/N] y
📥 Fetching logs from r2:logs/gateway_http/20250818 ...
🔎 Extracting HTTPHost (Action=="bypass" and .PolicyName==tls-error) ...
✅ Host CSV: host-20250818.csv
value
056b264e1876e518d243e771d269e5c5f57287e2b8d828eaa029ea4b59ed663.us-east-1.prod.service.minerva.devices.a2z.com
7d5ffad64cd1f33ac60eed6deab126529669ca66378c1e33ae16c171940fb40.us-east-1.prod.service.minerva.devices.a2z.com

実装:応用(自動)

自動化のテストをしてみます。

0. 前提

まっさらなバイパスのなしの全部インスペクトする状態から始めます。

  • クライアントへの CA 証明書は展開済み
  • Gateway HTTP Policy に Do Not Inspect のルールは一つもない
  • 過去 30 分の Gateway HTTP Logs に bypass が一つも残っていない

自動化の主な内容は下記になります。

  • Cloudflare Workers の Cron Triggers を利用し、サーバーレスで定期実行
  • Logpush から検索する作業を Log Explorer にやらせ、手間を省く

処理フロー

チャート

1. 準備(Gateway)

空の Gateway リスト(IP・DOMAIN)

API
curl -H "Authorization: Bearer $TOKEN" "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/gateway/lists/$LIST_IP" -s | jq '.result'

{
  "id": "29ed8d32-54fc-460b-aa98-786877bed34c",
  "name": "CLIENT_ERROR IP list",
  "description": "CLIENT_ERROR IP list",
  "type": "IP",
  "created_at": "2025-07-28T21:07:42Z",
  "updated_at": "2025-08-16T07:48:53Z"
}

curl -H "Authorization: Bearer $TOKEN" "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/gateway/lists/$LIST_HOST" -s | jq '.result'
   
{
  "id": "109d0ae0-811a-4960-ba68-cfdd0b7dc0a1",
  "name": "BYPASSED HOST list",
  "description": "BYPASSED HOST list",
  "type": "DOMAIN",
  "created_at": "2025-08-11T06:10:01Z",
  "updated_at": "2025-08-16T07:47:47Z"
}

Gateway HTTP Policy

  • ポリシーは先頭に配置(ポリシー名は Workers 内で利用)

  • Action は Do Not Inspect


API
curl -H "Authorization: Bearer $TOKEN" "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/gateway/rules/$RULE" -s | jq '.result.traffic'

"any(http.conn.dst_ip[*] in $29ed8d32-54fc-460b-aa98-786877bed34c) or http.conn.hostname in $109d0ae0-811a-4960-ba68-cfdd0b7dc0a1"

2. 準備(Workers)

空の KV

wrangler
npx wrangler kv key list --binding $KV_NAME --remote

[
  {
    "name": "unique_hosts"
  },
  {
    "name": "unique_ips"
  }
]

npx wrangler kv key get --binding $KV_NAME unique_hosts --remote
npx wrangler kv key get --binding $KV_NAME unique_ips --remote

Secrets

wrangler
npx wrangler secret list

[
  {
    "name": "ACCOUNT_ID",
    "type": "secret_text"
  },
  {
    "name": "GATEWAY_LIST_ID_HOST",
    "type": "secret_text"
  },
  {
    "name": "GATEWAY_LIST_ID_IP",
    "type": "secret_text"
  },
  {
    "name": "TOKEN",
    "type": "secret_text"
  }
]

3. Worker

Bindings

  • KV とバインド

Trigger Events

  • Cron を指定

サンプルのスクリプトと wrangler 設定

  • 5 分ごとに直近 30 分の CLIENT_TLS_ERROR を取り、Gateway List を更新
index.ts
export default {
  async scheduled(event, env, ctx) {
    // 定数
    const apiToken = env.TOKEN;
    const accountId = env.ACCOUNT_ID;
    const baseUrl = "https://api.cloudflare.com/client/v4/accounts/";
    const pathLogexp = "logs/explorer/query/sql";
    const pathGwlist = "gateway/lists";
    const gatewayHttpPolicy = "tls-error"; // バイパス用のポリシー。先頭に配置。
    // クエリ対象にするログの時刻範囲
    const now = new Date();
    const fromTime = new Date(now.getTime() - 30*60*1000); // 30 分前
    const toTime = now; 
    const pad = (num) => num.toString().padStart(2, '0');
    const formatTimestamp = (date) => {
        return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}Z`;
    };
    const fromTimeStr = formatTimestamp(fromTime); // Set start time
    console.log(`Start Time: ${fromTimeStr}`);
    const toTimeStr = formatTimestamp(toTime);   // Set end time
    console.log(`End Time: ${toTimeStr}`);
    /**
     * logQueryField: クエリ対象 (e.g., 'originip', 'httphost').
     * logTimeField: 対象期間 (e.g., 'sessionendtime', 'datetime').
     * kvKey: KV のキー (e.g., 'unique_ips', 'unique_hosts').
     * gatewayListId: Gateway のリスト ID
     * gatewayListName Gateway のリスト 名
     * gatewayListType Gateway のリスト タイプ ('IP' or 'DOMAIN').
     */
    async function processListAndSync(logQueryField, logTimeField, kvKey, gatewayListId, gatewayListName, gatewayListType) {
      // 1. Cloudflare ログをクエリー(Log Explorer)
      // const logQuery = `SELECT DISTINCT ${logQueryField} FROM ...
      // `DISTINCT` は戻りが空のときにエラーとなったので、使わない
      const logQuery = `SELECT ${logQueryField} FROM ${gatewayListType === 'IP' ? 'zero_trust_network_sessions' : 'gateway_http'} WHERE ${logTimeField} >= '${fromTimeStr}' AND ${logTimeField} <= '${toTimeStr}' AND ${gatewayListType === 'IP' ? "connectionclosereason IN ('CLIENT_TLS_ERROR') AND originport = 443" : `action = 'bypass' AND policyname = '${gatewayHttpPolicy}'`} LIMIT 10000`;
      console.log(`[${gatewayListType}] Log Query: ${logQuery}`);      
      const encodedQuery = encodeURIComponent(logQuery);
      const logexpApiUrl = `${baseUrl}${accountId}/${pathLogexp}?query=${encodedQuery}`;
      console.log(logexpApiUrl)
      const logResponse = await fetch(logexpApiUrl, {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${apiToken}`
        }
      });
      console.log(logResponse.status, logResponse.statusText);
      if (!logResponse.ok) {
        console.error(`API Error (Logs - ${gatewayListType}): ${await logResponse.text()}`);
        return;
      }
      const logsJson = await logResponse.json();
      const logsArray = logsJson.result.map(item => item[logQueryField]);
      const filteredLogs = logsArray.filter(item => item && item.length > 0);
      // 2. KV から既存のデータを取る
      const existingItemsStr = await env.TLSI_CLIENT_ERROR.get(kvKey);
      const existingItems = existingItemsStr ? JSON.parse(existingItemsStr) : [];
      const existingItemSet = new Set(existingItems);
      // 3. 新しいデータをチェックしてマージ
      let newItemsFound = false;
      filteredLogs.forEach(item => {
        if (!existingItemSet.has(item)) {
          existingItemSet.add(item);
          console.log(`[${gatewayListType}] New item found: ${item}`);
          newItemsFound = true;
        }
      });
      if (newItemsFound) {
        // 4. 新しければマージした情報で Gateway リストをアップデート
        const updatedItemList = Array.from(existingItemSet).sort();
        console.log(`[${gatewayListType}] ${updatedItemList.length - existingItems.length} new items found.`);
        await env.TLSI_CLIENT_ERROR.put(kvKey, JSON.stringify(updatedItemList));
        console.log('KV is ' + JSON.stringify(updatedItemList, null, 2));
        console.log(`[${gatewayListType}] KV updated.`);
        const gatewayApiUrl = `${baseUrl}${accountId}/${pathGwlist}/${gatewayListId}`;
        const requestBody = JSON.stringify({
          description: `${gatewayListName}`,
          items: updatedItemList.map(item => ({ value: item })),
          name: gatewayListName,
          type: gatewayListType
        });
        const gatewayResponse = await fetch(gatewayApiUrl, {
          method: "PUT",
          headers: {
            "Content-Type": "application/json",
            "Authorization": `Bearer ${apiToken}`
          },
          body: requestBody
        });
        if (!gatewayResponse.ok) {
          console.error(`API Error (Gateway List - ${gatewayListType}): ${await gatewayResponse.text()}`);
        } else {
          console.log(`[${gatewayListType}] Gateway List updated.`);
        }
      } else {
        console.log(`[${gatewayListType}] No new items found.`);
      }
    }
    // --- メイン ---
    console.log("Cron process started.");
    try {
      // IP addresses
      await processListAndSync('originip', 'sessionendtime', 'unique_ips', env.GATEWAY_LIST_ID_IP, 'CLIENT_ERROR IP list', 'IP');
      // Hostnames
      await processListAndSync('httphost', 'datetime', 'unique_hosts', env.GATEWAY_LIST_ID_HOST, 'BYPASSED HOST list', 'DOMAIN');
    } catch (error) {
      console.error(`An unexpected error occurred: ${error}`);
    }
    console.log("Cron process finished.");
  },
};
wrangler.jsonc
{
	"$schema": "node_modules/wrangler/config-schema.json",
	"name": "tlsi-failure-manager",
	"main": "src/index.ts",
	"compatibility_date": "2025-08-10",
	"observability": {
		"enabled": true
	},
	"triggers": {
		"crons": [
			"*/5 * * * *"
		]
	},
    "kv_namespaces": [
    {
      "binding": "TLSI_CLIENT_ERROR",
      "id": "<KV Namespaces の ID>"
    }
  ]
}

4. 稼働

Log Explorer クエリー

(log) [IP] Log Query: SELECT originip FROM zero_trust_network_sessions WHERE sessionendtime >= '2025-08-18T11:05:35Z' AND sessionendtime <= '2025-08-18T11:35:35Z' AND connectionclosereason IN ('CLIENT_TLS_ERROR') AND originport = 443 LIMIT 10000
(log) [DOMAIN] Log Query: SELECT httphost FROM gateway_http WHERE datetime >= '2025-08-18T11:05:35Z' AND datetime <= '2025-08-18T11:35:35Z' AND action = 'bypass' AND policyname = 'tls-error' LIMIT 10000

検知

  :
  (log) 200 OK
  (log) [IP] New item found: 2620:1ec:33::12
  (log) [IP] New item found: 2600:1406:2e00:62::172e:d88f
  (log) [IP] New item found: 2a02:26f0:4000:3::b819:6698
  (log) [IP] New item found: 51.105.71.136
  (log) [IP] New item found: 2603:1026:2415::
  (log) [IP] New item found: 2a02:26f0:4000:3::b819:6684
  (log) [IP] New item found: 2a02:26f0:5c00::214:bbba
  (log) [IP] 7 new items found.
  :
  (log) 200 OK
  (log) [DOMAIN] New item found: thaka.bing.com
  (log) [DOMAIN] New item found: adsdk.microsoft.com
  (log) [DOMAIN] 2 new items found.
  :
更新された KV

更新された Gateway List



ユーザーの体験

最初は App Store にアクセスできませんでしたが

時間が立つとアクセス可能になりました。

アプリケーションの利用が増えると、失敗が増えます。
それごとにバイパスの対象が増え、徐々にアクセスできるアプリケーションが増えていきます。

それでもアクセスができないケースは、下記のようなケースでした。
mTLS を要求するアプリケーション(Do not Inspect あるいは Split Tunnles で回避)
・TLS インスペクションの有無にかかわらず、Cloudflare Gateway を経由するだけでサービスを拒否するアプリケーション(Split Tunnles で回避)

改めてになりますが、ほかにもあります。

さいごに

Certificate pinning アプリケーションだけを確実に検知できるわけではありませんが、まずは TLS Inspection が通信を阻害するアプリケーションを探す手がかりになればと思います。

Discussion