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..05b853c --- /dev/null +++ b/tests/samourai-crew/audit/audit_nil_realm_hole.sh @@ -0,0 +1,146 @@ +#!/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 --- +# 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 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 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 f854465..1348218 100755 --- a/tests/samourai-crew/run_tests.sh +++ b/tests/samourai-crew/run_tests.sh @@ -172,8 +172,14 @@ 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) ==="