バンドルカードのバックエンドやインフラを担当しているエンジニアの summerwind です。最近は WebAssembly と JIT に興味があります。
4月23日に開催された Go Conference 2022 Spring ではカンムのメンバーが Go に関する内容で登壇しました。セッションで紹介したスライドは以下で参照できます。将棋プログラミングについては自分もまったく知らない世界の話だったのでとても興味深かったです。
今回のイベントにはカンムもスポンサーとして参加させていただき、今回もオフィスアワーの催しとして Go を使ったクイズ「ISO 8583 Message Challange」を公開しました。
クイズの問題
今回のクイズの問題は以下のリポジトリで参照できます。もし興味がありましたらぜひチャレンジしてみてください。
今回の問題のテーマは「バイナリ処理」です。インターネットでの通信やデータの保存などでは様々な形式のバイナリが使われていますが、一般的なプロダクト開発ではバイナリを直接扱うようなコードを書く機会は意外と少なかったりします。個人的には Go でバイナリを扱うコードを書くが好きなので、今回のクイズではバイナリのパースを通じてより多くの人にバイナリの操作を楽しんでもらえたらと思い、このテーマを設定してみました。
問題では、カンムの決済処理にも使われている ISO 8583 形式のメッセージをパースしてその中から答えとなる値を見つける、というゴールを設定しています。メッセージのバイナリファイルはリポジトリに message.bin
として保存されているので、これをパースしていくと答えが見つかる仕組みです。今回の問題では、より手軽にクイズにチャレンジしてもらえるよう以下のように parse.go
ファイルに定義された Parse()
関数の中身だけを実装すれば回答を出せるようにしてみました。
問題の解説
ここからは実際にバイナリをパースしていく方法を解説してみたいと思います。
まず最初に、実装が必要な Parse()
関数の定義を見てみると次のようになっています。引数 buf
にはバイナリの値が byte
の配列として保存されており、*Message
を返せばいいことが分かります。
// ISO 8583 メッセージのバイト列をパースして Message を返す関数 func Parse(buf []byte) *Message { var msg Message // TODO: バイト列 buf をパースして msg の各フィールドに値を設定してください return &msg }
次に、戻り値である *Message
の構造を見みてみると、次のようになっています。
// ISO 8583 メッセージ構造体 type Message struct { // メッセージ: 3桁の数字 // ISO 8583 バージョンの 0 は含まないことに注意してください Type uint16 // カード会員番号 (PAN): 16桁の数字 PrimaryAccountNumber uint64 // 処理コード: 6桁の数字 ProcessingCode uint32 // 取引金額: 任意の桁の数字 AmountTransaction uint64 // 有効期限: YYMM の4桁の数字 ExpirationDate uint16 }
この構造から今回のクイズでは、バイナリから以下の5つのデータをパースして抽出すればよいことが分かります。
- メッセージタイプ
- カード会員番号
- 処理コード
- 取引金額
- 有効期限
データの抽出には ISO 8583 メッセージのバイナリフォーマットの知識が必要になります。弊社の hiroakis が以前発表した資料にフォーマットの詳細と詳しい解説がありますので、ここからはこの資料とあわせて読み進めてみてください。
最初にメッセージタイプをパースします。これはメッセージの種類などを示す値でメッセージの先頭2バイトを4ビットのパック10進数 (Packed BCD) として扱う必要があります。今回の解説では処理を簡略化するため、BCD の処理については公開されているパッケージである https://github.com/albenik/bcd を利用していきます。
// メッセージタイプを取得 msg.Type = bcd.ToUint16(bin[0:2])
次にカード会員番号のパースに取り掛かりたいところですが、最初にビットマップと呼ばれる領域を取得しておく必要があります。
ビットマップはメッセージにどんな種類のデータが存在するかを示すビットを保存している64ビットの領域です (64ビットより長い場合もあります) 。この領域の各ビットの値が1だった場合はそのビット位置に対応するデータの存在を示しています。例えば、ビットマップの値が2進数で 01110010
であった場合、2、3、4、7番目のデータが存在していることを示しています。
// ビットマップ領域を取得 bitmap := binary.BigEndian.Uint64(bin[2:10])
ビットマップが取得できたので、カード会員番号を抽出します。カード会員番号の存在を示すのはビットマップの左から2ビット目の値なので AND 演算で確認します。ビットが存在した場合は値を抽出しますが、カード番号の値は可変長の値なので、最初に先頭1バイトの長さを抽出し、その長さ分のバイト数を Packed BCD として読み出します。
// バイナリのオフセット offset := 10 // カード会員番号のビットの存在を確認 if (bitmap & 0x4000000000000000) > 0 { // カード会員番号の長さを取得 length, _ := binary.Varint(buf[offset : offset+1]) offset += 1 // カード会員番号を取得 msg.PrimaryAccountNumber = bcd.ToUint64(buf[offset : offset+int(length)]) offset += int(length) }
次に処理コードを抽出します。カード会員番号と同じようにビットマップで存在を確認してから、処理コードの値を取得します。処理コードの値は3バイトの固定長なので、そのまま読み出します。
// 処理コードのビットの存在を確認 if (bitmap & 0x2000000000000000) > 0 { // 処理コードを取得 msg.ProcessingCode = bcd.ToUint32(buf[offset : offset+3]) offset += 3 }
残りは取引金額と有効期限になりますが、これらは処理コードと同じ固定長のフィールドなので、次のように処理コードと同じように値を取得できます。
// 取引金額の取得 if (bitmap & 0x1000000000000000) > 0 { msg.AmountTransaction = bcd.ToUint64(buf[offset : offset+6]) offset += 6 } // 有効期限の取得 if (bitmap & 0x0004000000000000) > 0 { msg.ExpirationDate = bcd.ToUint16(buf[offset : offset+2]) offset += 2 }
実はビットマップの存在確認に使用する各ビットの値は定数として事前に定義されているので、それらを使用すると最終的な Parse()
の実装は次のようになります。
// ISO 8583 メッセージのバイト列をパースして Message を返す関数 func Parse(buf []byte) *Message { var msg Message // メッセージタイプを取得 msg.Type = bcd.ToUint16(buf[0:2]) // ビットマップ領域を取得 bitmap := binary.BigEndian.Uint64(buf[2:10]) // バイナリのオフセット offset := 10 // カード会員番号を取得 if (bitmap & BitPrimaryAccountNumber) > 0 { length, _ := binary.Varint(buf[offset : offset+1]) offset += 1 msg.PrimaryAccountNumber = bcd.ToUint64(buf[offset : offset+int(length)]) offset += int(length) } // 処理コードを取得 if (bitmap & BitProcessingCode) > 0 { msg.ProcessingCode = bcd.ToUint32(buf[offset : offset+3]) offset += 3 } // 取引金額の取得 if (bitmap & BitAmountTransaction) > 0 { msg.AmountTransaction = bcd.ToUint64(buf[offset : offset+6]) offset += 6 } // 有効期限の取得 if (bitmap & BitExpirationDate) > 0 { msg.ExpirationDate = bcd.ToUint16(buf[offset : offset+2]) offset += 2 } return &msg }
実装ができたのでクイズの README.md の記載に従いコードを実行してみると、バイナリファイルから正しい値を取得して正解が表示されました。
$ go run . -------------------- Message Type: 100 PAN: 4019249999999999 Processing Code: 327327 Amount Transaction: 1138 Expiration Date: 2204 -------------------- Result: VALID: You have successfully parsed the ISO 8583 message! ...
回答判定の仕組み
問題が解けたところで、今回のクイズの回答判定の仕組みについても簡単に紹介します。
カンムではこれまでにも Go Conference の開催にあわせて CTF やクイズを公開してきました。これまでの問題では出題を担当するメンバーが独自に回答を判定する仕組みを実装していましたが、今回のクイズ作成にあたっては、より汎用的な回答判定をする仕組みを採用してみることにしました。
今回の回答判定に採用したのは Open Policy Agent (OPA) です。OPA を使用すれば正解判定を Rego で記述したポリシーとして扱うことができるため、非常に簡単に回答判定の仕組みが用意できました。今回の回答判定では次のようなポリシーを使用しています。
package iso8583 default validation = "INVALID" validation = "VALID: You have successfully parsed the ISO 8583 message!" { input.Type == 100 input.PrimaryAccountNumber == 4019249999999999 input.ProcessingCode == 327327 input.AmountTransaction = 1138 input.ExpirationDate = 2204 }
目視によるパース
実は ISO 8583 形式のメッセージは16進数で見てみると、ある程度データの値を推測できます。実際に目視でパースして正解した、という方もいたようでした。
$ hexdump -C message.bin 00000000 01 00 70 04 00 00 00 00 00 00 10 40 19 24 99 99 |..p........@.$..| 00000010 99 99 99 32 73 27 00 00 00 00 11 38 22 04 |...2s'.....8".| 0000001e
おまけ
クイズのバイナリに含まれる 327
や 1138
といった値はスターウォーズでたびたび登場するマジックナンバーに由来しています。これは Go のトリビアをリスペクトしてみました。
おわりに
Go Conference の開催中は、Twitter などでクイズの問題に実際に挑戦してバイナリパースの楽しさを感じてくれた方のコメントを見かけたりして嬉しかったです。
カンムでは実際に ISO 8583 のメッセージを処理するシステムを開発して決済サービスを提供しており、バイナリ処理に楽しさを感じるようなエンジニアを募集しています。カジュアル面談などは随時実施していますので、ぜひお気軽にお声がけください。
最後に、今回も素晴らしい Go Conference の場を提供してくれた運営のみなさま、参加者のみなさま、どうもありがとうございました!