An analysis of a couple Ethereum honeypot contracts

Josep Sanjuas
Coinmonks
Published in
8 min readMay 15, 2018

--

Etherscan is an Ethereum blockchain explorer that, besides other features, allows developers to submit the code of the smart contracts they deploy. The main benefit of this feature is that it allows users to check what contracts do by reading their source code. Etherscan makes sure that the code matches the smart contract as deployed.

The list of verified contracts is long. As of this writing, Etherscan offers the source code for 26055 contracts, which can be browsed here.

On a lazy Sunday afternoon I decided to casually browse it to see what kind of contracts people were running and get a sense of what people use the blockchain for, and how well written and secure these contracts are. Most contracts I found implemented tokens, crowdsales, multi-signature wallets, ponzis, and.. honeypots!

Honeypot contracts are the most interesting findings to me. Such contracts hold ether, and pretend to do so insecurely. In short, they are scam contracts that try to fool you into thinking you can steal the ether they hold, while in fact all you can do is lose ether.

A common pattern they follow is, in order to retrieve the ether they hold, you must send them some ether of your own first. Of course, if you try that, you’re in for a nasty surprise: the smart contract eats up your ether, and you find out that the smart contract does not do what you thought it did.

In this post I will analyze a couple honeypot contracts I came across, and explain what they seem to do, but really do.

The not-really-insecure non-lottery

The first contract I will go through implements a lottery that, apparently, is horribly insecure and easy to steal from with a guaranteed win. I have come across several of these. The last instance I found is deployed at address 0x8685631276cfcf17a973d92f6dc11645e5158c0c, and its source code can be read here. I am copying the code below for convenience. Can you spot the bait? Can you tell why, if you try to exploit it, you will actually lose ether?

pragma solidity ^0.4.23;// CryptoRoulette
//
// Guess the number secretly stored in the blockchain and win the whole contract balance!
// A new number is randomly chosen after each try.
//
// To play, call the play() method with the guessed number (1-16). Bet price: 0.2 ether
contract CryptoRoulette {
uint256 private secretNumber;
uint256 public lastPlayed;
uint256 public betPrice = 0.001 ether;
address public ownerAddr;
struct Game {
address player;
uint256 number;
}
Game[] public gamesPlayed;
constructor() public {
ownerAddr = msg.sender;
shuffle();
}
function shuffle() internal {
// randomly set secretNumber with a value between 1 and 10
secretNumber = 6;
}
function play(uint256 number) payable public {
require(msg.value >= betPrice && number <= 10);
Game game;
game.player = msg.sender;
game.number = number;
gamesPlayed.push(game);
if (number == secretNumber) {
// win!
msg.sender.transfer(this.balance);
}
//shuffle();
lastPlayed = now;
}
function kill() public {
if (msg.sender == ownerAddr && now > lastPlayed + 6 hours) {
suicide(msg.sender);
}
}
function() public payable { }
}

It’s easy to tell that the shuffle() method sets secretNumber to 6. Hence, if you call play(6)and send it 0.001 ether, you will always win your ether plus whatever the balance of the contract is, namely 0.015 ether. Easy money, right? Wrong.

What’s the trick? Look closely at how play() is implemented. It declares a variable Game game, but does not initialize it. It will therefore default to a pointer to slot zero of the contract’s storage space. Then, it stores your address in its first member, storage slot 0, and the submitted number in the second one, that maps to storage slot 1. So, in practice, this will end up overwriting the contract's secretNumber with the attacker account’s address, and lastPlayed with the number submitted.

Then, it will compare secretNumber, which is now your account’s address, with the number you submitted. Since you can only submit numbers smaller than 10, you can only win if your account’s address is within the range 0x0 to 0x0a. (Don’t bother trying to bruteforce-search for one account in that small range! Simply unfeasible.)

So, the comparison will fail, and the contract will keep your ether. Of course, the attacker can at any time call kill() to retrieve the ether.

The not-really-insecure non-riddle

This is another fun one. It had me scratching my head for a while. However, there is a huge giveaway that the contract is up to something nasty right away. But let’s not get ahead of ourselves.

Here is its code. Can you spot the supposed vulnerability? And, can you tell why an exploit won’t work? And what is the giveaway I was talking about?

