Reproduced Exploit

ZeroLend — the reward controller pays 0 while 1,000 ZERO sits in it

ZeroLend aTokens are an Aave fork. After a balance change they call handleAction. ZLRewardsController was taken from Radiant and only implements handleActionAfter. The hook reverts, the holder's accrued rewards stay 0, and claim pays 0 while 1,000 ZERO sits in the controller.

Jan 2024•Other•logic•4 min read

Chain

Other

Category

logic

Date

Jan 2024

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 40817-zlrewardscontroller-contract-is-incompatible-with-zerolend-s. Standalone Foundry PoC and full write-up: 40817-zlrewardscontroller-contract-is-incompatible-with-zerolend-s_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/reward-calculation · vuln/dos/frozen-funds

Key info#

ProtocolZeroLend
FindingCantina competition Jan 2024, AuditVault 40817
Vulnerable codeZeroLendAToken._transfer calls handleAction; ZLRewardsController only has handleActionAfter
HarmClaim pays 0; 1,000 ZERO stays in the controller
Chainlocal synthetic

TL;DR#

ZeroLend aTokens are an Aave fork. After a balance change they call handleAction. ZLRewardsController was taken from Radiant and only implements handleActionAfter. The hook reverts, the holder's accrued rewards stay 0, and claim pays 0 while 1,000 ZERO sits in the controller.

The vulnerable code#

The controller's hook, which the aToken never calls:

SOLIDITY
function handleActionAfter(address _user, uint256 _balance, uint256 _totalSupply) external {
    if (!validRTokens[msg.sender] && msg.sender != address(streamedVesting)) revert NotRTokenOrMfd();

    if (_user == address(streamedVesting)) return;

    _handleActionAfterForToken(msg.sender, _user, _balance, _totalSupply);
}

The aToken calls a different function:

SOLIDITY
IAaveIncentivesController incentivesControllerLocal = IAaveIncentivesController(controller);
if (address(incentivesControllerLocal) != address(0)) {
    uint256 currentTotalSupply = _totalSupply;
    // @> VULN: ZeroLend calls handleAction; ZLRewardsController only implements handleActionAfter
    incentivesControllerLocal.handleAction(sender, currentTotalSupply, oldSenderBalance);
}

There is no handleAction on the controller, so the external call reverts. accrued is only written inside _handleActionAfterForToken. claim therefore reads 0 and transfers nothing.

The control adds handleAction with the Aave argument order and the same accrual. The same transfer then succeeds, and claim pays the holder 1,000 ZERO.

Root cause#

Radiant's reward-bearing tokens call handleActionAfter after the balances have changed. ZeroLend's tokens still call Aave's handleAction(user, totalSupply, userBalance). The controller was copied from Radiant without that function, so every hooked balance change reverts and no user is ever accrued.

Preconditions#

The controller holds reward tokens. The user holds the reward-bearing aToken. The aToken's incentives controller is the ZLRewardsController. A transfer, mint, or burn is the action that should update the user's reward index.

Attack walkthrough#

  1. The holder approves the controller and deposits 1,000 ZERO. The controller's balance becomes 1,000. Accrued rewards for the holder are still 0.
  2. The holder transfers 1 aToken. _transfer updates balances, then calls handleAction.
  3. The controller has no handleAction. The call reverts and the transfer rolls back. The holder still has the full aToken balance. Accrued rewards stay 0.
  4. The holder calls claim. paid is read from accrued and is 0. The transfer branch is skipped.
  5. The holder' ZERO balance is 0. The controller still holds 1,000 ZERO.

The control wires the same aToken to a controller that implements handleAction. The transfer accrues the holder's share of the 1,000 ZERO, and claim pays all 1,000 to the holder. The controller's balance ends at 0.

Diagrams#

flowchart TD A["Controller is funded with 1000 ZERO"] --> B["Holder transfers 1 aToken"] B --> C["VULN: aToken calls handleAction"] C --> D["Controller only has handleActionAfter so the call reverts"] D --> E["Accrued rewards stay 0"] E --> F["claim reads a payout of 0"] F --> G["No ZERO is transferred"] G --> H["1000 ZERO remains in the controller"]

Impact#

The holder is owed the reward that was deposited for the aToken supply and receives 0. The 1,000 ZERO stays in ZLRewardsController. Every later balance change hits the same missing function, so the index is never updated and later claims stay at 0 as well.

Remediation#

Implement handleAction(address user, uint256 totalSupply, uint256 userBalance) on ZLRewardsController, and accrue inside it the way handleActionAfter already does. The aToken's existing call then updates the user, and claim can pay.

How to reproduce#

BASH
cd 40817-zlrewardscontroller-contract-is-incompatible-with-zerolend-s_exp
forge test -vv

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.