Files
finit/src/pam.c
T
Joachim Wiberg 61b0e0f3e6 Fix #420: run services inside a PAM session
Apply a PAM session to run/task/sysv/services Finit starts, pam_limits
above all, so a service running as a given user picks up that user's
limits the way a login does.

Add a new `pam` setting for the new block format (only), like the
per-service directories, naming a file in /etc/pam.d:

    service weston {
        user    = "weston"
        pam     = "weston-autologin"
        command = "/usr/bin/weston --continue-without-input"
    }

pam_close_session() has to be called by a process still holding the
handle, and the handle does not survive exec().  Hence the keeper: it
holds the handle, drops to the service's credentials, and waits for a
parent-death signal before closing the session.  Same shape as
systemd's (sd-pam), for the same reason, and one per fork, so the
script hooks open and close their own.

The keeper closes the descriptors it inherited from Finit and only
those.  Closing everything would also take out what pam_open_session()
opened for itself, a keyring fd or a lock file, and leave the modules
to close a session with those pulled out from under them.  Closing
nothing, as (sd-pam) does, would leave it holding the write end of the
notify pipe for the service's whole lifetime and starve notify = "s6"
services of their ready signal.  So the fds open before pam_start()
are snapshotted and exactly those are closed, while the ones PAM opens
after are marked close-on-exec so the daemon does not inherit them
either.

A refused value, a denied account stack, an uninstalled pam.d file,
and a build without PAM support all keep the service from starting
rather than running it with the stacks skipped: one that quietly loses
pam_limits and its private /tmp, with nothing said.  Capabilities a
module like pam_cap.so granted are merged into the IAB Finit applies
instead of being replaced by it, which only helps a service that also
sets capabilities, the other arm being a plain setuid() with nothing
left to restore once permitted is empty.

The test sysroot gains pam_permit.so, pam_deny.so and pam_limits.so,
which ldd cannot see, libpam dlopen()s them, and the test skips when
the host has none to stage.  The negative cases pin the exit status
rather than only asserting crashed, which serv reports for any early
exit, so a bad command or an unwritable pidfile cannot pass for a
rejected session.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-09-23 16:30:14 +02:00

399 lines
11 KiB
C

