
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] - 계약의 총 잔액은 였으며 이제 이므로 이 if 문이 통과합니다.
최종 결과는 공격자가 단일 트랜잭션으로 즉시 EtherStore 계약에서 모든 이더(1개 제외)를 출금한다는 것입니다.
스마트 계약에서 잠재적인 재진입 취약점을 방지하는 데 도움이 되는 몇 가지 일반적인 기법이 있습니다. 첫 번째는 ( 가능할 때마다) 외부 계약으로 이더를 보낼 때 내장된 transfer() 함수를 사용하는 것입니다. transfer 함수는 외부 호출과 함께 2300 gas만 보내므로 대상 주소/계약이 다른 계약을 호출(즉, 보내는 계약에 재진입)하기에는 충분하지 않습니다.
두 번째 기법은 상태 변수를 변경하는 모든 로직이 이더가 계약 밖으로 보내지기 전에(또는 외부 호출이 발생하기 전에) 실행되도록 하는 것입니다. EtherStore 예시에서 EtherStore.sol의 라인 [18] 및 [19]는 라인 [17] 앞에 있어야 합니다. 알 수 없는 주소에 외부 호출을 수행하는 모든 코드는 지역화된 함수 또는 코드 실행 조각의 마지막 작업으로 배치하는 것이 좋은 관행입니다. 이를 checks-effects-interactions 패턴이라고 합니다.
세 번째 기법은 뮤텍스(mutex)를 도입하는 것입니다. 즉, 코드 실행 중에 계약을 잠가 재진입 호출을 방지하는 상태 변수를 추가하는 것입니다.
이 모든 기법을 EtherStore.sol에 적용하면(세 가지 모두 꼭 필요한 것은 아니지만 설명을 위한 목적으로 적용됨) 재진입이 없는 계약이 생성됩니다:```solidity
contract EtherStore {
// initialise the mutex
bool reEntrancyMutex = false;
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(!reEntrancyMutex);
require(balances[msg.sender] >= _weiToWithdraw);
// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);
// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
// set the reEntrancy mutex before the external call
reEntrancyMutex = true;
msg.sender.transfer(_weiToWithdraw);
// release the mutex after the external call
reEntrancyMutex = false;
}
}
<h3 id="re-example">실제 사례: The DAO</h3>
[The DAO](https://en.wikipedia.org/wiki/The_DAO_(organization)) (탈중앙화 자치 조직)은 이더리움 초기 개발 과정에서 발생한 주요 해킹 사건 중 하나였습니다. 당시 해당 컨트랙트는 1억 5천만 달러(USD) 이상을 보유하고 있었습니다. 재진입(Re-entrancy)이 이 공격에서 주요한 역할을 했으며, 이는 결국 이더리움 클래식(ETC)을 탄생시킨 하드포크로 이어졌습니다. DAO 익스플로잇에 대한 좋은 분석은 [Phil Daian의 게시물](http://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/)을 참조하세요.
<h2 id="ouflow"><span id="SP-2">2. 산술 오버플로/언더플로</span></h2>
이더리움 가상 머신(EVM)은 정수에 대해 고정 크기 데이터 타입을 지정합니다. 즉, 정수 변수는 표현할 수 있는 숫자의 범위가 정해져 있습니다. 예를 들어 `uint8`은 \[0,255\] 범위의 숫자만 저장할 수 있습니다. `uint8`에 `256`을 저장하려고 하면 `0`이 됩니다. 주의하지 않으면, 사용자 입력이 검증되지 않은 상태에서 이를 저장하는 데이터 타입의 범위를 벗어나는 숫자를 결과로 내는 계산이 수행될 때 Solidity의 변수가 악용될 수 있습니다.
산술 오버플로/언더플로에 대한 자세한 내용은 [How to Secure Your Smart Contracts](https://medium.com/loom-network/how-to-secure-your-smart-contracts-6-solidity-vulnerabilities-and-how-to-avoid-them-part-1-c33048d4d17d), [Ethereum Smart Contract Best Practices](https://consensys.github.io/smart-contract-best-practices/known_attacks/#integer-overflow-and-underflow) 및 [Ethereum, Solidity and integer overflows: programming blockchains like 1970](https://randomoracle.wordpress.com/2018/04/27/ethereum-solidity-and-integer-overflows-programming-blockchains-like-1970/)을 참조하세요.
<h3 id="ou-vuln">취약점</h3>
오버플로/언더플로는 고정 크기 변수가 해당 변수의 데이터 타입 범위를 벗어나는 숫자(또는 데이터)를 저장하도록 요구하는 연산이 수행될 때 발생합니다.
예를 들어, 값으로 `0`을 저장하고 있는 `uint8`(8비트 부호 없는 정수, 즉 양수만) 변수에서 `1`을 빼면 숫자 `255`가 됩니다. 이것이 언더플로입니다. `uint8`의 범위보다 낮은 숫자를 할당한 것이며, 결과는 *래핑(wraps around)*되어 `uint8`이 저장할 수 있는 가장 큰 숫자가 됩니다. 마찬가지로 `uint8`에 `2^8=256`을 더하면 `uint`의 전체 길이를 한 바퀴 돌았기 때문에 변수는 그대로 유지됩니다 (수학자들을 위해 설명하자면, 이는 삼각 함수의 각도에 $2\pi$를 더하는 것과 유사합니다. $\sin(x) = \sin(x+2\pi)$). 데이터 타입의 범위보다 큰 숫자를 더하는 것을 오버플로라고 합니다. 명확히 하기 위해, 현재 값이 0인 `uint8`에 `257`을 더하면 숫자 `1`이 됩니다. 고정 타입 변수가 순환적이라고 생각하면 때로는 유익합니다. 저장 가능한 가장 큰 숫자보다 큰 숫자를 더하면 0부터 다시 시작하고, 그 반대로 0의 경우에는 0에서 더 많이 뺄수록 가장 큰 숫자부터 아래로 내려가며 셉니다.
이런 종류의 숫자적 함정은 공격자가 코드를 악용하여 예상치 못한 로직 흐름을 만들 수 있게 합니다. 예를 들어, 아래의 시간 잠금(TimeLock) 컨트랙트를 살펴보세요.
TimeLock.sol:```solidity
contract TimeLock {
mapping(address => uint) public balances;
mapping(address => uint) public lockTime;
function deposit() public payable {
balances[msg.sender] += msg.value;
lockTime[msg.sender] = now + 1 weeks;
}
function increaseLockTime(uint _secondsToIncrease) public {
lockTime[msg.sender] += _secondsToIncrease;
}
function withdraw() public {
require(balances[msg.sender] > 0);
require(now > lockTime[msg.sender]);
uint transferValue = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
이 계약은 시간 금고(time vault)처럼 작동하도록 설계되었습니다. 사용자는 계약에 이더(ether)를 예치할 수 있으며, 그 이더는 최소 1주일 동안 잠겨 있습니다. 사용자는 원한다면 대기 시간을 1주일보다 더 길게 연장할 수도 있습니다. 하지만 일단 예치하면, 사용자는 자신의 이더가 최소 1주일 동안 안전하게 잠겨 있다는 것을 확신할 수 있습니다. 정말 그럴까요?...
사용자가 개인 키를 강제로 넘겨야 하는 상황(인질 상황을 생각해 보세요)에서, 이런 계약은 짧은 시간 동안 이더를 얻을 수 없게 하는 데 유용할 수 있습니다. 사용자가 이 계약에 100 ether를 잠가 두고 공격자에게 키를 넘겼다면, 공격자는 lockTime에 관계없이 오버플로를 사용하여 이더를 받을 수 있습니다.
공격자는 현재 키를 보유하고 있는 주소의 현재 lockTime을 확인할 수 있습니다(공개 변수이기 때문입니다). 이를 userLockTime이라고 부르겠습니다. 그런 다음 공격자는 increaseLockTime 함수를 호출하면서 인수로 2^256 - userLockTime 숫자를 전달할 수 있습니다. 이 숫자는 현재 userLockTime에 더해져 오버플로를 일으키고, lockTime[msg.sender]를 0으로 리셋합니다. 그러면 공격자는 간단히 withdraw 함수를 호출하여 보상을 얻을 수 있습니다.
다른 예를 하나 살펴보겠습니다. 이번 예는 Ethernaut Challanges에서 가져온 것입니다.
스포일러 주의: 아직 Ethernaut 챌린지를 해보지 않았다면, 이 내용은 그중 한 레벨의 해답을 알려줍니다.```solidity pragma solidity ^0.4.18;
contract Token {
mapping(address => uint) balances; uint public totalSupply;
function Token(uint _initialSupply) { balances[msg.sender] = totalSupply = _initialSupply; }
function transfer(address _to, uint _value) public returns (bool) { require(balances[msg.sender] - _value >= 0); balances[msg.sender] -= _value; balances[_to] += _value; return true; }
function balanceOf(address _owner) public constant returns (uint balance) { return balances[_owner]; } }
This is a simple token contract which employs a `transfer()` function, allowing participants to move their tokens around. Can you see the error in this contract?
이것은 참가자들이 토큰을 이동할 수 있게 하는 `transfer()` 함수를 사용하는 간단한 토큰 컨트랙트입니다. 이 컨트랙트에서 오류를 찾을 수 있나요?
The flaw comes in the `transfer()` function. The require statement on line \[13\] can be bypassed using an underflow. Consider a user that has no balance. They could call the `transfer()` function with any non-zero `_value` and pass the require statement on line \[13\]. This is because `balances[msg.sender]` is zero (and a `uint256`) so subtracting any positive amount (excluding `2^256`) will result in a positive number due to the underflow we described above. This is also true for line \[14\], where our balance will be credited with a positive number. Thus, in this example, we have achieved free tokens due to an underflow vulnerability.
결함은 `transfer()` 함수에 있습니다. line \[13\]의 require 문은 언더플로를 사용하여 우회할 수 있습니다. 잔액이 없는 사용자를 고려해 보세요. 그들은 0이 아닌 `_value`로 `transfer()` 함수를 호출하고 line \[13\]의 require 문을 통과할 수 있습니다. `balances[msg.sender]`가 0이고 (`uint256`)이므로 양수 금액(`2^256` 제외)을 빼면 위에서 설명한 언더플로로 인해 양수가 되기 때문입니다. 이는 line \[14\]에도 해당되며, 여기서 우리의 잔액은 양수로 적립됩니다. 따라서 이 예시에서는 언더플로 취약점으로 인해 무료 토큰을 얻었습니다.
<h3 id="ou-prevention">예방 기법</h3>
The (currently) conventional technique to guard against under/overflow vulnerabilities is to use or build mathematical libraries which replace the standard math operators; addition, subtraction and multiplication (division is excluded as it doesn't cause over/under flows and the EVM reverts on division by 0).
(현재) 언더플로/오버플로 취약점을 방지하는 일반적인 기술은 표준 수학 연산자를 대체하는 수학 라이브러리를 사용하거나 구축하는 것입니다. 덧셈, 뺄셈, 곱셈(나눗셈은 오버/언더플로를 일으키지 않고 EVM이 0으로 나눌 때 되돌리기 때문에 제외됩니다).
[OppenZepplin](https://github.com/OpenZeppelin/zeppelin-solidity) have done a great job in building and auditing secure libraries which can be leveraged by the Ethereum community. In particular, their [Safe Math Library](https://github.com/OpenZeppelin/zeppelin-solidity/blob/master/contracts/math/SafeMath.sol) is a reference or library to use to avoid under/over flow vulnerabilities.
[OppenZepplin](https://github.com/OpenZeppelin/zeppelin-solidity)은 Ethereum 커뮤니티가 활용할 수 있는 보안 라이브러리를 구축하고 감사하는 훌륭한 작업을 해왔습니다. 특히 그들의 [Safe Math Library](https://github.com/OpenZeppelin/zeppelin-solidity/blob/master/contracts/math/SafeMath.sol)는 언더/오버플로 취약점을 피하기 위해 사용할 수 있는 참조 자료 또는 라이브러리입니다.
To demonstrate how these libraries are used in Solidity, let us correct the `TimeLock` contract, using Open Zepplin's `SafeMath` library. The over flow-free contract would become:
이 라이브러리들이 Solidity에서 어떻게 사용되는지 보여주기 위해, Open Zepplin의 `SafeMath` 라이브러리를 사용하여 `TimeLock` 컨트랙트를 수정해 보겠습니다. 오버플로가 없는 컨트랙트는 다음과 같습니다:```solidity
library SafeMath {
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) {
return 0;
}
uint256 c = a * b;
assert(c / a == b);
return c;
}
function div(uint256 a, uint256 b) internal pure returns (uint256) {
// assert(b > 0); // Solidity automatically throws when dividing by 0
uint256 c = a / b;
// assert(a == b * c + a % b); // There is no case in which this doesn't hold
return c;
}
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
assert(b <= a);
return a - b;
}
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
contract TimeLock {
using SafeMath for uint; // use the library for uint type
mapping(address => uint256) public balances;
mapping(address => uint256) public lockTime;
function deposit() public payable {
balances[msg.sender] = balances[msg.sender].add(msg.value);
lockTime[msg.sender] = now.add(1 weeks);
}
function increaseLockTime(uint256 _secondsToIncrease) public {
lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease);
}
function withdraw() public {
require(balances[msg.sender] > 0);
require(now > lockTime[msg.sender]);
uint transferValue = balances[msg.sender];
balances[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
Notice that all standard math operations have been replaced by the those defined in the SafeMath library. The TimeLock contract no longer performs any operation which is capable of doing an under/over flow.
한 4chan 그룹은 Solidity로 작성된 이더리움 기반 폰지 사기를 만드는 것이 좋은 아이디어라고 결정했습니다. 그들은 이를 Proof of Weak Hands Coin(PoWHC)이라고 불렀습니다. 불행히도 계약 작성자들은 이전에 오버플로/언더플로를 본 적이 없는 것 같았고, 그 결과 866 이더가 계약에서 빠져나갔습니다. 언더플로가 어떻게 발생하는지에 대한 훌륭한 개요(위의 Ethernaut 챌린지와 크게 다르지 않음)는 Eric Banisadar의 게시글에서 확인할 수 있습니다.
일부 개발자들은 일부 ERC20 토큰 계약에 batchTransfer() 함수를 구현하기도 했습니다. 그 구현에는 오버플로가 포함되어 있었습니다. 이 게시물이 이를 설명하지만, 제목이 오해의 소지가 있다고 생각합니다. ERC20 표준과는 아무 관련이 없으며, 오히려 일부 ERC20 토큰 계약에 취약한 batchTransfer() 함수가 구현되어 있기 때문입니다.
일반적으로 이더가 계약으로 전송될 때, 계약은 폴백 함수나 계약에 정의된 다른 함수를 실행해야 합니다. 여기에는 두 가지 예외가 있는데, 이 경우 이더는 어떤 코드도 실행하지 않고 계약에 존재할 수 있습니다. 계약으로 전송되는 모든 이더에 대해 코드 실행을 신뢰하는 계약은 이더가 강제로 계약에 전송되는 공격에 취약할 수 있습니다.
이에 대한 자세한 내용은 How to Secure Your Smart Contracts: 6와 Solidity 보안 패턴 - 계약에 이더 강제 전송 을 참조하세요.
올바른 상태 전이를 강제하거나 연산을 검증하는 데 유용한 일반적인 방어적 프로그래밍 기법은 *불변식 검사(invariant-checking)*입니다. 이 기법은 변경되지 않아야 하는 불변식(지표 또는 매개변수) 집합을 정의하고, 단일(또는 다수의) 연산 후에도 이러한 불변식이 변경되지 않았는지 확인하는 것을 포함합니다. 확인되는 불변식이 실제로 불변식인 경우 이는 일반적으로 좋은 설계입니다. 불변식의 한 예는 고정 발행 ERC20 토큰의 totalSupply입니다. 어떤 함수도 이 불변식을 수정해서는 안 되므로, transfer() 함수에 totalSupply가 변경되지 않았는지 확인하는 검사를 추가하여 함수가 예상대로 작동하는지 확인할 수 있습니다.
특히, 사용하고 싶어질 만한 명백한 불변식이 하나 있습니다.
하지만 이는 외부 사용자가 (스마트 계약에 설정된 규칙과 관계없이) 실제로
조작할 수 있습니다. 이것이 계약에 저장된 현재 이더입니다.
개발자들이 Solidity를 처음 배울 때 계약이 payable 함수를 통해서만
이더를 수락하거나 얻을 수 있다는 잘못된 생각을 하곤 합니다.
이러한 오해는 계약 내 이더 잔액에 대해 잘못된 가정을 하는 계약으로 이어질 수 있으며,
이는 다양한 취약점을 초래할 수 있습니다.
이 취약점의 결정적 증거는 this.balance의 (잘못된) 사용입니다.
앞으로 보게 되겠지만, this.balance의 잘못된 사용은
이러한 유형의 심각한 취약점으로 이어질 수 있습니다.
payable 함수를 사용하지 않거나 계약의 코드를 실행하지 않고 이더를 (강제로) 계약에 보낼 수 있는 두 가지 방법이 있습니다. 아래에 나열되어 있습니다.
모든 계약은 selfdestruct(address) 함수를 구현할 수 있습니다. 이 함수는 계약 주소에서 모든 바이트코드를 제거하고 거기에 저장된 모든 이더를 매개변수로 지정된 주소로 보냅니다. 이 지정된 주소가 계약인 경우에도 어떤 함수(폴백 포함)도 호출되지 않습니다. 따라서 selfdestruct() 함수는 계약에 존재하는 코드와 관계없이 모든 계약에 이더를 강제로 보내는 데 사용될 수 있습니다. 여기에는 payable 함수가 없는 계약도 포함됩니다. 즉, 공격자는 selfdestruct() 함수가 있는 계약을 만들고, 이더를 보낸 다음, selfdestruct(target)을 호출하여 target 계약으로 이더를 강제로 보낼 수 있습니다. Martin Swende는 self-destruct opcode의 몇 가지 특이점(Quirk #2)과 클라이언트 노드가 잘못된 불변식을 검사하여 클라이언트에 치명적인 타격을 줄 수 있었던 방법에 대한 설명을 담은 훌륭한 블로그 게시물을 작성했습니다.
계약이 selfdestruct() 함수를 사용하거나 payable 함수를 호출하지 않고 이더를 얻을 수 있는 두 번째 방법은 계약 주소에 이더를 미리 로드하는 것입니다. 계약 주소는 결정론적이며, 실제로 주소는 계약을 생성하는 주소와 계약을 생성하는 트랜잭션 nonce의 keccak256(SHA3와 동의어로 사용되기도 함) 해시로 계산됩니다. 구체적으로는 address = sha3(rlp.encode([account_address,transaction_nonce])) 형태입니다(이에 대한 재미있는 사용 사례는 Keyless Ether 참조). 즉, 누구나 계약이 생성되기 전에 계약 주소가 무엇인지 계산하고 해당 주소로 이더를 보낼 수 있습니다. 계약이 생성되면 0이 아닌 이더 잔액을 갖게 됩니다.
위 지식을 바탕으로 발생할 수 있는 몇 가지 함정을 살펴보겠습니다.
지나치게 단순한 계약을 고려해 보세요.
EtherGame.sol:```solidity contract EtherGame {
uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;
mapping(address => uint) redeemableEther;
// users pay 0.5 ether. At specific milestones, credit their accounts
function play() public payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
// if at a milestone credit the players account
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}
function claimReward() public {
// ensure the game is complete
require(this.balance == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
This contract represents a simple game (which would naturally invoke [race-conditions](#race-conditions)) whereby players send `0.5 ether` quanta to the contract in hope to be the player that reaches one of three milestones first. Milestone's are denominated in ether. The first to reach the milestone may claim a portion of the ether when the game has ended. The game ends when the final milestone (`10 ether`) is reached and users can claim their rewards.
The issues with the `EtherGame` contract come from the poor use of `this.balance` in both lines \[14\] (and by association \[16\]) and \[32\]. A mischievous attacker could forcibly send a small amount of ether, let's say `0.1 ether` via the `selfdestruct()` function (discussed above) to prevent any future players from reaching a milestone. As all legitimate players can only send `0.5 ether` increments, `this.balance` would no longer be half integer numbers, as it would also have the `0.1 ether` contribution. This prevents all the if conditions on lines \[18\], \[21\] and \[24\] from being true.
Even worse, a vengeful attacker who missed a milestone, could forcibly send `10 ether` (or an equivalent amount of ether that pushes the contract's balance above the `finalMileStone`) which would lock all rewards in the contract forever. This is because the `claimReward()` function will always revert, due to the require on line \[32\] (i.e. `this.balance` is greater than `finalMileStone`).
<h3 id="ether-prevention">예방 기법</h3>
이 취약점은 일반적으로 `this.balance`의 오용에서 발생합니다. 계약 로직은, 가능하다면, 계약의 잔액 정확한 값에 의존하지 않도록 해야 합니다. 잔액은 인위적으로 조작될 수 있기 때문입니다. `this.balance`에 기반한 로직을 적용한다면, 예상치 못한 잔액을 고려해야 합니다.
입금된 이더의 정확한 값이 필요한 경우, 지불 가능(payable) 함수에서 증가시키는 자체 정의 변수를 사용하여 입금된 이더를 안전하게 추적해야 합니다. 이 변수는 `selfdestruct()` 호출을 통해 강제로 전송된 이더의 영향을 받지 않습니다.
이를 고려하면, `EtherGame` 계약의 수정된 버전은 다음과 같을 수 있습니다:```solidity
contract EtherGame {
uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;
uint public depositedWei;
mapping (address => uint) redeemableEther;
function play() public payable {
require(msg.value == 0.5 ether);
uint currentBalance = depositedWei + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
depositedWei += msg.value;
return;
}
function claimReward() public {
// ensure the game is complete
require(depositedWei == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
uint transferValue = redeemableEther[msg.sender];
redeemableEther[msg.sender] = 0;
msg.sender.transfer(transferValue);
}
}
여기서 우리는 방금 새로운 변수 depositedWei를 생성했는데, 이는 알려진 입금된 이더를 추적하며, 우리의 요구사항과 테스트를 수행하는 대상이 바로 이 변수입니다. 이제 우리는 더 이상 this.balance에 대한 어떤 참조도 가지고 있지 않다는 점을 주목하세요.
아직까지 이것이 실제로 악용된 사례를 찾지 못했습니다. 그러나 악용 가능한 계약의 몇 가지 예시는 Underhanded Solidity Contest에서 확인할 수 있습니다.
CALL 및 DELEGATECALL opcode는 Ethereum 개발자가 코드를 모듈화할 수 있게 해주는 유용한 기능입니다. 계약에 대한 표준 외부 메시지 호출은 CALL opcode에 의해 처리되며, 이때 코드는 외부 계약/함수의 컨텍스트에서 실행됩니다. DELEGATECALL opcode는 표준 메시지 호출과 동일하지만, 대상 주소에서 실행되는 코드가 호출하는 계약의 컨텍스트에서 실행된다는 점과 msg.sender 및 msg.value가 변경되지 않은 상태로 유지된다는 점이 다릅니다. 이 기능은 개발자가 향후 계약을 위해 재사용 가능한 코드를 만들 수 있는 라이브러리의 구현을 가능하게 합니다.
이 두 opcode의 차이는 간단하고 직관적이지만, DELEGATECALL의 사용은 예기치 않은 코드 실행으로 이어질 수 있습니다.
추가 자료는 Ethereum Stack Exchange Question, Solidity Docs 및 How to Secure Your Smart Contracts: 6을 참조하세요.
DELEGATECALL의 컨텍스트 유지 특성은 취약점이 없는 맞춤형 라이브러리를 구축하는 것이 생각만큼 쉽지 않다는 것을 입증했습니다. 라이브러리 자체의 코드는 안전하고 취약점이 없을 수 있지만, 다른 애플리케이션의 컨텍스트에서 실행될 때 새로운 취약점이 발생할 수 있습니다. 피보나치 수를 사용하는 이 다소 복잡한 예를 살펴보겠습니다.
피보나치 수열 및 유사한 형태의 수열을 생성할 수 있는 다음 라이브러리를 고려해 보세요.
FibonacciLib.sol[^1]```solidity
// library contract - calculates fibonacci-like numbers;
contract FibonacciLib {
// initializing the standard fibonacci sequence;
uint public start;
uint public calculatedFibNumber;
// modify the zeroth number in the sequence
function setStart(uint _start) public {
start = _start;
}
function setFibonacci(uint n) public {
calculatedFibNumber = fibonacci(n);
}
function fibonacci(uint n) internal returns (uint) {
if (n == 0) return start;
else if (n == 1) return start + 1;
else return fibonacci(n - 1) + fibonacci(n - 2);
}
}
이 라이브러리는 수열에서 *n*번째 피보나치 수를 생성하는 함수를 제공합니다. 사용자는 수열의 시작 숫자(`start`)를 변경하고 이 새로운 수열에서 *n*번째 피보나치 유사 수를 계산할 수 있습니다.
이제 이 라이브러리를 사용하는 계약을 살펴보겠습니다.
`FibonacciBalance.sol`:```solidity
contract FibonacciBalance {
address public fibonacciLibrary;
// the current fibonacci number to withdraw
uint public calculatedFibNumber;
// the starting fibonacci sequence number
uint public start = 3;
uint public withdrawalCounter;
// the fibonancci function selector
bytes4 constant fibSig = bytes4(sha3("setFibonacci(uint256)"));
// constructor - loads the contract with ether
constructor(address _fibonacciLibrary) public payable {
fibonacciLibrary = _fibonacciLibrary;
}
function withdraw() {
withdrawalCounter += 1;
// calculate the fibonacci number for the current withdrawal user
// this sets calculatedFibNumber
require(fibonacciLibrary.delegatecall(fibSig, withdrawalCounter));
msg.sender.transfer(calculatedFibNumber * 1 ether);
}
// allow users to call fibonacci library functions
function() public {
require(fibonacciLibrary.delegatecall(msg.data));
}
}
이 계약은 참가자가 계약에서 이더를 인출할 수 있게 하며, 인출 금액은 참가자의 인출 순서에 해당하는 피보나치 수와 같습니다. 즉, 첫 번째 참가자는 1이더를 받고, 두 번째 참가자도 1을 받으며, 세 번째는 2, 네 번째는 3, 다섯 번째는 5를 받는 식입니다(계약 잔액이 인출되는 피보나치 수보다 작아질 때까지).
이 계약에는 설명이 필요한 몇 가지 요소가 있습니다. 첫째, 흥미로워 보이는 변수 fibSig가 있습니다. 이 변수는 문자열 "setFibonacci(uint256)"의 Keccak(SHA-3) 해시의 첫 4바이트를 보관합니다. 이는 함수 선택자(function selector)로 알려져 있으며, 스마트 계약의 어떤 함수를 호출할지 지정하기 위해 calldata에 들어갑니다. 이는 21행의 delegatecall 함수에서 setFibonacci(uint256) 함수를 실행하려는 의도를 지정하는 데 사용됩니다. delegatecall의 두 번째 인자는 함수에 전달하는 매개변수입니다. 둘째, FibonacciLib 라이브러리의 주소가 생성자에서 올바르게 참조된다고 가정합니다(외부 계약 참조 섹션에서는 이러한 종류의 계약 참조 초기화와 관련된 잠재적 취약점을 설명합니다).
이 계약에서 오류를 찾을 수 있나요? 이 코드를 Remix에 넣고 이더를 채운 다음 withdraw()를 호출하면 아마 되돌리기(revert)가 발생할 것입니다.
상태 변수 start가 라이브러리와 메인 호출 계약 양쪽 모두에서 사용된다는 점을 눈치챘을 것입니다. 라이브러리 계약에서 start는 피보나치 수열의 시작을 지정하는 데 사용되며 0으로 설정되지만, FibonacciBalance 계약에서는 3으로 설정됩니다. 또한 FibonacciBalance 계약의 폴백 함수가 모든 호출을 라이브러리 계약으로 전달하도록 허용하며, 이로 인해 라이브러리 계약의 setStart() 함수도 호출될 수 있다는 점도 확인했을 것입니다. 계약의 상태를 보존한다는 점을 상기하면, 이 함수가 로컬 FibonacciBalance 계약의 start 변수 상태를 변경할 수 있게 해 줄 것처럼 보일 수 있습니다. 만약 그렇다면, 결과로 나오는 calculatedFibNumber가 start 변수에 의존하므로(라이브러리 계약에서 볼 수 있듯이) 더 많은 이더를 인출할 수 있게 됩니다. 실제로 setStart() 함수는 FibonacciBalance 계약의 start 변수를 수정하지 않으며(수정할 수도 없습니다). 이 계약의 근본적인 취약점은 단순히 변수를 수정하는 것보다 훨씬 심각합니다.
실제 문제를 논의하기 전에, 상태 변수(storage 변수)가 계약에 실제로 어떻게 저장되는지 이해하기 위해 잠시 우회하겠습니다. 상태 또는 storage 변수(개별 트랜잭션에 걸쳐 유지되는 변수)는 계약에 도입되는 순서대로 슬롯(slot)에 순차적으로 배치됩니다. (여기에는 몇 가지 복잡한 점이 있으며, 더 철저한 이해를 위해 독자께서는 Storage의 상태 변수 레이아웃(Layout of State Variables in Storage)을 읽어보시기를 권장합니다.)
예를 들어 라이브러리 계약을 살펴보겠습니다. 여기에는 start와 calculatedFibNumber라는 두 개의 상태 변수가 있습니다. 첫 번째 변수는 start이므로 계약 스토리지의 slot[0](즉, 첫 번째 슬롯)에 저장됩니다. 두 번째 변수 calculatedFibNumber는 다음 사용 가능한 스토리지 슬롯인 slot[1]에 배치됩니다. setStart() 함수를 보면 입력을 받아 start를 입력값으로 설정합니다. 따라서 이 함수는 setStart() 함수에 제공하는 입력값으로 slot[0]을 설정하는 것입니다. 마찬가지로 setFibonacci() 함수는 calculatedFibNumber를 fibonacci(n)의 결과로 설정합니다. 이것 역시 단순히 스토리지 slot[1]을 fibonacci(n)의 값으로 설정하는 것입니다.
이제 FibonacciBalance 계약을 살펴보겠습니다. 스토리지 slot[0]은 이제 fibonacciLibrary 주소에 해당하고 slot[1]은 calculatedFibNumber에 해당합니다. 취약점이 발생하는 지점이 바로 이 잘못된 매핑입니다. delegatecall은 계약 컨텍스트를 보존합니다. 즉, delegatecall을 통해 실행되는 코드는 호출 계약의 상태(즉, 스토리지)에 대해 작동합니다.
이제 21행의 withdraw()에서 fibonacciLibrary.delegatecall(fibSig,withdrawalCounter)를 실행한다는 점에 주목하세요. 이는 setFibonacci() 함수를 호출하며, 앞서 논의했듯이 이 함수는 스토리지 slot[1]을 수정하는데, 현재 컨텍스트에서는 이것이 calculatedFibNumber입니다. 이는 예상된 동작입니다(즉, 실행 후 calculatedFibNumber가 조정됩니다). 그러나 FibonacciLib 계약의 start 변수는 스토리지 slot[0]에 있으며, 이는 현재 계약에서 fibonacciLibrary 주소라는 점을 기억하세요. 이는 fibonacci() 함수가 예상치 못한 결과를 반환한다는 것을 의미합니다. 그 이유는 현재 호출 컨텍스트에서 start(slot[0])가 fibonacciLibrary 주소를 참조하기 때문입니다(이 주소는 로 해석될 때 종종 상당히 큰 값이 됩니다). 따라서 함수는 가 반환할 만큼의 이더를 보유하지 못할 가능성이 높아 되돌리기될 가능성이 큽니다.
더 나쁜 것은, FibonacciBalance 계약은 사용자가 26행의 폴백 함수를 통해 fibonacciLibrary의 모든 함수를 호출할 수 있도록 허용한다는 점입니다. 앞서 논의했듯이 여기에는 setStart() 함수도 포함됩니다. 이 함수는 누구나 스토리지 slot[0]을 수정하거나 설정할 수 있게 해 준다고 논의했습니다. 이 경우 스토리지 slot[0]은 fibonacciLibrary 주소입니다. 따라서 공격자는 악성 계약(예시는 아래에 제공됨)을 만들고, 주소를 uint로 변환한 다음(Python에서 int('<address>',16)을 사용해 쉽게 변환할 수 있음), setStart(<attack_contract_address_as_uint>)를 호출할 수 있습니다. 이렇게 하면 fibonacciLibrary가 공격 계약의 주소로 변경됩니다. 그런 다음 사용자가 withdraw() 또는 폴백 함수를 호출할 때마다 악성 계약이 실행되어(계약의 전체 잔액을 탈취할 수 있음) fibonacciLibrary의 실제 주소가 수정되었기 때문입니다. 그러한 공격 계약의 예시는 다음과 같습니다.```solidity
contract Attack {
uint storageSlot0; // corresponds to fibonacciLibrary
uint storageSlot1; // corresponds to calculatedFibNumber
// fallback - this will run if a specified function is not found
function() public {
storageSlot1 = 0; // we set calculatedFibNumber to 0, so that if withdraw
// is called we don't send out any ether.
<attacker_address>.transfer(this.balance); // we take all the ether
}
}
이 공격 계약이 `calculatedFibNumber`를 스토리지 `slot[1]`을 변경하여 수정한다는 점에 유의하세요. 원칙적으로 공격자는 이 계약에 대한 모든 종류의 공격을 수행하기 위해 선택한 다른 스토리지 슬롯을 수정할 수 있습니다. 모든 독자들이 이 계약들을 [Remix](https://remix.ethereum.org)에 넣고 다양한 공격 계약과 상태 변경을 `delegatecall` 함수들을 통해 실험해 보시기를 권장합니다.
또한 `delegatecall`이 상태를 보존한다고 말할 때, 우리는 계약의 변수 이름을 말하는 것이 아니라 해당 이름이 가리키는 실제 스토리지 슬롯을 말한다는 점을 인지하는 것이 중요합니다. 이 예제에서 볼 수 있듯이, 단순한 실수가 공격자로 하여금 전체 계약과 그 이더를 탈취하게 만들 수 있습니다.
<h3 id="dc-prevention">예방 기술</h3>
Solidity는 라이브러리 계약을 구현하기 위한 `library` 키워드를 제공합니다 (자세한 내용은 [Solidity 문서](http://solidity.readthedocs.io/en/latest/contracts.html?highlight=library#libraries)를 참조하세요). 이는 라이브러리 계약이 상태 비저장(stateless)이고 자체 파괴가 불가능하도록 보장합니다. 라이브러리를 상태 비저장으로 강제하면 이 섹션에서 설명한 스토리지 컨텍스트의 복잡성이 완화됩니다. 상태 비저장 라이브러리는 또한 공격자가 라이브러리 코드에 의존하는 계약들에 영향을 주기 위해 라이브러리의 상태를 직접 수정하는 공격을 방지합니다.
일반적인 규칙으로, `DELEGATECALL`을 사용할 때는 라이브러리 계약과 호출 계약 양쪽의 가능한 호출 컨텍스트에 세심한 주의를 기울이고, 가능할 때마다 상태 비저장 라이브러리를 구축하세요.
<h3 id="dc-example">실제 사례: 패리티 멀티시그 월렛 (두 번째 해킹)</h3>
두 번째 패리티 멀티시그 월렛 해킹은 잘 작성된 라이브러리 코드의 컨텍스트가 의도되지 않은 컨텍스트에서 실행될 경우 어떻게 악용될 수 있는지 보여주는 사례입니다. 이 해킹에 대한 훌륭한 설명이 여러 가지 있습니다. 예를 들어 Anthony Akentiev의 이 개요: [Parity MultiSig Hacked. Again](https://medium.com/chain-cloud-company-blog/parity-multisig-hack-again-b46771eaa838), 이 [stack exchange 질문](https://ethereum.stackexchange.com/questions/30128/explanation-of-parity-library-suicide/30130), 그리고 [An In-Depth Look at the Parity Multisig Bug](http://hackingdistributed.com/2017/07/22/deep-dive-parity-bug/)가 있습니다.
이러한 참고 자료에 더해, 악용된 계약들을 살펴보겠습니다. 라이브러리 및 월렛 계약은 패리티 GitHub에서 [여기](https://github.com/paritytech/parity/blob/b640df8fbb964da7538eef268dffc125b081a82f/js/src/contracts/snippets/enhanced-wallet.sol)에서 찾을 수 있습니다.
이 계약의 관련 측면을 살펴보겠습니다. 여기에는 두 개의 관심 계약이 있습니다: 라이브러리 계약과 월렛 계약입니다.
라이브러리 계약,```solidity
contract WalletLibrary is WalletEvents {
...
// throw unless the contract is not yet initialized.
modifier only_uninitialized { if (m_numOwners > 0) throw; _; }
// constructor - just pass on the owner array to the multiowned and
// the limit to daylimit
function initWallet(address[] _owners, uint _required, uint _daylimit) only_uninitialized {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
// kills the contract sending everything to `_to`.
function kill(address _to) onlymanyowners(sha3(msg.data)) external {
suicide(_to);
}
...
}
그리고 지갑 계약,```solidity contract Wallet is WalletEvents {
...
// METHODS
// gets called when no other function matches function() payable { // just being sent some cash? if (msg.value > 0) Deposit(msg.sender, msg.value); else if (msg.data.length > 0) _walletLibrary.delegatecall(msg.data); }
...
// FIELDS address constant _walletLibrary = 0xcafecafecafecafecafecafecafecafecafecafe; }
Notice that the `Wallet` contract essentially passes all calls to the `WalletLibrary` contract via a delegate call. The constant `_walletLibrary` address in this code snippet acts as a placeholder for the actually deployed `WalletLibrary` contract (which was at `0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4`).
The intended operation of these contracts was to have a simple low-cost deployable `Wallet` contract whose code base and main functionality was in the `WalletLibrary` contract. Unfortunately, the `WalletLibrary` contract is itself a contract and maintains it's own state. Can you see why this might be an issue?
It is possible to send calls to the `WalletLibrary` contract itself. Specifically, the `WalletLibrary` contract could be initialised, and become owned. A user did this, by calling `initWallet()` function on the `WalletLibrary` contract, becoming an owner of the library contract. The same user, subsequently called the `kill()` function. Because the user was an owner of the Library contract, the modifier passed and the library contract suicided. As all `Wallet` contracts in existence refer to this library contract and contain no method to change this reference, all of their functionality, including the ability to withdraw ether is lost along with the `WalletLibrary` contract. More directly, all ether in all parity multi-sig wallets of this type instantly become lost or permanently unrecoverable.
<h2 id="visibility"><span id="SP-5">5. 기본 가시성</span></h2>
Solidity의 함수에는 함수가 어떻게 호출되도록 허용되는지를 결정하는 가시성 지정자(visibility specifier)가 있습니다. 가시성은 함수가 사용자에 의해 외부에서 호출될 수 있는지, 다른 파생 계약에 의해 호출될 수 있는지, 내부에서만 호출될 수 있는지, 아니면 외부에서만 호출될 수 있는지를 결정합니다. 네 가지 가시성 지정자가 있으며 자세한 내용은 [Solidity 문서](http://solidity.readthedocs.io/en/latest/contracts.html?highlight=library#visibility-and-getters)에 설명되어 있습니다. 함수는 기본적으로 `public`이며 사용자가 외부에서 호출할 수 있습니다. 가시성 지정자의 잘못된 사용은 이 섹션에서 논의할 스마트 계약의 심각한 취약점으로 이어질 수 있습니다.
<h3 id="visibility-vuln">취약점</h3>
함수의 기본 가시성은 `public`입니다. 따라서 가시성을 지정하지 않은 함수는 외부 사용자가 호출할 수 있습니다. 문제는 개발자가 private(또는 계약 내부에서만 호출 가능해야 하는) 함수에 가시성 지정자를 실수로 무시할 때 발생합니다.
빠르게 사소한 예제를 살펴보겠습니다.```solidity
contract HashForEther {
function withdrawWinnings() {
// Winner if the last 8 hex characters of the address are 0.
require(uint32(msg.sender) == 0);
_sendWinnings();
}
function _sendWinnings() {
msg.sender.transfer(this.balance);
}
}
이 간단한 계약은 주소 맞히기 현상금 게임 역할을 하도록 설계되었습니다. 계약의 잔액을 얻으려면 사용자는 마지막 8개의 16진수 문자가 0인 이더리움 주소를 생성해야 합니다. 이를 얻으면 WithdrawWinnings() 함수를 호출하여 현상금을 받을 수 있습니다.
안타깝게도 함수의 가시성이 지정되지 않았습니다. 특히 _sendWinnings() 함수는 public이므로 모든 주소가 이 함수를 호출하여 현상금을 훔칠 수 있습니다.
계약의 모든 함수에 대해 가시성을 항상 지정하는 것이 좋은 관행입니다. 의도적으로 public으로 지정하는 경우에도 마찬가지입니다. 최신 버전의 Solidity는 명시적 가시성이 설정되지 않은 함수에 대해 컴파일 중 경고를 표시하여 이 관행을 장려합니다.
첫 번째 패리티 멀티시그 해킹에서 약 $31M 상당의 이더가 주로 세 개의 지갑에서 도난당했습니다. 정확히 어떻게 이루어졌는지에 대한 좋은 요약은 Haseeb Qureshi가 이 글에서 제공합니다.
기본적으로, 멀티시그 월렛(여기에서 찾을 수 있음)은 핵심 기능을 포함하는 라이브러리 계약을 호출하는 기본 Wallet 계약으로 구성됩니다(실제 사례: Parity Multisig (두 번째 해킹)에서 설명한 바와 같습니다). 라이브러리 계약에는 다음 스니펫에서 볼 수 있듯이 지갑을 초기화하는 코드가 포함되어 있습니다.```solidity
contract WalletLibrary is WalletEvents {
...
// METHODS
...
// constructor is given number of sigs required to do protected "onlymanyowners" transactions // as well as the selection of addresses capable of confirming them. function initMultiowned(address[] _owners, uint _required) { m_numOwners = _owners.length + 1; m_owners[1] = uint(msg.sender); m_ownerIndex[uint(msg.sender)] = 1; for (uint i = 0; i < _owners.length; ++i) { m_owners[2 + i] = uint(_owners[i]); m_ownerIndex[uint(_owners[i])] = 2 + i; } m_required = _required; }
...
// constructor - just pass on the owner array to the multiowned and // the limit to daylimit function initWallet(address[] _owners, uint _required, uint _daylimit) { initDaylimit(_daylimit); initMultiowned(_owners, _required); } }
두 함수 모두 명시적으로 가시성(visibility)을 지정하지 않았다는 점에 유의하세요. 두 함수 모두 기본적으로 `public`입니다. `initWallet()` 함수는 지갑 생성자에서 호출되며, `initMultiowned()` 함수에서 볼 수 있듯이 다중 서명 지갑의 소유자를 설정합니다. 이 함수들이 실수로 `public`으로 남겨졌기 때문에, 공격자는 배포된 컨트랙트에서 이 함수들을 호출하여 소유권을 공격자의 주소로 재설정할 수 있었습니다. 그 후 공격자는 소유자 자격으로 지갑에서 모든 이더를 인출하여 무려 \$31M에 달하는 피해를 입혔습니다.
<h2 id="entropy"><span id="SP-6">6. 엔트로피 착각</span></h2>
Ethereum 블록체인의 모든 트랜잭션은 결정론적 상태 전환 연산입니다. 즉, 모든 트랜잭션은 Ethereum 생태계의 전역 상태를 수정하며, 그 과정은 불확실성 없이 계산 가능한 방식으로 이루어집니다. 이는 궁극적으로 블록체인 생태계 내부에는 엔트로피 또는 무작위성의 원천이 없음을 의미합니다. Solidity에는 `rand()` 함수가 없습니다. 분산형 엔트로피(무작위성)를 달성하는 것은 잘 알려진 문제이며, 이를 해결하기 위해 많은 아이디어가 제안되었습니다(예: [RandDAO](https://github.com/randao/randao) 또는 이 [게시물](https://vitalik.ca/files/randomness.html)에서 Vitalik이 설명한 해시 체인 사용).
<h3 id="entropy-vuln">취약점</h3>
Ethereum 플랫폼에 처음 구축된 일부 컨트랙트는 도박을 기반으로 했습니다. 근본적으로 도박은 불확실성(베팅할 무언가)을 요구하므로, 블록체인(결정론적 시스템)에 도박 시스템을 구축하는 것은 상당히 어렵습니다. 불확실성은 반드시 블록체인 외부의 소스에서 비롯되어야 한다는 것은 분명합니다. 이는 동료 간 베팅에서는 가능하지만(예: [커밋-리빌 기법](https://ethereum.stackexchange.com/questions/191/how-can-i-securely-generate-a-random-number-in-my-smart-contract)), 블랙잭이나 룰렛에서처럼 *하우스* 역할을 하는 컨트랙트를 구현하려면 훨씬 더 어렵습니다. 일반적인 함정은 해시, 타임스탬프, 블록 번호 또는 가스 한도와 같은 미래 블록 변수를 사용하는 것입니다. 이들 변수의 문제점은 블록을 채굴하는 채굴자가 통제하므로 진정한 난수가 아니라는 점입니다. 예를 들어, 다음 블록 해시가 짝수로 끝나면 검은색 번호를 반환하는 로직이 있는 룰렛 스마트 컨트랙트를 생각해 보겠습니다. 채굴자(또는 채굴 풀)는 검은색에 \\$1M을 베팅할 수 있습니다. 다음 블록을 풀었을 때 해시가 홀수로 끝나는 것을 발견하면 기꺼이 블록을 발행하지 않고, 블록 해시가 짝수인 솔루션을 찾을 때까지 다른 블록을 채굴할 것입니다(블록 보상과 수수료가 $1M 미만이라고 가정). 과거 또는 현재 변수를 사용하는 것은 Martin Swende가 자신의 훌륭한 [블로그 게시물](http://martin.swende.se/blog/Breaking_the_house.html)에서 입증한 것처럼 훨씬 더 파괴적일 수 있습니다. 또한 블록 변수만 사용하면 블록 내의 모든 트랜잭션에 대해 의사 난수가 동일해지므로, 공격자는 한 블록 내에서 많은 트랜잭션을 수행하여 승리 금액을 배가시킬 수 있습니다(최대 베팅이 있는 경우).
<h3 id="entropy-prevention">예방 기법</h3>
엔트로피(무작위성)의 소스는 반드시 블록체인 외부에 있어야 합니다. 이는 [커밋-리빌](https://ethereum.stackexchange.com/questions/191/how-can-i-securely-generate-a-random-number-in-my-smart-contract)과 같은 시스템을 통해 동료 간에 수행하거나, 신뢰 모델을 참여자 그룹으로 변경하는 방식(예: [RandDAO](https://github.com/randao/randao))으로 수행할 수 있습니다. 또한 무작위성 오라클 역할을 하는 중앙화된 엔티티를 통해서도 수행할 수 있습니다. 블록 변수(일반적으로 일부 예외가 있음)는 채굴자가 조작할 수 있으므로 엔트로피의 소스로 사용해서는 안 됩니다.
<h3 id="entropy-example">실제 사례: PRNG 컨트랙트</h3>
Arseny Reutov는 일종의 의사 난수 생성기(PRNG)를 사용하는 3649개의 실제 스마트 컨트랙트를 분석한 후 [블로그 게시물](https://blog.positive.com/predicting-random-numbers-in-ethereum-smart-contracts-e5358c6b8620)을 작성했으며, 그중 43개의 컨트랙트가 악용될 수 있음을 발견했습니다.
<h2 id="contract-reference"><span id="SP-7">7. 외부 컨트랙트 참조</span></h2>
Ethereum *글로벌 컴퓨터*의 이점 중 하나는 코드를 재사용하고 네트워크에 이미 배포된 컨트랙트와 상호작용할 수 있는 능력입니다. 그 결과, 많은 컨트랙트가 외부 컨트랙트를 참조하며, 일반적인 운영에서는 외부 메시지 호출을 사용하여 이러한 컨트랙트와 상호작용합니다. 이러한 외부 메시지 호출은 악의적인 행위자의 의도를 명확하지 않은 방식으로 숨길 수 있으며, 이에 대해 다룰 것입니다.
<h3 id="cr-vuln">취약점</h3>
Solidity에서는 해당 주소의 코드가 캐스팅되는 컨트랙트 유형을 나타내는지 여부와 관계없이 모든 주소를 컨트랙트로 캐스팅할 수 있습니다. 이는 특히 컨트랙트 작성자가 악성 코드를 숨기려 할 때 기만적일 수 있습니다. 예를 들어 이를 설명하겠습니다:
Rot13 암호를 기초적으로 구현한 코드 조각을 고려해 보겠습니다.
`Rot13Encryption.sol`:```solidity
//encryption contract
contract Rot13Encryption {
event Result(string convertedString);
//rot13 encrypt a string
function rot13Encrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
//inline assembly to modify the string
assembly {
char := byte(0,char) // get the first byte
if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping.
{ char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z.
if iszero(eq(char, 0x20)) // ignore spaces
{mstore8(add(add(text,0x20), mul(i,1)), add(char,13))} // add 13 to char.
}
}
emit Result(text);
}
// rot13 decrypt a string
function rot13Decrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
assembly {
char := byte(0,char)
if and(gt(char,0x60), lt(char,0x6E))
{ char:= add(0x7B, sub(char,0x61)) }
if iszero(eq(char, 0x20))
{mstore8(add(add(text,0x20), mul(i,1)), sub(char,13))}
}
}
emit Result(text);
}
}
이 코드는 단순히 문자열(검증 없는 a-z 문자)을 가져와 각 문자를 13자리 오른쪽으로 이동(‘z’에서 순환)시켜 암호화합니다. 즉, 'a'는 'n'으로, 'x'는 'k'로 이동합니다. 여기서의 어셈블리는 중요하지 않으므로, 이 단계에서 이해가 되지 않아도 걱정하지 마세요.
암호화에 이 코드를 사용하는 다음 계약을 고려해 보세요.```solidity import "Rot13Encryption.sol";
// encrypt your top secret info contract EncryptionContract { // library for encryption Rot13Encryption encryptionLibrary;
// constructor - initialise the library
constructor(Rot13Encryption _encryptionLibrary) {
encryptionLibrary = _encryptionLibrary;
}
function encryptPrivateData(string privateInfo) {
// potentially do some operations here
encryptionLibrary.rot13Encrypt(privateInfo);
}
}
이 계약의 문제는 `encryptionLibrary` 주소가 public 또는 constant가 아니라는 점입니다. 따라서 계약 배포자는 생성자에서 이 계약을 가리키는 주소를 전달할 수 있었습니다:```solidity
//encryption contract
contract Rot26Encryption {
event Result(string convertedString);
//rot13 encrypt a string
function rot13Encrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
//inline assembly to modify the string
assembly {
char := byte(0,char) // get the first byte
if and(gt(char,0x6D), lt(char,0x7B)) // if the character is in [n,z], i.e. wrapping.
{ char:= sub(0x60, sub(0x7A,char)) } // subtract from the ascii number a by the difference char is from z.
if iszero(eq(char, 0x20)) // ignore spaces
{mstore8(add(add(text,0x20), mul(i,1)), add(char,26))} // add 13 to char.
}
}
emit Result(text);
}
// rot13 decrypt a string
function rot13Decrypt (string text) public {
uint256 length = bytes(text).length;
for (var i = 0; i < length; i++) {
byte char = bytes(text)[i];
assembly {
char := byte(0,char)
if and(gt(char,0x60), lt(char,0x6E))
{ char:= add(0x7B, sub(char,0x61)) }
if iszero(eq(char, 0x20))
{mstore8(add(add(text,0x20), mul(i,1)), sub(char,26))}
}
}
emit Result(text);
}
}
이는 rot26 암호(각 문자를 26자리씩 이동시키는 암호, 아시겠죠? :p)를 구현합니다. 다시 말하지만, 이 계약의 어셈블리를 이해할 필요는 없습니다. 배포자는 다음과 같은 계약도 연결할 수 있었을 것입니다:```solidity contract Print{ event Print(string text);
function rot13Encrypt(string text) public {
emit Print(text);
}
}
생성자에 이 두 계약 중 하나의 주소가 지정된 경우, `encryptPrivateData()` 함수는 암호화되지 않은 개인 데이터를 출력하는 이벤트를 생성할 뿐입니다. 이 예제에서는 라이브러리 유사 계약이 생성자에 설정되었지만, 권한 있는 사용자(예: `owner`)가 라이브러리 계약 주소를 변경할 수 있는 경우가 많습니다. 링크된 계약에 호출된 함수가 포함되어 있지 않으면 폴백 함수가 실행됩니다. 예를 들어, `encryptionLibrary.rot13Encrypt()` 코드 줄에서 `encryptionLibrary`로 지정된 계약이 다음과 같다면:```solidity
contract Blank {
event Print(string text);
function () {
emit Print("Here");
//put malicious code here and it will run
}
}
then an event with the text "Here" would be emitted. Thus if users can alter contract libraries, they can in principle get users to unknowingly run arbitrary code.
Note: Don't use encryption contracts such as these, as the input parameters to smart contracts are visible on the blockchain. Also the Rot cipher is not a recommended encryption technique :p
위에서 설명한 것처럼, 취약점이 없는 컨트랙트도 (경우에 따라) 악의적으로 동작하도록 배포될 수 있습니다. 감사자는 컨트랙트를 공개적으로 검증하고, 소유자가 이를 악의적인 방식으로 배포하게 하여, 취약점이나 악의적인 의도를 가진 공개 감사 컨트랙트가 될 수 있습니다.
이러한 시나리오를 방지하는 여러 기법이 있습니다.
한 가지 기법은 new 키워드를 사용하여 컨트랙트를 생성하는 것입니다. 위 예제에서 생성자는 다음과 같이 작성할 수 있습니다:```solidity
constructor() {
encryptionLibrary = new Rot13Encryption();
}
This way an instance of the referenced contract is created at deployment time and the deployer cannot replace the `Rot13Encryption` contract with anything else without modifying the smart contract.
Another solution is to hard code any external contract addresses if they are known.
In general, code that calls external contracts should always be looked at carefully. As a developer, when defining external contracts, it can be a good idea to make the contract addresses public (which is not the case in the honey-pot example given below) to allow users to easily examine which code is being referenced by the contract. Conversely, if a contract has a private variable contract address it can be a sign of someone behaving maliciously (as shown in the real-world example). If a privileged (or any) user is capable of changing a contract address which is used to call external functions, it can be important (in a decentralised system context) to implement a time-lock or voting mechanism to allow users to see which code is being changed or to give participants a chance to opt in/out with the new contract address.
<h3 id="cr-example">실제 사례: 재진입 허니팟</h3>
최근 다수의 허니팟이 메인넷에 배포되었습니다. 이러한 계약들은 계약을 악용하려는 이더리움 해커들을 속이려고 시도하지만, 해커들은 결국 자신이 악용할 것으로 예상한 계약에 이더를 잃게 됩니다. 한 예로, 생성자에서 예상된 계약을 악성 계약으로 교체하는 위 공격을 사용합니다. 코드는 [여기](https://etherscan.io/address/0x95d34980095380851902ccd9a1fb4c813c2cb639#code)에서 확인할 수 있습니다:```solidity
pragma solidity ^0.4.19;
contract Private_Bank
{
mapping (address => uint) public balances;
uint public MinDeposit = 1 ether;
Log TransferLog;
function Private_Bank(address _log)
{
TransferLog = Log(_log);
}
function Deposit()
public
payable
{
if(msg.value >= MinDeposit)
{
balances[msg.sender]+=msg.value;
TransferLog.AddMessage(msg.sender,msg.value,"Deposit");
}
}
function CashOut(uint _am)
{
if(_am<=balances[msg.sender])
{
if(msg.sender.call.value(_am)())
{
balances[msg.sender]-=_am;
TransferLog.AddMessage(msg.sender,_am,"CashOut");
}
}
}
function() public payable{}
}
contract Log
{
struct Message
{
address Sender;
string Data;
uint Val;
uint Time;
}
Message[] public History;
Message LastMsg;
function AddMessage(address _adr,uint _val,string _data)
public
{
LastMsg.Sender = _adr;
LastMsg.Time = now;
LastMsg.Val = _val;
LastMsg.Data = _data;
History.push(LastMsg);
}
}
한 레딧 사용자가 작성한 이 게시물은 계약에 존재할 것으로 예상한 재진입(re-entrancy) 버그를 악용하려다 이 계약으로 1 ether를 잃은 과정을 설명합니다.
이 공격은 Solidity 계약 자체를 대상으로 수행되는 것이 아니라 계약과 상호작용할 수 있는 제3자 애플리케이션을 대상으로 수행됩니다. 완전성을 위해, 그리고 계약에서 매개변수가 어떻게 조작될 수 있는지 알 수 있도록 이 공격을 추가합니다.
더 자세한 내용은 ERC20 짧은 주소 공격 설명, ICO 스마트 계약 취약점: 짧은 주소 공격 또는 이 reddit 게시물을 참조하세요.
스마트 계약에 매개변수를 전달할 때, 매개변수는 ABI 사양에 따라 인코딩됩니다. 예상 매개변수 길이보다 짧은 인코딩 매개변수를 보내는 것이 가능합니다(예: 표준 40 hex 문자(20바이트) 대신 38 hex 문자(19바이트)만 포함된 주소를 보내는 경우). 이러한 시나리오에서 EVM은 인코딩된 매개변수의 끝에 0을 채워 예상 길이를 맞춥니다.
이는 제3자 애플리케이션이 입력을 검증하지 않을 때 문제가 됩니다. 가장 명확한 예는 사용자가 출금을 요청할 때 ERC20 토큰의 주소를 확인하지 않는 거래소입니다. 이 예는 위에서 언급한 Peter Venesses의 게시물 ERC20 짧은 주소 공격 설명에서 더 자세히 다룹니다.
매개변수의 순서에 주목하면서 표준 ERC20 transfer 함수 인터페이스를 고려해 보십시오,```solidity function transfer(address to, uint tokens) public returns (bool success);
이제 어떤 거래소가 많은 양의 토큰(예: `REP`)을 보유하고 있고, 한 사용자가 자신의 몫인 100개 토큰을 인출하려고 한다고 가정해 보자. 사용자는 자신의 주소인 `0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead`와 토큰 수량인 `100`을 제출할 것이다. 거래소는 `transfer()` 함수가 지정한 순서, 즉 `address` 다음에 `tokens` 순서로 이 매개변수들을 인코딩할 것이다. 인코딩된 결과는 `a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000` `000000000000000000000000000000000056bc75e2d63100000`가 된다. 첫 4바이트(`a9059cbb`)는 `transfer()`의 [함수 시그니처/셀렉터](https://solidity.readthedocs.io/en/latest/abi-spec.html#function-selector)이고, 다음 32바이트는 주소이며, 마지막 32바이트는 토큰 수량을 나타내는 `uint256` 값이다. 끝부분의 16진수 `56bc75e2d63100000`이 100개 토큰에 해당한다는 점에 유의하라(`REP` 토큰 계약에서 지정한 대로 소수점 18자리).
자, 그럼 1바이트(16진수 2자리)가 누락된 주소를 보내면 어떤 일이 발생하는지 살펴보자. 구체적으로, 공격자가 마지막 두 자리가 누락된 `0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde`를 주소로 보내고 동일한 `100` 토큰을 인출한다고 가정해 보자. 만약 거래소가 이 입력값을 검증하지 않는다면, 이는 `a9059cbb000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeadde00000000000000` `00000000000000000000000000000000056bc75e2d6310000000`로 인코딩될 것이다. 그 차이는 미묘하다. 전송된 짧은 주소를 보정하기 위해 인코딩 끝부분에 `00`이 패딩된다는 점에 유의하라. 이것이 스마트 계약으로 전송되면, `address` 매개변수는 `0xdeaddeaddeaddeaddeaddeaddeaddeaddeadde00`으로 읽히고 값은 `56bc75e2d6310000000`으로 읽힌다(추가된 두 개의 `0`에 유의하라). 이제 이 값은 `25600`개 토큰이다(값에 `256`이 곱해졌다). 이 예시에서, 거래소가 이만큼의 토큰을 보유하고 있다면 사용자는 수정된 주소로 `25600`개 토큰을 인출하게 될 것이다(거래소는 사용자가 단지 `100`개만 인출한다고 생각하지만). 물론 이 예시에서 공격자는 수정된 주소를 소유하지 않을 것이다. 그러나 공격자가 `0`으로 끝나는 임의의 주소를 생성하고(이는 쉽게 무차별 대입이 가능하다) 그 생성된 주소를 사용한다면, 아무것도 모르는 거래소로부터 토큰을 쉽게 훔칠 수 있다.
<h3 id="short-prev">예방 기법</h3>
모든 입력값을 블록체인에 보내기 전에 검증하면 이러한 종류의 공격을 예방할 수 있다는 것은 두말할 나위 없이 당연하다. 또한 매개변수의 순서가 여기서 중요한 역할을 한다는 점에 유의해야 한다. 패딩은 오직 끝부분에서만 발생하므로, 스마트 계약에서 매개변수 순서를 신중하게 정하면 이 공격의 일부 형태를 완화할 수 있다.
<h3 id="short-example">실제 사례: 알려진 바 없음</h3>
실제로 공개된 이런 유형의 공격 사례는 알지 못한다.
<h2 id="unchecked-calls"><span id="SP-9">9. 검사되지 않은 CALL 반환값</span></h2>
Solidity에서 외부 호출을 수행하는 방법에는 여러 가지가 있다. 이더를 외부 계정으로 보내는 것은 일반적으로 `transfer()` 메서드를 통해 수행된다. 그러나 `send()` 함수도 사용될 수 있고, 더 다양한 외부 호출을 위해 `CALL` 연산 코드를 Solidity에서 직접 사용할 수 있다. `call()` 및 `send()` 함수는 호출 성공 여부를 나타내는 불리언을 반환한다. 따라서 이 함수들에는 한 가지 중요한 주의사항이 있다. `call()` 또는 `send()`로 시작된 외부 호출이 실패하더라도 이 함수들을 실행하는 트랜잭션은 되돌려지지 않고, `call()` 또는 `send()`가 단순히 `false`를 반환할 뿐이다. 반환 값을 확인하지 않고 대신 revert가 발생할 것이라고 기대할 때 흔한 함정이 발생한다.
자세한 내용은 [DASP Top 10](http://www.dasp.co/#item-4) 및 [라이브 이더리움 계약에서 "Unchecked-Send" 버그 스캔하기](http://hackingdistributed.com/2016/06/16/scanning-live-ethereum-contracts-for-bugs/)를 참조하라.
<h3 id="unchecked-calls-vuln">취약점</h3>
다음 예시를 고려해 보자:```solidity
contract Lotto {
bool public payedOut = false;
address public winner;
uint public winAmount;
// ... extra functionality here
function sendToWinner() public {
require(!payedOut);
winner.send(winAmount);
payedOut = true;
}
function withdrawLeftOver() public {
require(payedOut);
msg.sender.send(this.balance);
}
}
이 계약은 Lotto와 유사한 계약으로, winner가 winAmount만큼의 ether를 받으며, 일반적으로 누구나 인출할 수 있는 약간의 잔액이 남습니다.
버그는 send()가 응답을 확인하지 않고 사용되는 [11]번 줄에 존재합니다. 이 간단한 예제에서, 트랜잭션이 실패하는 winner(가스가 부족하거나 폴백 함수에서 의도적으로 throw하는 컨트랙트인 경우)는 payedOut이 true로 설정되도록 허용합니다(ether가 전송되었는지 여부와 관계없이). 이 경우 공개는 withdrawLeftOver() 함수를 통해 winner의 상금을 인출할 수 있습니다.
가능할 때마다 send() 대신 transfer() 함수를 사용하세요. transfer()는 외부 트랜잭션이 되돌려지면(revert) revert되기 때문입니다. send()가 필요한 경우 항상 반환 값을 확인해야 합니다.
더욱 견고한 권장 사항은 *인출 패턴(withdrawal pattern)*을 채택하는 것입니다. 이 솔루션에서 각 사용자는 계약에서 ether 전송을 처리하고 실패한 전송 트랜잭션의 결과를 독립적으로 처리하는 분리된 함수(즉, withdraw 함수)를 호출해야 합니다. 핵심 아이디어는 외부 전송 기능을 코드 베이스의 나머지 부분과 논리적으로 분리하고, 잠재적으로 실패한 트랜잭션의 부담을 withdraw 함수를 호출하는 최종 사용자에게 두는 것입니다.
Etherpot은 위에서 언급한 예제 계약과 크게 다르지 않은 스마트 컨트랙트 복권이었습니다. etherpot의 solidity 코드는 여기에서 찾을 수 있습니다: lotto.sol. 이 계약의 주요 몰락 원인은 블록 해시의 잘못된 사용이었습니다(마지막 256개의 블록 해시만 사용할 수 있으며, Etherpot이 이를 올바르게 구현하지 못한 방법에 대한 Aakil Fernandes의 게시물을 참조하세요). 그러나 이 계약은 또한 확인되지 않은 호출 값(unchecked call value) 문제를 겪었습니다. lotto.sol의 [80]번 줄에 있는 cash() 함수를 주목하세요:```solidity
...
function cash(uint roundIndex, uint subpotIndex){
var subpotsCount = getSubpotsCount(roundIndex);
if(subpotIndex>=subpotsCount)
return;
var decisionBlockNumber = getDecisionBlockNumber(roundIndex,subpotIndex);
if(decisionBlockNumber>block.number)
return;
if(rounds[roundIndex].isCashed[subpotIndex])
return;
//Subpots can only be cashed once. This is to prevent double payouts
var winner = calculateWinner(roundIndex,subpotIndex);
var subpot = getSubpot(roundIndex);
winner.send(subpot);
rounds[roundIndex].isCashed[subpotIndex] = true;
//Mark the round as cashed
} ...
Notice that on line \[21\] the send 함수의 반환 값이 확인되지 않고, 다음 줄에서 승자에게 자금이 전송되었음을 나타내는 부울 값을 설정한다는 점에 유의하세요. 이 버그로 인해 승자가 이더를 받지 못하는 상태가 발생할 수 있지만, 계약의 상태는 승자가 이미 지급받았다고 표시될 수 있습니다.
이 버그의 더 심각한 버전은 [King of the Ether](https://www.kingoftheether.com/thrones/kingoftheether/index.html)에서 발생했습니다. 이 계약에 대한 훌륭한 [사후 분석](https://www.kingoftheether.com/postmortem.html)이 작성되었으며, 확인되지 않은 실패한 `send()`가 계약을 공격하는 데 어떻게 사용될 수 있는지 자세히 설명합니다.
<h2 id="race-conditions"><span id="SP-10">10. 경쟁 조건(Race Conditions) / 프론트 러닝(Front Running)</span></h2>
다른 계약에 대한 외부 호출과 기본 블록체인의 다중 사용자 특성이 결합되어 사용자가 예기치 않은 상태를 얻기 위해 코드 실행을 *경쟁*하게 되는 다양한 잠재적 Solidity 함정이 발생합니다. [Re-Entrancy](#reentrancy)는 이러한 경쟁 조건의 한 예입니다. 이 섹션에서는 이더리움 블록체인에서 발생할 수 있는 다양한 종류의 경쟁 조건에 대해 더 일반적으로 논의합니다. 이 주제에 대한 좋은 게시물이 여러 가지 있습니다: [Ethereum Wiki - Safety](https://github.com/ethereum/wiki/wiki/Safety#race-conditions), [DASP - Front-Running](http://www.dasp.co/#item-7) 및 [Consensus - Smart Contract Best Practices](https://consensys.github.io/smart-contract-best-practices/known_attacks/#race-conditions).
<h3 id="race-conditions-vuln">취약점</h3>
대부분의 블록체인과 마찬가지로 이더리움 노드는 트랜잭션을 풀링하여 블록으로 구성합니다. 트랜잭션은 채굴자가 합의 메커니즘(현재 이더리움의 [ETHASH](https://github.com/ethereum/wiki/wiki/Ethash) PoW)을 해결한 후에만 유효한 것으로 간주됩니다. 블록을 해결한 채굴자는 풀의 어떤 트랜잭션이 블록에 포함될지도 선택하며, 일반적으로 트랜잭션의 `gasPrice`에 따라 정렬됩니다. 여기에 잠재적인 공격 벡터가 있습니다. 공격자는 트랜잭션 풀에서 문제에 대한 해결책을 포함하거나, 공격자의 권한을 수정하거나 취소하거나, 공격자에게 바람직하지 않은 계약 상태를 변경하는 트랜잭션을 감시할 수 있습니다. 그런 다음 공격자는 이 트랜잭션에서 데이터를 가져와 더 높은 `gasPrice`로 자신의 트랜잭션을 생성하고 원래 트랜잭션보다 먼저 블록에 포함시킬 수 있습니다.
간단한 예를 통해 이것이 어떻게 작동하는지 살펴보겠습니다. `FindThisHash.sol` 계약을 고려해 보세요:```solidity
contract FindThisHash {
bytes32 constant public hash = 0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a;
constructor() public payable {} // load with ether
function solve(string solution) public {
// If you can find the pre image of the hash, receive 1000 ether
require(hash == sha3(solution));
msg.sender.transfer(1000 ether);
}
}
이 계약에 1000 이더가 들어 있다고 상상해 보세요. sha3 해시 0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a의 프리이미지를 찾은 사용자는 솔루션을 제출하고 1000 이더를 가져갈 수 있습니다. 한 사용자가 솔루션이 Ethereum!이라는 것을 알아냈다고 가정해 봅시다. 그들은 Ethereum!을 매개변수로 solve()를 호출합니다. 불행하게도 공격자는 솔루션을 제출하는 사람을 찾기 위해 트랜잭션 풀을 교묘하게 지켜보고 있습니다. 그들은 이 솔루션을 보고 유효성을 확인한 다음, 원래 트랜잭션보다 훨씬 높은 gasPrice로 동등한 트랜잭션을 제출합니다. 블록을 해결하는 채굴자는 더 높은 gasPrice 때문에 공격자에게 우선권을 줄 가능성이 높으며, 원래 해결자의 트랜잭션보다 먼저 수락합니다. 공격자는 1000 이더를 가져가고 문제를 해결한 사용자는 아무것도 얻지 못합니다 (계약에 남은 이더가 없기 때문입니다).
더 현실적인 문제는 향후 Casper 구현 설계에서 나타납니다. Casper 지분 증명(proof of stake) 계약은 검증자가 이중 투표를 하거나 잘못된 행동을 하는 것을 발견한 사용자가 그 증거를 제출하도록 인센티브를 제공하는 슬래싱(slashing) 조건을 호출합니다. 검증자는 처벌을 받고 사용자는 보상을 받습니다. 이러한 시나리오에서는 채굴자와 사용자가 그러한 모든 증거 제출을 프론트러닝(front-running)할 것으로 예상되며, 이 문제는 최종 릴리스 전에 해결되어야 합니다.
이런 종류의 프론트러닝 공격을 수행할 수 있는 사용자에는 두 가지 부류가 있습니다. 사용자(자신의 트랜잭션 gasPrice를 수정하는)와 채굴자 자신(블록 내 트랜잭션을 자신이 원하는 대로 재정렬할 수 있는)입니다. 첫 번째 부류(사용자)에 취약한 계약은 두 번째 부류(채굴자)에 취약한 계약보다 훨씬 더 나쁜 상황에 처합니다. 채굴자는 블록을 해결할 때만 공격을 수행할 수 있는데, 특정 블록을 노리는 개별 채굴자에게는 그럴 가능성이 낮기 때문입니다. 여기서는 어떤 부류의 공격자를 막을 수 있는지와 관련된 몇 가지 완화 조치를 나열하겠습니다.
사용할 수 있는 한 가지 방법은 계약에 gasPrice에 상한을 두는 로직을 만드는 것입니다. 이렇게 하면 사용자가 gasPrice를 높여 상한을 넘어선 선호 트랜잭션 순서를 얻는 것을 방지할 수 있습니다. 이 예방 조치는 첫 번째 부류의 공격자(임의의 사용자)만 완화합니다. 이 시나리오에서 채굴자는 가스 가격과 관계없이 블록 내 트랜잭션을 원하는 대로 정렬할 수 있으므로 여전히 계약을 공격할 수 있습니다.
더 강력한 방법은 가능할 때마다 커밋-리빌(commit-reveal) 방식을 사용하는 것입니다. 이러한 방식은 사용자가 숨겨진 정보(일반적으로 해시)를 포함한 트랜잭션을 보내도록 규정합니다. 트랜잭션이 블록에 포함된 후, 사용자는 보냈던 데이터를 공개하는 트랜잭션을 보냅니다(공개 단계). 이 방법은 채굴자와 사용자 모두 트랜잭션의 내용을 확인할 수 없기 때문에 트랜잭션을 프론트러닝하지 못하도록 방지합니다. 그러나 이 방법은 트랜잭션 값을 숨길 수 없습니다(경우에 따라 숨겨야 할 중요한 정보가 되는 값). ENS 스마트 계약은 사용자가 커밋된 데이터에 지출할 의향이 있는 이더 금액을 포함하여 트랜잭션을 보낼 수 있게 했습니다. 그러면 사용자는 임의의 값을 가진 트랜잭션을 보낼 수 있었습니다. 공개 단계에서 사용자는 트랜잭션에서 보낸 금액과 지출할 의향이 있는 금액의 차액을 환불받았습니다.
Lorenz, Phil, Ari, Florian의 추가 제안은 Submarine Sends를 사용하는 것입니다. 이 아이디어의 효율적인 구현에는 CREATE2 opcode가 필요한데, 현재 채택되지는 않았지만 곧 있을 하드 포크에서 채택될 가능성이 있어 보입니다.
ERC20 표준은 Ethereum에서 토큰을 만드는 데 매우 잘 알려져 있습니다. 이 표준은 approve() 함수로 인해 발생하는 잠재적인 프론트러닝 취약점이 있습니다. 이 취약점에 대한 좋은 설명은 여기에서 찾을 수 있습니다.
표준은 approve() 함수를 다음과 같이 명시합니다:```solidity
function approve(address _spender, uint256 _value) returns (bool success)
이 함수는 사용자가 다른 사용자에게 자신을 대신해 토큰을 전송할 수 있는 권한을 부여할 수 있게 해줍니다. 프론트러닝 취약점은 사용자 Alice가 친구 `Bob`이 `100 tokens`을 사용하도록 *승인*하는 시나리오에서 발생합니다. 이후 Alice는 `Bob`이 `100 tokens`을 사용하도록 한 승인을 철회하려고 하여, `Bob`의 할당량을 `50 tokens`으로 설정하는 트랜잭션을 생성합니다. `Bob`은 체인을 주의 깊게 관찰하고 있다가 이 트랜잭션을 보고 자신이 `100 tokens`을 사용하는 트랜잭션을 구성합니다. 그는 자신의 트랜잭션에 `Alice`의 트랜잭션보다 더 높은 `gasPrice`를 설정하여 자신의 트랜잭션이 그녀의 트랜잭션보다 우선 처리되도록 합니다. 일부 `approve()` 구현은 `Bob`이 자신의 `100 tokens`을 전송하도록 허용한 다음, `Alice`의 트랜잭션이 커밋되면 `Bob`의 승인을 `50 tokens`으로 재설정하여, 결과적으로 `Bob`에게 `150 tokens`에 대한 접근 권한을 부여합니다. 이 공격에 대한 완화 전략은 위에 링크된 문서의 [여기](https://docs.google.com/document/d/1YLPtQxZu1UAvO9cZ1O2RPXBbT0mooh4DYKjA_jp-RLM/edit)에서 확인할 수 있습니다.
또 다른 유명한 실제 사례로는 [Bancor](https://www.bancor.network/)가 있습니다. Ivan Bogatty와 그의 팀은 초기 Bancor 구현에 대한 수익성 있는 공격을 문서화했습니다. 그의 [블로그 게시물](https://hackernoon.com/front-running-bancor-in-150-lines-of-python-with-ethereum-api-d5e2bfd0d798)과 [Devon 3 발표](https://www.youtube.com/watch?v=RL2nE3huNiI)에서 이것이 어떻게 이루어졌는지 자세히 논의합니다. 본질적으로 토큰의 가격은 트랜잭션 값에 따라 결정되므로, 사용자는 트랜잭션 풀에서 Bancor 트랜잭션을 관찰하고 프론트러닝하여 가격 차이를 통해 이익을 얻을 수 있습니다. 이 공격은 Bancor 팀에 의해 해결되었습니다.
<h2 id="dos"><span id="SP-11">11. 서비스 거부 (DOS)</span></h2>
이 범주는 매우 광범위하지만, 기본적으로 사용자가 계약을 짧은 기간 동안 또는 어떤 경우에는 영구적으로 작동 불가능하게 만들 수 있는 공격들로 구성됩니다. 이는 [Second Parity MultiSig 해킹](#dc-example)의 경우처럼 이더를 계약에 영원히 가둘 수 있습니다.
<h3 id="dos-vuln">취약점</h3>
계약이 작동 불가능하게 되는 방법은 다양합니다. 여기서는 공격자가 DOS 공격을 수행하도록 이어질 수 있는, 잠재적으로 덜 명확한 블록체인 특유의 Solidity 코딩 패턴 몇 가지만 강조하겠습니다.
**1. 가스 스티펜드가 없는 외부 호출** - 알 수 없는 계약에 외부 호출을 하고 싶은 경우
그 호출의 성공 여부와 관계없이 트랜잭션 처리를 계속하고 싶은 경우가 있을 수 있습니다.
일반적으로 이는
`CALL` opcode를 사용하여 달성됩니다. `CALL`은 호출이 실패해도 트랜잭션을 되돌리지 않습니다
(자세한 내용과 예제는 [Unchecked CALL 반환 값](#unchecked-calls)을 참조하세요).
간단한 예로, `withdraw()` 함수가 호출될 때 이더를 천천히
흘려보내는 계약 지갑을 생각해 봅시다. `partner`는
자신의 주소를 추가하고 가스를 소비하여 출금을 호출할 수 있으며,
`partner`와 `owner` 모두에게 계약 총 잔액의 1%를 제공합니다.```solidity
contract TrickleWallet {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = 0xA9E;
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
require(partner == '0x0' || msg.sender == partner);
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint amountToSend = address(this).balance/100;
// perform a call without checking return
// the recipient can revert, the owner will still get their share
partner.call.value(amountToSend)();
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] += amountToSend;
}
// allow deposit of funds
function() payable {}
// convenience function
function contractBalance() view returns (uint) {
return address(this).balance;
}
}
라인 [17]에서 우리는 계약 잔액의 1%를
사용자가 지정한 계정으로 보내는 외부 호출을 수행한다는 점에 유의하세요. CALL opcode가 사용되는 이유는
외부 호출이 되돌려지더라도 소유자가 여전히 지급받도록 보장하기 위해서입니다. 문제는
트랜잭션이 모든 가스를 외부 호출로 보낸다는 것입니다 (실제로는 트랜잭션 가스의 대부분만 보내지며, 일부는 호출 처리를 완료하기 위해 남겨집니다). 사용자가 악의적이라면 모든 가스를 소비하는 계약을 생성하여 가스 부족으로 인해 모든 withdraw() 트랜잭션이 실패하도록 강제할 수 있습니다.
예를 들어, 모든 가스를 소비하는 다음과 같은 악성 계약을 고려해 보세요,```solidity contract ConsumeAllGas { function () payable { // an assert consumes all transaction gas, unlike a //revert which returns the remaining gas assert(1==2); } }
만약 인출 파트너가 계약 소유자가 마음에 들지 않게 되었다면,
그들은 파트너 주소를 이 계약으로 설정하고 `TrickleWallet` 계약에 있는
모든 자금을 영원히 잠글 수 있습니다.
이러한 DOS 공격 벡터를 방지하려면 외부 호출에 가스 stipend를 지정하여
해당 트랜잭션이 사용할 수 있는 가스량을 제한해야 합니다. 이 예시에서는
\[17\]번 줄을 다음과 같이 변경하여 이 공격을 해결할 수 있습니다.```solidity
partner.call.gas(50000).value(amountToSend)();
이 수정은 외부 트랜잭션에 50,000 gas만 소비되도록 허용합니다. owner는 외부 트랜잭션이 얼마나 사용하는지와 관계없이 자신의 트랜잭션이 완료되도록 이보다 큰 가스 가격을 설정할 수 있습니다.
2. 외부에서 조작되는 매핑 또는 배열에 대한 반복 - 제 경험상 이러한 패턴의 다양한 형태를 보아왔습니다. 일반적으로 owner가 투자자들에게 토큰을 분배하려는 시나리오에서 나타나며, 아래 예제 계약에서 볼 수 있는 것처럼 distribute()와 유사한 함수를 사용하여 수행됩니다.```solidity
contract DistributeTokens {
address public owner; // gets set somewhere
address[] investors; // array of investors
uint[] investorTokens; // the amount of tokens each investor gets
// ... extra functionality, including transfertoken()
function invest() public payable {
investors.push(msg.sender);
investorTokens.push(msg.value * 5); // 5 times the wei sent
}
function distribute() public {
require(msg.sender == owner); // only owner
for(uint i = 0; i < investors.length; i++) {
// here transferToken(to,amount) transfers "amount" of tokens to the address "to"
transferToken(investors[i],investorTokens[i]);
}
}
}
이 계약의 루프는 인위적으로 확장될 수 있는 배열을 반복한다는 점에 유의하십시오. 공격자는 많은 사용자 계정을 생성하여 `investor` 배열을 크게 만들 수 있습니다. 원칙적으로 이는 for 루프를 실행하는 데 필요한 가스가 블록 가스 한도를 초과하도록 만들어 `distribute()` 함수를 사실상 작동 불가능하게 만들 수 있습니다.
**3. 소유자 작업** - 또 다른 일반적인 패턴은 소유자가 계약에서 특정 권한을 갖고 계약이 다음 상태로 진행되기 위해 특정 작업을 수행해야 하는 경우입니다. 한 가지 예로 소유자가 계약을 `finalize()`해야 토큰을 양도할 수 있게 되는 ICO 계약을 들 수 있습니다. 즉,``` solidity
bool public isFinalized = false;
address public owner; // gets set somewhere
function finalize() public {
require(msg.sender == owner);
isFinalized == true;
}
// ... extra ICO functionality
// overloaded transfer function
function transfer(address _to, uint _value) returns (bool) {
require(isFinalized);
super.transfer(_to,_value)
}
...
그러한 경우, 권한 있는 사용자가 개인 키를 분실하거나 비활성화되면 전체 토큰 계약이 작동 불능 상태가 됩니다. 이 경우 owner가 finalize()를 호출할 수 없다면 어떤 토큰도 전송될 수 없습니다. 즉, 토큰 생태계 전체의 운영이 단 하나의 주소에 달려 있게 됩니다.
4. 외부 호출에 기반한 상태 진행 - 계약이 새로운 상태로 진행하기 위해 주소로 이더를 보내거나 외부 소스의 입력을 기다려야 하도록 작성되는 경우가 있습니다. 이러한 패턴은 외부 호출이 실패하거나 외부적인 이유로 차단될 때 DOS 공격으로 이어질 수 있습니다. 이더를 보내는 예시에서 사용자는 이더를 받지 않는 계약을 만들 수 있습니다. 계약이 새로운 상태로 진행하기 위해 이더 인출을 요구한다면(모든 이더가 인출되어야 다시 사용 가능한 타임락 계약을 생각해 보십시오), 이더는 이더를 받지 않는 사용자의 계약으로 절대 보내질 수 없으므로 계약은 결코 새로운 상태에 도달하지 못합니다.
첫 번째 예시에서 계약은 외부 사용자가 인위적으로 조작할 수 있는 데이터 구조를 루프로 순회하지 않아야 합니다. 각 투자자가 독립적으로 토큰을 청구하기 위해 인출 함수를 호출하는 인출 패턴이 권장됩니다.
두 번째 예시에서는 권한 있는 사용자가 계약의 상태를 변경해야 했습니다. 이러한 예시에서는(가능한 경우) owner가 기능을 상실하는 상황에 대비해 안전장치(fail-safe)를 사용할 수 있습니다. 한 가지 해결책은 owner를 multisig 계약으로 설정하는 것입니다. 또 다른 해결책은 타임락을 사용하는 것으로, [13]행의 require에 require(msg.sender == owner || now > unlockTime)와 같은 시간 기반 메커니즘을 포함시켜 unlockTime에 지정된 시간이 지나면 모든 사용자가 finalize를 호출할 수 있게 하는 것입니다. 이러한 종류의 완화 기법은 세 번째 예시에도 적용할 수 있습니다. 새로운 상태로 진행하기 위해 외부 호출이 필요한 경우, 해당 호출의 실패 가능성을 고려하고 원하는 호출이 이루어지지 않을 경우에 대비해 시간 기반 상태 진행을 추가하는 것이 좋습니다.
참고: 물론 이러한 제안에는 중앙집중식 대안도 있습니다. 필요하다면 DOS 공격 벡터 관련 문제를 해결하기 위해 나타날 수 있는 maintenanceUser를 추가할 수 있습니다. 일반적으로 이러한 종류의 계약은 그러한 주체의 권한에 대한 신뢰 문제를 내포하지만, 이는 이 섹션에서 다룰 주제는 아닙니다.
GovernMental은 상당히 많은 양의 이더를 축적한 오래된 폰지 사기였습니다. 실제로 한때 1100 이더를 축적했습니다. 불행히도 이 계약은 이 섹션에서 언급한 DOS 취약점에 취약했습니다. 이 Reddit 게시물은 이더를 인출하기 위해 계약이 대규모 매핑을 삭제해야 했다는 내용을 설명합니다. 이 매핑 삭제에는 당시 블록 가스 한도를 초과하는 가스 비용이 필요했으며, 따라서 1100 이더를 인출하는 것이 불가능했습니다. 계약 주소는 0xF45717552f12Ef7cb65e95476F217Ea008167Ae3이며, 트랜잭션 0x0d80d67202bd9cb6773df8dd2020e7190a1b0793e8ec4fc105257e8128f0506b에서 1100 이더가 마침내 250만 가스를 사용한 트랜잭션으로 획득되었음을 확인할 수 있습니다(블록 가스 한도가 그러한 트랜잭션을 허용한 이후).
블록 타임스탬프는 역사적으로 난수 엔트로피(자세한 내용은 엔트로피 환상 섹션 참조), 일정 기간 동안 자금 잠금, 시간에 의존하는 다양한 상태 변경 조건문 등 여러 애플리케이션에 사용되어 왔습니다. 채굴자는 타임스탬프를 약간 조정할 수 있는데, 스마트 계약에서 블록 타임스탬프를 잘못 사용하면 매우 위험할 수 있습니다.
이에 대한 유용한 참고 자료는 Solidity 문서, 이 Stack Exchange 질문입니다.
block.timestamp 또는 그 별칭 now는 채굴자에게 그러한 동기를 부여할 유인이 있다면 조작될 수 있습니다. 채굴자 악용에 취약한 간단한 게임을 구성해 보겠습니다.
roulette.sol:```solidity
contract Roulette {
uint public pastBlockTime; // Forces one bet per block
constructor() public payable {} // initially fund contract
// fallback function used to make a bet
function () public payable {
require(msg.value == 10 ether); // must send 10 ether to play
require(now != pastBlockTime); // only 1 transaction per block
pastBlockTime = now;
if(now % 15 == 0) { // winner
msg.sender.transfer(this.balance);
}
}
}
이 계약은 단순한 복권처럼 동작합니다. 각 블록의 트랜잭션 하나가 `10 ether`를 걸고 계약에 보관된 잔액을 얻을 기회를 가질 수 있습니다. 여기서 가정은 `block.timestamp`가 마지막 두 자리에 대해 균등하게 분포한다는 것입니다. 그렇다면 이 복권에 당첨될 확률은 1/15입니다.
하지만 알다시피, 채굴자(miner)는 필요하다면 타임스탬프를 조정할 수 있습니다. 이 특정한 경우, 계약에 충분한 이더가 축적되면 블록을 해결한 채굴자는 `block.timestamp` 또는 `now`를 15로 나눈 나머지가 `0`이 되는 타임스탬프를 선택할 유인이 생깁니다. 그렇게 함으로써 블록 보상과 함께 이 계약에 잠긴 이더를 얻을 수 있습니다. 블록당 한 사람만 베팅할 수 있기 때문에 이는 [선행 거래(front-running)](#race-conditions) 공격에도 취약합니다.
실제로 블록 타임스탬프는 단조 증가하므로 채굴자는 임의의 블록 타임스탬프를 선택할 수 없습니다(이전 블록의 타임스탬프보다 커야 합니다). 또한 너무 먼 미래로 블록 시간을 설정하는 것도 제한됩니다. 그러한 블록은 네트워크에서 거부될 가능성이 높기 때문입니다(노드는 미래의 타임스탬프를 가진 블록을 검증하지 않습니다).
<h3 id="block-timestamp-prev">예방 기법</h3>
블록 타임스탬프는 엔트로피나 난수 생성에 사용되어서는 안 됩니다. 즉, 게임 승리 또는 중요한 상태 변경(무작위로 가정되는 경우)의 결정 요인이 되어서는 안 됩니다(직접적으로든 어떤 파생을 통해서든).
시간에 민감한 로직이 필요한 경우도 있습니다. 예를 들어 계약 잠금 해제(타임락), 몇 주 후 ICO 종료, 만료 날짜 적용 등입니다. 때로는 시간을 추정하기 위해 `block.number`( [Solidity 문서](http://solidity.readthedocs.io/en/latest/units-and-global-variables.html#block-and-transaction-properties) 참조)와 평균 블록 시간을 사용하는 것이 권장됩니다. 예를 들어 `10 second`의 블록 시간으로 `1 week`는 약 `60480 blocks`에 해당합니다. 따라서 계약 상태를 변경할 블록 번호를 지정하는 것이 더 안전할 수 있습니다. 채굴자가 블록 번호를 그만큼 쉽게 조작할 수 없기 때문입니다. [BAT ICO](https://etherscan.io/address/0x0d8775f648430679a709e98d2b0cb6250d2887ef#code) 계약이 이 전략을 사용했습니다.
계약이 블록 타임스탬프의 채굴자 조작에 특별히 민감하지 않다면 이는 불필요할 수 있지만, 계약을 개발할 때 알아두어야 할 사항입니다.
<h3 id="block-timestamp-example">실제 사례: GovernMental</h3>
[GovernMental](http://governmental.github.io/GovernMental/)은 상당히 많은 양의 이더를 축적한 오래된 폰지 사기였습니다. 또한 타임스탬프 기반 공격에 취약했습니다. 이 계약은 한 라운드에서 (최소 1분 동안) 마지막으로 참여한 플레이어에게 지급했습니다. 따라서 플레이어인 채굴자는 타임스탬프를(1분이 경과한 것처럼 보이도록 미래 시간으로) 조정하여, 해당 플레이어가 1분 넘게 마지막으로 참여한 것처럼 보이게 할 수 있었습니다(실제로는 그렇지 않더라도). 이에 대한 자세한 내용은 Tanya Bahrynovska가 작성한 [History of Ethereum Security Vulnerabilities Post](https://applicature.com/blog/history-of-ethereum-security-vulnerabilities-hacks-and-their-fixes)에서 확인할 수 있습니다.
<h2 id="constructors"><span id="SP-13">13. 주의해서 작성해야 하는 생성자</span></h2>
생성자(constructor)는 계약을 초기화할 때 중요하고 권한이 필요한 작업을 수행하는 특수 함수입니다. Solidity `v0.4.22` 이전에는 생성자가 해당 계약과 동일한 이름을 가진 함수로 정의되었습니다. 따라서 개발 중에 계약 이름이 변경될 때 생성자 이름도 변경되지 않으면 일반 호출 가능 함수가 됩니다. 상상할 수 있듯이 이로 인해 흥미로운 계약 해킹 사례가 발생할 수 있었고(실제로 발생했습니다).
더 자세한 내용을 알고 싶다면 독자에게 [Ethernaught Challenges](https://github.com/OpenZeppelin/ethernaut)를 시도해 볼 것을 권장합니다(특히 Fallout 레벨).
<h3 id="constructors-vuln">취약점</h3>
계약 이름이 수정되거나 생성자 이름에 오타가 있어 더 이상 계약 이름과 일치하지 않으면 생성자는 일반 함수처럼 동작합니다. 이는 심각한 결과를 초래할 수 있으며, 특히 생성자가 권한이 필요한 작업을 수행하는 경우 더욱 그렇습니다. 다음 계약을 고려해 보세요.```solidity
contract OwnerWallet {
address public owner;
//constructor
function ownerWallet(address _owner) public {
owner = _owner;
}
// fallback. Collect ether.
function () payable {}
function withdraw() public {
require(msg.sender == owner);
msg.sender.transfer(this.balance);
}
}
This contract collects ether and only allows the owner to withdraw all the ether by calling the withdraw() function. The issue arises due to the fact that the constructor is not exactly named after the contract. Specifically, ownerWallet is not the same as OwnerWallet. Thus, any user can call the ownerWallet() function, set themselves as the owner and then take all the ether in the contract by calling withdraw().
이 계약은 이더를 모으고 소유자만이 withdraw() 함수를 호출하여 모든 이더를 인출할 수 있도록 허용합니다. 문제는 생성자(constructor)가 계약 이름과 정확히 일치하지 않는다는 사실에서 발생합니다. 구체적으로 ownerWallet은 OwnerWallet과 같지 않습니다. 따라서 모든 사용자가 ownerWallet() 함수를 호출하여 자신을 소유자로 설정한 다음 withdraw()를 호출하여 계약의 모든 이더를 가져갈 수 있습니다.
이 문제는 주로 Solidity 컴파일러 버전 0.4.22에서 해결되었습니다. 이 버전에서는 함수 이름이 계약 이름과 일치하도록 요구하는 대신 생성자를 지정하는 constructor 키워드를 도입했습니다. 위에서 강조한 명명 문제를 방지하려면 이 키워드를 사용하여 생성자를 지정하는 것이 권장됩니다.
Rubixi (contract code)는 이러한 종류의 취약점을 보여준 또 다른 피라미드 사기(pyramid scheme)였습니다. 원래는 DynamicPyramid라고 불렸지만 배포 전에 계약 이름이 Rubixi로 변경되었습니다. 생성자의 이름은 변경되지 않아 모든 사용자가 creator가 될 수 있었습니다. 이 버그와 관련된 흥미로운 논의는 이 Bitcoin Thread에서 찾을 수 있습니다. 궁극적으로 이로 인해 사용자들이 피라미드 사기의 수수료를 청구하기 위해 creator 상태를 두고 다툴 수 있게 되었습니다. 이 특정 버그에 대한 자세한 내용은 여기에서 확인할 수 있습니다.
EVM은 데이터를 storage 또는 memory로 저장합니다. 계약을 개발할 때 이것이 정확히 어떻게 이루어지는지와 함수의 지역 변수에 대한 기본 유형을 이해하는 것이 매우 권장됩니다. 변수를 부적절하게 초기화하면 취약한 계약이 생성될 수 있기 때문입니다.
EVM에서 storage와 memory에 대해 더 자세히 알아보려면 Solidity Docs: Data Location, Solidity Docs: Layout of State Variables in Storage, Solidity Docs: Layout in Memory를 참조하세요.
이 섹션은 Stefan Beyer의 훌륭한 게시물을 기반으로 합니다. 이 주제에 대한 추가 자료는 Sefan의 영감이 된 다음 reddit 스레드에서 찾을 수 있습니다.
함수 내의 지역 변수는 해당 유형에 따라 기본적으로 storage 또는 memory로 설정됩니다. 초기화되지 않은 지역 storage 변수는 계약의 다른 예기치 않은 스토리지 변수를 가리킬 수 있으며, 이로 인해 의도적(예: 개발자가 나중에 공격하기 위해 의도적으로 배치한 경우) 또는 비의도적 취약점이 발생할 수 있습니다.
다음의 비교적 간단한 이름 등록 계약을 고려해 보겠습니다.```solidity // A Locked Name Registrar contract NameRegistrar {
bool public unlocked = false; // registrar locked, no name updates
struct NameRecord { // map hashes to addresses
bytes32 name;
address mappedAddress;
}
mapping(address => NameRecord) public registeredNameRecord; // records who registered names
mapping(bytes32 => address) public resolve; // resolves hashes to addresses
function register(bytes32 _name, address _mappedAddress) public {
// set up the new NameRecord
NameRecord newRecord;
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;
resolve[_name] = _mappedAddress;
registeredNameRecord[msg.sender] = newRecord;
require(unlocked); // only allow registrations if contract is unlocked
}
}
이 간단한 이름 등록기(registrar)에는 단 하나의 함수만 있습니다. 계약이 `unlocked` 상태일 때, 누구나 이름을(`bytes32` 해시로) 등록하고 그 이름을 주소에 매핑할 수 있습니다. 안타깝게도 이 등록기는 처음에 잠겨 있으며, 23행의 `require`가 `register()`가 이름 레코드를 추가하지 못하게 막습니다. 하지만 이 계약에는 `unlocked` 변수와 무관하게 이름 등록을 허용하는 취약점이 있습니다.
이 취약점을 논의하려면 먼저 솔리디티에서 스토리지(storage)가 어떻게 동작하는지 이해해야 합니다. 높은 수준의 개요로는(적절한 기술적 세부사항 없이 - 정확한 검토를 위해 솔리디티 문서를 읽는 것을 권장합니다), 상태 변수들은 계약에 나타나는 순서대로 *슬롯(slot)*에 순차적으로 저장됩니다 (서로 묶일 수도 있지만, 이 예제에서는 해당하지 않으므로 걱정하지 않겠습니다). 따라서 `unlocked`는 `slot 0`에, `registeredNameRecord`는 `slot 1`에, `resolve`는 `slot 2`에 존재합니다. 각 슬롯은 32바이트 크기입니다 (지금은 무시하는 매핑과 관련된 추가적인 복잡성이 있습니다). 불리언 `unlocked`는 `false`의 경우 `0x000...0`(0x를 제외한 64개의 0)처럼 보이고, `true`의 경우 `0x000...1`(63개의 0)처럼 보입니다. 보시다시피, 이 특정 예제에서는 상당한 스토리지 낭비가 있습니다.
우리가 필요한 다음 정보는, 솔리디티가 `struct`와 같은 복합 데이터 타입을 지역 변수로 초기화할 때 기본적으로 `storage`로 지정한다는 것입니다. 따라서 16행의 `newRecord`는 기본적으로 `storage`가 됩니다. 취약점은 `newRecord`가 초기화되지 않았다는 사실에서 발생합니다. 기본적으로 storage이기 때문에 스토리지에 대한 포인터가 되고, 초기화되지 않았기 때문에 `slot 0`(즉, `unlocked`가 저장된 위치)을 가리킵니다. 17행과 18행에서 `nameRecord.name`을 `_name`으로, `nameRecord.mappedAddress`를 `_mappedAddress`로 설정하는 것을 주목하세요. 이는 실제로 slot 0과 slot 1의 스토리지 위치를 변경하여 `unlocked`와 `registeredNameRecord`와 연관된 스토리지 슬롯을 모두 수정합니다.
즉, `register()` 함수의 `bytes32 _name` 매개변수만으로 `unlocked`를 직접 수정할 수 있습니다. 따라서 `_name`의 마지막 바이트가 0이 아니면 스토리지 `slot 0`의 마지막 바이트를 수정하여 `unlocked`를 직접 `true`로 변경합니다. `unlocked`를 `true`로 설정하기 때문에 이러한 `_name` 값은 23행의 `require()`를 통과합니다. Remix에서 직접 시도해 보세요. 다음 형식의 `_name`을 사용하면 함수가 통과한다는 것을 알 수 있습니다: `0x0000000000000000000000000000000000000000000000000000000000000001`
<h3 id="storage-prev">예방 기법</h3>
솔리디티 컴파일러는 초기화되지 않은 스토리지 변수를 경고로 표시하므로, 개발자는 스마트 계약을 만들 때 이러한 경고에 세심한 주의를 기울여야 합니다. 현재 버전의 mist(0.10)는 이러한 계약의 컴파일을 허용하지 않습니다. 복합 타입을 다룰 때는 예상대로 동작하도록 `memory` 또는 `storage` 키워드를 명시적으로 사용하는 것이 좋은 습관입니다. 솔리디티 버전 `0.5.0`부터는 `memory`와 `storage`의 사용이 필수입니다.
<h3 id="storage-example">실제 사례: 허니팟(Honey Pots): OpenAddressLottery와 CryptoRoulette</h3>
OpenAddressLottery([계약 코드](https://etherscan.io/address/0x741f1923974464efd0aa70e77800ba5d9ed18902#code))라는 허니팟이 배포되었는데, 이 허니팟은 초기화되지 않은 스토리지 변수의 이 특성을 이용하여 몇몇 지망생 해커들로부터 이더를 모았습니다. 이 계약은 상당히 깊이가 있으므로, 공격이 아주 명확하게 설명된 이 [reddit 스레드](https://www.reddit.com/r/ethdev/comments/7wp363/how_does_this_honeypot_work_it_seems_like_a/)에 논의를 맡기겠습니다.
또 다른 허니팟인 CryptoRoulette([계약 코드](https://etherscan.io/address/0x8685631276cfcf17a973d92f6dc11645e5158c0c#code))도 이 같은 트릭을 사용하여 이더를 모으려고 시도했습니다. 공격이 어떻게 작동하는지 파악할 수 없다면, 이 계약과 다른 계약들에 대한 개요를 보려면 [An analysis of a couple Ethereum honeypot contracts](https://medium.com/@jsanjuas/an-analysis-of-a-couple-ethereum-honeypot-contracts-5c07c95b0a8d)를 참조하세요.
<h2 id="precision"><span id="SP-15">15. 부동 소수점과 정밀도</span></h2>
이 글을 작성하는 시점(Solidity v0.4.24)에서는 고정 소수점 또는 부동 소수점 숫자가 지원되지 않습니다. 이는 부동 소수점 표현을 Solidity의 정수 타입으로 만들어야 함을 의미합니다. 이는 올바르게 구현되지 않으면 오류/취약점으로 이어질 수 있습니다.
더 자세한 내용은 [Ethereum Contract Security Techniques and Tips - Rounding with Integer Division](https://github.com/ethereum/wiki/wiki/Safety#beware-rounding-with-integer-division),
<h3 id="precision-vuln">취약점</h3>
Solidity에는 고정 소수점 타입이 없기 때문에 개발자는 표준 정수 데이터 타입을 사용하여 자체적으로 구현해야 합니다. 이 과정에서 개발자가 부딪힐 수 있는 함정이 여러 가지 있습니다. 이 섹션에서는 그중 일부를 강조하려고 합니다.
코드 예제부터 시작하겠습니다 (단순화를 위해 오버플로/언더플로 문제는 무시하겠습니다).```solidity
contract FunWithNumbers {
uint constant public tokensPerEth = 10;
uint constant public weiPerEth = 1e18;
mapping(address => uint) public balances;
function buyTokens() public payable {
uint tokens = msg.value/weiPerEth*tokensPerEth; // convert wei to eth, then multiply by token rate
balances[msg.sender] += tokens;
}
function sellTokens(uint tokens) public {
require(balances[msg.sender] >= tokens);
uint eth = tokens/tokensPerEth;
balances[msg.sender] -= tokens;
msg.sender.transfer(eth*weiPerEth); //
}
}
이 간단한 토큰 매수/매도 계약에는 토큰 매매에 몇 가지 명백한 문제가 있습니다. 토큰 매수/매도에 대한 수학적 계산은 정확하지만, 부동 소수점 숫자가 없으면 잘못된 결과가 나올 수 있습니다. 예를 들어, [7]번 라인에서 토큰을 구매할 때, 값이 1 ether보다 작으면 초기 나눗셈 결과가 0이 되어 최종 곱셈도 0이 됩니다 (즉, 200 wei를 1e18 weiPerEth로 나누면 0입니다). 마찬가지로, 토큰을 판매할 때 10보다 작은 토큰은 0 ether가 됩니다. 실제로 여기서 반올림은 항상 내림이므로 29 tokens를 판매하면 2 ether가 됩니다.
이 계약의 문제는 정밀도가 가장 가까운 ether (즉, 1e18 wei)까지만 제공된다는 것입니다. 이는 더 높은 정밀도가 필요할 때 ERC20 토큰의 decimals를 다룰 때 까다로울 수 있습니다.
스마트 계약에서 올바른 정밀도를 유지하는 것은 매우 중요합니다. 특히 경제적 결정을 반영하는 비율(ratio)과 환율(rate)을 다룰 때 더욱 그렇습니다.
사용하는 모든 비율이나 환율이 분수의 큰 분자를 허용하도록 해야 합니다. 예를 들어, 위 예시에서는 tokensPerEth라는 비율을 사용했습니다. 큰 숫자가 되는 weiPerTokens를 사용하는 것이 더 나았을 것입니다. 토큰의 양을 구하기 위해 msg.value/weiPerTokens를 사용할 수 있었을 것입니다. 이렇게 하면 더 정밀한 결과를 얻을 수 있습니다.
또 하나 염두에 두어야 할 전술은 연산 순서(order of operations)를 주의하는 것입니다. 위 예시에서 토큰 구매 계산은 msg.value/weiPerEth*tokenPerEth였습니다. 곱셈보다 나눗셈이 먼저 일어난다는 점에 주목하세요. 곱셈을 먼저 수행한 다음 나눗셈을 수행했다면, 즉 msg.value*tokenPerEth/weiPerEth였다면 더 높은 정밀도를 얻을 수 있었을 것입니다.
마지막으로, 숫자에 임의 정밀도를 정의할 때는 변수를 더 높은 정밀도로 변환하고, 모든 수학 연산을 수행한 다음, 필요할 때 출력 정밀도로 다시 변환하는 것이 좋은 방법입니다. 일반적으로 uint256이 사용되는데(가스 사용량에 최적이기 때문에) 그 범위에서 약 60자릿수의 크기를 제공하며, 그중 일부는 수학 연산의 정밀도에 할애할 수 있습니다. 모든 변수를 solidity에서 높은 정밀도로 유지하고 외부 앱에서 더 낮은 정밀도로 다시 변환하는 것이 더 나을 수도 있습니다 (이것은 본질적으로 ERC20 Token 계약에서 decimals 변수가 작동하는 방식입니다). 이를 수행하는 방법과 라이브러리의 예를 보려면 Maker DAO DSMath를 살펴보는 것이 좋습니다. WAD와 RAY 같은 다소 독특한 이름을 사용하지만 그 개념은 유용합니다.
반올림으로 인해 계약에 심각한 문제가 발생한 좋은 예를 찾지 못했지만, 밖에는 그러한 사례가 많을 것이라고 확신합니다. 좋은 예가 있다면 이 내용을 업데이트해 주세요.
좋은 예가 없기 때문에 Ethstick에 주목하고 싶습니다. 주로 계약 내의 멋진 명명 방식이 마음에 들기 때문입니다. 이 계약은 확장된 정밀도를 사용하지 않지만 wei를 다룹니다. 따라서 이 계약은 반올림 문제가 있지만 정밀도가 wei 수준에서만 발생합니다. 더 심각한 결함도 있지만, 이는 블록체인에서 엔트로피를 얻는 것이 어렵다는 것과 관련이 있습니다 (Entropy Illusion 참조). Ethstick 계약에 대한 추가 논의는 Peter Venesses의 다른 글 Ethereum 계약은 해커에게 사탕과 같을 것이다를 참조하시기 바랍니다.
Solidity에는 호출 스택 전체를 탐색하여 호출(또는 트랜잭션)을 원래 보낸 계정의 주소를 반환하는 전역 변수 tx.origin이 있습니다. 스마트 계약에서 이 변수를 인증에 사용하면 계약이 피싱과 유사한 공격에 취약해집니다.
EtherStore10 ether9 etherAttack.sol - 라인 [27] - 폴백 함수는 그런 다음 EtherStore의 withdrawFunds() 함수를 다시 호출하여 EtherStore 계약을 "재진입"합니다.
EtherStore.sol - 라인 [11] - withdrawFunds()에 대한 이 두 번째 호출에서 라인 [18]이 아직 실행되지 않았으므로 우리의 잔액은 여전히 1 ether입니다. 따라서 여전히 balances[0x0..123] = 1 ether입니다. lastWithdrawTime 변수도 마찬가지입니다. 다시 한 번 모든 요구 사항을 통과합니다.
EtherStore.sol - 라인 [17] - 우리는 또 다른 1 ether를 출금합니다.
4~8단계가 반복됩니다 - EtherStore.balance >= 1이 될 때까지 Attack.sol의 라인 [26]이 지시하는 대로.
Attack.sol - 라인 [26] - EtherStore 계약에 남은 이더가 1 (또는 그 이하)이 되면 이 if 문은 실패합니다. 그러면 EtherStore 계약의 라인 [18] 및 [19]가(withdrawFunds() 함수에 대한 각 호출마다) 실행될 수 있습니다.
EtherStore.sol - 라인 [18] 및 [19] - balances 및 lastWithdrawTime 매핑이 설정되고 실행이 종료됩니다.
startuintwithdraw()calculatedFibNumberuint(fibonacciLibrary)