이십삼 가지 DeFi 안전 사고 요약: 스마트 계약 위험 및 예방
작성자: Austin Zhang, Jon Li, Asymmetries Technologies
스마트 계약의 보안 문제는 업계의 주요 주제 중 하나로, 프로그래머의 일부 실수로 인해 사고와 논리적 결함이 발생하여 해커에게 기회를 제공하게 됩니다. 우리는 현재 DeFi 분야에서 발생한 보안 사고가 있는 스마트 계약을 수집하고, 우리가 작성한 예제 코드를 바탕으로 그 원인을 실증적으로 분석하여 동료 및 업계 관계자들에게 통찰을 제공하고자 합니다.
(1) 재진입 공격
주요 공격 방식 중 하나: 계약이 악의적인 외부 계약을 호출하기 전에, 악의적인 외부 계약 함수가 원래 계약 함수를 역으로 호출하여 관련 취약점을 이용합니다.
(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 두 개의 계약으로 나뉩니다. 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 함수를 사용하여 호출이 성공했는지 반드시 확인해야 합니다. 주의: 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 여섯 가지 토큰의 계약은 permit을 구현하지 않고 fallback을 구현하여, Multichain이 이러한 토큰의 권한을 확인할 때 사용자가 공격자에게 전송할 수 있도록 권한을 부여했다고 잘못 생각하여 토큰이 도난당했습니다.
(2) 해결책: 서로 다른 토큰의 구현 방식이 다르므로 새로운 토큰을 도입하기 전에 그 구체적인 구현을 면밀히 검토해야 합니다.
(八) 전송 수수료가 있는 토큰 처리 미비
일부 토큰은 전송 시 일부 전송 수수료를 소각하여 실제로 수신되는 토큰 잔액이 적어질 수 있습니다. 개발자가 이를 고려하지 않으면 전송 값으로 계산할 때 편차가 발생할 수 있습니다.
(1) 사례: 2021년 8월 19일 Pinecone이 20만 달러 도난
사고 원인: Pinecone은 자금 풀의 스테이킹 토큰으로 PCT를 사용하며, PCT 전송 시 수수료 손실이 발생합니다. 계약은 관련 손실을 고려하지 않아 사용자 지분과 스테이킹된 PCT 총액에 편차가 발생하여 공격자가 이를 이용해 과도한 보상을 수령했습니다.
(2) 해결책: 모든 토큰의 전송 수수료가 native token이 아니라는 점을 명심해야 합니다.
(九) 서명 검증 취약점
서명이 반복 사용되거나 타원 곡선 서명 알고리즘의 대칭성을 이용하여 기존 서명을 기반으로 유효한 서명을 구성합니다.
(1) 사례: 2021년 7월 12일 AnySwap이 800만 달러 도난
사고 원인: 거래 서명에는 개인 키 외에 랜덤 수 R이 필요하지만, Anyswap이 새 계약을 배포하는 과정에서 실수로 BSC의 V3 라우터 MPC 계정 하에 두 개의 거래가 동일한 R 값 서명을 가지게 되어 공격자가 이 MPC 계정의 개인 키를 역추적하여 도난 자금을 전송했습니다.
(2) 해결책: EIP-712 표준을 사용하여 서명을 검증하고 OpenZeppelin의 구현을 참조합니다: https://docs.openzeppelin.com/contracts/3.x/api/drafts.
(十) 계약 잔액 변화 가능성 미고려
채굴자가 블록을 채굴하거나 스마트 계약이 selfdestruct 함수를 호출하여 자신을 파괴할 때 임의의 주소로 강제로 토큰을 전송하여 원래의 native 토큰 잔액을 변경할 수 있습니다. 잔액 함수의 반환 값을 판단 조건으로 사용할 때 잔액이 강제로 변경될 수 있어 위험이 발생할 수 있으며, 극단적인 경우 계약이 서비스 거부(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을 권한 검사에 사용하지 않아야 합니다.
(十三) 거래 정렬 경쟁
전체 노드 운영자는 거래가 확인되기 전에 거래 정보를 얻을 수 있으며, 이를 바탕으로 높은 수수료 거래를 구성하여 채굴자가 자신의 거래를 우선적으로 패키징하도록 하여 자신에게 유리한 전략을 실행할 수 있습니다. 예를 들어, 수수께끼 계약이 가장 빨리 정답을 찾은 사용자에게 보상을 주는 경우, 악의적인 사용자는 정직한 사용자가 제출한 정답을 알게 된 후 높은 수수료 거래를 구성하여 정직한 사용자가 제출한 정답을 우선적으로 제출하여 보상을 받을 수 있습니다. 또한 사용자가 권한 한도를 업데이트할 때, 권한을 부여받은 사용자가 업데이트 거래가 확인되기 전에 이전 권한 한도를 전송할 수 있습니다. 이로 인해 권한을 부여받은 사용자가 실제로 얻는 권한 한도는 두 번의 권한 한도의 합이 됩니다.
해결책: 수수께끼 계약에 대해 정답을 얻은 사용자가 먼저 "랜덤 수 + 자신의 주소 + 정답"의 해시 값을 제출하고, 수수께끼 계약이 해당 해시 값을 저장한 후 사용자가 랜덤 정보를 정답과 함께 제출하면 계약이 해시 값이 일치하는지 확인한 후 보상을 지급합니다. 권한 한도를 업데이트할 때는 먼저 권한 한도를 0으로 설정합니다.
(十四) block.timestamp 또는 block.number를 계약 시간 참조로 사용
block.timestamp와 block.number는 정확한 시간을 얻을 수 없으며, 스마트 계약의 시간 참조로 사용하면 잠재적인 위험을 초래할 수 있습니다.
해결책: oracle을 사용하여 시간 정보를 얻습니다.
(十五) 서비스 거부(DoS) 공격
외부 계약을 호출할 때 영구적으로 실패하여 본 계약이 새로운 지시를 받을 수 없게 될 수 있습니다. 예를 들어 계약이 다른 계약으로 전송을 수행하려고 할 때, 전송 계약에 전송을 수락하는 함수가 없으면 전송이 실패하고 계약이 서비스 거부 상태에 빠질 수 있습니다.
(1) 예제 코드
계약이 특정 계정으로의 전송이 실패하면 모든 전송이 실패하게 됩니다.
(2) 해결책: 계약이 외부 계약을 호출할 때 발생할 수 있는 실패를 처리하는 코드를 포함하여 계약이 서비스 거부 상태에 빠지지 않도록 해야 합니다.
(十六) 체인 속성을 랜덤 소스로 사용
체인 속성인 block.timestamp, blockhash, block.difficulty 및 기타 속성은 채굴자가 조작할 수 있어 위험이 존재합니다.
해결책: RANDAO, oracle 또는 비트코인 블록 해시를 랜덤 소스로 사용하는 것을 고려합니다.
(十七) 상속 순서 오류
여러 개의 상속 계약이 동일한 함수를 정의할 경우, 상속 계약이 해당 함수를 호출하는 우선 순위는 상속 순서에 따라 결정됩니다. 잘못된 상속 순서는 함수 호출 오류를 초래할 수 있습니다.
해결책: 상속 순서에 대한 설명은 공식 예제를 참조하십시오: https://solidity-by-example.org/inheritance/.
(十八) 가스 부족 공격
다중 서명 상황에서 또는 다른 사람이 가스를 대신 지불해야 할 때, 사용자는 서명된 거래를 준비하고 대행자에게 전달합니다. 대행자는 사용자의 거래를 실행 계약에 제출하기 전에 미리 검토할 수 있으며, 악의적인 대행자나 거래 내용이 대행자에게 불리할 경우 가스 공급을 제한하여 거래 실행을 실패하게 만들어 거래 실행을 방지할 수 있습니다.
(1) 예제 코드
Relayer 호출자가 가스 사용을 제한하여 특정 거래가 실패하게 되면, 실패한 거래는 다시 제출될 수 없습니다.
(2) 해결책: 신뢰할 수 있는 대행자를 선택하거나 실행 계약에서 대행자가 제공한 가스 비용이 충분한지 확인해야 합니다.
(十九) 함수 타입 변수 점프
Solidity는 함수 타입 변수를 지원하며, 함수 타입 변수가 어셈블리 명령어로 값을 할당할 때 악의적으로 구성된 함수로 지시될 수 있습니다.
해결책: 필요하지 않은 경우 스마트 계약에서 어셈블리 명령어 사용을 최대한 피해야 합니다.
(二十) 가스 한도 서비스 거부 공격(DoS)
블록은 가스 사용에 상한이 설정되어 있으며, 계약이 블록 가스 사용 상한을 초과하여 실행되면 계약이 성공적으로 실행될 수 없습니다.
(1) 예제 코드
작업의 반복 횟수가 너무 많으면 계약 실행에 필요한 가스가 블록 상한을 초과하여 계약 실행이 실패합니다.
(2) 해결책: 스마트 계약에서 큰 배열이나 반복 작업을 신중하게 처리해야 합니다.
(二十一) abi.encodePacked() 해시 충돌
abi.encodePacked()는 비패딩 직렬화를 사용하며, 직렬화 매개변수에 여러 가변 길이 배열이 포함될 경우 공격자는 모든 요소의 순서를 유지하면서 두 개의 가변 길이 배열의 요소를 변경하여 직렬화 결과가 동일하게 만들 수 있습니다.
(1) 예제 코드
addUser의 입력을 구성함으로써 공격자는 regularUsers의 구성원을 admins 구성원으로 추가할 수 있지만, 구성된 입력과 원래 입력의 서명이 동일합니다.
(2) 해결책: 고정 길이 배열을 사용하거나 호출자가 abi.encodePacked()의 매개변수를 전달하지 못하도록 하거나 abi.encode()를 사용해야 합니다.
(二十二) transfer() 및 send() 함수 가스 부족
transfer() 및 send() 함수는 재진입 공격을 방지하기 위해 2300 가스를 사용하며, 퍼블릭 체인 업그레이드 후 가스 부족을 초래할 수 있습니다.
해결책: call() 함수를 사용하는 것을 권장하지만 재진입 공격 방어를 잘 준비해야 합니다.
(二十三) 체인 상의 비암호화 개인 데이터
체인 상의 데이터는 완전히 투명하며, 계약의 private 키워드는 계약의 개인 데이터 유출을 방지할 수 없습니다.
(1) 예제 코드
players가 private이지만 공격자는 여전히 체인 상의 데이터를 분석하여 players를 읽을 수 있습니다.
(2) 해결책: 개인 데이터는 암호화하여 체인에 저장해야 합니다.
이상은 우리가 분석하고 요약한 23가지 보안 사고 유형의 총정리입니다. 여러분에게 조금이나마 참고와 통찰을 제공할 수 있기를 바랍니다.