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 group | Examples | Bytes used | Bits exposed |
|---|---|---|---|
| Unsigned integers | uint8 to uint248 | 1-31 | 248 down to 8 |
| Signed integers | int8 to int248 | 1-31 | 248 down to 8 |
| Boolean | bool | 1 (stored as uint8) | 248 |
| Fixed bytes | bytes1 to bytes31 | 1-31 | 248 down to 8 |
| Address | address | 20 | 96 |
| Enum | depends on value count | usually 1 | usually 248 |
Types that fill the full 32 bytes have no unused space:
uint256, int256, bytes32 => no dirty bits possible
addressis 20 bytes, leaving 12 bytes (96 bits) unused. Contracts that hashmsg.datacontaining 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.
Recommended Practice
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.