KelpDAO rsETH LayerZero Packet Drain

On Ethereum at 2026-04-18T17:35:35Z, transaction 0x1ae232da212c45f35c1525f851e4c41d529bf18af862d9ce9fd40bf709db4222 executed a LayerZero V2 inbound packet against KelpDAO’s rsETH OFT adapter and released 116,500 rsETH to 0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b. The exploit class is best described as an access_control failure in cross-chain message authorization: once the Ethereum endpoint believed nonce 308 from source endpoint 30320 had been verified, the drain transaction could permissionlessly execute it. The collected artifacts prove the Ethereum-side execution path and the exact token movement; they do not include the upstream verify / commitVerification transaction that originally made nonce 308 executable, so the specific off-chain DVN failure remains an evidence gap. Financial impact from the collected artifacts is exactly 116,500 rsETH, roughly $290M using the incident brief’s incident-time estimate.

Root Cause

Vulnerable Contract

The drained asset holder was KelpDAO’s verified RSETH_OFTAdapter at 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3. It is not a proxy and inherits the standard LayerZero OFTAdapter receive path. Its trusted inbound endpoint is LayerZero EndpointV2 at 0x1a44076050125825900e736c501f859c50fe728c, and the configured peer for srcEid = 30320 was already set to 0x000000000000000000000000c3eacf0612346366db554c991d7858716db09f58 per the collected plan metadata.

Vulnerable Function

The critical authorization gate is EndpointV2.verify((uint32,bytes32,uint64),address,bytes32) (0xa825d747) in contracts/EndpointV2.sol; the drain transaction then exercised EndpointV2.lzReceive((uint32,bytes32,uint64),address,bytes32,bytes,bytes) (0x0c0c389e). On the Kelp side, OAppReceiver.lzReceive((uint32,bytes32,uint64),bytes32,bytes,address,bytes) (0x13137d65) and OFTAdapter._credit(address,uint256,uint32) turned that verified packet into a token transfer.

Vulnerable Code

// contracts/EndpointV2.sol
function verify(Origin calldata _origin, address _receiver, bytes32 _payloadHash) external {
    if (!isValidReceiveLibrary(_receiver, _origin.srcEid, msg.sender)) revert Errors.LZ_InvalidReceiveLibrary();

    uint64 lazyNonce = lazyInboundNonce[_receiver][_origin.srcEid][_origin.sender];
    if (!_initializable(_origin, _receiver, lazyNonce)) revert Errors.LZ_PathNotInitializable();
    if (!_verifiable(_origin, _receiver, lazyNonce)) revert Errors.LZ_PathNotVerifiable();

    _inbound(_receiver, _origin.srcEid, _origin.sender, _origin.nonce, _payloadHash); // <-- VULNERABILITY: once this route accepts a forged payload hash, Ethereum stores it as executable state for the adapter
    emit PacketVerified(_origin, _receiver, _payloadHash);
}

function lzReceive(
    Origin calldata _origin,
    address _receiver,
    bytes32 _guid,
    bytes calldata _message,
    bytes calldata _extraData
) external payable {
    _clearPayload(_receiver, _origin.srcEid, _origin.sender, _origin.nonce, abi.encodePacked(_guid, _message));
    ILayerZeroReceiver(_receiver).lzReceive{ value: msg.value }(_origin, _guid, _message, msg.sender, _extraData); // <-- VULNERABILITY: execution is permissionless once the payload hash exists
    emit PacketDelivered(_origin, _receiver);
}

// contracts/oapp/OAppReceiver.sol
function lzReceive(
    Origin calldata _origin,
    bytes32 _guid,
    bytes calldata _message,
    address _executor,
    bytes calldata _extraData
) public payable virtual {
    if (address(endpoint) != msg.sender) revert OnlyEndpoint(msg.sender);
    if (_getPeerOrRevert(_origin.srcEid) != _origin.sender) revert OnlyPeer(_origin.srcEid, _origin.sender);
    _lzReceive(_origin, _guid, _message, _executor, _extraData); // <-- VULNERABILITY: no second proof of a real source-chain send is required here
}

