Ethernaut Lvl 17 Locked Walkthrough: How to properly use (and abuse) structs in Solidity

This is a in-depth series around Zeppelin team’s smart contract security puzzles. I’ll give you the direct resources and key concepts you’ll need to solve the puzzles 100% on your own.

This levels requires you to unlock a registrar by abusing a poorly initiated struct.


Best practices when using Structs

Like in object oriented programming, you can create composite datatypes via structs.

Structs can contain functions and other complex datatypes like mappings and arrays. These arrays and mappings can even contain more structs. However, structs cannot directly contain other structs (unless they are values in mappings or arrays).

Let’s step through what to do and what not to do when working with structs:

How to initialize a struct

Here’s an example from Solidity docs on how to declare structs:

struct Funder {
address addr;
uint amount;
}

struct StructOfStructs {
...
mapping (uint => Funder) funders;
}

There are various syntaxes for initializing a struct.

  1. You can directly pass values into the struct object:
... = Funder(msg.sender, msg.value);

2. Or, you can use object notation to pass values into the struct object, for better readability:

... = Funder({addr: msg.sender, amount: msg.value})

Common usage patterns (memory vs storage)

You can have single, globally declared struct objects in your contract, but that defeats the purpose for creating a new datatype.

More commonly, you’ll use an array or a mapping to save a collection of structs. For example, let’s create an array of Funders and a mapping of Funders.

An array of Funders:

Funders[] public funders;
function ... {
Funder memory f;
f.address = ...;
f.amount = ...;
funders.push(f);
}

Important to know: Struct declarations default to storage. You should always use a memory modifier when creating or copying structs. It is not recommended to use structs for any temporary computations inside functions.

A mapping of Funders:

mapping (uint => Funder) funders; 
function ... {
funders[index] = Funder(...);
}
Important to know: when you directly save a memory struct into a state variable, the memory struct is automatically forced into storage.

The following are examples of what NOT to do when creating a new struct.

Bad example 1

You should not declare a new storage struct in your function, as it will overwrite other globally stored variables. This is important to keep in mind to pass this Ethernaut level.

// Do NOT do this
function badFunction{
Funder f; //this defaults to storage
f.address = ...;
f.amount = ...;
funders.push(f); //this will fail
}

Bad example 2

You cannot implicitly convert memory into storage. The following will throw a compilation error:

// Do NOT do this
function badFunction{
Funder storage f = Funder(...);
}
// Do NOT do this
function badFunction(Funder _funder){
Funder storage f = _funder;
}

Notice that function input parameters are also memory, not storage reference pointers.


Detailed Walkthrough

This level requires you to change the unlocked global variable in Locked.sol from false to true.

Notice that the contract stores unlocked in its first storage slot. The next item is a bytes32 name so you know unlocked occupies the entire first slot. The bytecode for false is 0x00, so unlocked looks like this in the contract’s storage slot:

0x0000000000000000000000000000000000000000000000000000000000000000

Notice that this level commits a big no-no when implementing a struct inside the public register() function:

function register(_name...){
NameRecord newRecord; //storage declaration
newRecord.name = _name;
newRecord.mappedAddress = _mappedAddress;
...

newRecord defaults to storage! And any data saved inside newRecord will overwrite the existing slots 1 and 2 in storage.

Conveniently, unlocked is currently stored in slot 1. Let’s override unlocked by passing a true bool, masquerading as the _name variable, via the public register function.

  1. Convert true into a bytes32 variable:
0x0000000000000000000000000000000000000000000000000000000000000001

2. In Remix, invoke register with your bytes32 true and an arbitrary contract address. Remember to add quotes around your values as per Remix requirements.

3. Ignore the Metamask warning message and allocate extra gas.

4. Double check that your 0x01 value has overridden unlocked to be true. In console, check that the following is now true:

await contract.unlocked();

Key Security Takeaways

  • Struct declarations default to storage. You should always use a memory modifier when creating or copying structs inside functions. Do not use structs for in-function computations.
  • You should not declare a new storage struct in your function, as it will overwrite other globally stored variables.

Learn more about structs

Check out Solidity Koans (inspired by Ruby Koans) and practice using structs in Solidity by making tests pass!