Capture The Flag ️Writeups — AwesomWasm 2023 Pt. 2

JCsec
Oak Security
Published in
11 min readSep 26, 2023

Welcome to our official solutions for all the Oak Security CosmWasm CTF challenges! This post is the second part of two, please visit Part 1 if you haven’t done so. Enjoy our writeups for challenges 6 to 10.

Photo by Gabriel Crismariu on Unsplash

Challenge 06: Hofund

Purpose

The main purpose of this contract is to allow anyone to propose themselves as the owner of the contract. Users can vote in favor by sending a governance token. If a proposal with more than a third of the current supply was voted for, the user gets the owner role.

Entry points

  • Propose message:
    - Returns an error if there is an ongoing proposal.
    - Allows users to propose themselves, saving their address to storage to be voted on.
  • ResolveProposal message:
    - Returns an error if the voting window has not finished yet.
    - If the condition to pass the vote is met, the current proposer is assigned to the contract’s owner.
  • OwnerAction message:
    - Privileged entry point that restricts the caller to the owner.
    - Executes an arbitrary CosmosMsg.
  • Receive message:
    - Allows users to send CW20 tokens to vote if Cw20HookMsg::CastVote is attached.

Base scenario

The contract has been instantiated with zero funds.

Winning condition

Demonstrate how a proposer can obtain the owner role without controlling ⅓ of the total supply.

Solution 1

The contract checks if the message sent alongside the tokens is Cw20HookMsg::CastVote and returns an error if the voting window is closed. However, if a different message is received, instead of returning an error, it returns Response::default(). In addition, the amount of votes is not tracked, and the contract’s balance is used for that purpose.

This allows attackers to pass proposals that did not receive the required amount of votes through the means of a flash loan. The following steps should be followed to exploit the issue:

  1. The attacker proposes themselves.
  2. The voting time passes without receiving enough votes for the proposal to be passed.
  3. The attacker issues a transaction before other users call ResolveProposal containing the following calls:
  4. Requests a Flash Loan equal to ⅓ of the total supply.
  5. Sends the loaned tokens to the contract without any additional message.
  6. Calls ResolveProposal. As the contract now has at least ⅓ of the total supply, the proposal is marked as successful, and the attacker address is set as the owner.
  7. The attacker calls OwnerAction to retrieve the transferred loan.
  8. The attacker repays the Flash Loan.

Solution 2

In addition to the above, a second valid solution achieves the challenge’s goal.

The contract does not return the governance tokens sent to it upon a failing proposal. In conjunction with the votes being tracked through the contract’s balance, subsequent proposals need less than ⅓ of the supply to be passed, as the contract already has the tokens of previous proposals.

For example, a random user proposes themselves and only receives ¼ of the existing supply as votes, failing the proposal. Later, the attacker proposes themselves and receives 1/12 of the votes. This should not be successful. However, as the contract already holds ¼ of the supply due to the previous proposal, it is indeed enough to be considered successful and make the attacker the new owner.

Proof of concept

For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-06/src/exploit.rs.

Fix commit

The fix to this challenge requires two separate changes. First, the contract should return an error if the Cw20HookMsg::CastVote is not sent along with the tokens. Secondly, the votes for the current proposal should be tracked and checked to decide if a proposal passes, increasing the value upon handling a successful CastVote and resetting it to zero after resolution. See this commit of the “fixed” branch.

Please note that for simplification purposes, no refund mechanism was implemented. Although needed for a real public-facing contract, it is not required for showcasing the security issues outlined in this challenge that allows to achieve the set goal.

Challenge 07: Tyrfing

Purpose

The main purpose of this contract is to account for the top depositor. The owner can configure the required threshold to become the top depositor.

Entry points

  • Deposit message:
    - Requires the user to deposit DENOM.
    - Increases the user balance.
    - If the balance exceeds the configured THRESHOLD, the user address is saved into storage at TOP_DEPOSITOR, and the THRESHOLD is updated.
  • Withdraw message:
    - Deducts the user balance.
    - Transfers the withdrawn amount of DENOM to the sender.
  • OwnerAction message:
    - A privileged entry point that restricts the caller to the owner.
    - Executes an arbitrary CosmosMsg.
  • UpdateConfig message:
    - A privileged entry point that limits the caller to the owner.
    - Saves a new THRESHOLD on the config storage.

Base scenario

  1. The contract is instantiated.
  2. USER1 and USER2 deposit 10_000 tokens each.
  3. The owner role is assigned to the ADMIN address.

Winning condition

Demonstrate how an unprivileged user can drain all the contract’s funds.

Solution

The same storage key is used for both OWNER and TOP_DEPOSITOR in ctf-07/src/state.rs#L4 and ctf-07/src/contract.rs#L14. Therefore, if any user claims the “top” by depositing more than the current THRESHOLD and the contract modifies the content of the TOP_DEPOSITOR storage, the one OWNER refers to is also modified.