// contracts/oft/OFTAdapter.sol
function _credit(
    address _to,
    uint256 _amountLD,
    uint32 /*_srcEid*/
) internal virtual override returns (uint256 amountReceivedLD) {
    innerToken.safeTransfer(_to, _amountLD); // <-- VULNERABILITY: adapter inventory is released solely from the verified packet contents
    return _amountLD;
}

Why It’s Vulnerable

Expected behavior: a packet that unlocks rsETH on Ethereum should only become executable after the route has authenticated a real source-chain send for the same peer, nonce, recipient, and amount. In practice that means the verification path needs enough trust separation that a forged attestation cannot stage arbitrary payloads inside EndpointV2, and the adapter should ideally have a local sanity bound on how much inventory a single message can release.

Actual behavior: by the time this transaction started, EndpointV2 already held an executable payload for {receiver = 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3, srcEid = 30320, sender = 0x000000000000000000000000c3eacf0612346366db554c991d7858716db09f58, nonce = 308}. The drain transaction supplied the matching guid and 40-byte OFT message, EndpointV2.lzReceive() cleared the stored hash, OAppReceiver.lzReceive() only re-checked msg.sender == endpoint and origin.sender == peers[30320], and OFTAdapter._credit() immediately transferred the amount encoded in the packet.

Why this matters: once a forged verification lands, any executor can call the public endpoint and force token release. The Ethereum-side contracts do not re-check a source-chain PacketSent, source-side outboundNonce, or remote supply invariant during execution. The collected artifacts therefore support this root cause statement: the on-chain drain was enabled by a compromised or otherwise invalid cross-chain authorization step that made nonce 308 executable, after which the adapter behaved as designed and released 116,500 rsETH.

Normal flow vs attack flow:

  • Normal flow: a legitimate source-chain OFT send emits a packet, the receive library verifies that packet, EndpointV2 stores its hash, and an executor later calls lzReceive so the adapter releases the same amount on Ethereum.
  • Attack flow: the Ethereum endpoint already had a stored hash for nonce 308 without supporting source-side evidence in the collected artifact set, so the attacker only needed to call lzReceive with the matching payload to unlock adapter inventory.

Attack Execution

High-Level Flow

  1. A forged or otherwise invalid LayerZero packet for the Unichain route was already staged as verified for nonce 308 before the drain transaction began.
  2. The attacker called the public LayerZero lzReceive entrypoint on Ethereum and supplied the matching origin tuple, guid, and OFT payload.
  3. EndpointV2 accepted the payload as the queued packet for nonce 308, cleared it, and forwarded the message to KelpDAO’s rsETH OFT adapter.
  4. The adapter validated that the message came from the configured peer on srcEid = 30320, decoded the recipient and amount from the OFT payload, and treated the packet as authoritative.
  5. The adapter transferred 116,500 rsETH from its own inventory to the payload recipient, completing the drain without any swaps, flash loans, or reentrant callbacks.

