メインコンテンツまでスキップ

「Etheruem」タグの記事が5件件あります

全てのタグを見る

· 約2分
Thurendous

Hello! Everybody!

SafeERC20 の使い方 - What is and how to use SafeERC20

OpenZeppelin の SafeERC20 というコントラクトがあります。このコントラクトの趣旨は、ERC20 トークンには基準があるものの、基準に沿っていない有名なトークンがあるからです。例えば、BNB や USDT は bool 値を返さない transfer 関数を持っています。このようなトークンを扱うときに、SafeERC20 を使うことで、bool 値を返さない関数を使っても、失敗した場合には revert することができます。

では正しく使うにはどうしたらよいか

SafeERC20 の使用方法はよく誤解されることがあります。

SafeERC20 は、トークンを安全にするために使用する ERC20 の拡張ではありません(OpenZeppelin の ERC20 はすでに安全ですがw)。他人の ERC20 トークンとのやり取りを安全にするための補助機能です。

この補助機能が行うことは、

  • ERC20 操作のブール値の戻り値をチェックし、失敗した場合にトランザクションを revert させます。
  • 同時に、ブール値の戻り値を持たない非標準の ERC20 トークンもサポートします。
  • さらに、攻撃 を緩和するために、承認額を増減させるための補助機能も提供します。

例として、トークンとやり取りする必要がある契約を作成しましょう。それは、人々が一定の価格でトークンを販売できるようにし、後で購入者がそれらから購入できるようにするものです。いくつかのトークンの転送を実行する必要があるため、それらに SafeERC20 を使用します。IERC20 の型の値で安全な操作を使用できるように、行「using SafeERC20 for IERC20」に注意してください。例:tradingToken.safeTransferFrom。

contract FixedPriceMarket {
using SafeERC20 for IERC20;

IERC20 tradingToken = 0x1234...;
uint256 price = 128;

mapping (address => uint256) selling;

function sell(uint256 tokenValue) {
tradingToken.safeTransferFrom(msg.sender, this, tokenValue);
selling[msg.sender] = selling[msg.sender].add(tokenValue);
}

function buy(address seller, uint256 tokenValue) {
require(msg.value == tokenValue.mul(price));
selling[seller] = selling[seller].sub(tokenValue);
tradingToken.safeTransfer(msg.sender, tokenValue);
seller.transfer(msg.value);
}
}

Reference

OpenZeppelin Forum
SafeERC20 について

· 約8分
Thurendous

Hello, everybody!

Never Give Up

以前書いた時から、EVM について理解できてきているので、当時は読みづらかった技術系の文章もすらすら読めるようになりました。そこで EVM のことあるいは他のなにかで頑張っているあなたももしかして何かができずに悩んでいるのかもしれません。

ここで行っておきますが、僕もそうだったので、安心してください。そのうち理解できるようになるだろう。

ちなみに、こちらの画像をみてごらん。

本当に経験した人しかわからないと思いますし、経験したとしてももう一度経験すると、やはり信じられない気持ちになると思うよね。

何がどうなっても、続けることが一番大切。

  • 遅くてもいい

  • 理想と現実

  • 「続けてても意味ない」

  • 最初の第一歩が一番むずいけどね

  • 三日坊主 VS 続ける


今回は、メモリーについて説明する。

Part 1 では、EVM がどのようにバイトコードのどこを狙って run させるかを見てきた。 それは、外部の calldata を入力してきてコントラクトのどの関数を呼んでいるかを判別して run すべきバイトコードの箇所を決めていることがわかった。

これを理解することで、関数の署名・call stack・calldata・EVM のオペコード について理解が進んだと思う。

Part 2 では、EVM におけるメモリーについて色々見ていこう。

Memory

Part1 のコードを思いだしてみて。1_Storage.solというコントラクトがあった。

pragma solidity >=0.7.0 <0.9.0;

contract Storage {
uint256 number;

function store(uint256 num) public {
number = num;
}

function retrieve() public view returns (uint256) {
return number;
}
}

これのバイトコードを生成すると、こうなっている。

608060405234801561001057600080fd....

今回はこの部分にフォーカスして説明したいと思う。一番最初の 5bytes だ。

6080604052
60 80                       =   PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE

これが聞いたことあるがかもしれないけど、いわゆる「free memory pointer」だ。

