56629 bc insight there is an issue in mapping gas undercharge and is enables 30 extra node work per unit gas
Submitted on Oct 18th 2025 at 16:11:43 UTC by @XDZIBECX for Attackathon | VeChain Hayabusa Upgrade
Report ID: #56629
Report Type: Blockchain/DLT
Report severity: Insight
Target: https://github.com/vechain/thor/compare/master...release/hayabusa
Impacts: Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours
Description
There is an issue in the generic Mapping storage layer: gas is charged by the encoded/decoded byte-size of values instead of by the storage transition type. This causes two undercharged operations:
Clearing a previously non-zero slot to the type's zero value (treated as a hard delete) reports size == 0 and is billed 0 SstoreResetGas.
Reading an empty slot (len(raw) == 0) yields size == 0 and is billed 0 SloadGas.
Native builtin code always charges at least one SLOAD/SSTORE reset per operation regardless of the slot contents. The current Mapping behavior allows an attacker to craft transactions that flip entries non-zero→zero and perform empty reads while paying far less gas than intended. This can significantly increase node I/O/CPU per unit gas (the report demonstrates >30% and in the PoC a ~96.15% under-charge).
Brief / Intro
The
Mappingimplementation bills gas based on encoded/decoded byte-size (size) instead of the storage transition type.For empty slots (len(raw) == 0) or when writing a value equal to the type’s zero value,
sizebecomes 0 and the operation can be billed 0 gas.As a result, clearing non-zero slots to zero and empty reads are undercharged compared to native semantics (which always bill at least one SstoreResetGas or SloadGas).
An attacker can create many such cheap operations in transactions to increase validator resource consumption and degrade network performance or validator economics.
Vulnerability Details
The core bug is that gas is billed by the encoded/decoded byte-size (size) returned by Mapping's get/set, rather than by the kind of storage transition.
Key code points:
Get charges SLOAD as size * SloadGas; if size == 0 (empty slot), the call charges 0 gas:
func (m *Mapping[K, V]) Get(key K) (V, error) {
value, size, err := m.get(key)
m.context.UseGas(size * thor.SloadGas) // if size == 0 (empty slot), this charges 0
return value, err
}get sets size = toWordSize(len(raw)); for an empty slot len(raw) == 0 → size == 0:
func (m *Mapping[K, V]) get(key K) (V, uint64, error) {
// ...
err := m.context.state.DecodeStorage(..., func(raw []byte) error {
size = toWordSize(len(raw))
if len(raw) == 0 { // empty slot -> leaves value at zero and size == 0
return nil
}
return rlp.DecodeBytes(raw, &value)
})
// returns value, size
}Update bills SSTORE reset as size * SstoreResetGas; if set returns size == 0 (writing zero value treated as delete), then billed 0:
func (m *Mapping[K, V]) Update(key K, value V) error {
size, err := m.set(key, value)
if err != nil {
return err
}
m.context.UseGas(size * thor.SstoreResetGas) // if clear returns size == 0, this charges 0
return nil
}set treats the type zero value as a hard delete and returns size == 0:
func (m *Mapping[K, V]) set(key K, value V) (uint64, error) {
var zero V
if value == zero {
// hard delete storage
m.context.state.SetRawStorage(m.context.address, position, nil)
return 0, nil // no gas charged for setting nil << this is clear reports size 0
}
// else encode; size = toWordSize(len(buf))
}Because of this, when a slot is cleared from non-zero→zero the Mapping code reports size == 0 and charges no SstoreResetGas. Similarly empty reads report size == 0 and are billed 0 SloadGas. This deviates from native semantics and underprices operations that still require node I/O/CPU.
Impact Details
The undercharging enables attackers to create transactions containing many cheap state churn operations (non-zero→zero clears and empty reads) that force validators to perform much more I/O/CPU than the gas paid for.
The PoC demonstrates that with 20,000 clears and 20,000 empty reads:
Expected (native) billing: 104,000,000 gas
Mapping billed: 4,000,000 gas
Under-charge: 100,000,000 gas (96.15% under-charge)
This allows an attacker to sustain blocks dominated by underpriced churn and drive node resource consumption well beyond a 30% increase vs prior-day baseline at the same on-chain gas usage.
Consequences: degraded validator economics, increased block processing time, higher DB pressure, and a persistent griefing vector.
Proof of Concept
Test that demonstrates the issue:
func Test_Undercharge_Increases_WorkPerGas_By_AtLeast_30Percent(t *testing.T) {
const clears = 20000 // non-zero→zero clears
const emptyReads = 20000 // empty SLOADs
const totalOps = clears + emptyReads
// --- Setup mapping with a simple value type (address) ---
charger, mapping := SetupMapping[thor.Address]()
// Pre-populate `clears` keys with non-zero so we can clear them.
keys := make([]thor.Bytes32, clears)
for i := 0; i < clears; i++ {
k := datagen.RandomHash()
keys[i] = k
require.NoError(t, mapping.Insert(k, datagen.RandAddress()))
}
// Reset gas to measure only the "block" workload
charger = gascharger.New(newXenv())
mapping.context.charger = charger
// --- Phase A: perform clears that should be charged as SstoreResetGas (but aren't) ---
for i := 0; i < clears; i++ {
require.NoError(t, mapping.Update(keys[i], thor.Address{})) // non-zero → zero (clear)
}
gasAfterClears := charger.TotalGas()
// --- Phase B: perform empty reads that should be charged as SloadGas (but aren't) ---
// Ensure these keys were never written so len(raw)==0
for i := 0; i < emptyReads; i++ {
k := datagen.RandomHash()
_, err := mapping.Get(k) // empty SLOAD
require.NoError(t, err)
}
actualGas := charger.TotalGas()
actualClearsGas := gasAfterClears
actualReadsGas := actualGas - gasAfterClears
// --- Baseline (native) gas expectation for the same logical work ---
// Every clear should cost at least 1× SstoreResetGas; every read at least 1× SloadGas.
expectedClearsGas := uint64(clears) * thor.SstoreResetGas
expectedReadsGas := uint64(emptyReads) * thor.SloadGas
expectedGas := expectedClearsGas + expectedReadsGas
// --- Compute under-charge and impact ---
var shortfall uint64
if actualGas < expectedGas {
shortfall = expectedGas - actualGas
}
shortfallPct := float64(shortfall) / float64(expectedGas) * 100.0
// --- Print an undeniable summary ---
t.Logf("=== Mapping under-charge impact ===")
t.Logf("Operations: clears=%d, emptyReads=%d (total=%d)", clears, emptyReads, totalOps)
t.Logf("Expected (native semantics): clears=%d*SstoreResetGas=%d, reads=%d*SloadGas=%d, total=%d",
clears, expectedClearsGas, emptyReads, expectedReadsGas, expectedGas)
t.Logf("Actual (Mapping billed): clearsGas=%d, readsGas=%d, totalBilled=%d",
actualClearsGas, actualReadsGas, actualGas)
t.Logf("Under-charge: %d (%.2f%% of expected)", shortfall, shortfallPct)
// --- The assertion that proves the bounty criterion (≥30%) ---
require.GreaterOrEqual(t, shortfallPct, 30.0, "under-charge %.2f%% < 30%%; mapping must be fixed", shortfallPct)
}Example test output (logs):
=== RUN Test_Undercharge_Increases_WorkPerGas_By_AtLeast_30Percent
c:\Users\baouc\thor\builtin\solidity\mapping_test.go:353: === Mapping under-charge impact ===
c:\Users\baouc\thor\builtin\solidity\mapping_test.go:354: Operations: clears=20000, emptyReads=20000 (total=40000)
c:\Users\baouc\thor\builtin\solidity\mapping_test.go:355: Expected (native semantics): clears=20000*SstoreResetGas=100000000, reads=20000*SloadGas=4000000, total=104000000
c:\Users\baouc\thor\builtin\solidity\mapping_test.go:357: Actual (Mapping billed): clearsGas=0, readsGas=4000000, totalBilled=4000000
c:\Users\baouc\thor\builtin\solidity\mapping_test.go:359: Under-charge: 100000000 (96.15% of expected)
--- PASS: Test_Undercharge_Increases_WorkPerGas_By_AtLeast_30Percent (0.05s)
PASS
ok github.com/vechain/thor/v2/builtin/solidity 2.357sReferences
Vulnerability details and code links referenced above are from the target repository: https://github.com/vechain/thor/compare/master...release/hayabusa
Suggested Fix (summary)
Bill at least one SloadGas for every read and at least one SstoreResetGas for any storage reset (non-zero → zero), regardless of encoded byte-size.
Replace size-based minimum with transition-type based billing (e.g., detect non-zero→zero and empty→non-empty transitions and charge appropriate minimums to match native semantics).
This report shows an economic/griefing risk by underpricing storage operations. It should be remediated to ensure node resource usage matches gas payments and to prevent denial-of-service or degradation via cheap state churn.
Was this helpful?