All incidents

SmartBank balance-manipulation bug drains USDT via flash-loan roundtrip

Share
Jul 11, 2024 11:24 UTCAttackLoss: 57,445.21 USDT, 18,700,116 SBTManually checked1 exploit txWindow: Atomic

Root Cause Analysis

Smart_Bank balance-manipulation bug drains USDT via flash-loan roundtrip

1. Incident Overview TL;DR

An unprivileged adversary on BSC used a flash loan from a Pancake V3 USDT/WBNB pool to manipulate the internal USDT balance of the Smart_Bank contract 0x2b45…f351, then executed a carefully chosen Buy_SBT/Sell_SBT round-trip on its bonding-curve price functions to drain USDT and SBT from the protocol. In a single transaction at block 40378160, the adversary realized approximately 56,450.71 USDT net profit to EOA 0x3026c4…ce32 and extracted 18,700,116 SBT into helper contract 0x88f9e1, while Smart_Bank lost 57,445.21 USDT and 18,700,116 SBT.

Smart_Bank’s SBT/USDT pricing logic relies directly on USDT.balanceOf(this) and SBT.balanceOf(this) and assumes those balances are only changed by its own accounting, but it never defends against arbitrary external transfers; this allows an adversary to first donate USDT to inflate Smart_Bank_USDT_Balance, then combine Buy_SBT and Sell_SBT with specific sizes in a single tx (funded by a flash loan) to extract more USDT than originally supplied while also siphoning SBT from the contract.

2. Key Background

Key Smart_Bank pricing helpers (from verified Contract.sol):

    function SBT_Price() private view returns(uint256) {return((Smart_Bank_USDT_Balance()*10**18)/(Smart_Bank__SBT_Balance()));}
    function SBT_Price_Sell(uint256 Value) private view returns(uint256) {return((Smart_Bank_USDT_Balance()*10**18)/(Value + Smart_Bank__SBT_Balance()));}
    function Smart_Bank_USDT_Balance() public view returns(uint256) {return USDT.balanceOf(address(this)) / 10**18;}
    function Smart_Bank__SBT_Balance() public view returns(uint256) {return SBT.balanceOf(address(this)) / 10**18;}

3. Vulnerability Analysis & Root Cause Summary

The root cause is a bonding-curve/accounting bug in Smart_Bank’s USDT/SBT pricing logic: SBT_Price() and SBT_Price_Sell(Value) are based solely on raw ERC20 balances, so an adversary can manipulate Smart_Bank_USDT_Balance() with an exogenous USDT transfer, then exploit the mismatch between the buy and sell formulas in a single tx (using flash-loaned liquidity) to drain USDT and SBT without net depositing equivalent value.

Invariant:

For any sequence of user interactions using Smart_Bank’s Buy_SBT and Sell_SBT functions (and related staking/loan logic) from a public pre-state σ_B, if a user starts and ends with zero net SBT exposure to Smart_Bank (i.e., returns at least as many SBT to the contract as they withdrew), then the user’s net USDT received from Smart_Bank, after accounting for protocol fees and any USDT they transferred into the contract, should be less than or equal to zero; equivalently, Smart_Bank should not pay out more USDT than it receives in such a closed SBT round-trip.

Breakpoint (code-level violation):

In Contract.sol for Smart_Bank 0x2b45…f351, the private helpers SBT_Price() and SBT_Price_Sell(uint256 Value) are defined as SBT_Price() = (Smart_Bank_USDT_Balance() * 1018) / Smart_Bank__SBT_Balance() and SBT_Price_Sell(Value) = (Smart_Bank_USDT_Balance() * 1018) / (Value + Smart_Bank__SBT_Balance()), where Smart_Bank_USDT_Balance() = USDT.balanceOf(address(this)) / 10**18; in Sell_Token(uint256 X), the contract then pays USDT.safeTransfer(msg.sender, (X * SBT_Price_Sell(X) * 90) / 100). Because Smart_Bank_USDT_Balance() uses the raw ERC20 balance, an adversary can first transfer 950,000 USDT directly into the contract to increase Smart_Bank_USDT_Balance() without increasing Smart_Bank__SBT_Balance(), then invoke Buy_Token and Sell_Token with X_buy = 20,000,000 and X_sell = 1,299,884 in the same tx, causing the contract to pay out more than 1.9M USDT while only accounting for ~1.91M USDT of inbound transfers, violating the invariant that closed SBT round-trips should not yield positive net USDT.