これを理解するには、まずはコントラクトのメモリーを理解しなければならない。

メモリのデータ構造

コントラクトのメモリはシンプルにいうと、byte の array だ。32bytes(256bit)あるいは 1byte(8bit)の単位として保存される。読まれるときには、32bytes(256bit)単位ごとになる。下記の画像は話した内容を具現化したもの。

この機能は3つの opcode に左右されている。

  • MSTORE(x, y): 32 byte(256 bit) の値 y をメモリのロケーションの x に保存
  • MLOAD(x): 32 byte(256 bit) のメモリの場所をロケーション x から読み出し、スタックに入れる
  • MSOTRE8(x, y): 1 byte (8 bit)の値 y をメモリロケーションの x に保存する

メモリロケーションのことはどこからメモリを読み取る/書き込むかを決めていると考えてよい。もし、1byte より多く読み取る/書き込むことをしたいのであれば、そのまま継続すればよいだけの話になる。

EVM playground

この EVM playground はあなたの理解の手助けができるだろう。Run をクリックして、右上の矢印をクリックしてみよう。矢印ボタンはコードの稼働プロセスをたどっていってくれる。スタック、メモリはどうなっているのかもわかる。すごく直感的なツールだと感心する。

// MSTORE 32 bytes 0x11...1 at memory location 0
PUSH32 0x1111111111111111111111111111111111111111111111111111111111111111
PUSH1 0x00
MSTORE

// MSTORE8 1 byte 0x22 at memory location 32 (0x20 in hex)
PUSH1 0x22
PUSH1 0x20
MSTORE8

// MSTORE8 1 byte 0x33 at memory location 33 (0x21 in hex)
PUSH1 0x33
PUSH1 0x21
MSTORE8

// MLOAD 32 bytes to the call stack from memory location 0 ie 0-32 bytes of memory
PUSH1 0x00
MLOAD

// MLOAD 32 bytes to the call stack from memory location 32 ie 32-64 bytes of memory
PUSH1 0x20
MLOAD

// MLOAD 32 to the call stack from memory location 33 ie 33-65 bytes of memory
PUSH1 0x21
MLOAD

opcode についても、英語だが説明文がついている。

やってみて変だなと思うことはないのか? まず、MSTORE8 を使って 1 byte の 0x22 をメモリの 0x20 に書き込んでいるつもりだが、何故かメモリはここから、

こう変わるんだ。

この余分なゼロってなんだと思うだろう。

メモリの拡張

コントラクトがメモリへなにかを書き込む際、byte 数に従ってガス代を支払っている。これが opcode のコストと言う。もし、これまでメモリへ書き込んだことがない場所へ書き込む場合、追加的にメモリ拡張のコストがかかる。

これまで書き込んだことがない箇所へ書き込む場合、メモリは 32bytes(256bit)拡張されることになる。

メモリのコストの増え方に関しては、最初の 724bytes は線形的に増加するが、それ以降は二次指数関数的になる。

上の例では、まず 0x00 のロケーションへ 32 bytes 書き込んだが、そこからさらに書き込むとなると、メモリ拡張をしなければならず、結果的に、メモリは 64 bytes になった。

メモリにある保存領域の最初のデフォルト値はゼロ。だから 2200000000000000000000000000000000000000000000000000000000000000 がメモリに追加された。

メモリは byte の Array だから

次に注意しないといけないのは、メモリロケーションの 33(0x21)から MLOAD したときに、下記の値が返された。

3300000000000000000000000000000000000000000000000000000000000000

32 bytes ずつ読み込んでいなくても、読み込めるということであった。

memory はただの byte array なので、どこの場所からでも読み込めることを覚えて下さい。32bytes の制限は特になく、どこからでも byte 単位から読み込める。

関数内でのみ新たにメモリを作成することができます。それは新たにインスタンス化された複雑な型(例えば new int[...] など)であったり、ストレージ参照変数からコピーされたものであったりします。

現在、我々はデータ構造について理解できたので、free memory の話へ戻ろう。

Free Memory Pointer

Free Memory Pointer とは、簡単にいうとメモリロケーションのポインターであり、Free Memory がどこからスタートすべきなのかを記録している。言い換えると、メモリのロケーションはどこまで書き込まれていて、どこから書き込むべきなのかを記録している。