This results in the user becoming the owner of the contract, which in addition could set an arbitrarily large THRESHOLD through the UpdateConfig entry-point so no other user can claim both the top and the ownership. At this point, the attacker can call OwnerAction to transfer all the contract’s funds to their address.

Proof of concept

For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-07/src/exploit.rs

Fix commit

The fix to this challenge is to set distinct storage keys for both affected constants. Please check this commit of the “fixed” branch.

Challenge 08: Gjallarhorn

Purpose

The main purpose of this contract is to implement an open marketplace for an NFT project. Users can sell their NFTs at any price or allow others to offer different NFTs to be traded if the seller chooses so.

Entry points

  • BuyNFT message:
    - Checks if the price of the target NFT has been paid in DENOM to the contract.
    - Transfers the NFT to the buyer.
    - Transfers the funds to the sale owner.
  • NewSale message:
    - Checks if the owner of the offered NFT is the caller.
    - Saves the details of the new sale into storage.
    - The NFT is transferred from the contract.
  • CancelSale message:
    - Checks if the caller is the owner of the target sale.
    - Transfers the NFT to the sales owner.
    - Removes the target sale from storage.
  • NewTrade message:
    - Checks if the owner of the offered NFT is the caller.
    - Checks if the target sale is configured as tradable.
    - Saves the proposed trade into storage.
  • AcceptTrade message:
    - Checks if the caller is the owner of the target sale.
    - Transfers the offered NFT to the owner of the trade.
    - Transfers the traded NFT to the owner of the sale.
    - Removes the trade from storage.
  • CancelTrade message:
    - Checks if the caller owns the target trade.
    - Transfers the traded NFT back to the owner.
    - Removes the trade from storage.

Base scenario

  1. The contract is instantiated.
  2. USER1 and USER2 place new sales of their NFTs; one is open for trade, and the other is not.

Winning condition

Demonstrate how a user can retrieve other users’ NFT for free.

Solution 1

The exec_accept_trade function creates two submessages in lines 257 and 270 that are configured as reply_always, but the success of it is not checked in the reply entry point. In addition, when a user offers a trade to an existing Sale, the contract does not require the NFT to be transferred and only validates that the trader is the current owner.

This allows a malicious user to steal NFTs following these steps:

  1. The attacker creates a trade offering an NFT they own. At this point, the contract checks that it is authorized as the operator of the NFT.
  2. The attacker removes the contract’s address from the NFT’s approved operators.
  3. When the owner of the target sale accepts the trade, the submessage created in line 270 will fail. However, as it has been declared to reply_always, this error will not revert the transaction.
  4. As the reply handling function does not check the submessage status, the transfer silently fails.
  5. As a result, the attacker owns both the NFT of the target sale and the NFT offered for the trade.

Proof of concept

For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-08/src/exploit.rs.

Fix commit

To fix this challenge, the affected submessages should be modified to user reply_on_success instead.

Please check this commit of the “fixed” branch.

Solution 2

After a successful trade, the accepted trade is removed from the TRADES storage, but the same is not done for the finished sale from SALES. The smart contract keeps considering that an already traded NFT is owned by its previous owner. This will allow the original owner to steal the already traded NFT if it is ever offered in trade again in the future.

The scenario is described below

  1. The attacker creates a new tradeable sale of NFT1
  2. The victim creates a trade targeting the mentioned sale
  3. The attacker accepts the trade, transferring the ownership of NFT1 to the victim and receiving the traded NFT themselves
  4. At this point, the sale is still considered valid in storage.
  5. Now the victim user wants to trade NFT1. For that, they create a trade targeting any other available sale.
  6. At this point, the contract is approved as an operator of NFT1.
  7. The attacker cancels their initial sale. As a result, the contract transfers the ownership of NFT1 from the victim to the attacker without the victim being aware.

Feel free to check out a new challenge of the Oak Security Dojo showcasing this scenario.

Solution 2 remediation

The exec_accept_trade function should delete the target sale from the SALES storage upon a successful resolution, as performed at the end of the exec_buy function.

Challenge 09: Brisingamen

Purpose

The main purpose of this contract is to enable users to accrue staking rewards, with the reward structure determined by both the global and user indexes. Users can stake their deposits in exchange for potential rewards, which are evenly distributed via the update_rewards function.

Entry points

  • IncreaseReward message:
    - The entry point for the contract owner to distribute rewards.
    - Deposited funds are used to increase the global index.
  • Deposit message:
    - The entry point for users to deposit funds.
    - Computes rewards and updates user index.
    - Increases the user’s staked amount.
    - Increases the total staked amount.
  • Withdraw message:
    - The entry point for users to withdraw funds.
    - Computes rewards and updates user index.
    - Decreases the user’s staked amount.
    - Decreases the total staked amount.
    - Sends funds to the user.
  • ClaimRewards message:
    - The entry point for users to claim rewards.
    - Computes rewards and updates user index.
    - Resets the user’s pending rewards.
    - Sends rewards to the user.

