蟻地獄

Twitterに書ききれない長めの文とか書くよ

Monappy事件はどうすれば防げたのか

この記事は VALU Advent Calendar 2018 25日目のエントリです。

株式会社VALUのバックエンドエンジニアのMito Memelです。始皇帝が宇宙開発したりガンジーが核撃ってくるシヴィライゼーションっていうゲームが好きです。

今年9月にMonacoinウォレットサービス「Monappy」でホットウォレットに保管されていた全てのMonacoinが不正に出金されるという事件がありましたが、詳細な原因が早期に公開されたことが話題になりました。

medium.com

本エントリでは上記の記事を元に、どうすれば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を書いてくれるバックエンドエンジニアやその他だいたい全部の職種を募集しています。電話会社を最近退職した分散トランザクションが得意な方などはぜひご応募下さい。

graspy.jp

BIP44が分からなくなる話

この記事はVALU Advent Calendar 7日目のエントリです。

株式会社VALUのバックエンドエンジニアのMito Memelです。 先日、HDウォレットの残高を監視するコードを書いた際にBIP44について無駄に詳しくなりましたので、知見を共有します。

BIP44とは

BIP44はHDウォレットのアドレス階層の仕様です。 HDウォレットのアドレス生成アルゴリズムBIP32で定められていますが、BIP32だけでは「あるHDウォレットのマスターキーを別のメーカーのHDウォレットにインポートしたら残高が復元できる」ということを実現するには足りません。なぜかというと、マスターキーからはアドレスが無限に生成できるので、好き勝手なアドレスに入金してしまうとインポート先のウォレットでどのアドレスに入金されているのかを調べるのに無限時間掛かってしまうためです。

アドレス階層

BIP44では、アドレス階層の使い方を以下の形式に定めています。

m / purpose' / coin_type' / account' / change / address_index

先頭の m はマスターキーのことです。' は強化鍵を使っているかどうかですが、説明すると長くなるのでBIP32を読んでください。各階層の意味は以下の通りです。

purpose

アドレス階層がどのBIPに従っているかを示す番号です。BIP44に従っている場合は 44 になります。

coin_type

Bitcoin0 、Ethereumは 60 、というように通貨種別を表す定数です。定数は https://github.com/satoshilabs/slips/blob/master/slip-0044.md で定義されています。この階層により、複数通貨に対応したウォレットも1つのマスターキーだけで表現可能になります。

account

HDウォレットは通常、1種類の通貨内でも複数の口座に分けて残高を管理することができます。この階層にはどの口座かを示すindexが入ります。

change

BIP44では入金用アドレスとお釣り用アドレスの階層を明確に分けています。この階層が0なら入金アドレス、1ならお釣りアドレスです。

address_index

アドレスの連番です。新しいアドレスを生成する際はここをインクリメントしていくことになります。


以上の階層を使用することにより、例えば以下のようにアドレス階層を一意に決めることができます。

  • Bitcoinの2番目の口座の10番目の入金アドレス: m/44’/0’/1’/0/9
  • Ethereumの5番目の口座の8番目のお釣りアドレス: m/44’/60’/4’/1/7

※indexは0始まりなので、「2番目」のindexは 1 になります。

アカウント探索

さて、前述の「どのアドレスに入金されているのかを調べるのに無限時間掛かってしまう」問題についてですが、これを解決するためにBIP44では gap limitという概念を導入しています。gap limitとは、「この数以上入金履歴がないアドレスが続いた場合、それ以降のアドレスには入金しない」ということを定めた定数です。BIP44ではこの定数は20となっています。また、account の階層では、未使用の口座がある状態で新しい口座を作成することは禁止されています。つまり、「ウォレット内の全てのBTC口座」を列挙する擬似コードは以下のようになります。

function get_accounts($wallet)
{
    $result = [];
    $account = 0;
    $index = 0;

    while(true){
        $address = $wallet->derive_address("m/44'/0'/{$account}'/0/{$index}");
        $transactions = fetch_transactions($address);
        if($transactions->isEmpty()){
            if(++$index == 20){
                break;
            }
        }else{
            $result[] = $account;
            $account++;
            $index = 0;
        }
    }

    return $result;
}

これで account 階層の列挙はできました。各 account の残高を取得する擬似コードは以下のようになります。

