Signature Malleability from Dirty Bits in Solidity Types

Solidity stores all values in 32-byte slots. A uint8 only needs 1 byte. The other 31 bytes are unused, and they can hold any data the caller puts there.

For most operations, this does not matter. Solidity masks the unused bits automatically, so your function logic sees clean values. But if your contract hashes msg.data directly (to build a unique transaction ID or verify a signature), those extra bits become part of the hash. Two calls with identical logical parameters but different dirty bits will produce different hashes.

This is the dirty higher-order bits vulnerability. It allows an attacker to replay a transaction your contract believes it has already seen.


How Solidity Pads Smaller Types

Every EVM word is 32 bytes (256 bits). When you pass a uint8 to a function, the EVM encodes it in a 32-byte slot. The value goes in the rightmost 8 bits. The remaining 248 bits are the "higher-order" bits, and they can be anything.

Here is a simple example:

function transfer(uint8 amount) public {
    // Solidity reads only the lowest 8 bits of the input.
    // The upper 248 bits in msg.data are ignored here.
}

These two raw inputs both call transfer(1):

// Clean input: upper bits are zero
0x0000000000000000000000000000000000000000000000000000000000000001

// Dirty input: upper bits contain arbitrary data
0xff00000000000000000000000000000000000000000000000000000000000001

Inside the function body, amount equals 1 in both cases. Solidity masks off the extra bits before your code runs. The problem is that msg.data gives you the raw, unmasked bytes.


Where the Vulnerability Appears

The issue only matters when your contract hashes msg.data, for example to track whether a transaction has already been executed.

contract VulnerableContract {
    mapping(bytes32 => bool) public executed;

    function executeOnce(uint8 amount) public {
        // keccak256(msg.data) includes the dirty upper bits.
        // Two calls with the same `amount` but different padding
        // will produce different hashes.
        bytes32 txHash = keccak256(msg.data);

        require(!executed[txHash], "Already executed");
        executed[txHash] = true;

        // ... do something with amount
    }
}

An attacker can call executeOnce(1) twice: once with clean data and once with dirty data. Each call produces a different txHash. The require passes both times. The replay protection is broken.

The attacker does not need any special access. Any caller can craft dirty msg.data by constructing the raw transaction manually.


Which Types Are Affected

Any Solidity type smaller than 32 bytes can carry dirty bits.

Type groupExamplesBytes usedBits exposed
Unsigned integersuint8 to uint2481-31248 down to 8
Signed integersint8 to int2481-31248 down to 8
Booleanbool1 (stored as uint8)248
Fixed bytesbytes1 to bytes311-31248 down to 8
Addressaddress2096
Enumdepends on value countusually 1usually 248

Types that fill the full 32 bytes have no unused space:

uint256, int256, bytes32  =>  no dirty bits possible

address is 20 bytes, leaving 12 bytes (96 bits) unused. Contracts that hash msg.data containing an address parameter are also vulnerable.


How to Fix It

Hash the Parameters Directly, Not msg.data

The cleanest fix is to never hash msg.data. Hash the decoded, cleaned parameters instead. Solidity cleans the parameters when it decodes them, so abi.encode(amount) will always produce the same bytes for the same logical value, regardless of what the caller put in the upper bits.

contract SafeContract {
    mapping(bytes32 => bool) public executed;

    function executeOnce(uint8 amount) public {
        // abi.encode uses the cleaned, decoded value.
        // Dirty bits in msg.data have no effect here.
        bytes32 paramHash = keccak256(abi.encode(amount));

        require(!executed[paramHash], "Already executed");
        executed[paramHash] = true;

        // ... do something with amount
    }
}

abi.encode pads values to 32 bytes in a canonical way. Two calls with the same logical amount will always produce the same paramHash.

If your function takes multiple parameters, encode them all together:

bytes32 paramHash = keccak256(abi.encode(amount, recipient, nonce));

Do not use abi.encodePacked here. It removes padding, which can create a different kind of collision when types of different sizes are concatenated.

Use uint256 When Smaller Types Are Not Necessary

Gas savings from uint8 over uint256 are often minimal. The EVM operates on 32-byte words anyway. If the smaller type is not required by your data model, use the full-width type.

// uint256 leaves no room for dirty bits, so the hash
// of msg.data is always canonical for this parameter.
function transfer(uint256 amount) public {
    bytes32 txHash = keccak256(msg.data); // safe with uint256
}

This only helps if every parameter in the function is a full 32-byte type. One uint8 or address in the signature is enough to reintroduce the problem.

Validate Input to Reject Dirty Calls

If you must use a smaller type and cannot switch to abi.encode, you can reject calls that contain dirty bits with explicit validation:

function strictTransfer(uint8 amount) public {
    // Reject any input where the upper bits are not zero.
    // This forces callers to submit clean data.
    require(
        msg.data.length >= 4 &&
        uint256(bytes32(msg.data[4:])) == uint256(amount),
        "Dirty input rejected"
    );

    // proceed safely
}

This approach is more fragile and harder to maintain. The abi.encode fix is simpler and correct by construction.


Always hash cleaned, decoded parameters. Never hash raw msg.data.

// Correct
bytes32 id = keccak256(abi.encode(param1, param2));

// Vulnerable
bytes32 id = keccak256(msg.data);

If your contract tracks executed transactions, generates identifiers from function inputs, or builds any hash from msg.data, audit those call sites. Check the types involved. If any parameter is smaller than 32 bytes, you may have this vulnerability.

The EVM will not protect you here. The caller controls msg.data, and Solidity only cleans inputs for your function body, not before the bytes hit msg.data.