Flatt Security Blog

株式会社Flatt Securityの公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

株式会社Flatt Securityの公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

セキュリティ企業における開発とドッグフーディング - gRPC-web採用プロダクトの脆弱性診断を効率的に行えるようになるまで

こんにちは、Flatt Securityでインターンをしている@smallkirbyです1

皆さんは、「ドッグフーディング」という言葉をご存知でしょうか。開発周りでは、書いたコードを開発者側で積極的に利用し、生成されたフィードバックをまた開発に投入していくフローのことを指します。

先日のブログでは、Flatt Securityの脆弱性診断において利用されているORCAsというプラットフォームについて紹介しました。ORCAsは、この世の全てが古のスプレッドシートで管理されていた旧石器の時代を一気に文明開化まで押し上げ、Flatt Securityの脆弱性診断業務を圧倒的に効率化した事で今やFlatt Security内の必需品となっています。

ORCAsはブログ著者の@Sz4rnyさんが中心となって従来の不便を解消するために立ち上げられ、今や総コミット数5000に達しようとする中規模プロジェクトになってきました。Flatt SecurityはTECHを扱う企業である以上、ORCAsの他にも社内の多くの人が、日々の業務を改善するために大小問わず自発的にツールを書き上げています。まさにドッグフーディングですね。

本ブログは、診断上で遭遇したgRPCに関わる不便と、それに対するドッグフーディング・チックな解決方法についてのお話です。堅苦しい話や他のブログみたいに頭の良い内容ではないですが、あまりブログで書かれていないFlatt Securityの脆弱性診断における裏側ゆるふわ日記的にお楽しみください。

本ブログは10月11日に公開しましたが、翌日10月12日にはセキュリティエンジニア向け採用説明会も実施されるそうなので、この記事で興味を持った方はぜひ参加してみてください。

gRPCとの邂逅、そして閉口

ある時、オフィス内から悲鳴が聞こえてきました。駆けつけて理由を聞いてみると、通信内容がバイナリで書かれていて何が書いてあるのか分からないとのことでした(やばいなり)。

通信内容がバイナリ中心になっている

なるほど、確かにいくつかStringこそ見えるものの通信の大枠がバイナリになっています。このままでは通信内容が理解できないし、任意のリクエストを飛ばすことも難しいので不便そうです。

この通信で用いられていたのは、gRPCというRPCプロトコル2です(厳密には、WebブラウザのAPI制限上、ブラウザで使えるgRPC仕様はgRPC-webといいます)。

gRPCはスキーマファーストのプロトコルであり、Protocol Buffersと呼ばれるバイナリ形式でデータを送受信します3。開発者はスキーマファイルから自動的に各言語用に生成されるブロブコードを利用してサーバ・クライアント側のコードを書きます。通信する際には決められたルールでデータがエンコードされ、受信側は共通で保持しているスキーマ情報と照らし合わせてデータをデコードします。

これは、裏を返すと通信の間に立っている人間からはデータがエンコードされた状態しか見れないということを意味します。上の画像で見たようなバイナリデータが見えてしまうということですね。これは、通信の両エンドポイントにいるサーバ・クライアントからしてみれば問題ありませんが、診断する側からするととても困ったことです。

試しにFlatt Security社内のエンジニアに、gRPCの通信データに遭遇した時どうするかというアンケートをとってみようと思いました:

gRPCの通信を読む時には...?

なるほど。さて、Flatt SecurityではWebアプリケーションの診断時にローカルプロキシツールとしてBurpSuite を利用することが多いです4

BurpではExtenderと呼ばれる拡張機能を書くことが可能となっています。Flatt Security社内では、脆弱性診断をより便利で効率よくするために、このExtender自体の管理フレームワーク・ライブラリを開発し、種々の自作Extenderを登録して利用しています5。ちなみに、このフレームワークは100%Kotlinで書かれています。

それでは、ExtenderとしてBurpでgRPC(-web)を扱えるようにしてみましょう。

Protocol Buffers binary format basics

まずは、gRPCで利用されているデータ形式であるProtocol Buffersのデータフォーマットを知る必要があります。簡単な例で見ていきましょう。

以下のような.protoファイルがあったとします:

message Date {
  uint32 seconds = 1;
  uint64 nanos = 2;
}

この時、送信されるデータは例えば以下のようになります:

08 04 10 EF FD 02

