Tokemak operates a liquidity farming strategy. The funds are held by Tokemak manager contract (here). For maintenance operations, the manager delegateCalls into specialized controllers (_executeControllerCommand). The discussed bug is a privilege escalation, from having maintenance ability, to stealing the tokens. Having maintenance ability is not trivial. However, the controllers which can only be operated from Manager are in scope for the bug bounty, so I assumed exploiting them is valid.
SushiswapControllerV2 and UniswapController have deploy() and withdraw() functions that allow the protocol to fund LPs and farm rewards. However, the deploy() function can be abused by anyone with ADD_LIQUIDITY_ROLE in Uniswap, and anyone in SushiswapControllerV2 (due to lack of protection), to steal tokens held by the protocol.
The idea is to plant a Uniswap/Sushi pair with a bad ratio between tokenA and tokenB. When the attacker calls deploy() which deposits tokens to the pool and receives LP tokens, the controller is impacted by the difference between the LP ratio and the real ratio, losing up to the full reserve amount of the chosen token.
The attack looks different for existing pair and for non-existing pair, I will detail the full flow for non-existing pair as it impacts the current Tokemak reserve (FOX and ALCX tokens).
Protocol begins with 4048 ALCX and 460000 FOX tokens (current balance)
Attacker calls addLiquidity() on Sushi router which creates a pair if it doesn't exist. He will deposit with ratio 4048/1 ( real ratio is around 1/400). It's enough to deposit a tiny amount of initial ALCX/FOX tokens.
Attacker uses executeRollover/executeMaintenance to encode controller calldata, which will call deploy(). Attacker requests to deposit exactly 4048 ALCX and 1 FOX in the ALCX/FOX pair, which fits the planted ratio. Controller receives LP tokens.
Attacker calls removeLiquidity() and burns his LP tokens, getting back his initial deposit. Now, the pool is 100% deposited controller money.
Attacker calls swapExactTokensForTokens() to swap 4047 FOX tokens to 4047 ALCX tokens! This is due to the constant product formula used by Uniswap pool. 4048:1 becomes 1:4048 if you send 4047 tokens to the scarce token side.
Attacker profits 4047 ALCX - 4047 FOX = $95,137 !
For existing pair (not the case currently, but could be), the attack would look something like:
Manipulate pool ratio using swap()
Trigger deploy() function as specified
Restore pool ratio to market value, which collects almost for free the tokens controller deposited during the wrong evaluation.
Note that this flow could be repeated in the opposite direction, in order to claim almost all the controller's FOX tokens as well.
ALCX / FOX tokens held in reserve could be stolen by attacker, which only has permission to call addLiquidity. In SushiswapControllerV2, an additional vulnerability (lack of onlyAddLiquidity check) means anyone which can call controller code can steal those funds. Risk Breakdown
The root cause of the exploit is that Controller trusts the existing pair has a correct ratio. This has to be validated using a TWAP oracle such as Chainlink, in both deploy() and withdraw() functions.
Proof of Concept
The full POC is implemented on tenderly.co, using a private mainnet fork. It is annotated with explanations. The data encoded in the executeMaintenance() call is generated using the following python code:
tokA = '0xdBdb4d16EdA451D0503b854CF79D55697F90c8DF' tokB = '0xc770EEfAd204B5180dF6a14Ee197D99d808ee52d' aDesired = aMin = 4048 * 10**18 bDesired = bMin = 1 * 10**18 to = '0xA86e412109f77c45a3BC1c5870b880492Fb86A14' deadline = 1672717334 poolId = 0 toDeposit = False deploy_hash = sha3.keccak_256('deploy(bytes)'.encode()).hexdigest()[:8] deploy_hash = binascii.unhexlify(deploy_hash) params = eth_abi.abi.encode_abi(['address','address','uint256','uint256','uint256','uint256','address','uint256','uint256','bool'], [tokA, tokB, aDesired, bDesired, aMin, bMin, to, deadline, poolId, toDeposit]) deploy_param = eth_abi.abi.encode_abi(['bytes'], [params]) data = deploy_hash + deploy_param controllerId = binascii.unhexlify('7375736869737761707632000000000000000000000000000000000000000000') execution = eth_abi.abi.encode_abi(['((bytes32,bytes))'], [[((controllerId,data),)]]) execution_string = binascii.hexlify(execution) executemaintenance_sig = sha3.keccak_256('executeMaintenance(((bytes32,bytes)))'.encode()).hexdigest()[:8] calldata = executemaintenance_sig + execution_string.decode() print(calldata)
For convenience, below are screenshots of the attack:
Bootstrapping attacker address with FOX/ALCX:
Attacker creates new Pair and mints LP tokens - bad ratio:
Attacker calls Tokemak manager's executeMaintenance() and enters addLiquidity():
Attacker burns his LP tokens, getting his initial deposit back:
Finally attack makes massive profit, converting FOX to ALCX for 1:1
After submitting the bug, it was quickly closed by Tokemak because of "access to privileged addresses". In my opinion, this is incorrect because:
SushiswapControllerV2.sol is explicitly in scope for Tokemak Bug Bounty
The exploit did not require any privilege above being able to reach SushiswapControllerV2.sol surface. If Tokemak thinks the controller code is privileged, it should not be in their scope.