Ethereum Smart Contract Security Fundamentals

·

Ethereum smart contracts power decentralized applications, but even minor coding oversights can lead to catastrophic vulnerabilities. This guide walks through common security flaws using real-world-like challenge scenarios, helping developers and security enthusiasts understand how exploits work—and how to prevent them. Each section dissects a vulnerability type, explains the underlying mechanism, and demonstrates practical exploitation techniques in Solidity.

Fallback: Exploiting Receive Functions and Ownership Logic

The Fallback contract presents a dual objective: become the owner and drain the contract’s balance. At first glance, the contribute() function seems like a path to ownership—but it requires contributions exceeding the current owner’s balance (1 ether), with a strict limit of less than 0.001 ether per transaction. This makes it impractical to overtake the owner through repeated small deposits.

However, a more direct route exists: the receive() function. In Solidity 0.6+, receive() is a special payable function triggered when Ether is sent to the contract without calldata. It executes only if no other function matches the input data. Here, receive() has two conditions:

👉 Discover how real-world contracts handle Ether reception securely

Meeting both conditions allows the caller to seize ownership. The attack flow is simple:

  1. Call contribute() with a minimal amount (e.g., 1 wei) to register a non-zero contribution.
  2. Send Ether directly to the contract, invoking receive() and claiming ownership.
  3. Call withdraw() to transfer the full balance to your address.
contract.contribute({value: 1});
contract.sendTransaction({value: 1});
contract.withdraw();

This case highlights the importance of secure fallback logic and validating state changes even in seemingly passive functions like receive.

FAQ: Understanding Fallback and Receive Functions

Q: What's the difference between fallback() and receive()?
A: receive() handles plain Ether transfers with empty calldata. fallback() handles all other unmatched calls. Only one of each can exist per contract.

Q: Why is receive() marked payable?
A: Because it accepts Ether. Any function receiving funds directly must be marked payable.

Q: How can I protect against unauthorized ownership takeover via receive?
A: Avoid placing critical logic like ownership assignment in receive. Use explicit functions with access controls instead.

Fallout: Constructor Misnaming Vulnerability

In older Solidity versions (<0.5), constructors were defined as functions with the same name as the contract. The Fallout challenge mimics a real-world incident involving the Rubixi project, where a renamed contract retained an old constructor function—turning it into a callable public method.

Here, the function Fal1out() (note the number '1' instead of letter 'l') is not a constructor because its name doesn’t match the contract name Fallout. As a result, it remains an ordinary public function that anyone can invoke to claim ownership.

Exploitation is trivial:

contract.Fal1out();

After calling this function, the attacker becomes the owner and can call collectAllocations() to withdraw funds.

This vulnerability underscores why modern Solidity uses the constructor keyword—eliminating naming ambiguity and preventing such mistakes.

Coin Flip: Predictable Randomness in Smart Contracts

The CoinFlip game simulates randomness using blockhash(block.number - 1), which appears unpredictable. However, on-chain data is public, and miners control block timing. An attacker can exploit this by deploying a helper contract that:

  1. Computes the same flip logic using the previous blockhash.
  2. Submits the correct guess in the same block.

Since both transactions reside in one block, the attacker knows the outcome before submitting their guess.

uint256 blockValue = uint256(blockhash(block.number - 1));
bool guess = (blockValue / FACTOR) == 1;
expFlip.flip(guess);

This flaw illustrates why on-chain randomness is inherently weak. Secure alternatives include chainlink VRF or commit-reveal schemes.

FAQ: Preventing Randomness Attacks

Q: Can I use block.timestamp or block.difficulty for randomness?
A: No—both are manipulable by miners and unsuitable for secure randomness.

Q: What's a safe way to generate random numbers in Ethereum?
A: Use off-chain randomness with verifiable proofs, such as Chainlink VRF.

Q: Is there any way to fix the CoinFlip contract without external dependencies?
A: Not reliably. On-chain entropy sources are too predictable for fair games.

Telephone: Dangers of Using tx.origin

The Telephone contract uses tx.origin != msg.sender as an access control check. While intended to restrict direct calls, this creates an exploitable loophole.

tx.origin refers to the original external account that initiated the transaction, while msg.sender is the immediate caller (which could be a contract). By calling changeOwner() through an intermediate contract:

contract Exploit {
    Telephone target = Telephone(LEVEL_ADDRESS);
    function hack() public {
        target.changeOwner(msg.sender);
    }
}

Using tx.origin for authorization is dangerous and deprecated. Always prefer msg.sender unless you have a specific cross-contract logic need—and even then, proceed with caution.

Token: Integer Underflow Exploit

The Token contract checks balances with:

require(balances[msg.sender] - _value >= 0);

In Solidity <0.8, arithmetic underflows silently wrap around. If a user has 0 tokens and transfers a large value (e.g., 999999), the subtraction wraps to a huge positive number, bypassing the check.

An attacker can exploit this to mint unlimited tokens:

contract.claim();

Where claim() calls transfer() with a massive _value, causing underflow and crediting the attacker.

👉 Learn how modern DeFi protocols prevent arithmetic overflows

This vulnerability led to major hacks like the BeautyChain incident. Modern solutions include using SafeMath libraries or upgrading to Solidity 0.8+, where arithmetic checks are built-in.

Delegation: Delegatecall Context Confusion

The Delegation contract uses delegatecall to forward calls to a logic contract (Delegate). Unlike regular call, delegatecall executes code in the caller’s context—meaning storage, state variables, and msg.sender belong to the calling contract.

When pwn() is called via delegatecall, it modifies Delegation’s storage, not Delegate’s. Since both contracts have an owner variable at slot 0, calling:

contract.sendTransaction({data: web3.utils.sha3("pwn()").slice(0,10)});

invokes the function via fallback and changes Delegation’s owner.

This pattern is common in proxy-based upgradeable contracts but requires careful storage layout management to avoid corruption or hijacking.

Core Keywords

These examples reveal recurring themes in smart contract insecurity: reliance on flawed assumptions, misuse of low-level calls, and inadequate input validation. By studying these patterns, developers can write more robust code—and defenders can better audit for risks.

As decentralized systems grow in complexity, understanding these fundamentals becomes essential for building trustworthy blockchain applications.