Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/internal.c
Original file line number Diff line number Diff line change
Expand Up @@ -9561,6 +9561,25 @@ static int DoUserAuthRequest(WOLFSSH* ssh,
}
}

if (ret == WS_SUCCESS && serviceValid) {
/* Bind the username to the first userauth request. A later request
* that changes it ends the session, so a pipelined request cannot
* rebind a pending keyboard-interactive exchange to another user. */
if (!ssh->userAuthSeen) {
ret = wolfSSH_SetUsernameRaw(ssh,
authData.username, authData.usernameSz);
if (ret == WS_SUCCESS)
ssh->userAuthSeen = 1;
}
else if (authData.usernameSz != ssh->userNameSz
|| WMEMCMP(authData.username, ssh->userName,
authData.usernameSz) != 0) {
WLOG(WS_LOG_DEBUG, "DUAR: username change not allowed");
(void)SendDisconnect(ssh, WOLFSSH_DISCONNECT_PROTOCOL_ERROR);
ret = WS_INVALID_STATE_E;
}
}

if (ret == WS_SUCCESS && serviceValid) {
authNameId = NameToId((const char*)authData.authName, authData.authNameSz);
ssh->authId = authNameId;
Expand Down Expand Up @@ -9592,12 +9611,6 @@ static int DoUserAuthRequest(WOLFSSH* ssh,
begin = len;
}

if (ret == WS_SUCCESS) {
/* Set the username for valid service only */
ret = wolfSSH_SetUsernameRaw(ssh,
authData.username, authData.usernameSz);
}

*idx = begin;
}

