🌟

BurpSuiteとFridaの橋渡し「Brida」の紹介

2021/12/22に公開

この記事は、 DeNA 21新卒×22新卒内定者 Advent Calendar 2021 の15日目の投稿です。Brida という Burp Suite Extension の解説を Android アプリを実用的な例として挙げて行います。

Bridaとは

Brida is a Burp Suite Extension that, working as a bridge between Burp Suite and Frida, lets you use and manipulate applications’ own methods while tampering the traffic exchanged between the applications and their back-end services/servers. It supports all platforms supported by Frida (Windows, macOS, Linux, iOS, Android, and QNX).
Bridaより引用)

文字通り HTTP 通信の記録を行う Burp Suite の拡張で、アプリケーション層による独自暗号化等に対しても HTTP 通信に対応できるようにする、というものです。これにより、暗号化されているアプリケーションにおいても Burp Suite を使ってセキュリティテストを行うことが可能です。その際に Frida という動的解析・改ざんツールを用いることで、Burp Suite に追加情報を付与します。

この Frida を用いることで、暗号化・復号ロジックはアプリケーションに存在するものをそのまま呼び出すことが出来る というのは Brida の大きな特徴の 1 つだと思います。この特徴についても実際に感じ取れたら幸いです。

導入方法

以下のものが必要です。なお、この記事では v0.4 のものを導入していますが、執筆時点で GitHub 上にて v0.5 がリリースされております。そちらを使う場合、各ライブラリのバージョンに変更があるので、適切なバージョンでインストールを行ってください。

  • Burp Suite (1.X or 2.X)
    • 一度起動したら Extender > BApp Store > Search より「Brida」で検索して、Brida をインストールしましょう。
  • Frida client
$ pip install frida-tools
  • Pyro4 (Pyro5 には対応してなさそうです)
$ pip install Pyro4
  • frida-compile
    • この記事の執筆時点では、frida-compile 10 に Brida が追いついておらず、issue になっています。そのため、frida-compile 9 を利用します。
    • また、node のバージョンによっては動作しない可能性があります(関連Issue)今回は13.12.0を利用してます。
$ npm install -g frida-compile@9.5.2
  • Frida Serverが実行できる環境と対象のソフト
    • Android を例にすると、Root 権限を取ることで frida-server が動く、もしくはアプリに Frida Gadget を埋め込んでいる状態を指します。

検証環境の作成

サーバーは Go、クライアントは Kotlin を用いた Android Native で AES-GCM で暗号化された通信をする検証環境を作成しました。以下そのコード(クライアントは概要)です。ガバガバ環境ですが、ソースコードは GitHub にあります。

main.go
package main

import (
  "crypto/aes"
  "crypto/cipher"
  "crypto/rand"
  "encoding/base64"
  "fmt"
  "io"
  "os"
  "net/http"
  "github.com/gin-gonic/gin"
)


// cipher
var KEY = []byte("FLAG{5ecret_ke4}")

func encrypt(s string) (string, error) {
  c, err := aes.NewCipher(KEY)
  if err != nil {
    return "", err
  }

  gcm, err := cipher.NewGCM(c)
  if err != nil {
    return "", err
  }

  nonce := make([]byte, gcm.NonceSize())
  if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
    return "", err
  }

  out := gcm.Seal(nonce, nonce, []byte(s), nil)
  out_enc := base64.StdEncoding.EncodeToString(out)
  return out_enc, err
}

func decrypt(param_name string) ([]byte, error) {
  enc_name_byte, err := base64.StdEncoding.DecodeString(param_name)
  if err != nil {
    return []byte{}, err
  }

  c, err := aes.NewCipher(KEY)
  if err != nil {
    return []byte{}, err
  }

  gcm, err := cipher.NewGCM(c)
  if err != nil {
    return []byte{}, err
  }

  nonceSize := gcm.NonceSize()
  if len(enc_name_byte) < nonceSize {
    return []byte{}, fmt.Errorf("too short param")
  }

  nonce, cipherText := enc_name_byte[:nonceSize], enc_name_byte[nonceSize:]
  plain, err := gcm.Open(nil, nonce, cipherText, nil)
  if err != nil {
    return []byte{}, err
  }

  return plain, err
}

