‹ Back to blog

deception

The goal is just to flip the target’s public solved value to true. The supplied Solidity even appears to hand us the password. With a challenge called deception, that was suspiciously generous.

The setup deploys one target and considers the instance solved when TARGET.solved() returns true.

contract Setup {
    deception public immutable TARGET;

    constructor() payable {
        TARGET = new deception();
    }

    function isSolved() public view returns (bool) {
        return TARGET.solved();
    }
}

The target stores a private owner, exposes a protected password function and accepts a string whose Keccak-256 hash matches a hard-coded value.

contract deception {
    address private owner;
    bool public solved;

    constructor() {
        owner = msg.sender;
        solved = false;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner can access");
        _;
    }

    function password() onlyOwner public view returns (string memory) {
        return "secret";
    }

    function solve(string memory secret) public {
        require(
            keccak256(abi.encodePacked(secret)) ==
            0x65462b0520ef7d3df61b9992ed3bea0c56ead753be7c8b3614e0ce01e4cac41b,
            "invalid"
        );
        solved = true;
    }
}

Hashing the visible password produces exactly the constant from solve(), so I first tried the obvious transaction.

> keccak256(abi.encodePacked("secret"))
0x65462b0520ef7d3df61b9992ed3bea0c56ead753be7c8b3614e0ce01e4cac41b

$ cast send TARGET "solve(string)" "secret" ...
Error: execution reverted: invalid

That contradiction is the real challenge. The transaction is reaching a contract whose behavior does not match the published source. Instead of trusting the file, I queried the deployed contract. password() is restricted to the owner, but the owner is the Setup contract that created the target. For an eth_call we can set the simulated sender with --from, so no private key for the Setup address is needed.

$ cast call TARGET "password()(string)" \
    --rpc-url RPC \
    --from SETUP

xyzabc

The live contract returns xyzabc, not secret. Sending the value reported by the deployed bytecode succeeds.

$ cast send TARGET "solve(string)" "xyzabc" \
    --rpc-url RPC --private-key PRIVATE_KEY

$ cast call SETUP "isSolved()(bool)" --rpc-url RPC
true

After asking the challenge service for the flag we get:

crew{d0nt_tru5t_wh4t_y0u_s3e_4s5_50urc3!}

The supplied file was a decoy. The chain never cared what it said; asking the deployed contract from the owner’s address was the entire solve.