Expand Down
266 changes: 266 additions & 0 deletions tests/regress.c
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
#define AssertNotNull(x) Assert((x), ("%s is not null", #x), (#x " => NULL"))
#define AssertIntEQ(x, y) do { int _x = (int)(x); int _y = (int)(y); \
Assert(_x == _y, ("%s == %s", #x, #y), ("%d != %d", _x, _y)); } while (0)
#define AssertStrEQ(x, y) do { const char* _x = (const char*)(x); \
const char* _y = (const char*)(y); \
Assert(WSTRCMP(_x, _y) == 0, ("%s == %s", #x, #y), \
("\"%s\" != \"%s\"", _x, _y)); } while (0)


static void ResetSession(WOLFSSH* ssh)
Expand Down Expand Up @@ -1602,6 +1606,264 @@ static void TestSecondSessionChannelRejected(void)
FreeChannelOpenHarness(&harness);
}

/* Records the username the auth callback was invoked with, so a test can prove
* which identity a userauth request bound to. */
static char authCbUserName[64];
static word32 authCbUserNameSz;
static int authCbInvoked;

static void ResetAuthCbRecord(void)
{
WMEMSET(authCbUserName, 0, sizeof(authCbUserName));
authCbUserNameSz = 0;
authCbInvoked = 0;
}

static int RecordUserAuthCb(byte authType, WS_UserAuthData* authData, void* ctx)
{
(void)ctx;
#ifndef WOLFSSH_KEYBOARD_INTERACTIVE
(void)authType;
#endif

#ifdef WOLFSSH_KEYBOARD_INTERACTIVE
if (authType == WOLFSSH_USERAUTH_KEYBOARD_SETUP) {
static byte kbPrompt[] = "Password: ";
static byte* kbPrompts[1];
static word32 kbPromptLengths[1];
static byte kbPromptEcho[1];

kbPrompts[0] = kbPrompt;
kbPromptLengths[0] = (word32)sizeof(kbPrompt) - 1;
kbPromptEcho[0] = 0;
authData->sf.keyboard.promptCount = 1;
authData->sf.keyboard.prompts = kbPrompts;
authData->sf.keyboard.promptLengths = kbPromptLengths;
authData->sf.keyboard.promptEcho = kbPromptEcho;
authData->sf.keyboard.promptName = NULL;
authData->sf.keyboard.promptInstruction = NULL;
authData->sf.keyboard.promptLanguage = NULL;
return WOLFSSH_USERAUTH_SUCCESS;
}
#endif

authCbInvoked = 1;
authCbUserNameSz = authData->usernameSz;
if (authCbUserNameSz >= (word32)sizeof(authCbUserName))
authCbUserNameSz = (word32)sizeof(authCbUserName) - 1;
if (authData->username != NULL && authCbUserNameSz > 0)
WMEMCPY(authCbUserName, authData->username, authCbUserNameSz);
authCbUserName[authCbUserNameSz] = '\0';

#ifdef WOLFSSH_KEYBOARD_INTERACTIVE
if (authType == WOLFSSH_USERAUTH_KEYBOARD)
return WOLFSSH_USERAUTH_SUCCESS;
#endif
/* Reject other methods so the connection stays up for the next request
* without completing authentication. */
return WOLFSSH_USERAUTH_INVALID_PASSWORD;
}

static word32 BuildUserAuthPasswordRequest(const char* user, const char* pw,
byte* out, word32 outSz)
{
byte payload[256];
word32 idx = 0;

idx = AppendString(payload, sizeof(payload), idx, user);
idx = AppendString(payload, sizeof(payload), idx, "ssh-connection");
idx = AppendString(payload, sizeof(payload), idx, "password");
idx = AppendByte(payload, sizeof(payload), idx, 0); /* not changing pw */
idx = AppendString(payload, sizeof(payload), idx, pw);

return WrapPacket(MSGID_USERAUTH_REQUEST, payload, idx, out, outSz);
}

/* Drive a userauth request against a fresh in-memory server harness. The accept
* state is rewound to before userauth completes so USERAUTH_REQUEST and
* USERAUTH_INFO_RESPONSE are accepted by the message filter. */
static void InitUserAuthHarness(ChannelOpenHarness* harness,
byte* in, word32 inSz)
{
InitChannelOpenHarness(harness, in, inSz);
wolfSSH_SetUserAuth(harness->ctx, RecordUserAuthCb);
harness->ssh->acceptState = ACCEPT_SERVER_USERAUTH_ACCEPT_SENT;
}

static void RepointHarnessInput(ChannelOpenHarness* harness,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] Same-user multi-method retry path not covered by a regression test
💡 SUGGEST test

The new guard added in DoUserAuthRequest intentionally allows a second USERAUTH_REQUEST when the username is unchanged (e.g. publickey probe then password, or a failed password retry). The PR description explicitly claims "Same-username multi-method retries ... are unaffected, since the username does not change," but no test asserts this positive path: every added test either changes the username (expected disconnect) or uses a single request. A latent bug that erroneously rejected a same-user second request (for example a bad size comparison) would not be caught. Adding a test that sends two same-user requests and asserts the second is accepted (not WS_INVALID_STATE_E, no MSGID_DISCONNECT) would lock in the intended behavior.

Recommendation: Add a positive-path regression test for a same-username second request to protect the allowed retry behavior. Suggested: a TestSameUserSecondRequestAllowed() that issues two BuildUserAuthPasswordRequest("alice", ...) calls and asserts the second DoReceive does not disconnect and re-invokes the callback bound to alice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Added TestSameUserRetryAllowed() to cover it.

It drives two USERAUTH_REQUESTs for the same user (alice, wrong password then a second attempt) through the same in-memory harness, and asserts the second request is accepted rather than treated as a rebind:

  • DoReceive returns WS_SUCCESS (not WS_FATAL_ERROR)
  • ssh->error stays WS_SUCCESS (not WS_INVALID_STATE_E)
  • the auth callback is re-invoked, still bound to alice
  • the server replies MSGID_USERAUTH_FAILURE, not MSGID_DISCONNECT

That pins both halves of the guard: the disconnect tests would still pass if the comparison rejected everything, and this one fails if it does. It lives in the default (non-KBI) runner since it uses the password method, so the retry path is locked in for the most-shipped configuration.

Confirmed it catches the class of bug you described: mutating the guard to reject every second request (i.e. the usernameSz/WMEMCMP comparison always failing) makes the new test fail at the retry assertion, while the existing tests stay green.

Verification: -Werror clean and regress.test + api.test pass in both the default build and --enable-keyboard-interactive --enable-sftp; ASan+UBSan run of regress.test under the KBI config is clean.

byte* in, word32 inSz)
{
harness->io.in = in;
harness->io.inSz = inSz;
harness->io.inOff = 0;
harness->io.outSz = 0;
}

