DEV Community

Daniel
Daniel

Posted on

How Permissionless Pyth Lazer Calls Could Drain 0xMarkets’ Oracle Fee Balance and Break Price Updates

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
Enter fullscreen mode Exit fullscreen mode

while the provider paid the verification fee from its own native balance.

In my proof of concept, the provider held:

0.05 ETH
Enter fullscreen mode Exit fullscreen mode

and the local verification fee was:

0.01 ETH
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
        );
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

and then executed:

pythLazer.verifyUpdate{
    value: fee
}(
    data
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It was:

Anyone can request an oracle validation
and make the protocol pay for it
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

During _validatePrices, the Oracle calls the configured provider:

OracleUtils.ValidatedPrice memory validatedPrice =
    IOracleProvider(
        provider
    ).getOraclePrice(
        token,
        data
    );
Enter fullscreen mode Exit fullscreen mode

So the legitimate architecture looked like this:

Controller
    ↓
Oracle.setPrices
    ↓
Oracle._validatePrices
    ↓
PythLazerFeedProvider.getOraclePrice
    ↓
pythLazer.verifyUpdate
    ↓
Validated price
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The configured fee was:

Verification fee
0.010000000000000000 ETH
Enter fullscreen mode Exit fullscreen mode

The attacker executed:

Direct transactions
5

msg.value per transaction
0 ETH
Enter fullscreen mode Exit fullscreen mode

Afterward:

Provider balance
0 ETH

Provider loss
0.05 ETH

Verifier gain
0.05 ETH
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The access-control failure and provider-funded payment remained inside the real PythLazerFeedProvider.

The test suite completed with:

3 passing
Enter fullscreen mode Exit fullscreen mode

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"
        );
    }

    _;
}
Enter fullscreen mode Exit fullscreen mode

and:

function getOraclePrice(
    address token,
    bytes memory data
)
    external
    onlyOracle
    returns (
        OracleUtils.ValidatedPrice memory
    )
{
Enter fullscreen mode Exit fullscreen mode

The PoC tested both providers:

Direct attacker call to ChainlinkDataStreamProvider
REJECTED

Direct attacker call to PythLazerFeedProvider
ACCEPTED
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

It does not answer another:

Who is allowed to make the protocol pay to verify it?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

and enforce:

modifier onlyOracle() {
    if (
        msg.sender != oracle
    ) {
        revert Errors.Unauthorized(
            msg.sender,
            "Oracle"
        );
    }

    _;
}
Enter fullscreen mode Exit fullscreen mode

The provider entrypoint then becomes:

function getOraclePrice(
    address token,
    bytes memory data
)
    external
    onlyOracle
    returns (
        OracleUtils.ValidatedPrice memory
    )
{
Enter fullscreen mode Exit fullscreen mode

The constructor should receive the Oracle address:

constructor(
    DataStore _dataStore,
    address _oracle,
    address pythLazerFeedVerifier
) {
    dataStore =
        _dataStore;

    oracle =
        _oracle;

    pythLazer =
        PythLazer(
            pythLazerFeedVerifier
        );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After the fix:

Oracle.setPrices
ALLOWED

Oracle.setPricesForAtomicAction
ALLOWED through Oracle

Direct external provider call
REJECTED

Provider funding
UNCHANGED

Authorized Pyth Lazer verification
UNCHANGED
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}(...)
Enter fullscreen mode Exit fullscreen mode

the audit needs to treat caller authorization as an economic security boundary.

The useful question is not only:

Can the returned data be trusted?
Enter fullscreen mode Exit fullscreen mode

It is also:

Who controls how often this contract pays to obtain that data?
Enter fullscreen mode Exit fullscreen mode

In this case, the answer was:

Any external account with valid update data
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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)