これはコントラクトがメモリを上書きするのを防ぐためでもある。

変数がメモリへ書き込むときに、コントラクトはまず Free Memory Pointer を参照し、どこから書き込むべきかを決めないといけない。それから Free Memory Pointer をアップデートし、最新のメモリの「境界値」あるいは「オフセット」を記録する。

  newFreeMemoryPointer = freeMemoryPointer + dataSizeBytes

Bytecode

先程にも言及したように、Free Memory Pointer は最初の runtime bytecode のこの部分によって定義される。

60 80                       =   PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE

これは基本的に何を言っているかというと、Free Memory Pointer はメモリロケーションの 0x40(64 in decimal)で、値は 0x80(128 in decimal)となる。

すぐに疑問に思うのは、なぜこの数字なのということだろう。

答え:

0x00 - 0x3f (64 bytes): scratch space

0x40 - 0x5f (32 bytes): free memory pointer

0x60 - 0x7f (32 bytes): zero slot

0x40 は solidity が定義した一番最初の Free Memory Pointer のメモリロケーションだ。値 0x80 は単に最初の 4 つの 32 bytes のポジションを記録している。

これらの予約されたメモリについて

  • Scrach space: inline assembly のハッシングメソッドとして使っても良い
  • Free memroy pointer: 今のメモリのサイズ、free memory のスタートロケーション、最初は 0x80
  • The zero slot: 動的な Memory Array の初期値であり、どんなときにでも書き込まれることはない

コントラクトのメモリ

リアルなコントラクトのメモリについて見ていくとしようか。

非常にシンプルなコントラクトを作成した。こいつの名は MemoryLane。たった一個の関数を持っており、2つの Array を持っている。それから b[0]に値 1 を付与する。

3 行程度のコードだけど、かなり多くのことが起きているよ。

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.3;

contract MemoryLane {
function memoryLane() public pure {
bytes32[5] memory a;
bytes32[5] memory b;
b[0] = bytes32(uint256(1));
}
}

上記のコードをもう一度 remix へ投入。

それから、コンパイルし、デプロイしてください。

memoryLane()関数を run して、debugging mode へ入ってください。

opcode を少しずつ見ていこう。

少し簡単なバージョンをEVM playgroundへ入れている。その opcode はそのようになっている。

この簡単なバージョンは opcode を順番に構成し、JUMP などのメモリ操作と関連しないことを除外した。いろいろコメントもしているので、参照してほしい。

このコードは 6 部分から構成されていて、これから dive in する。

play ground を使うことを強くおすすめする。

Free Memory Pointer Initialisation (EVM Playground Lines 1-15)

まず、“free memory pointer initialisation”からやっていく。上記でもあったように、0x80(128 in decimal)をスタックに入れる。これは free memory pointer の値として保存される。Solidity の memory のレイアウトを決めている。

この段階では、まだメモリにはなにもない。

続いて free memory pointer のロケーションの 0x40(64 in decimal)をプッシュする。

最後に、MSTORE を使って、0x40 ロケーションに 0x80 の値を記録。

この後、スタックには何もなく、メモリになにかを残せたことになる。メモリは 16 進数になっており、数字ごとに 4bit のデータを記録している。

今となって、192 の 16 進数の数字となっている(数字 2 桁 = 1 byte = 8 bit)。

先程の内容を思い出してください。最初の 64bytes は scrach space で、次の 32bytes は free memory pointer になる。これはまさに下記の状態。

Memory Allocation Variable “a” & Free Memory Pointer Update (EVM Playground Lines 16-34)

残りの部分については、簡単にするために一旦最後の飛ばしてハイレベルの概要を見てみよう。

次のメモリに関して、変数a(bytes32[5])のことを配置して free memory pointer をアップデートするということをしなければならないのがわかる。

コンパイラは array size によってどれくらいのスペースが必要か決定する。

メモリ array の要素の占める領域は 32 bytes の倍数になる。これは bytes1[]に対しても通用する。ただし、bytesstring は違う。

array のサイズ ×32bytes でどれくらいのメモリを食うかわかる。

この場合、5 * 32 bytes で、0xa0 (160)になるのだ。したがってその値は stack にプッシュされ、現在の free memory pointer に保存された。

