EIP-7702 Flashloan-Protection Bypass Case Research: When tx.origin == msg.sender Stopped Meaning EOA
Most exploits I trace are the protocol's fault in an obvious way — a missing check, a bad math path. This one is more unsettling, because the victim contract did everything the 2023 playbook told it to do. It had a flashloan guard. The guard was the industry-standard tx.origin == msg.sender "EOA-only" check. And it failed completely — not because it was coded wrong, but because Ethereum changed what an EOA is underneath it. Pectra shipped EIP-7702, and a one-line assumption that had protected thousands of contracts quietly became false.
This is a reconstruction from OpenZeppelin's incident analysis plus the on-chain transactions on BscScan. The dollar figure is small (~$85K) — I'm not writing this up for the size. I'm writing it because it's one of the first in-the-wild EIP-7702 exploits, and the bug class is going to recur across every contract still relying on that guard.
The takeaway I keep coming back to:
tx.origin == msg.senderwas never a flashloan guard. It was a proxy for "the caller has no code," and EIP-7702 severed that link. The check still passes; it just no longer means what every audit assumed.
Background: why anyone wrote tx.origin == msg.sender
Before Pectra, the EVM had a hard invariant: a transaction could only be initiated by an externally owned account, and tx.origin was always that EOA. So inside a function, tx.origin == msg.sender was true only if the immediate caller was the transaction's originating EOA — i.e., not a contract. Contracts can't originate transactions, so a contract calling you would have msg.sender == contractAddr while tx.origin == someEOA, and the check would fail.
That single comparison became the canonical "no contracts allowed" gate, and people leaned on it as a flashloan / reentrancy guard: flashloans require a contract in the call stack to receive the borrowed funds and run logic atomically. If you reject contract callers, you reject the atomic flashloan-and-act pattern, and an attacker can't borrow → manipulate → profit → repay in one transaction.
EIP-7702, introduced in the Pectra upgrade, lets an EOA set code on itself via a delegation designator. After a 7702 authorization, the account at an EOA address executes contract bytecode — but it is still an EOA for the purposes of tx.origin. So an account can now simultaneously: originate a transaction (tx.origin == self), be the msg.sender to a victim (msg.sender == self), and run arbitrary code. The "EOA-only" check passes, and the attacker has a contract anyway.
Target architecture: the POT staking contract
The victim (0x0aeb…feb53, unverified) on BSC was a staking contract for Wrapped POT (0xC14e…A888). You stake POT, it accrues time-based rewards in POT, and you unstake later. Two design choices set up the exploit:
- It values your stake in BSC-USD at stake time, using a spot price. The stake function (selector
0x93649277) reads the POT price from the PancakeSwap BSC-USD/POT pool (0x408e…cf36) viagetReserves()and records the BSC-USD value of your deposit. - It "protects" that spot read with the EOA-only check. Conceptually:
// Victim stake() — selector 0x93649277 (decompiled shape)
function stake(uint256 potAmount) external {
// "flashloan guard": reject contract callers
require(tx.origin == msg.sender, "no contracts");
// spot price straight from a Uniswap-V2-style pool — no TWAP
(uint112 r0, uint112 r1, ) = pancakePair.getReserves();
uint256 potPriceInBusd = quote(r0, r1); // manipulable
// record the BSC-USD value of the staked POT at THIS price
positions[msg.sender] += potAmount * potPriceInBusd;
// ...later, unstake pays out POT worth this stored BSC-USD value...
}
The logic is internally coherent: a flashloan can't be used to spike the PancakeSwap price because the guard rejects the contract you'd need to orchestrate the flashloan. Pre-Pectra, this is a defensible (if dated) pattern. Post-Pectra, the first line is a no-op against a prepared attacker.
There's also a third detail the attacker had to handle: a 1-day delay between stake and unstake. More on how they sidestepped it without waiting.
Root cause: the guard checks the wrong property
The bug is conceptual, so let me state it precisely. The contract wanted to assert: "no contract code runs in this call." What it actually asserted: "the immediate caller equals the transaction originator." Pre-7702 those were equivalent. Post-7702 they are not, because a 7702-delegated account is both the originator and a code-bearing account.
So the attacker's job reduces to: get the malicious logic to execute as the EOA itself, so that when the victim reads msg.sender and tx.origin, they're the same address — and that address is the attacker's delegated account running attacker code.
Attack chain: step by step
All on BSC. Attacker contracts/txs below.
Step 0 — Delegate code onto the EOA. The attacker deploys a malicious implementation (0x3f94…8dEd) and issues a 7702-type authorization (0xd0b5…e993) pointing their EOA's code at it. From now on, calling the EOA runs that contract's logic — including its fallback.
Step 1 — Wake the delegated code via a self-transfer. The attacker sends ~13.9 BNB to itself. Because the destination (the EOA) now has code, a plain native-token transfer triggers the delegated contract's fallback(). This is the clever part: the whole attack runs inside that fallback, executing as the EOA, so every downstream msg.sender/tx.origin the victim sees is the same EOA address.
// Attacker's delegated implementation (conceptual)
fallback() external payable {
// executing AS the EOA: tx.origin == msg.sender == address(this)
uint256 borrowed = flashLoan(BUSD, 3_500_000e18); // step 2
buyPOT(pancakePair, borrowed); // step 3: spike POT price
victim.call(abi.encodeWithSelector(0x93649277, 220_000e18)); // step 4: stake at inflated price
sellPOT(pancakePair, /* remaining POT */); // step 5: reset price
repay(BUSD, borrowed + fee); // repay flashloan
}
Step 2 — Flash loan. Inside the fallback, borrow ~$3.5M BSC-USD. Note this is exactly the move the EOA-only guard was supposed to prevent — but the guard never fires, because the call into the victim will come from the EOA.
Step 3 — Inflate the oracle. Buy ~$3.5M of POT from the PancakeSwap BSC-USD/POT pool, pushing POT's spot price up sharply. Because the victim reads getReserves() with no TWAP, the manipulated price is the price.
Step 4 — Stake at the lie. Call stake (0x93649277) for ~220k POT. The guard's tx.origin == msg.sender passes (both are the delegated EOA). The contract reads the inflated POT price and records a hugely overstated BSC-USD value for the position.
Step 5 — Reset and repay. Sell the POT back to the pool to restore the price to ~its original level, and repay the flashloan. The ~13.9 BNB sent in step 1 is swapped to BSC-USD to fill the gap left by the 220k POT that stayed locked in the stake.
Step 6 — Beat the 1-day delay with a timestamp seam. The contract enforces ~1 day between stake and unstake. Instead of waiting, the attacker timed the stake tx at 2025-08-24 23:59:06 UTC and the unstake tx at 2025-08-25 00:00:14 UTC — 68 seconds apart, but straddling whatever day boundary the contract's delay check used. The "1-day" guard was satisfied on paper.
Step 7 — Unstake the inflated position. The unstake path, again invoked through the attacker's own delegated EOA (via a withdraw → unstake hop), paid out based on the stored (inflated) BSC-USD value while POT was back at its normal price. Result: ~3.3M POT out against ~220k POT in. Swapped to BSC-USD, net profit ~$85K.
The whole thing is two transactions a minute apart, and the only "novel" ingredient is Step 0–1. Everything after that is a textbook spot-oracle manipulation — the kind the EOA-only guard was specifically meant to make impossible.
Transaction trail
- 7702 delegation —
0xd0b5…e993 - Stake (price inflated) —
0x8a7c…f36f@ 2025-08-24 23:59:06 UTC - Unstake (3.3M POT out) —
0x089e…44fc@ 2025-08-25 00:00:14 UTC - Malicious delegated impl —
0x3f94…8dEd - Victim staking contract —
0x0aeb…feb53 - POT token —
0xC14e…A888 - PancakeSwap BSC-USD/POT pair —
0x408e…cf36
There was no laundering theater here — small haul, profit swapped to BSC-USD on-chain. The forensic value is the technique, not the money.
Full kill chain
- May 2025 (Pectra) — EIP-7702 activates on Ethereum mainnet; BNB Chain ships compatible support. The
tx.origin == msg.senderinvariant is now breakable, but most deployed contracts still rely on it. - Aug 24, 2025 — Attacker deploys delegated impl
0x3f94…8dEd, authorizes it onto their EOA (0xd0b5…e993). - Aug 24, 23:59:06 UTC — Self-send ~13.9 BNB to trigger fallback → flashloan $3.5M BSC-USD → buy POT to spike price →
stake220k POT at inflated value → sell POT, repay (0x8a7c…f36f). - Aug 25, 00:00:14 UTC —
unstakevia own delegated EOA → 3.3M POT out → swap to BSC-USD. ~$85K profit (0x089e…44fc). - Sep 17, 2025 — OpenZeppelin publishes the analysis, flagging it as one of the first 7702-based exploits of the EOA-only assumption.
The asymmetry worth noting: this needed no protocol bug in the traditional sense and no privileged access — just an EVM semantics change that retroactively invalidated a guard the victim had every reason to trust.
What I'd actually change (operator/auditor view)
This is the rare case where the lesson generalizes far beyond the victim:
- Stop using
tx.origin == msg.senderas a contract/flashloan guard. Full stop. It is now unsound. If you need "no code at this address," the only forward-compatible check isaddress(account).code.length == 0and checking for a 7702 delegation designator (the0xef0100prefix). Even then, treat it as best-effort, not a security boundary. - Don't gate on caller-type; gate on the thing you actually fear. The real risk was a manipulable spot price. Fix that: use a TWAP or a manipulation-resistant oracle, so a single-block price spike can't be staked against — regardless of who the caller is.
- Re-audit every contract that shipped before Pectra. Grep your whole portfolio for
tx.origin == msg.sender(andtx.origin != msg.sender). Each hit is a possible silent regression introduced by an EVM upgrade, not by your code. - Don't trust "delay" checks that compare wall-clock day boundaries. The 68-second stake→unstake straddle shows a "1-day delay" implemented against a coarse boundary is not a 1-day delay. Use elapsed-time deltas (
block.timestamp - stakedAt >= 1 days), not calendar comparisons. - Assume EIP-7702 means every EOA can be a contract. Any invariant that depended on "EOAs can't run code mid-call" — reentrancy assumptions, allowlists keyed on
extcodesize, signature flows — needs re-examination under delegated accounts.
Confidence notes
- Firm: the mechanism (7702 delegation → fallback-driven flashloan → PancakeSwap spot manipulation → inflated stake → oversized unstake) and all transaction hashes/addresses are from OpenZeppelin's analysis and verifiable on BscScan.
- Firm: the timestamps (23:59:06 → 00:00:14) and the ~$3.5M flashloan, ~220k POT staked, ~3.3M POT withdrawn, ~$85K profit are quoted from the same source.
- Representative, not verbatim: the victim contract is unverified, so the Solidity above reconstructs the documented behavior (guard + spot read + stored BSC-USD value) rather than reproducing exact source. The selector
0x93649277and thegetReserves()price path are confirmed; field names are inferred. - Class, not magnitude: the importance here is the EIP-7702 bug class. Expect higher-value repeats wherever
tx.origin == msg.senderstill backstops a manipulable action.
Sources
- OpenZeppelin — The Notorious Bug Digest #5 (EIP-7702 delegated-EOA flashloan-protection bypass, full code analysis):
https://www.openzeppelin.com/news/the-notorious-bug-digest-5 - EIP-7702 — Set EOA account code (Pectra):
https://eips.ethereum.org/EIPS/eip-7702 - BscScan — stake tx:
https://bscscan.com/tx/0x8a7c96521ac64fc33d8d8ceecdea9c1da9c72148c4399905c38a07ee47c3f36f - BscScan — unstake tx:
https://bscscan.com/tx/0x089e37fc8d51a16e4cf1865a5c2ad75ea0c06e50f3e43beb7368706f852f44fc - BscScan — malicious delegated implementation:
https://bscscan.com/address/0x3f94E8d115FB7a27ac6b8CfeaacfFe94652E8dEd#code
