diff --git a/apps/wolfsshd/auth.c b/apps/wolfsshd/auth.c index 4ed6bd81a..02cbbaced 100644 --- a/apps/wolfsshd/auth.c +++ b/apps/wolfsshd/auth.c @@ -111,6 +111,10 @@ int (*wsshd_seteuid_cb)(WUID_T) = seteuid; struct passwd* (*wsshd_getpwnam_cb)(const char*) = getpwnam; #define getpwnam wsshd_getpwnam_cb int (*wsshd_setgroups_cb)(int, const WGID_T*) = wsshd_setgroups_default; +#ifdef HAVE_SHADOW +struct spwd* (*wsshd_getspnam_cb)(const char*) = getspnam; +#define getspnam wsshd_getspnam_cb +#endif #endif #ifdef WOLFSSH_OSSH_CERTS @@ -318,6 +322,131 @@ static int CheckAuthKeysLine(char* line, word32 lineSz, const byte* key, return ret; } +#ifdef HAVE_SHADOW +/* Shared sizing for the dummy-hash buffers used to equalize crypt() timing + * across real and fake password checks. Also used in CheckPasswordUnix to + * size the real shadow hash copy buffer, so raising this changes both the + * fake-hash template capacity and the real-hash fail-closed threshold. */ +#define WSSHD_FAKE_HASH_SZ 256 +/* Modular crypt bcrypt format: "$2$$" prefix is 7 bytes, + * followed by a 22-byte base64-like salt, before the 31-byte digest. */ +#define WSSHD_BCRYPT_PREFIX_LEN 7 +#define WSSHD_BCRYPT_SALT_LEN 22 +/* Must be at least WSSHD_BCRYPT_SALT_LEN characters; indexed modulo its own + * length below so a mismatch can't read out of bounds. */ +#define WSSHD_DUMMY_SALT_ALPHABET "ABCDEFGHIJKLMNOPQRSTUV" + +static char cachedFakeHash[WSSHD_FAKE_HASH_SZ] = {0}; + +#ifdef WOLFSSHD_UNIT_TEST +void GetFakeHashFromTemplate(const char* tmpl, char* out, word32 outSz) +#else +static void GetFakeHashFromTemplate(const char* tmpl, char* out, word32 outSz) +#endif +{ + word32 i; + word32 dollarCount = 0; + word32 lastDollarIdx = 0; + + if (tmpl == NULL || out == NULL || outSz < 3) return; + + /* Output will always be considered a "locked" account by prefixing '!' */ + out[0] = '!'; + + /* If it doesn't look like a modular crypt format, fallback */ + if (tmpl[0] != '$') { + XSTRNCPY(out + 1, "*", outSz - 1); + return; + } + + if (XSTRNCMP(tmpl, "$2", 2) == 0) { + /* bcrypt: copy only prefix+salt, excluding the digest. Legacy + * "$2$NN$" has no variant letter, so its prefix is 1 byte shorter + * than "$2a$NN$" etc. */ + word32 prefixLen = (tmpl[2] == '$') ? + WSSHD_BCRYPT_PREFIX_LEN - 1 : WSSHD_BCRYPT_PREFIX_LEN; + word32 saltEnd = prefixLen + WSSHD_BCRYPT_SALT_LEN; + word32 copyLen = (saltEnd < outSz - 1) ? saltEnd : (outSz - 2); + + if (copyLen > XSTRLEN(tmpl)) { + copyLen = (word32)XSTRLEN(tmpl); + } + + XMEMCPY(out + 1, tmpl, copyLen); + out[1 + copyLen] = '\0'; + for (i = prefixLen; i < copyLen; i++) { + /* Overwrite with a dummy alphanumeric salt */ + out[i + 1] = WSSHD_DUMMY_SALT_ALPHABET[ + (i - prefixLen) % (sizeof(WSSHD_DUMMY_SALT_ALPHABET) - 1)]; + } + } + else { + word32 prevDollarIdx = 0; + /* Others (MD5, SHA, yescrypt): salt ends at the last '$' before the hash */ + for (i = 0; tmpl[i] != '\0'; i++) { + if (tmpl[i] == '$') { + dollarCount++; + prevDollarIdx = lastDollarIdx; + lastDollarIdx = i; + } + } + + /* e.g., $6$rounds=5000$salt$hash -> prevDollarIdx is before 'salt' */ + if (dollarCount >= 3 && prevDollarIdx + 2 < outSz) { + XMEMCPY(out + 1, tmpl, prevDollarIdx + 1); + out[prevDollarIdx + 2] = '\0'; + XSTRNCAT(out, "wolfSSHFakeSalt$", outSz - XSTRLEN(out) - 1); + } + else { + XSTRNCPY(out + 1, "*", outSz - 1); + } + } +} + +#ifdef WOLFSSHD_UNIT_TEST +/* Test-only hook to seed cachedFakeHash without a real /etc/shadow entry. */ +void wolfSSHD_SetCachedFakeHashForTest(const char* hash) +{ + if (hash == NULL) { + cachedFakeHash[0] = '\0'; + } + else { + XSTRNCPY(cachedFakeHash, hash, sizeof(cachedFakeHash)); + cachedFakeHash[sizeof(cachedFakeHash) - 1] = '\0'; + } +} + +/* Test-only accessor so tests can verify wolfSSHD_AuthInit() actually + * populated cachedFakeHash from a real shadow entry. */ +void wolfSSHD_GetCachedFakeHashForTest(char* out, word32 outSz) +{ + if (out == NULL || outSz == 0) return; + XSTRNCPY(out, cachedFakeHash, outSz); + out[outSz - 1] = '\0'; +} +#endif /* WOLFSSHD_UNIT_TEST */ +#endif /* HAVE_SHADOW */ + +void wolfSSHD_AuthInit(void) +{ +#ifdef HAVE_SHADOW + struct spwd* rootShadow = getspnam("root"); + if (rootShadow != NULL && rootShadow->sp_pwdp != NULL) { + GetFakeHashFromTemplate(rootShadow->sp_pwdp, cachedFakeHash, sizeof(cachedFakeHash)); + } + else { + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Error getting root password info"); + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Possibly permissions level error?" + " i.e SSHD not ran as sudo"); + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Timing side-channel mitigation degraded: using a" + " fixed-cost fake hash instead of matching real crypt() cost"); + } +#endif +} + #ifndef _WIN32 #ifdef WOLFSSH_USE_PAM @@ -387,20 +516,29 @@ static int ExtractSalt(char* hash, char** salt, int saltSz) #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) #ifdef WOLFSSHD_UNIT_TEST -int CheckPasswordHashUnix(const char* input, char* stored) +int CheckPasswordHashUnix(const char* input, const char* stored) #else -static int CheckPasswordHashUnix(const char* input, char* stored) +static int CheckPasswordHashUnix(const char* input, const char* stored) #endif { + /* Use fake hashes for locked/empty accounts to invoke crypt() + * and prevent timing side channels. SHA-512 is the primary choice + * to match genuine cost; others are fallbacks. */ + static const char fakeHashSHA512[] = + "$6$rounds=5000$wolfSSHdFakeSalt$"; + static const char fakeHashMD5[] = "$1$wolfSSHd$UkYLseEmSSXHYyxsWDQC80"; + static const char fakeHashDES[] = "wowolfSSHdUkYLs"; int ret = WSSHD_AUTH_SUCCESS; - char* hashedInput; + int locked = 0; + char* hashedInput = NULL; word32 hashedInputSz = 0, storedSz = 0; if (input == NULL || stored == NULL) { ret = WS_BAD_ARGUMENT; } - /* empty password case */ + /* Fast return for genuine empty passwords. The dummy caller + * never passes an empty stored hash, avoiding a timing oracle. */ if (ret == WSSHD_AUTH_SUCCESS && stored[0] == 0 && WSTRLEN(input) == 0) { wolfSSH_Log(WS_LOG_INFO, "[SSHD] User logged in with empty password"); @@ -408,16 +546,43 @@ static int CheckPasswordHashUnix(const char* input, char* stored) } if (ret == WSSHD_AUTH_SUCCESS) { - hashedInput = crypt(input, stored); + const char* salt = stored; + + storedSz = (word32)WSTRLEN(stored); + locked = (storedSz == 0 || stored[0] == '*' || stored[0] == '!'); + + if (locked) { + /* Try to reuse the salt from the locked hash if it exists and uses '!' */ + if (storedSz > 1 && stored[0] == '!') { + salt = stored + 1; + } + else { + salt = fakeHashSHA512; + } + } + + hashedInput = crypt(input, salt); + /* glibc signals an unsupported salt with a "*"-prefixed sentinel, + * not NULL; check both so the fallback engages on every libc. */ + if (locked && (hashedInput == NULL || hashedInput[0] == '*')) { + salt = fakeHashMD5; + hashedInput = crypt(input, salt); + if (hashedInput == NULL || hashedInput[0] == '*') { + salt = fakeHashDES; + hashedInput = crypt(input, salt); + } + } + if (hashedInput == NULL) { ret = WS_FATAL_ERROR; } + else if (locked) { + ret = WSSHD_AUTH_FAILURE; + } else { hashedInputSz = (word32)WSTRLEN(hashedInput); - storedSz = (word32)WSTRLEN(stored); - if (storedSz == 0 || stored[0] == '*' || - hashedInputSz == 0 || hashedInput[0] == '*' || + if (hashedInputSz == 0 || hashedInput[0] == '*' || hashedInputSz != storedSz || ConstantCompare((const byte*)hashedInput, (const byte*)stored, storedSz) != 0) { @@ -425,12 +590,15 @@ static int CheckPasswordHashUnix(const char* input, char* stored) } } } - return ret; } #endif /* WOLFSSH_HAVE_LIBCRYPT || WOLFSSH_HAVE_LIBLOGIN */ +#ifdef WOLFSSHD_UNIT_TEST +int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFSSHD_AUTH* authCtx) +#else static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFSSHD_AUTH* authCtx) +#endif { int ret = WS_SUCCESS; char* pwStr = NULL; @@ -439,8 +607,12 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS struct spwd* shadowInfo; #endif /* The hash of the user's password stored on the system. */ - char* storedHash; + const char* storedHash = "*"; char* storedHashCpy = NULL; +#ifdef HAVE_SHADOW + char hashBuf[WSSHD_FAKE_HASH_SZ]; + WMEMSET(hashBuf, 0, sizeof(hashBuf)); +#endif /* Allow zero length passwords, but not NULL pointers. */ if (usr == NULL || (pw == NULL && pwSz != 0)) { @@ -464,37 +636,55 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS pwInfo = getpwnam((const char*)usr); if (pwInfo == NULL) { /* user name not found on system */ - ret = WS_FATAL_ERROR; - wolfSSH_Log(WS_LOG_ERROR, + wolfSSH_Log(WS_LOG_INFO, "[SSHD] User name not found on system"); } - } - - if (ret == WS_SUCCESS) { - #ifdef HAVE_SHADOW - if (pwInfo->pw_passwd[0] == 'x') { - #ifdef WOLFSSH_HAVE_LIBCRYPT - shadowInfo = getspnam((const char*)usr); - #else - shadowInfo = getspnam((char*)usr); - #endif - if (shadowInfo == NULL) { - wolfSSH_Log(WS_LOG_ERROR, - "[SSHD] Error getting user password info"); - wolfSSH_Log(WS_LOG_ERROR, - "[SSHD] Possibly permissions level error?" - " i.e SSHD not ran as sudo"); - ret = WS_FATAL_ERROR; + else { +#ifdef HAVE_SHADOW + if (pwInfo->pw_passwd[0] == 'x') { +#ifdef WOLFSSH_HAVE_LIBCRYPT + shadowInfo = getspnam((const char*)usr); +#else + shadowInfo = getspnam((char*)usr); +#endif + if (shadowInfo == NULL) { + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Error getting user password info"); + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Possibly permissions level error?" + " i.e SSHD not ran as sudo"); + /* Fail closed: RequestAuthentication's error-branch + * DoFakePasswordCheck() still equalizes timing for this + * case, same as it does for the oversized-hash case. */ + ret = WS_FATAL_ERROR; + } + else if (shadowInfo->sp_pwdp == NULL) { + /* Fail closed: some NSS backends (e.g. NIS/LDAP) can + * return a spwd entry with a NULL sp_pwdp. */ + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Shadow entry missing password hash"); + ret = WS_FATAL_ERROR; + } + else if (WSTRLEN(shadowInfo->sp_pwdp) >= sizeof(hashBuf)) { + /* Fail closed instead of silently truncating the hash. */ + wolfSSH_Log(WS_LOG_ERROR, + "[SSHD] Stored password hash too long for buffer"); + ret = WS_FATAL_ERROR; + } + else { + /* Copy immediately: sp_pwdp points into getspnam()'s static + * buffer which may be overwritten by the next library call. */ + XSTRNCPY(hashBuf, shadowInfo->sp_pwdp, sizeof(hashBuf)); + hashBuf[sizeof(hashBuf) - 1] = '\0'; + storedHash = hashBuf; + } } - else { - storedHash = shadowInfo->sp_pwdp; + else +#endif + { + storedHash = pwInfo->pw_passwd; } } - else - #endif - { - storedHash = pwInfo->pw_passwd; - } } if (ret == WS_SUCCESS) { storedHashCpy = WSTRDUP(storedHash, NULL, DYNTYPE_STRING); @@ -507,6 +697,11 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS if (ret == WS_SUCCESS) { #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + /* For nonexistent users, storedHash is "*", so CheckPasswordHashUnix + * already fails; forcing failure here would mask WS_FATAL_ERROR. + * DoCheckUser() already filters unknown users on the real request + * path (timing there is equalized by DoFakePasswordCheck()), so + * this is defense-in-depth for direct callers only. */ ret = CheckPasswordHashUnix(pwStr, storedHashCpy); #else wolfSSH_Log(WS_LOG_ERROR, "[SSHD] No compiled in password check"); @@ -522,6 +717,9 @@ static int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, WOLFS WS_FORCEZERO(storedHashCpy, (word32)WSTRLEN(storedHashCpy) + 1); WFREE(storedHashCpy, NULL, DYNTYPE_STRING); } +#ifdef HAVE_SHADOW + WS_FORCEZERO(hashBuf, sizeof(hashBuf)); +#endif WOLFSSH_UNUSED(authCtx); return ret; @@ -1907,6 +2105,73 @@ WOLFSSHD_STATIC int MatchUPNToUser(const char* usr, const char* name, #endif /* WOLFSSL_FPKI || WOLFSSHD_UNIT_TEST */ +#ifdef WOLFSSHD_UNIT_TEST +/* Test-only spy so tests can assert whether DoFakePasswordCheck() ran, + * independent of return codes that look the same whether or not it fired. */ +static int fakePasswordCheckCallCount = 0; + +void wolfSSHD_ResetFakePasswordCheckCountForTest(void) +{ + fakePasswordCheckCallCount = 0; +} + +int wolfSSHD_GetFakePasswordCheckCountForTest(void) +{ + return fakePasswordCheckCallCount; +} +#endif + +/* Runs a fake crypt() to prevent user enumeration via timing attacks. + * Uses a dummy password for non-password auth types. */ +#ifdef WOLFSSHD_UNIT_TEST +void DoFakePasswordCheck(WS_UserAuthData* authData) +#else +static void DoFakePasswordCheck(WS_UserAuthData* authData) +#endif +{ +#ifdef WOLFSSHD_UNIT_TEST + fakePasswordCheckCallCount++; +#endif +#if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + char* fakePwStr = NULL; + const byte* fakePw = NULL; + word32 fakePwSz = 0; + const char* fakeHash = "*"; + + if (authData->type == WOLFSSH_USERAUTH_PASSWORD) { + fakePw = authData->sf.password.password; + fakePwSz = authData->sf.password.passwordSz; + } + +#ifdef HAVE_SHADOW + if (cachedFakeHash[0] != '\0') { + fakeHash = cachedFakeHash; + } +#endif + + fakePwStr = (char*)WMALLOC(fakePwSz + 1, NULL, DYNTYPE_STRING); + if (fakePwStr != NULL) { + if (fakePwSz > 0 && fakePw != NULL) { + XMEMCPY(fakePwStr, fakePw, fakePwSz); + } + fakePwStr[fakePwSz] = 0; + + /* Return value intentionally ignored: this is a fake check whose + * result must never influence real authentication. */ + CheckPasswordHashUnix(fakePwStr, fakeHash); + + WS_FORCEZERO(fakePwStr, fakePwSz + 1); + WFREE(fakePwStr, NULL, DYNTYPE_STRING); + } + else { + CheckPasswordHashUnix("", fakeHash); + } +#else + WOLFSSH_UNUSED(authData); +#endif +} + + /* * @TODO this will take a pipe or equivalent to talk to a privileged thread * rather than having WOLFSSHD_AUTH directly with privilege separation. @@ -1949,12 +2214,26 @@ static int RequestAuthentication(WS_UserAuthData* authData, usr = (const char*)authData->username; ret = DoCheckUser(usr, authCtx); + + /* Only password auth has a crypt() cost to equalize; doing this for + * other auth types would itself be a timing oracle. */ + if (ret != WOLFSSH_USERAUTH_SUCCESS && + authData->type == WOLFSSH_USERAUTH_PASSWORD) { + DoFakePasswordCheck(authData); + } + /* temporarily elevate permissions */ if (ret == WOLFSSH_USERAUTH_SUCCESS && wolfSSHD_AuthRaisePermissions(authCtx) != WS_SUCCESS) { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Failure to raise permissions for auth"); ret = WOLFSSH_USERAUTH_FAILURE; + + /* Only password auth has a crypt() cost to equalize here; see the + * comment on the DoCheckUser() call above. */ + if (authData->type == WOLFSSH_USERAUTH_PASSWORD) { + DoFakePasswordCheck(authData); + } } /* Resolve the per-user configuration so that Match block overrides are @@ -1978,6 +2257,12 @@ static int RequestAuthentication(WS_UserAuthData* authData, "[SSHD] Failure to get user configuration for auth (user=%s)", usr); ret = WOLFSSH_USERAUTH_FAILURE; + + /* Only password auth has a crypt() cost to equalize here; see + * the comment on the DoCheckUser() call above. */ + if (authData->type == WOLFSSH_USERAUTH_PASSWORD) { + DoFakePasswordCheck(authData); + } } } @@ -1997,7 +2282,9 @@ static int RequestAuthentication(WS_UserAuthData* authData, "configuration!"); ret = WOLFSSH_USERAUTH_FAILURE; } - else { + + /* Only run password check when config allows it (avoids leaks). */ + if (ret == WOLFSSH_USERAUTH_SUCCESS) { rc = authCtx->checkPasswordCb(usr, authData->sf.password.password, authData->sf.password.passwordSz, authCtx); if (rc == WSSHD_AUTH_SUCCESS) { @@ -2017,8 +2304,16 @@ static int RequestAuthentication(WS_UserAuthData* authData, else { wolfSSH_Log(WS_LOG_ERROR, "[SSHD] Error checking password."); ret = WOLFSSH_USERAUTH_FAILURE; + + /* Fake crypt() prevents this error path (e.g. oversized + * shadow hash) from being distinguishable from a normal + * wrong-password rejection. */ + DoFakePasswordCheck(authData); } } + else { + DoFakePasswordCheck(authData); + } } diff --git a/apps/wolfsshd/auth.h b/apps/wolfsshd/auth.h index 78bc2c905..756b89fac 100644 --- a/apps/wolfsshd/auth.h +++ b/apps/wolfsshd/auth.h @@ -69,6 +69,7 @@ typedef int (*CallbackCheckPublicKey)(const char* usr, const char* authorizedKeysFile, WOLFSSHD_AUTH* authCtx); +void wolfSSHD_AuthInit(void); WOLFSSHD_AUTH* wolfSSHD_AuthCreateUser(void* heap, const WOLFSSHD_CONFIG* conf); int wolfSSHD_AuthFreeUser(WOLFSSHD_AUTH* auth); int wolfSSHD_AuthReducePermissions(WOLFSSHD_AUTH* auth); @@ -109,6 +110,10 @@ extern int (*wsshd_setegid_cb)(WGID_T); extern int (*wsshd_seteuid_cb)(WUID_T); extern struct passwd* (*wsshd_getpwnam_cb)(const char*); extern int (*wsshd_setgroups_cb)(int, const WGID_T*); +/* Mirrors auth.c's HAVE_SHADOW condition; only defined there under it. */ +#if !defined(__OSX__) && !defined(__APPLE__) +extern struct spwd* (*wsshd_getspnam_cb)(const char*); +#endif extern int (*wsshd_getgrouplist_cb)(const char*, WGID_T, WGID_T*, int*); int wolfSSHD_GetUserGroupNames(void* heap, const char* usr, WGID_T primaryGid, char*** outNames, word32* outCount); @@ -119,7 +124,32 @@ int SearchForPubKey(const char* path, const char* authKeysFile, WUID_T uid, int strictModes); #endif #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) -int CheckPasswordHashUnix(const char* input, char* stored); +int CheckPasswordHashUnix(const char* input, const char* stored); +#endif +/* Mirrors auth.c's HAVE_SHADOW condition; only defined there under it. */ +#if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) +void GetFakeHashFromTemplate(const char* tmpl, char* out, word32 outSz); +#ifdef WOLFSSHD_UNIT_TEST +/* Unlike GetFakeHashFromTemplate() above, these two are pure test hooks: + * auth.c only defines them under HAVE_SHADOW && WOLFSSHD_UNIT_TEST. */ +void wolfSSHD_SetCachedFakeHashForTest(const char* hash); +void wolfSSHD_GetCachedFakeHashForTest(char* out, word32 outSz); +#endif +#endif +/* CheckPasswordUnix and DoFakePasswordCheck are not shadow-specific in + * auth.c, so they aren't excluded on OSX/APPLE there. */ +#if !defined(_WIN32) && !defined(WOLFSSH_USE_PAM) +/* Returns WSSHD_AUTH_SUCCESS/WSSHD_AUTH_FAILURE for a resolved user + * (including one unknown to getpwnam()), or a WS_* error code for a + * system/config failure (e.g. shadow lookup). */ +int CheckPasswordUnix(const char* usr, const byte* pw, word32 pwSz, + WOLFSSHD_AUTH* authCtx); +#endif +void DoFakePasswordCheck(WS_UserAuthData* authData); +#ifdef WOLFSSHD_UNIT_TEST +/* Pure test hooks: auth.c only defines these under WOLFSSHD_UNIT_TEST. */ +void wolfSSHD_ResetFakePasswordCheckCountForTest(void); +int wolfSSHD_GetFakePasswordCheckCountForTest(void); #endif int CheckAuthKeysLine(char* line, word32 lineSz, const byte* key, word32 keySz); diff --git a/apps/wolfsshd/test/test_configuration.c b/apps/wolfsshd/test/test_configuration.c index 4f9d6c44a..743e547ac 100644 --- a/apps/wolfsshd/test/test_configuration.c +++ b/apps/wolfsshd/test/test_configuration.c @@ -21,6 +21,9 @@ #include #include #endif +#if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) + #include +#endif #include #include @@ -1290,9 +1293,11 @@ static int test_ConfigFree(void) } #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) -/* Negative-path coverage for CheckPasswordHashUnix so mutation of the - * ConstantCompare clause (the only substantive check once crypt() has - * produced its fixed-length output) does not survive the test suite. */ +/* Negative-path coverage for CheckPasswordHashUnix to prevent mutation of + * the ConstantCompare clause. + * + * fakeHashMD5/fakeHashDES fallback is not covered because glibc crypt() + * returns "*0" rather than NULL, making the branch unreachable here. */ static int test_CheckPasswordHashUnix(void) { int ret = WS_SUCCESS; @@ -1342,12 +1347,13 @@ static int test_CheckPasswordHashUnix(void) } } - /* (a) empty input + empty stored hash -> SUCCESS (pins the empty branch). */ if (ret == WS_SUCCESS) { - char emptyStored[2]; - emptyStored[0] = 0; - Log(" Testing scenario: empty password + empty stored hash authenticates."); - rc = CheckPasswordHashUnix("", emptyStored); + char empty[1]; + + empty[0] = '\0'; + + Log(" Empty password + empty stored: "); + rc = CheckPasswordHashUnix(empty, empty); if (rc == WSSHD_AUTH_SUCCESS) { Log(" PASSED.\n"); } @@ -1357,10 +1363,13 @@ static int test_CheckPasswordHashUnix(void) } } - /* (b) empty input + real $6$ hash -> FAILURE (kills the && -> || mutant). */ if (ret == WS_SUCCESS) { - Log(" Testing scenario: empty password + real hash is rejected."); - rc = CheckPasswordHashUnix("", stored); + char empty[1]; + + empty[0] = '\0'; + + Log(" Empty password vs real hash: "); + rc = CheckPasswordHashUnix(empty, stored); if (rc == WSSHD_AUTH_FAILURE) { Log(" PASSED.\n"); } @@ -1370,11 +1379,12 @@ static int test_CheckPasswordHashUnix(void) } } - /* (c) non-empty input + empty stored hash -> FAILURE. */ if (ret == WS_SUCCESS) { - char emptyStored[2]; - emptyStored[0] = 0; - Log(" Testing scenario: non-empty password + empty stored hash is rejected."); + char emptyStored[1]; + + emptyStored[0] = '\0'; + + Log(" Non-empty password vs empty stored: "); rc = CheckPasswordHashUnix(correct, emptyStored); if (rc == WSSHD_AUTH_FAILURE) { Log(" PASSED.\n"); @@ -1385,13 +1395,30 @@ static int test_CheckPasswordHashUnix(void) } } - /* (d) locked account (stored[0]=='*') -> FAILURE (pins the '*' guard). */ if (ret == WS_SUCCESS) { - char lockedStored[3]; - lockedStored[0] = '*'; - lockedStored[1] = 0; - Log(" Testing scenario: locked account is rejected."); - rc = CheckPasswordHashUnix(correct, lockedStored); + char locked[] = "*"; + + Log(" Locked account (stored[0] == '*'): "); + rc = CheckPasswordHashUnix(correct, locked); + if (rc == WSSHD_AUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + char lockedWithSalt[130]; + + /* A locked account starting with '!' exercises salt-reuse instead of + * fake hash fallback. It must fail auth even with a correct password. */ + lockedWithSalt[0] = '!'; + WMEMCPY(lockedWithSalt + 1, stored, WSTRLEN(stored) + 1); + + Log(" Locked account with reusable '!' salt: "); + rc = CheckPasswordHashUnix(correct, lockedWithSalt); if (rc == WSSHD_AUTH_FAILURE) { Log(" PASSED.\n"); } @@ -1403,6 +1430,662 @@ static int test_CheckPasswordHashUnix(void) return ret; } + +#if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) +/* Coverage for GetFakeHashFromTemplate's format-specific branches: bcrypt's + * fixed-width copy, the dollar-counting split used by SHA/MD5/yescrypt, and + * the buffer-bound fallbacks that must not overflow `out`. */ +static int test_GetFakeHashFromTemplate(void) +{ + int ret = WS_SUCCESS; + char out[256]; + + Log(" Non modular-crypt-format template falls back to '!*': "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate("plaintextnotahash", out, sizeof(out)); + if (out[0] == '!' && WSTRCMP(out + 1, "*") == 0) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + + if (ret == WS_SUCCESS) { + /* bcrypt: prefix+cost (7) + salt (22) copied, never the 31-byte + * digest that follows. */ + const char* bcryptTmpl = + "$2b$12$abcdefghijklmnopqrstuvXYZZYXWVUTSRQPONMLKJIHGFEDCBA"; + + Log(" bcrypt template copies only prefix+salt, not digest: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(bcryptTmpl, out, sizeof(out)); + if (out[0] == '!' && WSTRLEN(out) == 1 + 7 + 22 && + WSTRNCMP(out + 1, bcryptTmpl, 4) == 0 && + WSTRSTR(out, "XYZZYXWVUTSRQPONMLKJIHGFEDCBA") == NULL && + WSTRSTR(out, "abcdefghijklmnopqrstuv") == NULL) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + /* Legacy "$2$NN$" bcrypt has a 6-byte prefix, one shorter than + * "$2b$NN$"'s 7. */ + const char* bcryptBareTmpl = + "$2$12$abcdefghijklmnopqrstuvXYZZYXWVUTSRQPONMLKJIHGFEDCBA"; + + Log(" Bare \"$2$NN$\" bcrypt template copies only prefix+salt, " + "not digest: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(bcryptBareTmpl, out, sizeof(out)); + if (out[0] == '!' && WSTRLEN(out) == 1 + 6 + 22 && + WSTRNCMP(out + 1, bcryptBareTmpl, 3) == 0 && + WSTRSTR(out, "XYZZYXWVUTSRQPONMLKJIHGFEDCBA") == NULL) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + const char* bcryptTruncated = "$2a$10$tooshort"; + + Log(" bcrypt template shorter than salt length is not overrun: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(bcryptTruncated, out, sizeof(out)); + if (out[0] == '!' && + WSTRLEN(out) == 1 + WSTRLEN(bcryptTruncated)) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + /* $6$rounds=5000$salt$digest -- prevDollarIdx sits just before the + * real salt, so the copied prefix is "$6$rounds=5000$"; both the + * real salt and digest are dropped and replaced by a fixed dummy + * salt, keeping only the algorithm id and rounds cost. */ + const char* sha512Tmpl = + "$6$rounds=5000$realsaltvalue$realdigestshouldnotappearhere"; + + Log(" SHA-512 template drops real salt and digest, keeps " + "prefix+rounds: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(sha512Tmpl, out, sizeof(out)); + if (WSTRCMP(out, "!$6$rounds=5000$wolfSSHFakeSalt$") == 0 && + WSTRSTR(out, "realsaltvalue") == NULL && + WSTRSTR(out, "realdigestshouldnotappearhere") == NULL) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + /* Only a single '$' in the whole template: dollarCount < 3, so this + * must fall back to '!*' rather than reading past the template. */ + const char* singleDollar = "$onlyonedollar"; + + Log(" Template with fewer than 3 salt dollars falls back: "); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate(singleDollar, out, sizeof(out)); + if (out[0] == '!' && WSTRCMP(out + 1, "*") == 0) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + /* A tiny output buffer must not be overrun regardless of template + * shape or which branch is taken. */ + char tiny[3]; + const char* sha512Tmpl = + "$6$rounds=5000$realsaltvalue$realdigestshouldnotappearhere"; + + Log(" Undersized output buffer is not overrun: "); + WMEMSET(tiny, 0, sizeof(tiny)); + GetFakeHashFromTemplate(sha512Tmpl, tiny, sizeof(tiny)); + if (tiny[0] == '!' && WSTRLEN(tiny) < sizeof(tiny)) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + if (ret == WS_SUCCESS) { + Log(" NULL template/out and outSz < 3 are rejected without a crash: "); + GetFakeHashFromTemplate(NULL, out, sizeof(out)); + GetFakeHashFromTemplate("$6$a$b$c", NULL, sizeof(out)); + WMEMSET(out, 0, sizeof(out)); + GetFakeHashFromTemplate("$6$a$b$c", out, 2); + if (out[0] == '\0') { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + return ret; +} +#endif /* !_WIN32 && !(__OSX__ || __APPLE__) */ + +static int test_DefaultUserAuth_OOBRead(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + char* passwordHeap; + word32 passwordSz = 16; + int rc; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + Log(" Skipping test: wolfSSHD_AuthCreateUser failed (likely missing 'sshd' user).\n"); + return WS_SUCCESS; + } + + passwordHeap = (char*)WMALLOC(passwordSz, NULL, DYNAMIC_TYPE_STRING); + if (passwordHeap != NULL) { + WMEMSET(passwordHeap, 'A', passwordSz); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.username = (const byte*)"nonexistent_test_user_xyz"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + authData.sf.password.password = (const byte*)passwordHeap; + authData.sf.password.passwordSz = passwordSz; + + Log(" Testing scenario: DefaultUserAuth with non-NUL-terminated password (OOB read check)."); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc == WOLFSSH_USERAUTH_FAILURE || rc == WOLFSSH_USERAUTH_REJECTED || + rc == WOLFSSH_USERAUTH_INVALID_USER) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + + WFREE(passwordHeap, NULL, DYNAMIC_TYPE_STRING); + } + else { + ret = WS_MEMORY_E; + } + + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; +} + +#if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) +/* wolfSSHD_AuthInit() (which populates cachedFakeHash) is never called by + * this suite, so seed it directly to exercise DoFakePasswordCheck(), which + * RequestAuthentication calls after DoCheckUser rejects the nonexistent + * user (CheckPasswordUnix is never reached for this username). */ +static int test_CachedFakeHashConsumption(void) +{ + int ret = WS_SUCCESS; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + char* passwordHeap; + word32 passwordSz = 8; + int rc; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + Log(" Skipping test: wolfSSHD_AuthCreateUser failed (likely missing 'sshd' user).\n"); + return WS_SUCCESS; + } + + wolfSSHD_SetCachedFakeHashForTest("!$6$wolfsshtestsalt$wolfSSHFakeSalt$"); + + passwordHeap = (char*)WMALLOC(passwordSz, NULL, DYNAMIC_TYPE_STRING); + if (passwordHeap != NULL) { + WMEMCPY(passwordHeap, "guessme", passwordSz); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.username = (const byte*)"nonexistent_test_user_xyz"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + authData.sf.password.password = (const byte*)passwordHeap; + authData.sf.password.passwordSz = passwordSz; + + Log(" Testing scenario: nonexistent user checked against seeded " + "cachedFakeHash."); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc == WOLFSSH_USERAUTH_FAILURE || rc == WOLFSSH_USERAUTH_REJECTED || + rc == WOLFSSH_USERAUTH_INVALID_PASSWORD || + rc == WOLFSSH_USERAUTH_INVALID_USER) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + + WFREE(passwordHeap, NULL, DYNAMIC_TYPE_STRING); + } + else { + ret = WS_MEMORY_E; + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; +} + +/* Synthetic account used to drive CheckPasswordUnix's shadow-lookup + * branches without depending on the host's real /etc/shadow contents. */ +static struct passwd stub_shadow_test_pw; +static struct spwd stub_shadow_test_sp; +static char stub_shadow_test_hash[300]; + +static struct passwd* stub_getpwnam_shadowUser(const char* name) +{ + if (name != NULL && WSTRCMP(name, "shadow_branch_test_user") == 0) { + WMEMSET(&stub_shadow_test_pw, 0, sizeof(stub_shadow_test_pw)); + stub_shadow_test_pw.pw_name = (char*)"shadow_branch_test_user"; + stub_shadow_test_pw.pw_uid = 1002; + stub_shadow_test_pw.pw_gid = 1002; + stub_shadow_test_pw.pw_passwd = (char*)"x"; + return &stub_shadow_test_pw; + } + return NULL; +} + +static struct passwd* stub_getpwnam_null(const char* name) +{ + (void)name; + return NULL; +} + +static struct spwd* stub_getspnam_null(const char* name) +{ + (void)name; + return NULL; +} + +static struct spwd* stub_getspnam_oversizedHash(const char* name) +{ + (void)name; + WMEMSET(&stub_shadow_test_sp, 0, sizeof(stub_shadow_test_sp)); + stub_shadow_test_sp.sp_namp = (char*)"shadow_branch_test_user"; + /* hashBuf in CheckPasswordUnix is 256 bytes; this exceeds it. */ + WMEMSET(stub_shadow_test_hash, 'A', sizeof(stub_shadow_test_hash) - 1); + stub_shadow_test_hash[sizeof(stub_shadow_test_hash) - 1] = '\0'; + stub_shadow_test_sp.sp_pwdp = stub_shadow_test_hash; + return &stub_shadow_test_sp; +} + +/* getspnam() entry exists (e.g. NIS/LDAP-backed root account) but has no + * password field populated. */ +static struct spwd* stub_getspnam_nullPassword(const char* name) +{ + (void)name; + WMEMSET(&stub_shadow_test_sp, 0, sizeof(stub_shadow_test_sp)); + stub_shadow_test_sp.sp_namp = (char*)"root"; + stub_shadow_test_sp.sp_pwdp = NULL; + return &stub_shadow_test_sp; +} + +/* Unknown user must fall through to the "*" stored hash and fail via + * CheckPasswordHashUnix, not crash or succeed. */ +static int test_CheckPasswordUnix_unknownUser(void) +{ + int ret = WS_SUCCESS; +#if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + int rc; + struct passwd* (*savedGetpwnam)(const char*); + static const byte pw[] = "guessme"; + + savedGetpwnam = wsshd_getpwnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_null; + + rc = CheckPasswordUnix("nonexistent_test_user_xyz", pw, + (word32)(sizeof(pw) - 1), NULL); + if (rc != WSSHD_AUTH_FAILURE) { + Log(" FAILED: expected WSSHD_AUTH_FAILURE for unknown user, " + "got %d.\n", rc); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; +#else + (void)stub_getpwnam_null; + Log(" Skipping test: password hash checking not compiled in.\n"); +#endif + return ret; +} + +/* getspnam() failing (e.g. SSHD not run as root) must fail closed rather + * than silently falling through to compare against the "*" default hash. */ +static int test_CheckPasswordUnix_shadowLookupFails(void) +{ + int ret = WS_SUCCESS; + int rc; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + static const byte pw[] = "guessme"; + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_null; + + rc = CheckPasswordUnix("shadow_branch_test_user", pw, + (word32)(sizeof(pw) - 1), NULL); + if (rc != WS_FATAL_ERROR) { + Log(" FAILED: expected WS_FATAL_ERROR when getspnam() fails.\n"); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; + return ret; +} + +/* A shadow hash too long for CheckPasswordUnix's fixed hashBuf must fail + * closed instead of being silently truncated. */ +static int test_CheckPasswordUnix_shadowHashTooLong(void) +{ + int ret = WS_SUCCESS; + int rc; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + static const byte pw[] = "guessme"; + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_oversizedHash; + + rc = CheckPasswordUnix("shadow_branch_test_user", pw, + (word32)(sizeof(pw) - 1), NULL); + if (rc != WS_FATAL_ERROR) { + Log(" FAILED: expected WS_FATAL_ERROR for oversized shadow hash.\n"); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; + return ret; +} + +/* A shadow entry with a NULL sp_pwdp (e.g. an NIS/LDAP-backed account) must + * fail closed instead of crashing on a NULL dereference in WSTRLEN(). */ +static int test_CheckPasswordUnix_shadowNullPassword(void) +{ + int ret = WS_SUCCESS; + int rc; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + static const byte pw[] = "guessme"; + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_nullPassword; + + rc = CheckPasswordUnix("shadow_branch_test_user", pw, + (word32)(sizeof(pw) - 1), NULL); + if (rc != WS_FATAL_ERROR) { + Log(" FAILED: expected WS_FATAL_ERROR for a shadow entry with a " + "NULL password field, got %d.\n", rc); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; + return ret; +} + +static struct spwd* stub_getspnam_validHash(const char* name) +{ + (void)name; + WMEMSET(&stub_shadow_test_sp, 0, sizeof(stub_shadow_test_sp)); + stub_shadow_test_sp.sp_namp = (char*)"shadow_branch_test_user"; + stub_shadow_test_sp.sp_pwdp = stub_shadow_test_hash; + return &stub_shadow_test_sp; +} + +/* Copy-then-succeed path: a normal-length shadow hash copied into + * CheckPasswordUnix's hashBuf, then compared for real. */ +static int test_CheckPasswordUnix_shadowLookupSucceeds(void) +{ + int ret = WS_SUCCESS; +#if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) + int rc; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + static const byte correctPw[] = "guessme"; + static const byte wrongPw[] = "wrongpw"; + /* SHA-512 crypt salt; portable across glibc-based crypt() impls. */ + const char* salt = "$6$wolfsshtestsalt$"; + char* hash; + + hash = crypt((const char*)correctPw, salt); + /* See test_CheckPasswordHashUnix: some libc (macOS/BSD) ignore the + * modular salt and fall back to legacy DES, so skip there. */ + if (hash == NULL || hash[0] == '*' || WSTRLEN(hash) == 0 || + WSTRNCMP(hash, "$6$", 3) != 0) { + Log(" crypt() did not honor $6$ SHA-512, skipping.\n"); + return WS_SUCCESS; + } + if (WSTRLEN(hash) >= sizeof(stub_shadow_test_hash)) { + return WS_FATAL_ERROR; + } + WMEMCPY(stub_shadow_test_hash, hash, WSTRLEN(hash) + 1); + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_validHash; + + Log(" Testing scenario: correct password against copied shadow hash."); + rc = CheckPasswordUnix("shadow_branch_test_user", correctPw, + (word32)(sizeof(correctPw) - 1), NULL); + if (rc == WSSHD_AUTH_SUCCESS) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + + if (ret == WS_SUCCESS) { + Log(" Testing scenario: wrong password against copied shadow hash."); + rc = CheckPasswordUnix("shadow_branch_test_user", wrongPw, + (word32)(sizeof(wrongPw) - 1), NULL); + if (rc == WSSHD_AUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED.\n"); + ret = WS_FATAL_ERROR; + } + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; +#else + Log(" Skipping test: password hash checking not compiled in.\n"); +#endif + return ret; +} + +/* authData->sf is a union; for a non-password auth type DoFakePasswordCheck + * must not read the password/passwordSz members at all. Poison those exact + * bytes (invalid pointer, huge length) via the union before tagging the type + * as PUBLICKEY, so a regression that reinstates an unconditional read would + * dereference an invalid pointer here instead of quietly working by luck. */ +static int test_DoFakePasswordCheck_pubkeyUnionSafety(void) +{ + WS_UserAuthData authData; + + WMEMSET(&authData, 0, sizeof(authData)); + authData.sf.password.password = (const byte*)(size_t)1; + authData.sf.password.passwordSz = 0xFFFFFFFFU; + authData.type = WOLFSSH_USERAUTH_PUBLICKEY; + + Log(" Testing scenario: DoFakePasswordCheck with PUBLICKEY type and " + "poisoned password union fields."); + DoFakePasswordCheck(&authData); + Log(" PASSED.\n"); + + return WS_SUCCESS; +} + +/* Exercises wolfSSHD_AuthInit() itself, rather than just seeding + * cachedFakeHash through the test hook, to catch regressions in its + * getspnam("root")/sp_pwdp wiring (wrong struct field, wrong sizeof, etc). + * Requires read access to /etc/shadow; skips gracefully otherwise. */ +static int test_AuthInit(void) +{ + int ret = WS_SUCCESS; + struct spwd* rootShadow; + struct spwd* (*savedGetspnam)(const char*); + char expected[256]; + char actual[256]; + + wolfSSHD_SetCachedFakeHashForTest(NULL); + + /* Force the real getspnam(), independent of what earlier tests left + * this global as. */ + savedGetspnam = wsshd_getspnam_cb; + wsshd_getspnam_cb = getspnam; + + rootShadow = getspnam("root"); + if (rootShadow == NULL || rootShadow->sp_pwdp == NULL) { + Log(" Skipping test: no read access to /etc/shadow " + "(likely not running as root).\n"); + wsshd_getspnam_cb = savedGetspnam; + return WS_SUCCESS; + } + + WMEMSET(expected, 0, sizeof(expected)); + GetFakeHashFromTemplate(rootShadow->sp_pwdp, expected, sizeof(expected)); + + wolfSSHD_AuthInit(); + + WMEMSET(actual, 0, sizeof(actual)); + wolfSSHD_GetCachedFakeHashForTest(actual, sizeof(actual)); + + if (WSTRNCMP(expected, actual, sizeof(expected)) != 0) { + Log(" FAILED: wolfSSHD_AuthInit() did not populate " + "cachedFakeHash as expected.\n"); + ret = WS_FATAL_ERROR; + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + wsshd_getspnam_cb = savedGetspnam; + + return ret; +} + +/* test_AuthInit only covers the getspnam("root") success path and skips + * when not root. Stub getspnam() to force the degraded-mode branch + * regardless of process privileges. */ +static int test_AuthInit_degradedMode(void) +{ + int ret = WS_SUCCESS; + struct spwd* (*savedGetspnam)(const char*); + char actual[256]; + + savedGetspnam = wsshd_getspnam_cb; + wsshd_getspnam_cb = stub_getspnam_null; + + wolfSSHD_SetCachedFakeHashForTest("!$6$wolfsshtestsalt$priorFakeHash$"); + + Log(" Testing scenario: wolfSSHD_AuthInit() with getspnam() " + "failing."); + wolfSSHD_AuthInit(); + + /* Degraded mode must leave any previously-cached fake hash alone + * rather than clobbering it with something derived from no data. */ + WMEMSET(actual, 0, sizeof(actual)); + wolfSSHD_GetCachedFakeHashForTest(actual, sizeof(actual)); + if (WSTRCMP(actual, "!$6$wolfsshtestsalt$priorFakeHash$") != 0) { + Log(" FAILED: cachedFakeHash was modified on getspnam() failure.\n"); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + wsshd_getspnam_cb = savedGetspnam; + + return ret; +} + +/* getspnam("root") succeeding but with a null sp_pwdp (e.g. NIS/LDAP-backed + * root accounts) must also degrade gracefully, not call + * GetFakeHashFromTemplate() with a NULL template. */ +static int test_AuthInit_nullPasswordField(void) +{ + int ret = WS_SUCCESS; + struct spwd* (*savedGetspnam)(const char*); + char actual[256]; + + savedGetspnam = wsshd_getspnam_cb; + wsshd_getspnam_cb = stub_getspnam_nullPassword; + + wolfSSHD_SetCachedFakeHashForTest("!$6$wolfsshtestsalt$priorFakeHash$"); + + Log(" Testing scenario: wolfSSHD_AuthInit() with getspnam(\"root\") " + "returning a null sp_pwdp."); + wolfSSHD_AuthInit(); + + /* Degraded mode must leave any previously-cached fake hash alone + * rather than clobbering it with something derived from no data. */ + WMEMSET(actual, 0, sizeof(actual)); + wolfSSHD_GetCachedFakeHashForTest(actual, sizeof(actual)); + if (WSTRCMP(actual, "!$6$wolfsshtestsalt$priorFakeHash$") != 0) { + Log(" FAILED: cachedFakeHash was modified when sp_pwdp was " + "NULL.\n"); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wolfSSHD_SetCachedFakeHashForTest(NULL); + wsshd_getspnam_cb = savedGetspnam; + + return ret; +} +#endif /* !_WIN32 && !(__OSX__ || __APPLE__) */ #endif /* WOLFSSH_HAVE_LIBCRYPT || WOLFSSH_HAVE_LIBLOGIN */ #ifdef WOLFSSL_BASE64_ENCODE @@ -1960,6 +2643,306 @@ static int test_AuthCreateUser_privSepOff(void) return ret; } +#if (defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN)) && \ + !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) +/* PasswordAuthentication no must reject through RequestAuthentication's + * DoFakePasswordCheck() branch without ever calling checkPasswordCb. */ +static int test_RequestAuth_pwAuthNoRejectsBeforePasswordCheck(void) +{ + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + int (*savedGrouplist)(const char*, WGID_T, WGID_T*, int*); + int (*savedSetgroups)(int, const WGID_T*); + static const byte pw[] = "guessme"; + static const char line1[] = "UsePrivilegeSeparation no"; + static const char line2[] = "PasswordAuthentication no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + if (ParseConfigLine(&conf, line1, (int)WSTRLEN(line1), 0) != WS_SUCCESS || + ParseConfigLine(&conf, line2, (int)WSTRLEN(line2), 0) != + WS_SUCCESS) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + savedGetpwnam = wsshd_getpwnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + InstallGroupStubs(0, &savedGrouplist, &savedSetgroups); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + authData.sf.password.password = pw; + authData.sf.password.passwordSz = (word32)(sizeof(pw) - 1); + + Log(" Testing scenario: PasswordAuthentication no rejects."); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc == WOLFSSH_USERAUTH_REJECTED) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED: got %d.\n", rc); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getgrouplist_cb = savedGrouplist; + wsshd_setgroups_cb = savedSetgroups; + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; +} + +/* checkPasswordCb returning something other than WSSHD_AUTH_SUCCESS/FAILURE + * (e.g. CheckPasswordUnix's oversized-shadow-hash fail-closed path) must + * surface as WOLFSSH_USERAUTH_FAILURE through RequestAuthentication, taking + * the DoFakePasswordCheck() branch rather than crashing or succeeding. */ +static int test_RequestAuth_checkPasswordCbErrorFailsClosed(void) +{ + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + struct spwd* (*savedGetspnam)(const char*); + int (*savedGrouplist)(const char*, WGID_T, WGID_T*, int*); + int (*savedSetgroups)(int, const WGID_T*); + static const byte pw[] = "guessme"; + static const char line1[] = "UsePrivilegeSeparation no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + if (ParseConfigLine(&conf, line1, (int)WSTRLEN(line1), 0) != WS_SUCCESS) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + savedGetpwnam = wsshd_getpwnam_cb; + savedGetspnam = wsshd_getspnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + wsshd_getspnam_cb = stub_getspnam_oversizedHash; + InstallGroupStubs(0, &savedGrouplist, &savedSetgroups); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + authData.sf.password.password = pw; + authData.sf.password.passwordSz = (word32)(sizeof(pw) - 1); + + Log(" Testing scenario: checkPasswordCb error fails closed."); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc == WOLFSSH_USERAUTH_FAILURE) { + Log(" PASSED.\n"); + } + else { + Log(" FAILED: got %d.\n", rc); + ret = WS_FATAL_ERROR; + } + + wsshd_getpwnam_cb = savedGetpwnam; + wsshd_getspnam_cb = savedGetspnam; + wsshd_getgrouplist_cb = savedGrouplist; + wsshd_setgroups_cb = savedSetgroups; + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; +} + +/* wolfSSHD_AuthRaisePermissions() failing must equalize timing with a fake + * crypt() only for password auth; doing so for pubkey auth would itself be a + * timing oracle (see the comment on RequestAuthentication's DoCheckUser() + * call). Drives both auth types through the same failure so a regression + * that re-ungates the pubkey path is caught by an actual call count rather + * than by a return code that looks identical either way. */ +static int test_RequestAuth_raisePermissionsFailFakeCheckGating(void) +{ + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + int (*savedEgid)(WGID_T); + int (*savedEuid)(WUID_T); + static const byte pw[] = "guessme"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + /* privilege separation defaults to on; stub getpwnam("sshd") so + * AuthCreateUser can resolve the saved uid/gid without a real system + * account. */ + InstallGetpwnamStub(&savedGetpwnam); + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + /* swap to the auth-target stub for the DoCheckUser() lookups below */ + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + InstallPrivRaiseStubs(-1, 0, &savedEgid, &savedEuid); + + WMEMSET(&authData, 0, sizeof(authData)); + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + + Log(" Testing scenario: AuthRaisePermissions failure fakes password " + "check for PASSWORD auth."); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.sf.password.password = pw; + authData.sf.password.passwordSz = (word32)(sizeof(pw) - 1); + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc != WOLFSSH_USERAUTH_FAILURE || + wolfSSHD_GetFakePasswordCheckCountForTest() == 0) { + Log(" FAILED: rc=%d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + Log(" Testing scenario: AuthRaisePermissions failure must not fake " + "password check for PUBLICKEY auth."); + /* Clear the union's leftover password fields before reinterpreting it + * as sf.publicKey, rather than relying on neither branch reading it. */ + WMEMSET(&authData.sf, 0, sizeof(authData.sf)); + authData.type = WOLFSSH_USERAUTH_PUBLICKEY; + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PUBLICKEY, &authData, authCtx); + if (rc != WOLFSSH_USERAUTH_FAILURE || + wolfSSHD_GetFakePasswordCheckCountForTest() != 0) { + Log(" FAILED: rc=%d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wsshd_setegid_cb = savedEgid; + wsshd_seteuid_cb = savedEuid; + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; +} + +/* wolfSSHD_AuthGetUserConf() returning NULL must equalize timing with a fake + * crypt() only for password auth, for the same reason as the + * AuthRaisePermissions-failure branch above. Forces the NULL return via a + * getgrouplist() that never succeeds, while getpwnam() keeps succeeding, so + * the failure is isolated to group resolution rather than user lookup. */ +static int test_RequestAuth_userConfNullFakeCheckGating(void) +{ + int ret = WS_SUCCESS; + int rc; + WOLFSSHD_CONFIG* conf; + WOLFSSHD_AUTH* authCtx; + WS_UserAuthData authData; + struct passwd* (*savedGetpwnam)(const char*); + int (*savedGrouplist)(const char*, WGID_T, WGID_T*, int*); + int (*savedSetgroups)(int, const WGID_T*); + static const byte pw[] = "guessme"; + static const char line1[] = "UsePrivilegeSeparation no"; + + conf = wolfSSHD_ConfigNew(NULL); + if (conf == NULL) return WS_MEMORY_E; + + if (ParseConfigLine(&conf, line1, (int)WSTRLEN(line1), 0) != WS_SUCCESS) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + authCtx = wolfSSHD_AuthCreateUser(NULL, conf); + if (authCtx == NULL) { + wolfSSHD_ConfigFree(conf); + return WS_FATAL_ERROR; + } + + savedGetpwnam = wsshd_getpwnam_cb; + wsshd_getpwnam_cb = stub_getpwnam_shadowUser; + InstallGroupStubs(0, &savedGrouplist, &savedSetgroups); + s_grouplist_always_fail = 1; + + WMEMSET(&authData, 0, sizeof(authData)); + authData.username = (const byte*)"shadow_branch_test_user"; + authData.usernameSz = (word32)WSTRLEN((const char*)authData.username); + + Log(" Testing scenario: AuthGetUserConf NULL fakes password check " + "for PASSWORD auth."); + authData.type = WOLFSSH_USERAUTH_PASSWORD; + authData.sf.password.password = pw; + authData.sf.password.passwordSz = (word32)(sizeof(pw) - 1); + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PASSWORD, &authData, authCtx); + if (rc != WOLFSSH_USERAUTH_FAILURE || + wolfSSHD_GetFakePasswordCheckCountForTest() == 0) { + Log(" FAILED: rc=%d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + Log(" Testing scenario: AuthGetUserConf NULL must not fake password " + "check for PUBLICKEY auth."); + /* Clear the union's leftover password fields before reinterpreting it + * as sf.publicKey, rather than relying on neither branch reading it. */ + WMEMSET(&authData.sf, 0, sizeof(authData.sf)); + authData.type = WOLFSSH_USERAUTH_PUBLICKEY; + wolfSSHD_ResetFakePasswordCheckCountForTest(); + rc = DefaultUserAuth(WOLFSSH_USERAUTH_PUBLICKEY, &authData, authCtx); + if (rc != WOLFSSH_USERAUTH_FAILURE || + wolfSSHD_GetFakePasswordCheckCountForTest() != 0) { + Log(" FAILED: rc=%d, fakeCheckCount=%d.\n", rc, + wolfSSHD_GetFakePasswordCheckCountForTest()); + ret = WS_FATAL_ERROR; + } + else { + Log(" PASSED.\n"); + } + + wsshd_getgrouplist_cb = savedGrouplist; + wsshd_setgroups_cb = savedSetgroups; + wsshd_getpwnam_cb = savedGetpwnam; + wolfSSHD_AuthFreeUser(authCtx); + wolfSSHD_ConfigFree(conf); + + return ret; +} +#endif /* (WOLFSSH_HAVE_LIBCRYPT || WOLFSSH_HAVE_LIBLOGIN) && + * !_WIN32 && !(__OSX__ || __APPLE__) */ + /* wolfSSHD_AuthRaisePermissions must not touch setegid/seteuid at all when * privilege separation is off, since the process never dropped privileges. */ static int test_AuthRaisePermissions_offSkipsSyscalls(void) @@ -3759,6 +4742,13 @@ const TEST_CASE testCases[] = { TEST_DECL(test_AuthReducePermissionsUser_gid_fail), TEST_DECL(test_AuthReducePermissionsUser_uid_fail), TEST_DECL(test_AuthCreateUser_privSepOff), +#if (defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN)) && \ + !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) + TEST_DECL(test_RequestAuth_pwAuthNoRejectsBeforePasswordCheck), + TEST_DECL(test_RequestAuth_checkPasswordCbErrorFailsClosed), + TEST_DECL(test_RequestAuth_raisePermissionsFailFakeCheckGating), + TEST_DECL(test_RequestAuth_userConfNullFakeCheckGating), +#endif TEST_DECL(test_AuthRaisePermissions_offSkipsSyscalls), TEST_DECL(test_AuthRaisePermissions_separateCallsSyscalls), TEST_DECL(test_AuthRaisePermissions_nullArg), @@ -3770,6 +4760,20 @@ const TEST_CASE testCases[] = { #endif #if defined(WOLFSSH_HAVE_LIBCRYPT) || defined(WOLFSSH_HAVE_LIBLOGIN) TEST_DECL(test_CheckPasswordHashUnix), +#if !defined(_WIN32) && !(defined(__OSX__) || defined(__APPLE__)) + TEST_DECL(test_GetFakeHashFromTemplate), + TEST_DECL(test_CachedFakeHashConsumption), + TEST_DECL(test_CheckPasswordUnix_shadowLookupFails), + TEST_DECL(test_CheckPasswordUnix_shadowHashTooLong), + TEST_DECL(test_CheckPasswordUnix_shadowNullPassword), + TEST_DECL(test_CheckPasswordUnix_shadowLookupSucceeds), + TEST_DECL(test_CheckPasswordUnix_unknownUser), + TEST_DECL(test_DoFakePasswordCheck_pubkeyUnionSafety), + TEST_DECL(test_AuthInit), + TEST_DECL(test_AuthInit_degradedMode), + TEST_DECL(test_AuthInit_nullPasswordField), +#endif + TEST_DECL(test_DefaultUserAuth_OOBRead), #endif #if defined(WOLFSSH_OSSH_CERTS) && !defined(_WIN32) TEST_DECL(test_OsshPrefixMatch), diff --git a/apps/wolfsshd/wolfsshd.c b/apps/wolfsshd/wolfsshd.c index 54031b6f8..675d94fea 100644 --- a/apps/wolfsshd/wolfsshd.c +++ b/apps/wolfsshd/wolfsshd.c @@ -2755,6 +2755,10 @@ static int StartSSHD(int argc, char** argv) } } + if (ret == WS_SUCCESS && !testMode) { + wolfSSHD_AuthInit(); + } + if (ret == WS_SUCCESS) { ret = wolfSSHD_ConfigLoad(conf, configFile); if (ret != WS_SUCCESS) {