bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
//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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user