$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

Solidity Compiler Bugs: Dirty Bytes Arrays in Storage

Updated 5 min read

Quick answer. Solidity compiler bugs are flaws in specific compiler versions that make correct-looking code misbehave. In the bug when copying dirty bytes arrays to storage, an empty .push() can reveal leftover bytes instead of a zero byte.

There are only a handful of known Solidity compiler bugs. Knowing them lets you spot vulnerabilities that look simply incomprehensible to everyone else.

This guide takes one known compiler bug apart and shows how it surfaces as a vulnerability. Along the way, it covers how dynamic bytes arrays are stored in storage.

The Bug Disclosure

The Solidity team disclosed this one on the Solidity blog as the "bug when copying dirty bytes arrays to storage."

The disclosure says most instances of copying bytes arrays from memory or calldata to storage can be affected, in that they may write dirty values.

Those dirty values only become visible after an empty .push() to the bytes array, when the new elements are assumed to be zero without ever being written.

In plain terms: you would expect s.push() on a bytes array to append one empty byte. With this bug, the push can append a byte holding a leftover dirty value instead.

To see how, first look at how a bytes array sits in storage.

How Bytes Arrays Sit in Storage

The example is deliberately contrived to show this one bug. It is a single contract with a bytes variable, currentCode, and a function to set a referral code.

That function does three things:

  1. It checks the code is allowed. Only the empty string, "red", "sky" and "air" pass.
  2. It casts the code to bytes and assigns it to storage.
  3. It loops three times, calling .push() to pad every code with three empty bytes.
contract IsItVulnerable {
    bytes public currentCode;

    function setReferralCode(string memory code, uint256[1] memory list) external {
        require(_isAllowedCode(code), "Bad Code!");
        currentCode = bytes(code);

        // All codes are padded with 3 empty bytes
        for (uint256 i; i < 3; i++) {
            currentCode.push();
        }
    }

    function _isAllowedCode(string memory code) public pure returns (bool) {
        bytes32 h = keccak256(bytes(code));
        return h == keccak256("") || h == keccak256("red")
            || h == keccak256("sky") || h == keccak256("air");
    }
}

The list parameter is never used. It is added only to make the bug exploitable, as shown below. Without it, on a current compiler, this contract has no known compiler bugs.

Call it with "red" and step through the transaction in a debugger. After the assignment, the first storage slot holds three bytes: 72, 65 and 64.

Those are the bytes for r, e and d. You can confirm it in Chisel with abi.encodePacked("red"), which returns 0x726564.

The last byte of that storage word is 06. It encodes the length of the dynamic bytes array, here six hex characters.

Now watch the loop. Each .push() of an empty byte writes no data at all. It only raises the length field by two: 06, then 08, 0a and finally 0c.

The final value reads as r, e, d plus three empty bytes. That works because the rest of the word really is zero, and the push assumes it is.

Making the Bug Appear

Switch the compiler back to Solidity 0.7.0 and keep the extra uint256[1] memory list parameter.

It is key that list is in memory, not calldata. That is what brings the compiler bug to the surface.

Deploy, then call setReferralCode with the empty string, which is an allowed code. For list, pass one very large uint256 whose first three bytes are chosen carefully.

Afterward, currentCode should be three empty bytes, so six zeros. Instead it returns 0x647279.

Those are exactly the first three bytes of the uint256 passed in list. The function never references list, yet its bytes ended up in the storage variable.

What the debugger shows

  1. Right after the assignment, the slot for currentCode already holds dirty bits. It is more or less the uint256 from list, except its last byte now holds the length field.
  2. The length field is 00, because the code was the empty string. Read now, the array really is empty.
  3. But the rest of the word is full of leftover bytes from list.
  4. The first push raises the length to 02 without clearing anything, so the array now spans the first dirty byte.
  5. The second and third pushes raise it to 04 and 06. The array now reads 64 72 79.

That is the crux of the bug. The dirty data should never sit in storage, waiting for a push that only increments the length to reveal it.

The Same Code on 0.8.19

Switch the compiler to 0.8.19, redeploy and send the exact same input. This time currentCode is three empty bytes, as expected.

In the debugger, no dirty bits are written to storage. Right after the assignment, the slot for currentCode is a completely empty word.

The pushes raise the length to 02, 04 and 06, and the array reads six zero hex characters. Solidity 0.8.19 does not have this bug.

Takeaways for Auditors and Developers

  • Check the version. When a contract uses an older Solidity version, review the Solidity blog for every bug disclosed for that version, such as the storage write removal bug on conditional early termination.
  • Use a current compiler. Use the most up-to-date Solidity version your target chain supports. At the time of recording (July 2023), that meant 0.8.19 or newer, depending on whether your chain supported the new PUSH0 opcode.
  • Look for the trigger. If a contract on an old version such as 0.7.0 makes an empty .push() to a bytes array, it may let dirty bytes in from memory or calldata.
  • Reproduce it yourself. Step through the example in Remix to watch the storage word change. It is the fastest way to understand how dynamic bytes arrays are stored.

For more low-level bugs like this one, see sneaky Solidity bugs, our Solidity security best practices, and how the EVM works.

How Guardian Helps

A Guardian smart contract audit pairs an invariant suite around accounting, rounding and edge states with senior researchers who attack the protocol with proof-of-concept depth.

With Vanguard, two independent teams review the same codebase, and Guardian keeps reviewing updates to the audited scope for six months after production.

Frequently Asked Questions

What is a Solidity compiler bug?

A flaw in the compiler rather than in your source code. It makes specific compiler versions produce bytecode that behaves differently from what the code says. The Solidity team publishes disclosures for known bugs.

What is the bug when copying dirty bytes arrays to storage?

When a bytes array is copied from memory or calldata to storage, affected compilers can leave dirty bytes in the storage slot. An empty .push() then only raises the length, revealing those bytes instead of a zero byte.

How do I check whether my contract is affected?

Note the exact compiler version in the pragma, then check the Solidity team's disclosures for bugs that affect it. Look for the pattern each bug needs, such as an empty .push() after copying bytes from memory or calldata.

Which Solidity version should I use?

Use the most up-to-date compiler your target chain supports. In this example, the same code leaked dirty bytes on 0.7.0 and behaved correctly on 0.8.19.

Sources

Audit your contracts against known compiler bugs

Guardian researchers review your code and compiler version with proof-of-concept depth before launch.