High severity

Delegatecall to a caller-controlled destination

A delegatecall runs another contract's code in this contract's context: the callee reads and writes this contract's storage, spends its balance, and sees the original msg.sender. That is exactly what a proxy wants when it forwards to a trusted implementation. It becomes a critical bug the moment the destination is a value the caller supplies. If anyone can pass the address that gets delegatecalled, they can point it at code of their own and run that code against this contract's storage — overwriting the owner slot, moving funds, or calling selfdestruct to wipe the contract entirely. The usual shape is a forwarder, multicall, or “executor” that takes a target (or implementation) argument and delegatecalls into it with no restriction on what that address may be.

Vulnerable pattern

A minimal contract with the bug
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Forwarder {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    // Delegatecalls into a caller-supplied address. The callee executes in
    // this contract's storage context, so an attacker can pass their own
    // contract and overwrite `owner`, drain the balance, or selfdestruct
    // this contract. No restriction on `target`.
    function execute(address target, bytes calldata data) external {
        (bool ok, ) = target.delegatecall(data);
        require(ok, "call failed");
    }

    receive() external payable {}
}

The fix

Never delegatecall into an address a caller provides. Delegatecall only to a fixed, trusted implementation: pin it at construction as an immutable address, or, if the logic must be upgradeable, change it only through an access-controlled setter (an onlyOwner or role gate, ideally behind a timelock) — never from a per-call argument. Drop target from the external function so the destination is no longer attacker-influenceable.

The same contract, corrected
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Forwarder {
    address public owner;
    address public immutable implementation;      // trusted, fixed at deploy

    constructor(address impl) {
        owner = msg.sender;
        implementation = impl;
    }

    // Delegatecalls only to the pinned implementation. The caller chooses the
    // calldata, not the code address, so no attacker contract can run here.
    function execute(bytes calldata data) external {
        (bool ok, ) = implementation.delegatecall(data);
        require(ok, "call failed");
    }

    receive() external payable {}
}

Scan your contract for this

pactlint flags controlled-delegatecall and other recurring DeFi bug classes in Solidity source or a verified contract on Ethereum or Base. Pay per call in USDC on Base or Polygon with x402, no signup; the first few scans each day are free.

How to scan →

A delegatecall to a caller-controlled destination is detected by Slither's stock controlled-delegatecall detector, rated high impact at medium confidence, which pactlint surfaces at high severity — it keeps Slither's own impact-and-confidence rating, with no extra downgrade or triage. Run it in CI with the pactlint GitHub Action. Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.