Detailed mechanism:

From the verified Smart_Bank source, we have:\n- Smart_Bank_USDT_Balance() = USDT.balanceOf(address(this)) / 1018;\n- Smart_Bank__SBT_Balance() = SBT.balanceOf(address(this)) / 1018;\n- SBT_Price() = (Smart_Bank_USDT_Balance() * 1018) / Smart_Bank__SBT_Balance();\n- SBT_Price_Sell(uint256 Value) = (Smart_Bank_USDT_Balance() * 1018) / (Value + Smart_Bank__SBT_Balance()).\nBuy_Token(uint256 X) (called via Buy_SBT) charges the user USDT.safeTransferFrom(msg.sender, address(this), X * SBT_Price()) and sends X * 1018 SBT. Sell_Token(uint256 X) (called via Sell_SBT) takes X * 1018 SBT from the user and pays (X * SBT_Price_Sell(X) * 90) / 100 USDT. These formulas assume that Smart_Bank_USDT_Balance and Smart_Bank__SBT_Balance reflect only the protocol’s controlled reserves, but they are in fact the live token balances that can be manipulated by arbitrary external transfers.\nIn the exploit tx, helper 0x88f9e1 first borrows 1,950,000 USDT from Pancake V3 pool 0x3669…2050 via a flash loan. It then calls USDT.transfer(0x2b45…f351, 950,000 USDT), raising Smart_Bank_USDT_Balance() before any Smart_Bank function is called. With this inflated USDT balance, the adversary calls the Buy_Token-equivalent function (selector 0x4eda1f98) with X_buy = 20,000,000, causing Smart_Bank to compute a high SBT_Price() and charge USDT.safeTransferFrom(0x88f9e1, 0x2b45…f351, 959,484.79032011366 USDT) while sending 20,000,000 SBT to 0x88f9e1. Next, the adversary calls Sell_Token-equivalent function (selector 0x25115948) with X_sell = 1,299,884, at which point Smart_Bank_USDT_Balance() includes both the original USDT reserves and the two previous inbound transfers; SBT_Price_Sell(X_sell) is computed off this inflated balance and a reduced SBT reserve (20,999,916.4308 − 20,000,000 + 1,299,884), and Sell_Token pays USDT.safeTransfer(0x2b45…f351 -> 0x88f9e1, 1,966,930 USDT).\nAcross these three USDT transfers involving Smart_Bank, the contract receives 950,000 + 959,484.79032011366 ≈ 1,909,484.79032011366 USDT and pays out 1,966,930 USDT, a net loss of exactly 57,445.20967988634 USDT as independently confirmed by usdt_wbnb_extended_seed_tx.json. At the same time, Smart_Bank sends out 20,000,000 SBT and takes back 1,299,884 SBT, for a net SBT loss of 18,700,116 tokens. The adversary’s flash loan and donation exploit the fact that Smart_Bank’s pricing formulas use raw balances and ignore the provenance of USDT/SBT in the pool: the protocol mistakenly treats the 950,000 USDT donation as part of its price backing but then effectively returns most of it plus additional USDT and SBT to the attacker. This is a pure accounting/price-curve bug; no privileged roles are used, and the same pattern can be reproduced by any unprivileged account with access to USDT and SBT and optional flash-loan liquidity.

Vulnerable components:

  • Smart_Bank contract 0x2b45dd1d909c01aad96fa6b67108d691b432f351, specifically functions Smart_Bank_USDT_Balance(), Smart_Bank__SBT_Balance(), SBT_Price(), SBT_Price_Sell(uint256), Buy_Token(uint256) / Buy_SBT, and Sell_Token(uint256) / Sell_SBT in Contract.sol.
  • Smart_Bank_Token SBT 0x94441698165fb7e132e207800b3ea57e34c93a72 is used as the internal token whose balances underpin Smart_Bank__SBT_Balance(); while its ERC20 implementation is standard, its interaction with Smart_Bank’s flawed pricing logic makes SBT reserves exploitable.

