mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-25 09:01:29 +02:00
360 lines
13 KiB
Go
360 lines
13 KiB
Go
package elevate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"unsafe"
|
|
|
|
"github.com/ebitengine/purego"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// Authorization Services, reached through purego rather than cgo so the released
|
|
// binaries keep building with CGO_ENABLED=0.
|
|
//
|
|
// The prompt belongs to this process, which is what makes it carry the
|
|
// application's name and our own explanation. Going through osascript instead puts
|
|
// the very same trampoline behind a dialog attributed to osascript, and means
|
|
// handing a shell a command line to re-parse.
|
|
//
|
|
// # On AuthorizationExecuteWithPrivileges
|
|
//
|
|
// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on
|
|
// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's
|
|
// been deprecated for many years. Do not use it in a widely distributed product."
|
|
// It is used here anyway, knowingly, because the alternatives Apple offers are for
|
|
// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless —
|
|
// and NetBird already has what they would install: a launchd daemon running as
|
|
// root. What is missing is only a way for an unprivileged client to ask it to act.
|
|
//
|
|
// The way to that without a deprecated call is to authorize the client instead of
|
|
// elevating one: the app takes the right with AuthorizationCreate, passes the
|
|
// AuthorizationExternalForm to the daemon, and the daemon checks it with
|
|
// AuthorizationCopyRights before acting — none of which is deprecated. It is the
|
|
// better design and it is where this should end up. It also means the daemon
|
|
// accepting an authorization over its control socket, which is a new way to be
|
|
// asked for privileged work and wants reviewing as such, so it is deliberately not
|
|
// bundled in with the rest of this.
|
|
//
|
|
// Until then, three things keep the deprecation from being a trap. Every symbol is
|
|
// resolved with an error rather than a panic, so a macOS that has dropped this
|
|
// function leaves the app offering the user a command instead of crashing on the
|
|
// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the
|
|
// fallback is the same one an agent-less Linux session gets. And the whole path
|
|
// runs under guard, which turns a panic out of the FFI layer into that same
|
|
// fallback.
|
|
//
|
|
// The trampoline passes on the environment it was given, so what it starts as root
|
|
// must be an executable this user's peers cannot influence: that is what
|
|
// trustedSelf refuses, and what signing the binary settles for the loader.
|
|
|
|
const (
|
|
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
|
libSystem = "/usr/lib/libSystem.B.dylib"
|
|
|
|
// trampoline is what the framework hands the tool to. Present on every macOS,
|
|
// and worth confirming before offering a prompt rather than mid-prompt.
|
|
trampoline = "/usr/libexec/security_authtrampoline"
|
|
)
|
|
|
|
// rightExecute is the right an administrator holds, and what
|
|
// AuthorizationExecuteWithPrivileges requires of us.
|
|
const rightExecute = "system.privilege.admin"
|
|
|
|
// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above
|
|
// the system's in the dialog. It is about the change rather than the mechanism.
|
|
const (
|
|
promptKey = "prompt"
|
|
promptText = "NetBird needs to change a setting that grants SSH access to this computer."
|
|
)
|
|
|
|
// OSStatus values from SecBase.h that mean something to us; anything else is
|
|
// reported as it comes.
|
|
const (
|
|
errAuthorizationSuccess = 0
|
|
errAuthorizationDenied = -60005
|
|
errAuthorizationCanceled = -60006
|
|
errAuthorizationInteractionNotAllowed = -60007
|
|
errAuthorizationToolExecuteFailure = -60031
|
|
errAuthorizationToolEnvironmentError = -60032
|
|
)
|
|
|
|
// AuthorizationFlags from Authorization.h.
|
|
const (
|
|
flagDefaults = 0
|
|
flagInteractionAllowed = 1 << 0
|
|
flagExtendRights = 1 << 1
|
|
flagDestroyRights = 1 << 3
|
|
flagPreAuthorize = 1 << 4
|
|
)
|
|
|
|
// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives
|
|
// meaning to. 32 bytes on both amd64 and arm64.
|
|
type authorizationItem struct {
|
|
name *byte
|
|
valueLength uintptr
|
|
value unsafe.Pointer
|
|
// flags is reserved by the API and always zero. Declared because the layout
|
|
// is the contract: without it the struct is 24 bytes where C reads 32.
|
|
flags uint32 //nolint:unused // part of the C layout
|
|
}
|
|
|
|
// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an
|
|
// AuthorizationRights and an AuthorizationEnvironment.
|
|
type authorizationItemSet struct {
|
|
count uint32
|
|
items *authorizationItem
|
|
}
|
|
|
|
var (
|
|
authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32
|
|
authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32
|
|
authorizationFree func(authorization uintptr, flags uint32) int32
|
|
fileno func(stream uintptr) int32
|
|
fclose func(stream uintptr) int32
|
|
|
|
loadOnce sync.Once
|
|
loadErr error
|
|
)
|
|
|
|
// load resolves the functions once. A framework that cannot be opened, or a symbol
|
|
// that is no longer there, leaves the host without a mechanism rather than taking
|
|
// the process down with it: see the note on deprecation above.
|
|
func load() error {
|
|
loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) })
|
|
return loadErr
|
|
}
|
|
|
|
// guard turns a panic out of the FFI layer into an error, so an API that has
|
|
// changed under us costs the user a prompt rather than the window they were
|
|
// clicking in. purego panics on a signature it cannot map, and this is the one
|
|
// place in the client that calls a deprecated system function.
|
|
//
|
|
// It catches Go panics, which is what purego raises. A fault inside the framework
|
|
// itself is not a panic and not recoverable; the layout the tests pin down is what
|
|
// stands between us and that.
|
|
func guard(what string, fn func() error) (err error) {
|
|
defer func() {
|
|
r := recover()
|
|
if r == nil {
|
|
return
|
|
}
|
|
log.Errorf("%s panicked: %v", what, r)
|
|
err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r)
|
|
}()
|
|
return fn()
|
|
}
|
|
|
|
func resolve() error {
|
|
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", securityFramework, err)
|
|
}
|
|
system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", libSystem, err)
|
|
}
|
|
|
|
// purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a
|
|
// deprecated function's disappearance should reach the user.
|
|
for _, fn := range []struct {
|
|
ptr any
|
|
handle uintptr
|
|
name string
|
|
}{
|
|
{&authorizationCreate, security, "AuthorizationCreate"},
|
|
{&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"},
|
|
{&authorizationFree, security, "AuthorizationFree"},
|
|
{&fileno, system, "fileno"},
|
|
{&fclose, system, "fclose"},
|
|
} {
|
|
symbol, err := purego.Dlsym(fn.handle, fn.name)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
|
}
|
|
if symbol == 0 {
|
|
return fmt.Errorf("resolve %s: not present on this system", fn.name)
|
|
}
|
|
purego.RegisterFunc(fn.ptr, symbol)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// run asks the system to run self as root: first for the right, which is what puts
|
|
// up the authentication dialog and collects the password or takes the Touch ID,
|
|
// then for the tool. The credentials go to the system's authorization trampoline
|
|
// and never to us.
|
|
//
|
|
// The context bounds only our own waiting; the dialog belongs to the system and
|
|
// closes when the user answers it.
|
|
func run(ctx context.Context, self string, args []string) error {
|
|
if err := load(); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
|
|
return guard("asking for privileges", func() error {
|
|
authorization, err := authorize()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer authorizationFree(authorization, flagDestroyRights)
|
|
|
|
return execute(ctx, authorization, self, args)
|
|
})
|
|
}
|
|
|
|
func mechanismAvailable() bool {
|
|
if err := load(); err != nil {
|
|
return false
|
|
}
|
|
info, err := os.Stat(trampoline)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
// authorize obtains the right, prompting for it. A dismissed dialog comes back as
|
|
// errAuthorizationCanceled and a password given up on as errAuthorizationDenied;
|
|
// both are the user's answer rather than a failure.
|
|
func authorize() (uintptr, error) {
|
|
var pinner runtime.Pinner
|
|
defer pinner.Unpin()
|
|
|
|
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
|
environment := itemSet(&pinner, promptItem(&pinner))
|
|
|
|
var authorization uintptr
|
|
status := authorizationCreate(rights, environment,
|
|
flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization)
|
|
|
|
switch status {
|
|
case errAuthorizationSuccess:
|
|
return authorization, nil
|
|
case errAuthorizationCanceled, errAuthorizationDenied:
|
|
return 0, ErrDeclined
|
|
case errAuthorizationInteractionNotAllowed:
|
|
// Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or
|
|
// a session with no window server.
|
|
return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable)
|
|
default:
|
|
return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status)
|
|
}
|
|
}
|
|
|
|
// execute runs the tool with the right in hand and waits for it by reading the pipe
|
|
// it is given until the tool closes it.
|
|
//
|
|
// AuthorizationExecuteWithPrivileges reports no exit status and does not say what
|
|
// process it started, which is why the one-shot says so itself: what it prints is
|
|
// the only evidence that the change was applied.
|
|
func execute(ctx context.Context, authorization uintptr, self string, args []string) error {
|
|
var pinner runtime.Pinner
|
|
defer pinner.Unpin()
|
|
|
|
argv := make([]uintptr, 0, len(args)+1)
|
|
for _, arg := range args {
|
|
argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg))))
|
|
}
|
|
argv = append(argv, 0)
|
|
pinner.Pin(&argv[0])
|
|
|
|
var pipe uintptr
|
|
status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe)
|
|
switch status {
|
|
case errAuthorizationSuccess:
|
|
case errAuthorizationCanceled:
|
|
return ErrDeclined
|
|
case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError:
|
|
// The right was granted and the tool still did not start. Nothing the user
|
|
// can do about it from here, so point them at the command instead.
|
|
return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status)
|
|
default:
|
|
return fmt.Errorf("run %s elevated: OSStatus %d", self, status)
|
|
}
|
|
|
|
out, err := readPipe(ctx, pipe)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return checkApplied(out)
|
|
}
|
|
|
|
// checkApplied reads the one-shot's report, which stands in for the exit status
|
|
// there is no way to ask for here. A run that said nothing did not apply the
|
|
// change, whatever else went on.
|
|
func checkApplied(out string) error {
|
|
if !strings.Contains(out, AppliedMarker) {
|
|
return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// readPipe drains the tool's output, which ends when the tool exits and is
|
|
// therefore also how we wait for it.
|
|
func readPipe(ctx context.Context, pipe uintptr) (string, error) {
|
|
if pipe == 0 {
|
|
return "", nil
|
|
}
|
|
defer fclose(pipe)
|
|
|
|
fd := int(fileno(pipe))
|
|
if fd < 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var out strings.Builder
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return out.String(), err
|
|
}
|
|
n, err := syscall.Read(fd, buf)
|
|
if n > 0 {
|
|
out.Write(buf[:n])
|
|
}
|
|
switch {
|
|
case errors.Is(err, syscall.EINTR):
|
|
// A signal landed mid-read, which says nothing about the tool.
|
|
continue
|
|
case err != nil:
|
|
log.Debugf("read the elevated process's output: %v", err)
|
|
return out.String(), nil
|
|
case n <= 0:
|
|
// End of file: the tool closed the pipe, which is how it exiting
|
|
// reaches us.
|
|
return out.String(), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// itemSet builds an AuthorizationItemSet over items, pinned for the call.
|
|
func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet {
|
|
pinner.Pin(&items[0])
|
|
set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]}
|
|
pinner.Pin(set)
|
|
return set
|
|
}
|
|
|
|
// promptItem is the environment entry carrying our sentence for the dialog.
|
|
func promptItem(pinner *runtime.Pinner) authorizationItem {
|
|
value := []byte(promptText)
|
|
pinner.Pin(&value[0])
|
|
return authorizationItem{
|
|
name: cString(pinner, promptKey),
|
|
valueLength: uintptr(len(value)),
|
|
value: unsafe.Pointer(&value[0]),
|
|
}
|
|
}
|
|
|
|
// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for
|
|
// the duration of the call.
|
|
func cString(pinner *runtime.Pinner, s string) *byte {
|
|
b := append([]byte(s), 0)
|
|
pinner.Pin(&b[0])
|
|
return &b[0]
|
|
}
|