/* A username change after the first userauth request must end the session. */
static void TestUsernameChangeDisconnects(void)
{
ChannelOpenHarness harness;
byte inAlice[128];
byte inBob[128];
word32 inAliceSz;
word32 inBobSz;

inAliceSz = BuildUserAuthPasswordRequest("alice", "pw",
inAlice, sizeof(inAlice));
inBobSz = BuildUserAuthPasswordRequest("bob", "pw", inBob, sizeof(inBob));

ResetAuthCbRecord();
InitUserAuthHarness(&harness, inAlice, inAliceSz);

/* First request binds the username; callback sees alice and rejects. */
AssertIntEQ(DoReceive(harness.ssh), WS_SUCCESS);
AssertStrEQ(authCbUserName, "alice");

/* Second request changes the username: session must be torn down and the
* callback must not be invoked as bob. */
ResetAuthCbRecord();
RepointHarnessInput(&harness, inBob, inBobSz);
AssertIntEQ(DoReceive(harness.ssh), WS_FATAL_ERROR);
AssertIntEQ(harness.ssh->error, WS_INVALID_STATE_E);
AssertTrue(harness.io.outSz > 0);
AssertIntEQ(ParseMsgId(harness.io.out, harness.io.outSz), MSGID_DISCONNECT);
AssertIntEQ(authCbInvoked, 0);

FreeChannelOpenHarness(&harness);
}

/* A second userauth request that keeps the username is the normal retry path
* (failed password, or publickey probe then password) and must be accepted. */
static void TestSameUserRetryAllowed(void)
{
ChannelOpenHarness harness;
byte inFirst[128];
byte inSecond[128];
word32 inFirstSz;
word32 inSecondSz;

inFirstSz = BuildUserAuthPasswordRequest("alice", "wrong",
inFirst, sizeof(inFirst));
inSecondSz = BuildUserAuthPasswordRequest("alice", "pw",
inSecond, sizeof(inSecond));

ResetAuthCbRecord();
InitUserAuthHarness(&harness, inFirst, inFirstSz);

/* First request binds alice; the callback rejects the password. */
AssertIntEQ(DoReceive(harness.ssh), WS_SUCCESS);
AssertIntEQ(authCbInvoked, 1);
AssertStrEQ(authCbUserName, "alice");
AssertIntEQ(ParseMsgId(harness.io.out, harness.io.outSz),
MSGID_USERAUTH_FAILURE);

/* Retry as the same user: accepted, callback runs again bound to alice. */
ResetAuthCbRecord();
RepointHarnessInput(&harness, inSecond, inSecondSz);
AssertIntEQ(DoReceive(harness.ssh), WS_SUCCESS);
AssertIntEQ(harness.ssh->error, WS_SUCCESS);
AssertIntEQ(authCbInvoked, 1);
AssertStrEQ(authCbUserName, "alice");
AssertTrue(harness.io.outSz > 0);
AssertIntEQ(ParseMsgId(harness.io.out, harness.io.outSz),
MSGID_USERAUTH_FAILURE);

FreeChannelOpenHarness(&harness);
}

#ifdef WOLFSSH_KEYBOARD_INTERACTIVE
static word32 BuildUserAuthKeyboardRequest(const char* user,
byte* out, word32 outSz)
{
byte payload[256];
word32 idx = 0;

idx = AppendString(payload, sizeof(payload), idx, user);
idx = AppendString(payload, sizeof(payload), idx, "ssh-connection");
idx = AppendString(payload, sizeof(payload), idx, "keyboard-interactive");
idx = AppendString(payload, sizeof(payload), idx, ""); /* language tag */
idx = AppendString(payload, sizeof(payload), idx, ""); /* submethods */

return WrapPacket(MSGID_USERAUTH_REQUEST, payload, idx, out, outSz);
}

static word32 BuildUserAuthInfoResponse(const char* response,
byte* out, word32 outSz)
{
byte payload[128];
word32 idx = 0;

idx = AppendUint32(payload, sizeof(payload), idx, 1); /* responseCount */
idx = AppendString(payload, sizeof(payload), idx, response);

return WrapPacket(MSGID_USERAUTH_INFO_RESPONSE, payload, idx, out, outSz);
}

