From e10fb16f4ff51f5f792b4273a904fcee30bfe2f6 Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 2 Jun 2026 13:10:21 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(audit):=20add=20vague=201=20=E2=80=94?= =?UTF-8?q?=20nil-realm=20cross-realm=20write=20hole=20audits=20(PR=20#575?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new audit scripts targeting commit 2c7f1abe3 (PR #5758): - audit_nil_realm_hole.sh: deploys a /p/-init-stamped Dispatcher + EvilInt (int-based Mutator, no PkgID anchor) and verifies the VM blocks the write to a /r/-stamped Slot routed through the nil-realm path. - audit_launder_pointer_write.sh: victim realm exposes &gValue via GetPtr(); verifies the VM blocks a direct *ptr = "pwnd" from outside the realm (PkgID mismatch: caller context vs victim-stamped string). - audit_launder_panic_recover.sh: /p/-init-stamped SafeRunner wraps a victim.SetAndPanic(100) call with defer/recover; verifies that state is fully rolled back to 0 despite the panic being caught in /p/ context (extends audit_cross_realm_recover for the nil-realm recover path). --- .../audit/audit_launder_panic_recover.sh | 134 +++++++++++++++++ .../audit/audit_launder_pointer_write.sh | 99 +++++++++++++ .../audit/audit_nil_realm_hole.sh | 139 ++++++++++++++++++ tests/samourai-crew/run_tests.sh | 7 +- 4 files changed, 377 insertions(+), 2 deletions(-) create mode 100755 tests/samourai-crew/audit/audit_launder_panic_recover.sh create mode 100755 tests/samourai-crew/audit/audit_launder_pointer_write.sh create mode 100755 tests/samourai-crew/audit/audit_nil_realm_hole.sh diff --git a/tests/samourai-crew/audit/audit_launder_panic_recover.sh b/tests/samourai-crew/audit/audit_launder_panic_recover.sh new file mode 100755 index 0000000..e2a1c3c --- /dev/null +++ b/tests/samourai-crew/audit/audit_launder_panic_recover.sh @@ -0,0 +1,134 @@ +#!/bin/sh +# Targets: fix(gnovm): close the nil-realm cross-realm write hole for /p/ and stdlib +# Commit: 2c7f1abe3 — PR #5758 (extends f87249327 / PR #5330) +# Vector: a /p/-init-stamped SafeRunner wraps a victim call with defer/recover. +# When the victim writes state then panics, the /p/-stamped recover catches the panic. +# Before 2c7f1abe3: the SafeRunner method ran with nil-realm, and the recover could +# prevent the VM from performing a full state rollback — leaving victim state corrupted. +# After the fix: the /p/ frozen realm ensures panic handling still rolls back /r/ state. +# Extends audit_cross_realm_recover.sh: that test had recover in a plain main() function; +# this test has recover inside a /p/-init-stamped object's method. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=common.sh +. "$SCRIPT_DIR/common.sh" + +SUFFIX=$(date +%s) +PKGPATH_P="gno.land/p/${KEY_ADDR}/audit/saferun${SUFFIX}" +PKGPATH_R="gno.land/r/${KEY_ADDR}/audit/panicvictim${SUFFIX}" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "2c7f1abe3 — Panic/recover state rollback via /p/-init-stamped SafeRunner" +echo " SafeRunner : $PKGPATH_P" +echo " Victim : $PKGPATH_R" + +mkdir -p "$TMPDIR/p" "$TMPDIR/r" + +# --- deploy /p/ SafeRunner package --- +# PSafe is /p/-init-stamped. Run() defers recover() before calling fn(). +# Before 2c7f1abe3: Run() ran with nil-realm — recover inside could interfere with +# the VM's state-rollback machinery. +# After 2c7f1abe3: Run() runs with /p/'s frozen realm — state rollback is unaffected. +cat > "$TMPDIR/p/saferun.gno" << EOF +package saferun + +type SafeRunner struct{} + +func (r *SafeRunner) Run(fn func()) { + defer func() { recover() }() + fn() +} + +var PSafe = &SafeRunner{} +EOF + +cat > "$TMPDIR/p/gnomod.toml" << EOF +module = "${PKGPATH_P}" +gno = "0.9" +EOF + +echo -n " Deploying SafeRunner (/p/)... " +DEPLOY_P=$(echo "$PASSWORD" | gnokey maketx addpkg \ + -pkgpath "$PKGPATH_P" -pkgdir "$TMPDIR/p" \ + -gas-fee 1000000ugnot -gas-wanted 10000000 \ + -broadcast -chainid "$CHAINID" -remote "$RPC" \ + -insecure-password-stdin \ + -home "$GNOKEY_HOME" \ + "$KEY" 2>&1) +if echo "$DEPLOY_P" | grep -q "OK!"; then echo "OK"; else + echo "FAILED"; echo "$DEPLOY_P"; exit 1 +fi + +# --- deploy /r/ victim realm --- +cat > "$TMPDIR/r/panicvictim.gno" << EOF +package panicvictim + +import "strconv" + +var value = 0 + +func SetAndPanic(v int) { + value = v + panic("deliberate panic after state write") +} + +func Render(_ string) string { return strconv.Itoa(value) } +EOF + +cat > "$TMPDIR/r/gnomod.toml" << EOF +module = "${PKGPATH_R}" +gno = "0.9" +EOF + +echo -n " Deploying victim realm (/r/)... " +DEPLOY_R=$(echo "$PASSWORD" | gnokey maketx addpkg \ + -pkgpath "$PKGPATH_R" -pkgdir "$TMPDIR/r" \ + -gas-fee 1000000ugnot -gas-wanted 10000000 \ + -broadcast -chainid "$CHAINID" -remote "$RPC" \ + -insecure-password-stdin \ + -home "$GNOKEY_HOME" \ + "$KEY" 2>&1) +if echo "$DEPLOY_R" | grep -q "OK!"; then echo "OK"; else + echo "FAILED"; echo "$DEPLOY_R"; exit 1 +fi + +# --- trigger: PSafe.Run captures the panic from victim.SetAndPanic(100) --- +cat > "$TMPDIR/run.gno" << EOF +package main + +import ( + safe "${PKGPATH_P}" + victim "${PKGPATH_R}" +) + +func main() { + safe.PSafe.Run(func() { + victim.SetAndPanic(100) + }) +} +EOF + +echo -n " Calling PSafe.Run(SetAndPanic(100))... " +CALL=$(echo "$PASSWORD" | gnokey maketx run \ + -gas-fee 1000000ugnot -gas-wanted 10000000 \ + -broadcast -chainid "$CHAINID" -remote "$RPC" \ + -insecure-password-stdin \ + -home "$GNOKEY_HOME" \ + "$KEY" "$TMPDIR/run.gno" 2>&1) +echo "$(echo "$CALL" | grep -oE 'OK!|error' | head -1)" + +# --- verify state rollback --- +echo -n " Querying victim state (expect 0)... " +RESULT=$(gnokey query "vm/qeval" \ + -data "${PKGPATH_R}.Render(\"\")" \ + -remote "$RPC" 2>&1) + +if echo "$RESULT" | grep -q '"0"'; then + echo "✅ PATCHED — state rolled back to 0 after panic caught by /p/ SafeRunner" +elif echo "$RESULT" | grep -q '"100"'; then + echo "❌ VULNERABLE — state corrupted to 100 (rollback bypassed via /p/ recover)" + exit 1 +else + echo "⚠️ UNKNOWN OUTPUT"; echo "$RESULT"; exit 1 +fi diff --git a/tests/samourai-crew/audit/audit_launder_pointer_write.sh b/tests/samourai-crew/audit/audit_launder_pointer_write.sh new file mode 100755 index 0000000..5a42a7a --- /dev/null +++ b/tests/samourai-crew/audit/audit_launder_pointer_write.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# Targets: fix(gnovm): close the nil-realm cross-realm write hole for /p/ and stdlib +# Commit: 2c7f1abe3 — PR #5758 +# Vector: an attacker obtains a pointer to a victim realm's /r/-stamped data via an +# exported GetPtr() function, then writes through that pointer from outside the victim +# realm. The cross-realm write check (PkgID mismatch: attacker context vs victim-stamped +# object) must block the write regardless of nil-realm state. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=common.sh +. "$SCRIPT_DIR/common.sh" + +SUFFIX=$(date +%s) +PKGPATH="gno.land/r/${KEY_ADDR}/audit/ptrwrite${SUFFIX}" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "2c7f1abe3 — Cross-realm pointer write (direct deref from outside realm)" +echo " Victim pkg : $PKGPATH" + +# --- deploy victim realm --- +# gValue is /r/-stamped. GetPtr() exposes &gValue to callers outside this realm. +cat > "$TMPDIR/ptrwrite.gno" << EOF +package ptrwrite + +var gValue = "original" + +func GetPtr() *string { return &gValue } +func GetValue() string { return gValue } +EOF + +cat > "$TMPDIR/gnomod.toml" << EOF +module = "${PKGPATH}" +gno = "0.9" +EOF + +echo -n " Deploying victim realm... " +DEPLOY=$(echo "$PASSWORD" | gnokey maketx addpkg \ + -pkgpath "$PKGPATH" -pkgdir "$TMPDIR" \ + -gas-fee 1000000ugnot -gas-wanted 10000000 \ + -broadcast -chainid "$CHAINID" -remote "$RPC" \ + -insecure-password-stdin \ + -home "$GNOKEY_HOME" \ + "$KEY" 2>&1) +if echo "$DEPLOY" | grep -q "OK!"; then echo "OK"; else + echo "FAILED"; echo "$DEPLOY"; exit 1 +fi + +# --- attempt cross-realm pointer write from outside --- +# main() runs in the caller's context (not the victim's realm). +# *ptr = "pwnd" attempts to write to a victim-stamped string via a raw pointer. +# The VM should block this: the object's PkgID (victim) != m.Realm (caller). +cat > "$TMPDIR/attack.gno" << EOF +package main + +import v "${PKGPATH}" + +func main() { + ptr := v.GetPtr() + *ptr = "pwnd" +} +EOF + +echo -n " Writing through pointer from outside realm... " +ATTACK=$(echo "$PASSWORD" | gnokey maketx run \ + -gas-fee 1000000ugnot -gas-wanted 5000000 \ + -broadcast -chainid "$CHAINID" -remote "$RPC" \ + -insecure-password-stdin \ + -home "$GNOKEY_HOME" \ + "$KEY" "$TMPDIR/attack.gno" 2>&1) + +# PATCHED: VM panics — write to foreign-stamped object blocked +if echo "$ATTACK" | grep -qi "readonly\|tainted\|cannot.*modif\|unauthorized"; then + echo "✅ PATCHED — cross-realm pointer write blocked by VM" + exit 0 +fi + +if ! echo "$ATTACK" | grep -q "OK!"; then + echo "REJECTED (unexpected)" + echo "$ATTACK" + exit 1 +fi + +echo "OK (tx accepted — querying state)" + +# Transaction succeeded — verify state +echo -n " Querying gValue (expect 'original')... " +RESULT=$(gnokey query "vm/qeval" \ + -data "${PKGPATH}.GetValue()" \ + -remote "$RPC" 2>&1) + +if echo "$RESULT" | grep -q '"original"'; then + echo "✅ PATCHED — gValue unchanged despite tx success" +elif echo "$RESULT" | grep -q '"pwnd"'; then + echo "❌ VULNERABLE — gValue corrupted to 'pwnd' via cross-realm pointer write" + exit 1 +else + echo "⚠️ UNKNOWN OUTPUT"; echo "$RESULT"; exit 1 +fi diff --git a/tests/samourai-crew/audit/audit_nil_realm_hole.sh b/tests/samourai-crew/audit/audit_nil_realm_hole.sh new file mode 100755 index 0000000..3601d18 --- /dev/null +++ b/tests/samourai-crew/audit/audit_nil_realm_hole.sh @@ -0,0 +1,139 @@ +#!/bin/sh +# Targets: fix(gnovm): close the nil-realm cross-realm write hole for /p/ and stdlib +# Commit: 2c7f1abe3 — PR #5758 +# Vector: a /p/-init-stamped object's method ran with m.Realm==nil, disabling the +# cross-realm write check. Attacker dispatches a Mutator (int-based, no PkgID) through +# the /p/-init-stamped Dispatcher, inheriting the nil-realm context and writing to +# a /r/-stamped victim slot. After the fix, /p/ gets a frozen realm and the write +# is blocked with a "readonly tainted object" error. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=common.sh +. "$SCRIPT_DIR/common.sh" + +SUFFIX=$(date +%s) +PKGPATH_P="gno.land/p/${KEY_ADDR}/audit/nilhole${SUFFIX}" +PKGPATH_R="gno.land/r/${KEY_ADDR}/audit/nilvictim${SUFFIX}" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "2c7f1abe3 — Nil-realm cross-realm write hole (/p/-init-stamped dispatcher)" +echo " Attack pkg : $PKGPATH_P" +echo " Victim pkg : $PKGPATH_R" + +mkdir -p "$TMPDIR/p" "$TMPDIR/r" + +# --- deploy /p/ attack package --- +# Dispatcher.UseMutator is called on PDispatch (a /p/-init-stamped *Dispatcher). +# EvilInt implements Mutator with an int underlying type (no PkgID anchor). +# Before 2c7f1abe3: UseMutator borrows m.Realm to nil; EvilInt.Run inherits nil; +# write to victim-stamped Slot proceeds unchecked. +# After 2c7f1abe3: UseMutator borrows m.Realm to /p/'s frozen realm; readonly check fires. +cat > "$TMPDIR/p/nilhole.gno" << EOF +package nilhole + +type Slot struct{ Field string } + +type Mutator interface{ Run(s *Slot) } + +type Dispatcher struct{} + +func (d *Dispatcher) UseMutator(s *Slot, m Mutator) { m.Run(s) } + +var PDispatch = &Dispatcher{} + +type EvilInt int + +func (EvilInt) Run(s *Slot) { s.Field = "pwnd" } +EOF + +cat > "$TMPDIR/p/gnomod.toml" << EOF +module = "${PKGPATH_P}" +gno = "0.9" +EOF + +echo -n " Deploying attack package (/p/)... " +DEPLOY_P=$(echo "$PASSWORD" | gnokey maketx addpkg \ + -pkgpath "$PKGPATH_P" -pkgdir "$TMPDIR/p" \ + -gas-fee 1000000ugnot -gas-wanted 10000000 \ + -broadcast -chainid "$CHAINID" -remote "$RPC" \ + -insecure-password-stdin \ + -home "$GNOKEY_HOME" \ + "$KEY" 2>&1) +if echo "$DEPLOY_P" | grep -q "OK!"; then echo "OK"; else + echo "FAILED"; echo "$DEPLOY_P"; exit 1 +fi + +# --- deploy /r/ victim realm --- +# gSlot is /r/-stamped. Attack() passes &gSlot to PDispatch.UseMutator. +cat > "$TMPDIR/r/nilvictim.gno" << EOF +package nilvictim + +import nilhole "${PKGPATH_P}" + +var gSlot = nilhole.Slot{Field: "original"} + +func Attack() { + nilhole.PDispatch.UseMutator(&gSlot, nilhole.EvilInt(0)) +} + +func GetField() string { return gSlot.Field } +EOF + +cat > "$TMPDIR/r/gnomod.toml" << EOF +module = "${PKGPATH_R}" +gno = "0.9" +EOF + +echo -n " Deploying victim realm (/r/)... " +DEPLOY_R=$(echo "$PASSWORD" | gnokey maketx addpkg \ + -pkgpath "$PKGPATH_R" -pkgdir "$TMPDIR/r" \ + -gas-fee 1000000ugnot -gas-wanted 10000000 \ + -broadcast -chainid "$CHAINID" -remote "$RPC" \ + -insecure-password-stdin \ + -home "$GNOKEY_HOME" \ + "$KEY" 2>&1) +if echo "$DEPLOY_R" | grep -q "OK!"; then echo "OK"; else + echo "FAILED"; echo "$DEPLOY_R"; exit 1 +fi + +# --- trigger attack --- +echo -n " Calling Attack() (PDispatch.UseMutator -> EvilInt.Run -> gSlot.Field)... " +ATTACK=$(echo "$PASSWORD" | gnokey maketx call \ + -pkgpath "$PKGPATH_R" \ + -func "Attack" \ + -gas-fee 1000000ugnot -gas-wanted 5000000 \ + -broadcast -chainid "$CHAINID" -remote "$RPC" \ + -insecure-password-stdin \ + -home "$GNOKEY_HOME" \ + "$KEY" 2>&1) + +# PATCHED: VM panics with "readonly tainted" — transaction rejected +if echo "$ATTACK" | grep -qi "readonly\|tainted\|cannot.*modif"; then + echo "✅ PATCHED — cross-realm write blocked by VM" + exit 0 +fi + +if ! echo "$ATTACK" | grep -q "OK!"; then + # Transaction rejected for unexpected reason + echo "REJECTED (unexpected)" + echo "$ATTACK" + exit 1 +fi + +echo "OK (tx accepted — querying state)" + +# Transaction succeeded — check if gSlot was corrupted +echo -n " Querying gSlot.Field (expect 'original')... " +RESULT=$(gnokey query "vm/qeval" \ + -data "${PKGPATH_R}.GetField()" \ + -remote "$RPC" 2>&1) + +if echo "$RESULT" | grep -q '"original"'; then + echo "✅ PATCHED — gSlot unchanged despite tx success" +elif echo "$RESULT" | grep -q '"pwnd"'; then + echo "❌ VULNERABLE — gSlot corrupted to 'pwnd' via nil-realm hole" + exit 1 +else + echo "⚠️ UNKNOWN OUTPUT"; echo "$RESULT"; exit 1 +fi diff --git a/tests/samourai-crew/run_tests.sh b/tests/samourai-crew/run_tests.sh index beda8cc..b5fcedf 100755 --- a/tests/samourai-crew/run_tests.sh +++ b/tests/samourai-crew/run_tests.sh @@ -172,9 +172,12 @@ if [ "$MODE" = "one-shot" ] || [ "$MODE" = "all" ]; then run_test "audit_gas_alloc" /tests/audit/audit_gas_alloc.sh run_test "audit_byteslice" /tests/audit/audit_byteslice.sh run_test "audit_array_alias" /tests/audit/audit_array_alias.sh - run_test "audit_var_init_order" /tests/audit/audit_var_init_order.sh - run_test "audit_cross_realm_recover" /tests/audit/audit_cross_realm_recover.sh \ + run_test "audit_var_init_order" /tests/audit/audit_var_init_order.sh + run_test "audit_cross_realm_recover" /tests/audit/audit_cross_realm_recover.sh \ "broader pattern not yet fixed, see f87249327" + run_test "audit_nil_realm_hole" /tests/audit/audit_nil_realm_hole.sh + run_test "audit_launder_pointer_write" /tests/audit/audit_launder_pointer_write.sh + run_test "audit_launder_panic_recover" /tests/audit/audit_launder_panic_recover.sh echo "" echo "=== E2E Tests (one-shot) ===" From cdaf8f04b1c9f1f894abeff3eb122c10e48e71b5 Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 2 Jun 2026 13:28:24 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(audit):=20use=20maketx=20run=20for=20ni?= =?UTF-8?q?l=5Frealm=5Fhole=20=E2=80=94=20Attack()=20has=20no=20cur=20real?= =?UTF-8?q?m=20(PR=20#5669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/samourai-crew/audit/audit_nil_realm_hole.sh | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/samourai-crew/audit/audit_nil_realm_hole.sh b/tests/samourai-crew/audit/audit_nil_realm_hole.sh index 3601d18..05b853c 100755 --- a/tests/samourai-crew/audit/audit_nil_realm_hole.sh +++ b/tests/samourai-crew/audit/audit_nil_realm_hole.sh @@ -98,15 +98,22 @@ if echo "$DEPLOY_R" | grep -q "OK!"; then echo "OK"; else fi # --- trigger attack --- +# Use maketx run (not maketx call) — Attack() has no cur realm declaration (PR #5669). +cat > "$TMPDIR/attack.gno" << EOF +package main + +import v "${PKGPATH_R}" + +func main() { v.Attack() } +EOF + echo -n " Calling Attack() (PDispatch.UseMutator -> EvilInt.Run -> gSlot.Field)... " -ATTACK=$(echo "$PASSWORD" | gnokey maketx call \ - -pkgpath "$PKGPATH_R" \ - -func "Attack" \ +ATTACK=$(echo "$PASSWORD" | gnokey maketx run \ -gas-fee 1000000ugnot -gas-wanted 5000000 \ -broadcast -chainid "$CHAINID" -remote "$RPC" \ -insecure-password-stdin \ -home "$GNOKEY_HOME" \ - "$KEY" 2>&1) + "$KEY" "$TMPDIR/attack.gno" 2>&1) # PATCHED: VM panics with "readonly tainted" — transaction rejected if echo "$ATTACK" | grep -qi "readonly\|tainted\|cannot.*modif"; then