Company Simulator

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

Investor Withdrawal Can Lock Funds if Company Balance is Insufficient

[H-04] Investor Withdrawal Can Lock Funds if Company Balance is Insufficient

Description

withdraw_shares() is intended to allow investors to redeem their shares for ETH based on the current share price. The function applies early withdrawal penalties, caps payout at MAX_PAYOUT_PER_SHARE, resets investor shares, and transfers ETH.

The function resets the investor’s share balance before verifying sufficient company funds. If company_balance < payout, the assert will revert after shares have already been deducted, permanently locking the investor out of their funds.

# Root cause in src/Cyfrin_Hub.vy
def withdraw_shares():
...
self.shares[msg.sender] = 0
self.issued_shares -= shares_owned
assert self.company_balance >= payout, "Insufficient company funds!!!" # @audit Vulnerable ordering
send(msg.sender, payout)

Risk

Likelihood: Medium

  • Can occur naturally if company liquidity is low.

  • Exploitable by malicious owner manipulation of company_balance.

Impact: High

  • Investor funds can be permanently locked.

  • Reputation/trust damage for the platform.

  • Multiple investors could be affected if withdrawals are attempted simultaneously under low balance.

Proof of Concept

The following test demonstrates that once company funds are drained, the investor’s withdrawal either reverts or burns their shares, making recovery impossible.

  • tests/test_withdraw_lock.py

# tests/test_withdraw_lock.py
import boa
from eth_utils import to_wei
from pathlib import Path
CONTRACT_PATH = Path("src/Cyfrin_Hub.vy")
def test_withdraw_shares_revert_and_state_rollback():
"""
PoC for [H-04] Investor Withdrawal Can Lock Funds if Company Balance is Insufficient.
Demonstrates shares reset before fund check, causing locked funds even if not reverted.
"""
# --- Setup accounts ---
owner = boa.env.generate_address("owner")
investor = boa.env.generate_address("investor")
boa.env.set_balance(owner, to_wei(10, "ether"))
boa.env.set_balance(investor, to_wei(10, "ether"))
# --- Deploy contract ---
with boa.env.prank(owner):
company = boa.load_partial(str(CONTRACT_PATH))()
# --- Fund company ---
with boa.env.prank(owner):
company.fund_cyfrin(0, value=to_wei(5, "ether"))
# --- Investor buys shares ---
with boa.env.prank(investor):
company.fund_cyfrin(1, value=to_wei(1, "ether"))
# --- Confirm shares exist ---
with boa.env.prank(investor):
shares_before = company.get_my_shares()
assert shares_before > 0, f"Investor should have shares, got {shares_before}"
# --- Drain company funds via production ---
with boa.env.prank(owner):
try:
company.produce(600) # large enough to drain funds
except Exception:
pass
drained_balance = company.company_balance()
print("After drain -> company_balance:", drained_balance)
assert drained_balance == 0, "Company should have zero balance after draining"
# --- Try to withdraw ---
with boa.env.prank(investor):
try:
tx = company.withdraw_shares()
reverted = False
except Exception as e:
reverted = True
revert_msg = str(e)
# --- Validate results ---
with boa.env.prank(investor):
shares_after = company.get_my_shares()
if reverted:
print(f"withdraw_shares() reverted as expected: {revert_msg}")
else:
print("withdraw_shares() did NOT revert — checking payout and share state...")
# If not reverted, check whether investor still lost shares
assert shares_after == 0, "Investor shares should be reset even without revert"
# --- Final vulnerability confirmation ---
assert reverted or shares_after == 0, (
"Investor funds are locked either due to revert or zero-payout burn"
)
print(f"Shares before: {shares_before}, after: {shares_after}")
print("Vulnerability confirmed — investor loses claim despite insufficient funds.")
  • Run with

mox test tests/test_withdraw_lock.py -vv

Recommended Mitigation

Reorder operations to validate liquidity before modifying investor state.
If funds are insufficient, revert before burning or resetting any shares.

@external
def withdraw_shares():
shares_owned: uint256 = self.shares[msg.sender]
payout: uint256 = shares_owned * self.share_price
- self.shares[msg.sender] = 0
- self.issued_shares -= shares_owned
- assert self.company_balance >= payout, "Insufficient company funds!!!"
+ assert self.company_balance >= payout, "Insufficient company funds!!!"
+ self.shares[msg.sender] = 0
+ self.issued_shares -= shares_owned
send(msg.sender, payout)
Updates

Lead Judging Commences

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

Support

FAQs

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