Evidence-backed draft · reproduced and fix verified locally. The code excerpts below come from the cited upstream commits. The lab used the exact parent and fixed revisions in a disposable, networkless QEMU/KVM VM.
Why SSH remote forwarding needs two authorization decisions
SSH port forwarding is not one operation. For remote forwarding, a client first asks a server to listen on an address and port with a tcpip-forward global request. Later, when a connection reaches that listener, the server asks the client to open a forwarded-tcpip channel. RFC 4254 §7.2 ties that second message to the first registration.
That creates two distinct trust questions:
- Is this endpoint allowed to create a forwarding channel at all?
- Does an incoming
forwarded-tcpipchannel correspond to a forward that this client actually registered?
The affected wolfSSH code did not consistently enforce either contract. In builds with --enable-fwd, a peer could get a forwarding channel admitted through paths that were intended to be policy-gated. Repeated channel opens also allocate per-channel state, which is why an authorization mistake can become a resource-exhaustion problem.
Read the diagram as text
An SSH peer sends a channel-open packet. DoChannelOpen parses it as forwarded-tcpip. The generic channel callback can accept it, but the forwarding gate only handles direct-tcpip, so the forwarded channel remains allocated without the required forwarding policy decision.
The protocol sequence and the missing relationship
A legitimate remote-forwarding exchange has state: the later channel open must name something registered earlier.
Read the diagram as text
The client first asks the server to register a bind address and port. After the server confirms that registration and a remote connection reaches the listener, the server asks the client to open a forwarded-tcpip channel. Client policy must match that channel to the earlier registration before accepting it.
The vulnerable behavior treated an incoming channel too much like an isolated message. On the server side, a forwarded-tcpip open was the wrong direction entirely. On the client side, a forwarded open needed to be matched against registration state. Both checks have to happen before the new channel is accepted.
Root cause: forwarded-tcpip missed the forwarding gate
The first fix commit shows the critical asymmetry in DoChannelOpen(). Before the patch, the forwarding callback was reached only for ID_CHANTYPE_TCPIP_DIRECT:
if (ssh->ctx->channelOpenCb) {
ret = ssh->ctx->channelOpenCb(newChannel, ssh->channelOpenCtx);
}
else {
/* No generic callback: accept by default. */
}
#ifdef WOLFSSH_FWD
if (typeId == ID_CHANTYPE_TCPIP_DIRECT) {
ChannelUpdateForward(newChannel,
host, hostPort, origin, originPort, isDirect);
if (ssh->ctx->fwdCb) {
ret = ssh->ctx->fwdCb(WOLFSSH_FWD_LOCAL_SETUP,
ssh->fwdCbCtx, host, hostPort);
}
else {
fail_reason = OPEN_ADMINISTRATIVELY_PROHIBITED;
ret = WS_ERROR;
}
}
#endif
This is important because ID_CHANTYPE_TCPIP_FORWARD is a different channel type. It could pass the generic channelOpenCb path, including its default-accept behavior, without ever reaching the forwarding-specific callback. The policy hook existed; the vulnerable type simply did not go through it.
Commit 616eb681… makes three boundaries explicit:
+if (typeId == ID_CHANTYPE_TCPIP_FORWARD &&
+ ssh->ctx->side == WOLFSSH_ENDPOINT_SERVER) {
+ fail_reason = OPEN_ADMINISTRATIVELY_PROHIBITED;
+ ret = WS_ERROR;
+}
-if (typeId == ID_CHANTYPE_TCPIP_DIRECT) {
+if (ret == WS_SUCCESS &&
+ (typeId == ID_CHANTYPE_TCPIP_DIRECT ||
+ typeId == ID_CHANTYPE_TCPIP_FORWARD)) {
if (ssh->ctx->fwdCb) {
...
}
else {
+ /* Both forwarding types fail closed without a policy callback. */
fail_reason = OPEN_ADMINISTRATIVELY_PROHIBITED;
ret = WS_ERROR;
}
}
First, a server rejects a server-to-client channel type received in the wrong direction. Second, both forwarding channel types reach fwdCb. Third, the absence of that callback fails closed rather than falling back to generic acceptance.
A second boundary: the channel must match a registered forward
Direction and callback checks are necessary, but they are not enough. A malicious server could still ask a client to open a channel for an address and port that the client never requested.
Commits e396a0a4… and 79a7f299… add and then tighten per-session registration tracking. The final condition no longer enables matching only after the first registration; an empty registration list means there is nothing a forwarded open can legitimately answer:
if (ret == WS_SUCCESS && typeId == ID_CHANTYPE_TCPIP_FORWARD &&
- ssh->fwdRemoteTracked &&
!FwdRemoteMatch(ssh, host, hostPort)) {
WLOG(WS_LOG_WARN,
"Rejecting forwarded-tcpip channel open for the unregistered forward");
fail_reason = OPEN_ADMINISTRATIVELY_PROHIBITED;
ret = WS_ERROR;
}
That small deletion changes the default from “do not check until tracking has started” to “always check unless the application explicitly opts out.” It closes the empty-list edge case: a client that registered no forwards must accept no forwarded channels.
Read the diagram as text
After parsing forwarded-tcpip, wolfSSH validates the endpoint direction, requires the forwarding callback to permit the request, and matches the address and port to a registered remote forward. Any failed check returns an administrative prohibition. Only a request passing all three checks can retain a channel.
The upstream regression is the executable assertion
The first fix includes a focused test named TestForwardedTcpipOnServerSendsOpenFail. It builds a forwarded-tcpip packet, initializes a server-side harness, deliberately installs an accepting forwarding callback, and still expects an administrative channel-open failure:
extraSz = BuildDirectTcpipExtra(
"127.0.0.1", 8080, "127.0.0.1", 2222,
extra, sizeof(extra));
inSz = BuildChannelOpenPacket(
"forwarded-tcpip", 9, 0x4000, 0x8000,
extra, extraSz, in, sizeof(in));
InitChannelOpenHarness(&harness, in, inSz);
AssertIntEQ(wolfSSH_CTX_SetFwdCb(
harness.ctx, AcceptFwdCb, NULL), WS_SUCCESS);
ret = DoReceive(harness.ssh);
AssertChannelOpenFailResponse(&harness, ret);
The accepting callback matters: it isolates the library-level direction check. If the test fails on the parent revision and passes after the source fix, the difference is not an application callback choosing to reject the channel.
Local reproduction and fixed-version comparison
The lab agent derived a bounded recipe from the fix commit rather than inventing a packet generator:
- Resolve fix commit
616eb681…and its exact parent673b2d25…. - Extract only the upstream
tests/regress.cchange from the fix patch. - Apply that unchanged regression change to the parent tree. The vulnerable implementation itself remains untouched.
- Build both trees with
--enable-fwdin the same Alpine userspace. - Run the same
tests/regress.testbinary in each tree. - Capture the VM console, serial transcript, exact test patch, manifest, source archive hashes, package-database hash, and teardown result.
PATCH_RESULT=0
BUILD_RESULT_vulnerable=0
TEST_RESULT_vulnerable=134
Aborted
BUILD_RESULT_fixed=0
TEST_RESULT_fixed=0
regress: PASS
LAB_DIFFERENTIAL_COMPLETE
The vulnerable parent built successfully but aborted when the newly added assertion expected the wrong-direction channel to be rejected. The fixed commit built successfully and passed the same regression. This is why the article carries both reproduced locally and fix verified locally labels.
The screenshot below is the actual guest console from that run, not a generated illustration. The complete transcript and manifest remain available beside it as selectable evidence.
Evidence and limitations
The differential establishes one precise behavior: with the upstream regression applied, the parent does not produce the required channel-open failure, while the fixed source does. It also confirms that both revisions compiled in the recorded environment.
It does not demonstrate remote code execution, and the advisory does not require that claim. It does not quantify how many channel opens a particular deployment can tolerate, prove every forwarding configuration vulnerable, or cover unrelated SSH implementations. The later registration-matching commits describe additional client-side cases; this run exercised the regression introduced by 616eb681… only.
Mitigation and review checklist
Upgrade to a wolfSSH release that contains all upstream fixes referenced by the advisory, not merely a locally cherry-picked first commit. Confirm that forwarding is compiled and configured as expected, that the application installs an explicit forwarding policy callback, and that clients track the forwards they request. Disabling forwarding where it is not needed reduces exposure but should not substitute for updating affected code.
For source review, verify these invariants:
forwarded-tcpipreceived by a server fails before a policy callback can admit it.- Both forwarding channel types are routed through the forwarding-specific policy hook.
- No callback means fail closed.
- Client-side forwarded opens match an existing registered address and port, including when the registration list is empty.
- Channel state is retained only after authorization succeeds.