Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions tests/samourai-crew/audit/audit_launder_panic_recover.sh
Original file line number Diff line number Diff line change
@@ -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
99 changes: 99 additions & 0 deletions tests/samourai-crew/audit/audit_launder_pointer_write.sh
Original file line number Diff line number Diff line change
@@ -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
146 changes: 146 additions & 0 deletions tests/samourai-crew/audit/audit_nil_realm_hole.sh
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions tests/samourai-crew/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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) ==="
Expand Down
Loading