paint-brush
Solidity のスマート コントラクト セキュリティに関する 2023 年の調査@rareskills
6,103 測定値
6,103 測定値

Solidity のスマート コントラクト セキュリティに関する 2023 年の調査

RareSkills53m2023/05/16
Read on Terminal Reader

長すぎる; 読むには

Solidity のセキュリティ問題は、つまるところ、スマート コントラクトが意図したとおりに動作しないことにあります。これは大きく 4 つのカテゴリに分類されます。 資金が盗まれる 契約内で資金がロックアップまたは凍結される 人々が受け取る報酬が予想よりも少ない(報酬が遅れたり、減額される) 問題が発生する可能性のあるすべての包括的なリストを作成することは不可能です。
featured image - Solidity のスマート コントラクト セキュリティに関する 2023 年の調査
RareSkills HackerNoon profile picture
0-item
1-item
2-item


Solidity の脆弱性のリスト

この記事はスマート コントラクトのセキュリティに関するミニコースとして機能し、Solidity スマート コントラクトで再発する傾向がある問題と脆弱性の広範なリストを提供します。これらは、品質監査で取り上げられる可能性のある種類の問題です。


Solidity のセキュリティ問題は、つまるところ、スマート コントラクトが意図したとおりに動作しないことにあります。


これは、次の 4 つの大きなカテゴリに分類できます。

  • 資金が盗まれる

  • 契約内で資金がロックアップまたは凍結される

  • 人々が受け取る報酬が予想よりも少ない(報酬が遅れたり、減額されたりする)

  • 人々は予想よりも多くの報酬を受け取ります(インフレと価値の切り下げにつながります)


問題が発生する可能性のあるすべての包括的なリストを作成することは不可能です。ただし、従来のソフトウェア エンジニアリングに SQL インジェクション、バッファ オーバーラン、クロスサイト スクリプティングなどの脆弱性という共通のテーマがあるのと同様に、スマート コントラクトにも文書化できる繰り返し発生するアンチパターンがあります。


このガイドは単なる参考資料として考えてください。これを本にすることなくすべてのコンセプトを詳細に議論することは不可能です (公正な警告: この記事は 10,000 ワード以上の長さなので、ブックマークして少しずつ読んでも構いません)。ただし、これは、何に注意し、何を勉強すべきかのリストとして役立ちます。トピックに馴染みのないものがある場合は、そのクラスの脆弱性を特定する練習に時間を費やす価値があることを示す指標となります。

前提条件

この記事は、 Solidityの基本的な習熟度を前提としています。 Solidity を初めて使用する場合は、無料の Solidity チュートリアルを参照してください。

リエントランシー

スマート コントラクトのリエントラントについては詳しく書いてきたので、ここでは繰り返しません。ただし、簡単な要約は次のとおりです。


スマート コントラクトが別のスマート コントラクトの関数を呼び出したり、スマート コントラクトにイーサを送信したり、トークンを転送したりすると、再入の可能性が生じます。


  • イーサが転送されると、受信コントラクトのフォールバックまたは受信関数が呼び出されます。これにより、制御が受信機に渡されます。
  • 一部のトークン プロトコルは、所定の関数を呼び出すことによって、受信側スマート コントラクトにトークンを受信したことを警告します。これにより、制御フローがその関数に渡されます。
  • 攻撃側のコントラクトが制御を受け取った場合、制御を渡したときと同じ関数を呼び出す必要はありません。被害者のスマート コントラクトの別の関数 (機能間リエントランシー) や、別のコントラクト (コントラクト間リエントランシー) を呼び出す可能性もあります。
  • コントラクトが中間状態にあるときにビュー関数にアクセスすると、読み取り専用の再入が発生します。


再入可能性はおそらく最もよく知られているスマート コントラクトの脆弱性であるにもかかわらず、実際に発生するハッキングのほんの一部にすぎません。セキュリティ研究者の Pascal Caversaccio (pcaveraccio) は、再入攻撃の最新の Github リストを保管しています。 2023 年 4 月の時点で、そのリポジトリには 46 件の再入攻撃が記録されています。

アクセス制御

単純な間違いのように思えますが、機密機能 (イーサの引き出しや所有権の変更など) を呼び出すことができるユーザーに制限を設けるのを忘れることは、驚くほど頻繁に発生します。


修飾子が指定されている場合でも、次の例のように require ステートメントが欠落しているなど、修飾子が正しく実装されていない場合があります。

 // DO NOT USE! modifier onlyMinter { minters[msg.sender] == true_; }

上記のコードは、この監査の実際の例です: https://code4rena.com/reports/2023-01-rabbithole/#h-01-bad-implementation-in-minter-access-control-for-rabbitholereceipt-and- Rabbitholetickets-契約


アクセス制御が失敗するもう一つの原因は次のとおりです

function claimAirdrop(bytes32 calldata proof[]) { bool verified = MerkleProof.verifyCalldata(proof, merkleRoot, keccak256(abi.encode(msg.sender))); require(verified, "not verified"); require(alreadyClaimed[msg.sender], "already claimed"); _transfer(msg.sender, AIRDROP_AMOUNT); }

この場合、「alreadyClaimed」が true に設定されることはないため、要求者は関数の呼び出しを複数回発行できます。

実際の例: トレーダーボットが悪用される

不十分なアクセス制御のごく最近の例は、取引ボット (アドレスがそのシーケンスで始まるため、0xbad という名前で呼ばれました) によってフラッシュローンを受け取る保護されていない機能でした。ある日、フラッシュローンプロバイダーだけでなく、どのアドレスでもフラッシュローン受信関数を呼び出せることに攻撃者が気づくまで、100万ドル以上の利益を上げていました。


取引ボットの場合はよくあることですが、取引を実行するためのスマート コントラクト コードは検証されていませんでしたが、とにかく攻撃者は弱点を発見しました。詳細については、 rekt のニュース報道をご覧ください。

不適切な入力検証

アクセス制御が関数を呼び出すユーザーを制御することである場合、入力検証はコントラクトを呼び出す相手を制御することです。


これは通常、適切な require ステートメントを配置するのを忘れたことが原因で発生します。

