Capture The Flag ️Writeups — AwesomWasm 2023 Pt. 1
Welcome to our official solutions for all the Oak Security CosmWasm CTF challenges! This post will be the first part of two, enjoy our writeups for challenges 1 to 5.
Challenge 01: Mjolnir
Purpose
The main purpose of this contract is to allow users to lock their funds for a predetermined period. Once the period elapses, users can then unlock them and retrieve their funds.
Entry points
Deposit
message: allows users to deposit funds to create a lockup.Withdraw
message:
- Allows users to withdraw funds from their lockups.
- Responsible for ensuring the lockup period has ended and the lockup ID belongs to the caller.
Base scenario
- The contract is instantiated with initial funds, see /ctf-01/src/integration_tests.rs#L40-L45
- A user will deposit funds into the contract, see /ctf-01/src/integration_tests.rs#L50-L59
Winning condition
Demonstrate how an unprivileged user can drain all funds inside the contract.
Solution
One of the features of the Withdraw
message is that it allows users to withdraw multiple lockup ids
within a single transaction. This is facilitated by the ids parameter, a vector composed of unsigned integers representing the lockup ID.
However, the function does not verify the absence of duplicate lockup IDs in the parameter. If a user includes the same lockup ID multiple times in the ids
parameter, the function will inaccurately calculate the locked value more than once, leading to an overpayment to the user.
Consequently, an attacker can steal funds from the contract by performing a withdrawal with a duplicated lockup ID in the ids
parameter, causing a loss of funds to other users.
Proof of concept
For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-01/src/exploit.rs.
Fix commit
The withdrawal function is fixed by ensuring no duplicate lockup IDs exist within the provided IDs vector. Please check this commit of the “fixed” branch.
Challenge 02: Gungnir
Purpose
The main purpose of this contract is to allow users to deposit funds and stake them in exchange for voting power. The user can unstake them and withdraw the funds after a predefined time.
Entry points
Deposit
message:
- Allows users to deposit funds, increasing theirtotal_tokens
balance.Withdraw
message:
- Allows users to withdraw funds, decreasing theirtotal_tokens
balance.
- Responsible for ensuring the withdrawal amount does not exceed the staked voting power.Stake
message:
- Allows users to stake deposited funds in return for voting power.
- Voting power received is directly proportional to the staked token amount.
- Once staked, tokens can only be unstaked after the end of theLOCK_PERIOD
.Unstake
Message:
- Allows users to unstake tokens, reducing their voting power.
- Responsible for ensuring the tokens can only be unstaked after theLOCK_PERIOD
has elapsed.
Base scenario
The contract is instantiated with zero funds, see /ctf-02/src/integration_tests.rs#L23-L41.
Winning condition
Demonstrate how an unprivileged user can achieve an unfair amount of voting power.
Solution
When the user unstakes their tokens, their voting power will be deducted by the value specified in the unlock_amount
parameter. See /ctf-02/src/contract.rs#L137 and /ctf-02/src/state.rs#L10-L11.
In the state.rs
file, the voting_power
variable is configured as u128
, which is Rust’s native unsigned integer. If the user’s voting power is less than the unlock_amount
value, an overflow error will occur and revert the transaction when overflow-checks
are enabled.
However, overflow-checks
are disabled intentionally for this challenge, as seen in the Cargo.toml
file. This implies that Rust will not check for overflows during arithmetic operations and return the overflowed value.
Consequently, an attacker can use the Unstake
message with an unlock_amount
larger than their actual voting power. This would cause the subtraction to overflow, resulting in their voting power being erroneously large without requiring them to stake an equal amount of tokens.
Proof of concept
The test case needs to be executed with release mode to reproduce the issue correctly.
cargo test — release — exploit::tests::exploit — exact — nocapture
Without specifying the release mode explicitly, the test case defaults to debug mode. Since debug mode captures all overflow errors regardless of the overflow-checks configuration, the test case will not work properly.
By specifying the mode to release, we reproduce how the contract will behave in the mainnet, as deployed contracts will be compiled in release mode instead of debug mode.
For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-02/src/exploit.rs.
Fix commit
The fix to this challenge is to enable overflow-checks
in the Cargo.toml
file. Alternatively, it is possible to prevent this issue by using checked_sub
(reference) to return an error if an overflow occurs.
Please check this commit of the “fixed” branch.
Challenge 03: Laevateinn
Purpose
The main purpose of the contracts is to allow users to request a flash loan for arbitrage purposes. Users are expected to interact with the proxy contract to request flash loans, which calls the underlying flash loan contract that handles most of the core logic.
Entry points
Proxy contract:
RequestFlashLoan
message:
- The entry point for users to request a flash loan.
- Therecipient
parameter indicates the contract to be executed with the flash loaned funds.
- Themsg
parameter represents the message to execute.
- Several validations are performed, such as: 1) ensures the recipient is not the flash loan contract address, 2) ensures the proxy contract is authorized to call the flash loan contract and 3) ensures the borrowed funds are returned in the same transaction.
Flash loan contract:
SetProxyAddr
message:
- Sets the proxy contract address.
- Ensures the caller is the current contract owner.FlashLoan
message:
- The entry point for the proxy contract to initiate a flash loan.
- Performs a state check to store flash loan progress.
- Sends all funds to the proxy contract address.SettleLoan
message:
- The entry point for the proxy contract to complete a flash loan.
- Performs a state check to load flash loan progress.
- Ensures the amount received is not lower than the requested amount.WithdrawFunds
message:
- Sends all funds inside the contract to the recipient’s address.
- Ensures the caller is the current contract owner.TransferOwner
message:
- Transfers the contract owner to thenew_owner
address.
- Ensures the caller is the current contract owner or the proxy contract address.
Base scenario
- The proxy contract is instantiated with the flash loan contract address, see /ctf-03/contracts/proxy/src/integration_tests.rs#L64-L77.
- The flash loan contract is instantiated with initial funds, see /ctf-03/contracts/proxy/src/integration_tests.rs#L92-L93.
- The proxy contract address is configured to the flash loan contract using the
SetProxyAddr
message, see /ctf-03/contracts/proxy/src/integration_tests.rs#L95-L104
Winning condition
Demonstrate how an unprivileged user can drain all funds from the flash loan contract.
Solution
When requesting a flash loan, the CallToFlashLoan
error will revert the transaction if the recipient address is the flash loan contract address. This validation is important to prevent the user from controlling the proxy contract to execute authenticated messages in the flash loan contract, such as the TransferOwner
message. See /ctf-03/contracts/proxy/src/contract.rs#L58-L61.
Notice that when the proxy contract is instantiated, the flash loan contract address is validated with the addr_validate
function. This ensures that the flash loan contract address is in lowercase format.
An error will occur if an uppercase address is validated with the addr_validate
function. This is demonstrated in the test case below:
#[test]
fn test_address_validation() {
use cosmwasm_std::testing::mock_dependencies;
use cosmwasm_std::Api;
let deps = mock_dependencies();
let lowercase_addr = "cosmos100000v3fpv4qg2a9ea6sj70gykxpt63wgjen2p";
// no error
deps.api.addr_validate(lowercase_addr).unwrap();
let uppercase_addr = lowercase_addr.to_ascii_uppercase();
// error
deps.api.addr_validate(&uppercase_addr).unwrap();
}
The lowercase validation is mainly due to the Bech32 address format used by the Cosmos blockchain. Bech32 format treats both uppercase and lowercase formats as the same address.
As the flash loan contract address is validated with the addr_validate
function, this means the address format will be lowercase. By specifying the recipient
parameter as the uppercase flash loan contract address, we can bypass the validation and control the proxy contract to execute authenticated messages in the flash loan contract.
To drain all funds in the flash loan contract, we can specify the msg
parameter as TransferOwner
message with the new_owner
address set to our address. Once we obtain ownership of the flash loan contract, we call the WithdrawFunds
message to complete our exploit flow.
Proof of concept
For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-03/contracts/proxy/src/exploit.rs.
Fix commit
The fix to this challenge is to validate the recipient’s address to ensure uppercase addresses are not allowed.
Please check this commit of the “fixed” branch.
Challenge 04: Gram
Purpose
The main purpose of this contract is to allow users to deposit funds and receive shares in return. Conversely, users can redeem their shares for the underlying assets. After yields are received through simple transfers to the contract from third parties, the total number of underlying assets increases while total shares remain constant, enabling users to redeem shares for their initial deposit with distributed rewards.
Entry points
Mint
message:
- The entry point for users to mint shares by depositing funds.
- Ensures the mint amount is not zero.
- Increases the total supply value.
- Increases the user’s shares balance.Burn
message:
- The entry point for users to burn shares in return for funds.
- Ensures the asset amount to return is not zero.
- Decreases the total supply value.
- Decreases the user’s shares balance.
Base scenario
The contract is instantiated with zero funds, see /ctf-04/src/integration_tests.rs#L24-L42.
Winning condition
Demonstrate how an unprivileged user can withdraw more funds than deposited.
Solution
When there are no deposits in the contract, the shares minted will be proportional to the deposit amount as shown in lines 58 and 59. This is problematic as it allows the attacker to carry out a “share inflation attack”.
The attack requires several circumstances to succeed. Firstly, the attacker needs to be the first depositor and deposit a small amount of funds in exchange for a share. This step allows the attacker to influence the mint amount later.
When a legitimate user deposits next, the attacker will front-run their transaction to send funds directly to the contract. The attacker’s transaction will be processed first, and the contract’s underlying asset balance will increase.
The mint amount calculation will have been manipulated when the user’s transaction gets processed. As the attacker artificially increases the total_assets
value, the resulting mint_amount
value will decrease.
Consequently, users will receive less funds than their deposit amount after burning all their shares, causing a loss of funds scenario.
Proof of concept
For the proof of concept that demonstrates the attack scenario mentioned above see /ctf-04/src/exploit.rs
Fix commit
The fix to this challenge is to implement virtual assets protection, similar to the ERC4626 approach.
Please check this commit of the “fixed” branch.
Challenge 05: Draupnir
Purpose
The main purpose of this contract is to allow users to deposit and withdraw their tokens, which will be internally accounted for. The vault’s owner can perform arbitrary actions through the OwnerAction
entry point. In addition, a two-step address transfer is implemented for the owner role.
Entry points
Deposit
message:
- Requires the user to depositDENOM
.
- Increases the user balance.Withdraw
message:
- Deducts the user balance.
- Transfers the withdrawn amount ofDENOM
to the sender.OwnerAction
message:
- Privileged entry point that restricts the caller to the owner.
- Executes an arbitraryCosmosMsg
.ProposeNewOwner
message:
- A privileged entry point that limits the caller to theowner
.
- Saves a proposed validated address into the storage.AcceptOwnership
message:
- A privileged entry point that restricts the caller toproposed_owner
in storage.
- Sets the proposed owner as the currentowner
.DropOwnershipProposal
message:
- Privileged entry point that restricts the caller to theowner
.
- Clears the contents of the currentproposed_owner
from storage.
Base scenario
- The contract has been instantiated with zero funds.
USER1
andUSER2
deposit10_000
tokens each.- The
owner
role is assigned to theADMIN
address.
Winning condition
Demonstrate how an unprivileged user can drain all the funds inside the contract.
Solution
The accept_owner
function of the contract checks if the caller’s address is that of the proposed_owner
in line 129
. If not, it should return an Unauthorized
error. However, instead of returning the error, it just declares the ContractError
struct’s element Unauthorized
, resulting in a statement without effect that allows any attacker to bypass the condition and claim the contract’s ownership.
At this point, the attacker can call the OwnerAction
message 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-05/src/exploit.rs
Fix commit
The fix to this challenge is to change line 130
such that it returns the error through a return
statement.
Please check this commit of the “fixed” branch.
The second part is ready and will be released soon, stay tuned!
If you are looking for support for your project’s security, please get in touch, 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: JC and Richie.