func main() {

  // web server
  r := gin.Default()

  r.POST("/greet", func(c *gin.Context) {
    param_name := c.PostForm("name")
    decrypted, err := decrypt(param_name)
    if err != nil {
      c.String(http.StatusBadRequest, err.Error())
      return
    }

    res := []byte(fmt.Sprintf("Hello, %s!", decrypted))

    enc, err := encrypt(string(res))
    if err != nil {
      c.String(http.StatusBadRequest, err.Error())
      return
    }

    c.String(http.StatusOK, enc)
  })

  r.GET("/", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello, World!")
  })

  port := os.Getenv("PORT")
  if port == "" {
    port = "8080"
  }

  if err := r.Run(":" + port); err != nil {
    panic(err)
  }
}

Go は Gin を用いて、POST /greetname パラメータにある内容が AES-GCM で暗号化されているという前提の元、復号・特定の文字列を追加して再度暗号化して返しています。

LoginDataSource.kt
package dev.fiord.encrypted_communication_sample.data

import android.util.Base64
import android.util.Log
import com.example.client.data.model.LoggedInUser
import com.example.client.io.AppNetwork
import java.io.IOException
import java.lang.Exception
import java.nio.charset.StandardCharsets
import java.util.*
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec

/**
 * Class that handles authentication w/ login credentials and retrieves user information.
 */
class LoginDataSource {
    private fun encrypt(username: String): String {
        try {
            val decodedKey = KEY.toByteArray(StandardCharsets.UTF_8)
            val key: SecretKey = SecretKeySpec(decodedKey, "AES")
            val cipher = Cipher.getInstance("AES_128/GCM/NoPadding")
            cipher.init(Cipher.ENCRYPT_MODE, key)
            val iv = cipher.iv.copyOf()
            if (iv.size != GCM_NONCE_SIZE) {
                Log.w("encrypt", "nonce size is different. expected: ${GCM_NONCE_SIZE}, actual: ${iv.size}")
            }
            val param = username.toByteArray(StandardCharsets.UTF_8)
            val cipherText = cipher.doFinal(param) // cipherText + tag
            if (cipherText.size != username.length + GCM_TAG_LENGTH) {
                Log.w("encrypt", "cipherText + TAG length is different. expected: ${username.length + GCM_TAG_LENGTH}, actual: ${cipherText.size}")
            }
            return Base64.encodeToString(iv + cipherText, Base64.NO_WRAP)
        } catch (e: Exception) {
            Log.e("encrypt", e.toString())
            throw e
        }
    }

    private fun decrypt(response: String): String {
        try {
            val decodedKey = KEY.toByteArray(StandardCharsets.UTF_8)
            val key: SecretKey =
                SecretKeySpec(decodedKey, "AES")
            val cipher = Cipher.getInstance("AES_128/GCM/NoPadding")
            val param: ByteArray =
                Base64.decode(response.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
            val iv = param.copyOfRange(0, GCM_NONCE_SIZE)
            val encrypted = param.copyOfRange(GCM_NONCE_SIZE, param.size)

            val spec = GCMParameterSpec(GCM_TAG_LENGTH * 8, iv)
            cipher.init(Cipher.DECRYPT_MODE, key, spec)

            val plainText = cipher.doFinal(encrypted)
            return String(plainText, Charsets.UTF_8)
        } catch (e: Exception) {
            Log.e("decrypt", e.toString())
            throw e
        }
    }

    fun login(username: String?): Result<LoggedInUser> {
        return try {
            var username = username ?: "test_user"
            val encrypted = encrypt(username)
            val response = AppNetwork.greetPost(encrypted)
            Log.v("login", "response: ${response}")
            val decrypted = decrypt(response)
            val fakeUser = LoggedInUser(
                UUID.randomUUID().toString(),
                decrypted
            )
            Result.success<LoggedInUser>(fakeUser)
        } catch (e: Exception) {
            Result.failure<LoggedInUser>(IOException("Error logging in", e))
        }
    }

    companion object {
        private const val AES_KEY_SIZE = 16
        private const val GCM_NONCE_SIZE = 12
        private const val GCM_TAG_LENGTH = 16
        private const val KEY = "FLAG{5ecret_ke4}"
    }
}
AppNetwork.kt
package com.example.client.io

import android.util.Log
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import java.lang.Exception

class AppNetwork {
     companion object {
         fun greetPost(encryptedName: String): String {
             try {
                 Log.d("Network", "send name: ${encryptedName}")

                 val client = OkHttpClient()
                 val req = Request.Builder().apply {
                     url("${SERVER_HOST}/greet")
                     post(FormBody.Builder()
                         .add("name", encryptedName)
                         .build())
                 }.build()

                 client.newCall(req).execute().use {
                     val str = it.body?.string()
                     Log.d("Network", "response: ${str}")
                     return str!!
                 }
             } catch (e: Exception) {
                 Log.e("AppNetwork", e.toString())
                 throw e
             }
         }
    }
}

一方、クライアントサイド(Android StudioのLogin Templateを流用してます)では入力で受け取ったフォームを AES-GCM で暗号化して POST /greet に送信、受け取った内容を復号して Toast に出力します。

検証環境での Brida の検証

環境の詳細

