Skip to content

OpenZiti edge router MFA posture nil-pointer panic causing authenticated router DoS

Moderate
plorenz published GHSA-354c-gpg9-j988 Aug 21, 2026

Package

No package listed

Affected versions

0.34.x,1.1.x,1.5.x,1.6.x,2.0.x

Patched versions

None

Description

Summary

The OpenZiti edge router evaluates posture checks locally when an OIDC/JWT-session client dials or
binds a service. The router's MFA posture check (router/posture/mfa.go, MfaCheck.Evaluate)
reads state.Woken.Time and state.Unlocked.Time without checking whether state.Woken /
state.Unlocked are nil. When an administrator has enabled promptOnWake and/or promptOnUnlock
on an MFA posture check, and an authorized client has passed MFA but has not reported a
wake/unlock event (the normal default state), the field is nil and evaluation panics. The panic
occurs in an unrecovered goroutine, crashing the entire edge-router process (data-plane DoS). The
controller's equivalent check treats nil wake/unlock timestamps as "pass", so the router behavior
is a semantic-mismatch bug.

Affected Commit

6237a18ad953a14d47d0018f6b7c411a7f2e2e5e (HEAD, 2026-06-04).

Affected Component

  • Module/package: router/posture (edge router data-plane authorization).
  • File/function: router/posture/mfa.go, func (m *MfaCheck) Evaluate(state *InstanceData) *CheckError.
  • Lines: 53–63 (PromptOnWakestate.Woken.Time.AsTime()), 65–76 (PromptOnUnlock
    state.Unlocked.Time.AsTime()).

Vulnerability Class

  • Primary: CWE-476 NULL Pointer Dereference.
  • Secondary: CWE-248 Uncaught Exception (panic crosses an unrecovered goroutine boundary →
    process termination).

Attacker Model

An authenticated, enrolled client identity that:

  • holds an OIDC/JWT API session (the modern 2.x/HA default), and
  • is authorized by policy to dial or bind a service that an administrator has guarded with an MFA
    posture check having promptOnWake and/or promptOnUnlock enabled, and
  • has passed MFA but has not reported a wake/unlock posture event.

This is NOT an unauthenticated or remote-anonymous DoS. The attacker uses access the policy
already grants and a normal MFA login; it then triggers an existing administrator-enabled posture
configuration.

Preconditions

  • An MFA posture check with promptOnWake=true and/or promptOnUnlock=true is associated (via a
    service policy) with at least one service. These are documented, supported options
    (CHANGELOG.0.20), configured by an administrator.
  • An enrolled client with an OIDC/JWT session and a dial/bind authorization path to that service.
  • The client has passed MFA (PassedMfaAt set) and has not reported a wake/unlock event
    (Woken/Unlocked nil) — the normal default for a client that has not slept/locked.

Root Cause

MfaCheck.Evaluate assumes state.Woken / state.Unlocked are always populated when the
corresponding prompt flag is set, and dereferences them unconditionally. They are independent,
optional posture fields (PostureResponse_Woken / PostureResponse_Unlocked) that default to nil
and are only set when the SDK reports a wake/unlock. Passing MFA sets only PassedMfaAt, not these
fields. The controller's reference implementation
(controller/model/posture_check_model_mfa.go, PassedOnWake / PassedOnUnlock) explicitly
treats nil wake/unlock timestamps as "pass" and never dereferences a nil — the router diverges.

Reachability

Dial/bind authorization path (OIDC/JWT sessions):

SDK dial/bind
 -> router/xgress_edge/listener.go  checkAccess()           (dial:776, bind:907/1062)
 -> router/state/manager.go         HasAccess()
 -> router/posture/access.go        posture.HasAccess() -> IsPassing()
 -> router/posture/errors.go        EvaluatePostureCheck()
 -> router/posture/mfa.go           MfaCheck.Evaluate()  -> nil deref panic

The dial/bind handlers are registered with channel.AsyncFunctionReceiveAdapter, whose
HandleReceive runs the handler in a bare goroutine (go adapter.Handler(...)) with no recover;
there is no recover() in router/xgress_edge or router/posture, and the channel rx loop's
deferred recover re-panics. An unrecovered panic in the goroutine therefore terminates the router
process.

