This is a post-mortem document describing a critical [1] bug that was fixed on 8/24/22.
Summary
A vulnerability in the BondingManager contract used to manage the LPT stake of orchestrators and delegators was reported to the core team on 8/24/22 by Jinheon Lee of HeungBu, a whitehat hacker, via Livepeer’s Immunefi bug bounty program. This vulnerability, if exploited, would have allowed an attacker to freeze some or all of the delegated stake of an orchestrator. Neither orchestrator nor delegator funds were at risk of direct theft, but an exploit could have prevented stake from being moved (i.e., rebonded or unbonded).
The core team confirmed the issue within hours and then implemented, validated and deployed the fix on the same day.
Vulnerability
Description
The BondingManager contract keeps track of the delegated stake for an orchestrator via a delegatedAmount field. Whenever stake is delegated to the orchestrator, the delegatedAmount field is increased. Whenever stake is undelegated (either via a rebond or unbond transaction) the delegatedAmount field is decreased.
The contract uses safe math operations such that a revert would be triggered for subtraction underflows (i.e. b > a for a - b). As a result, if the amount of stake being undelegated in a rebond or unbond transaction ever exceeded delegatedAmount, the transaction would revert. Under normal circumstances, this scenario will never occur because there is supposed to be a contract invariant where the delegatedAmount field for an orchestrator is always greater than or equal to the sum of the orchestrator’s self-stake (stake that it has delegated to itself) and the stake of all of the orchestrator’s delegators. However, the reported vulnerability could allow an attacker to break this invariant.
In order to support L1 to L2 stake migrations, the Confluence upgrade introduced a transferBond() function to the BondingManager contract that allows any address to transfer its own stake to a new recipient address such that the recipient becomes the new owner of the stake. The original implementation of this function set the delegate address of the recipient to the sender’s delegate if the recipient’s delegate was the zero address: if recipientDelegate = address(0), then recipientDelegate = sender delegate
. This implementation incorrectly assumed that if the recipient’s delegate was the zero address that the recipient did not have any stake. The consequence of this incorrect assumption was that if an attacker controlled two addresses, the following could occur:
- Address A delegates any amount of stake to an orchestrator
- Address B delegates stake to the zero address
- Address A calls transferBond() to transfer its stake to address B
- Address B’s delegate is set to address A’s orchestrator without the contract properly incrementing the delegated stake of address A’s orchestrator
- Address B unbonds to enter the unbonding period, which subtracts its stake from the orchestrator’s delegated stake
- The end result is that the orchestrator’s delegated stake is incorrectly reduced such that the orchestrator’s delegatedAmount is less than sum of the orchestrator’s self-stake and the stake of all of the orchestrator’s delegators
- The difference between the orchestrator’s delegatedAmount and the sum of the orchestrator’s self-stake and the stake of all of the orchestrator’s delegators would be frozen because that amount of stake would not be movable
The impact of frozen stake would only be felt by orchestrators and delegators after the orchestrator’s remaining delegatedAmount is moved. At that point, an attempt to move the remainder of the orchestrator’s self-stake and delegators’ stake would result in a reverted transaction.
Potential Impact
While an attacker would not have been able to steal user funds (i.e. no user funds could be transferred to the attacker), they would have been able to freeze user stake. The amount of stake that could be frozen would have been equal to the amount of LPT that the attacker staked themselves.
In the event of such an attack, all LPT for frozen stake would have remained under the custody of the Minter contract meaning that a community governance initiated action could have unfrozen the stake. However, such an action would be a precedent setting intervention.
Likelihood of Exploitation
The primary motivation for an attacker would be to grief orchestrators and delegators by freezing their stake, and the attack would likely not be profit motivated given that user funds could not have been extracted.
At a technical level, the successful exploitation of the vulnerability would have required deep familiarity with the internal accounting mechanics of the BondingManager contract as well as the correct sequencing of multiple transactions from multiple accounts.
At the capital cost level, the successful exploitation of the vulnerability would have required an attacker to acquire an amount of LPT proportional to the amount of stake that they wanted to freeze. Moreover, the LPT would be inaccessible to the attacker for at least the duration of the unbonding period because the attacker needs to stake the LPT in order to exploit the vulnerability. Alternatively, the attacker could acquire a smaller amount of LPT and attempt to execute the attack repeatedly with the downside being that the LPT would be tied up for a longer time period.
Mitigation
Description
The fix for the vulnerability was a single update to a conditional statement that ensured that the delegate for a recipient in the transferBond() function would only be updated if the recipient also has no stake.
Timeline
8/24/22: The whitehat reports the vulnerability on Immunefi
8/24/22 Afternoon EST: The core team confirms the vulnerability.
8/24/22 Evening EST: The core team validates and deploys a fix for the vulnerability.
Takeaways
The vulnerability could have been prevented with a more thorough review of the assumptions made in the logic implemented in the transferBond() function and additional test cases for the scenario where the recipient has a zero address delegate, but also could have different state for the other fields that the contract tracks (i.e. stake). Additionally, fuzz testing for the transferBond() function that explores different state configurations for the sender and recipient given the desired contract invariant about the orchestrator’s delegated stake would have identified this vulnerability prior to deployment.
In the future, when introducing any new functionality, all test cases written for conditional paths in a function will take into account assumptions being made about state (i.e. in this case, the assumption was that an address with a zero address delegate would have zero stake) and additional fuzz testing will be incorporated into the test suite to validate that contract invariants are preserved.
Conclusion
The core team thanks the Jinheon Lee, the Immunefi whitehat hacker, for their responsible disclosure of this vulnerability which helped safeguard the users of the Livepeer network.They have been awarded a $25k bug bounty for the disclosure, based on the guidelines for reward amounts of critical vulnerabilities in the bug bounty program
[1] As classified based on the severity system used for the Immunifi bug bounty program.