57021 bc insight lack of panic recovery in housekeeping goroutine creates potential for denial of service

Submitted on Oct 22nd 2025 at 18:01:35 UTC by @rionnaldi for Attackathon | VeChain Hayabusa Upgrade

  • Report ID: #57021

  • Report Type: Blockchain/DLT

  • Report severity: Insight

  • Target: https://github.com/vechain/thor/compare/v2.3.2...release/hayabusa

  • Impacts:

    • Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments

Description

Brief/Intro

The lack of a defer recover() statement at the beginning of the housekeeping goroutine is a critical fragility. Long-running goroutines responsible for core node functions, especially those processing potentially malicious external input, must be resilient to panics. The wash function is a prime candidate for panics because it involves many complex interactions with the state and untrusted transaction data.

Vulnerability Details

The housekeeping function runs as a long-lived goroutine, performing the critical task of periodically cleaning ("washing") the transaction pool. This wash operation is complex, involving the creation of new state objects and the validation of transactions against the state by calling txObj.Executable(). The Executable method in turn calls resolved.BuyGas(), which interacts with multiple native smart contracts.

The housekeeping goroutine does not implement a recover mechanism. If any part of the code within the for loop panics (e.g., due to a nil pointer dereference, an out-of-bounds slice access, or a bug in a native contract triggered by a malformed transaction), the panic will be unhandled. This will cause the housekeeping goroutine to crash and exit permanently, leaving the node without its transaction pool cleaning mechanism.

Vulnerable Code (in txpool/tx_pool.go):

func (p *TxPool) housekeeping() {
	logger.Debug("enter housekeeping")
	defer logger.Debug("leave housekeeping")

	ticker := time.NewTicker(time.Second * 1)
	defer ticker.Stop()

	headSummary := p.repo.BestBlockSummary()

	for {
		// NO RECOVER MECHANISM HERE
		select {
		case <-p.ctx.Done():
			return
		case <-ticker.C:
            // ... complex logic that calls wash() ...
            // A panic inside wash() will crash this goroutine.
				executables, removedLegacy, removedDynamicFee, err := p.wash(headSummary)
        }
    }
}

Impact Details

  • Permanent Denial of Service on Transaction Processing: As demonstrated in the PoC, once the housekeeping goroutine crashes, the node permanently loses its ability to clean the transaction pool. Expired transactions will accumulate indefinitely. The pool will fill with these "zombie" transactions, eventually reaching its capacity (options.Limit). At this point, the node will reject all new incoming transactions, effectively taking it offline in terms of transaction processing and propagation. The only remediation is to restart the node.

  • Resource Exhaustion: The accumulation of unswept transactions in memory will lead to a gradual increase in the node's memory footprint, which can eventually lead to an Out-Of-Memory (OOM) crash, causing the entire node process to terminate.

  • Network Degradation: If an attacker can successfully execute this attack against multiple nodes, particularly Authority Masternodes, it could significantly impair the transaction processing capability of the entire network, leading to increased confirmation times and reduced throughput.

References

https://github.com/vechain/thor/blob/release/hayabusa/txpool/tx_pool.go

https://gist.github.com/rionnaldi/fb50d3b13e3aad311cf37d1f63c89451

Proof of Concept

1

Attack overview

  1. State Poisoning: The attacker first submits a transaction that writes a malformed RLP value (specifically 0x01, which is invalid as a vp-encoded string) to a storage slot associated with a contract. The PoC simulates this by directly modifying the state database to place this value in the user credit slot of the prototype contract.

  2. Triggering the Panic: The attacker submits a second transaction to the target node's txpool. This transaction is crafted to trigger a read of the poisoned storage slot during validation (e.g., by calling a function on the contract that checks the user's credit).

  3. Goroutine Crash: When the housekeeping goroutine's wash function processes this second transaction, it calls txObj.Executable(). This leads to the prototype contract attempting to decode the malformed RLP data from storage. The RLP parser panics due to a "slice bounds out of range" error.

  4. Permanent DoS: The panic is unhandled, crashing the housekeeping goroutine. The node's transaction pool is no longer cleaned of expired, invalid, or successfully processed transactions.

2

Steps to reproduce

  1. Copy the code from provided gist.

  2. Create a file named h01_poc_test.go inside the txpool folder, then paste the code.

  3. Run the code with:

3

Observed behavior (Expected vs Actual)

Expected: Housekeeping continues running and cleans expired transactions after processing the malicious transaction.

Actual: Housekeeping crashes due to an unhandled panic, leaving expired transactions in the pool and causing progressive resource depletion and eventual denial of service until node restart.

Expected test output from PoC

Was this helpful?