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.
- 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.
- Convert
true
into abytes32
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!