wolfsshd: open PID file with O_NOFOLLOW and a fixed mode

pull/1113/head
Yosuke Shimizu 2026-06-30 09:57:04 +09:00 committed by John Safranek
parent a10e6e5c0c
commit cc5311a829
3 changed files with 483 additions and 24 deletions

View File

@ -29,6 +29,18 @@
#endif
#ifdef WOLFSSH_SSHD
/* Define POSIX feature-test macros before any system header (as auth.c does) so
* fdopen/ftruncate/lstat/S_ISLNK are declared under strict -std= builds. */
#ifdef __linux__
#ifndef _XOPEN_SOURCE
#define _XOPEN_SOURCE
#endif
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#endif
/* functions for parsing out options from a config file and for handling loading
* key/certs using the env. filesystem */
@ -51,10 +63,14 @@
#include "configuration.h"
#ifndef WIN32
#ifndef _WIN32
#include <dirent.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
#endif
#ifdef WIN32
#ifdef _WIN32
#include <process.h>
#endif
#ifdef HAVE_LIMITS_H
@ -1814,18 +1830,113 @@ void wolfSSHD_ConfigSavePID(const WOLFSSHD_CONFIG* conf)
{
FILE* f;
char buf[12]; /* large enough to hold 'int' type with null terminator */
word32 pidLen;
int writeOk;
#ifndef _WIN32
int fd;
int ok = 1;
int created = 1; /* whether this call created the PID file */
struct stat st;
#endif
if (conf->pidFile != NULL) {
WMEMSET(buf, 0, sizeof(buf));
if (WFOPEN(NULL, &f, conf->pidFile, "wb") == 0) {
#ifndef WIN32
WSNPRINTF(buf, sizeof(buf), "%d", getpid());
#else
WSNPRINTF(buf, sizeof(buf), "%d", _getpid());
#endif
WFWRITE(NULL, buf, 1, WSTRLEN(buf), f);
WFCLOSE(NULL, f);
#ifndef _WIN32
/* raw open, not WFOPEN: the O_NOFOLLOW hardening needs a real fd */
#if defined(WOLFSSH_HAVE_SYMLINK) && WOLFSSH_O_NOFOLLOW == 0
/* no O_NOFOLLOW here, so pre-check with a racy best-effort lstat */
if (lstat(conf->pidFile, &st) == 0 && S_ISLNK(st.st_mode)) {
wolfSSH_Log(WS_LOG_ERROR,
"[SSHD] Refusing symlinked PID file %s", conf->pidFile);
ok = 0;
}
#endif
if (ok) {
/* O_EXCL first so 'created' distinguishes creation from reuse; on
* EEXIST reopen (O_NOFOLLOW still guards a symlink). No O_TRUNC and
* O_NONBLOCK so a planted FIFO fails fast, nothing clobbered. */
fd = open(conf->pidFile,
O_WRONLY | O_CREAT | O_EXCL | O_NONBLOCK |
WOLFSSH_O_NOFOLLOW, 0644);
if (fd < 0 && errno == EEXIST) {
created = 0;
fd = open(conf->pidFile,
O_WRONLY | O_NONBLOCK | WOLFSSH_O_NOFOLLOW, 0);
}
if (fd < 0) {
wolfSSH_Log(WS_LOG_ERROR,
"[SSHD] Unable to open PID file %s", conf->pidFile);
}
/* O_NOFOLLOW stops a symlink but not a hard link; demand a
* single-link regular file owned by us or by root. */
else if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode) ||
st.st_nlink != 1 ||
(st.st_uid != geteuid() && st.st_uid != 0)) {
wolfSSH_Log(WS_LOG_ERROR,
"[SSHD] Refusing unsafe PID file %s", conf->pidFile);
close(fd);
}
/* open()'s 0644 applies only on creation, so an existing
* world-writable PID file needs its mode reset here. */
else if (fchmod(fd, 0644) != 0) {
wolfSSH_Log(WS_LOG_ERROR,
"[SSHD] Unable to set mode on PID file %s", conf->pidFile);
close(fd);
/* remove only a file we just made, never a valid existing one */
if (created) {
unlink(conf->pidFile);
}
}
else if (ftruncate(fd, 0) != 0) {
wolfSSH_Log(WS_LOG_ERROR,
"[SSHD] Unable to truncate PID file %s", conf->pidFile);
close(fd);
if (created) {
unlink(conf->pidFile);
}
}
else {
f = fdopen(fd, "wb");
if (f == NULL) {
wolfSSH_Log(WS_LOG_ERROR,
"[SSHD] Unable to open PID file stream %s",
conf->pidFile);
close(fd);
/* drop the empty leftover, a reader must not see no PID */
unlink(conf->pidFile);
}
else {
WSNPRINTF(buf, sizeof(buf), "%d", getpid());
pidLen = (word32)WSTRLEN(buf);
writeOk = ((word32)WFWRITE(NULL, buf, 1, pidLen, f) == pidLen);
if (WFCLOSE(NULL, f) != 0) {
writeOk = 0;
}
if (!writeOk) {
/* a reader must not parse a truncated PID */
wolfSSH_Log(WS_LOG_ERROR,
"[SSHD] Failed to write PID file %s", conf->pidFile);
unlink(conf->pidFile);
}
}
}
}
#else
if (WFOPEN(NULL, &f, conf->pidFile, "wb") == 0) {
WSNPRINTF(buf, sizeof(buf), "%d", _getpid());
pidLen = (word32)WSTRLEN(buf);
writeOk = ((word32)WFWRITE(NULL, buf, 1, pidLen, f) == pidLen);
if (WFCLOSE(NULL, f) != 0) {
writeOk = 0;
}
if (!writeOk) {
/* a reader must not parse a truncated PID */
wolfSSH_Log(WS_LOG_ERROR,
"[SSHD] Failed to write PID file %s", conf->pidFile);
WREMOVE(NULL, conf->pidFile);
}
}
#endif
}
}