この後、前の free memory pointer の値と足し算をして、値 0x120 (288)が生成された。この値が free memory pointer に保存された。

stack には変数aメモリをロケーション 0x80 にキープして後から参照できるようにしている。0xffffは JUMP のロケーションを意味しており、関係ないので当面は無視してよい(stack の underflow を防ぐためにある)。

Memory Initialisation Variable “a” (EVM Playground Lines 35-95)

今ではメモリに保存されて free memory pointer がアップデートされた。ここからは変数aのメモリにおける初期化をする。変数は宣言されて値代入していないので、ゼロ値になっているはず。

これをするためには、EVM の CALLDATACOPY を使って実施する。この opcode は3つの引数が必要:

  • memoryOffset(メモリロケーションへコピーする場所)
  • calldataOffset(calldata における byte offset のこと)
  • size(コピーする byte size)

今回の場合、memoryOffset はaのメモリロケーション 0x80。

calldataOffset は実際の calldata のサイズ、なぜならそもそも calldata の copy がいらない。メモリをゼロ値で初期化したい。結果的にサイズが 0xa0 あるいは 160 bytes になる。

ここで、メモリが 288 bytes になったとわかる(zero slot も含めて)。stack は今もう一度変数のメモリロケーションと JUMP ロケーションを持っている状態。

(その間に細かい操作については、何度も pop しているのがあるが、個人的にはまだなぞで、わかる方なら教えて下さい)

Memory Allocation Variable “b” & Free Memory Pointer Update (EVM Playground Lines 96-112)

ここでの操作は上記の流れと非常に似ているが、ただし、今回はbytes32[2] memory bとなる。

free memory pointer を 0x160(352 in decimal)にアップデートする(前の free memory pointer 288 + 新しい変数のサイズ 64 bytes)。

注意としては、free memory pointer はメモリにて 0x160 にアップデートされ、そこで現在は変数bのメモリロケーション(0x120)をスタックに入れてある。

Memory Initialisation Variable “b” (EVM Playground Lines 113-162)

続いて変数b初期化について説明する。

現在、メモリは 352bytes へ拡張された。スタックにはメモリロケーションを 2 個持っている。

Assign Value to b[0] (EVM Playground Lines 163-207)

やっと最後の段階に来れた。正直にいって難しかった。

ここでは、array b の index 0に値を付与したい。

コードでは b[0] = 1 となっている。

まず、この値が stack に突っ込まれる。その後、bit shift が起きるが、これ移動したのは 0 で何も変わりはないことを意味する。

次に array の index には 0x00 へ書き込まれ、0 positon へ書き込むことを意味する。その際に、array length の 2 を超えていないことをチェック。もし条件をクリアできない場合、異なるバイトコードのポジションへ飛び、エラーハンドリングの部分へ飛ばされる。

MUL、ADD opcode はメモリいにおいて値が書き込まれる場所を決めている。

0x20 (32 in decimal) * 0x00 (0 in decimal) = 0x00

メモリの array は 32 byte 単位であることを思い出してほしい。こちらは 0x00 になるので、offset は不要でそのまま 0 のポジションから書着込む。

0x00 + 0x120 = 0x120 (288 in decimal)

ADD は offset の値をメモリにある変数 b のロケーションを定義するように使われた。offset は今回 0 なので、そのまま、free memory pointer の位置に書き込むこととなる。

一番最後に、MSTORE を使って値 0x01 をメモリロケーションの 0x120 へ書き込む。そして、スタックにあるすべての要素を pop し、終了。

これでメモリはb[0] = 1となっている。下から 3 行目のところに 1 という数字が入っているのがわかる。

今回の記事はここまで!Bye!

Reference

EVM Deep Dives: The Path to Shadowy Super Coder 🥷 💻 - Part 2

· 約4分
Thurendous

Hello, everybody!

First Principle という言葉を耳にされたことはないだろうか。それは奥深く物事の基礎を理解した上で、よりよい発想を生まれることという概念だ。

スマートコントラクトの世界では、EVM とその周りにあるアルゴリズム、データ構造に関しては、まさにこの First Principle である。Solidity あるいはスマートコントラクトはこの基礎の上に作る構造物なので、EVM のことを理解せずにはよい solidity dev と称するにはまだ早いと言ってよいだろう。

