Azure MonitorでECサイトのクレジットカードの大量試行を検知した話

# はじめに
[Azure Advent Calendar 2020](https://qiita.com/advent-calendar/2020/azure) 23日目の記事ということで、[Azure Monitor](https://docs.microsoft.com/ja-jp/azure/azure-monitor/overview)とKustoの話を書きます。
本記事はクレジットカードを取り扱うサイトにおいて、悪用を試験的に検知した話になります。
**悪用対策のベストプラクティスではなく、Azure Monitorでこんなこともできるよ的な内容**になりますので、
その旨ご留意いただければと思います。

また本記事はKusto初学者による試行錯誤が含まれていますので、
同じくこれからKustoにチャレンジされる方はぜひご参考ください。
Kustoに改善点があればご指摘いただけますと幸いです。

### 背景
#### 有効なクレジットカードの当たり判定に悪用されてしまった…
勤め先はWebシステムのパッケージを持ち、カスタマイズして納品するBtoB企業です。
ECサイトの構築事例も多いのですが、EC案件でクレジットカードを機械的に大量に試行され、
有効なカードの判定に使われてしまったことがありました。

カードの有効性の確認はいわゆるXSSやSQLインジェクション等の攻撃とは異なり、
**アプリケーションの仕様を悪用したもの**のため、IPSやWAFでは検知できませんでした。

#### 先手を打ちたい！
後になってカード会社や決済代行会社から顧客を通じて連絡があり、
対応が後手に回ることが多かったため、被害を最小化すべく先手を打ちたいと考えていました。

ちょうどAzureで何ができるのか探していた時に**「Azure Monitorで怪しい動きを見つけられないか」**という話があり、
**アプリケーションを改修せずに**クレジットカードの大量試行を検出できるか試すことになりました。

# Azure Monitor の設定方法
Azure Monitorとは、ざっくりいうと収集したサーバー上のログやパフォーマンスなどのメトリックデータを監視して、
条件に満たしたときにアクションを起こせるツールです。
今回はWebサーバーログを利用しました。

### Log Analytics とは
ログの収集にはLog Analyticsを利用します。
収集したデータに対して[Kusto](https://docs.microsoft.com/ja-jp/azure/data-explorer/kusto/concepts/)というSQLのようなクエリを用いて、分析をすることができます。
収集対象のサーバーはAzure上にある必要はなく、[ログエージェント](https://docs.microsoft.com/ja-jp/azure/azure-monitor/learn/quick-collect-windows-computer)をインストールすれば
他クラウドやオンプレでも収集可能です。WindowsでもLinuxでも可です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/3727294d-316f-e80b-6829-5f2c57e7501c.png)
[Azure Monitor の概要](https://docs.microsoft.com/ja-jp/azure/azure-monitor/overview)より引用

### Kusto とは
Log AnalyticsがストアしたデータをSQLのようなクエリで可視化することができます。
といってもSQLとは似つかない構造のため、初めて触れたときにはなかなか難儀しました。

```kusto:Kusto
Logs
| where Level == "Critical"
| count
```
はSQLでいうところの以下に相当します。

```sql:SQL
SELECT COUNT(*)
FROM Logs
WHERE Level = 'Critical';
```

### アラートを起こすには
アラートルールの条件にKustoを書き、**最短5分間隔で実行**して結果を評価し、条件を満たせばアクションを呼び出します。

#### アラートロジックの基準
Kustoを実行したときの`count`すなわち**結果の数**か、
変数に格納した値の**メトリック測定**かどちらかが選択できます。

今回はメトリック測定を使いましたが、これを使うには`AggregatedValue`という固定の変数名に
時間ごとに集計した値 `bin(TimeGenerated, roundTo)` を代入することが必須になっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/d634c7c6-ddef-9793-4783-25a6fa80cf0d.png)
たとえばWebサーバーログ(IIS)で`500`を応答した行数を10分ごとに分割して集計し、
メトリック測定で判定したい場合は以下のようなKustoになります。

```kusto
W3CIISLog
| where scStatus == 500
| summarize AggregatedValue = count() by bin(TimeGenerated, 10m)
```
これをシグナルロジックの構成の検索クエリに設定し、アラートの発火条件を設定します。
余談ですが**アラートをトリガーする基準**の`次の値より大きい`は0は動くのですが、
画面のように1以上の値を入れると全く動きませんでした。情報求む…。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/6526af7f-6b7c-8c39-026b-909833017653.png)

### アクショングループ
条件を満たしたときの動作です。
アクショングループでは通知とアクションの2種類があります。
#### 通知
メールやSMS、Azureアプリへのプッシュ通知、音声(電話をかけてくる)ということができます。
メールを使ってみましたが、件名のみ変更可能で文面は変更できません。

#### アクション
Automation Runbook、Azure Function、ITSM、ロジックアプリ、Webhook のセキュリティ保護、Webhook が選べます。
**Microsoft Teams**を使っているため、Webhookを使いました。

なお、Webhookを使う場合は`アクションをカスタマイズ`で`webhook 用のカスタム Json ペイロードを含む`を使わないとちゃんと通知してくれませんでした。
以下のようなカスタムJsonを設定しておきます。

```json
{"text":"怪しい動きを検出しました: #alertrulename"}
```

# 本題: クレジットカードの試行を検出する

Azure Monitorの設定方法を簡単に紹介しました。
ここからは実際にどうKustoクエリを作り、どういう結果が得られたのか紹介します。

### 今回のECサイトの仕様
注文周りの画面は以下のように構成されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/31a2c1e2-5245-3687-259e-45fc8e9e2627.png)
<dt>①買い物かご</dt><dd>買い物かごに入っている商品を表示するページ。</dd>
<dt>②配送方法・決済選択</dt><dd>配送の希望日や希望時間、決済方法を決めるページ。
クレジットカードやコンビニ払い、代金引換などを選びます。
ボタン操作でPOSTすると請求額の計算をして302リダイレクトで次に進みます。
失敗時は200を返してエラーメッセージとともに再表示します。</dd>
<dt>③請求額確認</dt><dd>商品と決済方法から最終的に消費者に請求する金額を表示するページ。
クレジットカードを選んでいる場合は**ここでカード番号、有効期限、セキュリティコード等を入力**してもらいます。
POSTするとカードの場合は決済代行会社へトークン化したカード情報を送信して与信処理をし、
その後注文データを作成して成功すれば302リダイレクトで次に進みます。
どこかで失敗すれば200を返してエラーメッセージとともに再表示します。</dd>
<dt>④注文完了</dt><dd>作成した注文データの情報（受付番号など）を出すページです。</dd>

