High severity
Storage array modified by value
Updated 2026-10-13
In Solidity an array variable can live in storage, where it persists between calls, or in memory, a scratch space that vanishes when the call returns. When you pass an array to a function, the parameter’s declared data location decides which you get. Declare the parameter storage and the function receives a reference to the caller’s array: writes go straight to the chain. Declare it memory and the function receives a fresh copy: writes land on the copy and are thrown away when the function returns. A helper written to update a storage array, but whose parameter says memory, compiles cleanly, runs without reverting, and quietly changes nothing. The caller’s balances, queue, or allowlist stay exactly as they were, and nothing on-chain signals that the update was lost.
Vulnerable pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract Ledger {
uint256[] public balances; // storage
function credit(uint256 i, uint256 amount) external {
_bump(balances, i, amount); // intends to update balances[i]
}
// Parameter is `memory`, so `arr` is a throwaway copy of the storage array.
function _bump(uint256[] memory arr, uint256 i, uint256 amount) internal {
arr[i] += amount; // writes to the copy; balances is untouched
}
}A caller invokes credit expecting balances[i] to grow by amount. The transaction succeeds, no event or revert hints at trouble, and balances[i] is unchanged. The write inside _bump updated the memory copy that the EVM discards at the end of the internal call. If credit is how deposits, rewards, or vote tallies are recorded, every one of them silently evaporates while the surrounding accounting believes they were booked. The gap between “the function ran” and “the state changed” is exactly the kind of thing a passing test suite with weak assertions sails straight past.
The fix
Change one word: declare the parameter storage so the function works on the caller’s array by reference. The write then reaches the chain. If the helper genuinely needs a scratch copy and the caller needs the result, the honest alternative is to return the modified array and have the caller reassign it — but when the intent is to mutate state in place, storage is the answer. Make every array, struct, and mapping parameter’s location explicit and deliberate, and never reach for memory on a parameter you mean to persist.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract Ledger {
uint256[] public balances;
function credit(uint256 i, uint256 amount) external {
_bump(balances, i, amount);
}
// `storage`: `arr` references the caller's array, so the write persists.
function _bump(uint256[] storage arr, uint256 i, uint256 amount) internal {
arr[i] += amount;
}
}A note on scope
This is a compiler-era footgun that modern Solidity narrowed but did not close. Before version 0.5.0 the data location on a parameter was optional and defaulted to memory, so a developer could leave it off, assume the array was passed by reference, and lose every write without ever typing the word memory. Since 0.5.0 the location is mandatory on array, struct, and mapping parameters, which turns the silent default into a deliberate keyword — but a developer who writes memory out of habit, or copies a signature, still hits the same lost write. The detector flags the call site whenever a storage array is passed to a function whose matching parameter is not storage and that function writes to it. It reasons about the write, so a helper that takes a memory array and only reads it is not flagged; the finding means a write you probably expected to persist will not. It is rated high impact at high confidence because the mistake changes nothing visibly while breaking the contract’s core bookkeeping.
Scan your contract for this
pactlint flags array-by-reference and other recurring DeFi bug classes in Solidity
source or a verified contract on Ethereum or Base. Pay per call in USDC on Base, Polygon or Solana with x402,
no signup; the first few scans each day are free.
A storage array passed by value is detected by Slither’s stock
array-by-reference detector, rated high impact at high confidence. Because high impact only ever
drops a notch when confidence is low, pactlint surfaces this one at high severity, and it runs no
worker-side triage on it — it is neither suppressed as noise nor downgraded, so a reported finding stays
High as the analyser rates it. Run it in CI with the
pactlint GitHub Action.
Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.