Company Simulator

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

Reputation-gated sales can be permanently DoS'd via public CustomerEngine

Root + Impact

Description

  • The CustomerEnginecontract triggers demand in the Cyfrin_Hubcontract and is the only one with access control to call Cyfrin_Hub::sell_to_customer. If the company does not have inventorywhen the CustomerEngine::trigger_demand function is called the company looses 5 points in reputaion. If company successfully sells a product it will gain 2 points The reputation starts at 100 and when it goes below 60 the reputation is too low for company to sell.

  • Since anyone can call trigger_demandfunction an attacker can spam demand when inventory is 0 and push reputation< 60, after which no sales are possible, and reputation cannot recover.

@payable
@> @external
def trigger_demand():
# code

Risk

Likelihood:

  • The attack only requires repeatedly calling trigger_demandwhile company has no inventory

  • The function is public and permissionless, meaning any user (or multiple Sybil addresses) can trigger it

  • There is no significant cost, global cooldown, or access restrictions - only a 60 seconds per address cooldown delay, which is easely bypassed

  • The attack can be completed in just a few transactions

Impact:

  • Exploitation leads to complete and permanent denial-of-service for protocol's core functionality:

    • Once reputation falls below 60, the CustomerEngineblocks all future sales by reverting trigger_demand

    • Since reputation can only recover through successfull sales, and no sales can occur because reputaion is below minimum threshold, the system enters an irreversible deadlock

    • Even when the owner restocks inventory, the business remains non-functional

    • This halts all revenue generation and damages investors returns

Proof of Concept

Flow:

  1. Multiple addresses calls trigger_demandwhile inventory== 0 until reputationfalls below 60

  2. Patrick calls trigger_demandand the transaction reverts

  3. Owner restocks

  4. Patrick calls trigger_demandand the transaction reverts still even though company has stock because reputation< 60

# conftest.py
@pytest.fixture(scope="function")
def multiple_buyers():
buyers = []
for i in range(9):
buyers.append(_generate_account_with_balance(f"buyer_{i}"))
return buyers
def test_attack_reputation(customer_engine_contract, industry_contract, multiple_buyers, PATRICK, OWNER):
# Owner funds the contract first
boa.env.set_balance(OWNER, SET_OWNER_BALANCE)
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0,value=to_wei(50, "ether"))
print(f"Industry inventory: {industry_contract.inventory()}")
print(f"Industry reputation before funding: {industry_contract.get_reputation_tier()}")
# Multiple buyers trigger demand with small amounts to reduce reputation when company has no inventory
# This simulates an attack on the reputation system
for buyer in multiple_buyers:
with boa.env.prank(buyer):
customer_engine_contract.trigger_demand(value=to_wei(0.1, "ether"))
print(f"Industry reputation after funding: {industry_contract.get_reputation_tier()}")
assert industry_contract.reputation() < 60, "Reputation should be reduced after multiple failed demands"
# When Patrick triggers demand it will fail due to low reputation
with boa.env.prank(PATRICK):
with boa.reverts("Reputation too low for demand!!!"):
customer_engine_contract.trigger_demand(value=to_wei(5, "ether"))
# Owner produces to replenish inventory
with boa.env.prank(OWNER):
industry_contract.produce(50)
print(f"Industry inventory after production: {industry_contract.inventory()}")
# Reverts even after inventory is replenished because reputation is too low
with boa.env.prank(PATRICK):
with boa.reverts("Reputation too low for demand!!!"):
customer_engine_contract.trigger_demand(value=to_wei(5, "ether"))

Recommended Mitigation

Remove the assert reputation >= MIN_REPUTATION

Change reputation reward to 1 when under 60

@external
@payable
def sell_to_customer(requested: uint256):
assert msg.sender == self.CUSTOMER_ENGINE, "Not the customer engine!!!"
assert not self._is_bankrupt(), "Company is bankrupt!!!"
self._apply_holding_cost()
if self.inventory >= requested:
self.inventory -= requested
revenue: uint256 = requested * SALE_PRICE
self.company_balance += revenue
- if self.reputation < 100:
- # Increase reputation for successful sale
- self.reputation = min(self.reputation + REPUTATION_REWARD, 100)
- else:
- # Maintain reputation if already at max
- self.reputation = 100
+ if self.reputation < 60:
+ self.reputation = min(self.reputation + 1, 100)
+ elif self.reputation < 100:
+ self.reputation = min(self.reputation + REPUTATION_REWARD, 100)
log Sold(amount=requested, revenue=revenue)
else:
self.reputation = min(max(self.reputation - REPUTATION_PENALTY, 0), 100)
log ReputationChanged(new_reputation=self.reputation)
@payable
@external
def trigger_demand():
"""
@notice Simulates a customer placing a demand for items from the company.
@dev Users must pay ETH to request items. Excess ETH is refunded.
@dev Demand size is pseudo-random (1 - 5 items), influenced by company reputation.
@dev Can only be called once per address every `COOLDOWN` seconds.
@dev Requires company reputation ≥ `MIN_REPUTATION`.
@dev Calls `sell_to_customer` on CompanyGame with exact ETH required.
@dev Reverts if call fails (e.g., insufficient inventory).
"""
# Cooldown enforcement
assert (
block.timestamp > self.last_trigger[msg.sender] + COOLDOWN
), "Wait before next demand!!!"
self.last_trigger[msg.sender] = block.timestamp
- # Reputation check
- rep: uint256 = staticcall CompanyGame(self.company).reputation()
- assert rep >= MIN_REPUTATION, "Reputation too low for demand!!!"
# Pseudo-random demand calculation
seed: uint256 = convert(
keccak256(
concat(
convert(block.timestamp, bytes32), convert(msg.sender, bytes32)
)
),
uint256,
)
base: uint256 = seed % 5 # 0 to 4
extra_item_chance: uint256 = 0
if (seed % 100) < (rep - 50):
extra_item_chance = 1
requested: uint256 = base + 1 + extra_item_chance # 1 to 6
requested = min(requested, MAX_REQUEST) # cap at 5
# ETH payment enforcement
total_cost: uint256 = requested * ITEM_PRICE
assert msg.value >= total_cost, "Insufficient payment!!!"
# Refund excess ETH
excess: uint256 = msg.value - total_cost
if excess > 0:
send(msg.sender, excess)
data: Bytes[36] = concat(
method_id("sell_to_customer(uint256)"), convert(requested, bytes32)
)
# Call CompanyGame
raw_call(self.company, data, value=total_cost, revert_on_failure=True)
Updates

Lead Judging Commences

0xshaedyw Lead Judge
9 days ago
0xshaedyw Lead Judge 8 days ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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