基礎:Solidity → Bytecode → Opcode

まず、基礎的な部分について一定程度の知識を有することを前提としている。

  • おさらい:
    • Bytecode & ABI: バイトコードとは EVM 上で実行可能なコードで、ABI とはこの EVM バイトコードとやり取りができるための interface。
    • Solidity コードはまずバイトコードへコンパイルしてから、イーサリアムブロックチェーンへ乗せるという流れになる。バイトコードは実は色々なオペコードを意味している。

今回は基本的な solidity コントラクトについてのバイトコードの一部を一緒にみていき、EVM がどのように関数を選んでいるかを見よう。

コントラクトのバイトコードはコントラクトからコンパイルされたもので、コントラクトにはいくつかもの関数があるだろう。

よくある質問は、デプロイした後、EVM はどのようにバイトコードのどの部分を実行すべきかがわかったのかというのがある。

1_Storage.sol コントラクト

pragma solidity >=0.7.0 <0.9.0;

contract Storage {
uint256 number;

function store(uint256 num) public {
number = num;
}

function retrieve() public view returns (uint256) {
return number;
}
}

今回のコントラクトは上記のものとなる。コントラクトには二個の関数がある。store()retrieve()

runtime のバイトコードは以下の通り:

608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea2646970667358221220404e37f487a89a932dca5e77faaf6ca2de3b991f93d230604b1b8daaef64766264736f6c63430008070033

今回フォーカスするのは以下の部分:

60003560e01c80632e64cec11461003b5780636057361d1461005957

この部分は全体のバイトコードから抜き取ったもの。

関数のセレクタのロジックが含まれている。

言い遅れたが、EVM のオペコードに関しては、ここから確認できる。

オペコードは 1 byte の長さになる。そうすると自然に 256 パターンがあると分ると思う。

しかし、実際には 140 個のオペコードしか存在しない。

以下はバイトコードを関係するオペコードへ変換したもの。

60 00                       =   PUSH1 0x00
35 = CALLDATALOAD
60 e0 = PUSH1 0xe0
1c = SHR
80 = DUP1
63 2e64cec1 = PUSH4 0x2e64cec1
14 = EQ
61 003b = PUSH2 0x003b
57 = JUMPI
80 = DUP1
63 6057361d = PUSH4 0x6057361d
14 = EQ
61 0059 = PUSH2 0x0059
57 = JUMPI

スマコンの関数呼び出しや Calldata

オペコードへダイブする前に、コントラクトの関数の呼び出しについて復習しよう。

関数の呼び出しの前に、calldata に、関数の署名、そして続いて引数も入れる。

Solidity のコードに表現してもらうと、こうなるだろう。

event FunctionCalldata(bytes);
bytes memory functionCalldata = abi.encodeWithSignature('store(uint256)', 10);
emit FunctionCalldata(functionCalldata);
address(storageContract).call(functionCalldata);

今回の場合、僕は store 関数を呼び、10 引数として代入したい。

0x6057361d000000000000000000000000000000000000000000000000000000000000000a

上の長い数字の配列は abi.encodeWithSignature(”store(uint256)”,10)の結果。

関数シグネチャーは4バイトの Keccak ハッシュ値によって定義されている「0x6057361d」。

keccak256(“store(uint256)”) →  first 4 bytes = 6057361d

keccak256(“retrieve()”) → first 4 bytes = 2e64cec1

先程の calldata を見ると、全部で 36bytes あった。最初の 4bytes: 6057361dは関数のセレクタ関連で、store(uint256)と関係する。

ご自身ではッシュしてみて →ここ

6057361d = function signature (4 bytes)

000000000000000000000000000000000000000000000000000000000000000a = uint256 input (32 bytes)

以上、calldata の準備だった。

オペコード&スタック

それでは、EVM レベルへダイブしていこう。スタックについて理解しておく必要がある。わからない場合はこれを見て。

先程のオペコードはこれ:

60 00                       =   PUSH1 0x00
35 = CALLDATALOAD
60 e0 = PUSH1 0xe0
1c = SHR
80 = DUP1
63 2e64cec1 = PUSH4 0x2e64cec1
14 = EQ
61 003b = PUSH2 0x003b
57 = JUMPI
80 = DUP1
63 6057361d = PUSH4 0x6057361d
14 = EQ
61 0059 = PUSH2 0x0059
57 = JUMPI