View File

@ -30,6 +30,8 @@
#ifndef _WIN32
#include <unistd.h>
#include <sys/stat.h>
#include <sys/resource.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#endif
@ -2294,6 +2296,340 @@ static int test_OpenSecureFile(void)
return ret;
}
/* write a config file whose PidFile is 'pidTarget' */
static int pidConfWrite(const char* confPath, const char* pidTarget)
{
FILE* f;
f = fopen(confPath, "w");
if (f == NULL) {
return WS_FATAL_ERROR;
}
fprintf(f, "PidFile %s\n", pidTarget);
fclose(f);
return WS_SUCCESS;
}
/* load 'confPath' and run wolfSSHD_ConfigSavePID under umask(0), restoring the
* umask afterward so later tests are unaffected */
static int pidSaveRun(const char* confPath)
{
int ret;
mode_t old;
WOLFSSHD_CONFIG* cfg;
cfg = wolfSSHD_ConfigNew(NULL);
if (cfg == NULL) {
return WS_MEMORY_E;
}
ret = wolfSSHD_ConfigLoad(cfg, confPath);
if (ret == WS_SUCCESS) {
/* deliberate worst case: prove the explicit 0644 mode holds even when
* the umask would not mask any bits */
old = umask(0);
wolfSSHD_ConfigSavePID(cfg);
umask(old);
}
wolfSSHD_ConfigFree(cfg);
return ret;
}
static int pidSave(const char* confPath, const char* pidTarget)
{
int ret;
ret = pidConfWrite(confPath, pidTarget);
if (ret == WS_SUCCESS) {
ret = pidSaveRun(confPath);
}
return ret;
}
/* wolfSSHD_ConfigSavePID must refuse a symlink at the PID path so a planted
* link cannot truncate another file, and must leave the PID file without group
* or world write even under a permissive umask, whether it creates the file or
* reuses an existing one. */
static int test_ConfigSavePID(void)
{
int ret = WS_SUCCESS;
int rd;
long pid = -1;
char base[] = "/tmp/wolfsshd_pidXXXXXX";
char conf[80] = "";
char pidPath[96] = "";
char victim[96] = "";
char linkPath[96] = "";
char hvictim[96] = "";
char hlink[96] = "";
char fifo[96] = "";
char stale[96] = "";
char foreign[96] = "";
const char* secret = "VICTIM-CONTENTS\n";
FILE* f = NULL;
struct stat st;
char rbuf[64];
#ifdef RLIMIT_FSIZE
char failPath[96] = "";
struct rlimit rlOld;
struct rlimit rlNew;
void (*prevXfsz)(int);
#endif
if (mkdtemp(base) == NULL) {
Log(" mkdtemp failed.\n");
ret = WS_FATAL_ERROR;
}
if (ret == WS_SUCCESS) {
snprintf(conf, sizeof(conf), "%s/sshd_config", base);
snprintf(pidPath, sizeof(pidPath), "%s/wolfsshd.pid", base);
snprintf(victim, sizeof(victim), "%s/victim", base);
snprintf(linkPath, sizeof(linkPath), "%s/link.pid", base);
snprintf(hvictim, sizeof(hvictim), "%s/hvictim", base);
snprintf(hlink, sizeof(hlink), "%s/hlink.pid", base);
snprintf(fifo, sizeof(fifo), "%s/fifo.pid", base);
snprintf(stale, sizeof(stale), "%s/stale.pid", base);
snprintf(foreign, sizeof(foreign), "%s/foreign.pid", base);
#ifdef RLIMIT_FSIZE
snprintf(failPath, sizeof(failPath), "%s/fail.pid", base);
#endif
}
/* Scenario 1: a normal path is written with our PID and is not group or
* world writable despite umask(0). */
if (ret == WS_SUCCESS) {
ret = pidSave(conf, pidPath);
}
if (ret == WS_SUCCESS) {
if (stat(pidPath, &st) != 0) {
ret = WS_FATAL_ERROR;
}
else {
f = fopen(pidPath, "r");
rd = (f != NULL) ? fscanf(f, "%ld", &pid) : 0;
if (f != NULL) {
fclose(f);
}
ret = smExpect("normal PID file written with our PID",
(rd == 1 && pid == (long)getpid()) ? WS_SUCCESS
: WS_FATAL_ERROR, 1);
if (ret == WS_SUCCESS) {
ret = smExpect("PID file not group or world writable",
(st.st_mode & (S_IWGRP | S_IWOTH)) ? WS_FATAL_ERROR
: WS_SUCCESS, 1);
}
}
}
/* Scenario 2: a symlink at the PID path is refused, link target untouched.
* The common build exercises the atomic O_NOFOLLOW path; the lstat fallback
* needs a symlink-capable platform without O_NOFOLLOW. */
if (ret == WS_SUCCESS) {
f = fopen(victim, "w");
if (f == NULL) {
ret = WS_FATAL_ERROR;
}
else {
fputs(secret, f);
fclose(f);
}
}
if (ret == WS_SUCCESS && symlink(victim, linkPath) != 0) {
ret = WS_FATAL_ERROR;
}
if (ret == WS_SUCCESS) {
ret = pidSave(conf, linkPath);
}
if (ret == WS_SUCCESS) {
f = fopen(victim, "r");
WMEMSET(rbuf, 0, sizeof(rbuf));
rd = (f != NULL) ? (int)fread(rbuf, 1, sizeof(rbuf) - 1, f) : -1;
if (f != NULL) {
fclose(f);
}
(void)rd;
/* A WOLFSSH_NO_SYMLINK_CHECK build does no symlink check by design, so
* only assert the target survived when the check is compiled in. */
#ifdef WOLFSSH_HAVE_SYMLINK
ret = smExpect("symlinked PID path not followed, target intact",
(WSTRCMP(rbuf, secret) == 0) ? WS_SUCCESS : WS_FATAL_ERROR, 1);
#else
(void)rbuf;
#endif
}
/* Scenario 3: a hard link at the PID path is refused (O_NOFOLLOW stops a
* symlink but not a hard link), leaving the link target untouched. The
* st_nlink check that enforces this is unconditional, so this always runs. */
if (ret == WS_SUCCESS) {
f = fopen(hvictim, "w");
if (f == NULL) {
ret = WS_FATAL_ERROR;
}
else {
fputs(secret, f);
fclose(f);
}
}
if (ret == WS_SUCCESS && link(hvictim, hlink) != 0) {
ret = WS_FATAL_ERROR;
}
if (ret == WS_SUCCESS) {
ret = pidSave(conf, hlink);
}
if (ret == WS_SUCCESS) {
f = fopen(hvictim, "r");
WMEMSET(rbuf, 0, sizeof(rbuf));
rd = (f != NULL) ? (int)fread(rbuf, 1, sizeof(rbuf) - 1, f) : -1;
if (f != NULL) {
fclose(f);
}
(void)rd;
ret = smExpect("hard-linked PID path refused, target intact",
(WSTRCMP(rbuf, secret) == 0) ? WS_SUCCESS : WS_FATAL_ERROR, 1);
}
/* Scenario 4: a FIFO at the PID path is rejected (O_NONBLOCK fast-fails the
* open and the S_ISREG check refuses it), and the FIFO is left in place
* rather than replaced by a regular PID file. */
if (ret == WS_SUCCESS && mkfifo(fifo, 0600) != 0) {
ret = WS_FATAL_ERROR;
}
if (ret == WS_SUCCESS) {
ret = pidSave(conf, fifo);
}
if (ret == WS_SUCCESS) {
ret = smExpect("FIFO PID path refused, still a FIFO",
(lstat(fifo, &st) == 0 && S_ISFIFO(st.st_mode)) ? WS_SUCCESS
: WS_FATAL_ERROR, 1);
}
/* Scenario 5: an already existing PID file is reused, but its mode is reset
* so a world-writable file left behind by an earlier run does not stay
* world writable. The mode passed to open() applies only on creation. */
if (ret == WS_SUCCESS) {
f = fopen(stale, "w");
if (f == NULL) {
ret = WS_FATAL_ERROR;
}
else {
fputs(secret, f);
fclose(f);
if (chmod(stale, 0666) != 0) {
ret = WS_FATAL_ERROR;
}
}
}
if (ret == WS_SUCCESS) {
ret = pidSave(conf, stale);
}
if (ret == WS_SUCCESS) {
pid = -1;
if (stat(stale, &st) != 0) {
ret = WS_FATAL_ERROR;
}
else {
f = fopen(stale, "r");
rd = (f != NULL) ? fscanf(f, "%ld", &pid) : 0;
if (f != NULL) {
fclose(f);
}
ret = smExpect("existing PID file rewritten with our PID",
(rd == 1 && pid == (long)getpid()) ? WS_SUCCESS
: WS_FATAL_ERROR, 1);
if (ret == WS_SUCCESS) {
ret = smExpect("existing PID file mode reset, not world "
"writable",
(st.st_mode & (S_IWGRP | S_IWOTH)) ? WS_FATAL_ERROR
: WS_SUCCESS, 1);
}
}
}
/* Scenario 6: a PID file owned by another user is refused instead of being
* truncated and adopted. Handing a file to another uid needs root, so this
* only runs when the test is executed as root. */
if (ret == WS_SUCCESS && geteuid() == 0) {
f = fopen(foreign, "w");
if (f == NULL) {
ret = WS_FATAL_ERROR;
}
else {
fputs(secret, f);
fclose(f);
if (chown(foreign, 1, 1) != 0) {
ret = WS_FATAL_ERROR;
}
}
if (ret == WS_SUCCESS) {
ret = pidSave(conf, foreign);
}
if (ret == WS_SUCCESS) {
f = fopen(foreign, "r");
WMEMSET(rbuf, 0, sizeof(rbuf));
rd = (f != NULL) ? (int)fread(rbuf, 1, sizeof(rbuf) - 1, f) : -1;
if (f != NULL) {
fclose(f);
}
(void)rd;
ret = smExpect("PID file owned by another user refused, intact",
(WSTRCMP(rbuf, secret) == 0) ? WS_SUCCESS : WS_FATAL_ERROR, 1);
}
}
#ifdef RLIMIT_FSIZE
/* Scenario 7: a PID file whose write fails is removed rather than left
* behind empty. A zero RLIMIT_FSIZE makes the flush at close fail, which is
* the recovery path taken by a short or unflushed write. */
if (ret == WS_SUCCESS) {
ret = pidConfWrite(conf, failPath);
}
if (ret == WS_SUCCESS && getrlimit(RLIMIT_FSIZE, &rlOld) != 0) {
ret = WS_FATAL_ERROR;
}
if (ret == WS_SUCCESS) {
/* exceeding the limit raises SIGXFSZ, which would kill the test */
prevXfsz = signal(SIGXFSZ, SIG_IGN);
rlNew = rlOld;
rlNew.rlim_cur = 0;
/* flush first: while the cap is set, a write to a redirected
* stdout/stderr would silently fail (SIGXFSZ is ignored) */
fflush(stdout);
if (setrlimit(RLIMIT_FSIZE, &rlNew) != 0) {
ret = WS_FATAL_ERROR;
}
else {
ret = pidSaveRun(conf);
if (setrlimit(RLIMIT_FSIZE, &rlOld) != 0) {
ret = WS_FATAL_ERROR;
}
}
signal(SIGXFSZ, prevXfsz);
}
if (ret == WS_SUCCESS) {
ret = smExpect("PID file removed when the write fails",
(stat(failPath, &st) != 0) ? WS_SUCCESS : WS_FATAL_ERROR, 1);
}
#endif /* RLIMIT_FSIZE */
/* cleanup */
unlink(fifo);
unlink(linkPath);
unlink(victim);
unlink(hlink);
unlink(hvictim);
unlink(stale);
unlink(foreign);
#ifdef RLIMIT_FSIZE
unlink(failPath);
#endif
unlink(pidPath);
unlink(conf);
rmdir(base);
return ret;
}
#endif /* !_WIN32 */
/* Verify AuthorizedKeysFile token substitution so an absolute pattern with %u
@ -2443,6 +2779,7 @@ const TEST_CASE testCases[] = {
TEST_DECL(test_ConfigFree),
#ifndef _WIN32
TEST_DECL(test_OpenSecureFile),
TEST_DECL(test_ConfigSavePID),
#endif
#ifdef WOLFSSL_BASE64_ENCODE
TEST_DECL(test_CheckAuthKeysLine),

View File

@ -1584,20 +1584,15 @@ extern "C" {
#define WOLFSSH_HAVE_SYMLINK
#endif
/* wIsSymlink lives in the always-compiled port.c, but its filesystem
* dependencies (WSTAT_T/WLSTAT/S_ISLNK on POSIX, WS_GetFileAttributesExA on
* Windows) and its only callers exist solely in the SFTP and SCP code, so
* gate it on those features in addition to the platform capability. Shared by
* the SFTP path-confinement guard and the SCP send guards. */
#if defined(WOLFSSH_HAVE_SYMLINK) && \
(defined(WOLFSSH_SFTP) || defined(WOLFSSH_SCP))
WOLFSSH_LOCAL int wIsSymlink(const char* path);
/* Open flag that refuses to follow a final-component symbolic link, so the
* open is atomic against a swapped link. Maps to the platform primitive
* where available (POSIX O_NOFOLLOW) and to 0 elsewhere (Windows and any
* filesystem lacking it), where wFopenNoFollow falls back to a wIsSymlink
* check before the open. */
/* No-follow open flags, defined for every filesystem that can store a symlink
* (POSIX and Windows) so the SFTP, SCP, and SSHD code share one definition. */
#ifdef WOLFSSH_HAVE_SYMLINK
#ifndef USE_WINDOWS_API
#include <fcntl.h> /* supplies O_NOFOLLOW/O_DIRECTORY on POSIX */
#endif
/* Open flag refusing to follow a final-component symlink, so the open is
* atomic against a swapped link. 0 where unavailable (Windows, link-less
* filesystems), where the caller falls back to a wIsSymlink check. */
#ifdef O_NOFOLLOW
#define WOLFSSH_O_NOFOLLOW O_NOFOLLOW
#else
@ -1610,6 +1605,22 @@ extern "C" {
#else
#define WOLFSSH_O_DIRECTORY 0
#endif
#endif /* WOLFSSH_HAVE_SYMLINK */
/* Catch-all so callers can use WOLFSSH_O_NOFOLLOW unconditionally; resolves to
* 0 when symlinks are not a concern (no-symlink filesystems, opt-out builds). */
#ifndef WOLFSSH_O_NOFOLLOW
#define WOLFSSH_O_NOFOLLOW 0
#endif
/* wIsSymlink lives in the always-compiled port.c, but its filesystem
* dependencies (WSTAT_T/WLSTAT/S_ISLNK on POSIX, WS_GetFileAttributesExA on
* Windows) and its only callers exist solely in the SFTP and SCP code, so
* gate it on those features in addition to the platform capability. Shared by
* the SFTP path-confinement guard and the SCP send guards. */
#if defined(WOLFSSH_HAVE_SYMLINK) && \
(defined(WOLFSSH_SFTP) || defined(WOLFSSH_SCP))
WOLFSSH_LOCAL int wIsSymlink(const char* path);
/* Open a file for reading without following a final-component symlink.
* Returns 0 on success, non-zero on failure, matching the wfopen