Harden SSH parsing and add notice tooling
- Enforce exact service and channel names with bounded failure parsing - Add hash-pinned offline notice assembly and regression coverage - Record advisory dispositions, provenance, integration evidence, and remaining gates
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
# Remaining SSH parser review — 2026-09-16
|
||||
|
||||
## Completed scope and decision
|
||||
|
||||
**This finite review is complete for the current pinned server profile:** PR899
|
||||
key skips and CHANNEL_FAILURE, PR902 service validation, PR918/919 forwarding
|
||||
callback applicability, the remaining generic signature-API caller question, and
|
||||
**exact `DoChannelRequest` name dispatch including application callback gates**.
|
||||
Three bounded current-path corrections are implemented. This is not a complete
|
||||
wolfSSH/wolfSSL audit, a full backport of these PRs, firmware validation, or Phase9
|
||||
sign-off. It supplements the historical/deferred statements in
|
||||
[wolf review](wolf_security_review.md), [key-validation review](ssh_key_validation_review.md)
|
||||
and the [parser contract](../tests/wolfssh_parser_contract/README.md).
|
||||
|
||||
Only `tools/security_overrides.py`, `tests/wolfssh_parser_contract/*`, and this report
|
||||
are owned by this slice. No ordering delta, crypto configuration, version, managed
|
||||
component, application, production generated file, PlatformIO or device change.
|
||||
Concurrent packaging/IDF-review work is unrelated and left untouched.
|
||||
|
||||
| Reviewed item | Current-profile disposition |
|
||||
| --- | --- |
|
||||
| PR899 RSA/ECC unchecked key skips | Confirmed in the pinned client parsers, not reachable through current server dispatch. No speculative client patch. |
|
||||
| PR899 CHANNEL_FAILURE length predicate | Reachable after authentication. Corrected with a bounded exact recipient parser, not just the upstream predicate change. Existing fatal failure policy retained. |
|
||||
| PR902 | **Service names**, not channel callbacks. Exact `ssh-userauth` required by current server handler. Client accept half unused/unmodified. |
|
||||
| PR918/919 | Forwarding global/channel callback handling; compiled out with `WOLFSSH_FWD` absent. No forwarding patch. |
|
||||
| `DoChannelRequest` prefix/NUL name aliases | Closed: all nine name predicates require exact length then exact bytes; branch bodies and unknown-request handling preserved. |
|
||||
| Generic signature API / PR10131 remaining question | Weak generic API remains, but no attacker-selected short digest/OID path in the inspected current SSH caller set. No crypto/API patch justified for this profile. |
|
||||
|
||||
## Exact source and upstream provenance
|
||||
|
||||
The authoritative source is wolfSSH **1.4.20 original + existing ordering delta +
|
||||
existing parser/password edits + the three corrections below**, not installed source
|
||||
alone. `render_entry` verifies original SHA-256 and exact-once edit anchors. Tests
|
||||
render into temporary files; production generated inputs are not overwritten.
|
||||
|
||||
| Input | SHA-256 |
|
||||
| --- | --- |
|
||||
| Original `managed_components/wolfssl__wolfssh/src/internal.c` | `81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9` |
|
||||
| Reviewed prior original+ordering+parser generated `internal.c` | `4948f8c447670eb54153dd1f3db69e4fa3092f7d7f7ed58a18a8fa05fcd168ca` |
|
||||
| Fresh generated `internal.c` after this review | `1fdd608d45c4f33da82b0765dc66e1ec2060e78c744bc539906ef1b8a0f783ae` |
|
||||
| Unchanged `tools/wolfssh_order/delta.json` | `6a81376fe3ffc5f449cde105402963f52d2d78cc844153e869a7e1e0f734fb76` |
|
||||
| wolfSSL 5.8.2 `wolfcrypt/src/signature.c` | `62ab3db3dfd251b2a2c73b69ef05aab6085d2e0d673fd9159514b3ee261cea4f` |
|
||||
|
||||
Fetched official PR patches and independently fetched their commit patches on
|
||||
2026-09-16; each pair was **byte-identical**. Exact archives and URL/SHA-256 records
|
||||
are in `tests/wolfssh_parser_contract/pr*.patch` and `provenance.json`. The runner
|
||||
also pins those commits/hashes/URLs independently; no download occurs during tests
|
||||
or configuration.
|
||||
|
||||
- [PR899 commit d2eeec5e263a4821c90805963eeb0666e99868a6](https://github.com/wolfSSL/wolfssh/commit/d2eeec5e263a4821c90805963eeb0666e99868a6): RSA algorithm skip, ECC curve skip, CHANNEL_FAILURE predicate; Windows file/terminal hunks unused on ESP.
|
||||
- [PR902 commit ffa646a4b9d47d5d9d6127db140c433c58b1e276](https://github.com/wolfSSL/wolfssh/commit/ffa646a4b9d47d5d9d6127db140c433c58b1e276): service request/accept name checks.
|
||||
- [PR918 commit fd82a4bcf55935f0801b14bca6be9c71e32ae914](https://github.com/wolfSSL/wolfssh/commit/fd82a4bcf55935f0801b14bca6be9c71e32ae914): global forwarding callback-before-reply, missing-callback rejection and cancel reply framing.
|
||||
- [PR919 commit 0317c40fc131fab952d291d43c56c7b7ce5f4303](https://github.com/wolfSSL/wolfssh/commit/0317c40fc131fab952d291d43c56c7b7ce5f4303): reject direct-tcpip channel without forwarding callback.
|
||||
|
||||
`review.py` independently reverses only the new notice, the two initial handler
|
||||
changes and nine exact channel-name predicates, then requires the complete prior
|
||||
generated-source hash. The complete original `DoChannelRequest` is recovered by
|
||||
reversing just those predicates. Any other source change, including ordering,
|
||||
client parsing, request branch bodies, password wiping or crypto callers, fails
|
||||
this fence. Original/version/anchor guards remain;
|
||||
no silent repin or removal of existing strict build-source checks.
|
||||
|
||||
## PR899: distinguish client host keys from server authentication
|
||||
|
||||
`ParseRSAPubKey` reads an unchecked algorithm-name length then adds it to its index.
|
||||
`ParseECCPubKey` does the same for the curve name. An oversized/wrapping length can
|
||||
invalidate the intended cursor progression. PR899 replaces these skips with
|
||||
`GetSkip`; it does not establish full curve-name/key-blob semantic validation.
|
||||
These are real dependency defects, not evidence of current server exploitation.
|
||||
|
||||
Exact static call chain: `DoKexDhReply` → `ParsePubKey` → `ParseRSAPubKey` or
|
||||
`ParseECCPubKey`. There is one call to `ParsePubKey`, in `DoKexDhReply`. The current
|
||||
server user-key verifier instead uses `DoUserAuthRequestPublicKey` →
|
||||
`DoUserAuthRequestEcc`/`DoUserAuthRequestEd25519`; it does not call either PR899 key
|
||||
parser. Application authorization additionally validates and exactly matches the
|
||||
stored key blob before successful proof-of-possession verification.
|
||||
|
||||
This exclusion is **not just a server-role assertion**:
|
||||
|
||||
1. `src/ssh_transport.c` creates only `WOLFSSH_ENDPOINT_SERVER` contexts.
|
||||
2. Generated `DoPacket` checks `IsMessageAllowed(..., WS_MSG_RECV)` before dispatch.
|
||||
3. The current two KEX choices set the server expectation to `MSGID_KEXDH_INIT`
|
||||
(30), then `MSGID_NEWKEYS` (21), never `MSGID_KEXDH_REPLY` (31) or GEX reply (33).
|
||||
Both initial KEX and rekey use those restrictions. Before peer KEXINIT or with
|
||||
no nonzero matching expectation, reply messages are rejected as well.
|
||||
4. Both dispatch routes to `DoKexDhReply` (31 and 33) therefore fail the generated
|
||||
expectation gate. Disabled DH/GEX must not be confused with the compiled case
|
||||
labels. The full generated ordering suite was rerun, including wrong-message
|
||||
rejection and both KEX exchanges/rekeys.
|
||||
5. Production macro replay confirms `WOLFSSH_NO_RSA`, `WOLFSSH_NO_DH`, and disabled
|
||||
certificates. Client code is not generally compile-disabled: ECC client parser
|
||||
safety depends on the role/ordering contract, not on dead-code assumptions.
|
||||
|
||||
**Disposition:** retain both key parsers byte-identical, fence the call chain and
|
||||
ordering source, and require this decision to be revisited before enabling client
|
||||
use or widening KEX. No standalone client parsing safety claim.
|
||||
|
||||
### CHANNEL_FAILURE correction and behavior
|
||||
|
||||
The pinned `DoChannelFailure` did not read a recipient at all: `len != 0` returned
|
||||
`WS_BAD_ARGUMENT`; an empty payload returned `WS_CHANOPEN_FAILED`. It did not have
|
||||
the out-of-bounds read implied by blindly treating it as the newer parser. But
|
||||
CHANNEL_FAILURE is a connection-protocol message allowed after authentication by
|
||||
the current server gate, so its malformed-input contract is relevant even though
|
||||
the application has no useful outstanding channel-request workflow needing it.
|
||||
|
||||
The local adaptation validates pointers, uses `GetUint32` on a local cursor,
|
||||
requires exactly one remaining recipient field (`begin == len`), and verifies the
|
||||
recipient through `ChannelFind(..., WS_CHANNEL_ID_SELF)`. Only then does it publish
|
||||
the cursor and return the existing `WS_CHANOPEN_FAILED`. Truncation/wrapping offsets
|
||||
and trailing data return `WS_BUFFER_E`; an unknown recipient returns
|
||||
`WS_INVALID_CHANID`. Failure leaves the caller index unchanged; no channel/session
|
||||
state is mutated. No new queue, allocation, retry or callback is introduced.
|
||||
|
||||
This **does not turn CHANNEL_FAILURE into a recoverable reply** or implement
|
||||
request correlation. Both old nonempty rejection and new parsed failure remain
|
||||
fatal to this application's worker path, which treats only its explicit
|
||||
would-block/receive statuses as retryable. The correction establishes bounded
|
||||
framing and the appropriate existing failure result, not an authentication-bypass
|
||||
or memory-corruption exploit fix. It is a local adaptation, not a full PR899 patch.
|
||||
|
||||
## PR902 and present channel callback policy
|
||||
|
||||
The old bounded `DoServiceRequest` accepted any short service string and advanced
|
||||
to `CLIENT_USERAUTH_REQUEST_DONE`. PR902 really is applicable before user
|
||||
authentication: ordering permits SERVICE_REQUEST at `ACCEPT_KEYED` but does not
|
||||
validate its name. The generated handler now requires length 12 and exact bytes
|
||||
`ssh-userauth`; mismatch returns `WS_INVALID_STATE_E` before index/state publication.
|
||||
Length comparison short-circuits before the fixed-span comparison. Existing bounds
|
||||
and the strict name-capacity limit remain. Unlike upstream's later-tree patch,
|
||||
this subset does not queue a best-effort disconnect: the owner already closes on
|
||||
this error. Valid-service transition is unchanged. `DoServiceAccept` is unchanged;
|
||||
the current server gate rejects SERVICE_ACCEPT before dispatch.
|
||||
|
||||
For the channel/forwarding question:
|
||||
|
||||
- Actual Xtensa replay confirms `WOLFSSH_FWD`, `WOLFSSH_AGENT`, `WOLFSSH_CERTS`,
|
||||
`WOLFSSH_SFTP`, and `WOLFSSH_SCP` absent. PR918's `DoGlobalRequestFwd` call sites
|
||||
and PR919's direct-tcpip handling are under `WOLFSSH_FWD`. Unsupported forwarding
|
||||
channel types take the default unknown-type failure before channel allocation;
|
||||
global forwarding requests fall through to failure if a reply is requested.
|
||||
- `create_context` registers shell, exec and subsystem callbacks, not a channel-open
|
||||
or global-request callback. Default session-channel acceptance is intentional:
|
||||
the pinned handler limits it to one channel, and auth ordering precedes it.
|
||||
- Shell callback marks `shell_requested`; exec/subsystem callbacks reject.
|
||||
`process_handshake` additionally requires an authenticated/current principal,
|
||||
that flag and `WOLFSSH_SESSION_SHELL` before broker/admin routing. Callback
|
||||
rejection alone is not the whole policy: the library stores session type and
|
||||
completion state even for rejected requests, while the application gate stops
|
||||
exec/subsystem admission. The registered callbacks never execute commands.
|
||||
- The pinned generic channel-open callback rejection path appends the channel even
|
||||
after callback failure; no callback is installed here, so that dormant path is
|
||||
not patched by this review. Revisit before adding one. Do not infer that PR919
|
||||
repairs generic channel-open callbacks; its archived hunk is forwarding-only.
|
||||
|
||||
### Completed follow-up: exact channel-request names
|
||||
|
||||
The concrete prefix issue is **closed**, without refactoring the request parser.
|
||||
All nine `WSTRNCMP(type, literal, typeSz) == 0` predicates are replaced by
|
||||
`typeSz == sizeof(literal) - 1 && WMEMCMP(type, literal, sizeof(literal) - 1) == 0`.
|
||||
The length check short-circuits before any comparison on a short name. `memcmp`
|
||||
compares through embedded NULs instead of accepting a terminated prefix. Existing
|
||||
bounded `GetString` copies at most 31 bytes; every recognized name is shorter, so
|
||||
an oversized name truncated to 31 bytes cannot alias a recognized name. No new
|
||||
allocation, helper, protocol response, state transition or feature setting.
|
||||
|
||||
The full handler and application gates were rechecked, not just the shell branch:
|
||||
|
||||
| Exact name | Existing branch / actual production gate |
|
||||
| --- | --- |
|
||||
| `env` | Parses two strings; no environment-setting callback. Always compiled. |
|
||||
| `shell` | Sets shell session type, calls registered `accept_shell`, marks library completion. Application still requires the callback's `shell_requested` flag and shell session type. |
|
||||
| `exec`, `subsystem` | Parse command, store their session type, call registered rejecting callbacks. Library completion is not application admission; no command is executed by these callbacks. |
|
||||
| `pty-req` | Under `WOLFSSH_TERM`, **present** in production. Parses term/dimensions/modes; resize callback is optional and not installed by this application. Does not authorize a shell. |
|
||||
| `window-change` | Requires both `WOLFSSH_TERM` and `WOLFSSH_SHELL`; **absent** because production has no `WOLFSSH_SHELL`. Remains on the unknown path in that profile. |
|
||||
| `exit-status`, `exit-signal` | Under TERM or SHELL; **present** via TERM. Existing payload parsing preserved. |
|
||||
| `auth-agent-req@openssh.com` | Under `WOLFSSH_AGENT`; **absent**. Optional enabled-branch comparison tested only in a host fixture, not enabled in firmware. |
|
||||
|
||||
Empty names, proper prefixes, same-prefix suffixes, same-length wrong bytes,
|
||||
embedded NULs and overlong names no longer select any recognized branch. They take
|
||||
the **unchanged unknown-request path**: no branch callback/session-type update,
|
||||
consume the payload and return success (send channel success if requested).
|
||||
This deliberately does not introduce unknown-request rejection or strict trailing
|
||||
payload validation. Malformed header/name/boolean framing still fails before
|
||||
lookup/callback. Existing exec/subsystem behavior of calling their rejecting
|
||||
callbacks even after a command-payload parse error is also preserved; those real
|
||||
callbacks cannot execute commands. A prior accepted shell does not authorize a
|
||||
later exec/subsystem: the actual application session-type gate still rejects it,
|
||||
with or without a requested reply. No unauthenticated route is introduced.
|
||||
|
||||
`channel_request.c` executes actual generated helpers and the complete handler.
|
||||
`channel_request.py` separately hash-pins and extracts the real `accept_shell`,
|
||||
`reject_channel_request` and complete `process_handshake` bodies, checks their
|
||||
registration/context wiring, and executes them with platform/routing doubles.
|
||||
Tests exercise both broker/admin shell admission and rejection for missing callback
|
||||
context/flag, missing authentication/principal, stale principal, non-shell session
|
||||
and unsupported role. This is not a live broker/admin or task-lifecycle test.
|
||||
|
||||
## Generic signature API: finite caller closure, not library closure
|
||||
|
||||
Rechecked exact pinned `signature.c` and generated SSH calls, supplementing the
|
||||
[key-validation trace](ssh_key_validation_review.md#raw-signatures-and-cve-2026-5194-applicability):
|
||||
|
||||
- `wc_SignatureVerifyHash` and `wc_SignatureGenerateHash_ex` reject zero sizes and
|
||||
invalid hash types but do **not** require the supplied hash length to equal the
|
||||
algorithm's digest length. That generic weakness remains; no global PR10131
|
||||
backport or crypto configuration change is made.
|
||||
- Current server ECC authentication is the sole enabled SSH
|
||||
`wc_SignatureVerifyHash` caller. `DoUserAuthRequestPublicKey` derives the digest
|
||||
size from `HashForId(pkTypeId)` and `wc_HashGetDigestSize`, checks errors, hashes
|
||||
locally, then passes it to `DoUserAuthRequestEcc`. Authorized P256 implies SHA256,
|
||||
32 bytes. A peer signature field does not supply this digest length. The other
|
||||
SSH VerifyHash call is certificate-gated and absent.
|
||||
- `SignHEcdsa` hashes exchange H locally using the negotiated P256 host-key hash and
|
||||
calls `wc_ecc_sign_hash` with the full 32-byte digest. Ed25519 authentication uses
|
||||
streamed message verification, not generic prehash verification.
|
||||
- Both `wc_SignatureVerify` call sites are in blocked client `DoKexDhReply`; that
|
||||
wrapper also derives/hashes a full digest internally. Client ECC auth signing
|
||||
and certificate signing are not current server paths; agent signing is disabled.
|
||||
No application `src/` call to generic signature generation/verification APIs or
|
||||
raw `wc_ecc_sign_hash`/`wc_ecc_verify_hash` was found outside these vendor paths.
|
||||
- No wolfSSL TLS context/connect/accept use was found in application `src/`;
|
||||
HTTPS uses mbedTLS. This is application reachability evidence, **not** a claim
|
||||
that wolfSSL TLS or generic ASN/signature APIs are compiled out or fixed.
|
||||
|
||||
**Closed question:** no short-digest/OID-confusion trigger in this inspected current
|
||||
SSH caller set. **Reopen on:** certificate/client/agent enablement, new raw API
|
||||
callers, key/KEX widening or a changed authorization/hash construction. General
|
||||
wolfSSL TLS/ASN/API auditing remains outside this finite scope.
|
||||
|
||||
## Validation and remaining handoff
|
||||
|
||||
Executed in this slice:
|
||||
|
||||
| Command (all prefixed `CCACHE_DISABLE=1`) | Result |
|
||||
| --- | --- |
|
||||
| `python3 tests/wolfssh_parser_contract/run.py` | PASS: existing 3,258 cases × two stack modes / 11 mutations, plus channel matrix below / 20 additional rejected mutations; independent full-source/provenance fences. |
|
||||
| `python3 tests/wolfssh_parser_contract/review.py --profile` | PASS: actual saved Xtensa feature replay and fresh-source syntax. Explicitly reports production input is the reviewed **prior** baseline. |
|
||||
| `python3 tests/wolfssh_auth_contract/run.py --host-only` | PASS: 135 password/control-flow/wipe cases. |
|
||||
| `python3 tests/sdk_security_overrides/run.py` | PASS: generator and CMake fixtures, including existing SDK corrections. No actual build-registration option used. |
|
||||
| `python3 tests/wolfssh_order_contract/run.py` | PASS: 8,028 full-generated-source/real-crypto checks and seven rejected mutations. No OpenSSH interop option used. |
|
||||
| `python3 tests/wolf_crypto_policy/run.py --host-only` (initial review, not rerun for name-only follow-up) | PASS: 20 guards, PUBLIC CMake fixture, real vendor small-math/P256/ASN vectors. No strict production crypto rerun. |
|
||||
| `python3 tests/ssh_protocol_policy/run.py` (initial review; not rerun while build remains stale) | **Blocked as expected:** `Generated wolfSSH source differs from render_entry; reconfigure the build`. Its strict guard was not changed or bypassed. |
|
||||
|
||||
Follow-up channel matrix: **2,737 cases per stack mode** for production TERM-only,
|
||||
TERM+SHELL, and TERM+SHELL+AGENT profiles; **2,735 per stack mode** for no-terminal
|
||||
and SHELL-only profiles. All five profiles run both stack modes with guard pages
|
||||
and UBSan trap instrumentation. The alternative features are host-only coverage,
|
||||
not production settings. Tests cover every proper prefix, valid names, appended
|
||||
bytes/NUL suffixes, every embedded-NUL/same-length wrong-byte position, 31–65-byte
|
||||
names, every packet/payload truncation, oversized/wrapping declared lengths,
|
||||
nonzero offsets, want-reply both ways, known/unknown channels, PTY callbacks and
|
||||
real application admission. Instrumented comparison asserts that the compared
|
||||
span equals the initialized name length. **18 name/length mutations and two real
|
||||
application shell-admission gate mutations are rejected**, in addition to the
|
||||
existing 11 parser mutations. Ordering/auth/SDK suites and Xtensa profile/syntax
|
||||
were rerun successfully after the follow-up; `git diff --check` also passed.
|
||||
|
||||
The new parser tests use crypto/channel doubles; they establish dispatch/gating
|
||||
and preserved state contracts, not cryptographic arithmetic or actual channel lifetime. The
|
||||
ordering suite supplies separate full-library host evidence. No resource/timing,
|
||||
firmware link, device, broad fuzzing, new network SSH or Phase9 acceptance claim.
|
||||
|
||||
Remaining handoff is bounded:
|
||||
|
||||
1. Parent-approved regeneration/build and strict production-source suites after
|
||||
integrating concurrent work; production generated bytes are deliberately stale.
|
||||
2. The requested channel-name dispatch and callback-gate review is **complete**.
|
||||
Existing unknown/trailing-payload behavior is explicitly preserved, not certified
|
||||
as a generally strict parser and not expanded into another parser inventory.
|
||||
3. Existing whole-phase hardware/rekey cleanup, compatibility and heap/stack/CPU
|
||||
gates remain as recorded in the ordering/key-validation reviews. No new target
|
||||
cost measurement is claimed for these allocation-free checks.
|
||||
4. Dormant client-key/forwarding/certificate/generic-API defects are documented
|
||||
profile exclusions, not fixed dependency features. Re-audit only if those
|
||||
capabilities or the pinned source/role/ordering contract change.
|
||||
Reference in New Issue
Block a user