Capture The Flag ️Writeups — AwesomWasm 2023 Pt. 2
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.
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 arbitraryCosmosMsg
.Receive
message:
- Allows users to send CW20 tokens to vote ifCw20HookMsg::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:
- The attacker proposes themselves.
- The voting time passes without receiving enough votes for the proposal to be passed.
- The attacker issues a transaction before other users call
ResolveProposal
containing the following calls: - Requests a Flash Loan equal to ⅓ of the total supply.
- Sends the loaned tokens to the contract without any additional message.
- 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. - The attacker calls
OwnerAction
to retrieve the transferred loan. - 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 depositDENOM
.
- Increases the user balance.
- If the balance exceeds the configuredTHRESHOLD
, the user address is saved into storage atTOP_DEPOSITOR
, and theTHRESHOLD
is updated.Withdraw
message:
- Deducts the user balance.
- Transfers the withdrawn amount ofDENOM
to the sender.OwnerAction
message:
- A privileged entry point that restricts the caller to the owner.
- Executes an arbitraryCosmosMsg
.UpdateConfig
message:
- A privileged entry point that limits the caller to the owner.
- Saves a newTHRESHOLD
on the config storage.
Base scenario
- The contract is instantiated.
USER1
andUSER2
deposit10_000
tokens each.- 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 inDENOM
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
- The contract is instantiated.
USER1
andUSER2
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:
- 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.
- The attacker removes the contract’s address from the NFT’s approved operators.
- 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. - As the reply handling function does not check the submessage status, the transfer silently fails.
- 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
- The attacker creates a new tradeable sale of NFT1
- The victim creates a trade targeting the mentioned sale
- The attacker accepts the trade, transferring the ownership of NFT1 to the victim and receiving the traded NFT themselves
- At this point, the sale is still considered valid in storage.
- Now the victim user wants to trade NFT1. For that, they create a trade targeting any other available sale.
- At this point, the contract is approved as an operator of NFT1.
- 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
- A user will deposit funds into the contract, see /ctf-09/src/integration_tests.rs#L99-L106.
- 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 themint_per_user
limit.
- Increases the number of total tokens.
- Mints an NFT and sends it to the user.
Base scenario
- The contract is instantiated with the
mint_per_user
limit as 3; see /ctf-10/src/integration_tests.rs#L43. - The whitelisted users are
USER1
,USER2
, andUSER3
; 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.