Capture The Ether: donation

Tomás
BLOCK6
Published in
4 min readJun 16, 2022

This challenge will make us dive even deeper into Ethereum’s storage layout.

Let’s analyze some key things:

  • The contract: it’s used to ‘donate’ ether to its owner, who unfortunately… is not us.
  • Our objective: drain the contract’s balance.
  • The problem: only the contract’s owner can withdraw the balance.

Let’s take a look at it:

The first thing we see is a struct that will apparently be used to organize the different donations made to the contract.

Then, there’s a dynamic length array, that as we’ve seen on the previous challenge, is special in the way that it is stored: one full 32 bytes slot (p) is reserved for the length of it and these elements are stored starting at slot number keccak256(p).

And then the owner variable.

To see this more clearly, i’ve written a very simple js script to check the first slots of the contract’s storage:

const main = async () => {
let arr = []
for(let i=0;i<2;i++){
const storage = await web3.eth.getStorageAt(contractAddress, i);
arr.push([i, storage]);
}
return arr
}
main().then((res) => console.log(res));

If you run this right after deployment you’ll see this:

  • Slot 0: donations[]
  • Slot 1: owner

The struct is not stored because at this point it was just declared.

Going forward, if we skip the usual lines with the constructor and the isComplete() function, we reach the donate() function, which at this time is the only one we can call, so let’s take a look at it:

  • uint256 scale: as we know, for the EVM, 1 ether equals 10^18, so in this case the value is 10^18 * 10^18 = 1000000000000000000000000000000000000 (that’s 36 0's).
  • a require statement: whatever we send as msg.value must equal what we input as etherAmount / scale. The thing is, as scale is such a large number, it’s very hard for us to send an amount of ether that could satisfy this requirement.
    E.g.: let’s say we want to send 1 ether -> msg.value == 10^18 then etherAmount should be 10⁵⁴… Ok, no. It’s that or the comment about amount is in ether bla bla is wrong and very confusing.
    What if we input etherAmount = 1 to send this 1 ether? Nope, in this case 10^18 == 1 / 10^36 == 0 so the requirement is not fulfilled. But wait, 1 / 10^36 == 0 so we can apparently send etherAmount = 1 with no msg.value at all. If we try that, the transaction will be completed. Weird, huh?

After trying that, let’s move on to the next few lines.

Donation donation;
donation.timestamp = now;
donation.etherAmount = etherAmount;
donations.push(donations);

At first glance it seems like we are just adding a struct with the donation’s info to the donations[] array defined globaly. But let’s see something first…

If we check the storage again we’ll get something different. Assuming you sent etherAmount = 1, you’ll get something like this:

Why is this happening?

As we’ve seen before, there are different data locations in Ethereum: storage, memory & calldata. Each data type will be allocated in one of these depending on its type and where it’s defined within the contract.

Structs defined within functions, if not explicitly assigned, default to storage. In our case there’s a line that creates a vulnerability:

Donation donation. This is an Uninitialized Storage Pointer. Meaning this new struct is replacing the one defined at the beginning of the contract and assigning:

  • now to slot 0 (unix timestamp)
  • etherAmount to slot 1

So that 0x00...01 you see at slot 1 is just the etherAmount in hexadecimal format.

Now that we know this we can assign any number we want to the owner variable in order to withdraw the balance, we just need to pass our address as etherAmount with an appropriate msg.value.

To calculate this, we need to convert our address to decimals and then divide it by 10³⁶ in order to fulfil the requirement. This can be easily calculated by removing the last 36 digits of that number.

Solution: you need to call donate() with your address as input and your address / 10³⁶ as amount in wei. Then call withdraw().

Conclusion: uninitialized storage pointers are dangerous because they open the door for storage manipulation. Keep in mind that this vulnerability has been addressed in newer versions of solidity and you won’t be able to compile the contract with it.

On the next article we’ll solve the last of the Math challenges: ‘Fifty years’.

Contents distributed by Learn.Block6.tech

👉 Discord — Live Talks

👉 Twitter — Latest articles

👉 LinkTr.ee

--

--

Tomás
BLOCK6
Writer for

Blockchain Developer and Security enthusiast. I write stuff to understand it better.