Company Simulator

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

Sybil attack on CustomerEngine.trigger_demand() bypasses rate limit

Description

  • The CustomerEngine.trigger_demand() function rate-limits calls per user address using a cooldown mapping. This is intended to prevent spamming demand generation and to simulate realistic, throttled customer activity.

  • The cooldown is enforced only per address, so an adversary can rotate through unlimited new EOAs (or cheap contracts) to bypass the rate limit entirely. This is a classic Sybil vector: the system treats each address as a distinct “customer,” enabling high-frequency calls that the cooldown was designed to prevent.

// Root cause in the codebase with @> marks to highlight the relevant section
// File: CustomerEngine.vy
# STATE
last_trigger: public(HashMap[address, uint256])
@payable
@external
def trigger_demand():
# @> Cooldown enforcement keyed solely by msg.sender
assert (
block.timestamp > self.last_trigger[msg.sender] + COOLDOWN
), "Wait before next demand!!!"
self.last_trigger[msg.sender] = block.timestamp
# ... rest of logic (reputation check, pseudo-random request size, refund, raw_call)

Risk

Likelihood: High

  • The attack occurs whenever an adversary can create additional EOAs (cost ~0 off-chain, small on-chain for first tx) to alternate calls and evade the per-address timer.

  • It also occurs whenever bot operators or MEV searchers want to maximize reputation gains or revenue events by fanning out transactions across fresh addresses.

Impact: High

  • Spam demand / economic distortion — Attackers can generate near-continuous demand, manipulating inventory turnover and internal accounting in CompanyGame.sell_to_customer().

  • Reputation manipulation — Because successful sales increase the company’s reputation, an attacker can artificially inflate reputation by mass-calling via Sybil addresses (and further exploit other issues like missing msg.value validation in sell_to_customer() from a separate finding).

Proof of Concept

  • Copy below function to the test_Engine.py

  • Run command mox test -k test_Rate_Limit_Bypass_With_Sybil_Attack

def test_Rate_Limit_Bypass_With_Sybil_Attack(industry_contract, customer_engine_contract, OWNER, PATRICK):
# arrange
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0, value=to_wei(10, "ether"))
industry_contract.produce(50) # produce enough items
assert industry_contract.inventory() == 50, "Inventory should be equals to 50"
# act
initial_inventory = industry_contract.inventory()
# Test rate limiting by attempting to trigger demand twice from the same account
# First call should succeed
with boa.env.prank(PATRICK):
customer_engine_contract.trigger_demand(value=to_wei(0.1, "ether"))
with boa.reverts():
# Second call should fail due to rate limiting
customer_engine_contract.trigger_demand(value=to_wei(0.1, "ether"))
num_sybil_accounts = 10
for i in range(num_sybil_accounts):
sybil_account = boa.env.generate_address()
boa.env.set_balance(sybil_account, to_wei(1, "ether"))
with boa.env.prank(sybil_account):
# Each Sybil account triggers demand bypassing rate limit
customer_engine_contract.trigger_demand(value=to_wei(0.1, "ether"))
# assert
final_inventory = industry_contract.inventory()
assert final_inventory < initial_inventory, "Inventory should reduce after Sybil attack"

Recommended Mitigation

Global rate limit + per-address limit
Introduce a global last-trigger timestamp and enforce a minimum inter-arrival time across all callers in addition to the per-address cooldown. This caps system-wide throughput.

- last_trigger: public(HashMap[address, uint256])
+ last_trigger: public(HashMap[address, uint256])
+ last_global_trigger: public(uint256)
@payable
@external
def trigger_demand():
- assert (block.timestamp > self.last_trigger[msg.sender] + COOLDOWN), "Wait before next demand!!!"
+ # Per-address cooldown
+ assert (block.timestamp > self.last_trigger[msg.sender] + COOLDOWN), "Wait before next demand!!!"
+ # Global cooldown to limit total system throughput
+ assert (block.timestamp > self.last_global_trigger + COOLDOWN), "System busy, try again"
self.last_trigger[msg.sender] = block.timestamp
+ self.last_global_trigger = block.timestamp
# ... rest of logic
Updates

Lead Judging Commences

0xshaedyw Lead Judge
4 days ago
0xshaedyw Lead Judge 3 days ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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