Base scenario

  1. A user will deposit funds into the contract, see /ctf-09/src/integration_tests.rs#L99-L106.
  2. The contract owner increases the rewards of the contract, see /ctf-09/src/integration_tests.rs#L108-L115

Winning condition

Demonstrate how a user can earn an unfair amount of rewards in relation to other users.

Solution

When computing user rewards, the update_rewards function returns early if a user’s staked amount is zero. This approach will skip the reward calculation and not update the user index to the global index; see /ctf-09/src/contract.rs#L179-L188.

It is important to update the user index to the global index in order to distribute rewards correctly. The global index denotes the latest reward index, while the user index represents the previous state of the global index and typically has a smaller value. User rewards are determined by multiplying the difference between these indices by the current staked amount.

For example, when new users deposit for the first time, their user index is set to match the global index. This ensures that rewards are only distributed to users that stake before the reward accumulation period. Users who deposit after that will only receive rewards that accumulate in the future, see /ctf-09/src/contract.rs#L88.

The flaw in this implementation arises when existing users make a full withdrawal and then redeposit. Due to the early return statement, their user index is not updated to the global index. If there is an increase in the global index, the function will compute rewards for the user as it thinks they have staked funds before the reward accrual periods.

Consequently, the user will receive rewards for periods they did not stake, allowing them to withdraw rewards that belong to other users.

Proof of concept

For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-09/src/exploit.rs.

Fix commit

The fix to this challenge is to remove the early return statement. Please check this commit of the “fixed” branch.

Challenge 10: Mistilteinn

Purpose

The main purpose of this contract is to enable whitelisted users to mint a certain amount of NFTs, with the minting limit per user controlled by the mint_per_user value. If the user exceeds the minting limit, a MaxLimitExceeded error will occur and revert the transaction.

Entry points

  • Mint message:
    - The entry point for users to mint NFTs.
    - Ensures the caller is a whitelisted user.
    - Ensures the user has not exceeded the mint_per_user limit.
    - Increases the number of total tokens.
    - Mints an NFT and sends it to the user.

Base scenario

  1. The contract is instantiated with the mint_per_user limit as 3; see /ctf-10/src/integration_tests.rs#L43.
  2. The whitelisted users are USER1, USER2, and USER3; see /ctf-10/src/integration_tests.rs#L44.

Winning condition

Demonstrate how a whitelisted user can bypass the mint_per_user limitation.

Solution 1

When validating the mint_per_user limit, a Tokens query message is dispatched to the NFT contract to retrieve the number of NFTs owned by the user. If the number of tokens owned equals or exceeds the limit, the mint function fails and reverts with an error, see /ctf-10/src/contract.rs#L95-L107.

The flaw in this implementation lies in the validation process, which is based on the number of NFTs the user owns at the time of execution. If a user transfers all their tokens to other accounts after reaching the mint limit, the function would perceive the user as having minted zero NFTs.

Consequently, this allows whitelisted users to mint a number of NFTs beyond the configured mint_per_user limit.

Proof of concept

For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-10/src/exploit.rs.

Fix commit

The fix to this challenge is to track the number of NFTs minted by each user within the contract’s storage.

Please check this commit of the “fixed” branch.

Solution 2

When minting the NFT, a MintPerUser query message is dispatched to retrieve the number of NFTs minted by the user previously to ensure the mint_per_user limit has not been exceeded; see /ctf-10/src/contract.rs#L95-L107.

Note that the limit parameter is provided as None. This means the query would retrieve a maximum of 10 NFTs owned by the user because it unwraps to the DEFAULT_LIMIT in the CW721 query, see /cw721-base/src/query.rs#L188.

This is problematic if the owner instantiates the mint_per_user limit to be higher than the DEFAULT_LIMIT. In this case, the minted_nfts.len() value will always return as 10, although the mint_per_user limit was exceeded. The reason for this is that w721QueryMsg::Tokens will take a maximum limit of 10 NFTs and return to the caller while ignoring the rest, see /cw721-base/src/query.rs#L192-L199.

Feel free to check out a new challenge of the Oak Security Dojo showcasing this scenario.

Remediation

When validating the mint_per_user limit, the mint function should load the user’s MINT_PER_USER storage without the max limit to ensure the configured limit cannot be exceeded.

Hope you enjoyed our writeups! follow us here on Medium and Twitter to be updated with our content.

If you are looking for support for your project’s security, please contact us and we can schedule a call to discuss your needs.

Stay in touch: Website | Twitter | LinkedIn

This content was prepared by the two Oak Security auditors who designed the CTF challenges: Richie and JC.

--

--

JCsec
Oak Security

Smart Contract security auditor specialized in CosmWasm. Follow me on Twitter @jcsec_audits and Github https://github.com/jcsec-security