Malleability Attack Due to Dirty Higher Order Bits
Solidity types smaller than 32 bytes can contain unexpected data in unused bits, creating signature malleability risks. This explains how padding works, why it matters, and how to protect your smart contracts.
Published on 06 December, 2025
The Problem with Partial Data Types
Solidity stores most data in 32-byte slots, but not all types need the full space. A
uint8 only needs 1 byte, leaving 31 bytes unused. Here's where things get tricky.When you declare
uint8 x, Solidity only cares about the rightmost 8 bits. The remaining 248 bits can contain any data, and Solidity ignores them during normal operations. But msg.data contains the raw transaction data, dirty bits and all.How Dirty Bits Work
Consider this function:
function transfer(uint8 amount) public { // Solidity sees amount as the value in the lowest 8 bits // But msg.data contains the full 32-byte input }
Both of these transaction inputs will work identically:
0x00000001(clean)0xff000001(dirty higher-order bits)
The function receives
amount = 1 in both cases. Solidity masks off the extra bits automatically. But if your contract examines msg.data directly, these inputs produce completely different results.The Security Risk
The malleability issue emerges when contracts hash
msg.data for signatures or unique identifiers:contract VulnerableContract { mapping(bytes32 => bool) executed; function executeOnce(uint8 amount) public { bytes32 txHash = keccak256(msg.data); require(!executed[txHash], "Already executed"); executed[txHash] = true; // Do something with amount } }
An attacker can call this function multiple times with the same logical parameters but different dirty bits:
- First call:
executeOnce(1)with clean data0x00000001 - Second call:
executeOnce(1)with dirty data0xff000001
Both calls pass the duplicate check because
keccak256(msg.data) produces different hashes, even though the function behavior is identical.Which Types Are Affected
Any type smaller than 32 bytes can have dirty bits:
uint8, uint16, uint24, ..., uint248 // All uints under 256 bits int8, int16, int24, ..., int248 // All ints under 256 bits bool // Stored as uint8 internally bytes1, bytes2, ..., bytes31 // Fixed-size bytes under 32 address // 20 bytes, 12 bytes unused enum types // Depend on number of values
Types that use the full 32 bytes are safe:
uint256, int256, bytes32 // No unused space for dirty bits
Safe Coding Practices
Hash Parameters Instead of msg.data
Instead of hashing raw transaction data, hash the cleaned parameters:
contract SafeContract { mapping(bytes32 => bool) executed; function executeOnce(uint8 amount) public { // Hash the cleaned parameter, not raw msg.data bytes32 paramHash = keccak256(abi.encode(amount)); require(!executed[paramHash], "Already executed"); executed[paramHash] = true; } }
Use Full-Width Types When Possible
If you don't need the gas savings from smaller types, use
uint256:// Instead of this: function risky(uint8 small) public { bytes32 hash = keccak256(msg.data); // Vulnerable } // Do this: function safer(uint256 full) public { bytes32 hash = keccak256(msg.data); // Less vulnerable }
Validate Input Ranges Explicitly
When you must use smaller types, add explicit validation:
function validateInput(uint8 amount) public pure returns (uint8) { // This forces cleaning of dirty bits require(amount <= type(uint8).max, "Invalid input"); return amount; }
Conclusion
Dirty higher-order bits in Solidity types create signature malleability risks when contracts hash
msg.data directly. The safest approach is hashing cleaned parameters with abi.encode() instead of raw transaction data, ensuring your contract logic remains predictable regardless of input padding.