Company Simulator

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

Missing Reentrancy Protection in Share Withdrawal

Root

The contract declares pragma nonreentrancy on at the global level but fails to apply the @nonreentrant decorator to the withdraw_shares() function. In Vyper, the pragma declaration alone is insufficient - each vulnerable function must be explicitly decorated.


Impact

An attacker can drain the entire company_balance through recursive withdrawal calls, stealing funds that belong to all legitimate investors.

Once the attacker drains significant funds, legitimate investors cannot withdraw their investments, creating a cascade of losses.

Description

The withdraw_shares() function in CompanyGame lacks the @nonreentrant decorator despite the contract declaring pragma nonreentrancy on. This creates a potential reentrancy vulnerability that could allow malicious investors to drain the contract.

// Root cause in the codebase with @> marks to highlight the relevant section
# pragma nonreentrancy on <- Declared but not enforced
@external
def withdraw_shares(): # <- Missing @nonreentrant decorator
shares_owned: uint256 = self.shares[msg.sender]
assert shares_owned > 0, "Not an investor!!!"
# ... calculations ...
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
self.company_balance -= payout
# EXTERNAL CALL - Vulnerability point
raw_call(
msg.sender,
b"",
value=payout,
revert_on_failure=True,
)

Risk

Likelihood:

  • Exploitation Occurs During Normal Withdrawal Operations by Malicious Smart Contract Investors.

    When this occur -


    Any investor can become a smart contract (rather than an EOA - Externally Owned Account) by simply deploying a contract and having that contract invest in the company. The moment a smart contract investor accumulates shares and calls withdraw_shares(), the vulnerability becomes actively exploitable.


  • Exploitation Occurs When Company Balance Exceeds Total Share Valuation, Creating Profit Opportunity


    When this occur-

    The economic incentive for exploitation becomes maximized during periods when the company's actual balance significantly exceeds the calculated share valuations, creating an arbitrage opportunity for attackers.

Impact:

  • Attacker can withdraw multiple times before state updates

  • Depletes company_balance, preventing legitimate withdrawals

  • All investors lose funds

Proof of Concept (PoC)

# Attacker contract
interface CompanyGame:
def fund_cyfrin(action: uint256): payable
def withdraw_shares(): nonpayable
attacker_balance: uint256
@external
@payable
def __default__():
# Receive callback
if self.attacker_balance < TARGET_AMOUNT:
extcall CompanyGame(COMPANY).withdraw_shares()
@external
def attack():
# 1. Buy shares
extcall CompanyGame(COMPANY).fund_cyfrin(1, value=1 * 10**18)
# 2. Trigger withdrawal (will re-enter via fallback)
extcall CompanyGame(COMPANY).withdraw_shares()

Recommended Mitigation

- remove this code
@external
def withdraw_shares(): # <- Missing @nonreentrant decorator
shares_owned: uint256 = self.shares[msg.sender]
assert shares_owned > 0, "Not an investor!!!"
# ... calculations ...
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
self.company_balance -= payout
# EXTERNAL CALL - Vulnerability point
raw_call(
msg.sender,
b"",
value=payout,
revert_on_failure=True,
)
+ add this code
@external
@nonreentrant # ADD THIS
def withdraw_shares():
shares_owned: uint256 = self.shares[msg.sender]
assert shares_owned > 0, "Not an investor!!!"
share_price: uint256 = self.get_share_price()
payout: uint256 = shares_owned * share_price
# Check lockup and calculate penalty
time_held: uint256 = block.timestamp - self.share_received_time[msg.sender]
if time_held < LOCKUP_PERIOD:
penalty: uint256 = payout * EARLY_WITHDRAWAL_PENALTY // 100
payout -= penalty
# Apply max payout cap
max_payout: uint256 = shares_owned * MAX_PAYOUT_PER_SHARE
if payout > max_payout:
payout = max_payout
# STATE CHANGES BEFORE EXTERNAL CALL
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
self.share_received_time[msg.sender] = 0
assert self.company_balance >= payout, "Insufficient company funds!!!"
self.company_balance -= payout
# External call (now protected)
raw_call(msg.sender, b"", value=payout, revert_on_failure=True)
log Withdrawn_Shares(investor=msg.sender, shares=shares_owned)
Updates

Lead Judging Commences

0xshaedyw Lead Judge
5 days ago
0xshaedyw Lead Judge 3 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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