こんにちは、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の通信データに遭遇した時どうするかというアンケートをとってみようと思いました:
なるほど。さて、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
ファイルがあったとします:
この時、送信されるデータは例えば以下のようになります:
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はkey
とvalue
の繰り返しとなっています。ここで、key
はTag ID
とWire Type
から成っています。Tag ID
とは、メッセージにおけるフィールドIDを、Wire Type
はデータの型を表しています。
但し、Protocol Buffersにおいて.proto
ファイルで利用される型(ここではConcrete Type
と呼びます)と実際に通信時にエンコードされる型(Wire Type
)は異なり、Concrete Type
はWire Type
の部分集合となっています。
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
なのかがわかりません。WireType
がLengthDelimited
だった場合、文字列なのかネストされたメッセージなのかすらわかりません。
なんと中間者に優しくないプロトコルなんでしょうか。以下のメッセージを考えてみると、大きく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
を決め打ちしています。
だいぶ荒っぽいデコード方法のため、Variant
型として負数が飛んできたときにも正数として解釈してしまいますが、これがblackboxの限界です。
ただ、LengthDelimited
型の場合に全てbytes
として解釈してしまうととんでもないことになるため、ある程度デコードの優先順位を決めています。これは、LengthDelimited
のConcreteType
である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 ID
とConcrete Type
を詰め込んでしまうことで、IR->JSON / JSON->IRの両方の場合に完全な変数の情報を保ったまま変換が可能になります。以降は、この形式のIRと等価な情報量を持つJSON形式をIR-JSON
と呼びます7。
さて、これでProtocol BuffersのデータをIRに変換し、さらにユーザの見やすいJSON形式にして提供することができるようになりました。画像からもわかるように、リクエスト/レスポンスの両方でメッセージがデコードされ、それなりに読みやすい形式で提供されています。勿論データの値を変更したり、さらにはIR-JSON中のConcreteType
を編集することでフィールドの型を変更することも可能です。
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.Empty
はUnit
に、google.protobuf.TimeStamp
はInstant
に)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部分を勝手に書き換え対象として検知してくれるようになり、パラメタを自由に変化させて送ることができるようになります。
この状態のリクエストは当然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
に置き換えることで、適切な位置にペイロードを挿入させることができるようになっています:
課題
gRPCと言いつつProtocol Buffersの話がメインになってしまいました。それもそのはずで、gRPCの部分に関してはデコードもエンコードも特に難しいことがなく、実装量はごくわずかになっています。よって、書くことがありませんでした。
本Extenderは、現在のところUnary RPC
のみに対応しています。というよりは、gRPC-web自体が現在のところclient-side streaming
とbi-directional streaming
に対応していません。ブラウザがこれらの通信をサポートした場合には、本Extenderにも対応する処理を実装する必要があります。
本来のgRPCがブラウザで利用できないのはブラウザのAPI制限によるものですが、APIの仕様で機能が制限されるというのはBurpにおいても同じことです。本ExtenderはgRPC-webを扱えるようにしましたが、gRPCを扱うことができません。これは、BurpSuiteが現時点でHTTP2メッセージの処理に関する十分なAPIをExtenderに向けて提供しておらず、HTTP2の機能をフル活用するgRPCをデコードすることができないからです12。
PacketProxyの ようにゼロベースで開発すればAPIによる制限に悩まされることはないのでしょうが、工数と得られる利点のトレードオフが現状難しいところです。
最後に、本ブログを読んだ人の中には「これNCC Group Plcのblackboxprotobufで良くない?」と思った方もいるのではないかと思います。実はblackboxprotobufの存在に気づいたのが、ブログを書いている最中でした。やはり事前のリサーチというのは大事ですね。
但し、言い訳をすると本Extenderはblackboxprotobufにはない機能も有しています。例えば最後に紹介したIntruder/Extender対応がその最たるものです。また、ネストされた型や循環する型の復元ができることも挙げられます13。何より、バグフィックスやフィーチャーリクエストに対して迅速に対応できるという最大のメリットがあります。以上、言い訳でした。
アウトロ
さて、本ブログではFlatt Securityの脆弱性診断において遭遇する不便に対してコードを以てドッグフーディング🐶14的に対処する様子ををお見せしました。紹介したExtenderは、現在実際の診断でも利用しており、さらに改良を加えている最中です。もしかするとオープンソースとして公開する日が来るかもしれませんね。来ないかもしれませんね。
本ブログで、Flatt Securityの日常の雰囲気がお伝えできていれば幸いです。
Flatt Securityに少しでも興味を持ってしまった方はぜひ下のバナーより会社説明会にご参加ください。
また、脆弱性診断の実施に興味を持った方はサービス詳細ページや、下のバナーから料金資料をダウンロードしてみてください。
また、Flatt Securityはセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式Twitterのフォローをぜひお願いします!
ここまでお読みいただきありがとうございました。
- 最近他のインターンにお世話になり、そこでgRPCについて講義していただきましたが、本ブログは該当インターンの前に執筆を終えており、一切関係がないことをここに断っておきます。(参考Tweet: https://twitter.com/toyojuni/status/1560617016083357696)↩
- gRPCの"g"は、Googleの"g"ではありません。バージョン1.46では"golazo"を意味しています。↩
- 実際には、通信時にはJSON形式で通信することも可能です。また、gRPC-webにおいてはBase64エンコードしてprintableな文字だけで送信することも可能です。↩
- 勿論、好みと時と場合によってはmitmproxyやPacketProxy等を利用する場合もあります。とりわけ、PacketProxyはgRPCをサポートしています。↩
- 残念ながら、このフレームワークには名前をつけていないので名前を呼ぶことはできません。悲しいですね。↩
-
この場合、
string
だと解釈するとnon-printable文字が出現してしまうため、bytes
として解釈するほうが自然ですね。↩ - Protocol Buffersのエンコード方法として、JSONにエンコードする方法もあるらしいです。↩
- なぜprotocを使わなかったのかは、僕もわかりません。↩
-
厳密にはKotlinの
Long
やString
等はプリミティブ型ではなくオブジェクトですが、一般的なプログラミング言語で言うところのLong
やString
等の基本的な型のことを、ここではプリミティブ型と呼ぶことにします。↩ - 言語処理系をいじっている人からするとなんて雑な解決方法なんだと思われるかもしれませんが、ごめんなさい。大学でコンパイラの講義を取らなかったことを初めて後悔しました。↩
- protocの全言語のプラグインを調べたわけではありません。↩
- そのため、本モジュールをgRPCに対して利用するためには間にEnvoy等のプロキシを挟んでやる必要があります。↩
- blackboxprotobufを手元で動かして確かめた限り、ネストされたメッセージは最上位の型以外フィールド名が補完されませんでした。↩
- これは本質情報ですが、猫好きが多いこの業界ですが僕は犬が好きです。↩