まず、Protocol Buffersのデータはストリームと呼ばれるデータを最小単位としています。ストリームは、各バイトのMSB(一番左のbit)が0であるようなバイトで区切られます。例えば上のデータは以下のような4つのストリームに区切られます:

(0)0001000
(0)0000100
(0)0010000
(1)1101111, (1)1111101, (0)0000010

続いて、Protocol Buffersはkeyvalueの繰り返しとなっています。ここで、keyTag IDWire Typeから成っています。Tag IDとは、メッセージにおけるフィールドIDを、Wire Typeはデータの型を表しています。

但し、Protocol Buffersにおいて.protoファイルで利用される型(ここではConcrete Typeと呼びます)と実際に通信時にエンコードされる型(Wire Type)は異なり、Concrete TypeWire Typeの部分集合となっています。

WireTypeとConcreteTypeの関係性
Cite: Google, Protocol Buffers Encoding

keyストリームの中で、LSBの3bitがWire Typeを表しており、残りのビットがTag IDを表しています。よって、上のストリーム群は以下のような意味を持つことになります:

(0)0001000 : key = 0001 + 000 = Tag(1) + Wire(0=Variant)
(0)0000100 : value
(0)0010000 : key = 0010 + 000 = Tag(2) + Wire(0=Variant)
(1)1101111, (1)1111101, (0)0000010 : value

さて、今回はvalueの型がVariantになっています。これは定義したフィールドのuint32/uint64に対応する型であり、ストリームが続く限りの可変長の変数値を持つことができます。

例えば4を送るときには1byteで済むし、0xBEEFを送るときには3byte(各バイトのMSBはストリームの終端を示すのに使うため、2byte値を送信するのに3byte必要です)を使うことになるので、固定長よりもバイト数を削減することができます。Variant型はlittle endianなので、7bitずつを逆順でつないであげればよく、上のメッセージは以下のような2つの変数からなるものだと解釈できました:

(0)0001000 : key = 0001 + 000 = Tag(1) + Wire(0=Variant)
(0)0000100 : value = 4
(0)0010000 : key = 0010 + 000 = Tag(2) + Wire(0=Variant)
(1)1101111, (1)1111101, (0)0000010 : value = b1011 1110 1110 1111 = 0xBEEF

他の型(64-bit, Length-delimited, 32-bit)はまた異なるデコード方法が必要になります。他の例を見たい方は、Googleのページをご覧ください。

blackboxでの型解釈

Protocol Buffersのメッセージ自体は上記のようにストリームの連続であり、各Wire Typeに対応するようなデコードをしてやればいいことになります。最終的にKotlinが扱える型に変換することができれば、Burpの各標準機能やExtenderで利用可能な形でプロキシできます。

但し通信を間で見る診断者からすれば、厳密な型情報(.proto)は知りえません。よって、Wire Typeから型情報を解釈してKotlinが扱える型に変換してやる必要があります。例えば、中間者には飛んできたVariant型がuint32なのかsint64なのかがわかりません。WireTypeLengthDelimitedだった場合、文字列なのかネストされたメッセージなのかすらわかりません。

なんと中間者に優しくないプロトコルなんでしょうか。以下のメッセージを考えてみると、大きく2通りの解釈方法があることがわかります:

0A 08 [08 40 10 42 20 43 40 44]
↓
(0)0001010 : key = 0001 + 010 = Tag(1) + Wire(2=LengthDelimited)
(0)0001000 : value = 8 (field length)
[
  (0)0001000 : key = 0001 + 000 = Tag(1) + Wire(0=Variant)
  (0)1000000 : value = 0x40
  (0)0010000 : key = 0010 + 000 = Tag(2) + Wire(0=Variant)
  (0)1000010 : value = 0x42
  (0)0100000 : key = 0100 + 000 = Tag(4) + Wire(0=Variant)
  (0)1000100 : value = 0x44
]
or
[
  08 40 10 42 20 43 40 44 : string("\b@\nB C@D")
]

つまり、以下の2通りの型の可能性があります:

message OuterNest {
  message InnerNest {
    uint64 value1 = 1;
    uint64 value2 = 2;
    repeated Nested some = 3; // non-packed repeated型は要素数0の時ストリーム中に現れません
    uint64 value3 = 4;
  }
  InnerNest inner = 1;
}

or

