Skyward Finance Incident Analysis

Article realeased on Nov 11, 2022


This article analyzes the recent incident case of Skyward Finance.
In this post, we analyze the incident case on the NEAR protocol.
The analysis process until finding the cause of the vulnerability goes into detail.

Table of contents

Transaction Process Analysis
Vulnerability Analysis


Recently, a vulnerability was discovered from the contract of Skyward Finance, which was operated for fair token distribution and price discovery on the NEAR Protocol, resulting in a sudden change in the value of the token.
We can check the price chart of Skyward Finance on this page. If we check the chart based on 7 days from the current time, 2022-11-05, you can check whether the chart has plunged as follows.
Approximately, 1M$ of NEAR tokens have been stolen, and it is determined that an analysis of the root cause is necessary, so this article is written.

Transaction Process Analysis

If you check Skyward Finance’s official Twitter, the attacker’s transaction identified by Skyward Finance is as follows.
If you check the receiver that receives the token in the transaction, you can see that the following user acted as an attacker.
If each process and process of the transaction performed by the attacker is checked, you can find the operations in the following way on below.
1. Attacker receives enough tokens to exploit 2. Attacker swaps their owned tokens to meta-token.near and token.skyward.near 3. Attacker registers wrap.near and token.skyward.near to destination token into skyward.near 4. Attacker transfers 130000000000000000000 amounts of tokens for AccountDeposit to token.skyward.near 5. Attacker redeems tokens from contract with redeem_skyward function 6. Attacker withdraws 139939556466618377392020312024 amounts of tokens from contract ...
If you look at a part of the transaction performed by the attacker, a suspicious part is confirmed, and it can be seen that the amount actually withdrawn after 13000000000000000000000 of tokens was 139939556466618377392020312024.
That is, the root cause of the vulnerability is expected to be in redeem_skyward, and when the redeem_skyward function is actually called, the following abnormal data is confirmed.

Vulnerability Analysis

