VYPR
High severity7.0NVD Advisory· Published Jun 5, 2026· Updated Jun 5, 2026

Omni has a TOCTOU race condition that allows multiple concurrent uses of a single-use SAML session token

CVE-2026-45720

Description

Summary

SAML.getSession (internal/pkg/auth/interceptor/saml.go) checks the Used flag on a SAMLAssertion resource and then marks it used in two separate state operations. Because the check and the update are not atomic, concurrent requests carrying the same saml-session token can both observe Used == false, both pass validation, and both return a successful authentication context. An attacker who obtains a valid saml-session token can exploit this window to authenticate as the token's owner multiple times, defeating the one-time-use guarantee.

Severity

  • Attack Vector: Local: the attacker needs to either be able to intercept the local, unencrypted traffic or needs access to user's browser.
  • Attack Complexity: High: the attacker must first obtain a valid saml-session token belonging to the victim (requires a separate interception step; the token is ephemeral and single-use by design).
  • Privileges Required: None: no Omni account is required to carry out the race once the session token is in hand.
  • User Interaction: Required: the victim must initiate a SAML authentication flow to produce the session token that the attacker intercepts.
  • Scope: Unchanged: the impact stays within Omni's authorization boundary.
  • Confidentiality Impact: High: successful exploitation authenticates the attacker as the victim's email identity, granting read access to any resource accessible to that identity.
  • Integrity Impact: High: the attacker can confirm one or more public keys under the victim's identity (via ConfirmPublicKey), establishing persistent access credentials tied to the victim's account.
  • Availability Impact: High: if the attacker can successfully perform the attack and if the victim is a privileged Omni user, e.g., an Omni Operator or Admin, they can take Omni down.

Impact

  • Session replay: A stolen saml-session token can be used more than once, defeating its single-use guarantee.
  • Multiple public key confirmations: An attacker who steals the session can confirm N attacker-controlled public keys under the victim's identity in a single stolen session window, creating N persistent long-lived API credentials tied to the victim's account.
  • Authentication as victim: Any gRPC endpoint gated by the SAML interceptor can be reached as the victim's email identity during the race window.
  • Audit log pollution: Each raced call generates an audit entry attributed to the victim's email, obscuring the attacker's actions.

Credit

This vulnerability was discovered and reported by bugbunny.ai.

Affected products

2

Patches

2
272d3f4dd00e

fix: consume SAML sessions once