message JustString {
  string content = 1;
}

通信中のバイナリデータのみからは、上のどちらの型なのかを判断することはできません。また、さらに言うとJustStringメッセージ型はstringではなく単なるバイト列(bytes型)を保持している可能性もあります6。それらを見分けることもできません。

それではモジュール中でどう対処したかと言うと、アドホックにWireTypeに対応するConcreteTypeを決め打ちしています。

WireTypeからConcreteTypeへの変換規則

だいぶ荒っぽいデコード方法のため、Variant型として負数が飛んできたときにも正数として解釈してしまいますが、これがblackboxの限界です。

ただ、LengthDelimited型の場合に全てbytesとして解釈してしまうととんでもないことになるため、ある程度デコードの優先順位を決めています。これは、LengthDelimitedConcreteTypeであるstring/embedded/packed repeated型にはデコード可能な場合と不可能な場合が存在し、ある程度は見分けることができるから可能なことです。

型情報を保ったJSON形式の提供

さて、このようにアドホックにストリームをパースしていくことで、生のProtocol BuffersデータをKotlinプログラム中で扱うことのできるIR(中間形式)データとして解釈することができました。続いて、このIRを診断員が見やすいように提供し、且つリクエストを編集して値を書き換えることができるようにする必要があります。

今回はユーザ(= 診断員)に提供する表現形式としてJSONを選択しました。

IR->JSONに変換する際には、ユーザに提供するJSONはIRが保持する型情報を全て保持したまま変換されなければならないという大原則があります。そうでなければ、編集されたJSONデータを再びIRやProtocol Buffersにエンコードする際に、フィールドの型が分からなくなってしまいます。型情報の全てとは、tag ID,・Concrete Type実際の値の3つを指します。

そこで、今回は以下のような独自のJSONフォーマットを定義しました:

{
  "tagID: Concrete-Type": "Value",
  ...
}

JSONのkeyとしてtag IDConcrete Typeを詰め込んでしまうことで、IR->JSON / JSON->IRの両方の場合に完全な変数の情報を保ったまま変換が可能になります。以降は、この形式のIRと等価な情報量を持つJSON形式をIR-JSONと呼びます7

さて、これでProtocol BuffersのデータをIRに変換し、さらにユーザの見やすいJSON形式にして提供することができるようになりました。画像からもわかるように、リクエスト/レスポンスの両方でメッセージがデコードされ、それなりに読みやすい形式で提供されています。勿論データの値を変更したり、さらにはIR-JSON中のConcreteTypeを編集することでフィールドの型を変更することも可能です。

ProtocolBuffersをIR-JSON形式で表示

whiteboxでの型復元

Wireコンパイラ

さて、上の例ではデコードされたメッセージは以下のように提供されていました:

{
  "1 : embedded/repeated" : [ {
    "1 : string" : "Dragon",
    "2 : uint64" : "999",
    "3 : string" : "uouo fish life"
  }, {
    "1 : string" : "Dog",
    "2 : uint64" : "1024",
    "3 : string" : "uouo fish life"
  } ],
  "2 : uint64" : "777"
}

これがblackboxの限界とは言え、フィールドのkeyが単なる数字(tagID)だとやっぱり心もとないですよね。TagID = 1のフィールドが、本のタイトルなのか、好きな動物なのか、それともUIDなのか全くわかりません。

そこで、次は.protoファイルが与えられている場合のデコード方法を考えてみます。

.protoファイルが与えられている時、それらから型情報を取得してデコード時に利用するにはいくつかの方法が考えられます。しかし今回はかなり荒いアプローチを採用しました。Reflectionをゴリゴリに使います(ゴリゴリリフレクションプログラミング、通称ゴリミング)。

まず、.protoファイルをKotlinへとコンパイルします。今回はコンパイラとしてWireを採用しました8。以下のようなサービス型を考えます:

package echoback;
message EchobackNestedRequest {
  message BookInfo {
    string title = 1;
    uint32 price = 2;
    string description = 3;
  }
  repeated BookInfo books = 1;
  uint32 total_price = 2;
}
service Echoback {
  rpc EchobackNest (EchobackNestedRequest) returns (SimpleEchobackResponse) {}
}

この時、Wireコンパイラは次のようなブロブファイルを生成します:

