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 を検知・バイパスする方法を試してみます。
実装の流れ
基本の流れは下記になります。
-
Zero Trust Network Session Logs を ConnectionCloseReason
CLIENT_TLS_ERRORでフィルター
➜ これで拾える通信が Certificate pinning にあてはまることが多い - その OrigiIP を HTTP Policy の Destination IP に適用し Do Not Inspect
当該宛先 IP への通信が再度流れると、バイパスされるようになります。
また、その後のオプションとしては下記があります。
-
Gateway HTTP Logs を Action
bypassでフィルター - その HTTPHost を HTTP Policy の Host に適用し Do Not Inspect
これにより、IP アドレスによらず、ホスト名(SNI)ベースでバイパスすることができます。
CLIENT_TLS_ERROR となるセッションの例
実際に CLIENT_TLS_ERROR となった TLS セッションの一例を見てみましょう。

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

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

実装:基本(手動)
1. Network Session Logs の保管
-
Network Session Logs を R2 などに Logpush


ConnectionCloseReasonOriginIPOriginPortは含むようにします。


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 などでダウンロード
-
ConnectionCloseReasonがCLIENT_TLS_ERRORのOriginIPを抽出 -
sortuniqなどし、一行目にvalueを入れて、Gateway の List(IP)化
リスト作成
# 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 Policy の
Destination IPでDo 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)を抽出
リスト作成
# 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