One of the services I've been offering for the past several months is quick (less than 2hr) security checks of yield farming pools. I look for rug-pull potential and security risks in yield farming contracts to help protect would-be farmers and LPs. I basically try to spot trouble with farms before a user puts their funds at risk.
As you you might imagine, I've seen countless forks of MasterChef
-- both its original incarnation and its official second version, MasterChefV2. I've seen all manner of shenanigans and optimizations, but this article is about a property of the official MasterChefV2
and any fork that remains faithful to its original functionality.
One benefit of the original MasterChef
is that the owner of the contract cannot steal a user's principal, even if the owner were malicious. A malicious owner might be able to mess with a user's rewards, but they could never prevent a user from getting back the LP tokens they had initially invested. For example, with the original MasterChef
, a user can always call the emergencyWithdraw function to retrieve their LP tokens, and there is nothing in the world that the owner of the MasterChef
contract can do to prevent that.
By contrast, the owner of a MasterChefV2
contract can freeze any user's principal at will. This freezing can be parlayed into a ransom attack, whereby users are able to remove their principal from MasterChefV2
if and only if they've paid the owner a ransom.
This property of MasterChefV2
means that I always have to perform additional safety checks (discussed below) on the owner and the values/addresses they set in MasterChefV2
. It also means the client/user needs to make sure they have good alerting set up to catch any shenanigans the owner may try to pull.
The key to freezing user’s funds is the “set” function, which the owner can call at any time, including after a pool has been initialized and users have deposited LP tokens. The set
function allows the owner to assign a rewarder
to a pool (or update a pool's rewarder
if it already has one). The rewarder
is a contract that implements a mutable “onSushiReward” function, which can be anything the owner wants.
This function on the rewarder
is called during both the “withdraw” function and the “emergencyWithdraw” function -- which are the only two functions that a user can call to get their LP tokens out of the contract.
To freeze all users’ LP tokens for a given pool, the owner can call the set
function and set the rewarder
of the pool to be the following contract:
contract BadRewarder {
function onSushiReward(...) external {
revert('lulz');
}
}
The result would be that all user's attempts to withdraw would result in a reverting transaction.
If so desired, the owner can make the freeze selective, singling out individual users, by setting the rewarder
to a contract like this:
contract SelectiveFreezer {
mapping (address => bool) public isFrozen;
function setIsFrozen(address _user, bool _isFrozen) external onlyOwner {
isFrozen[_user] = _isFrozen;
}
function onSushiReward(...) external {
if (isFrozen[user]) revert('lulz');
}
}
It is commonly the case that a DoS attack, like the above "freeze" attack, can be parlayed into a ransom attack. It’s just the old “pay me and I’ll stop DoS-ing you“ bit, but in smart contract form.
A malicious MasterChefV2
owner could set the rewarder
to be a contract similar to this one:
contract Ransom {
uint256 constant public requiredRansom = 1 ether;
mapping (address => bool) public hasPaidRansom;
function payRansom() external payable {
require(msg.value >= requiredRansom, 'send more money');
hasPaidRansom[msg.sender] = true;
}
function onSushiReward(...) external {
if (!hasPaidRansom[user]) revert('must pay ransom');
}
function collectRansom() external onlyOwner {
payable(owner).transfer(address(this).balance);
}
}
The malicious owner would then need to verify this contract on Etherscan, and then transfer ownership of the MasterChefV2
instance to the zero address.
The result is that users would be able to withdraw their LP from MasterChefV2
if and only if they pay a 1 ETH ransom.
Note that this is just a simple example, and the malicious rewarder
could be made to scale its ransom based on the amount of LP tokens the user had deposited in MasterChefV2
.
This property of MasterChefV2
means that users must trust the owner. As a result, I always have to perform checks that the owner is, for example, an instance of the Timelock contract with a reasonable value for MINIMUM_DELAY
, and that the client/user always has good alerting set up to be notified if/when the owner tries to make a change to the MasterChefV2
fork. It also means that I need to check the current rewarder
(if any) before the user apes into the pool.
For honest developers who are forking MasterChefV2
, I recommend removing the _rewarder.onSushiReward
call from the emergencyWithdraw
function. The emergencyWithdraw
function should be a clear path for users to exit without any possibility of a malicious owner stopping them. (Note that a try/catch could work here, but you would need to be sure not to forward too much gas to the potentially-malicious rewarder
, which could otherwise burn enough gas so that the subsequent safeTransfer
would always fail). If you don’t make this contract-level fix, then be sure to make the owner an instance of the Timelock
contract with a reasonable minimum delay (e.g., 2 days or more).
For users aping into these MasterChefV2
forks, be sure to check for all the things that I do:
Timelock
with a good minimum delay.rewarder
is currently set for your pool, it should be verified on Etherscan and its onSushiReward
function should have no possibility of reverting or burning a ton of gas.Timelock
, that way you can exit MasterChefV2
before a malicious rewarder
gets dropped into your pool.If you found this helpful, please consider contributing to my security spot checks project.
Stay safe out there.
Cheers!