PUSH1 は 1byte のデータをスタックへ入れることを意味する。そうすると、スタックはこうなる

PUSH1 0x00    | 0 |

続いて CALLDATALOAD は最初の stack(0)の値をポップさせる。この 0 値を input として使用し、offset として使う。スタックアイテムのサイズは 32bytes なのに対し、今回の calldata は 36bytes になっている。プッシュする値は msg.data[i : i+32]で、i は今回の input 値となる。これは毎回プッシュする値が 32bytes になることを保証できる。同時に、どの部分にもアクセルできる。

今回の場合、offset はなかったので、32bytes の calldata の値をスタックに push した。さきほど用意した calldata はこれ。やっと出番がきた。

0x6057361d000000000000000000000000000000000000000000000000000000000000000a

というのは、最後の0000000aが除外された 32bytes が今回の入力となる。

CALLDATALOAD    | 0x6057361d0...00 |

次に PUSH1 を使って hex value の 0xe0 をスタックに入れる。これは十進数だと 224。

PUSH1 0xe0      |       224        |
| 0x6057361d0...00 |

SHR を使ってライトシフトさせる。今回は最初のアイテムである 224 を取り出し、input として扱う。スタックにある二番目のアイテムをどれくらい右へライトシフトさせるかを定義している。256 - 244 = 32 bit とわかるように、最後には 4bytes のセレクタが call stack に残る。

SHR    | 0x6057361d |

DUP1 を使ってスタックの一番上の値をコピーする。

DUP1    | 0x6057361d |
| 0x6057361d |

PUSH4 を使って 4byte の関数のシグネチャーのretrieve() (0x2e64cec1) をスタックにプッシュする。

PUSH4 0x2e64cec1    | 0x2e64cec1 |
| 0x6057361d |
| 0x6057361d |

EQ は二個の値をスタックから出し、イコールなのかどうかをチェックする。もしイコールなら 1(true) をスタックにプッシュ、そうでない場合は 0(false) をプッシュする。

EQ          |      0     |
| 0x6057361d |

次に PUSH2 を使って二個の値をプッシュ。(0x003b, 十進数だと 59)

ここでは、59 が出たのはプログラムカウンターがバイトコードに次の実行コマンドはどこにあるのかを確認しているから。この 59 は retrieve()がスタート地点は 59 を意味している。

PUSH2 0x003b    |     59     |
| 0 |
| 0x6057361d |

JUMPI は”jump if”を意味する。二個の値をポップさせ、一個目の値は 59 で、二個目は 0。二個目の値は bool 値でこの jump を実行すべきかを確定している。1 = true, 0 = false。

もし true の場合、プログラムカウンターはアップデートされ実行はそちらへ jump する。今回は false なので、スキップ。

JUMPI    | 0x6057361d |

DUP1 again

DUP1    | 0x6057361d |
| 0x6057361d |

PUSH4 は 4byte の値をスタックにプッシュする

PUSH4 0x6057361d    | 0x6057361d |
| 0x6057361d |
| 0x6057361d |

また EQ して、今回は true なので。シグネチャーが合った。

EQ    |      1     |
| 0x6057361d |

JUMPI、今回は true なんで jump を実行する。プログラムカウンターは 89 で、バイトコードの違う場所へ移動。

PUSH2 0x0059    |     89     |
| 1 |
| 0x6057361d |

この場所に JUMPDEST オペコードがある。これがないと失敗する。

JUMPI    | 0x6057361d |

そこで終わり。これでオペコードの実行はstore(uint156)の場所へ移動できた。

今回は二個の関数しかないものの、たとえ 20 個の関数があったとしても、プロセスは一緒。

このリンクは非常におすすめ。触ってみると吉。

EVM のスタック・メモリ・ストレージをシミュレートしてくれる。

Reference

EVM Deep Dives: The Path to Shadowy Super Coder 🥷 💻 - Part 1

· 約2分
Thurendous
Polymetis

Hello, everybody!

EVM のインタラクションについては、外部のコントラクトを呼び出すときに、主導権は全部外部のコントラクトが握ることとなり、危険がケースがある。もしその外部のコントラクトは悪意のあるコントラクトの場合、悪用されるリスクにさらされる。