4. Detailed Root Cause Analysis

From the verified Smart_Bank source, we have:\n- Smart_Bank_USDT_Balance() = USDT.balanceOf(address(this)) / 1018;\n- Smart_Bank__SBT_Balance() = SBT.balanceOf(address(this)) / 1018;\n- SBT_Price() = (Smart_Bank_USDT_Balance() * 1018) / Smart_Bank__SBT_Balance();\n- SBT_Price_Sell(uint256 Value) = (Smart_Bank_USDT_Balance() * 1018) / (Value + Smart_Bank__SBT_Balance()).\nBuy_Token(uint256 X) (called via Buy_SBT) charges the user USDT.safeTransferFrom(msg.sender, address(this), X * SBT_Price()) and sends X * 1018 SBT. Sell_Token(uint256 X) (called via Sell_SBT) takes X * 1018 SBT from the user and pays (X * SBT_Price_Sell(X) * 90) / 100 USDT. These formulas assume that Smart_Bank_USDT_Balance and Smart_Bank__SBT_Balance reflect only the protocol’s controlled reserves, but they are in fact the live token balances that can be manipulated by arbitrary external transfers.\nIn the exploit tx, helper 0x88f9e1 first borrows 1,950,000 USDT from Pancake V3 pool 0x3669…2050 via a flash loan. It then calls USDT.transfer(0x2b45…f351, 950,000 USDT), raising Smart_Bank_USDT_Balance() before any Smart_Bank function is called. With this inflated USDT balance, the adversary calls the Buy_Token-equivalent function (selector 0x4eda1f98) with X_buy = 20,000,000, causing Smart_Bank to compute a high SBT_Price() and charge USDT.safeTransferFrom(0x88f9e1, 0x2b45…f351, 959,484.79032011366 USDT) while sending 20,000,000 SBT to 0x88f9e1. Next, the adversary calls Sell_Token-equivalent function (selector 0x25115948) with X_sell = 1,299,884, at which point Smart_Bank_USDT_Balance() includes both the original USDT reserves and the two previous inbound transfers; SBT_Price_Sell(X_sell) is computed off this inflated balance and a reduced SBT reserve (20,999,916.4308 − 20,000,000 + 1,299,884), and Sell_Token pays USDT.safeTransfer(0x2b45…f351 -> 0x88f9e1, 1,966,930 USDT).\nAcross these three USDT transfers involving Smart_Bank, the contract receives 950,000 + 959,484.79032011366 ≈ 1,909,484.79032011366 USDT and pays out 1,966,930 USDT, a net loss of exactly 57,445.20967988634 USDT as independently confirmed by usdt_wbnb_extended_seed_tx.json. At the same time, Smart_Bank sends out 20,000,000 SBT and takes back 1,299,884 SBT, for a net SBT loss of 18,700,116 tokens. The adversary’s flash loan and donation exploit the fact that Smart_Bank’s pricing formulas use raw balances and ignore the provenance of USDT/SBT in the pool: the protocol mistakenly treats the 950,000 USDT donation as part of its price backing but then effectively returns most of it plus additional USDT and SBT to the attacker. This is a pure accounting/price-curve bug; no privileged roles are used, and the same pattern can be reproduced by any unprivileged account with access to USDT and SBT and optional flash-loan liquidity.

Conditions required for the exploit (ACT opportunity):

  • Smart_Bank Start flag must be enabled so that Buy_SBT and Sell_SBT are callable (as is true in the incident tx).
  • Smart_Bank must hold a nontrivial amount of USDT and SBT so that Smart_Bank_USDT_Balance() and Smart_Bank__SBT_Balance() yield usable prices for the bonding curve.
  • The adversary must be able to transfer USDT directly to Smart_Bank (USDT.transfer) and then call Buy_SBT and Sell_SBT within the same transaction so that the manipulated balances are used for SBT_Price() and SBT_Price_Sell(X) before any external arbitrage can correct them.
  • Sufficient USDT/SBT liquidity must exist (either held by the adversary or obtainable via a flash loan from an external pool such as Pancake V3) to execute the two-step buy/sell with parameters analogous to X_buy = 20,000,000 and X_sell = 1,299,884 without reverting due to balance checks.
  • No reentrancy or access-control guard in Smart_Bank restricts the sequence of operations used in the exploit; any EOA can call the same functions via their own helper contract.

Seed transaction balance diff excerpt (USDT & SBT):

[
  {
    "token": "0x55d398326f99059ff775485246999027b3197955",
    "holder": "0x36696169c63e42cd08ce11f5deebbcebae652050",
    "before": "36133491513892207520461026",
    "after": "36134486013892207520461026",
    "delta": "994500000000000000000",
    "balances_slot": "1",
    "slot_key": "0x4b4dfb61299ca62a744d1ae05c6e24e84740e94efd86eec09992191d35df35c4",
    "contract_name": "BEP20USDT"
  },
  {
    "token": "0x55d398326f99059ff775485246999027b3197955",
    "holder": "0x2b45dd1d909c01aad96fa6b67108d691b432f351",
    "before": "57455165109033599609515",
    "after": "9955429147259609515",
    "delta": "-57445209679886340000000",
    "balances_slot": "1",
    "slot_key": "0x87c17ff7bc843b6009e27da1c090f17e711ae6ed4f6be458b92ea9392953eadc",
    "contract_name": "BEP20USDT"
  },
  {
    "token": "0x55d398326f99059ff775485246999027b3197955",
    "holder": "0x3026c464d3bd6ef0ced0d49e80f171b58176ce32",
    "before": "123564839816",
    "after": "56450709680009904839816",
    "delta": "56450709679886340000000",
    "balances_slot": "1",
    "slot_key": "0x87c83e759d7b138d17b0e45f5f685f0c63524d86ad54e2a2f032a714b034a9ee",
    "contract_name": "BEP20USDT"
  },
  {
    "token": "0x94441698165fb7e132e207800b3ea57e34c93a72",
    "holder": "0x2b45dd1d909c01aad96fa6b67108d691b432f351",
    "before": "20999916430830343150012642",
    "after": "2299800430830343150012642",
    "delta": "-18700116000000000000000000",
    "balances_slot": "0",
    "slot_key": "0x80e24935e0b7a44eb071f236ef12dd8f0eda2b64eaab9e861ff84745818197c2",
    "contract_name": "Smart_Bank_Token"
  },
  {
    "token": "0x94441698165fb7e132e207800b3ea57e34c93a72",
    "holder": "0x88f9e1799465655f0dd206093dbd08922a1d9e28",
    "before": "0",
    "after": "18700116000000000000000000",
    "delta": "18700116000000000000000000",
    "balances_slot": "0",
    "slot_key": "0x62beb675b005c77f080f94ff9b46d61a7fc1f39f886c48a00838e11c357fdd56",
    "contract_name": "Smart_Bank_Token"
  }
]

5. Adversary Flow Analysis

The adversary executes a single, carefully constructed transaction via a freshly deployed helper contract that (1) takes a USDT flash loan from a Pancake V3 pool, (2) donates USDT directly to Smart_Bank to manipulate its internal price, (3) performs a large Buy_SBT and a partial Sell_SBT on the mispriced bonding curve to extract USDT and SBT, (4) repays the flash loan with fee, and (5) forwards the net USDT profit to the originating EOA.

Adversary Helper Deployment

EOA 0x3026c4…ce32 deploys helper contract 0x88f9e1, which will act as the orchestrator for flash loans and Smart_Bank interactions in the subsequent exploit tx.

Transactions:

  • chain 56, tx 0xf233386175b699c7c9dd1944d229833ad90dc898c6fa560ee8f2b777caf8d7ff, block 40378055, mechanism contract_deploy

Evidence notes:

artifacts/root_cause/data_collector/iter_1/address/56/0x88f9e1799465655f0dd206093dbd08922a1d9e28/txlist.json shows tx 0xf23338…d7ff as the creation of 0x88f9e1 by 0x3026c4…ce32.

Flash Loan Acquisition

Helper 0x88f9e1 calls Pancake V3 pool 0x3669…2050 to obtain a 1,950,000 USDT flash loan; the pool transfers USDT from its reserves to 0x88f9e1 and later expects principal plus a 0.05% fee to be returned within the same transaction.

Transactions:

  • chain 56, tx 0x9a8c4c4edb7a76ecfa935780124c409f83a08d15c560bb67302182f8969be20d, block 40378160, mechanism flashloan

Evidence notes:

debug_trace_call_tracer.json shows 0x88f9e1 calling 0x3669…2050 with selector 0x490e6cbc, followed by USDT.transfer(0x3669…2050 -> 0x88f9e1, 1,950,000 * 10^18) and a later USDT.transfer(0x88f9e1 -> 0x3669…2050, 1,950,994.5 * 10^18); usdt_wbnb_extended_seed_tx.json confirms the pool’s net +994.5 USDT balance change.

Smart_Bank Balance Manipulation and Exploit

Helper 0x88f9e1 donates 950,000 USDT to Smart_Bank, calls its Buy_SBT-equivalent function with X_buy = 20,000,000 to exchange 959,484.79032011366 USDT for 20,000,000 SBT, then calls its Sell_SBT-equivalent with X_sell = 1,299,884 to exchange 1,299,884 SBT for 1,966,930 USDT; this sequence exploits Smart_Bank’s price functions to cause a net outflow of 57,445.20967988634 USDT and 18,700,116 SBT from Smart_Bank.

Transactions:

  • chain 56, tx 0x9a8c4c4edb7a76ecfa935780124c409f83a08d15c560bb67302182f8969be20d, block 40378160, mechanism protocol_interaction

Evidence notes:

debug_trace_call_tracer.json shows USDT.transfer(0x88f9e1 -> 0x2b45…f351, 950,000 USDT), USDT.transferFrom(0x88f9e1 -> 0x2b45…f351, 959,484.79032011366 USDT), SBT.transfer(0x2b45…f351 -> 0x88f9e1, 20,000,000 SBT), SBT.transferFrom(0x88f9e1 -> 0x2b45…f351, 1,299,884 SBT), and USDT.transfer(0x2b45…f351 -> 0x88f9e1, 1,966,930 USDT). Smart_Bank Contract.sol defines SBT_Price() and SBT_Price_Sell(Value) in terms of Smart_Bank_USDT_Balance() and Smart_Bank__SBT_Balance(), which use raw token balances and are thus sensitive to the initial 950,000 USDT donation.

Flash Loan Repayment and Profit Realization

Helper 0x88f9e1 repays 1,950,994.5 USDT (principal plus fee) to the Pancake V3 pool, then transfers 56,450.70967988634 USDT to the originating EOA 0x3026c4…ce32 and retains 18,700,116 SBT. The EOA’s net portfolio value in USDT increases by 56,450.70967988634 after accounting for the flash-fee and gas.

Transactions:

  • chain 56, tx 0x9a8c4c4edb7a76ecfa935780124c409f83a08d15c560bb67302182f8969be20d, block 40378160, mechanism repay_and_profit

Evidence notes:

debug_trace_call_tracer.json contains USDT.transfer(0x88f9e1 -> 0x3669…2050, 1,950,994.5 USDT) and USDT.transfer(0x88f9e1 -> 0x3026c4…ce32, 56,450.70967988634 USDT). usdt_wbnb_extended_seed_tx.json records the corresponding USDT deltas for the pool ( +994.5 USDT ) and EOA 0x3026c4…ce32 ( +56,450.70967988634 USDT ).

6. Impact & Losses

Total protocol-side losses:

  • USDT: 57,445.20967988634
  • SBT: 18,700,116

During the exploit tx, Smart_Bank loses 57,445.20967988634 USDT and 18,700,116 SBT from its internal reserves. The USDT loss is immediately crystallized as profit for the adversary cluster (with 56,450.70967988634 USDT flowing to EOA 0x3026c4…ce32 and 994.5 USDT accruing as flash fee to the Pancake pool), while the SBT loss leaves Smart_Bank with significantly reduced SBT backing and exposes remaining users to potential further dilution or subsequent liquidation of the attacker’s SBT holdings. The exploit demonstrates that Smart_Bank’s USDT/SBT bonding curve can be profitably abused by any sufficiently capitalized adversary, severely undermining the protocol’s economic security.

7. References