ChatWork Creator's Note

ビジネスチャット「チャットワーク」のエンジニアとデザイナーのブログです。

akka-httpでの総当たり攻撃(brute-force-attack)対策について

こんにちはかとじゅん(@j5ik2o)です。

akka-httpで特定の失敗条件を元に、特定のリクエストをブロックするための仕組みを、akka-guardとして実装したので、設計思想や使い方に関して簡単にお知らせします。今回、想定したシナリオは、認証の総当たり攻撃(brute-force-attack)の対策で、認証の失敗回数が閾値を超えた場合、一定期間認証リクエストをブロックする対策を想定しています。

設計は主に僕がやりましたので僕の方から紹介します。実装は主に藤井(@yoshiyoshifujii)さんにお願いしたので、後半は藤井さん対応で。

akka-guard

github.com

設計(担当:かとう)

akka-httpで認証・認可を伴うAPIを公開した際に、セキュリティ対策の一貫として、総当たり攻撃(brute-force-attack)対策は必須にする必要がありました。さまざまな実装方法がありますが、akka-http上で特定の条件を満たしたリクエストを一定の時間遮断する仕組みを検討しました。

まず、akka-guardのモデル(考え方)を説明します。

リクエストを遮断するためのガードというものが存在します。このガードには対応するガードIDといういものがあります。このIDは文字列でどんなものでも割り当てることができます。たとえば、リクエストに含まれるクライアントIDなどです。遮断するかしないかは、このガードIDごとに判断します。リクエストをハンドリングする処理(リクエスト・レスポンス処理)は、akka-guardのAPI経由でガードに設定し、常にガード内で実行されます。

ガード内で実行されたリクエスト・レスポンス処理が失敗した場合、あらかじめて設定した判定関数によってガード状態に合致する場合、失敗カウントがインクリメントされます。その失敗カウントが故障を計測する期間(failureDuration)内に遮断する故障回数(maxFailures)に達した場合、遮断状態(Open)に遷移します(遮断していない状態はClosedです)。遮断中は無条件にあらかじめ設定されたレスポンスを返します。遮断時間の設定は線形的バックオフか指数関数的バックオフを指定できます。ガードの状態は、オンメモリで管理しています。線形の場合は遮断期間中のみメモリを消費しますが、指数関数のときは常時メモリを消費します。ガード上で実行されるリクエスト・レスポンス処理が一定期間ない場合、ガード状態を強制的破棄する設定(guardResetTimeout)も有効にすることで、メモリを解放させることもできます。

akka-guardの設定項目は以下になります。

項目名 意味
maxFailures遮断する故障回数。この回数になったらキーに対応するガードが遮断(Open)に遷移する。非遮断状態はClosed状態
failureDuration故障を計測する期間。この時間以内にmaxFailureに達するとガードが遮断状態になります
backoff遮断時のバックオフ設定。バックオフには線形的バックオフ(LinealBackoff)と、指数関数的バックオフ(ExponentialBackoff)があります
guardResetTimeoutガードをリセットするタイムアウト時間。リクエストがこの時間 ガードを通過しなければ、ガード機能が強制的に破棄されます。無効です
  • LinealBackoff時、固定の遮断時間を指定できます。
  • ExponentialBackoff時は、最小(minBackoff), 最大(maxBackoff), ランダムファクタ(randomFactor)、最大から最小にリセットする時間(backoffReset, デフォルトはminBackoff)を指定できます。

詳しい使い方は、 How to useを確認してください。

実装(担当:ふじい)

こんにちは。

今回、実装について担当させていただきました藤井(@yoshiyoshifujii)です。

ここからは、実装について、ご紹介させていただきます。

akka-guard は、ガード状態を管理するアクターと、そのアクターを組み込んだAkka HTTPのDirectiveを部品として提供します。

ガード状態を管理するアクター

ガード状態を管理するアクターとして、 com.chatwork.akka.guard.SABActor[T, R] を作成しました。SABとはService Attack Blockerの略です。

このアクターを生成するとClosed状態として初期化されます。メッセージを受信すると、任意のリクエストを処理したうえで、レスポンスの結果の内容を任意の処理で判定したうえで、失敗の判定となりますと、失敗のカウントをアップさせ、そのカウントを保持したアクターへと状態を変えます。任意の閾値を超えるまで、Closed状態のとして振舞い、閾値を超えると、Open状態に遷移します。 Open状態では、メッセージを受信すると、任意のリクエストを処理せず、問答無用で任意の失敗を返却するようになります。

