Platform: Code4rena
Start Date: 13/01/2022
Pot Size: $75,000 USDC
Total HM: 9
Participants: 27
Period: 7 days
Judge: leastwood
Total Solo HM: 5
Id: 73
League: ETH
Rank: 4/27
Findings: 6
Award: $4,987.89
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: WatchPug
Also found by: Ruhum, gzeon, harleythedog
20.7302 LPT - $768.68
2113.9229 USDC - $2,113.92
Ruhum
The L1Migrator.migrateETH()
function can be called by anyone. It pulls all the ETH from the BridgeMinter
contract and starts the process of moving the funds to L2. First of all, this function is only executable once. The RetryableTicket created with the first call is the only chance of moving the funds to L2.
The attacker can call the function with parameters that make the creation of the RetryableTicket on L2 fail. Thus, the ETH sits in the L1Migrator contract with no way of moving it to L2 or anywhere else. Effectively, the funds are lost.
The function is only executable once because it uses the amount
returned by IBridgeMinter(bridgeMinterAddr).withdrawETHToL1Migrator()
to specify the amount of ETH to be sent to L2: https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/contracts/L1/gateway/L1Migrator.sol#L308
After the first call to migrateETH()
that function will always return 0 since the BridgeMinter
won't have any more ETH: https://github.com/livepeer/protocol/blob/streamflow/contracts/token/BridgeMinter.sol#L91
So after the attacker called migrateETH()
with insufficient funds to create a RetryableTicket on L2 we have the following state:
The same thing can also be triggered by a non-malicious caller by simply providing insufficient funds. The whole design of only being able to try once is the issue here.
none
Instead of using the amount
returned by IBridgeMinter(bridgeMinterAddr).withdrawETHToL1Migrator()
you should use the balance of the L1Migrator
contract.
It might also make sense to not allow anybody to call the function. I don't see the benefit of that.
#0 - yondonfu
2022-01-23T13:56:18Z
🌟 Selected for report: Ruhum
Also found by: gzeon, harleythedog
9.2134 LPT - $341.63
939.5213 USDC - $939.52
Ruhum
Same thing as the ETH issue I reported earlier. I wasn't sure if those are supposed to be a single issue or not. The concept is the same. But, now you lose LPT tokens.
The L1Migrator.migrateLPT()
function can be called by anyone. It pulls all the LPT from the BridgeMinter
contract and starts the process of moving the funds to L2. First of all, this function is only executable once. The RetryableTicket created with the first call is the only chance of moving the funds to L2.
The attacker can call the function with parameters that make the creation of the RetryableTicket on L2 fail. Thus, the LPT sits in the L1Migrator contract with no way of moving it to L2 or anywhere else. Effectively, the funds are lost.
The function is only executable once because it uses the amount
returned by IBridgeMinter(bridgeMinterAddr).withdrawLPTToL1Migrator()
to specify the amount of LPT to be sent to L2: https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/contracts/L1/gateway/L1Migrator.sol#L342
After the first call to migrateLPT()
that function will always return 0 since the BridgeMinter
won't have any more LPT: https://github.com/livepeer/protocol/blob/streamflow/contracts/token/BridgeMinter.sol#L107
So after the attacker called migrateLPT()
with insufficient funds to create a RetryableTicket on L2 we have the following state:
The same thing can also be triggered by a non-malicious caller by simply providing insufficient funds. The whole design of only being able to try once is the issue here.
none
Instead of using the amount
returned by IBridgeMinter(bridgeMinterAddr).withdrawLPTToL1Migrator()
you should use the balance of the L1Migrator
contract.
It might also make sense to not allow anybody to call the function. I don't see the benefit of that.
EDIT
Actually, the funds aren't lost. The funds are sent to the Escrow contract which can be used to transfer the funds back to the BridgeMinter contract. Thus, you could reset the whole thing to its initial state and call L1Migrator.migrateLPT()
again. But, a really persistent attacker has the ability to DoS the function by frontrunning any call to it which results in the RetryableTicket failing again. Thus, you'd have to transfer the funds from the Escrow contract to the BrigeMinter again and so on.
So the same scenario I've outlined earlier is still viable. It's just a bit more difficult now since it has a higher cost for the attacker now. Because of that I think it's an medium issue instead of high.
Also, the mitigation steps I've given aren't valid. You can't use the L1Migrator
contract's balance since it will always be 0 (the funds are sent to the Escrow contract). Thus the best solution would be to just limit the access to the function.
#0 - itsmetechjay
2022-01-18T14:15:42Z
Per Ruhum, "I'd like to reduce the severity to medium and add the comment included in the EDIT section"
#1 - 0xleastwood
2022-01-29T23:48:08Z
Nice find! The warden has outlined a potential DOS attack which can lead to funds lost which are only recoverable by the transferring the funds in the escrow contract back to the bridge minter contract.
#2 - yondonfu
2022-01-31T21:21:02Z
🌟 Selected for report: Ruhum
0.8082 LPT - $29.97
82.4134 USDC - $82.41
Ruhum
Since the token is only ever allowed to be l1lpt
you might as well drop the parameter and the require statement here:
https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/contracts/L1/gateway/L1LPTGateway.sol#L143
https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/contracts/L2/gateway/L2LPTGateway.sol#L71
https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/contracts/L2/gateway/L2LPTGateway.sol#L107
#0 - yondonfu
2022-01-24T01:57:02Z
This argument is required by the Arbitrum L1GatewayRouter [1].
5.1186 LPT - $189.80
521.9563 USDC - $521.96
Ruhum
According to the spec both the L1LPTGateway
and the L2LPTGateway
should be paused at deployment. That's not the case. I'm not sure how that can be abused. I marked it low
since it's still a spec issue.
While both contracts are pausable through their base contract ControlledGateway
they are not paused by default after deployment.
pause()
is not called in their respective constructor or deployment scripts.
https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/contracts/L1/gateway/L1LPTGateway.sol#L41
https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/contracts/L2/gateway/L2LPTGateway.sol#L38
https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/deploy/L1/gateway.deploy.ts#L16
https://github.com/livepeer/arbitrum-lpt-bridge/blob/main/deploy/L2/gateway.deploy.ts#L15
none
Call pause()
in the constructor of both contracts
#0 - yondonfu
2022-01-23T01:02:43Z