/* The reported attack: pipeline a keyboard-interactive request for alice then
* bob before answering. The bob request must end the session, not rebind the
* pending challenge. */
static void TestKbUsernameChangeDisconnects(void)
{
ChannelOpenHarness harness;
byte inAlice[128];
byte inBob[128];
word32 inAliceSz;
word32 inBobSz;

inAliceSz = BuildUserAuthKeyboardRequest("alice", inAlice, sizeof(inAlice));
inBobSz = BuildUserAuthKeyboardRequest("bob", inBob, sizeof(inBob));

ResetAuthCbRecord();
InitUserAuthHarness(&harness, inAlice, inAliceSz);

/* alice's request opens the challenge: server emits an INFO_REQUEST. */
AssertIntEQ(DoReceive(harness.ssh), WS_SUCCESS);
AssertTrue(harness.io.outSz > 0);
AssertIntEQ(ParseMsgId(harness.io.out, harness.io.outSz),
MSGID_USERAUTH_INFO_REQUEST);
AssertIntEQ(authCbInvoked, 0); /* setup only, no response yet */

/* bob's pipelined request must disconnect, never reach the callback. */
ResetAuthCbRecord();
RepointHarnessInput(&harness, inBob, inBobSz);
AssertIntEQ(DoReceive(harness.ssh), WS_FATAL_ERROR);
AssertIntEQ(harness.ssh->error, WS_INVALID_STATE_E);
AssertTrue(harness.io.outSz > 0);
AssertIntEQ(ParseMsgId(harness.io.out, harness.io.outSz), MSGID_DISCONNECT);
AssertIntEQ(authCbInvoked, 0);

FreeChannelOpenHarness(&harness);
}

/* A single-user keyboard-interactive exchange still reaches the callback bound
* to the originating user. */
static void TestKbSameUserResponseSucceeds(void)
{
ChannelOpenHarness harness;
byte inReq[128];
byte inResp[128];
word32 inReqSz;
word32 inRespSz;

inReqSz = BuildUserAuthKeyboardRequest("alice", inReq, sizeof(inReq));
inRespSz = BuildUserAuthInfoResponse("secret", inResp, sizeof(inResp));

ResetAuthCbRecord();
InitUserAuthHarness(&harness, inReq, inReqSz);

AssertIntEQ(DoReceive(harness.ssh), WS_SUCCESS);
AssertIntEQ(ParseMsgId(harness.io.out, harness.io.outSz),
MSGID_USERAUTH_INFO_REQUEST);

RepointHarnessInput(&harness, inResp, inRespSz);
AssertIntEQ(DoReceive(harness.ssh), WS_SUCCESS);
AssertIntEQ(authCbInvoked, 1);
AssertStrEQ(authCbUserName, "alice");

FreeChannelOpenHarness(&harness);
}
#endif /* WOLFSSH_KEYBOARD_INTERACTIVE */

#ifdef WOLFSSH_FWD
static void TestDirectTcpipRejectSendsOpenFail(void)
{
Expand Down Expand Up @@ -4637,6 +4899,8 @@ int main(int argc, char** argv)
#endif
TestChannelOpenCallbackRejectSendsOpenFail();
TestSecondSessionChannelRejected();
TestUsernameChangeDisconnects();
TestSameUserRetryAllowed();
#ifdef WOLFSSH_FWD
TestDirectTcpipRejectSendsOpenFail();
TestDirectTcpipNoFwdCbSendsOpenFail();
Expand Down Expand Up @@ -4723,6 +4987,8 @@ int main(int argc, char** argv)
TestKeyboardResponseNoUserAuthCallback(ssh, ctx);
TestKeyboardResponseNullSsh();
TestKeyboardResponseNullCtx(ssh);
TestKbUsernameChangeDisconnects();
TestKbSameUserResponseSucceeds();
#endif

/* TODO: add app-level regressions that simulate stdin EOF/password
Expand Down
1 change: 1 addition & 0 deletions wolfssh/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,7 @@ struct WOLFSSH {
word32 testSftpSendCap; /* test hook: cap per-call SFTP buffer send */
word32 testSftpStallPending; /* test hook: force N flush-only resumes */
#endif
byte userAuthSeen; /* a userauth request has bound the username */
};


Expand Down
Loading