### クレジットカードの試行エラーの検知条件
サイトの仕様から、**③請求額確認(カード入力)の画面で200が応答される**場合、以下のどれかの問題であることがわかります。

* Validationの失敗
* 決済代行会社への処理（カードの与信処理含む）の失敗
* 注文データ作成の失敗

Validationについては商品在庫が引き当らないか、コメント欄にたくさん文字を入れてしまった場合なので、そんなに頻度はありません。
注文データ作成の失敗についてもDBでのINSERT処理の失敗なので、こちらもあまり起きません。

決済代行会社が障害により通信できない場合は一定期間POST時に200が出現しますが、
平時と比べて極端に増えるものでなく、また決済代行会社のサポートから連絡があるため、判別が付きます。

したがって③でPOST時に200が平時とは異なり大量に出てくる場合は、
**ほぼカードの与信処理に失敗している＝色々なカードを試している状態である**とみなせます。

### Kusto(1) 特定URLの200の応答数を数える
まず素直に③の画面でPOSTしたときに200が返ってくる件数で仕掛けてみます。

```kusto:(1)特定のURLにPOSTしたとき200が返ってきた数が一定値以上か
W3CIISLog
| where csUriStem contains "③のURL"
| where csMethod == "POST"
| where scStatus == 200
| summarize AggregatedValue = count() by bin(TimeGenerated, 10m)
```
#### 平時
数はそれほど多くありません。概ねアクセス数とリンクした値になっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/0034a74d-c064-14e2-90f1-26c30802e612.png)
※**実際のデータをお見せすることはできないためモザイク処理を入れています**。すみません…。

#### クレジットカードの大量試行をされた時
中央やや右の部分に極端に大きな値が出ていました。
平時と比べて数百倍大きいため、検出は容易そうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/74de803b-55c5-0392-65d5-ef2a4e3a431c.png)
※こちらもモザイク処理を入れています。

あとはこれを平時と比べて大きな値になったときアラートを発火するようにしました。