https://github.com/siderolabs/omniUtku OzdemirMay 11, 2026Fixed in 1.7.3via ghsa-release-walk
2 files changed · +125 9
  • internal/pkg/auth/interceptor/saml.go+25 9 modified
    @@ -27,7 +27,10 @@ import (
     	"github.com/siderolabs/omni/internal/pkg/ctxstore"
     )
     
    -var errGRPCInvalidSAML = status.Error(codes.Unauthenticated, "invalid session")
    +var (
    +	errGRPCInvalidSAML        = status.Error(codes.Unauthenticated, "invalid session")
    +	errSAMLSessionAlreadyUsed = errors.New("session was already used")
    +)
     
     // SAML is a GRPC interceptor that verifies SAML session.
     type SAML struct {
    @@ -109,30 +112,43 @@ func (i *SAML) getSession(ctx context.Context, sessionID string) (*authres.SAMLA
     		return nil, errGRPCInvalidSAML
     	}
     
    +	if acs.TypedSpec().Value.Used {
    +		i.logger.Info("invalid session", zap.Error(errSAMLSessionAlreadyUsed))
    +
    +		return nil, errGRPCInvalidSAML
    +	}
    +
     	var assertion saml.Assertion
     
     	err = json.Unmarshal(acs.TypedSpec().Value.Data, &assertion)
     	if err != nil {
     		return nil, err
     	}
     
    -	if acs.TypedSpec().Value.Used {
    -		i.logger.Info("invalid session", zap.Error(errors.New("session was already used")))
    -
    -		return nil, errGRPCInvalidSAML
    -	}
    -
     	if assertion.IssueInstant.Add(saml.MaxIssueDelay).Before(time.Now().UTC()) {
     		i.logger.Info("invalid session", zap.Error(errors.New("SAML assertion expired")))
     
     		return nil, errGRPCInvalidSAML
     	}
     
    -	_, err = safe.StateUpdateWithConflicts(ctx, i.state, acs.Metadata(), func(r *authres.SAMLAssertion) error {
    +	acs, err = safe.StateUpdateWithConflicts(ctx, i.state, acs.Metadata(), func(r *authres.SAMLAssertion) error {
    +		if r.TypedSpec().Value.Used {
    +			return errSAMLSessionAlreadyUsed
    +		}
    +
     		r.TypedSpec().Value.Used = true
     
     		return nil
     	})
    +	if err != nil {
    +		if errors.Is(err, errSAMLSessionAlreadyUsed) {
    +			i.logger.Info("invalid session", zap.Error(err))
    +
    +			return nil, errGRPCInvalidSAML
    +		}
    +
    +		return nil, err
    +	}
     
    -	return acs, err
    +	return acs, nil
     }
    
  • internal/pkg/auth/interceptor/saml_test.go+100 0 added
    @@ -0,0 +1,100 @@
    +// Copyright (c) 2026 Sidero Labs, Inc.
    +//
    +// Use of this software is governed by the Business Source License
    +// included in the LICENSE file.
    +
    +package interceptor_test
    +
    +import (
    +	"context"
    +	"encoding/json"
    +	"sync/atomic"
    +	"testing"
    +	"time"
    +
    +	"github.com/cosi-project/runtime/pkg/safe"
    +	"github.com/cosi-project/runtime/pkg/state"
    +	"github.com/cosi-project/runtime/pkg/state/impl/inmem"
    +	"github.com/cosi-project/runtime/pkg/state/impl/namespaced"
    +	"github.com/crewjam/saml"
    +	"github.com/siderolabs/go-api-signature/pkg/message"
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +	"go.uber.org/zap"
    +	"golang.org/x/sync/errgroup"
    +	"google.golang.org/grpc/codes"
    +	"google.golang.org/grpc/metadata"
    +	"google.golang.org/grpc/status"
    +
    +	authres "github.com/siderolabs/omni/client/pkg/omni/resources/auth"
    +	"github.com/siderolabs/omni/internal/backend/runtime/omni/audit/auditlog"
    +	"github.com/siderolabs/omni/internal/pkg/auth"
    +	"github.com/siderolabs/omni/internal/pkg/auth/actor"
    +	"github.com/siderolabs/omni/internal/pkg/auth/interceptor"
    +	"github.com/siderolabs/omni/internal/pkg/ctxstore"
    +)
    +
    +func TestSAMLSessionCanOnlyBeUsedOnceConcurrently(t *testing.T) {
    +	t.Parallel()
    +
    +	const (
    +		sessionID    = "test-session"
    +		requestCount = 32
    +		email        = "user@example.com"
    +	)
    +
    +	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
    +	t.Cleanup(cancel)
    +
    +	st := state.WrapCore(namespaced.NewState(inmem.Build))
    +
    +	assertionData, err := json.Marshal(saml.Assertion{IssueInstant: time.Now().UTC()})
    +	require.NoError(t, err)
    +
    +	assertionResource := authres.NewSAMLAssertion(sessionID)
    +	assertionResource.TypedSpec().Value.Data = assertionData
    +	assertionResource.TypedSpec().Value.Email = email
    +
    +	require.NoError(t, st.Create(actor.MarkContextAsInternalActor(ctx), assertionResource))
    +
    +	samlInterceptor := interceptor.NewSAML(st, zap.NewNop())
    +
    +	var successCount, unauthenticatedCount atomic.Int32
    +
    +	eg, ctx := errgroup.WithContext(ctx)
    +
    +	for range requestCount {
    +		eg.Go(func() error {
    +			_, callErr := samlInterceptor.Unary()(samlRequestContext(ctx, sessionID), nil, nil, noopHandler)
    +
    +			switch {
    +			case callErr == nil:
    +				successCount.Add(1)
    +			case status.Code(callErr) == codes.Unauthenticated:
    +				unauthenticatedCount.Add(1)
    +			default:
    +				return callErr
    +			}
    +
    +			return nil
    +		})
    +	}
    +
    +	require.NoError(t, eg.Wait())
    +	assert.Equal(t, int32(1), successCount.Load())
    +	assert.Equal(t, int32(requestCount-1), unauthenticatedCount.Load())
    +
    +	usedAssertion, err := safe.StateGetByID[*authres.SAMLAssertion](ctx, st, sessionID)
    +	require.NoError(t, err)
    +	assert.True(t, usedAssertion.TypedSpec().Value.Used)
    +}
    +
    +func samlRequestContext(ctx context.Context, sessionID string) context.Context {
    +	md := metadata.Pairs(auth.SamlSessionHeaderKey, sessionID)
    +
    +	ctx = metadata.NewIncomingContext(ctx, md)
    +	ctx = ctxstore.WithValue(ctx, auth.GRPCMessageContextKey{Message: message.NewGRPC(md, "/omni.test/SAML")})
    +	ctx = ctxstore.WithValue(ctx, &auditlog.Data{})
    +
    +	return ctx
    +}
    
4c4c1e2d8226

fix: consume SAML sessions once

https://github.com/siderolabs/omniUtku OzdemirMay 11, 2026Fixed in 1.6.6via ghsa-release-walk
2 files changed · +125 9
  • internal/pkg/auth/interceptor/saml.go+25 9 modified
    @@ -27,7 +27,10 @@ import (
     	"github.com/siderolabs/omni/internal/pkg/ctxstore"
     )
     
    -var errGRPCInvalidSAML = status.Error(codes.Unauthenticated, "invalid session")
    +var (
    +	errGRPCInvalidSAML        = status.Error(codes.Unauthenticated, "invalid session")
    +	errSAMLSessionAlreadyUsed = errors.New("session was already used")
    +)
     
     // SAML is a GRPC interceptor that verifies SAML session.
     type SAML struct {
    @@ -109,30 +112,43 @@ func (i *SAML) getSession(ctx context.Context, sessionID string) (*authres.SAMLA
     		return nil, errGRPCInvalidSAML
     	}
     
    +	if acs.TypedSpec().Value.Used {
    +		i.logger.Info("invalid session", zap.Error(errSAMLSessionAlreadyUsed))
    +
    +		return nil, errGRPCInvalidSAML
    +	}
    +
     	var assertion saml.Assertion
     
     	err = json.Unmarshal(acs.TypedSpec().Value.Data, &assertion)
     	if err != nil {
     		return nil, err
     	}
     
    -	if acs.TypedSpec().Value.Used {
    -		i.logger.Info("invalid session", zap.Error(errors.New("session was already used")))
    -
    -		return nil, errGRPCInvalidSAML
    -	}
    -
     	if assertion.IssueInstant.Add(saml.MaxIssueDelay).Before(time.Now().UTC()) {
     		i.logger.Info("invalid session", zap.Error(errors.New("SAML assertion expired")))
     
     		return nil, errGRPCInvalidSAML
     	}
     
    -	_, err = safe.StateUpdateWithConflicts(ctx, i.state, acs.Metadata(), func(r *authres.SAMLAssertion) error {
    +	acs, err = safe.StateUpdateWithConflicts(ctx, i.state, acs.Metadata(), func(r *authres.SAMLAssertion) error {
    +		if r.TypedSpec().Value.Used {
    +			return errSAMLSessionAlreadyUsed
    +		}
    +
     		r.TypedSpec().Value.Used = true
     
     		return nil
     	})
    +	if err != nil {
    +		if errors.Is(err, errSAMLSessionAlreadyUsed) {
    +			i.logger.Info("invalid session", zap.Error(err))
    +
    +			return nil, errGRPCInvalidSAML
    +		}
    +
    +		return nil, err
    +	}
     
    -	return acs, err
    +	return acs, nil
     }
    
  • internal/pkg/auth/interceptor/saml_test.go+100 0 added
    @@ -0,0 +1,100 @@
    +// Copyright (c) 2026 Sidero Labs, Inc.
    +//
    +// Use of this software is governed by the Business Source License
    +// included in the LICENSE file.
    +
    +package interceptor_test
    +
    +import (
    +	"context"
    +	"encoding/json"
    +	"sync/atomic"
    +	"testing"
    +	"time"
    +
    +	"github.com/cosi-project/runtime/pkg/safe"
    +	"github.com/cosi-project/runtime/pkg/state"
    +	"github.com/cosi-project/runtime/pkg/state/impl/inmem"
    +	"github.com/cosi-project/runtime/pkg/state/impl/namespaced"
    +	"github.com/crewjam/saml"
    +	"github.com/siderolabs/go-api-signature/pkg/message"
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +	"go.uber.org/zap"
    +	"golang.org/x/sync/errgroup"
    +	"google.golang.org/grpc/codes"
    +	"google.golang.org/grpc/metadata"
    +	"google.golang.org/grpc/status"
    +
    +	authres "github.com/siderolabs/omni/client/pkg/omni/resources/auth"
    +	"github.com/siderolabs/omni/internal/backend/runtime/omni/audit/auditlog"
    +	"github.com/siderolabs/omni/internal/pkg/auth"
    +	"github.com/siderolabs/omni/internal/pkg/auth/actor"
    +	"github.com/siderolabs/omni/internal/pkg/auth/interceptor"
    +	"github.com/siderolabs/omni/internal/pkg/ctxstore"
    +)
    +
    +func TestSAMLSessionCanOnlyBeUsedOnceConcurrently(t *testing.T) {
    +	t.Parallel()
    +
    +	const (
    +		sessionID    = "test-session"
    +		requestCount = 32
    +		email        = "user@example.com"
    +	)
    +
    +	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
    +	t.Cleanup(cancel)
    +
    +	st := state.WrapCore(namespaced.NewState(inmem.Build))
    +
    +	assertionData, err := json.Marshal(saml.Assertion{IssueInstant: time.Now().UTC()})
    +	require.NoError(t, err)
    +
    +	assertionResource := authres.NewSAMLAssertion(sessionID)
    +	assertionResource.TypedSpec().Value.Data = assertionData
    +	assertionResource.TypedSpec().Value.Email = email
    +
    +	require.NoError(t, st.Create(actor.MarkContextAsInternalActor(ctx), assertionResource))
    +
    +	samlInterceptor := interceptor.NewSAML(st, zap.NewNop())
    +
    +	var successCount, unauthenticatedCount atomic.Int32
    +
    +	eg, ctx := errgroup.WithContext(ctx)
    +
    +	for range requestCount {
    +		eg.Go(func() error {
    +			_, callErr := samlInterceptor.Unary()(samlRequestContext(ctx, sessionID), nil, nil, noopHandler)
    +
    +			switch {
    +			case callErr == nil:
    +				successCount.Add(1)
    +			case status.Code(callErr) == codes.Unauthenticated:
    +				unauthenticatedCount.Add(1)
    +			default:
    +				return callErr
    +			}
    +
    +			return nil
    +		})
    +	}
    +
    +	require.NoError(t, eg.Wait())
    +	assert.Equal(t, int32(1), successCount.Load())
    +	assert.Equal(t, int32(requestCount-1), unauthenticatedCount.Load())
    +
    +	usedAssertion, err := safe.StateGetByID[*authres.SAMLAssertion](ctx, st, sessionID)
    +	require.NoError(t, err)
    +	assert.True(t, usedAssertion.TypedSpec().Value.Used)
    +}
    +
    +func samlRequestContext(ctx context.Context, sessionID string) context.Context {
    +	md := metadata.Pairs(auth.SamlSessionHeaderKey, sessionID)
    +
    +	ctx = metadata.NewIncomingContext(ctx, md)
    +	ctx = ctxstore.WithValue(ctx, auth.GRPCMessageContextKey{Message: message.NewGRPC(md, "/omni.test/SAML")})
    +	ctx = ctxstore.WithValue(ctx, &auditlog.Data{})
    +
    +	return ctx
    +}
    

Vulnerability mechanics

Root cause

"The check for the SAML assertion's used status and the marking of the assertion as used are not performed atomically, creating a race condition."

Attack vector

An attacker must first obtain a valid `saml-session` token, which requires the victim to initiate a SAML authentication flow and the attacker to intercept the token. With the token in hand, the attacker can send multiple concurrent requests using the same token. If these requests arrive within the race window, they will all be validated successfully, allowing the attacker to authenticate multiple times as the victim. This bypasses the intended single-use guarantee of the SAML session token [ref_id=1].

Affected code

The vulnerability exists in the `getSession` function within the file `internal/pkg/auth/interceptor/saml.go`. Specifically, the code first checks if the `Used` flag is set on a `SAMLAssertion` resource and then proceeds to update this flag in a separate state operation. This non-atomic check-then-update pattern is the root cause of the race condition [ref_id=1].

What the fix does

The patch modifies the `getSession` function in `internal/pkg/auth/interceptor/saml.go` to ensure that the check for the `Used` flag and the update to mark the session as used are performed atomically within a single state transaction. This prevents concurrent requests from observing the session as unused after it has already been consumed by another request, thus closing the TOCTOU race condition [patch_id=4935413, patch_id=4935414].

Preconditions

  • inputAttacker must obtain a valid `saml-session` token.
  • networkAttacker needs to intercept local, unencrypted traffic or access the user's browser to obtain the token.
  • authNo Omni account privileges are required to exploit the vulnerability once the session token is obtained.
  • inputVictim must initiate a SAML authentication flow to generate the session token.

Generated on Jun 5, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.