Compare commits

...

1 Commits

Author SHA1 Message Date
Andrew Lytvynov
7ee8828139 ipn: mark /etc/sudoers members as local admin on linux
Just a POC, probably a bad idea.
2023-11-02 16:39:08 -06:00
4 changed files with 120 additions and 32 deletions

View File

@@ -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()
}

View File

@@ -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.

View File

@@ -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() {

View File

@@ -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
}