基本的な例を次に示します。

 contract UnsafeBank { mapping(address => uint256) public balances; // allow depositing on other's behalf function deposit(address for) public payable { balances += msg.value; } function withdraw(address from, uint256 amount) public { require(balances[from] <= amount, "insufficient balance"); balances[from] -= amount; msg.sender.call{value: amout}(""); } }

上記の契約では、アカウントにある以上の金額を引き出していないことを確認しますが、任意のアカウントからの引き出しを阻止するものではありません。

実際の例: Sushiswap

Sushiswap では、外部関数のパラメータの 1 つがサニタイズされていなかったため、この種のハッキングが発生しました。

寿司交換ハック

不適切なアクセス制御と不適切な入力検証の違いは何ですか?

アクセス制御が不適切であるということは、msg.sender に適切な制限がないことを意味します。入力検証が不適切であるということは、関数の引数が十分にサニタイズされていないことを意味します。このアンチパターンの逆もあり、関数呼び出しに過剰な制限を課すことになります。

過度な機能制限

過剰な検証はおそらく資金が盗まれないことを意味しますが、資金が契約にロックされる可能性があります。過剰な安全策を講じることも良いことではありません。

実際の例: Akutars NFT

最も注目を集めた事件の 1 つは、Akutars NFT で、最終的に 3,400 万ドル相当の Eth がスマート コントラクト内に閉じ込められ、引き出すことができなくなりました。


この契約には、オランダのオークション価格を超えて支払われたすべての払い戻しが行われるまで、契約の所有者が撤回できないようにするための善意の仕組みが組み込まれていました。しかし、以下にリンクされている Twitter スレッドに記載されているバグにより、所有者は資金を引き出すことができませんでした。

Akutars NFTの脆弱性

バランスを正しく取る

Sushiswap は信頼できないユーザーに多すぎる権限を与え、Akutars NFT は管理者に与える権限が少なすぎました。スマート コントラクトを設計する場合、各クラスのユーザーにどの程度の自由を与えるかについて主観的な判断を下す必要があり、この決定を自動化されたテストやツールに任せることはできません。分散化、セキュリティ、UX には考慮する必要がある重大なトレードオフがあります。


スマート コントラクト プログラマーにとって、ユーザーが特定の機能で何を実行すべきか、何を実行できないかを明示的に記述することは、開発プロセスの重要な部分です。

強力な管理者のトピックについては、後でもう一度取り上げます。

多くの場合、セキュリティは、契約からお金が流出する方法を管理することに要約されます。

冒頭で述べたように、スマート コントラクトがハッキングされる主な方法は次の 4 つです。


  • 盗まれたお金
  • お金が凍結される
  • 報酬が不十分
  • 過剰な報酬


ここでの「お金」とは、仮想通貨だけでなく、トークンなどの価値のあるものを指します。スマート コントラクトをコーディングまたは監査するとき、開発者はコントラクトに価値が流入および流出する意図された方法に注意を払う必要があります。上記の問題はスマート コントラクトがハッキングされる主な方法ですが、重大な問題に連鎖する可能性のある根本原因は他にもたくさんあります。それについては以下で説明します。

二重投票またはメッセージ送信者のなりすまし

攻撃者が 1 つのアドレスで投票し、トークンを別のアドレスに転送し、そのアドレスから再度投票する可能性があるため、バニラ ERC20 トークンまたは NFT を投票を評価するためのチケットとして使用することは安全ではありません。

最小限の例を次に示します。

 // A malicious voter can simply transfer their tokens to // another address and vote again. contract UnsafeBallot { uint256 public proposal1VoteCount; uint256 public proposal2VoteCount; IERC20 immutable private governanceToken; constructor(IERC20 _governanceToken) { governanceToken = _governanceToken; } function voteFor1() external notAlreadyVoted { proposal1VoteCount += governanceToken.balanceOf(msg.sender); } function voteFor2() external notAlreadyVoted { proposal2VoteCount += governanceToken.balanceOf(msg.sender); } // prevent the same address from voting twice, // however the attacker can simply // transfer to a new address modifier notAlreadyVoted { require(!alreadyVoted[msg.sender], "already voted"); _; alreadyVoted[msg.sender] = true; } }

この攻撃を防ぐには、 ERC20 スナップショットまたはERC20 投票を使用する必要があります。過去のある時点のスナップショットを取得することで、現在のトークン残高を操作して不正な投票権を得ることができなくなります。

フラッシュローンガバナンス攻撃

ただし、フラッシュローンを利用して一時的に残高を増やし、同じトランザクションで残高のスナップショットを取得できる場合、スナップショットまたは投票機能を備えたERC20トークンを使用しても、問題は完全には解決されません。そのスナップショットが投票に使用されると、不当に大量の票を自由に使えることになります。


フラッシュローンは、アドレスに大量のイーサまたはトークンを貸し出しますが、同じ取引でお金が返済されない場合は元に戻ります。

 contract SimpleFlashloan { function borrowERC20Tokens() public { uint256 before = token.balanceOf(address(this)); // send tokens to the borrower token.transfer(msg.sender, amount); // hand control back to the borrower to // let them do something IBorrower(msg.sender).onFlashLoan(); // require that the tokens got returned require(token.balanceOf(address(this) >= before); } }

攻撃者はフラッシュローンを使用して突然大量の票を獲得し、提案を自分たちに有利に動かしたり、悪意のあることを実行したりする可能性があります。

フラッシュローンの価格攻撃

これはおそらく、DeFi に対する最も一般的な (または少なくとも最も注目を集めた) 攻撃であり、数億ドルの損失を引き起こしています。ここでは注目度の高いもののリストを示します。


ブロックチェーン上の資産の価格は、多くの場合、資産間の現在の為替レートとして計算されます。たとえば、契約が現在 100 k9coin に対して 1 USDC で取引されている場合、k9coin の価格は 0.01 USDC であると言えます。ただし、一般的に価格は売買圧力に応じて変動するため、フラッシュローンは巨大な売買圧力を引き起こす可能性があります。


資産の価格について別のスマート コントラクトをクエリする場合、開発者は、呼び出しているスマート コントラクトがフラッシュ ローン操作の影響を受けないと想定しているため、細心の注意を払う必要があります。

契約チェックを回避する

バイトコード サイズを調べることで、アドレスがスマート コントラクトであるかどうかを「確認」できます。外部所有のアカウント (通常のウォレット) にはバイトコードがありません。いくつかの方法をご紹介します

import "@openzeppelin/contracts/utils/Address.sol" contract CheckIfContract { using Address for address; function addressIsContractV1(address _a) { return _a.code.length == 0; } function addressIsContractV2(address _a) { // use the openzeppelin libraryreturn _a.isContract(); } }


ただし、これにはいくつかの制限があります

  • コントラクトがコンストラクターから外部呼び出しを行う場合、スマート コントラクトのデプロイメント コードがランタイム コードをまだ返していないため、バイトコード サイズは明らかにゼロになります。
  • 現在そのスペースは空である可能性がありますが、攻撃者は将来、create2 を使用してそこにスマート コントラクトを展開できることを知っている可能性があります。


一般に、アドレスがコントラクトであるかどうかを確認することは、通常 (常にではありませんが) アンチパターンです。マルチシグネチャ ウォレット自体がスマート コントラクトであり、マルチシグネチャ ウォレットを破壊する可能性のあることを行うと、コンポーザビリティが破壊されます。


例外は、転送フックを呼び出す前にターゲットがスマート コントラクトであるかどうかを確認することです。これについては後で詳しく説明します。

tx.origin

tx.origin を使用する正当な理由はほとんどありません。 tx.origin を使用して送信者を特定すると、中間者攻撃が可能になります。ユーザーがだまされて悪意のあるスマート コントラクトを呼び出すと、スマート コントラクトは tx.origin が持つすべての権限を使用して大混乱を引き起こす可能性があります。


次の演習とコードの上のコメントを考慮してください。

 contract Phish { function phishingFunction() public { // this fails, because this contract does not have approval/allowance token.transferFrom(msg.sender, address(this), token.balanceOf(msg.sender)); // this also fails, because this creates approval for the contract,// not the wallet calling this phishing function token.approve(address(this), type(uint256).max); } }


これは、任意のスマート コントラクトを安全に呼び出すことができるという意味ではありません。ただし、ほとんどのプロトコルには安全層が組み込まれており、tx.origin が認証に使用される場合はバイパスされます。

場合によっては、次のようなコードが表示されることがあります。

 require(msg.sender == tx.origin, "no contracts");


スマート コントラクトが別のスマート コントラクトを呼び出す場合、msg.sender がスマート コントラクトとなり、tx.origin がユーザーのウォレットとなるため、着信呼び出しがスマート コントラクトからのものであることを確実に示します。これは、呼び出しがコンストラクターから行われた場合にも当てはまります。


ほとんどの場合、このデザイン パターンは良いアイデアではありません。マルチシグネチャウォレットと EIP 4337 のウォレットは、このコードを持つ関数と対話できません。このパターンは NFT ミントでよく見られ、ほとんどのユーザーが従来のウォレットを使用していると予想できます。しかし、アカウントの抽象化が普及するにつれて、このパターンは役立つどころか、むしろ妨げになってしまいます。

ガスグリーティングまたはサービス拒否

グリーフィング攻撃とは、たとえ経済的に利益を得られなかったとしても、ハッカーが他の人々に「悲しみを引き起こそうと」していることを意味します。


スマート コントラクトは、無限ループに入ることで、転送されたすべてのガスを悪意を持って使い果たす可能性があります。次の例を考えてみましょう。

 contract Mal { fallback() external payable { // infinite loop uses up all the gas while (true) { } } }


別のコントラクトが次のようなアドレスのリストにイーサを配布するとします。

 contract Distribute { funtion distribute(uint256 total) public nonReentrant { for (uint i; i < addresses.length; ) { (bool ok, ) addresses.call{value: total / addresses.length}(""); // ignore ok, if it reverts we move on // traditional gas saving trick for for loops unchecked { ++i; } } } }


その後、Ether を Mal に送信すると、関数は元に戻ります。上記のコードの呼び出しでは、利用可能なガスの 63 / 64 が転送されるため、ガスが 1/64 しか残っていない状態で操作を完了するのに十分なガスがない可能性があります。


スマート コントラクトは、大量のガスを消費する大規模なメモリ配列を返す可能性があります

次の例を考えてみましょう

function largeReturn() public { // result might be extremely long! (book ok, bytes memory result) = otherContract.call(abi.encodeWithSignature("foo()")); require(ok, "call failed"); }

メモリ配列は 724 バイト以降、2 次量のガスを消費するため、慎重に選択された戻りデータ サイズは呼び出し元を悩ませる可能性があります。


変数 result が使用されない場合でも、メモリにコピーされます。返品サイズを一定量に制限したい場合は、アセンブリを使用できます。

 function largeReturn() public { assembly { let ok := call(gas(), destinationAddress, value, dataOffset, dataSize, 0x00, 0x00); // nothing is copied to memory until you // use returndatacopy() } }

他の人が追加できる配列を削除することもサービス拒否ベクトルです

ストレージの消去はガス効率の高い操作ですが、それでも純コストがかかります。配列が長くなりすぎると削除できなくなります。これは最小限の例です

contract VulnerableArray { address[] public stuff; function addSomething(address something) public { stuff.push(something); } // if stuff is too long, this will become undeletable due to // the gas cost function deleteEverything() public onlyOwner { delete stuff; } }

ERC777、ERC721、ERC1155 もグリーフィングベクターとなる可能性があります

スマート コントラクトが転送フックを持つトークンを転送する場合、攻撃者はトークンを受け入れないコントラクトを設定する可能性があります (onReceive 関数がないか、元に戻す関数をプログラムするかのいずれかです)。これにより、トークンは転送できなくなり、トランザクション全体が元に戻ります。


safeTransfer または transfer を使用する前に、受信者がトランザクションを強制的に元に戻す可能性を考慮してください。

 contract Mal is IERC721Receiver, IERC1155Receiver, IERC777Receiver { // this will intercept any transfer hook fallback() external payable { // infinite loop uses up all the gaswhile (true) { } } // we could also selectively deny transactions function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { if (wakeUpChooseViolence()) { revert(); } else { return IERC721Receiver.onERC721Received.selector; } } }

安全でないランダム性

現時点では、ブロックチェーン上の単一トランザクションでランダム性を安全に生成することはできません。ブロックチェーンは完全に決定論的である必要があり、そうでないと分散ノードは状態についての合意に達することができません。それらは完全に決定論的であるため、あらゆる「乱数」を予測できます。以下のダイスロール機能を活用できます。


 contract UnsafeDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } // our dice can land on one of {0,1,2,3,4,5}function rollDice() public payable { require(msg.value == 1 ether); if (randomness() % 6) == 5) { msg.sender.call{value: 2 ether}(""); } } } contract ExploitDice { function randomness() internal returns (uint256) { return keccak256(abi.encode(msg.sender, tx.origin, block.timestamp, tx.gasprice, blockhash(block.number - 1); } function betSafely(IUnsafeDice game) public payable { if (randomness % 6) == 5)) { game.betSafely{value: 1 ether}() } // else don't do anything } }


攻撃者はランダム性を正確に複製できるため、ランダム性をどのように生成するかは問題ではありません。スマートコントラクトはエントロピーを 2 つ測定できるため、msg.sender、タイムスタンプなどの「エントロピー」のソースをさらに投入しても効果はありません。

チェーンリンクのランダム性オラクルの使用は間違っています

Chainlink は、安全な乱数を取得するための一般的なソリューションです。それは 2 つのステップで行われます。まず、スマート コントラクトがオラクルにランダム性リクエストを送信し、数ブロック後にオラクルが乱数で応答します。


攻撃者は未来を予測できないため、乱数を予測することもできません。

スマートコントラクトがオラクルを間違って使用しない限り。


  • ランダム性を要求するスマート コントラクトは、乱数が返されるまで何も実行してはなりません。それ以外の場合、攻撃者は乱数を返すオラクルのメモリプールを監視し、乱数が何になるかを知ってオラクルをフロントランする可能性があります。
  • ランダムオラクル自体がアプリケーションを操作しようとする可能性があります。他のノードからのコンセンサスなしに乱数を選択することはできませんが、アプリケーションが同時に複数の乱数を要求した場合は、乱数を保留したり並べ替えたりすることはできます。
  • Ethereumやその他のほとんどの EVM チェーンでは、ファイナリティは即時ではありません。あるブロックが最新のものであるからといって、必ずしもそのブロックがそのまま残るとは限りません。これを「連鎖再組織化」といいます。実際、チェーンは最後のブロック以外にも変更を加えることができます。これを「再組織の深さ」と呼びます。 Etherscan は、Ethereum の再組織や Polygon の再組織など、さまざまなチェーンの再組織をレポートします。 Polygon では再編成の深さが 30 ブロック以上になる可能性があるため、待機ブロックが少ないとアプリケーションが脆弱になる可能性があります (ファイナリティはイーサリアムのものと一致するため、zk-evm が Polygon の標準コンセンサスになるとこれは変わる可能性がありますが、これは将来の予測です) 、現在に関する事実ではありません)。

価格 Oracle から古いデータを取得する

Chainlink には、一定の期間内に価格オラクルを最新の状態に保つための SLA (サービス レベル アグリーメント) はありません。チェーンが極度に混雑している場合(Yuga Labs Otherside のミントがイーサリアムを圧倒し、トランザクションが実行されなくなった場合など)、価格の更新が遅れる可能性があります。


価格オラクルを使用するスマート コントラクトは、データが古くないこと、つまり、あるしきい値内で最近更新されたことを明示的にチェックする必要があります。そうしないと、価格に関して信頼できる決定を下すことができません。


価格が偏差しきい値を超えて変化しない場合、オラクルはガスを節約するために価格を更新しない可能性があるため、さらに複雑になります。そのため、これは、どの時点のしきい値が「古い」とみなされるかに影響を与える可能性があります。


スマート コントラクトが依存するオラクルの SLA を理解することが重要です。

たった一つの神託に頼る

オラクルがどれほど安全であるように見えても、将来的に攻撃が発見される可能性があります。これに対する唯一の防御策は、複数の独立したオラクルを使用することです。

一般的にオラクルを正しく理解するのは難しい

ブロックチェーンは非常に安全ですが、最初にデータをチェーンに置くと、ブロックチェーンが提供するセキュリティ保証がすべて無視される、ある種のオフチェーン操作が必要になります。たとえオラクルが誠実であったとしても、そのデータソースは操作される可能性があります。たとえば、オラクルは集中取引所から価格を確実に報告できますが、大規模な売買注文によって価格が操作される可能性があります。同様に、センサー データや一部の Web2 API に依存するオラクルは、従来のハッキング ベクトルの影響を受けます。


優れたスマート コントラクト アーキテクチャでは、可能な限りオラクルの使用を完全に回避します。

混合会計

次の契約を考えてみましょう

contract MixedAccounting { uint256 myBalance; function deposit() public payable { myBalance = myBalance + msg.value; } function myBalanceIntrospect() public view returns (uint256) { return address(this).balance; } function myBalanceVariable() public view returns (uint256) { return myBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }


上記のコントラクトには受信またはフォールバック機能がないため、イーサを直接転送すると元に戻ります。ただし、コントラクトは、自己破壊を使用して強制的にイーサを送信することができます。


その場合、myBalanceIntrospect() は myBalanceVariable() より大きくなります。イーサアカウンティング方法は問題ありませんが、両方を使用すると、コントラクトの動作が矛盾する可能性があります。


ERC20トークンにも同じことが当てはまります。

 contract MixedAccountingERC20 { IERC20 token; uint256 myTokenBalance; function deposit(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); myTokenBalance = myTokenBalance + amount; } function myBalanceIntrospect() public view returns (uint256) { return token.balanceOf(address(this)); } function myBalanceVariable() public view returns (uint256) { return myTokenBalance; } function notAlwaysTrue() public view returns (bool) { return myBalanceIntrospect() == myBalanceVariable(); } }

繰り返しになりますが、myBalanceIntrospect() と myBalanceVariable() が常に同じ値を返すとは想定できません。デポジット関数をバイパスし、myTokenBalance 変数を更新せずに、ERC20 トークンを MixedAccountingERC20 に直接転送することが可能です。


内省でバランスをチェックする場合、バランスは部外者によって自由に変更される可能性があるため、厳密な等価性チェックを使用することは避けるべきです。

暗号証明をパスワードのように扱う

これは Solidity の癖ではなく、暗号化を使用してアドレスに特別な権限を与える方法について開発者の間でよくある誤解です。次のコードは安全ではありません

contract InsecureMerkleRoot { bytes32 merkleRoot; function airdrop(bytes[] calldata proof, bytes32 leaf) external { require(MerkleProof.verifyCalldata(proof, merkleRoot, leaf), "not verified"); require(!alreadyClaimed[leaf], "already claimed airdrop"); alreadyClaimed[leaf] = true; mint(msg.sender, AIRDROP_AMOUNT); } }


このコードは次の 3 つの理由により安全ではありません。

  1. エアドロップ用に選択されたアドレスを知っている人なら誰でも、マークル ツリーを再作成して有効な証明を作成できます。
  2. リーフはハッシュ化されていません。攻撃者はマークル ルートと等しいリーフを送信し、require ステートメントをバイパスすることができます。
  3. 上記 2 つの問題が解決されたとしても、誰かが有効な証拠を提出すると、最前線で争われる可能性があります。


暗号証明 (マークル ツリー、署名など) は msg.sender に結び付ける必要があり、攻撃者は秘密キーを取得しない限りこれを操作できません。

Solidity が最終的な単位サイズにアップキャストされない

function limitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = a * b; }

product はuint256変数ですが、乗算結果は 255 を超えることはできません。255 を超えるとコードが元に戻ります。


この問題は、各変数を個別にアップキャストすることで軽減できます。

 function unlimitedMultiply(uint8 a, uint8 b) public pure returns (uint256 product) { product = uint256(a) * uint256(b); }

構造体にパックされた整数を乗算すると、このような状況が発生する可能性があります。構造体にパックされた小さな値を乗算するときは、このことに注意する必要があります。

 struct Packed { uint8 time; uint16 rewardRate } //... Packed p; p.time * p.rewardRate; // this might revert!

Solidity のダウンキャストがオーバーフロー時に元に戻らない

Solidity は、整数をより小さい整数にキャストしても安全かどうかをチェックしません。何らかのビジネス ロジックによってダウンキャストが安全であることが保証されていない限り、 SafeCastなどのライブラリを使用する必要があります。

 function test(int256 value) public pure returns (int8) { return int8(value + 1); // overflows and does not revert }

ストレージ ポインターへの書き込みでは、新しいデータは保存されません。

このコードは、myArray[1] のデータを myArray[0] にコピーしているように見えますが、実際はコピーしません。関数の最後の行をコメントアウトすると、コンパイラは関数をビュー関数に変換する必要があると通知します。 foo への書き込みは、基礎となるストレージには書き込みません。


 contract DoesNotWrite { struct Foo { uint256 bar; } Foo[] public myArray; function moveToSlot0() external { Foo storage foo = myArray[0]; foo = myArray[1]; // myArray[0] is unchanged // we do this to make the function a state // changing operation // and silence the compiler warning myArray[1] = Foo({bar: 100}); } }

したがって、ストレージ ポインターには書き込まないでください。

動的データ型を含む構造体を削除しても、動的データは削除されません

マッピング (または動的配列) が構造体の内部にあり、その構造体が削除された場合、マッピングまたは配列は削除されません。


アレイの削除を除き、delete キーワードで削除できるのは 1 つのストレージ スロットのみです。ストレージ スロットに他のストレージ スロットへの参照が含まれている場合、それらは削除されません。

 contract NestedDelete { mapping(uint256 => Foo) buzz; struct Foo { mapping(uint256 => uint256) bar; } Foo foo; function addToFoo(uint256 i) external { buzz[i].bar[5] = 6; } function getFromFoo(uint256 i) external view returns (uint256) { return buzz[i].bar[5]; } function deleteFoo(uint256 i) external { // internal map still holds the data in the // mapping and array delete buzz[i]; } }


それでは、次のトランザクションシーケンスを実行してみましょう

  1. addToFoo(1)
  2. getFromFoo(1) は 6 を返します
  3. 削除Foo(1)
  4. getFromFoo(1) は依然として 6 を返します。


Solidity ではマップが「空」になることはありません。したがって、誰かが削除された項目にアクセスした場合、トランザクションは元に戻らず、代わりにそのデータ型のゼロ値を返します。

ERC20トークンの問題

信頼できる ERC20 トークンのみを扱う場合、これらの問題のほとんどは当てはまりません。ただし、任意の、または部分的に信頼できない ERC20 トークンを操作する場合は、次の点に注意する必要があります。

ERC20: 送金手数料

信頼できないトークンを扱う場合、残高が必ずしもその分だけ増えるとは考えるべきではありません。 ERC20 トークンは次のように伝達関数を実装できます。

 contract ERC20 { // internally called by transfer() and transferFrom() // balance and approval checks happen in the caller function _transfer(address from, address to, uint256 amount) internal returns (bool) { fee = amount * 100 / 99; balanceOf[from] -= to; balanceOf[to] += (amount - fee); balanceOf[TREASURY] += fee; emit Transfer(msg.sender, to, (amount - fee)); return true; } }


このトークンには、すべての取引に 1% の税金が適用されます。したがって、スマート コントラクトが次のようにトークンと対話すると、予期しないリバートが発生するか、お金が盗まれることになります。

 contract Stake { mapping(address => uint256) public balancesInContract; function stake(uint256 amount) public { token.transferFrom(msg.sender, address(this), amount); balancesInContract[msg.sender] += amount; // THIS IS WRONG! } function unstake() public { uint256 toSend = balancesInContract[msg.sender]; delete balancesInContract[msg.sender]; // this could revert because toSend is 1% greater than// the amount in the contract. Otherwise, 1% will be "stolen"// from other depositors. token.transfer(msg.sender, toSend); } }

ERC20: トークンのリベース

リベース トークンは、 Olympus DAOの sOhm トークンとAmpleforth のAMPL トークンによって普及しました。 Coingecko は、リベース ERC20 トークンのリストを管理しています。


トークンがリベースされると、総供給量が変化し、リベースの方向に応じて全員の残高が増減します。


次のコードは、リベース トークンを処理するときに壊れる可能性があります。

 contract WillBreak { mapping(address => uint256) public balanceHeld; IERC20 private rebasingToken function deposit(uint256 amount) external { balanceHeld[msg.sender] = amount; rebasingToken.transferFrom(msg.sender, address(this), amount); } function withdraw() external { amount = balanceHeld[msg.sender]; delete balanceHeld[msg.sender]; // ERROR, amount might exceed the amount // actually held by the contract rebasingToken.transfer(msg.sender, amount); } }


多くのコントラクトの解決策は、単純にトークンのリベースを禁止することです。ただし、アカウント残高を送信者に転送する前に、balanceOf(address(this)) をチェックするように上記のコードを変更することもできます。そうすれば、バランスが変わっても機能します。

ERC20: ERC20 衣類を着た ERC777

ERC20 が標準に従って実装されている場合、ERC20 トークンには転送フックがないため、transfer と transferFrom には再入可能の問題はありません。


転送フックを備えたトークンには大きな利点があり、それがすべての NFT 標準が転送フックを実装する理由であり、ERC777 が最終決定された理由です。しかし、これは十分な混乱を引き起こしたため、Openzeppelin は ERC777 ライブラリを非推奨にしました


ERC20 トークンのように動作するが転送フックを備えたトークンとプロトコルに互換性を持たせたい場合は、関数 transfer と transferFrom を、受信側に関数呼び出しを発行するかのように扱うだけで済みます。


この ERC777 の再入は Uniswap で発生しました (Openzeppelin のエクスプロイトについては、興味があればここに記載しています)。

ERC20: すべての ERC20 トークンが true を返すわけではありません

ERC20 仕様では、転送が成功した場合、 ERC20 トークンはtrue を返す必要があると規定されています。ほとんどの ERC20 実装は、許容量が不十分な場合や転送量が多すぎる場合を除いて失敗することはないため、ほとんどの開発者は ERC20 トークンの戻り値を無視し、失敗した転送は元に戻ると想定することに慣れています。


率直に言って、動作がわかっている信頼できる ERC20 トークンのみを使用している場合、これは重要ではありません。ただし、任意の ERC20 トークンを扱う場合は、この動作の差異を考慮する必要があります。


ほとんどの ERC20 トークンには false を返すメカニズムがないため、失敗した転送は常に false を返すのではなく、元に戻す必要があるという暗黙の期待が多くのコントラクトにあり、これが多くの混乱を引き起こしています。


この問題をさらに複雑にしているのは、一部の ERC20 トークン、特に Tether が true を返すプロトコルに従っていないことです。一部のトークンは転送に失敗すると元に戻り、呼び出し側に元に戻る原因となります。したがって、一部のライブラリは ERC20 トークン転送呼び出しをラップして、リバートをインターセプトし、代わりにブール値を返します。


ここにいくつかの実装があります

オープンツェッペリンのセーフトランスファー

Solady SafeTransfer (ガス効率が大幅に向上)

ERC20: アドレスポイズニング

これはスマート コントラクトの脆弱性ではありませんが、完全を期すためにここで言及します。

ゼロの ERC20 トークンの転送は仕様により許可されています。これにより、フロントエンド アプリケーションが混乱し、最近トークンを送信した相手についてユーザーが騙される可能性があります。 Metamask については、このスレッドで詳しく説明されています。

ERC20: とにかく頑丈

(web3 用語で「頑丈」とは、「敷物が下から引き抜かれている」という意味です。)

誰かが ERC20 トークンに、自由にトークンを作成、転送、書き込みできる機能を追加したり、自己破壊したりアップグレードしたりすることを妨げるものはありません。したがって、基本的に、ERC20 トークンがどの程度「信頼できない」かには限界があります。


融資プロトコルのロジックのバグ

貸し借りベースの DeFi プロトコルがどのように壊れる可能性があるかを考えるとき、バグがソフトウェア レベルで伝播し、ビジネス ロジック レベルに影響を与えることを考えると役立ちます。債券契約の締結と締結には多くの手順が必要です。考慮すべき攻撃ベクトルをいくつか示します。

貸し手が損失を被る仕組み

  • 支払いを行わずに元金が減額(ゼロになる可能性あり)できるバグ。
  • ローンが返済されない場合、または担保が基準値を下回った場合、買い手の担保は清算できません。
  • プロトコルに債権の所有権を移転するメカニズムがあれば、これが貸し手から債権を盗む媒介となる可能性がある。
  • ローン元金または支払いの期日が不適切に延期される。

借り手が損失を被る仕組み

  • 元金を返済しても元本が減額されないバグ。
  • バグまたはグリーフィング攻撃により、ユーザーは支払いを行うことができなくなります。
  • 元金または金利が不当に増額される。
  • オラクルの操作は担保の価値を下げることにつながります。
  • ローン元金または支払いの期日が不適切に繰り上げられます。


担保がプロトコルから流出した場合、借り手はローンを返済するインセンティブがなくなり、借り手は元本を失うため、貸し手と借り手の両方が損失を被ることになります。


上記でわかるように、DeFi プロトコルが「ハッキング」されるには、プロトコルから多額の資金が流出する(通常ニュースになるような出来事)よりもはるかに多くのレベルがあります。これは、CTF (キャプチャ ザ フラッグ) セキュリティ演習が誤解を招く可能性がある領域の 1 つです。プロトコルの資金が盗まれるのは最も悲惨な結果ですが、防御できるのは決してそれだけではありません。

チェックされていない戻り値

外部スマート コントラクトを呼び出すには 2 つの方法があります。1) インターフェイス定義を使用して関数を呼び出す。 2) .call メソッドを使用します。これを以下に示します

contract A { uint256 public x; function setx(uint256 _x) external { require(_x > 10, "x must be bigger than 10"); x = _x; } } interface IA { function setx(uint256 _x) external; } contract B { function setXV1(IA a, uint256 _x) external { a.setx(_x); } function setXV2(address a, uint256 _x) external { (bool success, ) = a.call(abi.encodeWithSignature("setx(uint256)", _x)); // success is not checked! } }

コントラクト B では、_x が 10 未満の場合、setXV2 は通知なく失敗する可能性があります。関数が .call メソッド経由で呼び出された場合、呼び出し先は元に戻せますが、親は元に戻りません。 success の値をチェックし、それに応じてコードの動作を分岐する必要があります。

プライベート変数

プライベート変数はブロックチェーン上で引き続き表示されるため、機密情報をブロックチェーンに保存すべきではありません。アクセスできない場合、バリデーターはその値に依存するトランザクションをどのように処理できるでしょうか?プライベート変数は外部の Solidity コントラクトから読み取ることはできませんが、Ethereum クライアントを使用してオフチェーンで読み取ることができます。


変数を読み取るには、そのストレージ スロットを知る必要があります。次の例では、myPrivateVar のストレージ スロットは 0 です。

 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract PrivateVarExample { uint256 private myPrivateVar; constructor(uint256 _initialValue) { myPrivateVar = _initialValue; } }

デプロイされたスマート コントラクトのプライベート変数を読み取る JavaScript コードは次のとおりです。

 const Web3 = require("web3"); const PRIVATE_VAR_EXAMPLE_ADDRESS = "0x123..."; // Replace with your contract address async function readPrivateVar() { const web3 = new Web3("http://localhost:8545"); // Replace with your provider's URL // Read storage slot 0 (where 'myPrivateVar' is stored) const storageSlot = 0; const privateVarValue = await web3.eth.getStorageAt( PRIVATE_VAR_EXAMPLE_ADDRESS, storageSlot ); console.log("Value of private variable 'myPrivateVar':", web3.utils.hexToNumberString(privateVarValue)); } readPrivateVar();

安全でない参加者通話

Delegatecall はすべての制御を delegatecallee に渡すため、信頼できないコントラクトでは決して使用しないでください。この例では、信頼できないコントラクトがコントラクト内のすべてのイーサを盗みます。

 contract UntrustedDelegateCall { constructor() payable { require(msg.value == 1 ether); } function doDelegateCall(address _delegate, bytes calldata data) public { (bool ok, ) = _delegate.delegatecall(data); require(ok, "delegatecall failed"); } } contract StealEther { function steal() public { // you could also selfdestruct here // if you really wanted to be mean (bool ok,) = tx.origin.call{value: address(this).balance}(""); require(ok); } function attack(address victim) public { UntrustedDelegateCall(victim).doDelegateCall( address(this), abi.encodeWithSignature("steal()")); } }

プロキシに関連するアップグレードのバグ

このトピックを 1 つのセクションで正確に説明することはできません。通常、アップグレードのバグのほとんどは、Openzeppelin のハードハット プラグインを使用し、それがどのような問題から保護するのかを読むことで回避できます。 ( https://docs.openzeppelin.com/upgrades-plugins/1.x/ )。


簡単にまとめると、スマート コントラクトのアップグレードに関連する問題は次のとおりです。

  • selfdestruct と delegatecall は実装コントラクト内で使用しないでください
  • アップグレード中にストレージ変数が互いに上書きされないように注意する必要があります。
  • 外部ライブラリの呼び出しは、ストレージ アクセスにどのような影響を与えるかを予測できないため、実装契約では避けるべきです。
  • デプロイヤは初期化関数の呼び出しを決して怠ってはなりません
  • 新しい変数が基本コントラクトに追加されるときにストレージの衝突を防ぐために、基本コントラクトにギャップ変数を含めません (これはハードハット プラグインによって自動的に処理されます)
  • 不変変数の値はアップグレード間で保持されません。
  • 将来のアップグレードでは、互換性を維持するために同一のコンストラクター ロジックを実行する必要があるため、コンストラクター内で何かを行うことは強くお勧めできません。

圧倒的な管理者

契約に所有者または管理者がいるからといって、その権限が無制限である必要があるというわけではありません。 NFTを考えてみましょう。所有者のみがNFT販売からの収益を引き出すのは合理的ですが、契約を一時停止(転送をブロック)できることは、所有者の秘密鍵が侵害された場合に大混乱を引き起こす可能性があります。一般に、不必要なリスクを最小限に抑えるために、管理者の権限は最小限にする必要があります。


契約所有権といえば…

Ownable の代わりに Ownable2Step を使用する

これは技術的には脆弱性ではありませんが、 OpenZeppelin の所有権が存在しないアドレスに所有権が移転されると、契約の所有権が失われる可能性があります。 Ownable2step では、受信者が所有権を確認する必要があります。これにより、誤って入力したアドレスに所有権を送信してしまうことがなくなります。

丸め誤差

Solidity には浮動小数点がないため、丸め誤差は避けられません。設計者は、四捨五入するのが正しいのか、四捨五入するのが正しいのか、どちらに有利な四捨五入を行うべきかを意識する必要があります。


除算は常に最後に実行する必要があります。次のコードは、小数点以下の桁数が異なるステーブルコイン間で誤って変換します。次の交換メカニズムにより、ユーザーは dai (小数点以下 18 桁) と交換する際に、少量の USDC (小数点以下 6 桁) を無料で受け取ることができます。変数 daiToTake はゼロに切り捨てられ、ゼロ以外の usdcAmount と引き換えにユーザーから何も受け取りません。

 contract Exchange { uint256 private constant CONVERSION = 1e12; function swapDAIForUSDC(uint256 usdcAmount) external pure returns (uint256 a) { uint256 daiToTake = usdcAmount / CONVERSION; conductSwap(daiToTake, usdcAmount); } }

最前線で活躍する

Etheruem (および同様のチェーン) のコンテキストにおけるフロントランニングとは、保留中のトランザクションを監視し、その前に高いガス価格を支払って別のトランザクションを実行することを意味します。つまり、攻撃者はトランザクションの「先頭に立っていた」のです。取引が収益性の高い取引である場合、より高いガス価格を支払うことを除いて、取引を正確にコピーすることは理にかなっています。この現象は、MEV と呼ばれることもあります。これは、抽出可能なマイナー値を意味しますが、他のコンテキストでは抽出可能な最大値を意味することもあります。ブロックプロデューサーは、トランザクションを並べ替えたり、独自のトランザクションを挿入したりする無制限の権限を持っており、歴史的に、イーサリアムがプルーフオブステークに移行する前はブロックプロデューサーはマイナーであったため、この名前が付けられました。

フロントランニング: 保護されていない撤退

スマートコントラクトからイーサを引き出すことは「収益性の高い取引」とみなされる可能性があります。 (ガスを除いて) ゼロコストのトランザクションを実行すると、最終的には当初よりも多くの暗号通貨を手に入れることになります。

 contract UnprotectedWithdraw { constructor() payable { require(msg.value == 1 ether, "must create with 1 eth"); } function unsafeWithdraw() external { (bool ok, ) = msg.sender.call{value: address(this).value}(""); require(ok, "transfer failed"). } }


このコントラクトをデプロイして撤回しようとすると、フロントランナー ボットがメモリプール内の「unsafeWithdraw」への呼び出しに気づき、それをコピーして最初にイーサを取得します。

フロントランニング: ERC4626 インフレ攻撃、フロントランニングと丸め誤差の組み合わせ

ERC-4626インフレ攻撃については、 ERC4626 チュートリアルで詳しく説明しています。しかし、その要点は、ERC4626 コントラクトは、トレーダーが貢献した「資産」の割合に基づいて「シェア」トークンを配布するということです。


大まかに言うと、次のように動作します。

 function getShares(...) external { // code shares_received = assets_contributed / total_assets; // more code }

もちろん、誰も資産を寄付して株式を取り戻さないでしょうが、誰かが株式を取得するために取引を最前線で実行できる場合、そのようなことが起こることは予測できません。


たとえば、プールに 20 の資産がある場合、彼らは 200 の資産を寄付し、100 株の取得を期待します。しかし、誰かが取引をフロントランして 200 資産を入金した場合、計算式は 200 / 220 となり、切り捨てられてゼロになり、被害者は資産を失い、取り戻される株式はゼロになります。

最前線: ERC20 の承認

これを抽象的に説明するよりも、実際の例で説明するのが最善です。


  1. アリスがイブを 100 トークンで承認したとします。 (ボブではなくイブが常に悪者なので、慣例に従います)。
  2. アリスは気が変わり、イブの承認を 50 に変更するトランザクションを送信します。
  3. 承認を 50 に変更するトランザクションがブロックに含まれる前に、トランザクションはイブが確認できるメモリプールに置かれます。
  4. イブは、50 個の承認を前倒しするために 100 個のトークンを要求するトランザクションを送信します。
  5. 50件の承認が通過
  6. イブは50個のトークンを集めます。


これで、Eve のトークンは 100 または 50 ではなく 150 になりました。これに対する解決策は、信頼できない承認を処理する場合、承認を増減する前に承認をゼロに設定することです。

最前線: サンドイッチ攻撃

資産の価格は売買圧力に応じて変動します。大量の注文がメンプールに残っている場合、トレーダーは注文をコピーするインセンティブを持ちますが、ガス価格は高くなります。そうすることで、彼らは資産を購入し、大量の注文で価格が上昇し、すぐに売却します。売り注文は「バックランニング」と呼ばれることもあります。売り注文は、より低いガス価格で売り注文を出すことで完了できます。シーケンスは次のようになります。


  1. フロントラン購入
  2. 大きな買い物
  3. 売る


この攻撃に対する主な防御策は、「スリッページ」パラメータを提供することです。 「フロントラン買い」自体が特定のしきい値を超えて価格を押し上げると、「ラージバイ」注文が元に戻り、フロントランナーが取引で失敗します。


これは、大規模な買いがフロントランの買いとバックランの売りによってサンドウィッチされるため、サンドウィッチと呼ばれます。この攻撃は、まったく逆の方向で、大量の売り注文にも機能します。

フロントランニングについて詳しく見る

フロントランニングは大きなテーマです。 Flashbots はこのトピックを広範囲に調査し、負の外部性を最小限に抑えるためにいくつかのツールと研究記事を公開しました。


適切なブロックチェーン アーキテクチャでフロントランニングを「排除」できるかどうかは議論の対象であり、まだ最終的には解決されていません。次の 2 つの記事は、このテーマに関する不朽の古典です。


イーサリアムは暗い森です

暗い森からの脱出

署名関連

デジタル署名には、スマート コントラクトのコンテキストで 2 つの用途があります。

  • 実際のトランザクションを行わずにアドレスがブロックチェーン上のトランザクションを承認できるようにする
  • 事前定義されたアドレスに従って、送信者が何かを行う何らかの権限を持っていることをスマート コントラクトに証明します。

以下は、デジタル署名を安全に使用してユーザーに NFT を鋳造する権限を与える例です。

 import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; contract NFT is ERC721("name", "symbol") { function mint(bytes calldata signature) external { address recovered = keccak256(abi.encode(msg.sender)).toEthSignedMessageHash().recover(signature); require(recovered == authorizer, "signature does not match"); } }

典型的な例は、ERC20 の承認機能です。アカウントから一定量のトークンを引き出すためのアドレスを承認するには、実際のイーサリアムトランザクションを行う必要があり、それにはガスがかかります。


場合によっては、デジタル署名をオフチェーンで受信者に渡し、受信者がその署名をスマート コントラクトに提供して、トランザクションを実行する権限があったことを証明する方が効率的である場合があります。


ERC20Permit により、デジタル署名による承認が可能になります。機能は次のように説明されます

function permit(address owner, address spender, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) public

実際の承認トランザクションを送信する代わりに、所有者は支出者の承認に (期限とともに) 「署名」することができます。承認された支出者は、指定されたパラメーターを使用して許可関数を呼び出すことができます。

署名の構造

変数 v、r、s が頻繁に表示されます。これらはそれぞれ、データ型 uint8、bytes32、および bytes32 でソリッドに表現されます。場合によっては、署名は、これらすべての値が abi.encodePacked(r, s, v); として連結された 65 バイトの配列として表されることがあります。


署名の他の 2 つの必須コンポーネントは、メッセージ ハッシュ (32 バイト) と署名アドレスです。シーケンスは次のようになります


  1. 秘密キー (privKey) はパブリック アドレス (ethAddress) を生成するために使用されます。

  2. スマート コントラクトは ethAddress を事前に保存します

  3. オフチェーン ユーザーはメッセージをハッシュし、そのハッシュに署名します。これにより、msgHash と署名 (r、s、v) のペアが生成されます。

  4. スマート コントラクトはメッセージを受信し、それをハッシュして msgHash を生成し、それを (r, s, v) と組み合わせて、どのアドレスが出力されるかを確認します。

  5. アドレスが ethAddress と一致する場合、署名は有効です (特定の仮定の下では、これについては後ほど説明します)。


スマート コントラクトは、ステップ 4 でプリコンパイルされたコントラクトecrecover を使用して、いわゆる組み合わせを実行し、アドレスを取得します。


このプロセスには、物事が横道に逸れる可能性のあるステップが数多くあります。

署名: ecrecover は address(0) を返し、アドレスが無効な場合は元に戻りません

これにより、初期化されていない変数が ecrecover の出力と比較される場合、脆弱性が発生する可能性があります。

このコードには脆弱性があります

contract InsecureContract { address signer; // defaults to address(0) // who lets us give the beneficiary the airdrop without them// spending gas function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { // ecrecover returns address(0) if the signature is invalid require(signer == ecrecover(keccak256(abi.encode(who, amount)), v, r, s), "invalid signature"); mint(msg.sender, AIRDROP_AMOUNT); } }

署名リプレイ

署名の再使用は、署名が以前に使用されたかどうかを契約が追跡していない場合に発生します。次のコードでは、以前の問題を修正していますが、まだ安全ではありません。

 contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); mint(msg.sender, amount); } }

エアドロップは何度でも申請できます。


次の行を追加できます

bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // mapping(bytes => bool); used[signature] = true;

残念ながら、コードはまだ安全ではありません。

特徴的な展性

有効な署名が与えられると、攻撃者は簡単な計算を行って別の署名を導き出すことができます。攻撃者は、この変更された署名を「再生」することができます。しかしその前に、有効な署名から開始し、それを変更し、新しい署名がまだ合格することを示すことができることを示すコードを提供しましょう。

 contract Malleable { // v = 28 // r = 0xf8479d94c011613baeffe9239e4ff65e2adbac744c34217ca7d51378e72c5204 // s = 0x57af17590a914b759c45aaeabaf513d5ef72d7da1bdd19d9f2e1bc371ece5b86 // m = 0x0000000000000000000000000000000000000000000000000000000000000003 function foo(bytes calldata msg, uint8 v, bytes32 r, bytes32 s) public pure returns (address, address){ bytes32 h = keccak256(msg); address a = ecrecover(h, v, r, s); // The following is math magic to invert the // signature and create a valid one // flip s bytes32 s2 = bytes32(uint256(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141) - uint256(s)); // invert v uint8 v2; require(v == 27 || v == 28, "invalid v"); v2 = v == 27 ? 28 : 27; address b = ecrecover(h, v2, r, s2); assert(a == b); // different signatures, same address!; return (a, b); } }

そのため、実行中のサンプルにはまだ脆弱性があります。誰かが有効な署名を提示すると、その鏡像署名が生成され、使用済み署名のチェックが回避される可能性があります。

 contract InsecureContract { address signer; function airdrop(address who, uint256 amount, uint8 v, bytes32 r, bytes32 s) external { address recovered == ecrecover(keccak256(abi.encode(who, amount)), v, r, s); require(recovered != address(0), "invalid signature"); require(recovered == signer, "recovered signature not equal signer"); bytes memory signature = abi.encodePacked(v, r, s); require(!used[signature], "signature already used"); // this can be bypassed used[signature] = true; mint(msg.sender, amount); } }

安全な署名

おそらくこの時点で、安全な署名コードが必要になるでしょう? Solidity での署名の作成とファウンドリでのテストに関するチュートリアルを参照してください。


しかし、ここにチェックリストがあります。


  • openzeppelin のライブラリを使用して展性攻撃を防ぎ、問題をゼロに回復します
  • 署名をパスワードとして使用しないでください。メッセージには、攻撃者が簡単に再利用できない情報 (例: msg.sender) が含まれている必要があります。
  • オンチェーンで署名しているものをハッシュする
  • nonce を使用してリプレイ攻撃を防ぎます。さらに良いことに、EIP712 に従って、ユーザーが署名している内容を確認できるようにし、コントラクトと異なるチェーン間で署名が再利用されるのを防ぐことができます。

署名は適切な保護策がなければ偽造または作成される可能性があります

チェーン上でハッシュが行われない場合、上記の攻撃はさらに一般化できます。上記の例では、ハッシュ化はスマート コントラクト内で行われたため、上記の例は次のエクスプロイトに対して脆弱ではありません。


署名を回復するコードを見てみましょう

// this code is vulnerable! function recoverSigner(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public returns (address signer) { require(signer == ecrecover(hash, v, r, s), "signer does not match"); // more actions }


ユーザーはハッシュと署名の両方を指定します。攻撃者が署名者からの有効な署名をすでに確認している場合、別のメッセージのハッシュと署名を再利用することができます。

このため、オフチェーンではなく、スマート コントラクト内でメッセージをハッシュすることが非常に重要です。

このエクスプロイトの実際の動作を確認するには、Twitter に投稿した CTF を参照してください。


元の課題:

パート 1: https://twitter.com/RareSkills_io/status/1650869999266037760

パート 2: https://twitter.com/RareSkills_io/status/1650897671543197701

解決策:

https://twitter.com/RareSkills_io/status/1651527648676573185 https://twitter.com/RareSkills_io/status/1651224817465540611

識別子としての署名

署名はユーザーを識別するために使用されるべきではありません。展性があるため、それらは一意であると想定できません。 Msg.sender には、より強力な一意性が保証されています。

一部の Solidity コンパイラのバージョンにはバグがあります

Twitter で主催したセキュリティ演習については、こちらをご覧ください。コードベースを監査するときは、Solidity ページのリリース発表と照らし合わせて Solidity のバージョンをチェックし、バグが存在する可能性があるかどうかを確認してください。

スマートコントラクトが不変であると仮定する

スマート コントラクトは、プロキシ パターン (または、まれにメタモーフィック パターン) を使用してアップグレードできます。スマート コントラクトは、変更されないままにするために任意のスマート コントラクトの機能に依存すべきではありません。

Transfer() と send() はマルチシグネチャウォレットで機能しなくなる可能性があります

Solidity 関数の transfer および send は使用しないでください。トランザクションで転送されるガスの量を意図的に 2,300 に制限しているため、ほとんどの操作でガスが不足します。


一般的に使用される gnosis セーフ マルチシグネチャ ウォレットは、 フォールバック関数での別のアドレスへの呼び出しの転送をサポートしています。誰かが転送または送信を使用して Ether をマルチシグ ウォレットに送信すると、フォールバック機能のガスが不足して転送が失敗する可能性があります。 gnosis セーフ フォールバック機能のスクリーンショットを以下に示します。読者には、2300 ガスを使い切るには十分すぎる操作があることがはっきりとわかります。


転送と送信を使用するコントラクトを操作する必要がある場合は、ストレージおよびコントラクト アクセス操作のガス コストを削減できるイーサリアム アクセス リスト トランザクションに関する記事を参照してください。

算術オーバーフローは依然として関連していますか?

Solidity 0.8.0 には、オーバーフローおよびアンダーフロー保護が組み込まれています。したがって、チェックされていないブロックが存在するか、Yul の低レベル コードが使用されない限り、オーバーフローの危険はありません。したがって、SafeMath ライブラリは追加のチェックでガスを浪費するため、使用しないでください。

block.timestamp はどうでしょうか?

一部の文献では、block.timestamp はマイナーが操作できるため、脆弱性ベクトルであると記載されています。これは通常、ランダム性のソースとしてタイムスタンプを使用する場合に当てはまりますが、前に説明したように、とにかく使用すべきではありません。マージ後の Ethereum は、正確に 12 秒 (または 12 秒の倍数) 間隔でタイムスタンプを更新します。ただし、第 2 レベルの粒度で時間を測定するのはアンチパターンです。 1 分のスケールでは、バリデーターがブロック スロットを見逃し、ブロック生成に 24 秒のギャップが発生すると、エラーが発生する可能性がかなり高くなります。

コーナーケース、エッジケース、オフバイワンエラー

コーナーケースを簡単に定義することはできませんが、コーナーケースを十分に見れば、そのケースに対する直観が養われ始めます。コーナーケースとは、報酬を請求しようとしているものの、何も賭けていないようなケースのことです。これは正当であり、彼らに報酬をゼロにすべきです。同様に、通常は報酬を均等に分割したいと考えていますが、受信者が 1 人しかおらず、技術的に分割が発生しない場合はどうなるでしょうか?

コーナーケース: 例 1

この例は、Akshay Srivastav のTwitter スレッドから抜粋され、変更されたものです。

一連の特権アドレスが署名を提供する場合、誰かが特権アクションを実行できる場合を考えてみましょう。

 contract VulnerableMultisigAuthorization { struct Authorization { bytes signature; address authorizer; bytes32 hashOfAction; // more fields } // more codef unction takeAction(Authorization[] calldata auths, bytes calldata action) public { // logic for avoiding replay attacks for (uint256 i; i < auths.length; ++i) { require(validateSignature(auths[i].signature, auths[i].authorizer), "invalid signature"); require(authorizers[auths[i].authorizer], "address is not an authorizer"); } doTheAction(action) } }

いずれかの署名が無効である場合、または署名が有効なアドレスと一致しない場合は、元に戻されます。しかし、配列が空の場合はどうなるでしょうか?その場合、署名を必要とせずに doTheAction までジャンプします。

オフバイワン: 例 2

 contract ProportionalRewards { mapping(address => uint256) originalId; address[] stakers; function stake(uint256 id) public { nft.transferFrom(msg.sender, address(this), id); stakers.append(msg.sender); } function unstake(uint256 id) public { require(originalId[id] == msg.sender, "not the owner"); removeFromArray(msg.sender, stakers); sendRewards(msg.sender, totalRewardsSinceLastclaim() / stakers.length()); nft.transferFrom(address(this), msg.sender, id); } }

上記のコードはすべての関数の実装を示しているわけではありませんが、関数が名前の説明どおりに動作するとしても、依然としてバグは存在します。見つけられますか?下にスクロールする前に、答えが表示されないようにスペースを与えるために、次の図を示します。

RemoveFromArray 関数と sendRewards 関数の順序が間違っています。ステーカー配列にユーザーが 1 人しかいない場合、ゼロ除算エラーが発生し、ユーザーは NFT を引き出すことができません。さらに、報酬はおそらく作者の意図どおりに分割されません。元のステーカーが 4 人いて、1 人が撤退した場合、撤退時の配列の長さは 3 であるため、その人は報酬の 3 分の 1 を受け取ります。

コーナーケース例 3: 複合金融の報酬の計算間違い

ある推定では 1 億ドルを超える損害が発生したという実際の例を使ってみましょう。 Compound プロトコルを完全に理解していなくても心配する必要はありません。関連する部分のみに焦点を当てます。 (また、複合プロトコルは、DeFiの歴史の中で最も重要かつ重要なプロトコルの1つです。私たちはそれをDeFiブートキャンプで教えています。そのため、これがプロトコルの第一印象である場合は、誤解しないでください)。


いずれにせよ、Compound の目的は、使用されていない暗号通貨を、それを使用できる可能性のある他のトレーダーに貸し出したユーザーに報酬を与えることです。貸し手には利子と COMP トークンの両方が支払われます (借り手は COMP トークンの報酬を請求することもできますが、ここでは焦点を当てません)。

Compound Comptroller は、Compound Governance によって設定できる実装への呼び出しを委任するプロキシ コントラクトです。


2021年9月30日のガバナンス提案62において、脆弱性を有する実装契約となった。公開されたのと同じ日に、トークンをステーキングしていないにもかかわらず、一部のトランザクションが COMP 報酬を請求していることがTwitterで観察されました。

脆弱な関数distributeSupplierComp()


元のコードはこちらです


皮肉なことに、バグは TODO コメントにあります。 「ユーザーがサプライヤー市場にいない場合は、サプライヤー COMP を配布しないでください。」しかし、コードにはそのためのチェックがありません。ユーザーがウォレットにステーキング トークン (CToken(cToken).balanceOf(supplier);) を保持している限り、

プロポーザル 64 は、 2021 年 10 月 9 日にバグを修正しました。


これは入力検証のバグであると主張される可能性がありますが、ユーザーは悪意のあるものを送信していません。誰かが何も賭けずに報酬を請求しようとした場合、正しい計算はゼロになるはずです。おそらく、これはビジネス ロジックまたは特殊なエラーに近いものです。

現実世界のハック

現実世界で発生する DeFi ハッキングは、上記の適切なカテゴリに当てはまらないことがよくあります。

ペアリティウォレット凍結(2017年11月)

パリティ ウォレットは直接使用することを目的としていませんでした。これは、 スマート コントラクトのクローンが指す参照実装でした。実装では、必要に応じてクローンを自己破壊することができましたが、これにはすべてのウォレット所有者が承認する必要がありました。

 // throw unless the contract is not yet initialized.modifier only_uninitialized { if (m_numOwners > 0) throw; _; } function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized { initDaylimit(_daylimit); initMultiowned(_owners, _required); }

ウォレットの所有者が宣言される

// kills the contract sending everything to `_to`.function kill(address _to) onlymanyowners(sha3(msg.data)) external { suicide(_to); }

一部の文献では、これを「保護されていない自己破壊」、つまりアクセス制御の失敗と説明していますが、これは完全に正確ではありません。問題は、実装コントラクトで initWallet 関数が呼び出されず、誰かが自分で initWallet 関数を呼び出して自分自身を所有者にすることができてしまうことでした。これにより、kill 関数を呼び出す権限が与えられました。根本的な原因は、実装が初期化されていないことでした。したがって、このバグは、Solidity コードの欠陥が原因ではなく、デプロイメント プロセスの欠陥が原因で発生しました。

Badger DAO ハック (2021 年 12 月)

このハッキングでは、Solidity コードは悪用されませんでした。代わりに、攻撃者はCloudflare APIキーを取得し、ユーザーのトランザクションを変更して攻撃者のアドレスに直接引き出しを行うスクリプトをWebサイトのフロントエンドに挿入しました。詳細については、この記事をご覧ください。

ウォレットの攻撃ベクトル

ランダム性が不十分な秘密鍵

先頭にゼロが多く含まれるアドレスを検出する動機は、アドレスの使用効率がより高いためです。イーサリアムトランザクションには、トランザクションデータのゼロバイトに対して 4 ガス、ゼロ以外のバイトに対して 16 ガスが課金されます。


そのため、Wintermute は冒涜的なアドレス ( writeup ) を使用していたためにハッキングされました。以下は、冒とく的なアドレス生成プログラムがどのように侵害されたかについての 1inch の記事です。


トラストウォレットには、この記事 ( https://blog.ledger.com/Funds-of-every-wallet-created-with-the-Trust-Wallet-browser-extension-could-have-been- ) に記載されている同様の脆弱性がありました。 盗まれた/ )


スマート コントラクトには秘密キーがないため、これは、create2 のソルトを変更することで検出された先頭のゼロを持つスマート コントラクトには適用されないことに注意してください。

再利用されたノンスまたは不十分にランダムなノンス。

楕円曲線署名上の「r」および「s」点は次のように生成されます。

 r = k * G (mod N) s = k^-1 * (h + r * privateKey) (mod N)


G、r、s、h、N はすべて公知です。 「k」がパブリックになった場合、「privateKey」は唯一の未知の変数であり、解決できます。このため、ウォレットは完全にランダムに k を生成し、決して再利用しない必要があります。ランダム性が完全にランダムではない場合、k は推測できます。


Java ライブラリでの安全でないランダム生成により、2013 年に多くの Android ビットコイン ウォレットが脆弱になりました。(ビットコインはイーサリアムと同じ署名アルゴリズムを使用しています)。


( https://arstechnica.com/information-technology/2013/08/all-android-created-bitcoin-wallets-vulnerable-to-theft/ )。

ほとんどの脆弱性はアプリケーション固有のものです

このリストのアンチパターンをすぐに認識できるように自分を訓練することで、より有能なスマート コントラクト プログラマーになれるでしょう。ただし、スマート コントラクトの重大なバグのほとんどは、意図されたビジネス ロジックとコードが実際に行う動作との間の不一致が原因です。


バグが発生する可能性のあるその他の領域:

  • 悪いトークンノミクスインセンティブ
  • 1つのエラーでずれます
  • 誤字脱字
  • 管理者またはユーザーが秘密キーを盗まれる

単体テストで多くの脆弱性を発見できた可能性がある

スマート コントラクトの単体テストはおそらくスマート コントラクトの最も基本的な保護手段ですが、驚くほど多くのスマート コントラクトにはそれが欠けているか、テスト範囲が不十分です。

しかし、単体テストは、コントラクトの「ハッピー パス」(期待/設計された動作) のみをテストする傾向があります。驚くべきケースをテストするには、追加のテスト方法を適用する必要があります。


スマート コントラクトを監査のために送信する前に、まず次のことを行う必要があります。

  • Slither などのツールを使用した静的分析により、基本的な間違いを見逃さないようにします
  • 単体テストを通じて回線と分岐を 100% カバレッジ
  • 単体テストに堅牢な Assert ステートメントがあることを確認するための突然変異テスト
  • ファズテスト、特に算術テスト
  • ステートフル プロパティの不変テスト
  • 必要に応じて正式な検証


ここで紹介するいくつかの方法論に慣れていない人のために、Cyfrin Audits の Patrick Collins がビデオでステートフルおよびステートレス ファジングについてユーモラスに紹介しています。


これらのタスクを実行するためのツールは急速に普及し、使いやすくなっています。

その他のリソース

一部の著者は、以前の DeFi ハックのリストを次のリポジトリにまとめています。


Secureum はセキュリティの研究と実践に広く使用されていますが、リポジトリは 2 年間実質的に更新されていないことに注意してください


Solidity Riddlesリポジトリを使用して、Solidity の脆弱性を悪用する練習をすることができます。


DamnVulnerableDeFi は、すべての開発者が実践すべき古典的なウォーゲームです


Capture The Ether と Ethernaut は古典的ですが、一部の問題は非現実的に簡単であるか、時代遅れの Solidity 概念を教えていることに注意してください。


一部の評判の良いクラウドソーシングセキュリティ会社は、研究に役立つ過去の監査のリストを持っています。

スマートコントラクト監査人になる

Solidity に精通していなければ、Ethereum スマート コントラクトを監査することはできません。


スマートコントラクト監査人になるための業界で認められた認定資格はありません。誰でも、Solidity 監査人であると主張する Web サイトやソーシャル メディアのプロフィールを作成し、サービスの販売を開始することができ、多くの人がそうしています。したがって、雇用する前に注意して紹介を受けてください。

スマート コントラクトの監査人になるには、バグの発見において平均的な Solidity 開発者よりも大幅に優れている必要があります。したがって、監査人になるための「ロードマップ」は、他の人よりも優れたスマート コントラクトのバグ キャッチャーになるまで、何ヶ月も何ヶ月も執拗に意図的に練習することに他なりません。


脆弱性の特定において他の人よりも優れているという決意が欠けている場合、高度な訓練を受け、意欲を持った犯罪者よりも先に重大な問題を発見できる可能性は低いでしょう。

スマートコントラクトのセキュリティ監査人になれる可能性についての冷たい真実

スマート コントラクトの監査は、最近、儲かるという認識から、取り組むのに望ましい分野として認識されています。実際、一部のバグ報奨金の支払い額は 100 万ドルを超えていますが、これは非常にまれな例外であり、一般的なものではありません。


Code4rena には、監査コンテストにおける競合他社からの支払いに関する公開リーダーボードがあり、成功率に関するデータが得られます。


理事会には 1171 人の名前が載っていますが、

  • 生涯収入が 10 万ドルを超える競合他社は 29 社のみ (2.4%)
  • 生涯所得が 50,000 ドルを超える人は 57 人のみ (4.9%)
  • 生涯所得が 10,000 ドルを超える人はわずか 170 人 (14.5%)


これも考慮してください。Openzeppelin がセキュリティ研究フェローシップ (仕事ではなく、就職前のスクリーニングとトレーニング) への応募を受け付けたとき、300 件を超える応募があったのですが、選ばれた候補者は 10 人未満で、その中で完全な資格を得られるのはさらに少数でした。時間の仕事。

OpenZeppelin スマートコントラクト監査人の求人応募

これはハーバード大学よりも低い入学率です。


スマート コントラクトの監査は競争力のあるゼロサム ゲームです。監査すべきプロジェクトの数も限られており、セキュリティのための予算も限られており、見つけなければならないバグの数も限られています。今からセキュリティの勉強を始めれば、あなたよりもはるかに有利なスタートを切れる、モチベーションの高い個人やチームが何十人もいるでしょう。ほとんどのプロジェクトは、テストされていない新しい監査人ではなく、評判のある監査人にプレミアムを支払うことをいとわないでしょう。


この記事では、少なくとも 20 の異なるカテゴリの脆弱性をリストしました。それぞれの項目を習得するのに 1 週間を費やしたとしても (これはやや楽観的ですが)、経験豊富な監査人にとっての常識が何なのかを理解し始めたばかりです。この記事では、ガスの最適化やトークンノミクスについては説明していませんが、どちらも監査人が理解する上で重要なトピックです。計算してみると、これが短い旅ではないことがわかります。


とはいえ、コミュニティは概して初心者にとってフレンドリーで親切で、ヒントやコツが豊富にあります。しかし、スマートコントラクトセキュリティでキャリアを築くことを期待してこの記事を読んでいる人にとって、高収入のキャリアを得る可能性はあなたに有利ではないことを明確に理解することが重要です。成功はデフォルトの結果ではありません。


もちろんそれは可能であり、Solidity のことをまったく知らなかった状態から、監査で儲かるキャリアを築くまでに至った人も少なくありません。おそらく、ロースクールに入学して司法試験に合格するよりも、2 年間でスマートコントラクト監査人としての仕事を得る方が簡単です。他の多くのキャリアの選択肢と比較して、それは確かに多くの利点があります。


しかし、それでも、目の前で急速に進化する知識の山をマスターし、バグを見つけるための直観を磨くには、大変な忍耐力が必要です。

これは、スマート コントラクトのセキュリティを学ぶことが価値のない追求であるということではありません。それは絶対にそうです。ただし、ドル記号を目にしながら現場に近づく場合は、期待を抑えてください。

結論

既知のアンチパターンを認識しておくことが重要です。ただし、現実世界のバグのほとんどはアプリケーション固有のものです。いずれのカテゴリーの脆弱性を特定するには、継続的かつ慎重な実践が必要です。


業界をリードする当社の堅牢性トレーニングで、スマート コントラクトのセキュリティやその他多くのイーサリアム開発トピックを学びましょう。


この記事のリード画像は、HackerNoon のAI Image Generatorによって「コンピューターを保護するロボット」というプロンプトを介して生成されました。