この記事は VALU Advent Calendar 2018 25日目のエントリです。
株式会社VALUのバックエンドエンジニアのMito Memelです。始皇帝が宇宙開発したりガンジーが核撃ってくるシヴィライゼーションっていうゲームが好きです。
今年9月にMonacoinウォレットサービス「Monappy」でホットウォレットに保管されていた全てのMonacoinが不正に出金されるという事件がありましたが、詳細な原因が早期に公開されたことが話題になりました。
本エントリでは上記の記事を元に、どうすればMonacoinの多重送金を防げたのかを考えます。
上記の記事では、多重送金の原因が以下のように説明されています。
MonappyからMonacoinを送信する際は、monacoindという別のサーバ上のアプリケーションと通信する仕様になっています。この通信がタイムアウト等で失敗した場合はサーバダウンなどで送金に失敗しているとみなして、取引をロールバックする仕様となっていました。また、ギフトコードの処理に関してはデータベースのトランザクション機能等を利用しており、本来同じギフトコードを二重に使用することはできないようになっていました。しかし、高負荷状態でギフトコードを連続して使用しようとした場合、通信を受け取ったmonacoindの応答に時間がかかり、サイト側ではタイムアウトとなってロールバックされたあとにmonacoind側では送金が行われていました。この結果、一つのギフトコードから複数回送金されたものと考えられます。
中央集権型ウォレットサービスにおけるコインの出金処理とは、単純化すると以下の処理を行うことです。
- ブロックチェーン上で指定されたアドレスにコインを送金する
- サービス側のDB(通常はリレーショナルデータベース)上のユーザーの残高を減らす(Monappyの場合はギフトコードを使用済みにする)
ではまずダメな例を考えてみましょう。上記の処理を単純にコード化すると以下のようになります。
function use_giftcode($code, $address) { try{ DB::statement("START TRANSACTION"); $giftcode = DB::select("SELECT * FROM giftcodes WHERE code=? AND status='unused' FOR UPDATE", [$code]); if(!$giftcode){ throw Exception("ギフトコードが既に使用されています"); } $tx = send_monacoin($address, $giftcode->mona_amount); // ブロックチェーン上で送金 DB::update("UPDATE giftcodes SET status='used', tx_id=? WHERE code=?", [$tx->id, $code]); DB::statement("COMMIT"); }catch(Exception $e){ DB::statement("ROLLBACK"); throw $e; } }
※実際にはブロックチェーン上での承認数の確認なども必要になりますが、それはまた別レイヤーの話なので省略します。
上記のコードの場合、send_monacoin()より下のDB更新で失敗すると、ブロックチェーン上での送金は成功しているのにギフトコードが使用済みになりません。攻撃者がDB負荷を高めるなどして意図的にDB書き込みの失敗率を上げられるのであれば、ホットウォレットが枯渇するまで出金し放題です。
これを防ぐためには、どの時点で失敗したとしても、
という状態にする必要があります。つまり、ブロックチェーンとRDBをまたいだ分散トランザクションが必要になるわけです。はい、胃が痛くなってきましたね。
では次に、上記を考慮したコードにしてみましょう。下記がおそらくMonacoinの多重送金が発生した際のコードに近いと思われます。
function use_giftcode($code, $address) { $giftcode = DB::select("SELECT * FROM giftcodes WHERE code=?", [$code]); // ローカルでMonacoinのトランザクションデータを作成 $tx = build_monacoin_transaction($address, $giftcode->mona_amount); try{ DB::statement("START TRANSACTION"); $giftcode = DB::select("SELECT * FROM giftcodes WHERE code=? AND status='unused' FOR UPDATE", [$code]); if(!$giftcode){ throw Exception("ギフトコードが既に使用されています"); } DB::update("UPDATE giftcodes SET status='sending', tx_id=? WHERE code=?", [$tx->id, $code]); DB::statement("COMMIT"); }catch(Exception $e){ DB::statement("ROLLBACK"); throw $e; } try{ // トランザクションデータをMonacoinネットワークにブロードキャスト broadcast_monacoin_transaction($tx); }catch(Exception $e){ // 送金が失敗したのでギフトコードを未使用状態にロールバック DB::update("UPDATE giftcodes SET status='unused', tx_id=NULL WHERE code=?", [$code]); throw $e; } DB::update("UPDATE giftcodes SET status='used' WHERE code=?", [$code]); }
まず、ローカルでMonacoinの送金トランザクションを作成し、トランザクションIDをギフトコードのDBレコードに紐付けます。次にMonacoinの送金をブロードキャストし、成功すればギフトコードを使用済みに更新、失敗すれば未使用状態にロールバックします。こうすることで、どの時点で失敗してもMonacoinの送金が成功したかどうかをトランザクションIDで追跡可能になるので、statusが sending
のギフトコードを定期監視するバッチがあれば自動リカバリ可能です。
しかし、上記のコードにも問題があります。それは、broadcast_monacoin_transaction()が失敗した際に、エラー種別に関係なく必ずギフトコードを未使用状態にしていることです。例えば、monacoindがブロードキャストに時間が掛かりタイムアウトした場合、broadcast_monacoin_transaction()は失敗扱いになりますが、ブロードキャスト自体は成功している場合があり得ます。その場合、送金が成功したのにギフトコードが未使用状態になります。攻撃者がこの状態を意図的に発生させることが出来れば、ホットウォレットが枯渇するまで出金し放題です。これを防ぐためにはブロードキャストの部分を下記のように修正する必要があります。
try{ // トランザクションデータをMonacoinネットワークにブロードキャスト broadcast_monacoin_transaction($tx); }catch(BeforeBroadcastException $e /*ブロードキャスト前に失敗したことが確実な場合*/){ DB::update("UPDATE giftcodes SET status='unused', tx_id=NULL WHERE code=?", [$code]); throw $e; }
エラーの種別を分けて、Monacoinネットワークにブロードキャストする前に失敗したことが確実な場合のみ未使用状態へのロールバックを行います。ブロードキャストが成功したか失敗したか分からない場合はギフトコードを sending
状態のまま保留しておき、後で定期監視バッチにより成否を判定します。
まとめ
以上がMonappyの公開情報から推測した多重送金の仕組みと対策案です。「ブロックチェーンは多重送金が仕組み上不可能で信頼性が高い」というのは取引がブロックチェーン上だけで完結する場合の話です。現実世界でサービスを提供するには多くの場合外部リソースとの連携が不可欠で、上記のような分散トランザクションが必要になります。それを解決するのがスマートコントラクトということなのでしょうが、それはそれでスケーラビリティなど問題が山積みです。
最後に
弊社では一緒に来年のAdvent Calendarを書いてくれるバックエンドエンジニアやその他だいたい全部の職種を募集しています。電話会社を最近退職した分散トランザクションが得意な方などはぜひご応募下さい。