Skip to content

Commit 5823776

Browse files
wolfsshd: add StrictModes and secure loading of trust anchors
1 parent 52f6db9 commit 5823776

11 files changed

Lines changed: 959 additions & 20 deletions

File tree

.github/workflows/paramiko-sftp-test.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,14 @@ jobs:
9494
Subsystem sftp internal-sftp
9595
EOF
9696
97-
# Set proper permissions for keys
97+
# Set proper permissions for keys. wolfSSHd loads the host key
98+
# through the secure gate, which refuses a key not owned by root or
99+
# the daemon's user, or one that is group/world readable or writable.
100+
# The daemon is launched with sudo (euid 0) while the checkout key is
101+
# owned by the runner, so make it root-owned and 0600.
98102
chmod 600 ./keys/server-key.pem
99-
103+
sudo chown 0:0 ./keys/server-key.pem
104+
100105
# Print debug info
101106
echo "Contents of sshd_config.txt:"
102107
cat sshd_config.txt

.github/workflows/sshd-test.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ jobs:
107107
sudo apt-get -y install valgrind
108108
touch sshd_config.txt
109109
./configure --enable-all LDFLAGS="-L${{ github.workspace }}/build-dir/lib" CPPFLAGS="-I${{ github.workspace }}/build-dir/include -DWOLFSSH_NO_FPKI -DWOLFSSH_NO_SFTP_TIMEOUT -DWOLFSSH_MAX_SFTP_RW=4000000 -DMAX_PATH_SZ=120" --enable-static --disable-shared && make
110+
# wolfSSHd loads the host key through the secure gate; the daemon runs
111+
# under sudo (euid 0) so make the key root-owned and 0600.
112+
sudo chmod 600 ./keys/server-key.pem
113+
sudo chown 0:0 ./keys/server-key.pem
110114
sudo timeout --preserve-status -s 2 5 valgrind --error-exitcode=1 --leak-check=full ./apps/wolfsshd/wolfsshd -D -f sshd_config -h ./keys/server-key.pem -d -p 22222
111115
112116
# regression test, check that cat command does not hang
@@ -119,6 +123,10 @@ jobs:
119123
cat ./keys/hansel-*.pub > authorized_keys_test
120124
sed -i.bak "s/hansel/$USER/" ./authorized_keys_test
121125
./configure --enable-all LDFLAGS="-L${{ github.workspace }}/build-dir/lib" CPPFLAGS="-I${{ github.workspace }}/build-dir/include -DWOLFSSH_NO_FPKI -DWOLFSSH_NO_SFTP_TIMEOUT -DWOLFSSH_MAX_SFTP_RW=4000000 -DMAX_PATH_SZ=120" --enable-static --disable-shared && make
126+
# Host key must be root-owned and 0600 for the sudo-launched daemon's
127+
# secure gate to load it.
128+
sudo chmod 600 ./keys/server-key.pem
129+
sudo chown 0:0 ./keys/server-key.pem
122130
sudo ./apps/wolfsshd/wolfsshd -f sshd_config.txt -h ./keys/server-key.pem -p 22225
123131
chmod 600 ./keys/hansel-key-rsa.pem
124132
tail -c 50000 /dev/urandom > test

.github/workflows/x509-interop.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ jobs:
169169
- name: Start wolfSSHd
170170
working-directory: ./wolfssh/
171171
run: |
172+
# wolfSSHd loads the host key, host cert, and user CA through the
173+
# secure gate. The daemon runs under sudo (euid 0), so make all three
174+
# root-owned. The secret host key must also be 0600; the public cert
175+
# and CA stay 0644 (not group/world writable, still runner readable).
176+
sudo chmod 600 $PWD/keys/server-key.pem
177+
sudo chown 0:0 $PWD/keys/server-key.pem $PWD/keys/server-cert.pem \
178+
$PWD/keys/ca-cert-ecc.pem
172179
sudo ./apps/wolfsshd/wolfsshd -f sshd_config -d \
173180
-E $PWD/wolfsshd-log.txt &
174181
for i in $(seq 1 20); do

apps/wolfsshd/auth.c

Lines changed: 253 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,22 @@
6969

7070
#ifndef _WIN32
7171
#include <sys/types.h>
72+
#include <sys/stat.h>
7273
#include <pwd.h>
7374
#include <grp.h>
7475
#include <errno.h>
76+
#include <fcntl.h>
77+
#include <unistd.h>
78+
#include <limits.h>
79+
#include <stdlib.h>
80+
#ifndef O_NOFOLLOW
81+
/* Older platforms lack O_NOFOLLOW; the lstat() pre-check and the post-open
82+
* st_dev/st_ino comparison still reject a symlinked leaf there. */
83+
#define O_NOFOLLOW 0
84+
#endif
85+
#ifndef PATH_MAX
86+
#define PATH_MAX 4096
87+
#endif
7588
#endif
7689