#### 課題1
* **平時と比べて十分大きな値**が案件により異なるため、  
  案件ごとにどういった値が適切なのか調べて設定する必要がありました。  
  * どの案件でも共通で入れられるように**移動平均を取って判定に加えてみます**。

### Kusto(2) 24時間移動平均の5倍以上であるか

「5倍」の根拠は明確にはありませんが、過去に発生したログを分析したところ、概ねこれくらいなら確実に検知できるだろう値で設定しています。
※もっと統計的に処理してみたく3σや5σ以上としてみたのですが、母集団が少ないためかあまりうまくいきませんでした。

ここから急にKustoの難易度が上がり、この時点でKustoに触り始めて1週間程度の自分にはなかなか理解できないものでした。
2週間程度試行錯誤の上、ようやくKustoを編み出します。

```kusto:(2)24時間移動平均の5倍以上か(最低XX件以上)
let hour = 24h;
let span = 10m;
let gap_multiple = 5;
let thresold = XX;
let Log = ()
{
    W3CIISLog
    | where csUriStem contains "③のURL"
    | where csMethod == "POST"
    | where scStatus == 200
    | make-series num=count() default=0 on TimeGenerated in range(ago(hour*2), now() , span) 
    | extend ma=series_fir(num, repeat(1, toint(hour/span)))
    | mv-expand TimeGenerated, num, ma
    | project todatetime(TimeGenerated), tolong(num), todouble(ma)
};
Log
| where TimeGenerated >= ago(hour)
| where num >= ma * gap_multiple
| summarize AggregatedValue = sum(num) by bin(TimeGenerated, span)
```
※`thresold`はしきい値を設定します。平時から注文が少ないサイトだと誤検知が多いため最低エラー件数を設定します。

