summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrew Lytvynov <awly@tailscale.com>2023-11-02 16:39:08 -0600
committerAndrew Lytvynov <awly@tailscale.com>2023-11-02 16:39:08 -0600
commit7ee8828139382ae814662d828fbc5ba4804ed4b2 (patch)
treeba2be9865c752bb333bc443b9a2fdfd4956cc4c5
parent71450164146ec634dce148969ec96b785476d768 (diff)
downloadtailscale-awly/linux-sudoers-local-admin-poc.tar.xz
tailscale-awly/linux-sudoers-local-admin-poc.zip
ipn: mark /etc/sudoers members as local admin on linuxawly/linux-sudoers-local-admin-poc
Just a POC, probably a bad idea.
-rw-r--r--ipn/ipnauth/ipnauth.go95
-rw-r--r--ipn/ipnserver/server.go30
-rw-r--r--ipn/localapi/localapi.go4
-rw-r--r--util/groupmember/groupmember.go23
4 files changed, 120 insertions, 32 deletions
diff --git a/ipn/ipnauth/ipnauth.go b/ipn/ipnauth/ipnauth.go
index 5dc2e2768..d16bf1123 100644
--- a/ipn/ipnauth/ipnauth.go
+++ b/ipn/ipnauth/ipnauth.go
@@ -5,15 +5,20 @@
package ipnauth
import (
+ "bufio"
"errors"
"fmt"
"io"
+ "log"
"net"
"net/netip"
"os"
"os/user"
+ "path/filepath"
"runtime"
+ "slices"
"strconv"
+ "strings"
"inet.af/peercred"
"tailscale.com/envknob"
@@ -191,21 +196,47 @@ func (ci *ConnIdentity) IsReadonlyConn(operatorUID string, logf logger.Logf) boo
return ro
}
+// IsLocalAdmin reports whether the connected user has local administrative
+// privileges on the host. This means root, or one of:
+//
+// - Windows: member of the Administrators group
+// - macOS: member of the admin group
+// - Linux: member of any sudoers group (usually "sudo" or "wheel")
+func (ci *ConnIdentity) IsLocalAdmin() (bool, error) {
+ if ci.creds == nil {
+ return false, nil
+ }
+ uid, ok := ci.creds.UserID()
+ if !ok {
+ return false, nil
+ }
+ if uid == "0" {
+ return true, nil
+ }
+ return isLocalAdmin(uid)
+}
+
func isLocalAdmin(uid string) (bool, error) {
u, err := user.LookupId(uid)
if err != nil {
return false, err
}
- var adminGroup string
+ var adminGroups []string
switch {
case runtime.GOOS == "darwin":
- adminGroup = "admin"
+ adminGroups = []string{"admin"}
case distro.Get() == distro.QNAP:
- adminGroup = "administrators"
+ adminGroups = []string{"administrators"}
+ case runtime.GOOS == "linux":
+ adminGroups, err = linuxSudoersGroups("/etc/sudoers")
+ log.Printf("========= linuxSudoersGroups(etc/sudoers): %q %v", adminGroups, err)
+ if err != nil {
+ return false, err
+ }
default:
return false, fmt.Errorf("no system admin group found")
}
- return groupmember.IsMemberOfGroup(adminGroup, u.Username)
+ return groupmember.IsMemberOfAnyGroup(u.Username, adminGroups...)
}
func peerPid(entries []netstat.Entry, la, ra netip.AddrPort) int {
@@ -216,3 +247,59 @@ func peerPid(entries []netstat.Entry, la, ra netip.AddrPort) int {
}
return 0
}
+
+func linuxSudoersGroups(path string) ([]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ // We're looking for lines like:
+ //
+ // %wheel ALL=(ALL:ALL) ALL
+ // %sudo ALL=(ALL:ALL) ALL
+ //
+ // where group name after % is allowed to sudo as any user and run any
+ // command. Membership in these groups is equivalent to local admin.
+ s := bufio.NewScanner(f)
+ var groups []string
+ for s.Scan() {
+ line := s.Text()
+ if strings.HasPrefix(line, "@includedir ") {
+ dir := strings.TrimPrefix(line, "@includedir ")
+ paths, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ for _, p := range paths {
+ if !p.Type().IsRegular() {
+ continue
+ }
+ incGroups, err := linuxSudoersGroups(filepath.Join(dir, p.Name()))
+ log.Printf("========= linuxSudoersGroups(%q): %q %v", filepath.Join(dir, p.Name()), incGroups, err)
+ if err != nil {
+ return nil, err
+ }
+ groups = append(groups, incGroups...)
+ }
+ continue
+ }
+ if !strings.HasPrefix(line, "%") {
+ continue
+ }
+ parts := strings.SplitN(line, " ", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ if !slices.Contains([]string{"ALL=(ALL:ALL) ALL", "ALL=(ALL) ALL"}, parts[1]) {
+ continue
+ }
+ group := strings.TrimPrefix(parts[0], "%")
+ if group != "" {
+ groups = append(groups, group)
+ }
+ }
+
+ return groups, s.Err()
+}
diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go
index 755919275..9cdb90905 100644
--- a/ipn/ipnserver/server.go
+++ b/ipn/ipnserver/server.go
@@ -202,7 +202,10 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
lah := localapi.NewHandler(lb, s.logf, s.netMon, s.backendLogID)
lah.PermitRead, lah.PermitWrite = s.localAPIPermissions(ci)
lah.PermitCert = s.connCanFetchCerts(ci)
- lah.CallerIsLocalAdmin = s.connIsLocalAdmin(ci)
+ lah.CallerIsLocalAdmin, err = ci.IsLocalAdmin()
+ if err != nil {
+ s.logf("IsLocalAdmin: %v", err)
+ }
lah.ServeHTTP(w, r)
return
}
@@ -364,31 +367,6 @@ func (s *Server) connCanFetchCerts(ci *ipnauth.ConnIdentity) bool {
return false
}
-// connIsLocalAdmin reports whether ci has administrative access to the local
-// machine, for whatever that means with respect to the current OS.
-//
-// This returns true only on Windows machines when the client user is a
-// member of the built-in Administrators group (but not necessarily elevated).
-// This is useful because, on Windows, tailscaled itself always runs with
-// elevated rights: we want to avoid privilege escalation for certain mutative operations.
-func (s *Server) connIsLocalAdmin(ci *ipnauth.ConnIdentity) bool {
- tok, err := ci.WindowsToken()
- if err != nil {
- if !errors.Is(err, ipnauth.ErrNotImplemented) {
- s.logf("ipnauth.ConnIdentity.WindowsToken() error: %v", err)
- }
- return false
- }
- defer tok.Close()
-
- isAdmin, err := tok.IsAdministrator()
- if err != nil {
- s.logf("ipnauth.WindowsToken.IsAdministrator() error: %v", err)
- return false
- }
- return isAdmin
-}
-
// addActiveHTTPRequest adds c to the server's list of active HTTP requests.
//
// If the returned error may be of type inUseOtherUserError.
diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go
index 83df7ef0e..195c2e31e 100644
--- a/ipn/localapi/localapi.go
+++ b/ipn/localapi/localapi.go
@@ -921,7 +921,7 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
// TODO: roll-up this Windows-specific check into either PermitWrite
// or a global admin escalation check.
if shouldDenyServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h) {
- http.Error(w, "must be a Windows local admin to serve a path", http.StatusUnauthorized)
+ http.Error(w, "must be a local admin to serve a path", http.StatusUnauthorized)
return
}
@@ -941,7 +941,7 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
}
func shouldDenyServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) bool {
- if goos != "windows" {
+ if !slices.Contains([]string{"windows", "linux"}, goos) {
return false
}
if !configIn.HasPathHandler() {
diff --git a/util/groupmember/groupmember.go b/util/groupmember/groupmember.go
index d60416816..672d2baee 100644
--- a/util/groupmember/groupmember.go
+++ b/util/groupmember/groupmember.go
@@ -27,3 +27,26 @@ func IsMemberOfGroup(group, userName string) (bool, error) {
}
return slices.Contains(ugids, g.Gid), nil
}
+
+// IsMemberOfAnyGroup reports whether the provided user is a member of the any
+// of the provided system groups.
+func IsMemberOfAnyGroup(userName string, groups ...string) (bool, error) {
+ u, err := user.Lookup(userName)
+ if err != nil {
+ return false, err
+ }
+ ugids, err := u.GroupIds()
+ if err != nil {
+ return false, err
+ }
+ for _, group := range groups {
+ g, err := user.LookupGroup(group)
+ if err != nil {
+ return false, err
+ }
+ if slices.Contains(ugids, g.Gid) {
+ return true, nil
+ }
+ }
+ return false, nil
+}