7790
#if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__))
@@ -526,8 +539,225 @@ static int ResolveAuthKeysPath(const char* homeDir, const char* pattern,
526539
return ret;
527540
}
528541

542+
/* Securely open a trusted file, failing closed on a symlink, bad ownership, or
543+
* unsafe permissions, and hand back an open stream ready for reading. This is
544+
* the single gate for every security-critical file wolfsshd loads: a user's
545+
* authorized_keys, the host private key, the host certificate, and the user
546+
* certificate-authority keys.
547+
*
548+
* path - file to open.
549+
* ownerUid - the file itself must be owned by this user id or by root
550+
* (0). authorized_keys uses the owning user's id; the
551+
* daemon's trust anchors use the effective user id. Parent
552+
* directories are checked for writability but not ownership,
553+
* so a file may legitimately live under a directory owned by
554+
* a third party (e.g. a key under a build checkout or a
555+
* service account's tree).
556+
* rejectReadable - when set, also refuse a file that is group or world
557+
* readable. Used for secrets such as the host private key.
558+
* heap - heap hint for the temporary path buffer.
559+
* out - set to the open stream on success, WBADFILE otherwise.
560+
*
561+
* Returns WS_SUCCESS and sets *out on success; a specific reason is logged on
562+
* failure. On platforms without POSIX ownership semantics (_WIN32) the checks
563+
* are skipped and the file is opened directly, relying on filesystem ACLs. */
564+
int wolfSSHD_OpenSecureFile(const char* path, WUID_T ownerUid,
565+
int rejectReadable, void* heap, WFILE** out)
566+
{
567+
#ifndef _WIN32
568+
int ret = WS_SUCCESS;
569+
int fd = -1;
570+
int flags;
571+
struct stat lst;
572+
struct stat st;
573+
WFILE* f;
574+
char* resolved = NULL;
575+
char* slash;
576+
word32 i;
577+
578+
if (path == NULL || out == NULL) {
579+
return WS_BAD_ARGUMENT;
580+
}
581+
*out = WBADFILE;
582+
583+
/* The leaf must be a real, regular file. lstat() (not stat()) is used so a
584+
* symlinked leaf is rejected outright rather than silently followed to an
585+
* attacker-chosen target. */
586+
if (lstat(path, &lst) != 0 || !S_ISREG(lst.st_mode)) {
587+
wolfSSH_Log(WS_LOG_ERROR,
588+
"[SSHD] Refusing to load %s: missing, not a regular file, or a "
589+
"symlink", path);
590+
ret = WS_BAD_FILE_E;
591+
}
592+
593+
/* Canonicalize the path with realpath(), resolving any intermediate
594+
* symlinks, then open and validate that canonical path so the file opened
595+
* and the parent chain validated below are one and the same. */
596+
if (ret == WS_SUCCESS) {
597+
resolved = (char*)WMALLOC(PATH_MAX, heap, DYNTYPE_BUFFER);
598+
if (resolved == NULL) {
599+
ret = WS_MEMORY_E;
600+
}
601+
}
602+
if (ret == WS_SUCCESS) {
603+
if (realpath(path, resolved) == NULL) {
604+
wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Unable to resolve path %s", path);
605+
ret = WS_BAD_FILE_E;
606+
}
607+
}
608+
609+
/* Open the canonicalized path (not the original) so the directory chain
610+
* validated below is exactly the chain open() traverses. realpath() already
611+
* resolved every intermediate symlink; O_NOFOLLOW guards the
612+
* already-verified non-symlink leaf, and O_NONBLOCK keeps the open from
613+
* stalling on a FIFO swapped in after the lstat() and is cleared before the
614+
* buffered reads. The original path is used only in log messages. */
615+
if (ret == WS_SUCCESS) {
616+
fd = open(resolved, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
617+
if (fd < 0) {
618+
wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Unable to open %s", path);
619+
ret = WS_BAD_FILE_E;
620+
}
621+
}
622+
if (ret == WS_SUCCESS) {
623+
if (fstat(fd, &st) != 0) {
624+
wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Unable to stat %s", path);
625+
ret = WS_BAD_FILE_E;
626+
}
627+
}
628+
/* The ownership and mode checks run on the opened descriptor so there is no
629+
* window to swap the file after the check. Comparing st_dev/st_ino against
630+
* the earlier lstat() closes the narrow swap window on platforms where
631+
* O_NOFOLLOW is unavailable and compiles to 0. */
632+
if (ret == WS_SUCCESS) {
633+
if (!S_ISREG(st.st_mode)) {
634+
wolfSSH_Log(WS_LOG_ERROR,
635+
"[SSHD] Refusing to load %s: not a regular file", path);
636+
ret = WS_BAD_FILE_E;
637+
}
638+
else if (st.st_uid != ownerUid && st.st_uid != 0) {
639+
wolfSSH_Log(WS_LOG_ERROR,
640+
"[SSHD] Refusing to load %s: not owned by the user or root",
641+
path);
642+
ret = WS_BAD_FILE_E;
643+
}
644+
else if ((st.st_mode & (S_IWGRP | S_IWOTH)) != 0) {
645+
wolfSSH_Log(WS_LOG_ERROR,
646+
"[SSHD] Refusing to load %s: group or world writable", path);
647+
ret = WS_BAD_FILE_E;
648+
}
649+
else if (rejectReadable && (st.st_mode & (S_IRGRP | S_IROTH)) != 0) {
650+
wolfSSH_Log(WS_LOG_ERROR,
651+
"[SSHD] Refusing to load %s: group or world readable", path);
652+
ret = WS_BAD_FILE_E;
653+
}
654+
else if (st.st_dev != lst.st_dev || st.st_ino != lst.st_ino) {
655+
wolfSSH_Log(WS_LOG_ERROR,
656+
"[SSHD] Refusing to load %s: file changed during open", path);
657+
ret = WS_BAD_FILE_E;
658+
}
659+
}
660+
661+
/* Validate every parent directory of the canonicalized path up to the
662+
* filesystem root: none may be group or world writable (unless sticky),
663+
* which is what would let another user rename the file and swap it. Ancestor
664+
* ownership is not enforced; the leaf owner check above is what stops a file
665+
* owned by a third party from being loaded. Since realpath() resolved all
666+
* intermediate symlinks, this is the same chain open() traversed. The walk
667+
* trims components from 'resolved' in place, which is fine now that the file
668+
* is already open. */
669+
while (ret == WS_SUCCESS) {
670+
/* trim the last component to move up one directory */
671+
slash = NULL;
672+
for (i = 0; resolved[i] != '\0'; i++) {
673+
if (resolved[i] == '/') {
674+
slash = &resolved[i];
675+
}
676+
}
677+
if (slash == NULL) {
678+
break; /* no further parent (realpath always returns an absolute
679+
* path, so this is not expected) */
680+
}
681+
if (slash == resolved) {
682+
resolved[1] = '\0'; /* parent is the root directory "/" */
683+
}
684+
else {
685+
*slash = '\0';
686+
}
687+
688+
if (stat(resolved, &st) != 0) {
689+
wolfSSH_Log(WS_LOG_ERROR,
690+
"[SSHD] Unable to stat directory %s", resolved);
691+
ret = WS_BAD_FILE_E;
692+
}
693+
else if (!S_ISDIR(st.st_mode)) {
694+
wolfSSH_Log(WS_LOG_ERROR,
695+
"[SSHD] %s is not a directory", resolved);
696+
ret = WS_BAD_FILE_E;
697+
}
698+
else if ((st.st_mode & (S_IWGRP | S_IWOTH)) != 0 &&
699+
(st.st_mode & S_ISVTX) == 0) {
700+
/* A world/group writable directory is unsafe unless it is sticky:
701+
* the sticky bit stops a non-owner from renaming or deleting files
702+
* they do not own, which is exactly the substitution this guards
703+
* against (e.g. /tmp is mode 1777). */
704+
wolfSSH_Log(WS_LOG_ERROR,
705+
"[SSHD] Directory %s is group or world writable", resolved);
706+
ret = WS_BAD_FILE_E;
707+
}
708+
709+
if (ret != WS_SUCCESS || WSTRCMP(resolved, "/") == 0) {
710+
break; /* reached the filesystem root */
711+
}
712+
}
713+
714+
/* The target is a regular file, so restore blocking semantics for the
715+
* buffered reads the caller will perform. */
716+
if (ret == WS_SUCCESS) {
717+
flags = fcntl(fd, F_GETFL);
718+
if (flags != -1) {
719+
(void)fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
720+
}
721+
f = fdopen(fd, "rb");
722+
if (f == NULL) {
723+
wolfSSH_Log(WS_LOG_ERROR,
724+
"[SSHD] Unable to open stream for %s", path);
725+
ret = WS_BAD_FILE_E;
726+
}
727+
else {
728+
fd = -1; /* ownership of the descriptor moved to the stream */
729+
*out = f;
730+
}
731+
}
732+
733+
if (fd >= 0) {
734+
close(fd);
735+
}
736+
if (resolved != NULL) {
737+
WFREE(resolved, heap, DYNTYPE_BUFFER);
738+
}
739+
740+
return ret;
741+
#else
742+
WOLFSSH_UNUSED(ownerUid);
743+
WOLFSSH_UNUSED(rejectReadable);
744+
WOLFSSH_UNUSED(heap);
745+
746+
if (path == NULL || out == NULL) {
747+
return WS_BAD_ARGUMENT;
748+
}
749+
*out = WBADFILE;
750+
if (WFOPEN(NULL, out, path, "rb") != 0) {
751+
wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Unable to open %s", path);
752+
return WS_BAD_FILE_E;
753+
}
754+
return WS_SUCCESS;
755+
#endif
756+
}
757+
529758
static int SearchForPubKey(const char* path, const char* authKeysFile,
530-
const WS_UserAuthData_PublicKey* pubKeyCtx)
759+
const WS_UserAuthData_PublicKey* pubKeyCtx,
760+
WUID_T uid, int strictModes)
531761
{
532762
int ret = WSSHD_AUTH_SUCCESS;
533763
char authKeysPath[MAX_PATH_SZ];
@@ -546,8 +776,21 @@ static int SearchForPubKey(const char* path, const char* authKeysFile,
546776
ret = rc;
547777
}
548778

779+
/* When StrictModes is enabled, open through the secure gate: the file must
780+
* be a regular file (no symlink), owned by the user or root, with no
781+
* group/world writable component in its path. When disabled, fall back to a
782+
* plain open. */
549783
if (ret == WSSHD_AUTH_SUCCESS) {
550-
if (WFOPEN(NULL, &f, authKeysPath, "rb") != 0) {
784+
if (strictModes) {
785+
if (wolfSSHD_OpenSecureFile(authKeysPath, uid,
786+
0 /* rejectReadable */, NULL, &f) != WS_SUCCESS) {
787+
wolfSSH_Log(WS_LOG_ERROR,
788+
"[SSHD] Authorized keys file %s failed StrictModes check",
789+
authKeysPath);
790+
ret = WSSHD_AUTH_FAILURE;
791+
}
792+
}
793+
else if (WFOPEN(NULL, &f, authKeysPath, "rb") != 0) {
551794
wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Unable to open %s",
552795
authKeysPath);
553796
ret = WS_BAD_FILE_E;
@@ -593,6 +836,10 @@ static int SearchForPubKey(const char* path, const char* authKeysFile,
593836
WFCLOSE(NULL, f);
594837
}
595838

839+
if (lineBuf != NULL) {
840+
WFREE(lineBuf, NULL, DYNTYPE_BUFFER);
841+
}
842+
596843
if (ret == WSSHD_AUTH_SUCCESS && !foundKey) {
597844
ret = WSSHD_AUTH_FAILURE;
598845
}
@@ -705,7 +952,8 @@ static int CheckPublicKeyUnix(const char* name,
705952
}
706953

707954
if (ret == WSSHD_AUTH_SUCCESS) {
708-
ret = SearchForPubKey(pwInfo->pw_dir, authorizedKeysFile, pubKeyCtx);
955+
ret = SearchForPubKey(pwInfo->pw_dir, authorizedKeysFile, pubKeyCtx,
956+
pwInfo->pw_uid, wolfSSHD_ConfigGetStrictModes(authCtx->conf));
709957
}
710958
}
711959

@@ -1049,7 +1297,8 @@ static int CheckPublicKeyWIN(const char* usr,
10491297
if (ret == WSSHD_AUTH_SUCCESS) {
10501298
r[rSz-1] = L'\0';
10511299

1052-
ret = SearchForPubKey(r, authorizedKeysFile, pubKeyCtx);
1300+
ret = SearchForPubKey(r, authorizedKeysFile, pubKeyCtx, 0,
1301+
wolfSSHD_ConfigGetStrictModes(authCtx->conf));
10531302
if (ret != WSSHD_AUTH_SUCCESS) {
10541303
wolfSSH_Log(WS_LOG_ERROR,
10551304
"[SSHD] Failed to find public key for user %s", usr);

apps/wolfsshd/auth.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ HANDLE wolfSSHD_GetAuthToken(const WOLFSSHD_AUTH* auth);
8080
int wolfSSHD_GetHomeDirectory(WOLFSSHD_AUTH* auth, WOLFSSH* ssh, WCHAR* out, int outSz);
8181
#endif
8282

83+
/* Secure open for trusted files, shared by the authorized_keys path (auth.c)
84+
* and the trust-anchor loads in wolfsshd.c (host key, host cert, user CA keys).
85+
* See the definition in auth.c for the meaning of each argument. */
86+
int wolfSSHD_OpenSecureFile(const char* path, WUID_T ownerUid,
87+
int rejectReadable, void* heap, WFILE** out);
88+
8389
#ifdef WOLFSSHD_UNIT_TEST
8490
#ifndef _WIN32
8591
extern int (*wsshd_setregid_cb)(WGID_T, WGID_T);

0 commit comments

Comments
 (0)