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 (
PromptOnWake → state.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
(processPostureResponse → go ProcessPostureResponses → onPostureDataUpdate → HasAccess)
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?
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.Timeandstate.Unlocked.Timewithout checking whetherstate.Woken/state.Unlockedare nil. When an administrator has enabledpromptOnWakeand/orpromptOnUnlockon 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
router/posture(edge router data-plane authorization).router/posture/mfa.go,func (m *MfaCheck) Evaluate(state *InstanceData) *CheckError.PromptOnWake→state.Woken.Time.AsTime()), 65–76 (PromptOnUnlock→state.Unlocked.Time.AsTime()).Vulnerability Class
process termination).
Attacker Model
An authenticated, enrolled client identity that:
posture check having
promptOnWakeand/orpromptOnUnlockenabled, andThis 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
promptOnWake=trueand/orpromptOnUnlock=trueis associated (via aservice policy) with at least one service. These are documented, supported options
(CHANGELOG.0.20), configured by an administrator.
PassedMfaAtset) and has not reported a wake/unlock event(
Woken/Unlockednil) — the normal default for a client that has not slept/locked.Root Cause
MfaCheck.Evaluateassumesstate.Woken/state.Unlockedare always populated when thecorresponding prompt flag is set, and dereferences them unconditionally. They are independent,
optional posture fields (
PostureResponse_Woken/PostureResponse_Unlocked) that default to niland are only set when the SDK reports a wake/unlock. Passing MFA sets only
PassedMfaAt, not thesefields. The controller's reference implementation
(
controller/model/posture_check_model_mfa.go,PassedOnWake/PassedOnUnlock) explicitlytreats nil wake/unlock timestamps as "pass" and never dereferences a nil — the router diverges.
Reachability
Dial/bind authorization path (OIDC/JWT sessions):
The dial/bind handlers are registered with
channel.AsyncFunctionReceiveAdapter, whoseHandleReceiveruns the handler in a bare goroutine (go adapter.Handler(...)) with no recover;there is no
recover()inrouter/xgress_edgeorrouter/posture, and the channel rx loop'sdeferred 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
(
processPostureResponse→go ProcessPostureResponses→onPostureDataUpdate→HasAccess)re-evaluates posture for active connections and hits
MfaCheck.Evaluateas well.Local Reproduction
Deterministic local Go tests; synthetic data only; no network/services.
PoC sources (in
poc/):mfa_poc_test.go(directMfaCheck.Evaluate, wake + unlock + twocontrols) and
mfa_e2e_poc_test.go(end-to-end through the publicposture.HasAccesswith arealistic
RouterDataModel). Captured output intest_output.txt.Expected Behavior
When
promptOnWake/promptOnUnlockis enabled but no corresponding event has been reported (niltimestamp), the sub-check should treat the condition as satisfied (pass) — consistent with the
controller's
PassedOnWake/PassedOnUnlock. Posture evaluation must never panic on absentoptional posture data.
Actual Behavior
MfaCheck.Evaluatedereferences nilstate.Woken/state.Unlocked, producingruntime error: invalid memory address or nil pointer dereferenceduring access evaluation,crashing the router process.
Impact
carried by the router are dropped, and the crash recurs whenever the same access path is
exercised.
posture configuration.
theft, or controller compromise is claimed or demonstrated.
Evidence
poc/mfa_poc_test.go: directEvaluatepanics for wake and unlock; controls (no-prompt; Wokenpresent) do not panic.
poc/mfa_e2e_poc_test.go: panic via the publicposture.HasAccessrouter authorizationfunction 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 averified fix that removes the panic while preserving MFA enforcement.
Suggested Fix
Nil-check
state.Woken/state.Unlockedbefore reading.Time, and align the router with thecontroller's
PassedOnWake/PassedOnUnlocksemantics (nil or pre-MFA event ⇒ pass; post-MFAevent beyond grace ⇒ fail). Add regression tests in
router/posture(currently none). Asdefense-in-depth, wrap router posture evaluation in a
recover()that converts an unexpectedpanic 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/postureunit tests covering this. The controller already implements the correct nil-safebehavior. 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 publicOpenZiti/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?