package echoback
public class EchobackNestedRequest(  
  books: List<BookInfo> = emptyList(),  
  @field:WireField( // アノテートされたフィールド
    tag = 2,  
    adapter = "com.squareup.wire.ProtoAdapter#UINT32",  
    label = WireField.Label.OMIT_IDENTITY,  
    jsonName = "totalPrice"  
  )  
  public val total_price: Int = 0,  
) : Message<EchobackNestedRequest, Nothing>(ADAPTER, uinknownFields) {  
  @field:WireField( // アノテートされたフィールド
    tag = 1,  
    adapter = "echoback.EchobackNestedRequest${'$'}BookInfo#ADAPTER",  
    label = WireField.Label.REPEATED  
  )  
  public val books: List<BookInfo> = immutableCopyOf("books", books)
  (snipped...)
  
  public class BookInfo(  
    @field:WireField(  // アノテートされたフィールド
      tag = 1,  
      adapter = "com.squareup.wire.ProtoAdapter#STRING",  
      label = WireField.Label.OMIT_IDENTITY  
    )  
    public val title: String = "",  
    @field:WireField(   // アノテートされたフィールド
      tag = 2,  
      adapter = "com.squareup.wire.ProtoAdapter#UINT32",  
      label = WireField.Label.OMIT_IDENTITY  
    )  
    public val price: Int = 0,  
    @field:WireField(   // アノテートされたフィールド
      tag = 3,  
      adapter = "com.squareup.wire.ProtoAdapter#STRING",  
      label = WireField.Label.OMIT_IDENTITY  
    )  
    public val description: String = "",  
  ) : Message<BookInfo, Nothing>(ADAPTER, unknownFields) { 
    (snipped...)
  }
}

色々とごちゃごちゃしているように見えますが、大事なことは@field:WireFieldでアノテートされているプロパティ達です。

例えば.proto中でEchobackNestedRequestとして定義されているメッセージ型は3つのフィールド(title, price, description)を持っていますが、Kotlinファイル中のEchobackNestedRequestクラスもそれに対応するだけの@fieldアノテーションされたプロパティを保持しています。これを使って、型情報を復元することができそうです。

実際の型復元フェーズ

以下のようなメッセージが飛んできたとします:

