Understanding How Upgradable Smart Contracts Work (WazirX)
One popular staple of smart contracts is the 'upgradable' smart contract. For those that are familiar with smart contract development, you've likely encountered these types of contracts before.
However, if you have not - then this is going to serve as a brief guide on how they work and what their purpose is. We're also going to cover some of the potential pitfalls and catch 22's associated with upgradable smart contracts. Our rationale for doing so is to provide greater context to the WazirX hack.
Concept of Upgradable Smart Contracts
In general, smart contracts are designed to be 'immutable'. This means that once the smart contract is deployed on the blockchain, whatever code that's written in it is fixed in stone.
Thus, the concept of the 'upgradable' smart contract was introduced to allow developers to 'update' the actual code that gets executed when someone makes a call upon a specific smart contract.
Proxy and Logic Smart Contracts
In order to construct this pipeline, multiple different smart contracts are deployed to operate in concert with one another.
As can be seen in the illustration above, these types of smart contract orchestrations (at least) involve two contracts, which are known as the - proxy contract and implementation contract.
The proxy contract is the 'front-facing' contract where users issue all their calls. Calls made upon the proxy contract then get forwarded to the implementation contract (which holds the business logic of the proxy contract).
This is the part where things get a bit confusing, but I'm going to try my best to break this down (again) in a digestible manner. The first concept we need to grasp is that even though calls are made to the proxy contract, the actual functions that we need to call are likely defined in our implementation contract.
So, for example, if we wanted to transfer an ERC20 token for some sort of DeFi protocol we've set up using this orchestration, then the likely function we'd be calling is a _transfer
function.
Below is an example of such:
function _transfer(address sender, address recipient, uint256 amount) internal {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
uint256 senderBalance = _balances[sender];
require(senderBalance >= amount, "ERC20: transfer amount exceeds balance");
_balances[sender] = senderBalance - amount;
_balances[recipient] += amount;
emit Transfer(sender, recipient, amount);
_afterTokenTransfer(sender, recipient, amount);
}
As we can see above, the _transfer
function is fully defined above. This function exists within the implementation contract. However, this is not the contract that we're going to be making a call on directly.
Below is an example of a (very basic) proxy contract that could be associated with the example implementation contract provided above.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
contract MyTokenProxy is TransparentUpgradeableProxy {
constructor(address _logic, address admin_, bytes memory _data)
TransparentUpgradeableProxy(_logic, admin_, _data)
{}
}
As we can see in the proxy contract above, there are no functions governing the _transfer
function. However, if we did want to transfer tokens in this orchestration, then we would be making that call on this proxy contract here.
How the Fallback Function Works
Every Ethereum smart contract contains something called a 'fallback' function. Essentially, this function is triggered in either of the two following conditions:
Ethereum is sent to a smart contract without any associated data.
A function is called on a smart contract that isn't defined in said smart contract.
Below is a very basic example implementation of a generic smart contract that contains a fallback
function (which typically is not defined or labeled as 'fallback' within the smart contract).
Check it out below:
pragma solidity ^0.8.0;
contract ExampleContract {
event FallbackCalled(address sender, uint amount, bytes data);
// FALLBACK FUNCTION!!!
fallback() external payable {
emit FallbackCalled(msg.sender, msg.value, msg.data);
}
}
What the Smart Contract Does When the Fallback Function is Triggered: It emits the
FallbackCalled
event, logging the sender’s address (msg.sender
), the amount of Ether sent (msg.value
), and any data sent with the transaction (msg.data
).
Quick Dirty on Fallback Functions:
They have no need for a name.
They can be externally called.
Per contract, there can only be one or zero fallback functions.
The functions are automatically triggere dwhen called by another contract given that the called function has no name.
The functions also allow for arbitrary logic to be included.
It can also be triggered if a token(s) is sent to a contract that has fallback functions with no declared
receive()
function and no accompanyingcalldata
.
Source: *https://info.etherscan.com/what-is-a-fallback-function/
Breaking Down the Infamous DelegateCall
The most important facet of the smart contract upgradable proxy pattern that we're going to focus on in this piece is the delegatecall
function.
As noted before, whenever interacting with a smart contract orchestration that utilizes the proxy upgradable pattern, the proxy is going to serve as a the front-facing contract that users interact with. Calls made upon this contract are typically done based on the logic contained within the implementation contract that the proxy forwards calls to (via the fallback
function, however that's setup in the proxy).
This is where the delegatecall
function becomes relevant (making it the most relevant function that we're going to focus on here).
In the context of a proxy smart contract in Ethereum, the delegatecall
function is a low-level function that allows a contract to execute code from another contract while preserving the context (i.e., msg.sender
, msg.value
, and storage
) of the calling contract.
Here's a detailed breakdown of how delegatecall
is used within a proxy contract:
Proxy Pattern: The proxy pattern involves a proxy contract that does not contain the actual business logic but instead forwards calls to another contract (often referred to as the implementation or logic contract). This pattern is commonly used for upgradability, where the logic contract can be replaced without changing the proxy contract's address.
Preserving State: When using
delegatecall
, the storage of the proxy contract is used instead of the storage of the logic contract. This means that any state changes made by the logic contract will affect the proxy contract's storage.Execution Context: The
delegatecall
function ensures that themsg.sender
andmsg.value
remain the same as if the call was made directly to the proxy contract. This is important for maintaining the correct context, especially for access control and payment forwarding.Function Call Forwarding: In a typical proxy contract implementation, the fallback function is overridden to use
delegatecall
to forward any call to the logic contract.
Below is an example of a smart contract that contains the relevant delegatecall
functionality within the fallback function of the proxy:
pragma solidity ^0.8.0;
contract Proxy {
address public implementation;
constructor(address _implementation) {
implementation = _implementation;
}
fallback() external payable {
address _impl = implementation;
require(_impl != address(0), "Implementation contract not set");
// Delegate the call to the implementation contract
(bool success, ) = _impl.delegatecall(msg.data);
require(success, "Delegatecall failed");
}
// Optional: receive function to handle plain ether transfers
receive() external payable {}
}
In this example:
The
implementation
address is set to point to the logic contract.The
fallback
function is defined to catch all calls to the proxy contract and forward them to the logic contract usingdelegatecall
.The
msg.data
is passed to the logic contract, which contains the function selector and arguments for the intended function call.
- Upgradability: The proxy contract can be upgraded by changing the
implementation
address to point to a new logic contract. This allows the proxy contract to forward calls to the new logic contract without changing the proxy's address, thereby preserving the contract's state and external interfaces.
Looking at an Example (WazirX 2) Implementation of the Proxy Upgradable Smart Contract Pattern
Relevantly, WazirX - the Indian cryptocurrency exchange that recently suffered a $230 million smart contract exploit, had employed a proxy upgradable structure to govern the custody and transfer of their Ethereum assets.
However, to give us some better context and understanding on how such a smart contract orchestration works in practice, we're going to look at one of their pre-hack transactions.
Specifically this one:
https://etherscan.io/search?q=0xfa377a00729d54d167ebbc37a42f040c9237c56ade00843f1cc24421e2bf2d81
Please note that WazirX's smart contract (0x27fD43
) is the proxy contract for their upgradable proxy pattern implementation.
If we take a look at the 'internal txns' (internal transactions) panel provided on Etherscan for this specific transaction, we'll see that a delegatecall
was triggered by this transaction.
To be clear, in this specific transaction, there are two delegatecall
made to two different smart contracts.
The first one (at the bottom), here governs the transfer of $USDC (which is also orchestrated by an upgradable proxy smart contract pattern).
When WazirX attempts to transfer USDC using their upgradable proxy, the following sequence of calls occurs:
First Call to the WazirX Proxy Contract: - You initiate a call to your proxy contract to perform the USDC transfer. - Your proxy contract uses
delegatecall
to forward this call to its implementation contract.Call to USDC Proxy Contract: - The implementation contract of your proxy contract contains the logic to transfer USDC. - This logic involves calling the USDC contract to perform the transfer. - If the USDC contract is also a proxy (which it is), it will use
delegatecall
to forward the call to its own implementation contract.
Below is a more detailed look at the transaction as presented on the site 'Tenderly':
Specifically we can see that the fallback
function that gets triggered in the WazirX proxy contract is the following:
/// @dev Fallback function forwards all transactions and returns all received return data.
fallback() external payable {
// solhint-disable-next-line no-inline-assembly
assembly {
let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
// 0xa619486e == keccak("masterCopy()"). The value is right padded to 32-bytes with 0s
if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
mstore(0, _singleton)
return(0, 0x20)
}
calldatacopy(0, 0, calldatasize())
let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
if eq(success, 0) {
revert(0, returndatasize())
}
return(0, returndatasize())
}
}
Or as seen below:
There are some other functions of interest that we're going to take a look at in the actual exploit analysis. But for now this brief guide should serve as sufficient context for the upgradable proxy smart contract pattern to help provide an understanding for those looking to get a better grasp of how WazirX was compromised.
Subscribe to my newsletter
Read articles from Cryptomedication directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Cryptomedication
Cryptomedication
#Blockchain #BlockSec #OSINT #CyberSec #Darkweb | Isaiah 54:17 Fingerpint: 54EADD6FCBCF520E37A003E04D3ECE027AEFA381