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,
EndpointV2stores its hash, and an executor later callslzReceiveso the adapter releases the same amount on Ethereum. - Attack flow: the Ethereum endpoint already had a stored hash for nonce
308without supporting source-side evidence in the collected artifact set, so the attacker only needed to calllzReceivewith the matching payload to unlock adapter inventory.
Attack Execution
High-Level Flow
- A forged or otherwise invalid LayerZero packet for the Unichain route was already staged as verified for nonce
308before the drain transaction began. - The attacker called the public LayerZero
lzReceiveentrypoint on Ethereum and supplied the matching origin tuple,guid, and OFT payload. EndpointV2accepted the payload as the queued packet for nonce308, cleared it, and forwarded the message to KelpDAO’s rsETH OFT adapter.- 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. - The adapter transferred
116,500 rsETHfrom 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
- Function:
- Depth 1
CALL:0x1a44076050125825900e736c501f859c50fe728c->0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3- Function:
lzReceive((uint32,bytes32,uint64),bytes32,bytes,address,bytes)(0x13137d65) - Executor passed to adapter:
0x4966260619701a80637cdbdac6a6ce0131f8575e - The 40-byte
_messagedecodes to recipient0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3bplus shared-decimal amount0x0000001b1ff0ed00 = 116500000000 - ETH value:
0
- Function:
- Depth 2
CALL:0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3->0xa1290d69c65a6fe4df752f95823fae25cb99e5a7- Function:
transfer(address,uint256)(0xa9059cbb) - Arguments: recipient
0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3b, amount116500000000000000000000 - ETH value:
0
- Function:
- Depth 3
DELEGATECALL:0xa1290d69c65a6fe4df752f95823fae25cb99e5a7->0x7159107483e623707c18c6e06cbc095bd0717783- Function:
transfer(address,uint256)(0xa9059cbb) - Standard proxy delegation into the rsETH implementation
- ETH value:
0
- Function:
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-chainPacketSent. - 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/PacketDeliveredfor nonce jumps, outsized amounts, or executions without a matching source-side packet, and pause the route when anomalies appear.
Evidence
tx.json: top-level caller0x4966260619701a80637cdbdac6a6ce0131f8575edirectly invokedEndpointV2.lzReceiveon0x1a44076050125825900e736c501f859c50fe728c.receipt.json:status = 0x1.receipt.jsonlog0xa6:Transfer(address,address,uint256)topic0xddf252ad...from adapter0x85d456b2dff1fd8245387c0bfb64dfb700e98ef3to0x8b1b6c9a6db1304000412dd21ae6a70a82d60d3bfor0x18ab7a47948bcfd00000 = 116500000000000000000000.receipt.jsonlog0xa7:OFTReceived(bytes32,uint32,address,uint256)topic0xefed6d35..., proving the adapter processed the inbound packet and emitted the receive event forsrcEid = 0x7670 = 30320.receipt.jsonlog0xa8:PacketDelivered((uint32,bytes32,uint64),address)topic0x3cd5e48f..., provingEndpointV2considered the packet successfully delivered.trace_prestateTracer.json: oneEndpointV2storage slot advanced from0x133to0x134during execution, consistent with the route’s lazy inbound nonce moving from307to308.OFTMsgCodec.amountSD()reads bytes[32:40]as the shared-decimal amount, andOFTCore._toLD()multiplies that value bydecimalConversionRate = 10 ** (18 - 6) = 1e12; the message tail0x0000001b1ff0ed00therefore becomes116500000000 * 1e12 = 116500000000000000000000, matching the transfer amount exactly.