  • 実行する Android: Android Emulator(注意: x86_64 で検証しています)
    • Magisk によって Root 権限を持っており、MagiskFrida を内部で動かして常時 Frida Server が動いている状態にしています。
    • これを行わない場合、Frida Gadget を用いて Frida がアプリで起動するようにしておきましょう。
  • サーバーは heroku に上げておきます。
  • Android Emulator のホストは Frida をインストールしておきましょう(frida-tools のインストールで入っているはずです)

Brida 無しの場合

まず、Brida 抜きでどうなるかを確認します。Android からの通信が Burp Suite を経由するように Android、Burp Suite 両者の設定を行います。

  • Burp Suite: ホストで Burp Suite を起動し、 8080 番ポートで透過プロキシを許可した状態にします。
  • Android: Root 権限がある場合、iptablesが使えるので以下のコードを実行します。これにより、Android から HTTP/HTTPS 通信を行う際は Burp Suite を経由して通信が行われるようになります。
$ adb shell
$ su
# iptables -t nat -A OUTPUT -p tcp --destination-port 80 -j DNAT --to-destination 10.0.2.2:8080
# iptables -t nat -A OUTPUT -p tcp --destination-port 443 -j DNAT --to-destination 10.0.2.2:8080
# iptables -t nat -A POSTROUTING -j MASQUERADE

この際、あらかじめ Burp Suite の証明書をユーザーの証明書としてで構わないので Android にインストールしておいてください。

この状態でアプリを起動して、適当な値を入力して送信すると、以下の通信が取得できます。

通信が暗号化されており、内容が読み取れず、このままではセキュリティテストが行うことが出来ません。今回のケースでは鍵があれば Burp Extension で解読が可能ですが、例えば毎回鍵の内容が変わったり、難読化によって解読に苦しんでいるようなケースもあるかと思われます。

Brida 有りの場合

1. 設定

アプリを起動した状態で、Burp Suite の Brida タブから以下の設定を行います。以下 Windows 機での設定例です。

設定で分かりづらい点としては、

  • Pyro server を起動すると、Frida JS files folder で設定されたディレクトリに Frida Scripts が出力されます。
  • Frida JS files folder にてSelect folder(Frida JS filesのディレクトリ指定)とLoad default JS files(指定したディレクトリに Frida Scripts を置いてくれる)があるので、両方やること
  • Application ID(spawn)/PID(attach) は記載した内容によって以降 attach するか spawn するかが変わります。画像では spawn を前提としており、以降の解説でも spawn で解説を行います。

2. 暗号化関数を探す

この状態で「Start server」→「Compile & Spawn」を押していってアプリを起動します。上の画像にて「Server status」「Application status」がともに running(緑文字)であれば成功です。

この状態で「Graphical analysis」へ移動し、右側のメニューから「Load Tree」を選択すると以下のようになります。

メソッド一覧ですね。今回は Kotlin から生成しているので、Java からバイナリが読み取れそうですが、Unity製などの場合は Modules に xxx.so が並んでいるので、そちらから読み取れそうです。ここから、encrypt/decryptをしている関数を探し出します。

今回は以下の関数が関係ありそうです。気になったら実際に Frida で hook、実行時に引数と返り値でも返してみると「実際に使われているか」「実際に使われている場合、どのような引数・返り値が実際に出るか」を知ることができます。

