LayerZero Endpoint V2 - Sui
Findings & Analysis Report
2025-11-05
Table of contents
Overview
About C4
Code4rena (C4) is a competitive audit platform where security researchers, referred to as Wardens, review, audit, and analyze codebases for security vulnerabilities in exchange for bounties provided by sponsoring projects.
During the audit outlined in this document, C4 conducted an analysis of the LayerZero Endpoint V2 - Sui smart contract system. The audit took place from September 17 to September 30, 2025.
Final report assembled by Code4rena.
Summary
The C4 analysis yielded an aggregated total of 0 High and Medium vulnerabilities. Additionally, C4 analysis included 2 reports detailing issues with a risk rating of LOW severity or non-critical.
All of the issues presented here are linked back to their original finding, which may include relevant context from the judge and LayerZero team.
Scope
The code under review can be found within the C4 LayerZero Endpoint V2 - Sui repository.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.
Low Risk and Non-Critical Issues
For this audit, 2 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by Rhaydden received the top score from the judge.
The following wardens also submitted reports: K42.
[01] OApp can set DEFAULT_BUILDER without a system default, leading to aborts (DoS) in PTB construction
set_msglib_ptb_builder function permits an OApp to set its PTB builder to DEFAULT_BUILDER for a message library without validating that a default PTB builder exists for that library. This can result in runtime failures during PTB construction, as get_effective_msglib_ptb_builder will attempt to retrieve a non-existent default and abort with EBuilderNotFound.
// set_msglib_ptb_builder (original behavior)
public fun set_msglib_ptb_builder(
self: &mut EndpointPtbBuilder,
caller: &CallCap,
endpoint: &EndpointV2,
oapp: address,
message_lib: address,
ptb_builder: address,
) {
assert!(caller.id() == oapp || caller.id() == endpoint.get_delegate(oapp), EUnauthorized);
// NO CHECK when ptb_builder == DEFAULT_BUILDER (bug)
if (ptb_builder != DEFAULT_BUILDER) {
self.registry.assert_msglib_ptb_builder_supported(message_lib, ptb_builder);
};
table_ext::upsert!(&mut self.oapp_configs, OAppConfigKey { oapp, lib: message_lib }, ptb_builder);
event::emit(MsglibPtbBuilderSetEvent { oapp, message_lib, ptb_builder });
}
// get_effective_msglib_ptb_builder -> falls back to default
public fun get_effective_msglib_ptb_builder(self: &EndpointPtbBuilder, oapp: address, lib: address): address {
let builder = *table_ext::borrow_with_default!(&self.oapp_configs, OAppConfigKey { oapp, lib }, &DEFAULT_BUILDER);
if (builder != DEFAULT_BUILDER) { builder } else { self.get_default_msglib_ptb_builder(lib) }
}
// get_default_msglib_ptb_builder -> aborts if default missing
public fun get_default_msglib_ptb_builder(self: &EndpointPtbBuilder, lib: address): address {
*table_ext::borrow_or_abort!(&self.default_configs, lib, EBuilderNotFound)
}
This prevents invalid configurations that could brick PTB building for the OApp.
Proof of Concept
- Added a simple test that sets an OApp override to
DEFAULT_BUILDERwithout a system default, then attempts to build a PTB and expectsEBuilderNotFound. Attach the test toendpoint_ptb_builder_tests.move:
#[test]
#[expected_failure(abort_code = endpoint_ptb_builder::EBuilderNotFound)]
fun test_set_msglib_ptb_builder_to_default_without_system_default_causes_stop() {
let (scenario, admin_cap, endpoint_admin_cap, mut endpoint_ptb_builder, endpoint, oapp_cap) = setup();
// Do not set any default PTB builder for MESSAGE_LIB_ADDRESS
let oapp_address = oapp_cap.id();
// OApp explicitly sets its override to DEFAULT_BUILDER
endpoint_ptb_builder.set_msglib_ptb_builder(
&oapp_cap,
&endpoint,
oapp_address,
MESSAGE_LIB_ADDRESS,
@0x0,
);
// Building a PTB should now abort because the system default is missing
endpoint_ptb_builder.build_quote_ptb(&endpoint, oapp_address, EID);
clean(scenario, admin_cap, endpoint_admin_cap, endpoint_ptb_builder, endpoint, oapp_cap);
}
Logs
[ PASS ] endpoint_ptb_builder::endpoint_ptb_builder_tests::test_set_msglib_ptb_builder_to_default_without_system_default_causes_stop
Test result: OK. Total tests: 29; passed: 29; failed: 0
Recommended Fix
Add a guard in set_msglib_ptb_builder() to ensure a protocol default exists when an OApp sets DEFAULT_BUILDER; otherwise, validate non-default builders as before.
[02] Comment in burn() about which packets can be burned is misleading
In messaging_channel.move, the burn() comment says “Can only be called on packets that have been verified and not executed yet,” but the precondition targets packets at or below the cleared boundary (lazy_inbound_nonce) that still have a stored hash.
Channel indicates lazy_inbound_nonce is the cleared/executed boundary:
// Highest nonce that has been cleared (executed)
lazy_inbound_nonce: u64,
burn() only permits nonce <= lazy_inbound_nonce and requires a stored hash:
/// Permanently removes a packet, making it unexecutable.
/// Can only be called on packets that have been verified and not executed yet.
public(package) fun burn(...){
let channel = self.channel_mut(src_eid, sender);
assert!(nonce <= channel.lazy_inbound_nonce && channel.inbound_payload_hashes.contains(nonce), EInvalidNonce);
...
}
clear_payload() advances the boundary and removes only the delivered nonce’s hash, intentionally leaving earlier verified hashes for later cleanup (e.g., burn()):
if (nonce > current_nonce) {
let mut i = current_nonce + 1;
while (i <= nonce) {
assert!(channel.inbound_payload_hashes.contains(i), EInvalidNonce);
i = i + 1
};
channel.lazy_inbound_nonce = nonce;
};
let expected_hash = table_ext::try_remove!(&mut channel.inbound_payload_hashes, nonce);
Recommended Fix
Update the burn() comment to reflect intent.
[03] Doc fix
The docs state that clearing happens after execution, but the endpoint clears before creating the call.
-
/// **Flow:** /// 1. OApp calls send_compose() during or after lz_receive to queue a compose message /// 2. Message hash is stored in the composer's queue /// 3. Executor calls lz_compose() to process the queued message /// 4. clear_compose() marks the message as delivered and prevents re-execution ... /// Clears a compose message after successful execution. public(package) fun clear_compose(...) { ... }
But actually, in endpoint_v2.move:
public fun lz_compose(...): Call<LzComposeParam, Void> {
compose_queue.clear_compose(from, guid, index, message); // cleared BEFORE call creation
let param = lz_compose::create_param(...);
call::create(&self.call_cap, compose_queue.composer(), true, param, ctx)
}
This can mislead anyone into thinking clearing happens post execution, while it actually happens pre-call to enforce exactly - once call creation.
Recommended Fix
Update docs in messaging_composer.move by changing flow step 4 to: “Endpoint clears the compose before creating the lz_compose Call to prevent duplicate Call creation; the entry remains with 0xff as a delivered marker.”
LayerZero commented:
- Acknowledged. This is not a security issue, and adding the guard as you suggest wouldn’t change the impact — the OApp still can’t use it until LayerZero sets the default
- Acknowledged.
- Acknowledged.
Disclosures
C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.