EVM(Ethereum Virtual Machine) の世界では、Re-entrancy の攻撃というのがある。

まさにこの攻撃手法が取られることがある。

攻撃のコントラクトはコントラクトに何度も入り込んで、チェックする条件が反映される前になんども同じ操作をされてしまうというやり口である。本来なら一度のみ呼ばれるものが何度もチェックを抜けて呼ばれると予期しない挙動になったりして、資産が盗まれたりする。

この攻撃手法は昔の The DAO 事件がやられた手法でもある。歴史に残る大事件だ。

他のソフトウェア環境では起こらないよう脆弱性だ。これは新しい開発者だとやりがちなミスになる。

このような状況において、Checks-Effects-Interactions を適用してください。

  • 外部のコントラクトを呼び出すのは避けられない
  • Re-entrancy 攻撃を避けたい

実装

  • まず、check

関数を呼び出してまず実行する条件をチェック(例えば十分な資金をもっているか)。

  • 次、effect

その次にすべての状態変数を更新すべき

  • 最後、interaction

最後に外部のコントラクトとのインタラクションをすべき。内部の状態が完全に更新された後に、初めて外部のコントラクトを呼び出す。

この順番さえ守れば、Re-entrancy 攻撃は最初の関門をくぐりぬけれなくなります。

もし、最後に状態の変更をするとなると、状態変更される前に何回も同じ関数の部分が呼び出されてしまい、Re-entrancy 攻撃の罠にハマってしまう。

実例

pragma solidity 0.8.13;


contract reentrancyVictim{
mapping(address => uint256) public balances;
uint256 public contractBalance;

function payIn() public payable{
balances[msg.sender] += msg.value;
}

function withdraw() public payable{
require(balances[msg.sender] > 0, "Insufficiant balance");
payable(msg.sender).call{value: balances[msg.sender]}("");
balances[msg.sender] = 0;
}

function updateContractBalance() public{
contractBalance = address(this).balance;
}
}

このコードは一見して大丈夫なように見えるが、withdraw()関数の 2 行目に十分な資産があるかを確認している。次にアセットをユーザーへ送付している。最後にバランスを更新している。

これは check-effect-interaction のルールを守っていない。

  • check: require(balances[msg.sender] > 0, "Insufficiant balance");
  • interaction: payable(msg.sender).call{value: balances[msg.sender]}("");
  • effect: balances[msg.sender] = 0;

となっており、effect と interaction の順番が逆。

その結果、Re-entrancy 攻撃が可能となる。

具体的なスキームはこんなかんじ:

adjust funds の前に、何回も何回も資産が抜き出されて、最後に資金がそこをつくことになるだろう。

Attacker のコードは以下:

pragma solidity 0.8.13;


contract reentrancyAttack{

address public victim;
uint256 public amount;
uint256 public counter;

constructor(address _victim) payable{
victim = _victim;
amount = msg.value;
}

receive() external payable{ // イーサ受け取り用のreceive関数
counter++;
attack();
}

function payIn() public returns (bool success){
(bool success, bytes memory data) = payable(victim).call{value: amount}(abi.encodeWithSignature("payIn()"));
}

function withdrawAttack() public{
if(counter < 4){
// もう一度withdrawを呼ぶ用
payable(victim).call(abi.encodeWithSignature("withdraw()"));
}
}
}

ソリューション

その対策としては、実はものすごくシンプルにwithdraw()の第 3, 4 行を逆転させて、修正を加える。

 function withdraw() public payable{
// check
require(balances[msg.sender] > 0, "Insufficiant balance");
// effect
uint256 payBalance = balances[msg.sender];
balances[msg.sender] = 0;
// interaction
(bool success, bytes memory data) = payable(msg.sender).call{value: payBalance}("");
if(!success){ // catch the case where the send was unsuccesful
balances[msg.sender] = payBalance;
}
}

もし、success が false の場合、バランスを元通りに戻す操作をしている。 // interaction の行において reenter されても、すでにバランスはゼロなので、revert され、問題はなくなった。

THE END

· 約3分
Thurendous
Polymetis

Hi Hi, never give up!

ここでは、opcode(オペレーションコード)について語るには EVM についても語る必要がある。

