Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

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 t

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

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.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.