function get_balance($wallet, $account)
{
    $balance = 0;
    $index = 0;
    $gap = 0;

    foreach([0, 1] as $change){
        while(true){
            $address = $wallet->derive_address("m/44'/0'/{$account}'/{$change}/{$index}");
            $transactions = fetch_transactions($address);
            if($transactions->isEmpty()){
                if(++$gap == 20){
                    break;
                }
            }else{
                $balance += get_balance($transactions);
                $gap = 0;
            }
            $index++;
        }
    }

    return $balance;
}

account の列挙では change が0のアドレスのみ走査すれば大丈夫でしたが、残高を取得するにはお釣りアドレスも走査する必要があります。ここが罠ポイントで、BIP44には紛らわしい記述があります。

We scan just the external chains, because internal chains receive only coins that come from the associated external chains.

この記述は前述の account 列挙時の話であって、入金アドレスの走査だけだと「複数のoutputのうちどれが出金先でどれがお釣りなのか?」が分からないので残高を出すことはできません。ここで引っかかっている人は多いようで、Stack Exchangeにも複数の質問が上がっていました。

次に問題になるのが、お釣りアドレスにもgap limitが設定されているのか?それとも account 階層のように必ず 0 から順番に使わないといけないのか?という点ですが、実はこれについてはBIP44の記述は曖昧です。gap limitは入金アドレスについての記述しかなく、上記の擬似コードではひとまず入金アドレスと同じ20としています。実際のウォレットではお釣りアドレスにもっと小さいgap limitを使用している場合もありますが、とりあえず大きめに取っておけばアドレスの取りこぼしは防げます。そこ曖昧にしたらあかんやろと思うのですが、完璧な絶望が存在しないように完璧な仕様も存在しないので、適当にうまいことやりましょう。

まとめ

HDウォレットの互換性はわりとふわっとしているので、同じBIPを採用していると謳っている別メーカーのウォレットにマスターキーをインポートしても100%残高が復旧できるとは限りません。が、同じアドレス階層を採用したウォレットで、インポート先でgap limitを大きく取れば大抵はなんとかなると思われます。知らんけど。

株式会社VALUにJOINします

この度、私 @mito_memel は、株式会社フロム・ソフトウェアを退職し、株式会社VALUに入社することになりましたのでご報告いたします。本格的に開発に携わるのはまだ1ヶ月ほど先なのですが、既に内部情報にアクセスできる状態ですのでサクラっぽくなってもアレなのでこのタイミングで公表いたしました。

で、誰?

アスキーアートが立体化するアバターチャット作ったりLingrがサービス終了した時に避難所としてCometを使ったWebチャット作ったりHTML5の黎明期にWebGL+WebSocketで3Dアバターチャット作ったりAmazonガチャの炎上に便乗してAmazonガチャシミュレータ作ったりVALUのパロディとして無を売買できるサービス作ったりしている、CivilizationFactorioをこよなく愛するゆるふわエンジニアです。技術ブロガーとしても無名の名を欲しいままにしており、最近書いた記事はこの辺りです。

最近はVALUのタイムライン上でひたすらVALUをdisっており、たぶんVALUをdisった回数で言うと人類で一番多いんじゃないかと思います。

なんで入ったの?

今さらVALUを取り巻く状況については説明しませんが、ご存知の通りVALUは出来が悪いサービスで、そもそも暗号通貨業界自体、私は以前から懐疑的です。NILUを作ったのがきっかけでオファーをいただいたのですが、VALUのフロントエンドのコードはバグだらけだし、プレスリリースで出てくる文章は下手くそかよだし、正直あまり魅力を感じる会社ではありませんでした。しかし、小飼弾さんがリードエンジニアに就任したというニュースを聞き、考えが変わりました。小飼弾さんが入ったことで優秀なエンジニアを集めやすくなりますし、エンジニアの作業環境や待遇がいい感じになる可能性も高くなります。また、VALUの胡散臭さについては、中の人の話を聞いたところ、どうも悪意があるわけではなく本当に天然でただただ手が足りてないだけということが分かってきまして、エンジニアリングとして見てもクソゲーをまともに遊べるようにするというチャレンジには魅力を感じましたので、お話を受けることにしました。たとえVALUがすぐ潰れたとしても幸い再就職先には困っておりませんので、心置きなくこの機会を目一杯楽しもうと思っています。

