← Week of October 5, 2026
THE DEEP DIVE / CVE-2026-81535 9.1 CRITICAL

wolfSSH.

In wolfSSH through 1.5.0 built with --enable-fwd, DoChannelOpen() in src/internal.c gates only direct-tcpip channel opens with the forwarding policy callback. forwarded-tcpip opens are admitted without an authorization check and are not capped in number, allowing a malicious SSH peer to make an endpoint allocate unbounded per-channel buffers for forwarding channels the application never authorized. A client also does not check a forwarded-tcpip open against the forwards it registered with a tcpip-forward request, as RFC 4254 section 7.2 requires, so a malicious server can open forwarding channels for addresses and ports the client never asked it to forward.

READING MODE

Concepts, source walkthroughs, diagrams, evidence, and limitations.

Full analysis selected.

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:

  1. Is this endpoint allowed to create a forwarding channel at all?
  2. Does an incoming forwarded-tcpip channel 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.

Rendering diagram…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.
Processing stepLabelled direction
Data-flow diagramThe vulnerable channel type bypasses the forwarding-specific policy gate.
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.

Rendering diagram…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.
Actor or componentNumbered message directionLifetime during sequence
Sequence diagramA forwarded channel is legitimate only when it corresponds to an earlier registration.
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
C codeBefore the fix, only direct-tcpip entered the forwarding-specific callback path.

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;
     }
 }
Source diffThe first patch rejects the wrong direction and makes both forwarding types fail closed.

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;
 }
Source diffRemoving the tracking guard makes an empty registration list reject every forwarded channel.

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.

Rendering diagram…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.
Processing stepLabelled direction
Data-flow diagramThe fixed path retains a channel only after three independent authorization checks.
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);
C codeThe upstream regression constructs a wrong-direction forwarded-tcpip packet and expects rejection.

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:

  1. Resolve fix commit 616eb681… and its exact parent 673b2d25….
  2. Extract only the upstream tests/regress.c change from the fix patch.
  3. Apply that unchanged regression change to the parent tree. The vulnerable implementation itself remains untouched.
  4. Build both trees with --enable-fwd in the same Alpine userspace.
  5. Run the same tests/regress.test binary in each tree.
  6. 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
Text outputThe unchanged regression fails on the vulnerable parent and passes on the fixed commit.

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-tcpip received 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.

Sources

FOLLOW THE EVIDENCE

Sources & further reading

  1. github.com ↗
  2. github.com ↗
  3. github.com ↗

Technical details can change. Consult upstream advisories before making remediation decisions.