contract G_GAME
{
function Play(string _response)
external
payable
{
require(msg.sender == tx.origin);
if(responseHash == keccak256(_response) && msg.value>1 ether)
{
msg.sender.transfer(this.balance);
}
}

string public question;
address questionSender;
bytes32 responseHash;

function StartGame(string _question,string _response)
public
payable
{
if(responseHash==0x0)
{
responseHash = keccak256(_response);
question = _question;
questionSender = msg.sender;
}
}

function StopGame()
public
payable
{
require(msg.sender==questionSender);
msg.sender.transfer(this.balance);
}

function NewQuestion(string _question, bytes32 _responseHash)
public
payable
{
require(msg.sender==questionSender);
question = _question;
responseHash = _responseHash;
}

function() public payable{}
}

The code supposedly implements a riddle. It sets up a question, and, if you can tell what the answer is, it will presumably send you its balance, currently a little more than 1 ether. Of course, to produce an answer, you must send an ether first, which you will get back if you are correct. The code seems fine, but there is a dirty trick: notice how NewQuestion allows questionSender to submit a hash that does not match _question. So, as long as this function isn’t used, we should be alright.

Can we tell what the question and answer are? If you read the transaction history of the contract on etherscan, it appears that the 2nd transaction sets up the question. It’s even more obvious if you click the “Convert to UT8” button on etherscan. This reveals the question “I am very easy to get into,but it is hard to get out of me. What am I?”, and the answer “TroublE”.

Since this transaction is called, according to etherscan, after the creation of the contract, responseHash is going to be zero, and will become keccak265("TroublE"). Then, there is a third transaction that loads up one ether in the contract. So, apparently, we could call Play("TroublE") and send one ether to get two ether back. Too good to be true? Probably. Let’s make sure.

We can make sure we will the contract’s ether by inspecting the state of the smart contract. Its variables are not public, but still all it takes is just a few extra strokes to retrieve their values by querying the blockchain. questionSender and responseHash are the 2nd and 3rd variables, so they will occupy slots 1 and 2 on the storage space of the smart contract. Let’s retrieve their values.

web3.eth.getStorageAt(‘0x3caf97b4d97276d75185aaf1dcf3a2a8755afe27’, 1, console.log);

The result is `0x0..0765951ab946f3a6f0379680a6b05fb807d52ba09`. That spells trouble (pun intended) for an attacker, since the transaction setting up the question came from an account starting with0x21d2. Something’s up.

web3.eth.getStorageAt(‘0x3caf97b4d97276d75185aaf1dcf3a2a8755afe27’, 2, console.log);

The result is `0xc3fa7df9bf24…`. Is this the hash of “TroublE”?

web3.sha3('TroublE');

That call returns 0x92a930d5..., so it turns out that, if we were to call Play("TroublE") and send 1 ether, we’d actually lose it. But how is it possible that the hashes do not match?

Notice how StartGame does nothing if responseHash is already set. Clearly, that second transaction did not alter the state of the contract, so it must have already been set before this transaction. But how is it possible that responseHash was already initialized, if that was the first transaction after the creation of the contract?

After some serious head scratching, I found a recent interesting post on honeypot contracts that explains that Etherscan does not show transactions between contracts when msg.value is zero. Other blockchain explorers such as Etherchain do show them. Surely enough, etherchain reveals a couple additional transactions in the contract’s history, where a contract at 0x765951.. modifies responseHash via a zero-value transactions.

So let’s check these transactions; perhaps the ether can still be stolen? To track what happened, we need to decode these calls. We can get the contract’s ABI from Etherscan, and the internal transaction data from the “parity traces” of Etherchain (first, second). That’s all we need to decode the transactions into human readable format.

