Oracle integrations often look like read-only infrastructure.
This one was not.
Inside 0xMarkets, PythLazerFeedProvider.getOraclePrice could be called directly by any external account. Every successful call then paid the Pyth Lazer verification fee using ETH already held by the provider contract.
The caller did not need to be the protocol Oracle.
The caller did not need the CONTROLLER role.
The caller did not need administrator privileges.
The caller did not even need to send ETH.
With valid update data, an unprivileged account could repeatedly call the provider with:
msg.value
0 ETH
while the provider paid the verification fee from its own native balance.
In my proof of concept, the provider held:
0.05 ETH
and the local verification fee was:
0.01 ETH
Five zero-value attacker transactions reduced the provider balance to zero.
The same legitimate Oracle.setPrices flow that worked before the drain then failed because PythLazerFeedProvider could no longer fund pythLazer.verifyUpdate.
I submitted this finding as Medium through the 0xMarkets audit contest on HackenProof.
It earned $1.24.
The interesting part was never the small test balance.
The real issue was the authorization boundary:
Any external account with valid update data could decide when the protocol spent its oracle-verification balance and could keep doing so until the provider no longer had enough funds to execute the legitimate price-update path.
The design assumption
PythLazerFeedProvider was intentionally able to hold ETH:
// Accept ETH to cover Pyth verification fees
receive() external payable {}
That is reasonable if the provider is expected to pay a verification fee on behalf of the protocol.
The problem was that the function spending that balance was unrestricted:
function getOraclePrice(
address token,
bytes memory data
) external returns (OracleUtils.ValidatedPrice memory) {
uint32 feedId =
uint32(
dataStore.getUint(
Keys.pythLazerFeedIdKey(token)
)
);
if (feedId == 0) {
revert Errors.EmptyPythLazerFeedId(token);
}
uint256 feedMultiplier =
dataStore.getUint(
Keys.pythLazerFeedMultiplierKey(token)
);
if (feedMultiplier == 0) {
revert Errors.EmptyPythLazerFeedMultiplier(token);
}
bool inverted =
dataStore.getBool(
Keys.pythLazerFeedInvertedKey(token)
);
uint256 fee =
pythLazer.verification_fee();
(
bytes memory payload,
) =
pythLazer.verifyUpdate{
value: fee
}(
data
);
There was no onlyOracle style guard.
There was no onlyController restriction.
Nothing checked msg.sender before the fee-bearing external call.
The original implementation is visible in PythLazerFeedProvider.sol.
Why zero msg.value is the decisive detail
A natural reaction to a paid oracle call is:
The caller is paying the verification fee.
That would change the threat model completely.
But it was not what happened here.
The provider calculated the fee internally:
uint256 fee =
pythLazer.verification_fee();
and then executed:
pythLazer.verifyUpdate{
value: fee
}(
data
);
That value came from the balance of PythLazerFeedProvider.
The caller only paid transaction gas.
The PoC made this explicit by sending every drain transaction with:
attacker msg.value
0 ETH
Each successful call still reduced the provider balance by exactly one verification fee.
The vulnerable capability was therefore not merely:
Anyone can request an oracle validation
It was:
Anyone can request an oracle validation
and make the protocol pay for it
The legitimate oracle path
The intended flow was protected at the higher level.
Oracle.setPrices is restricted to controllers:
function setPrices(
OracleUtils.SetPricesParams memory params
) external onlyController {
OracleUtils.ValidatedPrice[] memory prices =
_validatePrices(
params,
false
);
_setPrices(
prices
);
}
During _validatePrices, the Oracle calls the configured provider:
OracleUtils.ValidatedPrice memory validatedPrice =
IOracleProvider(
provider
).getOraclePrice(
token,
data
);
So the legitimate architecture looked like this:
Controller
↓
Oracle.setPrices
↓
Oracle._validatePrices
↓
PythLazerFeedProvider.getOraclePrice
↓
pythLazer.verifyUpdate
↓
Validated price
The problem was that nothing forced callers to use that architecture.
An attacker could skip the protected Oracle contract and call the provider directly:
Unprivileged account
↓
PythLazerFeedProvider.getOraclePrice
↓
Provider pays verification fee
0xMarkets protected who could set prices.
It did not protect who could spend the fee balance required to validate those prices.
The attack in eleven steps
The proof used a configured token and valid local Pyth Lazer update data.
The complete sequence was:
1. PythLazerFeedProvider is funded with 0.05 ETH
2. The verification fee is configured to 0.01 ETH
3. A controller calls Oracle.setPrices
4. The legitimate price update succeeds
5. An unprivileged account calls getOraclePrice directly
6. The attacker sends zero ETH
7. The provider pays 0.01 ETH to the verifier
8. The attacker repeats the call five times
9. The provider balance reaches zero
10. The controller calls Oracle.setPrices again
11. The legitimate update fails because the provider cannot fund verifyUpdate
The attacker did not receive the drained ETH.
That distinction matters.
This was not demonstrated as direct attacker profit.
It was protocol-funded griefing: the attacker controlled when operational ETH was converted into verification fees.
The accounting was exact
The test started with:
Provider balance
0.050000000000000000 ETH
The configured fee was:
Verification fee
0.010000000000000000 ETH
The attacker executed:
Direct transactions
5
msg.value per transaction
0 ETH
Afterward:
Provider balance
0 ETH
Provider loss
0.05 ETH
Verifier gain
0.05 ETH
The provider lost exactly the amount the verifier gained.
That was important because it proved where the ETH went.
This was not an estimate based on gas usage.
It was not a balance change caused by the attacker sending value.
It was the provider paying the verification fee five times.
The legitimate price path failed afterward
The PoC established a before-and-after control.
Before depletion:
Oracle.setPrices
PASS
Primary price
SET
The test then cleared the previously stored price state before starting the attacker drain.
After depletion:
Oracle.setPrices
FAIL
Price count after failed update
0
The second setPrices attempt failed because the provider no longer had the funds required for verifyUpdate.
This is the actual liveness impact.
For a token configured to use this provider, fresh Pyth Lazer prices could no longer be validated through the demonstrated path until the provider was funded again or the implementation was corrected.
The PoC exercised the real vulnerable path
The proof was not built around a replacement provider or copied vulnerable function.
It used the real:
PythLazerFeedProvider
PythLazerFeedProvider.getOraclePrice
PythLazerFeedProvider.receive
Oracle.setPrices
Oracle._validatePrices
ChainlinkDataStreamProvider
The local Pyth Lazer verifier was the only external boundary simulated.
Its job in the test was narrow:
Expose the verification fee
Accept the valid local update payload
Receive the fee
Return parsed payload data
The access-control failure and provider-funded payment remained inside the real PythLazerFeedProvider.
The test suite completed with:
3 passing
The Chainlink provider exposed the inconsistency
The strongest comparison was already in the same repository.
ChainlinkDataStreamProvider protected its equivalent provider entrypoint with:
modifier onlyOracle() {
if (
msg.sender != oracle
) {
revert Errors.Unauthorized(
msg.sender,
"Oracle"
);
}
_;
}
and:
function getOraclePrice(
address token,
bytes memory data
)
external
onlyOracle
returns (
OracleUtils.ValidatedPrice memory
)
{
The PoC tested both providers:
Direct attacker call to ChainlinkDataStreamProvider
REJECTED
Direct attacker call to PythLazerFeedProvider
ACCEPTED
That comparison did more than suggest a possible fix.
It showed that the codebase already had the correct trust boundary for another oracle provider.
The Pyth Lazer implementation was the outlier.
The static-call control removed another false positive
The PoC also tested repeated callStatic invocations.
Those calls validated successfully but did not change the provider balance.
Then the attacker sent a real transaction using the same entrypoint.
The provider lost one fee.
The control result was:
callStatic
No balance change
Real transaction
Provider balance decreases
That matters because an oracle function can appear exploitable during simulation while no persistent value movement actually occurs.
Here, real transactions produced the drain.
Valid update data was a precondition, not an authorization control
The attacker needed valid update data for a configured feed.
That is an important limitation and should not be hidden.
But valid oracle data answers one question:
Is this update acceptable?
It does not answer another:
Who is allowed to make the protocol pay to verify it?
The attack did not depend on forged prices.
The attacker could use legitimate update material and repeatedly externalize the verification cost to the protocol.
Data validity and spending authorization were separate security boundaries.
Only the first one was enforced.
Why I submitted it as Medium
The proof demonstrated concrete economic and liveness impact:
Permissionless provider-funded verification
No protocol role required
Zero attacker msg.value
Deterministic depletion of operational ETH
Legitimate Oracle.setPrices failure after depletion
Configured token price updates unavailable through this provider until recovery
It did not demonstrate:
Direct theft of trader or LP funds
Oracle price forgery
Permanent corruption of oracle state
Irrecoverable protocol shutdown
Attacker capture of the drained ETH
The provider could be funded again.
That bounded the duration of the liveness failure.
For that reason, I submitted the issue as Medium: the attack could force the protocol to spend operational funds and disable a critical price-validation path, but the proof did not establish direct user-fund theft or an irreversible outage.
The HackenProof reward was $1.24.
The root cause in one table
| Property | Observed behavior |
|---|---|
| Native fee balance |
PythLazerFeedProvider accepts ETH |
| Unrestricted entrypoint |
getOraclePrice has no caller guard |
| Provider-funded verification |
verifyUpdate spends the provider balance |
| Attacker privileges | None |
| Attacker value | Zero msg.value
|
| Broken legitimate path | Oracle.setPrices |
Any one of those design choices can be reasonable in isolation.
The dangerous combination was:
A protocol-funded external call exposed through a permissionless entrypoint.
The missing invariant was simple:
Only the protocol Oracle should be able to trigger provider-funded Pyth Lazer verification.
Recommended fix
The cleanest fix is to mirror ChainlinkDataStreamProvider.
PythLazerFeedProvider should know the authorized Oracle:
address public immutable oracle;
and enforce:
modifier onlyOracle() {
if (
msg.sender != oracle
) {
revert Errors.Unauthorized(
msg.sender,
"Oracle"
);
}
_;
}
The provider entrypoint then becomes:
function getOraclePrice(
address token,
bytes memory data
)
external
onlyOracle
returns (
OracleUtils.ValidatedPrice memory
)
{
The constructor should receive the Oracle address:
constructor(
DataStore _dataStore,
address _oracle,
address pythLazerFeedVerifier
) {
dataStore =
_dataStore;
oracle =
_oracle;
pythLazer =
PythLazer(
pythLazerFeedVerifier
);
}
The deployment configuration must pass the Oracle contract address, and existing deployments would need to migrate to the corrected provider and update the configured provider for affected tokens.
Why the fix preserves the intended flow
The legitimate call already originates through Oracle:
Controller
↓
Oracle.setPrices
↓
Oracle._validatePrices
↓
PythLazerFeedProvider.getOraclePrice
After the fix:
Oracle.setPrices
ALLOWED
Oracle.setPricesForAtomicAction
ALLOWED through Oracle
Direct external provider call
REJECTED
Provider funding
UNCHANGED
Authorized Pyth Lazer verification
UNCHANGED
The patch does not remove provider-funded verification.
It restricts who can trigger that expenditure.
Regression coverage
The regression suite should permanently establish:
Direct attacker call to PythLazerFeedProvider.getOraclePrice
REVERTS
Oracle.setPrices with valid Pyth Lazer data
SUCCEEDS when provider is funded
Repeated unauthorized calls
CANNOT reduce provider balance
Authorized Oracle calls
CAN spend the configured verification fee
Direct Chainlink provider calls
CONTINUE to revert
It is also worth applying the same review rule to any future provider that pays an external service from protocol-owned funds.
The broader audit lesson
A function named getOraclePrice sounds like a read.
This one was a spend.
That distinction is easy to miss during a large protocol review.
The moment a function performs:
externalCall{
value: protocolFunds
}(...)
the audit needs to treat caller authorization as an economic security boundary.
The useful question is not only:
Can the returned data be trusted?
It is also:
Who controls how often this contract pays to obtain that data?
In this case, the answer was:
Any external account with valid update data
That was enough to turn the oracle fee balance into a permissionless griefing surface.
Conclusion
PythLazerFeedProvider held ETH to pay Pyth Lazer verification fees.
Its getOraclePrice function was externally callable and did not restrict the caller to the protocol Oracle.
Every successful call executed:
pythLazer.verifyUpdate{
value: fee
}(
data
);
using ETH from the provider itself.
The attacker sent zero ETH.
Five real transactions consumed the complete 0.05 ETH test balance.
The verifier received the same 0.05 ETH.
Before depletion, the legitimate Oracle.setPrices flow succeeded.
After depletion, that same path failed because the provider could no longer fund verification.
The finding was submitted as Medium through HackenProof and earned $1.24.

The engineering lesson is broader than this one provider:
If the protocol funds oracle verification, permission to request that verification is permission to spend protocol funds.
That permission should belong to the protocol’s Oracle path, not to every external account.
Top comments (0)