diff options
| author | Ophestra Umiker <cat@ophivana.moe> | 2024-11-02 03:03:44 +0900 |
|---|---|---|
| committer | Ophestra Umiker <cat@ophivana.moe> | 2024-11-02 03:13:57 +0900 |
| commit | 584732f80ab91afb349720cfb8e9979ed2ba173e (patch) | |
| tree | 8ef6ab9f8c9d3b8197682a53fb2c4a7b2ce8da55 /cmd | |
| parent | 4b7b899bb35fb4ea218dabe49a674f4d2f80e7f8 (diff) | |
cmd: shim and init into separate binaries
This change also fixes a deadlock when shim fails to connect and complete the setup.
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/finit/ipc/payload.go | 15 | ||||
| -rw-r--r-- | cmd/finit/main.go | 189 | ||||
| -rw-r--r-- | cmd/fshim/ipc/payload.go | 42 | ||||
| -rw-r--r-- | cmd/fshim/ipc/shim/shim.go | 222 | ||||
| -rw-r--r-- | cmd/fshim/ipc/wayland.go | 75 | ||||
| -rw-r--r-- | cmd/fshim/main.go | 194 | ||||
| -rw-r--r-- | cmd/fsu/main.go | 17 |
7 files changed, 745 insertions, 9 deletions
diff --git a/cmd/finit/ipc/payload.go b/cmd/finit/ipc/payload.go new file mode 100644 index 00000000..535a0e70 --- /dev/null +++ b/cmd/finit/ipc/payload.go @@ -0,0 +1,15 @@ +package init0 + +const Env = "FORTIFY_INIT" + +type Payload struct { + // target full exec path + Argv0 string + // child full argv + Argv []string + // wayland fd, -1 to disable + WL int + + // verbosity pass through + Verbose bool +} diff --git a/cmd/finit/main.go b/cmd/finit/main.go new file mode 100644 index 00000000..e92af811 --- /dev/null +++ b/cmd/finit/main.go @@ -0,0 +1,189 @@ +package main + +import ( + "encoding/gob" + "errors" + "os" + "os/exec" + "os/signal" + "path" + "strconv" + "syscall" + "time" + + init0 "git.ophivana.moe/security/fortify/cmd/finit/ipc" + "git.ophivana.moe/security/fortify/internal" + "git.ophivana.moe/security/fortify/internal/fmsg" +) + +const ( + // time to wait for linger processes after death of initial process + residualProcessTimeout = 5 * time.Second +) + +// everything beyond this point runs within pid namespace +// proceed with caution! + +func main() { + // sharing stdout with shim + // USE WITH CAUTION + fmsg.SetPrefix("init") + + // setting this prevents ptrace + if err := internal.PR_SET_DUMPABLE__SUID_DUMP_DISABLE(); err != nil { + fmsg.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err) + panic("unreachable") + } + + if os.Getpid() != 1 { + fmsg.Fatal("this process must run as pid 1") + panic("unreachable") + } + + // re-exec + if len(os.Args) > 0 && (os.Args[0] != "finit" || len(os.Args) != 1) && path.IsAbs(os.Args[0]) { + if err := syscall.Exec(os.Args[0], []string{"finit"}, os.Environ()); err != nil { + fmsg.Println("cannot re-exec self:", err) + // continue anyway + } + } + + // setup pipe fd from environment + var setup *os.File + if s, ok := os.LookupEnv(init0.Env); !ok { + fmsg.Fatal("FORTIFY_INIT not set") + panic("unreachable") + } else { + if fd, err := strconv.Atoi(s); err != nil { + fmsg.Fatalf("cannot parse %q: %v", s, err) + panic("unreachable") + } else { + setup = os.NewFile(uintptr(fd), "setup") + if setup == nil { + fmsg.Fatal("invalid config descriptor") + panic("unreachable") + } + } + } + + var payload init0.Payload + if err := gob.NewDecoder(setup).Decode(&payload); err != nil { + fmsg.Fatal("cannot decode init setup payload:", err) + panic("unreachable") + } else { + fmsg.SetVerbose(payload.Verbose) + + // child does not need to see this + if err = os.Unsetenv(init0.Env); err != nil { + fmsg.Printf("cannot unset %s: %v", init0.Env, err) + // not fatal + } else { + fmsg.VPrintln("received configuration") + } + } + + // die with parent + if err := internal.PR_SET_PDEATHSIG__SIGKILL(); err != nil { + fmsg.Fatalf("prctl(PR_SET_PDEATHSIG, SIGKILL): %v", err) + } + + cmd := exec.Command(payload.Argv0) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Args = payload.Argv + cmd.Env = os.Environ() + + // pass wayland fd + if payload.WL != -1 { + if f := os.NewFile(uintptr(payload.WL), "wayland"); f != nil { + cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(3+len(cmd.ExtraFiles))) + cmd.ExtraFiles = append(cmd.ExtraFiles, f) + } + } + + if err := cmd.Start(); err != nil { + fmsg.Fatalf("cannot start %q: %v", payload.Argv0, err) + } + fmsg.Withhold() + + // close setup pipe as setup is now complete + if err := setup.Close(); err != nil { + fmsg.Println("cannot close setup pipe:", err) + // not fatal + } + + sig := make(chan os.Signal, 2) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + + type winfo struct { + wpid int + wstatus syscall.WaitStatus + } + info := make(chan winfo, 1) + done := make(chan struct{}) + + go func() { + var ( + err error + wpid = -2 + wstatus syscall.WaitStatus + ) + + // keep going until no child process is left + for wpid != -1 { + if err != nil { + break + } + + if wpid != -2 { + info <- winfo{wpid, wstatus} + } + + err = syscall.EINTR + for errors.Is(err, syscall.EINTR) { + wpid, err = syscall.Wait4(-1, &wstatus, 0, nil) + } + } + if !errors.Is(err, syscall.ECHILD) { + fmsg.Println("unexpected wait4 response:", err) + } + + close(done) + }() + + // closed after residualProcessTimeout has elapsed after initial process death + timeout := make(chan struct{}) + + r := 2 + for { + select { + case s := <-sig: + fmsg.VPrintln("received", s.String()) + fmsg.Resume() // output could still be withheld at this point, so resume is called + fmsg.Exit(0) + case w := <-info: + if w.wpid == cmd.Process.Pid { + // initial process exited, output is most likely available again + fmsg.Resume() + + switch { + case w.wstatus.Exited(): + r = w.wstatus.ExitStatus() + case w.wstatus.Signaled(): + r = 128 + int(w.wstatus.Signal()) + default: + r = 255 + } + + go func() { + time.Sleep(residualProcessTimeout) + close(timeout) + }() + } + case <-done: + fmsg.Exit(r) + case <-timeout: + fmsg.Println("timeout exceeded waiting for lingering processes") + fmsg.Exit(r) + } + } +} diff --git a/cmd/fshim/ipc/payload.go b/cmd/fshim/ipc/payload.go new file mode 100644 index 00000000..1aaddb1f --- /dev/null +++ b/cmd/fshim/ipc/payload.go @@ -0,0 +1,42 @@ +package shim0 + +import ( + "encoding/gob" + "errors" + "net" + + "git.ophivana.moe/security/fortify/helper/bwrap" + "git.ophivana.moe/security/fortify/internal/fmsg" +) + +const Env = "FORTIFY_SHIM" + +type Payload struct { + // child full argv + Argv []string + // bwrap, target full exec path + Exec [2]string + // bwrap config + Bwrap *bwrap.Config + // whether to pass wayland fd + WL bool + + // verbosity pass through + Verbose bool +} + +func (p *Payload) Serve(conn *net.UnixConn, wl *Wayland) error { + if err := gob.NewEncoder(conn).Encode(*p); err != nil { + return fmsg.WrapErrorSuffix(err, + "cannot stream shim payload:") + } + + if wl != nil { + if err := wl.WriteUnix(conn); err != nil { + return errors.Join(err, conn.Close()) + } + } + + return fmsg.WrapErrorSuffix(conn.Close(), + "cannot close setup connection:") +} diff --git a/cmd/fshim/ipc/shim/shim.go b/cmd/fshim/ipc/shim/shim.go new file mode 100644 index 00000000..5e1606e3 --- /dev/null +++ b/cmd/fshim/ipc/shim/shim.go @@ -0,0 +1,222 @@ +package shim + +import ( + "errors" + "net" + "os" + "os/exec" + "sync" + "sync/atomic" + "syscall" + "time" + + "git.ophivana.moe/security/fortify/acl" + shim0 "git.ophivana.moe/security/fortify/cmd/fshim/ipc" + "git.ophivana.moe/security/fortify/internal/fmsg" +) + +const shimSetupTimeout = 5 * time.Second + +// used by the parent process + +type Shim struct { + // user switcher process + cmd *exec.Cmd + // uid of shim target user + uid uint32 + // whether to check shim pid + checkPid bool + // user switcher executable path + executable string + // path to setup socket + socket string + // shim setup abort reason and completion + abort chan error + abortErr atomic.Pointer[error] + abortOnce sync.Once + // wayland mediation, nil if disabled + wl *shim0.Wayland + // shim setup payload + payload *shim0.Payload +} + +func New(executable string, uid uint32, socket string, wl *shim0.Wayland, payload *shim0.Payload, checkPid bool) *Shim { + return &Shim{uid: uid, executable: executable, socket: socket, wl: wl, payload: payload, checkPid: checkPid} +} + +func (s *Shim) String() string { + if s.cmd == nil { + return "(unused shim manager)" + } + return s.cmd.String() +} + +func (s *Shim) Unwrap() *exec.Cmd { + return s.cmd +} + +func (s *Shim) Abort(err error) { + s.abortOnce.Do(func() { + s.abortErr.Store(&err) + // s.abort is buffered so this will never block + s.abort <- err + }) +} + +func (s *Shim) AbortWait(err error) { + s.Abort(err) + <-s.abort +} + +type CommandBuilder func(shimEnv string) (args []string) + +func (s *Shim) Start(f CommandBuilder) (*time.Time, error) { + var ( + cf chan *net.UnixConn + accept func() + ) + + // listen on setup socket + if c, a, err := s.serve(); err != nil { + return nil, fmsg.WrapErrorSuffix(err, + "cannot listen on shim setup socket:") + } else { + // accepts a connection after each call to accept + // connections are sent to the channel cf + cf, accept = c, a + } + + // start user switcher process and save time + s.cmd = exec.Command(s.executable, f(shim0.Env+"="+s.socket)...) + s.cmd.Env = []string{} + s.cmd.Stdin, s.cmd.Stdout, s.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + s.cmd.Dir = "/" + fmsg.VPrintln("starting shim via user switcher:", s.cmd) + fmsg.Withhold() // withhold messages to stderr + if err := s.cmd.Start(); err != nil { + return nil, fmsg.WrapErrorSuffix(err, + "cannot start user switcher:") + } + startTime := time.Now().UTC() + + // kill shim if something goes wrong and an error is returned + killShim := func() { + if err := s.cmd.Process.Signal(os.Interrupt); err != nil { + fmsg.Println("cannot terminate shim on faulted setup:", err) + } + } + defer func() { killShim() }() + + accept() + var conn *net.UnixConn + select { + case c := <-cf: + if c == nil { + return &startTime, fmsg.WrapErrorSuffix(*s.abortErr.Load(), "cannot accept call on setup socket:") + } else { + conn = c + } + case <-time.After(shimSetupTimeout): + err := errors.New("timed out waiting for shim") + s.AbortWait(err) + return &startTime, err + } + + // authenticate against called provided uid and shim pid + if cred, err := peerCred(conn); err != nil { + return &startTime, fmsg.WrapErrorSuffix(*s.abortErr.Load(), "cannot retrieve shim credentials:") + } else if cred.Uid != s.uid { + fmsg.Printf("process %d owned by user %d tried to connect, expecting %d", + cred.Pid, cred.Uid, s.uid) + err = errors.New("compromised fortify build") + s.Abort(err) + return &startTime, err + } else if s.checkPid && cred.Pid != int32(s.cmd.Process.Pid) { + fmsg.Printf("process %d tried to connect to shim setup socket, expecting shim %d", + cred.Pid, s.cmd.Process.Pid) + err = errors.New("compromised target user") + s.Abort(err) + return &startTime, err + } + + // serve payload and wayland fd if enabled + // this also closes the connection + err := s.payload.Serve(conn, s.wl) + if err == nil { + killShim = func() {} + } + s.Abort(err) // aborting with nil indicates success + return &startTime, err +} + +func (s *Shim) serve() (chan *net.UnixConn, func(), error) { + if s.abort != nil { + panic("attempted to serve shim setup twice") + } + s.abort = make(chan error, 1) + + cf := make(chan *net.UnixConn) + accept := make(chan struct{}, 1) + + if l, err := net.ListenUnix("unix", &net.UnixAddr{Name: s.socket, Net: "unix"}); err != nil { + return nil, nil, err + } else { + l.SetUnlinkOnClose(true) + + fmsg.VPrintf("listening on shim setup socket %q", s.socket) + if err = acl.UpdatePerm(s.socket, int(s.uid), acl.Read, acl.Write, acl.Execute); err != nil { + fmsg.Println("cannot append ACL entry to shim setup socket:", err) + s.Abort(err) // ensures setup socket cleanup + } + + go func() { + cfWg := new(sync.WaitGroup) + for { + select { + case err = <-s.abort: + if err != nil { + fmsg.VPrintln("aborting shim setup, reason:", err) + } + if err = l.Close(); err != nil { + fmsg.Println("cannot close setup socket:", err) + } + close(s.abort) + go func() { + cfWg.Wait() + close(cf) + }() + return + case <-accept: + cfWg.Add(1) + go func() { + defer cfWg.Done() + if conn, err0 := l.AcceptUnix(); err0 != nil { + // breaks loop + s.Abort(err0) + // receiver sees nil value and loads err0 stored during abort + cf <- nil + } else { + cf <- conn + } + }() + } + } + }() + } + + return cf, func() { accept <- struct{}{} }, nil +} + +// peerCred fetches peer credentials of conn +func peerCred(conn *net.UnixConn) (ucred *syscall.Ucred, err error) { + var raw syscall.RawConn + if raw, err = conn.SyscallConn(); err != nil { + return + } + + err0 := raw.Control(func(fd uintptr) { + ucred, err = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) + }) + err = errors.Join(err, err0) + return +} diff --git a/cmd/fshim/ipc/wayland.go b/cmd/fshim/ipc/wayland.go new file mode 100644 index 00000000..132e74f4 --- /dev/null +++ b/cmd/fshim/ipc/wayland.go @@ -0,0 +1,75 @@ +package shim0 + +import ( + "fmt" + "net" + "sync" + "syscall" + + "git.ophivana.moe/security/fortify/internal/fmsg" +) + +// Wayland implements wayland mediation. +type Wayland struct { + // wayland socket path + Path string + + // wayland connection + conn *net.UnixConn + + connErr error + sync.Once + // wait for wayland client to exit + done chan struct{} +} + +func (wl *Wayland) WriteUnix(conn *net.UnixConn) error { + // connect to host wayland socket + if f, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: wl.Path, Net: "unix"}); err != nil { + return fmsg.WrapErrorSuffix(err, + fmt.Sprintf("cannot connect to wayland at %q:", wl.Path)) + } else { + fmsg.VPrintf("connected to wayland at %q", wl.Path) + wl.conn = f + } + + // set up for passing wayland socket + if rc, err := wl.conn.SyscallConn(); err != nil { + return fmsg.WrapErrorSuffix(err, "cannot obtain raw wayland connection:") + } else { + ec := make(chan error) + go func() { + // pass wayland connection fd + if err = rc.Control(func(fd uintptr) { + if _, _, err = conn.WriteMsgUnix(nil, syscall.UnixRights(int(fd)), nil); err != nil { + ec <- fmsg.WrapErrorSuffix(err, "cannot pass wayland connection to shim:") + return + } + ec <- nil + + // block until shim exits + <-wl.done + fmsg.VPrintln("releasing wayland connection") + }); err != nil { + ec <- fmsg.WrapErrorSuffix(err, "cannot obtain wayland connection fd:") + return + } + }() + return <-ec + } +} + +func (wl *Wayland) Close() error { + wl.Do(func() { + close(wl.done) + wl.connErr = wl.conn.Close() + }) + + return wl.connErr +} + +func NewWayland() *Wayland { + wl := new(Wayland) + wl.done = make(chan struct{}) + return wl +} diff --git a/cmd/fshim/main.go b/cmd/fshim/main.go new file mode 100644 index 00000000..43248f69 --- /dev/null +++ b/cmd/fshim/main.go @@ -0,0 +1,194 @@ +package main + +import ( + "encoding/gob" + "errors" + "net" + "os" + "path" + "strconv" + "syscall" + + init0 "git.ophivana.moe/security/fortify/cmd/finit/ipc" + shim "git.ophivana.moe/security/fortify/cmd/fshim/ipc" + "git.ophivana.moe/security/fortify/helper" + "git.ophivana.moe/security/fortify/internal" + "git.ophivana.moe/security/fortify/internal/fmsg" +) + +// everything beyond this point runs as unconstrained target user +// proceed with caution! + +func main() { + // sharing stdout with fortify + // USE WITH CAUTION + fmsg.SetPrefix("shim") + + // setting this prevents ptrace + if err := internal.PR_SET_DUMPABLE__SUID_DUMP_DISABLE(); err != nil { + fmsg.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err) + panic("unreachable") + } + + // re-exec + if len(os.Args) > 0 && (os.Args[0] != "fshim" || len(os.Args) != 1) && path.IsAbs(os.Args[0]) { + if err := syscall.Exec(os.Args[0], []string{"fshim"}, os.Environ()); err != nil { + fmsg.Println("cannot re-exec self:", err) + // continue anyway + } + } + + // lookup socket path from environment + var socketPath string + if s, ok := os.LookupEnv(shim.Env); !ok { + fmsg.Fatal("FORTIFY_SHIM not set") + panic("unreachable") + } else { + socketPath = s + } + + // check path to finit + var finitPath string + if p, ok := internal.Path(internal.Finit); !ok { + fmsg.Fatal("invalid finit path, this copy of fshim is not compiled correctly") + } else { + finitPath = p + } + + // dial setup socket + var conn *net.UnixConn + if c, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socketPath, Net: "unix"}); err != nil { + fmsg.Fatal("cannot dial setup socket:", err) + panic("unreachable") + } else { + conn = c + } + + // decode payload gob stream + var payload shim.Payload + if err := gob.NewDecoder(conn).Decode(&payload); err != nil { + fmsg.Fatal("cannot decode shim payload:", err) + } else { + fmsg.SetVerbose(payload.Verbose) + } + + if payload.Bwrap == nil { + fmsg.Fatal("bwrap config not supplied") + } + + // receive wayland fd over socket + wfd := -1 + if payload.WL { + if fd, err := receiveWLfd(conn); err != nil { + fmsg.Fatal("cannot receive wayland fd:", err) + } else { + wfd = fd + } + } + + // close setup socket + if err := conn.Close(); err != nil { + fmsg.Println("cannot close setup socket:", err) + // not fatal + } + + var ic init0.Payload + + // resolve argv0 + ic.Argv = payload.Argv + if len(ic.Argv) > 0 { + // looked up from $PATH by parent + ic.Argv0 = payload.Exec[1] + } else { + // no argv, look up shell instead + var ok bool + if ic.Argv0, ok = os.LookupEnv("SHELL"); !ok { + fmsg.Fatal("no command was specified and $SHELL was unset") + } + + ic.Argv = []string{ic.Argv0} + } + + conf := payload.Bwrap + + var extraFiles []*os.File + + // pass wayland fd + if wfd != -1 { + if f := os.NewFile(uintptr(wfd), "wayland"); f != nil { + ic.WL = 3 + len(extraFiles) + extraFiles = append(extraFiles, f) + } + } else { + ic.WL = -1 + } + + // share config pipe + if r, w, err := os.Pipe(); err != nil { + fmsg.Fatal("cannot pipe:", err) + } else { + conf.SetEnv[init0.Env] = strconv.Itoa(3 + len(extraFiles)) + extraFiles = append(extraFiles, r) + + fmsg.VPrintln("transmitting config to init") + go func() { + // stream config to pipe + if err = gob.NewEncoder(w).Encode(&ic); err != nil { + fmsg.Fatal("cannot transmit init config:", err) + } + }() + } + + helper.BubblewrapName = payload.Exec[0] // resolved bwrap path by parent + if b, err := helper.NewBwrap(conf, nil, finitPath, + func(int, int) []string { return make([]string, 0) }); err != nil { + fmsg.Fatal("malformed sandbox config:", err) + } else { + cmd := b.Unwrap() + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.ExtraFiles = extraFiles + + if fmsg.Verbose() { + fmsg.VPrintln("bwrap args:", conf.Args()) + } + + // run and pass through exit code + if err = b.Start(); err != nil { + fmsg.Fatal("cannot start target process:", err) + } else if err = b.Wait(); err != nil { + fmsg.VPrintln("wait:", err) + } + if b.Unwrap().ProcessState != nil { + fmsg.Exit(b.Unwrap().ProcessState.ExitCode()) + } else { + fmsg.Exit(127) + } + } +} + +func receiveWLfd(conn *net.UnixConn) (int, error) { + oob := make([]byte, syscall.CmsgSpace(4)) // single fd + + if _, oobn, _, _, err := conn.ReadMsgUnix(nil, oob); err != nil { + return -1, err + } else if len(oob) != oobn { + return -1, errors.New("invalid message length") + } + + var msg syscall.SocketControlMessage + if messages, err := syscall.ParseSocketControlMessage(oob); err != nil { + return -1, err + } else if len(messages) != 1 { + return -1, errors.New("unexpected message count") + } else { + msg = messages[0] + } + + if fds, err := syscall.ParseUnixRights(&msg); err != nil { + return -1, err + } else if len(fds) != 1 { + return -1, errors.New("unexpected fd count") + } else { + return fds[0], nil + } +} diff --git a/cmd/fsu/main.go b/cmd/fsu/main.go index af0315ea..2d3ad8bf 100644 --- a/cmd/fsu/main.go +++ b/cmd/fsu/main.go @@ -8,19 +8,16 @@ import ( "strconv" "strings" "syscall" + + "git.ophivana.moe/security/fortify/internal" ) const ( fsuConfFile = "/etc/fsurc" envShim = "FORTIFY_SHIM" envAID = "FORTIFY_APP_ID" - - fpPoison = "INVALIDINVALIDINVALIDINVALIDINVALID" ) -// FortifyPath is the path to fortify, set at compile time. -var FortifyPath = fpPoison - func main() { log.SetFlags(0) log.SetPrefix("fsu: ") @@ -35,9 +32,11 @@ func main() { log.Fatal("this program must not be started by root") } - // validate compiled in fortify path - if FortifyPath == fpPoison || !path.IsAbs(FortifyPath) { + var fmain string + if p, ok := internal.Path(internal.Fmain); !ok { log.Fatal("invalid fortify path, this copy of fsu is not compiled correctly") + } else { + fmain = p } pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe") @@ -45,7 +44,7 @@ func main() { log.Fatalf("cannot read parent executable path: %v", err) } else if strings.HasSuffix(p, " (deleted)") { log.Fatal("fortify executable has been deleted") - } else if p != FortifyPath { + } else if p != fmain { log.Fatal("this program must be started by fortify") } @@ -86,7 +85,7 @@ func main() { if err := syscall.Setresuid(uid, uid, uid); err != nil { log.Fatalf("cannot set uid: %v", err) } - if err := syscall.Exec(FortifyPath, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupPath}); err != nil { + if err := syscall.Exec(fmain, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupPath}); err != nil { log.Fatalf("cannot start shim: %v", err) } |