#### Kustoクエリの解説
* `Log`変数にクエリの実行結果を格納し、そこからメトリック測定で使う`AggregatedValue`に値を代入できるようにしています。
* [make-series](https://docs.microsoft.com/ja-jp/azure/data-explorer/kusto/query/make-seriesoperator)で行方向ではなく配列(Dynamic型)としてカウントしたデータを時系列に作り`num`に入れます。  
  24時間移動平均なので、その2倍の48時間を拾っています。
* [series_fir](https://docs.microsoft.com/ja-jp/azure/data-explorer/kusto/query/series-firfunction)で、`num`の移動平均を作ります。  
  引数の`repeat(1, toint(hour/span))`は10分間隔の24時間で144個の`1`だけの配列をフィルタとして入れています。
* [mv-expand](https://docs.microsoft.com/ja-jp/azure/data-explorer/kusto/query/mvexpandoperator)でDynamic型を行方向に変換します。これを知るまで1週間かかりました…。
* `project`で必要な列を用意します。SQLでいう`SELECT`みたいなものです。
* 取り出したテーブルを`Log`に格納し`| where num >= ma * gap_multiple`で移動平均の5倍以上という判定を加えています。

#### 平時
`| where num >= ma * gap_multiple`を外し、
`| render columnchart kind=unstacked`でグラフ化すると以下のようになります。
`num`は先ほどの平時のグラフと同じで、`ma`は24時間移動平均です。
この中で移動平均の5倍以上になるものはありませんでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/e02d28af-1700-e12e-6786-2f8509e7f2c7.png)
※例によってモザイク処理のためわかりにくくてすみません。

#### クレジットカードの大量試行をされた時
平時の数百倍のため左側が真っ白に見えますが、実際には僅かに表示されています。
もちろん移動平均の数百倍でもあるため、ちゃんと検知可能です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/016bdf4c-831e-f0f4-409d-e391bf89b902.png)
※例によって(略

移動平均を取ることで、普段からものすごいPVをたたき出してバンバン注文が入るサイトでも
常にアラートが出っ放しということは無くなりました。

#### 課題2
* マーケティング施策（限定商品セールのプッシュ通知など）による正常なアクセス集中・注文集中で誤検知してしまう。  
  * セールの場合はカード大量試行と違ってエラー率が異なる点に着目して、見分けられるようにしてみます。

### Kusto(3) エラー率は70%以上か
この`70%`という値も過去のデータから大体の値で決めました。

一般客の正常な決済も混ざっている状態で、エラー率は概ね以下でした。

* クレジットカードの大量試行が起きている場合のエラー率は85～99%程度。
* 正常なアクセス集中時のエラー率は20～40%程度。

すでにKustoに慣れてきていたため、そんなに時間もかからず編み出しました。
ついでにデータ集計期間を移動平均の2倍ではなく任意にできるよう`period`に定義しました。(グラフの可視化などで便利なため)

```kusto:(3)エラー率は70%以上か
let period = 48h;
let ng_rate = 0.7;
let hour = 24h;
let span = 10m;
let gap_multiple = 5;
let thresold = XX;
let Log = () 
{
    W3CIISLog
    | where csUriStem contains "③のURL"
    | where csMethod == "POST"
    | make-series num=count() default=0,
         numok=countif(scStatus=='302') default=0,
         numng=countif(scStatus=='200') default=0 
         on TimeGenerated in range(ago(period), now() , span) 
    | extend ma=series_fir(numng, repeat(1, toint(hour/span)))
    | mv-expand TimeGenerated, num, numok, numng, ma
    | project todatetime(TimeGenerated), tolong(num), tolong(numok), tolong(numng), numrate=iif(num>0, todouble(numng)/todouble(num), 0.0), todouble(ma)
};
Log
| where TimeGenerated >= ago(period)
| where numng >= thresold
| where numng >= ma * gap_multiple
| where numrate >= ng_rate 
| summarize AggregatedValue = sum(numng) by bin(TimeGenerated, span)
```
#### Kustoクエリの解説

* `make-series`でHTTPレスポンスコードが302の場合と200の場合を`countif`を使って別々に集計し、Dynamicの配列を作成しています。
* `project`にて`numrate`変数にNGを全体数で割った値を入れています。
* `numok`はアラート条件としては何も使っていないですが可視化用に取っています。

#### 平時
* 青がエラーでほとんど20%程度ですが、10分間隔のため件数が少ないと100%みたいな場合もあります。
* 件数の少なさは(2)のときのthresoldや移動平均でカバーしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/5c9d360d-efa4-e269-6bf9-7e36637ce112.png)
※例によって(略

#### クレジットカードの大量試行をされた時
* 当該時間だけ圧倒的に青（エラー）です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/4deb568c-4c68-ec1f-a486-680a325ae28b.png)
※例によって(略

#### セールによるアクセス集中の時
* クレジットカードの大量試行とは異なり、エラー率は最大で45%ほどでした。
* エラー率70%を境としてアクセス集中と大量試行を区別できています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/dcff1e5e-2cae-68c3-f8bd-ecf453dfcd77.png)
※例によって(略

#### 課題3 (余談)
* Webサーバーログが30日で数十GBたまってしまった場合の月額費用が、  
  アプリケーション改修して検知できるようにした場合と比べて割に合うものか…。

# まとめ
* Webサイトの仕様によっては、アプリケーションを改修せずとも  
  **Webサーバーログだけで**高い精度でクレジットカードの大量試行を検知できました。  
  改修難易度が高い案件、古くて調査に時間がかかる保守案件など、手が出しにくい環境下でも導入しやすいです。
* 既にWebサーバーログを収集しているのであれば、追加コストはアラートルール程度で1件につき月額0.5～1.5ドル程度ですが、  
  これだけのためにWebサーバーログ収集をした場合はログの保持の月額がやや高額のため、**割に合わない可能性**があります。  
  今回はMonitorの可能性を検証するために実施しましたが、実際に導入される場合は十分に試算してからがおすすめです。

# Azure Monitor、Kusto、完全に理解した
`mv-expand`を使えるようになったあたりから**完全に理解した**と思っていましたが、
[ダニング＝クルーガー効果](https://www.google.com/search?q=%E3%83%80%E3%83%8B%E3%83%B3%E3%82%B0%E3%82%AF%E3%83%AB%E3%83%BC%E3%82%AC%E3%83%BC%E5%8A%B9%E6%9E%9C)が示す通り、ここからがスタートだと考えて来年も精進していきたいと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/117852/6a62442c-79bd-0718-f2f6-3873f806c7ee.png)
<details><summary>Kusto</summary><div>


```
range x from 0 to 130 step 0.2
| extend experience=datetime_add('hour',toint(round(x*120)),make_datetime('2020/11/1'))
| extend confidence=iif(x<10, toreal(x*10), pow(x-77,2)*0.02+10)
| project experience, confidence
| render timechart with(title='ダニング＝クルーガー効果', ymax=100 )
```

</div>
</details>