akash11

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 data 0x00000001
  • Second call: executeOnce(1) with dirty data 0xff000001
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.

Copyright © 2025

Akash Vaghela