Front-Running Randomness: Manipulating Pyth Entropy Outcomes
Introduction
This post describes a critical bug I found in the Pyth Network protocol. Pyth is an oracle - a decentralized application (dApp) that delivers off-chain data to the blockchain, giving other decentralized applications access to real-world information, such as real-time market data.
The bug allows certain types of attacks that could compromise the fairness of random number generation in dApps and let an attacker influence the outcome of random events. In applications that rely on randomness, like on-chain games or lotteries, this could give the attacker an unfair advantage.
Pyth Entropy
The said bug is in Pyth’s Entropy protocol - in the Solidity implementation. The goal of the Entropy protocol is to provide verifiable randomness for dApps that require it, such as on-chain games.
A useful example (although I do not encourage gambling!) is to think of an on-chain casino: users can lock their cryptocurrency in a smart contract, and either lose the locked funds or win extra funds, depending on the outcome of a random number generation.
Hence, the casino (the smart contract) needs to provide a random number that the users can trust is truly random, and is not controlled by the casino for their unfair advantage. This is where the Entropy protocol comes in - it provides a way for the smart contract to generate random numbers that both the users and the casino can trust.
How to generate random numbers you can trust?
For both the user and the service provider to trust the random number, the random outcome is generated by using an entropy source from both entities. Even if one of them is dishonest, it is enough for just one of the inputs to be random for the outcome to be random.
The protocol works as follows:
- The provider provides a commitment of its entropy source. If their input is, say, \(x\), then the provider reveals \(keccak256(x)\) - which is a hash that does not reveal any information on \(x\), but does commit to it so the provider must use it.
- More specifically, the provider can hash the input many times, generating a sequence of numbers which act both as entropy sources and commitments. The provider can provide the last commitment in the sequence and its length by calling
register(..., bytes32 commitment, ..., uint64 chainLength)
.
- More specifically, the provider can hash the input many times, generating a sequence of numbers which act both as entropy sources and commitments. The provider can provide the last commitment in the sequence and its length by calling
- The user can then request to use a random source, while providing their own source of randomness as well, by calling
requestWithCallback(address provider, bytes32 userRandomNumber)
. - The provider can then reveal their own random number, which is verified against their commitment and used together with the user randomness to generate the final random number:
function revealWithCallback( address provider, uint64 sequenceNumber, bytes32 userRandomNumber, bytes32 providerRevelation ) public override { ... // verify provider commitment ... randomNumber = combineRandomValues( userRevelation, providerRevelation, blockHash ); ... }
The bug
The issue stems from the following fact: when calling requestWithCallback
, the user reveals their own random number publicly to the blockchain. It takes a while for the blockchain to accept the transaction, so the provider can try and insert their own transaction before requestWithCallback
is committed (this is called front-running).
The provider can thus read the user’s random value, predict which final random value will be generated, and quickly call register
again to change the result in their favor. In the casino analogy, the casino can predict if the user will win money, and front-run the user to make them lose!
sequenceDiagram
actor ServiceProvider
actor User
participant Mempool
participant Blockchain
ServiceProvider->>Blockchain: register(initialCommitment)
User->>Mempool: requestWithCallback(userRandomNumber)
Note over Mempool: Transaction pending
ServiceProvider-->>Mempool: Observes userRandomNumber
Note over ServiceProvider: Calculates potential outcome
ServiceProvider->>Blockchain: register(newCommitment)
Mempool-->>Blockchain: requestWithCallback confirmed
ServiceProvider->>Blockchain: revealWithCallback(manipulated revelation)
Note over Blockchain: Final random number<br/>now biased in provider's favor
Responsible disclosure
I reported the bug to Pyth using the Immunefi platform.
Timeline
- June 29th, 2024: report sent and escalated.
- July 3rd, 2024: the project acknowledged the issue, and confirmed it has Critical severity.
- July 17th, 2024: reward paid.
- Oct 8th, 2024: the project approved publication of the bug.
Fix
After weighing the user experience trade-offs of requiring the user to send a commitment as well, the project has decided to act optimistically instead of directly fixing the bug.
Since exploitation of this bug is provably identifiable on-chain, protocols are simply encouraged to verify the honesty of an entropy provider, and blacklist dishonest providers.