他のプログラミング言語と同じように、solidity はハイレベルなプログラミング言語であり、人間が読みやすい一方で、コンピューターは理解できない。私達が geth をインストールすると、それに加えて Ethereum Virtual Machine がつきものとしてついてくる。EVM とは軽量級の OS みたいなもので、スマートコントラクトをランする環境として用意されている。

Solidity のコードを solc コンパイラを使って compile するときに、コードをバイトコードに変換される。EVM はバイトコードが理解できる。

下記のスマートコントラクトの例を見てみよう。

pragma solidity ^0.4.11;
contract MyContract {
uint i = (10 + 2) * 2;
}

この場合、remix browser で走らせた場合、コントラクトの details を見てみてください。そうすると、以下の情報が見えてくる。

このケースでは、コンパイルされたコードは:

60606040525b600080fd00a165627a7a7230582012c9bd00152fa1c480f6827f81515bb19c3e63bf7ed9ffbb5fda0265983ac7980029

このデータはヘクサデシマルデータとして現れ、コントラクトの最終の形となっている。バイトコードと呼ばれているものだ。

実際にコントラクトをデプロイするときには、このバイトコードをデプロイしていることとイコール。

ウォレットあるいはアドレスの前にある"0x"とは何を意味するのだろうか。"0x"からスタートするものはこれは EVM と会話をするには、EVM はどんなデータでも 16 進数のデータとして扱うのがデフォルトだからである。

opcode もある。

PUSH1 0x60 PUSH1 0x40 MSTORE PUSH1 0x18 PUSH1 0x0 SSTORE CALLVALUE ISZERO PUSH1 0x13 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST JUMPDEST PUSH1 0x36 DUP1 PUSH1 0x21 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x60 PUSH1 0x40 MSTORE JUMPDEST PUSH1 0x0 DUP1 REVERT STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 SLT 0xc9 0xbd STOP ISZERO 0x2f LOG1 0xc4 DUP1 0xf6 DUP3 PUSH32

opcode の情報については、yellow paper に書かれている。

EVM はスタックマシンである。

簡単に理解するために、以下を参考にしてください。

まずこのルールは伝統的なパソコンの LIFO と同じである。

通常の計算の場合:

10 + 2 * 2
// 答えは14

// stack machineの場合
2 2 * 10 +

上記はスタックマシンの場合で、まず2をいれて次の2を入れて、次に乗法記号の*をいれて、計算し 4 になり、次に 10、最後にプラス記号で加法計算して、14 になる。

これで 14 の結果になる。

スタックに入れる命令はPUSHといい、スタックから除外する命令はPOPという。一番良く見るパターンとしては、PUSH1があり、それは 1byte のデータをスタックにいれる

PUSH1 0x60

これは 1byte のデータとして0x60をスタックに入れることを意味する。そして、PUSH1の 16 進数表示は偶然に0x60と同じ。ここで、0x を除外すると、6060になる。

PUSH1 0x60 PUSH1 0x40 MSTORE

MSTORE は2つの入力が求められ、output なし。なので、上記の解釈をすると、

  1. PUSH1(0x60): put 0x60 in the stack
  2. PUSH1(0x40): put 0x40 in the stack
  3. MSTORE(0x52): allocate 0x60 of memory space and move to the 0x40 position

実はいつも同じようなマジックナンバー6060604052を見るのは、solidity bytecode の誘導の始まりだからだ。

また、実は 16 進数ということは、10 進数で考えると、40 は 64 で 60 は 96 になっている。

“PUSH1 0x60 PUSH1 0x40 MSTORE”がやっているのは、96bytes のメモリーを allocate し、ポインターを 64 個目のバイトの開始位置に移動することになる。

これで、64byte のスクラッチスペースと 32byte の一時的なメモリを確保することができた。

EVM において、3 種類のデータ保存領域がある。

  • 今紹介したスタックにある場所
  • RAM のメモリー領域で、MSTORE の opcode のところ
  • SSTORE の永久保存領域(とてもガス代がかかる)

そして、Assembly Language を使って、スマートコントラクトをかける。これによって opcode を使うことができる。結構難しいけど、有用なときもある。solidity のみではできないようなこともできてしまう。

THE END

Bernard さんの記事を参考に抜粋・翻訳したりしている。