Rob Behnke
March 10th, 2023
As one of the first smart contract-oriented programming languages, Solidity is indispensable for developers building decentralized applications. However, Solidity also has certain quirks that can produce unpredictable behavior at runtime.
In this article, we discuss how the delegatecall
in Solidity can introduce vulnerabilities in smart contracts. We also highlight measures for preventing security issues associated with using delegatecall
in your code.
In Solidity, call
and delegatecall
are low-level interfaces for interacting with contracts (by sending external message calls to functions). Triggering the call
function in a contract causes the code at that address to execute in the context of the target contract. This means whatever state is modified as part of the operation will always belong to the called contract.
delegatecall
works differently because execution occurs in the context (programming environment) of the caller contract. For example, a delegatecall
from contract A to contract B would modify contract B’s storage using functions in contract A.
The image above further shows two features of the delegatecall
function:
The execution of delegatecall
always alters the state of the contract account that initiated the operation (instead of the target contact's state as in call
).
delegatecall
preserves the context of the original message call—that is, the values of msg.sender
, msg.data
, and msg.value
don’t change—unlike call
where those values might change during execution.
The main use case for delegatecall
is to allow a contract to delegate the execution of a particular message call to another contract. Since the target contract in a delegatecall
writes to storage of the calling contract, it's possible to have contracts that run code stored inside another contract as though calling an internal function.
For example, you can deploy a library contract with functions/utilities intended for use across a bunch of different contracts. Delegatecall
enables those contracts to reuse the same library at runtime—removing the need to duplicate code and keeping contracts modular and gas-efficient.
Delegatecall
further supports so-called “proxy patterns” used in upgrading smart contracts. In this case, a proxy contract (holding contract state) delegates execution to logic contract (holding business logic). The proxy contract remains immutable, but it is possible to point it to a new logic contract—thereby simulating an upgrade to a protocol.
As explained, delegatecall preserves the context of the original call—that is, variables like msg.sender
, msg.value
, and msg.data
don't change. This feature is useful in cases like proxy patterns where users interact with an immutable contract that delegates execution to upgradable proxies, or when using libraries.
However, this pattern may cause certain security issues, especially around access control in smart contracts. To illustrate, consider the following example from the Ethernaut CTF wargame:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Delegate {
address public owner;
constructor(address _owner) public {
owner = _owner;
}
function pwn() public {
owner = msg.sender;
}
}
contract Delegation {
address public owner;
Delegate delegate;
constructor(address _delegateAddress) public {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}
Delegation
is a contract that forwards message calls to Delegate
. Delegation
is initialized with the deployer’s address and has no function for updating the value of owner
. However, this doesn’t make it impossible for an attacker to claim unauthorized ownership of Delegation
.
Here’s how the attack works in practice:
1. The attacker creates a contract that calls Delegation
and passes the function selector of pwn()
in the calldata. Since no function in Delegation
matches pwn()
, it catches the error using the fallback function and forwards the call to Delegate
. pwn()` exists in Delegate
, so the call—which tells Delegate
to update owner
to msg.sender
—can run.
2. Delegate
executes the call using the original values of msg.sender
and msg.data
and updates the owner
variable in Delegation
to the value of msg.sender
(ie. ie. the attacker’s contract address). The attacker gains control of Delegation
and proceeds to trigger malicious operations, like draining funds stored in the contract.
The first Parity multisig wallet hack is an example of a real-world exploit involving the delegatecall
vulnerability. The attacker re-initialized the wallet using functions stored in another library and claimed ownership of the multisig wallet, before draining funds from it. (A similar vulnerability was also exploited in the Punk Protocol hack, allowing the attacker to hijack the protocol’s contract and steal user deposits.)
The delegatecall vulnerability described in this article can be prevented by using a “stateless library” (ie. a library contract that only exposes pure
or view
functions and doesn’t modify state in client contracts). If the library contract in the earlier example (or the Parity wallet library) had no state-modifying functions, attackers cannot exploit delegatecall
to hijack the contract.