今後について

NILUおよびvaluforumについては今後も個人として運営を続けます。NDAの都合上、VALUについての意見の表明は減るかと思いますが、今までVALUをdisることに注いでいた情熱は今後はサービスの改善に注ぐつもりですので生温かく見守っていただければと思います。現在所持している他者のVAについてはそのまま保持しますが、今後は他者のVAの購入は行いません。現在設定してある優待は出来る限り続けるつもりですが、今後のサービスの展開次第では提供をやめる可能性もありますのであらかじめご了承下さい。

VALUの正体はIPO投資バブル

VALUは発行者がMy VALUを100%持った状態でスタートします。発行者の人気が出るとVALUが欲しい人が増えるので、需要と供給の原則によりVALU価格は上がります。発行者がMy VALUを売却すると市場に流通するVALU総量が増えるので、需要と供給の原則によりVALU価格は下がります。つまり、発行者の人気上昇速度とMy VALU売却速度が均衡してやっと現在価格が維持できるのです。人気が上昇していないのにMy VALUを売却すれば価格は下がるのが自然です。逆にVALUが値上がりするためには、人気上昇速度がMy VALU売却速度を上回る必要があります。VALUの値上がりを期待して買うというのは、実はかなり分が悪い勝負なのです。

でもVALUをやっていると、「有名人の初動を押さえて儲かった!」とか「○日連続ストップ高!」みたいな景気の良い話をよく聞きますよね。実はこれ、上記の需要と供給の仕組みとは全く関係ありません。VALUの初値は登録時のSNSフォロワー数によって決まります。ですが、この初値は大抵安すぎる価格になっています。なので、初値から適正価格に調整されるまでの間は価格が上がり続けるのです。初値近くで買って、買う人がいるうちに売り抜けられれば差額が得られます。これは株の世界でいう「IPO投資」に当たります。これは参加者同士でゼロサムゲームをやっているだけであり、「発行者に支援したら人気が出てVALU価格が上がった」のではありません。そもそも、アーリーステージの個人に投資して、1~2週間みたいな短期で結果が出るわけがないのです。現在のVALUは、運営が意図した「個人を応援する」のとは全く別のゲームで盛り上がっている状態です。

VALUが運営の意図した使い方をされるためには、「IPO投資で儲からなくする」というのが一番有効なのではないかと思います。もちろんそれにより流入する資金は減るでしょうが、このままIPO投資バブルが続けばVALUは個人の応援とは名ばかりのevilなサービスになってしまいます。

VALUユーザー向けクイズ 中級編

次の中から正しい記述をすべて選べ。

  1. VALUは、個人を会社に見立てて疑似株式を発行できるサービスである
  2. VALUは、個人の信用を時価総額という形で数値化するサービスである
  3. VALUは、継続的に支援者を募ることができるサービスである
  4. My VALUを売却するときは、価格がなるべく高くなるように少しずつ売却すべきである
  5. My VALUを売却するときは、既存のVALUERに損をさせないように現在値より高い価格で売却注文を出すべきである
  6. 他の人のVALUを売却するときは、VALUの価格を下げないように現在値より高い価格で売却注文を出すべきである
  7. My VALUの価格を吊り上げすぎて売れにくくなってしまっても、今後VALU分割が実装されるので問題ない
  8. My VALUの現在値に応じて優待の所有VA条件はこまめに変更すべきである
  9. 優待には期限が設定できるため、継続しにくい手間のかかる優待を設定しても問題ない
  10. VALUを退会するときは、売却したMy VALUを全て買い戻すのが望ましい

VALUユーザー向けクイズ 初級編

Twitterのフォロワー数がそこそこいるたかし君は、最近話題になっているVALUというサービスが気になり登録してみました。最初は胡散臭く感じたのですが、小飼弾さんも入社したということですし、きっと悪いようにはならないでしょう。たかし君はMy VALUの発行を申請し、無事審査を通過しました。

たかし君のVALUは売り始めてから数日間は飛ぶように売れました。投機目的の人や、元からファンだった人が、安いうちに買っておこうと我先にと飛びついたからです。たかし君はVALU上での宣伝活動も積極的に行っていたので、たかし君の才能を見つけた新たなファンもVALUを買ってくれました。たかし君は今後もこの勢いでVALUが売れ続けると信じて気前の良い優待を設定しました。「マンツーマンでブログのコンサルをします!」

