Always use W^X if possible. (#2429)

This always uses W^X (pages are never both writable and executable at
the same time) where possible, which is everywhere except
Solaris/illumos because it turns out wrapping `mprotect` there is
infeasible (without the helping hand of `x/sys/unix`).

This is mostly to improve security (make our executable code harder to
corrupt, accidentally or on purpose).

I expected it to make performance worse, and tried to mitigate it with
#2428). But at least on x64 macOS, it seems to make things better? Not
sure if this was a fluke:

```sh
% benchstat head wx  
goos: darwin
goarch: amd64
pkg: github.com/tetratelabs/wazero/internal/integration_test/stdlibs
cpu: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
                             │    head     │                 wx                  │
                             │   sec/op    │   sec/op     vs base                │
Zig/Compile/test-opt.wasm-12   5.410 ± ∞ ¹   4.783 ± ∞ ¹       ~ (p=1.000 n=1) ²
Zig/Run/test-opt.wasm-12       34.74 ± ∞ ¹   33.48 ± ∞ ¹       ~ (p=1.000 n=1) ²
Zig/Compile/test.wasm-12       5.978 ± ∞ ¹   5.412 ± ∞ ¹       ~ (p=1.000 n=1) ²
Zig/Run/test.wasm-12           35.67 ± ∞ ¹   34.47 ± ∞ ¹       ~ (p=1.000 n=1) ²
geomean                        14.15         13.15        -7.08%
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

                             │     head      │                  wx                   │
                             │     B/op      │     B/op       vs base                │
Zig/Compile/test-opt.wasm-12   359.5Mi ± ∞ ¹   359.5Mi ± ∞ ¹       ~ (p=1.000 n=1) ²
Zig/Run/test-opt.wasm-12       741.8Mi ± ∞ ¹   741.8Mi ± ∞ ¹       ~ (p=1.000 n=1) ²
Zig/Compile/test.wasm-12       574.3Mi ± ∞ ¹   574.3Mi ± ∞ ¹       ~ (p=1.000 n=1) ²
Zig/Run/test.wasm-12           1.297Gi ± ∞ ¹   1.297Gi ± ∞ ¹       ~ (p=1.000 n=1) ²
geomean                        671.5Mi         671.5Mi        -0.00%
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

                             │     head     │                  wx                  │
                             │  allocs/op   │  allocs/op    vs base                │
Zig/Compile/test-opt.wasm-12   336.2k ± ∞ ¹   336.2k ± ∞ ¹       ~ (p=1.000 n=1) ²
Zig/Run/test-opt.wasm-12       52.00k ± ∞ ¹   52.00k ± ∞ ¹       ~ (p=1.000 n=1) ³
Zig/Compile/test.wasm-12       289.7k ± ∞ ¹   289.7k ± ∞ ¹       ~ (p=1.000 n=1) ³
Zig/Run/test.wasm-12           2.156M ± ∞ ¹   2.156M ± ∞ ¹       ~ (p=1.000 n=1) ³
geomean                        323.3k         323.3k        -0.00%
¹ need >= 6 samples for confidence interval at level 0.95
² all samples are equal
³ need >= 4 samples to detect a difference at alpha level 0.05
```

Signed-off-by: Nuno Cruces <ncruces@users.noreply.github.com>
This commit is contained in:
Nuno Cruces
2025-10-22 19:35:53 +01:00
committed by GitHub
parent a14d2f95aa
commit b94a430704
11 changed files with 32 additions and 72 deletions
+6 -15
View File
@@ -342,11 +342,8 @@ func (e *engine) compileModule(ctx context.Context, module *wasm.Module, listene
machine.ResolveRelocations(refToBinaryOffset, importedFns, executable, rels, callTrampolineIslandOffsets)
}
if runtime.GOARCH == "arm64" {
// On arm64, we cannot give all of rwx at the same time, so we change it to exec.
if err = platform.MprotectRX(executable); err != nil {
return nil, err
}
if err = platform.MprotectRX(executable); err != nil {
return nil, err
}
cm.sharedFunctions = e.sharedFunctions
e.setFinalizer(cm.executables, executablesFinalizer)
@@ -500,11 +497,8 @@ func (e *engine) compileHostModule(ctx context.Context, module *wasm.Module, lis
wazevoapi.PerfMap.Flush(uintptr(unsafe.Pointer(&executable[0])), cm.functionOffsets)
}
if runtime.GOARCH == "arm64" {
// On arm64, we cannot give all of rwx at the same time, so we change it to exec.
if err = platform.MprotectRX(executable); err != nil {
return nil, err
}
if err = platform.MprotectRX(executable); err != nil {
return nil, err
}
e.setFinalizer(cm.executables, executablesFinalizer)
return cm, nil
@@ -778,11 +772,8 @@ func mmapExecutable(src []byte) []byte {
copy(executable, src)
if runtime.GOARCH == "arm64" {
// On arm64, we cannot give all of rwx at the same time, so we change it to exec.
if err = platform.MprotectRX(executable); err != nil {
panic(err)
}
if err = platform.MprotectRX(executable); err != nil {
panic(err)
}
return executable
}
+2 -6
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"hash/crc32"
"io"
"runtime"
"unsafe"
"github.com/tetratelabs/wazero/experimental"
@@ -246,11 +245,8 @@ func deserializeCompiledModule(wazeroVersion string, reader io.ReadCloser) (cm *
return nil, false, fmt.Errorf("compilationcache: checksum mismatch (expected %d, got %d)", expected, checksum)
}
if runtime.GOARCH == "arm64" {
// On arm64, we cannot give all of rwx at the same time, so we change it to exec.
if err = platform.MprotectRX(executable); err != nil {
return nil, false, err
}
if err = platform.MprotectRX(executable); err != nil {
return nil, false, err
}
cm.executable = executable
}
+8 -4
View File
@@ -59,12 +59,16 @@ func init() {
})
}
func mmapCodeSegment(size, prot int) ([]byte, error) {
flags := syscall.MAP_ANON | syscall.MAP_PRIVATE
func mmapCodeSegment(size int) ([]byte, error) {
flag := syscall.MAP_ANON | syscall.MAP_PRIVATE
prot := syscall.PROT_READ | syscall.PROT_WRITE
if noopMprotectRX {
prot = syscall.PROT_READ | syscall.PROT_WRITE | syscall.PROT_EXEC
}
for _, hugePagesConfig := range hugePagesConfigs {
if hugePagesConfig.match(size) {
b, err := syscall.Mmap(-1, 0, size, prot, flags|hugePagesConfig.flag)
b, err := syscall.Mmap(-1, 0, size, prot, flag|hugePagesConfig.flag)
if err != nil {
continue
}
@@ -72,5 +76,5 @@ func mmapCodeSegment(size, prot int) ([]byte, error) {
}
}
return syscall.Mmap(-1, 0, size, prot, flags)
return syscall.Mmap(-1, 0, size, prot, flag)
}
+5 -1
View File
@@ -5,7 +5,11 @@ package platform
import "syscall"
func mmapCodeSegment(size, prot int) ([]byte, error) {
func mmapCodeSegment(size int) ([]byte, error) {
prot := syscall.PROT_READ | syscall.PROT_WRITE
if noopMprotectRX {
prot = syscall.PROT_READ | syscall.PROT_WRITE | syscall.PROT_EXEC
}
return syscall.Mmap(
-1,
0,
+1 -24
View File
@@ -2,31 +2,8 @@
package platform
import (
"syscall"
)
const (
mmapProtAMD64 = syscall.PROT_READ | syscall.PROT_WRITE | syscall.PROT_EXEC
mmapProtARM64 = syscall.PROT_READ | syscall.PROT_WRITE
)
import "syscall"
func munmapCodeSegment(code []byte) error {
return syscall.Munmap(code)
}
// mmapCodeSegmentAMD64 gives all read-write-exec permission to the mmap region
// to enter the function. Otherwise, segmentation fault exception is raised.
func mmapCodeSegmentAMD64(size int) ([]byte, error) {
// The region must be RWX: RW for writing native codes, X for executing the region.
return mmapCodeSegment(size, mmapProtAMD64)
}
// mmapCodeSegmentARM64 cannot give all read-write-exec permission to the mmap region.
// Otherwise, the mmap systemcall would raise an error. Here we give read-write
// to the region so that we can write contents at call-sites. Callers are responsible to
// execute MprotectRX on the returned buffer.
func mmapCodeSegmentARM64(size int) ([]byte, error) {
// The region must be RW: RW for writing native codes.
return mmapCodeSegment(size, mmapProtARM64)
}
+1 -5
View File
@@ -13,11 +13,7 @@ func munmapCodeSegment(code []byte) error {
panic(errUnsupported)
}
func mmapCodeSegmentAMD64(size int) ([]byte, error) {
panic(errUnsupported)
}
func mmapCodeSegmentARM64(size int) ([]byte, error) {
func mmapCodeSegment(size int) ([]byte, error) {
panic(errUnsupported)
}
+1 -10
View File
@@ -56,16 +56,7 @@ func virtualProtect(address, size, newprotect uintptr, oldprotect *uint32) error
return nil
}
func mmapCodeSegmentAMD64(size int) ([]byte, error) {
p, err := allocateMemory(uintptr(size), windows_PAGE_EXECUTE_READWRITE)
if err != nil {
return nil, err
}
return unsafe.Slice((*byte)(unsafe.Pointer(p)), size), nil
}
func mmapCodeSegmentARM64(size int) ([]byte, error) {
func mmapCodeSegment(size int) ([]byte, error) {
p, err := allocateMemory(uintptr(size), windows_PAGE_READWRITE)
if err != nil {
return nil, err
+2
View File
@@ -7,6 +7,8 @@ import (
"unsafe"
)
const noopMprotectRX = false
// MprotectRX is like syscall.Mprotect with RX permission, defined locally so that BSD compiles.
func MprotectRX(b []byte) (err error) {
var _p0 unsafe.Pointer
+2
View File
@@ -4,6 +4,8 @@ package platform
import "syscall"
const noopMprotectRX = false
// MprotectRX is like syscall.Mprotect with RX permission.
func MprotectRX(b []byte) (err error) {
return syscall.Mprotect(b, syscall.PROT_READ|syscall.PROT_EXEC)
+3 -2
View File
@@ -2,8 +2,9 @@
package platform
import "syscall"
const noopMprotectRX = true
func MprotectRX(b []byte) error {
return syscall.ENOTSUP
// Assume we already called mmap with at least RX.
return nil
}
+1 -5
View File
@@ -40,11 +40,7 @@ func MmapCodeSegment(size int) ([]byte, error) {
if size == 0 {
panic("BUG: MmapCodeSegment with zero length")
}
if runtime.GOARCH == "amd64" {
return mmapCodeSegmentAMD64(size)
} else {
return mmapCodeSegmentARM64(size)
}
return mmapCodeSegment(size)
}
// MunmapCodeSegment unmaps the given memory region.