Skip to content

LibCast.asAddressesArray yields an address[] whose elements keep their upper 96 bits, which assembly consumers read raw #20

Description

@thedavidmeister

Unit

LibCast.asAddressesArray(uint256[] memory)src/LibCast.sol:20-24.

Intent oracle

The LibCast NatSpec, which is explicit that validity is not checked:

The cast does NOT (can't) check that the input is a valid output ... It is the
calling context that MUST ensure the validity of the data, the cast will merely
retype the data in place, generally without additional checks.

and the README:

a cast moves between Solidity types without changing the binary data.

So the absence of masking is intended. This issue is not a claim that the cast
should mask. It is about a consequence of that intended behaviour that is
nowhere written down, and about who is in a position to notice it.

The property in question

Solidity's memory convention is that a value narrower than 256 bits is held
zero-extended in memory. asAddressesArray retypes a uint256[] to address[]
without masking, so for any input word >= 2**160 the resulting address[]
contains memory words whose upper 96 bits are set. That array is well-typed to
the compiler but violates the representation invariant the compiler's own
convention establishes.

The question for triage is whether the resulting hazard is adequately covered by
"the calling context MUST ensure the validity of the data", given who the callers
are in this org.

Verified repro

test/Adversarial.t.sol (run against a4692fddbb95a0018cec42969ef6c7fa1b0cf86c,
not proposed for merge):

uint256[] memory us = new uint256[](1);
us[0] = type(uint256).max;
address[] memory a = LibCast.asAddressesArray(us);

Measured observables, all at solc 0.8.25 with the optimizer on:

Consumer Word observed
raw mload of the element 0xffff...ffff (all 256 bits set)
a[0] loaded into a stack slot 0xffff...ffff (not cleaned)
a[0] == address(type(uint160).max) true (cleaned)
abi.encode(a) cleaned to 160 bits
abi.encodePacked(a) cleaned to 160 bits
keccak256(abi.encodePacked(a[0])) equal to the clean equivalent
assignment into a storage address[] cleaned to 160 bits
use as a mapping(address => ...) key cleaned; reads back under the clean address
normal address[] element write cleaned to 160 bits
external call taking address[] calldata cleaned; no revert at the ABI boundary
mstore(ptr, a[0]) inside assembly 0xffff...ffff (not cleaned)

Neutral triage framing

Why this may be entirely intended. Every high-level Solidity consumer tested
cleans the value. There is no silent state corruption, no wrong storage write, no
ABI-boundary revert, and no mismatch between == and keccak256. The NatSpec
already assigns validity to the caller. On this evidence the library is behaving
exactly as documented and a reasonable reviewer may close this as by design.

Why it may still be worth acting on. The two rows that do not clean are
both assembly reads, and assembly is the normal consumer idiom across the rain
libraries. A downstream library that takes an address[] memory and reads
elements with mload — entirely reasonable, since it is entitled to assume
Solidity's memory convention holds — will observe a 256-bit word where it expects
an address, and will do so silently. The NatSpec's warning is generic ("ensure
the validity of the data") and does not name this specific hazard or say which
consumers are affected.

Cheap options, if any action is wanted. (a) Document the hazard on
asAddressesArray specifically, naming assembly consumers as the ones that see
the dirty word. (b) Additionally offer a checked or masking variant alongside the
unchecked cast, leaving the current function untouched. (c) Close as by design.

This is filed as a hazard-surface and documentation finding, not as a
correctness bug: no case was found in which the library produces a wrong result
or corrupts state.

Notes

Found during an adversarial mutation test run over the whole repo. The related
coverage gap — the test suite could not distinguish the documented no-masking
behaviour from its opposite, because the round trip tests compared the input
buffer against itself — is fixed separately in #18.

Metadata

Metadata

Assignees

No one assigned

    Labels

    adversarialFinding from an adversarial correctness passauditFinding from a codebase audit / mutation-test scan

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions