CashioApp Attack - What’s the Vulnerability and How Soteria Detects It
The Cashio stablecoin (CASH) protocol recently lost $50M in an attack. The attacker was able to mint 2,000,000,000 CASH tokens for almost free. The root cause is a vulnerability in the Cashio’s brrr smart contract.
Soteria team conducted an in-depth analysis of the attack and found that
(1) The attacker was sophisticated (started preparation five days before the attack)
(2) The vulnerability lies deeply in a missing check of an input (bank) account
(3) C̶a̶s̶h̶i̶o̶’̶s̶ ̶p̶a̶t̶c̶h̶ ̶t̶o̶ ̶f̶i̶x̶ ̶t̶h̶e̶ ̶m̶i̶s̶s̶i̶n̶g̶ ̶c̶h̶e̶c̶k̶ ̶i̶s̶ ̶s̶t̶i̶l̶l̶ ̶i̶n̶s̶u̶f̶f̶i̶c̶i̶e̶n̶t̶ (however, the brrr smart contract has been disabled at the time of writing). (Correction: after a careful discussion with @siintemal from Neodyme, we found that the patch indeed fixes the fake bank issue. More detail in Section “Cashio’s patch”. Credits: @sinntemal and @nick_soteria).
Importantly, the vulnerability can be automatically detected by Soteria’s Premium auto auditor. This article elaborates on the details.
The Vulnerability
The Cashio brrr program’s functionality is simple: print and burn CASH tokens. It has only two instructions: print_cash and burn_cash :
The complex part is that Cashio uses Saber LP Arrows as collateral. Both instructions must provide a sequence of input accounts related to Saber and Arrow. In total, there are 12 accounts declared in a struct BrrrCommon:
Account
1
bank
2
collateral
3
crate_token
4
crate_mint
5
crate_collateral_tokens
6
arrow
7
saber_swap
8
pool_mint
9
reserve_a
10
reserve_b
11
token_program
12
crate_token_program
(The accounts 6–10 are wrapped in a struct SaberSwapAccounts).
Because all input accounts are supplied by untrusted users and thus could be faked, a challenge here is how to properly validate them. The brrr program includes the following checks:
assert_keys_eq!(self.bank, self.collateral.bank);
assert_keys_eq!(self.crate_token, self.crate_collateral_tokens.owner);
assert_keys_eq!(self.crate_mint, self.crate_token.mint);
assert_keys_eq!(self.crate_collateral_tokens.mint, self.collateral.mint);
// saber swap
self.saber_swap.validate()?;
assert_keys_eq!(self.collateral.mint, self.saber_swap.arrow.mint);
Note: arrow.mint is checked
assert_keys_eq!(self.arrow.vendor_miner.mint, self.pool_mint);
assert_keys_eq!(self.saber_swap.pool_mint, self.pool_mint);
assert_keys_eq!(self.saber_swap.token_a.reserves, self.reserve_a);
assert_keys_eq!(self.saber_swap.token_b.reserves, self.reserve_b);
Although these assert_keys_eq checks look a bit overwhelming, they are still insufficient: a crucial check on the validity of the bank account is missing! If the attacker could supply a fake bank account, then all the other checks become meaningless since bank is the root of trust.
In fact, that’s exactly what the attacker did: out of these 12 input accounts, the attacker created 8 fake accounts to pass all the validity checks.
The Attack
-
Attaker’s wallet address: 6D7fgzpPZXtDB6Zqg3xRwfbohzerbytB2U5pFchnVuzw
-
Attacker’s CASH token account (created 5 days before the attack): 26rFraKwk3gurdLLzR2aU5Z2sGA4jJ4Nnr7QDECu5BAK
-
The attack transaction: 4fgL8D6QXKH1q3Gt9GPzeRDpTgq4cE5hxf1hNDUWrJVUe4qDJ1xmUZE7KJWDANT99jD8UvwNeBb1imvujz3Pz2K5
Input accounts (BrrrCommon):
Account
Address
Note
1
bank (faked)
5ahaayrV5epV3oKChn4S4F5oek2vzoMbRpuC2fB4Q2kv
created by bankman with faked crate_mint and faked crate_token
2
collateral (faked)
HrCe9oUYRJKpfWiUwrkRNCxHSRx8gDX1bSF98Aq8qqjq
created by the token program with faked collateral.mint
3
crate_token
J77Nq48nbq4Etf1voss38R3dTdR3yD7y5F6W6TaVHvmb
valid crate_token with valid CASH mint
4
crate_mint
CASHVDm2wsJXfhj6VWxb7GiMdoLc17Du7paH4bNr5woT
CASH mint
5
crate_collateral_tokens (faked)
EAYzx8dqABiNdZKtfavg16rdyShHQB2k5hUa6UmXHiky
created by the token program with faked crate_collateral_tokens.mint == collateral.mint
6
arrow (faked)
HnWb284fT2yw2jjWyw6Ex7cf72PJvjBSYsK5H4fHEGpw
created by the arrow program with faked arrow.mint == collateral.mint
7
saber_swap (faked)
8uBqLjfRrwKxDG92nxDVGbkhbsZfaBqJ8Y2wJoXuHmHU
faked swap_info account initialized by the Saber Stable Swap Program with faked pool_mint, reserve_a and reserve_b
8
pool_mint (faked)
GoSK6XvdKquQwVYokYz8sKhFgkJAYwjq4i8ttjeukBmp
9
reserve_a (faked)
DBgB7Bw7mQ5Qk7VVcV51qL8FyLDsJDHV5bnJNsPSwVgL
10
reserve_b (faked)
3efHXgB12zP1EzKivsYTZeqAWc5YYCio9Dri9XATFsu3
11
token_program
TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
12
crate_token_program
CRATwLpu6YZEeiVq9ajjxs61wPQ9f29s1UoQR9siJCRs
How to fix the vulnerability?
To establish the bank account’s validity, the input accounts must satisfy two conditions:
Condition 1: The bank account’s crate_token must be valid:
bank.crate_token == crate_token
Condition 2. The bank account’s crate_mint must be valid:
bank.crate_mint == crate_mint
Condition (1) ensures that bank cannot be faked if the supplied crate_token account is valid. The reason is that bank is a PDA initialized in the bankman smart contract, with crate_token as a part of the seeds:
#[account(
init,
seeds = [
b"Bank".as_ref(),
crate_token.key().to_bytes().as_ref()
],
bump,
payer = payer
)]
pub bank: Account<'info, Bank>,
Condition (2) ensures that the supplied crate_mint cannot be faked — it is the same as the bank account’s crate_mint . In this context, it ensures that bank.crate_mint is indeed the CASH token mint.
Cashio’s patch i̶s̶ ̶i̶n̶s̶u̶f̶f̶i̶c̶i̶e̶n̶t̶
fn validate(&self) -> Result<()> {
assert_keys_eq!(self.bank, self.collateral.bank);
+ assert_keys_eq!(self.bank.crate_mint, self.crate_mint);
assert_keys_eq!(self.crate_token, self.crate_collateral_tokens.owner);
assert_keys_eq!(self.crate_mint, self.crate_token.mint);
assert_keys_eq!(self.crate_collateral_tokens.mint, self.collateral.mint);
T̶h̶e̶ ̶p̶a̶t̶c̶h̶ ̶i̶n̶ ̶v̶0̶.̶2̶.̶1̶ ̶i̶s̶ ̶i̶n̶s̶u̶f̶f̶i̶c̶i̶e̶n̶t̶ ̶b̶e̶c̶a̶u̶s̶e̶ ̶i̶t̶ ̶o̶n̶l̶y̶ ̶e̶n̶s̶u̶r̶e̶s̶ ̶C̶o̶n̶d̶i̶t̶i̶o̶n̶ ̶2̶,̶ ̶b̶u̶t̶ ̶n̶o̶t̶ ̶C̶o̶n̶d̶i̶t̶i̶o̶n̶ ̶1̶.̶ ̶T̶h̶e̶ ̶a̶t̶t̶a̶c̶k̶e̶r̶ ̶c̶o̶u̶l̶d̶ ̶s̶t̶i̶l̶l̶ ̶c̶r̶e̶a̶t̶e̶ ̶a̶ ̶f̶a̶k̶e̶ ̶b̶a̶n̶k̶ ̶t̶h̶a̶t̶ ̶s̶a̶t̶i̶s̶f̶i̶e̶s̶ ̶C̶o̶n̶d̶i̶t̶i̶o̶n̶ ̶2̶,̶ ̶a̶n̶d̶ ̶s̶i̶m̶i̶l̶a̶r̶l̶y̶ ̶c̶r̶e̶a̶t̶e̶ ̶7̶ ̶o̶t̶h̶e̶r̶ ̶f̶a̶k̶e̶d̶ ̶a̶c̶c̶o̶u̶n̶t̶s̶ ̶t̶o̶ ̶p̶a̶s̶s̶ ̶a̶l̶l̶ ̶t̶h̶e̶ ̶o̶t̶h̶e̶r̶ ̶v̶a̶l̶i̶d̶i̶t̶y̶ ̶c̶h̶e̶c̶k̶s̶.̶
Correction: After this post, @siintemal and Soteria team had a careful discussion on the patch in v0.2.1. It turns out the patch is indeed sufficient to also ensure Condition 1 (due to an invariant enforced in bankman, as pointed out by @nick_soteria_)!_
Specifically, bank and crate_token are both PDAs uniquely determined by crate_mint , due to an invariant bank.crate_mint == bank.crate_token.mint enforced in the new_bank instruction of bankman:
crate_mint: ctx.accounts.crate_mint.to_account_info(),
bankman source code: crate_mint is assigned to bank.crate_token.mint
bankman source code: crate_mint is assigned to bank.crate_token.mint
bankman source code: crate_mint is assigned to bank.crate_mint
bank.crate_mint = ctx.accounts.crate_mint.key();
bankman source code: crate_mint is assigned to bank.crate_mint
Thus, crate_mint to crate_token and bank relations are both 1:1.
Additional Note: With the patch, the input bank account can no longer be faked, but the bank’s curator may still mint CASH tokens for free.
The bankman contract has an authorize_collateral instruction to add a bank to a collateral account by bank’s curator. Thus, a fake collateral account can be authorized by the bank’s curator to satisfy the validity check:
assert_keys_eq!(self.bank, self.collateral.bank);
However, we expect that the bank’s curator is a part of the trust base and must not be compromised.
How Soteria Premium detects the vulnerability?
The Soteria Premium Auto Auditor has a checker that automatically detects untrustful accounts (such as bank in this case) .
The checker uses an algorithm that infers the relationships and constraints among all input accounts. If any input account is not validated (i.e., it does not satisfy the inferred constraints) along any code path, then a potential vulnerability will be flagged.
The following shows a screenshot of a vulnerability detected on the bank account:
account: bank
==============VULNERABLE: UnvalidatedAccount!==============
Found a potential vulnerability at line 95, column 8 in src/lib.rs
The account may not be properly validated and may be untrustful:
89| pub issue_authority: UncheckedAccount<'info>,
90|}
91|
92|#[derive(Accounts)]
93|pub struct BrrrCommon<'info> {
94| /// Information about the bank.
>95| pub bank: Box<Account<'info, Bank>>,
96|
97| /// The [Collateral].
98| pub collateral: Box<Account<'info, Collateral>>,
99|
100| /// Information about the crate.
101| pub crate_token: Box<Account<'info, crate_token::CrateToken>>,
>>>Stack Trace:
The tool also flags the other unvalidated accounts, such as the arrow account:
****************** attack surface #1: sol.print_cash ******************
isOwnerOnly: 0
account: arrow
==============VULNERABLE: UnvalidatedAccount!==============
Found a potential vulnerability at line 58, column 8 in src/lib.rs
The account may not be properly validated and may be untrustful:
52|}
53|
54|/// Accounts related to the Saber pool.
55|#[derive(Accounts)]
56|pub struct SaberSwapAccounts<'info> {
57| /// The [Arrow] used as collateral.
>58| pub arrow: Box<Account<'info, Arrow>>,
59| /// The Saber [SwapInfo] of the collateral.
60| pub saber_swap: Box<Account<'info, SwapInfo>>,
61| /// Mint of the pool.
62| pub pool_mint: Box<Account<'info, Mint>>,
63| /// Reserve of token A.
64| pub reserve_a: Box<Account<'info, TokenAccount>>,
>>>Stack Trace:
The premium version is currently open to a small number of pilot customers. sec3 team has been working hard with pilot customers to release a version to the community as soon as possible.
sec3 Audit
sec3 (formerly Soteria)is founded by leading minds in the fields of blockchain security and software verification.
We are pleased to provide full audit services to high-impact Dapps on Solana. Please visit sec3.dev or email contact@sec3.dev