mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 10:26:52 +01:00
A program reading the info API opens the pipe by name and has no owner check of its own, so for a name any local user could create first, whoever did would be answering its queries. nebula refuses to serve a held name and logs it, but the program cannot tell. The daemon now warns at bind when the configured name is not under \\.\pipe\ProtectedPrefix\Administrators\, and the example config and CHANGELOG say to put it there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
273 lines
12 KiB
Go
273 lines
12 KiB
Go
//go:build windows
|
|
|
|
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Microsoft/go-winio"
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
// pipePrefix is where windows named pipes live. The pipe's own security descriptor
|
|
// carries the access control that the socket's mode carries on unix.
|
|
const pipePrefix = `\\.\pipe\`
|
|
|
|
// protectedPrefix is the pipe namespace that only administrators and SYSTEM may create
|
|
// names under; the kernel refuses everyone else. The default name lives there, so the
|
|
// name itself says who could have created the pipe answering it: no ordinary user can
|
|
// have put anything under it ahead of the daemon. Tailscale, WireGuard and NetBird keep
|
|
// their daemon pipes there for the same reason. nebula on windows is already an
|
|
// administrator, since creating the tun adapter requires it.
|
|
const protectedPrefix = pipePrefix + `ProtectedPrefix\Administrators\`
|
|
|
|
const network = "pipe"
|
|
|
|
// currentUID is the identity the unix owner checks are made for. There is no uid here
|
|
// and no per-connection check that would read one, so the value is never consulted; it
|
|
// exists so a Server is built the same way on every platform.
|
|
var currentUID = func() uint32 { return 0 }
|
|
|
|
// listen creates the named pipe at path with a security descriptor that admits the user
|
|
// nebula runs as, SYSTEM and Administrators, and the group acc names, if any, for
|
|
// reading and writing, which is what connecting to a pipe takes. acc's mode means
|
|
// nothing to a pipe.
|
|
//
|
|
// The first instance is created, never opened, so a name some other process already
|
|
// holds is refused rather than joined: that process would otherwise answer clients in
|
|
// nebula's place. Under protectedPrefix only an administrator could hold it; anywhere
|
|
// else any account could. Either way the refusal is reported as errEndpointHeld for the
|
|
// caller to log as the event it is, except when the refusal is the namespace's own: a
|
|
// daemon that is not an administrator cannot create under protectedPrefix at all, and
|
|
// that is told apart so the operator is sent to elevate or to name a pipe elsewhere
|
|
// rather than to look for a process that is not there.
|
|
func listen(path string, acc Access) (net.Listener, error) {
|
|
sddl, err := sddlFor(acc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not build a security descriptor for %s: %w", path, err)
|
|
}
|
|
|
|
ln, err := winio.ListenPipe(path, &winio.PipeConfig{
|
|
SecurityDescriptor: sddl,
|
|
InputBufferSize: int32(MaxRequestBytes),
|
|
OutputBufferSize: 64 * 1024,
|
|
})
|
|
if err != nil {
|
|
return nil, listenRefused(path, isAdministrator(), err)
|
|
}
|
|
|
|
return ln, nil
|
|
}
|
|
|
|
// listenRefused turns a failure to create the pipe at path into the error the caller
|
|
// logs. admin says whether this process may create under protectedPrefix.
|
|
//
|
|
// ERROR_ACCESS_DENIED is ambiguous: it is what the kernel says to a non-administrator
|
|
// creating under protectedPrefix, and also what an existing instance whose DACL does
|
|
// not admit us says to a second create. A process that is an administrator cannot be
|
|
// getting the first, so for it, and for every name outside the prefix, access denied
|
|
// means held.
|
|
func listenRefused(path string, admin bool, err error) error {
|
|
if errors.Is(err, windows.ERROR_ACCESS_DENIED) && isProtectedPath(path) && !admin {
|
|
return fmt.Errorf("creating %s requires an administrator, as only administrators may create pipes under %s; run nebula elevated, or set %s to a pipe name outside it: %w", path, protectedPrefix, CtlSocketKey, err)
|
|
}
|
|
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
|
|
return fmt.Errorf("%w: %s: %w", errEndpointHeld, path, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// isProtectedPath says whether path is a name under protectedPrefix. Pipe names are
|
|
// case insensitive, so the comparison is too.
|
|
func isProtectedPath(path string) bool {
|
|
return len(path) > len(protectedPrefix) && strings.EqualFold(path[:len(protectedPrefix)], protectedPrefix)
|
|
}
|
|
|
|
// isAdministrator says whether this process holds Administrators as an enabled group,
|
|
// which is what creating under protectedPrefix takes. SYSTEM holds it; an elevated
|
|
// administrator holds it; an administrator's unelevated token carries it deny-only,
|
|
// which the membership check correctly reads as no. The membership is checked against
|
|
// the thread's own token: CheckTokenMembership wants an impersonation token and makes
|
|
// one from the primary token when handed none. A check that cannot be made is read as
|
|
// not an administrator, which only changes the wording of a failure that has already
|
|
// happened.
|
|
func isAdministrator() bool {
|
|
admins, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
member, err := windows.Token(0).IsMember(admins)
|
|
return err == nil && member
|
|
}
|
|
|
|
// sddlFor grants full access to the account nebula is running as, to SYSTEM and to
|
|
// Administrators, and pipeClientAccess to the group acc names, if any; nobody else. P
|
|
// makes the DACL protected, so it does not inherit entries that would widen it. It is
|
|
// dockerd's descriptor for its pipe, with its -G group in the same place, except for
|
|
// the group's mask; see pipeClientAccess.
|
|
//
|
|
// Administrators is the counterpart of the uid 0 allowance in the unix peer check. The
|
|
// service normally runs as LocalSystem, so without it an operator in an elevated shell
|
|
// could not reach the daemon at all; and an administrator can already stop the service,
|
|
// so this grants no authority they did not have.
|
|
func sddlFor(acc Access) (string, error) {
|
|
token := windows.GetCurrentProcessToken()
|
|
|
|
user, err := token.GetTokenUser()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sddl := fmt.Sprintf("D:P(A;;GA;;;%s)(A;;GA;;;SY)(A;;GA;;;BA)", user.User.Sid.String())
|
|
if acc.shared() {
|
|
sddl += fmt.Sprintf("(A;;0x%x;;;%s)", pipeClientAccess, acc.Group.ID)
|
|
}
|
|
return sddl, nil
|
|
}
|
|
|
|
// pipeClientAccess is what a client needs on the pipe to talk to the daemon, and all a
|
|
// configured group is granted: read and write the data, read the pipe's attributes
|
|
// and its security descriptor, which the owner check needs, and wait on it. Not the
|
|
// generic rights. On a named pipe GENERIC_WRITE carries FILE_CREATE_PIPE_INSTANCE, the
|
|
// right to put a second instance under the same name, and a group member with that
|
|
// could sit on the name and answer other clients in the daemon's place; the client's
|
|
// owner check would not catch it, since the instance it reached would be the
|
|
// member's. A client therefore has to ask for exactly this, as dial does, and a
|
|
// program that opens the info pipe with generic read and write is refused.
|
|
const pipeClientAccess uint32 = windows.FILE_READ_DATA | windows.FILE_WRITE_DATA |
|
|
windows.FILE_READ_ATTRIBUTES | windows.READ_CONTROL | windows.SYNCHRONIZE
|
|
|
|
// removeSocket is a no-op: a named pipe disappears when its last handle closes, so
|
|
// there is nothing to unlink and no stale instance to clean up.
|
|
func removeSocket(path string, id socketID) {}
|
|
|
|
// identifySocket has no node to identify: a pipe is its handle, and it cannot be
|
|
// removed from under the daemon, so an endpoint on its path is always still there.
|
|
func identifySocket(path string) (socketID, bool) { return socketID{}, true }
|
|
|
|
// applyAccess cannot change a pipe's access in place: its security descriptor is fixed
|
|
// when it is created, so a change means creating it again, which the caller does.
|
|
func applyAccess(path string, acc Access) error { return errReopenForAccess }
|
|
|
|
// dial connects to the pipe and, before the connection carries anything, checks who
|
|
// serves it. The request names a command and its arguments, so it must not reach a
|
|
// pipe that some other account put under nebula's name. For the default name the
|
|
// namespace already promises that, since only an administrator could have created it;
|
|
// the check is what covers a CtlSocketKey override outside protectedPrefix, and costs
|
|
// nothing to make on every name.
|
|
func dial(path string, timeout time.Duration) (net.Conn, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
conn, err := winio.DialPipeAccess(ctx, path, pipeClientAccess)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := checkPipeOwner(conn, path); err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
|
|
return conn, nil
|
|
}
|
|
|
|
// checkPipeOwner refuses a pipe whose owner is not an account that could be running
|
|
// nebula. It is the unix owner check made from the other side: the handle winio dialed
|
|
// exposes the pipe object, and the object's owner is whoever created it.
|
|
//
|
|
// The owner is what makes this sound. An account can only create an object owned by
|
|
// itself, or by a SID it holds with the owner flag, unless it has SeRestorePrivilege,
|
|
// which ordinary users do not; so a pipe owned by SYSTEM, Administrators or a service
|
|
// account was put there by one of them, and never by a user squatting the name. winio
|
|
// dials with SECURITY_ANONYMOUS, so the impostor could not have impersonated the client
|
|
// either, but it could have read the command and answered it with whatever it liked.
|
|
func checkPipeOwner(conn net.Conn, path string) error {
|
|
h, ok := conn.(interface{ Fd() uintptr })
|
|
if !ok {
|
|
return fmt.Errorf("%s: cannot identify who serves the pipe", path)
|
|
}
|
|
|
|
sd, err := windows.GetSecurityInfo(windows.Handle(h.Fd()), windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: could not read the pipe's owner: %w", path, err)
|
|
}
|
|
|
|
owner, _, err := sd.Owner()
|
|
if err != nil {
|
|
return fmt.Errorf("%s: could not read the pipe's owner: %w", path, err)
|
|
}
|
|
if owner == nil {
|
|
return fmt.Errorf("%s has no owner; refusing to send to it", path)
|
|
}
|
|
|
|
me, err := windows.GetCurrentProcessToken().GetTokenUser()
|
|
if err != nil {
|
|
return fmt.Errorf("%s: could not read own identity: %w", path, err)
|
|
}
|
|
|
|
if ownerAccepted(owner, me.User.Sid) {
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("%s is served by %s, not by nebula; run nebula ctl as that user, or pass -config or -socket to name the daemon's pipe", path, describeSID(owner))
|
|
}
|
|
|
|
// ownerAccepted says whether a pipe owned by owner may be sent a request by a client
|
|
// running as me: the client's own account, and the two accounts a daemon runs under
|
|
// that no ordinary user can create an object as, SYSTEM and Administrators. Another
|
|
// account is refused, whoever the client is; the daemon's DACL decides who may connect,
|
|
// and this decides who may answer.
|
|
func ownerAccepted(owner, me *windows.SID) bool {
|
|
return owner.Equals(me) ||
|
|
owner.IsWellKnown(windows.WinLocalSystemSid) ||
|
|
owner.IsWellKnown(windows.WinBuiltinAdministratorsSid)
|
|
}
|
|
|
|
// describeSID renders a SID with its account name when the host can say what it is, so
|
|
// a message about somebody else's pipe tells the operator who that is.
|
|
func describeSID(sid *windows.SID) string {
|
|
account, domain, _, err := sid.LookupAccount("")
|
|
if err != nil || account == "" {
|
|
return sid.String()
|
|
}
|
|
if domain == "" {
|
|
return account
|
|
}
|
|
return domain + `\` + account
|
|
}
|
|
|
|
// DefaultSocketPath is the control pipe when the config names none. It is under
|
|
// protectedPrefix, so whatever answers it was created by an administrator or SYSTEM; see
|
|
// there.
|
|
const DefaultSocketPath = protectedPrefix + "nebula-ctl"
|
|
|
|
// checkSocketPath refuses a configured name that is not a full pipe name. A pipe lives
|
|
// under pipePrefix and nowhere else, so a bare name has no meaning here, and one under
|
|
// pipePrefix is used as given. An override outside protectedPrefix is a name any
|
|
// account could have created first; the owner check in dial is what protects a client
|
|
// of one.
|
|
// unprotectedNameWarning says what a pipe name outside protectedPrefix costs, or "" for
|
|
// one under it. A program reading the info API opens the pipe by name and has no owner
|
|
// check of its own, so for a name any local user could create first, whoever did
|
|
// would be answering its queries; the daemon refuses to serve a held name and logs it,
|
|
// but the program cannot tell. The default control pipe is under the prefix; an info
|
|
// pipe is wherever the operator put it.
|
|
func unprotectedNameWarning(path string) string {
|
|
if len(path) >= len(protectedPrefix) && strings.EqualFold(path[:len(protectedPrefix)], protectedPrefix) {
|
|
return ""
|
|
}
|
|
return "any local user can create a pipe by this name ahead of the daemon and answer in its place; name it under " + protectedPrefix
|
|
}
|
|
|
|
func checkSocketPath(key, value string) error {
|
|
if len(value) <= len(pipePrefix) || !strings.EqualFold(value[:len(pipePrefix)], pipePrefix) {
|
|
return fmt.Errorf(`%s must be a full pipe name under %s, got %q`, key, pipePrefix, value)
|
|
}
|
|
return nil
|
|
}
|