[RFC violation] Client accepts an unsolicited SSH_MSG_USERAUTH_SUCCESS and reports success to the application
Component: client state machine — src/internal.c (IsMessageAllowedClient, DoUserAuthSuccess), src/ssh.c (wolfSSH_connect)
Affected versions: v1.5.0-stable; master 6e49506b (2026-09-18) — both. Re-verified present at 6ed8e121 (2026-09-25), master HEAD at the time of writing
Type: RFC 4252 conformance / client state machine
Severity: Low (see Impact)
Discovery method: differential fuzzing with tlspuffin, then reduced to the standalone PoC below
Summary
A wolfSSH client that sends SSH_MSG_SERVICE_REQUEST and receives SSH_MSG_USERAUTH_SUCCESS (52) in
reply — rather than SSH_MSG_SERVICE_ACCEPT (6) — treats itself as authenticated and proceeds to the
connection layer, without ever receiving a reply to its own SSH_MSG_USERAUTH_REQUEST.
wolfSSH_connect() returns success to the calling application when no authentication has occurred, so an
application built on it mis-states its own security state. Of the conformance issues we are reporting, this
is the one we would fix first.
RFC citation
RFC 4252 §5.1, on SSH_MSG_USERAUTH_SUCCESS:
The server MUST NOT send this message until authentication has been successfully completed.
and RFC 4253 §10, on the service request:
After the key exchange, the client requests a service. […] If the server supports the service (and
permits the client to use it), it MUST respond with SSH_MSG_SERVICE_ACCEPT.
A client is entitled to expect that a USERAUTH_SUCCESS corresponds to an authentication request it made.
wolfSSH accepts one that answers nothing.
Reproduction
Server side is a ~190-line Python script speaking SSH-2.0 directly (curve25519-sha256 +
aes256-gcm@openssh.com, RSA host key signing rsa-sha2-256 over H, no SSH library). It completes an
honest key exchange, answers the client's SERVICE_REQUEST with 52 instead of 6, and then
deliberately never answers the client's own USERAUTH_REQUEST.
The non-answer is what makes the result conclusive: if the client reaches the connection layer having had no
reply to its authentication request, the only thing it can have acted on is the unsolicited 52.
Observed message sequence, identical on v1.5.0-stable and master:
client -> server 5 SERVICE_REQUEST ("ssh-userauth")
server -> client 52 USERAUTH_SUCCESS <-- unsolicited; no SERVICE_ACCEPT sent
client -> server 50 USERAUTH_REQUEST
(server sends NO reply to this, deliberately)
client -> server 90 CHANNEL_OPEN ("session")
client -> server 98 CHANNEL_REQUEST ("shell")
wolfSSH_connect() returns WS_SUCCESS. The client proceeds from an unsolicited USERAUTH_SUCCESS to
requesting an interactive shell, unaided by any application code.
Control: the same client against the same server in honest mode (SERVICE_ACCEPT, then USERAUTH_SUCCESS
only after a real USERAUTH_REQUEST) behaves correctly.
The client under test is a minimal wolfSSH_connect program using the public API only
(wolfSSH_CTX_new(WOLFSSH_ENDPOINT_CLIENT) / SetUsername / set_fd / connect). It deliberately does not
call wolfSSH_worker: the finding is complete at the end of wolfSSH_connect, and driving application
traffic would test the application rather than wolfSSH.
Attached reproducer — one file, runs both modes, prints a verdict
repro_issue_1_unsolicited_userauth_success.py (attached) is self-contained: it embeds the ~40-line
client, compiles it against your wolfSSH build, generates its own host key, runs both modes and reports.
Requires python3 with cryptography.
$ python3 repro_issue_1_unsolicited_userauth_success.py --wolfssh /path/to/wolfssh
mode connect auth reply? reached
unsolicited SUCCESS none CHANNEL_OPEN(session), CHANNEL_REQUEST(shell)
honest SUCCESS yes CHANNEL_OPEN(session), CHANNEL_REQUEST(shell)
VERDICT: REPRODUCED -- the client reached the connection layer with NO reply
to its USERAUTH_REQUEST, so it acted on the unsolicited 52.
It exits 0 when reproduced and 1 when not, so it can be dropped into a regression run. --client PATH uses
a client you have already built instead of compiling the embedded one. The output above is a real run
against v1.5.0-stable.
Root cause
Two independent conditions combine.
1. The userauth gate opens before any authentication request exists. IsMessageAllowedClient gates the
userauth message range on connectState < CONNECT_CLIENT_USERAUTH_REQUEST_SENT:
else if (MSGIDLIMIT_AUTH(msg)) {
/* Do not accept any userauth messages until we've asked for auth. */
if (ssh->connectState < CONNECT_CLIENT_USERAUTH_REQUEST_SENT) { ...reject... }
return 1;
}
but that state is set immediately after SendServiceRequest — ssh.c:845 sends the service request,
ssh.c:850 sets connectState = CONNECT_CLIENT_USERAUTH_REQUEST_SENT. So the gate is already satisfied
when the SERVICE_REQUEST goes out, and the comment's intent ("until we've asked for auth") is not what the
code tests.
2. DoUserAuthSuccess validates nothing. Its only check is that the payload is empty:
static int DoUserAuthSuccess(WOLFSSH* ssh, byte* buf, word32 len, word32* idx)
{
/* This message does not have any payload. len should be 0. */
if (ssh == NULL || buf == NULL || len != 0 || idx == NULL) { ret = WS_BAD_ARGUMENT; ... }
ssh->serverState = SERVER_USERAUTH_ACCEPT_DONE;
...
}
Why the conclusion is closed rather than behavioural. CHANNEL_OPEN is emitted only in the
CONNECT_SERVER_USERAUTH_ACCEPT_DONE arm of wolfSSH_connect, which is reached only when
case CONNECT_CLIENT_USERAUTH_SENT:
while (ssh->serverState < SERVER_USERAUTH_ACCEPT_DONE) {
if (DoReceive(ssh) < WS_SUCCESS) { ... return WS_FATAL_ERROR; }
}
exits — ssh.c:888 in v1.5.0, a loop with no timeout, no retry-to-advance and no alternative exit: the only
way past it is for serverState to reach SERVER_USERAUTH_ACCEPT_DONE. And
serverState = SERVER_USERAUTH_ACCEPT_DONE is assigned at exactly one site in each tree: inside
DoUserAuthSuccess (v1.5.0 internal.c:8495, master internal.c:11927), reachable only on receipt of
message 52. A client that sends CHANNEL_OPEN having received no reply to its USERAUTH_REQUEST must
therefore have processed an unsolicited 52.
Suggested fix
Track whether a USERAUTH_REQUEST was actually sent, rather than inferring it from an ordered connectState
comparison that the service request already satisfies:
/* in the userauth send path */
ssh->userAuthRequestSent = 1;
/* in IsMessageAllowedClient */
else if (MSGIDLIMIT_AUTH(msg)) {
if (!ssh->userAuthRequestSent) { ...reject... }
return 1;
}
Optionally, have DoUserAuthSuccess refuse when no request is outstanding, so the check does not depend on
the filter alone.
Impact
Low. The message must arrive over a completed key exchange, so the sender is one the client has already
accepted by host key — the legitimate server, or a holder of its private key. Such a party can already
induce the identical client state by answering the authentication request with USERAUTH_SUCCESS, so the
defect grants no capability that party does not have; it only saves it waiting for the request.
What makes it worth fixing is not exploitability but that it is the one issue here that mis-states the
security state of the connection to the application above it: wolfSSH_connect() returns success where no
authentication occurred, and an application has no way to tell the difference.
Notes
A sibling shape exists where the trigger is an unsolicited USERAUTH_FAILURE before any SERVICE_ACCEPT —
the same gate, a milder consequence. The fix above covers both.
We verified this is present on master before reporting, specifically to avoid reporting something already
fixed. IsMessageAllowedClient's userauth arm and DoUserAuthSuccess are unchanged there in the respects
that matter.
Acknowledgements
Found with the tlspuffin fuzzer — https://github.com/tlspuffin/tlspuffin — designed and developed by the
tlspuffin team: Nataël Baffou, Olivier Demengeon, Tom Gouville, Lucca Hirschi, Steve Kremer (Inria, LORIA, France).
repro_issue_1_unsolicited_userauth_success.py
[RFC violation] Client accepts an unsolicited SSH_MSG_USERAUTH_SUCCESS and reports success to the application
Component: client state machine —
src/internal.c(IsMessageAllowedClient,DoUserAuthSuccess),src/ssh.c(wolfSSH_connect)Affected versions: v1.5.0-stable; master
6e49506b(2026-09-18) — both. Re-verified present at6ed8e121(2026-09-25), master HEAD at the time of writingType: RFC 4252 conformance / client state machine
Severity: Low (see Impact)
Discovery method: differential fuzzing with tlspuffin, then reduced to the standalone PoC below
Summary
A wolfSSH client that sends
SSH_MSG_SERVICE_REQUESTand receivesSSH_MSG_USERAUTH_SUCCESS(52) inreply — rather than
SSH_MSG_SERVICE_ACCEPT(6) — treats itself as authenticated and proceeds to theconnection layer, without ever receiving a reply to its own
SSH_MSG_USERAUTH_REQUEST.wolfSSH_connect()returns success to the calling application when no authentication has occurred, so anapplication built on it mis-states its own security state. Of the conformance issues we are reporting, this
is the one we would fix first.
RFC citation
RFC 4252 §5.1, on
SSH_MSG_USERAUTH_SUCCESS:and RFC 4253 §10, on the service request:
A client is entitled to expect that a
USERAUTH_SUCCESScorresponds to an authentication request it made.wolfSSH accepts one that answers nothing.
Reproduction
Server side is a ~190-line Python script speaking SSH-2.0 directly (curve25519-sha256 +
aes256-gcm@openssh.com, RSA host key signing
rsa-sha2-256overH, no SSH library). It completes anhonest key exchange, answers the client's
SERVICE_REQUESTwith 52 instead of 6, and thendeliberately never answers the client's own
USERAUTH_REQUEST.The non-answer is what makes the result conclusive: if the client reaches the connection layer having had no
reply to its authentication request, the only thing it can have acted on is the unsolicited 52.
Observed message sequence, identical on v1.5.0-stable and master:
wolfSSH_connect()returnsWS_SUCCESS. The client proceeds from an unsolicitedUSERAUTH_SUCCESStorequesting an interactive shell, unaided by any application code.
Control: the same client against the same server in honest mode (
SERVICE_ACCEPT, thenUSERAUTH_SUCCESSonly after a real
USERAUTH_REQUEST) behaves correctly.The client under test is a minimal
wolfSSH_connectprogram using the public API only(
wolfSSH_CTX_new(WOLFSSH_ENDPOINT_CLIENT)/SetUsername/set_fd/connect). It deliberately does notcall
wolfSSH_worker: the finding is complete at the end ofwolfSSH_connect, and driving applicationtraffic would test the application rather than wolfSSH.
Attached reproducer — one file, runs both modes, prints a verdict
repro_issue_1_unsolicited_userauth_success.py(attached) is self-contained: it embeds the ~40-lineclient, compiles it against your wolfSSH build, generates its own host key, runs both modes and reports.
Requires
python3withcryptography.It exits 0 when reproduced and 1 when not, so it can be dropped into a regression run.
--client PATHusesa client you have already built instead of compiling the embedded one. The output above is a real run
against v1.5.0-stable.
Root cause
Two independent conditions combine.
1. The userauth gate opens before any authentication request exists.
IsMessageAllowedClientgates theuserauth message range on
connectState < CONNECT_CLIENT_USERAUTH_REQUEST_SENT:but that state is set immediately after
SendServiceRequest—ssh.c:845sends the service request,ssh.c:850setsconnectState = CONNECT_CLIENT_USERAUTH_REQUEST_SENT. So the gate is already satisfiedwhen the
SERVICE_REQUESTgoes out, and the comment's intent ("until we've asked for auth") is not what thecode tests.
2.
DoUserAuthSuccessvalidates nothing. Its only check is that the payload is empty:Why the conclusion is closed rather than behavioural.
CHANNEL_OPENis emitted only in theCONNECT_SERVER_USERAUTH_ACCEPT_DONEarm ofwolfSSH_connect, which is reached only whenexits —
ssh.c:888in v1.5.0, a loop with no timeout, no retry-to-advance and no alternative exit: the onlyway past it is for
serverStateto reachSERVER_USERAUTH_ACCEPT_DONE. AndserverState = SERVER_USERAUTH_ACCEPT_DONEis assigned at exactly one site in each tree: insideDoUserAuthSuccess(v1.5.0internal.c:8495, masterinternal.c:11927), reachable only on receipt ofmessage 52. A client that sends
CHANNEL_OPENhaving received no reply to itsUSERAUTH_REQUESTmusttherefore have processed an unsolicited 52.
Suggested fix
Track whether a
USERAUTH_REQUESTwas actually sent, rather than inferring it from an orderedconnectStatecomparison that the service request already satisfies:
Optionally, have
DoUserAuthSuccessrefuse when no request is outstanding, so the check does not depend onthe filter alone.
Impact
Low. The message must arrive over a completed key exchange, so the sender is one the client has already
accepted by host key — the legitimate server, or a holder of its private key. Such a party can already
induce the identical client state by answering the authentication request with
USERAUTH_SUCCESS, so thedefect grants no capability that party does not have; it only saves it waiting for the request.
What makes it worth fixing is not exploitability but that it is the one issue here that mis-states the
security state of the connection to the application above it:
wolfSSH_connect()returns success where noauthentication occurred, and an application has no way to tell the difference.
Notes
A sibling shape exists where the trigger is an unsolicited
USERAUTH_FAILUREbefore anySERVICE_ACCEPT—the same gate, a milder consequence. The fix above covers both.
We verified this is present on master before reporting, specifically to avoid reporting something already
fixed.
IsMessageAllowedClient's userauth arm andDoUserAuthSuccessare unchanged there in the respectsthat matter.
Acknowledgements
Found with the tlspuffin fuzzer — https://github.com/tlspuffin/tlspuffin — designed and developed by the
tlspuffin team: Nataël Baffou, Olivier Demengeon, Tom Gouville, Lucca Hirschi, Steve Kremer (Inria, LORIA, France).
repro_issue_1_unsolicited_userauth_success.py