/* Finit PAM session support
*
* Copyright (c) 2026 Joachim Wiberg <troglobit@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
#include "config.h" /* Generated by configure script */
#include <dirent.h> /* opendir(), readdir(), dirfd() */
#include <errno.h>
#include <fcntl.h> /* fcntl(), FD_CLOEXEC */
#include <grp.h> /* setgroups() */
#include <security/pam_appl.h>
#include <signal.h>
#include <stdlib.h> /* malloc(), free(), strtol() */
#include <string.h>
#include <sys/prctl.h>
#include <unistd.h>
#include "helpers.h"
#include "log.h"
#include "pam.h"
#include "svc.h"
/*
* A service has no terminal to prompt on and this path never
* authenticates, so a module that asks a question gets an error and its
* stack entry fails. For a non-interactive service that is the answer
* we want.
*/
static int pam_dialog(int num_msg, const struct pam_message **msg,
struct pam_response **resp, void *appdata)
{
return PAM_CONV_ERR;
}
static const struct pam_conv conv = {
.conv = pam_dialog,
.appdata_ptr = NULL,
};
/*
* The keeper never exec()s, so O_CLOEXEC does nothing for it, and it
* must not sit on Finit's event loop, api socket, inotify and
* notify-pipe descriptors for as long as the service lives. But PAM
* and its modules may have opened descriptors of their own while
* pam_open_session() ran, e.g. a pam_systemd/pam_elogind bus
* connection, a keyring fd, or a lock file, and those have to survive
* until pam_close_session() runs in the keeper. A blanket close of
* everything >= 3 cannot tell the two kinds apart, so take a snapshot
* of Finit's own fds before pam_start() opens anything of its own, and
* have the keeper close only those.
*
* Returns the number of fds found and sets *fdv to a malloc'd array of
* them, or -1 if /proc/self/fd could not be read, in which case the
* caller falls back to closing everything and PAM's own descriptors
* are lost along with Finit's.
*/
static int fdsnap(int **fdv)
{
struct dirent *d;
int *v;
int n, i;
DIR *dir;
int dfd;
dir = opendir("/proc/self/fd");
if (!dir)
return -1;
dfd = dirfd(dir);
n = 0;
while ((d = readdir(dir))) {
char *end;
int fd;
fd = (int)strtol(d->d_name, &end, 10);
if (*end || fd < 3 || fd == dfd)
continue;
n++;
}
if (!n) {
closedir(dir);
*fdv = NULL;
return 0;
}
v = malloc(n * sizeof(*v));
if (!v) {
closedir(dir);
return -1;
}
rewinddir(dir);
i = 0;
while (i < n && (d = readdir(dir))) {
char *end;
int fd;
fd = (int)strtol(d->d_name, &end, 10);
if (*end || fd < 3 || fd == dfd)
continue;
v[i++] = fd;
}
closedir(dir);
*fdv = v;
return i;
}
/*
* Close exactly the fds fdsnap() found, leaving PAM's own alone. With
* no snapshot (nfd < 0), fall back to closing everything >= 3, PAM's
* descriptors lost along with Finit's.
*/
static void closefds(int *fdv, int nfd)
{
long max;
int i;
if (nfd >= 0) {
for (i = 0; i < nfd; i++)
close(fdv[i]);
return;
}
max = sysconf(_SC_OPEN_MAX);
#ifdef HAVE_CLOSE_RANGE
if (!close_range(3, ~0U, 0))
return;
#endif
if (max < 0)
max = 1024;
for (i = 3; i < max; i++)
close(i);
}
/*
* The descriptors PAM opened have to survive in the keeper, but not in
* the process that goes on to exec() the service: a bus connection, a
* keyring fd or a lock file the stack opened would otherwise be
* inherited by the daemon, and by the logit helper forked next to it,
* for as long as the service lives. Finit's own fds are O_CLOEXEC,
* PAM's are not reliably, so diff a fresh snapshot against the one
* taken before pam_start() and mark everything new close-on-exec. The
* keeper is unaffected, it was forked already and has its own
* descriptor table.
*
* With no snapshot to diff against (nfd < 0) there is nothing to do
* here, telling Finit's fds from PAM's is exactly what the snapshot is
* for.
*/
static void cloexec_pamfds(const char *id, int *fdv, int nfd)
{
int *now = NULL;
int i, j, n;
if (nfd < 0)
return;
n = fdsnap(&now);
if (n < 0)
return;
for (i = 0; i < n; i++) {
int flags, old = 0;
for (j = 0; j < nfd; j++) {
if (now[i] == fdv[j]) {
old = 1;
break;
}
}
if (old)
continue;
flags = fcntl(now[i], F_GETFD);
if (flags == -1 || fcntl(now[i], F_SETFD, flags | FD_CLOEXEC))
logit(LOG_WARNING, "%s: pam: failed FD_CLOEXEC on fd %d,"
" the service inherits it", id, now[i]);
}
free(now);
}
/*
* pam_getenvlist() hands back a NUL-terminated array of malloc'd
* "NAME=value" strings. Free all of it so a failure past that point
* never leaves pamsess_open() returning a dangling *envp.
*/
static void free_envlist(char **envp)
{
char **e;
if (!envp)
return;
for (e = envp; *e; e++)
free(*e);
free(envp);
}
/*
* Holds the PAM handle for the lifetime of the service and closes the
* session when it dies. The handle was created before the fork, so
* pam_end() gets PAM_DATA_SILENT: this process is a copy, the modules
* must not run their full cleanup twice.
*/
static void keeper(pam_handle_t *h, uid_t uid, gid_t gid, pid_t ppid,
sigset_t *mask, const char *id, int *fdv, int nfd)
{
int rc, sig;
/* Finit's syslog socket is one of the fds about to be closed;
* close it here first so syslog(3) knows to reconnect instead
* of writing to a fd pulled out from under it. */
closelog();
closefds(fdv, nfd);
setprocnm("(finit-pam)");
/* No groups are needed to close a session */
if (setgroups(0, NULL))
warn("%s: pam: failed setgroups()", id);
if (setgid(gid))
warn("%s: pam: failed setgid(%d)", id, gid);
if (setuid(uid))
warn("%s: pam: failed setuid(%d)", id, uid);
/* After the credential change, which clears a pending pdeath signal */
if (prctl(PR_SET_PDEATHSIG, SIGTERM, 0, 0, 0))
warn("%s: pam: failed PR_SET_PDEATHSIG", id);
/* The service may have exited already, then no signal is coming.
* Anything but success means SIGTERM has not arrived yet, so keep
* waiting: closing the session under a service that is still
* running would leave nobody to close it later. glibc reports
* the error number as the return value and musl returns -1 with
* errno set, hence the test on rc alone. The mask is static, so
* a failure that never clears is not reachable here. */
if (getppid() == ppid) {
do {
rc = sigwait(mask, &sig);
} while (rc);
}
rc = pam_setcred(h, PAM_DELETE_CRED | PAM_SILENT);
if (rc != PAM_SUCCESS)
logit(LOG_WARNING, "%s: pam: failed pam_setcred(DELETE_CRED): %s",
id, pam_strerror(h, rc));
rc = pam_close_session(h, PAM_SILENT);
if (rc != PAM_SUCCESS)
logit(LOG_WARNING, "%s: pam: failed pam_close_session(): %s",
id, pam_strerror(h, rc));
pam_end(h, rc | PAM_DATA_SILENT);
_exit(0);
}
/*
* Open a PAM session for svc, as root, before privileges are dropped.
* On success a keeper process has been forked and *envp holds the
* session environment for the caller to apply. The process that goes
* on to exec() must not call pam_end(), its handle disappears with the
* exec; only the keeper ever closes the session. *envp is only ever
* non-NULL on success.
*/
int pamsess_open(svc_t *svc, uid_t uid, gid_t gid, char ***envp)
{
char *id = svc_ident(svc, NULL, 0);
pam_handle_t *h = NULL;
const char *user;
sigset_t mask, omask;
pid_t pid, ppid;
int *fdv = NULL;
int rc, nfd;
const char *why;
*envp = NULL;
/* Every fork funnels through here, service_start() only guards
* the daemon itself, so this is where a value that must not
* reach pam_start() is stopped. */
why = pam_invalid(svc->pam);
if (why) {
logit(LOG_ERR, "%s: pam '%s' %s", id, svc->pam, why);
return -1;
}
/* Snapshot Finit's own fds before pam_start() opens anything */
nfd = fdsnap(&fdv);
user = svc->username[0] ? svc->username : "root";
rc = pam_start(svc->pam, user, &conv, &h);
if (rc != PAM_SUCCESS) {
logit(LOG_ERR, "%s: failed pam_start(%s, %s): %s", id,
svc->pam, user, pam_strerror(h, rc));
free(fdv);
return -1;
}
if (svc_has_ctty(svc)) {
rc = pam_set_item(h, PAM_TTY, svc->log.ctty);
if (rc != PAM_SUCCESS)
logit(LOG_WARNING, "%s: failed pam_set_item(PAM_TTY, %s): %s",
id, svc->log.ctty, pam_strerror(h, rc));
}
rc = pam_acct_mgmt(h, PAM_SILENT);
if (rc != PAM_SUCCESS) {
logit(LOG_ERR, "%s: account denied by %s: %s", id, svc->pam,
pam_strerror(h, rc));
goto fail;
}
rc = pam_setcred(h, PAM_ESTABLISH_CRED | PAM_SILENT);
if (rc != PAM_SUCCESS) {
logit(LOG_ERR, "%s: failed pam_setcred(%s): %s", id, svc->pam,
pam_strerror(h, rc));
goto fail;
}
rc = pam_open_session(h, PAM_SILENT);
if (rc != PAM_SUCCESS) {
logit(LOG_ERR, "%s: failed pam_open_session(%s): %s", id,
svc->pam, pam_strerror(h, rc));
goto cred;
}
*envp = pam_getenvlist(h);
/* Block SIGTERM before forking, the keeper must not miss it */
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, &omask);
/* Forked here, before the service child calls setsid(), so the
* keeper stays in Finit's process group. Teardown does
* kill(-svc->pid, SIGKILL) on the service's group, and a keeper
* that had ended up there would be killed with the service,
* before it ever gets to close the session. */
ppid = getpid();
pid = fork();
if (pid < 0) {
err(1, "%s: failed forking off PAM session keeper", id);
sigprocmask(SIG_SETMASK, &omask, NULL);
pam_close_session(h, PAM_SILENT);
free_envlist(*envp);
*envp = NULL;
goto cred;
}
if (!pid)
keeper(h, uid, gid, ppid, &mask, id, fdv, nfd); /* does not return */
sigprocmask(SIG_SETMASK, &omask, NULL);
cloexec_pamfds(id, fdv, nfd);
free(fdv);
dbg("%s: PAM session %s open, keeper is PID %d", id, svc->pam, pid);
return 0;
cred:
pam_setcred(h, PAM_DELETE_CRED | PAM_SILENT);
fail:
free(fdv);
pam_end(h, rc);
return -1;
}
/**
* Local Variables:
* indent-tabs-mode: t
* c-file-style: "linux"
* End:
*/