A second path reaches the same code asynchronously: a posture response
(processPostureResponsego ProcessPostureResponsesonPostureDataUpdateHasAccess)
re-evaluates posture for active connections and hits MfaCheck.Evaluate as well.

Local Reproduction

Deterministic local Go tests; synthetic data only; no network/services.

chmod +x work/report_C2_router_mfa_posture_panic/repro.sh
./work/report_C2_router_mfa_posture_panic/repro.sh

PoC sources (in poc/): mfa_poc_test.go (direct MfaCheck.Evaluate, wake + unlock + two
controls) and mfa_e2e_poc_test.go (end-to-end through the public posture.HasAccess with a
realistic RouterDataModel). Captured output in test_output.txt.

Expected Behavior

When promptOnWake/promptOnUnlock is enabled but no corresponding event has been reported (nil
timestamp), the sub-check should treat the condition as satisfied (pass) — consistent with the
controller's PassedOnWake/PassedOnUnlock. Posture evaluation must never panic on absent
optional posture data.

Actual Behavior

MfaCheck.Evaluate dereferences nil state.Woken / state.Unlocked, producing
runtime error: invalid memory address or nil pointer dereference during access evaluation,
crashing the router process.

Impact

  • Deterministic, whole-process edge-router crash (availability impact); all circuits and sessions
    carried by the router are dropped, and the crash recurs whenever the same access path is
    exercised.
  • Triggered by an authenticated/enrolled client under an administrator-enabled MFA-on-wake/unlock
    posture configuration.
  • No confidentiality or integrity impact; no auth/MFA bypass, service-access gain, RCE, data
    theft, or controller compromise is claimed or demonstrated.
  • Conservative severity: Medium (indicative CVSS 3.1 AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H ≈ 6.5).

Evidence

  • poc/mfa_poc_test.go: direct Evaluate panics for wake and unlock; controls (no-prompt; Woken
    present) do not panic.
  • poc/mfa_e2e_poc_test.go: panic via the public posture.HasAccess router authorization
    function with a realistic RouterDataModel.
  • test_output.txt: captured passing run (the "VULNERABLE" tests pass by catching the panic).
  • SOURCE_REVALIDATION.md, REPRO_VALIDATION.md: source and behavioral confirmation, including a
    verified fix that removes the panic while preserving MFA enforcement.

Suggested Fix

Nil-check state.Woken / state.Unlocked before reading .Time, and align the router with the
controller's PassedOnWake/PassedOnUnlock semantics (nil or pre-MFA event ⇒ pass; post-MFA
event beyond grace ⇒ fail). Add regression tests in router/posture (currently none). As
defense-in-depth, wrap router posture evaluation in a recover() that converts an unexpected
panic into a denied access decision rather than a router crash, and factor a shared
controller/router MFA wake/unlock helper. Full diff and review in PATCH.md / PATCH_REVIEW.md.

Dedup / Prior Art Notes

Repository-local search found no existing nil-guard or fix at the affected commit and no
router/posture unit tests covering this. The controller already implements the correct nil-safe
behavior. Online dedup was not performed in this environment.

Local-only Testing Statement

All testing used synthetic in-memory posture state and a synthetic RouterDataModel. No public
OpenZiti/NetFoundry services were contacted; no real identities, certificates, tokens, or customer
data were used; no public disclosure was made.

Files Included

  • REPORT.md, SELF_REVIEW.md, SOURCE_REVALIDATION.md, REPRO_VALIDATION.md, DEDUP.md,
    PATCH.md, PATCH_REVIEW.md, repro.sh, test_output.txt, source_locations.txt,
    environment.txt, poc/mfa_poc_test.go, poc/mfa_e2e_poc_test.go.ho is impacted?_

Patches

Has the problem been patched? What versions should users upgrade to?

Workarounds

Is there a way for users to fix or remediate the vulnerability without upgrading?

References

Are there any links users can visit to find out more?

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

CVE ID

No known CVE

Weaknesses

No CWEs