Let’s check them one by one.
The following methods were used by the attacker.
near_deposit storage_deposit ft_transfer_call register_token redeem_skyward withdraw_token
Let’s analyze each function in detail to find the cause of the vulnerability one by one.
#[payable] pub fn near_deposit(&mut self) { let mut amount = env::attached_deposit(); assert!(amount > 0, "Requires positive attached deposit"); let account_id = env::predecessor_account_id(); if !self.ft.accounts.contains_key(&account_id) { // Not registered, register if enough $NEAR has been attached. // Subtract registration amount from the account balance. assert!( amount >= self.ft.storage_balance_bounds().min.0, "ERR_DEPOSIT_TOO_SMALL" ); self.ft.internal_register_account(&account_id); amount -= self.ft.storage_balance_bounds().min.0; } self.ft.internal_deposit(&account_id, amount); log!("Deposit {} NEAR to {}", amount, account_id); }
The implementation of the near_deposit function is as follows.
Literally, deposit as many NEAR tokens as needed based on the received amount (the basic unit is yocto).
pub(crate) fn internal_deposit(&mut self) -> u128 { let account_id = env::predecessor_account_id(); let mut account = self.internal_get_account(&account_id); let amount = env::attached_deposit(); account.unstaked += amount; self.internal_save_account(&account_id, &account); self.last_total_balance += amount; env::log( format!( "@{} deposited {}. New unstaked balance is {}", account_id, amount, account.unstaked ) .as_bytes(), ); amount }
By default, the internal function used for depositing is the internal_deposit function, and since deposits for tokens are basically non-staking tokens, record the funds to be deposited in account.
The two near_deposit transactions executed initially are steps to secure funds for the attack, and the attacker initially deposits 1 near for test purposes, and then deposits 500 near tokens after confirming normal operation.
After that, the amount of 500000000000000000000000000 is delivered to v2.ref-finance.near using ft_transfer_call. At this time, the NEAR token is
499983962554776324136341917 wrap.near is exchanged for 136091546062887414720 with token.skyward.near The remaining 16037445223675863658083 wrap.near is converted to 15377170193963823408346552 meta-token.near. 15377170193963823408346552 meta-token.near converted to 4406509057498642 token.skyward.near
The necessary tokens are exchanged through the following process, and the tokens delivered to token.skyward.near are used as funds for subsequent attacks.
Afterwards, the attacker fails several ft_transfer_calls, because wrap.near and token.skyward.near on skyward.near are not registered as target destination tokens. Therefore, the attacker registered skyward.near and wrap.near in the target list.
After registration through this process, the attacker performs a request to transfer funds once again.
fn ft_on_transfer( &mut self, sender_id: ValidAccountId, amount: U128, msg: String, ) -> PromiseOrValue<U128> { let args: FtOnTransferArgs = serde_json::from_str(&msg).expect(errors::FAILED_TO_PARSE_FT_ON_TRANSFER_MSG); let token_account_id = env::predecessor_account_id(); match args { FtOnTransferArgs::AccountDeposit => { let mut account = self.internal_unwrap_account(sender_id.as_ref()); account.internal_token_deposit(&token_account_id, amount.0); } FtOnTransferArgs::DonateToTreasury => { let initial_storage_usage = env::storage_usage(); self.treasury.internal_donate(&token_account_id, amount.0); assert_eq!( initial_storage_usage, env::storage_usage(), "{}", errors::UNREGISTERED_TREASURY_TOKEN ); } } PromiseOrValue::Value(0.into()) }
ft_on_transfer receives and performs this operation, and since it is an AccountDeposit command, internal_token_deposit function is called internally to deposit funds.
pub fn internal_token_deposit(&mut self, token_account_id: &TokenAccountId, amount: Balance) { let balance = self .balances .get(&token_account_id) .expect(errors::TOKEN_NOT_REGISTERED); let new_balance = balance.checked_add(amount).expect(errors::BALANCE_OVERFLOW); self.balances.insert(token_account_id, &new_balance); }
internal_token_deposit simply performs a function to update the balance and exits.
At this point, the attacker has deposited a token with a value of 1300000000000000000000 into his account in the skyward.near contract.
Afterwards, redeem_skyward is executed, and if you look at the passed argument, you can see that the argument is passed as follows.
{ "skyward_amount": "130000000000000000000", "token_account_ids": [ "wrap.near", "wrap.near", "wrap.near", "wrap.near", "wrap.near", "wrap.near", ... ] }
You can see that wrap.near is being called hundreds of times.
#[payable] pub fn redeem_skyward( &mut self, skyward_amount: WrappedBalance, token_account_ids: Vec<ValidAccountId>, ) { assert_one_yocto(); let skyward_amount: Balance = skyward_amount.into(); assert!(skyward_amount > 0, "{}", errors::ZERO_SKYWARD); let account_id = env::predecessor_account_id(); let mut account = self.internal_unwrap_account(&account_id); account.internal_token_withdraw(&self.treasury.skyward_token_id, skyward_amount); let numerator = U256::from(skyward_amount); let denominator = U256::from(self.get_skyward_circulating_supply().0); self.treasury.skyward_burned_amount += skyward_amount; for token_account_id in token_account_ids { let treasury_balance = self .treasury .balances .get(token_account_id.as_ref()) .expect(errors::TOKEN_NOT_REGISTERED); let amount = (U256::from(treasury_balance) * numerator / denominator).as_u128(); if amount > 0 { let new_balance = treasury_balance .checked_sub(amount) .expect(errors::NOT_ENOUGH_BALANCE); self.treasury .balances .insert(token_account_id.as_ref(), &new_balance); account.internal_token_deposit(token_account_id.as_ref(), amount); } } self.accounts.insert(&account_id, &account.into()); }
This happens because there is no duplicated element check or threshold validation on the vector.
for token_account_id in token_account_ids
Through the loop, the redeem command for wrap.near is repeatedly executed, and as a result, an amount of balance that should not actually be accumulated in the attacker’s account is excessively accumulated through wrap.near
For this reason, 139939556466618377392020312024, a very larger amount of funds than the previously deposited funds, was withdrawn from the contract, and by repeating this process, the attacker was able to steal a very large amount of funds through Skyward Finance.
KALOS is a flagship service of HAECHI LABS, the leader of the global blockchain industry. We bring together the best Web2 and Web3 experts. Security Researchers with expertise in cryptography, leaders of the global best hacker team, and blockchain/smart contract experts are responsible for securing your Web3 service.
We have secured over $60b worth of crypto assets across 400+ global crypto projects — L1/L2 projects, defi protocols, P2E games, and bridges — notably 1inch, SushiSwap, Badger DAO, SuperRare, Klaytn and Chainsafe. KALOS is the only blockchain technology company selected for the Samsung Electronics Startup Incubation Program in recognition of our expertise. We have also received technology grants from the Ethereum Foundation and Ethereum Community Fund.
Secure your smart contracts with KALOS.
Email: audit@kalos.xyz
Official website: https://kalos.xyz