Closed状態のレシーバーを抜粋すると、以下のようになります。

private def closed(attempt: Long, failureCount: Long): Receive = {
  ...

  case msg: Message =>
    val future = try {
      msg.execute
    } catch {
      case NonFatal(cause) => Future.failed(cause)
    }
    future.onComplete {
      case Failure(_)                 => self ! Failed(failureCount)
      case Success(r) if isFailed(r)  => self ! Failed(failureCount)
      case Success(r) if !isFailed(r) => self ! BecameClosed(attempt, 0, setTimer = false)
    }
    reply(future)
}

msg.execute で任意のリクエストを処理しており、処理の結果に応じて、Failedメッセージを自身に送信しております。Failedメッセージを受信した際は、failureCountを+1したうえで、閾値を超えていないかチェックし、閾値を超えている場合は、Open状態にアクターの状態を変えます。

Open状態のレシーバーは、以下のようになっております。

private val open: Receive = {
  ...

  case _: Message     => reply(Future.fromTry(failedResponse))
}

ここでは、任意のリクエストを評価せず、問答無用で失敗レスポンスを返却しております。

また、アクターをOpen状態に遷移した際に、Open状態からClosed状態に遷移するタイマーをセットする必要があります。そこでは、ExponentialBackoffもしくは、LinealBackoffを任意で選択したうえでセットできるようにしております。Backoffのロジックは、 com.chatwork.akka.guard.Backoff に集約してあり、SABConfigにセットするタイミングで、ExponentialかLinealを選択して設定しさえすれば、内部で良きように計算したうえで、Open状態を保持します。

このガード状態を保持するアクターは、Message Brokerパターンで生成される子アクターとなっており、メッセージに含まれる任意の識別子を用いて、ActorSystemの内部で保持されます。

Message Brokerの実装としては、シンプルに内部で識別子のnameを持つActorを探して、無ければ生成するようにしており、Actorを削除しない限り状態を保持するような仕組みとなっております。

Message Brokerについては、 以下の書籍に紹介があります。

Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions (Addison-Wesley Signature Series (Fowler))

Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions (Addison-Wesley Signature Series (Fowler))

Reactive Messaging Patterns with the Actor Model: Applications and Integration in Scala and Akka (English Edition)

Reactive Messaging Patterns with the Actor Model: Applications and Integration in Scala and Akka (English Edition)

Akka HTTPのDirective

ガード状態を管理するアクターをそのまま配布するだけでは、HTTPリクエスト・レスポンスの処理の流れにアスペクト的な組み込みが辛いので、Akka HTTPのDirectiveも部品として実装して配布しています。Akka ActorをAkka HTTPのDirectiveとして組み込む方法が、今回、私としては初の試みでしたので、どうするとうまく組み込めるかなーと試行錯誤しました。

結果的には、以下のようにシンプルなコードで組み込みができました。

def serviceAttackBlocker(serviceAttackBlocker: ServiceAttackBlocker,
                         timeout: Timeout = Timeout(3.seconds))(id: String): Directive0 =
  Directive[T] { inner => ctx =>
    val message: SABMessage[T, R] = SABMessage(id, (), a => inner(a)(ctx))
    serviceAttackBlocker.actorRef.ask(message)(timeout).mapTo[R]
  }

これ、少しほぐしますと、

  Directive.apply[T] { (inner: T => RequestContext => Future[RouteResult]) => (ctx: RequestContext) =>

って感じになっておりまして、

inner: T => RequestContext => Future[RouteResult]

に対して、 T (今回は、Unit)を与えると、 RequestContext => Future[RouteResult] が返ってきますから、さらに、 ctx: RequestContext このあたりを与えると、 Future[RouteResult] が得られるという関数の連続となっています。

そこで、 SABMessage を生成し、その内部で、innerにUnitを与えて得られた関数にさらにctxを与えてといった処理を実行し、そのメッセージ自体を、今回作成したアクターに送信し、得られたFutureの結果をそのまま返すという処理を組んでおります。

このことにより、Directiveとして、リクエスト・レスポンスの処理をフックして良きようにレスポンスの内容を判定してClosed状態、Open状態に応じた結果を返すDirectiveを実装しています。

使い方(担当:ふじい)

Akka HTTPに組み込む方法については、GitHubのREADME.mdの How to use に記載しております。

まとめ

社内で要望があった総当たり攻撃(brute-force-attack)に対応してみましたが、興味があればフィードバックをお願いします。今後、他の攻撃パターンに対応できるものがあれば検討していきたいと思います。