0xtruthlover
← Writing

Disclosure4 min read

One println could crash every gno.land validator

Printing a big slice in a gno.land contract used memory the VM never counted. One cheap transaction could kill every validator in the same block.

Severity
Critical
Program
gno.land
Platform
HackenProof
Status
Fixed and paid

Every validator on gno.land, the machines that run the chain, could be taken down at once by a single println call. Not fail a transaction. Kill the node process, on every machine, in the same block.

All it cost the attacker was ordinary gas.

I sent it to HackenProof in January 2026. The team confirmed it as Critical, paid it, and later shipped a fix that rebuilt how the VM prints things.

How GnoVM caps a transaction

gno.land runs smart contracts written in Gno, a dialect of Go, inside GnoVM, an interpreter built into each blockchain node. Every validator runs every transaction, so the VM has to cap what one transaction can use.

It does that in two ways: gas, a fee charged for each step of work, and a memory allocator that counts every allocation a contract makes. Go past 500MB in one transaction and the VM stops it.

There is also a safety net. The node’s recovery code catches the errors a bad contract can cause, such as running out of gas. The contract fails, and the node keeps going.

Memory nobody counted

Every byte a contract causes to be allocated is supposed to be counted. The print and println built-ins broke it.

To print a value, GnoVM turns it into a string. For byte slices, that code already capped output at 256 bytes. Every other slice type ([]int, []string, slices of structs) fell through to a generic path with no cap.

Simplified, the generic path looked like this (pseudocode, not the real source):

// simplified sketch of values_string.go
func (sv *SliceValue) ProtectedString(...) string {
    if isByteSlice(sv) {
        return truncate(sv, 256)               // protected
    }
    parts := make([]string, sv.Length)         // native Go heap
    for i := range parts {
        parts[i] = format(sv.element(i))       // native Go heap
    }
    return strings.Join(parts, ",")            // native Go heap, again
}

None of that memory went through the GnoVM allocator; the formatting file never called it once. The VM saw a modest slice. Go’s own memory (the heap the VM does not count) paid for every formatted element, every slot in the list and the final joined string.

It also amplifies. A million-element slice cost about 888MB of real memory while GnoVM counted 164MB, a 5.4x gap.

Scaled up, a slice that fits under the 500MB limit could drive nearly 3GB of real allocation.

Then the second problem. When Go runs out of memory it does not panic: it calls runtime.throw, a fatal error that kills the process outright.

It never reaches recover(), Go’s way of catching a panic, so the safety net never gets a turn.

Crashing every validator

  1. Deploy a contract holding a large []int that sits comfortably under the GnoVM memory limit.
  2. Send a transaction that calls println on it.
  3. It passes CheckTx, the check a node runs before admitting a transaction to its mempool, because gno.land skips actual execution there. It lands in a block untouched.
  4. During DeliverTx, when validators process the block, every one of them runs it.
  5. Native memory blows past what the machine has, Go throws a fatal error, and every validator dies at roughly the same moment.

My proof of concept drove the real interpreter and the real allocator, and measured native memory directly. Under the real 500MB limit, one run stayed at about 38MB of tracked memory while native allocation hit about 176MB. With a 10MB allocator limit, println still built a 1.6MB string the allocator never saw.

The side-by-side test said it plainly: a byte slice printed 531 bytes and stopped. A 1,000-element int slice printed all 9,897 bytes.

A chain halt

Every validator executes the same block, so every validator crashes on it. With no one left to agree on the next block, the chain stops. Bringing the network back takes manual work on each node.

The attacker needs no permissions, just a normal account and normal gas. And println on a slice is not exotic code. It is the line people write while debugging.

Once the network recovers, the same trick works again.

A metered printer

I recommended the smallest patch I could: apply the 256-element cap to every slice type, not just bytes. A long slice would print as a short summary like slice[...(N elements)] before any list of strings gets built.

The team went further, and the fix is confirmed. print and println now stream output through a small metered buffer and charge gas as each chunk is written, so memory stays bounded however big the value is.

A companion change puts the same kind of limit on how panics and errors get printed on validators.

Along the way I sent an updated proof of concept showing that the memory was spent before the gas check. The new design closes exactly that gap. It charges as it writes, not after.

What I learned

  • A memory limit only covers the memory it is told about. Native allocations driven by user input need to be charged, or bounded, where they happen.
  • Check every branch of a guard. The byte-slice case was protected. The general case, which is most slices, was not.
  • In Go, recover() does not catch running out of memory. If a user can push the process into a fatal throw, the only defense is never getting there.