Detailed Call Trace

  • Depth 0 CALL: 0x4966260619701a80637cdbdac6a6ce0131f8575e -> 0x1a44076050125825900e736c501f859c50fe728c
    • Function: lzReceive((uint32,bytes32,uint64),address,bytes32,bytes,bytes) (0x0c0c389e)
    • Origin: srcEid = 30320, sender = 0x000000000000000000000000c3eacf0612346366db554c991d7858716db09f58, nonce = 308
    • Receiver: 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3
    • ETH value: 0
  • Depth 1 CALL: 0x1a44076050125825900e736c501f859c50fe728c -> 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3
    • Function: lzReceive((uint32,bytes32,uint64),bytes32,bytes,address,bytes) (0x13137d65)
    • Executor passed to adapter: 0x4966260619701a80637cdbdac6a6ce0131f8575e
    • The 40-byte _message decodes to recipient 0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b plus shared-decimal amount 0x0000001b1ff0ed00 = 116500000000
    • ETH value: 0
  • Depth 2 CALL: 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 -> 0xa1290d69c65a6fe4df752f95823fae25cb99e5a7
    • Function: transfer(address,uint256) (0xa9059cbb)
    • Arguments: recipient 0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b, amount 116500000000000000000000
    • ETH value: 0
  • Depth 3 DELEGATECALL: 0xa1290d69c65a6fe4df752f95823fae25cb99e5a7 -> 0x7159107483e623707c18c6e06cbc095bd0717783
    • Function: transfer(address,uint256) (0xa9059cbb)
    • Standard proxy delegation into the rsETH implementation
    • ETH value: 0

Financial Impact

funds_flow.json shows one token movement and no other asset flows: 116,500 rsETH left the adapter 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 and arrived at 0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b. The raw amount was 116500000000000000000000, which matches the amount passed through the trace and the Transfer log in the receipt.

The collected artifacts do not contain a price snapshot, so the exact USD loss cannot be derived locally. Using the incident brief’s public-alert figure, the drain was roughly $290M. The beneficiary address is embedded directly in the forged OFT payload, but funds_flow.json does not label downstream addresses as attacker-controlled, so the dataset can prove reserve depletion and immediate receipt, not final profit realization.

The immediate loser was the adapter-held rsETH inventory backing the route. Because the transaction contained no flash loans, swaps, or other liabilities, execution cost was only the transaction gas (94,456 gas at 2,174,665,059 wei, about 0.00020541 ETH) and is immaterial relative to the drained amount. The bridge-side reserve pool was directly depleted by 116,500 rsETH, leaving any remote representation that depended on that inventory undercollateralized until replenished.

Mitigations

  • Remove single-signer or single-DVN trust from the affected route. A packet that can unlock adapter inventory should require independent quorum, not one successful verifier path.
  • Immediately nilify, burn, or otherwise invalidate any queued payloads for the affected (receiver, srcEid, sender) route that cannot be matched to a source-chain PacketSent.
  • Add route-level release caps or inventory sanity checks in the adapter layer so one inbound packet cannot unlock an implausibly large amount relative to observed bridged supply.
  • Monitor PacketVerified / PacketDelivered for nonce jumps, outsized amounts, or executions without a matching source-side packet, and pause the route when anomalies appear.

Evidence

  • tx.json: top-level caller 0x4966260619701a80637cdbdac6a6ce0131f8575e directly invoked EndpointV2.lzReceive on 0x1a44076050125825900e736c501f859c50fe728c.
  • receipt.json: status = 0x1.
  • receipt.json log 0xa6: Transfer(address,address,uint256) topic 0xddf252ad... from adapter 0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3 to 0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b for 0x18ab7a47948bcfd00000 = 116500000000000000000000.
  • receipt.json log 0xa7: OFTReceived(bytes32,uint32,address,uint256) topic 0xefed6d35..., proving the adapter processed the inbound packet and emitted the receive event for srcEid = 0x7670 = 30320.
  • receipt.json log 0xa8: PacketDelivered((uint32,bytes32,uint64),address) topic 0x3cd5e48f..., proving EndpointV2 considered the packet successfully delivered.
  • trace_prestateTracer.json: one EndpointV2 storage slot advanced from 0x133 to 0x134 during execution, consistent with the route’s lazy inbound nonce moving from 307 to 308.
  • OFTMsgCodec.amountSD() reads bytes [32:40] as the shared-decimal amount, and OFTCore._toLD() multiplies that value by decimalConversionRate = 10 ** (18 - 6) = 1e12; the message tail 0x0000001b1ff0ed00 therefore becomes 116500000000 * 1e12 = 116500000000000000000000, matching the transfer amount exactly.