454 lines
13 KiB
Go
454 lines
13 KiB
Go
//go:build linux
|
|
|
|
package main
|
|
|
|
// The connection watch on Linux: each root command starts inside a cgroup of
|
|
// its own (CLONE_INTO_CGROUP, so no child is ever outside it), and an
|
|
// nftables table this bastion creates for that command records every
|
|
// connection a socket of that cgroup opens: `socket cgroupv2` matches the
|
|
// cgroup the socket was made in, and a dynamic set with a counter keeps each
|
|
// destination once. The table's chain has policy accept and gives no verdict,
|
|
// so it changes nothing about what the machine allows: the firewall that is
|
|
// already here (ufw, its own nftables tables) is never edited, flushed or
|
|
// reordered, and a packet it drops is still dropped.
|
|
//
|
|
// When the command ends, the sets are read, any process the command left
|
|
// running is moved back to the bastion's own cgroup (where every command ran
|
|
// before this watch, so it lives on exactly as it did), and the cgroup and
|
|
// the table are removed. If the bastion dies first, systemd removes the
|
|
// cgroup with the unit, and the next start (and every new watch) deletes any
|
|
// table of this name it did not make itself.
|
|
//
|
|
// What it cannot see, plainly: connections another program makes on the
|
|
// command's behalf. `docker build` / `docker pull` ask dockerd, which pulls
|
|
// with its own sockets, and a container's processes live in docker's
|
|
// cgroups; `systemctl start`, `systemd-run` and snapd are the same. Those are
|
|
// not the command's process tree and are not attributed. A local proxy is
|
|
// seen at its door: a connection to one of the list's loopback_relays ports
|
|
// is recorded and counts as outside. A root command can also leave its cgroup
|
|
// or delete the table on purpose; a table that is gone when the command ends
|
|
// reads as "not watched". This is a tripwire for an accidental outside fetch,
|
|
// not a boundary: root can always defeat it.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
const (
|
|
rootExecWatchTablePrefix = "privatae_rootexec_"
|
|
rootExecWatchCgroupPrefix = "privatae-rootexec-"
|
|
rootExecWatchSetSize = 65535
|
|
cgroupRoot = "/sys/fs/cgroup"
|
|
)
|
|
|
|
var rootExecWatching = struct {
|
|
sync.Mutex
|
|
ids map[string]bool
|
|
}{ids: map[string]bool{}}
|
|
|
|
func rootExecWatchActive(id string) bool {
|
|
rootExecWatching.Lock()
|
|
defer rootExecWatching.Unlock()
|
|
return rootExecWatching.ids[id]
|
|
}
|
|
|
|
func rootExecNft() (string, error) {
|
|
for _, p := range []string{"/usr/sbin/nft", "/sbin/nft"} {
|
|
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
|
return p, nil
|
|
}
|
|
}
|
|
return exec.LookPath("nft")
|
|
}
|
|
|
|
func rootExecRunNft(stdin string, args ...string) ([]byte, error) {
|
|
bin, err := rootExecNft()
|
|
if err != nil {
|
|
return nil, errors.New("nft is not installed")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}
|
|
if stdin != "" {
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
}
|
|
var out, errb bytes.Buffer
|
|
cmd.Stdout, cmd.Stderr = &out, &errb
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(errb.String())
|
|
if len(msg) > 300 {
|
|
msg = msg[:300]
|
|
}
|
|
return nil, fmt.Errorf("nft %s: %v %s", strings.Join(args, " "), err, msg)
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// rootExecOwnCgroup is this process's cgroup v2 path, e.g.
|
|
// /system.slice/privatae-bastion.service.
|
|
func rootExecOwnCgroup() (string, error) {
|
|
raw, err := os.ReadFile("/proc/self/cgroup")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
if p, ok := strings.CutPrefix(line, "0::"); ok && strings.HasPrefix(p, "/") {
|
|
return p, nil
|
|
}
|
|
}
|
|
return "", errors.New("this machine does not run the unified cgroup hierarchy")
|
|
}
|
|
|
|
func rootExecWatchStartKernel(sessionID string) *rootExecWatch {
|
|
_ = sessionID
|
|
var b [8]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return &rootExecWatch{fd: -1, Err: "no randomness for the watch's name"}
|
|
}
|
|
w := &rootExecWatch{ID: hex.EncodeToString(b[:]), fd: -1}
|
|
rootExecWatching.Lock()
|
|
rootExecWatching.ids[w.ID] = true
|
|
rootExecWatching.Unlock()
|
|
rootExecWatchSweep()
|
|
if err := rootExecWatchSetup(w); err != nil {
|
|
w.Err = err.Error()
|
|
rootExecWatchCleanup(w)
|
|
}
|
|
return w
|
|
}
|
|
|
|
func rootExecWatchSetup(w *rootExecWatch) error {
|
|
if _, err := rootExecNft(); err != nil {
|
|
return errors.New("nft is not installed")
|
|
}
|
|
own, err := rootExecOwnCgroup()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel := strings.Trim(filepath.Join(own, rootExecWatchCgroupPrefix+w.ID), "/")
|
|
if strings.ContainsAny(rel, "\"\\\n") {
|
|
return fmt.Errorf("this bastion's cgroup path %q cannot be named in an nftables rule", own)
|
|
}
|
|
dir := filepath.Join(cgroupRoot, rel)
|
|
if err := os.Mkdir(dir, 0o755); err != nil {
|
|
return fmt.Errorf("cannot make the command's cgroup: %v", err)
|
|
}
|
|
w.dir = dir
|
|
level := strings.Count(rel, "/") + 1
|
|
table := rootExecWatchTablePrefix + w.ID
|
|
// Loopback is skipped, except TCP to a local relay's port (a proxy that
|
|
// fetches from outside, rootExecLoopbackRelays): that is recorded, and
|
|
// rootExecClassify counts it as outside.
|
|
lo := `oifname "lo" return`
|
|
if relays := rootExecLoopbackRelays(); len(relays) > 0 {
|
|
ports := make([]string, len(relays))
|
|
for i, p := range relays {
|
|
ports[i] = strconv.Itoa(p)
|
|
}
|
|
lo = `oifname "lo" meta l4proto != tcp return
|
|
oifname "lo" tcp dport != { ` + strings.Join(ports, ", ") + ` } return`
|
|
}
|
|
// ct state: one entry per connection opened, not per packet. untracked is
|
|
// there too, so a raw-table notrack rule elsewhere cannot hide a flow.
|
|
script := fmt.Sprintf(`table inet %[1]s {
|
|
set out4 {
|
|
type ipv4_addr . inet_proto . inet_service
|
|
flags dynamic
|
|
counter
|
|
size %[2]d
|
|
}
|
|
set out6 {
|
|
type ipv6_addr . inet_proto . inet_service
|
|
flags dynamic
|
|
counter
|
|
size %[2]d
|
|
}
|
|
set other4 {
|
|
type ipv4_addr . inet_proto
|
|
flags dynamic
|
|
counter
|
|
size %[2]d
|
|
}
|
|
set other6 {
|
|
type ipv6_addr . inet_proto
|
|
flags dynamic
|
|
counter
|
|
size %[2]d
|
|
}
|
|
chain seen {
|
|
meta nfproto ipv4 meta l4proto { tcp, udp } add @out4 { ip daddr . meta l4proto . th dport }
|
|
meta nfproto ipv6 meta l4proto { tcp, udp } add @out6 { ip6 daddr . meta l4proto . th dport }
|
|
meta nfproto ipv4 meta l4proto != { tcp, udp } add @other4 { ip daddr . meta l4proto }
|
|
meta nfproto ipv6 meta l4proto != { tcp, udp } add @other6 { ip6 daddr . meta l4proto }
|
|
}
|
|
chain out {
|
|
type filter hook output priority -190; policy accept;
|
|
%[5]s
|
|
socket cgroupv2 level %[3]d "%[4]s" ct state { new, untracked } jump seen
|
|
}
|
|
}
|
|
`, table, rootExecWatchSetSize, level, rel, lo)
|
|
if _, err := rootExecRunNft(script, "-f", "-"); err != nil {
|
|
return fmt.Errorf("cannot create the watch's nftables table: %v", err)
|
|
}
|
|
w.table = table
|
|
fd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot open the command's cgroup: %v", err)
|
|
}
|
|
w.fd = fd
|
|
return nil
|
|
}
|
|
|
|
// nftListTable is the part of `nft -j list table` the watch reads.
|
|
type nftListTable struct {
|
|
Nftables []struct {
|
|
Set *struct {
|
|
Name string `json:"name"`
|
|
Size int `json:"size"`
|
|
Elem []struct {
|
|
Elem struct {
|
|
Val struct {
|
|
Concat []any `json:"concat"`
|
|
} `json:"val"`
|
|
Counter struct {
|
|
Packets int64 `json:"packets"`
|
|
} `json:"counter"`
|
|
} `json:"elem"`
|
|
} `json:"elem"`
|
|
} `json:"set"`
|
|
Rule *struct {
|
|
Chain string `json:"chain"`
|
|
} `json:"rule"`
|
|
} `json:"nftables"`
|
|
}
|
|
|
|
func nftScalar(v any) string {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x
|
|
case float64:
|
|
return strconv.FormatInt(int64(x), 10)
|
|
}
|
|
return fmt.Sprint(v)
|
|
}
|
|
|
|
// rootExecWatchRead reads what the table recorded. A table that is gone, or
|
|
// whose watching rule is gone, is reported as not watched.
|
|
func rootExecWatchRead(table string) ([]rootExecConn, error) {
|
|
out, err := rootExecRunNft("", "-j", "list", "table", "inet", table)
|
|
if err != nil {
|
|
return nil, errors.New("the watch's table was gone when the command ended")
|
|
}
|
|
var lt nftListTable
|
|
if err := json.Unmarshal(out, <); err != nil {
|
|
return nil, fmt.Errorf("cannot read the watch's table: %v", err)
|
|
}
|
|
watching, sets := false, 0
|
|
var conns []rootExecConn
|
|
for _, o := range lt.Nftables {
|
|
if o.Rule != nil && o.Rule.Chain == "out" {
|
|
watching = true
|
|
}
|
|
s := o.Set
|
|
if s == nil {
|
|
continue
|
|
}
|
|
sets++
|
|
if s.Size > 0 && len(s.Elem) >= s.Size {
|
|
return nil, fmt.Errorf("more than %d destinations", s.Size)
|
|
}
|
|
for _, e := range s.Elem {
|
|
c := e.Elem.Val.Concat
|
|
if len(c) < 2 {
|
|
continue
|
|
}
|
|
rc := rootExecConn{Addr: nftScalar(c[0]), Proto: nftScalar(c[1]), Count: e.Elem.Counter.Packets}
|
|
if len(c) >= 3 {
|
|
rc.Port, _ = strconv.Atoi(nftScalar(c[2]))
|
|
}
|
|
conns = append(conns, rc)
|
|
}
|
|
}
|
|
if !watching || sets != 4 {
|
|
return nil, errors.New("the watch's rule or sets were removed while the command ran")
|
|
}
|
|
return conns, nil
|
|
}
|
|
|
|
func rootExecWatchStopKernel(w *rootExecWatch) ([]rootExecConn, string) {
|
|
if w == nil {
|
|
return nil, "no watch was started"
|
|
}
|
|
defer func() {
|
|
rootExecWatchCleanup(w)
|
|
rootExecWatching.Lock()
|
|
delete(rootExecWatching.ids, w.ID)
|
|
rootExecWatching.Unlock()
|
|
}()
|
|
if w.Err != "" {
|
|
return nil, w.Err
|
|
}
|
|
conns, err := rootExecWatchRead(w.table)
|
|
if err != nil {
|
|
return nil, err.Error()
|
|
}
|
|
return conns, ""
|
|
}
|
|
|
|
// rootExecWatchCleanup removes what a watch created. Safe to call twice.
|
|
func rootExecWatchCleanup(w *rootExecWatch) {
|
|
if w.fd >= 0 {
|
|
_ = unix.Close(w.fd)
|
|
w.fd = -1
|
|
}
|
|
if w.dir != "" {
|
|
if rootExecCgroupRemove(w.dir) == nil {
|
|
w.dir = ""
|
|
}
|
|
}
|
|
if w.table != "" {
|
|
if _, err := rootExecRunNft("", "delete", "table", "inet", w.table); err == nil {
|
|
w.table = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// rootExecKillPid sends SIGKILL to one process of a command's cgroup. A var
|
|
// so a test can fence it; it never takes pid 0 or 1.
|
|
var rootExecKillPid = func(pid int) error {
|
|
if pid <= 1 {
|
|
return syscall.EINVAL
|
|
}
|
|
return syscall.Kill(pid, syscall.SIGKILL)
|
|
}
|
|
|
|
// rootExecWatchKillKernel kills every process in the command's cgroup:
|
|
// cgroup.kill (Linux 5.14+), which the kernel applies to the whole cgroup at
|
|
// once, so a process forking meanwhile cannot slip out; on an older kernel,
|
|
// SIGKILL to each pid cgroup.procs lists, read again until it is empty or a
|
|
// few rounds have passed. A process that moved itself to another cgroup is not
|
|
// here; the process-group kill that runs beside this one still reaches it if it
|
|
// kept the command's group.
|
|
func rootExecWatchKillKernel(w *rootExecWatch) {
|
|
if w == nil || w.dir == "" {
|
|
return
|
|
}
|
|
if f, err := os.OpenFile(filepath.Join(w.dir, "cgroup.kill"), os.O_WRONLY, 0); err == nil {
|
|
_, werr := f.Write([]byte("1"))
|
|
_ = f.Close()
|
|
if werr == nil {
|
|
return
|
|
}
|
|
}
|
|
for round := 0; round < 20; round++ {
|
|
raw, err := os.ReadFile(filepath.Join(w.dir, "cgroup.procs"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
pids := strings.Fields(string(raw))
|
|
if len(pids) == 0 {
|
|
return
|
|
}
|
|
for _, f := range pids {
|
|
if pid, err := strconv.Atoi(f); err == nil && pid > 1 {
|
|
_ = rootExecKillPid(pid)
|
|
}
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// rootExecCgroupRemove moves whatever is left in a command's cgroup into its
|
|
// parent (the bastion's own cgroup, where a command's leftovers lived before
|
|
// the watch) and removes the cgroup.
|
|
func rootExecCgroupRemove(dir string) error {
|
|
parent := filepath.Join(filepath.Dir(dir), "cgroup.procs")
|
|
var err error
|
|
for i := 0; i < 50; i++ {
|
|
if err = syscall.Rmdir(dir); err == nil || errors.Is(err, syscall.ENOENT) {
|
|
return nil
|
|
}
|
|
raw, rerr := os.ReadFile(filepath.Join(dir, "cgroup.procs"))
|
|
if rerr != nil {
|
|
return rerr
|
|
}
|
|
for _, f := range strings.Fields(string(raw)) {
|
|
_ = os.WriteFile(parent, []byte(f), 0)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// rootExecWatchSweep removes every watch table and command cgroup that no
|
|
// watch of this process holds: what a bastion that died mid-command left.
|
|
func rootExecWatchSweep() {
|
|
if os.Geteuid() != 0 {
|
|
return
|
|
}
|
|
if out, err := rootExecRunNft("", "-j", "list", "tables", "inet"); err == nil {
|
|
var lt struct {
|
|
Nftables []struct {
|
|
Table *struct {
|
|
Family string `json:"family"`
|
|
Name string `json:"name"`
|
|
} `json:"table"`
|
|
} `json:"nftables"`
|
|
}
|
|
if json.Unmarshal(out, <) == nil {
|
|
for _, o := range lt.Nftables {
|
|
if o.Table == nil || o.Table.Family != "inet" {
|
|
continue
|
|
}
|
|
id, ok := strings.CutPrefix(o.Table.Name, rootExecWatchTablePrefix)
|
|
if !ok || rootExecWatchActive(id) {
|
|
continue
|
|
}
|
|
_, _ = rootExecRunNft("", "delete", "table", "inet", o.Table.Name)
|
|
}
|
|
}
|
|
}
|
|
own, err := rootExecOwnCgroup()
|
|
if err != nil {
|
|
return
|
|
}
|
|
ents, err := os.ReadDir(filepath.Join(cgroupRoot, own))
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range ents {
|
|
id, ok := strings.CutPrefix(e.Name(), rootExecWatchCgroupPrefix)
|
|
if !e.IsDir() || !ok || rootExecWatchActive(id) {
|
|
continue
|
|
}
|
|
_ = rootExecCgroupRemove(filepath.Join(cgroupRoot, own, e.Name()))
|
|
}
|
|
}
|
|
|
|
// rootExecIntoCgroup starts the command inside the watch's cgroup, so the
|
|
// shell and every child it makes are in it from their first instruction.
|
|
func rootExecIntoCgroup(attr *syscall.SysProcAttr, fd int) {
|
|
if fd >= 0 {
|
|
attr.UseCgroupFD = true
|
|
attr.CgroupFD = fd
|
|
}
|
|
}
|