const abiDecoder = require('abi-decoder');
const Web3 = require('web3');
const web3 = new Web3();
const abi = [{“constant”:false,”inputs”:[{“name”:”_question”,”type”:”string”},{“name”:”_response”,”type”:”string”}],”name”:”StartGame”,”outputs”:[],”payable”:true,”stateMutability”:”payable”,”type”:”function”},{“constant”:false,”inputs”:[{“name”:”_question”,”type”:”string”},{“name”:”_responseHash”,”type”:”bytes32"}],”name”:”NewQuestion”,”outputs”:[],”payable”:true,”stateMutability”:”payable”,”type”:”function”},{“constant”:true,”inputs”:[],”name”:”question”,”outputs”:[{“name”:””,”type”:”string”}],”payable”:false,”stateMutability”:”view”,”type”:”function”},{“constant”:false,”inputs”:[{“name”:”_response”,”type”:”string”}],”name”:”Play”,”outputs”:[],”payable”:true,”stateMutability”:”payable”,”type”:”function”},{“constant”:false,”inputs”:[],”name”:”StopGame”,”outputs”:[],”payable”:true,”stateMutability”:”payable”,”type”:”function”},{“payable”:true,”stateMutability”:”payable”,”type”:”fallback”}];const data1 = '0x1f1c827f000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000464920616d2076657279206561737920746f2067657420696e746f2c627574206974206973206861726420746f20676574206f7574206f66206d652e205768617420616d20493f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000754726f75626c4500000000000000000000000000000000000000000000000000';const data2 = '0x3e3ee8590000000000000000000000000000000000000000000000000000000000000040c3fa7df9bf247d144f6933776e672e599a5ed406cd0a15a9f2da09055b8f906700000000000000000000000000000000000000000000000000000000000000464920616d2076657279206561737920746f2067657420696e746f2c627574206974206973206861726420746f20676574206f7574206f66206d652e205768617420616d20493f0000000000000000000000000000000000000000000000000000';abiDecoder.addABI(abi);
console.log(abiDecoder.decodeMethod(data1));
console.log(abiDecoder.decodeMethod(data2));

Running this code, we get the following result:

{ name: ‘StartGame’,
params: [ { name: ‘_question’,
value: ‘I am very easy to get into,but it is hard to get out of me. What am I?’,
type: ‘string’ },
{ name: ‘_response’,
value: ‘TroublE’,
type: ‘string’ }
]
}
{ name: ‘NewQuestion’,
params: [ { name: ‘_question’,
value: ‘I am very easy to get into,but it is hard to get out of me. What am I?’,
type: ‘string’ },
{ name: ‘_responseHash’,
value: ‘0xc3fa7df9bf247d144f6933776e672e599a5ed406cd0a15a9f2da09055b8f9067’,
type: ‘bytes32’ }
]
}

We learn that the first transaction sets the answer to keccak256("TroublE"), but the second one sets the answer to a hash value for which we don’t know the original data! Again it’s quite easy to miss that the second call does not use _question to compute the hash; instead, it’s set to an arbitrary value that does not match the string provided in the previous call, although the question does match.

So, unless we can find out a value that produces the given hash, possibly via a dictionary attack or a bruteforce search, we’re out of luck. And, given how sophisticated this honeypot is, I would assume trying to bruteforce the hash is not going to work out very well for us.

Unraveling this honeypot took quite some effort. Its creator is ultimately counting on attackers trusting the etherscan data, which does not contain the full picture.

The giveaway

I said this contract contains a dead giveaway that its creator is playing tricks. This is in this line:

require(msg.sender == tx.origin);

What this line achieves is, it prevents contracts from calling Play. This is because tx.origin is always an “external account”, and never a smart contract. Why is this useful for the attacker? A way to safely attack a contract is to call them from an “attack contract” that reverts execution if it didn’t gain ether from attack:

function attack() {
uint intialBalance = this.balance;
attack_contract();
require (this.balance > initialBalance);
}

This way, unless the attacker’s contract’s balance increases, the transaction fails altogether. The creator of the honeypot wants to prevent an attacker from using this trick to protect themselves.

Conclusion

Honeypots are a moral grey area for me. Is it OK to scam those who are looking to steal from contracts? I don’t think so. But I do not feel very strongly about this. In the end, if you got scammed, it is because you were searching for smart contracts to steal from to begin with.

These scams play on the greed of people who are smart enough to figure out an apparent vulnerability in a contract, yet not knowledgeable enough to figure out what the underlying trap is.

If you want to get deeper into Smart Contract security, check this amazing wargame called Capture the Ether. It’s a fun way to hone your skills and train your eye for suspicious Solidity code.

--

--