二十三種類のDeFiセキュリティ事故のまとめ:スマートコントラクトのリスクと防止
執筆:Austin Zhang、Jon Li、Asymmetries Technologies
スマートコントラクトのセキュリティ問題は業界の重要な話題であり、プログラマーのいくつかの不注意によって思考や論理の欠陥が生じ、ハッカーに隙を与えることがあります。私たちは現在DeFi分野で発生したセキュリティ事故のスマートコントラクトを収集し、私たちが作成したサンプルコードに基づいてその原因を実証分析し、同僚や業界の仲間に何らかの示唆を与えられればと思います。
(一)再入攻撃
主要な攻撃手法の一つ:契約が悪意のある外部契約を呼び出す前に、悪意のある外部契約の関数が元の契約の関数を逆呼び出しして関連する脆弱性を利用する。
(1)サンプルコード
(2)ケース1:2021年12月22日、Uniswap V3流動性管理プロトコルVisorが120 ETHを盗まれる
事故原因:deposit関数には再入ロックがなく、fromアドレスが合法的なVisor契約アドレスであるかどうかの検証も行われていませんでした。攻撃者は攻撃契約アドレスを渡し、deposit関数を繰り返し呼び出して引き出し金額のチェックを回避し、何度も引き出しました。
(3)ケース2:2021年6月5日、BurgerSwapが700万ドルを盗まれる
事故原因:UniswapのオリジナルDEXに似ており、PlatformとPoolの2つの契約に分かれています。PlatformはUniswapのRouterに似ており、PairはUniswapのPoolに似ています。開発者はK値の検証をPlatformの計算に置いたため、攻撃者はPlatform内で再入攻撃を行い、古いK値でトークンを何度も交換し、流動性提供者に損失を与えました。
(4)解決策:外部契約を呼び出す前に、すべての中間状態変数が更新されていることを確認し、再入ロックを使用する(例:OpenZeppelinのReentrancyGuard)。
(二)関数の戻り値を確認していない
外部契約の関数を呼び出す際、一部の関数呼び出しが失敗してもエラーをスローせずにトランザクションをロールバックせず、falseを返すことがあります。関数の戻り値を確認し忘れると、呼び出しが成功したと誤解することになります。
(1)サンプルコード
(2)ケース:2021年4月4日、ForceDaoが183 ETHの損失を被る
事故原因:ForceトークンのtransferFromの残高が不足している場合、falseを返し、トランザクションを直接ロールバックしないため、契約内で判断が行われず、転送失敗時にも成功と見なされ、対応するトークンを交換できました。
(3)解決策:外部契約を呼び出す際には、呼び出しが成功したかどうかを必ず確認すること。注:callで外部契約を呼び出す際、関数が一致しない場合は、外部契約のfallbackまたはreceive関数が呼び出されます。外部契約にreceive関数が定義されていて、call関数がcalldataを持たない場合、外部契約のreceive関数が呼び出され、他の状況ではfallback関数が呼び出されます。
(三)関数の可視性を正しく設定していない
Solidityでは関数はデフォルトでpublicであり、外部から呼び出すことができます。重要な関数をPrivateに設定しないと、セキュリティリスクが生じます。
(1)サンプルコード
(2)ケース1:2022年1月22日、Dex Crosswiseが80万ドルの損失を被る
事故原因:Crosswiseは権限検証関数onlyOwnerを実装していますが、setTrustedForwarderをprivateに設定し忘れたため、攻撃者がそれを利用し、自分をプールのOwnerに設定し、トークンをすべて転送しました。
(3)ケース2:2020年6月18日、クロスチェーンブリッジBancor Networkが14万ドルの損失を被る
事故原因:契約で使用される転送関数がデフォルトでpublicであり、攻撃者は契約内のトークンを直接呼び出して転送することができました。
(4)解決策:引き出し関数は契約資産の移転に関わるため、権限管理を慎重に設定し、初期化関数が一度だけ実行されることを確保する必要があります。
(四)Map内のKeyが存在しない場合を検証していない
SolidityのMappingでは、対応するKeyのValueを取得する際、Keyが存在しない場合はエラーを返さず、対応する型のデフォルト値を返します。例えばMapping(int → int)の場合、対応するintのKeyが存在しない場合、デフォルト値0が返されます。
(1)サンプルコード
(2)ケース:2021年7月11日、クロスチェーンブリッジChainSwapが400万ドルの損失を被る
事故原因:ChainSwapはそのネットワーク内のvalidatorに依存して転送を行います。validatorが一度に自分のステークを超えてトークンを転送するのを制限するために、クォータを設定しました。その結果、契約内にクォータ制限を回避できる脆弱性が存在し、アドレス変数signatoryが存在しない場合、authQuotes[signatory]とlasttimeUpdateQuoteOf[signatory]が0を返し、クォータ計算が誤って大量のクォータを返しました。
(3)解決策:mapを使用する際は、必ずkeyが存在するかどうかを確認する必要があります。
(五)状態変更前に転送を行う
転送時に再入攻撃を受ける可能性があり、未変更の状態を利用して攻撃されることがあります。
(1)ケース:2021年8月17日、XSURGEが500万ドルの損失を被る
事故原因:転送後にtotalSupplyを変更し、転送時に再入攻撃を受けて500万ドルの損失を被りました。
(2)解決策:再入ロックを使用していても、すべての状態変更の後に転送を行う必要があります。
(六)初期化関数の呼び出しと権限制限を行っていない
多くの契約は子契約を初期化する必要があります。例えば、UniswapはFactory契約を通じてPool契約を初期化する必要があります。この際、子契約の初期化関数に権限と重複初期化制限を忘れると、攻撃者によって悪意のある初期化が行われる可能性があります。
(1)ケース:2021年8月11日、Punk Protocolが400万ドルの損失を被る
事故原因:プールのinitialize関数に権限と重複呼び出し制限が設けられておらず、攻撃者がこの関数を呼び出して自分をForge管理者権限に設定し、withdrawToForgeを呼び出してプールのすべての資金を攻撃者のアドレスに送信しました。
(2)解決策:初期化関数は一度だけ初期化できるように設定する必要があります。
(七)対応する契約関数の実装を正しく確認していない
通常、スマートコントラクトが呼び出される関数が存在しない場合はエラーが発生しますが、契約がfallback関数を実装している場合、fallback関数が自動的に呼び出されます。時にはfallback関数がエラーを報告しないことがあり、呼び出し元が呼び出しが成功したと誤解することがあります。
(1)ケース:2022年1月18日、クロスチェーンブリッジMultichainが450 ETHの損失を被る
事故原因:通常、ERC20の契約はpermit関数を実装しており、署名チェックと権限操作に使用されます(この関数はapproveに似ており、事前に生成された署名を使用して他の契約が呼び出すことができ、ユーザーのガス費を節約します)。しかし、WETH、PERI、OMT、WBNB、MATIC、AVAXの6種類のトークンの契約はpermitを実装せず、fallbackを実装していました。Multichainはこれらのトークンの権限を確認する際、ユーザーが攻撃者に転送することを許可したと誤解し、トークンが盗まれました。
(2)解決策:異なるトークンの実装方法は異なるため、新しいトークンを導入する前にその具体的な実装を慎重に確認する必要があります。
(八)転送手数料のあるトークンを正しく処理していない
一部のトークンは転送時に一部の転送手数料を消失させるため、実際に受け取るトークンの残高が少なくなります。開発者がこれを考慮しなかった場合、転送値で計算すると偏差が生じることがあります。
(1)ケース:2021年8月19日、Pineconeが20万ドルを盗まれる
事故原因:PineconeはそのトークンPCTを資金プールのステーキングトークンとして使用しており、PCTの転送には手数料の損失があります。契約は関連する損失を考慮しておらず、ユーザーのシェアとステーキングされたPCTの総額に偏差が生じ、攻撃者が余分な報酬を受け取ることができました。
(2)解決策:すべてのトークンの転送手数料がネイティブトークンであるわけではないことを忘れないでください。
(九)署名検証の脆弱性
署名が再利用されるか、楕円曲線署名アルゴリズムの対称性を利用して、既存の署名から合法的な署名を構築することがあります。
(1)ケース:2021年7月12日、AnySwapが800万ドルを盗まれる
事故原因:取引署名には私鍵の他にランダム数Rが必要ですが、Anyswapは新しい契約を誤ってデプロイし、BSC上のV3ルーターMPCアカウントの下に同じR値の署名を持つ2つの取引が存在しました。攻撃者はこのMPCアカウントの私鍵を逆推測し、盗まれた資金を転送しました。
(2)解決策:EIP-712標準を使用して署名を検証し、OpenZeppelinの実装を参考にしてください:https://docs.openzeppelin.com/contracts/3.x/api/drafts。
(十)契約残高の変化を考慮していない
マイナーがブロックを掘るときや、スマートコントラクトがselfdestruct関数を呼び出して自分自身を破壊するとき、任意のアドレスに強制的にトークンを送信してそのネイティブトークンの残高を変更することができます。残高関数の戻り値を判断条件として使用する際、残高が強制的に変更される可能性があり、リスクを引き起こすことがあります。極端な場合、契約がサービス拒否(DoS)に陥ることもあります。
(1)サンプルコード
寄付契約がトークン転送を受け付けられない場合でも、契約残高はデプロイ後に変更される可能性があり、エアドロップの総量と契約残高の合計が総供給量に等しいことを厳密に確認することは、寄付契約がサービス拒否(DoS)に陥る可能性があります。
(2)解決策:契約内で契約残高に対して厳密な等価チェックを避ける必要があります。
(十一)delegatecallを使用して外部契約を呼び出す
delegatecallは、対応する契約の関数コードを現在のコンテキストに埋め込んで実行することができ、内蔵関数を呼び出すように見えます。悪意のある契約を誤って呼び出すと、攻撃を引き起こす可能性があります。
(1)サンプルコード
攻撃者がforward関数を呼び出し、Attack契約アドレスとsetOwner()関数を引数として渡すと、Proxy契約のownerが攻撃者のアドレスに変更されます。
(2)解決策:外部契約を呼び出す際にdelegatecallを使用することは推奨されません。
(十二)tx.originを権限チェックに使用する
tx.originは取引の発起者アドレスであり、契約がtx.originを権限チェックに使用すると、契約の権限ユーザーが悪意のある契約と相互作用する際、悪意のある契約が契約を呼び出すことで権限チェックを通過することができます。
(1)サンプルコード
MyWallet契約のownerがtransferTo関数を使用してAttack契約に転送すると、Attack契約はMyWallet契約を再入し、transferTo関数を呼び出します。この時、tx.originは依然としてMyWalletのownerであり、require条件が満たされ、MyWalletの残高がすべてAttack契約に転送されます。
(2)解決策:権限チェックにtx.originを使用しないこと。
(十三)取引の順序競争
フルノードの運営者は取引が確認される前に取引情報を取得し、その情報に基づいて高い手数料の取引を構築し、マイナーが自分の取引を優先的にパッケージ化して有利な戦略を実行することができます。例えば、謎解き契約は最も早く答えを見つけたユーザーに報酬を与えますが、悪意のあるユーザーは誠実なユーザーが提出した答えを知った後、高い手数料の取引を構築して誠実なユーザーの提出した答えを優先的に取得し、報酬を得ることができます。また、ユーザーが権限額を更新する際、権限を与えられたユーザーは更新取引が確認される前に古い権限額を転送することができ、実際に権限を与えられた人が得る権限額は2回の権限額の合計になります。
解決策:謎解き契約に対して、答えを得たユーザーは最初に「ランダム数+自身のアドレス+謎解きの答え」のハッシュ値を提出し、謎解き契約がそのハッシュ値を保存した後、ユーザーはランダム情報と答えを提出します。契約はハッシュ値が一致することを確認した後に報酬を発放します。権限額を更新する際は、最初に権限額をゼロに設定します。
(十四)block.timestampまたはblock.numberを契約の時間参照として使用する
block.timestampとblock.numberは正確な時間を取得できず、スマートコントラクトの時間参照として使用すると潜在的なリスクを引き起こします。
解決策:オラクルを使用して時間情報を取得します。
(十五)サービス拒否(DoS)
外部契約を呼び出すことで永続的に失敗し、契約が新しい指示を受け付けられなくなる可能性があります。例えば、契約が別の契約に対して転送を行い、転送先の契約に転送を受け入れる関数がない場合、転送が失敗し、この時契約がサービス拒否状態に陥る可能性があります。
(1)サンプルコード
契約があるアカウントに転送が失敗すると、すべての転送が失敗します。
(2)解決策:契約が外部契約を呼び出す際に失敗が発生する可能性があるため、契約は呼び出し失敗の状況を処理するコードを含め、契約がサービス拒否状態に陥るのを防ぐ必要があります。
(十六)チェーン属性をランダムソースとして使用する
チェーン属性(block.timestamp、blockhash、block.difficultyなど)はマイナーによって操作される可能性があり、リスクが存在します。
解決策:RANDAO、オラクル、またはビットコインブロックのハッシュをランダムソースとして使用することを検討します。
(十七)継承順序の誤り
複数の継承契約が同じ関数を定義している場合、継承契約がその関数を呼び出す優先順位は継承順序によって決まります。誤った継承順序は関数呼び出しのエラーを引き起こします。
解決策:継承順序の説明は公式の例を参照してください:https://solidity-by-example.org/inheritance/。
(十八)ガス不足攻撃
マルチシグの場合や他の人にガスを代わりに支払ってもらう必要がある場合、ユーザーは署名された取引を準備し、代行者に渡します。代行者はユーザーの取引を実行契約に提出しますが、代行者はユーザーの取引を事前に審査でき、悪意のある代行者や取引内容が代行者に不利な場合、ガス供給を制限することで取引の実行を失敗させ、取引の実行を阻止することができます。
(1)サンプルコード
Relayer呼び出しがガス使用を制限することで特定の取引が失敗すると、その失敗した取引は二度と提出できなくなります。
(2)解決策:信頼できる代行者を選択するか、実行契約内で代理人が提供するガス料金が十分であるかを確認する必要があります。
(十九)関数型変数のジャンプ
Solidityは関数型変数をサポートしており、関数型変数にアセンブリ命令を使用して値を設定する際、関数型変数が悪意のある構造の関数を指す可能性があります。
解決策:必要がない限り、スマートコントラクト内でアセンブリ命令を使用することは避けるべきです。
(二十)ガスリミットサービス拒否攻撃(DoS)
ブロックにはガス使用の上限が設定されており、契約がブロックのガス使用上限を超えて実行されると、契約は永遠に成功裏に実行されることができません。
(1)サンプルコード
操作のループ回数が過大になると、契約の実行に必要なガスがブロックの上限を超え、契約の実行が失敗します。
(2)解決策:スマートコントラクト内で大きな配列やループを慎重に操作する必要があります。
(二十一)abi.encodePacked()ハッシュ衝突
abi.encodePacked()は非パディングシリアル化を採用しており、シリアル化パラメータに複数の可変長配列が含まれる場合、攻撃者はすべての要素の順序を保持したまま、2つの可変長配列の要素を変更することで、シリアル化結果が同じになることがあります。
(1)サンプルコード
addUserの入力を構築することで、攻撃者はregularUsersのメンバーをadminsメンバーに追加することができますが、構築された入力と元の入力の署名は同じです。
(2)解決策:固定長配列を使用するか、呼び出し元がabi.encodePacked()のパラメータを渡さないようにするか、またはabi.encode()を使用することを検討してください。
(二十二)transfer()およびsend()関数のガス不足
transfer()およびsend()関数は2300ガスを使用して再入攻撃を防止しますが、パブリックチェーンのアップグレード後にガス不足を引き起こす可能性があります。
解決策:call()関数の使用を推奨しますが、再入攻撃の防護を行う必要があります。
(二十三)チェーン上の未暗号化プライバシーデータ
チェーン上のデータは完全に透明であり、契約のprivateキーワードは契約のプライバシーデータの漏洩を防ぐことはできません。
(1)サンプルコード
playersがprivateであっても、攻撃者はチェーン上のデータを解析することでplayersを読み取ることができます。
(2)解決策:プライバシーデータは暗号化してチェーン上に置く必要があります。
以上は私たちが分析し、まとめた23種類のセキュリティ事故のタイプの概要です。皆様に何らかの参考や示唆を提供できれば幸いです。