しかししばらくすると、たかし君のVALUの売れ行きは落ち始めました。元からファンだった人には一通りVALUが行き渡り、投機目的の人は最初の勢いが落ちたと見て利確し始めたからです。今後は、新たなファンを増やすか、既存ファンに買い増ししてもらわないとVALUは売れません。しかし、たかし君のVALUは最初の数日間でストップ高を繰り返していたため既に結構な高値が付いてしまっており、ファンでもなかなか手が出なくなってしまいました。

たかし君は考えます。今の値段だと買いづらいので、もうちょっと値段を下げて売ってみよう。しかし売り注文を出すと、1人のVALUERから非難の声が上がりました。「私のほうが先にファンになっていち早くVALUを買ったのに、それより遅れてきたファンに安く売るとはなにごとだ。それならなぜ私にその値段で売ってくれなかったんだ。」なるほど、この主張はもっともです。たかし君は現在価格より安値ではVALUを売らないと決めました。

結局そのままたかし君のVALUの価格は下がらず、新規のファンがVALUを買いづらい状態が続きました。たかし君のVALU内での収入はほとんど0になってしまいました。この頃になって、たかし君はようやく気づきます。

「うわっ…私の優待、気前良すぎ…?」

たかし君はVALU内での収入が継続的にあることを前提に優待を設定していたのですが、今の収入はほぼ0です。しかしたかし君は既に結構な数のVALUERを抱えており、彼らに優待を行う責任があります。今さら優待をやめると言えば信用はダダ下がりです。たかし君は端金と引き換えに一生ブログのコンサルを続けることになりました。めでたしめでたし。

ここで問題です。たかし君はどうすれば良かったでしょうか?また、VALUのシステムをどう変えればこのような事態が起こらなくなるでしょうか?

VALUのルールはおかしい

VALUは発行数が決まっており、今のところ増資も分割もできません。これは、エルドラド(黄金郷)伝説に学ぶ、VALUで増資を許すことの愚かさで指摘されている通り、VALUを株式と捉えると至極真っ当な制限です。

では、VALUの増資・分割は今後もできないと仮定して、発行された自分のVALUを全て売り切ってしまった人はその後どうすれば良いでしょうか?まあ実際にはVALUを全て売り切るのはけっこう難しいので、売り切らなくても需要が飽和してVALUがそれ以上売れなくなったタイミングということでも良いです。要は、自分のVALU放出による収入が無くなった時点ということです。

この時点では既に結構な数のVALUERを抱えています。VALUによる収入はこれ以上見込めませんが、今後もVALUERに対して優待を続けていくつもりなのでしょうか?もちろん優待には期限が指定できるので、その期限切れをもって優待を終了とすることもできます。ですが、魅力的な優待でVALUの価格を維持してきた人の場合、優待を終了するとVALUの価格は暴落し、信頼は地に落ちます。各所で指摘されているような、売り逃げ悪徳ユーザーと変わらなくなってしまうわけです。

本物の株式の場合、IPOによって得た資金で事業を行い、その収益を株主に還元しています。事業によって継続的な収入があるため、当然ですが新規公開株を売り切った後も株主への還元を継続できるわけです。じゃあVALUでも新規公開VALUの売却益は事業を起こすために使い、その事業の収益をVALUERに還元するのが健全なんじゃないかと思いますよね。

ところがどっこい、実は「VALUの発行により得た対価を充てて事業を行い、これにより生じた収益又は当該事業にかかる財産をVALUERに分配する行為」は利用規約で明確に禁止されています。本物の株式になっちゃうと法律的にヤバいので、本物の株式のようには運用できないようになっているのです。VALUを売り切ってしまった人は、VALUと無関係の事業で得た収益を投じて優待をボランティアで継続していくか、優待を終了して売り逃げ呼ばわりされるかの2択を迫られるわけです。

このルールのおかしさは、VALUが株式を模しているにも関わらず、本物の株式になってしまうと違法であることに起因しています。VALUがこの先生きのこるためには、株式を模すことをやめ、現状多くのユーザーがそう認識しているように、サービス利用権のマーケットプレイスとしてルールを整備し直すのが健全だと思います。