[REPORT] Kangaroo Coin Flip Exploit - Post Mortem

In the past week a malicious user managed to exploit the Kangaroo Coin Flip contract and steal the house funds utilized to power the coin flip game. The attacker stole approximately 2,473 NEAR from kcfhouse.near. After performing the exploit, the attacker sent 100 NEAR back to the house wallet so the game would continue to operate after the majority of funds were stolen.

At this time, the team has implemented fixes for the exploit and secured the protocol. New risk mitigation procedures are in place so that this problem cannot happen again.

It is important to note that the Kangaroo Coin Flip code was fully audited by a third party firm prior to mainnet launch in February. The firm was regarded as reputable and had received grant funding from the NEAR Foundation and Astro DAO. Our team worked hard to maintain best practices from development, deployment and auditing. Despite our best efforts the Kangaroo Coin Flip code was exploited. This reinforces that no one is completely safe from digital theft. This is one of the main risks to building experimental technology in new platforms like NEAR protocol.

Below is a detailed description of the exploit and the alterations that were performed to the contract to make it secure.

Exploit Explanation
The attacker took advantage of a unique NEAR protocol concept called batch transactions, which allow a user to call multiple functions in the contract within the same transaction, as long as they adhere to gas limits.

This causes the transaction to be entirely atomic - which means, if one of the multiple function calls fail, all function calls get reverted.

In the coin flip contract, there was a way in which the attacker could bet and cause the transaction to fail and thus be reverted only in case they lost. This flaw operates through the following mechanism:

  1. The attacker deposits 5 NEAR into the contract;
  2. The attacker calls the play function
  3. If the attacker wins the coin flip at (2), his stake gets roughly doubled, if he loses, it goes to zero;
  4. The attacker calls the play function again.
    a. If they lost at (2) and their balance is now zero, the contract throws an error, which reverts all previous transactions;
    b. If they won at (2) the new bet is performed normally with 5 NEAR again, if the attacker wins the bet, his balance becomes roughly 15 NEAR, if they lose, it goes back to 5 NEAR;
  5. The attacker withdraws their funds from the contract.

In summary, the possible scenarios were:

  1. The attacker lost the first bet and the transaction got reverted → net result = 0;
  2. The attacker won the first bet and lost the second → net result ≃ - 0.15 NEAR;
  3. The attacker wins both bets → net result ≃ 10 NEAR;

Considering that scenario 1 has a 50% likelihood and scenarios 2 and 3 have a 25% likelihood, the attacker can keep playing indefinitely and lose almost nothing when they lose.

But whenever they win, they’re draining almost 10 NEAR from the contract.

The vulnerability is found in this piece of code:

pub fn play(&mut self, _bet_type: bool, bet_size: U128) -> bool {
        assert!(!self.panic_button, "Panic mode is on, contract has been paused by owner");

        // check that user has credits
        let account_id = env::predecessor_account_id();
        let mut credits = self.credits.get(&account_id).unwrap_or(0);
        assert!(credits > bet_size.0, "no credits to play");
        assert!(bet_size.0 >= self.min_bet, "minimum bet_size is {} yoctonear", self.min_bet);
        assert!(bet_size.0 <= self.max_bet, "maximum bet_size is {} yoctonear", self.max_bet);

        // charge dev and nft fees
        let mut net_bet: u128 = bet_size.0;
        let nft_cut: u128 = (&net_bet * self.nft_fee) / FRACTIONAL_BASE;
        let dev_cut: u128 = (&net_bet * self.dev_fee) / FRACTIONAL_BASE;
        let house_cut: u128 = (&net_bet * self.house_fee) / FRACTIONAL_BASE;
        
        net_bet = net_bet - &nft_cut - &dev_cut - &house_cut;
        self.nft_balance = self.nft_balance + nft_cut;
        self.dev_balance = self.dev_balance + dev_cut;

        // send off credits
        credits = credits - bet_size.0;
        
        let rand: u8 = *env::random_seed().get(0).unwrap();
        let outcome: bool = rand < PROB;
        if outcome {
            let won_value = (net_bet * self.win_multiplier) / FRACTIONAL_BASE;
            credits = credits + won_value;
        }

        self.credits.insert(&account_id, &credits);
        outcome
    }

The assertion that the user has enough balance allows the conditional failing of the call only when the attacker lost a previous bet. The fixed version looks like this:

pub fn play(&mut self, _bet_type: bool, bet_size: U128) -> bool {
        assert!(
            !self.panic_button,
            "Panic mode is on, contract has been paused by owner"
        );

        // check that user has credits
        let account_id = env::predecessor_account_id();
        let mut credits = self.credits.get(&account_id).unwrap_or(0);

        if credits >= bet_size.0 {
            assert!(
                bet_size.0 >= self.min_bet,
                "minimum bet_size is {} yoctonear",
                self.min_bet
            );
            assert!(
                bet_size.0 <= self.max_bet,
                "maximum bet_size is {} yoctonear",
                self.max_bet
            );

            // charge dev and nft fees
            let mut net_bet: u128 = bet_size.0;
            let nft_cut: u128 = (&net_bet * self.nft_fee) / FRACTIONAL_BASE;
            let dev_cut: u128 = (&net_bet * self.dev_fee) / FRACTIONAL_BASE;
            let house_cut: u128 = (&net_bet * self.house_fee) / FRACTIONAL_BASE;

            net_bet = net_bet - &nft_cut - &dev_cut - &house_cut;
            self.nft_balance = self.nft_balance + nft_cut;
            self.dev_balance = self.dev_balance + dev_cut;

            // send off credits
            credits = credits - bet_size.0;

            let rand: u8 = *env::random_seed().get(0).unwrap();
            let mut outcome: bool = rand < PROB;
            if u128::from_be_bytes(
                env::keccak256(&[(env::used_gas() % 256) as u8, rand])[0..16]
                    .try_into()
                    .unwrap(),
            ) % 2
                == 0
            {
                outcome = !outcome;
            }
            if outcome {
                let won_value = (net_bet * self.win_multiplier) / FRACTIONAL_BASE;
                credits = credits + won_value;
            }

            self.credits.insert(&account_id, &credits);
            outcome
        } else {
            false
        }
    }

In this new version the contract will not cause a roll back of previous transactions in case of insufficient balance, which prevents the exploit from being used.

Also, the new version implements a new source of randomness, meaning results of different bets in the same block are guaranteed to be different and totally uncorrelated, making it more difficult for future exploits to be found.

Attacker Details
The attacker appears to have used a “burner wallet” for this transaction. At this time is not evident whether the attacker’s wallet can be tracked in any impactful manner. Regardless, there is a high likelihood that this attack came from someone within the NEAR ecosystem. This is a small ecosystem so all NEAR NFT projects should be on high alert for bad actors.

Link to the NEAR address of the attacker
https://explorer.near.org/accounts/9c2a08cf1d06716bb03c662ed67096ea92cedacf058d50e1dea60ff442cf6180

Example of the batch transaction that the attacker used repeatedly

Batch Transaction
by 9c2a08cf1d06716bb03c662ed67096ea92cedacf058d50e1dea60ff442cf6180
	Called method: 'deposit' in contract: kcfhouse.near
	Called method: 'play' in contract: kcfhouse.near
	Called method: 'play' in contract: kcfhouse.near
	Called method: 'retrieve_credits' in contract: kcfhouse.near```

Risk Mitigation
The Kangaroo Coin Flip code has been fixed to prevent against this exploit.

There is always the possibility of unforseen contract interactions, even after going through a rigorous development and auditing process. We have worked hard to put together a detailed post mortem that can help NEAR developers protect their projects in the future.

For coin flip vaults, we recommend not keeping all eggs in a single basket. Instead, a two wallet approach should be used so the majority of funds are stored out of the contract. A multisig wallet that holds the majority of vault funds will mitigate losses in the case of a contract draining exploit. The multisig wallet can deposit or withdraw funds to the contract wallet as required.

Conclusion
After careful consideration, and with heavy hearts, we have decided to discontinue the operations of Kangaroo Coin Flip effective 8/1/22. The vault in its current state cannot sustain itself and the project does not have funds to replace what has been stolen. Unfortunately, the risk to reward for supporting the coin flip is no longer viable.

That being said, custom NEP-141 coin flips will continue to be offered as a service for ecosystem partners. The NEP-141 coin flips are a fun mini-game utility that add value for many web3 projects. As planned, Classy Kangaroos will receive a portion of token fees from every partner coin flip! NOTE: The NEP-141 coin flip code is not susceptible to the batch exploit.

The Kangaroo Coin Flip code will be made available open source for the NEAR community. A significant amount of funding and development time went into the creation of this code. It is the first coin flip that offers a deposit function which enables repeat flips without without requiring a transaction every time.

Despite the exploit, Kangaroo Coin Flip has accomplished its core intent. A strong community of early evangelists was formed around the Classy Kangaroo NFT collection. The Classy Kangaroos are rewarded greatly for being the foundation to web3 projects we are developing on NEAR. At this point it is known the real value proposition to the Classy Kangaroo collection is the rewards they gain access to.

We are so grateful to the NEAR community for the immense support. This attack will not bring us down. Instead we will learn and improve

Sorry to hear this but I appreciate the openness and honesty. This is an excellent write-up about what happened and how you fixed it. Hopefully this can be a learning experience for any developer who reads this.