Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ This repository is a component of [genvm-manager]; the canonical security policy

## Reporting a vulnerability

**Do not open a public issue.** Report privately via GitHub's
[private vulnerability reporting](https://github.com/genlayerlabs/genvm-executor/security/advisories/new),
or email code owners, kira@genlayerlabs.com for instance
**Before mainnet, report everything except remote code execution publicly** — open a
regular issue. Until there is value at stake, an open report gets triaged faster and is
useful to everyone reading along. RCE is the only exception; report it privately.

For remote code execution, **do not open a public issue** — report it via GitHub's
[private vulnerability reporting](https://github.com/genlayerlabs/genvm-manager/security/advisories/new)
on the [genvm-manager] repository.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Include a description, affected component/version, and a reproduction (a contract, calldata,
or test case) where possible. We aim to acknowledge within a few business days.
Expand All @@ -35,8 +39,14 @@ Issues are triaged by impact, highest first:
The following relationships are trusted. Hardening them is welcome, but a report that assumes
one side is hostile is not treated as a vulnerability:

- host and GenVM
- executor and manager
- the local disk and loopback in general
- Host and GenVM
- Executor and manager
- The local disk and loopback in general

The following inputs are untrusted, even when delivered through a trusted component:

- Intelligent Contract code and contract-controlled data, including calldata, messages, and persisted values
- Data originating from other validators, including leader results
- External content processed by modules, including HTTP responses, redirects, rendered pages, JavaScript, subresources, and model-provider responses

[genvm-manager]: https://github.com/genlayerlabs/genvm-manager
5 changes: 4 additions & 1 deletion executor/codegen/data/internal-constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
"runner_load_cost": 4096,
"vm_spawn_cost": 134217728,
"new_storage_page": 256,
"storage_page_inherited": 128
"storage_page_inherited": 128,
"execution_emission_base_size": 256,
"message_fee_rotation_element_size": 32,
"nondet_output_base_size": 32
}
},
{
Expand Down
3 changes: 3 additions & 0 deletions executor/crates/common/src/internal_constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ pub mod memory_limiter_consts {
pub const VM_SPAWN_COST: u32 = 134217728;
pub const NEW_STORAGE_PAGE: u32 = 256;
pub const STORAGE_PAGE_INHERITED: u32 = 128;
pub const EXECUTION_EMISSION_BASE_SIZE: u32 = 256;
pub const MESSAGE_FEE_ROTATION_ELEMENT_SIZE: u32 = 32;
pub const NONDET_OUTPUT_BASE_SIZE: u32 = 32;
}

pub mod top_limits {
Expand Down
52 changes: 41 additions & 11 deletions executor/src/rt/fees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,28 @@ impl DataLimit {

async fn consume_bucket_raw(&self, bucket: &Bucket, costs: &[primitive_types::U256]) -> bool {
let mut buckets = self.buckets.lock().await;
if !Self::bucket_costs_fit(&buckets, bucket, costs) {
return false;
}
for (i, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() {
buckets[usize::from(bno)] -= cost;
log_debug!(
bucket = bno,
cost:display = cost,
remaining:display = buckets[usize::from(bno)];
"consume_bucket: ok"
);
*bucket.total_consumed[i].lock().await += cost;
}
std::mem::drop(buckets);
true
}

fn bucket_costs_fit(
buckets: &[primitive_types::U256],
bucket: &Bucket,
costs: &[primitive_types::U256],
) -> bool {
for (idx, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() {
let Some(remaining) = buckets.get(usize::from(bno)) else {
log_warn!(bucket = bno; "consume_bucket: bucket index out of range");
Expand Down Expand Up @@ -391,20 +413,19 @@ impl DataLimit {
return false;
}
}
for (i, (&bno, &cost)) in bucket.bucket_nos.iter().zip(costs.iter()).enumerate() {
buckets[usize::from(bno)] -= cost;
log_debug!(
bucket = bno,
cost:display = cost,
remaining:display = buckets[usize::from(bno)];
"consume_bucket: ok"
);
*bucket.total_consumed[i].lock().await += cost;
}
std::mem::drop(buckets);
true
}

async fn can_consume_bucket(
&self,
bucket: &Bucket,
vars: &[(&str, genvm_common::expr::Value)],
) -> rt::errors::Result<bool> {
let costs = self.calculate_bucket(bucket, vars)?;
let buckets = self.buckets.lock().await;
Ok(Self::bucket_costs_fit(&buckets, bucket, &costs.0))
}

pub async fn remaining(&self) -> Vec<primitive_types::U256> {
self.buckets.lock().await.clone()
}
Expand Down Expand Up @@ -486,6 +507,15 @@ impl DataLimit {
.ctx("consuming nondet output")
}

pub async fn can_consume_nondet_output(&self, output_length: u64) -> rt::errors::Result<bool> {
self.can_consume_bucket(
&self.nondet_output,
&[("outputLength", output_length.into())],
)
.await
.ctx("checking nondet output")
}

pub async fn consume_event(
&self,
blob_size: u64,
Expand Down
83 changes: 83 additions & 0 deletions executor/src/rt/memlimiter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::rt;

struct LimiterInner {
remaining_memory: AtomicU32,
new_permanent_allocations: AtomicU32,
}

#[derive(Clone)]
Expand Down Expand Up @@ -35,6 +36,7 @@ impl Limiter {
pub fn with_limit(limit: u32) -> Self {
Self(Arc::new(LimiterInner {
remaining_memory: AtomicU32::new(limit),
new_permanent_allocations: AtomicU32::new(0),
}))
}

Expand All @@ -45,9 +47,64 @@ impl Limiter {
.remaining_memory
.load(std::sync::atomic::Ordering::SeqCst),
),
new_permanent_allocations: AtomicU32::new(0),
}))
}

pub fn reserve_permanent(&self, delta: u64) -> Option<PermanentAllocation> {
let delta = u32::try_from(delta).ok()?;
if !self.consume(delta) {
return None;
}
if self
.0
.new_permanent_allocations
.fetch_update(
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
|current| current.checked_add(delta),
)
.is_err()
{
self.release(delta);
return None;
}
Some(PermanentAllocation {
limiter: self.clone(),
delta,
committed: false,
})
}

pub fn fold_permanent(&self, child: &Self) -> bool {
let delta = child
.0
.new_permanent_allocations
.load(std::sync::atomic::Ordering::SeqCst);
if self
.0
.new_permanent_allocations
.fetch_update(
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
|current| current.checked_add(delta),
)
.is_err()
{
return false;
}
if !self.consume(delta) {
return false;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
true
}

pub fn get_new_permanent_allocations(&self) -> u32 {
self.0
.new_permanent_allocations
.load(std::sync::atomic::Ordering::SeqCst)
}

/// Charges `delta` bytes, failing (rather than truncating) when `delta`
/// does not fit in the `u32` budget. A body larger than `u32::MAX` can never
/// fit the 4 GiB budget anyway, so this maps cleanly onto the OOM path.
Expand Down Expand Up @@ -111,6 +168,32 @@ impl Limiter {
}
}

/// A RAM charge released on drop unless [`PermanentAllocation::commit`] retains it.
pub struct PermanentAllocation {
limiter: Limiter,
delta: u32,
committed: bool,
}

impl PermanentAllocation {
pub fn commit(mut self) {
self.committed = true;
}
}

impl Drop for PermanentAllocation {
fn drop(&mut self) {
if self.committed {
return;
}
self.limiter
.0
.new_permanent_allocations
.fetch_sub(self.delta, std::sync::atomic::Ordering::SeqCst);
self.limiter.release(self.delta);
}
}

impl wasmtime::ResourceLimiter for Limiter {
fn memory_growing(
&mut self,
Expand Down
52 changes: 16 additions & 36 deletions executor/src/rt/vm/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ struct StoragePagesOverride {
pages: rpds::RedBlackTreeMap<PageID, [u8; 32], archery::ArcTK>,
fee: Limiter,
mem: rt::memlimiter::Limiter,
new_pages: u32,
}

impl StoragePagesOverride {
Expand All @@ -112,7 +111,6 @@ impl StoragePagesOverride {
pages: Default::default(),
fee: storage_pages_limit,
mem,
new_pages: 0,
}
}

Expand All @@ -126,25 +124,19 @@ impl StoragePagesOverride {

async fn write_page(&mut self, key: PageID, value: [u8; 32]) -> rt::errors::Result<()> {
if !self.pages.contains_key(&key) {
let new_pages = self.new_pages.checked_add(1).ok_or_else(|| {
rt::errors::Error::wrap(
abi::consts::VmError::out_of().memory().val(),
anyhow::anyhow!("incrementing storage page count"),
)
})?;
if !self.mem.consume(memory_limiter_consts::NEW_STORAGE_PAGE) {
return Err(rt::errors::Error::wrap(
abi::consts::VmError::out_of().memory().val(),
anyhow::anyhow!("allocating storage page override"),
));
}
let allocation = self
.mem
.reserve_permanent(memory_limiter_consts::NEW_STORAGE_PAGE.into())
.ok_or_else(|| {
rt::errors::Error::wrap(
abi::consts::VmError::out_of().memory().val(),
anyhow::anyhow!("allocating storage page override"),
)
})?;
// Memory is charged first so a write refused for want of RAM costs no
// fee; a write refused for want of fee must likewise cost no memory.
if let Err(e) = self.fee.consume(1).await {
self.mem.release(memory_limiter_consts::NEW_STORAGE_PAGE);
return Err(e);
}
self.new_pages = new_pages;
self.fee.consume(1).await?;
allocation.commit();
}
self.pages = self.pages.insert(key, value);

Expand All @@ -171,34 +163,22 @@ impl StoragePagesOverride {
pages: self.pages.clone(),
fee: self.fee.clone(),
mem,
new_pages: 0,
})
}

fn fold(&mut self, child: Self) -> rt::errors::Result<()> {
// The parent pays only when it keeps the child's new pages.
let new_pages = self.new_pages.checked_add(child.new_pages).ok_or_else(|| {
rt::errors::Error::fatal_vm_cause(
abi::consts::VmError::out_of().memory().val(),
Some(anyhow::anyhow!("folding storage page count")),
)
})?;
let charged = self
.mem
.consume_mul(child.new_pages, memory_limiter_consts::NEW_STORAGE_PAGE);
let charged = self.mem.fold_permanent(&child.mem);
// Unreachable: the child's budget is a snapshot of ours taken at spawn,
// and it paid VM_SPAWN_COST out of it before writing a page, so what it
// owes us is strictly less than what we still hold. Getting here means a
// security researcher broke that invariant, so it is fatal and uncatchable.
debug_assert!(charged, "storage fold charge exceeded the parent budget");
// and it paid VM_SPAWN_COST out of it before retaining anything, so what
// it owes us is strictly less than what we still hold.
debug_assert!(charged, "permanent fold charge exceeded the parent budget");
if !charged {
return Err(rt::errors::Error::fatal_vm_cause(
abi::consts::VmError::out_of().memory().val(),
Some(anyhow::anyhow!("folding storage page overrides")),
Some(anyhow::anyhow!("folding permanent allocations")),
));
}
self.pages = child.pages;
self.new_pages = new_pages;
Ok(())
}
}
Expand Down
Loading