POST /echoback.Echoback/EchobackNest HTTP/1.1
Host: localhost:8080
Accept: */*
Content-Type: application/grpc-web+proto
Content-Length: 101

<raw gRPC binary data>

まずURLに注目すると/<package name>.<service name>/<RPC name>という形式になっていることがわかります。Wireコンパイラが生成するechoback.GrpcEchobackClientクラスは以下のようになっています:

package echoback

public class GrpcEchobackClient(  
  private val client: GrpcClient  
  public override fun EchobackNest(): GrpcCall<EchobackNestedRequest, SimpleEchobackResponse> =  
      client.newCall(GrpcMethod(  
      path = "/echoback.Echoback/EchobackNest",  
      requestAdapter = EchobackNestedRequest.ADAPTER,  
      responseAdapter = SimpleEchobackResponse.ADAPTER  
  ))  
}

関数の返り値(GrpcCall<EchobackNestedRequest, SimpleEchobackResponse>)の型パラメタがそれぞれEchobackNestサービスのリクエスト・レスポンスのメッセージ型になっています。そこで、まずはGrpcEchobackClientクラスをロードして該当関数の返り値の型からメッセージ型を復元します:

fun getTypes(servicePath: String, rpcName: String): GrpcWhiteService? {  
    val serviceClass = try {  
        Class.forName("${servicePath}Client") // サービスクラスをロード
    } catch (e: Exception) {  
        return null  
    }  
    val rpcMethod = serviceClass.methods.find { it.name == rpcName }!! // 探したいRPCに対応する関数を見つける 
  
    val retType = rpcMethod.genericReturnType // `返り値のGrpcCall<*,*>タイプを取得` 
    val typeName = retType.typeName  
    val retTypeInfo = Regex("com.squareup.wire.GrpcCall<(.*), (.*)>").matchEntire(typeName)
    val requestType = retTypeInfo.groupValues[1] // 型パラメタからリクエストのメッセージクラス取得 
    val responseType = retTypeInfo.groupValues[2] // 型パラメタからレスポンスのメッセージクラス取得 
  
    return GrpcWhiteService( // 再帰的に型情報を解決
        request = parseServiceRecursive(requestType, listOf(requestType)),  
        response = parseServiceRecursive(responseType, listOf(responseType)),  
    )  
}

Regexで型パラメタを取得するという荒業に出ていますが、動いているのでOKです🐶。これでリクエストのメッセージ型がechoback.EchobackNestRequestであることがわかりました。

続いて、EchobackNestRequestから@fieldアノテートされたプロパティ一覧を取得します:

private fun parseServiceRecursive(resourceClassName: String, stackedParentClassNames: List<String>): GrpcWhiteTypeInfo {  
    val resourceClass = Class.forName(resourceClassName)!!
    val fields = resourceClass.declaredFields
  
    // Retrieve the type information for each fields  
    val typeInfo = fields.map { field ->  
        val wireAnnotation = field.declaredAnnotations.find { annotation ->  
            annotation is WireField  
        }!! as WireField
  
        // Retrieve information from the Wire annotation  
        val packed = wireAnnotation.label == WireField.Label.PACKED  
        val repeated = wireAnnotation.label == WireField.Label.REPEATED || wireAnnotation.label == WireField.Label.PACKED  
        (snipped...)
        GrpcWhiteField( // アノテーションから読み取った情報
            tag = wireAnnotation.tag.toULong(),  
            name = field.name,  
            (snipped...)
        )  
    }.associateBy { it.tag }.toMutableMap()  
  
    // Cache type information  
    cachedTypes[resourceClassName] = typeInfo  
  
    return typeInfo  
}

@fieldアノテーションさえ取得してしまえば、この中に知りたいフィールド情報が全て入っています(ConcreteType, TagID, repeated, packed)。あとはこれをgRPCデコーダにgRPCメッセージと一緒に渡してやることで、デコード時にフィールド情報を補完してやることができます。

これで先程のgRPCメッセージは以下のように復元されるようになりました:

フィールド名が表示され可読性が向上

{
  "books : embedded/repeated" : [ {
    "title : string" : "Dragon",
    "price : uint64" : "999",
    "description : string" : "uouo fish life"
  }, {
    "title : string" : "Dog",
    "price : uint64" : "1024",
    "description : string" : "uouo fish life"
  } ],
  "total_price : uint64" : "777"
}

フィールド名が復元されるようになり、かなり読みやすくなりましたね!

循環を持つ型

ネストされたメッセージを持つ型

さて、先程お見せしたメッセージ型はフィールドとしてネストされたメッセージを保持していました:

message EchobackNestedRequest {
  message BookInfo {
    string title = 1;
    uint32 price = 2;
    string description = 3;
  }
  repeated BookInfo books = 1;
  uint32 total_price = 2;
}

この時、生成されるKotlinクラス内のフィールドは以下のようになっていました:

public class EchobackNestedRequest(  
  books: List<BookInfo> = emptyList(),  
  (snipped...)
) : Message<EchobackNestedRequest, Nothing>(ADAPTER, unknownFields) {  
  @field:WireField(...)  
  public val books: List<BookInfo> = immutableCopyOf("books", books)
  
  public class BookInfo(  
    @field:WireField(...)  
    public val title: String = "",
    (snipped...)
  ) : Message<BookInfo, Nothing>(ADAPTER, unknownFields) { (snipped...) }
}

booksプロパティとして、List<BookInfo>型の変数を保持しています。

このようなネストされたメッセージの型を復元するために、今回は「プリミティブ型9にたどり着くまで再帰的に型を復元していく」という方針を取っています。具体的には、以下の流れです:

  • echoback.EchobackNestedRequest型を探す
    • プロパティのbooksの型がList<BookInfo>であるため、BookInfo型を探す
      • プロパティのtitleの型がStringであるため、復元終了

そのため、最終的にデコーダに渡される型情報にはプリミティブ型のみが含まれていることになります。

Protocol Buffersにおける型の循環

上記のような型の復元方法には、少し問題があります。以下のようなメッセージ型を考えてみましょう。

message EchobackCircularRequest {
  message Content {
    repeated Content inner_contents = 1;
  }
  Content content = 1;
}

Content型が内部にContent型を含み、いわゆる型の循環が発生しています。このような循環はProtocol Buffersに限らず多くのプログラミング言語でありえます。

勿論型Aが型Aのフィールドとして直接保持されるような場合には型を解決しないため型としてinvalidですが、型A内にコンテナ型(リスト等)として保持されている場合にはvalidな型となります。

生成されるKotlinクラスも、当然のように循環する型を持っています:

public class EchobackCircularRequest(  
  @field:WireField(...)
  public val content: Content? = null,  
) : Message<EchobackCircularRequest, Nothing>(ADAPTER, unknownFields) {
  public class Content(  
    inner_contents: List<Content> = emptyList(), // 循環!!
  ) : Message<Content, Nothing>(ADAPTER, unknownFields) {  
    @field:WireField(...)  
    public val inner_contents: List<Content> = immutableCopyOf("inner_contents", inner_contents)
}

このような型を上記のような方法で復元しようとすると、以下のように無限ループが発生してしまいます:

  • echoback.EchobackCircularRequest型を探す
    • プロパティのcontentの型がList<Content>であるため、Content型を探す
      • プロパティのinner_contentsの型がList<Content>であるため、Content型を探す
        • プロパティのinner_contentsの型がList<content>であるため、Content型を探す
          • (以下無限ループ...)

時間とスタック用のメモリが無限にある場合にはまぁこれでも問題ないので直すかどうか迷ったのですが、時間はともかく支給されているPCのメモリが有限であることに気づいたため無限ループが発生すると困ってしまいます。

そこで、今回は型を遅延的に解決することにしました10

型の再帰的解決を行う関数(parseServiceRecursive())に対して、「これまでに解決対象となったことのある型のクラス名」リストを渡します。この関数内部でさらに再帰的に他の型の解決を行う際には、渡されたクラス名に自身のクラス名を足したリストをさらに引数とします。最終的に、解決対象の型が既にこのリストの中に見つかった場合には、そこで型の解決を中断します:

  • echoback.EchobackCircularRequest型を探す(stack = [EchobackCircularRequest])
    • プロパティのcontentの型がList<Content>であるため、Content型を探す(stack = [EchobackCircularRequest, Content])
      • プロパティのinner_contentsの型がList<Content>であるため、Content型を探そうとする。しかしstack内に既にContentがあるため解決を中止。

これで無限ループこそ起きませんが、型の解決ができていません。そこで、「解決された型情報を表す型」というのを、以下のようなUnion型として表現することにします:

sealed class ChildTypeInfo {  // 型の解決結果を保持するクラス
  // 最後まで解決された型
  data class NormalTypeInfo(val info: MutableMap<ULong, GrpcWhiteField>) : ChildTypeInfo()  
  // 循環が検知され解決が中断された型。lazyに解決される必要がある
  data class CircularTypeInfo(val className: String) : ChildTypeInfo()  
}
data class GrpcWhiteField(  // 最終的に解決された型情報
  val concreteType: ConcreteType,  // 型の具体的な情報
  (snipped...) // 型の具体的な情報
  val child: ChildTypeInfo?, // 再帰的に解決された型情報
)

NormalTypeInfoクラスは最後まで解決された型情報であるGrpcWhiteFieldをラップするため、デコーダはこれを直接利用してデータに型情報を付与することができます。デコーダはCircularTypeInfoを受け取ったときはデコード対象の型が循環していることを検知し、その型に出会うたびに型情報のキャッシュに対して該当型の型情報を問い合わせます:

{
  "contents : embedded": [{ // 子供の型の問い合わせ -> Content型
    "contents : embedded": [{ // 子供の型の問い合わせ -> Content型
      "contents : embedded": [] // 要素数0だから型情報は必要なし
    }]
  }]
}

上の例の場合、デコーダに渡されるcontentsフィールドの型がCircularTypeInfoであるため、CircularTypeInfo型をキャッシュに問い合わせ、再びCircularTypeInfo型を得ます。

これを繰り返すごとにネストの内側へと進んでいき、最終的に長さ0の配列に遭遇することになります。この時にはもう型解決対象の変数が存在しないため、型を問い合わせる必要がなくなりループが終了します(これが循環される型がコンテナ型において許容される理由です)。

well-known types

さて、これでwhitebox診断においても型情報を復元することができ、例え型が循環していたとしても有限時間内に型を解決することができるようになりました。

しかし、まだこのデコードには問題があります。

ここまで、型の解決はプリミティブ型になるまで再帰的に行うと書きましたが、実はプリミティブな型にならないメッセージ型というのが存在します。それがGoogleが提供するwell-known typesです。.protoファイルにおいて、import "google/protobuf/empty.proto";のようにimportして利用する型のことですね。

例えばTimeStamp型は実際は以下のように定義されています:

message TimeStamp {
  int64 seconds = 1;
  int32 nanos = 2;
}

しかし、Wireコンパイラはこの型をjava.time.Instantとして使ってしまいます。どうやら、well-known typesのような非primitive型は各言語の対応する型へと対応されるようです(Kotlinの場合、google.protobuf.EmptyUnitに、google.protobuf.TimeStampInstantに)11

そこで本モジュールではこのような非プリミティブな型は特例と見做し、ひとつひとつ例外ケースを書くようにしました。かなりアドホックで雑な解決方法ですが、well-known typesは種類がそこまで多いわけではないためこれで十分動作しています🐶:

when (resourceClass) {  
    Unit::class.java -> return mutableMapOf()  
    Instant::class.java -> return mutableMapOf(  
        1uL to GrpcWhiteField(  
            tag = 1uL,  
            concreteType = ConcreteType.Ulong64,  
            repeated = false,  
            packed = false,  
            name = "seconds",  
            child = null,  
        ),  
        2uL to GrpcWhiteField(  
            tag = 2uL,  
            concreteType = ConcreteType.Uint32,  
            repeated = false,  
            packed = false,  
            name = "nanos",  
            child = null,  
        ),  
    )
    ... -> ...
}

Burp機能とのIntegration

Intruder対応

ここまでで、blackbox/whiteboxの両方においてgRPCメッセージを復元できるようになりました。

ところで、BurpにはIntruderと呼ばれる、リクエスト中のパラメタを一定の規則のもとで変化させる機能があります。これは、ペイロードとして使える文字種を調べたり、はたまたテストデータを大量に作成したい場合にとても便利な機能です。

普通のHTTPリクエストにおいては、ボディがJSONの場合にJSONのvalueの部分を自動的に検知して値を挿入してくれるのですが、gRPC/Protocol Buffersのようなバイナリフォーマットだと値の挿入やエンコーディングが難しくなります:

バイナリ形式ではペイロードの挿入位置が分からない

そこで、Intruderに対して対象のリクエストを送る処理をExtenderでフックし、リクエストをExtenderが解釈できるIR-JSON形式に書き換えてからIntruderに送るようにします。

すると、以下の画像のようにIntruderがIR-JSONのJSON value部分を勝手に書き換え対象として検知してくれるようになり、パラメタを自由に変化させて送ることができるようになります。

IR-JSON形式でIntruderに渡すことで挿入位置が明確に

この状態のリクエストは当然Protocul Bufferとしては不正な状態です。そこで、Intruderにリクエストを送るフックの中でX-Flatt-BurpEx: gRPC-Intruder-irprotoというヘッダを追加します。

すると、Intruderがパラメタを書き換えて実際にサーバに送信する際にExtenderがこのヘッダの存在を検知し、IR-JSON形式のデータをgRPC/Protocol Buffersの形式に再度エンコードできるようになります。

Scanner対応

また、BurpにはIntruderの他にScannerという機能があります。

これはリクエスト中のパラメタに対して予め用意されたペイロードを仕込むことで、簡単なXSSやInjection系の脆弱性を勝手に検知してくれる便利な機能です。もちろん権限管理や条件が複雑に絡んだような脆弱性は人間が見なくてはいけないのですが、Scannerを使うことで人様がわざわざ一つ一つ確認するほどでもないような脆弱性にまで気を使わなくても良いようになり、時間と精神衛生の確保のために非常に重宝されます。

こちらもIntruderと同様、リクエスト中のどの部分に対してペイロードを注入するかを決定する必要があり、そのままではバイナリ形式であるProtocol Buffersリクエストに対してスキャンをかけることはできません。

やはりIntruderの場合と同様に、Scannerに対してリクエストを送る処理をフックしてリクエストボディをIR-JSONに置き換えることで、適切な位置にペイロードを挿入させることができるようになっています:

valueとしてScannerペイロードが挿入されている

課題

gRPCと言いつつProtocol Buffersの話がメインになってしまいました。それもそのはずで、gRPCの部分に関してはデコードもエンコードも特に難しいことがなく、実装量はごくわずかになっています。よって、書くことがありませんでした。

本Extenderは、現在のところUnary RPCのみに対応しています。というよりは、gRPC-web自体が現在のところclient-side streamingbi-directional streamingに対応していません。ブラウザがこれらの通信をサポートした場合には、本Extenderにも対応する処理を実装する必要があります。

本来のgRPCがブラウザで利用できないのはブラウザのAPI制限によるものですが、APIの仕様で機能が制限されるというのはBurpにおいても同じことです。本ExtenderはgRPC-webを扱えるようにしましたが、gRPCを扱うことができません。これは、BurpSuiteが現時点でHTTP2メッセージの処理に関する十分なAPIをExtenderに向けて提供しておらず、HTTP2の機能をフル活用するgRPCをデコードすることができないからです12

PacketProxyの ようにゼロベースで開発すればAPIによる制限に悩まされることはないのでしょうが、工数と得られる利点のトレードオフが現状難しいところです。

最後に、本ブログを読んだ人の中には「これNCC Group Plcblackboxprotobufで良くない?」と思った方もいるのではないかと思います。実はblackboxprotobufの存在に気づいたのが、ブログを書いている最中でした。やはり事前のリサーチというのは大事ですね。

但し、言い訳をすると本Extenderはblackboxprotobufにはない機能も有しています。例えば最後に紹介したIntruder/Extender対応がその最たるものです。また、ネストされた型や循環する型の復元ができることも挙げられます13。何より、バグフィックスやフィーチャーリクエストに対して迅速に対応できるという最大のメリットがあります。以上、言い訳でした。

アウトロ

さて、本ブログではFlatt Securityの脆弱性診断において遭遇する不便に対してコードを以てドッグフーディング🐶14的に対処する様子ををお見せしました。紹介したExtenderは、現在実際の診断でも利用しており、さらに改良を加えている最中です。もしかするとオープンソースとして公開する日が来るかもしれませんね。来ないかもしれませんね。

本ブログで、Flatt Securityの日常の雰囲気がお伝えできていれば幸いです。

Flatt Securityに少しでも興味を持ってしまった方はぜひ下のバナーより会社説明会にご参加ください。

また、脆弱性診断の実施に興味を持った方はサービス詳細ページや、下のバナーから料金資料をダウンロードしてみてください。

また、Flatt Securityはセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式Twitterのフォローをぜひお願いします!

twitter.com

ここまでお読みいただきありがとうございました。


  1. 最近他のインターンにお世話になり、そこでgRPCについて講義していただきましたが、本ブログは該当インターンの前に執筆を終えており、一切関係がないことをここに断っておきます。(参考Tweet: https://twitter.com/toyojuni/status/1560617016083357696)
  2. gRPCの"g"は、Googleの"g"ではありません。バージョン1.46では"golazo"を意味しています
  3. 実際には、通信時にはJSON形式で通信することも可能です。また、gRPC-webにおいてはBase64エンコードしてprintableな文字だけで送信することも可能です。
  4. 勿論、好みと時と場合によってはmitmproxyPacketProxy等を利用する場合もあります。とりわけ、PacketProxyはgRPCをサポートしています。
  5. 残念ながら、このフレームワークには名前をつけていないので名前を呼ぶことはできません。悲しいですね。
  6. この場合、stringだと解釈するとnon-printable文字が出現してしまうため、bytesとして解釈するほうが自然ですね。
  7. Protocol Buffersのエンコード方法として、JSONにエンコードする方法もあるらしいです。
  8. なぜprotocを使わなかったのかは、僕もわかりません。
  9. 厳密にはKotlinのLongString等はプリミティブ型ではなくオブジェクトですが、一般的なプログラミング言語で言うところのLongString等の基本的な型のことを、ここではプリミティブ型と呼ぶことにします。
  10. 言語処理系をいじっている人からするとなんて雑な解決方法なんだと思われるかもしれませんが、ごめんなさい。大学でコンパイラの講義を取らなかったことを初めて後悔しました。
  11. protocの全言語のプラグインを調べたわけではありません。
  12. そのため、本モジュールをgRPCに対して利用するためには間にEnvoy等のプロキシを挟んでやる必要があります。
  13. blackboxprotobufを手元で動かして確かめた限り、ネストされたメッセージは最上位の型以外フィールド名が補完されませんでした。
  14. これは本質情報ですが、猫好きが多いこの業界ですが僕は犬が好きです。