  • java.lang.String dev.fiord.encrypted_communication_sample.data.LoginDataSource.decrypt(java.lang.String)
  • java.lang.String dev.fiord.encrypted_communication_sample.data.LoginDataSource.encrypt(java.lang.String)

3. 暗号化・復号関数を使う rpc.export を作成

この後作る Burp Extension からは直接先ほど調べた暗号化関数を呼び出すことが出来ません。そのため、 brida.js 上でこれらの関数を呼び出す関数を作成する必要があります。

brida.js
 encryptrequest: function(name) {
  return new Promise(function(resolve, reject) {
    Java.perform(function() {
      try {
        var cls = Java.use("dev.fiord.encrypted_communication_sample.data.LoginDataSource");
        var instance = cls.$new();
        var res = instance.encrypt(name).toString();
        console.log(`encrypt: ${name} -> ${res}`);
        resolve(res);
      } catch(e) {
        reject(e);
      }
    });
  });
},
decryptrequest: function(name) {
  return new Promise(function(resolve, reject) {
    Java.perform(function() {
      try {
        var cls = Java.use("dev.fiord.encrypted_communication_sample.data.LoginDataSource");
        var instance = cls.$new();
        var uriDecoded = decodeURIComponent(name);
        var res = instance.decrypt(uriDecoded).toString();
        console.log(`decrypt: ${name} -> ${res}`);
        resolve(res);
      } catch(e) {
        reject(e);
      }
    });
  });
}

シンプルに関数を呼び出して、返り値を返していますが、実際には Promise を返しています。これは Frida の仕様で Java.perform の実行が必要ですが、これは asynchronous かつ返り値が return で決められないため、このような書き方になっています。

4. Burp Extension を作成

対象の関数が分かったら、Brida の「Generate stubs」へ移動し、右側のメニューから「Java Stub」を選択することで Pyro server と通信ができる Burp Extension の stub が出力されます。これを元に Burp Extension を作成する形です。が、簡単なものであれば実はそんなもの作る必要は無かったりします。

Bida の「Custom plugins」へ移動すると、簡易的な Encode/Deocde ができる Burp Extension を自動的に作成できる画面が表示されます。こちらを利用するとノーコードで Burp Extension を作ることが出来ます(正規表現とか普通に使うので、ノーコードではないと思いますが...)

Brida の Custom plugins でそれぞれ以下の設定をします。

  • Repeater 等 Burp Suite から送るリクエストを暗号化する plugin
  • Repeater 等 Burp Suite から送ったリクエストに対するレスポンスの復号をする plugin
  • アプリから送信したリクエストの復号をする plugin(Proxy にて使用)
  • アプリから送信したリクエストに対するレスポンスの復号をする plugin

こうすることで、以下のような恩恵が得られます。

  • Repeater 等では Burp 上でパラメータ内部が平文のものとして扱えるようになる。Burp にとって name=fiord として送信したデータが、plugin によって暗号化された状態で通信を行い、レスポンスも復号して可視化してくれる。
  • Proxyでは暗号化されていることを把握しつつ、能動的に中身を見たい通信は復号できる。

こうすることで、通信の内容を把握した上で Active Scan にかけることも出来ますし、payload を用意して Intruder でまとめてリクエストを投げることも出来ます。

今回は簡易的に扱える、ということで Custom plugins を取り扱ってみましたが、Burp Extension でも等価のものを作ることが可能です。また、例えば通信の度に鍵を変更するようなクライアント・サーバー等では(鍵変更のタイミング等にも依存しますが) Custom plugins のみでは難しいと思われますので、そのようなケースに対しては Burp Extension の柔軟性を活用することになりそうです。

まとめ

Brida は Burp Suite で手が届きにくい「独自暗号化された通信」に対して Reverse Engineering の手間を省きつつ、Frida の強みを使って Burp Suite で扱える形式に簡単に変えることができるモジュールでした。まだ発展途上(最新版がv0.5)ということもあり、困ることもあるかと思いますが、ぜひ使ってみてください!

また、DeNA 公式 Twitter アカウント @DeNAxTech では、Blog 記事だけでなく色々な勉強会での登壇資料も発信しているのでぜひフォローして下さい。

参考

Discussion